1use faer::{Mat, linalg::solvers::Svd};
4use thiserror::Error;
5
6#[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
31pub trait PcaComponent: sealed::Sealed + Sized + std::fmt::Debug {
34 fn n_components(&self) -> usize;
37}
38
39#[derive(Debug, Clone)]
55pub struct Pca<C: PcaComponent = Unfitted> {
56 state: C,
57}
58
59impl Pca<Unfitted> {
60 #[must_use]
64 pub fn new(n_components: usize) -> Self {
65 Pca { state: Unfitted { n_components } }
66 }
67
68 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 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 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 let svd = Svd::<f64>::new(cov.as_ref())
136 .map_err(|e| PcaError::SvdFailed(format!("{e:?}")))?;
137 let u = svd.U();
138 let sigma: Vec<f64> = svd.S().column_vector().iter().copied().collect();
141
142 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 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 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 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 #[must_use]
202 pub fn components(&self) -> &[f32] {
203 &self.state.components
204 }
205
206 #[must_use]
208 pub fn components_shape(&self) -> (usize, usize) {
209 (self.state.n_components, self.state.mean.len())
210 }
211
212 #[must_use]
214 pub fn explained_variance_ratio(&self) -> &[f32] {
215 &self.state.explained_variance_ratio
216 }
217
218 #[must_use]
220 pub fn mean(&self) -> &[f32] {
221 &self.state.mean
222 }
223}
224
225impl<C: PcaComponent> Pca<C> {
228 #[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 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 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 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 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 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 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 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 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}