1#[derive(Debug, Clone, Copy, PartialEq)]
43pub struct SmoothingForecast {
44 pub step: usize,
46 pub mean: f64,
48 pub lower: Option<f64>,
52 pub upper: Option<f64>,
54 pub cumulative_mean: f64,
56 pub cumulative_lower: Option<f64>,
58 pub cumulative_upper: Option<f64>,
60}
61
62pub(crate) fn one_step_mse(data: &[f64], fitted: &[f64], first: usize) -> Option<f64> {
65 let errors: Vec<f64> = data
66 .iter()
67 .zip(fitted)
68 .skip(first)
69 .map(|(x, f)| x - f)
70 .collect();
71 (!errors.is_empty()).then(|| errors.iter().map(|e| e * e).sum::<f64>() / errors.len() as f64)
72}
73
74pub(crate) fn residuals(data: &[f64], fitted: &[f64], first: usize) -> Vec<Option<f64>> {
77 data.iter()
78 .zip(fitted)
79 .enumerate()
80 .map(|(t, (x, f))| (t >= first).then(|| x - f))
81 .collect()
82}
83
84pub(crate) fn with_intervals(
87 horizon: usize,
88 level: f64,
89 mse: Option<f64>,
90 c: Option<&dyn Fn(usize) -> f64>,
91 mean: impl Fn(usize) -> f64,
92) -> Vec<SmoothingForecast> {
93 let z = u_numflow::special::inverse_normal_cdf(0.5 + level / 2.0);
94 let mut sum_c2 = 0.0;
95 let mut sum_c = 0.0;
97 let mut total_weight = 0.0;
98 let mut total = 0.0;
99 (1..=horizon)
100 .map(|h| {
101 let m = mean(h);
102 total += m;
103 let (half, total_half) = match (mse, c) {
104 (Some(mse), Some(c)) => {
105 if h > 1 {
106 let cj = c(h - 1);
107 sum_c2 += cj.powi(2);
108 sum_c += cj;
109 }
110 total_weight += (1.0 + sum_c).powi(2);
111 (
112 Some(z * (mse * (1.0 + sum_c2)).sqrt()),
113 Some(z * (mse * total_weight).sqrt()),
114 )
115 }
116 _ => (None, None),
117 };
118 SmoothingForecast {
119 step: h,
120 mean: m,
121 lower: half.map(|w| m - w),
122 upper: half.map(|w| m + w),
123 cumulative_mean: total,
124 cumulative_lower: total_half.map(|w| total - w),
125 cumulative_upper: total_half.map(|w| total + w),
126 }
127 })
128 .collect()
129}
130
131pub(crate) fn valid_level(level: f64) -> bool {
133 level > 0.0 && level < 1.0
134}
135
136pub(crate) fn passage_arguments(
139 horizon: usize,
140 threshold: f64,
141 quantiles: &[f64],
142 paths: usize,
143) -> bool {
144 horizon > 0
145 && paths > 0
146 && threshold.is_finite()
147 && threshold != 0.0
148 && quantiles.iter().all(|&q| q > 0.0 && q <= 1.0)
149}
150
151#[derive(Debug, Clone, PartialEq)]
155pub struct FirstPassage {
156 pub threshold: f64,
159 pub probability: f64,
162 pub quantiles: Vec<(f64, Option<f64>)>,
168 pub paths: usize,
170}
171
172#[derive(Debug, Clone, Copy)]
177pub(crate) struct InnovationWeights {
178 pub(crate) level: f64,
179 pub(crate) slope: f64,
180 pub(crate) season: f64,
181 pub(crate) period: usize,
183}
184
185impl InnovationWeights {
186 pub(crate) fn c(&self, j: usize) -> f64 {
187 self.level
188 + self.slope * j as f64
189 + if self.period > 0 && j % self.period == 0 {
190 self.season
191 } else {
192 0.0
193 }
194 }
195}
196
197pub(crate) fn first_passage(
209 means: &[f64],
210 mse: f64,
211 weights: InnovationWeights,
212 threshold: f64,
213 quantiles: &[f64],
214 paths: usize,
215 seed: u64,
216) -> FirstPassage {
217 use u_numflow::distributions::{Normal, Sample};
218 let horizon = means.len();
219 let InnovationWeights {
220 level: a,
221 slope: b,
222 season: g,
223 period: m,
224 } = weights;
225 let noise = (mse > 0.0).then(|| Normal::new(0.0, mse.sqrt()).expect("a finite, positive σ"));
227 let mut rng = u_numflow::random::create_rng(seed);
228 let reached = |total: f64| {
229 if threshold > 0.0 {
230 total >= threshold
231 } else {
232 total <= threshold
233 }
234 };
235 let mut by_residue = vec![0.0_f64; m.max(1)];
237 let mut times: Vec<f64> = Vec::with_capacity(paths);
238 for _ in 0..paths {
239 let draws: Vec<f64> = (0..horizon)
243 .map(|_| noise.as_ref().map_or(0.0, |n| n.sample(&mut rng)))
244 .collect();
245 let (mut s0, mut s1) = (0.0_f64, 0.0_f64);
246 by_residue.iter_mut().for_each(|r| *r = 0.0);
247 let mut total = 0.0_f64;
248 let mut time = f64::INFINITY;
249 for h in 0..horizon {
250 let earlier_season = if m > 0 { by_residue[h % m] } else { 0.0 };
253 let error = draws[h] + a * s0 + b * s1 + g * earlier_season;
254 s0 += draws[h];
255 s1 += s0;
256 if m > 0 {
257 by_residue[h % m] += draws[h];
258 }
259 let next = total + means[h] + error;
260 if reached(next) {
261 let fraction = ((threshold - total) / (next - total)).clamp(0.0, 1.0);
263 time = h as f64 + fraction;
264 break;
265 }
266 total = next;
267 }
268 times.push(time);
269 }
270 times.sort_by(f64::total_cmp);
271 let crossed = times.iter().filter(|t| t.is_finite()).count();
272 let quantiles = quantiles
273 .iter()
274 .map(|&q| {
275 let rank = ((q * paths as f64).ceil() as usize).clamp(1, paths);
277 let t = times[rank - 1];
278 (q, t.is_finite().then_some(t))
279 })
280 .collect();
281 FirstPassage {
282 threshold,
283 probability: crossed as f64 / paths as f64,
284 quantiles,
285 paths,
286 }
287}
288
289#[cfg(test)]
296#[allow(clippy::approx_constant)] mod statsmodels_reference {
298 use super::*;
299 use crate::smoothing::{
300 HoltLinear, HoltResult, HoltWinters, Seasonality, SesResult, SimpleExponentialSmoothing,
301 };
302
303 const SES_DATA: &[f64] = &[
305 49.695, 48.755, 48.385, 48.678, 47.146, 48.813, 49.806, 49.983, 50.957, 49.731, 48.969,
306 49.761, 48.5, 49.557, 50.796, 51.74, 52.288, 55.501, 55.645, 55.541, 55.655, 55.734,
307 56.497, 55.544, 53.348, 52.352, 53.479, 52.5, 53.481, 55.59,
308 ];
309 const SES_FITTED: &[f64] = &[
310 49.695,
311 49.366,
312 49.02265,
313 48.9020225,
314 48.287414625000004,
315 48.47136950625,
316 48.9384901790625,
317 49.30406861639062,
318 49.8825946006539,
319 49.82953649042504,
320 49.52834871877628,
321 49.609776667204585,
322 49.22135483368298,
323 49.338830641893935,
324 49.848839917231054,
325 50.51074594620019,
326 51.13278486503012,
327 52.66166016226957,
328 53.70582910547522,
329 54.34813891855889,
330 54.80554029706328,
331 55.130501193091135,
332 55.60877577550924,
333 55.586104254081,
334 54.80276776515265,
335 53.94499904734923,
336 53.781899380777,
337 53.33323459750505,
338 53.38495248837828,
339 ];
340 const SES_MEAN: &[f64] = &[
341 54.15671911744588,
342 54.15671911744588,
343 54.15671911744588,
344 54.15671911744588,
345 54.15671911744588,
346 54.15671911744588,
347 ];
348 const SES_LOWER: &[f64] = &[
349 51.50271152380141,
350 51.344848484798455,
351 51.195388909183464,
352 51.053118506866284,
353 50.917089987805795,
354 50.786547453385566,
355 ];
356 const SES_UPPER: &[f64] = &[
357 56.81072671109035,
358 56.96858975009331,
359 57.1180493257083,
360 57.26031972802548,
361 57.39634824708597,
362 57.5268907815062,
363 ];
364 const HOLT_DATA: &[f64] = &[
365 18.624, 22.028, 22.022, 22.77, 23.168, 25.438, 23.856, 26.135, 25.839, 25.885, 27.652,
366 29.208, 30.853, 32.14, 31.311, 31.097, 32.108, 31.913, 36.539, 34.939, 34.931, 36.448,
367 37.208, 36.854, 38.229, 38.696, 41.158, 43.842, 42.405, 41.971,
368 ];
369 const HOLT_FITTED: &[f64] = &[
370 25.432,
371 27.199199999999998,
372 28.204383999999997,
373 28.563783679999997,
374 29.4373607936,
375 28.882198198272,
376 29.24092478521344,
377 29.065606754561227,
378 28.724367395805,
379 29.140634388886866,
380 30.018183833625034,
381 31.269498793777935,
382 32.60472786636744,
383 32.97076708061175,
384 32.954889242709385,
385 33.28201140055122,
386 33.29076378321223,
387 35.406274110151855,
388 35.99819837750347,
389 36.26477706771417,
390 37.04618211642346,
391 37.83197057633515,
392 38.08360600617535,
393 38.796218783585445,
394 39.402568947344676,
395 40.89161352981263,
396 43.094671196908386,
397 43.78653210141317,
398 ];
399 const HOLT_MEAN: &[f64] = &[
400 43.88280607600299,
401 44.70529289115808,
402 45.52777970631317,
403 46.35026652146826,
404 47.172753336623344,
405 47.99524015177843,
406 ];
407 const HOLT_LOWER: &[f64] = &[
408 39.881805953904774,
409 40.26724764211682,
410 40.55622511518835,
411 40.758018971303315,
412 40.88214280288195,
413 40.93713118743697,
414 ];
415 const HOLT_UPPER: &[f64] = &[
416 47.88380619810121,
417 49.14333814019934,
418 50.49933429743799,
419 51.9425140716332,
420 53.46336387036474,
421 55.0533491161199,
422 ];
423 const HW_DATA: &[f64] = &[
424 107.195, 99.797, 99.111, 104.953, 111.536, 103.86, 105.869, 111.842, 117.337, 112.456,
425 110.451, 115.115, 124.234, 116.183, 117.719, 123.434, 127.431, 122.666, 123.802, 130.548,
426 135.536, 127.843, 129.797, 133.84, 142.645, 133.886, 134.436, 141.34, 148.881, 140.176,
427 141.191, 145.134,
428 ];
429 const HW_FITTED: &[f64] = &[
430 108.57318749999999,
431 103.53110312499999,
432 104.42071096874999,
433 112.21758513031247,
434 116.37452167699682,
435 110.342324187491,
436 112.08772508093094,
437 118.67183234829378,
438 122.04503373510259,
439 116.55551472827399,
440 116.797425126349,
441 123.78843943015733,
442 129.19938105911905,
443 122.02718587096355,
444 122.78208766197088,
445 129.56537788125988,
446 135.15618721562316,
447 129.1403441795491,
448 129.4136407564024,
449 136.01289275800133,
450 140.4703440485413,
451 134.67208484162995,
452 135.38085344677242,
453 141.08242228813216,
454 147.0508746050361,
455 140.6418295369387,
456 141.43920083128222,
457 147.6014010922094,
458 ];
459 const HW_MEAN: &[f64] = &[
460 152.98701643046797,
461 145.53169868698552,
462 146.42543580819583,
463 152.19971424007915,
464 158.7573450971372,
465 151.30202735365475,
466 ];
467 const HW_LOWER: &[f64] = &[
468 150.4614216897758,
469 142.8721382966387,
470 143.61475408277198,
471 149.22143937008624,
472 155.42143213962436,
473 147.77782707686427,
474 ];
475 const HW_UPPER: &[f64] = &[
476 155.51261117116016,
477 148.19125907733235,
478 149.23611753361968,
479 155.17798911007205,
480 162.09325805465005,
481 154.82622763044523,
482 ];
483
484 fn close(got: f64, want: f64, what: &str) {
485 let tol = 1e-9 * want.abs().max(1.0);
486 assert!((got - want).abs() <= tol, "{what}: {got} vs {want}");
487 }
488
489 fn compare(
490 fitted: &[f64],
491 first: usize,
492 forecasts: &[SmoothingForecast],
493 want_fitted: &[f64],
494 want: [&[f64]; 3],
495 ) {
496 assert_eq!(fitted.len() - first, want_fitted.len());
497 for (got, want) in fitted[first..].iter().zip(want_fitted) {
498 close(*got, *want, "fitted");
499 }
500 for (i, f) in forecasts.iter().enumerate() {
501 close(f.mean, want[0][i], "mean");
502 close(f.lower.expect("interval"), want[1][i], "lower");
503 close(f.upper.expect("interval"), want[2][i], "upper");
504 }
505 }
506
507 #[test]
508 fn ses_matches_ets_ann() {
509 let r = SimpleExponentialSmoothing::new(0.35)
510 .unwrap()
511 .smooth(SES_DATA)
512 .unwrap();
513 let f = r.forecasts(SES_DATA, 6, 0.9).unwrap();
514 compare(
515 &r.fitted,
516 SesResult::FIRST_ERROR,
517 &f,
518 SES_FITTED,
519 [SES_MEAN, SES_LOWER, SES_UPPER],
520 );
521 }
522
523 #[test]
524 fn holt_matches_ets_aan() {
525 let r = HoltLinear::new(0.4, 0.2)
526 .unwrap()
527 .smooth(HOLT_DATA)
528 .unwrap();
529 let f = r.forecasts(HOLT_DATA, 6, 0.9).unwrap();
530 compare(
531 &r.fitted,
532 HoltResult::FIRST_ERROR,
533 &f,
534 HOLT_FITTED,
535 [HOLT_MEAN, HOLT_LOWER, HOLT_UPPER],
536 );
537 }
538
539 #[test]
540 fn additive_holt_winters_matches_ets_aaa() {
541 let r = HoltWinters::new(0.3, 0.1, 0.25, 4, Seasonality::Additive)
542 .unwrap()
543 .smooth(HW_DATA)
544 .unwrap();
545 let f = r.forecasts(HW_DATA, 6, 0.9).unwrap();
546 compare(
547 &r.fitted,
548 r.first_error(),
549 &f,
550 HW_FITTED,
551 [HW_MEAN, HW_LOWER, HW_UPPER],
552 );
553 }
554}
555
556#[cfg(test)]
557mod cumulative {
558 use super::*;
559
560 const SES_03: InnovationWeights = InnovationWeights {
561 level: 0.3,
562 slope: 0.0,
563 season: 0.0,
564 period: 0,
565 };
566
567 fn total_variance_by_covariance(mse: f64, c: &dyn Fn(usize) -> f64, horizon: usize) -> f64 {
570 let w = |h: usize, t: usize| if t == h { 1.0 } else { c(h - t) };
571 let mut v = 0.0;
572 for h in 1..=horizon {
573 for g in 1..=horizon {
574 for t in 1..=h.min(g) {
575 v += w(h, t) * w(g, t);
576 }
577 }
578 }
579 mse * v
580 }
581
582 #[test]
583 fn the_totals_interval_is_the_sum_of_the_error_covariances() {
584 let z = u_numflow::special::inverse_normal_cdf(0.975);
585 let holt_winters =
586 |j: usize| 0.3 * (1.0 + j as f64 * 0.1) + if j % 4 == 0 { 0.2 * 0.7 } else { 0.0 };
587 let cases: [(&str, &dyn Fn(usize) -> f64); 3] = [
588 ("ses", &|_| 0.4),
589 ("holt", &|j| 0.3 * (1.0 + j as f64 * 0.1)),
590 ("holt_winters", &holt_winters),
591 ];
592 for (name, c) in cases {
593 let fs = with_intervals(12, 0.95, Some(2.5), Some(c), |h| 10.0 + h as f64);
594 for f in &fs {
595 let half = z * total_variance_by_covariance(2.5, c, f.step).sqrt();
596 let total: f64 = (1..=f.step).map(|h| 10.0 + h as f64).sum();
597 assert!(
598 (f.cumulative_mean - total).abs() < 1e-9,
599 "{name} h {}",
600 f.step
601 );
602 let lower = f.cumulative_lower.expect("an interval");
603 assert!(
604 (lower - (total - half)).abs() < 1e-9 * total.abs().max(1.0),
605 "{name} h {}",
606 f.step
607 );
608 }
609 assert_eq!(fs[0].cumulative_lower, fs[0].lower);
611 }
612 let fs = with_intervals(3, 0.95, None, None, |_| 1.0);
614 assert!(fs
615 .iter()
616 .all(|f| f.cumulative_lower.is_none() && f.cumulative_upper.is_none()));
617 assert_eq!(fs[2].cumulative_mean, 3.0);
618 }
619
620 #[test]
621 fn without_noise_the_level_is_reached_when_the_forecast_total_reaches_it() {
622 let fp = first_passage(&[760.0; 10], 0.0, SES_03, 4820.0, &[0.1, 0.5, 0.9], 50, 1);
624 assert_eq!(fp.probability, 1.0);
625 for (_, t) in &fp.quantiles {
626 assert!((t.expect("reached") - (6.0 + 260.0 / 760.0)).abs() < 1e-12);
627 }
628 let fp = first_passage(&[760.0; 5], 0.0, SES_03, 4820.0, &[0.5], 10, 1);
630 assert_eq!(fp.probability, 0.0);
631 assert_eq!(fp.quantiles, vec![(0.5, None)]);
632 let fp = first_passage(&[-2.0; 5], 0.0, SES_03, -5.0, &[0.5], 10, 1);
634 assert!((fp.quantiles[0].1.expect("reached") - 2.5).abs() < 1e-12);
635 }
636
637 #[test]
638 fn the_simulated_totals_spread_as_the_closed_form_says() {
639 let c = |j: usize| 0.3 * (1.0 + j as f64 * 0.1);
642 let (mse, horizon, paths) = (4.0, 8, 40_000);
643 let want = total_variance_by_covariance(mse, &c, horizon);
644 let coefficients: Vec<f64> = (1..horizon).map(c).collect();
645 use u_numflow::distributions::{Normal, Sample};
646 let noise = Normal::new(0.0, mse.sqrt()).expect("valid");
647 let mut rng = u_numflow::random::create_rng(9);
648 let totals: Vec<f64> = (0..paths)
649 .map(|_| {
650 let eps: Vec<f64> = (0..horizon).map(|_| noise.sample(&mut rng)).collect();
651 (0..horizon)
652 .map(|h| {
653 eps[h]
654 + (1..=h)
655 .map(|j| coefficients[j - 1] * eps[h - j])
656 .sum::<f64>()
657 })
658 .sum::<f64>()
659 })
660 .collect();
661 let mean = totals.iter().sum::<f64>() / paths as f64;
662 let var = totals.iter().map(|t| (t - mean).powi(2)).sum::<f64>() / (paths - 1) as f64;
663 assert!(
664 (var / want - 1.0).abs() < 0.03,
665 "simulated {var}, closed form {want}"
666 );
667 }
668
669 #[test]
670 fn the_same_seed_gives_the_same_times_and_the_median_sits_near_the_mean_path() {
671 let a = first_passage(
672 &[760.0; 12],
673 400.0,
674 SES_03,
675 4820.0,
676 &[0.1, 0.5, 0.9],
677 4000,
678 42,
679 );
680 let b = first_passage(
681 &[760.0; 12],
682 400.0,
683 SES_03,
684 4820.0,
685 &[0.1, 0.5, 0.9],
686 4000,
687 42,
688 );
689 assert_eq!(a, b);
690 let t = |q: usize| a.quantiles[q].1.expect("reached");
691 assert!(t(0) < t(1) && t(1) < t(2));
692 assert!(
693 (t(1) - (6.0 + 260.0 / 760.0)).abs() < 0.15,
694 "median {}",
695 t(1)
696 );
697 assert_eq!(a.paths, 4000);
698 }
699
700 fn times_by_convolution(
702 means: &[f64],
703 mse: f64,
704 w: InnovationWeights,
705 threshold: f64,
706 paths: usize,
707 seed: u64,
708 ) -> Vec<f64> {
709 use u_numflow::distributions::{Normal, Sample};
710 let noise = Normal::new(0.0, mse.sqrt()).expect("valid");
711 let mut rng = u_numflow::random::create_rng(seed);
712 (0..paths)
713 .map(|_| {
714 let eps: Vec<f64> = (0..means.len()).map(|_| noise.sample(&mut rng)).collect();
715 let mut total = 0.0;
716 for h in 0..means.len() {
717 let next = total
718 + means[h]
719 + eps[h]
720 + (1..=h).map(|j| w.c(j) * eps[h - j]).sum::<f64>();
721 if next >= threshold {
722 return h as f64 + (threshold - total) / (next - total);
723 }
724 total = next;
725 }
726 f64::INFINITY
727 })
728 .collect()
729 }
730
731 #[test]
732 fn the_running_sums_give_the_times_the_convolution_does() {
733 let hw = InnovationWeights {
734 level: 0.3,
735 slope: 0.03,
736 season: 0.14,
737 period: 4,
738 };
739 let means: Vec<f64> = (1..=40).map(|h| 10.0 + (h % 4) as f64).collect();
740 let mut want = times_by_convolution(&means, 9.0, hw, 250.0, 300, 5);
741 want.sort_by(f64::total_cmp);
742 let qs: Vec<f64> = (1..=300).map(|r| (r as f64 - 0.5) / 300.0).collect();
744 let got = first_passage(&means, 9.0, hw, 250.0, &qs, 300, 5);
745 for ((_, t), w) in got.quantiles.iter().zip(&want) {
746 match t {
747 Some(t) => assert!((t - w).abs() < 1e-9, "{t} vs {w}"),
748 None => assert!(w.is_infinite()),
749 }
750 }
751 }
752}