Skip to main content

rustyqlib/core/linalg/decomp/
svd.rs

1//! Singular value decomposition by one-sided Jacobi rotations — simple,
2//! accurate for the small-to-moderate matrices of quant workflows, and
3//! rank-revealing. `A = U diag(S) V^T` with orthonormal `U` (m x n,
4//! columns for nonzero singular values), non-negative `S` sorted
5//! descending, and orthogonal `V` (n x n).
6
7/// SVD of an `m x n` matrix (any shape; internally transposes when
8/// `m < n`). Returns `(u, s, v)` with `A = U diag(S) V^T`.
9pub fn svd(a: &[Vec<f64>]) -> (Vec<Vec<f64>>, Vec<f64>, Vec<Vec<f64>>) {
10    let m = a.len();
11    let n = if m == 0 { 0 } else { a[0].len() };
12    assert!(m > 0 && n > 0, "empty matrix");
13    assert!(a.iter().all(|row| row.len() == n), "ragged matrix");
14    if m < n {
15        // A^T = U' S V'^T  =>  A = V' S U'^T
16        let at: Vec<Vec<f64>> = (0..n).map(|j| (0..m).map(|i| a[i][j]).collect()).collect();
17        let (u_t, s, v_t) = svd(&at);
18        return (v_t, s, u_t);
19    }
20
21    // one-sided Jacobi: orthogonalize the columns of B = A V
22    let mut b = a.to_vec();
23    let mut v: Vec<Vec<f64>> = (0..n)
24        .map(|i| (0..n).map(|j| if i == j { 1.0 } else { 0.0 }).collect())
25        .collect();
26    for _sweep in 0..60 {
27        let mut off = 0.0_f64;
28        for i in 0..n {
29            for j in (i + 1)..n {
30                let mut alpha = 0.0;
31                let mut beta = 0.0;
32                let mut g = 0.0;
33                for row in &b {
34                    alpha += row[i] * row[i];
35                    beta += row[j] * row[j];
36                    g += row[i] * row[j];
37                }
38                if g.abs() <= 1e-14 * (alpha * beta).sqrt().max(1e-300) {
39                    continue;
40                }
41                off = off.max(g.abs());
42                let zeta = (beta - alpha) / (2.0 * g);
43                let t = zeta.signum() / (zeta.abs() + (1.0 + zeta * zeta).sqrt());
44                let c = 1.0 / (1.0 + t * t).sqrt();
45                let s = c * t;
46                for row in b.iter_mut() {
47                    let (bi, bj) = (row[i], row[j]);
48                    row[i] = c * bi - s * bj;
49                    row[j] = s * bi + c * bj;
50                }
51                for row in v.iter_mut() {
52                    let (vi, vj) = (row[i], row[j]);
53                    row[i] = c * vi - s * vj;
54                    row[j] = s * vi + c * vj;
55                }
56            }
57        }
58        if off == 0.0 {
59            break;
60        }
61    }
62
63    // singular values = column norms; U = normalized columns
64    let mut s: Vec<f64> = (0..n)
65        .map(|j| b.iter().map(|row| row[j] * row[j]).sum::<f64>().sqrt())
66        .collect();
67    let mut u = vec![vec![0.0; n]; m];
68    for j in 0..n {
69        if s[j] > 1e-300 {
70            for i in 0..m {
71                u[i][j] = b[i][j] / s[j];
72            }
73        }
74    }
75    // sort descending, permuting U and V columns alongside
76    let mut order: Vec<usize> = (0..n).collect();
77    order.sort_by(|&i, &j| s[j].total_cmp(&s[i]));
78    let s_sorted: Vec<f64> = order.iter().map(|&k| s[k]).collect();
79    let permute = |mat: &[Vec<f64>]| -> Vec<Vec<f64>> {
80        mat.iter().map(|row| order.iter().map(|&k| row[k]).collect()).collect()
81    };
82    let (u, v) = (permute(&u), permute(&v));
83    s = s_sorted;
84    (u, s, v)
85}
86
87/// Minimum-norm least-squares solve `A x ~ b` through the SVD
88/// pseudo-inverse, dropping singular values below `tol * s_max` — the
89/// robust choice for rank-deficient or ill-conditioned systems.
90pub fn pseudo_solve(a: &[Vec<f64>], b: &[f64], tol: f64) -> Vec<f64> {
91    let (u, s, v) = svd(a);
92    let m = a.len();
93    let n = s.len();
94    assert_eq!(b.len(), m, "dimension mismatch");
95    let cutoff = tol * s.first().copied().unwrap_or(0.0);
96    let mut x = vec![0.0; n];
97    for k in 0..n {
98        if s[k] <= cutoff || s[k] == 0.0 {
99            continue;
100        }
101        let utb: f64 = (0..m).map(|i| u[i][k] * b[i]).sum();
102        let coeff = utb / s[k];
103        for (j, xj) in x.iter_mut().enumerate() {
104            *xj += coeff * v[j][k];
105        }
106    }
107    x
108}
109
110#[cfg(test)]
111mod tests {
112    use super::super::qr::least_squares;
113    use super::*;
114
115    fn fixture() -> Vec<Vec<f64>> {
116        (0..5)
117            .map(|i| (0..3).map(|j| ((2 * i + 3 * j) as f64).cos() + if i == j { 1.5 } else { 0.0 }).collect())
118            .collect()
119    }
120
121    fn reconstruct(u: &[Vec<f64>], s: &[f64], v: &[Vec<f64>]) -> Vec<Vec<f64>> {
122        let (m, n) = (u.len(), s.len());
123        (0..m)
124            .map(|i| {
125                (0..v.len())
126                    .map(|j| (0..n).map(|k| u[i][k] * s[k] * v[j][k]).sum())
127                    .collect()
128            })
129            .collect()
130    }
131
132    #[test]
133    fn decomposition_reconstructs_and_is_orthogonal() {
134        let a = fixture();
135        let (u, s, v) = svd(&a);
136        let recon = reconstruct(&u, &s, &v);
137        for i in 0..5 {
138            for j in 0..3 {
139                assert!((recon[i][j] - a[i][j]).abs() < 1e-10, "[{i}][{j}]");
140            }
141        }
142        // descending non-negative singular values
143        assert!(s.windows(2).all(|w| w[0] >= w[1]) && s.iter().all(|&x| x >= 0.0));
144        // orthonormal columns
145        for j1 in 0..3 {
146            for j2 in 0..3 {
147                let want = if j1 == j2 { 1.0 } else { 0.0 };
148                let uu: f64 = (0..5).map(|i| u[i][j1] * u[i][j2]).sum();
149                let vv: f64 = (0..3).map(|i| v[i][j1] * v[i][j2]).sum();
150                assert!((uu - want).abs() < 1e-11, "U [{j1}][{j2}]");
151                assert!((vv - want).abs() < 1e-11, "V [{j1}][{j2}]");
152            }
153        }
154    }
155
156    #[test]
157    fn known_singular_values_of_a_diagonal_matrix() {
158        let (_, s, _) = svd(&[vec![2.0, 0.0], vec![0.0, -3.0]]);
159        assert!((s[0] - 3.0).abs() < 1e-12 && (s[1] - 2.0).abs() < 1e-12, "{s:?}");
160    }
161
162    #[test]
163    fn rank_deficiency_is_revealed_and_wide_matrices_work() {
164        // rank-1: second row is a multiple of the first; also test m < n
165        let a = vec![vec![1.0, 2.0, 3.0], vec![2.0, 4.0, 6.0]];
166        let (u, s, v) = svd(&a);
167        assert!(s[0] > 1.0 && s[1].abs() < 1e-10, "{s:?}");
168        let recon = reconstruct(&u, &s, &v);
169        for i in 0..2 {
170            for j in 0..3 {
171                assert!((recon[i][j] - a[i][j]).abs() < 1e-10);
172            }
173        }
174    }
175
176    #[test]
177    fn pseudo_solve_matches_qr_on_full_rank_and_handles_deficiency() {
178        let a = fixture();
179        let b = [1.0, -2.0, 0.5, 3.0, -1.0];
180        let via_qr = least_squares(&a, &b).unwrap();
181        let via_svd = pseudo_solve(&a, &b, 1e-12);
182        for (x, y) in via_qr.iter().zip(&via_svd) {
183            assert!((x - y).abs() < 1e-10, "{via_qr:?} vs {via_svd:?}");
184        }
185        // rank-deficient: QR errors, the pseudo-inverse returns the
186        // minimum-norm solution
187        let deficient: Vec<Vec<f64>> =
188            (0..4).map(|i| vec![i as f64 + 1.0, 2.0 * (i as f64 + 1.0)]).collect();
189        let rhs = [1.0, 2.0, 3.0, 4.0];
190        assert!(least_squares(&deficient, &rhs).is_err());
191        let x = pseudo_solve(&deficient, &rhs, 1e-12);
192        // x must satisfy the normal equations projected on the range:
193        // residual orthogonal to the columns
194        for j in 0..2 {
195            let r: f64 = (0..4)
196                .map(|i| {
197                    let ax: f64 = (0..2).map(|k| deficient[i][k] * x[k]).sum();
198                    deficient[i][j] * (ax - rhs[i])
199                })
200                .sum();
201            assert!(r.abs() < 1e-10, "column {j} residual {r}");
202        }
203        // and among solutions it is minimum norm: x parallel to (1, 2)
204        assert!((x[1] - 2.0 * x[0]).abs() < 1e-10, "{x:?}");
205    }
206}