2.13. Isolation Forest
IsolationForest is the unsupervised anomaly detector in RustyML.
Most anomaly detectors model where normal data lives, then flag points that fall outside that region. IsolationForest inverts this approach. It uses the fact that anomalies are few and different. A tree of random axis-aligned splits isolates an anomaly into its own leaf after only a few cuts. A point buried in a dense cluster needs many more cuts before the tree isolates it. RustyML averages this cut count over a forest of trees, then normalizes the result into an anomaly score.
This estimator matches sklearn.ensemble.IsolationForest, the same subsampling-based ensemble from Liu, Ting, and Zhou. The API now lines up with scikit-learn method for method. score_samples, decision_function, and predict mean what they mean in Python, including the sign of the scores. If you port code written against an older RustyML, read Section 2.13.4 first. Both the score sign and the method names changed there.
2.13.1. The isolation principle and the anomaly score
Each tree grows on a random subsample of the data. The build step repeats one simple move. It picks a feature at random. It picks a split threshold at random, between that feature’s minimum and maximum value in the current node. It sends points below the threshold to the left child and the rest to the right child.
The tree recurses until a node holds 1 point. It also stops when every value of the chosen feature is equal in that node, because there is nothing left to split. It stops too when it hits the depth cap. A point far from the bulk of the data needs only a handful of random cuts to isolate. A point in the middle of a dense cluster needs many more cuts.
The tree structure that records this stays minimal. An isolation tree does not need class counts or impurity. It only needs where it split and how many points reached each leaf:
pub enum IsolationTree {
Leaf { size: usize },
Internal {
feature: usize,
threshold: f64,
left: Box<IsolationTree>,
right: Box<IsolationTree>,
},
}
The raw signal for a sample is its path length, h(x). This is the number of edges from the root to the leaf where the sample lands. A tree has a depth cap, so a leaf can still hold several points the tree did not fully separate. The path length adds a correction for the subtree that would have continued below that leaf. A leaf at depth d holding size points contributes d + c(size) to the path length.
c(n) estimates the average extra depth needed to isolate n points. c(n) is also the normalization constant. It equals the expected path length of a failed search in a binary search tree of n points:
c(n) = 2 * H(n-1) - 2 * (n-1) / n, where H(m) is the m-th harmonic number.
RustyML computes H(m) exactly, as a running sum, when n <= 50. Above that, it switches to the asymptotic form ln(m) + gamma + 1 / (2 * m). Here gamma is the Euler-Mascheroni constant, and m = n - 1. This approximation keeps the error under 1e-3. RustyML pins 2 edge cases: c(n) = 0 when n <= 1, and c(2) = 1. The final score for a sample averages h(x) over every tree, then applies Liu et al.’s formula, negated:
s(x) = -2^(-E[h(x)] / c(n)).
The score lands in the range [-1, 0). A lower score means more anomalous. This is the reverse of the earlier RustyML convention.
A short average path is easy to isolate, so it is anomalous. It drives the score toward -1. A long average path is hard to isolate, so it is normal. It drives the score toward 0.
The negation puts the score on the usual convention where negative marks the rejected class. It also makes score_samples numerically identical to scikit-learn’s version.
The neutral point is s = -0.5. At this point, a sample’s expected path length equals c(n), so its isolation cost matches the tree average. Liu et al. call this the “no distinct anomaly” regime. Contamination::Auto uses -0.5 as its cutoff. Treat -0.5 as no signal, not as confirmed normal. A point earns “normal” only when it scores clearly above -0.5.
The c(n) in the denominator uses n = sample_size. This is the realized per-tree subsample size, covered in the next 2 sections. It is not max_samples. This distinction matters when your dataset is smaller than max_samples, because normalizing by the wrong n would shift every score.
RustyML’s test suite checks the closed form directly. It fits identical points with max_samples >= n_rows. Every score comes out exactly -0.5, because E[h(x)] = c(sample_size) cancels the exponent.
2.13.2. Constructing the forest
IsolationForest has 2 entry points. IsolationForest::default() gives the standard configuration. IsolationForest::new(n_estimators, max_samples) sets the 2 structural parameters. It validates them and returns Result<Self, Error>. 3 builder methods refine the result further. Each one consumes and returns self, so you can chain them:
impl IsolationForest {
pub fn new(n_estimators: usize, max_samples: usize) -> Result<Self, Error>;
pub fn with_max_depth(self, max_depth: usize) -> Result<Self, Error>;
pub fn with_random_state(self, seed: u64) -> Self;
pub fn with_contamination(self, contamination: Contamination) -> Result<Self, Error>;
}
use rustyml::machine_learning::IsolationForest;
fn main() {
// Standard config: 100 trees, subsample of 256 rows each,
// depth auto = ceil(log2(256)) = 8, no seed.
let _a = IsolationForest::default();
// Explicit config, with a seed and a hand-set depth cap that overrides the auto value.
let forest = IsolationForest::new(100, 256)
.unwrap()
.with_max_depth(10)
.unwrap()
.with_random_state(42);
assert_eq!(forest.get_n_estimators(), 100);
assert_eq!(forest.get_max_samples(), 256);
assert_eq!(forest.get_max_depth(), 10);
assert_eq!(forest.get_random_state(), Some(42));
}
new rejects n_estimators == 0 or max_samples == 0. It returns Error::InvalidParameter, naming the field that failed. with_max_depth(0) rejects the same way.
Note one asymmetry. An explicit depth of 0 is invalid. The auto-computed depth can still be 0. For example, max_samples == 1 gives ceil(log2(1)) = 0. This is fine, because a subsample of 1 point has nothing to split anyway.
| Parameter | Set by | Default | Constraint |
|---|---|---|---|
n_estimators | new / default | 100 | greater than 0 |
max_samples | new / default | 256 | greater than 0 |
max_depth | auto or with_max_depth | ceil(log2(max_samples)) = 8 | greater than 0 if set explicitly |
random_state | with_random_state | None (entropy-seeded) | any u64 |
contamination | with_contamination | Contamination::Auto | a Fraction(c) must be finite and in (0.0, 0.5] |
Every field has a getter. get_n_estimators, get_max_samples, get_max_depth, get_random_state (returns Option<u64>), and get_contamination work right after construction. 4 more getters make sense only after fitting: get_n_features, get_sample_size, get_offset (returns Option<f64>, the resolved score cutoff), and get_trees (returns Option<&Vec<IsolationTree>>, None until fitted).
Understand the auto depth before you override it. ceil(log2(max_samples)) estimates the height of a balanced tree over the subsample. Anomalies are roughly isolated by that height already. Splitting deeper mostly separates points inside dense normal clusters. The c(size) leaf correction already accounts for that effect.
A bigger max_depth buys almost no accuracy, and it costs more build time. Leave max_depth on auto, unless you have a specific reason to change it.
2.13.3. Subsampling, sample_size, and why 256
Isolation Forest makes an unusual design choice. Each tree trains on a small random subsample, not on the whole dataset. This is a feature, not a shortcut. RustyML draws sample_size = min(max_samples, n_rows) rows for each tree, without replacement, through a partial Fisher-Yates shuffle.
The default max_samples = 256 comes directly from the original paper. Small subsamples defeat 2 failure modes of density-based detectors. Swamping happens when normal points near a cluster of anomalies start to look anomalous. Masking happens when a dense clump of anomalies hides its own members from each other. Both problems get worse as each tree sees more data, because large samples let anomalies form their own mini-clusters. Those mini-clusters are no longer easy to isolate.
A 256-row subsample is large enough to profile the shape of the normal region. It is also small enough that anomalies stay sparse and stay easy to isolate. A bigger subsample mostly buys higher build cost and worse detection quality. That is why max_samples is a subsample budget, not a “use more data” knob.
This design has 2 consequences. First, when your dataset has fewer rows than max_samples, sample_size clamps to n_rows. Every tree then sees the whole dataset. The forest still works, because each tree still differs through its random splits. It just no longer subsamples. fit checks for this case and never panics.
Second, normalization uses c(sample_size), not c(max_samples). RustyML reads sample_size back from the fitted model instead of assuming max_samples. get_sample_size() reports the realized value after fit.
2.13.4. Fitting and reading scores
fit takes a 2-D f64 array, where rows are samples and columns are features. It returns Result<&mut Self, Error>. fit records n_features and sample_size, builds every tree, then resolves the contamination rule into a stored score cutoff, the offset. Scoring then exposes 4 surfaces. Each one shifts or thresholds the surface before it, in a chain:
pub fn fit<S>(&mut self, x: &ArrayBase<S, Ix2>) -> Result<&mut Self, Error>
where S: Data<Elem = f64> + Send + Sync;
pub fn score_samples<S>(&self, x: &ArrayBase<S, Ix2>) -> Result<Array1<f64>, Error>
where S: Data<Elem = f64>; // 1 score per row, in [-1, 0). Lower means more anomalous
pub fn score_sample(&self, sample: &[f64]) -> Result<f64, Error>; // 1 row, as a slice
pub fn decision_function<S>(&self, x: &ArrayBase<S, Ix2>) -> Result<Array1<f64>, Error>
where S: Data<Elem = f64>; // score_samples(x) minus offset. Negative marks an outlier
pub fn predict<S>(&self, x: &ArrayBase<S, Ix2>) -> Result<Array1<i32>, Error>
where S: Data<Elem = f64>; // sign of decision_function: {-1 outlier, +1 inlier}
Each name means exactly what it means in scikit-learn. score_samples gives the raw anomaly score. decision_function shifts that score so zero marks the decision boundary. predict gives the sign of the decision value. A strictly negative value gives -1. Everything else gives +1, so a sample that lands exactly on the cutoff counts as an inlier.
Pick the method the task needs. Use score_samples to rank samples, set your own cutoff, or feed a downstream calibrator. Use decision_function for a signed margin. Use predict for a hard {-1, +1} decision.
Earlier RustyML versions differ here in 2 ways. Both changes alter results silently, instead of failing to compile.
First, the scores are now negated. The old scale was [0, 1], where a higher score meant more anomalous. The new scale is [-1, 0), where a lower score means more anomalous. Flip every comparison that ranks or thresholds a score.
Second, the per-sample slice method is now called score_sample (singular). It replaces the old anomaly_score method. The old batch form, where predict returned scores, is gone. The old predict_labels(&x, contamination) method is gone too. score_samples replaces the first. predict, together with the fitted contamination rule, replaces the second.
score_sample scores one point at a time, for a live stream or an ad-hoc query. It takes a &[f64] slice instead of a matrix. fit_predict runs fit, then predict, on the same matrix. It returns labels, the standard “label the training set” convenience for unsupervised models.
use ndarray::array;
use rustyml::machine_learning::IsolationForest;
fn main() {
let train = array![[0.0, 0.0], [0.1, 0.0], [0.0, 0.1], [0.1, 0.1], [30.0, 30.0]];
let mut forest = IsolationForest::new(80, 32).unwrap().with_random_state(1);
forest.fit(&train).unwrap();
// Score points one at a time through the slice API.
let normal = forest.score_sample(&[0.05, 0.05]).unwrap();
let weird = forest.score_sample(&[30.0, 30.0]).unwrap();
assert!(weird < normal); // lower score means more anomalous
// The batch form, and the same values shifted by the fitted cutoff.
let scores = forest.score_samples(&train).unwrap();
let decision = forest.decision_function(&train).unwrap();
let offset = forest.get_offset().unwrap(); // -0.5 under Contamination::Auto
assert!((decision[0] - (scores[0] - offset)).abs() < 1e-12);
}
Every entry point validates its input and returns a typed Error. fit returns Error::EmptyInput on zero rows. fit returns Error::NonFinite on any NaN or infinite value. score_samples also checks Error::DimensionMismatch, when the feature count differs from training, and Error::NotFitted, before fit runs.
decision_function and predict propagate those same errors unchanged. score_sample checks only Error::NotFitted and Error::DimensionMismatch for its single slice. It does not check the slice for NaN or infinite values.
The batch calls accept any ArrayBase backing. This includes non-contiguous views, such as a transposed array. You do not need to copy .t() into a fresh buffer before scoring.
2.13.5. A worked example: injecting outliers into a blob
This is the standard sanity check for an anomaly detector. Build a tight cluster of inliers. Drop in a few points that clearly do not belong. Confirm that the forest ranks those points at the top.
use ndarray::Array2;
use rustyml::machine_learning::{Contamination, IsolationForest};
fn main() {
// 20 inliers in a tight blob near (5, 5)...
let mut rows: Vec<f64> = Vec::new();
for i in 0..20 {
let t = i as f64;
rows.push(5.0 + 0.15 * t.sin());
rows.push(5.0 + 0.15 * t.cos());
}
// ...plus 3 injected outliers far from the cluster.
for &(x, y) in &[(30.0, 30.0), (-20.0, 40.0), (50.0, -10.0)] {
rows.push(x);
rows.push(y);
}
let n = rows.len() / 2; // 23 rows
let data = Array2::from_shape_vec((n, 2), rows).unwrap();
// A 15% contamination budget, resolved into a fixed cutoff at fit time.
let mut forest = IsolationForest::new(100, 256)
.unwrap()
.with_random_state(42)
.with_contamination(Contamination::Fraction(0.15))
.unwrap();
let labels = forest.fit_predict(&data).unwrap();
let scores = forest.score_samples(&data).unwrap();
// The 3 injected rows (indices 20..23) should score well below the blob.
for i in (n - 3)..n {
println!("outlier row {i}: score {:.3}", scores[i]);
}
let flagged: Vec<usize> = labels
.iter()
.enumerate()
.filter(|&(_, &l)| l == -1)
.map(|(i, _)| i)
.collect();
println!("cutoff (offset): {:.3}", forest.get_offset().unwrap());
println!("flagged as outliers (-1): {flagged:?}");
}
The 3 outlier points land near the bottom of the score range. The blob rows sit close to zero:
outlier row 20: score -0.735
outlier row 21: score -0.779
outlier row 22: score -0.788
cutoff (offset): -0.419
flagged as outliers (-1): [2, 20, 21, 22]
Note what the cutoff is, and what it is not. Contamination::Fraction(0.15) sets the offset to the 15th percentile of the training scores. It separates off roughly the lowest 15% of samples. Here that is 4 rows out of 23, 1 more than the 3 rows actually injected.
The exact count depends on where the scores fall. It does not depend on a fixed ceil(0.15 * n), so an inlier can get swept in. This is what contamination means, and it motivates the next section.
2.13.6. Turning scores into labels: contamination
contamination is a rule you set on the builder. It is not an argument to a prediction call. fit resolves it into a single stored number, the offset:
pub enum Contamination {
Auto, // the paper's cutoff: offset = -0.5
Fraction(f64), // offset = the 100*c-th percentile of the TRAINING scores. c is in (0.0, 0.5]
}
Contamination::Auto is the default. It pins the cutoff at -0.5, the score where a sample isolates exactly as fast as the forest average. Contamination::Fraction(c) instead reads the cutoff from the training scores, at the 100*c-th percentile. It uses NumPy’s linear interpolation for that percentile. This choice makes get_offset() equal scikit-learn’s offset_ numerically. It does more than flag a comparable number of rows.
A Fraction value must be finite and inside (0.0, 0.5]. Otherwise, with_contamination returns Error::InvalidParameter. The upper bound of 0.5 encodes the assumption that anomalies are the minority.
Resolving the cutoff at fit time is the whole point of this design. The rule becomes model state. A sample gets the same label whether you score it alone, in a slice, or in the full batch.
The earlier design worked differently. It took a quantile of whatever batch it received, so it was transductive. A single-row call always came back -1. Splitting a test set in half changed its labels.
Changing the rule after fitting requires a new call to fit, because RustyML computes the offset from the training scores.
Contamination is still a budget, not a discovery. You tell the model what proportion of the training data you expect to be anomalous. fit places the cutoff accordingly, whether or not that many anomalous points actually exist. If you overestimate, the cutoff sweeps in normal points as false positives, as the worked example showed. If you underestimate, the cutoff labels real anomalies as inliers. The model cannot know the true rate, so there is no way around this tradeoff.
When you have ground-truth labels for a validation set, sweep the fraction. Pick the value that best trades precision against recall for your cost structure. The tools in 5.2. Classification Metrics apply here, if you treat -1 as the positive class.
When you do not have ground truth, prefer the raw score_samples values. Threshold them against a level you can justify. For example, use a percentile from a clean reference window or an absolute score cutoff. Do not just commit to a fixed proportion. If your idea of “outlier” is really “sparse region,” rather than “few and different,” compare against DBSCAN, which labels low-density points as noise.
2.13.7. Seeding and reproducible forests
An unseeded forest draws its randomness from entropy, so 2 fits differ. Pass with_random_state(seed) to make the whole forest reproducible. The same seed and the same data give bit-identical scores.
use ndarray::array;
use rustyml::machine_learning::IsolationForest;
fn main() {
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 a = IsolationForest::new(30, 20).unwrap().with_random_state(13);
a.fit(&data).unwrap();
let sa = a.score_samples(&data).unwrap();
let mut b = IsolationForest::new(30, 20).unwrap().with_random_state(13);
b.fit(&data).unwrap();
let sb = b.score_samples(&data).unwrap();
assert_eq!(sa, sb); // bit-identical, regardless of thread scheduling
}
This determinism is stronger than it looks, and the reason matters for the parallel build described in the next section. RustyML does not share a single RNG across the trees. Tree i gets its own generator, seeded with seed.wrapping_add(i). Each tree’s seed depends only on its index.
So the forest comes out identical whether the trees build serially or across rayon threads, in any order the scheduler picks. Parallelism can never change the result. This is a deliberate contrast with the neural-network components in this crate, where reproducibility can depend on order.
You can fix randomness globally instead of passing a seed to every constructor. rustyml::set_global_seed seeds an unseeded forest from the process-global, thread-local, random stream:
use ndarray::array;
use rustyml::machine_learning::IsolationForest;
use rustyml::set_global_seed;
fn main() {
set_global_seed(2026);
let data = array![[0.0, 0.0], [0.5, 0.5], [1.0, 1.0], [50.0, 50.0]];
// No with_random_state: per-tree seeds derive from the global stream.
let mut forest = IsolationForest::new(30, 16).unwrap();
let _labels = forest.fit_predict(&data).unwrap();
}
An explicit with_random_state always wins over the global seed. It also never consumes the global stream, so mixing the two stays predictable. The global seed is thread-local. Set it on the same thread that builds the model. See 7.1. Reproducibility and Random Seeds for the full seed-resolution rules.
2.13.8. Parallel training and prediction
Training and scoring both parallelize through rayon. A gate keeps tiny workloads serial, to avoid fork/join overhead. fit builds trees across a into_par_iter once n_estimators reaches its threshold of 10 trees. The default 100-tree forest always trains in parallel. Each tree is an independent subsample-and-build step, with its own index-seeded RNG. The work parallelizes cleanly, with no shared mutable state and no effect on the result.
score_samples parallelizes over rows once the estimated traversal work clears the calibrated tree-traversal gate. That work estimate is samples times trees times average path length. Below the gate, small batches score serially. decision_function and predict inherit this behavior, because they are thin wrappers over score_samples. score_sample always runs serially, because it handles only a single sample.
Isolation Forest scales well on wide forests and large scoring batches. Training cost is roughly n_estimators * sample_size * log(sample_size), and it is embarrassingly parallel across trees. Per-sample scoring is O(n_estimators * tree_depth), and it is independent across rows. See 7.3. Performance Tuning and Parallelism to tune the gates or study the cost model.
2.13.9. Saving and loading a forest
A fitted forest serializes to a compact postcard binary, through save_to_path and load_from_path. Every RustyML estimator exposes this same pair of methods. The whole state travels: every tree, the hyperparameters, n_features, sample_size, and the fitted offset. A reloaded model scores and labels identically to the original, byte for byte.
use ndarray::array;
use rustyml::machine_learning::IsolationForest;
fn main() {
let data = array![[0.0, 0.0], [0.1, 0.1], [0.2, 0.0], [10.0, 10.0]];
let mut forest = IsolationForest::new(50, 32).unwrap().with_random_state(7);
forest.fit(&data).unwrap();
let before = forest.score_samples(&data).unwrap();
let path = "isolation_forest_model.bin";
forest.save_to_path(path).unwrap();
let loaded = IsolationForest::load_from_path(path).unwrap();
let after = loaded.score_samples(&data).unwrap();
assert_eq!(before, after); // scoring survives the round trip exactly
std::fs::remove_file(path).unwrap();
}
load_from_path returns Error::Io when the file is missing, or when the bytes are not a valid serialized forest. RustyML persists sample_size, so a loaded model keeps the correct c(n) normalization, even though it never sees the training data again. Its scores stay on the same scale as before you saved it.
One migration note applies. The stored offset changed sign along with the scores. A forest saved by an older version resolves its cutoff on the wrong side of zero. Re-fit and re-save any persisted forest. For versioning and format details across the crate, see 7.2. Model Persistence in Depth.