Anomaly Detection with Isolation Forests
What you’ll learn
- why isolating an anomaly is easier than modelling normality, and why that inverts the usual cost
- the path-length score, its harmonic-number normalisation, and the meaning of
- measured separation: outliers isolate at depth 9.46, inliers at 18.09
- why
max_samples=256max_samples=256is the default and why subsampling improves accuracy here contaminationcontaminationas a threshold, not a model — the precision/recall curve it traces- four detectors on identical data: 16, 17, 13 and 18 correct out of 20
- when to use Isolation Forest, and the three situations where it is the wrong tool
Intuition
Most anomaly detection tries to build a model of “normal” and then flags whatever the model fits badly. That is expensive: you have to characterise the bulk of the data precisely, and the bulk is where nearly all the data is.
Isolation Forest inverts the problem. It observes that anomalies are few and different, and that those two properties make them easy to separate from everything else. So instead of describing normality, it repeatedly cuts the feature space at random and asks a much cheaper question: how many cuts did it take before this point ended up alone?
A point in the middle of a dense cloud is surrounded, so a random cut is unlikely to separate it from its neighbours — it takes many cuts. A point sitting far out on its own gets sliced off almost immediately. Short path to isolation means anomalous.
The consequence is unusual and worth appreciating: this algorithm never computes a distance, never estimates a density, and never looks at the whole dataset. It is the rare case where the anomaly detector is cheaper than the thing it is detecting anomalies in.
flowchart TD A["Subsample 256 rows"] --> B["Pick a random feature"] B --> C["Pick a random split value
between that feature's min and max"] C --> D{"Is the target point alone
in its region?"} D -->|"no"| B D -->|"yes"| E["Record the depth"] E --> F["Average over 100 trees"] F --> G["Short average depth -> anomaly
Long average depth -> normal"]
The math
An isolation tree is built by recursively choosing a random feature and a random split value uniformly between the current node’s minimum and maximum of , until every point is alone or a height limit is hit.
Write for the path length of point — the number of edges from the root to its terminating node.
Path length alone is not comparable across dataset sizes: a tree over 10,000 points is deeper than one over 100 points, for everyone. The normalisation comes from the fact that an isolation tree is structurally a binary search tree, and the average unsuccessful-search path length in a BST over nodes is known:
where is the Euler-Mascheroni constant. Dividing by makes depths comparable. The anomaly score is then
where is the average path length over all the trees. Read the three regimes off the exponent:
| relative to | Verdict | |
|---|---|---|
| Isolated instantly — definite anomaly | ||
| exactly 0.5 | Average depth — indistinguishable from normal | |
| Very deep — definitely normal |
With :
So a point isolated at average depth 4 scores , at depth 10.2448 scores exactly , and at depth 14 scores .
scikit-learn flips the sign. Its score_samplesscore_samples returns the negative of the above, so
lower is more anomalous and the values are negative. decision_functiondecision_function is
score_samples - offset_score_samples - offset_, which puts the decision boundary at zero. Getting this backwards is the
single most common bug with this estimator.
Why subsample?
The default max_samples=256max_samples=256 is not a speed compromise — it makes the detector more accurate.
Two effects work against large samples:
- Swamping: with many points, normal points near a cluster of anomalies get dragged into looking anomalous.
- Masking: a dense clump of anomalies looks like a legitimate small cluster and stops being isolated quickly.
Subsampling breaks up both. Each tree sees a different 256 rows, so an anomaly clump rarely survives intact in any one of them. The original paper found accuracy plateaus around 256 and adding more rows does not help — which is why the parameter is a fixed count, not a fraction.
Worked example by hand
Six points on a line: . The value 20 is the obvious anomaly.
Build one isolation tree. The root spans . Draw a random split .
The point 20 is isolated at depth 1 whenever — probability . Four times out of five, one cut is enough.
The point 3 can never be isolated at depth 1. It has neighbours on both sides, so any single split leaves it with company: gives on the right, and gives on the left. Its shortest possible path is 2, and typically it is much longer.
Averaging over 4,000 random trees:
| Point | |
|---|---|
| 1 | 3.904 |
| 2 | 5.285 |
| 3 | 5.952 |
| 4 | 6.309 |
| 5 | 5.244 |
| 20 | 1.222 |
With :
So 20 scores while 3 scores . Comfortably above and comfortably below 0.5 — exactly the split the score is designed to produce.
Note that the two edge points, 1 and 5, score higher than the interior ones. That is correct behaviour, not an artefact: being at the edge of the range genuinely does make you easier to isolate, and every isolation-based method inherits this mild edge bias.
Scaled up to 300 points in two dimensions — 280 from a correlated Gaussian and 20 uniform outliers — the separation is unambiguous:
| Group | Mean depth to isolation (60 trees) |
|---|---|
| True outliers | 9.46 |
| Inliers | 18.09 |
See it move
The next sketch shows the scoring formula directly: drag through the depth axis and watch cross 0.5 exactly where .
Notice how little moves: 64 rows give 7.72, 4096 give 15.79. Path length grows roughly logarithmically in , which is exactly why the normalisation is a logarithm.
contamination is a threshold, not a model
contaminationcontamination does not change a single tree. It only decides where to cut the score
distribution — it sets offset_offset_ to the corresponding quantile. Getting it wrong therefore costs
you nothing in model quality and everything in what comes out.
On 300 points containing 20 genuine outliers (a true rate of 6.67%):
contaminationcontamination | Points flagged | Precision | Recall |
|---|---|---|---|
| 0.01 | 3 | 1.0000 | 0.1500 |
| 0.02 | 6 | 1.0000 | 0.3000 |
| 0.04 | 12 | 0.9167 | 0.5500 |
| 0.0667 | 20 | 0.8000 | 0.8000 |
| 0.10 | 30 | 0.6333 | 0.9500 |
| 0.15 | 45 | 0.4444 | 1.0000 |
| 0.20 | 60 | 0.3333 | 1.0000 |
| 0.30 | 90 | 0.2222 | 1.0000 |
The number flagged is exactly round(contamination × n)round(contamination × n) in every row — the parameter is a quota,
not a discovery. Setting it to the true rate balances precision and recall at 0.80 each.
The underlying ranking is excellent regardless: the ROC AUC of the raw scores is 0.9902. The
model knows which points are odd. contaminationcontamination only decides how many of them you are told about.
Four detectors, one dataset
Same 300 points, same 20 planted outliers, same 7% budget:
| Detector | Correct (of 20) | False alarms | Idea |
|---|---|---|---|
IsolationForestIsolationForest | 16 | 5 | Random splits; short path = odd |
LocalOutlierFactorLocalOutlierFactor | 17 | 4 | Local density versus your neighbours’ local density |
OneClassSVMOneClassSVM | 13 | 7 | A boundary enclosing the normal region |
EllipticEnvelopeEllipticEnvelope | 18 | 3 | Robust Gaussian fit; Mahalanobis distance |
That result is worth stating plainly: the best detector on this page is not Isolation Forest. EllipticEnvelope wins because the data was generated from a bivariate Gaussian and it fits a bivariate Gaussian. That is a property of the benchmark, not evidence of general superiority — and it is the reason to be suspicious of any anomaly-detection comparison, including this one.
Isolation Forest’s case is different: it makes no distributional assumption, scales to millions of rows, handles dozens of features, and has essentially nothing to tune. On real data where you do not know the shape of “normal”, those properties matter more than two extra hits on a synthetic benchmark.
| Detector | Assumes | Scales to 1M rows | High dimensions | Has predictpredict |
|---|---|---|---|---|
| IsolationForest | nothing much | yes | good | yes |
| LocalOutlierFactor | local density is meaningful | no () | poor | only with novelty=Truenovelty=True |
| OneClassSVM | a kernel-shaped boundary | no (–) | moderate | yes |
| EllipticEnvelope | the data is Gaussian | moderate | poor if | yes |
| SGDOneClassSVM | a linear boundary in kernel space | yes | moderate | yes |
In code
import numpy as np
from sklearn.ensemble import IsolationForest
rng = np.random.default_rng(3)
cov = np.array([[1.0, 0.75], [0.75, 1.0]])
inliers = rng.multivariate_normal([0, 0], cov, size=280)
outliers = rng.uniform(-5, 5, size=(20, 2))
X = np.vstack([inliers, outliers])
clf = IsolationForest(
n_estimators=100, # more trees = more stable scores, no overfitting risk
max_samples=256, # the paper's default; subsampling improves accuracy
contamination=0.07, # a THRESHOLD, not a model parameter
random_state=0,
).fit(X)
pred = clf.predict(X) # +1 inlier, -1 outlier
scores = clf.score_samples(X) # LOWER is more anomalous (sklearn flips the sign)
margin = clf.decision_function(X) # score_samples - offset_; negative means flagged
print("flagged ", int((pred == -1).sum())) # 21
print("offset_ ", round(clf.offset_, 4)) # -0.5382
print("inlier mean s ", round(scores[:280].mean(), 4)) # -0.4212
print("outlier mean s ", round(scores[280:].mean(), 4)) # -0.6387import numpy as np
from sklearn.ensemble import IsolationForest
rng = np.random.default_rng(3)
cov = np.array([[1.0, 0.75], [0.75, 1.0]])
inliers = rng.multivariate_normal([0, 0], cov, size=280)
outliers = rng.uniform(-5, 5, size=(20, 2))
X = np.vstack([inliers, outliers])
clf = IsolationForest(
n_estimators=100, # more trees = more stable scores, no overfitting risk
max_samples=256, # the paper's default; subsampling improves accuracy
contamination=0.07, # a THRESHOLD, not a model parameter
random_state=0,
).fit(X)
pred = clf.predict(X) # +1 inlier, -1 outlier
scores = clf.score_samples(X) # LOWER is more anomalous (sklearn flips the sign)
margin = clf.decision_function(X) # score_samples - offset_; negative means flagged
print("flagged ", int((pred == -1).sum())) # 21
print("offset_ ", round(clf.offset_, 4)) # -0.5382
print("inlier mean s ", round(scores[:280].mean(), 4)) # -0.4212
print("outlier mean s ", round(scores[280:].mean(), 4)) # -0.6387Ranking beats thresholding whenever a human is going to look at the results anyway:
import numpy as np
# Forget contamination — hand the analyst the 20 oddest rows.
order = np.argsort(clf.score_samples(X)) # most anomalous first
top20 = order[:20]
print("indices of the 20 most anomalous rows:", top20)
# How many were real? (Only knowable here because we planted them.)
print("hits in the top 20:", int((top20 >= 280).sum()))import numpy as np
# Forget contamination — hand the analyst the 20 oddest rows.
order = np.argsort(clf.score_samples(X)) # most anomalous first
top20 = order[:20]
print("indices of the 20 most anomalous rows:", top20)
# How many were real? (Only knowable here because we planted them.)
print("hits in the top 20:", int((top20 >= 280).sum()))And when you genuinely have labels, measure the ranking rather than the threshold:
from sklearn.metrics import average_precision_score, roc_auc_score
y_true = np.concatenate([np.zeros(280), np.ones(20)]) # 1 = outlier
y_score = -clf.score_samples(X) # flip so higher = more anomalous
print("ROC AUC", round(roc_auc_score(y_true, y_score), 4)) # 0.9902
print("PR AUC ", round(average_precision_score(y_true, y_score), 4))from sklearn.metrics import average_precision_score, roc_auc_score
y_true = np.concatenate([np.zeros(280), np.ones(20)]) # 1 = outlier
y_score = -clf.score_samples(X) # flip so higher = more anomalous
print("ROC AUC", round(roc_auc_score(y_true, y_score), 4)) # 0.9902
print("PR AUC ", round(average_precision_score(y_true, y_score), 4))With 6.7% positives, average precision is the more honest number — ROC AUC flatters imbalanced problems, exactly as it does in supervised classification.
Novelty detection versus outlier detection
Two different jobs that use the same estimators:
Outlier detection — the training set is already contaminated, and you want to know which of
these rows are odd. fit_predict(X)fit_predict(X).
Novelty detection — the training set is clean, and you want to flag odd rows in future data.
fit(X_clean)fit(X_clean) then predict(X_new)predict(X_new).
Isolation Forest supports both directly. LocalOutlierFactorLocalOutlierFactor requires novelty=Truenovelty=True at
construction time and then forbids fit_predictfit_predict — a common source of confusion.
APIsklearn.ensemble.IsolationForest
Assumes
- Anomalies are FEW and DIFFERENT — the whole method rests on this
- Anomalies are separable by axis-aligned splits
- You can estimate roughly what fraction of the data is anomalous
Cost
- train
O(t * psi * log psi) — independent of n, because each tree sees only psi = max_samples rows- predict
O(t log psi) per point- memory
O(t * psi)
h(x) — path length to isolation; c(n) — average BST path length; s(x, n) = 2^(-E[h]/c(n))
Hyperparameters that matter
n_estimatorsdefault 100More trees stabilise the score. Cannot overfit; 100 is usually plenty.max_samplesdefault 'auto' = 256Subsampling counteracts swamping and masking. Larger is not better.contaminationdefault 'auto'A quantile threshold only. Sets offset_; flags exactly round(contamination * n) points.max_featuresdefault 1.0Feature subsampling per tree. Worth lowering when p is large.bootstrapdefault FalseSample with replacement. Rarely changes much.
Reach for it when
- You have many rows, many columns, and no labels
- You cannot assume any particular distribution for 'normal'
- You need something that trains in seconds and scores in microseconds
- You want a ranking to triage, not a hard yes/no
Look elsewhere when
- Anomalies are common — 'few and different' is the load-bearing assumption
- Anomalies are defined by combinations that are not axis-aligned (a diagonal band of odd values)
- You know the data is Gaussian — EllipticEnvelope will beat it, as measured above
- You have labels — a supervised classifier will beat any unsupervised detector
Pitfalls
Getting the sign backwards. In the paper, higher means more anomalous. In scikit-learn,
score_samplesscore_samples is negated, so lower means more anomalous. Flip it before feeding
roc_auc_scoreroc_auc_score, or your AUC will come out near 0.01 instead of 0.99.
Treating contaminationcontamination as tuning. It changes nothing about the trees. The sweep above shows
it moving precision from 1.00 to 0.22 while the underlying ranking (AUC 0.9902) is unchanged
throughout.
Raising max_samplesmax_samples to “use all the data”. Subsampling is a feature. Large samples reintroduce
swamping and masking, which is what the 256 default exists to prevent.
Expecting it to find contextual anomalies. A temperature of 30°C is normal in July and bizarre
in January. Isolation Forest sees only the marginal distribution unless you engineer the context
into a feature (temp_minus_monthly_meantemp_minus_monthly_mean).
Using it on categorical data. Random splits on a one-hot column are almost meaningless — the “range” of a binary column is and any cut is the same cut. Encode ordinally with a meaningful order, or use a method built for mixed types.
Ignoring that some anomalies are unfindable. In the measured data, several uniform outliers landed inside the Gaussian cloud. No unsupervised detector can flag those, and chasing them by raising contamination only buys false alarms — precision fell from 1.00 to 0.44 to recover the last few.
Assuming it beats the alternatives. It lost to EllipticEnvelope 18–16 on this page’s Gaussian data. Benchmark on your data.
Compare
| Isolation Forest | Local Outlier Factor | One-Class SVM | Elliptic Envelope | |
|---|---|---|---|---|
| Model of normality | none — isolation ease | local density ratio | kernel boundary | robust Gaussian |
| Correct on the page’s data | 16 / 20 | 17 / 20 | 13 / 20 | 18 / 20 |
| Training cost | – | |||
| Varying density | good | best | moderate | poor |
| Interpretable score | path length | density ratio | distance to boundary | Mahalanobis distance |
| Needs scaling | no | yes | yes | yes |
Isolation Forest not needing scaling is genuinely unusual and follows from the splits being per-feature: a cut is drawn uniformly within each feature’s own range, so the units cancel.
An Isolation Forest gives a point an average path length equal to c(n). What is its anomaly score in the paper's convention?
s = 2^(-E[h]/c(n)), so E[h] = c(n) gives 2^-1 = 0.5 exactly. That is the deliberate design: 0.5 means the point sits at the average depth and is therefore indistinguishable from normal.
Show answer
B — Exactly 0.5 — s = 2^(-E[h]/c(n)), so E[h] = c(n) gives 2^-1 = 0.5 exactly. That is the deliberate design: 0.5 means the point sits at the average depth and is therefore indistinguishable from normal.
You set contamination=0.30 on data that is really 5% anomalous. What happens?
contamination only sets the quantile threshold on already-computed scores. In the page's sweep it flagged exactly round(contamination x n) points every time, dropping precision to 0.22 while the underlying ROC AUC stayed at 0.9902.
Show answer
B — The trees are unchanged; you simply get 30% of your rows flagged, most of them normal — contamination only sets the quantile threshold on already-computed scores. In the page's sweep it flagged exactly round(contamination x n) points every time, dropping precision to 0.22 while the underlying ROC AUC stayed at 0.9902.
Why is the default max_samples 256 rather than the whole dataset?
With too many points, normal points near anomalies get swamped and dense clumps of anomalies mask each other. Each tree seeing a different small sample breaks up both effects. The original paper measured the plateau at around 256.
Show answer
B — Subsampling reduces swamping and masking, so accuracy actually improves and then plateaus — With too many points, normal points near anomalies get swamped and dense clumps of anomalies mask each other. Each tree seeing a different small sample breaks up both effects. The original paper measured the plateau at around 256.
In scikit-learn, which direction of score_samples means 'more anomalous'?
scikit-learn standardises on 'greater is more normal' across every outlier detector. So you negate before feeding roc_auc_score. Forgetting produces an AUC near 0.01 instead of 0.99 — an unmistakable symptom once you know it.
Show answer
B — Lower — scikit-learn negates the paper's score so that all its detectors share one convention — scikit-learn standardises on 'greater is more normal' across every outlier detector. So you negate before feeding roc_auc_score. Forgetting produces an AUC near 0.01 instead of 0.99 — an unmistakable symptom once you know it.
EllipticEnvelope beat Isolation Forest 18 to 16 on this page's data. What should you conclude?
The benchmark generated inliers from a bivariate Gaussian, so the estimator that fits a Gaussian had a structural advantage. On data whose 'normal' region is not ellipsoidal, the ranking reverses. Always benchmark on your own data.
Show answer
B — The inliers were drawn from a Gaussian, which is exactly EllipticEnvelope's assumption — the benchmark favoured it — The benchmark generated inliers from a bivariate Gaussian, so the estimator that fits a Gaussian had a structural advantage. On data whose 'normal' region is not ellipsoidal, the ranking reverses. Always benchmark on your own data.
🧪 Try It Yourself
Exercise 1 – The normalisation constant
Exercise 2 – Fit one and read the scores
Exercise 3 – contamination is a quota
Exercise 4 – Rank instead of threshold
Exercise 5 – Four detectors, one dataset
Recap
- Isolation Forest finds anomalies by how easily they are separated, not by modelling normality.
- The score uses the average BST path length so depths are comparable across dataset sizes. , and gives exactly .
- Measured separation: outliers isolate at mean depth 9.46, inliers at 18.09.
- Subsampling to 256 rows per tree is an accuracy improvement, not a compromise — it defeats swamping and masking.
contaminationcontaminationis a quantile threshold. It flagged exactly points at every setting while the ranking’s ROC AUC stayed at 0.9902.- scikit-learn negates the score: lower
score_samplesscore_samplesmeans more anomalous. - On this page’s Gaussian benchmark, EllipticEnvelope won 18–16. That reflects the benchmark, and it is the reason to test on your own data.
Exercise 6 – contaminationcontamination is a review budget
Next
Association Rule Learning (Apriori Algorithm) — a different kind of unsupervised structure entirely: not “which rows are alike?” but “which items show up together, and is that more often than chance?”
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
