← All Posts
Machine Learning · Anomaly Detection

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$:

Four possible partitions of the same five rows
Five training values on a number line; cut 10 isolates 20123420

The shaded region is the gap $(4, 20)$, which accounts for $16/19$ of the uniform draw space.

Left · value ≤ cut1, 2, 3, 4
Right · value > cut20

Cut 10: 4 rows left, 1 row right. 20 is isolated after one cut.

The four buttons show representative cuts from each partition interval. Under a uniform random threshold, the rightmost interval $(4, 20)$ is $16\times$ as wide as $[1, 2)$.

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:

$$h_t(x) = e_t(x) + c(n_{\text{leaf}})$$

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$.

Choose a query and inspect its route
  1. Cut 1: 20 ≤ 1.78?No → right branch.
  2. Cut 2: 20 ≤ 4.22?No → right branch.
  3. 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.

A fitted isolation tree with the selected query’s route highlightedInternal boxes contain thresholds; terminal boxes list their training rows. Green edges follow the query, and the reached leaf is gold.≤ 1.781≤ 4.22≤ 2.2323, 420

Query 20, tree 1: 2 cuts → leaf containing training row [20]. Add correction 0: corrected path = 2.

Corrected paths for the selected query across all three fitted trees
Tree 1Tree 2Tree 3Mean $\bar{h}(x)$
2111.3333
Cut labels are rounded to two decimals; routing uses full stored thresholds. Download the fitted trees and scores or reproduction script (verified with scikit-learn 1.9.1).

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:

$$c(n) = 2H_{n-1} - \frac{2(n-1)}{n} \quad (n > 1), \qquad c(1) = 0$$

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$:

$$c(5) = 2\bigl(\ln 4 + 0.57721566\bigr) - \frac{2(4)}{5} = 2(1.963510) - 1.6 = \mathbf{2.3270}$$

The normalized anomaly score $s(x) \in (0, 1]$ is then defined as:

$$s(x) = 2^{-\bar{h}(x)/c(\psi)}$$

where:

Step-by-step path and score calculation across all queries ($c(5) = 2.3270$).
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:

Change the cutoff while keeping the forest scores fixed
Anomaly score s0.6087Higher means more unusual
score_samples−0.6087Same ranking with reversed sign
decision_function−0.1087Negative triggers predict = −1

Query 1, contamination = auto. Offset = −0.5000. decision_function = −0.6087 − (−0.5000) = −0.1087. predict = −1 (library outlier).

With 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.

Summary of Isolation Forest design choices.
ChoiceWhat 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 coordinatesInterval 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