Skip to main content

single_svdlib/
types.rs

1//! Result and scalar types shared by every algorithm in the crate.
2
3use ndarray::{Array1, Array2};
4use single_utilities::traits::FloatOpsTS;
5
6/// Scalar types this crate can decompose.
7///
8/// Implemented for `f32` and `f64`. The supertraits are what the sparse kernels and
9/// the ndarray glue need; nothing here leaks a linear-algebra backend, because the
10/// small dense factorizations are always performed in `f64` and cast back.
11pub trait SvdFloat:
12    FloatOpsTS + ndarray::ScalarOperand + sprs::MulAcc + std::ops::DivAssign + 'static
13{
14    /// Machine epsilon.
15    fn eps() -> Self;
16    /// `eps^(3/4)`, the accuracy floor LAS2 clamps `kappa` to.
17    fn eps34() -> Self;
18    /// Widen to `f64` for the small dense factorizations.
19    fn to_f64(self) -> f64;
20    /// Narrow back from `f64`.
21    fn from_f64_val(v: f64) -> Self;
22    /// Equality within one ulp-ish epsilon.
23    fn close(a: Self, b: Self) -> bool {
24        num_traits::Float::abs(b - a) < Self::eps()
25    }
26}
27
28impl SvdFloat for f32 {
29    #[inline]
30    fn eps() -> Self {
31        f32::EPSILON
32    }
33    #[inline]
34    fn eps34() -> Self {
35        // Constant-folded rather than powf'd on every call; `eps34_constants_match_computed`
36        // pins these to `EPSILON.powf(0.75)`.
37        const V: f32 = 6.4155306e-6;
38        V
39    }
40    #[inline]
41    fn to_f64(self) -> f64 {
42        self as f64
43    }
44    #[inline]
45    fn from_f64_val(v: f64) -> Self {
46        v as f32
47    }
48}
49
50impl SvdFloat for f64 {
51    #[inline]
52    fn eps() -> Self {
53        f64::EPSILON
54    }
55    #[inline]
56    fn eps34() -> Self {
57        const V: f64 = 1.8189894035458565e-12;
58        V
59    }
60    #[inline]
61    fn to_f64(self) -> f64 {
62        self
63    }
64    #[inline]
65    fn from_f64_val(v: f64) -> Self {
66        v
67    }
68}
69
70/// Which algorithm produced a result.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum Algorithm {
73    /// Single-vector Lanczos, the SVDLIBC LAS2 port.
74    Las2,
75    /// Restarted Lanczos bidiagonalization.
76    Irlba,
77    /// Randomized range finder with power iterations.
78    Randomized,
79    /// Randomized block Krylov.
80    BlockKrylov,
81}
82
83/// Algorithm-specific counters. The fields common to every method live on
84/// [`Diagnostics`] itself.
85#[derive(Debug, Clone, PartialEq)]
86pub enum Detail<T> {
87    Lanczos {
88        iterations: usize,
89        lanczos_steps: usize,
90        ritz_values_stabilized: usize,
91        end_interval: [T; 2],
92        kappa: T,
93    },
94    Irlba {
95        restarts: usize,
96        converged: bool,
97        tolerance: T,
98        /// Largest residual `||A v - s u||` over the returned triplets.
99        max_residual: T,
100    },
101    Randomized {
102        oversamples: usize,
103        power_iterations: usize,
104        block_size: usize,
105    },
106}
107
108/// What the computation did, for logging and for deciding whether to trust a result.
109#[derive(Debug, Clone, PartialEq)]
110pub struct Diagnostics<T> {
111    pub algorithm: Algorithm,
112    /// Non-zeros in the input.
113    pub non_zero: usize,
114    /// Dimensions requested by the caller.
115    pub dimensions: usize,
116    /// Dimensions actually returned and considered significant.
117    pub significant_values: usize,
118    /// Whether the algorithm worked on the transpose internally.
119    pub transposed: bool,
120    pub random_seed: u64,
121    /// Sparse matrix-vector products performed, counting a block product against `k`
122    /// dense columns as `k`. Comparable across algorithms, so it is the honest way to
123    /// price one method against another.
124    pub matvecs: usize,
125    pub detail: Detail<T>,
126}
127
128/// A singular value decomposition.
129///
130/// # Orientation
131///
132/// `A ≈ u · diag(s) · vt`, matching the `numpy.linalg.svd` / `scipy` convention:
133///
134/// - `u` is `m × d` — left singular vectors are **columns**
135/// - `s` is `d`, descending
136/// - `vt` is `d × n` — right singular vectors are **rows**
137///
138/// In 1.x this was inconsistent: the Lanczos path returned `u` transposed (`d × m`)
139/// while the randomized path returned it as `m × d`, so [`SvdRec::recompose`] only
140/// worked for square inputs. Both paths now follow the convention above.
141#[derive(Debug, Clone, PartialEq)]
142pub struct SvdRec<T> {
143    /// Number of singular triplets returned.
144    pub d: usize,
145    /// Left singular vectors, `m × d`.
146    pub u: Array2<T>,
147    /// Singular values, length `d`, descending.
148    pub s: Array1<T>,
149    /// Transposed right singular vectors, `d × n`.
150    pub vt: Array2<T>,
151    /// `‖A‖²_F` of what was decomposed — the centered matrix if the solver was centering.
152    ///
153    /// Recorded because a truncated result can't recover it: `Σᵢ sᵢ²` over the returned
154    /// `d` values is only the part that was kept, not the tail it's measured against.
155    pub total_squared_norm: T,
156    pub diagnostics: Diagnostics<T>,
157}
158
159impl<T: SvdFloat> SvdRec<T> {
160    /// Rebuild the dense approximation `u · diag(s) · vt`.
161    ///
162    /// Allocates an `m × n` dense matrix — only reasonable for small inputs or for
163    /// checking reconstruction error in tests.
164    pub fn recompose(&self) -> Array2<T> {
165        let scaled = &self.u * &self.s.view().insert_axis(ndarray::Axis(0));
166        scaled.dot(&self.vt)
167    }
168
169    /// Whether an iterative method reached its tolerance.
170    ///
171    /// Always `true` for the randomized methods — they do a fixed amount of work and have
172    /// no convergence test. For IRLBA it's real: `false` means the restart budget ran out.
173    /// IRLBA refuses to return such a result by default, so this only matters if you
174    /// called `allow_unconverged`.
175    pub fn converged(&self) -> bool {
176        match self.diagnostics.detail {
177            Detail::Irlba { converged, .. } => converged,
178            Detail::Lanczos { .. } | Detail::Randomized { .. } => true,
179        }
180    }
181
182    /// Largest `‖A·vᵢ − σᵢ·uᵢ‖` over the returned triplets, for algorithms that track it.
183    /// Judge it against `s[0]`: `1e-9 · σ_max` is excellent, `0.1 · σ_max` is unusable.
184    pub fn max_residual(&self) -> Option<T> {
185        match self.diagnostics.detail {
186            Detail::Irlba { max_residual, .. } => Some(max_residual),
187            _ => None,
188        }
189    }
190
191    /// PCA scores, `u · diag(s)`, shaped `m × d` — the embedding you'd cluster or plot.
192    pub fn scores(&self) -> Array2<T> {
193        &self.u * &self.s.view().insert_axis(ndarray::Axis(0))
194    }
195
196    /// `sᵢ² / (m − 1)`, matching sklearn's `explained_variance_`. Only a variance in the
197    /// statistical sense if the decomposition was centered.
198    pub fn explained_variance(&self) -> Array1<T> {
199        let denom = self.variance_denominator();
200        self.s.mapv(|si| si * si / denom)
201    }
202
203    /// Fraction of the total squared norm each component captures. Sums to at most 1,
204    /// and to zero (not `NaN`) for an all-zero input.
205    pub fn explained_variance_ratio(&self) -> Array1<T> {
206        if self.total_squared_norm <= T::zero() {
207            return Array1::zeros(self.s.len());
208        }
209        self.s.mapv(|si| si * si / self.total_squared_norm)
210    }
211
212    /// `‖A‖²_F / (m − 1)` — the same scale as
213    /// [`explained_variance`](Self::explained_variance).
214    pub fn total_variance(&self) -> T {
215        self.total_squared_norm / self.variance_denominator()
216    }
217
218    /// `m − 1`, floored at 1 so a one-row input gives zeros rather than dividing by zero.
219    fn variance_denominator(&self) -> T {
220        T::from_f64_val(self.nrows().saturating_sub(1).max(1) as f64)
221    }
222
223    /// Number of rows of the original matrix.
224    pub fn nrows(&self) -> usize {
225        self.u.nrows()
226    }
227
228    /// Number of columns of the original matrix.
229    pub fn ncols(&self) -> usize {
230        self.vt.ncols()
231    }
232
233    /// Truncate to the leading `k` triplets in place.
234    pub fn truncate(&mut self, k: usize) {
235        let k = k.min(self.d);
236        if k == self.d {
237            return;
238        }
239        self.u = self.u.slice(ndarray::s![.., ..k]).to_owned();
240        self.s = self.s.slice(ndarray::s![..k]).to_owned();
241        self.vt = self.vt.slice(ndarray::s![..k, ..]).to_owned();
242        self.d = k;
243        self.diagnostics.significant_values = k;
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn eps34_constants_match_computed() {
253        // The hardcoded constants must equal the expression they replaced.
254        approx::assert_relative_eq!(f32::eps34(), f32::EPSILON.powf(0.75), max_relative = 1e-6);
255        approx::assert_relative_eq!(f64::eps34(), f64::EPSILON.powf(0.75), max_relative = 1e-12);
256    }
257
258    #[test]
259    fn recompose_is_orientation_correct_for_non_square() {
260        // A 3x2 rank-1 matrix: u (3x1), s (1), vt (1x2).
261        let u = ndarray::arr2(&[[1.0f64], [2.0], [3.0]]);
262        let s = ndarray::arr1(&[2.0f64]);
263        let vt = ndarray::arr2(&[[1.0f64, 10.0]]);
264        let rec = SvdRec {
265            d: 1,
266            u,
267            s,
268            vt,
269            total_squared_norm: 0.0,
270            diagnostics: Diagnostics {
271                algorithm: Algorithm::Las2,
272                non_zero: 6,
273                dimensions: 1,
274                significant_values: 1,
275                transposed: false,
276                random_seed: 0,
277                matvecs: 0,
278                detail: Detail::Lanczos {
279                    iterations: 0,
280                    lanczos_steps: 0,
281                    ritz_values_stabilized: 0,
282                    end_interval: [0.0, 0.0],
283                    kappa: 0.0,
284                },
285            },
286        };
287        let a = rec.recompose();
288        assert_eq!(a.dim(), (3, 2));
289        // row i = u[i] * s * vt
290        assert_eq!(a[[0, 0]], 2.0);
291        assert_eq!(a[[0, 1]], 20.0);
292        assert_eq!(a[[2, 0]], 6.0);
293        assert_eq!(a[[2, 1]], 60.0);
294    }
295}