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}