Skip to main content

stats_claw/algorithms/decomposition/
pca.rs

1//! Principal component analysis (the worked decomposition PATTERN).
2//!
3//! PCA finds the orthogonal directions of greatest variance: the data is
4//! mean-centered, its covariance matrix is formed, and that symmetric matrix is
5//! eigen-decomposed by Jacobi rotation. The eigenvectors sorted by descending
6//! eigenvalue are the principal components; each eigenvalue is the variance
7//! explained along its component, mirroring `sklearn.decomposition.PCA` with the
8//! full SVD solver (which uses the same unbiased `n − 1` covariance).
9//!
10//! Components are sign-ambiguous, so the equivalence suite aligns each component's
11//! sign before comparing and otherwise checks the sign-invariant explained-variance
12//! ratio and reconstruction error.
13
14use crate::algorithms::decomposition::{
15    at, covariance, descending_order, jacobi_eigen, mean_center, reconstruction_error,
16};
17
18/// Outcome of a PCA fit.
19#[derive(Debug, Clone)]
20pub struct PcaResult {
21    /// The top `k` principal components, one length-`dim` unit vector per row, in
22    /// descending order of explained variance.
23    pub components: Vec<Vec<f64>>,
24    /// Variance explained by each returned component (the leading eigenvalues of
25    /// the covariance matrix).
26    pub explained_variance: Vec<f64>,
27    /// Each component's explained variance as a fraction of the total variance
28    /// across all features.
29    pub explained_variance_ratio: Vec<f64>,
30    /// Mean squared error of reconstructing the input from the `k` components.
31    pub reconstruction_error: f64,
32}
33
34/// Fits PCA to `data`, returning the top `n_components` principal components.
35///
36/// # Arguments
37///
38/// * `data` — observations; each inner slice is one row of equal dimension. An
39///   empty input yields an empty result.
40/// * `n_components` — number of components to keep. Clamped to the feature
41///   dimension (cannot exceed the number of features).
42///
43/// # Returns
44///
45/// A [`PcaResult`] with the components, their explained variance and ratio, and
46/// the reconstruction error of projecting onto and back from those components.
47///
48/// # Examples
49///
50/// ```
51/// use stats_claw::algorithms::decomposition::pca;
52///
53/// // Variance lies entirely along the x-axis.
54/// let data = vec![vec![-2.0, 0.0], vec![-1.0, 0.0], vec![1.0, 0.0], vec![2.0, 0.0]];
55/// let r = pca(&data, 1);
56/// // The leading component points along x and explains all the variance.
57/// assert!((r.explained_variance_ratio.first().copied().unwrap_or(0.0) - 1.0).abs() < 1e-9);
58/// ```
59#[must_use]
60pub fn pca(data: &[Vec<f64>], n_components: usize) -> PcaResult {
61    let dim = data.first().map_or(0, Vec::len);
62    let k = n_components.min(dim);
63    if dim == 0 || k == 0 {
64        return PcaResult {
65            components: Vec::new(),
66            explained_variance: Vec::new(),
67            explained_variance_ratio: Vec::new(),
68            reconstruction_error: 0.0,
69        };
70    }
71    let (centered, _means) = mean_center(data, dim);
72    let cov = covariance(&centered, dim);
73    let (values, vectors) = jacobi_eigen(&cov, dim);
74    let order = descending_order(&values);
75    let total: f64 = values.iter().map(|v| v.max(0.0)).sum();
76
77    let mut components = Vec::with_capacity(k);
78    let mut explained_variance = Vec::with_capacity(k);
79    let mut explained_variance_ratio = Vec::with_capacity(k);
80    for &col in order.iter().take(k) {
81        let component: Vec<f64> = (0..dim).map(|row| at(&vectors, dim, row, col)).collect();
82        let variance = values.get(col).copied().unwrap_or(0.0).max(0.0);
83        explained_variance.push(variance);
84        explained_variance_ratio.push(if total > 0.0 { variance / total } else { 0.0 });
85        components.push(component);
86    }
87
88    let reconstructed = reconstruct(&centered, &components, dim);
89    let error = reconstruction_error(&centered, &reconstructed);
90    PcaResult {
91        components,
92        explained_variance,
93        explained_variance_ratio,
94        reconstruction_error: error,
95    }
96}
97
98/// Reconstructs the centered data from its projection onto `components`.
99///
100/// For each row `x`, the reconstruction is `Σ_c (x · cᵀ) c` — the projection onto
101/// the retained orthonormal components, which is exactly what `sklearn`'s
102/// `inverse_transform(transform(x))` computes in the centered space.
103fn reconstruct(centered: &[Vec<f64>], components: &[Vec<f64>], dim: usize) -> Vec<Vec<f64>> {
104    centered
105        .iter()
106        .map(|row| {
107            let mut recon = vec![0.0_f64; dim];
108            for component in components {
109                let score: f64 = row.iter().zip(component).map(|(&x, &c)| x * c).sum();
110                for (r, &c) in recon.iter_mut().zip(component) {
111                    *r = score.mul_add(c, *r);
112                }
113            }
114            recon
115        })
116        .collect()
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn leading_component_captures_x_axis_variance() {
125        let data = vec![
126            vec![-2.0, 0.0],
127            vec![-1.0, 0.0],
128            vec![1.0, 0.0],
129            vec![2.0, 0.0],
130        ];
131        let r = pca(&data, 1);
132        let ratio = r.explained_variance_ratio.first().copied().unwrap_or(0.0);
133        assert!((ratio - 1.0).abs() < 1e-9, "ratio was {ratio}");
134        // One component along the x-axis reconstructs the (already y-free) data exactly.
135        assert!(
136            r.reconstruction_error < 1e-9,
137            "recon error was {}",
138            r.reconstruction_error
139        );
140    }
141
142    #[test]
143    fn ratios_sum_to_one_when_all_components_kept() {
144        let data = vec![
145            vec![1.0, 2.0],
146            vec![3.0, 1.0],
147            vec![2.0, 4.0],
148            vec![5.0, 0.0],
149        ];
150        let r = pca(&data, 2);
151        let total: f64 = r.explained_variance_ratio.iter().sum();
152        assert!((total - 1.0).abs() < 1e-9, "ratios summed to {total}");
153    }
154
155    #[test]
156    fn k_is_clamped_to_dimension() {
157        let data = vec![vec![1.0, 2.0], vec![3.0, 4.0]];
158        let r = pca(&data, 5);
159        assert_eq!(r.components.len(), 2, "components should clamp to dim");
160    }
161}