Anomaly Detection: Isolation Forests & LOF
Derive the mathematical average path length of Binary Search Trees in Isolation Forests, density ratios in Local Outlier Factor, and zero-dependency implementations in Python and Rust.
Anomaly detection identifies observations that deviate so substantially from the remainder of the data as to arouse suspicion that they were generated by a different underlying mechanism.
Rather than attempting to model the complex multi-modal density profile of normal inliers, modern algorithms like Isolation Forests exploit two quantitative quantitative properties of anomalies: they are few in number and possess attribute values divergent from normal points.
Measures isolation depth relative to the expected path length of a random binary search tree.
1. Isolation Forest: Deriving Expected Path Length #
An Isolation Tree (iTree) recursively bisects data points by selecting a feature at random and choosing a uniform random split threshold between the feature's minimum and maximum values.
Because anomalies reside in low-density peripheral regions, they require remarkably few random cuts to isolate into singleton leaves compared to normal points clustered densely together.
Derivation of BST Average Path Length #
An iTree has an identical structure to an unsuccessful search in a Random Binary Search Tree (BST). We derive the average path length for a dataset of size .
Step 1: Formulate the Recurrence Relation
Let denote the average depth of an unsuccessful search in a BST constructed from elements. When inserting a new node, it can fall into any of the intervals between sorted keys with equal probability .
The recurrence relation is:
By symmetry, :
Step 2: Eliminate the Summation via Difference Equations
Multiply by :
Write the identical relation for :
Subtract the second equation from the first:
Rearranging terms:
Step 3: Expand the Telescoping Sum
Using partial fraction decomposition :
Evaluating the summation via Harmonic numbers :
Step 4: Apply the Euler-Mascheroni Constant
Approximating the Harmonic series , where is the Euler-Mascheroni constant:
This value serves as the universal normalization constant.
2. The Anomaly Score Function#
For an ensemble of isolation trees, let denote the path length (number of edges traversed from root to leaf) for point . The mean path length is .
The Anomaly Score is defined as:
- When : (point isolated immediately; definite anomaly).
- When : (point exhibits average depth; ordinary instance).
- When : (deep leaf; structural inlier).
3. Local Outlier Factor (LOF)#
For datasets with variable cluster densities, distance-to-centroid metrics fail because an outlier near a dense cluster may have a smaller absolute distance than an inlier in a sparse cluster.
Local Outlier Factor (LOF) (Breunig et al., 2000) measures anomalousness relative to local neighborhood density.
Step 1: Reachability Distance
The reachability distance of point from neighbor is:
Where is the distance from to its -th nearest neighbor.
Step 2: Local Reachability Density (lrd)
The local reachability density of is the inverse of the average reachability distance of its -nearest neighbors :
Step 3: Local Outlier Factor
The LOF score is the average ratio of the of its neighbors to the of :
- If : Point has density comparable to its neighbors (inlier).
- If : Point has substantially lower density than its neighbors (outlier).
4. From-Scratch Implementation: Zero Dependencies#
The following implementations construct an Isolation Forest from scratch in Python and Rust without third-party dependencies.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110import math import random from typing import List, Optional class IsolationTreeNode: def __init__(self, size: int): self.size = size self.split_feature: Optional[int] = None self.split_value: Optional[float] = None self.left: Optional['IsolationTreeNode'] = None self.right: Optional['IsolationTreeNode'] = None @property def is_leaf(self) -> bool: return self.left is None and self.right is None class IsolationTree: def __init__(self, max_depth: int): self.max_depth = max_depth self.root: Optional[IsolationTreeNode] = None def fit(self, X: List[List[float]], current_depth: int = 0) -> IsolationTreeNode: num_samples = len(X) if current_depth >= self.max_depth or num_samples <= 1: return IsolationTreeNode(size=num_samples) num_features = len(X[0]) # Randomly choose split feature feat = random.randint(0, num_features - 1) vals = [row[feat] for row in X] min_v, max_v = min(vals), max(vals) if min_v == max_v: return IsolationTreeNode(size=num_samples) # Uniform random split threshold split_val = random.uniform(min_v, max_v) left_X = [row for row in X if row[feat] < split_val] right_X = [row for row in X if row[feat] >= split_val] node = IsolationTreeNode(size=num_samples) node.split_feature = feat node.split_value = split_val node.left = self.fit(left_X, current_depth + 1) node.right = self.fit(right_X, current_depth + 1) return node def path_length(self, sample: List[float], node: IsolationTreeNode, current_depth: int = 0) -> float: if node.is_leaf: # Add average path adjustment for unbuilt subtrees if node.size > 1: return current_depth + IsolationForestFromScratch.c_factor(node.size) return float(current_depth) feat = node.split_feature if sample[feat] < node.split_value: return self.path_length(sample, node.left, current_depth + 1) return self.path_length(sample, node.right, current_depth + 1) class IsolationForestFromScratch: def __init__(self, n_estimators: int = 50, max_samples: int = 256): self.n_estimators = n_estimators self.max_samples = max_samples self.trees: List[IsolationTree] = [] @staticmethod def c_factor(n: int) -> float: if n <= 1: return 0.0 if n == 2: return 1.0 # 2 * (ln(n - 1) + 0.5772156649) - (2 * (n - 1) / n) return 2.0 * (math.log(n - 1) + 0.5772156649) - (2.0 * (n - 1) / n) def fit(self, X: List[List[float]]) -> None: random.seed(42) num_samples = len(X) subsample_size = min(self.max_samples, num_samples) max_depth = math.ceil(math.log2(max(subsample_size, 2))) self.trees = [] for _ in range(self.n_estimators): sample_sub = random.sample(X, subsample_size) itree = IsolationTree(max_depth=max_depth) itree.root = itree.fit(sample_sub) self.trees.append(itree) def anomaly_score(self, X: List[List[float]]) -> List[float]: num_samples = len(X) c = self.c_factor(min(self.max_samples, num_samples)) scores = [] for sample in X: avg_h = sum(tree.path_length(sample, tree.root) for tree in self.trees) / self.n_estimators score = 2.0 ** (-avg_h / c) if c > 0 else 0.5 scores.append(score) return scores if __name__ == "__main__": X_train = [ [1.0, 1.0], [1.1, 1.2], [0.9, 0.8], [1.2, 1.1], [1.0, 0.9], [2.0, 2.1], [2.1, 1.9], [1.9, 2.0], [2.2, 2.2], [15.0, 14.0] # Severe Anomaly ] forest = IsolationForestFromScratch(n_estimators=30) forest.fit(X_train) scores = forest.anomaly_score(X_train) print("Anomaly Scores (Higher => More Anomalous):") for sample, s in zip(X_train, scores): print(f" Sample {sample} -> Score: {s:.4f}")
5. Active Knowledge Checks#
Why do anomalies in an Isolation Forest yield an anomaly score s(x, n) approaching 1.0?
What unique problem in clustering and anomaly detection does the Local Outlier Factor (LOF) address that global distance metrics fail to capture?