Skip to main content

tsorder_core/
cancorr.rs

1//! Canonical correlations between the past and the future of a stationary
2//! series — the weighted (CCA) subspace step of Bauer (2001). The number of
3//! non-zero canonical correlations is the state dimension (McMillan degree),
4//! which is what the SVC criterion and the Gumbel-thresholded variant read
5//! off the estimated correlations.
6//!
7//! The blocks are built from the sample autocovariances: the joint vector
8//! `(x_{t+f-1}, …, x_t, x_{t-1}, …, x_{t-p})` is contiguous, so its covariance
9//! is the `(f+p) × (f+p)` Toeplitz matrix of `γ̂(0..f+p-1)`, which the biased
10//! estimator keeps positive semi-definite — hence all estimated canonical
11//! correlations are guaranteed to lie in `[0, 1]`.
12
13use crate::acov::autocovariance;
14use crate::criteria::{ic_order, Criterion};
15use crate::linalg::{singular_values, SquareMatrix};
16use crate::{check_input, Error, Result};
17
18/// Output of [`canonical_correlations`].
19#[derive(Debug, Clone)]
20pub struct Cancorr {
21    /// Estimated canonical correlations, descending, length `min(f, p)`.
22    pub correlations: Vec<f64>,
23    pub f: usize,
24    pub p: usize,
25}
26
27/// Canonical correlations between `(x_t, …, x_{t+f-1})` and
28/// `(x_{t-1}, …, x_{t-p})`, from the biased sample autocovariances.
29pub fn canonical_correlations(x: &[f64], f: usize, p: usize) -> Result<Cancorr> {
30    if f < 1 || p < 1 {
31        return Err(Error::Invalid("window sizes f and p must be >= 1"));
32    }
33    let r = autocovariance(x, f + p - 1)?;
34    canonical_correlations_from_acov(&r, f, p)
35}
36
37/// Same, from an autocovariance sequence `r[0..=f+p-1]` (exact inputs make
38/// exact tests possible).
39pub fn canonical_correlations_from_acov(r: &[f64], f: usize, p: usize) -> Result<Cancorr> {
40    if r.len() < f + p {
41        return Err(Error::TooShort {
42            n: r.len(),
43            needed: f + p,
44        });
45    }
46    let toeplitz = |m: usize| -> SquareMatrix {
47        let mut g = SquareMatrix::zeros(m);
48        for i in 0..m {
49            for j in 0..m {
50                g.set(i, j, r[i.abs_diff(j)]);
51            }
52        }
53        g
54    };
55    let lf = toeplitz(f).cholesky()?;
56    let lp = toeplitz(p).cholesky()?;
57    // Γ_fp[i][j] = Cov(x_{t+i}, x_{t-1-j}) = γ(i + j + 1), row-major f × p.
58    let mut gfp = vec![0.0; f * p];
59    for i in 0..f {
60        for j in 0..p {
61            gfp[i * p + j] = r[i + j + 1];
62        }
63    }
64    // X = L_f⁻¹ Γ_fp (forward substitution on each column).
65    let mut xm = vec![0.0; f * p];
66    for j in 0..p {
67        for i in 0..f {
68            let mut s = gfp[i * p + j];
69            for k in 0..i {
70                s -= lf.get(i, k) * xm[k * p + j];
71            }
72            xm[i * p + j] = s / lf.get(i, i);
73        }
74    }
75    // M = X L_p⁻ᵀ: solve L_p mᵀ-row = xᵀ-row for each row of X.
76    let mut m = vec![0.0; f * p];
77    for i in 0..f {
78        for j in 0..p {
79            let mut s = xm[i * p + j];
80            for k in 0..j {
81                s -= lp.get(j, k) * m[i * p + k];
82            }
83            m[i * p + j] = s / lp.get(j, j);
84        }
85    }
86    let mut correlations = singular_values(&m, f, p);
87    correlations.truncate(f.min(p));
88    // Clamp tiny numerical excursions above 1.
89    for c in correlations.iter_mut() {
90        if *c > 1.0 && *c < 1.0 + 1e-10 {
91            *c = 1.0;
92        }
93    }
94    Ok(Cancorr { correlations, f, p })
95}
96
97/// Canonical correlations from the *stacked-sample* covariances, Bauer's
98/// (2001) convention: averages of `Y⁺_{t,f} (Z⁻_{t,p})'` etc. over
99/// `t = p+1 … T−f` (his index range, divisor `1/T` — it cancels in the
100/// correlations). Differs from [`canonical_correlations`] only by edge
101/// effects `O((f+p)/T)`; the sample joint second-moment matrix is an average
102/// of outer products, hence PSD, so correlations stay in `[0, 1]`. The series
103/// is demeaned first (the paper's model is mean-zero; demeaning keeps the
104/// estimator translation-invariant).
105pub fn canonical_correlations_stacked(x: &[f64], f: usize, p: usize) -> Result<Cancorr> {
106    if f < 1 || p < 1 {
107        return Err(Error::Invalid("window sizes f and p must be >= 1"));
108    }
109    check_input(x, 2 * (f + p))?;
110    let t_len = x.len();
111    if t_len < f + p + f + p {
112        return Err(Error::TooShort {
113            n: t_len,
114            needed: 2 * (f + p),
115        });
116    }
117    let mean = x.iter().sum::<f64>() / t_len as f64;
118    let xc: Vec<f64> = x.iter().map(|v| v - mean).collect();
119    let mut gff = SquareMatrix::zeros(f);
120    let mut gpp = SquareMatrix::zeros(p);
121    let mut gfp = vec![0.0; f * p];
122    // Bauer's range: t = p+1 ..= T−f (1-based) → 0-based t = p .. T−f.
123    for t in p..t_len - f {
124        for i in 0..f {
125            let yi = xc[t + i];
126            for j in 0..=i {
127                let v = gff.get(i, j) + yi * xc[t + j];
128                gff.set(i, j, v);
129                gff.set(j, i, v);
130            }
131            for j in 0..p {
132                gfp[i * p + j] += yi * xc[t - 1 - j];
133            }
134        }
135        for i in 0..p {
136            let zi = xc[t - 1 - i];
137            for j in 0..=i {
138                let v = gpp.get(i, j) + zi * xc[t - 1 - j];
139                gpp.set(i, j, v);
140                gpp.set(j, i, v);
141            }
142        }
143    }
144    whitened_correlations(gff, gpp, gfp, f, p)
145}
146
147/// Cholesky-whitened singular values of `Γ_ff^{-1/2} Γ_fp Γ_pp^{-1/2}`
148/// (equal to those of the symmetric-square-root version Bauer analyses).
149fn whitened_correlations(
150    gff: SquareMatrix,
151    gpp: SquareMatrix,
152    gfp: Vec<f64>,
153    f: usize,
154    p: usize,
155) -> Result<Cancorr> {
156    let lf = gff.cholesky()?;
157    let lp = gpp.cholesky()?;
158    let mut xm = vec![0.0; f * p];
159    for j in 0..p {
160        for i in 0..f {
161            let mut s = gfp[i * p + j];
162            for k in 0..i {
163                s -= lf.get(i, k) * xm[k * p + j];
164            }
165            xm[i * p + j] = s / lf.get(i, i);
166        }
167    }
168    let mut m = vec![0.0; f * p];
169    for i in 0..f {
170        for j in 0..p {
171            let mut s = xm[i * p + j];
172            for k in 0..j {
173                s -= lp.get(j, k) * m[i * p + k];
174            }
175            m[i * p + j] = s / lp.get(j, j);
176        }
177    }
178    let mut correlations = singular_values(&m, f, p);
179    correlations.truncate(f.min(p));
180    for c in correlations.iter_mut() {
181        if *c > 1.0 && *c < 1.0 + 1e-10 {
182            *c = 1.0;
183        }
184    }
185    Ok(Cancorr { correlations, f, p })
186}
187
188/// Penalty `C(T)` of the SVC criterion.
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub enum SvcPenalty {
191    /// `C(T) = log T` — Bauer's practical recommendation; not covered by his
192    /// consistency theorem when `f, p` grow (keeps a flat few-percent
193    /// overestimation rate, Table 1).
194    LogT,
195    /// `C(T) = f·p·log T` — satisfies Theorem 3 (consistent), but strongly
196    /// underestimates in finite samples (Table 1).
197    FpLogT,
198}
199
200/// Configuration of [`svc_order`].
201#[derive(Debug, Clone, Copy)]
202pub struct SvcConfig {
203    /// Window `f = p`; `None` → `⌊window_factor · p̂_AIC⌋` with `p̂_AIC` the
204    /// AIC order of a long autoregression (Bauer §5), clamped to `≥ 2`.
205    pub window: Option<usize>,
206    /// Bauer's `d`; he recommends `1 ≤ d ≤ 2`, and the benchmark's Q4 round
207    /// found `d = 1` dominating every DGP tried, so it is the default.
208    pub window_factor: f64,
209    pub penalty: SvcPenalty,
210}
211
212impl Default for SvcConfig {
213    fn default() -> Self {
214        SvcConfig {
215            window: None,
216            window_factor: 1.0,
217            penalty: SvcPenalty::LogT,
218        }
219    }
220}
221
222/// Output of [`svc_order`].
223#[derive(Debug, Clone)]
224pub struct SvcResult {
225    /// Estimated McMillan degree (state dimension).
226    pub order: usize,
227    /// Estimated canonical correlations, descending.
228    pub correlations: Vec<f64>,
229    /// `SVC(n)` for `n = 0..=min(f, p)`.
230    pub values: Vec<f64>,
231    pub f: usize,
232    pub p: usize,
233}
234
235/// Bauer's (2001, eq. 4) SVC estimator of the McMillan degree, output-only
236/// scalar case with CCA weighting: `SVC(n) = σ̂²_{n+1} + C(T)·2n/T`
237/// (`d(n) = 2n` parameters for a scalar state-space system), minimised over
238/// `n = 0..=M`, `M = min(f, p)`, with the convention `σ̂_{M+1} = 0`.
239/// Consistency (his Theorem 3) needs `C(T)/(fp log log T) → ∞`.
240pub fn svc_order(x: &[f64], config: SvcConfig) -> Result<SvcResult> {
241    let t_len = x.len();
242    let w = match config.window {
243        Some(w) => w.max(1),
244        None => {
245            let aic = ic_order(x, Criterion::Aic, None)?;
246            ((config.window_factor * aic.order as f64).floor() as usize).max(2)
247        }
248    };
249    let cc = canonical_correlations_stacked(x, w, w)?;
250    let m = w;
251    let t = t_len as f64;
252    let c_t = match config.penalty {
253        SvcPenalty::LogT => t.ln(),
254        SvcPenalty::FpLogT => (w * w) as f64 * t.ln(),
255    };
256    let values: Vec<f64> = (0..=m)
257        .map(|k| {
258            let sig2 = if k < m {
259                cc.correlations[k].powi(2)
260            } else {
261                0.0
262            };
263            sig2 + c_t * (2 * k) as f64 / t
264        })
265        .collect();
266    let order = crate::criteria::argmin(&values);
267    Ok(SvcResult {
268        order,
269        correlations: cc.correlations,
270        values,
271        f: w,
272        p: w,
273    })
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    #[test]
281    fn ar1_has_exactly_one_canonical_correlation_equal_to_phi() {
282        // Exact autocovariances of AR(1): γ(k) = φ^k / (1 − φ²). The state is
283        // one-dimensional, so exactly one non-zero canonical correlation, and
284        // its value is φ (best predictor pair x_t ↔ x_{t-1}).
285        let phi: f64 = 0.8;
286        let r: Vec<f64> = (0..12).map(|k| phi.powi(k) / (1.0 - phi * phi)).collect();
287        let cc = canonical_correlations_from_acov(&r, 6, 6).unwrap();
288        assert!(
289            (cc.correlations[0] - phi).abs() < 1e-10,
290            "{:?}",
291            cc.correlations
292        );
293        for c in &cc.correlations[1..] {
294            assert!(c.abs() < 1e-8, "{:?}", cc.correlations);
295        }
296    }
297
298    #[test]
299    fn ma1_has_rank_one_past_future_covariance() {
300        // MA(1): γ(0) = 1 + θ², γ(1) = θ, 0 beyond — state dimension 1.
301        let theta = 0.6f64;
302        let mut r = vec![0.0; 12];
303        r[0] = 1.0 + theta * theta;
304        r[1] = theta;
305        let cc = canonical_correlations_from_acov(&r, 6, 6).unwrap();
306        assert!(cc.correlations[0] > 0.3);
307        for c in &cc.correlations[1..] {
308            assert!(c.abs() < 1e-10, "{:?}", cc.correlations);
309        }
310    }
311
312    #[test]
313    fn white_noise_correlations_are_zero() {
314        let mut r = vec![0.0; 10];
315        r[0] = 2.5;
316        let cc = canonical_correlations_from_acov(&r, 5, 5).unwrap();
317        for c in &cc.correlations {
318            assert!(c.abs() < 1e-12);
319        }
320    }
321}