Skip to main content

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}