use ndarray::{Array2, array, s};
use rustyml::error::Error;
use rustyml::machine_learning::{Contamination, IsolationForest};
#[test]
fn test_new_rejects_invalid_scalar_arguments() {
type Case = (&'static str, fn() -> Error);
let cases: [Case; 3] = [
("n_estimators", || IsolationForest::new(0, 256).unwrap_err()),
("max_samples", || IsolationForest::new(10, 0).unwrap_err()),
("max_depth", || {
IsolationForest::new(10, 256)
.unwrap()
.with_max_depth(0)
.unwrap_err()
}),
];
for (expected_name, build) in cases {
let err = build();
assert!(
matches!(err, Error::InvalidParameter { ref name, .. } if name == expected_name),
"expected InvalidParameter for {expected_name}, got: {err:?}"
);
}
}
#[test]
fn test_new_valid_explicit_max_depth_succeeds() {
let model = IsolationForest::new(20, 64)
.unwrap()
.with_max_depth(5)
.unwrap()
.with_random_state(99);
assert_eq!(model.get_n_estimators(), 20);
assert_eq!(model.get_max_samples(), 64);
assert_eq!(model.get_max_depth(), 5);
assert_eq!(model.get_random_state(), Some(99));
assert_eq!(model.get_n_features(), 0);
assert!(model.get_trees().is_none());
}
#[test]
fn test_new_auto_max_depth_ceil_log2_max_samples() {
let model = IsolationForest::new(10, 256).unwrap().with_random_state(0);
assert_eq!(model.get_max_depth(), 8);
}
#[test]
fn test_new_auto_max_depth_ceil_log2_non_power_of_two() {
let model = IsolationForest::new(10, 100).unwrap().with_random_state(0);
assert_eq!(model.get_max_depth(), 7);
}
#[test]
fn test_new_auto_max_depth_ceil_log2_two() {
let model = IsolationForest::new(10, 2).unwrap().with_random_state(0);
assert_eq!(model.get_max_depth(), 1);
}
#[test]
fn test_new_auto_max_depth_ceil_log2_one() {
let model = IsolationForest::new(10, 1).unwrap().with_random_state(0);
assert_eq!(model.get_max_depth(), 0);
}
#[test]
fn test_default_has_expected_parameter_values() {
let model = IsolationForest::default();
assert_eq!(model.get_n_estimators(), 100);
assert_eq!(model.get_max_samples(), 256);
assert_eq!(model.get_max_depth(), 8); assert_eq!(model.get_random_state(), None);
assert_eq!(model.get_n_features(), 0);
assert!(model.get_trees().is_none());
}
#[test]
fn test_predict_before_fit_returns_not_fitted() {
let model = IsolationForest::new(10, 50).unwrap().with_random_state(1);
let x = array![[1.0, 2.0], [3.0, 4.0]];
let err = model.predict(&x).unwrap_err();
assert!(
matches!(err, Error::NotFitted("IsolationForest")),
"expected NotFitted, got: {err:?}"
);
}
#[test]
fn test_score_sample_before_fit_returns_not_fitted() {
let model = IsolationForest::new(10, 50).unwrap().with_random_state(1);
let err = model.score_sample(&[1.0, 2.0]).unwrap_err();
assert!(
matches!(err, Error::NotFitted("IsolationForest")),
"expected NotFitted, got: {err:?}"
);
}
#[test]
fn test_fit_empty_data_returns_empty_input() {
let mut model = IsolationForest::new(10, 50).unwrap().with_random_state(1);
let x: Array2<f64> = Array2::zeros((0, 2));
let err = model.fit(&x).unwrap_err();
assert!(
matches!(err, Error::EmptyInput(_)),
"expected EmptyInput, got: {err:?}"
);
}
#[test]
fn test_fit_non_finite_returns_non_finite() {
for sentinel in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let mut model = IsolationForest::new(10, 50).unwrap().with_random_state(1);
let x = array![[1.0, sentinel], [2.0, 3.0]];
let err = model.fit(&x).unwrap_err();
assert!(
matches!(err, Error::NonFinite(_)),
"expected NonFinite for sentinel={sentinel:?}, got: {err:?}"
);
}
}
#[test]
fn test_predict_empty_data_returns_empty_input() {
let mut model = IsolationForest::new(10, 50).unwrap().with_random_state(1);
let train = array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]];
model.fit(&train).unwrap();
let x_empty: Array2<f64> = Array2::zeros((0, 2));
let err = model.predict(&x_empty).unwrap_err();
assert!(
matches!(err, Error::EmptyInput(_)),
"expected EmptyInput, got: {err:?}"
);
}
#[test]
fn test_predict_wrong_feature_count_returns_dimension_mismatch() {
let mut model = IsolationForest::new(10, 50).unwrap().with_random_state(1);
let train = array![[1.0, 2.0], [3.0, 4.0]];
model.fit(&train).unwrap();
let x_wrong = array![[1.0, 2.0, 3.0]];
let err = model.predict(&x_wrong).unwrap_err();
assert!(
matches!(
err,
Error::DimensionMismatch {
expected: 2,
found: 3
}
),
"expected DimensionMismatch{{expected:2, found:3}}, got: {err:?}"
);
}
#[test]
fn test_predict_nan_returns_non_finite() {
let mut model = IsolationForest::new(10, 50).unwrap().with_random_state(1);
let train = array![[1.0, 2.0], [3.0, 4.0]];
model.fit(&train).unwrap();
let x_nan = array![[f64::NAN, 2.0]];
let err = model.predict(&x_nan).unwrap_err();
assert!(
matches!(err, Error::NonFinite(_)),
"expected NonFinite, got: {err:?}"
);
}
#[test]
fn test_score_sample_wrong_dim_returns_dimension_mismatch() {
let mut model = IsolationForest::new(10, 50).unwrap().with_random_state(1);
let train = array![[1.0, 2.0], [3.0, 4.0]];
model.fit(&train).unwrap();
let err = model.score_sample(&[1.0, 2.0, 3.0]).unwrap_err();
assert!(
matches!(
err,
Error::DimensionMismatch {
expected: 2,
found: 3
}
),
"expected DimensionMismatch{{expected:2, found:3}}, got: {err:?}"
);
}
#[test]
fn test_fit_sets_n_features() {
let mut model = IsolationForest::new(10, 50).unwrap().with_random_state(42);
let train = array![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
model.fit(&train).unwrap();
assert_eq!(model.get_n_features(), 3);
}
#[test]
fn test_fit_stores_exactly_n_estimators_trees() {
let n_estimators = 15_usize;
let mut model = IsolationForest::new(n_estimators, 50)
.unwrap()
.with_random_state(42);
let train = array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0], [2.0, 3.0], [4.0, 5.0]];
model.fit(&train).unwrap();
let trees = model.get_trees().expect("trees should be Some after fit");
assert_eq!(
trees.len(),
n_estimators,
"expected exactly {n_estimators} trees, got {}",
trees.len()
);
}
#[test]
fn test_scores_are_in_negative_unit_interval() {
let mut model = IsolationForest::new(50, 64).unwrap().with_random_state(7);
let train = array![
[0.0, 0.0],
[0.1, 0.0],
[0.0, 0.1],
[0.1, 0.1],
[0.2, 0.2],
[100.0, 100.0]
];
model.fit(&train).unwrap();
let scores = model.score_samples(&train).unwrap();
for (i, &s) in scores.iter().enumerate() {
assert!(
(-1.0..0.0).contains(&s),
"score[{i}] = {s} is outside [0, 1]"
);
}
}
#[test]
fn test_score_sample_is_in_negative_unit_interval() {
let mut model = IsolationForest::new(50, 64).unwrap().with_random_state(7);
let train = array![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [50.0, 50.0]];
model.fit(&train).unwrap();
let s_inlier = model.score_sample(&[0.0, 0.0]).unwrap();
let s_outlier = model.score_sample(&[50.0, 50.0]).unwrap();
assert!(
(-1.0..0.0).contains(&s_inlier),
"inlier score {s_inlier} not in [0,1]"
);
assert!(
(-1.0..0.0).contains(&s_outlier),
"outlier score {s_outlier} not in [0,1]"
);
}
#[test]
fn test_outlier_score_exceeds_all_inlier_scores() {
let inliers = array![
[0.0, 0.0],
[0.1, 0.0],
[-0.1, 0.0],
[0.0, 0.1],
[0.0, -0.1],
[0.1, 0.1],
[-0.1, 0.1],
[0.1, -0.1],
[-0.1, -0.1],
[0.2, 0.2]
];
let outlier_row = array![[1000.0, 1000.0]];
let mut train_data: Array2<f64> = Array2::zeros((11, 2));
train_data.slice_mut(s![..10, ..]).assign(&inliers);
train_data.slice_mut(s![10..11, ..]).assign(&outlier_row);
let mut model = IsolationForest::new(100, 64).unwrap().with_random_state(42);
model.fit(&train_data).unwrap();
let scores = model.score_samples(&train_data).unwrap();
let outlier_score = scores[10];
let min_inlier_score = scores
.slice(s![..10])
.fold(f64::INFINITY, |acc, &v| acc.min(v));
assert!(
outlier_score < min_inlier_score,
"outlier score {outlier_score:.4} should fall below all inlier scores (min inlier: {min_inlier_score:.4})"
);
}
#[test]
fn test_outlier_score_sample_is_below_inlier_via_single_sample_api() {
let mut train_data: Array2<f64> = Array2::zeros((11, 2));
let inlier_coords: &[(f64, f64)] = &[
(0.0, 0.0),
(0.1, 0.0),
(-0.1, 0.0),
(0.0, 0.1),
(0.0, -0.1),
(0.1, 0.1),
(-0.1, 0.1),
(0.1, -0.1),
(-0.1, -0.1),
(0.2, 0.2),
];
for (i, &(x, y)) in inlier_coords.iter().enumerate() {
train_data[[i, 0]] = x;
train_data[[i, 1]] = y;
}
train_data[[10, 0]] = 1000.0;
train_data[[10, 1]] = 1000.0;
let mut model = IsolationForest::new(100, 64).unwrap().with_random_state(42);
model.fit(&train_data).unwrap();
let outlier_score = model.score_sample(&[1000.0, 1000.0]).unwrap();
let min_inlier_score = inlier_coords
.iter()
.map(|&(x, y)| model.score_sample(&[x, y]).unwrap())
.fold(f64::INFINITY, f64::min);
assert!(
outlier_score < min_inlier_score,
"outlier score {outlier_score:.4} should fall below min inlier score {min_inlier_score:.4}"
);
}
#[test]
fn test_identical_points_have_equal_scores() {
let data = array![
[1.0, 2.0],
[3.0, 4.0],
[1.0, 2.0], [5.0, 6.0]
];
let mut model = IsolationForest::new(50, 32).unwrap().with_random_state(55);
model.fit(&data).unwrap();
let scores = model.score_samples(&data).unwrap();
assert_eq!(
scores[0], scores[2],
"identical inputs must produce identical anomaly scores: {} vs {}",
scores[0], scores[2]
);
}
#[test]
fn test_same_seed_produces_identical_scores() {
let data = array![[0.0, 0.0], [0.5, 0.5], [1.0, 1.0], [2.0, 2.0], [50.0, 50.0]];
let mut model_a = IsolationForest::new(30, 20).unwrap().with_random_state(13);
model_a.fit(&data).unwrap();
let scores_a = model_a.score_samples(&data).unwrap();
let mut model_b = IsolationForest::new(30, 20).unwrap().with_random_state(13);
model_b.fit(&data).unwrap();
let scores_b = model_b.score_samples(&data).unwrap();
assert_eq!(
scores_a, scores_b,
"two models with the same seed must produce identical scores"
);
}
#[test]
fn test_different_seeds_may_produce_different_scores() {
let data = array![[0.0, 0.0], [0.5, 0.5], [1.0, 1.0], [2.0, 2.0], [50.0, 50.0]];
let mut model_a = IsolationForest::new(50, 32).unwrap().with_random_state(1);
model_a.fit(&data).unwrap();
let scores_a = model_a.score_samples(&data).unwrap();
let mut model_b = IsolationForest::new(50, 32).unwrap().with_random_state(2);
model_b.fit(&data).unwrap();
let scores_b = model_b.score_samples(&data).unwrap();
let any_differ = scores_a
.iter()
.zip(scores_b.iter())
.any(|(a, b)| (a - b).abs() > 1e-12);
assert!(
any_differ,
"different seeds should produce different scores (got identical scores for seeds 1 and 2)"
);
}
#[test]
fn test_fit_predict_matches_fit_then_predict() {
let data = array![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [10.0, 10.0]];
let mut model_a = IsolationForest::new(40, 32).unwrap().with_random_state(77);
model_a.fit(&data).unwrap();
let labels_a = model_a.predict(&data).unwrap();
let mut model_b = IsolationForest::new(40, 32).unwrap().with_random_state(77);
let labels_b = model_b.fit_predict(&data).unwrap();
assert_eq!(
labels_a, labels_b,
"fit_predict must produce the same labels as fit + predict with the same seed"
);
}
#[test]
fn test_fit_and_predict_on_single_sample() {
let mut model = IsolationForest::new(5, 10).unwrap().with_random_state(1);
let data = array![[3.0, 4.0]];
model.fit(&data).unwrap();
let scores = model.score_samples(&data).unwrap();
assert_eq!(scores.len(), 1);
assert!(
(scores[0] + 1.0).abs() < 1e-12,
"single-sample score should be -1.0, got {}",
scores[0]
);
}
#[test]
fn test_n_features_reflects_training_data_columns() {
let mut model = IsolationForest::new(10, 20).unwrap().with_random_state(1);
assert_eq!(model.get_n_features(), 0);
let data = array![[1.0, 2.0, 3.0, 4.0, 5.0]];
model.fit(&data).unwrap();
assert_eq!(model.get_n_features(), 5);
}
#[test]
fn test_fit_with_fewer_rows_than_max_samples_succeeds() {
let mut model = IsolationForest::new(10, 256).unwrap().with_random_state(1);
let data = array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0], [7.0, 8.0], [9.0, 10.0]];
model.fit(&data).unwrap();
let scores = model.score_samples(&data).unwrap();
assert_eq!(scores.len(), 5);
for &s in scores.iter() {
assert!((-1.0..0.0).contains(&s), "score {s} not in [0,1]");
}
}
#[test]
fn test_fit_with_constant_feature_column_does_not_panic() {
let mut model = IsolationForest::new(10, 32).unwrap().with_random_state(42);
let data = array![[1.0, 5.0], [2.0, 5.0], [3.0, 5.0], [4.0, 5.0], [5.0, 5.0]];
model.fit(&data).unwrap();
let scores = model.score_samples(&data).unwrap();
assert_eq!(scores.len(), 5);
}
#[test]
fn test_fit_and_predict_with_single_feature() {
let mut model = IsolationForest::new(20, 32).unwrap().with_random_state(42);
let data = array![[0.0], [0.1], [-0.1], [0.2], [999.0]];
model.fit(&data).unwrap();
assert_eq!(model.get_n_features(), 1);
let scores = model.score_samples(&data).unwrap();
assert_eq!(scores.len(), 5);
let outlier_score = scores[4];
let min_inlier = scores.slice(s![..4]).fold(f64::INFINITY, |a, &v| a.min(v));
assert!(
outlier_score < min_inlier,
"1-D outlier score {outlier_score:.4} should fall below inlier scores (min={min_inlier:.4})"
);
}
#[test]
fn test_fit_and_predict_high_dimensional() {
let mut model = IsolationForest::new(50, 32).unwrap().with_random_state(42);
let mut data: Array2<f64> = Array2::zeros((7, 5));
for i in 0..6 {
for j in 0..5 {
data[[i, j]] = 0.1 * i as f64;
}
}
for j in 0..5 {
data[[6, j]] = 100.0;
}
model.fit(&data).unwrap();
let scores = model.score_samples(&data).unwrap();
for &s in scores.iter() {
assert!((-1.0..0.0).contains(&s));
}
let outlier_score = scores[6];
let min_inlier = scores.slice(s![..6]).fold(f64::INFINITY, |a, &v| a.min(v));
assert!(
outlier_score < min_inlier,
"high-dim outlier {outlier_score:.4} must fall below inlier min {min_inlier:.4}"
);
}
#[test]
fn test_save_load_roundtrip_yields_identical_predictions() {
let data = array![[0.0, 0.0], [0.5, 0.5], [1.0, 0.0], [0.0, 1.0], [50.0, 50.0]];
let mut model = IsolationForest::new(30, 20).unwrap().with_random_state(99);
model.fit(&data).unwrap();
let scores_before = model.predict(&data).unwrap();
let path = "/tmp/rustyml_isolation_forest_test.bin";
model.save_to_path(path).unwrap();
let loaded = IsolationForest::load_from_path(path).unwrap();
let scores_after = loaded.predict(&data).unwrap();
assert_eq!(
scores_before, scores_after,
"predictions must be identical before and after save/load"
);
assert_eq!(loaded.get_n_estimators(), model.get_n_estimators());
assert_eq!(loaded.get_max_samples(), model.get_max_samples());
assert_eq!(loaded.get_max_depth(), model.get_max_depth());
assert_eq!(loaded.get_n_features(), model.get_n_features());
let _ = std::fs::remove_file(path);
}
#[test]
fn test_load_from_nonexistent_path_returns_io_error() {
let err =
IsolationForest::load_from_path("/tmp/this_file_does_not_exist_rustyml.bin").unwrap_err();
assert!(
matches!(err, Error::Io(_)),
"expected Io error when loading from missing file, got: {err:?}"
);
}
#[test]
fn test_identical_points_score_equals_one_half_when_sample_size_equals_max_samples() {
let data = array![[2.0, 7.0], [2.0, 7.0], [2.0, 7.0], [2.0, 7.0]];
let mut model = IsolationForest::new(20, 4).unwrap().with_random_state(123);
model.fit(&data).unwrap();
let scores = model.score_samples(&data).unwrap();
for (i, &s) in scores.iter().enumerate() {
assert!(
(s + 0.5).abs() < 1e-12,
"row {i}: expected exactly -0.5, got {s}"
);
}
}
#[test]
fn test_identical_points_score_matches_closed_form_when_sample_size_below_max_samples() {
let data = array![[1.0, -3.0], [1.0, -3.0], [1.0, -3.0], [1.0, -3.0]];
let mut model = IsolationForest::new(25, 8).unwrap().with_random_state(7);
model.fit(&data).unwrap();
let scores = model.score_samples(&data).unwrap();
for (i, &s) in scores.iter().enumerate() {
assert!(
(s + 0.5).abs() < 1e-12,
"row {i}: expected -0.5 (normalization uses c(sample_size), not c(max_samples)), got {s}"
);
}
}
#[test]
fn samples_exactly_on_the_cutoff_are_inliers() {
let data = array![[2.0, 7.0], [2.0, 7.0], [2.0, 7.0], [2.0, 7.0]];
let mut model = IsolationForest::new(20, 4).unwrap().with_random_state(123);
model.fit(&data).unwrap();
let decision = model.decision_function(&data).unwrap();
for (i, &d) in decision.iter().enumerate() {
assert!(d.abs() < 1e-12, "row {i}: decision should be 0.0, got {d}");
}
let labels = model.predict(&data).unwrap();
for (i, &l) in labels.iter().enumerate() {
assert_eq!(l, 1, "row {i}: a sample on the cutoff is an inlier");
}
}
#[test]
fn predict_handles_non_contiguous_input() {
let train = array![[0.0, 0.0], [0.1, 0.1], [0.2, -0.1], [5.0, 5.0], [-4.0, 4.0]];
let mut model = IsolationForest::new(20, 8).unwrap().with_random_state(1);
model.fit(&train).unwrap();
let ft = array![[0.0, 0.1, 5.0], [0.0, 0.1, 5.0]]; let x = ft.t(); assert!(
x.row(0).as_slice().is_none(),
"test setup: transposed rows must be non-contiguous to exercise the bug"
);
let result = model.predict(&x);
assert!(
result.is_ok(),
"predict must handle non-contiguous input without panicking, got {result:?}"
);
assert_eq!(result.unwrap().len(), 3, "one score per input row");
}
#[test]
fn predict_flags_obvious_outlier() {
let x = array![
[0.0, 0.0],
[0.1, 0.1],
[0.2, 0.0],
[0.0, 0.2],
[0.1, 0.2],
[0.2, 0.1],
[0.05, 0.15],
[0.15, 0.05],
[0.1, 0.1],
[10.0, 10.0] ];
let mut model = IsolationForest::new(100, 256)
.unwrap()
.with_contamination(Contamination::Fraction(0.1))
.unwrap()
.with_random_state(42);
model.fit(&x).unwrap();
let labels = model.predict(&x).unwrap();
let n_out = labels.iter().filter(|&&l| l == -1).count();
assert_eq!(
n_out, 1,
"contamination 0.1 on 10 samples flags exactly 1 outlier"
);
assert_eq!(labels[9], -1, "the far point must be the flagged outlier");
for i in 0..9 {
assert_eq!(labels[i], 1, "inlier {i} must be labelled +1");
}
}
#[test]
fn predict_count_matches_contamination() {
let mut flat = Vec::new();
for i in 0..20 {
let v = i as f64;
flat.push(v.sin());
flat.push(v.cos() * 3.0);
}
let x = Array2::from_shape_vec((20, 2), flat).unwrap();
let mut model = IsolationForest::new(100, 256)
.unwrap()
.with_contamination(Contamination::Fraction(0.25))
.unwrap()
.with_random_state(7);
model.fit(&x).unwrap();
let labels = model.predict(&x).unwrap();
let n_out = labels.iter().filter(|&&l| l == -1).count();
assert_eq!(
n_out, 5,
"ceil(0.25 * 20) = 5 outliers expected, got {n_out}"
);
for &l in labels.iter() {
assert!(l == -1 || l == 1, "label {l} not in {{-1, +1}}");
}
}
#[test]
fn with_contamination_rejects_invalid_fraction() {
for bad in [0.0, -0.1, 0.51, 1.0, f64::NAN, f64::INFINITY] {
let result = IsolationForest::new(10, 8)
.unwrap()
.with_contamination(Contamination::Fraction(bad));
assert!(
matches!(result, Err(Error::InvalidParameter { .. })),
"contamination={bad} must be rejected"
);
}
assert!(
IsolationForest::new(10, 8)
.unwrap()
.with_contamination(Contamination::Fraction(0.25))
.is_ok()
);
}
#[test]
fn predict_label_is_independent_of_batching() {
let x = array![
[0.0, 0.0],
[0.1, 0.1],
[0.2, 0.0],
[0.0, 0.2],
[0.1, 0.2],
[0.2, 0.1],
[0.05, 0.15],
[0.15, 0.05],
[0.1, 0.1],
[10.0, 10.0]
];
let mut model = IsolationForest::new(100, 256)
.unwrap()
.with_contamination(Contamination::Fraction(0.1))
.unwrap()
.with_random_state(42);
model.fit(&x).unwrap();
let whole = model.predict(&x).unwrap();
for i in 0..x.nrows() {
let row = x.slice(s![i..i + 1, ..]).to_owned();
let single = model.predict(&row).unwrap();
assert_eq!(
single[0], whole[i],
"sample {i} labelled {} alone but {} in the full batch",
single[0], whole[i]
);
}
let first = model.predict(&x.slice(s![..4, ..]).to_owned()).unwrap();
let second = model.predict(&x.slice(s![4.., ..]).to_owned()).unwrap();
let halves: Vec<i32> = first.iter().chain(second.iter()).copied().collect();
assert_eq!(
halves,
whole.to_vec(),
"splitting the batch must not change any label"
);
}
#[test]
fn predict_on_single_sample_is_not_forced_to_outlier() {
let x = array![
[0.0, 0.0],
[0.1, 0.1],
[0.2, 0.0],
[0.0, 0.2],
[0.1, 0.2],
[0.2, 0.1],
[0.05, 0.15],
[0.15, 0.05],
[0.1, 0.1],
[10.0, 10.0]
];
let mut model = IsolationForest::new(100, 256)
.unwrap()
.with_contamination(Contamination::Fraction(0.1))
.unwrap()
.with_random_state(42);
model.fit(&x).unwrap();
let inlier = array![[0.1, 0.1]];
assert_eq!(
model.predict(&inlier).unwrap()[0],
1,
"a sample sitting in the middle of the training cluster must score as an inlier"
);
let outlier = array![[10.0, 10.0]];
assert_eq!(
model.predict(&outlier).unwrap()[0],
-1,
"the far point must still be an outlier on its own"
);
}
#[test]
fn contamination_auto_uses_paper_threshold() {
let x = array![[0.0, 0.0], [0.1, 0.1], [0.2, 0.0], [0.0, 0.2], [10.0, 10.0]];
let mut model = IsolationForest::new(50, 32).unwrap().with_random_state(3);
assert_eq!(model.get_contamination(), Contamination::Auto);
assert!(model.get_offset().is_none(), "offset is unset before fit");
model.fit(&x).unwrap();
let offset = model.get_offset().expect("offset is set by fit");
assert!(
(offset + 0.5).abs() < 1e-12,
"Auto must resolve to the -0.5 cutoff, got {offset}"
);
let scores = model.score_samples(&x).unwrap();
let decision = model.decision_function(&x).unwrap();
let labels = model.predict(&x).unwrap();
for (i, (&s, (&d, &l))) in scores
.iter()
.zip(decision.iter().zip(labels.iter()))
.enumerate()
{
assert!(
(d - (s - offset)).abs() < 1e-12,
"sample {i}: decision {d} should be score {s} minus offset {offset}"
);
let expected = if d < 0.0 { -1 } else { 1 };
assert_eq!(l, expected, "sample {i}: decision {d}");
}
}
#[test]
fn contamination_fraction_offset_is_the_training_score_percentile() {
let x = array![
[0.0, 0.0],
[0.1, 0.1],
[0.2, 0.0],
[0.0, 0.2],
[0.1, 0.2],
[0.2, 0.1],
[0.05, 0.15],
[0.15, 0.05],
[0.1, 0.1],
[10.0, 10.0]
];
let mut model = IsolationForest::new(100, 256)
.unwrap()
.with_contamination(Contamination::Fraction(0.2))
.unwrap()
.with_random_state(11);
model.fit(&x).unwrap();
let offset = model.get_offset().expect("offset is set by fit");
let mut sorted = model.score_samples(&x).unwrap().to_vec();
sorted.sort_unstable_by(f64::total_cmp);
let expected = sorted[1] + 0.8 * (sorted[2] - sorted[1]);
assert!(
(offset - expected).abs() < 1e-12,
"offset {offset} should be the interpolated 20th percentile {expected}"
);
assert!(
offset > sorted[1] && offset < sorted[2],
"offset {offset} should sit strictly between {} and {}",
sorted[1],
sorted[2]
);
}
#[test]
fn offset_survives_save_load_round_trip() {
let x = array![
[0.0, 0.0],
[0.1, 0.1],
[0.2, 0.0],
[0.0, 0.2],
[0.1, 0.2],
[0.2, 0.1],
[0.05, 0.15],
[0.15, 0.05],
[0.1, 0.1],
[10.0, 10.0]
];
let mut model = IsolationForest::new(60, 32)
.unwrap()
.with_contamination(Contamination::Fraction(0.3))
.unwrap()
.with_random_state(21);
model.fit(&x).unwrap();
let path = "/tmp/rustyml_test_isolation_forest_offset.bin";
model.save_to_path(path).expect("save should succeed");
let loaded = IsolationForest::load_from_path(path).expect("load should succeed");
assert_eq!(loaded.get_contamination(), Contamination::Fraction(0.3));
assert_eq!(loaded.get_offset(), model.get_offset());
assert_eq!(
loaded.predict(&x).unwrap(),
model.predict(&x).unwrap(),
"a reloaded model must label identically"
);
}