use crate::error::Error;
use crate::machine_learning::validation::{
check_is_fitted, preliminary_check, validate_predict_input,
};
use crate::math::average_path_length_factor;
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;
use rayon::prelude::{IntoParallelIterator, ParallelIterator};
const DEFAULT_PARALLEL_THRESHOLD_TREES: usize = 10;
const ISOLATION_TREE_AVG_PATH: usize = 10;
#[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,
}
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,
}
}
}
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,
})
}
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
}
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_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?);
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 anomaly_score(&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 predict<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 predict_labels<S>(
&self,
x: &ArrayBase<S, Ix2>,
contamination: f64,
) -> Result<Array1<i32>, Error>
where
S: Data<Elem = f64>,
{
if !contamination.is_finite() || contamination <= 0.0 || contamination > 0.5 {
return Err(Error::invalid_parameter(
"contamination",
format!("must be in (0.0, 0.5], got {contamination}"),
));
}
let scores = self.predict(x)?;
let n = scores.len();
let n_outliers = (((n as f64) * contamination).ceil() as usize).clamp(1, n);
let mut sorted: Vec<f64> = scores.to_vec();
let kth = n - n_outliers;
sorted.select_nth_unstable_by(kth, |a, b| {
a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
});
let threshold = sorted[kth];
Ok(scores.mapv(|s| if s >= threshold { -1 } else { 1 }))
}
pub fn fit_predict<S>(&mut self, x: &ArrayBase<S, Ix2>) -> Result<Array1<f64>, Error>
where
S: Data<Elem = f64> + Send + Sync,
{
self.fit(x)?;
self.predict(x)
}
model_save_and_load_methods!(IsolationForest);
}