Skip to main content

single_svdlib/matrix/
mod.rs

1//! Sparse operands and the traits the solvers consume.
2//!
3//! # Index widths
4//!
5//! [`SvdMat`] defaults to `u32` column indices with `u64` row pointers. Against
6//! `usize`-everywhere that is 12 bytes per non-zero instead of 16 for `f64` data
7//! (8 instead of 16 for `f32`), and the separate pointer width keeps matrices with
8//! more than `u32::MAX` non-zeros representable. Callers who need wider indices can
9//! name them: `SvdMat<f64, u64, u64>`.
10
11pub mod kernels;
12pub mod masked;
13
14use crate::types::SvdFloat;
15use ndarray::{Array1, ArrayView1, ArrayView2, ArrayViewMut2};
16use rayon::prelude::*;
17use sprs::{CsMatI, CsMatViewI, SpIndex};
18
19pub use kernels::DEFAULT_SCRATCH_BUDGET;
20pub use masked::MaskedCsMat;
21
22/// An owned sparse matrix. Defaults to `u32` indices and `u64` row pointers.
23pub type SvdMat<T, I = u32, Iptr = u64> = CsMatI<T, I, Iptr>;
24/// A borrowed sparse matrix. Defaults to `u32` indices and `u64` row pointers.
25pub type SvdMatView<'a, T, I = u32, Iptr = u64> = CsMatViewI<'a, T, I, Iptr>;
26
27/// The operand interface the Krylov solvers ([`crate::lanczos`], [`crate::irlba`])
28/// need: shape and a matrix-vector product.
29///
30/// In 1.x this trait also carried the four blocked-product methods, which meant every
31/// built-in implementation left them as `todo!()` and the randomized solvers panicked
32/// on all three stock matrix types. Those methods now live on [`SparseMatDense`],
33/// which has working defaults, so the gap cannot reappear.
34pub trait SparseMat<T: SvdFloat>: Sync {
35    fn rows(&self) -> usize;
36    fn cols(&self) -> usize;
37    fn nnz(&self) -> usize;
38
39    /// `y = A·x` when `trans` is false, `y = Aᵀ·x` when true.
40    ///
41    /// `y` is fully overwritten. `x` must have length `cols()` (`rows()` when
42    /// transposed) and `y` length `rows()` (`cols()` when transposed).
43    fn mul_vec(&self, x: &[T], y: &mut [T], trans: bool);
44
45    /// `‖A‖²_F`. Accumulated in `f64` even for `f32` data — this sums every non-zero,
46    /// and an `f32` accumulator would drop the tail or overflow.
47    fn squared_frobenius(&self) -> f64;
48
49    /// `‖A − 1·meansᵀ‖²_F`.
50    ///
51    /// The default is the closed form `‖A‖²_F − rows·Σⱼ meanⱼ²`, which needs no pass over
52    /// the matrix but cancels badly when columns are large against their own spread: on
53    /// `f32` columns of `offset + O(1)` noise it is 3e-8 wrong at offset 0 and **5e-1**
54    /// wrong at offset 1000. Fine for counts data, where most entries are zero. The
55    /// built-in types override it with a per-entry sum that never cancels; do the same if
56    /// your data carries an offset.
57    fn centered_squared_frobenius(&self, means: ArrayView1<T>) -> f64 {
58        let shift: f64 = means.iter().map(|&m| m.to_f64() * m.to_f64()).sum();
59        (self.squared_frobenius() - self.rows() as f64 * shift).max(0.0)
60    }
61}
62
63/// `‖A‖²_F`, or the centered version when `means` is supplied. What the solvers store in
64/// [`SvdRec::total_squared_norm`](crate::SvdRec).
65pub fn total_squared_norm<T: SvdFloat, M: SparseMat<T> + ?Sized>(
66    a: &M,
67    means: Option<ArrayView1<T>>,
68) -> f64 {
69    match means {
70        Some(m) => a.centered_squared_frobenius(m),
71        None => a.squared_frobenius(),
72    }
73}
74
75/// `stored + Σⱼ (rows − nnzⱼ)·meanⱼ²`, where `stored` is `Σ (aᵢⱼ − meanⱼ)²` over the
76/// entries that exist. Every unstored entry contributes `meanⱼ²`.
77#[inline]
78fn centered_from_parts<T: SvdFloat>(
79    stored: f64,
80    col_nnz: &[usize],
81    means: ArrayView1<T>,
82    rows: usize,
83) -> f64 {
84    let missing: f64 = col_nnz
85        .iter()
86        .zip(means.iter())
87        .map(|(&n, &m)| {
88            let m = m.to_f64();
89            (rows - n) as f64 * m * m
90        })
91        .sum();
92    (stored + missing).max(0.0)
93}
94
95/// Blocked products, needed by the randomized solvers.
96///
97/// [`mul_dense`](Self::mul_dense) has no default — an implementor must supply it — but
98/// [`col_means`](Self::col_means) and
99/// [`mul_dense_centered`](Self::mul_dense_centered) do, so mean-centering comes for
100/// free once the plain product works.
101pub trait SparseMatDense<T: SvdFloat>: SparseMat<T> {
102    /// `out = A·rhs` when `trans` is false, `out = Aᵀ·rhs` when true.
103    ///
104    /// `out` is fully overwritten.
105    fn mul_dense(&self, rhs: ArrayView2<T>, out: ArrayViewMut2<T>, trans: bool);
106
107    /// Column means, length `cols()`.
108    ///
109    /// The default computes `Aᵀ·1 / rows()`, which routes through whichever
110    /// [`mul_vec`](SparseMat::mul_vec) direction is cheapest for the storage order.
111    fn col_means(&self) -> Array1<T> {
112        let m = self.rows();
113        let ones = vec![T::one(); m];
114        let mut sums = vec![T::zero(); self.cols()];
115        self.mul_vec(&ones, &mut sums, true);
116        let scale = if m == 0 {
117            T::zero()
118        } else {
119            T::one() / T::from_f64_val(m as f64)
120        };
121        Array1::from_vec(sums) * scale
122    }
123
124    /// The product against `A - 1·meansᵀ`, without ever forming it — centering a sparse
125    /// matrix would destroy its sparsity, so it goes in as the rank-1 update it is:
126    ///
127    /// - `trans == false`: `(A - 1·mᵀ)·D = A·D - 1·(mᵀ·D)`
128    /// - `trans == true`:  `(A - 1·mᵀ)ᵀ·D = Aᵀ·D - m·(1ᵀ·D)`
129    ///
130    /// The correction is one length-`k` vector either way, so it adds
131    /// `O(k·(rows + cols))` and allocates nothing else.
132    fn mul_dense_centered(
133        &self,
134        rhs: ArrayView2<T>,
135        mut out: ArrayViewMut2<T>,
136        trans: bool,
137        means: ArrayView1<T>,
138    ) {
139        assert_eq!(
140            means.len(),
141            self.cols(),
142            "mul_dense_centered: means must have length cols()"
143        );
144        self.mul_dense(rhs, out.view_mut(), trans);
145        apply_centering(rhs, out, trans, means);
146    }
147}
148
149/// Subtract the rank-1 centering term from an uncentered product. Public so an
150/// implementor writing a fused `mul_dense_centered` can reuse it.
151pub fn apply_centering<T: SvdFloat>(
152    rhs: ArrayView2<T>,
153    mut out: ArrayViewMut2<T>,
154    trans: bool,
155    means: ArrayView1<T>,
156) {
157    let k = rhs.ncols();
158    if k == 0 {
159        return;
160    }
161    if !trans {
162        // corr[c] = Σ_j means[j] · rhs[j, c]; subtract from every output row.
163        debug_assert_eq!(rhs.nrows(), means.len());
164        let mut corr = vec![T::zero(); k];
165        for (j, &mj) in means.iter().enumerate() {
166            if mj.is_zero() {
167                continue;
168            }
169            for (c, cv) in corr.iter_mut().enumerate() {
170                *cv += mj * rhs[[j, c]];
171            }
172        }
173        for mut orow in out.rows_mut() {
174            for (o, &c) in orow.iter_mut().zip(corr.iter()) {
175                *o -= c;
176            }
177        }
178    } else {
179        // colsum[c] = Σ_i rhs[i, c]; subtract means[j] · colsum[c] from out[j, c].
180        let mut colsum = vec![T::zero(); k];
181        for row in rhs.rows() {
182            for (c, cv) in colsum.iter_mut().enumerate() {
183                *cv += row[c];
184            }
185        }
186        debug_assert_eq!(out.nrows(), means.len());
187        for (j, mut orow) in out.rows_mut().into_iter().enumerate() {
188            let mj = means[j];
189            if mj.is_zero() {
190                continue;
191            }
192            for (o, &cs) in orow.iter_mut().zip(colsum.iter()) {
193                *o -= mj * cs;
194            }
195        }
196    }
197}
198
199/// A CSR view of any compressed matrix, plus a flag for whether the view is the
200/// transpose. CSC is bit-for-bit the CSR of its own transpose, so `transpose_view()`
201/// reaches it for free — every kernel is written once against CSR and the caller's
202/// `trans` is XORed with the flag.
203#[inline]
204fn csr_view<T, I: SpIndex, Iptr: SpIndex>(
205    m: &CsMatI<T, I, Iptr>,
206) -> (CsMatViewI<'_, T, I, Iptr>, bool) {
207    if m.is_csr() {
208        (m.view(), false)
209    } else {
210        (m.transpose_view(), true)
211    }
212}
213
214impl<T, I, Iptr> SparseMat<T> for CsMatI<T, I, Iptr>
215where
216    T: SvdFloat,
217    I: SpIndex,
218    Iptr: SpIndex,
219{
220    fn rows(&self) -> usize {
221        CsMatI::rows(self)
222    }
223    fn cols(&self) -> usize {
224        CsMatI::cols(self)
225    }
226    fn nnz(&self) -> usize {
227        CsMatI::nnz(self)
228    }
229
230    fn mul_vec(&self, x: &[T], y: &mut [T], trans: bool) {
231        let (view, flipped) = csr_view(self);
232        if trans ^ flipped {
233            kernels::scatter_mul_vec(view, x, y);
234        } else {
235            kernels::gather_mul_vec(view, x, y);
236        }
237    }
238
239    fn squared_frobenius(&self) -> f64 {
240        // Storage order doesn't matter — every value appears once.
241        self.data()
242            .par_iter()
243            .map(|&v| {
244                let x = v.to_f64();
245                x * x
246            })
247            .sum()
248    }
249
250    fn centered_squared_frobenius(&self, means: ArrayView1<T>) -> f64 {
251        assert_eq!(
252            means.len(),
253            SparseMat::cols(self),
254            "centered_squared_frobenius: means must have length cols()"
255        );
256        let rows = SparseMat::rows(self);
257        let cols = SparseMat::cols(self);
258
259        if self.is_csc() {
260            // Outer dimension is the column, so the count comes for free.
261            let total: f64 = (0..cols)
262                .into_par_iter()
263                .map(|j| {
264                    let m = means[j].to_f64();
265                    let (sum, n) = self.outer_view(j).map_or((0.0, 0), |col| {
266                        (
267                            col.iter()
268                                .map(|(_, &v)| {
269                                    let e = v.to_f64() - m;
270                                    e * e
271                                })
272                                .sum::<f64>(),
273                            col.nnz(),
274                        )
275                    });
276                    sum + (rows - n) as f64 * m * m
277                })
278                .sum();
279            return total.max(0.0);
280        }
281
282        // CSR: count per column first, then sum the entries.
283        let mut col_nnz = vec![0usize; cols];
284        for j in self.indices() {
285            col_nnz[j.index()] += 1;
286        }
287        let stored: f64 = (0..rows)
288            .into_par_iter()
289            .map(|i| {
290                self.outer_view(i).map_or(0.0, |row| {
291                    row.iter()
292                        .map(|(j, &v)| {
293                            let e = v.to_f64() - means[j].to_f64();
294                            e * e
295                        })
296                        .sum()
297                })
298            })
299            .sum();
300        centered_from_parts(stored, &col_nnz, means, rows)
301    }
302}
303
304impl<T, I, Iptr> SparseMatDense<T> for CsMatI<T, I, Iptr>
305where
306    T: SvdFloat,
307    I: SpIndex,
308    Iptr: SpIndex,
309{
310    fn mul_dense(&self, rhs: ArrayView2<T>, out: ArrayViewMut2<T>, trans: bool) {
311        let (view, flipped) = csr_view(self);
312        if trans ^ flipped {
313            kernels::scatter_mul(view, rhs, out, DEFAULT_SCRATCH_BUDGET);
314        } else {
315            kernels::gather_mul(view, rhs, out);
316        }
317    }
318}
319
320// Blanket forwarding so `&M` and `Arc<M>` work wherever `M` does.
321impl<T: SvdFloat, M: SparseMat<T> + ?Sized> SparseMat<T> for &M {
322    fn rows(&self) -> usize {
323        (**self).rows()
324    }
325    fn cols(&self) -> usize {
326        (**self).cols()
327    }
328    fn nnz(&self) -> usize {
329        (**self).nnz()
330    }
331    fn mul_vec(&self, x: &[T], y: &mut [T], trans: bool) {
332        (**self).mul_vec(x, y, trans)
333    }
334    fn squared_frobenius(&self) -> f64 {
335        (**self).squared_frobenius()
336    }
337    fn centered_squared_frobenius(&self, means: ArrayView1<T>) -> f64 {
338        (**self).centered_squared_frobenius(means)
339    }
340}
341
342impl<T: SvdFloat, M: SparseMatDense<T> + ?Sized> SparseMatDense<T> for &M {
343    fn mul_dense(&self, rhs: ArrayView2<T>, out: ArrayViewMut2<T>, trans: bool) {
344        (**self).mul_dense(rhs, out, trans)
345    }
346    fn col_means(&self) -> Array1<T> {
347        (**self).col_means()
348    }
349    fn mul_dense_centered(
350        &self,
351        rhs: ArrayView2<T>,
352        out: ArrayViewMut2<T>,
353        trans: bool,
354        means: ArrayView1<T>,
355    ) {
356        (**self).mul_dense_centered(rhs, out, trans, means)
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use ndarray::{arr2, Array2};
364    use sprs::TriMatI;
365
366    fn tiny_csr() -> SvdMat<f64> {
367        let mut t = TriMatI::<f64, u32>::new((4, 3));
368        t.add_triplet(0, 0, 1.0);
369        t.add_triplet(0, 2, 2.0);
370        t.add_triplet(1, 1, 3.0);
371        t.add_triplet(2, 0, 4.0);
372        t.add_triplet(2, 2, 5.0);
373        t.add_triplet(3, 1, 6.0);
374        t.to_csr::<u64>()
375    }
376
377    use crate::testing::dense_of;
378
379    /// CSR and CSC hold the same matrix, so every product must agree — this is what
380    /// makes the `transpose_view` dispatch safe.
381    #[test]
382    fn csr_and_csc_agree() {
383        let csr = tiny_csr();
384        let csc = csr.to_other_storage();
385        assert!(csr.is_csr() && csc.is_csc());
386        let d = dense_of(&csr);
387
388        let rhs = arr2(&[[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]);
389        let mut a = Array2::zeros((4, 2));
390        let mut b = Array2::zeros((4, 2));
391        SparseMatDense::mul_dense(&csr, rhs.view(), a.view_mut(), false);
392        SparseMatDense::mul_dense(&csc, rhs.view(), b.view_mut(), false);
393        assert_eq!(a, d.dot(&rhs));
394        assert_eq!(b, d.dot(&rhs));
395
396        let rhs_t = arr2(&[[1.0], [2.0], [3.0], [4.0]]);
397        let mut at = Array2::zeros((3, 1));
398        let mut bt = Array2::zeros((3, 1));
399        SparseMatDense::mul_dense(&csr, rhs_t.view(), at.view_mut(), true);
400        SparseMatDense::mul_dense(&csc, rhs_t.view(), bt.view_mut(), true);
401        assert_eq!(at, d.t().dot(&rhs_t));
402        assert_eq!(bt, d.t().dot(&rhs_t));
403    }
404
405    #[test]
406    fn col_means_match_dense() {
407        let a = tiny_csr();
408        let d = dense_of(&a);
409        let got = SparseMatDense::col_means(&a);
410        let want = d.mean_axis(ndarray::Axis(0)).unwrap();
411        for (g, w) in got.iter().zip(want.iter()) {
412            approx::assert_relative_eq!(g, w, max_relative = 1e-14);
413        }
414        // The CSC path must agree.
415        let got_csc = SparseMatDense::col_means(&a.to_other_storage());
416        for (g, w) in got_csc.iter().zip(want.iter()) {
417            approx::assert_relative_eq!(g, w, max_relative = 1e-14);
418        }
419    }
420
421    /// The rank-1 correction must equal explicitly forming the dense centered matrix.
422    #[test]
423    fn centering_matches_explicit_dense_centering() {
424        let a = tiny_csr();
425        let d = dense_of(&a);
426        let means = SparseMatDense::col_means(&a);
427        let centered = &d - &means.view().insert_axis(ndarray::Axis(0));
428
429        let rhs = arr2(&[[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]);
430        let mut out = Array2::zeros((4, 2));
431        SparseMatDense::mul_dense_centered(&a, rhs.view(), out.view_mut(), false, means.view());
432        let want = centered.dot(&rhs);
433        for (g, w) in out.iter().zip(want.iter()) {
434            approx::assert_relative_eq!(g, w, max_relative = 1e-12);
435        }
436
437        let rhs_t = arr2(&[[1.0, 0.5], [2.0, 1.5], [3.0, 2.5], [4.0, 3.5]]);
438        let mut out_t = Array2::zeros((3, 2));
439        SparseMatDense::mul_dense_centered(&a, rhs_t.view(), out_t.view_mut(), true, means.view());
440        let want_t = centered.t().dot(&rhs_t);
441        for (g, w) in out_t.iter().zip(want_t.iter()) {
442            approx::assert_relative_eq!(g, w, max_relative = 1e-12);
443        }
444    }
445
446    #[test]
447    fn squared_frobenius_matches_dense_and_is_storage_agnostic() {
448        let a = tiny_csr();
449        let d = dense_of(&a);
450        let want: f64 = d.iter().map(|&v| v * v).sum();
451        approx::assert_relative_eq!(SparseMat::squared_frobenius(&a), want, max_relative = 1e-14);
452        // Every stored value appears once whichever way the matrix is compressed.
453        approx::assert_relative_eq!(
454            SparseMat::squared_frobenius(&a.to_other_storage()),
455            want,
456            max_relative = 1e-14
457        );
458    }
459
460    /// Must equal building the centered matrix and summing it, in both storage orders.
461    #[test]
462    fn centered_squared_frobenius_matches_explicit_centering() {
463        let a = tiny_csr();
464        let d = dense_of(&a);
465        let means = SparseMatDense::col_means(&a);
466        let centered = &d - &means.view().insert_axis(ndarray::Axis(0));
467        let want: f64 = centered.iter().map(|&v| v * v).sum();
468
469        approx::assert_relative_eq!(
470            SparseMat::centered_squared_frobenius(&a, means.view()),
471            want,
472            max_relative = 1e-12
473        );
474        // CSC reaches the same answer by iterating columns instead of rows.
475        approx::assert_relative_eq!(
476            SparseMat::centered_squared_frobenius(&a.to_other_storage(), means.view()),
477            want,
478            max_relative = 1e-12
479        );
480    }
481
482    /// Centering can only remove norm, never add it, and never take it below zero.
483    #[test]
484    fn centering_never_increases_the_norm() {
485        let a = tiny_csr();
486        let means = SparseMatDense::col_means(&a);
487        let raw = SparseMat::squared_frobenius(&a);
488        let centered = SparseMat::centered_squared_frobenius(&a, means.view());
489        assert!(centered >= 0.0);
490        assert!(centered <= raw);
491    }
492
493    /// The closed form was 51% wrong at an `f32` offset of 1000. The per-entry form must
494    /// stay at machine precision and not degrade as the offset grows.
495    #[test]
496    fn centered_norm_survives_a_large_column_offset() {
497        for offset in [0.0f32, 10.0, 100.0, 1000.0] {
498            let (rows, cols) = (200usize, 20usize);
499            let mut t = sprs::TriMatI::<f32, u32>::new((rows, cols));
500            let mut dense = Array2::<f64>::zeros((rows, cols));
501            for i in 0..rows {
502                for j in 0..cols {
503                    let v = offset + (((i * 7 + j * 3) % 11) as f32 - 5.0) * 0.1;
504                    t.add_triplet(i, j, v);
505                    dense[[i, j]] = v as f64;
506                }
507            }
508            let a: SvdMat<f32> = t.to_csr::<u64>();
509            let means = SparseMatDense::col_means(&a);
510
511            // The reference uses the same f32 means the solver would, so this measures
512            // the summation and nothing else.
513            let mut want = 0.0f64;
514            for i in 0..rows {
515                for j in 0..cols {
516                    let e = dense[[i, j]] - means[j] as f64;
517                    want += e * e;
518                }
519            }
520
521            let got = SparseMat::centered_squared_frobenius(&a, means.view());
522            let rel = (got - want).abs() / want;
523            assert!(
524                rel < 1e-6,
525                "offset {offset}: centered norm {got:.6e} vs {want:.6e} (rel {rel:.3e})"
526            );
527        }
528    }
529
530    #[test]
531    fn mul_vec_matches_dense_both_directions() {
532        let a = tiny_csr();
533        let d = dense_of(&a);
534        let mut y = vec![0.0; 4];
535        SparseMat::mul_vec(&a, &[1.0, 2.0, 3.0], &mut y, false);
536        assert_eq!(y, d.dot(&ndarray::arr1(&[1.0, 2.0, 3.0])).to_vec());
537
538        let mut yt = vec![0.0; 3];
539        SparseMat::mul_vec(&a, &[1.0, 2.0, 3.0, 4.0], &mut yt, true);
540        assert_eq!(
541            yt,
542            d.t().dot(&ndarray::arr1(&[1.0, 2.0, 3.0, 4.0])).to_vec()
543        );
544    }
545}