1use super::forecast::{
37 first_passage, one_step_mse, passage_arguments, residuals, valid_level, with_intervals,
38 FirstPassage, InnovationWeights, SmoothingForecast,
39};
40
41#[derive(Debug, Clone, Copy, PartialEq)]
43pub enum Seasonality {
44 Additive,
46 Multiplicative,
48}
49
50#[derive(Debug, Clone)]
52pub struct HoltWintersResult {
53 pub level: Vec<f64>,
55 pub trend: Vec<f64>,
57 pub seasonal: Vec<f64>,
60 pub fitted: Vec<f64>,
63 pub alpha: f64,
65 pub beta: f64,
67 pub gamma: f64,
69 pub period: usize,
71 pub seasonality: Seasonality,
73}
74
75impl HoltWintersResult {
76 pub fn forecast(&self, h: usize) -> f64 {
78 let last_l = *self.level.last().expect("level must be non-empty");
79 let last_t = *self.trend.last().expect("trend must be non-empty");
80
81 let s_len = self.seasonal.len();
83 let idx = s_len - self.period + ((h - 1) % self.period);
84 let s = self.seasonal[idx];
85
86 match self.seasonality {
87 Seasonality::Additive => last_l + h as f64 * last_t + s,
88 Seasonality::Multiplicative => (last_l + h as f64 * last_t) * s,
89 }
90 }
91
92 pub fn first_error(&self) -> usize {
95 self.period
96 }
97
98 pub fn residuals(&self, data: &[f64]) -> Vec<Option<f64>> {
100 residuals(data, &self.fitted, self.first_error())
101 }
102
103 pub fn forecasts(
108 &self,
109 data: &[f64],
110 horizon: usize,
111 level: f64,
112 ) -> Option<Vec<SmoothingForecast>> {
113 if !valid_level(level) {
114 return None;
115 }
116 let weights = self.weights();
117 let c = move |j: usize| weights.c(j);
118 let c: Option<&dyn Fn(usize) -> f64> = match self.seasonality {
119 Seasonality::Additive => Some(&c),
120 Seasonality::Multiplicative => None,
121 };
122 Some(with_intervals(
123 horizon,
124 level,
125 one_step_mse(data, &self.fitted, self.first_error()),
126 c,
127 |h| self.forecast(h),
128 ))
129 }
130 fn weights(&self) -> InnovationWeights {
132 InnovationWeights {
133 level: self.alpha,
134 slope: self.alpha * self.beta,
135 season: self.gamma * (1.0 - self.alpha),
136 period: self.period,
137 }
138 }
139
140 #[allow(clippy::too_many_arguments)]
148 pub fn first_passage(
149 &self,
150 data: &[f64],
151 horizon: usize,
152 threshold: f64,
153 quantiles: &[f64],
154 paths: usize,
155 seed: u64,
156 ) -> Option<FirstPassage> {
157 if !passage_arguments(horizon, threshold, quantiles, paths) {
158 return None;
159 }
160 if self.seasonality == Seasonality::Multiplicative {
161 return None;
162 }
163 let mse = one_step_mse(data, &self.fitted, self.first_error())?;
164 let means: Vec<f64> = (1..=horizon).map(|h| self.forecast(h)).collect();
165 let weights = self.weights();
166 Some(first_passage(
167 &means, mse, weights, threshold, quantiles, paths, seed,
168 ))
169 }
170}
171
172pub struct HoltWinters {
174 alpha: f64,
175 beta: f64,
176 gamma: f64,
177 period: usize,
178 seasonality: Seasonality,
179}
180
181impl HoltWinters {
182 pub fn new(
193 alpha: f64,
194 beta: f64,
195 gamma: f64,
196 period: usize,
197 seasonality: Seasonality,
198 ) -> Option<Self> {
199 if !alpha.is_finite() || alpha <= 0.0 || alpha >= 1.0 {
200 return None;
201 }
202 if !beta.is_finite() || beta <= 0.0 || beta >= 1.0 {
203 return None;
204 }
205 if !gamma.is_finite() || gamma <= 0.0 || gamma >= 1.0 {
206 return None;
207 }
208 if period < 2 {
209 return None;
210 }
211 Some(Self {
212 alpha,
213 beta,
214 gamma,
215 period,
216 seasonality,
217 })
218 }
219
220 pub fn fit(
227 data: &[f64],
228 alpha: Option<f64>,
229 beta: Option<f64>,
230 gamma: Option<f64>,
231 period: usize,
232 seasonality: Seasonality,
233 ) -> Option<Self> {
234 let probe = Self::new(
235 alpha.unwrap_or(0.5),
236 beta.unwrap_or(0.5),
237 gamma.unwrap_or(0.5),
238 period,
239 seasonality,
240 )?;
241 probe.smooth(data)?;
242 let c = super::estimate::least_squares(&[alpha, beta, gamma], |c| {
243 let r = Self {
244 alpha: c[0],
245 beta: c[1],
246 gamma: c[2],
247 period,
248 seasonality,
249 }
250 .smooth(data)
251 .expect("checked by the probe above");
252 super::estimate::one_step_sse(data, &r.fitted, period)
253 });
254 Self::new(c[0], c[1], c[2], period, seasonality)
255 }
256
257 pub fn period(&self) -> usize {
259 self.period
260 }
261
262 pub fn seasonality(&self) -> Seasonality {
264 self.seasonality
265 }
266
267 pub fn smooth(&self, data: &[f64]) -> Option<HoltWintersResult> {
273 let m = self.period;
274 let n = data.len();
275
276 if n < 2 * m {
277 return None;
278 }
279
280 if self.seasonality == Seasonality::Multiplicative && data.iter().any(|&x| x <= 0.0) {
282 return None;
283 }
284
285 let l0: f64 = data[..m].iter().sum::<f64>() / m as f64;
287 let t0: f64 = (0..m)
288 .map(|i| (data[m + i] - data[i]) / m as f64)
289 .sum::<f64>()
290 / m as f64;
291
292 let mut seasonal = vec![0.0; n];
294 match self.seasonality {
295 Seasonality::Additive => {
296 for i in 0..m {
297 seasonal[i] = data[i] - l0;
298 }
299 }
300 Seasonality::Multiplicative => {
301 for i in 0..m {
302 seasonal[i] = data[i] / l0;
303 }
304 }
305 }
306
307 let mut level = vec![0.0; n];
308 let mut trend = vec![0.0; n];
309 let mut fitted = vec![0.0; n];
310
311 for i in 0..m {
313 level[i] = l0;
314 trend[i] = t0;
315 fitted[i] = match self.seasonality {
316 Seasonality::Additive => l0 + seasonal[i],
317 Seasonality::Multiplicative => l0 * seasonal[i],
318 };
319 }
320
321 for t in m..n {
323 let s_prev = seasonal[t - m];
324
325 let l = match self.seasonality {
326 Seasonality::Additive => {
327 self.alpha * (data[t] - s_prev)
328 + (1.0 - self.alpha) * (level[t - 1] + trend[t - 1])
329 }
330 Seasonality::Multiplicative => {
331 self.alpha * (data[t] / s_prev)
332 + (1.0 - self.alpha) * (level[t - 1] + trend[t - 1])
333 }
334 };
335
336 let b = self.beta * (l - level[t - 1]) + (1.0 - self.beta) * trend[t - 1];
337
338 let s = match self.seasonality {
339 Seasonality::Additive => self.gamma * (data[t] - l) + (1.0 - self.gamma) * s_prev,
340 Seasonality::Multiplicative => {
341 self.gamma * (data[t] / l) + (1.0 - self.gamma) * s_prev
342 }
343 };
344
345 level[t] = l;
346 trend[t] = b;
347 seasonal[t] = s;
348
349 fitted[t] = match self.seasonality {
351 Seasonality::Additive => level[t - 1] + trend[t - 1] + s_prev,
352 Seasonality::Multiplicative => (level[t - 1] + trend[t - 1]) * s_prev,
353 };
354 }
355
356 Some(HoltWintersResult {
357 level,
358 trend,
359 seasonal,
360 fitted,
361 alpha: self.alpha,
362 beta: self.beta,
363 gamma: self.gamma,
364 period: m,
365 seasonality: self.seasonality,
366 })
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 fn seasonal_additive_data() -> Vec<f64> {
375 let pattern = [10.0, -5.0, -5.0, 0.0];
377 (0..24)
378 .map(|t| 100.0 + 2.0 * t as f64 + pattern[t % 4])
379 .collect()
380 }
381
382 fn seasonal_multiplicative_data() -> Vec<f64> {
383 let pattern = [1.2, 0.8, 0.9, 1.1];
385 (0..24)
386 .map(|t| (100.0 + 2.0 * t as f64) * pattern[t % 4])
387 .collect()
388 }
389
390 #[test]
391 fn test_hw_additive_basic() {
392 let data = seasonal_additive_data();
393 let hw = HoltWinters::new(0.3, 0.1, 0.3, 4, Seasonality::Additive).unwrap();
394 let result = hw.smooth(&data).unwrap();
395
396 assert_eq!(result.level.len(), 24);
397 assert_eq!(result.trend.len(), 24);
398 assert_eq!(result.seasonal.len(), 24);
399 assert_eq!(result.fitted.len(), 24);
400 }
401
402 #[test]
403 fn test_hw_additive_forecast() {
404 let data = seasonal_additive_data();
405 let hw = HoltWinters::new(0.3, 0.1, 0.3, 4, Seasonality::Additive).unwrap();
406 let result = hw.smooth(&data).unwrap();
407
408 let f1 = result.forecast(1);
410 let f4 = result.forecast(4);
411
412 assert!(f1 > 100.0, "forecast(1) = {f1}");
414 assert!(f4 > f1 - 20.0, "forecast(4) = {f4}");
415 }
416
417 #[test]
418 fn test_hw_multiplicative_basic() {
419 let data = seasonal_multiplicative_data();
420 let hw = HoltWinters::new(0.3, 0.1, 0.3, 4, Seasonality::Multiplicative).unwrap();
421 let result = hw.smooth(&data).unwrap();
422
423 assert_eq!(result.level.len(), 24);
424 assert_eq!(result.fitted.len(), 24);
425 }
426
427 #[test]
428 fn test_hw_fitted_approximates_data() {
429 let data = seasonal_additive_data();
430 let hw = HoltWinters::new(0.5, 0.3, 0.5, 4, Seasonality::Additive).unwrap();
431 let result = hw.smooth(&data).unwrap();
432
433 let mape: f64 = (8..24)
435 .map(|i| ((result.fitted[i] - data[i]) / data[i]).abs())
436 .sum::<f64>()
437 / 16.0;
438
439 assert!(
440 mape < 0.10,
441 "mean absolute percentage error = {mape}, expected < 10%"
442 );
443 }
444
445 #[test]
446 fn test_hw_seasonal_pattern_detected() {
447 let data = seasonal_additive_data();
448 let hw = HoltWinters::new(0.3, 0.1, 0.5, 4, Seasonality::Additive).unwrap();
449 let result = hw.smooth(&data).unwrap();
450
451 let last_cycle: Vec<f64> = (20..24).map(|i| result.seasonal[i]).collect();
453
454 let max_idx = last_cycle
456 .iter()
457 .enumerate()
458 .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
459 .unwrap()
460 .0;
461 assert_eq!(max_idx, 0, "highest seasonal at wrong position");
462 }
463
464 #[test]
465 fn test_hw_insufficient_data() {
466 let hw = HoltWinters::new(0.3, 0.1, 0.3, 4, Seasonality::Additive).unwrap();
467 assert!(hw.smooth(&[1.0; 7]).is_none());
469 assert!(hw.smooth(&[1.0; 8]).is_some());
470 }
471
472 #[test]
473 fn test_hw_multiplicative_rejects_negative() {
474 let hw = HoltWinters::new(0.3, 0.1, 0.3, 4, Seasonality::Multiplicative).unwrap();
475 let data = vec![1.0, 2.0, -1.0, 4.0, 5.0, 6.0, 7.0, 8.0];
476 assert!(hw.smooth(&data).is_none());
477 }
478
479 #[test]
480 fn test_hw_invalid_params() {
481 assert!(HoltWinters::new(0.0, 0.5, 0.5, 4, Seasonality::Additive).is_none());
482 assert!(HoltWinters::new(0.5, 1.0, 0.5, 4, Seasonality::Additive).is_none());
483 assert!(HoltWinters::new(0.5, 0.5, 0.0, 4, Seasonality::Additive).is_none());
484 assert!(HoltWinters::new(0.5, 0.5, 0.5, 1, Seasonality::Additive).is_none());
485 }
486
487 #[test]
488 fn test_hw_trend_detected() {
489 let data = seasonal_additive_data();
490 let hw = HoltWinters::new(0.3, 0.3, 0.3, 4, Seasonality::Additive).unwrap();
491 let result = hw.smooth(&data).unwrap();
492
493 let last_trend = result.trend[23];
495 assert!(
496 last_trend > 1.0 && last_trend < 4.0,
497 "trend = {last_trend}, expected ~2.0"
498 );
499 }
500
501 #[test]
502 fn test_hw_level_tracks_mean() {
503 let data = seasonal_additive_data();
504 let hw = HoltWinters::new(0.3, 0.1, 0.3, 4, Seasonality::Additive).unwrap();
505 let result = hw.smooth(&data).unwrap();
506
507 let last_level = result.level[23];
509 assert!(
510 (last_level - 146.0).abs() < 10.0,
511 "level = {last_level}, expected ~146"
512 );
513 }
514}