1use super::forecast::{one_step_mse, residuals, valid_level, with_intervals, SmoothingForecast};
37
38#[derive(Debug, Clone, Copy, PartialEq)]
40pub enum Seasonality {
41 Additive,
43 Multiplicative,
45}
46
47#[derive(Debug, Clone)]
49pub struct HoltWintersResult {
50 pub level: Vec<f64>,
52 pub trend: Vec<f64>,
54 pub seasonal: Vec<f64>,
57 pub fitted: Vec<f64>,
60 pub alpha: f64,
62 pub beta: f64,
64 pub gamma: f64,
66 pub period: usize,
68 pub seasonality: Seasonality,
70}
71
72impl HoltWintersResult {
73 pub fn forecast(&self, h: usize) -> f64 {
75 let last_l = *self.level.last().expect("level must be non-empty");
76 let last_t = *self.trend.last().expect("trend must be non-empty");
77
78 let s_len = self.seasonal.len();
80 let idx = s_len - self.period + ((h - 1) % self.period);
81 let s = self.seasonal[idx];
82
83 match self.seasonality {
84 Seasonality::Additive => last_l + h as f64 * last_t + s,
85 Seasonality::Multiplicative => (last_l + h as f64 * last_t) * s,
86 }
87 }
88
89 pub fn first_error(&self) -> usize {
92 self.period
93 }
94
95 pub fn residuals(&self, data: &[f64]) -> Vec<Option<f64>> {
97 residuals(data, &self.fitted, self.first_error())
98 }
99
100 pub fn forecasts(
105 &self,
106 data: &[f64],
107 horizon: usize,
108 level: f64,
109 ) -> Option<Vec<SmoothingForecast>> {
110 if !valid_level(level) {
111 return None;
112 }
113 let (alpha, beta, gamma, m) = (self.alpha, self.beta, self.gamma, self.period);
114 let c = move |j: usize| {
115 alpha * (1.0 + j as f64 * beta)
116 + if j % m == 0 {
117 gamma * (1.0 - alpha)
118 } else {
119 0.0
120 }
121 };
122 let c: Option<&dyn Fn(usize) -> f64> = match self.seasonality {
123 Seasonality::Additive => Some(&c),
124 Seasonality::Multiplicative => None,
125 };
126 Some(with_intervals(
127 horizon,
128 level,
129 one_step_mse(data, &self.fitted, self.first_error()),
130 c,
131 |h| self.forecast(h),
132 ))
133 }
134}
135
136pub struct HoltWinters {
138 alpha: f64,
139 beta: f64,
140 gamma: f64,
141 period: usize,
142 seasonality: Seasonality,
143}
144
145impl HoltWinters {
146 pub fn new(
157 alpha: f64,
158 beta: f64,
159 gamma: f64,
160 period: usize,
161 seasonality: Seasonality,
162 ) -> Option<Self> {
163 if !alpha.is_finite() || alpha <= 0.0 || alpha >= 1.0 {
164 return None;
165 }
166 if !beta.is_finite() || beta <= 0.0 || beta >= 1.0 {
167 return None;
168 }
169 if !gamma.is_finite() || gamma <= 0.0 || gamma >= 1.0 {
170 return None;
171 }
172 if period < 2 {
173 return None;
174 }
175 Some(Self {
176 alpha,
177 beta,
178 gamma,
179 period,
180 seasonality,
181 })
182 }
183
184 pub fn fit(
191 data: &[f64],
192 alpha: Option<f64>,
193 beta: Option<f64>,
194 gamma: Option<f64>,
195 period: usize,
196 seasonality: Seasonality,
197 ) -> Option<Self> {
198 let probe = Self::new(
199 alpha.unwrap_or(0.5),
200 beta.unwrap_or(0.5),
201 gamma.unwrap_or(0.5),
202 period,
203 seasonality,
204 )?;
205 probe.smooth(data)?;
206 let c = super::estimate::least_squares(&[alpha, beta, gamma], |c| {
207 let r = Self {
208 alpha: c[0],
209 beta: c[1],
210 gamma: c[2],
211 period,
212 seasonality,
213 }
214 .smooth(data)
215 .expect("checked by the probe above");
216 super::estimate::one_step_sse(data, &r.fitted, period)
217 });
218 Self::new(c[0], c[1], c[2], period, seasonality)
219 }
220
221 pub fn period(&self) -> usize {
223 self.period
224 }
225
226 pub fn seasonality(&self) -> Seasonality {
228 self.seasonality
229 }
230
231 pub fn smooth(&self, data: &[f64]) -> Option<HoltWintersResult> {
237 let m = self.period;
238 let n = data.len();
239
240 if n < 2 * m {
241 return None;
242 }
243
244 if self.seasonality == Seasonality::Multiplicative && data.iter().any(|&x| x <= 0.0) {
246 return None;
247 }
248
249 let l0: f64 = data[..m].iter().sum::<f64>() / m as f64;
251 let t0: f64 = (0..m)
252 .map(|i| (data[m + i] - data[i]) / m as f64)
253 .sum::<f64>()
254 / m as f64;
255
256 let mut seasonal = vec![0.0; n];
258 match self.seasonality {
259 Seasonality::Additive => {
260 for i in 0..m {
261 seasonal[i] = data[i] - l0;
262 }
263 }
264 Seasonality::Multiplicative => {
265 for i in 0..m {
266 seasonal[i] = data[i] / l0;
267 }
268 }
269 }
270
271 let mut level = vec![0.0; n];
272 let mut trend = vec![0.0; n];
273 let mut fitted = vec![0.0; n];
274
275 for i in 0..m {
277 level[i] = l0;
278 trend[i] = t0;
279 fitted[i] = match self.seasonality {
280 Seasonality::Additive => l0 + seasonal[i],
281 Seasonality::Multiplicative => l0 * seasonal[i],
282 };
283 }
284
285 for t in m..n {
287 let s_prev = seasonal[t - m];
288
289 let l = match self.seasonality {
290 Seasonality::Additive => {
291 self.alpha * (data[t] - s_prev)
292 + (1.0 - self.alpha) * (level[t - 1] + trend[t - 1])
293 }
294 Seasonality::Multiplicative => {
295 self.alpha * (data[t] / s_prev)
296 + (1.0 - self.alpha) * (level[t - 1] + trend[t - 1])
297 }
298 };
299
300 let b = self.beta * (l - level[t - 1]) + (1.0 - self.beta) * trend[t - 1];
301
302 let s = match self.seasonality {
303 Seasonality::Additive => self.gamma * (data[t] - l) + (1.0 - self.gamma) * s_prev,
304 Seasonality::Multiplicative => {
305 self.gamma * (data[t] / l) + (1.0 - self.gamma) * s_prev
306 }
307 };
308
309 level[t] = l;
310 trend[t] = b;
311 seasonal[t] = s;
312
313 fitted[t] = match self.seasonality {
315 Seasonality::Additive => level[t - 1] + trend[t - 1] + s_prev,
316 Seasonality::Multiplicative => (level[t - 1] + trend[t - 1]) * s_prev,
317 };
318 }
319
320 Some(HoltWintersResult {
321 level,
322 trend,
323 seasonal,
324 fitted,
325 alpha: self.alpha,
326 beta: self.beta,
327 gamma: self.gamma,
328 period: m,
329 seasonality: self.seasonality,
330 })
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 fn seasonal_additive_data() -> Vec<f64> {
339 let pattern = [10.0, -5.0, -5.0, 0.0];
341 (0..24)
342 .map(|t| 100.0 + 2.0 * t as f64 + pattern[t % 4])
343 .collect()
344 }
345
346 fn seasonal_multiplicative_data() -> Vec<f64> {
347 let pattern = [1.2, 0.8, 0.9, 1.1];
349 (0..24)
350 .map(|t| (100.0 + 2.0 * t as f64) * pattern[t % 4])
351 .collect()
352 }
353
354 #[test]
355 fn test_hw_additive_basic() {
356 let data = seasonal_additive_data();
357 let hw = HoltWinters::new(0.3, 0.1, 0.3, 4, Seasonality::Additive).unwrap();
358 let result = hw.smooth(&data).unwrap();
359
360 assert_eq!(result.level.len(), 24);
361 assert_eq!(result.trend.len(), 24);
362 assert_eq!(result.seasonal.len(), 24);
363 assert_eq!(result.fitted.len(), 24);
364 }
365
366 #[test]
367 fn test_hw_additive_forecast() {
368 let data = seasonal_additive_data();
369 let hw = HoltWinters::new(0.3, 0.1, 0.3, 4, Seasonality::Additive).unwrap();
370 let result = hw.smooth(&data).unwrap();
371
372 let f1 = result.forecast(1);
374 let f4 = result.forecast(4);
375
376 assert!(f1 > 100.0, "forecast(1) = {f1}");
378 assert!(f4 > f1 - 20.0, "forecast(4) = {f4}");
379 }
380
381 #[test]
382 fn test_hw_multiplicative_basic() {
383 let data = seasonal_multiplicative_data();
384 let hw = HoltWinters::new(0.3, 0.1, 0.3, 4, Seasonality::Multiplicative).unwrap();
385 let result = hw.smooth(&data).unwrap();
386
387 assert_eq!(result.level.len(), 24);
388 assert_eq!(result.fitted.len(), 24);
389 }
390
391 #[test]
392 fn test_hw_fitted_approximates_data() {
393 let data = seasonal_additive_data();
394 let hw = HoltWinters::new(0.5, 0.3, 0.5, 4, Seasonality::Additive).unwrap();
395 let result = hw.smooth(&data).unwrap();
396
397 let mape: f64 = (8..24)
399 .map(|i| ((result.fitted[i] - data[i]) / data[i]).abs())
400 .sum::<f64>()
401 / 16.0;
402
403 assert!(
404 mape < 0.10,
405 "mean absolute percentage error = {mape}, expected < 10%"
406 );
407 }
408
409 #[test]
410 fn test_hw_seasonal_pattern_detected() {
411 let data = seasonal_additive_data();
412 let hw = HoltWinters::new(0.3, 0.1, 0.5, 4, Seasonality::Additive).unwrap();
413 let result = hw.smooth(&data).unwrap();
414
415 let last_cycle: Vec<f64> = (20..24).map(|i| result.seasonal[i]).collect();
417
418 let max_idx = last_cycle
420 .iter()
421 .enumerate()
422 .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
423 .unwrap()
424 .0;
425 assert_eq!(max_idx, 0, "highest seasonal at wrong position");
426 }
427
428 #[test]
429 fn test_hw_insufficient_data() {
430 let hw = HoltWinters::new(0.3, 0.1, 0.3, 4, Seasonality::Additive).unwrap();
431 assert!(hw.smooth(&[1.0; 7]).is_none());
433 assert!(hw.smooth(&[1.0; 8]).is_some());
434 }
435
436 #[test]
437 fn test_hw_multiplicative_rejects_negative() {
438 let hw = HoltWinters::new(0.3, 0.1, 0.3, 4, Seasonality::Multiplicative).unwrap();
439 let data = vec![1.0, 2.0, -1.0, 4.0, 5.0, 6.0, 7.0, 8.0];
440 assert!(hw.smooth(&data).is_none());
441 }
442
443 #[test]
444 fn test_hw_invalid_params() {
445 assert!(HoltWinters::new(0.0, 0.5, 0.5, 4, Seasonality::Additive).is_none());
446 assert!(HoltWinters::new(0.5, 1.0, 0.5, 4, Seasonality::Additive).is_none());
447 assert!(HoltWinters::new(0.5, 0.5, 0.0, 4, Seasonality::Additive).is_none());
448 assert!(HoltWinters::new(0.5, 0.5, 0.5, 1, Seasonality::Additive).is_none());
449 }
450
451 #[test]
452 fn test_hw_trend_detected() {
453 let data = seasonal_additive_data();
454 let hw = HoltWinters::new(0.3, 0.3, 0.3, 4, Seasonality::Additive).unwrap();
455 let result = hw.smooth(&data).unwrap();
456
457 let last_trend = result.trend[23];
459 assert!(
460 last_trend > 1.0 && last_trend < 4.0,
461 "trend = {last_trend}, expected ~2.0"
462 );
463 }
464
465 #[test]
466 fn test_hw_level_tracks_mean() {
467 let data = seasonal_additive_data();
468 let hw = HoltWinters::new(0.3, 0.1, 0.3, 4, Seasonality::Additive).unwrap();
469 let result = hw.smooth(&data).unwrap();
470
471 let last_level = result.level[23];
473 assert!(
474 (last_level - 146.0).abs() < 10.0,
475 "level = {last_level}, expected ~146"
476 );
477 }
478}