Skip to main content

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}