use crate::error::Error;
use crate::machine_learning::validation::{
check_is_fitted, preliminary_check, validate_predict_input,
};
use crate::parallel_gates::tree_traversal_min_visits;
use crate::{Deserialize, Serialize};
use ndarray::{Array1, ArrayBase, Axis, Data, Ix2};
use ndarray_rand::rand::Rng;
use ndarray_rand::rand::rngs::StdRng;
const EULER_GAMMA: f64 = 0.57721566490153286060651209008240243104215933593992;
#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
pub enum Contamination {
Auto,
Fraction(f64),
}
#[inline]
fn average_path_length_factor(n: usize) -> f64 {
if n <= 1 {
return 0.0;
}
if n == 2 {
return 1.0;
}
let h_n_minus_1 = if n > 50 {
let m = (n - 1) as f64;
m.ln() + EULER_GAMMA + 0.5 / m
} else {
(1..n).map(|i| 1.0 / i as f64).sum::<f64>()
};
2.0 * h_n_minus_1 - 2.0 * (n - 1) as f64 / n as f64
}
use rayon::prelude::{IntoParallelIterator, ParallelIterator};
const DEFAULT_PARALLEL_THRESHOLD_TREES: usize = 10;
const ISOLATION_TREE_AVG_PATH: usize = 10;
const AUTO_CONTAMINATION_OFFSET: f64 = -0.5;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub enum IsolationTree {
Leaf {
size: usize,
},
Internal {
feature: usize,
threshold: f64,
left: Box<IsolationTree>,
right: Box<IsolationTree>,
},
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct IsolationForest {
trees: Option<Vec<IsolationTree>>,
n_estimators: usize,
max_samples: usize,
max_depth: usize,
random_state: Option<u64>,
n_features: usize,
sample_size: usize,
contamination: Contamination,
offset: Option<f64>,
}
impl Default for IsolationForest {
fn default() -> Self {
Self {
trees: None,
n_estimators: 100,
max_samples: 256,
sample_size: 0,
max_depth: 8, random_state: None,
n_features: 0,
contamination: Contamination::Auto,
offset: None,
}
}
}
impl IsolationForest {
pub fn new(n_estimators: usize, max_samples: usize) -> Result<Self, Error> {
if n_estimators == 0 {
return Err(Error::invalid_parameter(
"n_estimators",
"must be greater than 0",
));
}
if max_samples == 0 {
return Err(Error::invalid_parameter(
"max_samples",
"must be greater than 0",
));
}
let computed_max_depth = (max_samples as f64).log2().ceil() as usize;
Ok(Self {
trees: None,
n_estimators,
max_samples,
sample_size: 0,
max_depth: computed_max_depth,
random_state: None,
n_features: 0,
contamination: Contamination::Auto,
offset: None,
})
}
pub fn with_max_depth(mut self, max_depth: usize) -> Result<Self, Error> {
if max_depth == 0 {
return Err(Error::invalid_parameter(
"max_depth",
"must be greater than 0",
));
}
self.max_depth = max_depth;
Ok(self)
}
pub fn with_random_state(mut self, seed: u64) -> Self {
self.random_state = Some(seed);
self
}
pub fn with_contamination(mut self, contamination: Contamination) -> Result<Self, Error> {
if let Contamination::Fraction(c) = contamination
&& (!c.is_finite() || c <= 0.0 || c > 0.5)
{
return Err(Error::invalid_parameter(
"contamination",
format!("must be in (0.0, 0.5], got {c}"),
));
}
self.contamination = contamination;
Ok(self)
}
get_field!(get_n_estimators, n_estimators, usize);
get_field!(get_max_samples, max_samples, usize);
get_field!(get_sample_size, sample_size, usize);
get_field!(get_max_depth, max_depth, usize);
get_field!(get_random_state, random_state, Option<u64>);
get_field!(get_n_features, n_features, usize);
get_field!(get_contamination, contamination, Contamination);
get_field!(get_offset, offset, Option<f64>);
get_field_as_ref!(get_trees, trees, Option<&Vec<IsolationTree>>);
pub fn fit<S>(&mut self, x: &ArrayBase<S, Ix2>) -> Result<&mut Self, Error>
where
S: Data<Elem = f64> + Send + Sync,
{
preliminary_check(x, None)?;
self.n_features = x.ncols();
self.sample_size = self.max_samples.min(x.nrows());
#[cfg(feature = "show_progress")]
let progress_bar = {
let pb = crate::create_progress_bar(
self.n_estimators as u64,
"[{elapsed_precise}] {bar:40} {pos}/{len} | {msg}",
);
pb.set_message("Building isolation trees");
pb
};
let build_tree = |i: usize| -> Result<IsolationTree, Error> {
let mut rng =
crate::random::make_rng(self.random_state.map(|s| s.wrapping_add(i as u64)));
let sample_indices = self.sample_indices(x.nrows(), self.sample_size, &mut rng);
let result = self.build_isolation_tree(x, &sample_indices, 0, &mut rng);
#[cfg(feature = "show_progress")]
progress_bar.inc(1);
result
};
let trees: Result<Vec<IsolationTree>, Error> =
if self.n_estimators >= DEFAULT_PARALLEL_THRESHOLD_TREES {
(0..self.n_estimators)
.into_par_iter()
.map(build_tree)
.collect()
} else {
(0..self.n_estimators).map(build_tree).collect()
};
#[cfg(feature = "show_progress")]
progress_bar.finish_with_message("Trees built successfully");
self.trees = Some(trees?);
self.offset = Some(match self.contamination {
Contamination::Auto => AUTO_CONTAMINATION_OFFSET,
Contamination::Fraction(c) => {
let scores = self.score_samples(x)?;
let mut sorted = scores.to_vec();
sorted.sort_unstable_by(f64::total_cmp);
let position = (sorted.len() - 1) as f64 * c;
let lower = position.floor() as usize;
let fraction = position - lower as f64;
if fraction == 0.0 || lower + 1 >= sorted.len() {
sorted[lower]
} else {
sorted[lower] + fraction * (sorted[lower + 1] - sorted[lower])
}
}
});
Ok(self)
}
fn sample_indices(&self, n: usize, sample_size: usize, rng: &mut StdRng) -> Vec<usize> {
let mut indices: Vec<usize> = (0..n).collect();
for i in 0..sample_size {
let j = rng.random_range(i..n);
indices.swap(i, j);
}
indices.truncate(sample_size);
indices
}
fn build_isolation_tree<S>(
&self,
x: &ArrayBase<S, Ix2>,
indices: &[usize],
current_depth: usize,
rng: &mut StdRng,
) -> Result<IsolationTree, Error>
where
S: Data<Elem = f64>,
{
if current_depth >= self.max_depth || indices.len() <= 1 {
return Ok(IsolationTree::Leaf {
size: indices.len(),
});
}
let feature_index = rng.random_range(0..self.n_features);
let mut min_val = f64::INFINITY;
let mut max_val = f64::NEG_INFINITY;
for &idx in indices {
let val = x[[idx, feature_index]];
min_val = min_val.min(val);
max_val = max_val.max(val);
}
if (max_val - min_val).abs() < 1e-10 {
return Ok(IsolationTree::Leaf {
size: indices.len(),
});
}
let threshold = rng.random_range(min_val..max_val);
let (left_indices, right_indices): (Vec<usize>, Vec<usize>) = indices
.iter()
.partition(|&&idx| x[[idx, feature_index]] < threshold);
if left_indices.is_empty() || right_indices.is_empty() {
return Ok(IsolationTree::Leaf {
size: indices.len(),
});
}
let left = self.build_isolation_tree(x, &left_indices, current_depth + 1, rng)?;
let right = self.build_isolation_tree(x, &right_indices, current_depth + 1, rng)?;
Ok(IsolationTree::Internal {
feature: feature_index,
threshold,
left: Box::new(left),
right: Box::new(right),
})
}
fn path_length(&self, sample: &[f64], node: &IsolationTree, current_depth: usize) -> f64 {
match node {
IsolationTree::Leaf { size } => {
current_depth as f64 + average_path_length_factor(*size)
}
IsolationTree::Internal {
feature,
threshold,
left,
right,
} => {
if sample[*feature] < *threshold {
self.path_length(sample, left, current_depth + 1)
} else {
self.path_length(sample, right, current_depth + 1)
}
}
}
}
pub fn score_sample(&self, sample: &[f64]) -> Result<f64, Error> {
if self.trees.is_none() {
return Err(Error::not_fitted("IsolationForest"));
}
if sample.len() != self.n_features {
return Err(Error::dimension_mismatch(self.n_features, sample.len()));
}
let trees = self.trees.as_ref().unwrap();
let c_n = average_path_length_factor(self.sample_size);
Ok(self.normalized_score(sample, trees, c_n))
}
fn normalized_score(&self, sample: &[f64], trees: &[IsolationTree], c_n: f64) -> f64 {
let avg_path_length: f64 = trees
.iter()
.map(|tree| self.path_length(sample, tree, 0))
.sum::<f64>()
/ trees.len() as f64;
if c_n <= 0.0 {
return -1.0;
}
-(2.0_f64.powf(-avg_path_length / c_n))
}
pub fn score_samples<S>(&self, x: &ArrayBase<S, Ix2>) -> Result<Array1<f64>, Error>
where
S: Data<Elem = f64>,
{
check_is_fitted(self.trees.is_some(), "IsolationForest")?;
validate_predict_input(x, self.n_features)?;
let trees = self.trees.as_ref().unwrap();
let c_n = average_path_length_factor(self.sample_size);
let visit_work = x
.nrows()
.saturating_mul(trees.len())
.saturating_mul(ISOLATION_TREE_AVG_PATH);
let score_row = |row: ndarray::ArrayView1<f64>| match row.as_slice() {
Some(slice) => self.normalized_score(slice, trees, c_n),
None => self.normalized_score(&row.to_vec(), trees, c_n),
};
let scores: Vec<f64> = if visit_work >= tree_traversal_min_visits() {
x.axis_iter(Axis(0))
.into_par_iter()
.map(score_row)
.collect()
} else {
x.axis_iter(Axis(0)).map(score_row).collect()
};
Ok(Array1::from_vec(scores))
}
pub fn decision_function<S>(&self, x: &ArrayBase<S, Ix2>) -> Result<Array1<f64>, Error>
where
S: Data<Elem = f64>,
{
let scores = self.score_samples(x)?;
let offset = self
.offset
.ok_or_else(|| Error::not_fitted("IsolationForest"))?;
Ok(scores.mapv(|s| s - offset))
}
pub fn predict<S>(&self, x: &ArrayBase<S, Ix2>) -> Result<Array1<i32>, Error>
where
S: Data<Elem = f64>,
{
let decision = self.decision_function(x)?;
Ok(decision.mapv(|d| if d < 0.0 { -1 } else { 1 }))
}
pub fn fit_predict<S>(&mut self, x: &ArrayBase<S, Ix2>) -> Result<Array1<i32>, Error>
where
S: Data<Elem = f64> + Send + Sync,
{
self.fit(x)?;
self.predict(x)
}
model_save_and_load_methods!(IsolationForest);
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_abs_diff_eq;
#[test]
fn test_average_path_length_factor_n0() {
assert_abs_diff_eq!(average_path_length_factor(0), 0.0, epsilon = 1e-10);
}
#[test]
fn test_average_path_length_factor_n1() {
assert_abs_diff_eq!(average_path_length_factor(1), 0.0, epsilon = 1e-10);
}
#[test]
fn test_average_path_length_factor_n2() {
assert_abs_diff_eq!(average_path_length_factor(2), 1.0, epsilon = 1e-10);
}
#[test]
fn test_average_path_length_factor_n3() {
let expected = 5.0_f64 / 3.0;
assert_abs_diff_eq!(average_path_length_factor(3), expected, epsilon = 1e-9);
}
#[test]
fn test_average_path_length_factor_monotone() {
let f10 = average_path_length_factor(10);
let f100 = average_path_length_factor(100);
let f1000 = average_path_length_factor(1000);
assert!(
f10 < f100,
"expected factor(10) < factor(100), got {f10} vs {f100}"
);
assert!(
f100 < f1000,
"expected factor(100) < factor(1000), got {f100} vs {f1000}"
);
}
#[test]
fn test_average_path_length_factor_monotone_across_branch_boundary() {
let f49 = average_path_length_factor(49); let f50 = average_path_length_factor(50); let f51 = average_path_length_factor(51); let f52 = average_path_length_factor(52); assert!(f49 < f50, "factor(49)={f49} should be < factor(50)={f50}");
assert!(f50 < f51, "factor(50)={f50} should be < factor(51)={f51}");
assert!(f51 < f52, "factor(51)={f51} should be < factor(52)={f52}");
}
#[test]
fn test_average_path_length_factor_matches_exact_harmonic_within_tolerance() {
for &n in &[51usize, 100, 1000] {
let exact_h: f64 = (1..n).map(|i| 1.0 / i as f64).sum();
let theoretical = 2.0 * exact_h - 2.0 * (n - 1) as f64 / n as f64;
let got = average_path_length_factor(n);
assert!(
(got - theoretical).abs() < 1e-3,
"n={n}: factor={got} deviates from theoretical {theoretical} by more than the documented approximation bound",
);
}
}
}