use crate::algorithms::decomposition::{
at, covariance, descending_order, jacobi_eigen, mean_center, reconstruction_error,
};
#[derive(Debug, Clone)]
pub struct PcaResult {
pub components: Vec<Vec<f64>>,
pub explained_variance: Vec<f64>,
pub explained_variance_ratio: Vec<f64>,
pub reconstruction_error: f64,
}
#[must_use]
pub fn pca(data: &[Vec<f64>], n_components: usize) -> PcaResult {
let dim = data.first().map_or(0, Vec::len);
let k = n_components.min(dim);
if dim == 0 || k == 0 {
return PcaResult {
components: Vec::new(),
explained_variance: Vec::new(),
explained_variance_ratio: Vec::new(),
reconstruction_error: 0.0,
};
}
let (centered, _means) = mean_center(data, dim);
let cov = covariance(¢ered, dim);
let (values, vectors) = jacobi_eigen(&cov, dim);
let order = descending_order(&values);
let total: f64 = values.iter().map(|v| v.max(0.0)).sum();
let mut components = Vec::with_capacity(k);
let mut explained_variance = Vec::with_capacity(k);
let mut explained_variance_ratio = Vec::with_capacity(k);
for &col in order.iter().take(k) {
let component: Vec<f64> = (0..dim).map(|row| at(&vectors, dim, row, col)).collect();
let variance = values.get(col).copied().unwrap_or(0.0).max(0.0);
explained_variance.push(variance);
explained_variance_ratio.push(if total > 0.0 { variance / total } else { 0.0 });
components.push(component);
}
let reconstructed = reconstruct(¢ered, &components, dim);
let error = reconstruction_error(¢ered, &reconstructed);
PcaResult {
components,
explained_variance,
explained_variance_ratio,
reconstruction_error: error,
}
}
fn reconstruct(centered: &[Vec<f64>], components: &[Vec<f64>], dim: usize) -> Vec<Vec<f64>> {
centered
.iter()
.map(|row| {
let mut recon = vec![0.0_f64; dim];
for component in components {
let score: f64 = row.iter().zip(component).map(|(&x, &c)| x * c).sum();
for (r, &c) in recon.iter_mut().zip(component) {
*r = score.mul_add(c, *r);
}
}
recon
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn leading_component_captures_x_axis_variance() {
let data = vec![
vec![-2.0, 0.0],
vec![-1.0, 0.0],
vec![1.0, 0.0],
vec![2.0, 0.0],
];
let r = pca(&data, 1);
let ratio = r.explained_variance_ratio.first().copied().unwrap_or(0.0);
assert!((ratio - 1.0).abs() < 1e-9, "ratio was {ratio}");
assert!(
r.reconstruction_error < 1e-9,
"recon error was {}",
r.reconstruction_error
);
}
#[test]
fn ratios_sum_to_one_when_all_components_kept() {
let data = vec![
vec![1.0, 2.0],
vec![3.0, 1.0],
vec![2.0, 4.0],
vec![5.0, 0.0],
];
let r = pca(&data, 2);
let total: f64 = r.explained_variance_ratio.iter().sum();
assert!((total - 1.0).abs() < 1e-9, "ratios summed to {total}");
}
#[test]
fn k_is_clamped_to_dimension() {
let data = vec![vec![1.0, 2.0], vec![3.0, 4.0]];
let r = pca(&data, 5);
assert_eq!(r.components.len(), 2, "components should clamp to dim");
}
}