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
441fn ln_gamma(x: f64) -> f64 {
443 const COEFFS: [f64; 9] = [
445 0.999_999_999_999_809_9,
446 676.520_368_121_885_1,
447 -1_259.139_216_722_402_9,
448 771.323_428_777_653_1,
449 -176.615_029_162_140_6,
450 12.507_343_278_686_905,
451 -0.138_571_095_265_720_12,
452 9.984_369_578_019_572e-6,
453 1.505_632_735_149_311_6e-7,
454 ];
455
456 if x < 0.5 {
457 let log_pi = PI.ln();
459 return log_pi - (PI * x).sin().abs().ln() - ln_gamma(1.0 - x);
460 }
461
462 let xx = x - 1.0;
463 let mut sum = COEFFS[0];
464 for (i, &c) in COEFFS.iter().enumerate().skip(1) {
465 sum += c / (xx + i as f64);
466 }
467
468 let t = xx + 7.5; 0.5 * (2.0 * PI).ln() + (xx + 0.5) * t.ln() - t + sum.ln()
470}
471
472#[cfg(test)]
475mod tests {
476 use super::*;
477
478 #[test]
479 fn rmse_basic() {
480 let actual = vec![1.0, 2.0, 3.0, 4.0, 5.0];
481 let predicted = vec![1.1, 2.2, 2.8, 4.1, 4.9];
482 let r = rmse(&actual, &predicted).unwrap();
483 assert!((r - 0.1414).abs() < 0.01, "rmse = {r}");
484 }
485
486 #[test]
487 fn mae_basic() {
488 let actual = vec![1.0, 2.0, 3.0];
489 let predicted = vec![1.5, 2.5, 2.5];
490 let m = mae(&actual, &predicted).unwrap();
491 assert!((m - 0.5).abs() < 1e-10, "mae = {m}");
492 }
493
494 #[test]
495 fn mape_skips_zeros() {
496 let actual = vec![0.0, 10.0, 20.0];
497 let predicted = vec![1.0, 11.0, 18.0];
498 let m = mape(&actual, &predicted).unwrap();
499 assert!((m - 10.0).abs() < 1e-10, "mape = {m}");
501 }
502
503 #[test]
504 fn smape_basic() {
505 let actual = vec![10.0, 20.0];
506 let predicted = vec![12.0, 18.0];
507 let s = smape(&actual, &predicted).unwrap();
510 assert!(s > 14.0 && s < 15.0, "smape = {s}");
511 }
512
513 #[test]
514 fn acf_lag0_is_one() {
515 let residuals = vec![1.0, -0.5, 0.3, -0.1, 0.7, -0.4];
516 let acf = residual_acf(&residuals, 3).unwrap();
517 assert!((acf[0] - 1.0).abs() < 1e-10);
518 assert_eq!(acf.len(), 4);
519 }
520
521 #[test]
522 fn acf_white_noise_near_zero() {
523 let mut state = 42u64;
526 let residuals: Vec<f64> = (0..500)
527 .map(|_| {
528 state = state.wrapping_mul(1_103_515_245).wrapping_add(12345) & 0x7FFF_FFFF;
529 (state as f64 / 0x7FFF_FFFF as f64) * 2.0 - 1.0
530 })
531 .collect();
532 let acf = residual_acf(&residuals, 10).unwrap();
533 for (lag, &r) in acf.iter().enumerate().skip(1) {
534 assert!(
535 r.abs() < 0.15,
536 "acf at lag {lag} = {r} too large for LCG noise"
537 );
538 }
539 }
540
541 #[test]
542 fn ljung_box_white_noise_not_significant() {
543 let mut state = 7u64;
545 let residuals: Vec<f64> = (0..500)
546 .map(|_| {
547 state = state.wrapping_mul(1_103_515_245).wrapping_add(12345) & 0x7FFF_FFFF;
548 (state as f64 / 0x7FFF_FFFF as f64) * 2.0 - 1.0
549 })
550 .collect();
551 let result = ljung_box(&residuals, 10, 2, 0.05).unwrap();
552 assert!(
553 !result.significant,
554 "LCG noise should not be significant: p = {}",
555 result.p_value
556 );
557 }
558
559 #[test]
560 fn ljung_box_autocorrelated_is_significant() {
561 let mut residuals = vec![0.0; 200];
563 residuals[0] = 1.0;
564 for i in 1..200 {
565 residuals[i] = 0.95 * residuals[i - 1] + 0.1 * ((i * 7 % 13) as f64 - 6.0);
566 }
567 let result = ljung_box(&residuals, 10, 0, 0.05).unwrap();
568 assert!(
569 result.significant,
570 "AR(1) residuals should be significant: p = {}, Q = {}",
571 result.p_value, result.q_statistic
572 );
573 }
574
575 #[test]
576 fn bic_basic() {
577 let val = bic(100.0, 50, 3).unwrap();
578 assert!(val > 45.0 && val < 48.0, "bic = {val}");
580 }
581
582 #[test]
583 fn aicc_basic() {
584 let val = aicc(100.0, 50, 3).unwrap();
585 assert!(val > 40.0 && val < 42.0, "aicc = {val}");
587 }
588
589 #[test]
590 fn aicc_small_sample_returns_none() {
591 assert!(aicc(10.0, 3, 3).is_none());
593 }
594
595 #[test]
596 fn compute_diagnostics_full() {
597 let actual: Vec<f64> = (0..100).map(|i| i as f64 * 0.5 + 10.0).collect();
598 let predicted: Vec<f64> = actual
599 .iter()
600 .enumerate()
601 .map(|(i, &v)| v + 0.1 * ((i * 7 % 11) as f64 - 5.0))
602 .collect();
603
604 let diag = compute_diagnostics(&actual, &predicted, 2, None).unwrap();
605 assert!(diag.rmse > 0.0);
606 assert!(diag.mae > 0.0);
607 assert!(diag.mape.is_some());
608 assert!(diag.smape.is_some());
609 assert!(!diag.acf.is_empty());
610 assert!(diag.bic.is_some());
611 assert!(diag.aicc.is_some());
612 }
613
614 #[test]
615 fn empty_inputs_return_none() {
616 assert!(rmse(&[], &[]).is_none());
617 assert!(mae(&[], &[]).is_none());
618 assert!(mape(&[], &[]).is_none());
619 assert!(residual_acf(&[], 1).is_none());
620 assert!(ljung_box(&[], 1, 0, 0.05).is_none());
621 assert!(bic(0.0, 0, 0).is_none());
622 }
623
624 #[test]
625 fn mismatched_lengths_return_none() {
626 assert!(rmse(&[1.0], &[1.0, 2.0]).is_none());
627 assert!(mae(&[1.0], &[1.0, 2.0]).is_none());
628 }
629
630 #[test]
631 fn ln_gamma_known_values() {
632 assert!((ln_gamma(1.0)).abs() < 1e-10);
634 assert!((ln_gamma(5.0) - 24.0_f64.ln()).abs() < 1e-6);
636 assert!((ln_gamma(0.5) - (PI.sqrt().ln())).abs() < 1e-6);
638 }
639
640 #[test]
641 fn chi_squared_survival_known() {
642 #[rustfmt::skip]
654 const REFERENCE: &[(f64, usize, f64)] = &[
655 (0.1_f64, 1, 0.7518296340458492),
656 (0.5_f64, 1, 0.47950012218695337),
657 (1.0_f64, 1, 0.31731050786291115),
658 (2.0_f64, 1, 0.15729920705028105),
659 (3.84_f64, 1, 0.050043521248705085),
660 (5.0_f64, 1, 0.025347318677468252),
661 (10.0_f64, 1, 0.0015654022580025482),
662 (15.0_f64, 1, 0.00010751117672950056),
663 (25.0_f64, 1, 5.733031437583878e-07),
664 (40.0_f64, 1, 2.5396285894708634e-10),
665 (60.0_f64, 1, 9.485737571073854e-15),
666 (100.0_f64, 1, 1.5239706048320995e-23),
667 (0.1_f64, 2, 0.951229424500714),
668 (0.5_f64, 2, 0.7788007830714049),
669 (1.0_f64, 2, 0.6065306597126334),
670 (2.0_f64, 2, 0.36787944117144245),
671 (3.84_f64, 2, 0.14660696213035015),
672 (5.0_f64, 2, 0.0820849986238988),
673 (10.0_f64, 2, 0.006737946999085468),
674 (15.0_f64, 2, 0.0005530843701478337),
675 (25.0_f64, 2, 3.7266531720786718e-06),
676 (40.0_f64, 2, 2.0611536224385566e-09),
677 (60.0_f64, 2, 9.357622968840163e-14),
678 (100.0_f64, 2, 1.9287498479639183e-22),
679 (0.1_f64, 3, 0.9918374237318764),
680 (0.5_f64, 3, 0.9188914116546758),
681 (1.0_f64, 3, 0.8012519569012009),
682 (2.0_f64, 3, 0.5724067044708798),
683 (3.84_f64, 3, 0.2792676171186097),
684 (5.0_f64, 3, 0.17179714429673323),
685 (10.0_f64, 3, 0.01856613546304323),
686 (15.0_f64, 3, 0.0018166489665723223),
687 (25.0_f64, 3, 1.544049829110137e-05),
688 (40.0_f64, 3, 1.0655090334255846e-08),
689 (60.0_f64, 3, 5.878230727906919e-13),
690 (100.0_f64, 3, 1.5541594313896026e-21),
691 (0.1_f64, 5, 0.9998376833880774),
692 (0.5_f64, 5, 0.9921232932326296),
693 (1.0_f64, 5, 0.9625657732472964),
694 (2.0_f64, 5, 0.8491450360846096),
695 (3.84_f64, 5, 0.5726744598320888),
696 (5.0_f64, 5, 0.41588018699550783),
697 (10.0_f64, 5, 0.07523524614651216),
698 (15.0_f64, 5, 0.010362337915786436),
699 (25.0_f64, 5, 0.0001393337911856263),
700 (40.0_f64, 5, 1.493367900050396e-07),
701 (60.0_f64, 5, 1.2154569777183006e-11),
702 (100.0_f64, 5, 5.285148360943219e-20),
703 (0.1_f64, 10, 0.9999999975020487),
704 (0.5_f64, 10, 0.999993388289439),
705 (1.0_f64, 10, 0.9998278843700441),
706 (2.0_f64, 10, 0.9963401531726563),
707 (3.84_f64, 10, 0.9542763043207358),
708 (5.0_f64, 10, 0.8911780189141513),
709 (10.0_f64, 10, 0.44049328506521246),
710 (15.0_f64, 10, 0.13206185628772055),
711 (25.0_f64, 10, 0.005345505487134069),
712 (40.0_f64, 10, 1.694474393006737e-05),
713 (60.0_f64, 10, 3.6243009520614924e-09),
714 (100.0_f64, 10, 5.4497019829205215e-17),
715 (0.1_f64, 20, 1.0),
716 (0.5_f64, 20, 0.9999999999997906),
717 (1.0_f64, 20, 0.999999999829033),
718 (2.0_f64, 20, 0.9999998885745217),
719 (3.84_f64, 20, 0.9999667953164147),
720 (5.0_f64, 20, 0.9997226479053791),
721 (10.0_f64, 20, 0.9681719426937951),
722 (15.0_f64, 20, 0.7764076130197146),
723 (25.0_f64, 20, 0.20143110494553587),
724 (40.0_f64, 20, 0.0049954123083075785),
725 (60.0_f64, 20, 7.12175086281558e-06),
726 (100.0_f64, 20, 1.2596084591660936e-12),
727 (0.1_f64, 50, 1.0),
728 (0.5_f64, 50, 1.0),
729 (1.0_f64, 50, 1.0),
730 (2.0_f64, 50, 1.0),
731 (3.84_f64, 50, 1.0),
732 (5.0_f64, 50, 1.0),
733 (10.0_f64, 50, 0.9999999998400414),
734 (15.0_f64, 50, 0.999999625021075),
735 (25.0_f64, 50, 0.9988075511517683),
736 (40.0_f64, 50, 0.8432273781737623),
737 (60.0_f64, 50, 0.1572420272383916),
738 (100.0_f64, 50, 3.4549313829848465e-05),
739 ];
740
741 for &(x, df, expected) in REFERENCE {
742 let got = chi_squared_survival(x, df);
743 let err = if expected > 1e-12 {
744 (got - expected).abs() / expected
745 } else {
746 (got - expected).abs()
747 };
748 assert!(
749 err < 1e-10,
750 "sf({x}, {df}) = {got}, expected {expected} (relative error {err:.3e})"
751 );
752 }
753
754 for df in 1..40 {
756 assert!((chi_squared_survival(0.0, df) - 1.0).abs() < 1e-12);
757 let mut prev = 1.0;
758 for k in 1..80 {
759 let p = chi_squared_survival(f64::from(k), df);
760 assert!(p <= prev + 1e-12, "sf is not monotone at x={k}, df={df}");
761 assert!(
762 (0.0..=1.0).contains(&p),
763 "sf({k}, {df}) = {p} is not a probability"
764 );
765 prev = p;
766 }
767 }
768 }
769}