tsorder_core/eacf.rs
1//! Extended sample autocorrelation function (Tsay & Tiao 1984) and the
2//! triangle-vertex identification of ARMA(p, q).
3//!
4//! The table cell (k, j) is the lag-(j+1) sample autocorrelation of
5//! `W_t = Z_t − Σ_l Φ̂^{(j+1)}_{l(k)} Z_{t−l}`, where the iterated AR
6//! estimates come from their recursion (2.7) over ordinary OLS AR fits (their
7//! Lemma 5.1 shows the residual regressions are not needed). Under ARMA(p, q)
8//! the cells with `row ≥ p` and `col − (row − p) ≥ q` converge to zero — a
9//! triangle whose vertex (p, q) names the order. Cells are flagged with the
10//! paper's crude white-noise band `band / sqrt(n − k − j)` (their 2-standard-
11//! deviation coding; they note it may understate the variance — it is not an
12//! exact studentization).
13
14use crate::linalg::ols;
15use crate::{check_input, Error, Result};
16
17/// Configuration of [`eacf`].
18#[derive(Debug, Clone, Copy)]
19pub struct EacfConfig {
20 /// Rows 0..=max_ar (default 7, matching R's `TSA::eacf`).
21 pub max_ar: usize,
22 /// Columns 0..=max_ma (default 13, matching R's `TSA::eacf`).
23 pub max_ma: usize,
24 /// Band multiplier (2.0 = the paper's coding; raise it for a stricter,
25 /// Fine–Hwang-style cut).
26 pub band: f64,
27}
28
29impl Default for EacfConfig {
30 fn default() -> Self {
31 EacfConfig {
32 max_ar: 7,
33 max_ma: 13,
34 band: 2.0,
35 }
36 }
37}
38
39/// Output of [`eacf`].
40#[derive(Debug, Clone)]
41pub struct EacfResult {
42 /// Vertex of the first all-insignificant triangle, scanning candidates by
43 /// `p + q` then by `p` (the upper-left/simplicity preference); `None` if
44 /// no full triangle inside the table is clean.
45 pub order: Option<(usize, usize)>,
46 /// `table[k][j]` = extended sample autocorrelation (row k, column j).
47 pub table: Vec<Vec<f64>>,
48 /// `significant[k][j]` = |table| above the band (the X of the X/O table).
49 pub significant: Vec<Vec<bool>>,
50}
51
52/// Compute the EACF table and read the order off the zero triangle.
53pub fn eacf(x: &[f64], config: EacfConfig) -> Result<EacfResult> {
54 let n = x.len();
55 let kmax = config.max_ar;
56 let jmax = config.max_ma;
57 let base_max = kmax + jmax + 1;
58 check_input(x, 2 * base_max + 2)?;
59 let mean = x.iter().sum::<f64>() / n as f64;
60 let xc: Vec<f64> = x.iter().map(|v| v - mean).collect();
61
62 // Ordinary OLS AR(k) fits, k = 1..=base_max: phi0[k][l-1], l = 1..=k.
63 let mut layers: Vec<Vec<Vec<f64>>> = Vec::with_capacity(jmax + 2);
64 let mut base: Vec<Vec<f64>> = vec![Vec::new()]; // index 0 unused (AR(0))
65 for k in 1..=base_max {
66 let rows = n - k;
67 let mut design = vec![0.0; rows * k];
68 let mut y = vec![0.0; rows];
69 for t in 0..rows {
70 y[t] = xc[k + t];
71 for l in 0..k {
72 design[t * k + l] = xc[k + t - l - 1];
73 }
74 }
75 let (beta, _s2, _se) = ols(&design, rows, k, &y)?;
76 base.push(beta);
77 }
78 layers.push(base);
79
80 // Iterated layers via recursion (2.7): layer j usable for k <= base_max - j.
81 for j in 1..=jmax + 1 {
82 let prev = &layers[j - 1];
83 let mut layer: Vec<Vec<f64>> = vec![Vec::new()];
84 for k in 1..=base_max - j {
85 let pk1 = &prev[k + 1]; // Φ̂^{(j-1)}_{(k+1)}, length k+1
86 let pk = &prev[k]; // Φ̂^{(j-1)}_{(k)}, length k
87 let denom = pk[k - 1];
88 if denom.abs() < 1e-300 {
89 return Err(Error::Singular);
90 }
91 let ratio = pk1[k] / denom;
92 let mut cur = Vec::with_capacity(k);
93 for l in 1..=k {
94 // Φ̂_{l-1(k)} with the Φ̂_{0} = −1 convention.
95 let prev_lm1 = if l == 1 { -1.0 } else { pk[l - 2] };
96 cur.push(pk1[l - 1] - prev_lm1 * ratio);
97 }
98 layer.push(cur);
99 }
100 layers.push(layer);
101 }
102
103 // Fill the table.
104 let mut table = vec![vec![0.0; jmax + 1]; kmax + 1];
105 let mut significant = vec![vec![false; jmax + 1]; kmax + 1];
106 let mut strong = vec![vec![false; jmax + 1]; kmax + 1];
107 for (k, row) in table.iter_mut().enumerate() {
108 for (j, cell) in row.iter_mut().enumerate() {
109 let lag = j + 1;
110 let r = if k == 0 {
111 acf_lag(&xc, lag)
112 } else {
113 let phi = &layers[lag][k];
114 let w: Vec<f64> = (k..n)
115 .map(|t| {
116 let mut v = xc[t];
117 for (l, p) in phi.iter().enumerate() {
118 v -= p * xc[t - l - 1];
119 }
120 v
121 })
122 .collect();
123 acf_lag(&w, lag)
124 };
125 *cell = r;
126 let se = 1.0 / ((n - k - lag) as f64).sqrt();
127 significant[k][j] = r.abs() > config.band * se;
128 // Structural cells are O(1) while noise is O(1/sqrt(n)) with a
129 // variance the naive band understates by ~1.4x at distant lags;
130 // 3x the band with an absolute 0.2 cap separates the two scales
131 // at every n.
132 strong[k][j] = r.abs() > (3.0 * config.band * se).min(0.2);
133 }
134 }
135
136 // Vertex search: candidates ordered by p + q, then p. A triangle is
137 // accepted when its number of significant cells does not exceed what the
138 // ~5 % per-cell false-positive rate of the 2σ band explains (binomial
139 // mean + 2 sd) — Tsay & Tiao's own examples read the vertex through one
140 // or two stray X's that "barely exceed" the band.
141 let mut order = None;
142 'outer: for total in 0..=kmax + jmax {
143 for p in 0..=total.min(kmax) {
144 let q = total - p;
145 if q > jmax {
146 continue;
147 }
148 // Scale separation does the work: structural (non-zero) cells are
149 // O(1) while noise cells are O(1/√n), and the 2σ band sits between
150 // noise and the stray fluctuations the paper itself warns about.
151 // The triangle must contain no *strong* X (twice the band ≈ 4σ,
152 // where noise essentially never reaches); the vertex contrast uses
153 // the ordinary band: the cell left of the vertex is non-zero under
154 // the true order (eq. 3.5), and for p ≥ 1 the row above must show
155 // structure somewhere.
156 let mut cells = 0usize;
157 let mut clean = true;
158 'tri: for d in 0..=kmax - p {
159 let start = (q + d).min(jmax + 1);
160 for cell in strong[p + d].iter().skip(start) {
161 cells += 1;
162 if *cell {
163 clean = false;
164 break 'tri;
165 }
166 }
167 }
168 let q_contrast = q == 0 || significant[p][q - 1];
169 let p_contrast = p == 0 || (0..=jmax).any(|j| strong[p - 1][j]);
170 if cells >= 3 && clean && q_contrast && p_contrast {
171 order = Some((p, q));
172 break 'outer;
173 }
174 }
175 }
176
177 Ok(EacfResult {
178 order,
179 table,
180 significant,
181 })
182}
183
184fn acf_lag(w: &[f64], lag: usize) -> f64 {
185 let n = w.len();
186 let mean = w.iter().sum::<f64>() / n as f64;
187 let mut num = 0.0;
188 let mut den = 0.0;
189 for t in 0..n {
190 let d = w[t] - mean;
191 den += d * d;
192 if t + lag < n {
193 num += d * (w[t + lag] - mean);
194 }
195 }
196 if den <= 0.0 {
197 0.0
198 } else {
199 num / den
200 }
201}