Skip to main content

single_svdlib/irlba/
mod.rs

1//! Thick-restarted Lanczos bidiagonalization.
2//!
3//! Golub–Kahan–Lanczos bidiagonalization with augmented thick restarts, following
4//! Baglama & Reichel (2005). This is the algorithm behind R's `irlba` and, in spirit,
5//! `scipy.sparse.linalg.svds`.
6//!
7//! # Why this rather than [`crate::lanczos`]
8//!
9//! LAS2 keeps every Lanczos vector it generates, so its basis grows with the iteration
10//! count — unbounded in practice, since `iterations` defaults to `min(rows, cols)`.
11//! Here the basis is fixed at `work` vectors (`rank + 7` by default) no matter how many
12//! restarts are needed, so peak memory is known before the solve starts:
13//!
14//! ```text
15//! (work + 1) · cols + work · rows   scalars
16//! ```
17//!
18//! For 200k × 30k at rank 50 that is about 95 MiB and it does not grow.
19//!
20//! # Method
21//!
22//! Each cycle extends the factorization
23//!
24//! ```text
25//! A·V  = U·B
26//! Aᵀ·U = V·Bᵀ + β·v_next·eᵀ
27//! ```
28//!
29//! to `work` columns, where `B` is small and bidiagonal, then takes the SVD of `B`.
30//! Its singular values are the Ritz estimates and `|β · P[work-1, i]|` is the residual
31//! for triplet `i`. Unconverged cycles restart from the `rank` best Ritz vectors plus
32//! the residual direction, which preserves the factorization's structure — the restart
33//! costs one extra column in `B` rather than throwing the subspace away.
34
35use crate::dense::{small_svd, svd_flip};
36use crate::error::{Result, SvdLibError};
37use crate::matrix::{SparseMat, SparseMatDense};
38use crate::types::{Algorithm, Detail, Diagnostics, SvdFloat, SvdRec};
39use ndarray::{s, Array1, Array2, ArrayView2};
40use num_traits::Float;
41use rand::rngs::StdRng;
42use rand::{rng, Rng, RngExt, SeedableRng};
43
44/// Default relative residual tolerance.
45pub const DEFAULT_TOL: f64 = 1e-10;
46/// Default extra basis vectors beyond the requested rank.
47pub const DEFAULT_EXTRA_WORK: usize = 7;
48/// Default cap on restart cycles.
49pub const DEFAULT_MAX_RESTARTS: usize = 1000;
50
51/// Configuration for [`svd_with`].
52#[derive(Debug, Clone)]
53pub struct IrlbaConfig {
54    /// Number of singular triplets wanted.
55    pub rank: usize,
56    /// Basis size. Must exceed `rank`; defaults to `rank + 7`, clamped to
57    /// `min(rows, cols)`.
58    ///
59    /// # This is the knob that matters on tall matrices
60    ///
61    /// Each restart re-orthogonalises against the whole basis, so a step costs
62    /// `O(work · (rows + cols))` — on a matrix with a million rows that dominates the
63    /// sparse products by a wide margin. A larger `work` makes each step dearer but
64    /// converges in far fewer restarts, and the second effect wins comfortably.
65    ///
66    /// Measured on 400k × 30k restricted to 2238 columns, 50 components
67    /// (`cargo run --release --example tune`):
68    ///
69    /// | `work` | time | matvecs | accuracy |
70    /// |---|---|---|---|
71    /// | `rank + 7` (default) | 30.2 s | 1515 | 6.7e-17 |
72    /// | `rank + 30` | **13.7 s** | 701 | 1.9e-14 |
73    /// | `rank + 50` | 18.6 s | 601 | 2.0e-14 |
74    /// | `rank + 100` | 21.4 s | 501 | 2.2e-14 |
75    ///
76    /// So `rank + 30` was **2.2× faster** at the same accuracy. The cost is basis
77    /// memory, which is linear in `work`: `work · rows` scalars for the left basis, or
78    /// 600 MiB rather than 456 MiB at a million rows. The default stays conservative
79    /// because memory is the reason to choose this crate; raise it when you have the
80    /// headroom.
81    ///
82    /// Run the `tune` example on your own shape rather than trusting these numbers —
83    /// the optimum moves with the aspect ratio and the spectrum.
84    pub work: Option<usize>,
85    /// Relative residual tolerance: triplet `i` is accepted once its residual falls
86    /// below `tol · σ_max`.
87    pub tol: f64,
88    /// Cap on restart cycles before giving up.
89    pub max_restarts: usize,
90    /// Fixed seed for the starting vector; `None` draws from the OS.
91    pub seed: Option<u64>,
92    /// Subtract column means without materialising the centered matrix — i.e. PCA
93    /// rather than plain SVD.
94    pub mean_center: bool,
95    /// Refuse to return triplets that did not reach [`tol`](Self::tol). Defaults to
96    /// `true`.
97    ///
98    /// Exhausting the restart budget means the answer is a best effort of unknown
99    /// quality. Returning it as `Ok` puts the burden on every caller to remember to
100    /// inspect the diagnostics, and a pipeline that forgets gets a silently degraded
101    /// decomposition feeding whatever comes next. Failing loudly is the safer default;
102    /// set this to `false` if a best effort is genuinely what you want, then check
103    /// [`SvdRec::converged`].
104    pub require_convergence: bool,
105}
106
107impl IrlbaConfig {
108    /// Configuration for `rank` triplets, everything else defaulted.
109    pub fn new(rank: usize) -> Self {
110        Self {
111            rank,
112            work: None,
113            tol: DEFAULT_TOL,
114            max_restarts: DEFAULT_MAX_RESTARTS,
115            seed: None,
116            mean_center: false,
117            require_convergence: true,
118        }
119    }
120    /// Set the basis size.
121    pub fn work(mut self, work: usize) -> Self {
122        self.work = Some(work);
123        self
124    }
125    /// Set the relative residual tolerance.
126    pub fn tol(mut self, tol: f64) -> Self {
127        self.tol = tol;
128        self
129    }
130    /// Set the restart cap.
131    pub fn max_restarts(mut self, n: usize) -> Self {
132        self.max_restarts = n;
133        self
134    }
135    /// Fix the seed, making the result reproducible.
136    pub fn seed(mut self, seed: u64) -> Self {
137        self.seed = Some(seed);
138        self
139    }
140    /// Enable implicit mean centering.
141    pub fn mean_center(mut self, yes: bool) -> Self {
142        self.mean_center = yes;
143        self
144    }
145    /// Accept a best-effort result instead of failing when the restart budget runs out.
146    pub fn allow_unconverged(mut self) -> Self {
147        self.require_convergence = false;
148        self
149    }
150}
151
152/// `rank` largest singular triplets, defaults throughout.
153pub fn svd<T: SvdFloat, M: SparseMat<T>>(a: &M, rank: usize) -> Result<SvdRec<T>> {
154    svd_with(a, &IrlbaConfig::new(rank), None)
155}
156
157/// `rank` largest singular triplets with a fixed seed.
158pub fn svd_seed<T: SvdFloat, M: SparseMat<T>>(a: &M, rank: usize, seed: u64) -> Result<SvdRec<T>> {
159    svd_with(a, &IrlbaConfig::new(rank).seed(seed), None)
160}
161
162/// PCA: `rank` largest singular triplets of the implicitly mean-centered matrix.
163///
164/// Requires [`SparseMatDense`] only to obtain the column means; the solve itself uses
165/// matrix-vector products throughout.
166pub fn svd_centered<T: SvdFloat, M: SparseMatDense<T>>(
167    a: &M,
168    rank: usize,
169    seed: Option<u64>,
170) -> Result<SvdRec<T>> {
171    let means = a.col_means();
172    let mut cfg = IrlbaConfig::new(rank).mean_center(true);
173    cfg.seed = seed;
174    svd_with(a, &cfg, Some(means))
175}
176
177/// An operand with optional implicit mean centering applied around its products.
178///
179/// Centering a sparse matrix would destroy its sparsity, so the shift is folded into
180/// each product as the rank-1 term it is.
181struct Op<'a, T, M> {
182    a: &'a M,
183    /// Column means, when centering.
184    means: Option<&'a [T]>,
185    _p: std::marker::PhantomData<T>,
186}
187
188impl<'a, T: SvdFloat, M: SparseMat<T>> Op<'a, T, M> {
189    fn rows(&self) -> usize {
190        self.a.rows()
191    }
192    fn cols(&self) -> usize {
193        self.a.cols()
194    }
195
196    /// `y = A·x` (`trans == false`) or `y = Aᵀ·x` (`trans == true`), centered if
197    /// configured.
198    fn mul(&self, x: &[T], y: &mut [T], trans: bool) {
199        self.a.mul_vec(x, y, trans);
200        let Some(m) = self.means else { return };
201        if !trans {
202            // (A − 1·mᵀ)x = A·x − 1·(m·x)
203            let c: T = m.iter().zip(x.iter()).map(|(&a, &b)| a * b).sum();
204            for yi in y.iter_mut() {
205                *yi -= c;
206            }
207        } else {
208            // (A − 1·mᵀ)ᵀx = Aᵀ·x − m·(1ᵀx)
209            let sum: T = x.iter().copied().sum();
210            for (yi, &mi) in y.iter_mut().zip(m.iter()) {
211                *yi -= mi * sum;
212            }
213        }
214    }
215}
216
217/// Two-pass classical Gram–Schmidt against the first `count` rows of `basis`.
218///
219/// One pass leaves `O(κ·eps)` non-orthogonality; twice is enough to reach machine
220/// precision (Kahan–Parlett), and expressing it as BLAS-2 products keeps it far cheaper
221/// than the `count` separate axpy pairs the equivalent loop would issue.
222///
223/// `coeffs` is a caller-owned scratch buffer of at least `count` elements, and the
224/// correction is accumulated straight into `w` via `general_mat_vec_mul`. Allocating
225/// either of those here would mean two heap allocations per Lanczos step, one of them
226/// the full length of `w`.
227/// Returns the *total* coefficient removed along each basis vector, accumulated over
228/// both passes.
229///
230/// Callers must record these. In an undisturbed Krylov recurrence they are numerical
231/// drift, around zero, and discarding them is harmless. After a breakdown restart they
232/// are not: the injected random direction has genuine components along every previous
233/// vector, and dropping them silently breaks `A·V = U·B`, so `B`'s spectrum stops being
234/// `A`'s. That surfaced as a *skipped* singular value — ten real triplets returned, but
235/// the ninth-largest missing — with every residual small enough to claim convergence.
236fn reorthogonalize<T: SvdFloat>(
237    w: &mut Array1<T>,
238    basis: &ArrayView2<T>,
239    count: usize,
240    coeffs: &mut Array1<T>,
241) {
242    if count == 0 {
243        return;
244    }
245    let b = basis.slice(s![..count, ..]);
246    let mut total = Array1::<T>::zeros(count);
247    {
248        let mut c = coeffs.slice_mut(s![..count]);
249        for _ in 0..2 {
250            // c = b · w
251            ndarray::linalg::general_mat_vec_mul(T::one(), &b, w, T::zero(), &mut c);
252            // w = w - bᵀ · c
253            ndarray::linalg::general_mat_vec_mul(-T::one(), &b.t(), &c, T::one(), w);
254            total += &c;
255        }
256    }
257    coeffs.slice_mut(s![..count]).assign(&total);
258}
259
260/// Draw a unit vector orthogonal to the first `count` rows of `basis`.
261///
262/// Returns `false` when the space is exhausted — no direction remains.
263///
264/// The quality floor matters. A random draw that happens to land mostly inside the
265/// existing span leaves a tiny residual, and normalising that residual scales the
266/// Gram-Schmidt error up by its reciprocal. Accepting any non-zero residual let a
267/// basis vector be orthogonal to only ~1e-8, which propagated into the returned
268/// singular vectors as `||UᵀU - I|| = 2e-7`. Re-drawing costs nothing here and keeps
269/// the amplification at O(1).
270fn random_orthogonal<T: SvdFloat>(
271    out: &mut Array1<T>,
272    basis: &ArrayView2<T>,
273    count: usize,
274    coeffs: &mut Array1<T>,
275    rng_state: &mut StdRng,
276) -> bool {
277    let floor = T::from_f64_val(0.1);
278    for _ in 0..4 {
279        random_unit(out, rng_state);
280        // The coefficients here describe the *random* vector, not `A·v`, so unlike the
281        // main path they must not be recorded in `B`.
282        reorthogonalize(out, basis, count, coeffs);
283        let n = norm(out);
284        if n > floor && num_traits::Float::is_finite(n) {
285            *out /= n;
286            return true;
287        }
288    }
289    false
290}
291
292fn norm<T: SvdFloat>(v: &Array1<T>) -> T {
293    v.iter().map(|&x| x * x).sum::<T>().sqrt()
294}
295
296/// Fill `v` with a deterministic unit random vector.
297fn random_unit<T: SvdFloat>(v: &mut Array1<T>, rng_state: &mut StdRng) {
298    for x in v.iter_mut() {
299        *x = T::from_f64_val(rng_state.random_range(-1.0..1.0));
300    }
301    let n = norm(v);
302    if n > T::zero() {
303        *v /= n;
304    } else {
305        v.fill(T::zero());
306        v[0] = T::one();
307    }
308}
309
310/// Compute a decomposition with explicit configuration.
311///
312/// `means`, when given, must have length `a.cols()` and is only consulted if
313/// `cfg.mean_center` is set. [`svd_centered`] is the convenient entry point.
314pub fn svd_with<T: SvdFloat, M: SparseMat<T>>(
315    a: &M,
316    cfg: &IrlbaConfig,
317    means: Option<Array1<T>>,
318) -> Result<SvdRec<T>> {
319    let (rows, cols) = (a.rows(), a.cols());
320    let min_dim = rows.min(cols);
321
322    if cfg.rank == 0 {
323        return Err(SvdLibError::invalid("irlba: rank must be at least 1"));
324    }
325    if cfg.rank > min_dim {
326        return Err(SvdLibError::invalid(format!(
327            "irlba: rank {} exceeds min(rows, cols) = {min_dim}",
328            cfg.rank
329        )));
330    }
331    if cfg.mean_center {
332        match &means {
333            Some(m) if m.len() == cols => {}
334            Some(m) => {
335                return Err(SvdLibError::shape(format!(
336                    "irlba: means has length {} but the matrix has {cols} columns",
337                    m.len()
338                )))
339            }
340            None => {
341                return Err(SvdLibError::invalid(
342                    "irlba: mean_center is set but no means were supplied; \
343                     use `svd_centered`",
344                ))
345            }
346        }
347    }
348
349    let k = cfg.rank;
350    let seed = cfg.seed.unwrap_or_else(|| rng().next_u64());
351
352    // A matrix with a dimension of 1 has exactly one singular triplet and no Krylov
353    // subspace to build. Solve it in closed form rather than rejecting it: masking down
354    // to a single column is a perfectly ordinary thing to do.
355    if min_dim == 1 {
356        return trivial_rank_one(a, &means, cfg, seed);
357    }
358
359    let work = cfg
360        .work
361        .unwrap_or(k + DEFAULT_EXTRA_WORK)
362        .clamp(k + 1, min_dim.max(k + 1))
363        .min(min_dim);
364    if work <= k {
365        return Err(SvdLibError::invalid(format!(
366            "irlba: rank {k} needs a basis of at least {} vectors but the matrix only \
367             admits {min_dim}; request at most {} triplets, or use \
368             `single_svdlib::randomized`, which supports the full rank",
369            k + 1,
370            min_dim - 1
371        )));
372    }
373
374    let means_slice = if cfg.mean_center {
375        means.as_ref().map(|m| m.as_slice().unwrap())
376    } else {
377        None
378    };
379    let op = Op {
380        a,
381        means: means_slice,
382        _p: std::marker::PhantomData,
383    };
384
385    let mut state = Solve::new(&op, work, k, cfg.tol, seed);
386    let outcome = state.run(cfg.max_restarts)?;
387
388    // Assemble the requested triplets.
389    let Solve { v, u, .. } = state;
390    let SolveOutcome {
391        p,
392        q,
393        sigma,
394        restarts,
395        converged,
396        max_residual,
397        matvecs,
398    } = outcome;
399
400    // u_out[r, i] = Σ_j P[j, i] · U[j, r]        (rows × k)
401    // vt_out[i, c] = Σ_j Q[j, i] · V[j, c]       (k × cols)
402    let pk = p.slice(s![.., ..k]);
403    let qk = q.slice(s![.., ..k]);
404    let mut u_out = pk
405        .t()
406        .dot(&u.slice(s![..work, ..]))
407        .reversed_axes()
408        .to_owned();
409    let mut vt_out = qk.t().dot(&v.slice(s![..work, ..])).to_owned();
410    let s_out = sigma.slice(s![..k]).to_owned();
411
412    if cfg.require_convergence && !converged {
413        return Err(SvdLibError::failed(
414            "irlba",
415            format!(
416                "did not converge in {restarts} restarts: largest residual is {:.3e} \
417                 against a threshold of {:.3e} (tol {:.1e} x sigma_max). Raise \
418                 `max_restarts` or `work`, loosen `tol`, or call `allow_unconverged` to \
419                 accept a best effort.",
420                max_residual.to_f64(),
421                cfg.tol * sigma[0].to_f64(),
422                cfg.tol,
423            ),
424        ));
425    }
426
427    // Pin the per-triplet sign so repeat runs agree.
428    svd_flip(&mut u_out, &mut vt_out);
429
430    Ok(SvdRec {
431        d: k,
432        u: u_out,
433        s: s_out,
434        vt: vt_out,
435        total_squared_norm: T::from_f64_val(crate::matrix::total_squared_norm(
436            a,
437            means.as_ref().map(|m| m.view()),
438        )),
439        diagnostics: Diagnostics {
440            algorithm: Algorithm::Irlba,
441            non_zero: a.nnz(),
442            dimensions: k,
443            significant_values: k,
444            transposed: false,
445            random_seed: seed,
446            matvecs,
447            detail: Detail::Irlba {
448                restarts,
449                converged,
450                tolerance: T::from_f64_val(cfg.tol),
451                max_residual,
452            },
453        },
454    })
455}
456
457/// The `min(rows, cols) == 1` case, in closed form.
458///
459/// Such a matrix is a single row or column, so it has exactly one singular value —
460/// the vector's norm — with the unit vector on the short side and the normalised
461/// vector on the long side.
462fn trivial_rank_one<T: SvdFloat, M: SparseMat<T>>(
463    a: &M,
464    means: &Option<Array1<T>>,
465    cfg: &IrlbaConfig,
466    seed: u64,
467) -> Result<SvdRec<T>> {
468    let (rows, cols) = (a.rows(), a.cols());
469    let op = Op {
470        a,
471        means: if cfg.mean_center {
472            means.as_ref().map(|m| m.as_slice().unwrap())
473        } else {
474            None
475        },
476        _p: std::marker::PhantomData,
477    };
478
479    // Materialise the single row (or column) by probing with a unit vector.
480    let (long, short, trans) = if rows == 1 {
481        (cols, rows, true)
482    } else {
483        (rows, cols, false)
484    };
485    let mut probe = vec![T::zero(); short];
486    probe[0] = T::one();
487    let mut vec = vec![T::zero(); long];
488    op.mul(&probe, &mut vec, trans);
489
490    let sigma = vec.iter().map(|&x| x * x).sum::<T>().sqrt();
491    if !num_traits::Float::is_finite(sigma) {
492        return Err(SvdLibError::failed("irlba", "the operand is not finite"));
493    }
494
495    let (u, vt) = if sigma > T::zero() {
496        let unit: Vec<T> = vec.iter().map(|&x| x / sigma).collect();
497        if rows == 1 {
498            // 1 x cols: u = [1], vt = row / sigma
499            (
500                Array2::from_shape_vec((1, 1), vec![T::one()])?,
501                Array2::from_shape_vec((1, cols), unit)?,
502            )
503        } else {
504            // rows x 1: u = col / sigma, vt = [1]
505            (
506                Array2::from_shape_vec((rows, 1), unit)?,
507                Array2::from_shape_vec((1, 1), vec![T::one()])?,
508            )
509        }
510    } else {
511        // The zero matrix: any unit vectors will do.
512        let mut u = Array2::<T>::zeros((rows, 1));
513        let mut vt = Array2::<T>::zeros((1, cols));
514        u[[0, 0]] = T::one();
515        vt[[0, 0]] = T::one();
516        (u, vt)
517    };
518
519    Ok(SvdRec {
520        d: 1,
521        u,
522        s: Array1::from_vec(vec![sigma]),
523        vt,
524        total_squared_norm: T::from_f64_val(crate::matrix::total_squared_norm(
525            a,
526            if cfg.mean_center {
527                means.as_ref().map(|m| m.view())
528            } else {
529                None
530            },
531        )),
532        diagnostics: Diagnostics {
533            algorithm: Algorithm::Irlba,
534            non_zero: a.nnz(),
535            dimensions: 1,
536            significant_values: 1,
537            transposed: false,
538            random_seed: seed,
539            matvecs: 1,
540            detail: Detail::Irlba {
541                restarts: 0,
542                converged: true,
543                tolerance: T::from_f64_val(cfg.tol),
544                max_residual: T::zero(),
545            },
546        },
547    })
548}
549
550struct SolveOutcome<T> {
551    /// Left singular vectors of `B`, `work × work`.
552    p: Array2<T>,
553    /// Right singular vectors of `B` (as columns), `work × work`.
554    q: Array2<T>,
555    sigma: Array1<T>,
556    restarts: usize,
557    converged: bool,
558    max_residual: T,
559    matvecs: usize,
560}
561
562/// The bidiagonalization state. Vectors are stored as **rows** so each is contiguous
563/// and can be handed to [`SparseMat::mul_vec`] without a copy.
564struct Solve<'a, T, M> {
565    op: &'a Op<'a, T, M>,
566    work: usize,
567    k: usize,
568    tol: f64,
569    /// `(work + 1) × cols`
570    v: Array2<T>,
571    /// `work × rows`
572    u: Array2<T>,
573    /// `work × work`, bidiagonal plus the restart coupling column.
574    b: Array2<T>,
575    rng: StdRng,
576    matvecs: usize,
577    /// Running estimate of `||A||`, taken as the largest recurrence coefficient seen.
578    /// The breakdown test has to be relative to this: a rank-deficient operand yields a
579    /// coefficient around `1e-17` rather than exactly zero, and dividing by it amplifies
580    /// rounding noise to O(1) garbage and then to NaN.
581    anorm: T,
582}
583
584impl<'a, T: SvdFloat, M: SparseMat<T>> Solve<'a, T, M> {
585    fn new(op: &'a Op<'a, T, M>, work: usize, k: usize, tol: f64, seed: u64) -> Self {
586        Self {
587            op,
588            work,
589            k,
590            tol,
591            v: Array2::zeros((work + 1, op.cols())),
592            u: Array2::zeros((work, op.rows())),
593            b: Array2::zeros((work, work)),
594            rng: StdRng::seed_from_u64(seed),
595            matvecs: 0,
596            anorm: T::zero(),
597        }
598    }
599
600    /// Below this, a recurrence coefficient is treated as zero and the subspace as
601    /// invariant. Scaled by the operator norm so it means the same thing whatever the
602    /// matrix's magnitude; when nothing has been seen yet (`anorm == 0`, e.g. an
603    /// all-zero matrix) it degenerates to an exact-zero test, which is correct.
604    fn breakdown_threshold(&self) -> T {
605        let dim = T::from_f64_val((self.op.rows().max(self.op.cols()) as f64).sqrt());
606        self.anorm * T::eps() * dim
607    }
608
609    /// Extend the factorization from column `start` to `work`.
610    ///
611    /// `coupling` is the restart's `ρ` vector when `start > 0`: at the first extended
612    /// column the new left vector must be orthogonalised against all `k` retained
613    /// left Ritz vectors, not just its immediate predecessor.
614    ///
615    /// Returns `(β, v_next)` — the trailing residual norm and direction.
616    fn extend(&mut self, start: usize, coupling: Option<&Array1<T>>) -> Result<(T, Array1<T>)> {
617        let (rows, cols) = (self.op.rows(), self.op.cols());
618        let mut w = Array1::<T>::zeros(rows);
619        let mut z = Array1::<T>::zeros(cols);
620        // Reused by every reorthogonalization in this sweep.
621        let mut coeffs = Array1::<T>::zeros(self.work + 1);
622
623        for j in start..self.work {
624            // w = A·v_j, minus the coupling to the already-built left vectors.
625            {
626                let vj = self.v.row(j).to_owned();
627                self.op
628                    .mul(vj.as_slice().unwrap(), w.as_slice_mut().unwrap(), false);
629                self.matvecs += 1;
630            }
631            if j == start && start > 0 {
632                let rho = coupling.expect("restart requires a coupling vector");
633                // w -= Σ_{i<k} ρ_i · u_i
634                let uk = self.u.slice(s![..self.k, ..]);
635                w -= &uk.t().dot(rho);
636            } else if j > 0 {
637                let beta_prev = self.b[[j - 1, j]];
638                let uprev = self.u.row(j - 1);
639                w.scaled_add(-beta_prev, &uprev);
640            }
641
642            {
643                let ub = self.u.view();
644                reorthogonalize(&mut w, &ub, j, &mut coeffs);
645            }
646            // Record what reorthogonalization removed. `A·v_j = Σ_i B[i,j]·u_i` only
647            // holds if these land in B; see `reorthogonalize`.
648            for i in 0..j {
649                self.b[[i, j]] += coeffs[i];
650            }
651            let alpha = norm(&w);
652            // A non-finite norm means the operand (or the iterate) is poisoned. Bail
653            // immediately: continuing would spend the whole restart budget producing
654            // NaN and then report a residual that means nothing.
655            if !num_traits::Float::is_finite(alpha) {
656                return Err(SvdLibError::failed(
657                    "irlba",
658                    "the left Krylov vector became non-finite; the matrix most likely \
659                     contains NaN or infinity",
660                ));
661            }
662            // `alpha` is the true recurrence coefficient even when the subspace has
663            // gone invariant, in which case it is (numerically) zero and a random
664            // direction carries the basis forward. Recording the *random* vector's norm
665            // here instead would invent a singular value out of nothing — on an
666            // all-zero matrix that reported 2.9.
667            let alpha_kept = if alpha <= self.breakdown_threshold() {
668                let ub = self.u.view();
669                if !random_orthogonal(&mut w, &ub, j, &mut coeffs, &mut self.rng) {
670                    // Same completion case as on the right, reached when `work` meets
671                    // `rows`: no direction remains orthogonal to those already held.
672                    return Ok((T::zero(), Array1::zeros(cols)));
673                }
674                T::zero()
675            } else {
676                self.anorm = Float::max(self.anorm, alpha);
677                w /= alpha;
678                alpha
679            };
680            self.u.row_mut(j).assign(&w);
681            self.b[[j, j]] = alpha_kept;
682
683            // z = Aᵀ·u_j − α·v_j
684            self.op
685                .mul(w.as_slice().unwrap(), z.as_slice_mut().unwrap(), true);
686            self.matvecs += 1;
687            {
688                let vj = self.v.row(j);
689                z.scaled_add(-alpha_kept, &vj);
690            }
691            {
692                let vb = self.v.view();
693                reorthogonalize(&mut z, &vb, j + 1, &mut coeffs);
694            }
695            let beta = norm(&z);
696            if !num_traits::Float::is_finite(beta) {
697                return Err(SvdLibError::failed(
698                    "irlba",
699                    "the right Krylov vector became non-finite; the matrix most likely \
700                     contains NaN or infinity",
701                ));
702            }
703            let (beta_kept, zn) = if beta <= self.breakdown_threshold() {
704                let vb = self.v.view();
705                if !random_orthogonal(&mut z, &vb, j + 1, &mut coeffs, &mut self.rng) {
706                    // No direction left that is orthogonal to the `j + 1` already held:
707                    // the basis spans the whole space. That is *completion*, not
708                    // failure — `A` has been fully captured, the residual is exactly
709                    // zero, and the untouched columns of `B` are correctly zero. It
710                    // happens whenever `work` reaches `cols`, which 6% of unseeded runs
711                    // on a 4x3 operand did.
712                    return Ok((T::zero(), Array1::zeros(cols)));
713                }
714                (T::zero(), z.clone())
715            } else {
716                self.anorm = Float::max(self.anorm, beta);
717                (beta, &z / beta)
718            };
719            self.v.row_mut(j + 1).assign(&zn);
720            if j + 1 < self.work {
721                self.b[[j, j + 1]] = beta_kept;
722            } else {
723                return Ok((beta_kept, zn));
724            }
725        }
726        unreachable!("extend always terminates at the final column")
727    }
728
729    fn run(&mut self, max_restarts: usize) -> Result<SolveOutcome<T>> {
730        // Starting vector, drawn from the *row space* rather than from all of R^cols.
731        //
732        // A random vector generally has a component in `null(A)`. The Krylov space then
733        // spends one of its `work` dimensions carrying that component, which is
734        // orthogonal to everything `A` can reach, so only `work - 1` row-space
735        // directions get explored. When `work` is close to `min(rows, cols)` that costs
736        // a real singular value: on an 11x13 operand with a 2-dimensional null space it
737        // returned ten genuine triplets while silently skipping the ninth-largest, and
738        // reported convergence, because every triplet it *did* return was accurate.
739        //
740        // `Aᵀ·r` lies in the row space by construction, so one extra product removes the
741        // whole failure mode.
742        {
743            let mut probe = Array1::<T>::zeros(self.op.rows());
744            random_unit(&mut probe, &mut self.rng);
745            let mut v0 = Array1::<T>::zeros(self.op.cols());
746            self.op
747                .mul(probe.as_slice().unwrap(), v0.as_slice_mut().unwrap(), true);
748            self.matvecs += 1;
749
750            let n = norm(&v0);
751            if n > T::zero() && num_traits::Float::is_finite(n) {
752                v0 /= n;
753            } else {
754                // `A` is (numerically) zero, so the row space is empty and any unit
755                // vector will do.
756                random_unit(&mut v0, &mut self.rng);
757            }
758            self.v.row_mut(0).assign(&v0);
759        }
760
761        let mut start = 0usize;
762        let mut coupling: Option<Array1<T>> = None;
763
764        for restart in 0..=max_restarts {
765            let (beta, v_next) = self.extend(start, coupling.as_ref())?;
766
767            let svd = small_svd(self.b.view())?;
768            let sigma = svd.s;
769            let p = svd.u; // work × work
770            let q = svd.vt.reversed_axes().as_standard_layout().to_owned(); // work × work
771
772            // Residual for triplet i is |β · P[work-1, i]|.
773            let smax = Float::max(sigma[0], T::eps());
774            let thresh = T::from_f64_val(self.tol) * smax;
775            let mut max_resid = T::zero();
776            for i in 0..self.k {
777                let r = Float::abs(beta * p[[self.work - 1, i]]);
778                if r > max_resid {
779                    max_resid = r;
780                }
781            }
782
783            if max_resid <= thresh || restart == max_restarts {
784                return Ok(SolveOutcome {
785                    p,
786                    q,
787                    sigma,
788                    restarts: restart,
789                    converged: max_resid <= thresh,
790                    max_residual: max_resid,
791                    matvecs: self.matvecs,
792                });
793            }
794
795            // Thick restart: retain the k best Ritz pairs plus the residual direction.
796            //
797            // A·(V·qᵢ) = σᵢ·(U·pᵢ) and Aᵀ·(U·pᵢ) = σᵢ·(V·qᵢ) + ρᵢ·v_next, so the
798            // restarted B is diag(σ) with ρ as its final column — the subspace is
799            // carried over rather than discarded.
800            let vk = q
801                .slice(s![.., ..self.k])
802                .t()
803                .dot(&self.v.slice(s![..self.work, ..]));
804            let uk = p
805                .slice(s![.., ..self.k])
806                .t()
807                .dot(&self.u.slice(s![..self.work, ..]));
808
809            let mut rho = Array1::<T>::zeros(self.k);
810            for i in 0..self.k {
811                rho[i] = beta * p[[self.work - 1, i]];
812            }
813
814            self.v.slice_mut(s![..self.k, ..]).assign(&vk);
815            self.u.slice_mut(s![..self.k, ..]).assign(&uk);
816            self.v.row_mut(self.k).assign(&v_next);
817
818            self.b.fill(T::zero());
819            for i in 0..self.k {
820                self.b[[i, i]] = sigma[i];
821                self.b[[i, self.k]] = rho[i];
822            }
823
824            start = self.k;
825            coupling = Some(rho);
826        }
827        unreachable!("the restart loop returns on its final iteration")
828    }
829}
830
831#[cfg(test)]
832mod tests {
833    use super::*;
834    use crate::matrix::SvdMat;
835    use crate::testing::{dense_of, gen_lowrank, gen_sparse, reference_singular_values, Lcg};
836    use ndarray::Axis;
837    use sprs::TriMatI;
838
839    fn diagonal(n: usize) -> SvdMat<f64> {
840        let mut t = TriMatI::<f64, u32>::new((n, n));
841        for i in 0..n {
842            t.add_triplet(i, i, (n - i) as f64);
843        }
844        t.to_csr::<u64>()
845    }
846
847    fn dense_random(r: usize, c: usize, seed: u64) -> SvdMat<f64> {
848        let mut rng = Lcg::new(seed);
849        let mut t = TriMatI::<f64, u32>::new((r, c));
850        for i in 0..r {
851            for j in 0..c {
852                t.add_triplet(i, j, rng.signed());
853            }
854        }
855        t.to_csr::<u64>()
856    }
857
858    /// The acceptance criterion: agreement with a dense LAPACK reference.
859    fn assert_matches_lapack(name: &str, a: &SvdMat<f64>, rank: usize, tol: f64) -> SvdRec<f64> {
860        let want = reference_singular_values(&dense_of(a));
861        let got = svd_seed(a, rank, 42).unwrap_or_else(|e| panic!("{name}: {e}"));
862        assert_eq!(got.d, rank, "{name}: rank");
863        for (i, &g) in got.s.iter().enumerate() {
864            let rel = (g - want[i]).abs() / want[i].abs().max(1e-30);
865            assert!(
866                rel < tol,
867                "{name}: singular value {i}: irlba {g:.12e} vs LAPACK {:.12e} (rel {rel:.3e})",
868                want[i]
869            );
870        }
871        got
872    }
873
874    #[test]
875    fn exact_on_diagonal_matrix() {
876        // Singular values are exactly 40, 39, 38, ... — the case LAS2 gets 20% wrong.
877        let a = diagonal(40);
878        let got = assert_matches_lapack("diagonal_40", &a, 10, 1e-10);
879        approx::assert_relative_eq!(got.s[0], 40.0, max_relative = 1e-10);
880        approx::assert_relative_eq!(got.s[9], 31.0, max_relative = 1e-10);
881    }
882
883    #[test]
884    fn matches_lapack_on_dense_random() {
885        assert_matches_lapack("dense_random_60x40", &dense_random(60, 40, 3), 10, 1e-9);
886    }
887
888    #[test]
889    fn matches_lapack_on_lowrank() {
890        assert_matches_lapack("lowrank_80x50_r8", &gen_lowrank(80, 50, 8, 21), 8, 1e-9);
891        assert_matches_lapack(
892            "lowrank_200x80_r10",
893            &gen_lowrank(200, 80, 10, 555),
894            15,
895            1e-8,
896        );
897    }
898
899    #[test]
900    fn matches_lapack_on_sparse() {
901        assert_matches_lapack("sparse_500x40", &gen_sparse(500, 40, 0.10, 7), 10, 1e-9);
902        assert_matches_lapack("sparse_200x120", &gen_sparse(200, 120, 0.05, 3), 20, 1e-9);
903        assert_matches_lapack(
904            "sparse_100x100",
905            &gen_sparse(100, 100, 0.0098, 42),
906            20,
907            1e-8,
908        );
909    }
910
911    /// Wide inputs must work as well as tall ones.
912    #[test]
913    fn matches_lapack_on_wide() {
914        assert_matches_lapack("wide_50x400", &gen_sparse(50, 400, 0.05, 1234), 10, 1e-9);
915    }
916
917    #[test]
918    fn orientation_and_reconstruction() {
919        for (r, c) in [(200usize, 60usize), (60, 200)] {
920            let a = gen_sparse(r, c, 0.1, 11);
921            let rank = 10;
922            let svd = svd_seed(&a, rank, 42).unwrap();
923            assert_eq!(svd.u.dim(), (r, rank), "u shape for {r}x{c}");
924            assert_eq!(svd.vt.dim(), (rank, c), "vt shape for {r}x{c}");
925
926            // Rank-`rank` truncation error must match the reference tail exactly:
927            // ||A - A_k||_F = sqrt(Σ_{i>k} σ_i²).
928            let dense = dense_of(&a);
929            let refs = reference_singular_values(&dense);
930            let tail: f64 = refs[rank..].iter().map(|v| v * v).sum::<f64>().sqrt();
931            let err: f64 = (&svd.recompose() - &dense)
932                .iter()
933                .map(|v| v * v)
934                .sum::<f64>()
935                .sqrt();
936            approx::assert_relative_eq!(err, tail, max_relative = 1e-6);
937        }
938    }
939
940    /// Singular vectors must be orthonormal and satisfy `A·vᵢ = σᵢ·uᵢ`.
941    #[test]
942    fn singular_vectors_are_orthonormal_and_consistent() {
943        let a = gen_sparse(300, 120, 0.06, 17);
944        let rank = 12;
945        let svd = svd_seed(&a, rank, 42).unwrap();
946
947        let orth_u = crate::dense::orthogonality_error(&svd.u.view());
948        assert!(orth_u < 1e-9, "||UᵀU - I|| = {orth_u:.3e}");
949        let vt_t = svd.vt.t().to_owned();
950        let orth_v = crate::dense::orthogonality_error(&vt_t.view());
951        assert!(orth_v < 1e-9, "||VᵀV - I|| = {orth_v:.3e}");
952
953        // A·vᵢ − σᵢ·uᵢ ≈ 0
954        for i in 0..rank {
955            let vi: Vec<f64> = svd.vt.row(i).to_vec();
956            let mut av = vec![0.0; a.rows()];
957            SparseMat::mul_vec(&a, &vi, &mut av, false);
958            let resid: f64 = av
959                .iter()
960                .zip(svd.u.column(i).iter())
961                .map(|(&x, &ui)| {
962                    let d = x - svd.s[i] * ui;
963                    d * d
964                })
965                .sum::<f64>()
966                .sqrt();
967            assert!(
968                resid / svd.s[0] < 1e-8,
969                "triplet {i}: ||A v - s u|| / s_max = {:.3e}",
970                resid / svd.s[0]
971            );
972        }
973    }
974
975    #[test]
976    fn csr_and_csc_agree() {
977        let a = gen_sparse(150, 90, 0.08, 5);
978        let csc = a.to_other_storage();
979        let x = svd_seed(&a, 12, 42).unwrap();
980        let y = svd_seed(&csc, 12, 42).unwrap();
981        for (p, q) in x.s.iter().zip(y.s.iter()) {
982            approx::assert_relative_eq!(p, q, max_relative = 1e-10);
983        }
984    }
985
986    /// Mean centering must match an explicitly centered dense reference — this is the
987    /// PCA path, and 1.x computed the correction wrongly.
988    #[test]
989    fn mean_centering_matches_dense_pca() {
990        let a = gen_lowrank(120, 40, 6, 31);
991        let dense = dense_of(&a);
992        let means = dense.mean_axis(Axis(0)).unwrap();
993        let centered = &dense - &means.view().insert_axis(Axis(0));
994        let want = reference_singular_values(&centered);
995
996        let got = svd_centered(&a, 6, Some(42)).unwrap();
997        for (i, &g) in got.s.iter().enumerate() {
998            let rel = (g - want[i]).abs() / want[i].abs().max(1e-30);
999            assert!(
1000                rel < 1e-8,
1001                "centered singular value {i}: {g:.9e} vs {:.9e} (rel {rel:.3e})",
1002                want[i]
1003            );
1004        }
1005    }
1006
1007    #[test]
1008    fn f32_matches_reference_at_f32_precision() {
1009        let a64 = gen_lowrank(100, 50, 6, 77);
1010        let want = reference_singular_values(&dense_of(&a64));
1011        // Same matrix at f32.
1012        let mut t = TriMatI::<f32, u32>::new((100, 50));
1013        for (v, (i, j)) in a64.iter() {
1014            t.add_triplet(i as usize, j as usize, *v as f32);
1015        }
1016        let a32: SvdMat<f32> = t.to_csr::<u64>();
1017        let got = svd_seed(&a32, 6, 42).unwrap();
1018        for (i, &g) in got.s.iter().enumerate() {
1019            let rel = ((g as f64) - want[i]).abs() / want[i].abs().max(1e-30);
1020            assert!(rel < 1e-4, "f32 singular value {i}: rel {rel:.3e}");
1021        }
1022    }
1023
1024    #[test]
1025    fn reports_convergence_and_bounded_restarts() {
1026        let a = gen_sparse(200, 100, 0.05, 9);
1027        let svd = svd_seed(&a, 10, 42).unwrap();
1028        assert_eq!(svd.diagnostics.algorithm, Algorithm::Irlba);
1029        match svd.diagnostics.detail {
1030            Detail::Irlba {
1031                converged,
1032                restarts,
1033                max_residual,
1034                ..
1035            } => {
1036                assert!(converged, "expected convergence");
1037                assert!(restarts < 50, "unexpectedly many restarts: {restarts}");
1038                assert!(max_residual >= 0.0);
1039            }
1040            ref other => panic!("wrong detail variant: {other:?}"),
1041        }
1042        assert!(svd.diagnostics.matvecs > 0);
1043    }
1044
1045    /// A tighter tolerance must not produce a worse answer.
1046    #[test]
1047    fn tolerance_is_monotone() {
1048        let a = gen_lowrank(150, 60, 8, 44);
1049        let want = reference_singular_values(&dense_of(&a));
1050        let mut prev = f64::INFINITY;
1051        for tol in [1e-4, 1e-8, 1e-12] {
1052            let cfg = IrlbaConfig::new(8).seed(42).tol(tol);
1053            let got = svd_with(&a, &cfg, None).unwrap();
1054            let err = (0..8)
1055                .map(|i| (got.s[i] - want[i]).abs() / want[i])
1056                .fold(0.0f64, f64::max);
1057            assert!(
1058                err <= prev * 10.0 + 1e-12,
1059                "tol {tol:.0e} gave error {err:.3e}, worse than the looser tolerance's {prev:.3e}"
1060            );
1061            prev = err.max(1e-16);
1062        }
1063    }
1064
1065    #[test]
1066    fn rejects_bad_configuration() {
1067        let a = gen_sparse(50, 30, 0.2, 1);
1068        assert!(matches!(svd(&a, 0), Err(SvdLibError::InvalidArgument(_))));
1069        assert!(matches!(svd(&a, 31), Err(SvdLibError::InvalidArgument(_))));
1070        // mean_center without means.
1071        let cfg = IrlbaConfig::new(5).mean_center(true);
1072        assert!(matches!(
1073            svd_with(&a, &cfg, None),
1074            Err(SvdLibError::InvalidArgument(_))
1075        ));
1076        // means of the wrong length.
1077        let cfg = IrlbaConfig::new(5).mean_center(true);
1078        assert!(matches!(
1079            svd_with(&a, &cfg, Some(Array1::zeros(7))),
1080            Err(SvdLibError::ShapeMismatch(_))
1081        ));
1082    }
1083
1084    /// The same seed must reproduce bit-identical output, including vector signs.
1085    #[test]
1086    fn is_reproducible_given_a_seed() {
1087        let a = gen_sparse(120, 70, 0.1, 23);
1088        let x = svd_seed(&a, 8, 1234).unwrap();
1089        let y = svd_seed(&a, 8, 1234).unwrap();
1090        assert_eq!(x.s, y.s);
1091        assert_eq!(x.u, y.u);
1092        assert_eq!(x.vt, y.vt);
1093    }
1094
1095    /// Full rank on a small matrix: every singular value, exactly.
1096    #[test]
1097    fn full_rank_request() {
1098        let a = gen_lowrank(30, 20, 20, 88);
1099        assert_matches_lapack("full_rank_30x20", &a, 19, 1e-8);
1100    }
1101}