Skip to main content

ogeom_math/
sparse.rs

1//! Sparse linear algebra: a compressed-sparse-row matrix and the conjugate
2//! gradient least-squares solver built on it.
3//!
4//! The consumer this exists for is a constraint solver whose Jacobian has a
5//! row per residual and entries only under the parameters that residual
6//! names, a matrix that is nearly all zeros and whose dense factorization
7//! cost grows with the whole parameter vector rather than with the
8//! constraints. CGNR works entirely through products with the matrix and
9//! its transpose, so its cost per iteration is the number of stored entries.
10//!
11//! Starting from zero, CGNR converges to the least-squares solution lying in
12//! the row space (the *minimum-norm* solution), which is the same answer a
13//! pseudo-inverse gives, so a caller can swap it for an SVD solve and expect
14//! agreement, not merely feasibility.
15
16/// A read-only sparse matrix in compressed-sparse-row form.
17#[derive(Debug, Clone)]
18pub struct SparseMatrix {
19    rows: usize,
20    cols: usize,
21    row_starts: Vec<usize>,
22    col_indices: Vec<usize>,
23    values: Vec<f64>,
24}
25
26impl SparseMatrix {
27    /// Build from `(row, column, value)` triplets. Duplicate positions sum,
28    /// which is what an assembly loop wants; explicit zeros are kept out.
29    ///
30    /// # Panics
31    /// If a triplet indexes outside `rows × cols`.
32    #[must_use]
33    pub fn from_triplets(rows: usize, cols: usize, triplets: &[(usize, usize, f64)]) -> Self {
34        let mut sorted: Vec<(usize, usize, f64)> = triplets
35            .iter()
36            .inspect(|(r, c, _)| {
37                assert!(*r < rows && *c < cols, "triplet outside the matrix");
38            })
39            .copied()
40            .collect();
41        sorted.sort_by_key(|&(r, c, _)| (r, c));
42
43        let mut row_starts = vec![0usize; rows + 1];
44        let mut col_indices = Vec::with_capacity(sorted.len());
45        let mut values: Vec<f64> = Vec::with_capacity(sorted.len());
46        let mut next = sorted.into_iter().peekable();
47        for (r, start) in row_starts.iter_mut().enumerate().take(rows) {
48            let row_begin = col_indices.len();
49            *start = row_begin;
50            while let Some(&(tr, c, v)) = next.peek() {
51                if tr != r {
52                    break;
53                }
54                next.next();
55                if col_indices.len() > row_begin && col_indices.last() == Some(&c) {
56                    let last = values.len() - 1;
57                    values[last] += v;
58                } else {
59                    col_indices.push(c);
60                    values.push(v);
61                }
62            }
63        }
64        row_starts[rows] = col_indices.len();
65        Self {
66            rows,
67            cols,
68            row_starts,
69            col_indices,
70            values,
71        }
72    }
73
74    /// The shape, `(rows, cols)`.
75    #[must_use]
76    pub fn shape(&self) -> (usize, usize) {
77        (self.rows, self.cols)
78    }
79
80    /// The number of stored entries.
81    #[must_use]
82    pub fn stored(&self) -> usize {
83        self.values.len()
84    }
85
86    /// `y = A·x`.
87    ///
88    /// # Panics
89    /// If `x` is not `cols` long.
90    #[must_use]
91    pub fn multiply(&self, x: &[f64]) -> Vec<f64> {
92        assert_eq!(x.len(), self.cols, "vector length must match columns");
93        let mut y = vec![0.0; self.rows];
94        for (y_r, window) in y.iter_mut().zip(self.row_starts.windows(2)) {
95            let mut sum = 0.0;
96            for i in window[0]..window[1] {
97                sum = self.values[i].mul_add(x[self.col_indices[i]], sum);
98            }
99            *y_r = sum;
100        }
101        y
102    }
103
104    /// `y = Aᵀ·x`.
105    ///
106    /// # Panics
107    /// If `x` is not `rows` long.
108    #[must_use]
109    pub fn transpose_multiply(&self, x: &[f64]) -> Vec<f64> {
110        assert_eq!(x.len(), self.rows, "vector length must match rows");
111        let mut y = vec![0.0; self.cols];
112        for (window, xr) in self.row_starts.windows(2).zip(x) {
113            for i in window[0]..window[1] {
114                y[self.col_indices[i]] = self.values[i].mul_add(*xr, y[self.col_indices[i]]);
115            }
116        }
117        y
118    }
119}
120
121fn dot(a: &[f64], b: &[f64]) -> f64 {
122    a.iter().zip(b).fold(0.0, |acc, (x, y)| x.mul_add(*y, acc))
123}
124
125/// Minimize `‖A·x − b‖` by conjugate gradient on the normal equations,
126/// from zero, so a rank-deficient system yields the minimum-norm
127/// least-squares solution. Convergence is declared when the gradient
128/// `‖Aᵀ(b − A·x)‖` falls to `tolerance` relative to `‖Aᵀb‖`; `None` means
129/// the iteration budget ran out before that happened.
130///
131/// # Panics
132/// If `b` is not `rows` long.
133#[must_use]
134pub fn least_squares_cgnr(
135    a: &SparseMatrix,
136    b: &[f64],
137    tolerance: f64,
138    max_iterations: usize,
139) -> Option<Vec<f64>> {
140    let (rows, cols) = a.shape();
141    assert_eq!(b.len(), rows, "right-hand side must match rows");
142    let mut x = vec![0.0; cols];
143    let mut r = b.to_vec();
144    let mut z = a.transpose_multiply(&r);
145    let target = tolerance * dot(&z, &z).sqrt().max(f64::MIN_POSITIVE);
146    let mut p = z.clone();
147    let mut zz = dot(&z, &z);
148    if zz.sqrt() <= target {
149        return Some(x);
150    }
151    for _ in 0..max_iterations {
152        let w = a.multiply(&p);
153        let ww = dot(&w, &w);
154        if ww <= 0.0 {
155            // A conjugate direction the matrix annihilates: the gradient is
156            // as reduced as this arithmetic can make it.
157            return Some(x);
158        }
159        let alpha = zz / ww;
160        for (xi, pi) in x.iter_mut().zip(&p) {
161            *xi = alpha.mul_add(*pi, *xi);
162        }
163        for (ri, wi) in r.iter_mut().zip(&w) {
164            *ri = alpha.mul_add(-wi, *ri);
165        }
166        z = a.transpose_multiply(&r);
167        let zz_next = dot(&z, &z);
168        if zz_next.sqrt() <= target {
169            return Some(x);
170        }
171        let beta = zz_next / zz;
172        zz = zz_next;
173        for (pi, zi) in p.iter_mut().zip(&z) {
174            *pi = beta.mul_add(*pi, *zi);
175        }
176    }
177    None
178}
179
180#[cfg(test)]
181#[allow(clippy::unwrap_used)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn triplets_assemble_sum_and_multiply() {
187        // [ 2 0 1 ]      duplicates at (0,0) sum to 2
188        // [ 0 3 0 ]
189        let a = SparseMatrix::from_triplets(
190            2,
191            3,
192            &[(0, 0, 1.0), (0, 2, 1.0), (1, 1, 3.0), (0, 0, 1.0)],
193        );
194        assert_eq!(a.stored(), 3);
195        assert_eq!(a.multiply(&[1.0, 2.0, 3.0]), vec![5.0, 6.0]);
196        assert_eq!(a.transpose_multiply(&[1.0, 1.0]), vec![2.0, 3.0, 1.0]);
197    }
198
199    #[test]
200    fn an_overdetermined_system_lands_on_the_normal_equation_answer() {
201        // Fit y = c0 + c1 t through (0,1), (1,2), (2,4): the closed-form
202        // least-squares line is c = (5/6, 3/2).
203        let a = SparseMatrix::from_triplets(
204            3,
205            2,
206            &[
207                (0, 0, 1.0),
208                (0, 1, 0.0),
209                (1, 0, 1.0),
210                (1, 1, 1.0),
211                (2, 0, 1.0),
212                (2, 1, 2.0),
213            ],
214        );
215        let x = least_squares_cgnr(&a, &[1.0, 2.0, 4.0], 1e-14, 100).unwrap();
216        assert!((x[0] - 5.0 / 6.0).abs() < 1e-10, "{x:?}");
217        assert!((x[1] - 1.5).abs() < 1e-10, "{x:?}");
218    }
219
220    #[test]
221    fn a_rank_deficient_system_returns_the_minimum_norm_solution() {
222        // One equation, two unknowns: x + y = 2. The minimum-norm answer is
223        // (1, 1), the same point a pseudo-inverse names.
224        let a = SparseMatrix::from_triplets(1, 2, &[(0, 0, 1.0), (0, 1, 1.0)]);
225        let x = least_squares_cgnr(&a, &[2.0], 1e-14, 50).unwrap();
226        assert!(
227            (x[0] - 1.0).abs() < 1e-12 && (x[1] - 1.0).abs() < 1e-12,
228            "{x:?}"
229        );
230    }
231
232    #[test]
233    fn a_zero_matrix_answers_zero_rather_than_spinning() {
234        let a = SparseMatrix::from_triplets(2, 2, &[]);
235        let x = least_squares_cgnr(&a, &[1.0, 1.0], 1e-12, 10).unwrap();
236        assert_eq!(x, vec![0.0, 0.0]);
237    }
238}