tsorder_core/select.rs
1//! Fine–Hwang threshold estimators with pluggable statistics, plus an ensemble.
2//!
3//! Every statistic produces, for lags `1..=p_n`, a value and a null standard
4//! error. The estimator clips `|stat_k| / se_k ≤ τ_n` to zero and returns the
5//! last surviving lag ("last non-zero coefficient", Fine & Hwang 1979, Def. 3).
6
7use crate::acov::{acf, autocovariance, bartlett_se};
8use crate::levinson::levinson;
9use crate::linalg::ols;
10use crate::threshold::{polylog_memory, Memory, Threshold};
11use crate::{check_input, Result};
12
13/// Which partial description Σ̂ of the process to threshold.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub enum Statistic {
16 /// Partial autocorrelation (AR order). Self-studentized: `se = 1/sqrt(n)` for lags
17 /// beyond the true order, whatever the process (Quenouille 1949). **Recommended for AR.**
18 Pacf,
19 /// Coefficients of the Yule–Walker long AR(`p_n`) with `se = 1/sqrt(n)` — the literal
20 /// Fine–Hwang 1979 statistic. Scale-dependent; kept for benchmarking.
21 LongArYw,
22 /// Coefficients of the OLS long AR(`p_n`) with classical standard errors. Scale-free.
23 LongArOls,
24 /// Autocorrelation with `se = 1/sqrt(n)` (literal Fine–Hwang MA statistic, §V-D). Scale-dependent.
25 Acf,
26 /// Autocorrelation studentized by Bartlett's formula (MA order). **Recommended for MA.**
27 AcfBartlett,
28 /// Inverse PACF (MA order): PACF of the inverse process, computed from the
29 /// inverse autocovariances of the long Yule–Walker AR(`p_n`) fit (the
30 /// autoregressive estimator of Cleveland 1972; the statistic itself is
31 /// Bhansali 1983, *J. Multivariate Anal.* 13 — this is exactly his
32 /// estimator). An MA(q) process has inverse PACF zero beyond `q` (his
33 /// Theorem 3.1), and his Theorem 4.2 gives the exact published null:
34 /// beyond `q` the estimates are asymptotically iid `N(0, 1/n)` and
35 /// independent of the earlier lags, so `se = 1/sqrt(n)` is the exact
36 /// studentisation (conditions: iid innovations with finite fourth moment,
37 /// invertibility, and a long-AR window growing at least like a multiple
38 /// of `log n` but with `p_n³/n → 0` — the defaults qualify).
39 InversePacf,
40 /// Yule–Walker long-AR coefficients with the exact null studentization of
41 /// Jirak (2012, Thm 2.2): `se_i = sqrt(γ̂*_ii · σ̂²(p_n) / n)` where `γ̂*_ii`
42 /// is the diagonal of the inverse of the estimated autocovariance matrix
43 /// `Γ̂_{p_n}` and `σ̂²(p_n)` the Yule–Walker innovation variance, so each
44 /// `values[i]/se[i]` is asymptotically standard normal under the null.
45 /// Paired with [`Threshold::Gumbel`] this is Jirak's practically
46 /// recommended band (his Remark 2.6 calibrates the threshold by the max of
47 /// `p_n` i.i.d. |N(0,1)|, which is exactly that rule); with `alpha_n → 0`
48 /// it is his consistent estimator q̂⁽¹⁾. Upgrades [`Statistic::LongArYw`],
49 /// whose `1/sqrt(n)` ignores the correlation structure.
50 JirakBand,
51 /// **Experimental.** PACF computed from the Q_n-based robust
52 /// autocorrelations of Ma & Genton (2000) (the bounded ratio form, whose
53 /// shared denominator cancels the additive-outlier bias), with the PSD
54 /// shrinking repair as a safety net ([`crate::robust`]). 25 % breakdown
55 /// point against additive outliers, where the ordinary PACF has none.
56 ///
57 /// Unlike the classical PACF this statistic is *not* self-studentized:
58 /// its null dispersion grows with the serial dependence of the process
59 /// (measured by simulation: ≈1.1/√n on white noise, ≈2.0/√n on a strongly
60 /// correlated AR(2), ≈2.4/√n on an MA(2)). `se = 2.0/sqrt(n)` is a
61 /// conservative empirical compromise pending exact theory, and the default
62 /// memory is the short poly-log window (per-lag estimation errors are
63 /// independent across lags, so long memories break positive definiteness).
64 PacfRobust,
65 /// **Experimental.** Robust autocorrelation (the Ma & Genton 2000 bounded
66 /// Q_n-ratio form, [`crate::robust::robust_autocorrelation`]) studentized
67 /// by Bartlett's formula computed from the robust ACF itself, inflated by
68 /// 1.15. Monte Carlo calibration (white noise, MA(1), MA(2), AR(2);
69 /// n = 500–8000; 400 reps) puts the null sd of
70 /// `ρ̂_rob(k) / se_Bartlett(ρ̂_rob)` at 1.05–1.12 across all designs, so
71 /// unlike [`Statistic::PacfRobust`] the plug-in pivot is nearly
72 /// process-independent and 1.15 is a mild safety margin, pending exact
73 /// theory (the asymptotic variance of the ratio estimator differs from
74 /// the classical ACF's; Lévy-Leduc et al. 2011 give it for Gaussian
75 /// long-memory processes). Targets MA order with a 25 % breakdown point
76 /// against additive outliers, where [`Statistic::AcfBartlett`] has none.
77 AcfRobust,
78}
79
80impl Statistic {
81 pub const ALL: [Statistic; 9] = [
82 Statistic::Pacf,
83 Statistic::LongArYw,
84 Statistic::LongArOls,
85 Statistic::Acf,
86 Statistic::AcfBartlett,
87 Statistic::InversePacf,
88 Statistic::JirakBand,
89 Statistic::PacfRobust,
90 Statistic::AcfRobust,
91 ];
92
93 pub fn name(self) -> &'static str {
94 match self {
95 Statistic::Pacf => "pacf",
96 Statistic::LongArYw => "long_ar_yw",
97 Statistic::LongArOls => "long_ar_ols",
98 Statistic::Acf => "acf",
99 Statistic::AcfBartlett => "acf_bartlett",
100 Statistic::InversePacf => "inverse_pacf",
101 Statistic::JirakBand => "jirak_band",
102 Statistic::PacfRobust => "pacf_robust",
103 Statistic::AcfRobust => "acf_robust",
104 }
105 }
106
107 /// Default growing memory `p_n` used when [`FhConfig::memory`] is `None`:
108 /// the poly-log window `⌈2 log n⌉` ([`polylog_memory`]), which dominated
109 /// the power rules in the benchmark (it reaches a seasonal AR(12) from
110 /// `n = 400`, where `⌊n^0.4⌋` cannot) and is the window covered by the
111 /// uniform LIL bounds for autocovariances. [`Statistic::PacfRobust`] uses
112 /// the shorter `⌈log n⌉` (its per-lag estimation errors are nearly
113 /// independent across lags, so long memories drift out of positive
114 /// definiteness and the deep PACF path turns into boundary noise).
115 pub fn default_memory(self, n: usize) -> usize {
116 match self {
117 Statistic::PacfRobust => polylog_memory(n, 1.0),
118 _ => polylog_memory(n, 2.0),
119 }
120 }
121
122 /// Which model family's order this statistic targets.
123 pub fn target(self) -> &'static str {
124 match self {
125 Statistic::Pacf
126 | Statistic::LongArYw
127 | Statistic::LongArOls
128 | Statistic::JirakBand
129 | Statistic::PacfRobust => "AR",
130 Statistic::Acf
131 | Statistic::AcfBartlett
132 | Statistic::InversePacf
133 | Statistic::AcfRobust => "MA",
134 }
135 }
136
137 /// Compute `(values, se)` for lags `1..=p_n`.
138 pub fn compute(self, x: &[f64], p_n: usize) -> Result<(Vec<f64>, Vec<f64>)> {
139 let n = x.len();
140 let root_n_se = vec![1.0 / (n as f64).sqrt(); p_n];
141 match self {
142 Statistic::Pacf => {
143 let r = autocovariance(x, p_n)?;
144 let l = levinson(&r)?;
145 Ok((l.pacf, root_n_se))
146 }
147 Statistic::LongArYw => {
148 let r = autocovariance(x, p_n)?;
149 let l = levinson(&r)?;
150 Ok((l.phi_last, root_n_se))
151 }
152 Statistic::LongArOls => {
153 // Demean first: the regression has no intercept, so without this
154 // step a level shift (x + 50) leaks into every lag coefficient. The
155 // autocovariance-based statistics demean inside `autocovariance`.
156 let mean = x.iter().sum::<f64>() / n as f64;
157 let xc: Vec<f64> = x.iter().map(|v| v - mean).collect();
158 let rows = n - p_n;
159 let mut design = vec![0.0; rows * p_n];
160 let mut y = vec![0.0; rows];
161 for t in 0..rows {
162 y[t] = xc[p_n + t];
163 for j in 0..p_n {
164 design[t * p_n + j] = xc[p_n + t - j - 1];
165 }
166 }
167 let (beta, _s2, se) = ols(&design, rows, p_n, &y)?;
168 Ok((beta, se))
169 }
170 Statistic::Acf => {
171 let r = acf(x, p_n)?;
172 Ok((r[1..].to_vec(), root_n_se))
173 }
174 Statistic::AcfBartlett => {
175 let r = acf(x, p_n)?;
176 let se = bartlett_se(&r, n);
177 Ok((r[1..].to_vec(), se[1..].to_vec()))
178 }
179 Statistic::InversePacf => {
180 // Long AR fit, its inverse autocovariances (an MA(p_n) sequence,
181 // so PSD and safe for Levinson), then the PACF of that sequence.
182 let r = autocovariance(x, p_n)?;
183 let l = levinson(&r)?;
184 let vi = crate::acov::inverse_acov_from_ar(&l.phi_last);
185 let li = levinson(&vi)?;
186 Ok((li.pacf, root_n_se))
187 }
188 Statistic::JirakBand => {
189 let r = autocovariance(x, p_n)?;
190 let l = levinson(&r)?;
191 // Diagonal of the inverse of the Toeplitz Γ̂_{p_n}.
192 let mut g = crate::linalg::SquareMatrix::zeros(p_n);
193 for i in 0..p_n {
194 for j in 0..p_n {
195 g.set(i, j, r[i.abs_diff(j)]);
196 }
197 }
198 let ch = g.cholesky()?;
199 let diag = crate::linalg::SquareMatrix::cholesky_inverse_diag(&ch);
200 let sigma2 = l.sigma2[p_n];
201 let nf = n as f64;
202 let se = diag.iter().map(|d| (d * sigma2 / nf).sqrt()).collect();
203 Ok((l.phi_last, se))
204 }
205 Statistic::PacfRobust => {
206 let r = crate::robust::robust_autocorrelation(x, p_n)?;
207 let r = crate::robust::shrink_to_psd(&r)?;
208 let l = levinson(&r)?;
209 // Monte Carlo null calibration pending exact theory (see the
210 // enum documentation).
211 let se = vec![2.0 / (n as f64).sqrt(); p_n];
212 Ok((l.pacf, se))
213 }
214 Statistic::AcfRobust => {
215 let r = crate::robust::robust_autocorrelation(x, p_n)?;
216 // Bartlett plug-in from the robust ACF itself, with the
217 // Monte Carlo safety margin (see the enum documentation).
218 let se: Vec<f64> = bartlett_se(&r, n)[1..].iter().map(|s| 1.15 * s).collect();
219 Ok((r[1..].to_vec(), se))
220 }
221 }
222 }
223}
224
225/// Configuration of a single Fine–Hwang estimator.
226#[derive(Debug, Clone, Copy, PartialEq)]
227pub struct FhConfig {
228 pub statistic: Statistic,
229 pub threshold: Threshold,
230 /// Growing memory rule; [`Memory::Default`] uses the statistic's own
231 /// default ([`Statistic::default_memory`]).
232 pub memory: Memory,
233}
234
235impl Default for FhConfig {
236 fn default() -> Self {
237 FhConfig {
238 statistic: Statistic::Pacf,
239 threshold: Threshold::default(),
240 memory: Memory::Default,
241 }
242 }
243}
244
245/// Output of [`fh_order`].
246#[derive(Debug, Clone)]
247pub struct FhResult {
248 pub config: FhConfig,
249 /// Selected order: last lag whose studentized statistic exceeds the threshold (0 if none).
250 pub order: usize,
251 /// Memory actually used.
252 pub p_n: usize,
253 /// Threshold actually used (studentized scale).
254 pub tau: f64,
255 /// Raw statistic per lag `1..=p_n`.
256 pub values: Vec<f64>,
257 /// Studentized statistic per lag `1..=p_n`.
258 pub studentized: Vec<f64>,
259}
260
261/// Fine–Hwang order estimator.
262pub fn fh_order(x: &[f64], config: FhConfig) -> Result<FhResult> {
263 let n = x.len();
264 config.threshold.validate()?;
265 let p_n = config.memory.resolve(n, config.statistic.default_memory(n));
266 if p_n < 1 {
267 return Err(crate::Error::Invalid("memory p_n must be >= 1"));
268 }
269 check_input(x, 2 * p_n + 2)?;
270 let (values, se) = config.statistic.compute(x, p_n)?;
271 let tau = config.threshold.value(n, p_n);
272 if !tau.is_finite() || tau < 0.0 {
273 return Err(crate::Error::Invalid(
274 "threshold evaluated to a non-finite or negative value",
275 ));
276 }
277 let studentized: Vec<f64> = values.iter().zip(&se).map(|(v, s)| v / s).collect();
278 let order = studentized
279 .iter()
280 .rposition(|t| t.abs() > tau)
281 .map(|i| i + 1)
282 .unwrap_or(0);
283 Ok(FhResult {
284 config,
285 order,
286 p_n,
287 tau,
288 values,
289 studentized,
290 })
291}
292
293/// Configuration of [`jirak_order`] (Jirak 2012, q̂⁽⁵⁾).
294#[derive(Debug, Clone, Copy, PartialEq)]
295pub struct JirakConfig {
296 /// Level of the Gumbel band. A fixed `alpha` controls the family-wise
297 /// overestimation probability at ≈ `alpha`; `alpha_n → 0` slowly gives the
298 /// consistent variant (Jirak 2012, Thm 2.4).
299 pub alpha: f64,
300 /// Growing memory rule for the maximal dimension `d_n`.
301 pub memory: Memory,
302}
303
304impl Default for JirakConfig {
305 fn default() -> Self {
306 JirakConfig {
307 alpha: 0.05,
308 memory: Memory::Default,
309 }
310 }
311}
312
313/// Output of [`jirak_order`].
314#[derive(Debug, Clone)]
315pub struct JirakResult {
316 pub config: JirakConfig,
317 /// q̂⁽⁵⁾: the maximum of the per-dimension estimates.
318 pub order: usize,
319 /// Maximal fitting dimension `d_n` actually used.
320 pub p_n: usize,
321 /// Gumbel threshold actually used (studentized scale, normalized by `d_n`).
322 pub tau: f64,
323 /// Per-dimension estimate q̂(k) for `k = 1..=d_n`: the last lag of the
324 /// Yule–Walker AR(`k`) fit whose studentized coefficient exceeds `tau`.
325 pub per_dimension: Vec<usize>,
326}
327
328/// Jirak's (2012, *Ann. Statist.* 40(2), eq. 2.14) refitted order estimator
329/// q̂⁽⁵⁾: fit Yule–Walker AR(`k`) for every dimension `k ≤ d_n`, studentize
330/// each coefficient vector with its exact null standard error
331/// `se_i = sqrt(γ̂*_ii(k) · σ̂²(k) / n)` (the [`Statistic::JirakBand`]
332/// studentization at dimension `k`), apply the common Gumbel threshold
333/// calibrated on the maximal dimension `d_n`, and return the **largest**
334/// per-dimension last-crossing.
335///
336/// The refits recover power that the single full-dimension fit
337/// ([`Statistic::JirakBand`] inside [`fh_order`]) can lose: a coefficient
338/// that is small in the AR(`d_n`) representation may be large in a smaller
339/// one, and under H₀ the extra comparisons are already paid for by the
340/// `d_n`-normalized threshold. Costs `O(d_n⁴)` — negligible for the
341/// poly-log default (`d_n = ⌈2 log n⌉`).
342pub fn jirak_order(x: &[f64], config: JirakConfig) -> Result<JirakResult> {
343 let n = x.len();
344 if !(config.alpha.is_finite() && config.alpha > 0.0 && config.alpha < 1.0) {
345 return Err(crate::Error::Invalid("alpha must be in (0, 1)"));
346 }
347 let p_n = config
348 .memory
349 .resolve(n, Statistic::JirakBand.default_memory(n));
350 if p_n < 1 {
351 return Err(crate::Error::Invalid("memory p_n must be >= 1"));
352 }
353 check_input(x, 2 * p_n + 2)?;
354 let r = autocovariance(x, p_n)?;
355 let tau = Threshold::Gumbel {
356 alpha: config.alpha,
357 }
358 .value(n, p_n);
359 let nf = n as f64;
360 let mut per_dimension = Vec::with_capacity(p_n);
361 for k in 1..=p_n {
362 let l = levinson(&r[..=k])?;
363 let mut g = crate::linalg::SquareMatrix::zeros(k);
364 for i in 0..k {
365 for j in 0..k {
366 g.set(i, j, r[i.abs_diff(j)]);
367 }
368 }
369 let ch = g.cholesky()?;
370 let diag = crate::linalg::SquareMatrix::cholesky_inverse_diag(&ch);
371 let sigma2 = l.sigma2[k];
372 let q_k = l
373 .phi_last
374 .iter()
375 .zip(&diag)
376 .rposition(|(phi, d)| phi.abs() / (d * sigma2 / nf).sqrt() > tau)
377 .map(|i| i + 1)
378 .unwrap_or(0);
379 per_dimension.push(q_k);
380 }
381 let order = per_dimension.iter().copied().max().unwrap_or(0);
382 Ok(JirakResult {
383 config,
384 order,
385 p_n,
386 tau,
387 per_dimension,
388 })
389}
390
391#[cfg(feature = "ensemble")]
392/// How to combine several estimators.
393#[derive(Debug, Clone, Copy, PartialEq, Eq)]
394pub enum EnsembleRule {
395 /// Most frequent order; ties broken towards the smaller order (parsimony).
396 Majority,
397 /// Median order (lower median on even counts).
398 Median,
399 /// Smallest order (most conservative).
400 Min,
401 /// Largest order.
402 Max,
403}
404
405#[cfg(feature = "ensemble")]
406#[derive(Debug, Clone)]
407pub struct EnsembleResult {
408 pub rule: EnsembleRule,
409 pub order: usize,
410 pub members: Vec<FhResult>,
411}
412
413#[cfg(feature = "ensemble")]
414/// Run several Fine–Hwang configurations and combine them.
415///
416/// ⚠️ Removed from the default public API: neither this ensemble nor a
417/// genuine mode-by-consensus beat their best member anywhere in the benchmark
418/// (the mode can fall *below its worst voter* under a parsimonious
419/// tie-break). Kept behind the `ensemble` feature so the benchmark figures
420/// remain reproducible.
421///
422/// Design note: the ensemble is *not* expected to beat the best single
423/// statistic in the well-specified case; its purpose is insurance under misspecification
424/// (contamination, unknown family). The benchmark will decide whether it stays.
425pub fn ensemble_order(
426 x: &[f64],
427 configs: &[FhConfig],
428 rule: EnsembleRule,
429) -> Result<EnsembleResult> {
430 if configs.is_empty() {
431 return Err(crate::Error::Invalid("ensemble needs at least one config"));
432 }
433 let members: Vec<FhResult> = configs
434 .iter()
435 .map(|&c| fh_order(x, c))
436 .collect::<Result<_>>()?;
437 let mut orders: Vec<usize> = members.iter().map(|m| m.order).collect();
438 orders.sort_unstable();
439 let order = match rule {
440 EnsembleRule::Min => orders[0],
441 EnsembleRule::Max => orders[orders.len() - 1],
442 EnsembleRule::Median => orders[(orders.len() - 1) / 2],
443 EnsembleRule::Majority => {
444 let mut best = orders[0];
445 let mut best_count = 0;
446 let mut i = 0;
447 while i < orders.len() {
448 let mut j = i;
449 while j < orders.len() && orders[j] == orders[i] {
450 j += 1;
451 }
452 if j - i > best_count {
453 best_count = j - i;
454 best = orders[i];
455 }
456 i = j;
457 }
458 best
459 }
460 };
461 Ok(EnsembleResult {
462 rule,
463 order,
464 members,
465 })
466}