异常检测:建模正常,标记偏离 本节摘要:「正常」容易定义,「异常」就是不符合的任何东西。一张信用卡下午 2 点在纽约刷,2:05 在东京刷;工厂传感器读 150 度而正常是 80120;服务器每秒发 5 万请求而日均 200。这些都是异常(Anomaly),找到它们很要紧——欺诈损失数十亿,设备故障损失停机,网络入侵损失数据。挑战在于:你几乎没有异常的标注样本。欺诈只占交易 0.1%,设备故障一年几次。你训不了标准分类器,因为「异常」类几乎没东西可学;而且见过的异常不是你会遇到的全部,明天的欺诈套路跟今天不一样。异常检测翻转问题:不学什么是异常,学什么是正常,任何偏离正常的都可疑。这无需标签、适应新型异常、可扩展到海量数据。
本节摘要:「正常」容易定义,「异常」就是不符合的任何东西。一张信用卡下午 2 点在纽约刷,2:05 在东京刷;工厂传感器读 150 度而正常是 80~120;服务器每秒发 5 万请求而日均 200。这些都是异常(Anomaly),找到它们很要紧——欺诈损失数十亿,设备故障损失停机,网络入侵损失数据。挑战在于:你几乎没有异常的标注样本。欺诈只占交易 0.1%,设备故障一年几次。你训不了标准分类器,因为「异常」类几乎没东西可学;而且见过的异常不是你会遇到的全部,明天的欺诈套路跟今天不一样。异常检测翻转问题:不学什么是异常,学什么是正常,任何偏离正常的都可疑。这无需标签、适应新型异常、可扩展到海量数据。本节讲透三种异常类型(点、上下文、集合)、有监督 vs 无监督的权衡,从零实现 Z 分数、IQR、孤立森林(Isolation Forest)、局部离群因子(LOF),并讨论生产里的阈值选择与告警疲劳。
阅读完本节,你应当能够:
一张信用卡下午 2 点在纽约刷,2:05 在东京刷。工厂传感器读 150 度而正常范围 80~120。服务器每秒发 5 万请求而日均 200。
这些都是异常。找到它们很要紧。欺诈损失数十亿。设备故障损失停机。网络入侵损失数据。
挑战在于:你几乎没有异常的标注样本。欺诈占交易 0.1%。设备故障一年几次。你训不了标准分类器,因为「异常」类里几乎没东西可学。即使你有些标签,你见过的异常类型不是你会遇到的全部。明天的欺诈套路跟今天不一样。
异常检测翻转问题。不学什么是异常,学什么是正常。任何偏离正常的都可疑。这无需标签、适应新型异常、可扩展到海量数据。
不是所有异常都一样:
多数方法检测点异常。上下文异常需要时间或位置特征。集合异常需要序列感知方法。
标准分类里两类都有标签。异常检测里你通常处于三种情况之一:
关键洞见:异常检测与分类根本不同。你在建模正常数据的分布,不是两类之间的决策边界。
若你确有标注异常,该用它们训练(有监督分类)还是仅评估(无监督检测)?
有监督(当分类处理):
无监督(建模正常,标记偏离):
实践中,最好的系统两者结合:无监督检测提供广覆盖,有监督模型处理已知高优先级异常类型,人工审核处理模糊情况。
最简单的方法。算每特征的均值和标准差。标记任何距均值超过 k 个标准差的点。
z_score = (x - mean) / std 若 |z_score| > threshold 则为异常
默认阈值 3.0(高斯分布下 99.7% 正常数据落在 3 个标准差内)。
优点:简单、快、可解释(「这个值偏离正常 4.5 个标准差」)。
缺点:假设数据正态分布。对训练数据里的离群点敏感(离群点拉偏均值、膨胀标准差,使自己更难被检测)。在多模态分布上失败。
何时好用:单特征监控,数据近似钟形。服务器响应时间、制造公差、有稳定基线的传感器读数。
何时失败:多簇数据(两个办公地不同基线温度)、偏斜数据(交易额 1000 美元稀有但非异常)、训练集含离群点。
比 Z 分数更鲁棒。用四分位距代替均值和标准差。
Q1 = 25 百分位 Q3 = 75 百分位 IQR = Q3 - Q1 下界 = Q1 - factor * IQR 上界 = Q3 + factor * IQR 若 x < 下界 或 x > 上界 则为异常
默认因子 1.5。
优点:对离群点鲁棒(百分位不受极值影响)。适用于偏斜分布。无正态假设。
缺点:仅单变量(每特征独立应用)。抓不到「单看每特征都正常,合起来才异常」的点(一个点可能在每特征上正常,但在联合空间异常)。
实践注记:IQR 的 1.5 因子对应箱线图的须。须外的点是潜在离群点。用 3.0 代替 1.5 让检测器更保守(更少标记、更少假阳性)。合适的因子取决于你对假告警的容忍度。
关键洞见:异常少且不同。在数据的随机划分里,异常更容易被孤立——它们需要更少随机分裂就能从其余中分离开。
工作原理:
为何有效: 正常点住在稠密区。需要许多随机分裂才能把它与邻居分开。异常住在稀疏区。一两个随机分裂就够孤立它。
异常分数基于所有树的平均路径长度,用随机二叉搜索树的期望路径长度归一化:
score(x) = 2^(-average_path_length(x) / c(n))
其中 c(n) 是 n 个样本的期望路径长度。分数近 1 意味异常。近 0.5 意味正常。近 0 意味非常正常(深在稠密簇里)。
优点:无分布假设。高维有效。扩展好(样本量次线性,因为每棵树用子样本)。处理混合特征类型。
缺点:在稠密区里的异常挣扎(掩蔽效应)。许多特征无关时随机分裂效果差。
关键超参数:
n_estimators:树数。100 通常够。更多树给更稳定分数但更慢。max_samples:每树样本数。原论文默认 256。更小值让单棵树更不准但增加多样性。子采样是孤立森林快的原因——每棵树只看数据的一小部分。contamination:预期异常比例。仅用于设阈值。不影响分数本身。LOF 比较一个点周围的局部密度与其邻居的密度。一个住在稀疏区、周围是稠密区的点是异常。
工作原理:
LOF 分数:
「局部」是关键。考虑一个数据集含两簇:1000 点的稠密簇和 50 点的稀疏簇。稀疏簇边缘的点并非全局不寻常——它有 50 个邻居。但若它的直接邻居比它稠密,它在局部就不寻常。LOF 抓住了全局方法错过的这种细微之处。
优点:检测局部异常(在邻域里不寻常的点,即使全局不寻常)。处理不同密度的簇。
缺点:大数据集慢(朴素实现 O(n^2))。对 k 的选择敏感。很高维表现差(维度灾难影响距离计算)。
| 方法 | 假设 | 速度 | 处理高维 | 检测局部异常 |
|---|---|---|---|---|
| Z 分数 | 正态分布 | 极快 | 是(每特征) | 否 |
| IQR | 无(每特征) | 极快 | 是(每特征) | 否 |
| 孤立森林 | 无 | 快 | 是 | 部分 |
| LOF | 距离有意义 | 慢 | 差 | 是 |
评估异常检测器比评估分类器难:
实践中,异常检测遵循这个流程:
流水线永不「完工」。数据分布漂移,新型异常涌现,阈值需调整。把异常检测当活系统,而非一次性模型。
code/anomaly_detection.py 从零实现 Z 分数、IQR、孤立森林。
def zscore_detect(X, threshold=3.0): mean = X.mean(axis=0) std = X.std(axis=0) std[std == 0] = 1.0 z = np.abs((X - mean) / std) return z.max(axis=1) > threshold
简单且向量化。任一特征超阈值就标记。
def iqr_detect(X, factor=1.5): q1 = np.percentile(X, 25, axis=0) q3 = np.percentile(X, 75, axis=0) iqr = q3 - q1 iqr[iqr == 0] = 1.0 lower = q1 - factor * iqr upper = q3 + factor * iqr outside = (X < lower) | (X > upper) return outside.any(axis=1)
从零实现构建随机划分特征空间的孤立树:
class IsolationTree: def __init__(self, max_depth): self.max_depth = max_depth def fit(self, X, depth=0): n, p = X.shape if depth >= self.max_depth or n <= 1: self.is_leaf = True self.size = n return self self.is_leaf = False self.feature = np.random.randint(p) x_min = X[:, self.feature].min() x_max = X[:, self.feature].max() if x_min == x_max: self.is_leaf = True self.size = n return self self.threshold = np.random.uniform(x_min, x_max) left_mask = X[:, self.feature] < self.threshold self.left = IsolationTree(self.max_depth).fit(X[left_mask], depth + 1) self.right = IsolationTree(self.max_depth).fit(X[~left_mask], depth + 1) return self
孤立一个点的路径长度决定其异常分数。路径越短越异常。
IsolationForest 类包装多棵树:
class IsolationForest: def __init__(self, n_estimators=100, max_samples=256, seed=42): self.n_estimators = n_estimators self.max_samples = max_samples def fit(self, X): sample_size = min(self.max_samples, X.shape[0]) max_depth = int(np.ceil(np.log2(sample_size))) for _ in range(self.n_estimators): idx = rng.choice(X.shape[0], size=sample_size, replace=False) tree = IsolationTree(max_depth=max_depth) tree.fit(X[idx]) self.trees.append(tree) def anomaly_score(self, X): avg_path = 各树上平均路径长度 scores = 2.0 ** (-avg_path / c(max_samples)) return scores
归一化因子 c(n) 是含 n 个元素的二叉搜索树里一次失败搜索的期望路径长度。它等于 2 * H(n-1) - 2*(n-1)/n,其中 H 是调和数。这个归一化保证分数跨不同大小数据集可比。
代码生成多个测试场景:
每个演示用精确率、召回率、F1、Precision@k 对比所有方法。
用 sklearn(用库实现,非从零):
from sklearn.ensemble import IsolationForest from sklearn.neighbors import LocalOutlierFactor iso = IsolationForest(n_estimators=100, contamination=0.05, random_state=42) iso.fit(X_train) predictions = iso.predict(X_test) lof = LocalOutlierFactor(n_neighbors=20, contamination=0.05, novelty=True) lof.fit(X_train) predictions = lof.predict(X_test)
注意 contamination 设预期异常比例。设对很重要——太低漏异常,太高造假告警。
code/anomaly_detection.py 在同一数据上对比从零实现与 sklearn。
sklearn 的 contamination 决定把连续异常分数转二元预测的阈值。它不改底层分数。
iso_5 = IsolationForest(contamination=0.05) iso_10 = IsolationForest(contamination=0.10)
两者产生相同异常分数。但 iso_5 标记前 5%、iso_10 标记前 10%。若你不知真实异常率(通常不知),设 contamination 为 "auto",直接用原始分数。基于假阳性与假阴性的成本权衡自设阈值。
另一个值得了解的无监督异常检测器。单类 SVM 在高维特征空间(用核技巧)里给正常数据拟合一个边界。
from sklearn.svm import OneClassSVM oc_svm = OneClassSVM(kernel="rbf", gamma="auto", nu=0.05) oc_svm.fit(X_train) predictions = oc_svm.predict(X_test)
nu 参数近似异常比例。单类 SVM 在中小数据集上好用,但不能扩展到很大数据(核矩阵二次增长)。
自编码器是学着压缩并重建数据的神经网络。在正常数据上训练。测试时,异常有高重建误差,因为网络只学了重建正常模式。
这在第 4 章(深度学习)讲,但原理相同:建模正常,标记偏离。
就像集成方法改进分类(第 11 节),组合多个异常检测器改进检测。最简方法:
这降假阳性,因为不同方法有不同失败模式。被四种方法都标记的点几乎肯定异常。只被一种标记的可能是那种方法的怪癖。
更复杂的集成按估计的可靠性加权每个检测器(在有已知异常的验证集上测,若有)。
| 维度 | 从零实现 | sklearn |
|---|---|---|
| 检测器 | Z、IQR、孤立森林 | 加 LOF、单类 SVM、Elliptic Envelope |
| 路径长度归一化 | 自己写 c(n) | 内置 |
| 适用 | 理解原理 | 生产 |
异常分数是连续的。你需要阈值做二元决策。这是业务决策,不是技术决策。
考虑两个场景:
两种情况下,最优阈值取决于假阳性与假阴性的成本比。在不同阈值画精确率和召回率,叠加成本函数,选最低成本点。
本节产出:
outputs/skill-anomaly-detector.md——一个选对检测器的决策技能。code/anomaly_detection.py——从零的 Z 分数、IQR、孤立森林,配 sklearn 对比。阈值调优:Z 分数检测器用阈值 1.0 到 5.0 步长 0.5 跑。画每阈值的精确率和召回率。你数据的甜点在哪?
多变量异常:造 2D 数据,每特征单独看正常,但组合异常(如远离主簇对角线的点)。展示每特征 Z 分数错过这些,但孤立森林抓住。
从零实现 LOF:用 k 近邻实现局部离群因子。在同一数据上对比 sklearn 的 LocalOutlierFactor。用 k=10 和 k=50——k 的选择如何影响结果?
流式异常检测:修改 Z 分数检测器为流式:随新点到达更新运行均值和方差(Welford 在线算法)。在同一数据上对比批量 Z 分数。
真实评估:取一个含已知异常的数据集(如 Kaggle 信用卡欺诈)。用 precision@100、precision@500、AUPRC 评估所有四种方法。哪个最好?为什么?
2^(-平均路径/c(n)),无分布假设、高维有效、子采样使其快。下一节,我们讲处理不平衡数据——为何 99% 准确率可能是个谎言,以及 SMOTE、类权重、阈值调优如何让模型关注稀有但重要的类。