1use 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#[inline]
14fn safe_div(x: f64, y: f64) -> f64 {
15 const EPS: f64 = 1e-10;
16 if y.abs() < EPS {
17 x / EPS.copysign(y)
18 } else {
19 x / y
20 }
21}
22
23#[derive(serde::Serialize, serde::Deserialize)]
25pub struct HoltWintersModel {
26 alpha: Option<f64>,
27 beta: Option<f64>,
28 gamma: Option<f64>,
29 period: Option<usize>,
30 multiplicative: bool,
31 params: ModelParams,
32 last_ts: i64,
33 interval_ns: i64,
34 seasonal_phase: usize,
38 fitted: bool,
39}
40
41impl HoltWintersModel {
42 pub fn new(
47 alpha: Option<f64>,
48 beta: Option<f64>,
49 gamma: Option<f64>,
50 period: Option<usize>,
51 multiplicative: bool,
52 ) -> Self {
53 Self {
54 alpha,
55 beta,
56 gamma,
57 period,
58 multiplicative,
59 params: ModelParams::HoltWinters {
60 alpha: 0.0,
61 beta: 0.0,
62 gamma: 0.0,
63 period: 0,
64 level: 0.0,
65 trend: 0.0,
66 seasonal: Vec::new(),
67 multiplicative: false,
68 residual_std: 0.0,
69 seasonal_phase: 0,
70 },
71 last_ts: 0,
72 interval_ns: 0,
73 seasonal_phase: 0,
74 fitted: false,
75 }
76 }
77
78 fn detect_period(values: &[f64]) -> usize {
85 crate::preprocess::detect_period(values, values.len() / 2).unwrap_or(1)
86 }
87
88 fn fit_non_seasonal(
92 &mut self,
93 timestamps: &[i64],
94 values: &[f64],
95 _start: std::time::Instant,
96 ) -> Result<(), ForecastError> {
97 let alpha = self.alpha.unwrap_or(0.3);
98 let beta = self.beta.unwrap_or(0.05);
99
100 let mut level = values[0];
101 let mut trend = if values.len() > 1 {
102 values[1] - values[0]
103 } else {
104 0.0
105 };
106 let mut sse = 0.0;
107 let mut count = 0;
108
109 for &v in &values[1..] {
110 let forecast = level + trend;
111 let err = v - forecast;
112 sse += err * err;
113 count += 1;
114 let new_level = alpha * v + (1.0 - alpha) * (level + trend);
115 trend = beta * (new_level - level) + (1.0 - beta) * trend;
116 level = new_level;
117 }
118
119 let dof = (values.len() as f64 - 3.0).max(1.0);
126 let residual_std = if count > 0 { (sse / dof).sqrt() } else { 0.0 };
127
128 self.interval_ns = median_interval(timestamps);
129 self.last_ts = timestamps[timestamps.len() - 1];
130 self.seasonal_phase = 0;
131
132 self.params = ModelParams::HoltWinters {
133 alpha,
134 beta,
135 gamma: 0.0,
136 period: 1,
137 level,
138 trend,
139 seasonal: vec![if self.multiplicative { 1.0 } else { 0.0 }],
140 multiplicative: self.multiplicative,
141 residual_std,
142 seasonal_phase: 0,
143 };
144 self.fitted = true;
145 metrics::histogram!("chronix_forecast_fit_duration_seconds", "model_type" => "holt_winters")
146 .record(_start.elapsed().as_secs_f64());
147 Ok(())
148 }
149
150 fn optimize_params(values: &[f64], period: usize, multiplicative: bool) -> (f64, f64, f64) {
151 let result = crate::forecast::optimizer::minimize_nelder_mead(
152 |p| Self::compute_mse(values, p[0], p[1], p[2], period, multiplicative),
153 &[0.3, 0.05, 0.1],
154 &[
155 crate::forecast::optimizer::Bound::new(0.01, 0.99),
156 crate::forecast::optimizer::Bound::new(0.001, 0.99),
157 crate::forecast::optimizer::Bound::new(0.01, 0.99),
158 ],
159 500,
160 1e-8,
161 );
162 (
163 result.params[0].clamp(0.01, 0.99),
164 result.params[1].clamp(0.001, 0.99),
165 result.params[2].clamp(0.01, 0.99),
166 )
167 }
168
169 fn compute_mse(
170 values: &[f64],
171 alpha: f64,
172 beta: f64,
173 gamma: f64,
174 period: usize,
175 multiplicative: bool,
176 ) -> f64 {
177 if values.len() < 2 * period {
178 return f64::INFINITY;
179 }
180
181 let (mut level, mut trend, mut seasonal) = Self::initialize(values, period, multiplicative);
182 let mut sse = 0.0;
183
184 #[allow(clippy::needless_range_loop)]
185 for i in period..values.len() {
186 let si = i % period;
187 let forecast = if multiplicative {
188 (level + trend) * seasonal[si]
189 } else {
190 level + trend + seasonal[si]
191 };
192 let err = values[i] - forecast;
193 sse += err * err;
194
195 let new_level = if multiplicative {
196 alpha * safe_div(values[i], seasonal[si]) + (1.0 - alpha) * (level + trend)
197 } else {
198 alpha * (values[i] - seasonal[si]) + (1.0 - alpha) * (level + trend)
199 };
200
201 let new_trend = beta * (new_level - level) + (1.0 - beta) * trend;
202
203 seasonal[si] = if multiplicative {
204 gamma * safe_div(values[i], new_level) + (1.0 - gamma) * seasonal[si]
205 } else {
206 gamma * (values[i] - new_level) + (1.0 - gamma) * seasonal[si]
207 };
208
209 level = new_level;
210 trend = new_trend;
211 }
212
213 sse / (values.len() - period) as f64
214 }
215
216 fn initialize(values: &[f64], period: usize, multiplicative: bool) -> (f64, f64, Vec<f64>) {
217 let level: f64 = values[..period].iter().sum::<f64>() / period as f64;
219
220 let trend = if values.len() >= 2 * period {
222 let mut sum = 0.0;
223 for i in 0..period {
224 sum += (values[period + i] - values[i]) / period as f64;
225 }
226 sum / period as f64
227 } else {
228 0.0
229 };
230
231 let seasonal: Vec<f64> = if multiplicative {
233 values[..period]
234 .iter()
235 .map(|&v| safe_div(v, level))
236 .collect()
237 } else {
238 values[..period].iter().map(|&v| v - level).collect()
239 };
240
241 (level, trend, seasonal)
242 }
243}
244
245#[must_use]
251pub fn holt_winters_variance_factor(
252 alpha: f64,
253 beta: f64,
254 gamma: f64,
255 period: usize,
256 h: usize,
257) -> f64 {
258 let m = period.max(1);
259 let mut acc = 1.0;
260 for j in 1..h {
261 let mut psi = alpha * (1.0 + beta * j as f64);
262 if j % m == 0 {
263 psi += gamma;
264 }
265 acc += psi * psi;
266 }
267 acc
268}
269
270impl ForecastModel for HoltWintersModel {
271 #[tracing::instrument(skip_all, level = "debug")]
272 fn fit(&mut self, timestamps: &[i64], values: &[f64]) -> Result<(), ForecastError> {
273 let _start = std::time::Instant::now();
274 validate_input(timestamps, values)?;
275 let mut period = self.period.unwrap_or_else(|| Self::detect_period(values));
276
277 if period >= 2 && values.len() < 2 * period {
282 tracing::warn!(
283 period,
284 n = values.len(),
285 "Insufficient data for seasonal Holt-Winters (need {}); \
286 falling back to non-seasonal model",
287 2 * period,
288 );
289 period = 1;
290 }
291 if period < 2 {
292 return self.fit_non_seasonal(timestamps, values, _start);
294 }
295
296 let (alpha, beta, gamma) = match (self.alpha, self.beta, self.gamma) {
297 (Some(a), Some(b), Some(g)) => (a, b, g),
298 _ => Self::optimize_params(values, period, self.multiplicative),
299 };
300
301 let (mut level, mut trend, mut seasonal) =
302 Self::initialize(values, period, self.multiplicative);
303 let mut sse = 0.0;
304 let mut count = 0;
305
306 #[allow(clippy::needless_range_loop)]
307 for i in period..values.len() {
308 let si = i % period;
309 let forecast = if self.multiplicative {
310 (level + trend) * seasonal[si]
311 } else {
312 level + trend + seasonal[si]
313 };
314 let err = values[i] - forecast;
315 sse += err * err;
316 count += 1;
317
318 let new_level = if self.multiplicative {
319 alpha * safe_div(values[i], seasonal[si]) + (1.0 - alpha) * (level + trend)
320 } else {
321 alpha * (values[i] - seasonal[si]) + (1.0 - alpha) * (level + trend)
322 };
323
324 let new_trend = beta * (new_level - level) + (1.0 - beta) * trend;
325
326 seasonal[si] = if self.multiplicative {
327 gamma * safe_div(values[i], new_level) + (1.0 - gamma) * seasonal[si]
328 } else {
329 gamma * (values[i] - new_level) + (1.0 - gamma) * seasonal[si]
330 };
331
332 level = new_level;
333 trend = new_trend;
334 }
335
336 let dof = (values.len() as f64 - period as f64 - 3.0).max(1.0);
342 let residual_std = if count > 0 { (sse / dof).sqrt() } else { 0.0 };
343
344 self.interval_ns = median_interval(timestamps);
345 self.last_ts = timestamps[timestamps.len() - 1];
346 self.seasonal_phase = values.len() % period;
347
348 self.params = ModelParams::HoltWinters {
349 alpha,
350 beta,
351 gamma,
352 period,
353 level,
354 trend,
355 seasonal,
356 multiplicative: self.multiplicative,
357 residual_std,
358 seasonal_phase: self.seasonal_phase,
359 };
360 self.fitted = true;
361 metrics::histogram!("chronix_forecast_fit_duration_seconds", "model_type" => "holt_winters").record(_start.elapsed().as_secs_f64());
362 Ok(())
363 }
364
365 #[tracing::instrument(skip_all, level = "debug")]
366 fn predict(&self, horizon: usize) -> Result<ForecastResult, ForecastError> {
367 let _start = std::time::Instant::now();
368 if !self.fitted {
369 return Err(ForecastError::NotFitted);
370 }
371
372 let ModelParams::HoltWinters {
373 alpha,
374 beta,
375 gamma,
376 level,
377 trend,
378 seasonal,
379 multiplicative,
380 period,
381 residual_std,
382 ..
383 } = &self.params
384 else {
385 return Err(ForecastError::InvalidInput(
386 "unexpected model params variant".into(),
387 ));
388 };
389
390 let z = 1.96;
391 let mut values = Vec::with_capacity(horizon);
392 let mut timestamps = Vec::with_capacity(horizon);
393 let mut lower = Vec::with_capacity(horizon);
394 let mut upper = Vec::with_capacity(horizon);
395
396 for h in 1..=horizon {
397 let ts = self
398 .last_ts
399 .saturating_add((h as i64).saturating_mul(self.interval_ns));
400 let si = (self.seasonal_phase + h - 1) % period;
401 let forecast = if *multiplicative {
402 (level + h as f64 * trend) * seasonal[si]
403 } else {
404 level + h as f64 * trend + seasonal[si]
405 };
406
407 let var_sum = holt_winters_variance_factor(*alpha, *beta, *gamma, *period, h);
425 let width = z * residual_std * var_sum.sqrt();
426 values.push(forecast);
427 timestamps.push(ts);
428 lower.push(forecast - width);
429 upper.push(forecast + width);
430 }
431
432 metrics::histogram!("chronix_forecast_predict_duration_seconds", "model_type" => "holt_winters").record(_start.elapsed().as_secs_f64());
433 Ok(ForecastResult {
434 values,
435 timestamps,
436 confidence_lower: lower,
437 confidence_upper: upper,
438 confidence_level: 0.95,
439 })
440 }
441
442 fn update(&mut self, timestamp: i64, value: f64) -> Result<(), ForecastError> {
443 if !self.fitted {
444 return Err(ForecastError::NotFitted);
445 }
446
447 let ModelParams::HoltWinters {
448 alpha,
449 beta,
450 gamma,
451 ref mut level,
452 ref mut trend,
453 ref mut seasonal,
454 multiplicative,
455 period,
456 ..
457 } = self.params
458 else {
459 return Err(ForecastError::InvalidInput(
460 "unexpected model params variant".into(),
461 ));
462 };
463
464 let elapsed = timestamp - self.last_ts;
466 let steps = (elapsed / self.interval_ns).max(1) as usize;
467 let si = (self.seasonal_phase + steps - 1) % period;
468
469 let new_level = if multiplicative {
470 alpha * safe_div(value, seasonal[si]) + (1.0 - alpha) * (*level + *trend)
471 } else {
472 alpha * (value - seasonal[si]) + (1.0 - alpha) * (*level + *trend)
473 };
474
475 *trend = beta * (new_level - *level) + (1.0 - beta) * *trend;
476
477 seasonal[si] = if multiplicative {
478 gamma * safe_div(value, new_level) + (1.0 - gamma) * seasonal[si]
479 } else {
480 gamma * (value - new_level) + (1.0 - gamma) * seasonal[si]
481 };
482
483 *level = new_level;
484 self.last_ts = timestamp;
485 self.seasonal_phase = (self.seasonal_phase + steps) % period;
488 Ok(())
489 }
490
491 fn model_type(&self) -> ModelType {
492 ModelType::HoltWinters
493 }
494
495 fn params(&self) -> &ModelParams {
496 &self.params
497 }
498}
499
500impl crate::forecast::storage::ModelStore for HoltWintersModel {}
501
502#[cfg(test)]
503mod tests {
504 use super::*;
505
506 fn seasonal_data(n: usize, period: usize) -> (Vec<i64>, Vec<f64>) {
507 let ts: Vec<i64> = (0..n).map(|i| i as i64 * 1_000_000_000).collect();
508 let vals: Vec<f64> = (0..n)
509 .map(|i| {
510 let trend = i as f64 * 0.5;
511 let season =
512 10.0 * (2.0 * std::f64::consts::PI * (i % period) as f64 / period as f64).sin();
513 trend + season + 50.0
514 })
515 .collect();
516 (ts, vals)
517 }
518
519 #[test]
524 fn hw_interval_matches_hyndman_psi_weights() {
525 let (ts, vals) = seasonal_data(120, 24);
526 let mut model = HoltWintersModel::new(Some(0.3), Some(0.1), Some(0.2), Some(24), false);
527 model.fit(&ts, &vals).unwrap();
528 let ModelParams::HoltWinters { residual_std, .. } = model.params() else {
529 panic!()
530 };
531 let sigma = *residual_std;
532 assert!(sigma > 0.0);
533 let r = model.predict(24).unwrap();
534
535 let z = 1.96;
536 let half1 = r.confidence_upper[0] - r.values[0];
537 assert!(
538 (half1 - z * sigma).abs() < 1e-12,
539 "h=1 half-width {half1} != z*sigma {}",
540 z * sigma
541 );
542
543 let expected_factor = 11.929_6_f64;
544 let ratio = (r.confidence_upper[23] - r.values[23]) / half1;
545 assert!(
546 (ratio * ratio - expected_factor).abs() < 1e-9,
547 "h=24 variance ratio {} != {expected_factor}",
548 ratio * ratio
549 );
550 assert!(
551 (ratio - expected_factor.sqrt()).abs() < 1e-9,
552 "h=24 width ratio {ratio} != {}",
553 expected_factor.sqrt()
554 );
555 }
556
557 #[test]
561 fn hw_variance_factor_uses_fitted_gamma_at_seasonal_boundary() {
562 let f = holt_winters_variance_factor(0.5, 0.1, 0.3, 4, 5);
564 let mut expected = 1.0;
565 for j in 1..5 {
566 let mut psi = 0.5 * (1.0 + 0.1 * j as f64);
567 if j % 4 == 0 {
568 psi += 0.3;
569 }
570 expected += psi * psi;
571 }
572 assert!((f - expected).abs() < 1e-12);
573 assert!((f - holt_winters_variance_factor(0.5, 0.1, 0.0, 4, 5)).abs() > 0.1);
575 }
576
577 #[test]
578 fn hw_additive_seasonal() {
579 let (ts, vals) = seasonal_data(200, 24);
580 let mut model = HoltWintersModel::new(Some(0.3), Some(0.1), Some(0.2), Some(24), false);
581 model.fit(&ts, &vals).unwrap();
582 let result = model.predict(24).unwrap();
583 assert_eq!(result.values.len(), 24);
584 let range = result.values.iter().copied().fold(f64::INFINITY, f64::min);
586 let max = result
587 .values
588 .iter()
589 .copied()
590 .fold(f64::NEG_INFINITY, f64::max);
591 assert!(max - range > 1.0); }
593
594 #[test]
595 fn hw_multiplicative() {
596 let (ts, vals) = seasonal_data(200, 12);
597 let vals: Vec<f64> = vals.iter().map(|v| v + 100.0).collect();
599 let mut model = HoltWintersModel::new(Some(0.3), Some(0.1), Some(0.2), Some(12), true);
600 model.fit(&ts, &vals).unwrap();
601 let result = model.predict(12).unwrap();
602 assert_eq!(result.values.len(), 12);
603 }
604
605 #[test]
606 fn hw_auto_period() {
607 let (ts, vals) = seasonal_data(200, 7);
608 let mut model = HoltWintersModel::new(None, None, None, None, false);
609 let result = model.fit(&ts, &vals);
611 if result.is_ok() {
614 let pred = model.predict(7).unwrap();
615 assert_eq!(pred.values.len(), 7);
616 }
617 }
618
619 #[test]
620 fn hw_insufficient_data_degrades_gracefully() {
621 let mut model = HoltWintersModel::new(None, None, None, Some(24), false);
623 let ts: Vec<i64> = (0..10).map(|i| i * 1_000_000_000).collect();
624 let vals: Vec<f64> = (0..10).map(|i| 50.0 + i as f64 * 2.0).collect();
625 model.fit(&ts, &vals).unwrap(); let result = model.predict(5).unwrap();
627 assert_eq!(result.values.len(), 5);
628 assert!(result.values[4] > result.values[0]);
630 }
631
632 #[test]
633 fn hw_non_seasonal_predicts_trend() {
634 let ts: Vec<i64> = (0..5).map(|i| i * 1_000_000_000).collect();
636 let vals = vec![10.0, 20.0, 30.0, 40.0, 50.0];
637 let mut model = HoltWintersModel::new(Some(0.8), Some(0.2), None, Some(1), false);
638 model.fit(&ts, &vals).unwrap();
639 let result = model.predict(3).unwrap();
640 assert_eq!(result.values.len(), 3);
641 assert!(result.values[0] > 45.0);
643 }
644
645 #[test]
646 fn hw_update() {
647 let (ts, vals) = seasonal_data(100, 12);
648 let mut model = HoltWintersModel::new(Some(0.3), Some(0.1), Some(0.2), Some(12), false);
649 model.fit(&ts, &vals).unwrap();
650 model.update(100_000_000_000, 200.0).unwrap();
651 let ModelParams::HoltWinters { level, .. } = model.params() else {
652 panic!()
653 };
654 assert!(*level > 50.0);
655 }
656
657 #[test]
658 fn hw_multiplicative_negative_seasonal_factors() {
659 let period = 4;
663 let n = 80;
664 let ts: Vec<i64> = (0..n).map(|i| i as i64 * 1_000_000_000).collect();
665 let vals: Vec<f64> = (0..n)
667 .map(|i| {
668 let base = 5.0 + i as f64 * 0.2;
669 let seasonal = match i % period {
670 0 => 1.2,
671 1 => 0.8,
672 2 => -0.3, 3 => 1.3,
674 _ => unreachable!(),
675 };
676 base * seasonal
677 })
678 .collect();
679 let mut model = HoltWintersModel::new(Some(0.3), Some(0.1), Some(0.2), Some(period), true);
680 model.fit(&ts, &vals).unwrap();
681 let result = model.predict(period).unwrap();
682 assert_eq!(result.values.len(), period);
683 assert!(
687 result.values[2] < 0.0,
688 "Expected negative forecast at seasonal index 2, got {}",
689 result.values[2]
690 );
691 assert!(result.values[0] > 0.0);
693 assert!(result.values[1] > 0.0);
694 assert!(result.values[3] > 0.0);
695 }
696
697 #[test]
698 fn safe_div_preserves_sign() {
699 assert!((safe_div(10.0, -0.5) - -20.0).abs() < 1e-6);
700 assert!((safe_div(10.0, 0.5) - 20.0).abs() < 1e-6);
701 assert!(safe_div(1.0, 1e-15) > 0.0);
703 assert!(safe_div(1.0, -1e-15) < 0.0);
705 }
706}