use ndarray::{Array2, array};
use rustyml::error::Error;
use rustyml::utils::pca::{PCA, SVDSolver};
use crate::common::assert_allclose;
fn collinear_data() -> Array2<f64> {
array![[1.0, 2.0], [2.0, 4.0], [3.0, 6.0], [4.0, 8.0], [5.0, 10.0]]
}
fn anisotropic_data() -> Array2<f64> {
array![[4.0, 0.0], [0.0, 2.0], [-4.0, 0.0], [0.0, -2.0]]
}
#[test]
fn test_new_n_components_zero_is_invalid_parameter() {
let err = PCA::new(0).unwrap_err();
assert!(
matches!(err, Error::InvalidParameter { .. }),
"expected InvalidParameter, got {:?}",
err
);
}
#[test]
fn test_new_valid_n_components() {
PCA::new(1)
.expect("n_components=1 should be valid")
.with_svd_solver(SVDSolver::Full);
PCA::new(2)
.expect("n_components=2 should be valid")
.with_svd_solver(SVDSolver::Full);
}
#[test]
fn test_transform_before_fit_is_not_fitted() {
let pca = PCA::new(1).unwrap().with_svd_solver(SVDSolver::Full);
let x = collinear_data();
let err = pca.transform(&x).unwrap_err();
assert!(
matches!(err, Error::NotFitted(_)),
"expected NotFitted, got {:?}",
err
);
}
#[test]
fn test_inverse_transform_before_fit_is_not_fitted() {
let pca = PCA::new(1).unwrap().with_svd_solver(SVDSolver::Full);
let scores = array![[1.0], [0.0], [0.0], [0.0], [0.0]];
let err = pca.inverse_transform(&scores).unwrap_err();
assert!(
matches!(err, Error::NotFitted(_)),
"expected NotFitted, got {:?}",
err
);
}
#[test]
fn test_fit_empty_input_is_empty_input() {
let mut pca = PCA::new(1).unwrap().with_svd_solver(SVDSolver::Full);
let x: Array2<f64> = Array2::zeros((0, 2));
let err = pca.fit(&x).unwrap_err();
assert!(
matches!(err, Error::EmptyInput(_)),
"expected EmptyInput, got {:?}",
err
);
}
#[test]
fn test_fit_single_sample_is_invalid_input() {
let mut pca = PCA::new(1).unwrap().with_svd_solver(SVDSolver::Full);
let x = array![[1.0, 2.0]];
let err = pca.fit(&x).unwrap_err();
assert!(
matches!(err, Error::InvalidInput(_)),
"expected InvalidInput, got {:?}",
err
);
}
#[test]
fn test_fit_n_components_exceeds_n_features_is_invalid_parameter() {
let mut pca = PCA::new(3).unwrap().with_svd_solver(SVDSolver::Full);
let x = collinear_data(); let err = pca.fit(&x).unwrap_err();
assert!(
matches!(err, Error::InvalidParameter { .. }),
"expected InvalidParameter for n_components > n_features, got {:?}",
err
);
}
#[test]
fn test_fit_nan_input_is_non_finite() {
let mut pca = PCA::new(1).unwrap().with_svd_solver(SVDSolver::Full);
let x = array![[1.0, f64::NAN], [2.0, 4.0], [3.0, 6.0]];
let err = pca.fit(&x).unwrap_err();
assert!(
matches!(err, Error::NonFinite(_)),
"expected NonFinite, got {:?}",
err
);
}
#[test]
fn test_fit_inf_input_is_non_finite() {
let mut pca = PCA::new(1).unwrap().with_svd_solver(SVDSolver::Full);
let x = array![[1.0, 2.0], [f64::INFINITY, 4.0], [3.0, 6.0]];
let err = pca.fit(&x).unwrap_err();
assert!(
matches!(err, Error::NonFinite(_)),
"expected NonFinite, got {:?}",
err
);
}
#[test]
fn test_transform_wrong_feature_count_is_dimension_mismatch() {
let mut pca = PCA::new(1).unwrap().with_svd_solver(SVDSolver::Full);
pca.fit(&collinear_data()).unwrap();
let x_wrong = array![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
let err = pca.transform(&x_wrong).unwrap_err();
assert!(
matches!(err, Error::DimensionMismatch { .. }),
"expected DimensionMismatch, got {:?}",
err
);
}
#[test]
fn test_transform_nan_is_non_finite() {
let mut pca = PCA::new(1).unwrap().with_svd_solver(SVDSolver::Full);
pca.fit(&collinear_data()).unwrap();
let x = array![[1.0, f64::NAN], [2.0, 4.0]];
let err = pca.transform(&x).unwrap_err();
assert!(
matches!(err, Error::NonFinite(_)),
"expected NonFinite, got {:?}",
err
);
}
#[test]
fn test_full_collinear_explained_variance_ratio() {
let mut pca = PCA::new(1).unwrap().with_svd_solver(SVDSolver::Full);
pca.fit(&collinear_data()).unwrap();
let ratio = pca
.get_explained_variance_ratio()
.expect("model should be fitted; ratio is Some");
assert_eq!(ratio.len(), 1, "should have 1 variance ratio");
assert!(
ratio[0] > 0.999,
"explained variance ratio should be ≥ 0.999, got {}",
ratio[0]
);
}
#[test]
fn test_full_collinear_singular_value() {
use approx::assert_abs_diff_eq;
let mut pca = PCA::new(1).unwrap().with_svd_solver(SVDSolver::Full);
pca.fit(&collinear_data()).unwrap();
let sv = pca.get_singular_values().expect("fitted; sv is Some");
assert_eq!(sv.len(), 1, "should have 1 singular value");
let expected_sv = 50.0_f64.sqrt(); assert_abs_diff_eq!(sv[0], expected_sv, epsilon = 1e-6);
}
#[test]
fn test_full_collinear_component_is_unit_norm() {
use approx::assert_abs_diff_eq;
let mut pca = PCA::new(1).unwrap().with_svd_solver(SVDSolver::Full);
pca.fit(&collinear_data()).unwrap();
let comp = pca.get_components().expect("fitted; components is Some");
assert_eq!(comp.nrows(), 1, "1 component row");
assert_eq!(comp.ncols(), 2, "2 features");
let norm = (comp[[0, 0]].powi(2) + comp[[0, 1]].powi(2)).sqrt();
assert_abs_diff_eq!(norm, 1.0, epsilon = 1e-8);
}
#[test]
fn test_full_collinear_component_direction() {
use approx::assert_abs_diff_eq;
let mut pca = PCA::new(1).unwrap().with_svd_solver(SVDSolver::Full);
pca.fit(&collinear_data()).unwrap();
let comp = pca.get_components().expect("fitted; components is Some");
let expected_0 = 1.0_f64 / 5.0_f64.sqrt();
let expected_1 = 2.0_f64 / 5.0_f64.sqrt();
let dot = comp[[0, 0]] * expected_0 + comp[[0, 1]] * expected_1;
assert_abs_diff_eq!(dot.abs(), 1.0, epsilon = 1e-8);
}
#[test]
fn test_full_collinear_inverse_transform_reconstruction() {
let x = collinear_data();
let mut pca = PCA::new(1).unwrap().with_svd_solver(SVDSolver::Full);
pca.fit(&x).unwrap();
let projected = pca.transform(&x).unwrap();
let reconstructed = pca.inverse_transform(&projected).unwrap();
assert_allclose(&reconstructed, &x, 1e-6);
}
#[test]
fn test_fit_transform_equals_fit_then_transform_full() {
let x = collinear_data();
let mut pca_a = PCA::new(1).unwrap().with_svd_solver(SVDSolver::Full);
let result_a = pca_a.fit_transform(&x).unwrap();
let mut pca_b = PCA::new(1).unwrap().with_svd_solver(SVDSolver::Full);
pca_b.fit(&x).unwrap();
let result_b = pca_b.transform(&x).unwrap();
assert_allclose(&result_a, &result_b, 1e-10);
}
#[test]
fn test_components_orthonormal_full() {
use approx::assert_abs_diff_eq;
let x = anisotropic_data();
let mut pca = PCA::new(2).unwrap().with_svd_solver(SVDSolver::Full);
pca.fit(&x).unwrap();
let comp = pca.get_components().expect("fitted; components is Some");
assert_eq!(comp.nrows(), 2);
assert_eq!(comp.ncols(), 2);
for i in 0..2 {
let norm = (comp[[i, 0]].powi(2) + comp[[i, 1]].powi(2)).sqrt();
assert_abs_diff_eq!(norm, 1.0, epsilon = 1e-8);
}
let dot = comp[[0, 0]] * comp[[1, 0]] + comp[[0, 1]] * comp[[1, 1]];
assert_abs_diff_eq!(dot.abs(), 0.0, epsilon = 1e-8);
}
#[test]
fn test_singular_values_descending_full() {
use approx::assert_abs_diff_eq;
let x = anisotropic_data();
let mut pca = PCA::new(2).unwrap().with_svd_solver(SVDSolver::Full);
pca.fit(&x).unwrap();
let sv = pca.get_singular_values().expect("fitted; sv is Some");
assert_eq!(sv.len(), 2);
let expected_sv1 = 32.0_f64.sqrt(); let expected_sv2 = 8.0_f64.sqrt();
assert_abs_diff_eq!(sv[0], expected_sv1, epsilon = 1e-4);
assert_abs_diff_eq!(sv[1], expected_sv2, epsilon = 1e-4);
assert!(
sv[0] >= sv[1],
"singular values should be non-ascending: {sv:?}"
);
}
#[test]
fn test_explained_variance_ratio_sums_to_one() {
use approx::assert_abs_diff_eq;
let x = anisotropic_data();
let mut pca = PCA::new(2).unwrap().with_svd_solver(SVDSolver::Full);
pca.fit(&x).unwrap();
let ratio = pca
.get_explained_variance_ratio()
.expect("fitted; ratio is Some");
let sum: f64 = ratio.iter().sum();
assert_abs_diff_eq!(sum, 1.0, epsilon = 1e-6);
assert_abs_diff_eq!(ratio[0].max(ratio[1]), 0.8, epsilon = 1e-4);
assert_abs_diff_eq!(ratio[0].min(ratio[1]), 0.2, epsilon = 1e-4);
}
#[test]
fn test_all_solvers_produce_correct_output_shape() {
let x = collinear_data();
let n_components = 1usize;
for solver in [
SVDSolver::Full,
SVDSolver::Randomized(42),
SVDSolver::PowerIteration,
] {
let mut pca = PCA::new(n_components).unwrap().with_svd_solver(solver);
pca.fit(&x).unwrap();
let projected = pca.transform(&x).unwrap();
assert_eq!(
projected.shape(),
&[5, n_components],
"solver {:?}: wrong output shape",
solver
);
}
}
#[test]
fn test_all_solvers_agree_on_singular_value() {
use approx::assert_abs_diff_eq;
let x = collinear_data();
let expected_sv = 50.0_f64.sqrt();
for solver in [
SVDSolver::Full,
SVDSolver::Randomized(42),
SVDSolver::PowerIteration,
] {
let mut pca = PCA::new(1).unwrap().with_svd_solver(solver);
pca.fit(&x).unwrap();
let sv = pca.get_singular_values().expect("fitted; sv is Some");
assert_abs_diff_eq!(
sv[0],
expected_sv,
epsilon = 1e-2,
);
}
}
#[test]
fn test_all_solvers_agree_on_component_signs() {
use approx::assert_abs_diff_eq;
let x = anisotropic_data();
let reference = {
let mut pca = PCA::new(2).unwrap().with_svd_solver(SVDSolver::Full);
pca.fit(&x).unwrap();
pca.get_components()
.expect("fitted; components is Some")
.clone()
};
for row in reference.rows() {
let signed_max = row
.iter()
.copied()
.max_by(|a, b| a.abs().partial_cmp(&b.abs()).unwrap())
.unwrap();
assert!(
signed_max >= 0.0,
"largest-magnitude loading should be non-negative, got {}",
signed_max
);
}
for solver in [SVDSolver::Randomized(42), SVDSolver::PowerIteration] {
let mut pca = PCA::new(2).unwrap().with_svd_solver(solver);
pca.fit(&x).unwrap();
let components = pca.get_components().expect("fitted; components is Some");
for (a, b) in reference.rows().into_iter().zip(components.rows()) {
for (&x_ref, &x_got) in a.iter().zip(b.iter()) {
assert_abs_diff_eq!(x_ref, x_got, epsilon = 1e-3);
}
}
}
}
#[test]
fn test_randomized_determinism_same_seed() {
let x = anisotropic_data();
let mut pca_a = PCA::new(2)
.unwrap()
.with_svd_solver(SVDSolver::Randomized(42));
let result_a = pca_a.fit_transform(&x).unwrap();
let mut pca_b = PCA::new(2)
.unwrap()
.with_svd_solver(SVDSolver::Randomized(42));
let result_b = pca_b.fit_transform(&x).unwrap();
assert_allclose(&result_a, &result_b, 0.0);
}
#[test]
fn test_randomized_components_orthonormal() {
use approx::assert_abs_diff_eq;
let x = anisotropic_data();
let mut pca = PCA::new(2)
.unwrap()
.with_svd_solver(SVDSolver::Randomized(42));
pca.fit(&x).unwrap();
let comp = pca.get_components().expect("fitted; components is Some");
for i in 0..2 {
let norm = (comp[[i, 0]].powi(2) + comp[[i, 1]].powi(2)).sqrt();
assert_abs_diff_eq!(norm, 1.0, epsilon = 1e-6);
}
let dot = comp[[0, 0]] * comp[[1, 0]] + comp[[0, 1]] * comp[[1, 1]];
assert_abs_diff_eq!(dot.abs(), 0.0, epsilon = 1e-6);
}
#[test]
fn test_power_iteration_component_unit_norm() {
use approx::assert_abs_diff_eq;
let x = anisotropic_data();
let mut pca = PCA::new(2)
.unwrap()
.with_svd_solver(SVDSolver::PowerIteration);
pca.fit(&x).unwrap();
let comp = pca.get_components().expect("fitted; components is Some");
for i in 0..2 {
let norm = (comp[[i, 0]].powi(2) + comp[[i, 1]].powi(2)).sqrt();
assert_abs_diff_eq!(norm, 1.0, epsilon = 1e-5);
}
}
#[test]
fn test_power_iteration_inverse_transform_recovers_collinear() {
let x = collinear_data();
let mut pca = PCA::new(1)
.unwrap()
.with_svd_solver(SVDSolver::PowerIteration);
pca.fit(&x).unwrap();
let projected = pca.transform(&x).unwrap();
let reconstructed = pca.inverse_transform(&projected).unwrap();
assert_allclose(&reconstructed, &x, 1e-4);
}
#[test]
fn test_all_solvers_capture_full_variance_on_collinear_data() {
let x = collinear_data();
for solver in [
SVDSolver::Full,
SVDSolver::Randomized(42),
SVDSolver::PowerIteration,
] {
let mut pca = PCA::new(1).unwrap().with_svd_solver(solver);
pca.fit(&x).unwrap();
let ratio = pca
.get_explained_variance_ratio()
.expect("fitted; ratio is Some");
assert!(
ratio[0] > 0.999,
"solver {:?}: expected ratio > 0.999, got {}",
solver,
ratio[0]
);
}
}
#[test]
fn test_save_load_roundtrip_preserves_transform() {
use std::fs;
let x = collinear_data();
let path = "/tmp/rustyml_pca_test_roundtrip.json";
let mut pca = PCA::new(1).unwrap().with_svd_solver(SVDSolver::Full);
pca.fit(&x).unwrap();
let result_before = pca.transform(&x).unwrap();
pca.save_to_path(path).unwrap();
let loaded = PCA::load_from_path(path).unwrap();
let result_after = loaded.transform(&x).unwrap();
assert_allclose(&result_before, &result_after, 1e-10);
let _ = fs::remove_file(path);
}
#[test]
fn test_load_nonexistent_path_is_error() {
let result = PCA::load_from_path("/tmp/rustyml_pca_no_such_file_xyz.json");
assert!(result.is_err(), "loading a missing file should fail");
}
#[test]
fn test_transform_output_shape() {
let x = collinear_data(); let mut pca = PCA::new(1).unwrap().with_svd_solver(SVDSolver::Full);
pca.fit(&x).unwrap();
let out = pca.transform(&x).unwrap();
assert_eq!(out.shape(), &[5, 1]);
}
#[test]
fn test_inverse_transform_output_shape() {
let x = collinear_data(); let mut pca = PCA::new(1).unwrap().with_svd_solver(SVDSolver::Full);
pca.fit(&x).unwrap();
let projected = pca.transform(&x).unwrap();
let reconstructed = pca.inverse_transform(&projected).unwrap();
assert_eq!(reconstructed.shape(), &[5, 2]);
}
#[test]
fn test_anisotropic_projection_values_full() {
use approx::assert_abs_diff_eq;
let x = anisotropic_data();
let mut pca = PCA::new(2).unwrap().with_svd_solver(SVDSolver::Full);
pca.fit(&x).unwrap();
let projected = pca.transform(&x).unwrap();
let col0_abs: Vec<f64> = (0..4).map(|i| projected[[i, 0]].abs()).collect();
assert_abs_diff_eq!(col0_abs[0], 4.0, epsilon = 1e-6);
assert_abs_diff_eq!(col0_abs[1], 0.0, epsilon = 1e-6);
assert_abs_diff_eq!(col0_abs[2], 4.0, epsilon = 1e-6);
assert_abs_diff_eq!(col0_abs[3], 0.0, epsilon = 1e-6);
let s0 = projected[[0, 0]];
let s2 = projected[[2, 0]];
assert_abs_diff_eq!(s0, -s2, epsilon = 1e-6);
let col1_abs: Vec<f64> = (0..4).map(|i| projected[[i, 1]].abs()).collect();
assert_abs_diff_eq!(col1_abs[0], 0.0, epsilon = 1e-6);
assert_abs_diff_eq!(col1_abs[1], 2.0, epsilon = 1e-6);
assert_abs_diff_eq!(col1_abs[2], 0.0, epsilon = 1e-6);
assert_abs_diff_eq!(col1_abs[3], 2.0, epsilon = 1e-6);
}
#[test]
fn test_full_collinear_get_mean() {
use approx::assert_abs_diff_eq;
let mut pca = PCA::new(1).unwrap().with_svd_solver(SVDSolver::Full);
pca.fit(&collinear_data()).unwrap();
let mean = pca.get_mean().expect("fitted; mean is Some");
assert_eq!(mean.len(), 2, "mean has one entry per feature");
assert_abs_diff_eq!(mean[0], 3.0, epsilon = 1e-9);
assert_abs_diff_eq!(mean[1], 6.0, epsilon = 1e-9);
}
#[test]
fn test_anisotropic_get_mean_is_zero() {
use approx::assert_abs_diff_eq;
let mut pca = PCA::new(2).unwrap().with_svd_solver(SVDSolver::Full);
pca.fit(&anisotropic_data()).unwrap();
let mean = pca.get_mean().expect("fitted; mean is Some");
assert_eq!(mean.len(), 2);
assert_abs_diff_eq!(mean[0], 0.0, epsilon = 1e-9);
assert_abs_diff_eq!(mean[1], 0.0, epsilon = 1e-9);
}
#[test]
fn test_anisotropic_get_explained_variance() {
use approx::assert_abs_diff_eq;
let mut pca = PCA::new(2).unwrap().with_svd_solver(SVDSolver::Full);
pca.fit(&anisotropic_data()).unwrap();
let ev = pca
.get_explained_variance()
.expect("fitted; explained_variance is Some");
assert_eq!(ev.len(), 2, "one variance per component");
assert_abs_diff_eq!(ev[0], 32.0 / 3.0, epsilon = 1e-6);
assert_abs_diff_eq!(ev[1], 8.0 / 3.0, epsilon = 1e-6);
}
#[test]
fn test_full_collinear_get_explained_variance() {
use approx::assert_abs_diff_eq;
let mut pca = PCA::new(1).unwrap().with_svd_solver(SVDSolver::Full);
pca.fit(&collinear_data()).unwrap();
let ev = pca
.get_explained_variance()
.expect("fitted; explained_variance is Some");
assert_eq!(ev.len(), 1);
assert_abs_diff_eq!(ev[0], 12.5, epsilon = 1e-6);
}