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 on a
253                    // seasonally differenced series, and the seasonal MA of
254                    // the airline model. Searching (P,D,Q) as well would
255                    // multiply the fits for a choice that is nearly always one
256                    // of these two.
257                    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
322/// Choose a forecast model for a series by rolling-origin cross-validation.
323///
324/// What is fitted, and why the winner is chosen by out-of-sample error rather
325/// than by an information criterion, is described on
326/// [`AutoForecastOptions`] and [`SelectionMetric`].
327///
328/// # Errors
329///
330/// [`ForecastError::InsufficientData`] when the series is too short to hold a
331/// training window and one `horizon`-long test window, and
332/// [`ForecastError::NumericalInstability`] when no candidate could be scored.
333pub 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    // One training window plus one test window is the floor; below it there is
349    // nothing to validate against and the honest answer is "not enough data".
350    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    // Reserve the last `horizon` observations for the final fold's test
360    // window, and leave room for the origins to step forward.
361    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
439/// Select a model, fit it on the whole series, and forecast `horizon` steps.
440///
441/// # Errors
442///
443/// Whatever [`select_model`] returns, plus any error from fitting the winner
444/// on the full series.
445pub 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    // Re-create the winner from the same candidate set and fit it on
454    // everything, which is strictly more data than any fold saw.
455    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        // Whatever wins, it must not be worse than doing nothing by much.
567        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        // Nothing is silently dropped: a candidate is either scored or has a
581        // stated reason.
582        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            // Every candidate carries all three metrics regardless of which
645            // one decided.
646            for c in &chosen.candidates {
647                assert!(c.rmse.is_finite() && c.mae.is_finite());
648            }
649        }
650    }
651}