Skip to main content

chronix_analytics/forecast/
diagnostics.rs

1//! Model diagnostics for forecast quality assessment.
2//!
3//! Provides statistical tests and metrics to evaluate fitted forecast models:
4//!
5//! - **[`ljung_box`]** — Ljung-Box Q test for residual autocorrelation
6//! - **[`residual_acf`]** — Sample autocorrelation function of residuals
7//! - **[`bic`]** — Bayesian Information Criterion
8//! - **[`aicc`]** — Corrected Akaike Information Criterion
9//! - **Error metrics**: [`rmse`], [`mae`], [`mape`], [`smape`]
10//! - **[`ModelDiagnostics`]** — All-in-one diagnostics report
11
12use std::f64::consts::PI;
13
14// ── Error Metrics ──────────────────────────────────────────────────
15
16/// Root Mean Squared Error.
17///
18/// $$\text{RMSE} = \sqrt{\frac{1}{n}\sum_{i=1}^{n}(y_i - \hat{y}_i)^2}$$
19///
20/// # Errors
21///
22/// Returns `None` if `actual` and `predicted` have different lengths or are empty.
23#[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/// Mean Absolute Error.
39///
40/// $$\text{MAE} = \frac{1}{n}\sum_{i=1}^{n}|y_i - \hat{y}_i|$$
41#[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/// Mean Absolute Percentage Error.
56///
57/// $$\text{MAPE} = \frac{100}{n}\sum_{i=1}^{n}\left|\frac{y_i - \hat{y}_i}{y_i}\right|$$
58///
59/// Skips zero actual values to avoid division by zero.
60#[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/// Symmetric Mean Absolute Percentage Error.
80///
81/// $$\text{sMAPE} = \frac{200}{n}\sum_{i=1}^{n}\frac{|y_i - \hat{y}_i|}{|y_i| + |\hat{y}_i|}$$
82///
83/// More robust than MAPE when values are near zero.
84#[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// ── Residual ACF ───────────────────────────────────────────────────
105
106/// Compute the sample autocorrelation function (ACF) of `residuals`
107/// for lags `0..=max_lag`.
108///
109/// Returns a vector of length `max_lag + 1` where element `k` is the
110/// autocorrelation at lag `k`.  Element 0 is always 1.0.
111///
112/// # Errors
113///
114/// Returns `None` if `residuals` has fewer than 2 elements or
115/// `max_lag >= residuals.len()`.
116#[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        // All residuals identical — no autocorrelation.
127        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// ── Ljung-Box Test ─────────────────────────────────────────────────
146
147/// Result of the Ljung-Box Q test.
148#[derive(Debug, Clone)]
149pub struct LjungBoxResult {
150    /// Test statistic Q.
151    pub q_statistic: f64,
152    /// Degrees of freedom (lags minus fitted AR/MA parameters).
153    pub df: usize,
154    /// Approximate p-value from chi-squared distribution.
155    pub p_value: f64,
156    /// Whether the null hypothesis (no autocorrelation) is rejected
157    /// at the specified significance level.
158    pub significant: bool,
159}
160
161/// Ljung-Box Q test for residual autocorrelation.
162///
163/// Tests the null hypothesis that the first `max_lag` autocorrelations
164/// are jointly zero.
165///
166/// $$Q = n(n+2)\sum_{k=1}^{h}\frac{\hat{\rho}_k^2}{n-k}$$
167///
168/// # Arguments
169///
170/// - `residuals` — model residuals
171/// - `max_lag`   — number of lags to include
172/// - `n_params`  — number of fitted model parameters (subtracted from df)
173/// - `alpha`     — significance level (e.g. 0.05)
174///
175/// # Errors
176///
177/// Returns `None` if the residuals are too short for the requested lags.
178#[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// ── Information Criteria ───────────────────────────────────────────
211
212/// Akaike Information Criterion.
213///
214/// $$\text{AIC} = n \ln(\text{RSS}/n) + 2k$$
215///
216/// where `rss` is the residual sum of squares, `n` is the sample size, and
217/// `k` is the number of estimated parameters.
218///
219/// **Comparable only between models of the same data.** Two ARIMA orders that
220/// difference the series a different number of times are fitted to different
221/// series, of different lengths and on different scales, and their AIC values
222/// do not rank anything — which is why
223/// [`select_differencing_order`](crate::forecast::select_differencing_order)
224/// exists.
225#[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
231/// Mean residual variance, floored so that a perfect fit still ranks.
232///
233/// A residual sum of squares of exactly zero makes the conditional
234/// log-likelihood unbounded and every information criterion `-∞`. Returning
235/// `None` there is worse than it sounds: a deterministic series — a synthetic
236/// ramp, a constant, a counter that has not moved — then produces *no*
237/// scorable model at all, and [`auto_arima`](crate::forecast::auto_arima)
238/// fails with "nothing could be fitted" on the easiest input there is. The
239/// variance is floored at the smallest positive double instead, so every
240/// perfect fit scores the same enormous negative number and the parameter
241/// penalty picks the simplest of them, which is the intended answer.
242fn 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/// Bayesian Information Criterion.
250///
251/// $$\text{BIC} = n \ln(\text{RSS}/n) + k \ln(n)$$
252///
253/// where `rss` is the residual sum of squares, `n` is the sample size,
254/// and `k` is the number of estimated parameters.
255#[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/// Corrected Akaike Information Criterion (AICc).
263///
264/// $$\text{AICc} = n \ln(\text{RSS}/n) + 2k + \frac{2k(k+1)}{n - k - 1}$$
265///
266/// The correction term prevents overfitting for small samples.
267#[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// ── Composite Diagnostics ──────────────────────────────────────────
281
282/// All-in-one diagnostics report for a fitted forecast model.
283#[derive(Debug, Clone)]
284pub struct ModelDiagnostics {
285    /// RMSE of in-sample residuals.
286    pub rmse: f64,
287    /// MAE of in-sample residuals.
288    pub mae: f64,
289    /// MAPE (if computable — `None` when actuals contain zeros).
290    pub mape: Option<f64>,
291    /// sMAPE.
292    pub smape: Option<f64>,
293    /// Sample autocorrelation function of residuals.
294    pub acf: Vec<f64>,
295    /// Ljung-Box test result.
296    pub ljung_box: Option<LjungBoxResult>,
297    /// BIC.
298    pub bic: Option<f64>,
299    /// AICc.
300    pub aicc: Option<f64>,
301}
302
303/// Compute comprehensive diagnostics from actual values, predictions,
304/// and model metadata.
305///
306/// # Arguments
307///
308/// - `actual`    — observed values
309/// - `predicted` — in-sample fitted values (same length as `actual`)
310/// - `n_params`  — number of estimated model parameters
311/// - `max_lag`   — max ACF / Ljung-Box lag (default: `min(20, n/5)`)
312///
313/// # Errors
314///
315/// Returns `None` if inputs are mismatched or too short.
316#[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
345// ── Chi-squared survival (upper-tail) via regularized gamma ────────
346
347/// Approximate chi-squared survival function P(X > x) for X ~ χ²(df).
348///
349/// Uses the regularized upper incomplete gamma function:
350/// P(X > x) = Γ(df/2, x/2) / Γ(df/2)
351fn 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
360/// Regularized upper incomplete gamma function Q(a, z) = 1 - P(a, z).
361///
362/// Uses Legendre's continued fraction for Q(a, z) when z > a + 1,
363/// and the series expansion for P(a, z) otherwise.
364fn 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
375/// P(a, z) via series expansion: P = e^{-z} z^a / Γ(a) * Σ z^n / (a)_n.
376fn 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
391/// Q(a, z) via Lentz continued fraction.
392fn gamma_cf(a: f64, z: f64) -> f64 {
393    let ln_gamma_a = ln_gamma(a);
394    let tiny = 1e-30_f64;
395
396    // b_0 = z + 1 - a, a_0 = 1
397    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
425/// Lanczos approximation for ln(Γ(x)).
426fn ln_gamma(x: f64) -> f64 {
427    // Coefficients for g = 7, n = 9 (Lanczos).
428    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        // Reflection formula
442        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; // g + 0.5
453    0.5 * (2.0 * PI).ln() + (xx + 0.5) * t.ln() - t + sum.ln()
454}
455
456// ── Tests ──────────────────────────────────────────────────────────
457
458#[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        // Only uses indices 1 and 2: (1/10 + 2/20)/2 * 100 = 10%
484        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        // |10-12|/(10+12) + |20-18|/(20+18) = 2/22 + 2/38
492        // smape = 200/2 * (2/22 + 2/38) = 100 * (0.0909 + 0.0526) = 14.35
493        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        // Use a longer, more varied pseudo-random sequence.
508        // Linear congruential generator (a=1103515245, c=12345, m=2^31).
509        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        // LCG pseudo-random data — should NOT reject null.
528        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        // Highly autocorrelated data.
546        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        // BIC = 50*ln(2) + 3*ln(50) = 34.66 + 11.74 ≈ 46.4
563        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        // AICc = 50*ln(2) + 6 + 24/46 ≈ 34.66 + 6 + 0.52 = 41.18
570        assert!(val > 40.0 && val < 42.0, "aicc = {val}");
571    }
572
573    #[test]
574    fn aicc_small_sample_returns_none() {
575        // n <= k + 1 should return None.
576        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        // Γ(1) = 1 → ln(Γ(1)) = 0
617        assert!((ln_gamma(1.0)).abs() < 1e-10);
618        // Γ(5) = 24 → ln(Γ(5)) ≈ 3.178
619        assert!((ln_gamma(5.0) - 24.0_f64.ln()).abs() < 1e-6);
620        // Γ(0.5) = √π → ln(Γ(0.5)) ≈ 0.5723
621        assert!((ln_gamma(0.5) - (PI.sqrt().ln())).abs() < 1e-6);
622    }
623
624    #[test]
625    fn chi_squared_survival_known() {
626        // For χ²(2), P(X > 0) = 1
627        let p = chi_squared_survival(0.0, 2);
628        assert!((p - 1.0).abs() < 1e-6, "p = {p}");
629
630        // For χ²(2), P(X > x) = e^{-x/2}
631        // P(X > 4) = e^{-2} ≈ 0.1353
632        let p = chi_squared_survival(4.0, 2);
633        assert!((p - (-2.0_f64).exp()).abs() < 0.01, "p = {p}");
634    }
635}