1use scirs2_core::ndarray::{Array1, Array2, ArrayView1, ArrayView2};
8use scirs2_core::random::thread_rng;
9use sklears_core::error::{Result as SklResult, SklearsError};
10type Result<T> = SklResult<T>;
11use std::collections::HashMap;
12use std::time::{Duration, Instant};
13use thiserror::Error;
14
15#[derive(Debug, Error)]
16pub enum BenchmarkError {
18 #[error("Invalid dataset configuration")]
19 InvalidDataset,
21 #[error("Method execution failed: {0}")]
22 MethodExecutionFailed(String),
24 #[error("Benchmark configuration error: {0}")]
25 ConfigurationError(String),
27 #[error("Statistical analysis failed")]
28 StatisticalAnalysisFailed,
30 #[error("Comparison baseline not found")]
31 BaselineNotFound,
33}
34
35impl From<BenchmarkError> for SklearsError {
36 fn from(err: BenchmarkError) -> Self {
37 SklearsError::FitError(format!("Benchmark error: {}", err))
38 }
39}
40
41#[derive(Debug)]
43pub struct FeatureSelectionBenchmark {
44 datasets: Vec<BenchmarkDataset>,
45 methods: HashMap<String, Box<dyn BenchmarkableMethod>>,
46 config: BenchmarkConfig,
47 results: Vec<BenchmarkResult>,
48}
49
50impl Default for FeatureSelectionBenchmark {
51 fn default() -> Self {
52 Self::new()
53 }
54}
55
56impl FeatureSelectionBenchmark {
57 pub fn new() -> Self {
59 Self {
60 datasets: Vec::new(),
61 methods: HashMap::new(),
62 config: BenchmarkConfig::default(),
63 results: Vec::new(),
64 }
65 }
66
67 pub fn with_config(mut self, config: BenchmarkConfig) -> Self {
69 self.config = config;
70 self
71 }
72
73 pub fn add_dataset(&mut self, dataset: BenchmarkDataset) -> Result<()> {
75 if dataset.X.is_empty() || dataset.y.is_empty() {
76 return Err(BenchmarkError::InvalidDataset.into());
77 }
78 self.datasets.push(dataset);
79 Ok(())
80 }
81
82 pub fn add_standard_datasets(&mut self) -> Result<()> {
84 self.add_dataset(self.create_synthetic_linear_dataset(500, 50, 10)?)?;
86 self.add_dataset(self.create_synthetic_nonlinear_dataset(300, 100, 15)?)?;
87 self.add_dataset(self.create_high_dimensional_dataset(200, 1000, 5)?)?;
88 self.add_dataset(self.create_correlated_features_dataset(400, 80, 20)?)?;
89 self.add_dataset(self.create_noisy_dataset(600, 60, 12)?)?;
90
91 Ok(())
92 }
93
94 pub fn register_method(&mut self, name: String, method: Box<dyn BenchmarkableMethod>) {
96 self.methods.insert(name, method);
97 }
98
99 pub fn run_benchmark(&mut self) -> Result<BenchmarkSuiteResults> {
101 if self.datasets.is_empty() {
102 return Err(BenchmarkError::ConfigurationError("No datasets added".to_string()).into());
103 }
104
105 if self.methods.is_empty() {
106 return Err(
107 BenchmarkError::ConfigurationError("No methods registered".to_string()).into(),
108 );
109 }
110
111 let mut all_results = Vec::new();
112 let start_time = Instant::now();
113
114 println!(
115 "Starting benchmark suite with {} datasets and {} methods...",
116 self.datasets.len(),
117 self.methods.len()
118 );
119
120 for (dataset_idx, dataset) in self.datasets.iter().enumerate() {
122 println!(
123 "Benchmarking dataset {}/{}: {}",
124 dataset_idx + 1,
125 self.datasets.len(),
126 dataset.name
127 );
128
129 for (method_name, method) in self.methods.iter() {
130 println!(" Running method: {}", method_name);
131
132 let mut method_results = Vec::new();
134
135 for iteration in 0..self.config.n_iterations {
136 if self.config.verbose && iteration % 10 == 0 {
137 println!(
138 " Iteration {}/{}",
139 iteration + 1,
140 self.config.n_iterations
141 );
142 }
143
144 let result = self.run_single_benchmark(
145 method.as_ref(),
146 method_name,
147 dataset,
148 iteration,
149 )?;
150
151 method_results.push(result);
152 }
153
154 let aggregated_result = self.aggregate_method_results(&method_results)?;
156 all_results.push(aggregated_result);
157 }
158 }
159
160 let total_duration = start_time.elapsed();
161
162 let statistical_analysis = self.perform_statistical_analysis(&all_results)?;
164
165 let rankings = self.generate_rankings(&all_results)?;
167
168 let suite_results = BenchmarkSuiteResults {
170 individual_results: all_results,
171 statistical_analysis,
172 rankings,
173 execution_summary: ExecutionSummary {
174 total_duration,
175 n_datasets: self.datasets.len(),
176 n_methods: self.methods.len(),
177 n_total_runs: self.datasets.len() * self.methods.len() * self.config.n_iterations,
178 },
179 configuration: self.config.clone(),
180 };
181
182 self.results
183 .extend(suite_results.individual_results.clone());
184
185 println!(
186 "Benchmark suite completed in {:.2}s",
187 total_duration.as_secs_f64()
188 );
189
190 Ok(suite_results)
191 }
192
193 #[allow(non_snake_case)]
195 fn run_single_benchmark(
196 &self,
197 method: &dyn BenchmarkableMethod,
198 method_name: &str,
199 dataset: &BenchmarkDataset,
200 iteration: usize,
201 ) -> Result<BenchmarkResult> {
202 let start_time = Instant::now();
203
204 let (train_indices, test_indices) = if self.config.use_cross_validation {
206 self.create_cv_split(dataset.X.nrows(), iteration % self.config.cv_folds)?
207 } else {
208 self.create_holdout_split(dataset.X.nrows())?
209 };
210
211 let X_train = self.extract_samples(dataset.X.view(), &train_indices);
213 let y_train = self.extract_targets(dataset.y.view(), &train_indices);
214 let X_test = self.extract_samples(dataset.X.view(), &test_indices);
215 let y_test = self.extract_targets(dataset.y.view(), &test_indices);
216
217 let memory_before = self.get_memory_usage();
219
220 let selected_features = match method.select_features(X_train.view(), y_train.view()) {
222 Ok(features) => features,
223 Err(e) => return Err(BenchmarkError::MethodExecutionFailed(format!("{:?}", e)).into()),
224 };
225
226 let selection_time = start_time.elapsed();
227
228 let memory_after = self.get_memory_usage();
230 let memory_delta = memory_after.saturating_sub(memory_before);
231
232 let evaluation_start = Instant::now();
234 let evaluation_metrics = self.evaluate_feature_selection(
235 X_train.view(),
236 y_train.view(),
237 X_test.view(),
238 y_test.view(),
239 &selected_features,
240 &dataset.ground_truth,
241 )?;
242 let evaluation_time = evaluation_start.elapsed();
243
244 let feature_metrics =
246 self.compute_feature_metrics(dataset.X.view(), dataset.y.view(), &selected_features)?;
247
248 Ok(BenchmarkResult {
249 method_name: method_name.to_string(),
250 dataset_name: dataset.name.clone(),
251 iteration,
252 selected_features: selected_features.clone(),
253 n_features_selected: selected_features.len(),
254 n_features_total: dataset.X.ncols(),
255 selection_time,
256 evaluation_time,
257 memory_usage: memory_delta,
258 evaluation_metrics,
259 feature_metrics,
260 dataset_properties: dataset.properties.clone(),
261 })
262 }
263
264 fn create_synthetic_linear_dataset(
266 &self,
267 n_samples: usize,
268 n_features: usize,
269 n_relevant: usize,
270 ) -> Result<BenchmarkDataset> {
271 let mut X = Array2::zeros((n_samples, n_features));
272 let mut y = Array1::zeros(n_samples);
273
274 for i in 0..n_samples {
276 let mut target = 0.0;
277
278 for j in 0..n_relevant {
279 let value = thread_rng().random::<f64>() * 10.0 - 5.0; X[[i, j]] = value;
281 target += value * (j + 1) as f64; }
283
284 for j in n_relevant..n_features {
286 X[[i, j]] = thread_rng().random::<f64>() * 10.0 - 5.0;
287 }
288
289 target += (thread_rng().random::<f64>() - 0.5) * 2.0;
291 y[i] = target;
292 }
293
294 let ground_truth = Some((0..n_relevant).collect());
295 let properties = DatasetProperties {
296 task_type: TaskType::Regression,
297 n_informative_features: n_relevant,
298 noise_level: 0.1,
299 correlation_structure: CorrelationStructure::Linear,
300 feature_types: vec![FeatureType::Continuous; n_features],
301 };
302
303 Ok(BenchmarkDataset {
304 name: format!("SyntheticLinear_{}x{}", n_samples, n_features),
305 X,
306 y,
307 ground_truth,
308 properties,
309 })
310 }
311
312 fn create_synthetic_nonlinear_dataset(
314 &self,
315 n_samples: usize,
316 n_features: usize,
317 n_relevant: usize,
318 ) -> Result<BenchmarkDataset> {
319 let mut X = Array2::zeros((n_samples, n_features));
320 let mut y = Array1::zeros(n_samples);
321
322 for i in 0..n_samples {
323 let mut target = 0.0;
324
325 for j in 0..n_relevant {
327 let value = thread_rng().random::<f64>() * 6.0 - 3.0; X[[i, j]] = value;
329
330 match j % 3 {
332 0 => target += value * value,
333 1 => target += (value * std::f64::consts::PI / 2.0).sin() * 2.0,
334 2 => target += (value.abs()).ln().max(-5.0),
335 _ => unreachable!(),
336 }
337 }
338
339 for j in n_relevant..n_features {
341 X[[i, j]] = thread_rng().random::<f64>() * 6.0 - 3.0;
342 }
343
344 y[i] = target + (thread_rng().random::<f64>() - 0.5) * 0.5;
345 }
346
347 let ground_truth = Some((0..n_relevant).collect());
348 let properties = DatasetProperties {
349 task_type: TaskType::Regression,
350 n_informative_features: n_relevant,
351 noise_level: 0.15,
352 correlation_structure: CorrelationStructure::Nonlinear,
353 feature_types: vec![FeatureType::Continuous; n_features],
354 };
355
356 Ok(BenchmarkDataset {
357 name: format!("SyntheticNonlinear_{}x{}", n_samples, n_features),
358 X,
359 y,
360 ground_truth,
361 properties,
362 })
363 }
364
365 fn create_high_dimensional_dataset(
367 &self,
368 n_samples: usize,
369 n_features: usize,
370 n_relevant: usize,
371 ) -> Result<BenchmarkDataset> {
372 let mut X = Array2::zeros((n_samples, n_features));
373 let mut y = Array1::zeros(n_samples);
374
375 for i in 0..n_samples {
376 let mut target = 0.0;
377
378 for j in 0..n_relevant {
380 let value = thread_rng().random::<f64>() * 4.0 - 2.0;
381 X[[i, j]] = value;
382 target += value * (1.0 / (j + 1) as f64); }
384
385 for j in n_relevant..n_features {
387 X[[i, j]] = thread_rng().random::<f64>() * 4.0 - 2.0;
388 }
389
390 y[i] = if target > 0.0 { 1.0 } else { 0.0 }; }
392
393 let ground_truth = Some((0..n_relevant).collect());
394 let properties = DatasetProperties {
395 task_type: TaskType::BinaryClassification,
396 n_informative_features: n_relevant,
397 noise_level: 0.2,
398 correlation_structure: CorrelationStructure::Sparse,
399 feature_types: vec![FeatureType::Continuous; n_features],
400 };
401
402 Ok(BenchmarkDataset {
403 name: format!("HighDimensional_{}x{}", n_samples, n_features),
404 X,
405 y,
406 ground_truth,
407 properties,
408 })
409 }
410
411 fn create_correlated_features_dataset(
413 &self,
414 n_samples: usize,
415 n_features: usize,
416 n_relevant: usize,
417 ) -> Result<BenchmarkDataset> {
418 let mut X = Array2::zeros((n_samples, n_features));
419 let mut y = Array1::zeros(n_samples);
420
421 for i in 0..n_samples {
422 let mut target = 0.0;
423
424 let base_features: Vec<f64> = (0..n_relevant)
426 .map(|_| thread_rng().random::<f64>() * 4.0 - 2.0)
427 .collect();
428
429 for j in 0..n_relevant {
430 X[[i, j]] = base_features[j];
431 target += base_features[j] * (j + 1) as f64;
432 }
433
434 let mut feature_idx = n_relevant;
436 for j in 0..n_relevant.min((n_features - n_relevant) / 3) {
437 if feature_idx < n_features {
438 X[[i, feature_idx]] =
440 base_features[j] + (thread_rng().random::<f64>() - 0.5) * 0.2;
441 feature_idx += 1;
442 }
443
444 if feature_idx < n_features && j + 1 < base_features.len() {
445 X[[i, feature_idx]] = 0.5 * base_features[j] + 0.3 * base_features[j + 1];
447 feature_idx += 1;
448 }
449 }
450
451 for j in feature_idx..n_features {
453 X[[i, j]] = thread_rng().random::<f64>() * 4.0 - 2.0;
454 }
455
456 y[i] = target + (thread_rng().random::<f64>() - 0.5) * 1.0;
457 }
458
459 let ground_truth = Some((0..n_relevant).collect());
460 let properties = DatasetProperties {
461 task_type: TaskType::Regression,
462 n_informative_features: n_relevant,
463 noise_level: 0.25,
464 correlation_structure: CorrelationStructure::HighlyCorrelated,
465 feature_types: vec![FeatureType::Continuous; n_features],
466 };
467
468 Ok(BenchmarkDataset {
469 name: format!("Correlated_{}x{}", n_samples, n_features),
470 X,
471 y,
472 ground_truth,
473 properties,
474 })
475 }
476
477 fn create_noisy_dataset(
479 &self,
480 n_samples: usize,
481 n_features: usize,
482 n_relevant: usize,
483 ) -> Result<BenchmarkDataset> {
484 let mut X = Array2::zeros((n_samples, n_features));
485 let mut y = Array1::zeros(n_samples);
486
487 for i in 0..n_samples {
488 let mut target = 0.0;
489
490 for j in 0..n_relevant {
491 let value = thread_rng().random::<f64>() * 8.0 - 4.0;
492 X[[i, j]] = value;
493 target += value * (j + 1) as f64;
494 }
495
496 for j in n_relevant..n_features {
498 X[[i, j]] = thread_rng().random::<f64>() * 8.0 - 4.0;
499 }
500
501 let noise = (thread_rng().random::<f64>() - 0.5) * target.abs() * 0.5;
503 y[i] = target + noise;
504 }
505
506 let ground_truth = Some((0..n_relevant).collect());
507 let properties = DatasetProperties {
508 task_type: TaskType::Regression,
509 n_informative_features: n_relevant,
510 noise_level: 0.5,
511 correlation_structure: CorrelationStructure::Noisy,
512 feature_types: vec![FeatureType::Continuous; n_features],
513 };
514
515 Ok(BenchmarkDataset {
516 name: format!("Noisy_{}x{}", n_samples, n_features),
517 X,
518 y,
519 ground_truth,
520 properties,
521 })
522 }
523
524 fn create_cv_split(&self, n_samples: usize, fold: usize) -> Result<(Vec<usize>, Vec<usize>)> {
526 let fold_size = n_samples / self.config.cv_folds;
527 let start = (fold % self.config.cv_folds) * fold_size;
528 let end = if fold % self.config.cv_folds == self.config.cv_folds - 1 {
529 n_samples
530 } else {
531 start + fold_size
532 };
533
534 let test_indices: Vec<usize> = (start..end).collect();
535 let train_indices: Vec<usize> = (0..start).chain(end..n_samples).collect();
536
537 Ok((train_indices, test_indices))
538 }
539
540 fn create_holdout_split(&self, n_samples: usize) -> Result<(Vec<usize>, Vec<usize>)> {
541 let n_test = (n_samples as f64 * self.config.test_size) as usize;
542 let n_train = n_samples - n_test;
543
544 let train_indices: Vec<usize> = (0..n_train).collect();
546 let test_indices: Vec<usize> = (n_train..n_samples).collect();
547
548 Ok((train_indices, test_indices))
549 }
550
551 fn extract_samples(&self, X: ArrayView2<f64>, indices: &[usize]) -> Array2<f64> {
552 let mut samples = Array2::zeros((indices.len(), X.ncols()));
553 for (i, &idx) in indices.iter().enumerate() {
554 samples.row_mut(i).assign(&X.row(idx));
555 }
556 samples
557 }
558
559 fn extract_targets(&self, y: ArrayView1<f64>, indices: &[usize]) -> Array1<f64> {
560 let mut targets = Array1::zeros(indices.len());
561 for (i, &idx) in indices.iter().enumerate() {
562 targets[i] = y[idx];
563 }
564 targets
565 }
566
567 fn get_memory_usage(&self) -> usize {
568 0
571 }
572
573 fn evaluate_feature_selection(
574 &self,
575 X_train: ArrayView2<f64>,
576 y_train: ArrayView1<f64>,
577 _X_test: ArrayView2<f64>,
578 _y_test: ArrayView1<f64>,
579 selected_features: &[usize],
580 ground_truth: &Option<Vec<usize>>,
581 ) -> Result<EvaluationMetrics> {
582 let mut metrics = EvaluationMetrics::default();
583
584 if !selected_features.is_empty() {
586 let mut total_relevance = 0.0;
587 for &feature_idx in selected_features {
588 if feature_idx < X_train.ncols() {
589 let correlation =
590 self.compute_correlation(X_train.column(feature_idx), y_train);
591 total_relevance += correlation.abs();
592 }
593 }
594 metrics.relevance_score = total_relevance / selected_features.len() as f64;
595 }
596
597 if selected_features.len() > 1 {
599 let mut total_correlation = 0.0;
600 let mut pair_count = 0;
601
602 for i in 0..selected_features.len() {
603 for j in (i + 1)..selected_features.len() {
604 if selected_features[i] < X_train.ncols()
605 && selected_features[j] < X_train.ncols()
606 {
607 let correlation = self.compute_correlation(
608 X_train.column(selected_features[i]),
609 X_train.column(selected_features[j]),
610 );
611 total_correlation += correlation.abs();
612 pair_count += 1;
613 }
614 }
615 }
616
617 if pair_count > 0 {
618 metrics.redundancy_score = total_correlation / pair_count as f64;
619 }
620 }
621
622 if let Some(true_features) = ground_truth {
624 let selected_set: std::collections::HashSet<_> = selected_features.iter().collect();
625 let true_set: std::collections::HashSet<_> = true_features.iter().collect();
626
627 let intersection = selected_set.intersection(&true_set).count() as f64;
628 let union = selected_set.union(&true_set).count() as f64;
629
630 if union > 0.0 {
631 metrics.jaccard_score = Some(intersection / union);
632 }
633
634 if !true_features.is_empty() {
635 metrics.precision = Some(intersection / selected_features.len() as f64);
636 metrics.recall = Some(intersection / true_features.len() as f64);
637
638 if let (Some(p), Some(r)) = (metrics.precision, metrics.recall) {
639 if p + r > 0.0 {
640 metrics.f1_score = Some(2.0 * p * r / (p + r));
641 }
642 }
643 }
644 }
645
646 metrics.predictive_score = metrics.relevance_score * (1.0 - metrics.redundancy_score * 0.5);
648
649 Ok(metrics)
650 }
651
652 fn compute_feature_metrics(
653 &self,
654 X: ArrayView2<f64>,
655 y: ArrayView1<f64>,
656 selected_features: &[usize],
657 ) -> Result<FeatureMetrics> {
658 let mut metrics = FeatureMetrics::default();
659
660 if selected_features.is_empty() {
661 return Ok(metrics);
662 }
663
664 metrics.selection_ratio = selected_features.len() as f64 / X.ncols() as f64;
666
667 let mut importances = Vec::new();
669 for &feature_idx in selected_features {
670 if feature_idx < X.ncols() {
671 let importance = self.compute_correlation(X.column(feature_idx), y).abs();
672 importances.push(importance);
673 }
674 }
675
676 if !importances.is_empty() {
677 importances.sort_by(|a, b| b.partial_cmp(a).expect("operation should succeed"));
678 metrics.importance_distribution = ImportanceDistribution {
679 mean: importances.iter().sum::<f64>() / importances.len() as f64,
680 std: {
681 let mean = importances.iter().sum::<f64>() / importances.len() as f64;
682 let variance = importances.iter().map(|x| (x - mean).powi(2)).sum::<f64>()
683 / importances.len() as f64;
684 variance.sqrt()
685 },
686 max: importances[0],
687 min: importances[importances.len() - 1],
688 };
689 }
690
691 Ok(metrics)
692 }
693
694 fn compute_correlation(&self, x: ArrayView1<f64>, y: ArrayView1<f64>) -> f64 {
695 let n = x.len() as f64;
696 if n < 2.0 {
697 return 0.0;
698 }
699
700 let mean_x = x.mean().unwrap_or(0.0);
701 let mean_y = y.mean().unwrap_or(0.0);
702
703 let mut sum_xy = 0.0;
704 let mut sum_x2 = 0.0;
705 let mut sum_y2 = 0.0;
706
707 for i in 0..x.len() {
708 let dx = x[i] - mean_x;
709 let dy = y[i] - mean_y;
710 sum_xy += dx * dy;
711 sum_x2 += dx * dx;
712 sum_y2 += dy * dy;
713 }
714
715 let denom = (sum_x2 * sum_y2).sqrt();
716 if denom < 1e-10 {
717 0.0
718 } else {
719 sum_xy / denom
720 }
721 }
722
723 fn aggregate_method_results(&self, results: &[BenchmarkResult]) -> Result<BenchmarkResult> {
724 if results.is_empty() {
725 return Err(BenchmarkError::StatisticalAnalysisFailed.into());
726 }
727
728 let first = &results[0];
729
730 let aggregate_evaluation_metrics = EvaluationMetrics {
732 relevance_score: results
733 .iter()
734 .map(|r| r.evaluation_metrics.relevance_score)
735 .sum::<f64>()
736 / results.len() as f64,
737 redundancy_score: results
738 .iter()
739 .map(|r| r.evaluation_metrics.redundancy_score)
740 .sum::<f64>()
741 / results.len() as f64,
742 predictive_score: results
743 .iter()
744 .map(|r| r.evaluation_metrics.predictive_score)
745 .sum::<f64>()
746 / results.len() as f64,
747 jaccard_score: if results
748 .iter()
749 .all(|r| r.evaluation_metrics.jaccard_score.is_some())
750 {
751 Some(
752 results
753 .iter()
754 .map(|r| {
755 r.evaluation_metrics
756 .jaccard_score
757 .expect("operation should succeed")
758 })
759 .sum::<f64>()
760 / results.len() as f64,
761 )
762 } else {
763 None
764 },
765 precision: if results
766 .iter()
767 .all(|r| r.evaluation_metrics.precision.is_some())
768 {
769 Some(
770 results
771 .iter()
772 .map(|r| {
773 r.evaluation_metrics
774 .precision
775 .expect("operation should succeed")
776 })
777 .sum::<f64>()
778 / results.len() as f64,
779 )
780 } else {
781 None
782 },
783 recall: if results
784 .iter()
785 .all(|r| r.evaluation_metrics.recall.is_some())
786 {
787 Some(
788 results
789 .iter()
790 .map(|r| {
791 r.evaluation_metrics
792 .recall
793 .expect("operation should succeed")
794 })
795 .sum::<f64>()
796 / results.len() as f64,
797 )
798 } else {
799 None
800 },
801 f1_score: if results
802 .iter()
803 .all(|r| r.evaluation_metrics.f1_score.is_some())
804 {
805 Some(
806 results
807 .iter()
808 .map(|r| {
809 r.evaluation_metrics
810 .f1_score
811 .expect("operation should succeed")
812 })
813 .sum::<f64>()
814 / results.len() as f64,
815 )
816 } else {
817 None
818 },
819 };
820
821 let most_common_features = first.selected_features.clone(); Ok(BenchmarkResult {
825 method_name: first.method_name.clone(),
826 dataset_name: first.dataset_name.clone(),
827 iteration: 999, selected_features: most_common_features,
829 n_features_selected: results.iter().map(|r| r.n_features_selected).sum::<usize>()
830 / results.len(),
831 n_features_total: first.n_features_total,
832 selection_time: Duration::from_nanos(
833 (results
834 .iter()
835 .map(|r| r.selection_time.as_nanos())
836 .sum::<u128>()
837 / results.len() as u128) as u64,
838 ),
839 evaluation_time: Duration::from_nanos(
840 (results
841 .iter()
842 .map(|r| r.evaluation_time.as_nanos())
843 .sum::<u128>()
844 / results.len() as u128) as u64,
845 ),
846 memory_usage: results.iter().map(|r| r.memory_usage).sum::<usize>() / results.len(),
847 evaluation_metrics: aggregate_evaluation_metrics,
848 feature_metrics: first.feature_metrics.clone(), dataset_properties: first.dataset_properties.clone(),
850 })
851 }
852
853 fn perform_statistical_analysis(
854 &self,
855 results: &[BenchmarkResult],
856 ) -> Result<StatisticalAnalysis> {
857 let mut method_performances: HashMap<String, Vec<f64>> = HashMap::new();
859 let mut dataset_difficulties: HashMap<String, Vec<f64>> = HashMap::new();
860
861 for result in results {
862 method_performances
863 .entry(result.method_name.clone())
864 .or_default()
865 .push(result.evaluation_metrics.predictive_score);
866
867 dataset_difficulties
868 .entry(result.dataset_name.clone())
869 .or_default()
870 .push(result.evaluation_metrics.predictive_score);
871 }
872
873 let mut method_rankings = Vec::new();
875 for (method, scores) in method_performances {
876 let mean_score = scores.iter().sum::<f64>() / scores.len() as f64;
877 let std_score = {
878 let variance = scores.iter().map(|s| (s - mean_score).powi(2)).sum::<f64>()
879 / scores.len() as f64;
880 variance.sqrt()
881 };
882
883 method_rankings.push(MethodRanking {
884 method_name: method,
885 mean_score,
886 std_score,
887 scores,
888 });
889 }
890
891 method_rankings.sort_by(|a, b| {
892 b.mean_score
893 .partial_cmp(&a.mean_score)
894 .expect("operation should succeed")
895 });
896
897 let mut dataset_rankings = Vec::new();
899 for (dataset, scores) in dataset_difficulties {
900 let mean_score = scores.iter().sum::<f64>() / scores.len() as f64;
901
902 dataset_rankings.push(DatasetRanking {
903 dataset_name: dataset,
904 difficulty_score: 1.0 - mean_score, mean_performance: mean_score,
906 });
907 }
908
909 dataset_rankings.sort_by(|a, b| {
910 b.difficulty_score
911 .partial_cmp(&a.difficulty_score)
912 .expect("operation should succeed")
913 });
914
915 let overall_best_method = method_rankings.first().map(|r| r.method_name.clone());
916
917 Ok(StatisticalAnalysis {
918 method_rankings,
919 dataset_rankings,
920 overall_best_method,
921 performance_summary: self.compute_performance_summary(results)?,
922 })
923 }
924
925 fn compute_performance_summary(
926 &self,
927 results: &[BenchmarkResult],
928 ) -> Result<PerformanceSummary> {
929 if results.is_empty() {
930 return Err(BenchmarkError::StatisticalAnalysisFailed.into());
931 }
932
933 let scores: Vec<f64> = results
934 .iter()
935 .map(|r| r.evaluation_metrics.predictive_score)
936 .collect();
937 let mean_score = scores.iter().sum::<f64>() / scores.len() as f64;
938
939 let std_score = {
940 let variance =
941 scores.iter().map(|s| (s - mean_score).powi(2)).sum::<f64>() / scores.len() as f64;
942 variance.sqrt()
943 };
944
945 let min_score = scores.iter().fold(f64::INFINITY, |acc, &x| acc.min(x));
946 let max_score = scores.iter().fold(f64::NEG_INFINITY, |acc, &x| acc.max(x));
947
948 let total_time: Duration = results
949 .iter()
950 .map(|r| r.selection_time + r.evaluation_time)
951 .sum();
952 let avg_features_selected = results.iter().map(|r| r.n_features_selected).sum::<usize>()
953 as f64
954 / results.len() as f64;
955
956 Ok(PerformanceSummary {
957 mean_score,
958 std_score,
959 min_score,
960 max_score,
961 total_execution_time: total_time,
962 avg_features_selected,
963 })
964 }
965
966 fn generate_rankings(&self, results: &[BenchmarkResult]) -> Result<Vec<MethodRanking>> {
967 let mut method_scores: HashMap<String, Vec<f64>> = HashMap::new();
968
969 for result in results {
970 method_scores
971 .entry(result.method_name.clone())
972 .or_default()
973 .push(result.evaluation_metrics.predictive_score);
974 }
975
976 let mut rankings = Vec::new();
977 for (method, scores) in method_scores {
978 let mean_score = scores.iter().sum::<f64>() / scores.len() as f64;
979 let std_score = {
980 let variance = scores.iter().map(|s| (s - mean_score).powi(2)).sum::<f64>()
981 / scores.len() as f64;
982 variance.sqrt()
983 };
984
985 rankings.push(MethodRanking {
986 method_name: method,
987 mean_score,
988 std_score,
989 scores,
990 });
991 }
992
993 rankings.sort_by(|a, b| {
994 b.mean_score
995 .partial_cmp(&a.mean_score)
996 .expect("operation should succeed")
997 });
998 Ok(rankings)
999 }
1000}
1001
1002pub trait BenchmarkableMethod: std::fmt::Debug + Send + Sync {
1004 fn select_features(
1006 &self,
1007 X: ArrayView2<f64>,
1008 y: ArrayView1<f64>,
1009 ) -> std::result::Result<Vec<usize>, BenchmarkError>;
1010 fn method_name(&self) -> &str;
1012 fn method_params(&self) -> HashMap<String, String>;
1014}
1015
1016#[derive(Debug, Clone)]
1019pub struct BenchmarkDataset {
1021 pub name: String,
1023 pub X: Array2<f64>,
1025 pub y: Array1<f64>,
1027 pub ground_truth: Option<Vec<usize>>,
1029 pub properties: DatasetProperties,
1031}
1032
1033#[derive(Debug, Clone)]
1034pub struct DatasetProperties {
1036 pub task_type: TaskType,
1038 pub n_informative_features: usize,
1040 pub noise_level: f64,
1042 pub correlation_structure: CorrelationStructure,
1044 pub feature_types: Vec<FeatureType>,
1046}
1047
1048#[derive(Debug, Clone, PartialEq)]
1049pub enum TaskType {
1051 BinaryClassification,
1053 MultiClassification,
1055 Regression,
1057 MultiLabel,
1059}
1060
1061#[derive(Debug, Clone, PartialEq)]
1062pub enum CorrelationStructure {
1064 Linear,
1066 Nonlinear,
1068 Sparse,
1070 HighlyCorrelated,
1072 Noisy,
1074}
1075
1076#[derive(Debug, Clone, PartialEq)]
1077pub enum FeatureType {
1079 Continuous,
1081 Discrete,
1083 Binary,
1085 Categorical,
1087}
1088
1089#[derive(Debug, Clone)]
1090pub struct BenchmarkConfig {
1092 pub n_iterations: usize,
1094 pub use_cross_validation: bool,
1096 pub cv_folds: usize,
1098 pub test_size: f64,
1100 pub timeout_seconds: Option<u64>,
1102 pub memory_limit_mb: Option<usize>,
1104 pub verbose: bool,
1106 pub random_seed: Option<u64>,
1108}
1109
1110impl Default for BenchmarkConfig {
1111 fn default() -> Self {
1112 Self {
1113 n_iterations: 5,
1114 use_cross_validation: true,
1115 cv_folds: 3,
1116 test_size: 0.3,
1117 timeout_seconds: Some(300), memory_limit_mb: Some(2048), verbose: true,
1120 random_seed: Some(42),
1121 }
1122 }
1123}
1124
1125#[derive(Debug, Clone)]
1126pub struct BenchmarkResult {
1128 pub method_name: String,
1130 pub dataset_name: String,
1132 pub iteration: usize,
1134 pub selected_features: Vec<usize>,
1136 pub n_features_selected: usize,
1138 pub n_features_total: usize,
1140 pub selection_time: Duration,
1142 pub evaluation_time: Duration,
1144 pub memory_usage: usize,
1146 pub evaluation_metrics: EvaluationMetrics,
1148 pub feature_metrics: FeatureMetrics,
1150 pub dataset_properties: DatasetProperties,
1152}
1153
1154#[derive(Debug, Clone, Default)]
1155pub struct EvaluationMetrics {
1157 pub relevance_score: f64,
1159 pub redundancy_score: f64,
1161 pub predictive_score: f64,
1163 pub jaccard_score: Option<f64>,
1165 pub precision: Option<f64>,
1167 pub recall: Option<f64>,
1169 pub f1_score: Option<f64>,
1171}
1172
1173#[derive(Debug, Clone, Default)]
1174pub struct FeatureMetrics {
1176 pub selection_ratio: f64,
1178 pub importance_distribution: ImportanceDistribution,
1180}
1181
1182#[derive(Debug, Clone, Default)]
1183pub struct ImportanceDistribution {
1185 pub mean: f64,
1187 pub std: f64,
1189 pub max: f64,
1191 pub min: f64,
1193}
1194
1195#[derive(Debug, Clone)]
1196pub struct BenchmarkSuiteResults {
1198 pub individual_results: Vec<BenchmarkResult>,
1200 pub statistical_analysis: StatisticalAnalysis,
1202 pub rankings: Vec<MethodRanking>,
1204 pub execution_summary: ExecutionSummary,
1206 pub configuration: BenchmarkConfig,
1208}
1209
1210impl BenchmarkSuiteResults {
1211 pub fn report(&self) -> String {
1213 let mut report = String::new();
1214
1215 report.push_str(
1216 "╔═══════════════════════════════════════════════════════════════════════════╗\n",
1217 );
1218 report.push_str(
1219 "║ Feature Selection Benchmark Report ║\n",
1220 );
1221 report.push_str(
1222 "╚═══════════════════════════════════════════════════════════════════════════╝\n\n",
1223 );
1224
1225 report.push_str("=== Execution Summary ===\n");
1227 report.push_str(&format!(
1228 "Total Duration: {:.2}s\n",
1229 self.execution_summary.total_duration.as_secs_f64()
1230 ));
1231 report.push_str(&format!(
1232 "Datasets: {}\n",
1233 self.execution_summary.n_datasets
1234 ));
1235 report.push_str(&format!("Methods: {}\n", self.execution_summary.n_methods));
1236 report.push_str(&format!(
1237 "Total Runs: {}\n",
1238 self.execution_summary.n_total_runs
1239 ));
1240
1241 if !self.rankings.is_empty() {
1243 report.push_str("\n=== Method Rankings ===\n");
1244 for (i, ranking) in self.rankings.iter().take(10).enumerate() {
1245 report.push_str(&format!(
1246 "{}. {} - Score: {:.4} ± {:.4}\n",
1247 i + 1,
1248 ranking.method_name,
1249 ranking.mean_score,
1250 ranking.std_score
1251 ));
1252 }
1253 }
1254
1255 if let Some(best_method) = &self.statistical_analysis.overall_best_method {
1257 report.push_str(&format!("\n=== Best Method: {} ===\n", best_method));
1258
1259 if let Some(best_ranking) = self.rankings.first() {
1260 report.push_str(&format!(
1261 "Mean Performance: {:.4}\n",
1262 best_ranking.mean_score
1263 ));
1264 report.push_str(&format!(
1265 "Consistency (StdDev): {:.4}\n",
1266 best_ranking.std_score
1267 ));
1268 report.push_str(&format!("Runs: {}\n", best_ranking.scores.len()));
1269 }
1270 }
1271
1272 if !self.statistical_analysis.dataset_rankings.is_empty() {
1274 report.push_str("\n=== Dataset Difficulty Ranking ===\n");
1275 for (i, dataset) in self
1276 .statistical_analysis
1277 .dataset_rankings
1278 .iter()
1279 .take(5)
1280 .enumerate()
1281 {
1282 report.push_str(&format!(
1283 "{}. {} - Difficulty: {:.4} (Avg Performance: {:.4})\n",
1284 i + 1,
1285 dataset.dataset_name,
1286 dataset.difficulty_score,
1287 dataset.mean_performance
1288 ));
1289 }
1290 }
1291
1292 report.push_str("\n=== Overall Performance Summary ===\n");
1294 let summary = &self.statistical_analysis.performance_summary;
1295 report.push_str(&format!(
1296 "Mean Score: {:.4} ± {:.4}\n",
1297 summary.mean_score, summary.std_score
1298 ));
1299 report.push_str(&format!(
1300 "Score Range: [{:.4}, {:.4}]\n",
1301 summary.min_score, summary.max_score
1302 ));
1303 report.push_str(&format!(
1304 "Total Time: {:.2}s\n",
1305 summary.total_execution_time.as_secs_f64()
1306 ));
1307 report.push_str(&format!(
1308 "Avg Features Selected: {:.1}\n",
1309 summary.avg_features_selected
1310 ));
1311
1312 report
1313 }
1314
1315 pub fn to_csv(&self) -> String {
1317 let mut csv = String::new();
1318
1319 csv.push_str("Method,Dataset,Iteration,FeaturesSelected,SelectionTime,RelevanceScore,RedundancyScore,PredictiveScore\n");
1320
1321 for result in &self.individual_results {
1322 csv.push_str(&format!(
1323 "{},{},{},{},{:.6},{:.4},{:.4},{:.4}\n",
1324 result.method_name,
1325 result.dataset_name,
1326 result.iteration,
1327 result.n_features_selected,
1328 result.selection_time.as_secs_f64(),
1329 result.evaluation_metrics.relevance_score,
1330 result.evaluation_metrics.redundancy_score,
1331 result.evaluation_metrics.predictive_score
1332 ));
1333 }
1334
1335 csv
1336 }
1337}
1338
1339#[derive(Debug, Clone)]
1340pub struct StatisticalAnalysis {
1342 pub method_rankings: Vec<MethodRanking>,
1344 pub dataset_rankings: Vec<DatasetRanking>,
1346 pub overall_best_method: Option<String>,
1348 pub performance_summary: PerformanceSummary,
1350}
1351
1352#[derive(Debug, Clone)]
1353pub struct MethodRanking {
1355 pub method_name: String,
1357 pub mean_score: f64,
1359 pub std_score: f64,
1361 pub scores: Vec<f64>,
1363}
1364
1365#[derive(Debug, Clone)]
1366pub struct DatasetRanking {
1368 pub dataset_name: String,
1370 pub difficulty_score: f64,
1372 pub mean_performance: f64,
1374}
1375
1376#[derive(Debug, Clone)]
1377pub struct PerformanceSummary {
1379 pub mean_score: f64,
1381 pub std_score: f64,
1383 pub min_score: f64,
1385 pub max_score: f64,
1387 pub total_execution_time: Duration,
1389 pub avg_features_selected: f64,
1391}
1392
1393#[derive(Debug, Clone)]
1394pub struct ExecutionSummary {
1396 pub total_duration: Duration,
1398 pub n_datasets: usize,
1400 pub n_methods: usize,
1402 pub n_total_runs: usize,
1404}
1405
1406#[derive(Debug)]
1408pub struct UnivariateFilterMethod {
1410 k: usize,
1411}
1412
1413impl UnivariateFilterMethod {
1414 pub fn new(k: usize) -> Self {
1416 Self { k }
1417 }
1418}
1419
1420impl BenchmarkableMethod for UnivariateFilterMethod {
1421 fn select_features(
1422 &self,
1423 X: ArrayView2<f64>,
1424 y: ArrayView1<f64>,
1425 ) -> std::result::Result<Vec<usize>, BenchmarkError> {
1426 let mut correlations: Vec<(usize, f64)> = Vec::new();
1427
1428 for i in 0..X.ncols() {
1429 let correlation = self.compute_correlation(X.column(i), y);
1430 correlations.push((i, correlation.abs()));
1431 }
1432
1433 correlations.sort_by(|a, b| b.1.partial_cmp(&a.1).expect("operation should succeed"));
1434
1435 Ok(correlations
1436 .into_iter()
1437 .take(self.k)
1438 .map(|(idx, _)| idx)
1439 .collect())
1440 }
1441
1442 fn method_name(&self) -> &str {
1443 "UnivariateFilter"
1444 }
1445
1446 fn method_params(&self) -> HashMap<String, String> {
1447 let mut params = HashMap::new();
1448 params.insert("k".to_string(), self.k.to_string());
1449 params
1450 }
1451}
1452
1453impl UnivariateFilterMethod {
1454 fn compute_correlation(&self, x: ArrayView1<f64>, y: ArrayView1<f64>) -> f64 {
1455 let n = x.len() as f64;
1456 if n < 2.0 {
1457 return 0.0;
1458 }
1459
1460 let mean_x = x.mean().unwrap_or(0.0);
1461 let mean_y = y.mean().unwrap_or(0.0);
1462
1463 let mut sum_xy = 0.0;
1464 let mut sum_x2 = 0.0;
1465 let mut sum_y2 = 0.0;
1466
1467 for i in 0..x.len() {
1468 let dx = x[i] - mean_x;
1469 let dy = y[i] - mean_y;
1470 sum_xy += dx * dy;
1471 sum_x2 += dx * dx;
1472 sum_y2 += dy * dy;
1473 }
1474
1475 let denom = (sum_x2 * sum_y2).sqrt();
1476 if denom < 1e-10 {
1477 0.0
1478 } else {
1479 sum_xy / denom
1480 }
1481 }
1482}
1483
1484#[derive(Debug)]
1485pub struct RandomSelectionMethod {
1487 k: usize,
1488}
1489
1490impl RandomSelectionMethod {
1491 pub fn new(k: usize) -> Self {
1493 Self { k }
1494 }
1495}
1496
1497impl BenchmarkableMethod for RandomSelectionMethod {
1498 fn select_features(
1499 &self,
1500 X: ArrayView2<f64>,
1501 _y: ArrayView1<f64>,
1502 ) -> std::result::Result<Vec<usize>, BenchmarkError> {
1503 let mut features: Vec<usize> = (0..X.ncols()).collect();
1504
1505 for i in (1..features.len()).rev() {
1507 let j = (thread_rng().random::<f64>() * (i + 1) as f64) as usize;
1508 features.swap(i, j);
1509 }
1510
1511 Ok(features.into_iter().take(self.k.min(X.ncols())).collect())
1512 }
1513
1514 fn method_name(&self) -> &str {
1515 "RandomSelection"
1516 }
1517
1518 fn method_params(&self) -> HashMap<String, String> {
1519 let mut params = HashMap::new();
1520 params.insert("k".to_string(), self.k.to_string());
1521 params
1522 }
1523}
1524
1525#[allow(non_snake_case)]
1526#[cfg(test)]
1527mod tests {
1528 use super::*;
1529 use scirs2_core::ndarray::array;
1530
1531 #[test]
1532 fn test_synthetic_dataset_creation() {
1533 let benchmark = FeatureSelectionBenchmark::new();
1534
1535 let dataset = benchmark
1536 .create_synthetic_linear_dataset(100, 20, 5)
1537 .expect("operation should succeed");
1538
1539 assert_eq!(dataset.X.nrows(), 100);
1540 assert_eq!(dataset.X.ncols(), 20);
1541 assert_eq!(dataset.y.len(), 100);
1542 assert_eq!(
1543 dataset
1544 .ground_truth
1545 .as_ref()
1546 .expect("operation should succeed")
1547 .len(),
1548 5
1549 );
1550 assert_eq!(dataset.properties.n_informative_features, 5);
1551 }
1552
1553 #[test]
1554 #[allow(non_snake_case)]
1555 fn test_benchmarkable_method() {
1556 let method = UnivariateFilterMethod::new(5);
1557
1558 let X = array![
1559 [1.0, 2.0, 3.0, 0.1],
1560 [2.0, 3.0, 4.0, 0.2],
1561 [3.0, 4.0, 5.0, 0.3],
1562 [4.0, 5.0, 6.0, 0.4],
1563 ];
1564 let y = array![1.0, 2.0, 3.0, 4.0];
1565
1566 let selected = method
1567 .select_features(X.view(), y.view())
1568 .expect("operation should succeed");
1569
1570 assert!(!selected.is_empty());
1571 assert!(selected.len() <= 4); assert!(selected.iter().all(|&idx| idx < X.ncols()));
1573 }
1574
1575 #[test]
1576 #[allow(non_snake_case)]
1577 fn test_benchmark_execution() {
1578 let mut benchmark = FeatureSelectionBenchmark::new();
1579 benchmark.config.n_iterations = 1; benchmark.config.verbose = false;
1581
1582 let X = array![
1584 [1.0, 2.0, 0.1, 0.2],
1585 [2.0, 3.0, 0.2, 0.3],
1586 [3.0, 4.0, 0.3, 0.4],
1587 [4.0, 5.0, 0.4, 0.5],
1588 [5.0, 6.0, 0.5, 0.6],
1589 ];
1590 let y = array![1.0, 2.0, 3.0, 4.0, 5.0];
1591
1592 let dataset = BenchmarkDataset {
1593 name: "TestDataset".to_string(),
1594 X,
1595 y,
1596 ground_truth: Some(vec![0, 1]),
1597 properties: DatasetProperties {
1598 task_type: TaskType::Regression,
1599 n_informative_features: 2,
1600 noise_level: 0.1,
1601 correlation_structure: CorrelationStructure::Linear,
1602 feature_types: vec![FeatureType::Continuous; 4],
1603 },
1604 };
1605
1606 benchmark
1607 .add_dataset(dataset)
1608 .expect("operation should succeed");
1609 benchmark.register_method(
1610 "UnivariateFilt".to_string(),
1611 Box::new(UnivariateFilterMethod::new(2)),
1612 );
1613 benchmark.register_method(
1614 "Random".to_string(),
1615 Box::new(RandomSelectionMethod::new(2)),
1616 );
1617
1618 let results = benchmark.run_benchmark().expect("operation should succeed");
1619
1620 assert_eq!(results.individual_results.len(), 2); assert_eq!(results.rankings.len(), 2);
1622 assert!(results.statistical_analysis.overall_best_method.is_some());
1623
1624 let report = results.report();
1625 assert!(report.contains("Benchmark Report"));
1626 assert!(report.contains("Method Rankings"));
1627
1628 let csv = results.to_csv();
1629 assert!(csv.contains("Method,Dataset"));
1630 }
1631
1632 #[test]
1633 #[allow(non_snake_case)]
1634 fn test_evaluation_metrics() {
1635 let benchmark = FeatureSelectionBenchmark::new();
1636
1637 let X = array![[1.0, 2.0, 0.1], [2.0, 3.0, 0.2], [3.0, 4.0, 0.3],];
1638 let y = array![1.0, 2.0, 3.0];
1639
1640 let selected_features = vec![0, 1];
1641 let ground_truth = Some(vec![0, 1]);
1642
1643 let metrics = benchmark
1644 .evaluate_feature_selection(
1645 X.view(),
1646 y.view(),
1647 X.view(),
1648 y.view(),
1649 &selected_features,
1650 &ground_truth,
1651 )
1652 .expect("operation should succeed");
1653
1654 assert!(metrics.relevance_score >= 0.0);
1655 assert!(metrics.redundancy_score >= 0.0);
1656 assert!(metrics.predictive_score >= 0.0);
1657 assert!(metrics.jaccard_score.is_some());
1658 assert!(metrics.precision.is_some());
1659 assert!(metrics.recall.is_some());
1660 assert!(metrics.f1_score.is_some());
1661 }
1662}