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}