Skip to main content

sklears_neural/
model_selection.rs

1use scirs2_core::ndarray::{Array1, Array2, ScalarOperand};
2use scirs2_core::numeric::{Float, ToPrimitive};
3use scirs2_core::random::{RngExt, SeedableRng};
4use std::fmt::Debug;
5
6use crate::activation::Activation;
7use crate::mlp_classifier::MLPClassifier;
8use crate::mlp_regressor::MLPRegressor;
9use sklears_core::error::SklearsError;
10use sklears_core::traits::{Fit, Predict};
11use sklears_core::types::FloatBounds;
12use std::collections::HashMap;
13
14/// Type alias for cross-validation fold indices: (train_indices, val_indices)
15pub type FoldIndices = (Vec<usize>, Vec<usize>);
16
17/// Type alias for the statistics tuple returned by learning curve analysis
18pub type LearningCurveStats<T> = (Vec<T>, Vec<T>, Vec<T>, Vec<T>);
19
20// Model comparison and selection utilities for neural networks.
21// This module provides tools for comparing different neural network architectures,
22// hyperparameter optimization, cross-validation, and model selection.
23
24/// Model performance metrics
25#[derive(Debug, Clone)]
26pub struct ModelMetrics<T: Float> {
27    /// Model identifier/name
28    pub model_name: String,
29    /// Training accuracy/error
30    pub train_score: T,
31    /// Validation accuracy/error
32    pub validation_score: T,
33    /// Test accuracy/error (if available)
34    pub test_score: Option<T>,
35    /// Training time in seconds
36    pub training_time: T,
37    /// Number of parameters
38    pub num_parameters: usize,
39    /// Model complexity score
40    pub complexity_score: T,
41    /// Additional metrics
42    pub additional_metrics: HashMap<String, T>,
43}
44
45impl<T: Float> ModelMetrics<T> {
46    /// Create new model metrics
47    pub fn new(model_name: String) -> Self {
48        Self {
49            model_name,
50            train_score: T::zero(),
51            validation_score: T::zero(),
52            test_score: None,
53            training_time: T::zero(),
54            num_parameters: 0,
55            complexity_score: T::zero(),
56            additional_metrics: HashMap::new(),
57        }
58    }
59
60    /// Add an additional metric
61    pub fn add_metric(&mut self, name: String, value: T) {
62        self.additional_metrics.insert(name, value);
63    }
64
65    /// Get metric by name
66    pub fn get_metric(&self, name: &str) -> Option<T> {
67        match name {
68            "train_score" => Some(self.train_score),
69            "validation_score" => Some(self.validation_score),
70            "test_score" => self.test_score,
71            "training_time" => Some(self.training_time),
72            "complexity_score" => Some(self.complexity_score),
73            _ => self.additional_metrics.get(name).copied(),
74        }
75    }
76}
77
78/// Cross-validation configuration
79#[derive(Debug, Clone)]
80pub struct CrossValidationConfig {
81    /// Number of folds
82    pub n_folds: usize,
83    /// Random seed for reproducibility
84    pub random_seed: Option<u64>,
85    /// Whether to shuffle data before folding
86    pub shuffle: bool,
87    /// Stratify for classification (maintain class distribution)
88    pub stratify: bool,
89}
90
91impl Default for CrossValidationConfig {
92    fn default() -> Self {
93        Self {
94            n_folds: 5,
95            random_seed: Some(42),
96            shuffle: true,
97            stratify: true,
98        }
99    }
100}
101
102/// Cross-validation results
103#[derive(Debug, Clone)]
104pub struct CrossValidationResults<T: Float> {
105    /// Scores for each fold
106    pub fold_scores: Vec<T>,
107    /// Mean score across folds
108    pub mean_score: T,
109    /// Standard deviation of scores
110    pub std_score: T,
111    /// Best fold index
112    pub best_fold: usize,
113    /// Worst fold index
114    pub worst_fold: usize,
115}
116
117impl<T: Float + std::iter::Sum> CrossValidationResults<T> {
118    /// Create from fold scores
119    pub fn from_scores(scores: Vec<T>) -> Self {
120        let mean_score =
121            scores.iter().cloned().sum::<T>() / T::from(scores.len()).unwrap_or_else(|| T::zero());
122
123        let variance = scores
124            .iter()
125            .map(|&score| {
126                let diff = score - mean_score;
127                diff * diff
128            })
129            .sum::<T>()
130            / T::from(scores.len()).unwrap_or_else(|| T::zero());
131
132        let std_score = variance.sqrt();
133
134        let best_fold = scores
135            .iter()
136            .enumerate()
137            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
138            .map(|(idx, _)| idx)
139            .unwrap_or(0);
140
141        let worst_fold = scores
142            .iter()
143            .enumerate()
144            .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
145            .map(|(idx, _)| idx)
146            .unwrap_or(0);
147
148        Self {
149            fold_scores: scores,
150            mean_score,
151            std_score,
152            best_fold,
153            worst_fold,
154        }
155    }
156}
157
158/// Grid search configuration for hyperparameter optimization
159#[derive(Debug, Clone)]
160pub struct GridSearchConfig<T: Float> {
161    /// Hidden layer sizes to try
162    pub hidden_layer_sizes: Vec<Vec<usize>>,
163    /// Learning rates to try
164    pub learning_rates: Vec<T>,
165    /// Regularization strengths to try
166    pub alphas: Vec<T>,
167    /// Activation functions to try
168    pub activations: Vec<Activation>,
169    /// Maximum iterations to try
170    pub max_iters: Vec<usize>,
171    /// Cross-validation configuration
172    pub cv_config: CrossValidationConfig,
173    /// Scoring metric
174    pub scoring: String,
175}
176
177impl<T: Float> Default for GridSearchConfig<T> {
178    fn default() -> Self {
179        Self {
180            hidden_layer_sizes: vec![vec![100], vec![50, 50], vec![100, 50], vec![100, 100]],
181            learning_rates: vec![
182                T::from(0.001).unwrap_or_else(|| T::zero()),
183                T::from(0.01).unwrap_or_else(|| T::zero()),
184                T::from(0.1).unwrap_or_else(|| T::zero()),
185            ],
186            alphas: vec![
187                T::from(0.0001).unwrap_or_else(|| T::zero()),
188                T::from(0.001).unwrap_or_else(|| T::zero()),
189                T::from(0.01).unwrap_or_else(|| T::zero()),
190            ],
191            activations: vec![Activation::Relu, Activation::Tanh, Activation::Logistic],
192            max_iters: vec![200, 500, 1000],
193            cv_config: CrossValidationConfig::default(),
194            scoring: "accuracy".to_string(),
195        }
196    }
197}
198
199/// Hyperparameter combination
200#[derive(Debug, Clone)]
201pub struct HyperparameterSet<T: Float> {
202    /// Hidden layer architecture (number of neurons per layer)
203    pub hidden_layer_sizes: Vec<usize>,
204    /// Learning rate for the optimizer
205    pub learning_rate: T,
206    /// L2 regularization coefficient
207    pub alpha: T,
208    /// Activation function applied to hidden layers
209    pub activation: Activation,
210    /// Maximum number of training epochs
211    pub max_iter: usize,
212}
213
214/// Grid search results
215#[derive(Debug, Clone)]
216pub struct GridSearchResults<T: Float> {
217    /// Best hyperparameters found
218    pub best_params: HyperparameterSet<T>,
219    /// Best cross-validation score
220    pub best_score: T,
221    /// All parameter combinations tried
222    pub all_results: Vec<(HyperparameterSet<T>, CrossValidationResults<T>)>,
223    /// Best model (trained on full dataset)
224    pub best_estimator_metrics: ModelMetrics<T>,
225}
226
227/// Model selection and comparison framework
228#[derive(Debug)]
229pub struct ModelSelector<T: FloatBounds + ScalarOperand + ToPrimitive + std::iter::Sum> {
230    /// Cross-validation configuration
231    cv_config: CrossValidationConfig,
232    /// Comparison results
233    results: Vec<ModelMetrics<T>>,
234}
235
236impl<T: FloatBounds + ScalarOperand + ToPrimitive + std::iter::Sum> ModelSelector<T> {
237    /// Create a new model selector
238    pub fn new(cv_config: CrossValidationConfig) -> Self {
239        Self {
240            cv_config,
241            results: Vec::new(),
242        }
243    }
244
245    /// Perform k-fold cross-validation on a classifier
246    pub fn cross_validate_classifier(
247        &self,
248        X: &Array2<T>,
249        y: &[usize],
250        hidden_layer_sizes: &[usize],
251        activation: Activation,
252        learning_rate: T,
253        alpha: T,
254        max_iter: usize,
255    ) -> Result<CrossValidationResults<T>, SklearsError> {
256        let folds = self.create_folds(X, Some(y))?;
257        let mut scores = Vec::new();
258
259        for (train_indices, val_indices) in folds {
260            // Create training and validation sets
261            let X_train = self.select_rows(X, &train_indices)?;
262            let y_train: Vec<usize> = train_indices.iter().map(|&i| y[i]).collect();
263            let X_val = self.select_rows(X, &val_indices)?;
264            let y_val: Vec<usize> = val_indices.iter().map(|&i| y[i]).collect();
265
266            // Train classifier
267            let classifier = MLPClassifier::new()
268                .hidden_layer_sizes(hidden_layer_sizes)
269                .activation(activation)
270                .learning_rate_init(learning_rate.to_f64().unwrap_or(0.0))
271                .alpha(alpha.to_f64().unwrap_or(0.0))
272                .max_iter(max_iter);
273
274            // Convert T arrays to f64 for neural network models
275            let X_train_f64 = X_train.mapv(|x| x.to_f64().unwrap_or(0.0));
276            let X_val_f64 = X_val.mapv(|x| x.to_f64().unwrap_or(0.0));
277
278            let trained_classifier = classifier.fit(&X_train_f64, &y_train)?;
279            let predictions = trained_classifier.predict(&X_val_f64)?;
280
281            // Compute accuracy
282            let accuracy = self.compute_accuracy(&predictions, &y_val);
283            scores.push(T::from(accuracy).unwrap_or_else(|| T::zero()));
284        }
285
286        Ok(CrossValidationResults::from_scores(scores))
287    }
288
289    /// Perform k-fold cross-validation on a regressor
290    pub fn cross_validate_regressor(
291        &self,
292        X: &Array2<T>,
293        y: &Array1<T>,
294        hidden_layer_sizes: &[usize],
295        activation: Activation,
296        learning_rate: T,
297        alpha: T,
298        max_iter: usize,
299    ) -> Result<CrossValidationResults<T>, SklearsError> {
300        let folds = self.create_folds(X, None)?;
301        let mut scores = Vec::new();
302
303        for (train_indices, val_indices) in folds {
304            // Create training and validation sets
305            let X_train = self.select_rows(X, &train_indices)?;
306            let y_train = self.select_elements(y, &train_indices)?;
307            let X_val = self.select_rows(X, &val_indices)?;
308            let y_val = self.select_elements(y, &val_indices)?;
309
310            // Train regressor
311            let regressor = MLPRegressor::new()
312                .hidden_layer_sizes(hidden_layer_sizes)
313                .activation(activation)
314                .learning_rate_init(learning_rate.to_f64().unwrap_or(0.0))
315                .alpha(alpha.to_f64().unwrap_or(0.0))
316                .max_iter(max_iter);
317
318            // Convert T arrays to f64 for neural network models
319            let X_train_f64 = X_train.mapv(|x| x.to_f64().unwrap_or(0.0));
320            let X_val_f64 = X_val.mapv(|x| x.to_f64().unwrap_or(0.0));
321            let y_train_f64 = y_train.mapv(|x| x.to_f64().unwrap_or(0.0));
322
323            // Reshape y for neural network (expects 2D)
324            let y_train_2d = y_train_f64.insert_axis(scirs2_core::ndarray::Axis(1));
325
326            let trained_regressor = regressor.fit(&X_train_f64, &y_train_2d)?;
327            let predictions = trained_regressor.predict(&X_val_f64)?;
328
329            // Convert predictions back to 1D for scoring and convert to T
330            let predictions_1d_f64 = predictions.column(0).to_owned();
331            let predictions_1d =
332                predictions_1d_f64.mapv(|x| T::from(x).unwrap_or_else(|| T::zero()));
333
334            // Compute R² score
335            let r2_score = self.compute_r2_score(&predictions_1d, &y_val);
336            scores.push(T::from(r2_score).unwrap_or_else(|| T::zero()));
337        }
338
339        Ok(CrossValidationResults::from_scores(scores))
340    }
341
342    /// Perform grid search for classifier
343    pub fn grid_search_classifier(
344        &self,
345        X: &Array2<T>,
346        y: &[usize],
347        config: GridSearchConfig<T>,
348    ) -> Result<GridSearchResults<T>, SklearsError> {
349        let mut best_score = T::neg_infinity();
350        let mut best_params = None;
351        let mut all_results = Vec::new();
352
353        // Try all parameter combinations
354        for hidden_layers in &config.hidden_layer_sizes {
355            for &learning_rate in &config.learning_rates {
356                for &alpha in &config.alphas {
357                    for activation in &config.activations {
358                        for &max_iter in &config.max_iters {
359                            let params = HyperparameterSet {
360                                hidden_layer_sizes: hidden_layers.clone(),
361                                learning_rate,
362                                alpha,
363                                activation: *activation,
364                                max_iter,
365                            };
366
367                            let cv_results = self.cross_validate_classifier(
368                                X,
369                                y,
370                                hidden_layers,
371                                *activation,
372                                learning_rate,
373                                alpha,
374                                max_iter,
375                            )?;
376
377                            if cv_results.mean_score > best_score {
378                                best_score = cv_results.mean_score;
379                                best_params = Some(params.clone());
380                            }
381
382                            all_results.push((params, cv_results));
383                        }
384                    }
385                }
386            }
387        }
388
389        let best_params = best_params.ok_or_else(|| SklearsError::InvalidParameter {
390            name: "grid_search".to_string(),
391            reason: "No valid parameter combinations found".to_string(),
392        })?;
393
394        // Train final model with best parameters
395        let final_classifier = MLPClassifier::new()
396            .hidden_layer_sizes(&best_params.hidden_layer_sizes)
397            .activation(best_params.activation)
398            .learning_rate_init(best_params.learning_rate.to_f64().unwrap_or(0.0))
399            .alpha(best_params.alpha.to_f64().unwrap_or(0.0))
400            .max_iter(best_params.max_iter);
401
402        // Convert to f64 for neural network model
403        let x_f64 = X.mapv(|x| x.to_f64().unwrap_or(0.0));
404        let y_vec = y.to_vec();
405        let _trained_final = final_classifier.fit(&x_f64, &y_vec)?;
406
407        let best_estimator_metrics = ModelMetrics {
408            model_name: "Best_MLP_Classifier".to_string(),
409            train_score: best_score,
410            validation_score: best_score,
411            test_score: None,
412            training_time: T::zero(),
413            num_parameters: 0,
414            complexity_score: T::zero(),
415            additional_metrics: HashMap::new(),
416        };
417
418        Ok(GridSearchResults {
419            best_params,
420            best_score,
421            all_results,
422            best_estimator_metrics,
423        })
424    }
425
426    /// Compare multiple models
427    pub fn compare_models(&mut self, models: Vec<ModelMetrics<T>>) -> Vec<ModelMetrics<T>> {
428        self.results.extend(models);
429
430        // Sort by validation score (descending)
431        self.results.sort_by(|a, b| {
432            b.validation_score
433                .partial_cmp(&a.validation_score)
434                .unwrap_or(std::cmp::Ordering::Equal)
435        });
436
437        self.results.clone()
438    }
439
440    /// Get model rankings
441    pub fn get_rankings(&self) -> Vec<(usize, &ModelMetrics<T>)> {
442        self.results.iter().enumerate().collect()
443    }
444
445    /// Create k-fold splits
446    /// Create k-fold splits from training data
447    fn create_folds(
448        &self,
449        X: &Array2<T>,
450        _y: Option<&[usize]>,
451    ) -> Result<Vec<FoldIndices>, SklearsError> {
452        let n_samples = X.nrows();
453        let fold_size = n_samples / self.cv_config.n_folds;
454        let mut indices: Vec<usize> = (0..n_samples).collect();
455
456        // Shuffle if requested
457        if self.cv_config.shuffle {
458            if let Some(seed) = self.cv_config.random_seed {
459                let mut rng = scirs2_core::random::rngs::StdRng::seed_from_u64(seed);
460                for i in (1..indices.len()).rev() {
461                    let j = rng.random_range(0..i + 1);
462                    indices.swap(i, j);
463                }
464            }
465        }
466
467        let mut folds = Vec::new();
468
469        for fold in 0..self.cv_config.n_folds {
470            let start = fold * fold_size;
471            let end = if fold == self.cv_config.n_folds - 1 {
472                n_samples // Include remaining samples in last fold
473            } else {
474                (fold + 1) * fold_size
475            };
476
477            let val_indices = indices[start..end].to_vec();
478            let train_indices: Vec<usize> = indices[0..start]
479                .iter()
480                .chain(indices[end..].iter())
481                .cloned()
482                .collect();
483
484            folds.push((train_indices, val_indices));
485        }
486
487        Ok(folds)
488    }
489
490    /// Select rows by indices
491    fn select_rows(&self, X: &Array2<T>, indices: &[usize]) -> Result<Array2<T>, SklearsError> {
492        let mut selected = Array2::zeros((indices.len(), X.ncols()));
493
494        for (i, &idx) in indices.iter().enumerate() {
495            if idx < X.nrows() {
496                selected.row_mut(i).assign(&X.row(idx));
497            }
498        }
499
500        Ok(selected)
501    }
502
503    /// Select elements by indices
504    fn select_elements(&self, y: &Array1<T>, indices: &[usize]) -> Result<Array1<T>, SklearsError> {
505        let mut selected = Array1::zeros(indices.len());
506
507        for (i, &idx) in indices.iter().enumerate() {
508            if idx < y.len() {
509                selected[i] = y[idx];
510            }
511        }
512
513        Ok(selected)
514    }
515
516    /// Compute classification accuracy
517    fn compute_accuracy(&self, predictions: &[usize], y_true: &[usize]) -> f64 {
518        if predictions.len() != y_true.len() {
519            return 0.0;
520        }
521
522        let correct = predictions
523            .iter()
524            .zip(y_true.iter())
525            .filter(|(pred, true_)| pred == true_)
526            .count();
527
528        correct as f64 / predictions.len() as f64
529    }
530
531    /// Compute R² score for regression
532    fn compute_r2_score(&self, predictions: &Array1<T>, y_true: &Array1<T>) -> f64 {
533        if predictions.len() != y_true.len() {
534            return 0.0;
535        }
536
537        // Convert to f64 for calculations
538        let pred_f64: Vec<f64> = predictions
539            .iter()
540            .map(|&x| x.to_f64().unwrap_or(0.0))
541            .collect();
542        let true_f64: Vec<f64> = y_true.iter().map(|&x| x.to_f64().unwrap_or(0.0)).collect();
543
544        let mean_true = true_f64.iter().sum::<f64>() / true_f64.len() as f64;
545
546        let ss_res: f64 = pred_f64
547            .iter()
548            .zip(true_f64.iter())
549            .map(|(pred, true_)| {
550                let diff = true_ - pred;
551                diff * diff
552            })
553            .sum();
554
555        let ss_tot: f64 = true_f64
556            .iter()
557            .map(|true_| {
558                let diff = true_ - mean_true;
559                diff * diff
560            })
561            .sum();
562
563        if ss_tot == 0.0 {
564            0.0
565        } else {
566            1.0 - (ss_res / ss_tot)
567        }
568    }
569}
570
571/// Learning curve analysis
572#[derive(Debug, Clone)]
573pub struct LearningCurveAnalyzer<T: Float> {
574    /// Training set sizes to analyze
575    pub train_sizes: Vec<usize>,
576    /// Training scores
577    pub train_scores: Vec<Vec<T>>,
578    /// Validation scores
579    pub validation_scores: Vec<Vec<T>>,
580}
581
582impl<T: Float + std::iter::Sum> Default for LearningCurveAnalyzer<T> {
583    fn default() -> Self {
584        Self::new()
585    }
586}
587
588impl<T: Float + std::iter::Sum> LearningCurveAnalyzer<T> {
589    /// Create new learning curve analyzer
590    pub fn new() -> Self {
591        Self {
592            train_sizes: Vec::new(),
593            train_scores: Vec::new(),
594            validation_scores: Vec::new(),
595        }
596    }
597
598    /// Analyze learning curves
599    pub fn analyze_learning_curve(
600        &mut self,
601        X: &Array2<T>,
602        y: &[usize],
603        hidden_layer_sizes: &[usize],
604        activation: Activation,
605        learning_rate: f64,
606        alpha: f64,
607        max_iter: usize,
608        train_sizes: Vec<usize>,
609    ) -> Result<(), SklearsError> {
610        self.train_sizes = train_sizes.clone();
611        self.train_scores.clear();
612        self.validation_scores.clear();
613
614        for &train_size in &train_sizes {
615            if train_size > X.nrows() {
616                continue;
617            }
618
619            // Create subset of data
620            let X_subset = X
621                .slice(scirs2_core::ndarray::s![..train_size, ..])
622                .to_owned();
623            let y_subset = &y[..train_size];
624
625            // Split into train/validation
626            let val_size = train_size / 5; // 20% for validation
627            let train_end = train_size - val_size;
628
629            let X_train = X_subset
630                .slice(scirs2_core::ndarray::s![..train_end, ..])
631                .to_owned();
632            let y_train = &y_subset[..train_end];
633            let X_val = X_subset
634                .slice(scirs2_core::ndarray::s![train_end.., ..])
635                .to_owned();
636            let y_val = &y_subset[train_end..];
637
638            // Train model
639            let classifier = MLPClassifier::new()
640                .hidden_layer_sizes(hidden_layer_sizes)
641                .activation(activation)
642                .learning_rate_init(learning_rate)
643                .alpha(alpha)
644                .max_iter(max_iter);
645
646            // Convert to f64 for neural network model
647            let X_train_f64 = X_train.mapv(|x| x.to_f64().unwrap_or(0.0));
648            let X_val_f64 = X_val.mapv(|x| x.to_f64().unwrap_or(0.0));
649            let y_train_vec = y_train.to_vec();
650
651            let trained = classifier.fit(&X_train_f64, &y_train_vec)?;
652
653            // Evaluate on training set
654            let train_pred = trained.predict(&X_train_f64)?;
655            let train_accuracy = self.compute_accuracy(&train_pred, y_train);
656
657            // Evaluate on validation set
658            let val_pred = trained.predict(&X_val_f64)?;
659            let val_accuracy = self.compute_accuracy(&val_pred, y_val);
660
661            self.train_scores
662                .push(vec![T::from(train_accuracy).unwrap_or_else(|| T::zero())]);
663            self.validation_scores
664                .push(vec![T::from(val_accuracy).unwrap_or_else(|| T::zero())]);
665        }
666
667        Ok(())
668    }
669
670    /// Get learning curve statistics
671    pub fn get_statistics(&self) -> Result<LearningCurveStats<T>, SklearsError> {
672        let train_means: Vec<T> = self
673            .train_scores
674            .iter()
675            .map(|scores| {
676                scores.iter().cloned().sum::<T>()
677                    / T::from(scores.len()).unwrap_or_else(|| T::zero())
678            })
679            .collect();
680
681        let train_stds: Vec<T> = self
682            .train_scores
683            .iter()
684            .zip(train_means.iter())
685            .map(|(scores, &mean)| {
686                let variance = scores
687                    .iter()
688                    .map(|&score| {
689                        let diff = score - mean;
690                        diff * diff
691                    })
692                    .sum::<T>()
693                    / T::from(scores.len()).unwrap_or_else(|| T::zero());
694                variance.sqrt()
695            })
696            .collect();
697
698        let val_means: Vec<T> = self
699            .validation_scores
700            .iter()
701            .map(|scores| {
702                scores.iter().cloned().sum::<T>()
703                    / T::from(scores.len()).unwrap_or_else(|| T::zero())
704            })
705            .collect();
706
707        let val_stds: Vec<T> = self
708            .validation_scores
709            .iter()
710            .zip(val_means.iter())
711            .map(|(scores, &mean)| {
712                let variance = scores
713                    .iter()
714                    .map(|&score| {
715                        let diff = score - mean;
716                        diff * diff
717                    })
718                    .sum::<T>()
719                    / T::from(scores.len()).unwrap_or_else(|| T::zero());
720                variance.sqrt()
721            })
722            .collect();
723
724        Ok((train_means, train_stds, val_means, val_stds))
725    }
726
727    /// Compute accuracy
728    fn compute_accuracy(&self, predictions: &[usize], y_true: &[usize]) -> f64 {
729        if predictions.len() != y_true.len() {
730            return 0.0;
731        }
732
733        let correct = predictions
734            .iter()
735            .zip(y_true.iter())
736            .filter(|(pred, true_)| pred == true_)
737            .count();
738
739        correct as f64 / predictions.len() as f64
740    }
741}
742
743#[allow(non_snake_case)]
744#[cfg(test)]
745mod tests {
746    use super::*;
747    use approx::assert_abs_diff_eq;
748
749    #[test]
750    fn test_model_metrics_creation() {
751        let mut metrics = ModelMetrics::<f32>::new("TestModel".to_string());
752        metrics.train_score = 0.95;
753        metrics.validation_score = 0.90;
754        metrics.add_metric("precision".to_string(), 0.88);
755
756        assert_eq!(metrics.model_name, "TestModel");
757        assert_eq!(metrics.train_score, 0.95);
758        assert_eq!(metrics.get_metric("precision"), Some(0.88));
759    }
760
761    #[test]
762    fn test_cross_validation_results() {
763        let scores = vec![0.8, 0.85, 0.82, 0.87, 0.83];
764        let results = CrossValidationResults::from_scores(scores);
765
766        assert_abs_diff_eq!(results.mean_score, 0.834, epsilon = 1e-3);
767        assert!(results.std_score > 0.0);
768        assert_eq!(results.best_fold, 3); // Index of 0.87
769    }
770
771    #[test]
772    fn test_model_selector_creation() {
773        let cv_config = CrossValidationConfig::default();
774        let selector = ModelSelector::<f32>::new(cv_config);
775        assert_eq!(selector.cv_config.n_folds, 5);
776    }
777
778    #[test]
779    fn test_fold_creation() {
780        let cv_config = CrossValidationConfig {
781            n_folds: 3,
782            random_seed: Some(42),
783            shuffle: false,
784            stratify: false,
785        };
786        let selector = ModelSelector::<f32>::new(cv_config);
787
788        let X = Array2::from_shape_vec((9, 2), vec![1.0; 18]).expect("array shape mismatch");
789        let folds = selector
790            .create_folds(&X, None)
791            .expect("operation should succeed");
792
793        assert_eq!(folds.len(), 3);
794        assert_eq!(folds[0].1.len(), 3); // First fold validation size
795        assert_eq!(folds[0].0.len(), 6); // First fold training size
796    }
797
798    #[test]
799    fn test_accuracy_computation() {
800        let selector = ModelSelector::<f32>::new(CrossValidationConfig::default());
801
802        let predictions = vec![0, 1, 1, 0, 1];
803        let y_true = vec![0, 1, 0, 0, 1];
804
805        let accuracy = selector.compute_accuracy(&predictions, &y_true);
806        assert_abs_diff_eq!(accuracy, 0.8, epsilon = 1e-6);
807    }
808
809    #[test]
810    fn test_r2_score_computation() {
811        let selector = ModelSelector::<f32>::new(CrossValidationConfig::default());
812
813        let predictions = Array1::from_vec(vec![1.0, 2.0, 3.0, 4.0]);
814        let y_true = Array1::from_vec(vec![1.1, 1.9, 3.1, 3.9]);
815
816        let r2 = selector.compute_r2_score(&predictions, &y_true);
817        assert!(r2 > 0.9); // Should be high for good predictions
818    }
819
820    #[test]
821    fn test_grid_search_config_default() {
822        let config = GridSearchConfig::<f32>::default();
823        assert!(!config.hidden_layer_sizes.is_empty());
824        assert!(!config.learning_rates.is_empty());
825        assert!(!config.activations.is_empty());
826    }
827
828    #[test]
829    fn test_hyperparameter_set() {
830        let params = HyperparameterSet {
831            hidden_layer_sizes: vec![100, 50],
832            learning_rate: 0.01,
833            alpha: 0.001,
834            activation: Activation::Relu,
835            max_iter: 1000,
836        };
837
838        assert_eq!(params.hidden_layer_sizes, vec![100, 50]);
839        assert_eq!(params.learning_rate, 0.01);
840    }
841
842    #[test]
843    fn test_learning_curve_analyzer() {
844        let analyzer = LearningCurveAnalyzer::<f32>::new();
845        assert!(analyzer.train_sizes.is_empty());
846        assert!(analyzer.train_scores.is_empty());
847    }
848
849    #[test]
850    fn test_model_comparison() {
851        let cv_config = CrossValidationConfig::default();
852        let mut selector = ModelSelector::<f32>::new(cv_config);
853
854        let model1 = ModelMetrics {
855            model_name: "Model1".to_string(),
856            train_score: 0.90,
857            validation_score: 0.85,
858            test_score: None,
859            training_time: 10.0,
860            num_parameters: 1000,
861            complexity_score: 0.5,
862            additional_metrics: HashMap::new(),
863        };
864
865        let model2 = ModelMetrics {
866            model_name: "Model2".to_string(),
867            train_score: 0.88,
868            validation_score: 0.87,
869            test_score: None,
870            training_time: 15.0,
871            num_parameters: 1500,
872            complexity_score: 0.7,
873            additional_metrics: HashMap::new(),
874        };
875
876        let results = selector.compare_models(vec![model1, model2]);
877        assert_eq!(results.len(), 2);
878        assert_eq!(results[0].model_name, "Model2"); // Better validation score
879    }
880}