Skip to main content

legume_numeric/matrix/
dmatrix_rsvd.rs

1use crate::matrix::traits::*;
2use nalgebra::{DMatrix, DVector};
3use nalgebra_sparse::{csc::CscMatrix, csr::CsrMatrix};
4use rayon::prelude::*;
5use std::borrow::Cow;
6
7/// Fixed start-vector seed for the randomized-SVD subspace iteration. The
8/// iteration converges onto the dominant subspace, so pinning the start makes
9/// `rsvd` reproducible without altering the subspace it recovers.
10const RSVD_SUBSPACE_SEED: u64 = 0x5253_5644_5342_5350; // "RSVDSBSP"
11
12/// Compute the Nystrom basis: `U * diag(1 / (s + eps))`.
13///
14/// Given the left singular vectors `u` and singular values `s` from an SVD,
15/// returns the pseudo-inverted projection matrix used for out-of-sample
16/// Nystrom extension.
17pub fn nystrom_basis(u: &DMatrix<f32>, s: &DVector<f32>) -> DMatrix<f32> {
18    let eps = 1e-8;
19    let sinv = DVector::from_iterator(s.len(), s.iter().map(|&si| 1.0 / (si + eps)));
20    u * DMatrix::from_diagonal(&sinv)
21}
22
23/// What the subspace iteration needs of `X`: products with `X` and `Xᵀ`.
24trait LinOp<T: nalgebra::Scalar> {
25    fn matmul(&self, other: &DMatrix<T>) -> DMatrix<T>;
26    fn transpose_matmul(&self, other: &DMatrix<T>) -> DMatrix<T>;
27    fn num_rows(&self) -> usize;
28    fn num_columns(&self) -> usize;
29}
30
31impl<T> LinOp<T> for DMatrix<T>
32where
33    T: nalgebra::RealField + num_traits::Float + Copy,
34{
35    fn matmul(&self, other: &DMatrix<T>) -> DMatrix<T> {
36        self * other
37    }
38
39    fn transpose_matmul(&self, other: &DMatrix<T>) -> DMatrix<T> {
40        self.transpose() * other
41    }
42
43    fn num_rows(&self) -> usize {
44        self.nrows()
45    }
46    fn num_columns(&self) -> usize {
47        self.ncols()
48    }
49}
50
51/// A sparse matrix held in both orientations for the subspace iteration,
52/// which multiplies by `X` and by `Xᵀ` in turn. Compressed rows of `X` give
53/// `X · B` and compressed columns, the rows of `Xᵀ`, give `Xᵀ · B`, each
54/// row-parallel with no transpose per product. The orientation the caller
55/// does not hold is built once.
56struct SparseOp<'a, T: nalgebra::Scalar> {
57    csr: Cow<'a, CsrMatrix<T>>,
58    csc: Cow<'a, CscMatrix<T>>,
59}
60
61impl<'a, T> SparseOp<'a, T>
62where
63    T: nalgebra::RealField + Copy,
64{
65    fn from_csr(x: &'a CsrMatrix<T>) -> Self {
66        Self {
67            csr: Cow::Borrowed(x),
68            csc: Cow::Owned(CscMatrix::from(x)),
69        }
70    }
71
72    fn from_csc(x: &'a CscMatrix<T>) -> Self {
73        Self {
74            csr: Cow::Owned(CsrMatrix::from(x)),
75            csc: Cow::Borrowed(x),
76        }
77    }
78}
79
80impl<T> LinOp<T> for SparseOp<'_, T>
81where
82    T: nalgebra::RealField + Copy,
83{
84    fn matmul(&self, other: &DMatrix<T>) -> DMatrix<T> {
85        let x = &*self.csr;
86        rows_times_dense(x.row_offsets(), x.col_indices(), x.values(), other)
87    }
88    fn transpose_matmul(&self, other: &DMatrix<T>) -> DMatrix<T> {
89        let x = &*self.csc;
90        rows_times_dense(x.col_offsets(), x.row_indices(), x.values(), other)
91    }
92    fn num_rows(&self) -> usize {
93        self.csr.nrows()
94    }
95    fn num_columns(&self) -> usize {
96        self.csr.ncols()
97    }
98}
99
100/// Rows below which a thread is not worth splitting off.
101const MIN_ROWS_PER_TASK: usize = 256;
102
103/// Output columns a row accumulates at once, in registers rather than in the
104/// output row, which would be reloaded and stored once per entry.
105const COLUMN_BLOCK: usize = 8;
106
107/// `S · B` for `S` given by compressed rows (`offsets`, `indices`, `values`),
108/// one row of the product per task. `B` is read through its transpose, so a
109/// row of `B` is contiguous; each output entry sums in stored order, so the
110/// result does not depend on the thread count. The product is bound by
111/// gathering rows of `B`, not by arithmetic, so wider SIMD buys nothing.
112fn rows_times_dense<T>(
113    offsets: &[usize],
114    indices: &[usize],
115    values: &[T],
116    b: &DMatrix<T>,
117) -> DMatrix<T>
118where
119    T: nalgebra::RealField + Copy,
120{
121    let m = offsets.len() - 1;
122    let c = b.ncols();
123    if c == 0 {
124        return DMatrix::zeros(m, 0);
125    }
126    let bt = b.transpose();
127    let bt = bt.as_slice();
128    let mut out = vec![T::zero(); m * c];
129    out.par_chunks_mut(c)
130        .enumerate()
131        .with_min_len(MIN_ROWS_PER_TASK)
132        .for_each(|(i, row)| {
133            let entries = offsets[i]..offsets[i + 1];
134            let (idx, val) = (&indices[entries.clone()], &values[entries]);
135            let mut k0 = 0;
136            while k0 + COLUMN_BLOCK <= c {
137                let mut acc = [T::zero(); COLUMN_BLOCK];
138                for (&j, &v) in idx.iter().zip(val) {
139                    let src = &bt[j * c + k0..j * c + k0 + COLUMN_BLOCK];
140                    for (a, &s) in acc.iter_mut().zip(src) {
141                        *a += v * s;
142                    }
143                }
144                row[k0..k0 + COLUMN_BLOCK].copy_from_slice(&acc);
145                k0 += COLUMN_BLOCK;
146            }
147            for (&j, &v) in idx.iter().zip(val) {
148                for (o, &s) in row[k0..].iter_mut().zip(&bt[j * c + k0..(j + 1) * c]) {
149                    *o += v * s;
150                }
151            }
152        });
153    DMatrix::from_row_slice(m, c, &out)
154}
155
156fn _subspace_iteration<T, D>(
157    xx: &D,
158    rank_and_oversample: usize,
159    power_iters: usize,
160) -> anyhow::Result<DMatrix<T>>
161where
162    T: nalgebra::RealField + num_traits::Float + Copy,
163    D: LinOp<T>,
164{
165    let nc = xx.num_columns();
166    // Fixed seed: with enough power iterations the subspace converges onto
167    // the dominant one regardless of the start, so a pinned (rather than
168    // entropy) draw makes the whole randomized SVD reproducible run-to-run —
169    // which in turn pins every downstream consumer (binary-sketch collapse,
170    // layout, SVD fits) — without changing what subspace it recovers. With
171    // very few iterations the result does depend on this start.
172    let mut qq = DMatrix::<T>::runif_seeded(nc, rank_and_oversample, RSVD_SUBSPACE_SEED);
173    let half = T::from(0.5).expect("no half found");
174    qq.iter_mut().for_each(|x| *x -= half);
175
176    // Each half-step re-orthonormalises the iterate with a thin QR. The
177    // basis must span exactly the range of the product it came from: a
178    // pivoted LU factor does not (its permutation is lost), and iterating on
179    // a row-permuted range does not converge onto the dominant subspace.
180    for _i in 0..power_iters {
181        let ll = xx.matmul(&qq).qr().q();
182        qq = xx.transpose_matmul(&ll).qr().q();
183    }
184
185    let qr_q = xx.matmul(&qq).qr().q();
186    let kk = rank_and_oversample.min(qr_q.ncols());
187    let ret = qr_q.columns(0, kk).into_owned();
188
189    Ok(ret)
190}
191
192fn _randomized_svd<T, D>(
193    xx: &D,
194    max_rank: usize,
195    args: &RsvdArgs,
196) -> anyhow::Result<(DMatrix<T>, DVector<T>, DMatrix<T>)>
197where
198    T: nalgebra::RealField + num_traits::Float + Copy,
199    D: LinOp<T>,
200{
201    let nr = xx.num_rows();
202    let nc = xx.num_columns();
203
204    let mut rank = nr.min(nc);
205    let mut oversample = 0;
206
207    if max_rank > 0 && rank > max_rank {
208        rank = max_rank;
209        oversample = args.oversample;
210    }
211
212    anyhow::ensure!(rank > 0, "randomized SVD of an empty {nr} x {nc} matrix");
213
214    // Keep the oversampled basis through the projection: its columns are
215    // not ordered by singular value, so truncating here would discard part
216    // of the dominant subspace. The rank is applied to the small SVD below.
217    // Columns beyond the matrix's own rank add nothing; the cap sits above
218    // anything the default oversampling reaches, so it changes no default
219    // result and only stops an oversized `oversample` from overflowing or
220    // allocating for nothing.
221    let width = rank
222        .saturating_add(oversample)
223        .min(nr.min(nc) + RsvdArgs::default().oversample);
224    let qq = _subspace_iteration(xx, width, args.power_iters)?;
225    let rank = rank.min(qq.ncols());
226
227    // bb = qqᵀ · xx
228    let bb = xx.transpose_matmul(&qq).transpose();
229
230    let svd = bb.svd(true, true);
231
232    if let (Some(svd_u), Some(svd_vt)) = (svd.u, svd.v_t) {
233        return Ok((
234            &qq * svd_u.columns(0, rank),
235            svd.singular_values.rows(0, rank).into_owned(),
236            svd_vt.transpose().columns(0, rank).into_owned(),
237        ));
238    }
239    Err(anyhow::anyhow!("randomized SVD failed"))
240}
241
242impl<T> RandomizedAlgs for DMatrix<T>
243where
244    T: nalgebra::RealField + num_traits::Float + Copy,
245{
246    type InMat = DMatrix<T>;
247    type OutMat = DMatrix<T>;
248    type DVec = DVector<T>;
249    type Scalar = T;
250
251    fn rsvd_with(
252        &self,
253        max_rank: usize,
254        args: &RsvdArgs,
255    ) -> anyhow::Result<(Self::OutMat, Self::DVec, Self::OutMat)> {
256        _randomized_svd(self, max_rank, args)
257    }
258}
259
260impl<T> RandomizedAlgs for CscMatrix<T>
261where
262    T: nalgebra::RealField + num_traits::Float + Copy,
263{
264    type InMat = CscMatrix<T>;
265    type OutMat = DMatrix<T>;
266    type DVec = DVector<T>;
267    type Scalar = T;
268
269    fn rsvd_with(
270        &self,
271        max_rank: usize,
272        args: &RsvdArgs,
273    ) -> anyhow::Result<(Self::OutMat, Self::DVec, Self::OutMat)> {
274        _randomized_svd(&SparseOp::from_csc(self), max_rank, args)
275    }
276}
277
278impl<T> RandomizedAlgs for CsrMatrix<T>
279where
280    T: nalgebra::RealField + num_traits::Float + Copy,
281{
282    type InMat = CsrMatrix<T>;
283    type OutMat = DMatrix<T>;
284    type DVec = DVector<T>;
285    type Scalar = T;
286
287    fn rsvd_with(
288        &self,
289        max_rank: usize,
290        args: &RsvdArgs,
291    ) -> anyhow::Result<(Self::OutMat, Self::DVec, Self::OutMat)> {
292        _randomized_svd(&SparseOp::from_csr(self), max_rank, args)
293    }
294}
295
296#[cfg(test)]
297#[path = "dmatrix_rsvd_tests.rs"]
298mod tests;