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