use ferrolearn_core::traits::{Fit, FitTransform, Transform};
use ferrolearn_preprocess::iterative_imputer::{
FittedIterativeImputer, InitialStrategy, IterativeImputer,
};
use ndarray::{Array1, Array2, array};
const SK_MEAN_FILL_DIV1: f64 = 13.0 / 3.0;
fn fit_div1_fixture(
imputer: &IterativeImputer<f64>,
x: &Array2<f64>,
) -> Result<FittedIterativeImputer<f64>, ferrolearn_core::error::FerroError> {
imputer.fit(x, &())
}
#[test]
fn divergence_max_iter_zero_returns_initial_fill() {
let imputer = IterativeImputer::<f64>::new(0, 1e-3, InitialStrategy::Mean);
let x = array![[1.0, 2.0], [f64::NAN, 3.0], [5.0, f64::NAN], [7.0, 8.0]];
let fitted = match fit_div1_fixture(&imputer, &x) {
Ok(f) => f,
Err(e) => {
panic!(
"DIV-1: ferrolearn rejected max_iter=0 with {e:?}; sklearn \
(_iterative.py:750-752) accepts it and returns the initial fill"
)
}
};
assert_eq!(
fitted.n_iter(),
0,
"DIV-1: sklearn sets n_iter_=0 for max_iter=0 (_iterative.py:751)"
);
let out = match fitted.transform(&x) {
Ok(o) => o,
Err(e) => panic!("DIV-1: transform after max_iter=0 fit failed: {e:?}"),
};
let expected = array![
[1.0, 2.0],
[SK_MEAN_FILL_DIV1, 3.0],
[5.0, SK_MEAN_FILL_DIV1],
[7.0, 8.0]
];
for (got, want) in out.iter().zip(expected.iter()) {
assert!(
(got - want).abs() < 1e-9,
"DIV-1: max_iter=0 output {got} != sklearn initial fill {want}"
);
}
}
#[test]
fn green_initial_fill_mean_matches_simpleimputer() {
let imputer = IterativeImputer::<f64>::new(10, 1e-3, InitialStrategy::Mean);
let x = array![[1.0, 2.0], [3.0, f64::NAN], [f64::NAN, 6.0]];
let fitted = imputer.fit(&x, &()).expect("fit should succeed");
let fill = fitted.initial_fill();
let sk_mean: Array1<f64> = array![2.0, 4.0]; assert_eq!(fill.len(), 2);
for (got, want) in fill.iter().zip(sk_mean.iter()) {
assert!(
(got - want).abs() < 1e-12,
"Mean initial fill {got} != SimpleImputer mean {want}"
);
}
}
#[test]
fn green_initial_fill_median_matches_simpleimputer() {
let imputer = IterativeImputer::<f64>::new(10, 1e-3, InitialStrategy::Median);
let x = array![[1.0, 2.0], [3.0, f64::NAN], [f64::NAN, 6.0]];
let fitted = imputer.fit(&x, &()).expect("fit should succeed");
let fill = fitted.initial_fill();
let sk_median: Array1<f64> = array![2.0, 4.0]; assert_eq!(fill.len(), 2);
for (got, want) in fill.iter().zip(sk_median.iter()) {
assert!(
(got - want).abs() < 1e-12,
"Median initial fill {got} != SimpleImputer median {want}"
);
}
}
#[test]
fn green_non_missing_preserved() {
let imputer = IterativeImputer::<f64>::new(10, 1e-3, InitialStrategy::Mean);
let x = array![[1.0, 2.0], [3.0, f64::NAN], [f64::NAN, 6.0], [5.0, 8.0]];
let out = imputer
.fit_transform(&x)
.expect("fit_transform should succeed");
for ((i, j), &v) in x.indexed_iter() {
if !v.is_nan() {
assert!(
(out[[i, j]] - v).abs() < 1e-12,
"observed entry [{i},{j}] changed: input {v} -> output {}",
out[[i, j]]
);
}
}
}
#[test]
fn green_output_shape_and_no_nan() {
let imputer = IterativeImputer::<f64>::new(10, 1e-3, InitialStrategy::Mean);
let x = array![[1.0, 2.0], [3.0, f64::NAN], [f64::NAN, 6.0]];
let out = imputer
.fit_transform(&x)
.expect("fit_transform should succeed");
assert_eq!(
out.dim(),
(3, 2),
"output shape must be (n_samples, n_features)"
);
for v in &out {
assert!(!v.is_nan(), "output must contain no NaN");
}
}
#[test]
fn green_determinism() {
let imputer = IterativeImputer::<f64>::new(10, 1e-3, InitialStrategy::Mean);
let x = array![[1.0, 2.0], [3.0, f64::NAN], [f64::NAN, 6.0], [5.0, 8.0]];
let out1 = imputer.fit_transform(&x).expect("run 1");
let out2 = imputer.fit_transform(&x).expect("run 2");
for (a, b) in out1.iter().zip(out2.iter()) {
assert_eq!(
a.to_bits(),
b.to_bits(),
"non-deterministic output: {a} vs {b}"
);
}
}
#[test]
fn green_termination_bounded() {
let max_iter = 10usize;
let imputer = IterativeImputer::<f64>::new(max_iter, 1e-3, InitialStrategy::Mean);
let x = array![[1.0, 2.0], [3.0, f64::NAN], [f64::NAN, 6.0]];
let fitted = imputer.fit(&x, &()).expect("fit should succeed");
let n = fitted.n_iter();
assert!(
n >= 1,
"n_iter() = {n} must be >= 1 for max_iter >= 1 with missing values"
);
assert!(
n <= max_iter,
"n_iter() = {n} must be <= max_iter = {max_iter}"
);
}
#[test]
fn green_error_zero_rows() {
let imputer = IterativeImputer::<f64>::new(10, 1e-3, InitialStrategy::Mean);
let x: Array2<f64> = Array2::zeros((0, 3));
assert!(imputer.fit(&x, &()).is_err(), "zero rows must error");
}
#[test]
fn green_error_transform_ncols_mismatch() {
let imputer = IterativeImputer::<f64>::new(10, 1e-3, InitialStrategy::Mean);
let x_train = array![[1.0, 2.0], [3.0, 4.0]];
let fitted = imputer.fit(&x_train, &()).expect("fit should succeed");
let x_bad = array![[1.0, 2.0, 3.0]];
assert!(
fitted.transform(&x_bad).is_err(),
"ncols mismatch must error"
);
}
#[test]
fn green_error_unfitted_transform() {
let imputer = IterativeImputer::<f64>::new(10, 1e-3, InitialStrategy::Mean);
let x = array![[1.0, 2.0]];
assert!(
imputer.transform(&x).is_err(),
"unfitted transform must error"
);
}
#[test]
fn green_f32_finite_output() {
let imputer = IterativeImputer::<f32>::new(10, 1e-3, InitialStrategy::Mean);
let x: Array2<f32> = array![[1.0f32, 2.0], [3.0, f32::NAN], [f32::NAN, 6.0]];
let out = imputer
.fit_transform(&x)
.expect("f32 fit_transform should succeed");
for v in &out {
assert!(v.is_finite(), "f32 output must be finite, got {v}");
}
}
#[test]
fn green_single_feature_imputes_to_column_mean() {
let imputer = IterativeImputer::<f64>::new(10, 1e-3, InitialStrategy::Mean);
let x = array![[1.0], [f64::NAN], [5.0], [7.0]];
let out = imputer
.fit_transform(&x)
.expect("single-feature fit_transform");
let expected = array![[1.0], [SK_MEAN_FILL_DIV1], [5.0], [7.0]];
for (got, want) in out.iter().zip(expected.iter()) {
assert!(
(got - want).abs() < 1e-9,
"single-feature output {got} != column mean fill {want}"
);
}
}