Skip to main content

chronix_analytics/forecast/
holt.rs

1//! Holt's Linear Trend (double exponential smoothing) forecast model.
2
3use crate::forecast::error::ForecastError;
4use crate::forecast::result::{ForecastResult, ModelParams, ModelType};
5use crate::forecast::traits::ForecastModel;
6use crate::forecast::util::{median_interval, validate_input};
7
8/// Holt's Linear Trend model with optional damping.
9#[derive(serde::Serialize, serde::Deserialize)]
10pub struct HoltLinearModel {
11    alpha: Option<f64>,
12    beta: Option<f64>,
13    phi: f64, // damping factor (1.0 = no damping)
14    params: ModelParams,
15    last_ts: i64,
16    interval_ns: i64,
17    fitted: bool,
18}
19
20impl HoltLinearModel {
21    /// Creates a new Holt's Linear Trend model.
22    ///
23    /// `phi` is the damping factor (0.8–1.0). Use 1.0 for no damping.
24    pub fn new(alpha: Option<f64>, beta: Option<f64>, phi: f64) -> Self {
25        Self {
26            alpha,
27            beta,
28            phi: phi.clamp(0.8, 1.0),
29            params: ModelParams::HoltLinear {
30                alpha: 0.0,
31                beta: 0.0,
32                level: 0.0,
33                trend: 0.0,
34                phi: 1.0,
35                residual_std: 0.0,
36            },
37            last_ts: 0,
38            interval_ns: 0,
39            fitted: false,
40        }
41    }
42
43    fn optimize_params(values: &[f64], phi: f64) -> (f64, f64) {
44        let result = crate::forecast::optimizer::minimize_nelder_mead(
45            |p| Self::compute_mse(values, p[0], p[1], phi),
46            &[0.3, 0.1],
47            &[
48                crate::forecast::optimizer::Bound::new(0.01, 0.99),
49                crate::forecast::optimizer::Bound::new(0.01, 0.99),
50            ],
51            300,
52            1e-8,
53        );
54        (
55            result.params[0].clamp(0.01, 0.99),
56            result.params[1].clamp(0.01, 0.99),
57        )
58    }
59
60    fn compute_mse(values: &[f64], alpha: f64, beta: f64, phi: f64) -> f64 {
61        if values.len() < 3 {
62            return f64::INFINITY;
63        }
64        let mut level = values[0];
65        let mut trend = values[1] - values[0];
66        let mut sse = 0.0;
67
68        for &value in values.iter().skip(1) {
69            let forecast = level + phi * trend;
70            let err = value - forecast;
71            sse += err * err;
72            let new_level = alpha * value + (1.0 - alpha) * (level + phi * trend);
73            trend = beta * (new_level - level) + (1.0 - beta) * phi * trend;
74            level = new_level;
75        }
76        sse / (values.len() - 1) as f64
77    }
78}
79
80/// psi-weight of the `j`-step-ahead forecast error for Holt's linear trend.
81///
82/// `psi_j = alpha * (1 + beta * phi_j)` where `phi_j = phi + ... + phi^j`,
83/// which collapses to `alpha * (1 + beta * j)` when `phi == 1`.
84/// Hyndman, Koehler, Ord & Snyder (2008), Table 6.1, class 1.
85#[inline]
86fn holt_psi(alpha: f64, beta: f64, phi: f64, j: usize) -> f64 {
87    let phi_j = if (phi - 1.0).abs() < 1e-10 {
88        j as f64
89    } else {
90        phi * (1.0 - phi.powi(j as i32)) / (1.0 - phi)
91    };
92    alpha * (1.0 + beta * phi_j)
93}
94
95/// `Var[e_h] / sigma^2 = 1 + sum_{j=1..h-1} psi_j^2` for Holt's linear trend.
96///
97/// Exactly `1.0` at `h = 1`, so the one-step interval is `z * sigma` and
98/// nothing else.
99#[must_use]
100pub fn holt_variance_factor(alpha: f64, beta: f64, phi: f64, h: usize) -> f64 {
101    let mut acc = 1.0;
102    for j in 1..h {
103        let psi = holt_psi(alpha, beta, phi, j);
104        acc += psi * psi;
105    }
106    acc
107}
108
109impl ForecastModel for HoltLinearModel {
110    #[tracing::instrument(skip_all, level = "debug")]
111    fn fit(&mut self, timestamps: &[i64], values: &[f64]) -> Result<(), ForecastError> {
112        let _start = std::time::Instant::now();
113        validate_input(timestamps, values)?;
114        if values.len() < 3 {
115            return Err(ForecastError::InsufficientData {
116                min: 3,
117                got: values.len(),
118            });
119        }
120
121        let (alpha, beta) = match (self.alpha, self.beta) {
122            (Some(a), Some(b)) => (a, b),
123            _ => Self::optimize_params(values, self.phi),
124        };
125
126        let mut level = values[0];
127        let mut trend = values[1] - values[0];
128        let mut sse = 0.0;
129
130        for &value in values.iter().skip(1) {
131            let forecast = level + self.phi * trend;
132            let err = value - forecast;
133            sse += err * err;
134            let new_level = alpha * value + (1.0 - alpha) * (level + self.phi * trend);
135            trend = beta * (new_level - level) + (1.0 - beta) * self.phi * trend;
136            level = new_level;
137        }
138
139        // Correct degrees of freedom — we have n-1 residuals and
140        // 3 estimated parameters (α, β, ϕ), so divisor = max(n-4, 1).
141        let dof = (values.len() as f64 - 4.0).max(1.0);
142        let residual_std = (sse / dof).sqrt();
143
144        self.interval_ns = median_interval(timestamps);
145        self.last_ts = timestamps[timestamps.len() - 1];
146
147        self.params = ModelParams::HoltLinear {
148            alpha,
149            beta,
150            level,
151            trend,
152            phi: self.phi,
153            residual_std,
154        };
155        self.fitted = true;
156        metrics::histogram!("chronix_forecast_fit_duration_seconds", "model_type" => "holt_linear")
157            .record(_start.elapsed().as_secs_f64());
158        Ok(())
159    }
160
161    #[tracing::instrument(skip_all, level = "debug")]
162    fn predict(&self, horizon: usize) -> Result<ForecastResult, ForecastError> {
163        let _start = std::time::Instant::now();
164        if !self.fitted {
165            return Err(ForecastError::NotFitted);
166        }
167
168        let ModelParams::HoltLinear {
169            alpha,
170            beta,
171            level,
172            trend,
173            phi,
174            residual_std,
175            ..
176        } = &self.params
177        else {
178            return Err(ForecastError::InvalidInput(
179                "unexpected model params variant".into(),
180            ));
181        };
182
183        let z = 1.96;
184        let mut values = Vec::with_capacity(horizon);
185        let mut timestamps = Vec::with_capacity(horizon);
186        let mut lower = Vec::with_capacity(horizon);
187        let mut upper = Vec::with_capacity(horizon);
188
189        for h in 1..=horizon {
190            let ts = self
191                .last_ts
192                .saturating_add((h as i64).saturating_mul(self.interval_ns));
193            // Damped trend: sum of phi^1 + phi^2 + ... + phi^h
194            let damped_sum = if (*phi - 1.0).abs() < 1e-10 {
195                h as f64
196            } else {
197                phi * (1.0 - phi.powi(h as i32)) / (1.0 - phi)
198            };
199            let forecast = level + damped_sum * trend;
200
201            // PI variance per Hyndman, Koehler, Ord & Snyder (2008), Table
202            // 6.1, class 1 (linear innovations state space, additive error):
203            //
204            //   Var[e_h] = sigma^2 * (1 + sum_{j=1..h-1} psi_j^2)
205            //
206            // with the *psi-weight* of the h-step error, not a running sum:
207            //
208            //   psi_j = alpha * (1 + beta * j)                       (undamped)
209            //   psi_j = alpha * (1 + beta * phi_j),
210            //           phi_j = phi + phi^2 + ... + phi^j            (damped)
211            //           phi_j = phi * (1 - phi^j) / (1 - phi)
212            //
213            // Setting `phi = 1` in the damped form recovers the undamped one,
214            // so the two agree at the boundary. At h = 1 the sum is empty and
215            // the half-width is exactly z*sigma.
216            let var_sum = holt_variance_factor(*alpha, *beta, *phi, h);
217            let width = z * residual_std * var_sum.sqrt();
218            values.push(forecast);
219            timestamps.push(ts);
220            lower.push(forecast - width);
221            upper.push(forecast + width);
222        }
223
224        metrics::histogram!("chronix_forecast_predict_duration_seconds", "model_type" => "holt_linear").record(_start.elapsed().as_secs_f64());
225        Ok(ForecastResult {
226            values,
227            timestamps,
228            confidence_lower: lower,
229            confidence_upper: upper,
230            confidence_level: 0.95,
231        })
232    }
233
234    fn update(&mut self, timestamp: i64, value: f64) -> Result<(), ForecastError> {
235        if !self.fitted {
236            return Err(ForecastError::NotFitted);
237        }
238
239        let ModelParams::HoltLinear {
240            alpha,
241            beta,
242            ref mut level,
243            ref mut trend,
244            phi,
245            ..
246        } = self.params
247        else {
248            return Err(ForecastError::InvalidInput(
249                "unexpected model params variant".into(),
250            ));
251        };
252
253        let new_level = alpha * value + (1.0 - alpha) * (*level + phi * *trend);
254        *trend = beta * (new_level - *level) + (1.0 - beta) * phi * *trend;
255        *level = new_level;
256        self.last_ts = timestamp;
257        Ok(())
258    }
259
260    fn model_type(&self) -> ModelType {
261        ModelType::HoltLinear
262    }
263
264    fn params(&self) -> &ModelParams {
265        &self.params
266    }
267}
268
269impl crate::forecast::storage::ModelStore for HoltLinearModel {}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn holt_linear_trend() {
277        let ts: Vec<i64> = (0..100).map(|i| i * 1_000_000_000).collect();
278        let vals: Vec<f64> = (0..100).map(|i| i as f64 * 3.0 + 10.0).collect();
279        let mut model = HoltLinearModel::new(Some(0.8), Some(0.2), 1.0);
280        model.fit(&ts, &vals).unwrap();
281        let result = model.predict(5).unwrap();
282        // Linear trend should be captured
283        let last = *vals.last().unwrap();
284        assert!((result.values[0] - (last + 3.0)).abs() < 5.0);
285    }
286
287    #[test]
288    fn holt_damped_trend() {
289        let ts: Vec<i64> = (0..50).map(|i| i * 1_000_000_000).collect();
290        let vals: Vec<f64> = (0..50).map(|i| i as f64 * 2.0).collect();
291        let mut model = HoltLinearModel::new(Some(0.8), Some(0.2), 0.9);
292        model.fit(&ts, &vals).unwrap();
293        let result = model.predict(20).unwrap();
294        // Damped trend should decay — later predictions should slow down
295        let diff_early = result.values[1] - result.values[0];
296        let diff_late = result.values[19] - result.values[18];
297        assert!(diff_late < diff_early);
298    }
299
300    #[test]
301    fn holt_auto_params() {
302        let ts: Vec<i64> = (0..30).map(|i| i * 1_000_000_000).collect();
303        let vals: Vec<f64> = (0..30).map(|i| i as f64 + (i as f64 * 0.3).sin()).collect();
304        let mut model = HoltLinearModel::new(None, None, 1.0);
305        model.fit(&ts, &vals).unwrap();
306        let result = model.predict(5).unwrap();
307        assert_eq!(result.values.len(), 5);
308    }
309
310    #[test]
311    fn holt_update() {
312        let ts: Vec<i64> = (0..20).map(|i| i * 1_000_000_000).collect();
313        let vals: Vec<f64> = (0..20).map(|i| i as f64).collect();
314        let mut model = HoltLinearModel::new(Some(0.5), Some(0.1), 1.0);
315        model.fit(&ts, &vals).unwrap();
316        model.update(20_000_000_000, 100.0).unwrap();
317        let ModelParams::HoltLinear { level, .. } = model.params() else {
318            panic!()
319        };
320        assert!(*level > 19.0);
321    }
322
323    /// h = 1 must be exactly z*sigma, and h = 3 must match the closed form
324    /// `1 + psi_1^2 + psi_2^2` with `psi_j = alpha(1 + beta*j)`.
325    /// alpha = 0.5, beta = 0.2: psi_1 = 0.6, psi_2 = 0.7 -> 1 + 0.36 + 0.49.
326    #[test]
327    fn holt_undamped_interval_matches_hyndman_psi_weights() {
328        let ts: Vec<i64> = (0..60).map(|i| i * 1_000_000_000).collect();
329        let vals: Vec<f64> = (0..60)
330            .map(|i| i as f64 * 2.0 + (i as f64 * 0.7).sin())
331            .collect();
332        let mut model = HoltLinearModel::new(Some(0.5), Some(0.2), 1.0);
333        model.fit(&ts, &vals).unwrap();
334        let ModelParams::HoltLinear { residual_std, .. } = model.params() else {
335            panic!()
336        };
337        let sigma = *residual_std;
338        let r = model.predict(3).unwrap();
339
340        let z = 1.96;
341        let half1 = r.confidence_upper[0] - r.values[0];
342        assert!(
343            (half1 - z * sigma).abs() < 1e-12,
344            "h=1 half-width {half1} != z*sigma {}",
345            z * sigma
346        );
347
348        let expected_factor: f64 = 1.85_f64; // 1 + 0.6^2 + 0.7^2
349        let half3 = r.confidence_upper[2] - r.values[2];
350        assert!(
351            (half3 - z * sigma * expected_factor.sqrt()).abs() < 1e-12,
352            "h=3 half-width {half3} != {}",
353            z * sigma * expected_factor.sqrt()
354        );
355    }
356
357    /// Damped: `psi_j = alpha(1 + beta * phi_j)`, `phi_j = phi + ... + phi^j`.
358    /// alpha = 0.5, beta = 0.2, phi = 0.9: phi_1 = 0.9, phi_2 = 1.71,
359    /// psi_1 = 0.59, psi_2 = 0.671 -> factor 1 + 0.3481 + 0.450241.
360    #[test]
361    fn holt_damped_interval_matches_hyndman_psi_weights() {
362        let ts: Vec<i64> = (0..60).map(|i| i * 1_000_000_000).collect();
363        let vals: Vec<f64> = (0..60)
364            .map(|i| i as f64 * 2.0 + (i as f64 * 0.7).sin())
365            .collect();
366        let mut model = HoltLinearModel::new(Some(0.5), Some(0.2), 0.9);
367        model.fit(&ts, &vals).unwrap();
368        let ModelParams::HoltLinear { residual_std, .. } = model.params() else {
369            panic!()
370        };
371        let sigma = *residual_std;
372        let r = model.predict(3).unwrap();
373
374        let z = 1.96;
375        assert!((r.confidence_upper[0] - r.values[0] - z * sigma).abs() < 1e-12);
376
377        let expected_factor: f64 = 1.0 + 0.59_f64 * 0.59 + 0.671_f64 * 0.671;
378        let half3 = r.confidence_upper[2] - r.values[2];
379        assert!(
380            (half3 - z * sigma * expected_factor.sqrt()).abs() < 1e-12,
381            "h=3 half-width {half3} != {}",
382            z * sigma * expected_factor.sqrt()
383        );
384    }
385
386    #[test]
387    fn holt_insufficient_data() {
388        let mut model = HoltLinearModel::new(None, None, 1.0);
389        assert!(model.fit(&[0, 1], &[1.0, 2.0]).is_err());
390    }
391}