chronix_analytics/forecast/
diagnostics.rs1use 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 {
393 let ln_gamma_a = ln_gamma(a);
394 let tiny = 1e-30_f64;
395
396 let b0 = z + 1.0 - a;
398 let mut d = 1.0 / b0.max(tiny);
399 let mut f = d;
400 let mut c = b0.max(tiny);
401
402 for n in 1..200 {
403 let an = if n % 2 == 1 {
404 let k = (n + 1) / 2;
405 k as f64 * (a - k as f64)
406 } else {
407 let k = n / 2;
408 -(a - 1.0 + k as f64) * (k as f64)
409 };
410 let bn = z + (2 * n + 1) as f64 - a;
411 d = (bn + an * d).max(tiny);
412 d = 1.0 / d;
413 c = (bn + an / c).max(tiny);
414 let delta = c * d;
415 f *= delta;
416 if (delta - 1.0).abs() < 1e-14 {
417 break;
418 }
419 }
420
421 let log_val = a * z.ln() - z - ln_gamma_a + f.ln();
422 log_val.exp().min(1.0)
423}
424
425fn ln_gamma(x: f64) -> f64 {
427 const COEFFS: [f64; 9] = [
429 0.999_999_999_999_809_9,
430 676.520_368_121_885_1,
431 -1_259.139_216_722_402_9,
432 771.323_428_777_653_1,
433 -176.615_029_162_140_6,
434 12.507_343_278_686_905,
435 -0.138_571_095_265_720_12,
436 9.984_369_578_019_572e-6,
437 1.505_632_735_149_311_6e-7,
438 ];
439
440 if x < 0.5 {
441 let log_pi = PI.ln();
443 return log_pi - (PI * x).sin().abs().ln() - ln_gamma(1.0 - x);
444 }
445
446 let xx = x - 1.0;
447 let mut sum = COEFFS[0];
448 for (i, &c) in COEFFS.iter().enumerate().skip(1) {
449 sum += c / (xx + i as f64);
450 }
451
452 let t = xx + 7.5; 0.5 * (2.0 * PI).ln() + (xx + 0.5) * t.ln() - t + sum.ln()
454}
455
456#[cfg(test)]
459mod tests {
460 use super::*;
461
462 #[test]
463 fn rmse_basic() {
464 let actual = vec![1.0, 2.0, 3.0, 4.0, 5.0];
465 let predicted = vec![1.1, 2.2, 2.8, 4.1, 4.9];
466 let r = rmse(&actual, &predicted).unwrap();
467 assert!((r - 0.1414).abs() < 0.01, "rmse = {r}");
468 }
469
470 #[test]
471 fn mae_basic() {
472 let actual = vec![1.0, 2.0, 3.0];
473 let predicted = vec![1.5, 2.5, 2.5];
474 let m = mae(&actual, &predicted).unwrap();
475 assert!((m - 0.5).abs() < 1e-10, "mae = {m}");
476 }
477
478 #[test]
479 fn mape_skips_zeros() {
480 let actual = vec![0.0, 10.0, 20.0];
481 let predicted = vec![1.0, 11.0, 18.0];
482 let m = mape(&actual, &predicted).unwrap();
483 assert!((m - 10.0).abs() < 1e-10, "mape = {m}");
485 }
486
487 #[test]
488 fn smape_basic() {
489 let actual = vec![10.0, 20.0];
490 let predicted = vec![12.0, 18.0];
491 let s = smape(&actual, &predicted).unwrap();
494 assert!(s > 14.0 && s < 15.0, "smape = {s}");
495 }
496
497 #[test]
498 fn acf_lag0_is_one() {
499 let residuals = vec![1.0, -0.5, 0.3, -0.1, 0.7, -0.4];
500 let acf = residual_acf(&residuals, 3).unwrap();
501 assert!((acf[0] - 1.0).abs() < 1e-10);
502 assert_eq!(acf.len(), 4);
503 }
504
505 #[test]
506 fn acf_white_noise_near_zero() {
507 let mut state = 42u64;
510 let residuals: Vec<f64> = (0..500)
511 .map(|_| {
512 state = state.wrapping_mul(1_103_515_245).wrapping_add(12345) & 0x7FFF_FFFF;
513 (state as f64 / 0x7FFF_FFFF as f64) * 2.0 - 1.0
514 })
515 .collect();
516 let acf = residual_acf(&residuals, 10).unwrap();
517 for (lag, &r) in acf.iter().enumerate().skip(1) {
518 assert!(
519 r.abs() < 0.15,
520 "acf at lag {lag} = {r} too large for LCG noise"
521 );
522 }
523 }
524
525 #[test]
526 fn ljung_box_white_noise_not_significant() {
527 let mut state = 7u64;
529 let residuals: Vec<f64> = (0..500)
530 .map(|_| {
531 state = state.wrapping_mul(1_103_515_245).wrapping_add(12345) & 0x7FFF_FFFF;
532 (state as f64 / 0x7FFF_FFFF as f64) * 2.0 - 1.0
533 })
534 .collect();
535 let result = ljung_box(&residuals, 10, 2, 0.05).unwrap();
536 assert!(
537 !result.significant,
538 "LCG noise should not be significant: p = {}",
539 result.p_value
540 );
541 }
542
543 #[test]
544 fn ljung_box_autocorrelated_is_significant() {
545 let mut residuals = vec![0.0; 200];
547 residuals[0] = 1.0;
548 for i in 1..200 {
549 residuals[i] = 0.95 * residuals[i - 1] + 0.1 * ((i * 7 % 13) as f64 - 6.0);
550 }
551 let result = ljung_box(&residuals, 10, 0, 0.05).unwrap();
552 assert!(
553 result.significant,
554 "AR(1) residuals should be significant: p = {}, Q = {}",
555 result.p_value, result.q_statistic
556 );
557 }
558
559 #[test]
560 fn bic_basic() {
561 let val = bic(100.0, 50, 3).unwrap();
562 assert!(val > 45.0 && val < 48.0, "bic = {val}");
564 }
565
566 #[test]
567 fn aicc_basic() {
568 let val = aicc(100.0, 50, 3).unwrap();
569 assert!(val > 40.0 && val < 42.0, "aicc = {val}");
571 }
572
573 #[test]
574 fn aicc_small_sample_returns_none() {
575 assert!(aicc(10.0, 3, 3).is_none());
577 }
578
579 #[test]
580 fn compute_diagnostics_full() {
581 let actual: Vec<f64> = (0..100).map(|i| i as f64 * 0.5 + 10.0).collect();
582 let predicted: Vec<f64> = actual
583 .iter()
584 .enumerate()
585 .map(|(i, &v)| v + 0.1 * ((i * 7 % 11) as f64 - 5.0))
586 .collect();
587
588 let diag = compute_diagnostics(&actual, &predicted, 2, None).unwrap();
589 assert!(diag.rmse > 0.0);
590 assert!(diag.mae > 0.0);
591 assert!(diag.mape.is_some());
592 assert!(diag.smape.is_some());
593 assert!(!diag.acf.is_empty());
594 assert!(diag.bic.is_some());
595 assert!(diag.aicc.is_some());
596 }
597
598 #[test]
599 fn empty_inputs_return_none() {
600 assert!(rmse(&[], &[]).is_none());
601 assert!(mae(&[], &[]).is_none());
602 assert!(mape(&[], &[]).is_none());
603 assert!(residual_acf(&[], 1).is_none());
604 assert!(ljung_box(&[], 1, 0, 0.05).is_none());
605 assert!(bic(0.0, 0, 0).is_none());
606 }
607
608 #[test]
609 fn mismatched_lengths_return_none() {
610 assert!(rmse(&[1.0], &[1.0, 2.0]).is_none());
611 assert!(mae(&[1.0], &[1.0, 2.0]).is_none());
612 }
613
614 #[test]
615 fn ln_gamma_known_values() {
616 assert!((ln_gamma(1.0)).abs() < 1e-10);
618 assert!((ln_gamma(5.0) - 24.0_f64.ln()).abs() < 1e-6);
620 assert!((ln_gamma(0.5) - (PI.sqrt().ln())).abs() < 1e-6);
622 }
623
624 #[test]
625 fn chi_squared_survival_known() {
626 let p = chi_squared_survival(0.0, 2);
628 assert!((p - 1.0).abs() < 1e-6, "p = {p}");
629
630 let p = chi_squared_survival(4.0, 2);
633 assert!((p - (-2.0_f64).exp()).abs() < 0.01, "p = {p}");
634 }
635}