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);
272 for (sp, sq) in [(1usize, 0usize), (0, 1)] {
273 let needed = (p.max(q) + sp.max(sq) * m) + 2 * d + 2 * sd * m + 10;
274 if min_train < needed {
275 rejected.push((
276 format!("SARIMA({p},{d},{q})({sp},{sd},{sq})[{m}]"),
277 format!("needs {needed} observations per fold, got {min_train}"),
278 ));
279 continue;
280 }
281 out.push(Candidate {
282 label: format!("SARIMA({p},{d},{q})({sp},{sd},{sq})[{m}]"),
283 model_type: ModelType::Sarima,
284 factory: Box::new(move || {
285 Box::new(SarimaModel::new(p, d, q, sp, sd, sq, m))
286 }),
287 });
288 }
289 }
290 }
291 Err(e) => rejected.push(("ARIMA".into(), e.to_string())),
292 }
293 } else {
294 rejected.push((
295 "ARIMA / SARIMA".into(),
296 format!("needs a 30-observation training window, got {min_train}"),
297 ));
298 }
299
300 match period {
301 Some(m) if min_train >= 2 * m => {
302 out.push(Candidate {
303 label: format!("Holt-Winters additive[{m}]"),
304 model_type: ModelType::HoltWinters,
305 factory: Box::new(move || {
306 Box::new(HoltWintersModel::new(None, None, None, Some(m), false))
307 }),
308 });
309 if values.iter().all(|v| *v > 0.0) {
310 out.push(Candidate {
311 label: format!("Holt-Winters multiplicative[{m}]"),
312 model_type: ModelType::HoltWinters,
313 factory: Box::new(move || {
314 Box::new(HoltWintersModel::new(None, None, None, Some(m), true))
315 }),
316 });
317 } else {
318 rejected.push((
319 "Holt-Winters multiplicative".into(),
320 "series contains a value that is not strictly positive".into(),
321 ));
322 }
323 }
324 Some(m) => rejected.push((
325 format!("Holt-Winters[{m}]"),
326 format!("needs two seasons ({}) per fold, got {min_train}", 2 * m),
327 )),
328 None => rejected.push((
329 "Holt-Winters / SARIMA".into(),
330 "no seasonal period detected".into(),
331 )),
332 }
333
334 out
335}
336
337pub fn select_model(
349 timestamps: &[i64],
350 values: &[f64],
351 horizon: usize,
352 options: &AutoForecastOptions,
353) -> Result<ModelSelection, ForecastError> {
354 crate::forecast::util::validate_input(timestamps, values)?;
355 if horizon == 0 {
356 return Err(ForecastError::InvalidParams {
357 name: "horizon",
358 value: "0".into(),
359 reason: "a forecast needs at least one step",
360 });
361 }
362 let n = values.len();
363 let min_required = horizon * 2 + 4;
366 if n < min_required {
367 return Err(ForecastError::InsufficientData {
368 min: min_required,
369 got: n,
370 });
371 }
372
373 let folds = options.folds.max(1);
374 let min_train = n
377 .saturating_sub(horizon * folds)
378 .max(n / 2)
379 .min(n - horizon);
380 let period = options
381 .period
382 .filter(|m| *m >= 2)
383 .or_else(|| crate::preprocess::detect_period(values, options.max_period.min(n / 2)));
384 let differencing_order = select_differencing_order(values, options.max_order.max(2));
385
386 let mut rejected = Vec::new();
387 let set = candidates(
388 timestamps,
389 values,
390 period,
391 min_train,
392 options,
393 &mut rejected,
394 );
395
396 let cv = CrossValidator::new(
397 CrossValidationMode::ExpandingWindow,
398 min_train,
399 horizon,
400 folds,
401 );
402
403 let mut scored: Vec<CandidateScore> = Vec::new();
404 for candidate in &set {
405 match cv.evaluate(timestamps, values, || (candidate.factory)()) {
406 Ok(r) => {
407 let score = match options.metric {
408 SelectionMetric::Rmse => r.mean_rmse,
409 SelectionMetric::Mae => r.mean_mae,
410 SelectionMetric::Smape => r.mean_smape.unwrap_or(r.mean_rmse),
411 };
412 if score.is_finite() {
413 scored.push(CandidateScore {
414 label: candidate.label.clone(),
415 model_type: candidate.model_type.clone(),
416 score,
417 rmse: r.mean_rmse,
418 mae: r.mean_mae,
419 smape: r.mean_smape,
420 });
421 } else {
422 rejected.push((candidate.label.clone(), "score was not finite".into()));
423 }
424 }
425 Err(e) => rejected.push((candidate.label.clone(), e.to_string())),
426 }
427 }
428
429 scored.sort_by(|a, b| a.score.total_cmp(&b.score));
430 let best = scored.first().cloned().ok_or_else(|| {
431 ForecastError::NumericalInstability(
432 "no forecast model could be cross-validated on this series".into(),
433 )
434 })?;
435
436 metrics::counter!("chronix_forecast_model_selected_total",
437 "model" => format!("{:?}", best.model_type))
438 .increment(1);
439
440 Ok(ModelSelection {
441 label: best.label,
442 model_type: best.model_type,
443 score: best.score,
444 metric: options.metric,
445 candidates: scored,
446 rejected,
447 period,
448 differencing_order,
449 folds,
450 min_train_size: min_train,
451 })
452}
453
454pub fn auto_forecast(
461 timestamps: &[i64],
462 values: &[f64],
463 horizon: usize,
464 options: &AutoForecastOptions,
465) -> Result<AutoForecast, ForecastError> {
466 let selection = select_model(timestamps, values, horizon, options)?;
467
468 let mut rejected = Vec::new();
471 let set = candidates(
472 timestamps,
473 values,
474 selection.period,
475 selection.min_train_size,
476 options,
477 &mut rejected,
478 );
479 let winner = set
480 .iter()
481 .find(|c| c.label == selection.label)
482 .ok_or_else(|| {
483 ForecastError::NumericalInstability(format!(
484 "selected model {} could not be rebuilt",
485 selection.label
486 ))
487 })?;
488
489 let mut model = (winner.factory)();
490 model.fit(timestamps, values)?;
491 let result = model.predict(horizon)?;
492 Ok(AutoForecast { result, selection })
493}
494
495#[cfg(test)]
496#[allow(clippy::unwrap_used, clippy::expect_used)]
497mod tests {
498 use super::*;
499
500 fn ts(n: usize) -> Vec<i64> {
501 (0..n as i64).map(|i| i * 1_000_000_000).collect()
502 }
503
504 fn noise(seed: u64, n: usize, amp: f64) -> Vec<f64> {
505 let mut s = seed;
506 (0..n)
507 .map(|_| {
508 s = s
509 .wrapping_mul(6_364_136_223_846_793_005)
510 .wrapping_add(1_442_695_040_888_963_407);
511 ((s >> 33) as f64 / (1u64 << 31) as f64 - 0.5) * 2.0 * amp
512 })
513 .collect()
514 }
515
516 #[test]
517 fn a_seasonal_series_selects_a_seasonal_model() {
518 let m = 24;
519 let n = 240;
520 let values: Vec<f64> = (0..n)
521 .map(|i| 50.0 + 10.0 * (2.0 * std::f64::consts::PI * (i % m) as f64 / m as f64).sin())
522 .zip(noise(1, n, 0.2))
523 .map(|(a, b)| a + b)
524 .collect();
525
526 let opts = AutoForecastOptions {
527 period: Some(m),
528 ..Default::default()
529 };
530 let chosen = select_model(&ts(n), &values, 12, &opts).unwrap();
531 assert!(
532 matches!(
533 chosen.model_type,
534 ModelType::HoltWinters | ModelType::Sarima
535 ),
536 "picked {} ({:?}); candidates: {:?}",
537 chosen.label,
538 chosen.model_type,
539 chosen
540 .candidates
541 .iter()
542 .map(|c| (&c.label, c.score))
543 .collect::<Vec<_>>()
544 );
545 assert_eq!(chosen.period, Some(m));
546 }
547
548 #[test]
549 fn a_trending_series_beats_ses_with_a_trended_model() {
550 let n = 200;
551 let values: Vec<f64> = (0..n)
552 .map(|i| 5.0 + i as f64 * 0.7)
553 .zip(noise(2, n, 0.5))
554 .map(|(a, b)| a + b)
555 .collect();
556 let chosen = select_model(&ts(n), &values, 10, &AutoForecastOptions::default()).unwrap();
557 let ses = chosen
558 .candidates
559 .iter()
560 .find(|c| c.label == "SES")
561 .expect("SES is always a candidate");
562 assert!(
563 chosen.score < ses.score,
564 "SES won on a trending series: {:?}",
565 chosen
566 .candidates
567 .iter()
568 .map(|c| (&c.label, c.score))
569 .collect::<Vec<_>>()
570 );
571 assert_ne!(chosen.model_type, ModelType::Ses);
572 }
573
574 #[test]
575 fn flat_noise_selects_a_flat_model() {
576 let n = 200;
577 let values: Vec<f64> = noise(3, n, 1.0).iter().map(|v| v + 20.0).collect();
578 let chosen = select_model(&ts(n), &values, 8, &AutoForecastOptions::default()).unwrap();
579 assert_eq!(chosen.differencing_order, 0, "stationary noise differenced");
580 assert!(chosen.score.is_finite());
581 let ses = chosen.candidates.iter().find(|c| c.label == "SES").unwrap();
583 assert!(chosen.score <= ses.score + 1e-9);
584 }
585
586 #[test]
587 fn every_candidate_is_scored_or_explained() {
588 let n = 120;
589 let values: Vec<f64> = noise(4, n, 1.0).iter().map(|v| v + 3.0).collect();
590 let chosen = select_model(&ts(n), &values, 6, &AutoForecastOptions::default()).unwrap();
591 assert!(!chosen.candidates.is_empty());
592 for w in chosen.candidates.windows(2) {
593 assert!(w[0].score <= w[1].score, "candidates not sorted");
594 }
595 for (label, reason) in &chosen.rejected {
598 assert!(!reason.is_empty(), "{label} rejected with no reason");
599 }
600 }
601
602 #[test]
603 fn auto_forecast_returns_the_winner_fitted_on_everything() {
604 let m = 12;
605 let n = 180;
606 let values: Vec<f64> = (0..n)
607 .map(|i| 20.0 + 4.0 * (2.0 * std::f64::consts::PI * (i % m) as f64 / m as f64).sin())
608 .collect();
609 let opts = AutoForecastOptions {
610 period: Some(m),
611 ..Default::default()
612 };
613 let out = auto_forecast(&ts(n), &values, m, &opts).unwrap();
614 assert_eq!(out.result.values.len(), m);
615 assert_eq!(out.result.timestamps.len(), m);
616 assert!(out.result.timestamps[0] > ts(n)[n - 1]);
617 assert_eq!(out.selection.label, out.selection.candidates[0].label);
618 for v in &out.result.values {
619 assert!(v.is_finite(), "forecast contained {v}");
620 }
621 }
622
623 #[test]
624 fn a_horizon_of_zero_is_rejected() {
625 let values: Vec<f64> = (0..50).map(f64::from).collect();
626 assert!(select_model(&ts(50), &values, 0, &AutoForecastOptions::default()).is_err());
627 }
628
629 #[test]
630 fn too_short_a_series_is_rejected() {
631 let values: Vec<f64> = (0..10).map(f64::from).collect();
632 assert!(select_model(&ts(10), &values, 8, &AutoForecastOptions::default()).is_err());
633 }
634
635 #[test]
636 fn the_metric_can_change_the_winner_ordering() {
637 let n = 200;
638 let values: Vec<f64> = (0..n)
639 .map(|i| 100.0 + (i as f64 * 0.05).sin() * 10.0)
640 .zip(noise(5, n, 1.0))
641 .map(|(a, b)| a + b)
642 .collect();
643 for metric in [
644 SelectionMetric::Rmse,
645 SelectionMetric::Mae,
646 SelectionMetric::Smape,
647 ] {
648 let opts = AutoForecastOptions {
649 metric,
650 ..Default::default()
651 };
652 let chosen = select_model(&ts(n), &values, 10, &opts).unwrap();
653 assert_eq!(chosen.metric, metric);
654 assert!(
655 chosen.score.is_finite(),
656 "{metric} produced {}",
657 chosen.score
658 );
659 for c in &chosen.candidates {
662 assert!(c.rmse.is_finite() && c.mae.is_finite());
663 }
664 }
665 }
666}