Skip to main content

flow_dimensional_reduction/pca/
mod.rs

1//! Principal Component Analysis via the covariance method.
2
3use faer::{Mat, linalg::solvers::Svd};
4use thiserror::Error;
5
6/// Error type for PCA operations.
7#[derive(Error, Debug)]
8pub enum PcaError {
9    #[error("Empty data")]
10    EmptyData,
11    #[error("Insufficient data: need at least {min} points, got {actual}")]
12    InsufficientData { min: usize, actual: usize },
13    #[error("Dimension mismatch: slice length {len} != n*d ({n}*{d})")]
14    DimensionMismatch { len: usize, n: usize, d: usize },
15    #[error("Feature count mismatch: model was fitted on {fitted} features, got {actual}")]
16    FeatureMismatch { fitted: usize, actual: usize },
17    #[error("SVD decomposition failed: {0}")]
18    SvdFailed(String),
19}
20
21pub type PcaResult<T> = Result<T, PcaError>;
22
23pub mod state;
24
25use state::{Fitted, Unfitted};
26
27mod sealed {
28    pub trait Sealed {}
29}
30
31/// State of a [`Pca`]. Sealed: downstream crates cannot add states, so the set
32/// of valid transitions stays a fact local to this module.
33pub trait PcaComponent: sealed::Sealed + Sized + std::fmt::Debug {
34    /// Requested component count on [`state::Unfitted`]; actual (clamped)
35    /// count on [`state::Fitted`].
36    fn n_components(&self) -> usize;
37}
38
39/// Principal Component Analysis, state-aware via type parameter.
40///
41/// Fit with [`Pca::fit`], then project new data with [`Pca::transform`].
42///
43/// `transform` and the basis accessors exist only on `Pca<Fitted>`,
44/// so projecting before fitting is a compile error rather than a runtime one:
45///
46/// ```compile_fail,E0599
47/// use flow_dimensional_reduction::Pca;
48/// let data = vec![1.0_f32, 2.0, 3.0, 4.0];
49/// // `transform` does not exist on an unfitted model.
50/// let _ = Pca::new(1).transform(&data, 2, 2);
51/// ```
52///
53/// The default type parameter keeps `Pca::new(k)` working without a turbofish.
54#[derive(Debug, Clone)]
55pub struct Pca<C: PcaComponent = Unfitted> {
56    state: C,
57}
58
59impl Pca<Unfitted> {
60    /// Create an unfitted PCA requesting `n_components` components.
61    ///
62    /// The actual count is clamped to the feature count `d` during [`Pca::fit`].
63    #[must_use]
64    pub fn new(n_components: usize) -> Self {
65        Pca { state: Unfitted { n_components } }
66    }
67
68    /// Fit to `n × d` row-major data, consuming the unfitted model.
69    ///
70    /// Means and the covariance matrix are accumulated in `f64` regardless of
71    /// the `f32` input, then downcast — `n` can be large enough that `f32`
72    /// accumulation loses significant precision.
73    ///
74    /// # Errors
75    /// [`PcaError::EmptyData`] if `n == 0` or `d == 0`;
76    /// [`PcaError::InsufficientData`] if `n < 2`;
77    /// [`PcaError::DimensionMismatch`] if `data.len() != n * d`;
78    /// [`PcaError::SvdFailed`] if the decomposition fails.
79    pub fn fit(self, data: &[f32], n: usize, d: usize) -> PcaResult<Pca<Fitted>> {
80        if n == 0 || d == 0 {
81            return Err(PcaError::EmptyData);
82        }
83        let expected_len = n
84            .checked_mul(d)
85            .ok_or(PcaError::DimensionMismatch { len: data.len(), n, d })?;
86        if data.len() != expected_len {
87            return Err(PcaError::DimensionMismatch { len: data.len(), n, d });
88        }
89        if n < 2 {
90            return Err(PcaError::InsufficientData { min: 2, actual: n });
91        }
92
93        // Column means, accumulated in f64.
94        let mut mean64 = vec![0.0_f64; d];
95        for row in data.chunks_exact(d) {
96            for (j, &v) in row.iter().enumerate() {
97                mean64[j] += f64::from(v);
98            }
99        }
100        let inv_n = 1.0_f64 / n as f64;
101        for m in &mut mean64 {
102            *m *= inv_n;
103        }
104
105        // Covariance C = (X - mean)^T (X - mean) / n, symmetric.
106        // Only the upper triangle is accumulated, then mirrored.
107        // The 1/n scaling affects neither the eigenvectors nor the variance
108        // ratios, but is applied so the matrix is a true covariance.
109        let mut cov = Mat::<f64>::zeros(d, d);
110        for row in data.chunks_exact(d) {
111            for i in 0..d {
112                let xi = f64::from(row[i]) - mean64[i];
113                for j in i..d {
114                    let xj = f64::from(row[j]) - mean64[j];
115                    cov[(i, j)] += xi * xj;
116                }
117            }
118        }
119        for i in 0..d {
120            for j in i..d {
121                cov[(i, j)] *= inv_n;
122                if i != j {
123                    cov[(j, i)] = cov[(i, j)];
124                }
125            }
126        }
127
128        let k = self.state.n_components.min(d);
129
130        // One decomposition: U and S come from the same Svd object, so they
131        // are guaranteed to correspond (sigma[i] <-> column i of U) — unlike
132        // pairing a standalone `singular_values()` call with a separate
133        // `Svd::new` call, which relies on an unstated ordering invariant
134        // between two independent decompositions.
135        let svd = Svd::<f64>::new(cov.as_ref())
136            .map_err(|e| PcaError::SvdFailed(format!("{e:?}")))?;
137        let u = svd.U();
138        // `S()` returns a `DiagRef`, not a slice/Vec; go through its column
139        // vector view to iterate the singular values in decomposition order.
140        let sigma: Vec<f64> = svd.S().column_vector().iter().copied().collect();
141
142        // Row i of `components` is the i-th principal axis (column i of U),
143        // stored flat row-major: axis i occupies [i*d .. (i+1)*d].
144        let mut components = vec![0.0_f32; k * d];
145        for i in 0..k {
146            for j in 0..d {
147                components[i * d + j] = *u.get(j, i) as f32;
148            }
149        }
150
151        // For a covariance matrix the singular values ARE the variances.
152        let total: f64 = sigma.iter().sum();
153        let explained_variance_ratio: Vec<f32> = if total > 0.0 {
154            sigma.iter().take(k).map(|&s| (s / total) as f32).collect()
155        } else {
156            vec![0.0; k]
157        };
158
159        let mean = mean64.into_iter().map(|m| m as f32).collect();
160
161        Ok(Pca { state: Fitted { n_components: k, components, explained_variance_ratio, mean } })
162    }
163}
164
165impl Pca<Fitted> {
166    /// Project `n × d` row-major data onto the fitted axes.
167    ///
168    /// Returns `n × n_components` row-major.
169    ///
170    /// # Errors
171    /// [`PcaError::DimensionMismatch`] if `data.len() != n * d`;
172    /// [`PcaError::FeatureMismatch`] if `d` differs from the fitted feature count.
173    pub fn transform(&self, data: &[f32], n: usize, d: usize) -> PcaResult<Vec<f32>> {
174        let expected_len = n
175            .checked_mul(d)
176            .ok_or(PcaError::DimensionMismatch { len: data.len(), n, d })?;
177        if data.len() != expected_len {
178            return Err(PcaError::DimensionMismatch { len: data.len(), n, d });
179        }
180        if d != self.state.mean.len() {
181            return Err(PcaError::FeatureMismatch { fitted: self.state.mean.len(), actual: d });
182        }
183
184        let k = self.state.n_components;
185        // `k <= d` (clamped in `fit`) and `n * d` did not overflow above, so
186        // `n * k` cannot overflow either.
187        let mut out = Vec::with_capacity(n * k);
188        for row in data.chunks_exact(d) {
189            for i in 0..k {
190                let mut acc = 0.0_f32;
191                for (j, (&x, &m)) in row.iter().zip(self.state.mean.iter()).enumerate() {
192                    acc += (x - m) * self.state.components[i * d + j];
193                }
194                out.push(acc);
195            }
196        }
197        Ok(out)
198    }
199
200    /// Principal axes, `k * d` row-major: axis `i` occupies `[i*d .. (i+1)*d]`.
201    #[must_use]
202    pub fn components(&self) -> &[f32] {
203        &self.state.components
204    }
205
206    /// Shape of [`Self::components`] as `(k, d)`.
207    #[must_use]
208    pub fn components_shape(&self) -> (usize, usize) {
209        (self.state.n_components, self.state.mean.len())
210    }
211
212    /// Fraction of total variance per component, descending.
213    #[must_use]
214    pub fn explained_variance_ratio(&self) -> &[f32] {
215        &self.state.explained_variance_ratio
216    }
217
218    /// Column means of the training data.
219    #[must_use]
220    pub fn mean(&self) -> &[f32] {
221        &self.state.mean
222    }
223}
224
225/// Available in every state — delegates to the trait method, since a generic
226/// `C` cannot be pattern-matched.
227impl<C: PcaComponent> Pca<C> {
228    /// Component count: requested before fitting, actual (clamped) after.
229    #[must_use]
230    pub fn n_components(&self) -> usize {
231        self.state.n_components()
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    /// Two strongly correlated columns; PC1 must capture nearly all variance.
240    /// Row-major, n=5, d=2.
241    fn fixture() -> (Vec<f32>, usize, usize) {
242        let data = vec![
243            1.0, 2.0,
244            2.0, 4.1,
245            3.0, 5.9,
246            4.0, 8.2,
247            5.0, 9.8,
248        ];
249        (data, 5, 2)
250    }
251
252    #[test]
253    fn fit_then_transform_projects_to_n_components() {
254        let (data, n, d) = fixture();
255        let pca = Pca::new(1).fit(&data, n, d).expect("fit");
256        let out = pca.transform(&data, n, d).expect("transform");
257        assert_eq!(out.len(), n, "output must be n x n_components row-major");
258    }
259
260    #[test]
261    fn first_component_dominates_variance() {
262        let (data, n, d) = fixture();
263        let pca = Pca::new(2).fit(&data, n, d).expect("fit");
264        assert!(
265            pca.explained_variance_ratio()[0] > 0.99,
266            "PC1 ratio was {}",
267            pca.explained_variance_ratio()[0]
268        );
269    }
270
271    #[test]
272    fn explained_variance_ratio_sums_to_one_and_descends() {
273        let (data, n, d) = fixture();
274        let pca = Pca::new(2).fit(&data, n, d).expect("fit");
275        let r = pca.explained_variance_ratio();
276        let total: f32 = r.iter().sum();
277        assert!((total - 1.0).abs() < 1e-4, "ratios summed to {total}");
278        assert!(r[0] >= r[1], "ratios must descend: {r:?}");
279    }
280
281    #[test]
282    fn n_components_clamped_to_d() {
283        let (data, n, d) = fixture();
284        let pca = Pca::new(10).fit(&data, n, d).expect("fit");
285        assert_eq!(pca.n_components(), 2, "must clamp to d");
286    }
287
288    #[test]
289    fn mean_is_column_mean() {
290        let (data, n, d) = fixture();
291        let pca = Pca::new(1).fit(&data, n, d).expect("fit");
292        // column 0 mean = (1+2+3+4+5)/5 = 3.0
293        approx::assert_abs_diff_eq!(pca.mean()[0], 3.0_f32, epsilon = 1e-5);
294    }
295
296    #[test]
297    fn separates_axis_aligned_clusters() {
298        // Mirrors the guard test in flow-pacmap/src/pca.rs:84 so Task 3 can
299        // rely on identical behaviour.
300        let mut data: Vec<f32> = Vec::new();
301        for _ in 0..50 {
302            data.extend_from_slice(&[0.0_f32, 0.0]);
303        }
304        for _ in 0..50 {
305            data.extend_from_slice(&[10.0_f32, 0.0]);
306        }
307        let pca = Pca::new(2).fit(&data, 100, 2).expect("fit");
308        let out = pca.transform(&data, 100, 2).expect("transform");
309        let left: f32 = out.chunks_exact(2).take(50).map(|c| c[0]).sum::<f32>() / 50.0;
310        let right: f32 = out.chunks_exact(2).skip(50).map(|c| c[0]).sum::<f32>() / 50.0;
311        assert!((left - right).abs() > 1.0, "PC1 must separate the clusters");
312    }
313
314    /// Non-degenerate 3-D data: points scaled along the direction `(1, 2, 3)`.
315    /// The covariance has a single nonzero eigenvalue, so PC1 is pinned up to
316    /// sign to `(1, 2, 3) / sqrt(14)` — no SVD sign-convention ambiguity
317    /// beyond an overall flip, and no degenerate subspace to hide a bug in.
318    ///
319    /// A 2-D fixture cannot do this job: for *any* 2x2 orthogonal matrix,
320    /// `|U(0,1)| == |U(1,0)|` is a structural identity (both rows and
321    /// columns of an orthogonal matrix are orthonormal), so comparing
322    /// `.abs()` values is blind to swapping `u.get(j, i)` for `u.get(i, j)`.
323    fn diagonal_fixture() -> (Vec<f32>, usize, usize) {
324        let dir = [1.0_f32, 2.0, 3.0];
325        let mut data = Vec::new();
326        for t in [-2.0_f32, -1.0, 0.0, 1.0, 2.0] {
327            for &c in &dir {
328                data.push(t * c);
329            }
330        }
331        (data, 5, 3)
332    }
333
334    #[test]
335    fn u_column_maps_to_matching_principal_axis() {
336        // Pins `components[(i, j)] = u.get(j, i)`. Transposing to
337        // `u.get(i, j)` pulls components 1 and 2 from the *degenerate*
338        // zero-eigenvalue subspace of U's first row, which will not equal
339        // these values, so the mutation is caught.
340        let (data, n, d) = diagonal_fixture();
341        let pca = Pca::new(1).fit(&data, n, d).expect("fit");
342        let norm = 14.0_f32.sqrt();
343        assert_eq!(pca.components_shape(), (1, 3), "k=1, d=3");
344        // k == 1, so axis 0 occupies the entire flat slice: indices 0..d.
345        let c = pca.components();
346        approx::assert_abs_diff_eq!(c[0].abs(), 1.0 / norm, epsilon = 1e-4);
347        approx::assert_abs_diff_eq!(c[1].abs(), 2.0 / norm, epsilon = 1e-4);
348        approx::assert_abs_diff_eq!(c[2].abs(), 3.0 / norm, epsilon = 1e-4);
349    }
350
351    #[test]
352    fn transform_centers_training_mean_to_zero() {
353        // Pins the `- mean[j]` term in `transform`: projecting the training
354        // centroid itself must land at the origin on every axis. Dropping
355        // the subtraction shifts every projection by the same constant,
356        // which the cluster-separation test cannot see (it only compares
357        // `left - right`), but this test does.
358        let (data, n, d) = fixture();
359        let pca = Pca::new(2).fit(&data, n, d).expect("fit");
360        let mean_row = pca.mean().to_vec();
361        let out = pca.transform(&mean_row, 1, d).expect("transform");
362        for v in out {
363            approx::assert_abs_diff_eq!(v, 0.0_f32, epsilon = 1e-4);
364        }
365    }
366
367    #[test]
368    fn fit_rejects_length_that_would_overflow_n_times_d() {
369        // n * d wraps to 0 in release mode; checked_mul must reject this
370        // instead of accepting an empty slice as a valid `usize::MAX x 2`
371        // input.
372        assert!(matches!(
373            Pca::new(1).fit(&[], usize::MAX, 2),
374            Err(PcaError::DimensionMismatch { .. })
375        ));
376    }
377
378    #[test]
379    fn transform_rejects_length_that_would_overflow_n_times_d() {
380        let (data, n, d) = fixture();
381        let pca = Pca::new(1).fit(&data, n, d).expect("fit");
382        assert!(matches!(
383            pca.transform(&[], usize::MAX, 2),
384            Err(PcaError::DimensionMismatch { .. })
385        ));
386    }
387
388    #[test]
389    fn fit_empty_data_errors() {
390        assert!(matches!(Pca::new(1).fit(&[], 0, 0), Err(PcaError::EmptyData)));
391    }
392
393    #[test]
394    fn fit_single_row_errors() {
395        let data = vec![1.0_f32, 2.0];
396        assert!(matches!(
397            Pca::new(1).fit(&data, 1, 2),
398            Err(PcaError::InsufficientData { min: 2, actual: 1 })
399        ));
400    }
401
402    #[test]
403    fn fit_length_mismatch_errors() {
404        let data = vec![1.0_f32, 2.0, 3.0];
405        assert!(matches!(
406            Pca::new(1).fit(&data, 2, 2),
407            Err(PcaError::DimensionMismatch { len: 3, n: 2, d: 2 })
408        ));
409    }
410
411    #[test]
412    fn transform_length_mismatch_errors() {
413        let (data, n, d) = fixture();
414        let pca = Pca::new(1).fit(&data, n, d).expect("fit");
415        let bad = vec![1.0_f32; 7];
416        assert!(matches!(
417            pca.transform(&bad, 3, 2),
418            Err(PcaError::DimensionMismatch { .. })
419        ));
420    }
421
422    #[test]
423    fn transform_rejects_wrong_feature_count() {
424        let (data, n, d) = fixture();
425        let pca = Pca::new(1).fit(&data, n, d).expect("fit");
426        let three_wide = vec![1.0_f32; 6];
427        assert!(
428            pca.transform(&three_wide, 2, 3).is_err(),
429            "transform must reject d != fitted d"
430        );
431    }
432}