Skip to content

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 s=0.5s = 0.5
  • measured separation: outliers isolate at depth 9.46, inliers at 18.09
  • why max_samples=256max_samples=256 is the default and why subsampling improves accuracy here
  • contaminationcontamination as 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.

diagram Diagram mermaid

The math

An isolation tree is built by recursively choosing a random feature qq and a random split value pp uniformly between the current node’s minimum and maximum of qq, until every point is alone or a height limit is hit.

Write h(x)h(x) for the path length of point xx — 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 nn nodes is known:

c(n)=2H(n1)2(n1)n,H(i)=ln(i)+γ,γ0.5772156649c(n) = 2H(n-1) - \frac{2(n-1)}{n}, \qquad H(i) = \ln(i) + \gamma, \quad \gamma \approx 0.5772156649

where γ\gamma is the Euler-Mascheroni constant. Dividing by c(n)c(n) makes depths comparable. The anomaly score is then

s(x,n)=2E[h(x)]c(n)s(x, n) = 2^{\,-\dfrac{E[h(x)]}{c(n)}}

where E[h(x)]E[h(x)] is the average path length over all the trees. Read the three regimes off the exponent:

E[h(x)]E[h(x)] relative to c(n)c(n)ssVerdict
E[h]0E[h] \to 01\to 1Isolated instantly — definite anomaly
E[h]=c(n)E[h] = c(n)exactly 0.5Average depth — indistinguishable from normal
E[h]n1E[h] \to n - 10\to 0Very deep — definitely normal

With n=256n = 256:

c(256)=2(ln255+0.5772)2×255256=10.2448c(256) = 2\big(\ln 255 + 0.5772\big) - \frac{2 \times 255}{256} = 10.2448

So a point isolated at average depth 4 scores 24/10.2448=0.76292^{-4/10.2448} = 0.7629, at depth 10.2448 scores exactly 0.50.5, and at depth 14 scores 0.38780.3878.

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: 1,2,3,4,5,201, 2, 3, 4, 5, 20. The value 20 is the obvious anomaly.

Build one isolation tree. The root spans [1,20][1, 20]. Draw a random split pU(1,20)p \sim U(1, 20).

The point 20 is isolated at depth 1 whenever p>5p > 5 — probability (205)/(201)=15/19=0.789(20 - 5)/(20 - 1) = 15/19 = 0.789. 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: p(2,3)p \in (2, 3) gives {3,4,5,20}\{3, 4, 5, 20\} on the right, and p(3,4)p \in (3, 4) gives {1,2,3}\{1, 2, 3\} on the left. Its shortest possible path is 2, and typically it is much longer.

Averaging over 4,000 random trees:

PointE[h]E[h]
13.904
25.285
35.952
46.309
55.244
201.222

With n=6n = 6:

c(6)=2(ln5+0.5772)2×56=4.37331.6667=2.7066c(6) = 2(\ln 5 + 0.5772) - \frac{2 \times 5}{6} = 4.3733 - 1.6667 = 2.7066

So 20 scores 21.222/2.7066=0.73142^{-1.222/2.7066} = 0.7314 while 3 scores 25.952/2.7066=0.21782^{-5.952/2.7066} = 0.2178. 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:

GroupMean depth to isolation (60 trees)
True outliers9.46
Inliers18.09
figureIsolation depth separates the two populationsmatplotlib
A scatter plot coloured by mean isolation depth, dark in the dense centre and bright at the edges, beside a histogram where the outlier depths cluster near 6 and the inlier depths near 18.A scatter plot coloured by mean isolation depth, dark in the dense centre and bright at the edges, beside a histogram where the outlier depths cluster near 6 and the inlier depths near 18.
Left: mean depth over 60 random trees, per point. Right: the same values as two histograms. Outliers average 9.46 and inliers 18.09. The overlap in the middle is real — a handful of the uniform outliers happened to land inside the cloud, and no method can find those.

See it move

sketch Random splits isolate outliers first p5.js
Each beat adds one random axis-aligned cut. Points turn red the moment they are alone in their region; the corner outliers go red in a few splits while the crowd takes dozens.

The next sketch shows the scoring formula directly: drag through the depth axis and watch ss cross 0.5 exactly where E[h]=c(n)E[h] = c(n).

sketch Path length becomes a score p5.js
The curve is s = 2^(-E[h]/c(n)). The marker sweeps along it; the readout shows the depth, the normalisation c(n) for the current sample size, and the resulting score.

Notice how little c(n)c(n) moves: 64 rows give 7.72, 4096 give 15.79. Path length grows roughly logarithmically in nn, 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%):

contaminationcontaminationPoints flaggedPrecisionRecall
0.0131.00000.1500
0.0261.00000.3000
0.04120.91670.5500
0.0667200.80000.8000
0.10300.63330.9500
0.15450.44441.0000
0.20600.33331.0000
0.30900.22221.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.

figureOne parameter, one trade-offmatplotlib
A line plot of precision falling and recall rising as contamination increases, crossing at the true rate of 6.7 percent, beside a bar chart showing the number of points flagged growing linearly with contamination.A line plot of precision falling and recall rising as contamination increases, crossing at the true rate of 6.7 percent, beside a bar chart showing the number of points flagged growing linearly with contamination.
Precision and recall cross exactly at the true contamination rate. The right panel shows why: the number flagged is contamination times n, regardless of what the scores look like. Estimate the rate from the domain, or set it low and triage the top of the ranking by hand.

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.

figureThe decision function across the whole planematplotlib
A filled contour plot with a blue high-scoring region over the data cloud fading to red at the edges, a thick contour line at zero, and amber crosses marking the flagged points outside it.A filled contour plot with a blue high-scoring region over the data cloud fading to red at the edges, a thick contour line at zero, and amber crosses marking the flagged points outside it.
decision_function is positive inside the learned normal region and negative outside; the thick line is the zero contour set by contamination=0.07. The contours are blocky because they are built from axis-aligned random cuts — this is what an ensemble of 100 isolation trees actually looks like.

Four detectors, one dataset

Same 300 points, same 20 planted outliers, same 7% budget:

DetectorCorrect (of 20)False alarmsIdea
IsolationForestIsolationForest165Random splits; short path = odd
LocalOutlierFactorLocalOutlierFactor174Local density versus your neighbours’ local density
OneClassSVMOneClassSVM137A boundary enclosing the normal region
EllipticEnvelopeEllipticEnvelope183Robust Gaussian fit; Mahalanobis distance
figureFour detectors on identical datamatplotlib
Four scatter plots of the same contaminated cloud. Green crosses mark correctly identified outliers and red crosses mark wrongly flagged inliers, with counts in each title.Four scatter plots of the same contaminated cloud. Green crosses mark correctly identified outliers and red crosses mark wrongly flagged inliers, with counts in each title.
EllipticEnvelope wins here, and it should: the inliers really were drawn from a single correlated Gaussian, which is exactly its assumption. Isolation Forest is two behind while assuming nothing about the distribution. On non-Gaussian data the ranking reverses.

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.

DetectorAssumesScales to 1M rowsHigh dimensionsHas predictpredict
IsolationForestnothing muchyesgoodyes
LocalOutlierFactorlocal density is meaningfulno (O(n2)O(n^2))pooronly with novelty=Truenovelty=True
OneClassSVMa kernel-shaped boundaryno (O(n2)O(n^2)O(n3)O(n^3))moderateyes
EllipticEnvelopethe data is Gaussianmoderatepoor if p>np > nyes
SGDOneClassSVMa linear boundary in kernel spaceyesmoderateyes

In code

isolation_forest.py
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.6387
isolation_forest.py
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.6387

Ranking beats thresholding whenever a human is going to look at the results anyway:

rank_dont_threshold.py
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()))
rank_dont_threshold.py
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:

evaluate.py
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))
evaluate.py
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.

algorithmIsolation ForestUnsupervised — anomaly / outlier detection

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 ss 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 [0,1][0, 1] 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 ForestLocal Outlier FactorOne-Class SVMElliptic Envelope
Model of normalitynone — isolation easelocal density ratiokernel boundaryrobust Gaussian
Correct on the page’s data16 / 2017 / 2013 / 2018 / 20
Training costO(tψlogψ)O(t\,\psi \log \psi)O(n2)O(n^2)O(n2)O(n^2)O(n3)O(n^3)O(np2)O(n p^2)
Varying densitygoodbestmoderatepoor
Interpretable scorepath lengthdensity ratiodistance to boundaryMahalanobis distance
Needs scalingnoyesyesyes

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.

quizCheck yourself
  1. An Isolation Forest gives a point an average path length equal to c(n). What is its anomaly score in the paper's convention?

    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.

  2. You set contamination=0.30 on data that is really 5% anomalous. What happens?

    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.

  3. Why is the default max_samples 256 rather than the whole dataset?

    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.

  4. In scikit-learn, which direction of score_samples means 'more anomalous'?

    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.

  5. EllipticEnvelope beat Isolation Forest 18 to 16 on this page's data. What should you conclude?

    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 s=2E[h]/c(n)s = 2^{-E[h]/c(n)} uses the average BST path length c(n)c(n) so depths are comparable across dataset sizes. c(256)=10.2448c(256) = 10.2448, and E[h]=c(n)E[h] = c(n) gives exactly s=0.5s = 0.5.
  • 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.
  • contaminationcontamination is a quantile threshold. It flagged exactly round(c×n)\text{round}(c \times n) points at every setting while the ranking’s ROC AUC stayed at 0.9902.
  • scikit-learn negates the score: lower score_samplesscore_samples means 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 coffee

Was this page helpful?

Let us know how we did