Skip to main content

single_svdlib/dense/
mod.rs

1//! Dense helpers: tall-skinny QR, and small factorizations on the reduced matrices the
2//! Krylov and randomized methods produce.
3
4pub mod jacobi;
5pub mod tsqr;
6
7pub use tsqr::{orthogonality_error, orthonormalize, tsqr};
8
9use crate::error::{Result, SvdLibError};
10use crate::types::SvdFloat;
11use ndarray::{Array1, Array2, ArrayView2};
12
13/// A thin SVD of a small dense matrix: `a ≈ u · diag(s) · vt`, `s` descending.
14pub struct SmallSvd<T> {
15    /// `m × k`, `k = min(m, n)`.
16    pub u: Array2<T>,
17    /// Length `k`, descending.
18    pub s: Array1<T>,
19    /// `k × n`.
20    pub vt: Array2<T>,
21}
22
23/// Thin SVD of a small dense matrix.
24///
25/// Always computed in `f64` and cast back, whatever `T` is. These operands are `l × l`
26/// or `l × n` with `l` on the order of the requested rank, so the widening costs
27/// nothing measurable and it keeps `f32` callers from losing accuracy in the one place
28/// where the whole result's conditioning is decided.
29///
30/// Uses [one-sided Jacobi](jacobi) rather than the bidiagonal QR a linear-algebra
31/// backend would provide. Jacobi is accurate to the condition number *after* column
32/// scaling, so a badly scaled reduced factor — which is what an ill-conditioned Krylov
33/// basis produces — is still factored to full relative accuracy. Golub–Reinsch is not:
34/// on a `5 × 2` operand with `κ ≈ 8·10⁶`, `nalgebra`'s implementation reconstructed to
35/// only `1.3·10⁻⁹` relative, against Jacobi's `< 1·10⁻¹⁵`.
36pub fn small_svd<T: SvdFloat>(a: ArrayView2<T>) -> Result<SmallSvd<T>> {
37    let (m, n) = a.dim();
38    if m == 0 || n == 0 {
39        return Err(SvdLibError::shape(format!(
40            "small_svd needs a non-empty matrix, got {m}x{n}"
41        )));
42    }
43    let k = m.min(n);
44
45    // Fail fast and legibly on a poisoned operand, rather than letting it reach the
46    // factorization and surface as an opaque non-convergence.
47    if let Some((i, j)) = a
48        .indexed_iter()
49        .find(|(_, v)| !num_traits::Float::is_finite(**v))
50        .map(|(idx, _)| idx)
51    {
52        return Err(SvdLibError::DenseFactorization {
53            factorization: "SVD",
54            message: format!("operand is not finite at ({i}, {j})"),
55        });
56    }
57
58    let a64 = Array2::<f64>::from_shape_fn((m, n), |(i, j)| a[[i, j]].to_f64());
59    let fail = || SvdLibError::DenseFactorization {
60        factorization: "SVD",
61        message: format!("one-sided Jacobi did not converge on a {m}x{n} operand"),
62    };
63
64    // Jacobi needs at least as many rows as columns. For a wide operand factor the
65    // transpose instead: `Aᵀ = U·Σ·Vᵀ` gives `A = V·Σ·Uᵀ`.
66    let (u64, s64, vt64) = if m >= n {
67        let j = jacobi::jacobi_svd(&a64).ok_or_else(fail)?;
68        (j.u, j.s, j.v.t().to_owned())
69    } else {
70        let j = jacobi::jacobi_svd(&a64.t().to_owned()).ok_or_else(fail)?;
71        (j.v, j.s, j.u.t().to_owned())
72    };
73    debug_assert_eq!(u64.dim(), (m, k));
74    debug_assert_eq!(vt64.dim(), (k, n));
75
76    // Jacobi already returns the singular values descending.
77    let mut out_u = Array2::<T>::zeros((m, k));
78    let mut out_s = Array1::<T>::zeros(k);
79    let mut out_vt = Array2::<T>::zeros((k, n));
80    for idx in 0..k {
81        out_s[idx] = T::from_f64_val(s64[idx]);
82        for i in 0..m {
83            out_u[[i, idx]] = T::from_f64_val(u64[[i, idx]]);
84        }
85        for j in 0..n {
86            out_vt[[idx, j]] = T::from_f64_val(vt64[[idx, j]]);
87        }
88    }
89    Ok(SmallSvd {
90        u: out_u,
91        s: out_s,
92        vt: out_vt,
93    })
94}
95
96/// Flip the sign of each singular-vector pair so the dominant entry of every column of
97/// `u` is positive.
98///
99/// The SVD is only unique up to a per-triplet sign, so two runs can disagree on it for
100/// no numerical reason. Pinning the sign makes results reproducible and comparable —
101/// this is what `sklearn`'s `svd_flip` is for.
102pub fn svd_flip<T: SvdFloat>(u: &mut Array2<T>, vt: &mut Array2<T>) {
103    let k = u.ncols().min(vt.nrows());
104    for j in 0..k {
105        // Locate the largest-magnitude entry of column j.
106        let mut best = T::zero();
107        let mut sign = T::one();
108        for i in 0..u.nrows() {
109            let v = u[[i, j]];
110            let mag = num_traits::Float::abs(v);
111            if mag > best {
112                best = mag;
113                sign = if v < T::zero() { -T::one() } else { T::one() };
114            }
115        }
116        if sign < T::zero() {
117            for i in 0..u.nrows() {
118                u[[i, j]] = -u[[i, j]];
119            }
120            for j2 in 0..vt.ncols() {
121                vt[[j, j2]] = -vt[[j, j2]];
122            }
123        }
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::testing::Lcg;
131
132    #[test]
133    fn small_svd_reconstructs_and_is_ordered() {
134        let mut rng = Lcg::new(11);
135        let a = Array2::from_shape_fn((9, 6), |_| rng.signed());
136        let svd = small_svd(a.view()).unwrap();
137
138        assert_eq!(svd.u.dim(), (9, 6));
139        assert_eq!(svd.s.len(), 6);
140        assert_eq!(svd.vt.dim(), (6, 6));
141
142        for w in svd.s.to_vec().windows(2) {
143            assert!(w[0] >= w[1], "singular values not descending: {:?}", svd.s);
144        }
145
146        let scaled = &svd.u * &svd.s.view().insert_axis(ndarray::Axis(0));
147        let recon = scaled.dot(&svd.vt);
148        let err: f64 = (&recon - &a).iter().map(|v| v * v).sum::<f64>().sqrt();
149        let scale: f64 = a.iter().map(|v| v * v).sum::<f64>().sqrt();
150        assert!(
151            err / scale < 1e-12,
152            "relative reconstruction {}",
153            err / scale
154        );
155    }
156
157    /// `f32` input must still be factored at `f64` precision internally.
158    #[test]
159    fn small_svd_promotes_f32() {
160        let mut rng = Lcg::new(13);
161        let a = Array2::from_shape_fn((7, 5), |_| rng.signed() as f32);
162        let svd = small_svd(a.view()).unwrap();
163        let scaled = &svd.u * &svd.s.view().insert_axis(ndarray::Axis(0));
164        let recon = scaled.dot(&svd.vt);
165        let err: f32 = (&recon - &a).iter().map(|v| v * v).sum::<f32>().sqrt();
166        let scale: f32 = a.iter().map(|v| v * v).sum::<f32>().sqrt();
167        assert!(
168            err / scale < 1e-5,
169            "relative reconstruction {}",
170            err / scale
171        );
172    }
173
174    #[test]
175    fn svd_flip_makes_signs_deterministic() {
176        let mut u = ndarray::arr2(&[[-3.0f64, 1.0], [1.0, -4.0]]);
177        let mut vt = ndarray::arr2(&[[1.0f64, 2.0], [3.0, 4.0]]);
178        let before = u.dot(&vt);
179        svd_flip(&mut u, &mut vt);
180        // Dominant entry of each column of u is now positive.
181        assert!(u[[0, 0]] > 0.0);
182        assert!(u[[1, 1]] > 0.0);
183        // The product is unchanged: flipping a column of u and the matching row of vt
184        // cancels.
185        let after = u.dot(&vt);
186        for (a, b) in before.iter().zip(after.iter()) {
187            approx::assert_relative_eq!(a, b, max_relative = 1e-14);
188        }
189    }
190
191    #[test]
192    fn small_svd_rejects_empty() {
193        let a = Array2::<f64>::zeros((0, 3));
194        assert!(small_svd(a.view()).is_err());
195    }
196}