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