Skip to main content

u_analytics/smoothing/
forecast.rs

1//! Forecasts of the smoothers with their prediction intervals.
2//!
3//! The additive smoothers are the exponential smoothing state-space models
4//! with additive errors -- SES is ETS(A,N,N), Holt ETS(A,A,N), additive
5//! Holt-Winters ETS(A,A,A) -- whose h-step forecast variance has a closed form
6//! (Hyndman, Koehler, Ord & Snyder 2008, Table 6.1, class 1):
7//!
8//! ```text
9//! Var(h) = σ² (1 + Σ_{j=1}^{h-1} c_j²)
10//! SES:          c_j = α
11//! Holt:         c_j = α (1 + j β)
12//! Holt-Winters: c_j = α (1 + j β) + γ (1 − α) [j mod m = 0]
13//! ```
14//!
15//! with the smoothing constants of the component form used here (the
16//! state-space form's trend and seasonal constants are αβ and γ(1 − α)), and σ²
17//! the mean squared one-step error over the observations the smoother updates
18//! on (not those its initialisation reproduces). Multiplicative seasonality has
19//! no such closed form (class 3), so its forecasts carry no interval.
20//!
21//! # Reference
22//!
23//! Hyndman, R.J., Koehler, A.B., Ord, J.K. & Snyder, R.D. (2008).
24//! *Forecasting with Exponential Smoothing: The State Space Approach*.
25//! Springer, chapter 6.
26
27/// One forecast of a smoother: `step` periods after the last observation.
28#[derive(Debug, Clone, Copy, PartialEq)]
29pub struct SmoothingForecast {
30    /// Steps ahead, from 1.
31    pub step: usize,
32    /// The point forecast.
33    pub mean: f64,
34    /// Lower end of the prediction interval; `None` when the smoother has no
35    /// closed-form interval (multiplicative seasonality) or no one-step errors
36    /// to estimate σ from.
37    pub lower: Option<f64>,
38    /// Upper end of the prediction interval.
39    pub upper: Option<f64>,
40}
41
42/// Mean squared one-step error `data[t] − fitted[t]` for `t >= first`, or
43/// `None` when there is none.
44pub(crate) fn one_step_mse(data: &[f64], fitted: &[f64], first: usize) -> Option<f64> {
45    let errors: Vec<f64> = data
46        .iter()
47        .zip(fitted)
48        .skip(first)
49        .map(|(x, f)| x - f)
50        .collect();
51    (!errors.is_empty()).then(|| errors.iter().map(|e| e * e).sum::<f64>() / errors.len() as f64)
52}
53
54/// The one-step errors from `first` on, `None` before it (where the fitted
55/// value is the initialisation).
56pub(crate) fn residuals(data: &[f64], fitted: &[f64], first: usize) -> Vec<Option<f64>> {
57    data.iter()
58        .zip(fitted)
59        .enumerate()
60        .map(|(t, (x, f))| (t >= first).then(|| x - f))
61        .collect()
62}
63
64/// Forecasts `mean(h)` for h = 1..=horizon with the class-1 interval at
65/// two-sided `level` when `mse` and `c` are given.
66pub(crate) fn with_intervals(
67    horizon: usize,
68    level: f64,
69    mse: Option<f64>,
70    c: Option<&dyn Fn(usize) -> f64>,
71    mean: impl Fn(usize) -> f64,
72) -> Vec<SmoothingForecast> {
73    let z = u_numflow::special::inverse_normal_cdf(0.5 + level / 2.0);
74    let mut sum_c2 = 0.0;
75    (1..=horizon)
76        .map(|h| {
77            let m = mean(h);
78            let half = match (mse, c) {
79                (Some(mse), Some(c)) => {
80                    if h > 1 {
81                        sum_c2 += c(h - 1).powi(2);
82                    }
83                    Some(z * (mse * (1.0 + sum_c2)).sqrt())
84                }
85                _ => None,
86            };
87            SmoothingForecast {
88                step: h,
89                mean: m,
90                lower: half.map(|w| m - w),
91                upper: half.map(|w| m + w),
92            }
93        })
94        .collect()
95}
96
97/// `level` strictly inside (0, 1).
98pub(crate) fn valid_level(level: f64) -> bool {
99    level > 0.0 && level < 1.0
100}
101
102/// The fitted values, forecasts and prediction intervals of the additive
103/// smoothers against statsmodels 0.15.0 `ETSModel(..., error="add",
104/// initialization_method="known").smooth(params)` with the same initial states
105/// (`get_prediction(...).summary_frame(alpha=0.1)`), on seeded series rounded to
106/// 3 places: a random walk (SES), a noisy line (Holt) and a period-4 seasonal
107/// line (Holt-Winters), six steps ahead.
108#[cfg(test)]
109#[allow(clippy::approx_constant)] // seeded data, not mathematical constants
110mod statsmodels_reference {
111    use super::*;
112    use crate::smoothing::{
113        HoltLinear, HoltResult, HoltWinters, Seasonality, SesResult, SimpleExponentialSmoothing,
114    };
115
116    // Generated by ets_ref.py - statsmodels 0.15.0, seed 20261028.
117    const SES_DATA: &[f64] = &[
118        49.695, 48.755, 48.385, 48.678, 47.146, 48.813, 49.806, 49.983, 50.957, 49.731, 48.969,
119        49.761, 48.5, 49.557, 50.796, 51.74, 52.288, 55.501, 55.645, 55.541, 55.655, 55.734,
120        56.497, 55.544, 53.348, 52.352, 53.479, 52.5, 53.481, 55.59,
121    ];
122    const SES_FITTED: &[f64] = &[
123        49.695,
124        49.366,
125        49.02265,
126        48.9020225,
127        48.287414625000004,
128        48.47136950625,
129        48.9384901790625,
130        49.30406861639062,
131        49.8825946006539,
132        49.82953649042504,
133        49.52834871877628,
134        49.609776667204585,
135        49.22135483368298,
136        49.338830641893935,
137        49.848839917231054,
138        50.51074594620019,
139        51.13278486503012,
140        52.66166016226957,
141        53.70582910547522,
142        54.34813891855889,
143        54.80554029706328,
144        55.130501193091135,
145        55.60877577550924,
146        55.586104254081,
147        54.80276776515265,
148        53.94499904734923,
149        53.781899380777,
150        53.33323459750505,
151        53.38495248837828,
152    ];
153    const SES_MEAN: &[f64] = &[
154        54.15671911744588,
155        54.15671911744588,
156        54.15671911744588,
157        54.15671911744588,
158        54.15671911744588,
159        54.15671911744588,
160    ];
161    const SES_LOWER: &[f64] = &[
162        51.50271152380141,
163        51.344848484798455,
164        51.195388909183464,
165        51.053118506866284,
166        50.917089987805795,
167        50.786547453385566,
168    ];
169    const SES_UPPER: &[f64] = &[
170        56.81072671109035,
171        56.96858975009331,
172        57.1180493257083,
173        57.26031972802548,
174        57.39634824708597,
175        57.5268907815062,
176    ];
177    const HOLT_DATA: &[f64] = &[
178        18.624, 22.028, 22.022, 22.77, 23.168, 25.438, 23.856, 26.135, 25.839, 25.885, 27.652,
179        29.208, 30.853, 32.14, 31.311, 31.097, 32.108, 31.913, 36.539, 34.939, 34.931, 36.448,
180        37.208, 36.854, 38.229, 38.696, 41.158, 43.842, 42.405, 41.971,
181    ];
182    const HOLT_FITTED: &[f64] = &[
183        25.432,
184        27.199199999999998,
185        28.204383999999997,
186        28.563783679999997,
187        29.4373607936,
188        28.882198198272,
189        29.24092478521344,
190        29.065606754561227,
191        28.724367395805,
192        29.140634388886866,
193        30.018183833625034,
194        31.269498793777935,
195        32.60472786636744,
196        32.97076708061175,
197        32.954889242709385,
198        33.28201140055122,
199        33.29076378321223,
200        35.406274110151855,
201        35.99819837750347,
202        36.26477706771417,
203        37.04618211642346,
204        37.83197057633515,
205        38.08360600617535,
206        38.796218783585445,
207        39.402568947344676,
208        40.89161352981263,
209        43.094671196908386,
210        43.78653210141317,
211    ];
212    const HOLT_MEAN: &[f64] = &[
213        43.88280607600299,
214        44.70529289115808,
215        45.52777970631317,
216        46.35026652146826,
217        47.172753336623344,
218        47.99524015177843,
219    ];
220    const HOLT_LOWER: &[f64] = &[
221        39.881805953904774,
222        40.26724764211682,
223        40.55622511518835,
224        40.758018971303315,
225        40.88214280288195,
226        40.93713118743697,
227    ];
228    const HOLT_UPPER: &[f64] = &[
229        47.88380619810121,
230        49.14333814019934,
231        50.49933429743799,
232        51.9425140716332,
233        53.46336387036474,
234        55.0533491161199,
235    ];
236    const HW_DATA: &[f64] = &[
237        107.195, 99.797, 99.111, 104.953, 111.536, 103.86, 105.869, 111.842, 117.337, 112.456,
238        110.451, 115.115, 124.234, 116.183, 117.719, 123.434, 127.431, 122.666, 123.802, 130.548,
239        135.536, 127.843, 129.797, 133.84, 142.645, 133.886, 134.436, 141.34, 148.881, 140.176,
240        141.191, 145.134,
241    ];
242    const HW_FITTED: &[f64] = &[
243        108.57318749999999,
244        103.53110312499999,
245        104.42071096874999,
246        112.21758513031247,
247        116.37452167699682,
248        110.342324187491,
249        112.08772508093094,
250        118.67183234829378,
251        122.04503373510259,
252        116.55551472827399,
253        116.797425126349,
254        123.78843943015733,
255        129.19938105911905,
256        122.02718587096355,
257        122.78208766197088,
258        129.56537788125988,
259        135.15618721562316,
260        129.1403441795491,
261        129.4136407564024,
262        136.01289275800133,
263        140.4703440485413,
264        134.67208484162995,
265        135.38085344677242,
266        141.08242228813216,
267        147.0508746050361,
268        140.6418295369387,
269        141.43920083128222,
270        147.6014010922094,
271    ];
272    const HW_MEAN: &[f64] = &[
273        152.98701643046797,
274        145.53169868698552,
275        146.42543580819583,
276        152.19971424007915,
277        158.7573450971372,
278        151.30202735365475,
279    ];
280    const HW_LOWER: &[f64] = &[
281        150.4614216897758,
282        142.8721382966387,
283        143.61475408277198,
284        149.22143937008624,
285        155.42143213962436,
286        147.77782707686427,
287    ];
288    const HW_UPPER: &[f64] = &[
289        155.51261117116016,
290        148.19125907733235,
291        149.23611753361968,
292        155.17798911007205,
293        162.09325805465005,
294        154.82622763044523,
295    ];
296
297    fn close(got: f64, want: f64, what: &str) {
298        let tol = 1e-9 * want.abs().max(1.0);
299        assert!((got - want).abs() <= tol, "{what}: {got} vs {want}");
300    }
301
302    fn compare(
303        fitted: &[f64],
304        first: usize,
305        forecasts: &[SmoothingForecast],
306        want_fitted: &[f64],
307        want: [&[f64]; 3],
308    ) {
309        assert_eq!(fitted.len() - first, want_fitted.len());
310        for (got, want) in fitted[first..].iter().zip(want_fitted) {
311            close(*got, *want, "fitted");
312        }
313        for (i, f) in forecasts.iter().enumerate() {
314            close(f.mean, want[0][i], "mean");
315            close(f.lower.expect("interval"), want[1][i], "lower");
316            close(f.upper.expect("interval"), want[2][i], "upper");
317        }
318    }
319
320    #[test]
321    fn ses_matches_ets_ann() {
322        let r = SimpleExponentialSmoothing::new(0.35)
323            .unwrap()
324            .smooth(SES_DATA)
325            .unwrap();
326        let f = r.forecasts(SES_DATA, 6, 0.9).unwrap();
327        compare(
328            &r.fitted,
329            SesResult::FIRST_ERROR,
330            &f,
331            SES_FITTED,
332            [SES_MEAN, SES_LOWER, SES_UPPER],
333        );
334    }
335
336    #[test]
337    fn holt_matches_ets_aan() {
338        let r = HoltLinear::new(0.4, 0.2)
339            .unwrap()
340            .smooth(HOLT_DATA)
341            .unwrap();
342        let f = r.forecasts(HOLT_DATA, 6, 0.9).unwrap();
343        compare(
344            &r.fitted,
345            HoltResult::FIRST_ERROR,
346            &f,
347            HOLT_FITTED,
348            [HOLT_MEAN, HOLT_LOWER, HOLT_UPPER],
349        );
350    }
351
352    #[test]
353    fn additive_holt_winters_matches_ets_aaa() {
354        let r = HoltWinters::new(0.3, 0.1, 0.25, 4, Seasonality::Additive)
355            .unwrap()
356            .smooth(HW_DATA)
357            .unwrap();
358        let f = r.forecasts(HW_DATA, 6, 0.9).unwrap();
359        compare(
360            &r.fitted,
361            r.first_error(),
362            &f,
363            HW_FITTED,
364            [HW_MEAN, HW_LOWER, HW_UPPER],
365        );
366    }
367}