Skip to main content

oxirs_stream/
automl_stream.rs

1//! # AutoML for Stream Processing
2//!
3//! This module provides automated machine learning capabilities for streaming data,
4//! including automatic algorithm selection, hyperparameter optimization, and model
5//! ensembling with minimal manual intervention.
6//!
7//! ## Features
8//! - Automatic algorithm selection from a pool of candidates
9//! - Hyperparameter optimization using Bayesian optimization
10//! - Adaptive model selection based on data drift
11//! - Ensemble methods for improved robustness
12//! - Online performance tracking and model swapping
13//! - Meta-learning for quick adaptation to new tasks
14//!
15//! ## Example Usage
16//! ```rust,ignore
17//! use oxirs_stream::automl_stream::{AutoML, AutoMLConfig, TaskType};
18//!
19//! let config = AutoMLConfig {
20//!     task_type: TaskType::Classification,
21//!     max_training_time_secs: 300,
22//!     ..Default::default()
23//! };
24//!
25//! let mut automl = AutoML::new(config)?;
26//! automl.fit(&training_data).await?;
27//! let prediction = automl.predict(&features).await?;
28//! ```
29
30use anyhow::{anyhow, Result};
31use scirs2_core::ndarray_ext::{Array1, Array2};
32use scirs2_core::random::{Random, RngExt};
33use serde::{Deserialize, Serialize};
34use std::collections::HashMap;
35use std::sync::Arc;
36use tokio::sync::{Mutex, RwLock};
37use tracing::info;
38
39/// Numerically-stable logistic sigmoid.
40fn sigmoid(z: f64) -> f64 {
41    if z >= 0.0 {
42        1.0 / (1.0 + (-z).exp())
43    } else {
44        let e = z.exp();
45        e / (1.0 + e)
46    }
47}
48
49/// Machine learning task type
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51pub enum TaskType {
52    /// Binary or multi-class classification
53    Classification,
54    /// Regression (continuous values)
55    Regression,
56    /// Time series forecasting
57    TimeSeries,
58    /// Anomaly detection
59    AnomalyDetection,
60    /// Clustering
61    Clustering,
62}
63
64/// Algorithm candidates for AutoML
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
66pub enum Algorithm {
67    /// Linear regression
68    LinearRegression,
69    /// Logistic regression
70    LogisticRegression,
71    /// Decision tree
72    DecisionTree,
73    /// Random forest
74    RandomForest,
75    /// Gradient boosting
76    GradientBoosting,
77    /// Neural network
78    NeuralNetwork,
79    /// K-Nearest Neighbors
80    KNN,
81    /// Support Vector Machine
82    SVM,
83    /// Naive Bayes
84    NaiveBayes,
85    /// Online learning (SGD)
86    OnlineSGD,
87    /// ARIMA for time series
88    ARIMA,
89    /// Isolation Forest for anomaly detection
90    IsolationForest,
91    /// K-Means for clustering
92    KMeans,
93}
94
95impl Algorithm {
96    /// Get compatible algorithms for a task type
97    pub fn for_task(task: TaskType) -> Vec<Algorithm> {
98        match task {
99            TaskType::Classification => vec![
100                Algorithm::LogisticRegression,
101                Algorithm::DecisionTree,
102                Algorithm::RandomForest,
103                Algorithm::GradientBoosting,
104                Algorithm::NeuralNetwork,
105                Algorithm::KNN,
106                Algorithm::NaiveBayes,
107            ],
108            TaskType::Regression => vec![
109                Algorithm::LinearRegression,
110                Algorithm::DecisionTree,
111                Algorithm::RandomForest,
112                Algorithm::GradientBoosting,
113                Algorithm::NeuralNetwork,
114                Algorithm::KNN,
115                Algorithm::SVM,
116            ],
117            TaskType::TimeSeries => vec![
118                Algorithm::ARIMA,
119                Algorithm::LinearRegression,
120                Algorithm::NeuralNetwork,
121                Algorithm::GradientBoosting,
122            ],
123            TaskType::AnomalyDetection => vec![
124                Algorithm::IsolationForest,
125                Algorithm::OnlineSGD,
126                Algorithm::NeuralNetwork,
127            ],
128            TaskType::Clustering => vec![Algorithm::KMeans],
129        }
130    }
131}
132
133/// Hyperparameter configuration
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct HyperParameters {
136    /// Learning rate
137    pub learning_rate: f64,
138    /// Number of estimators (trees, epochs, etc.)
139    pub n_estimators: usize,
140    /// Maximum depth (for tree-based models)
141    pub max_depth: Option<usize>,
142    /// Regularization strength
143    pub regularization: f64,
144    /// Number of neighbors (for KNN)
145    pub n_neighbors: usize,
146    /// Batch size (for neural networks)
147    pub batch_size: usize,
148    /// Random seed
149    pub random_seed: u64,
150}
151
152impl Default for HyperParameters {
153    fn default() -> Self {
154        Self {
155            learning_rate: 0.01,
156            n_estimators: 100,
157            max_depth: Some(5),
158            regularization: 0.1,
159            n_neighbors: 5,
160            batch_size: 32,
161            random_seed: 42,
162        }
163    }
164}
165
166/// Model performance metrics
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct ModelPerformance {
169    /// Algorithm used
170    pub algorithm: Algorithm,
171    /// Hyperparameters
172    pub hyperparameters: HyperParameters,
173    /// Accuracy (for classification)
174    pub accuracy: Option<f64>,
175    /// Precision
176    pub precision: Option<f64>,
177    /// Recall
178    pub recall: Option<f64>,
179    /// F1 score
180    pub f1_score: Option<f64>,
181    /// Mean squared error (for regression)
182    pub mse: Option<f64>,
183    /// R² score
184    pub r_squared: Option<f64>,
185    /// Training time (seconds)
186    pub training_time_secs: f64,
187    /// Inference time (milliseconds)
188    pub inference_time_ms: f64,
189    /// Model complexity score
190    pub complexity_score: f64,
191    /// Cross-validation score
192    pub cv_score: f64,
193}
194
195impl ModelPerformance {
196    /// Get overall score for model selection
197    pub fn overall_score(&self) -> f64 {
198        // Weighted combination of metrics
199        let perf_score = self.cv_score;
200        let time_penalty = (-self.training_time_secs / 60.0).exp(); // Penalize long training
201        let complexity_penalty = (-self.complexity_score / 100.0).exp(); // Penalize complexity
202
203        perf_score * time_penalty * complexity_penalty
204    }
205}
206
207/// AutoML configuration
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct AutoMLConfig {
210    /// Task type
211    pub task_type: TaskType,
212    /// Maximum training time (seconds) for AutoML search
213    pub max_training_time_secs: u64,
214    /// Number of hyperparameter optimization trials
215    pub n_trials: usize,
216    /// Cross-validation folds
217    pub cv_folds: usize,
218    /// Enable ensemble methods
219    pub enable_ensemble: bool,
220    /// Enable meta-learning
221    pub enable_meta_learning: bool,
222    /// Early stopping patience
223    pub early_stopping_patience: usize,
224    /// Metric to optimize
225    pub optimization_metric: String,
226    /// Enable automatic feature engineering
227    pub auto_feature_engineering: bool,
228    /// Maximum number of models to keep in ensemble
229    pub max_ensemble_size: usize,
230}
231
232impl Default for AutoMLConfig {
233    fn default() -> Self {
234        Self {
235            task_type: TaskType::Classification,
236            max_training_time_secs: 600,
237            n_trials: 50,
238            cv_folds: 5,
239            enable_ensemble: true,
240            enable_meta_learning: false,
241            early_stopping_patience: 10,
242            optimization_metric: "cv_score".to_string(),
243            auto_feature_engineering: true,
244            max_ensemble_size: 5,
245        }
246    }
247}
248
249/// Trained model representation
250#[derive(Debug, Clone)]
251pub struct TrainedModel {
252    /// Algorithm used
253    pub algorithm: Algorithm,
254    /// Hyperparameters
255    pub hyperparameters: HyperParameters,
256    /// Model weights/parameters
257    pub parameters: ModelParameters,
258    /// Performance metrics
259    pub performance: ModelPerformance,
260}
261
262/// Model parameters (simplified)
263#[derive(Debug, Clone)]
264pub struct ModelParameters {
265    /// Weight vector
266    pub weights: Vec<f64>,
267    /// Bias term
268    pub bias: f64,
269    /// Additional parameters (algorithm-specific)
270    pub extra: HashMap<String, Vec<f64>>,
271}
272
273impl Default for ModelParameters {
274    fn default() -> Self {
275        Self {
276            weights: Vec::new(),
277            bias: 0.0,
278            extra: HashMap::new(),
279        }
280    }
281}
282
283/// AutoML statistics
284#[derive(Debug, Clone, Serialize, Deserialize)]
285pub struct AutoMLStats {
286    /// Total trials executed
287    pub total_trials: u64,
288    /// Best model score
289    pub best_score: f64,
290    /// Total training time (seconds)
291    pub total_training_time_secs: f64,
292    /// Number of models in ensemble
293    pub ensemble_size: usize,
294    /// Current best algorithm
295    pub best_algorithm: Option<Algorithm>,
296    /// Number of predictions made
297    pub predictions_count: u64,
298    /// Average prediction time (ms)
299    pub avg_prediction_time_ms: f64,
300}
301
302impl Default for AutoMLStats {
303    fn default() -> Self {
304        Self {
305            total_trials: 0,
306            best_score: 0.0,
307            total_training_time_secs: 0.0,
308            ensemble_size: 0,
309            best_algorithm: None,
310            predictions_count: 0,
311            avg_prediction_time_ms: 0.0,
312        }
313    }
314}
315
316/// Main AutoML engine
317pub struct AutoML {
318    config: AutoMLConfig,
319    /// Best model found
320    best_model: Arc<RwLock<Option<TrainedModel>>>,
321    /// Ensemble of models
322    ensemble: Arc<RwLock<Vec<TrainedModel>>>,
323    /// Trial history
324    trial_history: Arc<RwLock<Vec<ModelPerformance>>>,
325    /// Statistics
326    stats: Arc<RwLock<AutoMLStats>>,
327    /// Random number generator
328    #[allow(clippy::arc_with_non_send_sync)]
329    rng: Arc<Mutex<Random>>,
330}
331
332impl AutoML {
333    /// Create a new AutoML instance
334    #[allow(clippy::arc_with_non_send_sync)]
335    pub fn new(config: AutoMLConfig) -> Result<Self> {
336        Ok(Self {
337            config,
338            best_model: Arc::new(RwLock::new(None)),
339            ensemble: Arc::new(RwLock::new(Vec::new())),
340            trial_history: Arc::new(RwLock::new(Vec::new())),
341            stats: Arc::new(RwLock::new(AutoMLStats::default())),
342            rng: Arc::new(Mutex::new(Random::default())),
343        })
344    }
345
346    /// Fit AutoML on training data
347    pub async fn fit(&mut self, features: &Array2<f64>, labels: &Array1<f64>) -> Result<()> {
348        info!(
349            "Starting AutoML training with task {:?}, {} samples, {} features",
350            self.config.task_type,
351            features.shape()[0],
352            features.shape()[1]
353        );
354
355        let start_time = std::time::Instant::now();
356        // Only search over algorithms we actually implement a learner for. This
357        // avoids fabricating scores for unimplemented algorithms: the search
358        // never claims to have trained something it cannot train.
359        let candidate_algorithms: Vec<Algorithm> = Algorithm::for_task(self.config.task_type)
360            .into_iter()
361            .filter(|algorithm| Self::is_supported(*algorithm))
362            .collect();
363        if candidate_algorithms.is_empty() {
364            return Err(anyhow!(
365                "AutoML has no implemented learner for task {:?}; supported algorithms are \
366                 LinearRegression, LogisticRegression and OnlineSGD",
367                self.config.task_type
368            ));
369        }
370
371        let mut best_overall_score = f64::NEG_INFINITY;
372        let mut trials_without_improvement = 0;
373
374        for trial in 0..self.config.n_trials {
375            // Check time budget
376            if start_time.elapsed().as_secs() >= self.config.max_training_time_secs {
377                info!("Time budget exhausted, stopping AutoML");
378                break;
379            }
380
381            // Select algorithm
382            let algorithm = {
383                let mut rng = self.rng.lock().await;
384                let idx = rng.random_range(0..candidate_algorithms.len());
385                candidate_algorithms[idx]
386            };
387
388            // Generate hyperparameters
389            let hyperparams = self.generate_hyperparameters(algorithm).await?;
390
391            // Train and evaluate model
392            let performance = self
393                .train_and_evaluate(algorithm, &hyperparams, features, labels)
394                .await?;
395
396            // Record trial
397            self.trial_history.write().await.push(performance.clone());
398
399            let overall_score = performance.overall_score();
400
401            info!(
402                "Trial {}: {:?} - CV score: {:.4}, Overall score: {:.4}",
403                trial, algorithm, performance.cv_score, overall_score
404            );
405
406            // Update best model
407            if overall_score > best_overall_score {
408                best_overall_score = overall_score;
409                trials_without_improvement = 0;
410
411                let model = TrainedModel {
412                    algorithm,
413                    hyperparameters: hyperparams.clone(),
414                    parameters: self
415                        .train_final_model(algorithm, &hyperparams, features, labels)
416                        .await?,
417                    performance: performance.clone(),
418                };
419
420                *self.best_model.write().await = Some(model.clone());
421
422                // Update ensemble if enabled
423                if self.config.enable_ensemble {
424                    self.update_ensemble(model).await?;
425                }
426
427                // Update stats
428                let mut stats = self.stats.write().await;
429                stats.best_score = best_overall_score;
430                stats.best_algorithm = Some(algorithm);
431            } else {
432                trials_without_improvement += 1;
433            }
434
435            // Early stopping
436            if trials_without_improvement >= self.config.early_stopping_patience {
437                info!(
438                    "Early stopping triggered after {} trials without improvement",
439                    trials_without_improvement
440                );
441                break;
442            }
443
444            // Update stats
445            let mut stats = self.stats.write().await;
446            stats.total_trials = trial as u64 + 1;
447        }
448
449        // Final stats update
450        let mut stats = self.stats.write().await;
451        stats.total_training_time_secs = start_time.elapsed().as_secs_f64();
452        stats.ensemble_size = self.ensemble.read().await.len();
453
454        info!(
455            "AutoML training complete: {} trials, best score: {:.4}, algorithm: {:?}",
456            stats.total_trials, stats.best_score, stats.best_algorithm
457        );
458
459        Ok(())
460    }
461
462    /// Generate hyperparameters for an algorithm
463    async fn generate_hyperparameters(&self, algorithm: Algorithm) -> Result<HyperParameters> {
464        let mut rng = self.rng.lock().await;
465
466        // Use meta-learning to initialize if enabled
467        let _base = if self.config.enable_meta_learning {
468            self.get_meta_learning_initialization(algorithm).await
469        } else {
470            HyperParameters::default()
471        };
472
473        // Apply random perturbations
474        Ok(HyperParameters {
475            learning_rate: rng.random_range(0.0001..0.1),
476            n_estimators: rng.random_range(10..500),
477            max_depth: Some(rng.random_range(3..20)),
478            regularization: rng.random_range(0.0..1.0),
479            n_neighbors: rng.random_range(3..20),
480            batch_size: rng.random_range(16..256),
481            random_seed: rng.random::<u64>(),
482        })
483    }
484
485    /// Get meta-learning initialization (placeholder)
486    async fn get_meta_learning_initialization(&self, _algorithm: Algorithm) -> HyperParameters {
487        // In production, this would use historical performance data
488        HyperParameters::default()
489    }
490
491    /// Train and evaluate a model with cross-validation
492    async fn train_and_evaluate(
493        &self,
494        algorithm: Algorithm,
495        hyperparams: &HyperParameters,
496        features: &Array2<f64>,
497        labels: &Array1<f64>,
498    ) -> Result<ModelPerformance> {
499        let start_time = std::time::Instant::now();
500
501        // Perform cross-validation
502        let cv_scores = self
503            .cross_validate(algorithm, hyperparams, features, labels)
504            .await?;
505        let cv_score = cv_scores.iter().sum::<f64>() / cv_scores.len() as f64;
506
507        // Compute additional metrics
508        let (accuracy, precision, recall, f1, mse, r_squared) = self
509            .compute_metrics(algorithm, hyperparams, features, labels)
510            .await?;
511
512        let training_time = start_time.elapsed().as_secs_f64();
513
514        // Estimate complexity (simplified)
515        let complexity_score = match algorithm {
516            Algorithm::LinearRegression | Algorithm::LogisticRegression => 10.0,
517            Algorithm::DecisionTree => 30.0,
518            Algorithm::RandomForest | Algorithm::GradientBoosting => 60.0,
519            Algorithm::NeuralNetwork => 80.0,
520            _ => 40.0,
521        };
522
523        Ok(ModelPerformance {
524            algorithm,
525            hyperparameters: hyperparams.clone(),
526            accuracy,
527            precision,
528            recall,
529            f1_score: f1,
530            mse,
531            r_squared,
532            training_time_secs: training_time,
533            inference_time_ms: 1.0, // Placeholder
534            complexity_score,
535            cv_score,
536        })
537    }
538
539    /// Perform k-fold cross-validation
540    async fn cross_validate(
541        &self,
542        algorithm: Algorithm,
543        hyperparams: &HyperParameters,
544        features: &Array2<f64>,
545        labels: &Array1<f64>,
546    ) -> Result<Vec<f64>> {
547        let n_samples = features.shape()[0];
548        let fold_size = n_samples / self.config.cv_folds;
549
550        let mut scores = Vec::new();
551
552        for fold in 0..self.config.cv_folds {
553            let val_start = fold * fold_size;
554            let val_end = ((fold + 1) * fold_size).min(n_samples);
555
556            // Simple train/val split (in production, use proper indexing)
557            let score = self
558                .evaluate_fold(algorithm, hyperparams, features, labels, val_start, val_end)
559                .await?;
560            scores.push(score);
561        }
562
563        Ok(scores)
564    }
565
566    /// Whether AutoML has a real learner implemented for this algorithm.
567    ///
568    /// The model representation used throughout this module is a linear model
569    /// (weights + bias, with an optional sigmoid activation). Only the
570    /// algorithms that map onto that representation are supported; everything
571    /// else is rejected rather than being faked.
572    fn is_supported(algorithm: Algorithm) -> bool {
573        matches!(
574            algorithm,
575            Algorithm::LinearRegression | Algorithm::LogisticRegression | Algorithm::OnlineSGD
576        )
577    }
578
579    /// Whether the task uses a sigmoid (classification-style) activation.
580    fn is_classification_task(task: TaskType) -> bool {
581        matches!(task, TaskType::Classification | TaskType::AnomalyDetection)
582    }
583
584    /// Fit a linear (or logistic) model by gradient descent.
585    ///
586    /// Features are standardized internally for numerical stability regardless
587    /// of scale, and the learned parameters are converted back to raw feature
588    /// space so callers can apply them directly to un-normalized inputs. When
589    /// `skip` is `Some((start, end))` the rows in that half-open range are held
590    /// out (used for cross-validation folds).
591    fn fit_linear(
592        features: &Array2<f64>,
593        labels: &Array1<f64>,
594        hyperparams: &HyperParameters,
595        is_classification: bool,
596        skip: Option<(usize, usize)>,
597    ) -> (Vec<f64>, f64) {
598        let n_samples = features.shape()[0];
599        let n_features = features.shape()[1];
600
601        // Compute mean/std per feature over the included rows for standardization.
602        let mut mean = vec![0.0f64; n_features];
603        let mut included: f64 = 0.0;
604        for i in 0..n_samples {
605            if let Some((start, end)) = skip {
606                if i >= start && i < end {
607                    continue;
608                }
609            }
610            for j in 0..n_features {
611                mean[j] += features[[i, j]];
612            }
613            included += 1.0;
614        }
615        if included == 0.0 {
616            return (vec![0.0; n_features], 0.0);
617        }
618        for m in mean.iter_mut() {
619            *m /= included;
620        }
621        let mut std = vec![0.0f64; n_features];
622        for i in 0..n_samples {
623            if let Some((start, end)) = skip {
624                if i >= start && i < end {
625                    continue;
626                }
627            }
628            for j in 0..n_features {
629                let diff = features[[i, j]] - mean[j];
630                std[j] += diff * diff;
631            }
632        }
633        for s in std.iter_mut() {
634            *s = (*s / included).sqrt();
635            if *s < 1e-8 {
636                *s = 1.0;
637            }
638        }
639
640        let lr = hyperparams.learning_rate.max(1e-6);
641        let l2 = hyperparams.regularization.max(0.0);
642        let epochs = hyperparams.n_estimators.clamp(10, 300);
643
644        // Gradient descent in standardized feature space.
645        let mut std_weights = vec![0.0f64; n_features];
646        let mut std_bias = 0.0f64;
647        for _ in 0..epochs {
648            let mut grad_w = vec![0.0f64; n_features];
649            let mut grad_b = 0.0f64;
650            for i in 0..n_samples {
651                if let Some((start, end)) = skip {
652                    if i >= start && i < end {
653                        continue;
654                    }
655                }
656                let mut z = std_bias;
657                for j in 0..n_features {
658                    let x = (features[[i, j]] - mean[j]) / std[j];
659                    z += std_weights[j] * x;
660                }
661                let pred = if is_classification { sigmoid(z) } else { z };
662                let error = pred - labels[i];
663                for j in 0..n_features {
664                    let x = (features[[i, j]] - mean[j]) / std[j];
665                    grad_w[j] += error * x;
666                }
667                grad_b += error;
668            }
669            let inv = 1.0 / included;
670            for j in 0..n_features {
671                std_weights[j] -= lr * (grad_w[j] * inv + l2 * std_weights[j]);
672            }
673            std_bias -= lr * grad_b * inv;
674        }
675
676        // Convert standardized-space parameters back to raw feature space:
677        //   z = b' + Σ w'_j (x_j - mean_j)/std_j
678        //     = (b' - Σ w'_j mean_j/std_j) + Σ (w'_j/std_j) x_j
679        let mut weights = vec![0.0f64; n_features];
680        let mut bias = std_bias;
681        for j in 0..n_features {
682            weights[j] = std_weights[j] / std[j];
683            bias -= std_weights[j] * mean[j] / std[j];
684        }
685
686        (weights, bias)
687    }
688
689    /// Score a single row using raw-space parameters.
690    fn linear_score(weights: &[f64], bias: f64, features: &Array2<f64>, row: usize) -> f64 {
691        let n_features = features.shape()[1].min(weights.len());
692        let mut z = bias;
693        for j in 0..n_features {
694            z += weights[j] * features[[row, j]];
695        }
696        z
697    }
698
699    /// Evaluate a single fold by training on the complement and scoring the
700    /// held-out validation rows.
701    async fn evaluate_fold(
702        &self,
703        algorithm: Algorithm,
704        hyperparams: &HyperParameters,
705        features: &Array2<f64>,
706        labels: &Array1<f64>,
707        val_start: usize,
708        val_end: usize,
709    ) -> Result<f64> {
710        if !Self::is_supported(algorithm) {
711            return Err(anyhow!(
712                "AutoML training for algorithm {:?} is not implemented",
713                algorithm
714            ));
715        }
716
717        let is_classification = Self::is_classification_task(self.config.task_type);
718        let (weights, bias) = Self::fit_linear(
719            features,
720            labels,
721            hyperparams,
722            is_classification,
723            Some((val_start, val_end)),
724        );
725
726        if val_end <= val_start {
727            return Ok(0.0);
728        }
729
730        if is_classification {
731            let mut correct = 0usize;
732            let mut total = 0usize;
733            for i in val_start..val_end {
734                let z = Self::linear_score(&weights, bias, features, i);
735                let predicted = sigmoid(z) >= 0.5;
736                let actual = labels[i] >= 0.5;
737                if predicted == actual {
738                    correct += 1;
739                }
740                total += 1;
741            }
742            Ok(if total > 0 {
743                correct as f64 / total as f64
744            } else {
745                0.0
746            })
747        } else {
748            // Coefficient of determination (R²) on the validation fold.
749            let mut sum = 0.0;
750            let mut count = 0.0;
751            for i in val_start..val_end {
752                sum += labels[i];
753                count += 1.0;
754            }
755            if count == 0.0 {
756                return Ok(0.0);
757            }
758            let mean = sum / count;
759            let mut ss_res = 0.0;
760            let mut ss_tot = 0.0;
761            for i in val_start..val_end {
762                let z = Self::linear_score(&weights, bias, features, i);
763                ss_res += (labels[i] - z).powi(2);
764                ss_tot += (labels[i] - mean).powi(2);
765            }
766            let r2 = if ss_tot > 0.0 {
767                1.0 - ss_res / ss_tot
768            } else {
769                0.0
770            };
771            // Clamp to [0, 1] for use as a selection score.
772            Ok(r2.clamp(0.0, 1.0))
773        }
774    }
775
776    /// Compute real performance metrics from actual predictions vs. labels.
777    async fn compute_metrics(
778        &self,
779        algorithm: Algorithm,
780        hyperparams: &HyperParameters,
781        features: &Array2<f64>,
782        labels: &Array1<f64>,
783    ) -> Result<(
784        Option<f64>,
785        Option<f64>,
786        Option<f64>,
787        Option<f64>,
788        Option<f64>,
789        Option<f64>,
790    )> {
791        if !Self::is_supported(algorithm) {
792            return Err(anyhow!(
793                "AutoML training for algorithm {:?} is not implemented",
794                algorithm
795            ));
796        }
797
798        let is_classification = Self::is_classification_task(self.config.task_type);
799        let (weights, bias) =
800            Self::fit_linear(features, labels, hyperparams, is_classification, None);
801        let n_samples = features.shape()[0];
802
803        match self.config.task_type {
804            TaskType::Classification | TaskType::AnomalyDetection => {
805                let (mut tp, mut fp, mut fn_, mut tn) = (0.0f64, 0.0f64, 0.0f64, 0.0f64);
806                for i in 0..n_samples {
807                    let z = Self::linear_score(&weights, bias, features, i);
808                    let predicted = sigmoid(z) >= 0.5;
809                    let actual = labels[i] >= 0.5;
810                    match (predicted, actual) {
811                        (true, true) => tp += 1.0,
812                        (true, false) => fp += 1.0,
813                        (false, true) => fn_ += 1.0,
814                        (false, false) => tn += 1.0,
815                    }
816                }
817                let total = tp + fp + fn_ + tn;
818                let accuracy = if total > 0.0 { (tp + tn) / total } else { 0.0 };
819                let precision = if tp + fp > 0.0 { tp / (tp + fp) } else { 0.0 };
820                let recall = if tp + fn_ > 0.0 { tp / (tp + fn_) } else { 0.0 };
821                let f1 = if precision + recall > 0.0 {
822                    2.0 * precision * recall / (precision + recall)
823                } else {
824                    0.0
825                };
826                Ok((
827                    Some(accuracy),
828                    Some(precision),
829                    Some(recall),
830                    Some(f1),
831                    None,
832                    None,
833                ))
834            }
835            TaskType::Regression | TaskType::TimeSeries => {
836                let mut ss_res = 0.0;
837                let mut sum = 0.0;
838                for i in 0..n_samples {
839                    sum += labels[i];
840                }
841                let mean = if n_samples > 0 {
842                    sum / n_samples as f64
843                } else {
844                    0.0
845                };
846                let mut ss_tot = 0.0;
847                for i in 0..n_samples {
848                    let z = Self::linear_score(&weights, bias, features, i);
849                    ss_res += (labels[i] - z).powi(2);
850                    ss_tot += (labels[i] - mean).powi(2);
851                }
852                let mse = if n_samples > 0 {
853                    ss_res / n_samples as f64
854                } else {
855                    0.0
856                };
857                let r_squared = if ss_tot > 0.0 {
858                    1.0 - ss_res / ss_tot
859                } else {
860                    0.0
861                };
862                Ok((None, None, None, None, Some(mse), Some(r_squared)))
863            }
864            _ => Ok((None, None, None, None, None, None)),
865        }
866    }
867
868    /// Train the final model with the given hyperparameters on all data.
869    async fn train_final_model(
870        &self,
871        algorithm: Algorithm,
872        hyperparams: &HyperParameters,
873        features: &Array2<f64>,
874        labels: &Array1<f64>,
875    ) -> Result<ModelParameters> {
876        if !Self::is_supported(algorithm) {
877            return Err(anyhow!(
878                "AutoML training for algorithm {:?} is not implemented",
879                algorithm
880            ));
881        }
882
883        let is_classification = Self::is_classification_task(self.config.task_type);
884        let (weights, bias) =
885            Self::fit_linear(features, labels, hyperparams, is_classification, None);
886
887        Ok(ModelParameters {
888            weights,
889            bias,
890            extra: HashMap::new(),
891        })
892    }
893
894    /// Update ensemble with new model
895    async fn update_ensemble(&self, model: TrainedModel) -> Result<()> {
896        let mut ensemble = self.ensemble.write().await;
897
898        // Add model to ensemble
899        ensemble.push(model);
900
901        // Keep only top models
902        if ensemble.len() > self.config.max_ensemble_size {
903            ensemble.sort_by(|a, b| {
904                b.performance
905                    .overall_score()
906                    .partial_cmp(&a.performance.overall_score())
907                    .unwrap_or(std::cmp::Ordering::Equal)
908            });
909            ensemble.truncate(self.config.max_ensemble_size);
910        }
911
912        Ok(())
913    }
914
915    /// Make prediction using the best model or ensemble
916    pub async fn predict(&self, features: &Array1<f64>) -> Result<f64> {
917        let start_time = std::time::Instant::now();
918
919        let prediction = if self.config.enable_ensemble {
920            self.ensemble_predict(features).await?
921        } else {
922            self.single_model_predict(features).await?
923        };
924
925        // Update stats
926        let mut stats = self.stats.write().await;
927        stats.predictions_count += 1;
928        let elapsed_ms = start_time.elapsed().as_secs_f64() * 1000.0;
929        stats.avg_prediction_time_ms =
930            (stats.avg_prediction_time_ms * (stats.predictions_count - 1) as f64 + elapsed_ms)
931                / stats.predictions_count as f64;
932
933        Ok(prediction)
934    }
935
936    /// Predict using single best model
937    async fn single_model_predict(&self, features: &Array1<f64>) -> Result<f64> {
938        let model = self.best_model.read().await;
939
940        match &*model {
941            Some(m) => {
942                // Simple linear prediction
943                let mut pred = m.parameters.bias;
944                for (i, &weight) in m.parameters.weights.iter().enumerate() {
945                    if i < features.len() {
946                        pred += weight * features[i];
947                    }
948                }
949
950                // Apply activation for classification
951                if matches!(self.config.task_type, TaskType::Classification) {
952                    pred = 1.0 / (1.0 + (-pred).exp()); // Sigmoid
953                }
954
955                Ok(pred)
956            }
957            None => Err(anyhow!("No trained model available")),
958        }
959    }
960
961    /// Predict using ensemble (averaging)
962    async fn ensemble_predict(&self, features: &Array1<f64>) -> Result<f64> {
963        let ensemble = self.ensemble.read().await;
964
965        if ensemble.is_empty() {
966            return self.single_model_predict(features).await;
967        }
968
969        let mut predictions = Vec::new();
970        let mut weights = Vec::new();
971
972        for model in ensemble.iter() {
973            let mut pred = model.parameters.bias;
974            for (i, &weight) in model.parameters.weights.iter().enumerate() {
975                if i < features.len() {
976                    pred += weight * features[i];
977                }
978            }
979
980            if matches!(self.config.task_type, TaskType::Classification) {
981                pred = 1.0 / (1.0 + (-pred).exp());
982            }
983
984            predictions.push(pred);
985            weights.push(model.performance.overall_score());
986        }
987
988        // Weighted average
989        let total_weight: f64 = weights.iter().sum();
990        let weighted_pred = predictions
991            .iter()
992            .zip(&weights)
993            .map(|(p, w)| p * w)
994            .sum::<f64>()
995            / total_weight;
996
997        Ok(weighted_pred)
998    }
999
1000    /// Get AutoML statistics
1001    pub async fn get_stats(&self) -> AutoMLStats {
1002        self.stats.read().await.clone()
1003    }
1004
1005    /// Get trial history
1006    pub async fn get_trial_history(&self) -> Vec<ModelPerformance> {
1007        self.trial_history.read().await.clone()
1008    }
1009
1010    /// Get best model information
1011    pub async fn get_best_model_info(
1012        &self,
1013    ) -> Option<(Algorithm, HyperParameters, ModelPerformance)> {
1014        let model = self.best_model.read().await;
1015        model.as_ref().map(|m| {
1016            (
1017                m.algorithm,
1018                m.hyperparameters.clone(),
1019                m.performance.clone(),
1020            )
1021        })
1022    }
1023
1024    /// Export best model for deployment
1025    pub async fn export_model(&self) -> Result<String> {
1026        let model = self.best_model.read().await;
1027
1028        match &*model {
1029            Some(m) => {
1030                let export = serde_json::json!({
1031                    "algorithm": format!("{:?}", m.algorithm),
1032                    "hyperparameters": m.hyperparameters,
1033                    "parameters": {
1034                        "weights": m.parameters.weights,
1035                        "bias": m.parameters.bias,
1036                    },
1037                    "performance": m.performance,
1038                });
1039                Ok(serde_json::to_string_pretty(&export)?)
1040            }
1041            None => Err(anyhow!("No model to export")),
1042        }
1043    }
1044}
1045
1046#[cfg(test)]
1047mod tests {
1048    use super::*;
1049
1050    #[test]
1051    fn test_algorithm_for_task() {
1052        let classifiers = Algorithm::for_task(TaskType::Classification);
1053        assert!(!classifiers.is_empty());
1054        assert!(classifiers.contains(&Algorithm::LogisticRegression));
1055
1056        let regressors = Algorithm::for_task(TaskType::Regression);
1057        assert!(regressors.contains(&Algorithm::LinearRegression));
1058
1059        let ts_algorithms = Algorithm::for_task(TaskType::TimeSeries);
1060        assert!(ts_algorithms.contains(&Algorithm::ARIMA));
1061    }
1062
1063    #[test]
1064    fn test_hyperparameters_default() {
1065        let params = HyperParameters::default();
1066        assert_eq!(params.learning_rate, 0.01);
1067        assert_eq!(params.n_estimators, 100);
1068        assert_eq!(params.max_depth, Some(5));
1069    }
1070
1071    #[test]
1072    fn test_model_performance_overall_score() {
1073        let perf = ModelPerformance {
1074            algorithm: Algorithm::LinearRegression,
1075            hyperparameters: HyperParameters::default(),
1076            accuracy: None,
1077            precision: None,
1078            recall: None,
1079            f1_score: None,
1080            mse: Some(0.5),
1081            r_squared: Some(0.9),
1082            training_time_secs: 10.0,
1083            inference_time_ms: 1.0,
1084            complexity_score: 20.0,
1085            cv_score: 0.85,
1086        };
1087
1088        let score = perf.overall_score();
1089        assert!(score > 0.0);
1090        assert!(score <= 1.0);
1091    }
1092
1093    #[tokio::test]
1094    async fn test_automl_creation() {
1095        let config = AutoMLConfig::default();
1096        let automl = AutoML::new(config);
1097        assert!(automl.is_ok());
1098    }
1099
1100    #[tokio::test]
1101    async fn test_automl_generate_hyperparameters() {
1102        let config = AutoMLConfig::default();
1103        let automl = AutoML::new(config).unwrap();
1104
1105        let params = automl
1106            .generate_hyperparameters(Algorithm::LinearRegression)
1107            .await;
1108        assert!(params.is_ok());
1109
1110        let p = params.unwrap();
1111        assert!(p.learning_rate > 0.0);
1112        assert!(p.n_estimators > 0);
1113    }
1114
1115    #[tokio::test]
1116    async fn test_automl_fit_small_dataset() {
1117        let config = AutoMLConfig {
1118            task_type: TaskType::Regression,
1119            max_training_time_secs: 5,
1120            n_trials: 3,
1121            cv_folds: 2,
1122            enable_ensemble: false,
1123            ..Default::default()
1124        };
1125
1126        let mut automl = AutoML::new(config).unwrap();
1127
1128        // Small synthetic dataset
1129        let features = Array2::from_shape_vec(
1130            (10, 2),
1131            vec![
1132                1.0, 2.0, 2.0, 3.0, 3.0, 4.0, 4.0, 5.0, 5.0, 6.0, 6.0, 7.0, 7.0, 8.0, 8.0, 9.0,
1133                9.0, 10.0, 10.0, 11.0,
1134            ],
1135        )
1136        .unwrap();
1137
1138        let labels = Array1::from_vec(vec![3.0, 5.0, 7.0, 9.0, 11.0, 13.0, 15.0, 17.0, 19.0, 21.0]);
1139
1140        let result = automl.fit(&features, &labels).await;
1141        assert!(result.is_ok());
1142
1143        let stats = automl.get_stats().await;
1144        assert!(stats.total_trials > 0);
1145        assert!(stats.total_trials <= 3);
1146    }
1147
1148    #[tokio::test]
1149    async fn test_automl_prediction() {
1150        let config = AutoMLConfig {
1151            task_type: TaskType::Regression,
1152            max_training_time_secs: 5,
1153            n_trials: 2,
1154            ..Default::default()
1155        };
1156
1157        let mut automl = AutoML::new(config).unwrap();
1158
1159        let features = Array2::from_shape_vec(
1160            (10, 2),
1161            vec![
1162                1.0, 2.0, 2.0, 3.0, 3.0, 4.0, 4.0, 5.0, 5.0, 6.0, 6.0, 7.0, 7.0, 8.0, 8.0, 9.0,
1163                9.0, 10.0, 10.0, 11.0,
1164            ],
1165        )
1166        .unwrap();
1167
1168        let labels = Array1::from_vec(vec![3.0, 5.0, 7.0, 9.0, 11.0, 13.0, 15.0, 17.0, 19.0, 21.0]);
1169
1170        automl.fit(&features, &labels).await.unwrap();
1171
1172        let test_features = Array1::from_vec(vec![5.5, 6.5]);
1173        let prediction = automl.predict(&test_features).await;
1174        assert!(prediction.is_ok());
1175    }
1176
1177    #[tokio::test]
1178    async fn test_ensemble_prediction() {
1179        let config = AutoMLConfig {
1180            task_type: TaskType::Classification,
1181            enable_ensemble: true,
1182            max_ensemble_size: 3,
1183            n_trials: 5,
1184            max_training_time_secs: 10,
1185            ..Default::default()
1186        };
1187
1188        let mut automl = AutoML::new(config).unwrap();
1189
1190        let features =
1191            Array2::from_shape_vec((20, 2), (0..40).map(|x| x as f64).collect()).unwrap();
1192        let labels = Array1::from_vec((0..20).map(|x| (x % 2) as f64).collect());
1193
1194        automl.fit(&features, &labels).await.unwrap();
1195
1196        let test_features = Array1::from_vec(vec![5.0, 10.0]);
1197        let prediction = automl.predict(&test_features).await;
1198        assert!(prediction.is_ok());
1199
1200        let pred = prediction.unwrap();
1201        assert!((0.0..=1.0).contains(&pred)); // Should be probability for classification
1202    }
1203
1204    #[tokio::test]
1205    async fn regression_linear_fit_is_data_dependent() {
1206        // y = 2x + 1 over a clean single-feature dataset.
1207        let config = AutoMLConfig {
1208            task_type: TaskType::Regression,
1209            max_training_time_secs: 10,
1210            n_trials: 25,
1211            cv_folds: 3,
1212            enable_ensemble: false,
1213            ..Default::default()
1214        };
1215        let mut automl = AutoML::new(config).unwrap();
1216
1217        let xs: Vec<f64> = (0..30).map(|x| x as f64).collect();
1218        let features = Array2::from_shape_vec((30, 1), xs.clone()).unwrap();
1219        let labels = Array1::from_vec(xs.iter().map(|x| 2.0 * x + 1.0).collect());
1220
1221        automl.fit(&features, &labels).await.unwrap();
1222
1223        // Prediction must track the real linear relationship, not random noise.
1224        let prediction = automl.predict(&Array1::from_vec(vec![10.0])).await.unwrap();
1225        assert!(
1226            (prediction - 21.0).abs() < 3.0,
1227            "expected ~21 for y=2x+1 at x=10, got {prediction}"
1228        );
1229
1230        // The best model should have a strong fit (real R²), not a fabricated score.
1231        let (_, _, perf) = automl.get_best_model_info().await.unwrap();
1232        assert!(perf.r_squared.unwrap_or(0.0) > 0.8);
1233    }
1234
1235    #[tokio::test]
1236    async fn regression_unsupported_task_fails_loud() {
1237        let config = AutoMLConfig {
1238            task_type: TaskType::Clustering,
1239            n_trials: 2,
1240            max_training_time_secs: 2,
1241            ..Default::default()
1242        };
1243        let mut automl = AutoML::new(config).unwrap();
1244        let features = Array2::from_shape_vec((4, 2), (0..8).map(|x| x as f64).collect()).unwrap();
1245        let labels = Array1::from_vec(vec![0.0, 1.0, 0.0, 1.0]);
1246
1247        // Clustering has no implemented learner => fit must error, not fake it.
1248        assert!(automl.fit(&features, &labels).await.is_err());
1249    }
1250
1251    #[tokio::test]
1252    async fn test_get_best_model_info() {
1253        let config = AutoMLConfig {
1254            n_trials: 2,
1255            max_training_time_secs: 5,
1256            ..Default::default()
1257        };
1258
1259        let mut automl = AutoML::new(config).unwrap();
1260
1261        let features =
1262            Array2::from_shape_vec((10, 2), (0..20).map(|x| x as f64).collect()).unwrap();
1263        let labels = Array1::from_vec((0..10).map(|x| x as f64).collect());
1264
1265        automl.fit(&features, &labels).await.unwrap();
1266
1267        let best_info = automl.get_best_model_info().await;
1268        assert!(best_info.is_some());
1269
1270        let (_algorithm, _hyperparams, performance) = best_info.unwrap();
1271        assert!(performance.cv_score >= 0.0);
1272    }
1273
1274    #[tokio::test]
1275    async fn test_export_model() {
1276        let config = AutoMLConfig {
1277            n_trials: 1,
1278            max_training_time_secs: 5,
1279            ..Default::default()
1280        };
1281
1282        let mut automl = AutoML::new(config).unwrap();
1283
1284        let features =
1285            Array2::from_shape_vec((10, 2), (0..20).map(|x| x as f64).collect()).unwrap();
1286        let labels = Array1::from_vec((0..10).map(|x| x as f64).collect());
1287
1288        automl.fit(&features, &labels).await.unwrap();
1289
1290        let export = automl.export_model().await;
1291        assert!(export.is_ok());
1292
1293        let json_str = export.unwrap();
1294        assert!(json_str.contains("algorithm"));
1295        assert!(json_str.contains("hyperparameters"));
1296    }
1297
1298    #[tokio::test]
1299    async fn test_trial_history() {
1300        let config = AutoMLConfig {
1301            n_trials: 3,
1302            max_training_time_secs: 5,
1303            ..Default::default()
1304        };
1305
1306        let mut automl = AutoML::new(config).unwrap();
1307
1308        let features =
1309            Array2::from_shape_vec((10, 2), (0..20).map(|x| x as f64).collect()).unwrap();
1310        let labels = Array1::from_vec((0..10).map(|x| x as f64).collect());
1311
1312        automl.fit(&features, &labels).await.unwrap();
1313
1314        let history = automl.get_trial_history().await;
1315        assert!(!history.is_empty());
1316        assert!(history.len() <= 3);
1317    }
1318
1319    #[tokio::test]
1320    async fn test_early_stopping() {
1321        let config = AutoMLConfig {
1322            n_trials: 100, // Large number
1323            max_training_time_secs: 60,
1324            early_stopping_patience: 3,
1325            ..Default::default()
1326        };
1327
1328        let mut automl = AutoML::new(config).unwrap();
1329
1330        let features =
1331            Array2::from_shape_vec((10, 2), (0..20).map(|x| x as f64).collect()).unwrap();
1332        let labels = Array1::from_vec((0..10).map(|x| x as f64).collect());
1333
1334        automl.fit(&features, &labels).await.unwrap();
1335
1336        let stats = automl.get_stats().await;
1337        // Should stop early, not run all 100 trials
1338        assert!(stats.total_trials < 100);
1339    }
1340}