Skip to main content

tsorder_core/
linalg.rs

1//! Minimal dense linear algebra (Cholesky) for the OLS long-AR path.
2//! Kept tiny on purpose; the core has no external dependencies.
3
4use crate::{Error, Result};
5
6/// Dense column-major-agnostic square matrix stored row-major.
7#[derive(Debug, Clone)]
8pub struct SquareMatrix {
9    pub n: usize,
10    pub data: Vec<f64>,
11}
12
13impl SquareMatrix {
14    pub fn zeros(n: usize) -> Self {
15        Self {
16            n,
17            data: vec![0.0; n * n],
18        }
19    }
20    #[inline]
21    pub fn get(&self, i: usize, j: usize) -> f64 {
22        self.data[i * self.n + j]
23    }
24    #[inline]
25    pub fn set(&mut self, i: usize, j: usize, v: f64) {
26        self.data[i * self.n + j] = v;
27    }
28
29    /// In-place lower Cholesky factor `L` with `A = L Lᵀ`.
30    pub fn cholesky(&self) -> Result<SquareMatrix> {
31        let n = self.n;
32        let mut l = SquareMatrix::zeros(n);
33        for i in 0..n {
34            for j in 0..=i {
35                let mut s = self.get(i, j);
36                for k in 0..j {
37                    s -= l.get(i, k) * l.get(j, k);
38                }
39                if i == j {
40                    if s <= 0.0 || !s.is_finite() {
41                        return Err(Error::Singular);
42                    }
43                    l.set(i, j, s.sqrt());
44                } else {
45                    l.set(i, j, s / l.get(j, j));
46                }
47            }
48        }
49        Ok(l)
50    }
51
52    /// Solve `A x = b` given the Cholesky factor `L` of `A`.
53    pub fn cholesky_solve(l: &SquareMatrix, b: &[f64]) -> Vec<f64> {
54        Self::cholesky_solve_leading(l, l.n, b)
55    }
56
57    /// Solve the leading `k × k` subsystem `A[..k, ..k] x = b` given the Cholesky
58    /// factor `L` of the full matrix. Valid because the Cholesky factor of a
59    /// leading principal submatrix is the leading submatrix of the factor, which
60    /// lets one `O(n³)` factorisation serve every nested model.
61    #[allow(clippy::needless_range_loop)] // index form mirrors the textbook recursion
62    pub fn cholesky_solve_leading(l: &SquareMatrix, k: usize, b: &[f64]) -> Vec<f64> {
63        debug_assert!(k <= l.n && b.len() >= k);
64        let mut y = vec![0.0; k];
65        for i in 0..k {
66            let mut s = b[i];
67            for j in 0..i {
68                s -= l.get(i, j) * y[j];
69            }
70            y[i] = s / l.get(i, i);
71        }
72        let mut x = vec![0.0; k];
73        for i in (0..k).rev() {
74            let mut s = y[i];
75            for j in i + 1..k {
76                s -= l.get(j, i) * x[j];
77            }
78            x[i] = s / l.get(i, i);
79        }
80        x
81    }
82
83    /// Diagonal of `A⁻¹` from the Cholesky factor (solves `n` unit systems; `O(n³)`,
84    /// fine for `n = p_n ≤ ~50`).
85    pub fn cholesky_inverse_diag(l: &SquareMatrix) -> Vec<f64> {
86        let n = l.n;
87        let mut diag = vec![0.0; n];
88        let mut e = vec![0.0; n];
89        for j in 0..n {
90            e.iter_mut().for_each(|v| *v = 0.0);
91            e[j] = 1.0;
92            let col = SquareMatrix::cholesky_solve(l, &e);
93            diag[j] = col[j];
94        }
95        diag
96    }
97}
98
99/// Singular values of a dense `rows × cols` matrix (row-major), descending,
100/// by one-sided Jacobi: pairwise orthogonalisation of the columns until every
101/// pair is numerically orthogonal; the singular values are then the column
102/// norms. `O(sweeps · cols² · rows)` with a handful of sweeps — fine for the
103/// `K × K` matrices of the canonical-correlation step (`K ≤ ~50`).
104pub fn singular_values(a: &[f64], rows: usize, cols: usize) -> Vec<f64> {
105    debug_assert_eq!(a.len(), rows * cols);
106    // Column-major working copy.
107    let mut u: Vec<f64> = Vec::with_capacity(rows * cols);
108    for j in 0..cols {
109        for i in 0..rows {
110            u.push(a[i * cols + j]);
111        }
112    }
113    let tol = 1e-14;
114    for _sweep in 0..60 {
115        let mut rotated = false;
116        for i in 0..cols {
117            for j in i + 1..cols {
118                let (mut aii, mut ajj, mut aij) = (0.0f64, 0.0f64, 0.0f64);
119                for t in 0..rows {
120                    let ui = u[i * rows + t];
121                    let uj = u[j * rows + t];
122                    aii += ui * ui;
123                    ajj += uj * uj;
124                    aij += ui * uj;
125                }
126                if aij.abs() <= tol * (aii * ajj).sqrt() || aii == 0.0 || ajj == 0.0 {
127                    continue;
128                }
129                rotated = true;
130                // Jacobi rotation that zeroes the off-diagonal of the 2×2 Gram block.
131                let zeta = (ajj - aii) / (2.0 * aij);
132                let t_ = zeta.signum() / (zeta.abs() + (1.0 + zeta * zeta).sqrt());
133                let c = 1.0 / (1.0 + t_ * t_).sqrt();
134                let s = c * t_;
135                for t in 0..rows {
136                    let ii = i * rows + t;
137                    let jj = j * rows + t;
138                    let (ui, uj) = (u[ii], u[jj]);
139                    u[ii] = c * ui - s * uj;
140                    u[jj] = s * ui + c * uj;
141                }
142            }
143        }
144        if !rotated {
145            break;
146        }
147    }
148    let mut sv: Vec<f64> = (0..cols)
149        .map(|j| {
150            (0..rows)
151                .map(|t| u[j * rows + t].powi(2))
152                .sum::<f64>()
153                .sqrt()
154        })
155        .collect();
156    sv.sort_unstable_by(|a, b| b.total_cmp(a));
157    sv
158}
159
160/// OLS fit of `y` on the columns of `X` (row-major `rows × cols`).
161/// Returns `(beta, residual_variance, se)` with `se` the classical standard errors
162/// `sqrt(σ̂² (XᵀX)⁻¹_jj)` and `σ̂² = RSS / (rows - cols)`.
163pub fn ols(x: &[f64], rows: usize, cols: usize, y: &[f64]) -> Result<(Vec<f64>, f64, Vec<f64>)> {
164    if rows <= cols {
165        return Err(Error::TooShort {
166            n: rows,
167            needed: cols + 1,
168        });
169    }
170    let mut xtx = SquareMatrix::zeros(cols);
171    let mut xty = vec![0.0; cols];
172    for t in 0..rows {
173        let row = &x[t * cols..(t + 1) * cols];
174        for i in 0..cols {
175            xty[i] += row[i] * y[t];
176            for j in 0..=i {
177                let v = xtx.get(i, j) + row[i] * row[j];
178                xtx.set(i, j, v);
179                xtx.set(j, i, v);
180            }
181        }
182    }
183    let l = xtx.cholesky()?;
184    let beta = SquareMatrix::cholesky_solve(&l, &xty);
185    let mut rss = 0.0;
186    for t in 0..rows {
187        let row = &x[t * cols..(t + 1) * cols];
188        let fit: f64 = row.iter().zip(&beta).map(|(a, b)| a * b).sum();
189        rss += (y[t] - fit).powi(2);
190    }
191    let s2 = rss / (rows - cols) as f64;
192    let dinv = SquareMatrix::cholesky_inverse_diag(&l);
193    let se = dinv.iter().map(|d| (s2 * d).sqrt()).collect();
194    Ok((beta, s2, se))
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn singular_values_known_cases() {
203        // Diagonal.
204        let d = [3.0, 0.0, 0.0, 2.0];
205        let sv = singular_values(&d, 2, 2);
206        assert!((sv[0] - 3.0).abs() < 1e-12 && (sv[1] - 2.0).abs() < 1e-12);
207        // 2x2 with known SVD: [[1,1],[0,1]] has sv sqrt((3±sqrt(5))/2).
208        let a = [1.0, 1.0, 0.0, 1.0];
209        let sv = singular_values(&a, 2, 2);
210        let s1 = ((3.0 + 5.0f64.sqrt()) / 2.0).sqrt();
211        let s2 = ((3.0 - 5.0f64.sqrt()) / 2.0).sqrt();
212        assert!((sv[0] - s1).abs() < 1e-12, "{sv:?}");
213        assert!((sv[1] - s2).abs() < 1e-12);
214        // Rectangular 3x2: Frobenius norm and product (via Gram determinant) match.
215        let b = [1.0, 2.0, -1.0, 0.5, 3.0, 1.0];
216        let sv = singular_values(&b, 3, 2);
217        let fro: f64 = b.iter().map(|v| v * v).sum();
218        assert!((sv.iter().map(|v| v * v).sum::<f64>() - fro).abs() < 1e-10);
219        // det(BᵀB) = (σ1 σ2)².
220        let (g00, g01, g11) = (1.0f64 + 1.0 + 9.0, 2.0 - 0.5 + 3.0, 4.0f64 + 0.25 + 1.0);
221        let det = g00 * g11 - g01 * g01;
222        assert!(((sv[0] * sv[1]).powi(2) - det).abs() < 1e-9);
223    }
224
225    #[test]
226    fn cholesky_solve_2x2() {
227        let mut a = SquareMatrix::zeros(2);
228        a.set(0, 0, 4.0);
229        a.set(0, 1, 2.0);
230        a.set(1, 0, 2.0);
231        a.set(1, 1, 3.0);
232        let l = a.cholesky().unwrap();
233        let x = SquareMatrix::cholesky_solve(&l, &[2.0, 1.0]);
234        // 4x+2y=2, 2x+3y=1 -> x=0.5, y=0
235        assert!((x[0] - 0.5).abs() < 1e-12 && x[1].abs() < 1e-12);
236        let d = SquareMatrix::cholesky_inverse_diag(&l);
237        // inv = [[3,-2],[-2,4]]/8
238        assert!((d[0] - 3.0 / 8.0).abs() < 1e-12 && (d[1] - 0.5).abs() < 1e-12);
239    }
240}