chronix_analytics/forecast/
holt.rs1use 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#[derive(serde::Serialize, serde::Deserialize)]
10pub struct HoltLinearModel {
11 alpha: Option<f64>,
12 beta: Option<f64>,
13 phi: f64, params: ModelParams,
15 last_ts: i64,
16 interval_ns: i64,
17 fitted: bool,
18}
19
20impl HoltLinearModel {
21 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#[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#[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 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 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 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 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 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 #[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; 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 #[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}