Skip to main content

stats_claw/algorithms/decomposition/
mod.rs

1//! Decomposition and embedding algorithms and their shared linear algebra.
2//!
3//! Each concrete algorithm lives in its own submodule (`pca`, `factor_analysis`,
4//! `ica`, `tsne`, `umap`, `lle`) and is re-exported here so callers name
5//! `decomposition::<algo>` without the submodule path. This module owns the dense
6//! linear-algebra primitives the family shares — mean-centering, the covariance
7//! matrix, a symmetric-eigenvalue Jacobi solver, and a reconstruction-error
8//! measure — operating on row-major `f64` buffers.
9//!
10//! Components and embedding axes are defined only up to sign (and, for ICA and
11//! spectral methods, up to ordering), so the equivalence suite compares the
12//! sign/order-invariant quantities — explained variance, reconstruction error, and
13//! trustworthiness — rather than raw component values.
14
15mod factor_analysis;
16mod ica;
17mod lle;
18mod pca;
19mod tsne;
20mod umap;
21
22pub use factor_analysis::{FactorResult, factor_analysis};
23pub use ica::{IcaResult, fast_ica};
24pub use lle::lle;
25pub use pca::{PcaResult, pca};
26pub use tsne::tsne;
27pub use umap::umap;
28
29/// Maximum cyclic-Jacobi sweeps before the symmetric eigensolver stops iterating.
30const JACOBI_SWEEPS: usize = 100;
31/// Off-diagonal magnitude below which a matrix is treated as already diagonal.
32const JACOBI_TOL: f64 = 1e-14;
33
34/// Widens a `usize` count to `f64` without an `as` cast.
35///
36/// Sample and dimension counts here are far below `2^53`, so splitting into 32-bit
37/// halves and recombining reproduces the value exactly while satisfying the
38/// `style.rs` no-`as` guard.
39///
40/// # Arguments
41///
42/// * `n` — the count to widen.
43///
44/// # Returns
45///
46/// `n` as an exact `f64`.
47#[must_use]
48pub fn count_to_f64(n: usize) -> f64 {
49    let wide = u64::try_from(n).unwrap_or(u64::MAX);
50    let hi = u32::try_from(wide >> 32).unwrap_or(0);
51    let lo = u32::try_from(wide & 0xFFFF_FFFF).unwrap_or(0);
52    f64::from(hi).mul_add(4_294_967_296.0, f64::from(lo))
53}
54
55/// Computes the per-feature mean (centroid) of a row-major data matrix.
56///
57/// # Arguments
58///
59/// * `data` — observations; each inner slice is one row of equal dimension `dim`.
60/// * `dim` — the number of features per row.
61///
62/// # Returns
63///
64/// A length-`dim` vector of column means (a zero vector when `data` is empty).
65#[must_use]
66pub fn column_means(data: &[Vec<f64>], dim: usize) -> Vec<f64> {
67    let mut sum = vec![0.0_f64; dim];
68    for row in data {
69        for (s, &v) in sum.iter_mut().zip(row) {
70            *s += v;
71        }
72    }
73    let n = count_to_f64(data.len());
74    if n > 0.0 {
75        for s in &mut sum {
76            *s /= n;
77        }
78    }
79    sum
80}
81
82/// Mean-centers `data`, returning the centered matrix and the column means.
83///
84/// # Arguments
85///
86/// * `data` — observations; each inner slice is one row of dimension `dim`.
87/// * `dim` — the number of features per row.
88///
89/// # Returns
90///
91/// `(centered, means)` where `centered[i][j] = data[i][j] − means[j]`.
92#[must_use]
93pub fn mean_center(data: &[Vec<f64>], dim: usize) -> (Vec<Vec<f64>>, Vec<f64>) {
94    let means = column_means(data, dim);
95    let centered = data
96        .iter()
97        .map(|row| {
98            row.iter()
99                .zip(&means)
100                .map(|(&v, &m)| v - m)
101                .collect::<Vec<f64>>()
102        })
103        .collect();
104    (centered, means)
105}
106
107/// Computes the `dim×dim` sample covariance matrix of `centered` (already
108/// mean-centered) using the unbiased `n − 1` denominator, matching `scikit-learn`.
109///
110/// # Arguments
111///
112/// * `centered` — mean-centered observations, one row per sample.
113/// * `dim` — the number of features.
114///
115/// # Returns
116///
117/// The covariance matrix as a row-major `dim×dim` buffer. With fewer than two
118/// samples the denominator collapses and a zero matrix is returned.
119#[must_use]
120pub fn covariance(centered: &[Vec<f64>], dim: usize) -> Vec<f64> {
121    let mut cov = vec![0.0_f64; dim * dim];
122    for row in centered {
123        for i in 0..dim {
124            let ri = row.get(i).copied().unwrap_or(0.0);
125            for j in 0..dim {
126                let rj = row.get(j).copied().unwrap_or(0.0);
127                if let Some(slot) = cov.get_mut(i * dim + j) {
128                    *slot = ri.mul_add(rj, *slot);
129                }
130            }
131        }
132    }
133    let denom = count_to_f64(centered.len()) - 1.0;
134    if denom > 0.0 {
135        for c in &mut cov {
136            *c /= denom;
137        }
138    }
139    cov
140}
141
142/// Reads entry `(i, j)` from a row-major `n×n` buffer (`0.0` if out of range).
143#[must_use]
144pub fn at(matrix: &[f64], n: usize, i: usize, j: usize) -> f64 {
145    matrix.get(i * n + j).copied().unwrap_or(0.0)
146}
147
148/// Writes entry `(i, j)` into a row-major `n×n` buffer (no-op if out of range).
149pub fn put(matrix: &mut [f64], n: usize, i: usize, j: usize, value: f64) {
150    if let Some(slot) = matrix.get_mut(i * n + j) {
151        *slot = value;
152    }
153}
154
155/// Builds the `n×n` identity matrix in a row-major buffer.
156#[must_use]
157pub fn identity(n: usize) -> Vec<f64> {
158    let mut v = vec![0.0_f64; n * n];
159    for i in 0..n {
160        put(&mut v, n, i, i, 1.0);
161    }
162    v
163}
164
165/// Computes the eigenvalues and eigenvectors of a symmetric `n×n` matrix by the
166/// cyclic Jacobi rotation method.
167///
168/// # Arguments
169///
170/// * `matrix` — a symmetric matrix in a row-major `n×n` buffer.
171/// * `n` — the matrix dimension.
172///
173/// # Returns
174///
175/// `(eigenvalues, eigenvectors)` where eigenvector `col` occupies column `col` of
176/// the returned row-major `n×n` buffer and pairs with `eigenvalues[col]`.
177#[must_use]
178pub fn jacobi_eigen(matrix: &[f64], n: usize) -> (Vec<f64>, Vec<f64>) {
179    let mut a = matrix.to_vec();
180    let mut v = identity(n);
181    for _ in 0..JACOBI_SWEEPS {
182        let mut off = 0.0_f64;
183        for p in 0..n {
184            for q in (p + 1)..n {
185                off += at(&a, n, p, q).abs();
186            }
187        }
188        if off < JACOBI_TOL {
189            break;
190        }
191        for p in 0..n {
192            for q in (p + 1)..n {
193                rotate(&mut a, &mut v, n, p, q);
194            }
195        }
196    }
197    let values = (0..n).map(|i| at(&a, n, i, i)).collect();
198    (values, v)
199}
200
201/// Applies one Jacobi rotation zeroing the `(p, q)` off-diagonal entry of `a` and
202/// accumulating it into the eigenvector matrix `v`.
203///
204/// The single-character names (`c`, `s`, `t`, `p`, `q`) are the canonical
205/// Jacobi-rotation notation from Numerical Recipes; renaming them would obscure the
206/// standard formulae rather than clarify them.
207#[allow(clippy::many_single_char_names)]
208fn rotate(a: &mut [f64], v: &mut [f64], n: usize, p: usize, q: usize) {
209    let apq = at(a, n, p, q);
210    if apq.abs() < JACOBI_TOL {
211        return;
212    }
213    let app = at(a, n, p, p);
214    let aqq = at(a, n, q, q);
215    let theta = (aqq - app) / (2.0 * apq);
216    let t = theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt());
217    let c = 1.0 / (t * t + 1.0).sqrt();
218    let s = t * c;
219    for i in 0..n {
220        let aip = at(a, n, i, p);
221        let aiq = at(a, n, i, q);
222        put(a, n, i, p, c.mul_add(aip, -(s * aiq)));
223        put(a, n, i, q, s.mul_add(aip, c * aiq));
224    }
225    for i in 0..n {
226        let api = at(a, n, p, i);
227        let aqi = at(a, n, q, i);
228        put(a, n, p, i, c.mul_add(api, -(s * aqi)));
229        put(a, n, q, i, s.mul_add(api, c * aqi));
230    }
231    for i in 0..n {
232        let vip = at(v, n, i, p);
233        let viq = at(v, n, i, q);
234        put(v, n, i, p, c.mul_add(vip, -(s * viq)));
235        put(v, n, i, q, s.mul_add(vip, c * viq));
236    }
237}
238
239/// Returns the eigenvalue indices in descending order of eigenvalue.
240///
241/// # Arguments
242///
243/// * `values` — eigenvalues to rank.
244///
245/// # Returns
246///
247/// Index permutation `order` with `values[order[0]]` the largest.
248#[must_use]
249pub fn descending_order(values: &[f64]) -> Vec<usize> {
250    let mut order: Vec<usize> = (0..values.len()).collect();
251    order.sort_by(|&a, &b| {
252        let va = values.get(a).copied().unwrap_or(f64::NEG_INFINITY);
253        let vb = values.get(b).copied().unwrap_or(f64::NEG_INFINITY);
254        vb.partial_cmp(&va).unwrap_or(std::cmp::Ordering::Equal)
255    });
256    order
257}
258
259/// Inverts a small symmetric positive-definite `n×n` matrix via its eigen-
260/// decomposition: `A⁻¹ = V diag(1/λ) Vᵀ`.
261///
262/// Used for the tiny `k×k` posterior-covariance solve in factor analysis. Near-zero
263/// eigenvalues are floored so the inverse stays finite for a degenerate input.
264///
265/// # Arguments
266///
267/// * `matrix` — a symmetric matrix in a row-major `n×n` buffer.
268/// * `n` — the matrix dimension.
269///
270/// # Returns
271///
272/// The inverse as a row-major `n×n` buffer.
273#[must_use]
274pub fn symmetric_inverse(matrix: &[f64], n: usize) -> Vec<f64> {
275    let (values, vectors) = jacobi_eigen(matrix, n);
276    let mut inv = vec![0.0_f64; n * n];
277    for i in 0..n {
278        for j in 0..n {
279            let mut acc = 0.0_f64;
280            for (k, &lambda) in values.iter().enumerate().take(n) {
281                let safe = if lambda.abs() < 1e-300 {
282                    1e-300
283                } else {
284                    lambda
285                };
286                acc += at(&vectors, n, i, k) * at(&vectors, n, j, k) / safe;
287            }
288            put(&mut inv, n, i, j, acc);
289        }
290    }
291    inv
292}
293
294/// Computes the mean squared reconstruction error between two equal-shaped
295/// matrices.
296///
297/// # Arguments
298///
299/// * `original` — the input rows.
300/// * `reconstructed` — the rows recovered from a low-dimensional representation;
301///   must match `original` in shape.
302///
303/// # Returns
304///
305/// `mean over all entries of (original − reconstructed)²` — `0.0` for empty input.
306#[must_use]
307pub fn reconstruction_error(original: &[Vec<f64>], reconstructed: &[Vec<f64>]) -> f64 {
308    let mut sum = 0.0_f64;
309    let mut count = 0_usize;
310    for (orig_row, rec_row) in original.iter().zip(reconstructed) {
311        for (&o, &r) in orig_row.iter().zip(rec_row) {
312            let d = o - r;
313            sum = d.mul_add(d, sum);
314            count += 1;
315        }
316    }
317    if count == 0 {
318        return 0.0;
319    }
320    sum / count_to_f64(count)
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn mean_center_zeroes_the_column_means() {
329        let data = vec![vec![1.0, 10.0], vec![3.0, 20.0]];
330        let (centered, means) = mean_center(&data, 2);
331        assert!((means.first().copied().unwrap_or(0.0) - 2.0).abs() < 1e-12);
332        assert!((means.get(1).copied().unwrap_or(0.0) - 15.0).abs() < 1e-12);
333        let first = centered
334            .first()
335            .and_then(|r| r.first())
336            .copied()
337            .unwrap_or(0.0);
338        assert!((first + 1.0).abs() < 1e-12, "centered[0][0] was {first}");
339    }
340
341    #[test]
342    fn jacobi_recovers_diagonal_eigenvalues() {
343        // diag(3, 1): eigenvalues are the diagonal entries.
344        let m = vec![3.0, 0.0, 0.0, 1.0];
345        let (values, _) = jacobi_eigen(&m, 2);
346        assert!(
347            values.iter().any(|v| (v - 3.0).abs() < 1e-9),
348            "missing eigenvalue 3: {values:?}"
349        );
350        assert!(
351            values.iter().any(|v| (v - 1.0).abs() < 1e-9),
352            "missing eigenvalue 1: {values:?}"
353        );
354    }
355
356    #[test]
357    fn covariance_of_centered_two_by_two() {
358        // Points (−1,0),(1,0): variance in x is (1+1)/(2−1)=2, none in y.
359        let centered = vec![vec![-1.0, 0.0], vec![1.0, 0.0]];
360        let cov = covariance(&centered, 2);
361        assert!(
362            (at(&cov, 2, 0, 0) - 2.0).abs() < 1e-12,
363            "var_x = {}",
364            at(&cov, 2, 0, 0)
365        );
366        assert!(
367            at(&cov, 2, 1, 1).abs() < 1e-12,
368            "var_y = {}",
369            at(&cov, 2, 1, 1)
370        );
371    }
372
373    #[test]
374    fn reconstruction_error_is_zero_for_identical() {
375        let a = vec![vec![1.0, 2.0], vec![3.0, 4.0]];
376        assert!(reconstruction_error(&a, &a).abs() < 1e-12);
377    }
378
379    #[test]
380    fn descending_order_ranks_largest_first() {
381        assert_eq!(descending_order(&[1.0, 5.0, 3.0]), vec![1, 2, 0]);
382    }
383}