Skip to main content

rigidity_core/linalg/
jacobi.rs

1//! Singular values by one-sided Jacobi.
2//!
3//! # Why not the QR algorithm
4//!
5//! Golub–Reinsch delivers singular values with **absolute** accuracy of
6//! order `ε·σ_max`. For the largest ones that is excellent; for the
7//! smallest it is useless: at `σ_min/σ_max = 10⁻¹²` the relative error is
8//! `10⁻¹⁶/10⁻¹² = 10⁻⁴`.
9//!
10//! One-sided Jacobi (Demmel, Veselić) delivers **relative** accuracy for
11//! all singular values, bounded by `ε·κ(A_c)` where `A_c` is the matrix
12//! with normalised columns. The distinction is the whole point: the
13//! smallest singular value is precisely what is being measured.
14//!
15//! The method orthogonalises the columns by rotations; when it finishes,
16//! the singular values are the column norms.
17
18/// Singular values together with the right singular vectors.
19#[derive(Debug, Clone, Copy, PartialEq)]
20pub struct Decomposition<const K: usize> {
21    /// Singular values in decreasing order.
22    pub values: [f64; K],
23    /// Right singular vectors: column `j` belongs to `values[j]`.
24    ///
25    /// Element `vectors[i][j]` is coordinate `i` of vector `j`.
26    pub vectors: [[f64; K]; K],
27}
28
29/// Singular values of a square matrix, in decreasing order.
30///
31/// The matrix is given row by row: `matrix[i][j]` is the element in row
32/// `i`, column `j`.
33///
34/// Applicable both to `R` from TSQR and to an explicitly assembled `H`. In
35/// the latter case it returns the eigenvalues of `H`, since `H` is
36/// symmetric positive semi-definite. Using one and the same routine on
37/// both paths is deliberate: then any difference in the results is
38/// explained by squaring the condition number alone, not by the choice of
39/// solver.
40pub fn singular_values<const K: usize>(matrix: &[[f64; K]; K]) -> [f64; K] {
41    decompose(matrix).values
42}
43
44/// The full decomposition: magnitudes and directions.
45///
46/// The method orthogonalises columns by rotations. The same rotations
47/// accumulate into an identity matrix, which yields `V`, since
48/// `A·V = U·Σ`. The directions are what let the answer be "this particular
49/// motion is unobservable" rather than "the problem is ill-conditioned".
50pub fn decompose<const K: usize>(matrix: &[[f64; K]; K]) -> Decomposition<K> {
51    /// Threshold: a rotation is skipped when the columns are already
52    /// nearly orthogonal.
53    const TOLERANCE: f64 = 1e-17;
54    /// Cap on the number of sweeps. At this size the method converges in
55    /// a handful of them; the cap is a safety net, not an operating mode.
56    const MAX_SWEEPS: usize = 40;
57
58    let mut work = *matrix;
59    let mut rotations = [[0.0f64; K]; K];
60    for (index, row) in rotations.iter_mut().enumerate() {
61        row[index] = 1.0;
62    }
63
64    for _ in 0..MAX_SWEEPS {
65        let mut rotated = false;
66
67        for p in 0..K {
68            for q in (p + 1)..K {
69                let mut alpha = 0.0;
70                let mut beta = 0.0;
71                let mut gamma = 0.0;
72                for row in work.iter() {
73                    alpha += row[p] * row[p];
74                    beta += row[q] * row[q];
75                    gamma += row[p] * row[q];
76                }
77
78                if gamma == 0.0 || alpha == 0.0 || beta == 0.0 {
79                    continue;
80                }
81                // The criterion is relative: columns of different scale
82                // must not be compared in absolute terms.
83                if gamma.abs() <= TOLERANCE * (alpha * beta).sqrt() {
84                    continue;
85                }
86
87                let zeta = (beta - alpha) / (2.0 * gamma);
88                let tangent = zeta.signum() / (zeta.abs() + (1.0 + zeta * zeta).sqrt());
89                let cosine = 1.0 / (1.0 + tangent * tangent).sqrt();
90                let sine = cosine * tangent;
91
92                for row in work.iter_mut().chain(rotations.iter_mut()) {
93                    let left = row[p];
94                    let right = row[q];
95                    row[p] = cosine * left - sine * right;
96                    row[q] = sine * left + cosine * right;
97                }
98                rotated = true;
99            }
100        }
101
102        if !rotated {
103            break;
104        }
105    }
106
107    let mut norms = [0.0f64; K];
108    for (column, value) in norms.iter_mut().enumerate() {
109        let mut sum = 0.0;
110        for row in work.iter() {
111            sum += row[column] * row[column];
112        }
113        *value = sum.sqrt();
114    }
115
116    // Sort in decreasing order, carrying the matching columns of `V`.
117    let mut order: [usize; K] = [0; K];
118    for (index, slot) in order.iter_mut().enumerate() {
119        *slot = index;
120    }
121    order.sort_by(|a, b| norms[*b].total_cmp(&norms[*a]));
122
123    let mut values = [0.0f64; K];
124    let mut vectors = [[0.0f64; K]; K];
125    for (target, source) in order.iter().enumerate() {
126        values[target] = norms[*source];
127        for axis in 0..K {
128            vectors[axis][target] = rotations[axis][*source];
129        }
130    }
131    Decomposition { values, vectors }
132}
133
134/// The condition number `σ_max / σ_min`.
135///
136/// Infinity when the smallest singular value is zero.
137pub fn condition_number<const K: usize>(values: &[f64; K]) -> f64 {
138    let largest = values[0];
139    let smallest = values[K - 1];
140    if smallest == 0.0 {
141        f64::INFINITY
142    } else {
143        largest / smallest
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    /// A diagonal matrix: the singular values are the absolute values of
152    /// the diagonal.
153    #[test]
154    fn diagonal_matrix_is_trivial() {
155        let mut matrix = [[0.0f64; 4]; 4];
156        for (i, value) in [3.0, -1.0, 0.25, 8.0].into_iter().enumerate() {
157            matrix[i][i] = value;
158        }
159        let values = singular_values(&matrix);
160        assert!((values[0] - 8.0).abs() < 1e-15);
161        assert!((values[1] - 3.0).abs() < 1e-15);
162        assert!((values[2] - 1.0).abs() < 1e-15);
163        assert!((values[3] - 0.25).abs() < 1e-15);
164    }
165
166    /// An orthogonal matrix: every singular value equals one.
167    #[test]
168    fn rotation_has_unit_spectrum() {
169        let angle = 0.7f64;
170        let matrix = [
171            [angle.cos(), -angle.sin(), 0.0],
172            [angle.sin(), angle.cos(), 0.0],
173            [0.0, 0.0, 1.0],
174        ];
175        for value in singular_values(&matrix) {
176            assert!((value - 1.0).abs() < 1e-15);
177        }
178    }
179
180    /// A rank-deficient matrix yields a zero singular value.
181    #[test]
182    fn rank_deficient_matrix_yields_zero() {
183        let matrix = [[1.0, 2.0, 3.0], [2.0, 4.0, 6.0], [-1.0, -2.0, -3.0]];
184        let values = singular_values(&matrix);
185        assert!(values[0] > 1.0);
186        assert!(values[1] < 1e-15, "second value {}", values[1]);
187        assert!(values[2] < 1e-15);
188        assert_eq!(condition_number(&values), f64::INFINITY);
189    }
190
191    /// The method keeps relative accuracy on a diagonal with an enormous
192    /// spread — something the QR algorithm does not promise.
193    #[test]
194    fn tiny_diagonal_entries_keep_relative_accuracy() {
195        let scales = [1.0, 1e-4, 1e-8, 1e-12, 1e-16, 1e-20];
196        let mut matrix = [[0.0f64; 6]; 6];
197        for (i, scale) in scales.iter().enumerate() {
198            matrix[i][i] = *scale;
199        }
200        let values = singular_values(&matrix);
201        for (found, expected) in values.iter().zip(scales.iter()) {
202            let relative = (found - expected).abs() / expected;
203            assert!(
204                relative < 1e-15,
205                "expected {expected:e}, got {found:e}, error {relative:.3e}"
206            );
207        }
208    }
209}
210
211#[cfg(test)]
212mod vector_tests {
213    use super::*;
214
215    /// The directions form an orthonormal basis.
216    #[test]
217    fn right_vectors_are_orthonormal() {
218        let matrix = [
219            [3.0, 1.0, -2.0, 0.5],
220            [0.0, 2.5, 1.0, -1.0],
221            [1.0, 0.0, 4.0, 2.0],
222            [-0.5, 1.5, 0.0, 3.0],
223        ];
224        let result = decompose(&matrix);
225        for i in 0..4 {
226            for j in 0..4 {
227                let dot: f64 = (0..4)
228                    .map(|axis| result.vectors[axis][i] * result.vectors[axis][j])
229                    .sum();
230                let expected = if i == j { 1.0 } else { 0.0 };
231                assert!((dot - expected).abs() < 1e-13, "V columns {i},{j}: {dot}");
232            }
233        }
234    }
235
236    /// `A·vⱼ` has norm `σⱼ`.
237    #[test]
238    fn vectors_match_their_values() {
239        let matrix = [[2.0, 0.0, 1.0], [0.0, 3.0, 0.0], [1.0, 0.0, 2.0]];
240        let result = decompose(&matrix);
241        for j in 0..3 {
242            let mut image = [0.0f64; 3];
243            for (i, slot) in image.iter_mut().enumerate() {
244                *slot = (0..3).map(|k| matrix[i][k] * result.vectors[k][j]).sum();
245            }
246            let norm = image.iter().map(|v| v * v).sum::<f64>().sqrt();
247            assert!(
248                (norm - result.values[j]).abs() < 1e-13,
249                "‖A·v{j}‖ = {norm}, but σ{j} = {}",
250                result.values[j]
251            );
252        }
253    }
254
255    /// The direction of a zero singular value lies in the kernel.
256    #[test]
257    fn null_direction_is_annihilated() {
258        let matrix = [[1.0, 2.0, 3.0], [2.0, 4.0, 6.0], [-1.0, -2.0, -3.0]];
259        let result = decompose(&matrix);
260        let null = [
261            result.vectors[0][2],
262            result.vectors[1][2],
263            result.vectors[2][2],
264        ];
265        for row in &matrix {
266            let value: f64 = (0..3).map(|k| row[k] * null[k]).sum();
267            assert!(value.abs() < 1e-13, "row gives {value}");
268        }
269    }
270}