use ferrolearn_core::error::FerroError;
use ferrolearn_core::traits::{Fit, Transform};
use ferrolearn_decomp::{Kernel, KernelPCA};
use ndarray::{Array2, array};
fn fixture() -> Array2<f64> {
array![
[1.49, -1.7, 1.33],
[1.35, -0.95, -1.36],
[-0.42, -0.18, 0.85],
[0.19, 2.94, -1.49],
[-0.96, -1.19, 1.27],
[-0.73, 0.33, -0.78],
[-1.21, -1.45, 1.28],
]
}
#[allow(
clippy::excessive_precision,
reason = "live sklearn 1.5.2 oracle (R-CHAR-3), _kernel_pca.py:368-370"
)]
const SK_EIGENVALUES: [f64; 2] = [1.633549109300395, 1.0480560989060168];
#[allow(
clippy::excessive_precision,
reason = "live sklearn 1.5.2 oracle (R-CHAR-3), _kernel_pca.py:373 + :512"
)]
const SK_TRANSFORM: [[f64; 2]; 7] = [
[-0.36062753121446034, -0.4476264648947989],
[-0.4622855214487781, -0.045225416039812685],
[0.2424469664734752, 0.3650775416777933],
[-0.44567892167324646, -0.3494782796191525],
[0.688764706508706, -0.09844927902441167],
[-0.331844164884922, 0.7432716506367129],
[0.6692244662392257, -0.16756975273633054],
];
fn rbf_2() -> KernelPCA<f64> {
KernelPCA::<f64>::new(2)
.with_kernel(Kernel::RBF)
.with_gamma(0.5)
}
#[test]
fn divergence_svd_flip_embedding_sign() {
let x = fixture();
let fitted = rbf_2().fit(&x, &()).expect("fit");
let t = fitted.transform(&x).expect("transform");
assert_eq!(t.dim(), (7, 2));
for (i, expected_row) in SK_TRANSFORM.iter().enumerate() {
for (j, &expected) in expected_row.iter().enumerate() {
assert!(
(t[[i, j]] - expected).abs() < 1e-6,
"embedding[{i},{j}]: ferrolearn={} sklearn={} (svd_flip sign divergence)",
t[[i, j]],
expected
);
}
}
}
#[test]
fn divergence_svd_flip_alphas_max_abs_positive() {
let x = fixture();
let fitted = rbf_2().fit(&x, &()).expect("fit");
let alphas = fitted.alphas();
for c in 0..alphas.ncols() {
let col = alphas.column(c);
let mut max_row = 0usize;
let mut max_abs = 0.0f64;
for (i, &v) in col.iter().enumerate() {
if v.abs() > max_abs {
max_abs = v.abs();
max_row = i;
}
}
assert!(
col[max_row] > 0.0,
"alphas col[{c}] max-abs entry at row {max_row} = {} should be POSITIVE \
per svd_flip(u, v=None); ferrolearn leaves the raw Jacobi sign",
col[max_row]
);
}
}
#[test]
fn divergence_coef0_default() {
const SK_COEF0_DEFAULT: f64 = 1.0;
let kpca = KernelPCA::<f64>::new(2).with_kernel(Kernel::Polynomial);
assert!(
(kpca.coef0() - SK_COEF0_DEFAULT).abs() < 1e-12,
"ferrolearn KernelPCA::new default coef0 = {} but sklearn default coef0 = {} \
(_kernel_pca.py:289)",
kpca.coef0(),
SK_COEF0_DEFAULT
);
}
#[test]
fn green_eigenvalues_match_sklearn() {
let x = fixture();
let fitted = rbf_2().fit(&x, &()).expect("fit");
let ev = fitted.eigenvalues();
assert_eq!(ev.len(), 2);
for (i, &expected) in SK_EIGENVALUES.iter().enumerate() {
assert!(
(ev[i] - expected).abs() < 1e-6,
"eigenvalue[{i}]: ferrolearn={} sklearn={}",
ev[i],
expected
);
}
}
#[test]
fn green_embedding_matches_sklearn_up_to_sign() {
let x = fixture();
let fitted = rbf_2().fit(&x, &()).expect("fit");
let t = fitted.transform(&x).expect("transform");
for (i, expected_row) in SK_TRANSFORM.iter().enumerate() {
for (j, &expected) in expected_row.iter().enumerate() {
assert!(
(t[[i, j]].abs() - expected.abs()).abs() < 1e-6,
"abs(embedding[{i},{j}]): ferrolearn={} sklearn={}",
t[[i, j]].abs(),
expected.abs()
);
}
}
}
#[test]
fn green_four_kernels_finite_shape() {
let x = fixture();
let n = x.nrows();
for kpca in [
KernelPCA::<f64>::new(2).with_kernel(Kernel::Linear),
KernelPCA::<f64>::new(2)
.with_kernel(Kernel::RBF)
.with_gamma(0.5),
KernelPCA::<f64>::new(2)
.with_kernel(Kernel::Polynomial)
.with_degree(3)
.with_gamma(1.0)
.with_coef0(1.0),
KernelPCA::<f64>::new(2)
.with_kernel(Kernel::Sigmoid)
.with_gamma(0.01)
.with_coef0(1.0),
] {
let kind = kpca.kernel();
let fitted = kpca.fit(&x, &()).expect("fit");
let t = fitted.transform(&x).expect("transform");
assert_eq!(t.dim(), (n, 2), "{kind:?}");
assert!(
t.iter().all(|v| v.is_finite()),
"{kind:?} embedding has non-finite entries"
);
}
}
#[test]
fn green_eigenvalues_nonneg_descending() {
let x = fixture();
let fitted = KernelPCA::<f64>::new(4)
.with_kernel(Kernel::RBF)
.with_gamma(0.5)
.fit(&x, &())
.expect("fit");
let ev = fitted.eigenvalues();
for &v in ev {
assert!(v >= 0.0, "eigenvalue {v} negative");
}
for i in 1..ev.len() {
assert!(ev[i - 1] >= ev[i] - 1e-12, "not descending at {i}");
}
}
#[test]
fn green_transform_new_data_shape() {
let x = fixture();
let fitted = rbf_2().fit(&x, &()).expect("fit");
let x_test = array![[0.1, 0.2, 0.3], [-0.5, 1.0, -1.0]];
let t = fitted.transform(&x_test).expect("transform");
assert_eq!(t.dim(), (2, 2));
assert!(t.iter().all(|v| v.is_finite()));
}
#[test]
fn green_auto_gamma() {
let x = fixture(); let fitted = KernelPCA::<f64>::new(2)
.with_kernel(Kernel::RBF)
.fit(&x, &())
.expect("fit");
let t = fitted.transform(&x).expect("transform");
assert_eq!(t.dim(), (7, 2));
}
#[test]
fn green_err_n_components_zero() {
let x = fixture();
let r = KernelPCA::<f64>::new(0).fit(&x, &());
assert!(matches!(r, Err(FerroError::InvalidParameter { .. })));
}
#[test]
fn green_err_n_components_too_large() {
let x = fixture(); let fitted = KernelPCA::<f64>::new(20)
.with_kernel(Kernel::Linear)
.fit(&x, &())
.expect("sklearn clamps n_components to n_samples and fits (_kernel_pca.py:337)");
assert_eq!(
fitted.eigenvalues().len(),
7,
"sklearn clamps to n_samples=7 eigenvalues (_kernel_pca.py:337)"
);
let projected = fitted.transform(&x).expect("transform");
assert_eq!(
projected.dim(),
(7, 7),
"sklearn transform shape is (n_samples, min(n_samples, n_components)) = (7, 7)"
);
}
#[test]
fn green_err_insufficient_samples() {
let x = array![[1.0, 2.0, 3.0]];
let r = KernelPCA::<f64>::new(1).fit(&x, &());
assert!(matches!(r, Err(FerroError::InsufficientSamples { .. })));
}
#[test]
fn green_err_transform_feature_mismatch() {
let x = fixture();
let fitted = rbf_2().fit(&x, &()).expect("fit");
let bad = array![[1.0, 2.0]]; assert!(matches!(
fitted.transform(&bad),
Err(FerroError::ShapeMismatch { .. })
));
}
#[test]
fn green_f32_path() {
let x: Array2<f32> = array![
[1.49f32, -1.7, 1.33],
[1.35, -0.95, -1.36],
[-0.42, -0.18, 0.85],
[0.19, 2.94, -1.49],
[-0.96, -1.19, 1.27],
];
let fitted = KernelPCA::<f32>::new(2)
.with_kernel(Kernel::RBF)
.with_gamma(0.5)
.fit(&x, &())
.expect("fit");
let t = fitted.transform(&x).expect("transform");
assert_eq!(t.ncols(), 2);
assert!(t.iter().all(|v| v.is_finite()));
}
#[test]
fn green_determinism() {
let x = fixture();
let f1 = rbf_2().fit(&x, &()).expect("fit");
let f2 = rbf_2().fit(&x, &()).expect("fit");
let a1 = f1.alphas();
let a2 = f2.alphas();
assert_eq!(a1.dim(), a2.dim());
for (v1, v2) in a1.iter().zip(a2.iter()) {
assert_eq!(v1, v2, "alphas not bit-identical across fits");
}
let t1 = f1.transform(&x).expect("transform");
let t2 = f2.transform(&x).expect("transform");
for (v1, v2) in t1.iter().zip(t2.iter()) {
assert_eq!(v1, v2, "embedding not bit-identical across fits");
}
}