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(¢ered, 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(¢ered, &components, dim);
89 let error = reconstruction_error(¢ered, &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}