Skip to main content

chronix_analytics/forecast/
selection.rs

1//! Automatic forecast-model selection.
2//!
3//! Picking a model is the decision that matters most and the one a caller is
4//! least equipped to make: it needs a seasonality test, a stationarity test,
5//! an order search and, above all, an out-of-sample comparison. This module
6//! runs all four and reports what it did.
7//!
8//! # How the winner is chosen
9//!
10//! By **rolling-origin cross-validation at the horizon actually wanted**, not
11//! by an information criterion.
12//!
13//! An information criterion cannot compare these models with each other. AIC
14//! is a statement about one likelihood, and the candidates here do not share
15//! one: the exponential smoothers are fitted by sum of squares rather than by
16//! maximum likelihood, and an ARIMA with `d = 1` is fitted to the *differenced*
17//! series — a different dataset, one observation shorter and on a different
18//! scale. Ranking them by AIC produces a number for every candidate and an
19//! ordering that means nothing. (Within one family, at one differencing order,
20//! AICc is the right tool, and that is where
21//! [`auto_arima`](crate::forecast::auto_arima) uses it.)
22//!
23//! Walk-forward validation asks the only question that transfers: *given data
24//! up to here, how wrong was this model `h` steps later?* It costs
25//! `candidates × folds` fits, which is why the fold count is small by default
26//! and why the candidate set is pruned by series length before anything is
27//! fitted.
28//!
29//! # What it fits
30//!
31//! | Candidate | Included when |
32//! |---|---|
33//! | SES | always |
34//! | Holt linear, and damped Holt (φ = 0.9) | ≥ 10 observations |
35//! | Linear regression | ≥ 10 observations |
36//! | ARIMA, at the order [`auto_arima`] picks | ≥ 30 observations |
37//! | Holt-Winters, additive | a period `m ≥ 2` is found and there are ≥ 2 seasons |
38//! | Holt-Winters, multiplicative | as above, and every value is strictly positive |
39//! | SARIMA(p,d,q)(1,1,0)ₘ and (0,1,1)ₘ | as above, with ≥ 3 seasons |
40//!
41//! The seasonal period comes from
42//! [`detect_period`](crate::preprocess::detect_period) unless one is given;
43//! the differencing order comes from the KPSS test
44//! ([`select_differencing_order`](crate::forecast::select_differencing_order)).
45//! The ARIMA order is chosen on the **first fold's training window**, not on
46//! the whole series, so the order search cannot see the data it is later
47//! scored against.
48//!
49//! # Example
50//!
51//! ```no_run
52//! use chronix_analytics::forecast::{auto_forecast, AutoForecastOptions};
53//!
54//! # let (timestamps, values): (Vec<i64>, Vec<f64>) = (vec![], vec![]);
55//! let chosen = auto_forecast(&timestamps, &values, 24, &AutoForecastOptions::default())?;
56//! println!("{} won with {} {:.3}", chosen.selection.label,
57//!          chosen.selection.metric, chosen.selection.score);
58//! # Ok::<(), chronix_analytics::forecast::ForecastError>(())
59//! ```
60
61use 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/// Which accuracy metric decides the winner.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
72pub enum SelectionMetric {
73    /// Root mean squared error — the default. Penalises large misses, which
74    /// is usually what a capacity or threshold decision cares about.
75    #[default]
76    Rmse,
77    /// Mean absolute error. Less sensitive to a single bad fold.
78    Mae,
79    /// Symmetric mean absolute percentage error, for comparing series of
80    /// different magnitudes. Undefined where actual and forecast are both
81    /// zero, so a candidate it cannot score is ranked by RMSE instead.
82    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/// Options for [`select_model`] and [`auto_forecast`].
96#[derive(Debug, Clone, Copy)]
97pub struct AutoForecastOptions {
98    /// Seasonal period, in observations. `None` detects it.
99    pub period: Option<usize>,
100    /// Largest period the detector will consider.
101    pub max_period: usize,
102    /// Rolling origins to score each candidate over.
103    pub folds: usize,
104    /// Metric that decides the winner.
105    pub metric: SelectionMetric,
106    /// Cap on the ARIMA orders searched.
107    pub max_order: usize,
108}
109
110impl Default for AutoForecastOptions {
111    fn default() -> Self {
112        Self {
113            period: None,
114            // 288 five-minute samples is a day, which is the longest cycle
115            // worth detecting automatically; a weekly cycle needs a period the
116            // caller states, because detecting one needs months of data.
117            max_period: 288,
118            // Three origins is enough to stop one lucky window deciding, and
119            // few enough that the whole search is a handful of fits.
120            folds: 3,
121            metric: SelectionMetric::default(),
122            max_order: 2,
123        }
124    }
125}
126
127/// One candidate's cross-validated score.
128#[derive(Debug, Clone)]
129pub struct CandidateScore {
130    /// Human-readable model description, e.g. `SARIMA(1,0,0)(1,1,0)[24]`.
131    pub label: String,
132    /// Model family.
133    pub model_type: ModelType,
134    /// Score under the selected metric, averaged over folds. Lower is better.
135    pub score: f64,
136    /// Mean RMSE across folds, always populated.
137    pub rmse: f64,
138    /// Mean MAE across folds, always populated.
139    pub mae: f64,
140    /// Mean sMAPE across folds, where it is defined.
141    pub smape: Option<f64>,
142}
143
144/// What [`select_model`] chose, and everything it had to work with.
145#[derive(Debug, Clone)]
146pub struct ModelSelection {
147    /// Winning model's label.
148    pub label: String,
149    /// Winning model's family.
150    pub model_type: ModelType,
151    /// Winning score under [`Self::metric`].
152    pub score: f64,
153    /// The metric that decided it.
154    pub metric: SelectionMetric,
155    /// Every candidate that produced a score, best first.
156    pub candidates: Vec<CandidateScore>,
157    /// Candidates that could not be scored, with the reason. Reported rather
158    /// than dropped: "SARIMA was not considered" and "SARIMA lost" are very
159    /// different answers to "why is my forecast not seasonal?".
160    pub rejected: Vec<(String, String)>,
161    /// Seasonal period used, if any.
162    pub period: Option<usize>,
163    /// Differencing order the KPSS test asked for.
164    pub differencing_order: usize,
165    /// Rolling origins each candidate was scored over.
166    pub folds: usize,
167    /// Observations in the first fold's training window.
168    pub min_train_size: usize,
169}
170
171/// A selected model, fitted on the whole series, and its forecast.
172#[derive(Debug, Clone)]
173pub struct AutoForecast {
174    /// The forecast from the winning model, fitted on all the data.
175    pub result: ForecastResult,
176    /// Why that model won.
177    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
188/// Build the candidate set for a series.
189fn 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    // The ARIMA order is searched on the first training window only, so the
230    // search cannot see the data the candidate is scored against.
231    if min_train >= 30 {
232        let opts = AutoArimaOptions {
233            max_evals: None,
234            strategy: SearchStrategy::Stepwise,
235        };
236        match auto_arima(
237            &timestamps[..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                    // Two standard seasonal shapes: a seasonal AR, and the
253                    // seasonal MA of the airline model. Searching (P,Q) as
254                    // well would multiply the fits for a choice that is
255                    // nearly always one of these two.
256                    //
257                    // `D` comes from seasonal **strength** over an STL
258                    // decomposition against a 0.64 threshold — R's
259                    // `forecast::nsdiffs` default, whose threshold was fitted
260                    // by minimising MASE on M3/M4 rather than derived. Strong
261                    // seasonality is differenced whether or not it has a unit
262                    // root, and a *stochastic* seasonal level scores low and
263                    // is missed. Both are pinned by tests beside the function.
264                    //
265                    // Proposing both orders and letting the folds choose was
266                    // measured and dropped: mean MASE moves by less than 0.01
267                    // across four regimes, it is *worse* on quarterly data,
268                    // and it costs 22% more fits. Holt-Winters already covers
269                    // the blind spot — its seasonal smoothing tracks a
270                    // drifting level. See `examples/seasonal_differencing.rs`.
271                    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
337/// Choose a forecast model for a series by rolling-origin cross-validation.
338///
339/// What is fitted, and why the winner is chosen by out-of-sample error rather
340/// than by an information criterion, is described on
341/// [`AutoForecastOptions`] and [`SelectionMetric`].
342///
343/// # Errors
344///
345/// [`ForecastError::InsufficientData`] when the series is too short to hold a
346/// training window and one `horizon`-long test window, and
347/// [`ForecastError::NumericalInstability`] when no candidate could be scored.
348pub 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    // One training window plus one test window is the floor; below it there is
364    // nothing to validate against and the honest answer is "not enough data".
365    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    // Reserve the last `horizon` observations for the final fold's test
375    // window, and leave room for the origins to step forward.
376    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
454/// Select a model, fit it on the whole series, and forecast `horizon` steps.
455///
456/// # Errors
457///
458/// Whatever [`select_model`] returns, plus any error from fitting the winner
459/// on the full series.
460pub 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    // Re-create the winner from the same candidate set and fit it on
469    // everything, which is strictly more data than any fold saw.
470    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        // Whatever wins, it must not be worse than doing nothing by much.
582        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        // Nothing is silently dropped: a candidate is either scored or has a
596        // stated reason.
597        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            // Every candidate carries all three metrics regardless of which
660            // one decided.
661            for c in &chosen.candidates {
662                assert!(c.rmse.is_finite() && c.mae.is_finite());
663            }
664        }
665    }
666}