Skip to main content

u_analytics/smoothing/
holt.rs

1//! Holt's Linear (Double Exponential) Smoothing.
2//!
3//! Extends simple exponential smoothing with a trend component for
4//! non-stationary time series that exhibit a linear trend.
5//!
6//! # Algorithm
7//!
8//! ```text
9//! Level:   L_t = α x_t + (1 - α)(L_{t-1} + T_{t-1})
10//! Trend:   T_t = β (L_t - L_{t-1}) + (1 - β) T_{t-1}
11//! Forecast: F_{t+h} = L_t + h T_t
12//! ```
13//!
14//! where α ∈ (0, 1) is the level smoothing constant and
15//! β ∈ (0, 1) is the trend smoothing constant.
16//!
17//! # Reference
18//!
19//! Holt, C.C. (1957). "Forecasting Seasonals and Trends by
20//! Exponentially Weighted Moving Averages", ONR Memo 52.
21
22use super::forecast::{
23    first_passage, one_step_mse, passage_arguments, residuals, valid_level, with_intervals,
24    FirstPassage, InnovationWeights, SmoothingForecast,
25};
26
27/// Result of Holt's linear smoothing at each time step.
28#[derive(Debug, Clone)]
29pub struct HoltResult {
30    /// Level estimates.
31    pub level: Vec<f64>,
32    /// Trend estimates.
33    pub trend: Vec<f64>,
34    /// Fitted values (level + trend from previous step).
35    pub fitted: Vec<f64>,
36    /// The level smoothing constant α.
37    pub alpha: f64,
38    /// The trend smoothing constant β.
39    pub beta: f64,
40}
41
42impl HoltResult {
43    /// The index of the first one-step error the smoother learns from: the
44    /// initial level and trend (`data[0]`, `data[1] − data[0]`) reproduce the
45    /// first two observations.
46    pub const FIRST_ERROR: usize = 2;
47
48    /// Returns a forecast h steps ahead from the last observation.
49    pub fn forecast(&self, h: usize) -> f64 {
50        let last_l = *self.level.last().expect("level must be non-empty");
51        let last_t = *self.trend.last().expect("trend must be non-empty");
52        last_l + h as f64 * last_t
53    }
54
55    /// `data[t] − fitted[t]`, `None` at the initialisation.
56    pub fn residuals(&self, data: &[f64]) -> Vec<Option<f64>> {
57        residuals(data, &self.fitted, Self::FIRST_ERROR)
58    }
59
60    /// Forecasts 1..=`horizon` steps ahead with the prediction interval of
61    /// ETS(A,A,N) at two-sided `level` ([module documentation](super#prediction-intervals)); `data` is the
62    /// series smoothed (for σ). `None` when `level` is not in (0, 1).
63    pub fn forecasts(
64        &self,
65        data: &[f64],
66        horizon: usize,
67        level: f64,
68    ) -> Option<Vec<SmoothingForecast>> {
69        if !valid_level(level) {
70            return None;
71        }
72        let weights = self.weights();
73        let c = move |j: usize| weights.c(j);
74        Some(with_intervals(
75            horizon,
76            level,
77            one_step_mse(data, &self.fitted, Self::FIRST_ERROR),
78            Some(&c),
79            |h| self.forecast(h),
80        ))
81    }
82    /// The innovation weights of the model: the Holt weights `c_j = α (1 + j β)`.
83    fn weights(&self) -> InnovationWeights {
84        InnovationWeights {
85            level: self.alpha,
86            slope: self.alpha * self.beta,
87            season: 0.0,
88            period: 0,
89        }
90    }
91
92    /// When the forecast total over steps 1, 2, … first reaches `threshold`
93    /// (non-zero; from below when positive, from above when negative), over
94    /// `paths` simulated futures of the fitted model seeded with `seed`, within
95    /// `horizon` steps -- with the `quantiles` (each in (0, 1]) of that time
96    /// ([`FirstPassage`]). `data` is the series smoothed (for σ). `None` when
97    /// an argument is outside those ranges or there is no one-step error to
98    /// estimate σ from.
99    #[allow(clippy::too_many_arguments)]
100    pub fn first_passage(
101        &self,
102        data: &[f64],
103        horizon: usize,
104        threshold: f64,
105        quantiles: &[f64],
106        paths: usize,
107        seed: u64,
108    ) -> Option<FirstPassage> {
109        if !passage_arguments(horizon, threshold, quantiles, paths) {
110            return None;
111        }
112        let mse = one_step_mse(data, &self.fitted, Self::FIRST_ERROR)?;
113        let means: Vec<f64> = (1..=horizon).map(|h| self.forecast(h)).collect();
114        let weights = self.weights();
115        Some(first_passage(
116            &means, mse, weights, threshold, quantiles, paths, seed,
117        ))
118    }
119}
120
121/// Holt's Linear Exponential Smoothing.
122pub struct HoltLinear {
123    alpha: f64,
124    beta: f64,
125}
126
127impl HoltLinear {
128    /// Creates a new Holt smoother.
129    ///
130    /// # Parameters
131    /// - `alpha`: level smoothing constant ∈ (0, 1)
132    /// - `beta`: trend smoothing constant ∈ (0, 1)
133    ///
134    /// Returns `None` if parameters are out of range.
135    pub fn new(alpha: f64, beta: f64) -> Option<Self> {
136        if !alpha.is_finite() || alpha <= 0.0 || alpha >= 1.0 {
137            return None;
138        }
139        if !beta.is_finite() || beta <= 0.0 || beta >= 1.0 {
140            return None;
141        }
142        Some(Self { alpha, beta })
143    }
144
145    /// The smoother whose constants minimise the sum of squared one-step
146    /// errors on `data` (from [`HoltResult::FIRST_ERROR`];
147    /// [module documentation](super#estimating-the-constants)); a constant given is held, one left `None` is
148    /// estimated.
149    ///
150    /// Returns `None` with fewer than 3 values, a non-finite one, or a given
151    /// constant outside (0, 1).
152    pub fn fit(data: &[f64], alpha: Option<f64>, beta: Option<f64>) -> Option<Self> {
153        if data.len() < 3 || data.iter().any(|v| !v.is_finite()) {
154            return None;
155        }
156        for c in [alpha, beta].into_iter().flatten() {
157            Self::new(c, c)?;
158        }
159        let c = super::estimate::least_squares(&[alpha, beta], |c| {
160            let r = Self {
161                alpha: c[0],
162                beta: c[1],
163            }
164            .smooth(data)
165            .expect("at least two values");
166            super::estimate::one_step_sse(data, &r.fitted, HoltResult::FIRST_ERROR)
167        });
168        Self::new(c[0], c[1])
169    }
170
171    /// Returns α.
172    pub fn alpha(&self) -> f64 {
173        self.alpha
174    }
175
176    /// Returns β.
177    pub fn beta(&self) -> f64 {
178        self.beta
179    }
180
181    /// Applies Holt's linear smoothing to the data.
182    ///
183    /// Requires at least 2 data points.
184    /// Returns `None` if data has fewer than 2 points.
185    pub fn smooth(&self, data: &[f64]) -> Option<HoltResult> {
186        if data.len() < 2 {
187            return None;
188        }
189
190        let n = data.len();
191        let mut level = Vec::with_capacity(n);
192        let mut trend = Vec::with_capacity(n);
193        let mut fitted = Vec::with_capacity(n);
194
195        // Initialize
196        let l0 = data[0];
197        let t0 = data[1] - data[0];
198        level.push(l0);
199        trend.push(t0);
200        fitted.push(l0); // first fitted = initial level
201
202        for i in 1..n {
203            let l_prev = level[i - 1];
204            let t_prev = trend[i - 1];
205
206            let l = self.alpha * data[i] + (1.0 - self.alpha) * (l_prev + t_prev);
207            let t = self.beta * (l - l_prev) + (1.0 - self.beta) * t_prev;
208
209            fitted.push(l_prev + t_prev); // one-step-ahead forecast
210            level.push(l);
211            trend.push(t);
212        }
213
214        Some(HoltResult {
215            level,
216            trend,
217            fitted,
218            alpha: self.alpha,
219            beta: self.beta,
220        })
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn test_holt_linear_trend() {
230        // Linear data: 10, 12, 14, 16, 18
231        let holt = HoltLinear::new(0.5, 0.5).unwrap();
232        let data = [10.0, 12.0, 14.0, 16.0, 18.0];
233        let result = holt.smooth(&data).unwrap();
234
235        // Forecast should be close to 20 for h=1
236        let f1 = result.forecast(1);
237        assert!((f1 - 20.0).abs() < 2.0, "forecast(1) = {f1}, expected ~20");
238    }
239
240    #[test]
241    fn test_holt_constant_series() {
242        let holt = HoltLinear::new(0.3, 0.3).unwrap();
243        let data = [5.0; 20];
244        let result = holt.smooth(&data).unwrap();
245
246        // Trend should converge to ~0
247        let last_trend = *result.trend.last().unwrap();
248        assert!(
249            last_trend.abs() < 0.1,
250            "trend should be near 0, got {last_trend}"
251        );
252    }
253
254    #[test]
255    fn test_holt_forecast_multi_step() {
256        let holt = HoltLinear::new(0.5, 0.5).unwrap();
257        let data = [10.0, 12.0, 14.0, 16.0, 18.0];
258        let result = holt.smooth(&data).unwrap();
259
260        let f1 = result.forecast(1);
261        let f3 = result.forecast(3);
262        let f5 = result.forecast(5);
263
264        // Multi-step forecasts should be in order
265        assert!(f1 < f3);
266        assert!(f3 < f5);
267    }
268
269    #[test]
270    fn test_holt_minimum_data() {
271        let holt = HoltLinear::new(0.5, 0.5).unwrap();
272        let result = holt.smooth(&[10.0, 15.0]).unwrap();
273        assert_eq!(result.level.len(), 2);
274        assert_eq!(result.fitted.len(), 2);
275    }
276
277    #[test]
278    fn test_holt_insufficient_data() {
279        let holt = HoltLinear::new(0.5, 0.5).unwrap();
280        assert!(holt.smooth(&[10.0]).is_none());
281        assert!(holt.smooth(&[]).is_none());
282    }
283
284    #[test]
285    fn test_holt_invalid_params() {
286        assert!(HoltLinear::new(0.0, 0.5).is_none());
287        assert!(HoltLinear::new(1.0, 0.5).is_none());
288        assert!(HoltLinear::new(0.5, 0.0).is_none());
289        assert!(HoltLinear::new(0.5, 1.0).is_none());
290        assert!(HoltLinear::new(f64::NAN, 0.5).is_none());
291    }
292
293    #[test]
294    fn test_holt_fitted_values() {
295        let holt = HoltLinear::new(0.5, 0.5).unwrap();
296        let data = [10.0, 12.0, 14.0, 16.0];
297        let result = holt.smooth(&data).unwrap();
298
299        assert_eq!(result.fitted.len(), data.len());
300        // First fitted = initial level
301        assert!((result.fitted[0] - 10.0).abs() < 1e-10);
302    }
303}