1use std::f64::consts::PI;
13
14#[must_use]
24pub fn rmse(actual: &[f64], predicted: &[f64]) -> Option<f64> {
25 if actual.len() != predicted.len() || actual.is_empty() {
26 return None;
27 }
28 let n = actual.len() as f64;
29 let mse: f64 = actual
30 .iter()
31 .zip(predicted)
32 .map(|(a, p)| (a - p).powi(2))
33 .sum::<f64>()
34 / n;
35 Some(mse.sqrt())
36}
37
38#[must_use]
42pub fn mae(actual: &[f64], predicted: &[f64]) -> Option<f64> {
43 if actual.len() != predicted.len() || actual.is_empty() {
44 return None;
45 }
46 let n = actual.len() as f64;
47 let sum: f64 = actual
48 .iter()
49 .zip(predicted)
50 .map(|(a, p)| (a - p).abs())
51 .sum();
52 Some(sum / n)
53}
54
55#[must_use]
61pub fn mape(actual: &[f64], predicted: &[f64]) -> Option<f64> {
62 if actual.len() != predicted.len() || actual.is_empty() {
63 return None;
64 }
65 let mut sum = 0.0;
66 let mut count = 0usize;
67 for (a, p) in actual.iter().zip(predicted) {
68 if a.abs() > f64::EPSILON {
69 sum += ((a - p) / a).abs();
70 count += 1;
71 }
72 }
73 if count == 0 {
74 return None;
75 }
76 Some(100.0 * sum / count as f64)
77}
78
79#[must_use]
85pub fn smape(actual: &[f64], predicted: &[f64]) -> Option<f64> {
86 if actual.len() != predicted.len() || actual.is_empty() {
87 return None;
88 }
89 let mut sum = 0.0;
90 let mut count = 0usize;
91 for (a, p) in actual.iter().zip(predicted) {
92 let denom = a.abs() + p.abs();
93 if denom > f64::EPSILON {
94 sum += (a - p).abs() / denom;
95 count += 1;
96 }
97 }
98 if count == 0 {
99 return None;
100 }
101 Some(200.0 * sum / count as f64)
102}
103
104#[must_use]
117pub fn residual_acf(residuals: &[f64], max_lag: usize) -> Option<Vec<f64>> {
118 let n = residuals.len();
119 if n < 2 || max_lag >= n {
120 return None;
121 }
122
123 let mean = residuals.iter().sum::<f64>() / n as f64;
124 let var: f64 = residuals.iter().map(|r| (r - mean).powi(2)).sum();
125 if var.abs() < f64::EPSILON {
126 let mut acf = vec![0.0; max_lag + 1];
128 acf[0] = 1.0;
129 return Some(acf);
130 }
131
132 let mut acf = Vec::with_capacity(max_lag + 1);
133 for lag in 0..=max_lag {
134 let cov: f64 = residuals
135 .iter()
136 .skip(lag)
137 .zip(residuals.iter())
138 .map(|(r_t, r_t_k)| (r_t - mean) * (r_t_k - mean))
139 .sum();
140 acf.push(cov / var);
141 }
142 Some(acf)
143}
144
145#[derive(Debug, Clone)]
149pub struct LjungBoxResult {
150 pub q_statistic: f64,
152 pub df: usize,
154 pub p_value: f64,
156 pub significant: bool,
159}
160
161#[must_use]
179pub fn ljung_box(
180 residuals: &[f64],
181 max_lag: usize,
182 n_params: usize,
183 alpha: f64,
184) -> Option<LjungBoxResult> {
185 let n = residuals.len();
186 if max_lag == 0 || max_lag >= n || n_params >= max_lag {
187 return None;
188 }
189
190 let acf = residual_acf(residuals, max_lag)?;
191
192 let n_f = n as f64;
193 let q: f64 = (1..=max_lag)
194 .map(|k| acf[k].powi(2) / (n_f - k as f64))
195 .sum::<f64>()
196 * n_f
197 * (n_f + 2.0);
198
199 let df = max_lag - n_params;
200 let p_value = chi_squared_survival(q, df);
201
202 Some(LjungBoxResult {
203 q_statistic: q,
204 df,
205 p_value,
206 significant: p_value < alpha,
207 })
208}
209
210#[must_use]
226pub fn aic(rss: f64, n: usize, k: usize) -> Option<f64> {
227 let v = mean_residual_variance(rss, n)?;
228 Some(n as f64 * v.ln() + 2.0 * (k as f64))
229}
230
231fn mean_residual_variance(rss: f64, n: usize) -> Option<f64> {
243 if n == 0 || !rss.is_finite() || rss < 0.0 {
244 return None;
245 }
246 Some((rss / n as f64).max(f64::MIN_POSITIVE))
247}
248
249#[must_use]
256pub fn bic(rss: f64, n: usize, k: usize) -> Option<f64> {
257 let v = mean_residual_variance(rss, n)?;
258 let n_f = n as f64;
259 Some(n_f * v.ln() + (k as f64) * n_f.ln())
260}
261
262#[must_use]
268pub fn aicc(rss: f64, n: usize, k: usize) -> Option<f64> {
269 if n <= k + 1 {
270 return None;
271 }
272 let v = mean_residual_variance(rss, n)?;
273 let n_f = n as f64;
274 let k_f = k as f64;
275 let base = n_f * v.ln() + 2.0 * k_f;
276 let correction = 2.0 * k_f * (k_f + 1.0) / (n_f - k_f - 1.0);
277 Some(base + correction)
278}
279
280#[derive(Debug, Clone)]
284pub struct ModelDiagnostics {
285 pub rmse: f64,
287 pub mae: f64,
289 pub mape: Option<f64>,
291 pub smape: Option<f64>,
293 pub acf: Vec<f64>,
295 pub ljung_box: Option<LjungBoxResult>,
297 pub bic: Option<f64>,
299 pub aicc: Option<f64>,
301}
302
303#[must_use]
317pub fn compute_diagnostics(
318 actual: &[f64],
319 predicted: &[f64],
320 n_params: usize,
321 max_lag: Option<usize>,
322) -> Option<ModelDiagnostics> {
323 if actual.len() != predicted.len() || actual.is_empty() {
324 return None;
325 }
326
327 let n = actual.len();
328 let residuals: Vec<f64> = actual.iter().zip(predicted).map(|(a, p)| a - p).collect();
329 let rss: f64 = residuals.iter().map(|r| r * r).sum();
330
331 let lag = max_lag.unwrap_or_else(|| 20.min(n / 5).max(1));
332
333 Some(ModelDiagnostics {
334 rmse: rmse(actual, predicted).unwrap_or(0.0),
335 mae: mae(actual, predicted).unwrap_or(0.0),
336 mape: mape(actual, predicted),
337 smape: smape(actual, predicted),
338 acf: residual_acf(&residuals, lag).unwrap_or_default(),
339 ljung_box: ljung_box(&residuals, lag, n_params, 0.05),
340 bic: bic(rss, n, n_params),
341 aicc: aicc(rss, n, n_params),
342 })
343}
344
345fn chi_squared_survival(x: f64, df: usize) -> f64 {
352 if df == 0 || x < 0.0 {
353 return 1.0;
354 }
355 let a = df as f64 / 2.0;
356 let z = x / 2.0;
357 regularized_upper_gamma(a, z)
358}
359
360fn regularized_upper_gamma(a: f64, z: f64) -> f64 {
365 if z < 0.0 {
366 return 1.0;
367 }
368 if z < a + 1.0 {
369 1.0 - gamma_series(a, z)
370 } else {
371 gamma_cf(a, z)
372 }
373}
374
375fn gamma_series(a: f64, z: f64) -> f64 {
377 let ln_gamma_a = ln_gamma(a);
378 let mut term = 1.0 / a;
379 let mut sum = term;
380 for n in 1..200 {
381 term *= z / (a + n as f64);
382 sum += term;
383 if term.abs() < sum.abs() * 1e-14 {
384 break;
385 }
386 }
387 let log_val = a * z.ln() - z - ln_gamma_a + sum.ln();
388 log_val.exp().min(1.0)
389}
390
391fn gamma_cf(a: f64, z: f64) -> f64 {
410 const TINY: f64 = 1e-300;
411 let ln_gamma_a = ln_gamma(a);
412
413 let mut b = z + 1.0 - a;
414 let mut c = 1.0 / TINY;
415 let mut d = 1.0 / if b.abs() < TINY { TINY } else { b };
416 let mut h = d;
417
418 for i in 1..300 {
419 let an = -(i as f64) * (i as f64 - a);
420 b += 2.0;
421 d = an.mul_add(d, b);
422 if d.abs() < TINY {
423 d = TINY;
424 }
425 c = b + an / c;
426 if c.abs() < TINY {
427 c = TINY;
428 }
429 d = 1.0 / d;
430 let delta = d * c;
431 h *= delta;
432 if (delta - 1.0).abs() < 1e-15 {
433 break;
434 }
435 }
436
437 let log_val = a.mul_add(z.ln(), -z) - ln_gamma_a + h.ln();
438 log_val.exp().clamp(0.0, 1.0)
439}
440
441pub(crate) fn ln_gamma(x: f64) -> f64 {
451 const COEFFS: [f64; 9] = [
453 0.999_999_999_999_809_9,
454 676.520_368_121_885_1,
455 -1_259.139_216_722_402_9,
456 771.323_428_777_653_1,
457 -176.615_029_162_140_6,
458 12.507_343_278_686_905,
459 -0.138_571_095_265_720_12,
460 9.984_369_578_019_572e-6,
461 1.505_632_735_149_311_6e-7,
462 ];
463
464 if x < 0.5 {
465 let log_pi = PI.ln();
467 return log_pi - (PI * x).sin().abs().ln() - ln_gamma(1.0 - x);
468 }
469
470 let xx = x - 1.0;
471 let mut sum = COEFFS[0];
472 for (i, &c) in COEFFS.iter().enumerate().skip(1) {
473 sum += c / (xx + i as f64);
474 }
475
476 let t = xx + 7.5; 0.5 * (2.0 * PI).ln() + (xx + 0.5) * t.ln() - t + sum.ln()
478}
479
480#[cfg(test)]
483mod tests {
484 use super::*;
485
486 #[test]
487 fn rmse_basic() {
488 let actual = vec![1.0, 2.0, 3.0, 4.0, 5.0];
489 let predicted = vec![1.1, 2.2, 2.8, 4.1, 4.9];
490 let r = rmse(&actual, &predicted).unwrap();
491 assert!((r - 0.1414).abs() < 0.01, "rmse = {r}");
492 }
493
494 #[test]
495 fn mae_basic() {
496 let actual = vec![1.0, 2.0, 3.0];
497 let predicted = vec![1.5, 2.5, 2.5];
498 let m = mae(&actual, &predicted).unwrap();
499 assert!((m - 0.5).abs() < 1e-10, "mae = {m}");
500 }
501
502 #[test]
503 fn mape_skips_zeros() {
504 let actual = vec![0.0, 10.0, 20.0];
505 let predicted = vec![1.0, 11.0, 18.0];
506 let m = mape(&actual, &predicted).unwrap();
507 assert!((m - 10.0).abs() < 1e-10, "mape = {m}");
509 }
510
511 #[test]
512 fn smape_basic() {
513 let actual = vec![10.0, 20.0];
514 let predicted = vec![12.0, 18.0];
515 let s = smape(&actual, &predicted).unwrap();
518 assert!(s > 14.0 && s < 15.0, "smape = {s}");
519 }
520
521 #[test]
522 fn acf_lag0_is_one() {
523 let residuals = vec![1.0, -0.5, 0.3, -0.1, 0.7, -0.4];
524 let acf = residual_acf(&residuals, 3).unwrap();
525 assert!((acf[0] - 1.0).abs() < 1e-10);
526 assert_eq!(acf.len(), 4);
527 }
528
529 #[test]
530 fn acf_white_noise_near_zero() {
531 let mut state = 42u64;
534 let residuals: Vec<f64> = (0..500)
535 .map(|_| {
536 state = state.wrapping_mul(1_103_515_245).wrapping_add(12345) & 0x7FFF_FFFF;
537 (state as f64 / 0x7FFF_FFFF as f64) * 2.0 - 1.0
538 })
539 .collect();
540 let acf = residual_acf(&residuals, 10).unwrap();
541 for (lag, &r) in acf.iter().enumerate().skip(1) {
542 assert!(
543 r.abs() < 0.15,
544 "acf at lag {lag} = {r} too large for LCG noise"
545 );
546 }
547 }
548
549 #[test]
550 fn ljung_box_white_noise_not_significant() {
551 let mut state = 7u64;
553 let residuals: Vec<f64> = (0..500)
554 .map(|_| {
555 state = state.wrapping_mul(1_103_515_245).wrapping_add(12345) & 0x7FFF_FFFF;
556 (state as f64 / 0x7FFF_FFFF as f64) * 2.0 - 1.0
557 })
558 .collect();
559 let result = ljung_box(&residuals, 10, 2, 0.05).unwrap();
560 assert!(
561 !result.significant,
562 "LCG noise should not be significant: p = {}",
563 result.p_value
564 );
565 }
566
567 #[test]
568 fn ljung_box_autocorrelated_is_significant() {
569 let mut residuals = vec![0.0; 200];
571 residuals[0] = 1.0;
572 for i in 1..200 {
573 residuals[i] = 0.95 * residuals[i - 1] + 0.1 * ((i * 7 % 13) as f64 - 6.0);
574 }
575 let result = ljung_box(&residuals, 10, 0, 0.05).unwrap();
576 assert!(
577 result.significant,
578 "AR(1) residuals should be significant: p = {}, Q = {}",
579 result.p_value, result.q_statistic
580 );
581 }
582
583 #[test]
584 fn bic_basic() {
585 let val = bic(100.0, 50, 3).unwrap();
586 assert!(val > 45.0 && val < 48.0, "bic = {val}");
588 }
589
590 #[test]
591 fn aicc_basic() {
592 let val = aicc(100.0, 50, 3).unwrap();
593 assert!(val > 40.0 && val < 42.0, "aicc = {val}");
595 }
596
597 #[test]
598 fn aicc_small_sample_returns_none() {
599 assert!(aicc(10.0, 3, 3).is_none());
601 }
602
603 #[test]
604 fn compute_diagnostics_full() {
605 let actual: Vec<f64> = (0..100).map(|i| i as f64 * 0.5 + 10.0).collect();
606 let predicted: Vec<f64> = actual
607 .iter()
608 .enumerate()
609 .map(|(i, &v)| v + 0.1 * ((i * 7 % 11) as f64 - 5.0))
610 .collect();
611
612 let diag = compute_diagnostics(&actual, &predicted, 2, None).unwrap();
613 assert!(diag.rmse > 0.0);
614 assert!(diag.mae > 0.0);
615 assert!(diag.mape.is_some());
616 assert!(diag.smape.is_some());
617 assert!(!diag.acf.is_empty());
618 assert!(diag.bic.is_some());
619 assert!(diag.aicc.is_some());
620 }
621
622 #[test]
623 fn empty_inputs_return_none() {
624 assert!(rmse(&[], &[]).is_none());
625 assert!(mae(&[], &[]).is_none());
626 assert!(mape(&[], &[]).is_none());
627 assert!(residual_acf(&[], 1).is_none());
628 assert!(ljung_box(&[], 1, 0, 0.05).is_none());
629 assert!(bic(0.0, 0, 0).is_none());
630 }
631
632 #[test]
633 fn mismatched_lengths_return_none() {
634 assert!(rmse(&[1.0], &[1.0, 2.0]).is_none());
635 assert!(mae(&[1.0], &[1.0, 2.0]).is_none());
636 }
637
638 #[test]
639 fn ln_gamma_known_values() {
640 assert!((ln_gamma(1.0)).abs() < 1e-10);
642 assert!((ln_gamma(5.0) - 24.0_f64.ln()).abs() < 1e-6);
644 assert!((ln_gamma(0.5) - (PI.sqrt().ln())).abs() < 1e-6);
646 }
647
648 #[test]
649 fn chi_squared_survival_known() {
650 #[rustfmt::skip]
662 const REFERENCE: &[(f64, usize, f64)] = &[
663 (0.1_f64, 1, 0.7518296340458492),
664 (0.5_f64, 1, 0.47950012218695337),
665 (1.0_f64, 1, 0.31731050786291115),
666 (2.0_f64, 1, 0.15729920705028105),
667 (3.84_f64, 1, 0.050043521248705085),
668 (5.0_f64, 1, 0.025347318677468252),
669 (10.0_f64, 1, 0.0015654022580025482),
670 (15.0_f64, 1, 0.00010751117672950056),
671 (25.0_f64, 1, 5.733031437583878e-07),
672 (40.0_f64, 1, 2.5396285894708634e-10),
673 (60.0_f64, 1, 9.485737571073854e-15),
674 (100.0_f64, 1, 1.5239706048320995e-23),
675 (0.1_f64, 2, 0.951229424500714),
676 (0.5_f64, 2, 0.7788007830714049),
677 (1.0_f64, 2, 0.6065306597126334),
678 (2.0_f64, 2, 0.36787944117144245),
679 (3.84_f64, 2, 0.14660696213035015),
680 (5.0_f64, 2, 0.0820849986238988),
681 (10.0_f64, 2, 0.006737946999085468),
682 (15.0_f64, 2, 0.0005530843701478337),
683 (25.0_f64, 2, 3.7266531720786718e-06),
684 (40.0_f64, 2, 2.0611536224385566e-09),
685 (60.0_f64, 2, 9.357622968840163e-14),
686 (100.0_f64, 2, 1.9287498479639183e-22),
687 (0.1_f64, 3, 0.9918374237318764),
688 (0.5_f64, 3, 0.9188914116546758),
689 (1.0_f64, 3, 0.8012519569012009),
690 (2.0_f64, 3, 0.5724067044708798),
691 (3.84_f64, 3, 0.2792676171186097),
692 (5.0_f64, 3, 0.17179714429673323),
693 (10.0_f64, 3, 0.01856613546304323),
694 (15.0_f64, 3, 0.0018166489665723223),
695 (25.0_f64, 3, 1.544049829110137e-05),
696 (40.0_f64, 3, 1.0655090334255846e-08),
697 (60.0_f64, 3, 5.878230727906919e-13),
698 (100.0_f64, 3, 1.5541594313896026e-21),
699 (0.1_f64, 5, 0.9998376833880774),
700 (0.5_f64, 5, 0.9921232932326296),
701 (1.0_f64, 5, 0.9625657732472964),
702 (2.0_f64, 5, 0.8491450360846096),
703 (3.84_f64, 5, 0.5726744598320888),
704 (5.0_f64, 5, 0.41588018699550783),
705 (10.0_f64, 5, 0.07523524614651216),
706 (15.0_f64, 5, 0.010362337915786436),
707 (25.0_f64, 5, 0.0001393337911856263),
708 (40.0_f64, 5, 1.493367900050396e-07),
709 (60.0_f64, 5, 1.2154569777183006e-11),
710 (100.0_f64, 5, 5.285148360943219e-20),
711 (0.1_f64, 10, 0.9999999975020487),
712 (0.5_f64, 10, 0.999993388289439),
713 (1.0_f64, 10, 0.9998278843700441),
714 (2.0_f64, 10, 0.9963401531726563),
715 (3.84_f64, 10, 0.9542763043207358),
716 (5.0_f64, 10, 0.8911780189141513),
717 (10.0_f64, 10, 0.44049328506521246),
718 (15.0_f64, 10, 0.13206185628772055),
719 (25.0_f64, 10, 0.005345505487134069),
720 (40.0_f64, 10, 1.694474393006737e-05),
721 (60.0_f64, 10, 3.6243009520614924e-09),
722 (100.0_f64, 10, 5.4497019829205215e-17),
723 (0.1_f64, 20, 1.0),
724 (0.5_f64, 20, 0.9999999999997906),
725 (1.0_f64, 20, 0.999999999829033),
726 (2.0_f64, 20, 0.9999998885745217),
727 (3.84_f64, 20, 0.9999667953164147),
728 (5.0_f64, 20, 0.9997226479053791),
729 (10.0_f64, 20, 0.9681719426937951),
730 (15.0_f64, 20, 0.7764076130197146),
731 (25.0_f64, 20, 0.20143110494553587),
732 (40.0_f64, 20, 0.0049954123083075785),
733 (60.0_f64, 20, 7.12175086281558e-06),
734 (100.0_f64, 20, 1.2596084591660936e-12),
735 (0.1_f64, 50, 1.0),
736 (0.5_f64, 50, 1.0),
737 (1.0_f64, 50, 1.0),
738 (2.0_f64, 50, 1.0),
739 (3.84_f64, 50, 1.0),
740 (5.0_f64, 50, 1.0),
741 (10.0_f64, 50, 0.9999999998400414),
742 (15.0_f64, 50, 0.999999625021075),
743 (25.0_f64, 50, 0.9988075511517683),
744 (40.0_f64, 50, 0.8432273781737623),
745 (60.0_f64, 50, 0.1572420272383916),
746 (100.0_f64, 50, 3.4549313829848465e-05),
747 ];
748
749 for &(x, df, expected) in REFERENCE {
750 let got = chi_squared_survival(x, df);
751 let err = if expected > 1e-12 {
752 (got - expected).abs() / expected
753 } else {
754 (got - expected).abs()
755 };
756 assert!(
757 err < 1e-10,
758 "sf({x}, {df}) = {got}, expected {expected} (relative error {err:.3e})"
759 );
760 }
761
762 for df in 1..40 {
764 assert!((chi_squared_survival(0.0, df) - 1.0).abs() < 1e-12);
765 let mut prev = 1.0;
766 for k in 1..80 {
767 let p = chi_squared_survival(f64::from(k), df);
768 assert!(p <= prev + 1e-12, "sf is not monotone at x={k}, df={df}");
769 assert!(
770 (0.0..=1.0).contains(&p),
771 "sf({k}, {df}) = {p} is not a probability"
772 );
773 prev = p;
774 }
775 }
776 }
777}