1use 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
39fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51pub enum TaskType {
52 Classification,
54 Regression,
56 TimeSeries,
58 AnomalyDetection,
60 Clustering,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
66pub enum Algorithm {
67 LinearRegression,
69 LogisticRegression,
71 DecisionTree,
73 RandomForest,
75 GradientBoosting,
77 NeuralNetwork,
79 KNN,
81 SVM,
83 NaiveBayes,
85 OnlineSGD,
87 ARIMA,
89 IsolationForest,
91 KMeans,
93}
94
95impl Algorithm {
96 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#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct HyperParameters {
136 pub learning_rate: f64,
138 pub n_estimators: usize,
140 pub max_depth: Option<usize>,
142 pub regularization: f64,
144 pub n_neighbors: usize,
146 pub batch_size: usize,
148 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#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct ModelPerformance {
169 pub algorithm: Algorithm,
171 pub hyperparameters: HyperParameters,
173 pub accuracy: Option<f64>,
175 pub precision: Option<f64>,
177 pub recall: Option<f64>,
179 pub f1_score: Option<f64>,
181 pub mse: Option<f64>,
183 pub r_squared: Option<f64>,
185 pub training_time_secs: f64,
187 pub inference_time_ms: f64,
189 pub complexity_score: f64,
191 pub cv_score: f64,
193}
194
195impl ModelPerformance {
196 pub fn overall_score(&self) -> f64 {
198 let perf_score = self.cv_score;
200 let time_penalty = (-self.training_time_secs / 60.0).exp(); let complexity_penalty = (-self.complexity_score / 100.0).exp(); perf_score * time_penalty * complexity_penalty
204 }
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct AutoMLConfig {
210 pub task_type: TaskType,
212 pub max_training_time_secs: u64,
214 pub n_trials: usize,
216 pub cv_folds: usize,
218 pub enable_ensemble: bool,
220 pub enable_meta_learning: bool,
222 pub early_stopping_patience: usize,
224 pub optimization_metric: String,
226 pub auto_feature_engineering: bool,
228 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#[derive(Debug, Clone)]
251pub struct TrainedModel {
252 pub algorithm: Algorithm,
254 pub hyperparameters: HyperParameters,
256 pub parameters: ModelParameters,
258 pub performance: ModelPerformance,
260}
261
262#[derive(Debug, Clone)]
264pub struct ModelParameters {
265 pub weights: Vec<f64>,
267 pub bias: f64,
269 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#[derive(Debug, Clone, Serialize, Deserialize)]
285pub struct AutoMLStats {
286 pub total_trials: u64,
288 pub best_score: f64,
290 pub total_training_time_secs: f64,
292 pub ensemble_size: usize,
294 pub best_algorithm: Option<Algorithm>,
296 pub predictions_count: u64,
298 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
316pub struct AutoML {
318 config: AutoMLConfig,
319 best_model: Arc<RwLock<Option<TrainedModel>>>,
321 ensemble: Arc<RwLock<Vec<TrainedModel>>>,
323 trial_history: Arc<RwLock<Vec<ModelPerformance>>>,
325 stats: Arc<RwLock<AutoMLStats>>,
327 #[allow(clippy::arc_with_non_send_sync)]
329 rng: Arc<Mutex<Random>>,
330}
331
332impl AutoML {
333 #[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 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 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 if start_time.elapsed().as_secs() >= self.config.max_training_time_secs {
377 info!("Time budget exhausted, stopping AutoML");
378 break;
379 }
380
381 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 let hyperparams = self.generate_hyperparameters(algorithm).await?;
390
391 let performance = self
393 .train_and_evaluate(algorithm, &hyperparams, features, labels)
394 .await?;
395
396 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 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 if self.config.enable_ensemble {
424 self.update_ensemble(model).await?;
425 }
426
427 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 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 let mut stats = self.stats.write().await;
446 stats.total_trials = trial as u64 + 1;
447 }
448
449 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 async fn generate_hyperparameters(&self, algorithm: Algorithm) -> Result<HyperParameters> {
464 let mut rng = self.rng.lock().await;
465
466 let _base = if self.config.enable_meta_learning {
468 self.get_meta_learning_initialization(algorithm).await
469 } else {
470 HyperParameters::default()
471 };
472
473 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 async fn get_meta_learning_initialization(&self, _algorithm: Algorithm) -> HyperParameters {
487 HyperParameters::default()
489 }
490
491 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 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 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 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, complexity_score,
535 cv_score,
536 })
537 }
538
539 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 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 fn is_supported(algorithm: Algorithm) -> bool {
573 matches!(
574 algorithm,
575 Algorithm::LinearRegression | Algorithm::LogisticRegression | Algorithm::OnlineSGD
576 )
577 }
578
579 fn is_classification_task(task: TaskType) -> bool {
581 matches!(task, TaskType::Classification | TaskType::AnomalyDetection)
582 }
583
584 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 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 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 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 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 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 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 Ok(r2.clamp(0.0, 1.0))
773 }
774 }
775
776 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 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 async fn update_ensemble(&self, model: TrainedModel) -> Result<()> {
896 let mut ensemble = self.ensemble.write().await;
897
898 ensemble.push(model);
900
901 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 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 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 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 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 if matches!(self.config.task_type, TaskType::Classification) {
952 pred = 1.0 / (1.0 + (-pred).exp()); }
954
955 Ok(pred)
956 }
957 None => Err(anyhow!("No trained model available")),
958 }
959 }
960
961 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 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 pub async fn get_stats(&self) -> AutoMLStats {
1002 self.stats.read().await.clone()
1003 }
1004
1005 pub async fn get_trial_history(&self) -> Vec<ModelPerformance> {
1007 self.trial_history.read().await.clone()
1008 }
1009
1010 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 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 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)); }
1203
1204 #[tokio::test]
1205 async fn regression_linear_fit_is_data_dependent() {
1206 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 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 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 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, 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 assert!(stats.total_trials < 100);
1339 }
1340}