孤立森林异常检测:用随机孤立路径给极端行情打分
传统异常检测先给『正常』建模——估个分布、算个距离,再看谁离得远。孤立森林反着来:异常点本来就少、本来就偏,随机切几刀就能把它单独隔出来,正常点则要切很多刀。于是『被孤立所需的平均路径长度』直接变成异常分数,不需要距离度量、不需要分布假设。合成实验:1000 个交易日、4 维特征(绝对收益、5 日已实现波动、成交量 z、相关性尖峰 z),注入 25 个崩盘日。孤立森林 AUC 0.984,只看收益 z-score 的单变量基线 AUC 0.782——差距来自多维联动:单独看每一维都不算极端、但『量价相关性同时抬升』的日子,只有多维方法抓得住。top-25 命中精度 60% vs 基线 44%。森林规模敏感性:5 棵树 AUC 0.964,50 棵 0.986,100 棵之后彻底平台化——路径长度平均的收敛速度快得惊人。纯 numpy 实现(随机特征+随机分割点递归建树、c(n) 修正项、2^(-h/c) 分数归一化),拆穿分数有绝对阈值/无监督=不用验证/维度越多越好/能检测=能预测/训练集必须干净五类陷阱(中阶)。
先说结论:别人问「什么是正常」,孤立森林问「谁容易被切出来」——异常点因为少而偏,随机切几刀就被单独隔离,路径短就是异常。 本文合成实验:1000 个交易日注入 25 个崩盘日,孤立森林 AUC 0.984,只看收益幅度的 z-score 基线只有 0.782。差距的来源不是玄学,是多维联动:有些日子单看每一维都不极端,但「放量 + 相关性尖峰 + 波动抬升」同时出现——这种组合异常,单变量方法结构性失明。

一、问题:极端行情的「极端」,往往不在单一维度上#
风控里最朴素的异常检测是给收益率算 z-score,超过 3 就报警。它能抓住单日暴跌,但市场真正危险的日子经常长这样:指数只跌了 1.5%(z 大约 1.5,不触发),但成交量放到 3 倍、板块间相关性从 0.3 跳到 0.8——流动性挤兑的典型前兆,每一维单独看都够不上警报线。
多维异常检测的传统路线是「先建正常模型」:估计协方差算马氏距离,或者做密度估计看谁在低密度区。两条路都有硬伤——协方差估计本身会被异常点污染(这正是要检测的东西),密度估计在维度升高后样本需求爆炸。
2008 年 Liu、Ting、Zhou 提出的孤立森林(Isolation Forest)换了个思路:不建模正常,直接建模「容易被孤立」这件事本身。
二、原理:异常点的隔离路径天然更短#
想象在特征空间里随机切刀:每次随机选一个特征、在其取值范围内随机选一个分割点,把数据切成两半,递归下去直到每个点被单独隔开。
关键洞察:异常点少而偏,位于数据云边缘,往往一两刀就被单独切出来;正常点挤在稠密区,要切很多刀才能和邻居分开。 于是「平均隔离路径长度」天然就是异常分数——不需要定义距离,不需要假设分布。
单棵树是随机的、很弱;但对几百棵树的路径长度取平均,噪声互相抵消,信号留下来。分数归一化用:
其中 是平均路径长度, 是 n 个样本的二叉搜索树平均失败查找深度(归一化常数)。s 接近 1 → 异常;s 在 0.5 以下 → 正常。
三、纯 numpy 实现#
三个部件:建树(随机特征 + 随机分割点)、路径长度(叶子里剩多个点时用 c(n) 补偿)、森林打分。
import numpy as np
def c_factor(n):
if n <= 1:
return 0.0
H = np.log(n - 1) + 0.5772156649 # 调和数近似
return 2 * H - 2 * (n - 1) / n
def build_tree(X, idx, depth, max_depth, rng):
n = len(idx)
if depth >= max_depth or n <= 1:
return ("leaf", n)
q = rng.integers(X.shape[1]) # 随机特征
lo, hi = X[idx, q].min(), X[idx, q].max()
if lo == hi:
return ("leaf", n)
p = rng.uniform(lo, hi) # 随机分割点
left, right = idx[X[idx, q] < p], idx[X[idx, q] >= p]
return ("node", q, p,
build_tree(X, left, depth + 1, max_depth, rng),
build_tree(X, right, depth + 1, max_depth, rng))
def path_length(tree, x, depth=0):
if tree[0] == "leaf":
n = tree[1]
return depth + (c_factor(n) if n > 1 else 0)
_, q, p, L, R = tree
return path_length(L if x[q] < p else R, x, depth + 1)
def isolation_forest(X, n_trees=200, sample=256, seed=0):
rng = np.random.default_rng(seed)
n = len(X); sample = min(sample, n)
max_depth = int(np.ceil(np.log2(sample)))
trees = [build_tree(X, rng.choice(n, sample, replace=False),
0, max_depth, rng) for _ in range(n_trees)]
cn = c_factor(sample)
return np.array([2 ** (-np.mean([path_length(t, x) for t in trees]) / cn)
for x in X])python两个原论文的设计细节容易被忽略:子采样 256 不是省算力的妥协,而是刻意为之——样本太多时正常点会「淹没」异常点周围的空间,小子样反而让异常更容易被早早隔离(swamping/masking 问题);树深上限 log2(256)=8 是因为超过平均深度的路径对区分异常没有增量信息,砍掉省时间。
四、实验:AUC 0.984 vs 单变量基线 0.782#
合成 1000 个交易日,正常日收益 ~N(0.04%, 1%)。注入 25 个崩盘日:收益波动放大 4.5 倍、成交量 z 均值 3.5、相关性尖峰 z 均值 3.0。特征取 4 维:绝对收益、5 日已实现波动、成交量 z、相关性尖峰 z。
| 方法 | AUC | precision@25 | recall@25 |
|---|---|---|---|
| 孤立森林(4 维) | 0.984 | 60.0% | 60.0% |
| 收益 z-score(单变量) | 0.782 | 44.0% | 44.0% |
异常日的平均分数 0.654,正常日 0.423,分布清晰分离:

在(成交量 z, 相关性 z)平面上看分数热图更直观——孤立森林没有用任何距离度量,却把边缘区域的分数自然抬高了。红圈是真实异常日,绝大多数落在高分区:

为什么单变量基线差这么多? 25 个异常日里有相当一部分收益幅度并不惊人(注入的是均值 0 的宽分布,部分样本碰巧接近 0),它们的「异常性」全在量和相关性维度上。z-score 基线对这些日子分数平平;孤立森林在 4 维空间里看到「这个组合位置很偏」,一刀就切出来了。
森林规模的收敛速度快得出乎意料:
| 树数 | 5 | 10 | 25 | 50 | 100 | 200 | 400 |
|---|---|---|---|---|---|---|---|
| AUC | 0.964 | 0.973 | 0.984 | 0.986 | 0.985 | 0.987 | 0.986 |

5 棵树就到 0.964,50 棵基本平台化。这是路径长度平均的方差衰减在起作用——生产上 100~200 棵完全够,不必堆到上千。
五、五个常见陷阱#
陷阱一:「分数超过 0.6 就是异常」——没有绝对阈值。 归一化分数的分布随数据维度、子样本大小、特征相关性变化。同一份代码换个数据集,正常日的分数中枢可能整体平移。正确用法是看分位数(比如 top 2%)或在有标注的历史窗口上校准阈值,绝不硬编码 0.6。
陷阱二:「无监督 = 不用验证」。 无监督只是说训练不用标签,不是说效果不用检验。至少要做:注入已知异常测召回(本文的做法)、人工复核 top-k、与简单基线对比。跳过验证直接上线的异常检测器,报出来的警报没人敢信,很快就会被运维静音——然后在真出事那天一起被静音。
陷阱三:「特征越多越好」。 孤立森林每次分裂随机选特征。如果 20 个特征里只有 3 个携带异常信号,大部分分裂刀切在无关维度上,信号被稀释。特征进模型前先问:这个维度上的极端值,业务上算异常吗?答案模糊的先别放。
陷阱四:「能检测 = 能预测」。 本文检测的是「当天已经是异常日」——事中识别,不是事前预警。把异常分数当预测信号用(今天分数高→明天减仓)需要单独检验分数对未来收益/波动的领先性,那是另一个实验,且大概率残酷得多。事中识别的价值在风控响应速度和数据清洗,别偷换成择时能力。
陷阱五:「训练集必须干净」。 恰恰相反,孤立森林的设计前提就是训练数据里混着异常(它靠异常「少而偏」的性质工作,不靠标签)。真正要担心的是反面情况:异常比例太高(>10%)时,「少」这个前提崩塌,异常点开始互相掩护(masking),路径长度优势消失。这时要么先粗筛降低异常浓度,要么换有监督方法。
六、工程要点#
- 子采样 256 是特性不是缺陷:加大子样本反而可能因 swamping 效应降低检测力,改之前先做对照实验。
- 滚动窗口重训:市场的「正常」在漂移,2020 年的正常波动放到 2017 年就是异常。用滚动 1~2 年窗口重建森林,别用全历史。
- 特征要预先思考方向性:孤立森林对「值域边缘」敏感,对「分布内部的结构异常」不敏感。相关性突变这类信号要先构造成显式特征(本文的 corr z),指望模型从原始价格里自己领悟是幻想。
- 分数落库、警报分级:连续分数比二值警报有用得多——0.72 和 0.95 的响应等级应该不同。
孤立森林的本质是把「异常 = 少而偏」这个先验直接编译成算法结构。它换来了免距离、免分布假设、近线性复杂度;代价是对「多而不偏」或「偏而不少」的异常失效。知道它的边界在哪,它就是风控工具箱里性价比最高的一把刀。