Skip to main content

u_analytics/smoothing/
holt_winters.rs

1//! Holt-Winters Triple Exponential Smoothing.
2//!
3//! Extends Holt's method with a seasonal component, supporting both
4//! additive and multiplicative seasonality.
5//!
6//! # Algorithm (Additive)
7//!
8//! ```text
9//! Level:    L_t = α (x_t - S_{t-m}) + (1 - α)(L_{t-1} + T_{t-1})
10//! Trend:    T_t = β (L_t - L_{t-1}) + (1 - β) T_{t-1}
11//! Season:   S_t = γ (x_t - L_t) + (1 - γ) S_{t-m}
12//! Forecast: F_{t+h} = L_t + h T_t + S_{t-m+h_m}
13//! ```
14//!
15//! # Algorithm (Multiplicative)
16//!
17//! ```text
18//! Level:    L_t = α (x_t / S_{t-m}) + (1 - α)(L_{t-1} + T_{t-1})
19//! Trend:    T_t = β (L_t - L_{t-1}) + (1 - β) T_{t-1}
20//! Season:   S_t = γ (x_t / L_t) + (1 - γ) S_{t-m}
21//! Forecast: F_{t+h} = (L_t + h T_t) S_{t-m+h_m}
22//! ```
23//!
24//! # Parameters
25//!
26//! - α ∈ (0, 1): level smoothing
27//! - β ∈ (0, 1): trend smoothing
28//! - γ ∈ (0, 1): seasonal smoothing
29//! - m: seasonal period (e.g., 12 for monthly data with yearly cycle)
30//!
31//! # Reference
32//!
33//! Winters, P.R. (1960). "Forecasting Sales by Exponentially Weighted
34//! Moving Averages", *Management Science* 6(3), pp. 324-342.
35
36use super::forecast::{
37    first_passage, one_step_mse, passage_arguments, residuals, valid_level, with_intervals,
38    FirstPassage, InnovationWeights, SmoothingForecast,
39};
40
41/// Seasonality type.
42#[derive(Debug, Clone, Copy, PartialEq)]
43pub enum Seasonality {
44    /// Additive: seasonal effect is added to the trend.
45    Additive,
46    /// Multiplicative: seasonal effect multiplies the trend.
47    Multiplicative,
48}
49
50/// Result of Holt-Winters smoothing.
51#[derive(Debug, Clone)]
52pub struct HoltWintersResult {
53    /// Level estimates.
54    pub level: Vec<f64>,
55    /// Trend estimates.
56    pub trend: Vec<f64>,
57    /// Seasonal components, one per observation (the first cycle's are the
58    /// initial ones).
59    pub seasonal: Vec<f64>,
60    /// Fitted values (one-step-ahead in-sample forecasts); over the first
61    /// cycle they are the initialisation, which reproduces the data.
62    pub fitted: Vec<f64>,
63    /// The level smoothing constant α.
64    pub alpha: f64,
65    /// The trend smoothing constant β.
66    pub beta: f64,
67    /// The seasonal smoothing constant γ.
68    pub gamma: f64,
69    /// The seasonal period m.
70    pub period: usize,
71    /// Additive or multiplicative.
72    pub seasonality: Seasonality,
73}
74
75impl HoltWintersResult {
76    /// Returns a forecast h steps ahead (from 1) of the last observation.
77    pub fn forecast(&self, h: usize) -> f64 {
78        let last_l = *self.level.last().expect("level must be non-empty");
79        let last_t = *self.trend.last().expect("trend must be non-empty");
80
81        // Seasonal factor: use the most recent completed cycle
82        let s_len = self.seasonal.len();
83        let idx = s_len - self.period + ((h - 1) % self.period);
84        let s = self.seasonal[idx];
85
86        match self.seasonality {
87            Seasonality::Additive => last_l + h as f64 * last_t + s,
88            Seasonality::Multiplicative => (last_l + h as f64 * last_t) * s,
89        }
90    }
91
92    /// The index of the first one-step error the smoother learns from: the
93    /// first cycle is the initialisation.
94    pub fn first_error(&self) -> usize {
95        self.period
96    }
97
98    /// `data[t] − fitted[t]`, `None` over the first cycle.
99    pub fn residuals(&self, data: &[f64]) -> Vec<Option<f64>> {
100        residuals(data, &self.fitted, self.first_error())
101    }
102
103    /// Forecasts 1..=`horizon` steps ahead at two-sided `level`, with the
104    /// prediction interval of ETS(A,A,A) when the seasonality is additive and
105    /// none when it is multiplicative ([module documentation](super#prediction-intervals)); `data` is the
106    /// series smoothed (for σ). `None` when `level` is not in (0, 1).
107    pub fn forecasts(
108        &self,
109        data: &[f64],
110        horizon: usize,
111        level: f64,
112    ) -> Option<Vec<SmoothingForecast>> {
113        if !valid_level(level) {
114            return None;
115        }
116        let weights = self.weights();
117        let c = move |j: usize| weights.c(j);
118        let c: Option<&dyn Fn(usize) -> f64> = match self.seasonality {
119            Seasonality::Additive => Some(&c),
120            Seasonality::Multiplicative => None,
121        };
122        Some(with_intervals(
123            horizon,
124            level,
125            one_step_mse(data, &self.fitted, self.first_error()),
126            c,
127            |h| self.forecast(h),
128        ))
129    }
130    /// The innovation weights of the model: the additive Holt-Winters weights `c_j = α (1 + j β) + γ (1 − α) [j mod m = 0]`.
131    fn weights(&self) -> InnovationWeights {
132        InnovationWeights {
133            level: self.alpha,
134            slope: self.alpha * self.beta,
135            season: self.gamma * (1.0 - self.alpha),
136            period: self.period,
137        }
138    }
139
140    /// When the forecast total over steps 1, 2, … first reaches `threshold`
141    /// (non-zero; from below when positive, from above when negative), over
142    /// `paths` simulated futures of the fitted model seeded with `seed`, within
143    /// `horizon` steps -- with the `quantiles` (each in (0, 1]) of that time
144    /// ([`FirstPassage`]). `data` is the series smoothed (for σ). `None` when
145    /// an argument is outside those ranges or there is no one-step error to
146    /// estimate σ from, or the seasonality is multiplicative (no additive innovations to simulate).
147    #[allow(clippy::too_many_arguments)]
148    pub fn first_passage(
149        &self,
150        data: &[f64],
151        horizon: usize,
152        threshold: f64,
153        quantiles: &[f64],
154        paths: usize,
155        seed: u64,
156    ) -> Option<FirstPassage> {
157        if !passage_arguments(horizon, threshold, quantiles, paths) {
158            return None;
159        }
160        if self.seasonality == Seasonality::Multiplicative {
161            return None;
162        }
163        let mse = one_step_mse(data, &self.fitted, self.first_error())?;
164        let means: Vec<f64> = (1..=horizon).map(|h| self.forecast(h)).collect();
165        let weights = self.weights();
166        Some(first_passage(
167            &means, mse, weights, threshold, quantiles, paths, seed,
168        ))
169    }
170}
171
172/// Holt-Winters Triple Exponential Smoothing.
173pub struct HoltWinters {
174    alpha: f64,
175    beta: f64,
176    gamma: f64,
177    period: usize,
178    seasonality: Seasonality,
179}
180
181impl HoltWinters {
182    /// Creates a new Holt-Winters smoother.
183    ///
184    /// # Parameters
185    /// - `alpha`: level smoothing constant ∈ (0, 1)
186    /// - `beta`: trend smoothing constant ∈ (0, 1)
187    /// - `gamma`: seasonal smoothing constant ∈ (0, 1)
188    /// - `period`: seasonal period (must be ≥ 2)
189    /// - `seasonality`: additive or multiplicative
190    ///
191    /// Returns `None` if parameters are invalid.
192    pub fn new(
193        alpha: f64,
194        beta: f64,
195        gamma: f64,
196        period: usize,
197        seasonality: Seasonality,
198    ) -> Option<Self> {
199        if !alpha.is_finite() || alpha <= 0.0 || alpha >= 1.0 {
200            return None;
201        }
202        if !beta.is_finite() || beta <= 0.0 || beta >= 1.0 {
203            return None;
204        }
205        if !gamma.is_finite() || gamma <= 0.0 || gamma >= 1.0 {
206            return None;
207        }
208        if period < 2 {
209            return None;
210        }
211        Some(Self {
212            alpha,
213            beta,
214            gamma,
215            period,
216            seasonality,
217        })
218    }
219
220    /// The smoother whose constants minimise the sum of squared one-step
221    /// errors on `data` (from the second cycle; [module documentation](super#estimating-the-constants)); a
222    /// constant given is held, one left `None` is estimated.
223    ///
224    /// Returns `None` when [`HoltWinters::new`] would refuse the given
225    /// constants or period, or [`HoltWinters::smooth`] the data.
226    pub fn fit(
227        data: &[f64],
228        alpha: Option<f64>,
229        beta: Option<f64>,
230        gamma: Option<f64>,
231        period: usize,
232        seasonality: Seasonality,
233    ) -> Option<Self> {
234        let probe = Self::new(
235            alpha.unwrap_or(0.5),
236            beta.unwrap_or(0.5),
237            gamma.unwrap_or(0.5),
238            period,
239            seasonality,
240        )?;
241        probe.smooth(data)?;
242        let c = super::estimate::least_squares(&[alpha, beta, gamma], |c| {
243            let r = Self {
244                alpha: c[0],
245                beta: c[1],
246                gamma: c[2],
247                period,
248                seasonality,
249            }
250            .smooth(data)
251            .expect("checked by the probe above");
252            super::estimate::one_step_sse(data, &r.fitted, period)
253        });
254        Self::new(c[0], c[1], c[2], period, seasonality)
255    }
256
257    /// Returns the seasonal period.
258    pub fn period(&self) -> usize {
259        self.period
260    }
261
262    /// Returns the seasonality type.
263    pub fn seasonality(&self) -> Seasonality {
264        self.seasonality
265    }
266
267    /// Applies Holt-Winters smoothing to the data.
268    ///
269    /// Requires at least `2 * period` data points for initialization.
270    /// Returns `None` if data is insufficient or if multiplicative
271    /// seasonality is used with non-positive data.
272    pub fn smooth(&self, data: &[f64]) -> Option<HoltWintersResult> {
273        let m = self.period;
274        let n = data.len();
275
276        if n < 2 * m {
277            return None;
278        }
279
280        // Multiplicative: all data must be positive
281        if self.seasonality == Seasonality::Multiplicative && data.iter().any(|&x| x <= 0.0) {
282            return None;
283        }
284
285        // --- Initialization ---
286        let l0: f64 = data[..m].iter().sum::<f64>() / m as f64;
287        let t0: f64 = (0..m)
288            .map(|i| (data[m + i] - data[i]) / m as f64)
289            .sum::<f64>()
290            / m as f64;
291
292        // seasonal[i] = seasonal factor for time i
293        let mut seasonal = vec![0.0; n];
294        match self.seasonality {
295            Seasonality::Additive => {
296                for i in 0..m {
297                    seasonal[i] = data[i] - l0;
298                }
299            }
300            Seasonality::Multiplicative => {
301                for i in 0..m {
302                    seasonal[i] = data[i] / l0;
303                }
304            }
305        }
306
307        let mut level = vec![0.0; n];
308        let mut trend = vec![0.0; n];
309        let mut fitted = vec![0.0; n];
310
311        // Set initial values for times 0..m-1
312        for i in 0..m {
313            level[i] = l0;
314            trend[i] = t0;
315            fitted[i] = match self.seasonality {
316                Seasonality::Additive => l0 + seasonal[i],
317                Seasonality::Multiplicative => l0 * seasonal[i],
318            };
319        }
320
321        // Main smoothing loop
322        for t in m..n {
323            let s_prev = seasonal[t - m];
324
325            let l = match self.seasonality {
326                Seasonality::Additive => {
327                    self.alpha * (data[t] - s_prev)
328                        + (1.0 - self.alpha) * (level[t - 1] + trend[t - 1])
329                }
330                Seasonality::Multiplicative => {
331                    self.alpha * (data[t] / s_prev)
332                        + (1.0 - self.alpha) * (level[t - 1] + trend[t - 1])
333                }
334            };
335
336            let b = self.beta * (l - level[t - 1]) + (1.0 - self.beta) * trend[t - 1];
337
338            let s = match self.seasonality {
339                Seasonality::Additive => self.gamma * (data[t] - l) + (1.0 - self.gamma) * s_prev,
340                Seasonality::Multiplicative => {
341                    self.gamma * (data[t] / l) + (1.0 - self.gamma) * s_prev
342                }
343            };
344
345            level[t] = l;
346            trend[t] = b;
347            seasonal[t] = s;
348
349            // One-step-ahead fitted value
350            fitted[t] = match self.seasonality {
351                Seasonality::Additive => level[t - 1] + trend[t - 1] + s_prev,
352                Seasonality::Multiplicative => (level[t - 1] + trend[t - 1]) * s_prev,
353            };
354        }
355
356        Some(HoltWintersResult {
357            level,
358            trend,
359            seasonal,
360            fitted,
361            alpha: self.alpha,
362            beta: self.beta,
363            gamma: self.gamma,
364            period: m,
365            seasonality: self.seasonality,
366        })
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    fn seasonal_additive_data() -> Vec<f64> {
375        // Base trend: 100 + 2*t with seasonal pattern [10, -5, -5, 0]
376        let pattern = [10.0, -5.0, -5.0, 0.0];
377        (0..24)
378            .map(|t| 100.0 + 2.0 * t as f64 + pattern[t % 4])
379            .collect()
380    }
381
382    fn seasonal_multiplicative_data() -> Vec<f64> {
383        // Base trend: 100 + 2*t with multiplicative seasonal [1.2, 0.8, 0.9, 1.1]
384        let pattern = [1.2, 0.8, 0.9, 1.1];
385        (0..24)
386            .map(|t| (100.0 + 2.0 * t as f64) * pattern[t % 4])
387            .collect()
388    }
389
390    #[test]
391    fn test_hw_additive_basic() {
392        let data = seasonal_additive_data();
393        let hw = HoltWinters::new(0.3, 0.1, 0.3, 4, Seasonality::Additive).unwrap();
394        let result = hw.smooth(&data).unwrap();
395
396        assert_eq!(result.level.len(), 24);
397        assert_eq!(result.trend.len(), 24);
398        assert_eq!(result.seasonal.len(), 24);
399        assert_eq!(result.fitted.len(), 24);
400    }
401
402    #[test]
403    fn test_hw_additive_forecast() {
404        let data = seasonal_additive_data();
405        let hw = HoltWinters::new(0.3, 0.1, 0.3, 4, Seasonality::Additive).unwrap();
406        let result = hw.smooth(&data).unwrap();
407
408        // Forecast should continue the pattern
409        let f1 = result.forecast(1);
410        let f4 = result.forecast(4);
411
412        // Both should be in reasonable range
413        assert!(f1 > 100.0, "forecast(1) = {f1}");
414        assert!(f4 > f1 - 20.0, "forecast(4) = {f4}");
415    }
416
417    #[test]
418    fn test_hw_multiplicative_basic() {
419        let data = seasonal_multiplicative_data();
420        let hw = HoltWinters::new(0.3, 0.1, 0.3, 4, Seasonality::Multiplicative).unwrap();
421        let result = hw.smooth(&data).unwrap();
422
423        assert_eq!(result.level.len(), 24);
424        assert_eq!(result.fitted.len(), 24);
425    }
426
427    #[test]
428    fn test_hw_fitted_approximates_data() {
429        let data = seasonal_additive_data();
430        let hw = HoltWinters::new(0.5, 0.3, 0.5, 4, Seasonality::Additive).unwrap();
431        let result = hw.smooth(&data).unwrap();
432
433        // After warm-up, fitted should be close to data
434        let mape: f64 = (8..24)
435            .map(|i| ((result.fitted[i] - data[i]) / data[i]).abs())
436            .sum::<f64>()
437            / 16.0;
438
439        assert!(
440            mape < 0.10,
441            "mean absolute percentage error = {mape}, expected < 10%"
442        );
443    }
444
445    #[test]
446    fn test_hw_seasonal_pattern_detected() {
447        let data = seasonal_additive_data();
448        let hw = HoltWinters::new(0.3, 0.1, 0.5, 4, Seasonality::Additive).unwrap();
449        let result = hw.smooth(&data).unwrap();
450
451        // Last seasonal factors should reflect the pattern [10, -5, -5, 0]
452        let last_cycle: Vec<f64> = (20..24).map(|i| result.seasonal[i]).collect();
453
454        // Highest seasonal should be at position 0 mod 4 (pattern = +10)
455        let max_idx = last_cycle
456            .iter()
457            .enumerate()
458            .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
459            .unwrap()
460            .0;
461        assert_eq!(max_idx, 0, "highest seasonal at wrong position");
462    }
463
464    #[test]
465    fn test_hw_insufficient_data() {
466        let hw = HoltWinters::new(0.3, 0.1, 0.3, 4, Seasonality::Additive).unwrap();
467        // Need at least 2*period = 8 data points
468        assert!(hw.smooth(&[1.0; 7]).is_none());
469        assert!(hw.smooth(&[1.0; 8]).is_some());
470    }
471
472    #[test]
473    fn test_hw_multiplicative_rejects_negative() {
474        let hw = HoltWinters::new(0.3, 0.1, 0.3, 4, Seasonality::Multiplicative).unwrap();
475        let data = vec![1.0, 2.0, -1.0, 4.0, 5.0, 6.0, 7.0, 8.0];
476        assert!(hw.smooth(&data).is_none());
477    }
478
479    #[test]
480    fn test_hw_invalid_params() {
481        assert!(HoltWinters::new(0.0, 0.5, 0.5, 4, Seasonality::Additive).is_none());
482        assert!(HoltWinters::new(0.5, 1.0, 0.5, 4, Seasonality::Additive).is_none());
483        assert!(HoltWinters::new(0.5, 0.5, 0.0, 4, Seasonality::Additive).is_none());
484        assert!(HoltWinters::new(0.5, 0.5, 0.5, 1, Seasonality::Additive).is_none());
485    }
486
487    #[test]
488    fn test_hw_trend_detected() {
489        let data = seasonal_additive_data();
490        let hw = HoltWinters::new(0.3, 0.3, 0.3, 4, Seasonality::Additive).unwrap();
491        let result = hw.smooth(&data).unwrap();
492
493        // True trend is +2.0 per step; estimated should be positive
494        let last_trend = result.trend[23];
495        assert!(
496            last_trend > 1.0 && last_trend < 4.0,
497            "trend = {last_trend}, expected ~2.0"
498        );
499    }
500
501    #[test]
502    fn test_hw_level_tracks_mean() {
503        let data = seasonal_additive_data();
504        let hw = HoltWinters::new(0.3, 0.1, 0.3, 4, Seasonality::Additive).unwrap();
505        let result = hw.smooth(&data).unwrap();
506
507        // At t=23, true level ≈ 100 + 2*23 = 146
508        let last_level = result.level[23];
509        assert!(
510            (last_level - 146.0).abs() < 10.0,
511            "level = {last_level}, expected ~146"
512        );
513    }
514}