This is the second post in the series on model-based outlier detection. The first post discussed what outliers are, why z-scores fail through masking, and how robust statistics help. In this post I shall discuss Isolation Forest, one of the most widely used methods for detecting anomalies, and build it from scratch before using scikit-learn.
The key idea: anomalies are easy to isolate
Most methods profile what "normal" looks like and then flag whatever does not fit. Isolation Forest (Liu, Ting and Zhou, 2008) turns this around. Anomalies are few and different, so if we keep cutting the feature space at random, an anomaly is separated from the rest after only a few cuts, while a point deep inside a dense cluster needs many more.
The number of cuts needed to isolate a point is its path length. Short paths suggest anomalies and long paths suggest regular points. No distance, density or distributional assumption is needed.
Building an isolation tree
An isolation tree (iTree) is built on a sample of \(\psi\) points as follows.
- Pick a feature \(q\) at random.
- Pick a split value \(p\) uniformly at random between the minimum and maximum of that feature in the current node.
- Send points with \(x_q < p\) to the left child and the rest to the right child.
- Repeat on each child until a node has one point, all its points are identical, or the tree reaches the height limit \(l = \lceil \log_2 \psi \rceil\).
The height limit is deliberate. We only care about points that are isolated early, so there is no need to grow the tree fully.
From path length to anomaly score
For a point \(x\), let \(h(x)\) be the number of edges from the root to the leaf where \(x\) ends up. If that leaf still holds \(n\) unseparated points (because the height limit was reached), we add an adjustment \(c(n)\) for the splits the tree would still have needed. Averaging over all trees gives \(E[h(x)]\).
The adjustment is the average path length of an unsuccessful search in a binary search tree built on \(n\) points:
where \(\gamma \approx 0.5772\) is Euler's constant. It also serves as a normaliser, so that scores are comparable across sample sizes. The anomaly score is
$$s(x, \psi) = 2^{-\frac{E[h(x)]}{c(\psi)}}$$The score behaves as follows.
- As \(E[h(x)] \to 0\), \(s \to 1\). The point is almost certainly an anomaly.
- When \(E[h(x)] = c(\psi)\), \(s = 0.5\). The point is average.
- As \(E[h(x)] \to \psi - 1\), \(s \to 0\). The point is very safely regular.
If every point scores close to 0.5, the data has no distinct anomalies. For the default \(\psi = 256\), \(c(\psi) \approx 10.24\).
Why subsampling helps
Each tree is built on a small random subsample (256 points by default in scikit-learn), not on the whole dataset. This sounds like it should hurt, but it helps. With the full data, anomalies sitting close to normal points (swamping) or clustered together (masking) become hard to isolate. A small sample thins out both effects. It also makes the method fast, since training cost depends on \(\psi\) and the number of trees rather than the dataset size, which is why Isolation Forest scales to large datasets.
Implementing it from scratch
The code below follows the description above. The three classes together are about 50 lines.
import numpy as np
def c(n):
"""Average path length of an unsuccessful search in a binary search tree."""
if n <= 1:
return 0.0
if n == 2:
return 1.0
return 2.0 * (np.log(n - 1) + np.euler_gamma) - 2.0 * (n - 1) / n
class IsoTree:
def __init__(self, X, height, max_height, rng):
n = X.shape[0]
self.size = n
self.leaf = (height >= max_height) or (n <= 1)
if not self.leaf:
q = rng.randint(X.shape[1]) # random feature
lo, hi = X[:, q].min(), X[:, q].max()
if lo == hi: # cannot split further
self.leaf = True
else:
self.q, self.p = q, rng.uniform(lo, hi) # random split value
mask = X[:, q] < self.p
self.left = IsoTree(X[mask], height + 1, max_height, rng)
self.right = IsoTree(X[~mask], height + 1, max_height, rng)
def path_length(self, x, height=0):
if self.leaf:
return height + c(self.size) # adjustment for unexpanded leaf
child = self.left if x[self.q] < self.p else self.right
return child.path_length(x, height + 1)
class MyIsolationForest:
def __init__(self, n_trees=100, psi=256, seed=0):
self.n_trees, self.psi, self.seed = n_trees, psi, seed
def fit(self, X):
rng = np.random.RandomState(self.seed)
self.psi_ = min(self.psi, len(X))
max_h = int(np.ceil(np.log2(self.psi_))) # height limit
self.trees = [
IsoTree(X[rng.choice(len(X), self.psi_, replace=False)], 0, max_h, rng)
for _ in range(self.n_trees)
]
return self
def avg_path_length(self, X):
return np.array([[t.path_length(x) for t in self.trees] for x in X]).mean(axis=1)
def anomaly_score(self, X):
return 2.0 ** (-self.avg_path_length(X) / c(self.psi_))
Comparing with scikit-learn
Now generate a dataset with two clusters of different densities plus 30 uniformly scattered outliers, and run both our forest and scikit-learn's IsolationForest on it. (Run the code above first, since this block reuses c and MyIsolationForest.)
from scipy.stats import spearmanr
from sklearn.datasets import make_blobs
from sklearn.ensemble import IsolationForest
from sklearn.preprocessing import StandardScaler
rng = np.random.RandomState(42)
X_in, _ = make_blobs(n_samples=[300, 300], centers=[[-2, -2], [3, 3]],
cluster_std=[0.5, 1.2], random_state=42)
X_out = rng.uniform(-6, 8, size=(30, 2))
X = StandardScaler().fit_transform(np.vstack([X_in, X_out]))
is_out = np.r_[np.zeros(len(X_in), bool), np.ones(len(X_out), bool)]
# 1. Our own forest
mine = MyIsolationForest(n_trees=100, psi=256, seed=0).fit(X)
eh = mine.avg_path_length(X) # E[h(x)]
s_mine = mine.anomaly_score(X) # s(x)
print("c(256) = %.2f" % c(256))
print("mean E[h]: regular = %.2f, planted outliers = %.2f"
% (eh[~is_out].mean(), eh[is_out].mean()))
print("mean s(x): regular = %.3f, planted outliers = %.3f"
% (s_mine[~is_out].mean(), s_mine[is_out].mean()))
# 2. scikit-learn
iso = IsolationForest(n_estimators=100, random_state=0).fit(X)
s_sk = -iso.score_samples(X) # score_samples returns the NEGATIVE of s(x)
print("max_samples_ = %d, offset_ = %.1f" % (iso.max_samples_, iso.offset_))
print("Spearman correlation (ours vs sklearn): %.3f" % spearmanr(s_mine, s_sk).correlation)
# 3. Effect of the contamination parameter
def report(name, model):
flagged = model.predict(X) == -1
tp = (flagged & is_out).sum()
fp = (flagged & ~is_out).sum()
print(f"{name}: flagged={flagged.sum()}, caught={tp}/{is_out.sum()}, false alarms={fp}")
report("contamination='auto'", iso)
for cont in [0.02, 0.05, 0.10, 0.20]:
report(f"contamination={cont}",
IsolationForest(n_estimators=100, contamination=cont, random_state=0).fit(X))
# 4. Sensitivity to the random seed (contamination fixed at 0.05)
for seed in range(3):
report(f"seed={seed}, contamination=0.05",
IsolationForest(n_estimators=100, contamination=0.05, random_state=seed).fit(X))
The output is as follows.
- The average path length \(E[h(x)]\) is 12.16 for regular points and 6.72 for the planted outliers. The mean anomaly score \(s(x)\) is 0.441 for regular points and 0.641 for the outliers.
- scikit-learn uses 256 samples per tree by default, and with
contamination='auto'its decision threshold corresponds to \(s = 0.5\) (offset_is −0.5). - The Spearman rank correlation between our scores and scikit-learn's is 0.975. The two forests are built from different random trees and differ in some implementation details, yet they rank the points almost identically.
One detail to remember: scikit-learn's score_samples returns the negative of \(s(x)\), so higher means more normal. This is why the code above uses -iso.score_samples(X). Likewise decision_function is positive for inliers and negative for outliers, and predict returns +1 or −1.
The contamination parameter matters a lot
The last loop in the code above varies the contamination parameter, which is the fraction of points you expect to be anomalies. The table shows what happened with 600 regular points and 30 planted outliers.
| contamination | flagged | outliers caught (of 30) | false alarms |
|---|---|---|---|
| 'auto' (threshold s = 0.5) | 97 | 27 | 70 |
| 0.02 | 13 | 13 | 0 |
| 0.05 | 32 | 22 | 10 |
| 0.10 | 63 | 27 | 36 |
| 0.20 | 126 | 27 | 99 |
Three lessons follow from this.
- The default is not a safe choice. With
'auto', the broad cluster's fringe scores above 0.5, so 70 regular points are flagged. A threshold of 0.5 is not a universal rule for "anomalous." - Raising contamination trades precision for recall. Going from 0.02 to 0.10 catches more outliers, but false alarms jump from 0 to 36.
- The seed matters only slightly. With
contamination=0.05, three different seeds all flag 32 points, and catch 22, 23 and 22 of the outliers.
In practice, treat the score as a ranking, look at the distribution of scores, and choose the threshold from the cost of a missed anomaly versus a false alarm. Reviewing the top-ranked cases by hand is often the best calibration you can get without labels.
Visualising the result
import matplotlib.pyplot as plt
m5 = IsolationForest(n_estimators=100, contamination=0.05, random_state=0).fit(X)
flagged = m5.predict(X) == -1
xx, yy = np.meshgrid(np.linspace(X[:, 0].min() - 0.5, X[:, 0].max() + 0.5, 300),
np.linspace(X[:, 1].min() - 0.5, X[:, 1].max() + 0.5, 300))
Z = -m5.score_samples(np.c_[xx.ravel(), yy.ravel()]).reshape(xx.shape)
fig, axes = plt.subplots(1, 2, figsize=(12, 4.8))
ax = axes[0]
cs = ax.contourf(xx, yy, Z, levels=20, cmap="viridis_r", alpha=0.85)
plt.colorbar(cs, ax=ax, label="anomaly score s(x)")
ax.scatter(X[~flagged, 0], X[~flagged, 1], s=12, c="white",
edgecolors="k", linewidths=0.4, label="not flagged")
ax.scatter(X[flagged, 0], X[flagged, 1], s=30, c="red",
edgecolors="k", linewidths=0.5, label="flagged")
ax.set_title("Isolation Forest anomaly-score map")
ax.set_xlabel("x1 (standardised)")
ax.set_ylabel("x2 (standardised)")
ax.legend(loc="lower right", fontsize=8, framealpha=0.9)
ax = axes[1]
bins = np.linspace(eh.min(), eh.max(), 30)
ax.hist(eh[~is_out], bins=bins, alpha=0.7, density=True, label="regular points")
ax.hist(eh[is_out], bins=bins, alpha=0.7, density=True, color="tab:red",
label="planted outliers")
ax.axvline(c(mine.psi_), color="k", ls="--", lw=1, label=r"$c(\psi)$ (score = 0.5)")
ax.set_title("Average path length E[h(x)] (our own forest)")
ax.set_xlabel("E[h(x)]")
ax.set_ylabel("density")
ax.legend(fontsize=8)
plt.tight_layout()
plt.show()
In the left panel, yellow-green regions have a low score (regular) and purple regions a high score (anomalous). In the right panel, most planted outliers have an average path length between about 4 and 9, while most regular points are above 10. The dashed line marks \(c(\psi)\), where the score is 0.5.
Notice the blocky, axis-aligned bands in the score map, especially in the corners. They come from the axis-parallel splits, which can give regions along the axes slightly different scores from regions in other directions. Variants such as the Extended Isolation Forest use randomly oriented splits to reduce this artefact.
Why were three outliers never caught?
In the table, no setting catches more than 27 of the 30 planted outliers. The reason is in the data. The outliers were drawn uniformly from a wide box, so a few of them landed inside the clusters, where they are not really different from the regular points. The code below checks this.
from sklearn.neighbors import NearestNeighbors
m20 = IsolationForest(n_estimators=100, contamination=0.20, random_state=0).fit(X)
missed = np.where(is_out & (m20.predict(X) == 1))[0] # planted outliers not flagged
nn = NearestNeighbors(n_neighbors=11).fit(X[~is_out])
d_missed = nn.kneighbors(X[missed])[0][:, 10] # distance to 10th regular neighbour
d_regular = nn.kneighbors(X[~is_out])[0][:, 10]
print("missed outliers:", missed)
print("their 10th-neighbour distances:", np.round(d_missed, 3))
print("regular points: median = %.3f, 90th percentile = %.3f"
% (np.median(d_regular), np.percentile(d_regular, 90)))
The three missed points have a 10th-nearest-neighbour distance of 0.14 to 0.17, which is within the range of regular points (median 0.115, 90th percentile 0.248). They are not outliers in any useful sense, and no detector should flag them without producing many false alarms. This is a good reminder that "planted outlier" and "detectable outlier" are not the same thing.
Practical notes
- Feature scaling is not required. Each split is drawn within a feature's own range, so rescaling a feature does not change the trees. I standardised the data here only to make plotting convenient.
- Irrelevant features hurt. Random features dilute the splits, so drop noisy features or select them first.
- Local anomalies are a weakness. Isolation Forest is strongest at global anomalies. A point that is unusual only relative to its immediate neighbourhood, especially next to a dense cluster, may score as normal. That is where density-based methods do better.
- Discrete and categorical features need care. Splits are on numeric ranges, so encode categories thoughtfully.
- Novelty detection: you can fit on clean data and call
predictordecision_functionon new observations.
Conclusion
In this post I discussed how Isolation Forest isolates anomalies with random splits, how path length becomes an anomaly score, and how to build the method from scratch and compare it with scikit-learn. I also showed that the choice of threshold matters at least as much as the algorithm. In the next post I shall discuss the Local Outlier Factor, which compares each point's density with that of its neighbours and so handles clusters of different densities.
Reference: F. T. Liu, K. M. Ting and Z.-H. Zhou, "Isolation Forest", Proceedings of the 8th IEEE International Conference on Data Mining (ICDM), 2008.
The code in this post was tested with Python 3 and scikit-learn 1.8.0.

