Isolation Forest: Follow One Point Through a Forest
Put five observations on a number line: $X_{\text{train}} = \{1, 2, 3, 4, 20\}$. Most of the span $[1, 20]$ lies in the empty gap between $4$ and $20$. A uniform random cut landing in $(4, 20)$ immediately separates $20$ from the other four observations. Isolation Forest turns this geometric property into a score: observations isolated in fewer random cuts receive higher anomaly scores.
A random cut is uniform in feature space
At each internal node, an isolation tree picks a feature at random and draws a split threshold $q \sim \mathrm{Uniform}(\min x, \max x)$ between the node's observed minimum and maximum. It splits the rows into left ($x \le q$) and right ($x > q$) children without using labels or class purity (Liu, Ting & Zhou, 2008).
On $X_{\text{train}} = \{1, 2, 3, 4, 20\}$, the root cut interval has length $20 - 1 = 19$:
- Isolating $20$ on cut 1: requires $q \in (4, 20)$, which has length $20 - 4 = 16$, so $P(\text{isolate } 20) = \frac{16}{19} \approx \mathbf{84.2\%}$.
- Isolating $1$ on cut 1: requires $q \in [1, 2)$, which has length $2 - 1 = 1$, so $P(\text{isolate } 1) = \frac{1}{19} \approx \mathbf{5.3\%}$.
- Interior points ($2, 3, 4$): cannot be isolated by a single threshold on this feature ($0\%$ on cut 1).
The shaded region is the gap $(4, 20)$, which accounts for $16/19$ of the uniform draw space.
Cut 10: 4 rows left, 1 row right. 20 is isolated after one cut.
Splitting continues until a row is alone in its leaf, all rows at the node have identical values, or the tree reaches its depth limit $\lceil \log_2 \psi \rceil$, where $\psi$ is the tree's subsample size.
Follow an actual fitted tree and its leaf correction
The interactive figure below shows a reproducible `scikit-learn` fit with $T = 3$ trees, subsample size $\psi = 5$, and `random_state=1`. The depth limit is $\lceil \log_2 5 \rceil = 3$. When a query $x$ reaches a leaf $L_t(x)$ at edge depth $e_t(x)$ containing $n_{\text{leaf}} = |L_t(x)|$ training rows, its corrected path length in tree $t$ is:
where $e_t(x)$ counts the cuts traversed from root to leaf, and $c(n_{\text{leaf}})$ adds the expected remaining cuts needed to isolate $n_{\text{leaf}}$ unsplit rows. For a singleton leaf ($n_{\text{leaf}} = 1$), $c(1) = 0$; for a two-row leaf ($n_{\text{leaf}} = 2$, such as $\{3, 4\}$ at depth $3$), $c(2) = 1$, giving $h_t(3) = 3 + 1 = 4$.
- Cut 1: 20 ≤ 1.78?No → right branch.
- Cut 2: 20 ≤ 4.22?No → right branch.
- Leaf: training rows [20]2 cuts + 0 correction = 2 path units.
Inspect the complete fitted tree
Left branches mean $\le$ the threshold; right branches mean $>$. Green edges follow the query.
Query 20, tree 1: 2 cuts → leaf containing training row [20]. Add correction 0: corrected path = 2.
| Tree 1 | Tree 2 | Tree 3 | Mean $\bar{h}(x)$ |
|---|---|---|---|
| 2 | 1 | 1 | 1.3333 |
Convert the average path into a normalized anomaly score
A tree built on a larger subsample $\psi$ produces deeper paths on average. Isolation Forest normalizes the mean corrected path $\bar{h}(x) = \frac{1}{T}\sum_{t=1}^T h_t(x)$ by the expected path length $c(\psi)$ of an unsuccessful search in a Binary Search Tree on $\psi$ nodes:
where $H_{n-1}$ is the harmonic number. In scikit-learn's implementation, $c(1) = 0$, $c(2) = 1$, and for $n > 2$ the harmonic number is approximated by $H_{n-1} \approx \ln(n-1) + \gamma$ (with Euler's constant $\gamma \approx 0.57721566$). Plugging in $\psi = 5$:
The normalized anomaly score $s(x) \in (0, 1]$ is then defined as:
where:
- $\bar{h}(x)$ is the mean corrected path length of $x$ across all $T$ trees (averaged before exponentiating),
- $c(\psi)$ is the reference path length for subsample size $\psi$ ($s(x) = 0.5$ when $\bar{h}(x) = c(\psi)$; shorter paths yield $s(x) \to 1$).
| Query $x$ | Tree paths $(h_1, h_2, h_3)$ | Mean path $\bar{h}(x)$ | Plugging into $s(x) = 2^{-\bar{h}(x)/c(5)}$ | Score $s(x)$ |
|---|---|---|---|---|
| $1$ | $1,\; 2,\; 2$ | $\frac{1+2+2}{3} = 1.6667$ | $2^{-1.6667 / 2.3270} = 2^{-0.7162}$ | $\mathbf{0.6087}$ |
| $2$ | $3,\; 3,\; 3$ | $\frac{3+3+3}{3} = 3.0000$ | $2^{-3.0000 / 2.3270} = 2^{-1.2892}$ | $\mathbf{0.4092}$ |
| $3$ | $3+1,\; 3+1,\; 3+1$ | $\frac{4+4+4}{3} = 4.0000$ | $2^{-4.0000 / 2.3270} = 2^{-1.7189}$ | $\mathbf{0.3038}$ |
| $4$ | $3+1,\; 3+1,\; 3+1$ | $\frac{4+4+4}{3} = 4.0000$ | $2^{-4.0000 / 2.3270} = 2^{-1.7189}$ | $\mathbf{0.3038}$ |
| $20$ | $2,\; 1,\; 1$ | $\frac{2+1+1}{3} = 1.3333$ | $2^{-1.3333 / 2.3270} = 2^{-0.5730}$ | $\mathbf{0.6722}$ |
| $100$ (unseen) | $2,\; 1,\; 1$ | $\frac{2+1+1}{3} = 1.3333$ | $2^{-1.3333 / 2.3270} = 2^{-0.5730}$ | $\mathbf{0.6722}$ |
Three output names, two different decisions
Scikit-learn exposes three related quantities from the same fitted forest:
- Anomaly score $s(x)$: higher means more unusual ($s(x) = 2^{-\bar{h}(x)/c(\psi)}$).
score_samples(x): returns $-s(x)$, reversing the sign so lower (more negative) values mean more unusual.decision_function(x): shiftsscore_samples(x)by the thresholdoffset_: $$\mathrm{decision\_function}(x) = \mathrm{score\_samples}(x) - \mathrm{offset\_} = -s(x) - \mathrm{offset\_}$$ When $\mathrm{decision\_function}(x) < 0$,predict(x)returns $-1$ (outlier); otherwise $+1$ (inlier).
Query 1, contamination = auto. Offset = −0.5000. decision_function = −0.6087 − (−0.5000) = −0.1087. predict = −1 (library outlier).
contamination="auto", offset_ = -0.5000 (marking $s(x) > 0.5$ as outliers). With contamination=0.2, offset_ = -0.6214 (the 20th percentile of training score_samples), which flips query $1$ from $-1$ to $+1$ without changing $s(1) = 0.6087$.Run the fitted example
import numpy as np
from sklearn.ensemble import IsolationForest
X_train = np.array([1, 2, 3, 4, 20], dtype=np.float32).reshape(-1, 1)
X_query = np.array([1, 2, 3, 4, 20, 100], dtype=np.float32).reshape(-1, 1)
model = IsolationForest(
n_estimators=3, max_samples=5, contamination="auto", random_state=1,
).fit(X_train)
unusualness = -model.score_samples(X_query)
decision = model.decision_function(X_query)
print(np.round(unusualness, 4))
# [0.6087 0.4092 0.3038 0.3038 0.6722 0.6722]
The full script exports each tree, query route, corrected path, offset, and score, and verifies the manual formula against score_samples.
Structural limits of axis-aligned cuts
Notice that queries $20$ and $100$ traverse the exact same branches and receive identical scores ($s(20) = s(100) = 0.6722$). Once a query exceeds the highest split threshold in a tree, it lands in the outermost leaf—trees do not extrapolate distance beyond training cuts. Likewise, monotone nonlinear transforms (like $\operatorname{log1p}$) preserve sorting order but compress tail intervals, changing the probability of isolating extreme points—covered next in the telemetry chapter.
| Choice | What it controls |
|---|---|
| Number of trees $T$ | Variance of the sample mean path $\bar{h}(x)$ across random partitions. |
| Subsample size $\psi$ | Tree depth limit $\lceil \log_2 \psi \rceil$, normalization constant $c(\psi)$, and sensitivity to clustered outliers (swamping/masking). |
| Feature coordinates | Interval lengths $[\min x, \max x]$ from which uniform splits are drawn. |
Contamination / offset_ | Binary decision cutoff in decision_function; leaves $s(x)$ and rankings unchanged. |
References and further reading
- Liu, Ting & Zhou: Isolation Forest — Random partitioning, average path lengths, subsampling, and the reference-length score.
- scikit-learn: IsolationForest — Parameters and score/predict conventions; the example was run with version 1.9.1.
- scikit-learn 1.9.1 source — The actual depth correction, normalization, score transform, and contamination offset used for these numbers.