1use crate::forecast::cross_validation::{CrossValidationMode, CrossValidator};
62use crate::forecast::error::ForecastError;
63use crate::forecast::result::{ForecastResult, ModelType};
64use crate::forecast::traits::ForecastModel;
65use crate::forecast::{
66 auto_arima, select_differencing_order, ArimaModel, AutoArimaOptions, HoltLinearModel,
67 HoltWintersModel, LinearRegressionModel, SarimaModel, SearchStrategy, SesModel,
68};
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
72pub enum SelectionMetric {
73 #[default]
76 Rmse,
77 Mae,
79 Smape,
83}
84
85impl std::fmt::Display for SelectionMetric {
86 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87 f.write_str(match self {
88 Self::Rmse => "RMSE",
89 Self::Mae => "MAE",
90 Self::Smape => "sMAPE",
91 })
92 }
93}
94
95#[derive(Debug, Clone, Copy)]
97pub struct AutoForecastOptions {
98 pub period: Option<usize>,
100 pub max_period: usize,
102 pub folds: usize,
104 pub metric: SelectionMetric,
106 pub max_order: usize,
108}
109
110impl Default for AutoForecastOptions {
111 fn default() -> Self {
112 Self {
113 period: None,
114 max_period: 288,
118 folds: 3,
121 metric: SelectionMetric::default(),
122 max_order: 2,
123 }
124 }
125}
126
127#[derive(Debug, Clone)]
129pub struct CandidateScore {
130 pub label: String,
132 pub model_type: ModelType,
134 pub score: f64,
136 pub rmse: f64,
138 pub mae: f64,
140 pub smape: Option<f64>,
142}
143
144#[derive(Debug, Clone)]
146pub struct ModelSelection {
147 pub label: String,
149 pub model_type: ModelType,
151 pub score: f64,
153 pub metric: SelectionMetric,
155 pub candidates: Vec<CandidateScore>,
157 pub rejected: Vec<(String, String)>,
161 pub period: Option<usize>,
163 pub differencing_order: usize,
165 pub folds: usize,
167 pub min_train_size: usize,
169}
170
171#[derive(Debug, Clone)]
173pub struct AutoForecast {
174 pub result: ForecastResult,
176 pub selection: ModelSelection,
178}
179
180type Factory = Box<dyn Fn() -> Box<dyn ForecastModel> + Send + Sync>;
181
182struct Candidate {
183 label: String,
184 model_type: ModelType,
185 factory: Factory,
186}
187
188fn candidates(
190 timestamps: &[i64],
191 values: &[f64],
192 period: Option<usize>,
193 min_train: usize,
194 options: &AutoForecastOptions,
195 rejected: &mut Vec<(String, String)>,
196) -> Vec<Candidate> {
197 let n = values.len();
198 let mut out: Vec<Candidate> = Vec::new();
199
200 out.push(Candidate {
201 label: "SES".into(),
202 model_type: ModelType::Ses,
203 factory: Box::new(|| Box::new(SesModel::new(None))),
204 });
205
206 if n >= 10 {
207 out.push(Candidate {
208 label: "Holt linear".into(),
209 model_type: ModelType::HoltLinear,
210 factory: Box::new(|| Box::new(HoltLinearModel::new(None, None, 1.0))),
211 });
212 out.push(Candidate {
213 label: "Holt damped (φ=0.9)".into(),
214 model_type: ModelType::HoltLinear,
215 factory: Box::new(|| Box::new(HoltLinearModel::new(None, None, 0.9))),
216 });
217 out.push(Candidate {
218 label: "Linear regression".into(),
219 model_type: ModelType::LinearRegression,
220 factory: Box::new(|| Box::new(LinearRegressionModel::new())),
221 });
222 } else {
223 rejected.push((
224 "Holt / linear regression".into(),
225 format!("needs 10 observations, got {n}"),
226 ));
227 }
228
229 if min_train >= 30 {
232 let opts = AutoArimaOptions {
233 max_evals: None,
234 strategy: SearchStrategy::Stepwise,
235 };
236 match auto_arima(
237 ×tamps[..min_train],
238 &values[..min_train],
239 options.max_order,
240 options.max_order,
241 options.max_order,
242 Some(&opts),
243 ) {
244 Ok(found) => {
245 let (p, d, q) = found.order;
246 out.push(Candidate {
247 label: format!("ARIMA({p},{d},{q})"),
248 model_type: ModelType::Arima,
249 factory: Box::new(move || Box::new(ArimaModel::new(p, d, q))),
250 });
251 if let Some(m) = period {
252 let sd = crate::forecast::select_seasonal_differencing_order(values, m, 1);
277 for (sp, sq) in [(1usize, 0usize), (0, 1)] {
278 let needed = (p.max(q) + sp.max(sq) * m) + 2 * d + 2 * sd * m + 10;
279 if min_train < needed {
280 rejected.push((
281 format!("SARIMA({p},{d},{q})({sp},{sd},{sq})[{m}]"),
282 format!("needs {needed} observations per fold, got {min_train}"),
283 ));
284 continue;
285 }
286 out.push(Candidate {
287 label: format!("SARIMA({p},{d},{q})({sp},{sd},{sq})[{m}]"),
288 model_type: ModelType::Sarima,
289 factory: Box::new(move || {
290 Box::new(SarimaModel::new(p, d, q, sp, sd, sq, m))
291 }),
292 });
293 }
294 }
295 }
296 Err(e) => rejected.push(("ARIMA".into(), e.to_string())),
297 }
298 } else {
299 rejected.push((
300 "ARIMA / SARIMA".into(),
301 format!("needs a 30-observation training window, got {min_train}"),
302 ));
303 }
304
305 match period {
306 Some(m) if min_train >= 2 * m => {
307 out.push(Candidate {
308 label: format!("Holt-Winters additive[{m}]"),
309 model_type: ModelType::HoltWinters,
310 factory: Box::new(move || {
311 Box::new(HoltWintersModel::new(None, None, None, Some(m), false))
312 }),
313 });
314 if values.iter().all(|v| *v > 0.0) {
315 out.push(Candidate {
316 label: format!("Holt-Winters multiplicative[{m}]"),
317 model_type: ModelType::HoltWinters,
318 factory: Box::new(move || {
319 Box::new(HoltWintersModel::new(None, None, None, Some(m), true))
320 }),
321 });
322 } else {
323 rejected.push((
324 "Holt-Winters multiplicative".into(),
325 "series contains a value that is not strictly positive".into(),
326 ));
327 }
328 }
329 Some(m) => rejected.push((
330 format!("Holt-Winters[{m}]"),
331 format!("needs two seasons ({}) per fold, got {min_train}", 2 * m),
332 )),
333 None => rejected.push((
334 "Holt-Winters / SARIMA".into(),
335 "no seasonal period detected".into(),
336 )),
337 }
338
339 out
340}
341
342pub fn select_model(
354 timestamps: &[i64],
355 values: &[f64],
356 horizon: usize,
357 options: &AutoForecastOptions,
358) -> Result<ModelSelection, ForecastError> {
359 crate::forecast::util::validate_input(timestamps, values)?;
360 if horizon == 0 {
361 return Err(ForecastError::InvalidParams {
362 name: "horizon",
363 value: "0".into(),
364 reason: "a forecast needs at least one step",
365 });
366 }
367 let n = values.len();
368 let min_required = horizon * 2 + 4;
371 if n < min_required {
372 return Err(ForecastError::InsufficientData {
373 min: min_required,
374 got: n,
375 });
376 }
377
378 let folds = options.folds.max(1);
379 let min_train = n
382 .saturating_sub(horizon * folds)
383 .max(n / 2)
384 .min(n - horizon);
385 let period = options
386 .period
387 .filter(|m| *m >= 2)
388 .or_else(|| crate::preprocess::detect_period(values, options.max_period.min(n / 2)));
389 let differencing_order = select_differencing_order(values, options.max_order.max(2));
390
391 let mut rejected = Vec::new();
392 let set = candidates(
393 timestamps,
394 values,
395 period,
396 min_train,
397 options,
398 &mut rejected,
399 );
400
401 let cv = CrossValidator::new(
402 CrossValidationMode::ExpandingWindow,
403 min_train,
404 horizon,
405 folds,
406 );
407
408 let mut scored: Vec<CandidateScore> = Vec::new();
409 for candidate in &set {
410 match cv.evaluate(timestamps, values, || (candidate.factory)()) {
411 Ok(r) => {
412 let score = match options.metric {
413 SelectionMetric::Rmse => r.mean_rmse,
414 SelectionMetric::Mae => r.mean_mae,
415 SelectionMetric::Smape => r.mean_smape.unwrap_or(r.mean_rmse),
416 };
417 if score.is_finite() {
418 scored.push(CandidateScore {
419 label: candidate.label.clone(),
420 model_type: candidate.model_type.clone(),
421 score,
422 rmse: r.mean_rmse,
423 mae: r.mean_mae,
424 smape: r.mean_smape,
425 });
426 } else {
427 rejected.push((candidate.label.clone(), "score was not finite".into()));
428 }
429 }
430 Err(e) => rejected.push((candidate.label.clone(), e.to_string())),
431 }
432 }
433
434 scored.sort_by(|a, b| a.score.total_cmp(&b.score));
435 let best = scored.first().cloned().ok_or_else(|| {
436 ForecastError::NumericalInstability(
437 "no forecast model could be cross-validated on this series".into(),
438 )
439 })?;
440
441 metrics::counter!("chronix_forecast_model_selected_total",
442 "model" => format!("{:?}", best.model_type))
443 .increment(1);
444
445 Ok(ModelSelection {
446 label: best.label,
447 model_type: best.model_type,
448 score: best.score,
449 metric: options.metric,
450 candidates: scored,
451 rejected,
452 period,
453 differencing_order,
454 folds,
455 min_train_size: min_train,
456 })
457}
458
459pub fn auto_forecast(
466 timestamps: &[i64],
467 values: &[f64],
468 horizon: usize,
469 options: &AutoForecastOptions,
470) -> Result<AutoForecast, ForecastError> {
471 let selection = select_model(timestamps, values, horizon, options)?;
472
473 let mut rejected = Vec::new();
476 let set = candidates(
477 timestamps,
478 values,
479 selection.period,
480 selection.min_train_size,
481 options,
482 &mut rejected,
483 );
484 let winner = set
485 .iter()
486 .find(|c| c.label == selection.label)
487 .ok_or_else(|| {
488 ForecastError::NumericalInstability(format!(
489 "selected model {} could not be rebuilt",
490 selection.label
491 ))
492 })?;
493
494 let mut model = (winner.factory)();
495 model.fit(timestamps, values)?;
496 let result = model.predict(horizon)?;
497 Ok(AutoForecast { result, selection })
498}
499
500#[cfg(test)]
501#[allow(clippy::unwrap_used, clippy::expect_used)]
502mod tests {
503 use super::*;
504
505 fn ts(n: usize) -> Vec<i64> {
506 (0..n as i64).map(|i| i * 1_000_000_000).collect()
507 }
508
509 fn noise(seed: u64, n: usize, amp: f64) -> Vec<f64> {
510 let mut s = seed;
511 (0..n)
512 .map(|_| {
513 s = s
514 .wrapping_mul(6_364_136_223_846_793_005)
515 .wrapping_add(1_442_695_040_888_963_407);
516 ((s >> 33) as f64 / (1u64 << 31) as f64 - 0.5) * 2.0 * amp
517 })
518 .collect()
519 }
520
521 #[test]
522 fn a_seasonal_series_selects_a_seasonal_model() {
523 let m = 24;
524 let n = 240;
525 let values: Vec<f64> = (0..n)
526 .map(|i| 50.0 + 10.0 * (2.0 * std::f64::consts::PI * (i % m) as f64 / m as f64).sin())
527 .zip(noise(1, n, 0.2))
528 .map(|(a, b)| a + b)
529 .collect();
530
531 let opts = AutoForecastOptions {
532 period: Some(m),
533 ..Default::default()
534 };
535 let chosen = select_model(&ts(n), &values, 12, &opts).unwrap();
536 assert!(
537 matches!(
538 chosen.model_type,
539 ModelType::HoltWinters | ModelType::Sarima
540 ),
541 "picked {} ({:?}); candidates: {:?}",
542 chosen.label,
543 chosen.model_type,
544 chosen
545 .candidates
546 .iter()
547 .map(|c| (&c.label, c.score))
548 .collect::<Vec<_>>()
549 );
550 assert_eq!(chosen.period, Some(m));
551 }
552
553 #[test]
554 fn a_trending_series_beats_ses_with_a_trended_model() {
555 let n = 200;
556 let values: Vec<f64> = (0..n)
557 .map(|i| 5.0 + i as f64 * 0.7)
558 .zip(noise(2, n, 0.5))
559 .map(|(a, b)| a + b)
560 .collect();
561 let chosen = select_model(&ts(n), &values, 10, &AutoForecastOptions::default()).unwrap();
562 let ses = chosen
563 .candidates
564 .iter()
565 .find(|c| c.label == "SES")
566 .expect("SES is always a candidate");
567 assert!(
568 chosen.score < ses.score,
569 "SES won on a trending series: {:?}",
570 chosen
571 .candidates
572 .iter()
573 .map(|c| (&c.label, c.score))
574 .collect::<Vec<_>>()
575 );
576 assert_ne!(chosen.model_type, ModelType::Ses);
577 }
578
579 #[test]
580 fn flat_noise_selects_a_flat_model() {
581 let n = 200;
582 let values: Vec<f64> = noise(3, n, 1.0).iter().map(|v| v + 20.0).collect();
583 let chosen = select_model(&ts(n), &values, 8, &AutoForecastOptions::default()).unwrap();
584 assert_eq!(chosen.differencing_order, 0, "stationary noise differenced");
585 assert!(chosen.score.is_finite());
586 let ses = chosen.candidates.iter().find(|c| c.label == "SES").unwrap();
588 assert!(chosen.score <= ses.score + 1e-9);
589 }
590
591 #[test]
592 fn every_candidate_is_scored_or_explained() {
593 let n = 120;
594 let values: Vec<f64> = noise(4, n, 1.0).iter().map(|v| v + 3.0).collect();
595 let chosen = select_model(&ts(n), &values, 6, &AutoForecastOptions::default()).unwrap();
596 assert!(!chosen.candidates.is_empty());
597 for w in chosen.candidates.windows(2) {
598 assert!(w[0].score <= w[1].score, "candidates not sorted");
599 }
600 for (label, reason) in &chosen.rejected {
603 assert!(!reason.is_empty(), "{label} rejected with no reason");
604 }
605 }
606
607 #[test]
608 fn auto_forecast_returns_the_winner_fitted_on_everything() {
609 let m = 12;
610 let n = 180;
611 let values: Vec<f64> = (0..n)
612 .map(|i| 20.0 + 4.0 * (2.0 * std::f64::consts::PI * (i % m) as f64 / m as f64).sin())
613 .collect();
614 let opts = AutoForecastOptions {
615 period: Some(m),
616 ..Default::default()
617 };
618 let out = auto_forecast(&ts(n), &values, m, &opts).unwrap();
619 assert_eq!(out.result.values.len(), m);
620 assert_eq!(out.result.timestamps.len(), m);
621 assert!(out.result.timestamps[0] > ts(n)[n - 1]);
622 assert_eq!(out.selection.label, out.selection.candidates[0].label);
623 for v in &out.result.values {
624 assert!(v.is_finite(), "forecast contained {v}");
625 }
626 }
627
628 #[test]
629 fn a_horizon_of_zero_is_rejected() {
630 let values: Vec<f64> = (0..50).map(f64::from).collect();
631 assert!(select_model(&ts(50), &values, 0, &AutoForecastOptions::default()).is_err());
632 }
633
634 #[test]
635 fn too_short_a_series_is_rejected() {
636 let values: Vec<f64> = (0..10).map(f64::from).collect();
637 assert!(select_model(&ts(10), &values, 8, &AutoForecastOptions::default()).is_err());
638 }
639
640 #[test]
641 fn the_metric_can_change_the_winner_ordering() {
642 let n = 200;
643 let values: Vec<f64> = (0..n)
644 .map(|i| 100.0 + (i as f64 * 0.05).sin() * 10.0)
645 .zip(noise(5, n, 1.0))
646 .map(|(a, b)| a + b)
647 .collect();
648 for metric in [
649 SelectionMetric::Rmse,
650 SelectionMetric::Mae,
651 SelectionMetric::Smape,
652 ] {
653 let opts = AutoForecastOptions {
654 metric,
655 ..Default::default()
656 };
657 let chosen = select_model(&ts(n), &values, 10, &opts).unwrap();
658 assert_eq!(chosen.metric, metric);
659 assert!(
660 chosen.score.is_finite(),
661 "{metric} produced {}",
662 chosen.score
663 );
664 for c in &chosen.candidates {
667 assert!(c.rmse.is_finite() && c.mae.is_finite());
668 }
669 }
670 }
671}