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` is *chosen*, not fixed. It used to be hard-coded
258                    // to 1 in both shapes, so `SARIMA(p,d,q)(P,0,Q)[m]` could
259                    // not be proposed at all — and the series that needs it
260                    // is the one `detect_period` finds a period in while the
261                    // seasonal term explains almost none of the variance.
262                    // Differencing that spends `m` observations, adds a
263                    // moving-average term the data does not support, and
264                    // widens every interval built on the result. It does not
265                    // fail; it just forecasts worse.
266                    //
267                    // The measure is seasonal **strength** over an STL
268                    // decomposition against a 0.64 threshold — R's
269                    // `forecast::nsdiffs` default, whose threshold was fitted
270                    // by minimising MASE on M3/M4 rather than derived. Worth
271                    // being exact about what that does and does not buy:
272                    // strong seasonality is differenced whether or not it has
273                    // a unit root, and a *stochastic* seasonal level scores
274                    // low and is missed. Both are pinned by tests beside the
275                    // function.
276                    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
342/// Choose a forecast model for a series by rolling-origin cross-validation.
343///
344/// What is fitted, and why the winner is chosen by out-of-sample error rather
345/// than by an information criterion, is described on
346/// [`AutoForecastOptions`] and [`SelectionMetric`].
347///
348/// # Errors
349///
350/// [`ForecastError::InsufficientData`] when the series is too short to hold a
351/// training window and one `horizon`-long test window, and
352/// [`ForecastError::NumericalInstability`] when no candidate could be scored.
353pub 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    // One training window plus one test window is the floor; below it there is
369    // nothing to validate against and the honest answer is "not enough data".
370    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    // Reserve the last `horizon` observations for the final fold's test
380    // window, and leave room for the origins to step forward.
381    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
459/// Select a model, fit it on the whole series, and forecast `horizon` steps.
460///
461/// # Errors
462///
463/// Whatever [`select_model`] returns, plus any error from fitting the winner
464/// on the full series.
465pub 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    // Re-create the winner from the same candidate set and fit it on
474    // everything, which is strictly more data than any fold saw.
475    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        // Whatever wins, it must not be worse than doing nothing by much.
587        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        // Nothing is silently dropped: a candidate is either scored or has a
601        // stated reason.
602        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            // Every candidate carries all three metrics regardless of which
665            // one decided.
666            for c in &chosen.candidates {
667                assert!(c.rmse.is_finite() && c.mae.is_finite());
668            }
669        }
670    }
671}