Skip to main content

tsorder_core/
criteria.rs

1//! Classical information criteria over nested AR fits.
2//!
3//! Innovation variances come from the Yule–Walker/Levinson recursion
4//! (same convention as R's `ar.yw`), so every order is costed in one `O(K²)`
5//! pass. An OLS-conditional variant is planned so that the
6//! comparison with the OLS-based Fine–Hwang statistic is apples-to-apples.
7
8use crate::levinson::{burg_from_series, levinson_from_series};
9use crate::linalg::SquareMatrix;
10use crate::{check_input, Error, Result};
11
12/// Penalised-likelihood criteria (all "smaller is better").
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub enum Criterion {
15    /// Akaike (1973): `n log σ̂² + 2k`. Not consistent; prediction-efficient (Shibata 1980).
16    Aic,
17    /// Hurvich & Tsai (1989) small-sample correction: `AIC + 2K(K+1)/(n-K-1)` with
18    /// `K = k + 1` free parameters (the `k` AR coefficients **plus the innovation variance**),
19    /// the convention of `forecast::Arima` without constant. The series is demeaned
20    /// before fitting but the mean is not counted (same as R's `ar()`).
21    Aicc,
22    /// Schwarz (1978): `n log σ̂² + k log n`. Strongly consistent (Hannan 1980).
23    Bic,
24    /// Hannan & Quinn (1979): `n log σ̂² + 2 c k log log n`, `c = 1` here. Minimal consistent penalty.
25    Hq,
26    /// Akaike (1969) final prediction error: `σ̂² (n+k)/(n-k)`. Not consistent.
27    Fpe,
28    /// Parzen (1974, 1977) criterion autoregressive transfer function:
29    /// `CAT(k) = (1/n) Σ_{j=1..k} 1/σ̄²_j − 1/σ̄²_k` with the unbiased-adjusted
30    /// *relative* variances `σ̄²_j = n (σ̂²_j/σ̂²_0) / (n − j)` (innovation variance
31    /// divided by the sample variance, as in the ACF-based recursion Parzen uses),
32    /// and `CAT(0) = −(1 + 1/n)`. The normalisation makes the value dimensionless
33    /// and the argmin scale-invariant. Asymptotically equivalent to AIC; not consistent.
34    Cat,
35    /// Rissanen (1978) two-part minimum description length:
36    /// `(n/2) log σ̂² + (k/2) log n`. In this AR form every value is exactly
37    /// `BIC/2`, so the argmin coincides with BIC's; included so the benchmark
38    /// can report it under its own name.
39    Mdl,
40}
41
42impl Criterion {
43    pub const ALL: [Criterion; 7] = [
44        Criterion::Aic,
45        Criterion::Aicc,
46        Criterion::Bic,
47        Criterion::Hq,
48        Criterion::Fpe,
49        Criterion::Cat,
50        Criterion::Mdl,
51    ];
52
53    pub fn name(self) -> &'static str {
54        match self {
55            Criterion::Aic => "AIC",
56            Criterion::Aicc => "AICc",
57            Criterion::Bic => "BIC",
58            Criterion::Hq => "HQ",
59            Criterion::Fpe => "FPE",
60            Criterion::Cat => "CAT",
61            Criterion::Mdl => "MDL",
62        }
63    }
64
65    /// Number of free parameters counted by the penalty for an AR(`k`) fit: the `k`
66    /// coefficients plus the innovation variance. For AIC/BIC/HQ the variance term is a
67    /// constant shift that does not affect the argmin, so they use `k` directly as in the
68    /// textbook formulas; AICc's correction is nonlinear in `K`, so there it matters.
69    pub fn n_params(k: usize) -> usize {
70        k + 1
71    }
72
73    /// Criterion value for an AR(`k`) fit on `n` observations. `sigma2[j]` must hold the
74    /// innovation variance of the AR(`j`) fit for `j = 0..=k`; every criterion except CAT
75    /// reads only `sigma2[k]`, but CAT accumulates over the whole nested path.
76    pub fn value(self, sigma2: &[f64], k: usize, n: usize) -> f64 {
77        let nf = n as f64;
78        let kf = k as f64;
79        let s2 = sigma2[k];
80        let ll = nf * s2.ln();
81        match self {
82            Criterion::Aic => ll + 2.0 * kf,
83            Criterion::Aicc => {
84                let kp = Self::n_params(k) as f64;
85                ll + 2.0 * kp + 2.0 * kp * (kp + 1.0) / (nf - kp - 1.0).max(1.0)
86            }
87            Criterion::Bic => ll + kf * nf.ln(),
88            Criterion::Hq => ll + 2.0 * kf * nf.ln().ln().max(0.0),
89            Criterion::Fpe => s2 * (nf + kf) / (nf - kf).max(1.0),
90            Criterion::Cat => {
91                if k == 0 {
92                    return -(1.0 + 1.0 / nf);
93                }
94                // 1/σ̄²_j with σ̄²_j = n (σ̂²_j/σ̂²_0) / (n − j).
95                let inv_bar = |j: usize| (nf - j as f64) * sigma2[0] / (nf * sigma2[j]);
96                (1..=k).map(inv_bar).sum::<f64>() / nf - inv_bar(k)
97            }
98            Criterion::Mdl => 0.5 * (ll + kf * nf.ln()),
99        }
100    }
101}
102
103/// How the innovation variances of the nested AR fits are estimated.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
105pub enum IcMethod {
106    /// Yule–Walker via one Levinson–Durbin recursion (the default; same
107    /// convention as R's `ar.yw`).
108    YuleWalker,
109    /// Conditional least squares on the common sample `t = max_order+1 ..= n`,
110    /// so every candidate order is fit to the same observations and the nested
111    /// residual variances `σ̂²_k = RSS_k / (n − max_order)` are directly
112    /// comparable — apples-to-apples with [`crate::Statistic::LongArOls`].
113    /// The criteria formulas still use the series length `n`.
114    Ols,
115    /// Burg (1967; Kay & Marple 1981): minimises forward plus backward
116    /// prediction error power; markedly better than Yule–Walker near the unit
117    /// circle.
118    Burg,
119}
120
121impl IcMethod {
122    pub const ALL: [IcMethod; 3] = [IcMethod::YuleWalker, IcMethod::Ols, IcMethod::Burg];
123
124    pub fn name(self) -> &'static str {
125        match self {
126            IcMethod::YuleWalker => "yule_walker",
127            IcMethod::Ols => "ols",
128            IcMethod::Burg => "burg",
129        }
130    }
131}
132
133/// Result of [`ic_order`].
134#[derive(Debug, Clone)]
135pub struct IcResult {
136    pub criterion: Criterion,
137    /// How the innovation variances were estimated.
138    pub method: IcMethod,
139    /// Selected order (argmin of `values`).
140    pub order: usize,
141    /// Criterion value for each order `0..=max_order`.
142    pub values: Vec<f64>,
143    /// Innovation variance for each order `0..=max_order`.
144    pub sigma2: Vec<f64>,
145}
146
147/// Default maximum order used by R's `ar()`: `min(n-1, 10 log10 n)`.
148pub fn default_max_order(n: usize) -> usize {
149    let m = (10.0 * (n as f64).log10()).floor() as usize;
150    m.min(n.saturating_sub(1)).max(1)
151}
152
153/// Select an AR order by minimising `criterion` over `0..=max_order`, with
154/// Yule–Walker innovation variances. See [`ic_order_with`] for other methods.
155pub fn ic_order(x: &[f64], criterion: Criterion, max_order: Option<usize>) -> Result<IcResult> {
156    ic_order_with(x, criterion, IcMethod::YuleWalker, max_order)
157}
158
159/// Select an AR order by minimising `criterion` over `0..=max_order`, with the
160/// innovation variances of the nested fits estimated by `method`.
161pub fn ic_order_with(
162    x: &[f64],
163    criterion: Criterion,
164    method: IcMethod,
165    max_order: Option<usize>,
166) -> Result<IcResult> {
167    let n = x.len();
168    let k_max = max_order.unwrap_or_else(|| default_max_order(n));
169    let sigma2 = match method {
170        IcMethod::YuleWalker => levinson_from_series(x, k_max)?.sigma2,
171        IcMethod::Burg => burg_from_series(x, k_max)?.sigma2,
172        IcMethod::Ols => ols_sigma2_path(x, k_max)?,
173    };
174    let values: Vec<f64> = (0..sigma2.len())
175        .map(|k| criterion.value(&sigma2, k, n))
176        .collect();
177    let order = argmin(&values);
178    Ok(IcResult {
179        criterion,
180        method,
181        order,
182        values,
183        sigma2,
184    })
185}
186
187/// Residual variances of the nested conditional-OLS AR fits `k = 0..=k_max`,
188/// all on the common sample `t = k_max..n-1` (one Gram matrix, one Cholesky;
189/// each nested system is solved on the leading submatrix of the factor).
190fn ols_sigma2_path(x: &[f64], k_max: usize) -> Result<Vec<f64>> {
191    let n = x.len();
192    check_input(x, 2 * k_max + 2)?;
193    if k_max == 0 {
194        return Err(Error::Invalid("max_order must be >= 1"));
195    }
196    let mean = x.iter().sum::<f64>() / n as f64;
197    let xc: Vec<f64> = x.iter().map(|v| v - mean).collect();
198    let rows = n - k_max;
199    let mut gram = SquareMatrix::zeros(k_max);
200    let mut xty = vec![0.0; k_max];
201    let mut yy = 0.0;
202    for t in 0..rows {
203        let y = xc[k_max + t];
204        yy += y * y;
205        for i in 0..k_max {
206            let xi = xc[k_max + t - i - 1];
207            xty[i] += xi * y;
208            for j in 0..=i {
209                let v = gram.get(i, j) + xi * xc[k_max + t - j - 1];
210                gram.set(i, j, v);
211                gram.set(j, i, v);
212            }
213        }
214    }
215    if yy <= 0.0 {
216        return Err(Error::Singular);
217    }
218    let l = gram.cholesky()?;
219    let mut out = Vec::with_capacity(k_max + 1);
220    out.push(yy / rows as f64);
221    for k in 1..=k_max {
222        let beta = SquareMatrix::cholesky_solve_leading(&l, k, &xty);
223        let explained: f64 = beta.iter().zip(&xty).map(|(b, c)| b * c).sum();
224        // Guard against cancellation when the fit is near-perfect.
225        let rss = (yy - explained).max(f64::EPSILON * yy);
226        out.push(rss / rows as f64);
227    }
228    Ok(out)
229}
230
231/// All criteria in one pass (shares the Levinson recursion).
232pub fn ic_order_all(x: &[f64], max_order: Option<usize>) -> Result<Vec<IcResult>> {
233    let n = x.len();
234    let k_max = max_order.unwrap_or_else(|| default_max_order(n));
235    let l = levinson_from_series(x, k_max)?;
236    Ok(Criterion::ALL
237        .iter()
238        .map(|&c| {
239            let values: Vec<f64> = (0..l.sigma2.len())
240                .map(|k| c.value(&l.sigma2, k, n))
241                .collect();
242            IcResult {
243                criterion: c,
244                method: IcMethod::YuleWalker,
245                order: argmin(&values),
246                values,
247                sigma2: l.sigma2.clone(),
248            }
249        })
250        .collect())
251}
252
253pub(crate) fn argmin(v: &[f64]) -> usize {
254    let mut best = 0;
255    for (i, &x) in v.iter().enumerate() {
256        if x < v[best] {
257            best = i;
258        }
259    }
260    best
261}