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::{one_step_mse, residuals, valid_level, with_intervals, SmoothingForecast};
23
24/// Result of Holt's linear smoothing at each time step.
25#[derive(Debug, Clone)]
26pub struct HoltResult {
27    /// Level estimates.
28    pub level: Vec<f64>,
29    /// Trend estimates.
30    pub trend: Vec<f64>,
31    /// Fitted values (level + trend from previous step).
32    pub fitted: Vec<f64>,
33    /// The level smoothing constant α.
34    pub alpha: f64,
35    /// The trend smoothing constant β.
36    pub beta: f64,
37}
38
39impl HoltResult {
40    /// The index of the first one-step error the smoother learns from: the
41    /// initial level and trend (`data[0]`, `data[1] − data[0]`) reproduce the
42    /// first two observations.
43    pub const FIRST_ERROR: usize = 2;
44
45    /// Returns a forecast h steps ahead from the last observation.
46    pub fn forecast(&self, h: usize) -> f64 {
47        let last_l = *self.level.last().expect("level must be non-empty");
48        let last_t = *self.trend.last().expect("trend must be non-empty");
49        last_l + h as f64 * last_t
50    }
51
52    /// `data[t] − fitted[t]`, `None` at the initialisation.
53    pub fn residuals(&self, data: &[f64]) -> Vec<Option<f64>> {
54        residuals(data, &self.fitted, Self::FIRST_ERROR)
55    }
56
57    /// Forecasts 1..=`horizon` steps ahead with the prediction interval of
58    /// ETS(A,A,N) at two-sided `level` ([module documentation](super#prediction-intervals)); `data` is the
59    /// series smoothed (for σ). `None` when `level` is not in (0, 1).
60    pub fn forecasts(
61        &self,
62        data: &[f64],
63        horizon: usize,
64        level: f64,
65    ) -> Option<Vec<SmoothingForecast>> {
66        if !valid_level(level) {
67            return None;
68        }
69        let (alpha, beta) = (self.alpha, self.beta);
70        let c = move |j: usize| alpha * (1.0 + j as f64 * beta);
71        Some(with_intervals(
72            horizon,
73            level,
74            one_step_mse(data, &self.fitted, Self::FIRST_ERROR),
75            Some(&c),
76            |h| self.forecast(h),
77        ))
78    }
79}
80
81/// Holt's Linear Exponential Smoothing.
82pub struct HoltLinear {
83    alpha: f64,
84    beta: f64,
85}
86
87impl HoltLinear {
88    /// Creates a new Holt smoother.
89    ///
90    /// # Parameters
91    /// - `alpha`: level smoothing constant ∈ (0, 1)
92    /// - `beta`: trend smoothing constant ∈ (0, 1)
93    ///
94    /// Returns `None` if parameters are out of range.
95    pub fn new(alpha: f64, beta: f64) -> Option<Self> {
96        if !alpha.is_finite() || alpha <= 0.0 || alpha >= 1.0 {
97            return None;
98        }
99        if !beta.is_finite() || beta <= 0.0 || beta >= 1.0 {
100            return None;
101        }
102        Some(Self { alpha, beta })
103    }
104
105    /// The smoother whose constants minimise the sum of squared one-step
106    /// errors on `data` (from [`HoltResult::FIRST_ERROR`];
107    /// [module documentation](super#estimating-the-constants)); a constant given is held, one left `None` is
108    /// estimated.
109    ///
110    /// Returns `None` with fewer than 3 values, a non-finite one, or a given
111    /// constant outside (0, 1).
112    pub fn fit(data: &[f64], alpha: Option<f64>, beta: Option<f64>) -> Option<Self> {
113        if data.len() < 3 || data.iter().any(|v| !v.is_finite()) {
114            return None;
115        }
116        for c in [alpha, beta].into_iter().flatten() {
117            Self::new(c, c)?;
118        }
119        let c = super::estimate::least_squares(&[alpha, beta], |c| {
120            let r = Self {
121                alpha: c[0],
122                beta: c[1],
123            }
124            .smooth(data)
125            .expect("at least two values");
126            super::estimate::one_step_sse(data, &r.fitted, HoltResult::FIRST_ERROR)
127        });
128        Self::new(c[0], c[1])
129    }
130
131    /// Returns α.
132    pub fn alpha(&self) -> f64 {
133        self.alpha
134    }
135
136    /// Returns β.
137    pub fn beta(&self) -> f64 {
138        self.beta
139    }
140
141    /// Applies Holt's linear smoothing to the data.
142    ///
143    /// Requires at least 2 data points.
144    /// Returns `None` if data has fewer than 2 points.
145    pub fn smooth(&self, data: &[f64]) -> Option<HoltResult> {
146        if data.len() < 2 {
147            return None;
148        }
149
150        let n = data.len();
151        let mut level = Vec::with_capacity(n);
152        let mut trend = Vec::with_capacity(n);
153        let mut fitted = Vec::with_capacity(n);
154
155        // Initialize
156        let l0 = data[0];
157        let t0 = data[1] - data[0];
158        level.push(l0);
159        trend.push(t0);
160        fitted.push(l0); // first fitted = initial level
161
162        for i in 1..n {
163            let l_prev = level[i - 1];
164            let t_prev = trend[i - 1];
165
166            let l = self.alpha * data[i] + (1.0 - self.alpha) * (l_prev + t_prev);
167            let t = self.beta * (l - l_prev) + (1.0 - self.beta) * t_prev;
168
169            fitted.push(l_prev + t_prev); // one-step-ahead forecast
170            level.push(l);
171            trend.push(t);
172        }
173
174        Some(HoltResult {
175            level,
176            trend,
177            fitted,
178            alpha: self.alpha,
179            beta: self.beta,
180        })
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn test_holt_linear_trend() {
190        // Linear data: 10, 12, 14, 16, 18
191        let holt = HoltLinear::new(0.5, 0.5).unwrap();
192        let data = [10.0, 12.0, 14.0, 16.0, 18.0];
193        let result = holt.smooth(&data).unwrap();
194
195        // Forecast should be close to 20 for h=1
196        let f1 = result.forecast(1);
197        assert!((f1 - 20.0).abs() < 2.0, "forecast(1) = {f1}, expected ~20");
198    }
199
200    #[test]
201    fn test_holt_constant_series() {
202        let holt = HoltLinear::new(0.3, 0.3).unwrap();
203        let data = [5.0; 20];
204        let result = holt.smooth(&data).unwrap();
205
206        // Trend should converge to ~0
207        let last_trend = *result.trend.last().unwrap();
208        assert!(
209            last_trend.abs() < 0.1,
210            "trend should be near 0, got {last_trend}"
211        );
212    }
213
214    #[test]
215    fn test_holt_forecast_multi_step() {
216        let holt = HoltLinear::new(0.5, 0.5).unwrap();
217        let data = [10.0, 12.0, 14.0, 16.0, 18.0];
218        let result = holt.smooth(&data).unwrap();
219
220        let f1 = result.forecast(1);
221        let f3 = result.forecast(3);
222        let f5 = result.forecast(5);
223
224        // Multi-step forecasts should be in order
225        assert!(f1 < f3);
226        assert!(f3 < f5);
227    }
228
229    #[test]
230    fn test_holt_minimum_data() {
231        let holt = HoltLinear::new(0.5, 0.5).unwrap();
232        let result = holt.smooth(&[10.0, 15.0]).unwrap();
233        assert_eq!(result.level.len(), 2);
234        assert_eq!(result.fitted.len(), 2);
235    }
236
237    #[test]
238    fn test_holt_insufficient_data() {
239        let holt = HoltLinear::new(0.5, 0.5).unwrap();
240        assert!(holt.smooth(&[10.0]).is_none());
241        assert!(holt.smooth(&[]).is_none());
242    }
243
244    #[test]
245    fn test_holt_invalid_params() {
246        assert!(HoltLinear::new(0.0, 0.5).is_none());
247        assert!(HoltLinear::new(1.0, 0.5).is_none());
248        assert!(HoltLinear::new(0.5, 0.0).is_none());
249        assert!(HoltLinear::new(0.5, 1.0).is_none());
250        assert!(HoltLinear::new(f64::NAN, 0.5).is_none());
251    }
252
253    #[test]
254    fn test_holt_fitted_values() {
255        let holt = HoltLinear::new(0.5, 0.5).unwrap();
256        let data = [10.0, 12.0, 14.0, 16.0];
257        let result = holt.smooth(&data).unwrap();
258
259        assert_eq!(result.fitted.len(), data.len());
260        // First fitted = initial level
261        assert!((result.fitted[0] - 10.0).abs() < 1e-10);
262    }
263}