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
22/// Result of Holt's linear smoothing at each time step.
23#[derive(Debug, Clone)]
24pub struct HoltResult {
25    /// Level estimates.
26    pub level: Vec<f64>,
27    /// Trend estimates.
28    pub trend: Vec<f64>,
29    /// Fitted values (level + trend from previous step).
30    pub fitted: Vec<f64>,
31}
32
33impl HoltResult {
34    /// Returns a forecast h steps ahead from the last observation.
35    pub fn forecast(&self, h: usize) -> f64 {
36        let last_l = *self.level.last().expect("level must be non-empty");
37        let last_t = *self.trend.last().expect("trend must be non-empty");
38        last_l + h as f64 * last_t
39    }
40}
41
42/// Holt's Linear Exponential Smoothing.
43pub struct HoltLinear {
44    alpha: f64,
45    beta: f64,
46}
47
48impl HoltLinear {
49    /// Creates a new Holt smoother.
50    ///
51    /// # Parameters
52    /// - `alpha`: level smoothing constant ∈ (0, 1)
53    /// - `beta`: trend smoothing constant ∈ (0, 1)
54    ///
55    /// Returns `None` if parameters are out of range.
56    pub fn new(alpha: f64, beta: f64) -> Option<Self> {
57        if !alpha.is_finite() || alpha <= 0.0 || alpha >= 1.0 {
58            return None;
59        }
60        if !beta.is_finite() || beta <= 0.0 || beta >= 1.0 {
61            return None;
62        }
63        Some(Self { alpha, beta })
64    }
65
66    /// Returns α.
67    pub fn alpha(&self) -> f64 {
68        self.alpha
69    }
70
71    /// Returns β.
72    pub fn beta(&self) -> f64 {
73        self.beta
74    }
75
76    /// Applies Holt's linear smoothing to the data.
77    ///
78    /// Requires at least 2 data points.
79    /// Returns `None` if data has fewer than 2 points.
80    pub fn smooth(&self, data: &[f64]) -> Option<HoltResult> {
81        if data.len() < 2 {
82            return None;
83        }
84
85        let n = data.len();
86        let mut level = Vec::with_capacity(n);
87        let mut trend = Vec::with_capacity(n);
88        let mut fitted = Vec::with_capacity(n);
89
90        // Initialize
91        let l0 = data[0];
92        let t0 = data[1] - data[0];
93        level.push(l0);
94        trend.push(t0);
95        fitted.push(l0); // first fitted = initial level
96
97        for i in 1..n {
98            let l_prev = level[i - 1];
99            let t_prev = trend[i - 1];
100
101            let l = self.alpha * data[i] + (1.0 - self.alpha) * (l_prev + t_prev);
102            let t = self.beta * (l - l_prev) + (1.0 - self.beta) * t_prev;
103
104            fitted.push(l_prev + t_prev); // one-step-ahead forecast
105            level.push(l);
106            trend.push(t);
107        }
108
109        Some(HoltResult {
110            level,
111            trend,
112            fitted,
113        })
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn test_holt_linear_trend() {
123        // Linear data: 10, 12, 14, 16, 18
124        let holt = HoltLinear::new(0.5, 0.5).unwrap();
125        let data = [10.0, 12.0, 14.0, 16.0, 18.0];
126        let result = holt.smooth(&data).unwrap();
127
128        // Forecast should be close to 20 for h=1
129        let f1 = result.forecast(1);
130        assert!((f1 - 20.0).abs() < 2.0, "forecast(1) = {f1}, expected ~20");
131    }
132
133    #[test]
134    fn test_holt_constant_series() {
135        let holt = HoltLinear::new(0.3, 0.3).unwrap();
136        let data = [5.0; 20];
137        let result = holt.smooth(&data).unwrap();
138
139        // Trend should converge to ~0
140        let last_trend = *result.trend.last().unwrap();
141        assert!(
142            last_trend.abs() < 0.1,
143            "trend should be near 0, got {last_trend}"
144        );
145    }
146
147    #[test]
148    fn test_holt_forecast_multi_step() {
149        let holt = HoltLinear::new(0.5, 0.5).unwrap();
150        let data = [10.0, 12.0, 14.0, 16.0, 18.0];
151        let result = holt.smooth(&data).unwrap();
152
153        let f1 = result.forecast(1);
154        let f3 = result.forecast(3);
155        let f5 = result.forecast(5);
156
157        // Multi-step forecasts should be in order
158        assert!(f1 < f3);
159        assert!(f3 < f5);
160    }
161
162    #[test]
163    fn test_holt_minimum_data() {
164        let holt = HoltLinear::new(0.5, 0.5).unwrap();
165        let result = holt.smooth(&[10.0, 15.0]).unwrap();
166        assert_eq!(result.level.len(), 2);
167        assert_eq!(result.fitted.len(), 2);
168    }
169
170    #[test]
171    fn test_holt_insufficient_data() {
172        let holt = HoltLinear::new(0.5, 0.5).unwrap();
173        assert!(holt.smooth(&[10.0]).is_none());
174        assert!(holt.smooth(&[]).is_none());
175    }
176
177    #[test]
178    fn test_holt_invalid_params() {
179        assert!(HoltLinear::new(0.0, 0.5).is_none());
180        assert!(HoltLinear::new(1.0, 0.5).is_none());
181        assert!(HoltLinear::new(0.5, 0.0).is_none());
182        assert!(HoltLinear::new(0.5, 1.0).is_none());
183        assert!(HoltLinear::new(f64::NAN, 0.5).is_none());
184    }
185
186    #[test]
187    fn test_holt_fitted_values() {
188        let holt = HoltLinear::new(0.5, 0.5).unwrap();
189        let data = [10.0, 12.0, 14.0, 16.0];
190        let result = holt.smooth(&data).unwrap();
191
192        assert_eq!(result.fitted.len(), data.len());
193        // First fitted = initial level
194        assert!((result.fitted[0] - 10.0).abs() < 1e-10);
195    }
196}