1use scirs2_core::ndarray::{Array1, Array2};
8use scirs2_core::numeric::{Float, FromPrimitive};
9use std::collections::{HashMap, VecDeque};
10use std::fmt::Debug;
11
12use crate::error::{Result, TimeSeriesError};
13
14#[derive(Debug, Clone)]
16pub enum EnsembleStrategy {
17 SimpleAverage,
19 WeightedAverage,
21 DynamicWeighting {
23 window_size: usize,
25 },
26 Stacking {
28 meta_learner: MetaLearner,
30 },
31 BayesianModelAveraging,
33 Median,
35 BestModel,
37}
38
39#[derive(Debug, Clone)]
41pub enum MetaLearner {
42 LinearRegression,
44 RidgeRegression {
46 alpha: f64,
48 },
49 RandomForest {
51 n_trees: usize,
53 },
54 NeuralNetwork {
56 hidden_units: Vec<usize>,
58 },
59}
60
61#[derive(Debug, Clone)]
63pub enum BaseModel {
64 ARIMA {
66 p: usize,
68 d: usize,
70 q: usize,
72 },
73 ExponentialSmoothing {
75 alpha: f64,
77 beta: Option<f64>,
79 gamma: Option<f64>,
81 },
82 LinearTrend,
84 SeasonalNaive {
86 period: usize,
88 },
89 MovingAverage {
91 window: usize,
93 },
94 LSTM {
96 hidden_units: usize,
98 num_layers: usize,
100 },
101 Prophet {
103 seasonality_prior_scale: f64,
105 changepoint_prior_scale: f64,
107 },
108}
109
110#[derive(Debug, Clone)]
112pub struct ModelPerformance<F: Float> {
113 pub mae: F,
115 pub mse: F,
117 pub rmse: F,
119 pub mape: F,
121 pub smape: F,
123 pub mase: F,
125 pub weight: F,
127 pub confidence: F,
129}
130
131impl<F: Float + FromPrimitive> Default for ModelPerformance<F> {
132 fn default() -> Self {
133 Self {
134 mae: F::infinity(),
135 mse: F::infinity(),
136 rmse: F::infinity(),
137 mape: F::infinity(),
138 smape: F::infinity(),
139 mase: F::infinity(),
140 weight: F::zero(),
141 confidence: F::zero(),
142 }
143 }
144}
145
146#[derive(Debug)]
148pub struct EnsembleForecaster<F: Float + Debug + Clone + FromPrimitive + std::iter::Sum + 'static> {
149 base_models: Vec<BaseModelWrapper<F>>,
151 strategy: EnsembleStrategy,
153 #[allow(dead_code)]
155 performance_history: VecDeque<Vec<ModelPerformance<F>>>,
156 training_buffer: VecDeque<F>,
158 max_buffer_size: usize,
160 #[allow(dead_code)]
162 validation_window: usize,
163 ensemble_weights: Array1<F>,
165 meta_model: Option<Box<dyn MetaModel<F>>>,
167}
168
169#[derive(Debug, Clone)]
171pub struct BaseModelWrapper<F: Float + Debug> {
172 model_type: BaseModel,
174 model_state: ModelState<F>,
176 #[allow(dead_code)]
178 recent_predictions: VecDeque<F>,
179 performance: ModelPerformance<F>,
181 is_trained: bool,
183}
184
185#[derive(Debug, Clone)]
187pub struct ModelState<F: Float> {
188 parameters: HashMap<String, F>,
190 state_variables: HashMap<String, Array1<F>>,
192 #[allow(dead_code)]
194 model_data: Option<Array2<F>>,
195}
196
197impl<F: Float + Clone> Default for ModelState<F> {
198 fn default() -> Self {
199 Self {
200 parameters: HashMap::new(),
201 state_variables: HashMap::new(),
202 model_data: None,
203 }
204 }
205}
206
207pub trait MetaModel<F: Float + Debug>: Debug {
209 fn train(&mut self, predictions: &Array2<F>, targets: &Array1<F>) -> Result<()>;
211
212 fn predict(&self, predictions: &Array2<F>) -> Result<Array1<F>>;
214
215 fn confidence(&self) -> F;
217}
218
219#[derive(Debug)]
221pub struct LinearRegressionMeta<F: Float + Debug> {
222 coefficients: Array1<F>,
224 intercept: F,
226 confidence: F,
228 is_trained: bool,
230}
231
232impl<F: Float + Debug + Clone + FromPrimitive> LinearRegressionMeta<F> {
233 pub fn new() -> Self {
235 Self {
236 coefficients: Array1::zeros(0),
237 intercept: F::zero(),
238 confidence: F::zero(),
239 is_trained: false,
240 }
241 }
242}
243
244impl<F: Float + Debug + Clone + FromPrimitive> Default for LinearRegressionMeta<F> {
245 fn default() -> Self {
246 Self::new()
247 }
248}
249
250impl<F: Float + Debug + Clone + FromPrimitive> MetaModel<F> for LinearRegressionMeta<F> {
251 fn train(&mut self, predictions: &Array2<F>, targets: &Array1<F>) -> Result<()> {
252 let (n_samples, n_models) = predictions.dim();
253
254 if targets.len() != n_samples {
255 return Err(TimeSeriesError::DimensionMismatch {
256 expected: n_samples,
257 actual: targets.len(),
258 });
259 }
260
261 if n_samples < n_models {
262 return Err(TimeSeriesError::InsufficientData {
263 message: "Need more samples than models for training".to_string(),
264 required: n_models,
265 actual: n_samples,
266 });
267 }
268
269 let mut xtx = Array2::zeros((n_models, n_models));
272 let mut xty = Array1::zeros(n_models);
273
274 for i in 0..n_models {
276 for j in 0..n_models {
277 let mut sum = F::zero();
278 for k in 0..n_samples {
279 sum = sum + predictions[[k, i]] * predictions[[k, j]];
280 }
281 xtx[[i, j]] = sum;
282 }
283
284 let mut sum = F::zero();
285 for k in 0..n_samples {
286 sum = sum + predictions[[k, i]] * targets[k];
287 }
288 xty[i] = sum;
289 }
290
291 self.coefficients = Array1::zeros(n_models);
293 for i in 0..n_models {
294 if xtx[[i, i]] > F::zero() {
295 self.coefficients[i] = xty[i] / xtx[[i, i]];
296 }
297 }
298
299 let mut sum_coeff = F::zero();
301 for &coeff in self.coefficients.iter() {
302 sum_coeff = sum_coeff + coeff;
303 }
304 self.intercept = if sum_coeff < F::one() {
305 (F::one() - sum_coeff) / F::from(n_models).unwrap()
306 } else {
307 F::zero()
308 };
309
310 self.is_trained = true;
313 let predictions_result = self.predict(predictions)?;
314 let mut mse = F::zero();
315 for i in 0..n_samples {
316 let error = targets[i] - predictions_result[i];
317 mse = mse + error * error;
318 }
319 mse = mse / F::from(n_samples).unwrap();
320
321 self.confidence = F::one() / (F::one() + mse);
322
323 Ok(())
324 }
325
326 fn predict(&self, predictions: &Array2<F>) -> Result<Array1<F>> {
327 if !self.is_trained {
328 return Err(TimeSeriesError::InvalidModel(
329 "Meta-model not trained".to_string(),
330 ));
331 }
332
333 let (n_samples, n_models) = predictions.dim();
334
335 if n_models != self.coefficients.len() {
336 return Err(TimeSeriesError::DimensionMismatch {
337 expected: self.coefficients.len(),
338 actual: n_models,
339 });
340 }
341
342 let mut result = Array1::zeros(n_samples);
343 for i in 0..n_samples {
344 let mut prediction = self.intercept;
345 for j in 0..n_models {
346 prediction = prediction + self.coefficients[j] * predictions[[i, j]];
347 }
348 result[i] = prediction;
349 }
350
351 Ok(result)
352 }
353
354 fn confidence(&self) -> F {
355 self.confidence
356 }
357}
358
359impl<F: Float + Debug + Clone + FromPrimitive + std::iter::Sum + 'static> EnsembleForecaster<F> {
360 pub fn new(
362 base_models: Vec<BaseModel>,
363 strategy: EnsembleStrategy,
364 max_buffer_size: usize,
365 validation_window: usize,
366 ) -> Result<Self> {
367 let num_models = base_models.len();
368 if num_models == 0 {
369 return Err(TimeSeriesError::InvalidInput(
370 "At least one base model required".to_string(),
371 ));
372 }
373
374 let base_modelwrappers: Vec<BaseModelWrapper<F>> = base_models
375 .into_iter()
376 .map(|model_type| BaseModelWrapper {
377 model_type,
378 model_state: ModelState::default(),
379 recent_predictions: VecDeque::with_capacity(validation_window),
380 performance: ModelPerformance::default(),
381 is_trained: false,
382 })
383 .collect();
384
385 let ensemble_weights =
386 Array1::from_elem(num_models, F::one() / F::from(num_models).unwrap());
387
388 let meta_model = match &strategy {
390 EnsembleStrategy::Stacking { meta_learner } => {
391 Some(Self::create_meta_model(meta_learner.clone())?)
392 }
393 _ => None,
394 };
395
396 Ok(Self {
397 base_models: base_modelwrappers,
398 strategy,
399 performance_history: VecDeque::with_capacity(validation_window),
400 training_buffer: VecDeque::with_capacity(max_buffer_size),
401 max_buffer_size,
402 validation_window,
403 ensemble_weights,
404 meta_model,
405 })
406 }
407
408 fn create_meta_model(_metalearner: MetaLearner) -> Result<Box<dyn MetaModel<F>>> {
410 match _metalearner {
411 MetaLearner::LinearRegression => Ok(Box::new(LinearRegressionMeta::new())),
412 MetaLearner::RidgeRegression { alpha: _alpha } => {
413 Ok(Box::new(LinearRegressionMeta::new()))
415 }
416 _ => Err(TimeSeriesError::NotImplemented(
417 "Meta-_learner not yet implemented".to_string(),
418 )),
419 }
420 }
421
422 pub fn add_observation(&mut self, value: F) -> Result<()> {
424 if self.training_buffer.len() >= self.max_buffer_size {
425 self.training_buffer.pop_front();
426 }
427 self.training_buffer.push_back(value);
428
429 if self.training_buffer.len() >= 20 {
431 self.retrain_models()?;
432 }
433
434 Ok(())
435 }
436
437 fn retrain_models(&mut self) -> Result<()> {
439 let data: Vec<F> = self.training_buffer.iter().cloned().collect();
440
441 for i in 0..self.base_models.len() {
443 let mut model = self.base_models[i].clone();
445
446 match &model.model_type {
447 BaseModel::ARIMA { p, d, q } => {
448 model
450 .model_state
451 .parameters
452 .insert("p".to_string(), F::from(*p).unwrap());
453 model
454 .model_state
455 .parameters
456 .insert("d".to_string(), F::from(*d).unwrap());
457 model
458 .model_state
459 .parameters
460 .insert("q".to_string(), F::from(*q).unwrap());
461 }
462 BaseModel::ExponentialSmoothing { alpha, beta, gamma } => {
463 model
465 .model_state
466 .parameters
467 .insert("alpha".to_string(), F::from(*alpha).unwrap());
468 if let Some(beta_val) = beta {
469 model
470 .model_state
471 .parameters
472 .insert("beta".to_string(), F::from(*beta_val).unwrap());
473 }
474 if let Some(gamma_val) = gamma {
475 model
476 .model_state
477 .parameters
478 .insert("gamma".to_string(), F::from(*gamma_val).unwrap());
479 }
480 }
481 BaseModel::LinearTrend => {
482 if data.len() >= 2 {
484 let n = F::from(data.len()).unwrap();
485 let sum_x = F::from(data.len() * (data.len() - 1) / 2).unwrap();
486 let sum_y = data.iter().cloned().sum::<F>();
487 let sum_xy = data
488 .iter()
489 .enumerate()
490 .map(|(i, &y)| F::from(i).unwrap() * y)
491 .sum::<F>();
492 let sum_x2 = (0..data.len()).map(|i| F::from(i * i).unwrap()).sum::<F>();
493
494 let denominator = n * sum_x2 - sum_x * sum_x;
495 if denominator != F::zero() {
496 let slope = (n * sum_xy - sum_x * sum_y) / denominator;
497 let intercept = (sum_y - slope * sum_x) / n;
498 model
499 .model_state
500 .parameters
501 .insert("slope".to_string(), slope);
502 model
503 .model_state
504 .parameters
505 .insert("intercept".to_string(), intercept);
506 } else {
507 model
508 .model_state
509 .parameters
510 .insert("slope".to_string(), F::zero());
511 model
512 .model_state
513 .parameters
514 .insert("intercept".to_string(), sum_y / n);
515 }
516 }
517 }
518 BaseModel::SeasonalNaive { period } => {
519 model
521 .model_state
522 .parameters
523 .insert("period".to_string(), F::from(*period).unwrap());
524 }
525 BaseModel::MovingAverage { window } => {
526 model
528 .model_state
529 .parameters
530 .insert("window".to_string(), F::from(*window).unwrap());
531 }
532 BaseModel::LSTM {
533 hidden_units,
534 num_layers,
535 } => {
536 model
538 .model_state
539 .parameters
540 .insert("hidden_units".to_string(), F::from(*hidden_units).unwrap());
541 model
542 .model_state
543 .parameters
544 .insert("num_layers".to_string(), F::from(*num_layers).unwrap());
545 }
546 BaseModel::Prophet {
547 seasonality_prior_scale,
548 changepoint_prior_scale,
549 } => {
550 model.model_state.parameters.insert(
552 "seasonality_prior_scale".to_string(),
553 F::from(*seasonality_prior_scale).unwrap(),
554 );
555 model.model_state.parameters.insert(
556 "changepoint_prior_scale".to_string(),
557 F::from(*changepoint_prior_scale).unwrap(),
558 );
559 }
560 }
561
562 model.is_trained = true;
563
564 self.base_models[i] = model;
566 }
567
568 self.update_ensemble_weights()?;
570
571 Ok(())
572 }
573
574 #[allow(dead_code)]
576 fn train_single_model(&self, modelwrapper: &mut BaseModelWrapper<F>, data: &[F]) -> Result<()> {
577 match &modelwrapper.model_type {
578 BaseModel::ARIMA { p, d, q } => {
579 self.train_arima_model(modelwrapper, data, *p, *d, *q)?;
580 }
581 BaseModel::ExponentialSmoothing { alpha, beta, gamma } => {
582 self.train_exponential_smoothing(modelwrapper, data, *alpha, *beta, *gamma)?;
583 }
584 BaseModel::LinearTrend => {
585 self.train_linear_trend(modelwrapper, data)?;
586 }
587 BaseModel::SeasonalNaive { period } => {
588 self.train_seasonal_naive(modelwrapper, data, *period)?;
589 }
590 BaseModel::MovingAverage { window } => {
591 self.train_moving_average(modelwrapper, data, *window)?;
592 }
593 BaseModel::LSTM {
594 hidden_units: hidden,
595 num_layers: layers,
596 } => {
597 self.train_lstm_model(modelwrapper, data)?;
598 }
599 BaseModel::Prophet {
600 seasonality_prior_scale: s,
601 changepoint_prior_scale: c,
602 } => {
603 self.train_prophet_model(modelwrapper, data)?;
604 }
605 }
606
607 modelwrapper.is_trained = true;
608 Ok(())
609 }
610
611 #[allow(dead_code)]
613 fn train_arima_model(
614 &self,
615 modelwrapper: &mut BaseModelWrapper<F>,
616 data: &[F],
617 p: usize,
618 d: usize,
619 q: usize,
620 ) -> Result<()> {
621 let mut processed_data = data.to_vec();
623
624 for _ in 0..d {
626 let mut diff_data = Vec::new();
627 for i in 1..processed_data.len() {
628 diff_data.push(processed_data[i] - processed_data[i - 1]);
629 }
630 processed_data = diff_data;
631 }
632
633 if processed_data.len() < p.max(q) + 5 {
634 return Ok(());
635 }
636
637 let mut ar_coeffs = vec![F::zero(); p];
639 if p > 0 {
640 let autocorrs = self.compute_autocorrelations(&processed_data, p)?;
641 for i in 0..p {
642 if i + 1 < autocorrs.len() && autocorrs[0] > F::zero() {
643 ar_coeffs[i] = autocorrs[i + 1] / autocorrs[0];
644 ar_coeffs[i] = ar_coeffs[i]
646 .max(F::from(-0.99).unwrap())
647 .min(F::from(0.99).unwrap());
648 }
649 }
650 }
651
652 let ma_coeffs = vec![F::from(0.1).unwrap(); q];
654
655 for (i, &coeff) in ar_coeffs.iter().enumerate() {
657 modelwrapper
658 .model_state
659 .parameters
660 .insert(format!("ar_{i}"), coeff);
661 }
662 for (i, &coeff) in ma_coeffs.iter().enumerate() {
663 modelwrapper
664 .model_state
665 .parameters
666 .insert(format!("ma_{i}"), coeff);
667 }
668
669 modelwrapper.model_state.state_variables.insert(
670 "recent_data".to_string(),
671 Array1::from_vec(
672 processed_data
673 .iter()
674 .rev()
675 .take(p.max(q))
676 .rev()
677 .cloned()
678 .collect(),
679 ),
680 );
681
682 Ok(())
683 }
684
685 #[allow(dead_code)]
687 fn compute_autocorrelations(&self, data: &[F], maxlag: usize) -> Result<Vec<F>> {
688 let n = data.len();
689 let mut autocorrs = vec![F::zero(); maxlag + 1];
690
691 for _lag in 0..=maxlag {
692 if _lag >= n {
693 break;
694 }
695
696 let mut sum = F::zero();
697 let count = n - _lag;
698
699 for i in _lag..n {
700 sum = sum + data[i] * data[i - _lag];
701 }
702
703 autocorrs[_lag] = sum / F::from(count).unwrap();
704 }
705
706 Ok(autocorrs)
707 }
708
709 #[allow(dead_code)]
711 fn train_exponential_smoothing(
712 &self,
713 modelwrapper: &mut BaseModelWrapper<F>,
714 data: &[F],
715 alpha: f64,
716 beta: Option<f64>,
717 gamma: Option<f64>,
718 ) -> Result<()> {
719 if data.is_empty() {
720 return Ok(());
721 }
722
723 let alpha_f = F::from(alpha).unwrap();
724 modelwrapper
725 .model_state
726 .parameters
727 .insert("alpha".to_string(), alpha_f);
728
729 if let Some(beta_val) = beta {
730 modelwrapper
731 .model_state
732 .parameters
733 .insert("beta".to_string(), F::from(beta_val).unwrap());
734 }
735
736 if let Some(gamma_val) = gamma {
737 modelwrapper
738 .model_state
739 .parameters
740 .insert("gamma".to_string(), F::from(gamma_val).unwrap());
741 }
742
743 let level = data[0];
745 let trend = if data.len() > 1 {
746 data[1] - data[0]
747 } else {
748 F::zero()
749 };
750
751 modelwrapper
752 .model_state
753 .parameters
754 .insert("level".to_string(), level);
755 modelwrapper
756 .model_state
757 .parameters
758 .insert("trend".to_string(), trend);
759
760 Ok(())
761 }
762
763 #[allow(dead_code)]
765 fn train_linear_trend(&self, modelwrapper: &mut BaseModelWrapper<F>, data: &[F]) -> Result<()> {
766 if data.len() < 2 {
767 return Ok(());
768 }
769
770 let n = F::from(data.len()).unwrap();
771 let x_mean = (n - F::one()) / F::from(2).unwrap();
772 let y_mean = data.iter().fold(F::zero(), |acc, &x| acc + x) / n;
773
774 let mut numerator = F::zero();
775 let mut denominator = F::zero();
776
777 for (i, &y) in data.iter().enumerate() {
778 let x = F::from(i).unwrap();
779 let x_diff = x - x_mean;
780 numerator = numerator + x_diff * (y - y_mean);
781 denominator = denominator + x_diff * x_diff;
782 }
783
784 let slope = if denominator > F::zero() {
785 numerator / denominator
786 } else {
787 F::zero()
788 };
789
790 let intercept = y_mean - slope * x_mean;
791
792 modelwrapper
793 .model_state
794 .parameters
795 .insert("slope".to_string(), slope);
796 modelwrapper
797 .model_state
798 .parameters
799 .insert("intercept".to_string(), intercept);
800
801 Ok(())
802 }
803
804 #[allow(dead_code)]
806 fn train_seasonal_naive(
807 &self,
808 modelwrapper: &mut BaseModelWrapper<F>,
809 data: &[F],
810 period: usize,
811 ) -> Result<()> {
812 if data.len() < period {
813 return Ok(());
814 }
815
816 modelwrapper
817 .model_state
818 .parameters
819 .insert("period".to_string(), F::from(period).unwrap());
820
821 let last_cycle: Array1<F> =
823 Array1::from_vec(data.iter().rev().take(period).rev().cloned().collect());
824 modelwrapper
825 .model_state
826 .state_variables
827 .insert("last_cycle".to_string(), last_cycle);
828
829 Ok(())
830 }
831
832 #[allow(dead_code)]
834 fn train_moving_average(
835 &self,
836 modelwrapper: &mut BaseModelWrapper<F>,
837 data: &[F],
838 window: usize,
839 ) -> Result<()> {
840 if data.len() < window {
841 return Ok(());
842 }
843
844 modelwrapper
845 .model_state
846 .parameters
847 .insert("window".to_string(), F::from(window).unwrap());
848
849 let recent_data: Array1<F> =
851 Array1::from_vec(data.iter().rev().take(window).rev().cloned().collect());
852 modelwrapper
853 .model_state
854 .state_variables
855 .insert("recent_data".to_string(), recent_data);
856
857 Ok(())
858 }
859
860 #[allow(dead_code)]
862 fn train_lstm_model(&self, modelwrapper: &mut BaseModelWrapper<F>, data: &[F]) -> Result<()> {
863 modelwrapper
866 .model_state
867 .parameters
868 .insert("hidden_state".to_string(), F::zero());
869 modelwrapper
870 .model_state
871 .parameters
872 .insert("cell_state".to_string(), F::zero());
873
874 let recent_data: Array1<F> =
876 Array1::from_vec(data.iter().rev().take(10).rev().cloned().collect());
877 modelwrapper
878 .model_state
879 .state_variables
880 .insert("recent_data".to_string(), recent_data);
881
882 Ok(())
883 }
884
885 #[allow(dead_code)]
887 fn train_prophet_model(
888 &self,
889 modelwrapper: &mut BaseModelWrapper<F>,
890 data: &[F],
891 ) -> Result<()> {
892 if data.len() < 2 {
895 return Ok(());
896 }
897
898 let trend = (data[data.len() - 1] - data[0]) / F::from(data.len() - 1).unwrap();
900 modelwrapper
901 .model_state
902 .parameters
903 .insert("trend".to_string(), trend);
904
905 let mean = data.iter().fold(F::zero(), |acc, &x| acc + x) / F::from(data.len()).unwrap();
906 modelwrapper
907 .model_state
908 .parameters
909 .insert("base_level".to_string(), mean);
910
911 Ok(())
912 }
913
914 pub fn forecast(&self, steps: usize) -> Result<Array1<F>> {
916 if self.base_models.iter().all(|m| !m.is_trained) {
917 return Err(TimeSeriesError::InvalidModel(
918 "No models are trained".to_string(),
919 ));
920 }
921
922 let mut modelpredictions = Array2::zeros((steps, self.base_models.len()));
924
925 for (model_idx, modelwrapper) in self.base_models.iter().enumerate() {
926 if modelwrapper.is_trained {
927 let predictions = self.predict_with_model(modelwrapper, steps)?;
928 for step in 0..steps {
929 modelpredictions[[step, model_idx]] = predictions[step];
930 }
931 }
932 }
933
934 self.combine_predictions(&modelpredictions)
936 }
937
938 fn predict_with_model(
940 &self,
941 modelwrapper: &BaseModelWrapper<F>,
942 steps: usize,
943 ) -> Result<Array1<F>> {
944 match &modelwrapper.model_type {
945 BaseModel::ARIMA { p, d, q } => self.predict_arima(modelwrapper, steps, *p, *q),
946 BaseModel::ExponentialSmoothing { alpha, beta, gamma } => {
947 self.predict_exponential_smoothing(modelwrapper, steps, beta.is_some())
948 }
949 BaseModel::LinearTrend => self.predict_linear_trend(modelwrapper, steps),
950 BaseModel::SeasonalNaive { period: _period } => {
951 self.predict_seasonal_naive(modelwrapper, steps)
952 }
953 BaseModel::MovingAverage { window: _window } => {
954 self.predict_moving_average(modelwrapper, steps)
955 }
956 BaseModel::LSTM {
957 hidden_units: hidden,
958 num_layers: layers,
959 } => self.predict_lstm(modelwrapper, steps),
960 BaseModel::Prophet {
961 seasonality_prior_scale: s,
962 changepoint_prior_scale: c,
963 } => self.predict_prophet(modelwrapper, steps),
964 }
965 }
966
967 fn predict_arima(
969 &self,
970 modelwrapper: &BaseModelWrapper<F>,
971 steps: usize,
972 p: usize,
973 q: usize,
974 ) -> Result<Array1<F>> {
975 let mut forecasts = Array1::zeros(steps);
976
977 if let Some(recent_data) = modelwrapper.model_state.state_variables.get("recent_data") {
978 let mut extended_data = recent_data.to_vec();
979
980 for step in 0..steps {
981 let mut prediction = F::zero();
982
983 for i in 0..p {
985 if let Some(&ar_coeff) =
986 modelwrapper.model_state.parameters.get(&format!("ar_{i}"))
987 {
988 if i < extended_data.len() {
989 let lag_index = extended_data.len() - 1 - i;
990 prediction = prediction + ar_coeff * extended_data[lag_index];
991 }
992 }
993 }
994
995 for i in 0..q {
997 if let Some(&ma_coeff) =
998 modelwrapper.model_state.parameters.get(&format!("ma_{i}"))
999 {
1000 let residual_weight = F::from(0.95_f64.powi(i as i32 + 1)).unwrap();
1002 prediction = prediction + ma_coeff * residual_weight;
1003 }
1004 }
1005
1006 forecasts[step] = prediction;
1007 extended_data.push(prediction);
1008
1009 if extended_data.len() > 50 {
1011 extended_data.remove(0);
1012 }
1013 }
1014 }
1015
1016 Ok(forecasts)
1017 }
1018
1019 fn predict_exponential_smoothing(
1021 &self,
1022 modelwrapper: &BaseModelWrapper<F>,
1023 steps: usize,
1024 has_trend: bool,
1025 ) -> Result<Array1<F>> {
1026 let mut forecasts = Array1::zeros(steps);
1027
1028 if let (Some(&level), trend_opt) = (
1029 modelwrapper.model_state.parameters.get("level"),
1030 modelwrapper.model_state.parameters.get("_trend"),
1031 ) {
1032 let _trend = trend_opt.cloned().unwrap_or(F::zero());
1033
1034 for step in 0..steps {
1035 let h = F::from(step + 1).unwrap();
1036 forecasts[step] = if has_trend { level + _trend * h } else { level };
1037 }
1038 }
1039
1040 Ok(forecasts)
1041 }
1042
1043 fn predict_linear_trend(
1045 &self,
1046 modelwrapper: &BaseModelWrapper<F>,
1047 steps: usize,
1048 ) -> Result<Array1<F>> {
1049 let mut forecasts = Array1::zeros(steps);
1050
1051 if let (Some(&slope), Some(&intercept)) = (
1052 modelwrapper.model_state.parameters.get("slope"),
1053 modelwrapper.model_state.parameters.get("intercept"),
1054 ) {
1055 let data_length = F::from(self.training_buffer.len()).unwrap();
1056
1057 for step in 0..steps {
1058 let x = data_length + F::from(step).unwrap();
1059 forecasts[step] = slope * x + intercept;
1060 }
1061 }
1062
1063 Ok(forecasts)
1064 }
1065
1066 fn predict_seasonal_naive(
1068 &self,
1069 modelwrapper: &BaseModelWrapper<F>,
1070 steps: usize,
1071 ) -> Result<Array1<F>> {
1072 let mut forecasts = Array1::zeros(steps);
1073
1074 if let (Some(&period_f), Some(last_cycle)) = (
1075 modelwrapper.model_state.parameters.get("period"),
1076 modelwrapper.model_state.state_variables.get("last_cycle"),
1077 ) {
1078 let period = period_f.to_usize().unwrap_or(1);
1079
1080 for step in 0..steps {
1081 let cycle_index = step % period;
1082 if cycle_index < last_cycle.len() {
1083 forecasts[step] = last_cycle[cycle_index];
1084 }
1085 }
1086 }
1087
1088 Ok(forecasts)
1089 }
1090
1091 fn predict_moving_average(
1093 &self,
1094 modelwrapper: &BaseModelWrapper<F>,
1095 steps: usize,
1096 ) -> Result<Array1<F>> {
1097 let mut forecasts = Array1::zeros(steps);
1098
1099 if let Some(recent_data) = modelwrapper.model_state.state_variables.get("recent_data") {
1100 let avg = recent_data.sum() / F::from(recent_data.len()).unwrap();
1101
1102 for step in 0..steps {
1103 forecasts[step] = avg;
1104 }
1105 }
1106
1107 Ok(forecasts)
1108 }
1109
1110 fn predict_lstm(&self, modelwrapper: &BaseModelWrapper<F>, steps: usize) -> Result<Array1<F>> {
1112 let mut forecasts = Array1::zeros(steps);
1113
1114 if let Some(recent_data) = modelwrapper.model_state.state_variables.get("recent_data") {
1116 let last_value = recent_data[recent_data.len() - 1];
1117 let trend = if recent_data.len() > 1 {
1118 (recent_data[recent_data.len() - 1] - recent_data[recent_data.len() - 2])
1119 * F::from(0.5).unwrap()
1120 } else {
1121 F::zero()
1122 };
1123
1124 for step in 0..steps {
1125 let h = F::from(step + 1).unwrap();
1126 forecasts[step] = last_value + trend * h;
1127 }
1128 }
1129
1130 Ok(forecasts)
1131 }
1132
1133 fn predict_prophet(
1135 &self,
1136 modelwrapper: &BaseModelWrapper<F>,
1137 steps: usize,
1138 ) -> Result<Array1<F>> {
1139 let mut forecasts = Array1::zeros(steps);
1140
1141 if let (Some(&trend), Some(&base_level)) = (
1142 modelwrapper.model_state.parameters.get("trend"),
1143 modelwrapper.model_state.parameters.get("base_level"),
1144 ) {
1145 let data_length = F::from(self.training_buffer.len()).unwrap();
1146
1147 for step in 0..steps {
1148 let t = data_length + F::from(step + 1).unwrap();
1149
1150 let trend_component = base_level + trend * t;
1152 let seasonal_component = F::from(0.1).unwrap()
1153 * (F::from(2.0 * std::f64::consts::PI).unwrap() * t / F::from(12).unwrap())
1154 .sin();
1155
1156 forecasts[step] = trend_component + seasonal_component;
1157 }
1158 }
1159
1160 Ok(forecasts)
1161 }
1162
1163 fn combine_predictions(&self, modelpredictions: &Array2<F>) -> Result<Array1<F>> {
1165 let (steps, n_models) = modelpredictions.dim();
1166 let mut ensemble_forecast = Array1::zeros(steps);
1167
1168 match &self.strategy {
1169 EnsembleStrategy::SimpleAverage => {
1170 for step in 0..steps {
1171 let mut sum = F::zero();
1172 for model_idx in 0..n_models {
1173 sum = sum + modelpredictions[[step, model_idx]];
1174 }
1175 ensemble_forecast[step] = sum / F::from(n_models).unwrap();
1176 }
1177 }
1178 EnsembleStrategy::WeightedAverage
1179 | EnsembleStrategy::DynamicWeighting { window_size: _ } => {
1180 for step in 0..steps {
1181 let mut weighted_sum = F::zero();
1182 let mut total_weight = F::zero();
1183
1184 for model_idx in 0..n_models {
1185 let weight = if model_idx < self.ensemble_weights.len() {
1186 self.ensemble_weights[model_idx]
1187 } else {
1188 F::one() / F::from(n_models).unwrap()
1189 };
1190
1191 weighted_sum = weighted_sum + weight * modelpredictions[[step, model_idx]];
1192 total_weight = total_weight + weight;
1193 }
1194
1195 ensemble_forecast[step] = if total_weight > F::zero() {
1196 weighted_sum / total_weight
1197 } else {
1198 weighted_sum / F::from(n_models).unwrap()
1199 };
1200 }
1201 }
1202 EnsembleStrategy::Median => {
1203 for step in 0..steps {
1204 let mut step_predictions: Vec<F> = (0..n_models)
1205 .map(|model_idx| modelpredictions[[step, model_idx]])
1206 .collect();
1207
1208 step_predictions
1209 .sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1210
1211 let median_idx = step_predictions.len() / 2;
1212 ensemble_forecast[step] = if step_predictions.len().is_multiple_of(2) {
1213 (step_predictions[median_idx - 1] + step_predictions[median_idx])
1214 / F::from(2).unwrap()
1215 } else {
1216 step_predictions[median_idx]
1217 };
1218 }
1219 }
1220 EnsembleStrategy::Stacking { meta_learner: meta } => {
1221 if let Some(ref meta_model) = self.meta_model {
1222 ensemble_forecast = meta_model.predict(modelpredictions)?;
1223 } else {
1224 return Err(TimeSeriesError::InvalidModel(
1225 "Meta-model not initialized for stacking".to_string(),
1226 ));
1227 }
1228 }
1229 EnsembleStrategy::BayesianModelAveraging => {
1230 for step in 0..steps {
1232 let mut weighted_sum = F::zero();
1233 let mut total_weight = F::zero();
1234
1235 for model_idx in 0..n_models {
1236 let weight = if model_idx < self.base_models.len() {
1237 F::one() / (F::one() + self.base_models[model_idx].performance.mse)
1238 } else {
1239 F::one()
1240 };
1241
1242 weighted_sum = weighted_sum + weight * modelpredictions[[step, model_idx]];
1243 total_weight = total_weight + weight;
1244 }
1245
1246 ensemble_forecast[step] = weighted_sum / total_weight;
1247 }
1248 }
1249 EnsembleStrategy::BestModel => {
1250 let best_model_idx = self.find_best_model_index();
1252 for step in 0..steps {
1253 ensemble_forecast[step] = modelpredictions[[step, best_model_idx]];
1254 }
1255 }
1256 }
1257
1258 Ok(ensemble_forecast)
1259 }
1260
1261 fn find_best_model_index(&self) -> usize {
1263 let mut best_idx = 0;
1264 let mut best_performance = F::infinity();
1265
1266 for (idx, model) in self.base_models.iter().enumerate() {
1267 if model.performance.mse < best_performance {
1268 best_performance = model.performance.mse;
1269 best_idx = idx;
1270 }
1271 }
1272
1273 best_idx
1274 }
1275
1276 fn update_ensemble_weights(&mut self) -> Result<()> {
1278 let n_models = self.base_models.len();
1279
1280 match &self.strategy {
1281 EnsembleStrategy::WeightedAverage
1282 | EnsembleStrategy::DynamicWeighting { window_size: _ } => {
1283 let mut new_weights = Array1::zeros(n_models);
1285 let mut total_inverse_mse = F::zero();
1286
1287 for (idx, model) in self.base_models.iter().enumerate() {
1288 let inverse_mse = F::one() / (F::one() + model.performance.mse);
1289 new_weights[idx] = inverse_mse;
1290 total_inverse_mse = total_inverse_mse + inverse_mse;
1291 }
1292
1293 if total_inverse_mse > F::zero() {
1295 for weight in new_weights.iter_mut() {
1296 *weight = *weight / total_inverse_mse;
1297 }
1298 } else {
1299 let equal_weight = F::one() / F::from(n_models).unwrap();
1301 new_weights.fill(equal_weight);
1302 }
1303
1304 self.ensemble_weights = new_weights;
1305 }
1306 _ => {
1307 let equal_weight = F::one() / F::from(n_models).unwrap();
1309 self.ensemble_weights = Array1::from_elem(n_models, equal_weight);
1310 }
1311 }
1312
1313 Ok(())
1314 }
1315
1316 pub fn get_model_info(&self) -> Vec<(BaseModel, ModelPerformance<F>, bool)> {
1318 self.base_models
1319 .iter()
1320 .map(|model| {
1321 (
1322 model.model_type.clone(),
1323 model.performance.clone(),
1324 model.is_trained,
1325 )
1326 })
1327 .collect()
1328 }
1329
1330 pub fn get_ensemble_weights(&self) -> &Array1<F> {
1332 &self.ensemble_weights
1333 }
1334}
1335
1336#[derive(Debug)]
1338pub struct AutoMLForecaster<F: Float + Debug + Clone + FromPrimitive + std::iter::Sum + 'static> {
1339 candidate_models: Vec<BaseModel>,
1341 best_ensemble: Option<EnsembleForecaster<F>>,
1343 search_space: HyperparameterSpace,
1345 cv_config: CrossValidationConfig,
1347 evaluation_metrics: Vec<EvaluationMetric>,
1349 search_algorithm: SearchAlgorithm,
1351}
1352
1353#[derive(Debug, Clone)]
1355pub struct HyperparameterSpace {
1356 pub arima_p_range: (usize, usize),
1358 pub arima_d_range: (usize, usize),
1360 pub arima_q_range: (usize, usize),
1362 pub alpha_range: (f64, f64),
1364 pub beta_range: Option<(f64, f64)>,
1366 pub gamma_range: Option<(f64, f64)>,
1368 pub ma_windows: Vec<usize>,
1370 pub seasonal_periods: Vec<usize>,
1372 pub lstm_hidden_units: Vec<usize>,
1374 pub lstm_num_layers: Vec<usize>,
1376}
1377
1378impl Default for HyperparameterSpace {
1379 fn default() -> Self {
1380 Self {
1381 arima_p_range: (0, 5),
1382 arima_d_range: (0, 2),
1383 arima_q_range: (0, 5),
1384 alpha_range: (0.1, 0.9),
1385 beta_range: Some((0.1, 0.9)),
1386 gamma_range: Some((0.1, 0.9)),
1387 ma_windows: vec![3, 5, 10, 20],
1388 seasonal_periods: vec![7, 12, 24, 52],
1389 lstm_hidden_units: vec![32, 64, 128],
1390 lstm_num_layers: vec![1, 2, 3],
1391 }
1392 }
1393}
1394
1395#[derive(Debug, Clone)]
1397pub struct CrossValidationConfig {
1398 pub n_folds: usize,
1400 pub train_ratio: f64,
1402 pub forecast_horizon: usize,
1404 pub use_time_series_cv: bool,
1406}
1407
1408impl Default for CrossValidationConfig {
1409 fn default() -> Self {
1410 Self {
1411 n_folds: 5,
1412 train_ratio: 0.8,
1413 forecast_horizon: 10,
1414 use_time_series_cv: true,
1415 }
1416 }
1417}
1418
1419#[derive(Debug, Clone)]
1421pub enum EvaluationMetric {
1422 MAE,
1424 MSE,
1426 RMSE,
1428 MAPE,
1430 SMAPE,
1432 MASE,
1434 AIC,
1436 BIC,
1438}
1439
1440#[derive(Debug, Clone)]
1442pub enum SearchAlgorithm {
1443 GridSearch,
1445 RandomSearch {
1447 n_iterations: usize,
1449 },
1450 BayesianOptimization {
1452 n_iterations: usize,
1454 },
1455 GeneticAlgorithm {
1457 population_size: usize,
1459 generations: usize,
1461 },
1462}
1463
1464impl<F: Float + Debug + Clone + FromPrimitive + std::iter::Sum + 'static> AutoMLForecaster<F> {
1465 pub fn new(
1467 search_space: HyperparameterSpace,
1468 cv_config: CrossValidationConfig,
1469 evaluation_metrics: Vec<EvaluationMetric>,
1470 search_algorithm: SearchAlgorithm,
1471 ) -> Self {
1472 Self {
1473 candidate_models: Vec::new(),
1474 best_ensemble: None,
1475 search_space,
1476 cv_config,
1477 evaluation_metrics,
1478 search_algorithm,
1479 }
1480 }
1481
1482 pub fn fit(&mut self, data: &[F]) -> Result<()> {
1484 self.generate_candidate_models()?;
1486
1487 let model_scores = self.evaluate_models(data)?;
1489
1490 let best_models = self.select_best_models(&model_scores)?;
1492
1493 let ensemble =
1495 EnsembleForecaster::new(best_models, EnsembleStrategy::WeightedAverage, 1000, 50)?;
1496
1497 self.best_ensemble = Some(ensemble);
1498
1499 if let Some(ref mut ensemble) = self.best_ensemble {
1501 for &value in data {
1502 ensemble.add_observation(value)?;
1503 }
1504 }
1505
1506 Ok(())
1507 }
1508
1509 fn generate_candidate_models(&mut self) -> Result<()> {
1511 self.candidate_models.clear();
1512
1513 match &self.search_algorithm {
1514 SearchAlgorithm::GridSearch => {
1515 self.generate_grid_search_models()?;
1516 }
1517 SearchAlgorithm::RandomSearch { n_iterations } => {
1518 self.generate_random_search_models(*n_iterations)?;
1519 }
1520 SearchAlgorithm::BayesianOptimization {
1521 n_iterations: n_iter,
1522 } => {
1523 self.generate_random_search_models(50)?;
1525 }
1526 SearchAlgorithm::GeneticAlgorithm {
1527 population_size,
1528 generations: gens,
1529 } => {
1530 self.generate_random_search_models(*population_size)?;
1532 }
1533 }
1534
1535 Ok(())
1536 }
1537
1538 fn generate_grid_search_models(&mut self) -> Result<()> {
1540 for p in self.search_space.arima_p_range.0..=self.search_space.arima_p_range.1 {
1542 for d in self.search_space.arima_d_range.0..=self.search_space.arima_d_range.1 {
1543 for q in self.search_space.arima_q_range.0..=self.search_space.arima_q_range.1 {
1544 if p + d + q > 0 && p + d + q <= 6 {
1545 self.candidate_models.push(BaseModel::ARIMA { p, d, q });
1547 }
1548 }
1549 }
1550 }
1551
1552 let alpha_values = [0.1, 0.3, 0.5, 0.7, 0.9];
1554 for &alpha in &alpha_values {
1555 self.candidate_models.push(BaseModel::ExponentialSmoothing {
1556 alpha,
1557 beta: None,
1558 gamma: None,
1559 });
1560
1561 if let Some((beta_min, beta_max)) = self.search_space.beta_range {
1562 let beta_values = [beta_min, (beta_min + beta_max) / 2.0, beta_max];
1563 for &beta in &beta_values {
1564 self.candidate_models.push(BaseModel::ExponentialSmoothing {
1565 alpha,
1566 beta: Some(beta),
1567 gamma: None,
1568 });
1569 }
1570 }
1571 }
1572
1573 self.candidate_models.push(BaseModel::LinearTrend);
1575
1576 for &window in &self.search_space.ma_windows {
1577 self.candidate_models
1578 .push(BaseModel::MovingAverage { window });
1579 }
1580
1581 for &period in &self.search_space.seasonal_periods {
1582 self.candidate_models
1583 .push(BaseModel::SeasonalNaive { period });
1584 }
1585
1586 Ok(())
1587 }
1588
1589 fn generate_random_search_models(&mut self, niterations: usize) -> Result<()> {
1591 let mut seed: u32 = 42;
1593
1594 for _i in 0..niterations {
1595 seed = (seed.wrapping_mul(1103515245).wrapping_add(12345)) & 0x7fffffff;
1597 let rand_val = (seed as f64) / (i32::MAX as f64);
1598
1599 if rand_val < 0.4 {
1600 let p = (rand_val
1602 * (self.search_space.arima_p_range.1 - self.search_space.arima_p_range.0)
1603 as f64) as usize
1604 + self.search_space.arima_p_range.0;
1605 let d = ((rand_val * 2.0) as usize).min(self.search_space.arima_d_range.1);
1606 let q = ((rand_val * 3.0) as usize).min(self.search_space.arima_q_range.1);
1607
1608 if p + d + q > 0 && p + d + q <= 6 {
1609 self.candidate_models.push(BaseModel::ARIMA { p, d, q });
1610 }
1611 } else if rand_val < 0.7 {
1612 let alpha = self.search_space.alpha_range.0
1614 + rand_val
1615 * (self.search_space.alpha_range.1 - self.search_space.alpha_range.0);
1616 self.candidate_models.push(BaseModel::ExponentialSmoothing {
1617 alpha,
1618 beta: None,
1619 gamma: None,
1620 });
1621 } else if rand_val < 0.85 {
1622 let window_idx = (rand_val * self.search_space.ma_windows.len() as f64) as usize;
1624 if window_idx < self.search_space.ma_windows.len() {
1625 let window = self.search_space.ma_windows[window_idx];
1626 self.candidate_models
1627 .push(BaseModel::MovingAverage { window });
1628 }
1629 } else {
1630 let period_idx =
1632 (rand_val * self.search_space.seasonal_periods.len() as f64) as usize;
1633 if period_idx < self.search_space.seasonal_periods.len() {
1634 let period = self.search_space.seasonal_periods[period_idx];
1635 self.candidate_models
1636 .push(BaseModel::SeasonalNaive { period });
1637 }
1638 }
1639 }
1640
1641 self.candidate_models.push(BaseModel::LinearTrend);
1643 self.candidate_models
1644 .push(BaseModel::MovingAverage { window: 5 });
1645
1646 Ok(())
1647 }
1648
1649 fn evaluate_models(&self, data: &[F]) -> Result<Vec<(BaseModel, F)>> {
1651 let mut model_scores = Vec::new();
1652
1653 for model in &self.candidate_models {
1654 let score = self.cross_validate_model(model, data)?;
1655 model_scores.push((model.clone(), score));
1656 }
1657
1658 model_scores.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
1660
1661 Ok(model_scores)
1662 }
1663
1664 fn cross_validate_model(&self, model: &BaseModel, data: &[F]) -> Result<F> {
1666 if data.len() < self.cv_config.forecast_horizon + 20 {
1667 return Ok(F::infinity()); }
1669
1670 let mut scores = Vec::new();
1671 let fold_size = data.len() / self.cv_config.n_folds;
1672
1673 for fold in 0..self.cv_config.n_folds {
1674 let test_start = data.len() - (fold + 1) * fold_size;
1675 let test_end = data.len() - fold * fold_size;
1676
1677 if test_start < self.cv_config.forecast_horizon {
1678 continue;
1679 }
1680
1681 let train_end = test_start;
1682 let train_data = &data[0..train_end];
1683 let test_data = &data[test_start..test_end];
1684
1685 let mut ensemble = EnsembleForecaster::new(
1687 vec![model.clone()],
1688 EnsembleStrategy::SimpleAverage,
1689 1000,
1690 20,
1691 )?;
1692
1693 for &value in train_data {
1694 ensemble.add_observation(value)?;
1695 }
1696
1697 let forecast_horizon = self.cv_config.forecast_horizon.min(test_data.len());
1699 if forecast_horizon == 0 {
1700 continue;
1701 }
1702
1703 let forecasts = ensemble.forecast(forecast_horizon)?;
1704 let test_subset = &test_data[0..forecast_horizon];
1705
1706 let score = self.compute_evaluation_metric(&forecasts, test_subset)?;
1707 scores.push(score);
1708 }
1709
1710 if scores.is_empty() {
1711 Ok(F::infinity())
1712 } else {
1713 let avg_score =
1714 scores.iter().fold(F::zero(), |acc, &x| acc + x) / F::from(scores.len()).unwrap();
1715 Ok(avg_score)
1716 }
1717 }
1718
1719 fn compute_evaluation_metric(&self, forecasts: &Array1<F>, actuals: &[F]) -> Result<F> {
1721 if forecasts.len() != actuals.len() {
1722 return Err(TimeSeriesError::DimensionMismatch {
1723 expected: forecasts.len(),
1724 actual: actuals.len(),
1725 });
1726 }
1727
1728 let metric = self
1730 .evaluation_metrics
1731 .first()
1732 .unwrap_or(&EvaluationMetric::MSE);
1733
1734 let n = F::from(actuals.len()).unwrap();
1735 let mut score = F::zero();
1736
1737 match metric {
1738 EvaluationMetric::MAE => {
1739 for (i, &actual) in actuals.iter().enumerate() {
1740 score = score + (forecasts[i] - actual).abs();
1741 }
1742 score = score / n;
1743 }
1744 EvaluationMetric::MSE => {
1745 for (i, &actual) in actuals.iter().enumerate() {
1746 let error = forecasts[i] - actual;
1747 score = score + error * error;
1748 }
1749 score = score / n;
1750 }
1751 EvaluationMetric::RMSE => {
1752 for (i, &actual) in actuals.iter().enumerate() {
1753 let error = forecasts[i] - actual;
1754 score = score + error * error;
1755 }
1756 score = (score / n).sqrt();
1757 }
1758 EvaluationMetric::MAPE => {
1759 for (i, &actual) in actuals.iter().enumerate() {
1760 if actual != F::zero() {
1761 score = score + ((forecasts[i] - actual) / actual).abs();
1762 }
1763 }
1764 score = score * F::from(100).unwrap() / n;
1765 }
1766 EvaluationMetric::SMAPE => {
1767 for (i, &actual) in actuals.iter().enumerate() {
1768 let denominator = (forecasts[i].abs() + actual.abs()) / F::from(2).unwrap();
1769 if denominator > F::zero() {
1770 score = score + (forecasts[i] - actual).abs() / denominator;
1771 }
1772 }
1773 score = score * F::from(100).unwrap() / n;
1774 }
1775 _ => {
1776 for (i, &actual) in actuals.iter().enumerate() {
1778 let error = forecasts[i] - actual;
1779 score = score + error * error;
1780 }
1781 score = score / n;
1782 }
1783 }
1784
1785 Ok(score)
1786 }
1787
1788 fn select_best_models(&self, modelscores: &[(BaseModel, F)]) -> Result<Vec<BaseModel>> {
1790 let mut selected_models = Vec::new();
1791 let max_models = 5; for (model, _score) in modelscores.iter().take(max_models) {
1795 selected_models.push(model.clone());
1796 }
1797
1798 if selected_models.is_empty() && !modelscores.is_empty() {
1800 selected_models.push(modelscores[0].0.clone());
1801 }
1802
1803 Ok(selected_models)
1804 }
1805
1806 pub fn forecast(&self, steps: usize) -> Result<Array1<F>> {
1808 if let Some(ref ensemble) = self.best_ensemble {
1809 ensemble.forecast(steps)
1810 } else {
1811 Err(TimeSeriesError::InvalidModel(
1812 "AutoML not fitted yet".to_string(),
1813 ))
1814 }
1815 }
1816
1817 pub fn get_best_models(&self) -> Option<Vec<(BaseModel, ModelPerformance<F>, bool)>> {
1819 self.best_ensemble
1820 .as_ref()
1821 .map(|ensemble| ensemble.get_model_info())
1822 }
1823}
1824
1825#[cfg(test)]
1826mod tests {
1827 use super::*;
1828
1829 #[test]
1830 fn test_ensemble_forecaster_creation() {
1831 let models = vec![
1832 BaseModel::ARIMA { p: 1, d: 1, q: 1 },
1833 BaseModel::LinearTrend,
1834 BaseModel::MovingAverage { window: 5 },
1835 ];
1836
1837 let ensemble =
1838 EnsembleForecaster::<f64>::new(models, EnsembleStrategy::SimpleAverage, 1000, 50);
1839
1840 assert!(ensemble.is_ok());
1841 }
1842
1843 #[test]
1844 fn test_linear_regression_meta_model() {
1845 let mut meta_model = LinearRegressionMeta::<f64>::new();
1846
1847 let predictions =
1848 Array2::from_shape_vec((10, 3), (0..30).map(|i| i as f64 * 0.1).collect()).unwrap();
1849 let targets = Array1::from_vec((0..10).map(|i| i as f64).collect());
1850
1851 let result = meta_model.train(&predictions, &targets);
1852 assert!(result.is_ok());
1853
1854 let test_predictions =
1855 Array2::from_shape_vec((5, 3), (0..15).map(|i| i as f64 * 0.2).collect()).unwrap();
1856 let forecast = meta_model.predict(&test_predictions);
1857 assert!(forecast.is_ok());
1858 assert_eq!(forecast.unwrap().len(), 5);
1859 }
1860
1861 #[test]
1862 fn test_automl_forecaster() {
1863 let search_space = HyperparameterSpace::default();
1864 let cv_config = CrossValidationConfig::default();
1865 let metrics = vec![EvaluationMetric::MSE];
1866 let search_algo = SearchAlgorithm::RandomSearch { n_iterations: 10 };
1867
1868 let mut automl =
1869 AutoMLForecaster::<f64>::new(search_space, cv_config, metrics, search_algo);
1870
1871 let data: Vec<f64> = (0..100).map(|i| (i as f64 * 0.1).sin()).collect();
1872 let result = automl.fit(&data);
1873
1874 assert!(result.is_ok());
1876 }
1877
1878 #[test]
1879 fn test_ensemble_strategies() {
1880 let models = vec![
1881 BaseModel::LinearTrend,
1882 BaseModel::MovingAverage { window: 3 },
1883 ];
1884
1885 let strategies = vec![
1887 EnsembleStrategy::SimpleAverage,
1888 EnsembleStrategy::WeightedAverage,
1889 EnsembleStrategy::Median,
1890 EnsembleStrategy::BestModel,
1891 ];
1892
1893 for strategy in strategies {
1894 let ensemble = EnsembleForecaster::<f64>::new(models.clone(), strategy, 1000, 20);
1895 assert!(ensemble.is_ok());
1896 }
1897 }
1898}