use ferrolearn_core::traits::{Fit, Transform};
use ferrolearn_decomp::FactorAnalysis;
use ndarray::{Array1, Array2};
fn probe1() -> Array2<f64> {
Array2::from_shape_vec(
(10, 4),
vec![
1.20, 0.50, 2.10, -0.30, -0.80, 1.40, -1.20, 0.90, 0.30, -0.60, 0.80, 1.10, 2.10, 0.20,
3.40, -1.00, -1.50, 2.20, -2.10, 1.80, 0.60, -1.10, 1.50, 0.40, 1.80, 0.90, 2.60,
-0.70, -0.40, 1.80, -0.90, 1.30, 0.90, -0.30, 1.20, 0.20, -1.10, 2.50, -1.70, 2.00,
],
)
.expect("probe1 fixture shape")
}
fn simple_data() -> Array2<f64> {
Array2::from_shape_vec(
(10, 4),
vec![
1.0, 2.0, 1.5, 3.0, 1.1, 2.1, 1.6, 3.1, 0.9, 1.9, 1.4, 2.9, 2.0, 4.0, 3.0, 6.0, 2.1,
4.1, 3.1, 6.1, 1.9, 3.9, 2.9, 5.9, 0.5, 1.0, 0.7, 1.5, 0.4, 0.9, 0.6, 1.4, 0.6, 1.1,
0.8, 1.6, 1.5, 3.0, 2.2, 4.5,
],
)
.expect("simple_data fixture shape")
}
fn implied_covariance(w: &Array2<f64>, psi: &Array1<f64>) -> Array2<f64> {
let (p, k) = (w.nrows(), w.ncols());
let mut cov = Array2::<f64>::zeros((p, p));
for i in 0..p {
for j in 0..p {
let mut s = 0.0;
for c in 0..k {
s += w[[i, c]] * w[[j, c]];
}
cov[[i, j]] = s;
}
cov[[i, i]] += psi[i];
}
cov
}
#[test]
fn divergence_fa_rotation_invariant_covariance() {
#[allow(
clippy::excessive_precision,
reason = "live sklearn 1.5.2 oracle (R-CHAR-3)"
)]
let sk_cov_diag = [
1.364869529838_f64,
1.347659729737,
3.33640246802,
0.947929114262,
];
#[allow(
clippy::excessive_precision,
reason = "live sklearn 1.5.2 oracle (R-CHAR-3)"
)]
let sk_noise = [
0.009165047048_f64,
0.18929822983,
0.014331031175,
0.062762007729,
];
#[allow(
clippy::excessive_precision,
reason = "live sklearn 1.5.2 oracle (R-CHAR-3)"
)]
let sk_loglike: f64 = -27.644987554315705;
let x = probe1();
let fitted = FactorAnalysis::<f64>::new(2)
.with_random_state(42)
.fit(&x, &())
.expect("fit probe1");
let cov = implied_covariance(fitted.components(), fitted.noise_variance());
let psi = fitted.noise_variance();
for (i, &exp) in sk_cov_diag.iter().enumerate() {
let got = cov[[i, i]];
assert!(
(got - exp).abs() < 1e-2,
"covariance diag[{i}]: sklearn {exp}, ferrolearn {got} (diff {})",
(got - exp).abs()
);
}
for (i, &exp) in sk_noise.iter().enumerate() {
let got = psi[i];
assert!(
(got - exp).abs() < 1e-2,
"noise_variance[{i}]: sklearn {exp}, ferrolearn {got} (diff {})",
(got - exp).abs()
);
}
let got_ll = fitted.log_likelihood();
assert!(
(got_ll - sk_loglike).abs() < 1e-2,
"log_likelihood: sklearn {sk_loglike}, ferrolearn {got_ll} (diff {})",
(got_ll - sk_loglike).abs()
);
}
#[test]
fn divergence_fa_simple_data_loglike() {
#[allow(
clippy::excessive_precision,
reason = "live sklearn 1.5.2 oracle (R-CHAR-3)"
)]
let sk_loglike: f64 = 147.56994485222165;
#[allow(
clippy::excessive_precision,
reason = "live sklearn 1.5.2 oracle (R-CHAR-3)"
)]
let sk_cov_diag = [0.366_f64, 1.446, 0.8436, 3.246];
let x = simple_data();
let fitted = FactorAnalysis::<f64>::new(2)
.with_random_state(42)
.fit(&x, &())
.expect("fit simple_data");
let cov = implied_covariance(fitted.components(), fitted.noise_variance());
let got_ll = fitted.log_likelihood();
assert!(
(got_ll - sk_loglike).abs() < 1e-1,
"log_likelihood: sklearn {sk_loglike}, ferrolearn {got_ll} (diff {})",
(got_ll - sk_loglike).abs()
);
for (i, &exp) in sk_cov_diag.iter().enumerate() {
let got = cov[[i, i]];
assert!(
(got - exp).abs() < 1e-1,
"covariance diag[{i}]: sklearn {exp}, ferrolearn {got} (diff {})",
(got - exp).abs()
);
}
}
#[test]
fn carveout_fa_loadings_only_rotation_invariant() {
let x = probe1();
let fitted = FactorAnalysis::<f64>::new(2)
.with_random_state(42)
.fit(&x, &())
.expect("fit probe1");
let cov = implied_covariance(fitted.components(), fitted.noise_variance());
let p = cov.nrows();
for i in 0..p {
for j in 0..p {
assert!(
(cov[[i, j]] - cov[[j, i]]).abs() < 1e-12,
"implied covariance must be symmetric"
);
}
}
}
#[test]
fn green_components_shape_is_features_by_components() {
let x = probe1();
let fitted = FactorAnalysis::<f64>::new(2).fit(&x, &()).expect("fit");
assert_eq!(fitted.components().dim(), (4, 2));
}
#[test]
fn green_transform_scores_shape() {
let x = probe1();
let fitted = FactorAnalysis::<f64>::new(2).fit(&x, &()).expect("fit");
let scores = fitted.transform(&x).expect("transform");
assert_eq!(scores.dim(), (10, 2));
}
#[test]
fn green_noise_variance_strictly_positive() {
let x = probe1();
let fitted = FactorAnalysis::<f64>::new(2).fit(&x, &()).expect("fit");
for &v in fitted.noise_variance() {
assert!(v > 0.0, "noise variance must be strictly positive, got {v}");
}
}
#[test]
fn green_log_likelihood_finite_and_n_iter_in_range() {
let x = probe1();
let max_iter = 1000;
let fitted = FactorAnalysis::<f64>::new(2)
.with_max_iter(max_iter)
.fit(&x, &())
.expect("fit");
assert!(fitted.log_likelihood().is_finite());
assert!(fitted.n_iter() >= 1 && fitted.n_iter() <= max_iter);
}
#[test]
fn green_determinism_same_seed_identical() {
let x = probe1();
let f1 = FactorAnalysis::<f64>::new(2)
.with_random_state(42)
.fit(&x, &())
.expect("fit1");
let f2 = FactorAnalysis::<f64>::new(2)
.with_random_state(42)
.fit(&x, &())
.expect("fit2");
for (a, b) in f1.components().iter().zip(f2.components().iter()) {
assert!(
(a - b).abs() < 1e-15,
"components must be identical for same seed"
);
}
for (a, b) in f1.noise_variance().iter().zip(f2.noise_variance().iter()) {
assert!(
(a - b).abs() < 1e-15,
"noise_variance must be identical for same seed"
);
}
}
#[test]
fn green_error_zero_components() {
let x = probe1();
assert!(FactorAnalysis::<f64>::new(0).fit(&x, &()).is_err());
}
#[test]
fn green_error_too_many_components() {
let x = probe1();
assert!(FactorAnalysis::<f64>::new(5).fit(&x, &()).is_err());
}
#[test]
fn green_error_insufficient_samples() {
let x = Array2::<f64>::from_shape_vec((1, 4), vec![1.0, 2.0, 3.0, 4.0]).expect("shape");
assert!(FactorAnalysis::<f64>::new(1).fit(&x, &()).is_err());
}
#[test]
fn green_error_transform_col_mismatch() {
let x = probe1();
let fitted = FactorAnalysis::<f64>::new(2).fit(&x, &()).expect("fit");
let bad = Array2::<f64>::zeros((3, 7));
assert!(fitted.transform(&bad).is_err());
}
#[test]
fn green_inverse_transform_shape_and_col_mismatch() {
let x = probe1();
let fitted = FactorAnalysis::<f64>::new(2).fit(&x, &()).expect("fit");
let z = Array2::<f64>::zeros((5, 2));
let recon = fitted.inverse_transform(&z).expect("inverse_transform");
assert_eq!(recon.dim(), (5, 4));
let z_bad = Array2::<f64>::zeros((5, 3));
assert!(fitted.inverse_transform(&z_bad).is_err());
}
#[test]
fn divergence_fa_default_tol_n_iter_and_loglike() {
let sk_n_iter: usize = 15;
#[allow(
clippy::excessive_precision,
reason = "live sklearn 1.5.2 oracle (R-CHAR-3)"
)]
let sk_loglike: f64 = -27.644987554315705;
let x = probe1();
let fitted = FactorAnalysis::<f64>::new(2)
.fit(&x, &())
.expect("fit probe1 default");
assert_eq!(
fitted.n_iter(),
sk_n_iter,
"n_iter_: sklearn default (tol=1e-2) {sk_n_iter}, ferrolearn default {}",
fitted.n_iter()
);
let got_ll = fitted.log_likelihood();
assert!(
(got_ll - sk_loglike).abs() < 1e-6,
"final loglike: sklearn default (tol=1e-2) {sk_loglike}, ferrolearn default {got_ll} (diff {})",
(got_ll - sk_loglike).abs()
);
}
#[test]
fn green_fa_matched_tol_matches_sklearn() {
let sk_n_iter: usize = 15;
#[allow(
clippy::excessive_precision,
reason = "live sklearn 1.5.2 oracle (R-CHAR-3)"
)]
let sk_loglike: f64 = -27.644987554315705;
#[allow(
clippy::excessive_precision,
reason = "live sklearn 1.5.2 oracle (R-CHAR-3)"
)]
let sk_noise = [
0.009165047048366848_f64,
0.1892982298298851,
0.014331031175260911,
0.06276200772936313,
];
let x = probe1();
let fitted = FactorAnalysis::<f64>::new(2)
.with_tol(1e-2)
.fit(&x, &())
.expect("fit probe1 tol=1e-2");
assert_eq!(fitted.n_iter(), sk_n_iter);
assert!((fitted.log_likelihood() - sk_loglike).abs() < 1e-6);
for (i, &exp) in sk_noise.iter().enumerate() {
assert!(
(fitted.noise_variance()[i] - exp).abs() < 1e-6,
"noise_variance[{i}]: sklearn {exp}, ferrolearn {}",
fitted.noise_variance()[i]
);
}
}