single_svdlib/lib.rs
1//! Sparse singular value decomposition.
2//!
3//! Three solvers over [`sprs`] matrices, all returning the same [`SvdRec`]:
4//!
5//! | module | method | use when |
6//! |---|---|---|
7//! | [`irlba`] | thick-restarted Lanczos bidiagonalization | **default.** Accurate, memory bounded by the requested rank |
8//! | [`randomized`] | randomized range finder, power iteration or block Krylov | very large inputs where an approximation is acceptable |
9//! | `lanczos` | LAS2 from SVDLIBC | **deprecated, numerically unreliable** — behind the off-by-default `las2` feature |
10//!
11//! # Quick start
12//!
13//! ```
14//! use single_svdlib::{sprs::TriMatI, SvdMat};
15//!
16//! // A 4x3 matrix in triplet form, converted to CSR with u32 indices.
17//! let mut tri = TriMatI::<f64, u32>::new((4, 3));
18//! tri.add_triplet(0, 0, 1.0);
19//! tri.add_triplet(1, 1, 2.0);
20//! tri.add_triplet(2, 2, 3.0);
21//! tri.add_triplet(3, 0, 4.0);
22//! let a: SvdMat<f64> = tri.to_csr::<u64>();
23//!
24//! // Two largest singular triplets.
25//! let svd = single_svdlib::svd(&a, 2)?;
26//!
27//! assert_eq!(svd.s.len(), 2);
28//! assert_eq!(svd.u.dim(), (4, 2)); // left vectors are columns
29//! assert_eq!(svd.vt.dim(), (2, 3)); // right vectors are rows
30//! assert!(svd.s[0] >= svd.s[1]);
31//! # Ok::<(), single_svdlib::SvdLibError>(())
32//! ```
33//!
34//! # Index widths
35//!
36//! [`SvdMat<T>`] defaults to `u32` column indices with `u64` row pointers, which is
37//! 12 bytes per non-zero for `f64` data against the 16 that `usize`-everywhere costs
38//! (8 against 16 for `f32`). Name the parameters to widen: `SvdMat<f64, u64, u64>`.
39//!
40//! # Orientation
41//!
42//! `A ≈ u · diag(s) · vt`, matching `numpy.linalg.svd`: `u` is `m × d` with left
43//! vectors as columns, `s` is descending, `vt` is `d × n` with right vectors as rows.
44//! 1.x was inconsistent between solvers on this point.
45
46// Numeric kernels index several arrays in step from one loop variable, and
47// offset arithmetic is load-bearing; iterator rewrites obscure which array an
48// index belongs to.
49#![allow(clippy::needless_range_loop)]
50
51pub mod dense;
52pub mod error;
53pub mod irlba;
54/// LAS2, from SVDLIBC. Deprecated and numerically unreliable — enable `las2` only to
55/// keep a 1.x caller compiling while it moves to [`irlba`].
56#[cfg(feature = "las2")]
57pub mod lanczos;
58pub mod matrix;
59pub mod randomized;
60pub mod types;
61
62#[cfg(test)]
63mod testing;
64
65pub use error::{Result, SvdLibError};
66pub use matrix::{
67 MaskedCsMat, SparseMat, SparseMatDense, SvdMat, SvdMatView, DEFAULT_SCRATCH_BUDGET,
68};
69pub use types::{Algorithm, Detail, Diagnostics, SvdFloat, SvdRec};
70
71/// Re-exported so callers construct matrices without pinning `sprs` themselves.
72pub use sprs;
73
74/// Runs the README's Rust examples as doctests. `cfg(doctest)` keeps it out of the
75/// rendered docs, so the README gets checked without being duplicated.
76#[cfg(doctest)]
77#[doc = include_str!("../README.md")]
78pub struct ReadmeDoctests;
79
80/// The `rank` largest singular triplets.
81///
82/// Dispatches to [`irlba`], which is accurate and holds a basis bounded by `rank`.
83/// Reach past this for a fixed seed ([`irlba::svd_seed`]), PCA
84/// ([`irlba::svd_centered`]), or an approximation on a very large input
85/// ([`randomized`]).
86pub fn svd<T: SvdFloat, M: SparseMat<T>>(a: &M, rank: usize) -> Result<SvdRec<T>> {
87 irlba::svd(a, rank)
88}
89
90/// The `rank` largest singular triplets, reproducibly.
91pub fn svd_seed<T: SvdFloat, M: SparseMat<T>>(a: &M, rank: usize, seed: u64) -> Result<SvdRec<T>> {
92 irlba::svd_seed(a, rank, seed)
93}
94
95/// PCA: the `rank` largest singular triplets of the implicitly mean-centered matrix.
96///
97/// The centering is applied as a rank-1 correction inside each product, so the matrix
98/// is never densified.
99pub fn svd_centered<T: SvdFloat, M: SparseMatDense<T>>(
100 a: &M,
101 rank: usize,
102 seed: Option<u64>,
103) -> Result<SvdRec<T>> {
104 irlba::svd_centered(a, rank, seed)
105}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110 use crate::testing::{dense_of, gen_lowrank, gen_sparse, reference_singular_values};
111
112 /// Every solver must agree with a dense LAPACK reference on the same matrix, to
113 /// each one's own accuracy class. This is the cross-algorithm contract.
114 #[test]
115 fn all_solvers_agree_with_lapack() {
116 let a = gen_lowrank(300, 100, 10, 101);
117 let want = reference_singular_values(&dense_of(&a));
118 let rank = 10;
119
120 let by_irlba = irlba::svd_seed(&a, rank, 42).unwrap();
121 let by_random = randomized::svd_with(
122 &a,
123 &randomized::RandomizedConfig::new(rank)
124 .seed(42)
125 .power_iterations(4),
126 None,
127 )
128 .unwrap();
129 let by_default = svd_seed(&a, rank, 42).unwrap();
130
131 for i in 0..rank {
132 for (name, got, tol) in [
133 ("irlba", by_irlba.s[i], 1e-9),
134 ("randomized", by_random.s[i], 1e-6),
135 ("top-level default", by_default.s[i], 1e-9),
136 ] {
137 let rel = (got - want[i]).abs() / want[i];
138 assert!(
139 rel < tol,
140 "{name} triplet {i}: {got:.12e} vs LAPACK {:.12e} (rel {rel:.3e})",
141 want[i]
142 );
143 }
144 }
145 }
146
147 /// The top-level entry point must be IRLBA, as documented.
148 #[test]
149 fn top_level_dispatches_to_irlba() {
150 let a = gen_sparse(120, 60, 0.1, 7);
151 let got = svd(&a, 5).unwrap();
152 assert_eq!(got.diagnostics.algorithm, Algorithm::Irlba);
153 }
154
155 /// `u32`-indexed and `u64`-indexed matrices must give identical answers — the
156 /// memory win must not cost accuracy.
157 #[test]
158 fn index_width_does_not_change_results() {
159 use sprs::TriMatI;
160 let a32 = gen_sparse(200, 80, 0.08, 13);
161
162 let mut t = TriMatI::<f64, u64>::new((200, 80));
163 for (v, (i, j)) in a32.iter() {
164 t.add_triplet(i as usize, j as usize, *v);
165 }
166 let a64: SvdMat<f64, u64, u64> = t.to_csr::<u64>();
167
168 let x = svd_seed(&a32, 10, 42).unwrap();
169 let y = svd_seed(&a64, 10, 42).unwrap();
170 for (p, q) in x.s.iter().zip(y.s.iter()) {
171 approx::assert_relative_eq!(p, q, max_relative = 1e-12);
172 }
173 }
174
175 /// The documented memory claim, checked against the buffers sprs actually holds.
176 #[test]
177 fn u32_indices_are_smaller_than_usize_indices() {
178 let a = gen_sparse(2000, 500, 0.02, 3);
179 let nnz = a.nnz();
180 let rows = a.rows();
181
182 assert_eq!(a.indices().len(), nnz);
183 assert_eq!(a.data().len(), nnz);
184
185 let ours = (rows + 1) * std::mem::size_of::<u64>()
186 + nnz * std::mem::size_of::<u32>()
187 + nnz * std::mem::size_of::<f64>();
188 let usize_everywhere = (rows + 1) * std::mem::size_of::<usize>()
189 + nnz * std::mem::size_of::<usize>()
190 + nnz * std::mem::size_of::<f64>();
191
192 let saving = 1.0 - (ours as f64 / usize_everywhere as f64);
193 assert!(
194 saving > 0.2,
195 "expected >20% smaller, got {:.1}%",
196 saving * 100.0
197 );
198 }
199}