文章目录
-
- 一、异常的三种形态与对应算法框架
- 二、统计方法:简单但有明确的物理含义
-
- [2.1 Z-score(标准分数法)](#2.1 Z-score(标准分数法))
- [2.2 IQR 方法与 Grubbs 检验](#2.2 IQR 方法与 Grubbs 检验)
- [2.3 多变量统计:马氏距离](#2.3 多变量统计:马氏距离)
- [三、Isolation Forest:不是「找密集」而是「找孤立」](#三、Isolation Forest:不是「找密集」而是「找孤立」)
-
- [3.1 核心思想:路径长度 = 异常程度](#3.1 核心思想:路径长度 = 异常程度)
- [3.2 实现细节与工程陷阱](#3.2 实现细节与工程陷阱)
- [3.3 Isolation Forest 的两个鲜为人知的局限](#3.3 Isolation Forest 的两个鲜为人知的局限)
- 四、LOF:局部密度视角下的异常
-
- [4.1 LOF 的思路:与邻居比较密度](#4.1 LOF 的思路:与邻居比较密度)
- [4.2 LOF 完整实现与参数解析](#4.2 LOF 完整实现与参数解析)
- [4.3 LOF 的参数敏感性分析](#4.3 LOF 的参数敏感性分析)
- [五、HBOS 与 COPOD:轻量级的工程选择](#五、HBOS 与 COPOD:轻量级的工程选择)
-
- [5.1 HBOS(Histogram-based Outlier Score)](#5.1 HBOS(Histogram-based Outlier Score))
- 六、场景化对比:什么场景用什么方法
-
- [6.1 方法选型决策框架](#6.1 方法选型决策框架)
- [6.2 工业场景的四种异常类型详解](#6.2 工业场景的四种异常类型详解)
- 七、评估:没有标注时怎么办
-
- [7.1 有限标注场景的评估](#7.1 有限标注场景的评估)
- [7.2 异常检测的评估指标选择](#7.2 异常检测的评估指标选择)
- 八、完整工程化实战:电商用户行为异常检测
- 小结
异常检测不是「一种算法」------而是「一种问题定义」。
设备传感器数据超过阈值是「异常」,但网络流量比同组用户高 10 倍也是「异常」,金融账户刻意伪装成正常的行为同样是「异常」。这三种异常的形态完全不同,自然对应不同的检测算法。多数入门教程混用「孤立森林」和「离群点检测」这两个概念------但会错误地在应该用 LOF 的场景用了 Isolation Forest,或者反过来。
一、异常的三种形态与对应算法框架
在选择算法之前,必须先定义「异常」的含义:
#mermaid-svg-YlFKDcub2c6z5XX1{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-YlFKDcub2c6z5XX1 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-YlFKDcub2c6z5XX1 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-YlFKDcub2c6z5XX1 .error-icon{fill:#552222;}#mermaid-svg-YlFKDcub2c6z5XX1 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-YlFKDcub2c6z5XX1 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-YlFKDcub2c6z5XX1 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-YlFKDcub2c6z5XX1 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-YlFKDcub2c6z5XX1 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-YlFKDcub2c6z5XX1 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-YlFKDcub2c6z5XX1 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-YlFKDcub2c6z5XX1 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-YlFKDcub2c6z5XX1 .marker.cross{stroke:#333333;}#mermaid-svg-YlFKDcub2c6z5XX1 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-YlFKDcub2c6z5XX1 p{margin:0;}#mermaid-svg-YlFKDcub2c6z5XX1 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-YlFKDcub2c6z5XX1 .cluster-label text{fill:#333;}#mermaid-svg-YlFKDcub2c6z5XX1 .cluster-label span{color:#333;}#mermaid-svg-YlFKDcub2c6z5XX1 .cluster-label span p{background-color:transparent;}#mermaid-svg-YlFKDcub2c6z5XX1 .label text,#mermaid-svg-YlFKDcub2c6z5XX1 span{fill:#333;color:#333;}#mermaid-svg-YlFKDcub2c6z5XX1 .node rect,#mermaid-svg-YlFKDcub2c6z5XX1 .node circle,#mermaid-svg-YlFKDcub2c6z5XX1 .node ellipse,#mermaid-svg-YlFKDcub2c6z5XX1 .node polygon,#mermaid-svg-YlFKDcub2c6z5XX1 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-YlFKDcub2c6z5XX1 .rough-node .label text,#mermaid-svg-YlFKDcub2c6z5XX1 .node .label text,#mermaid-svg-YlFKDcub2c6z5XX1 .image-shape .label,#mermaid-svg-YlFKDcub2c6z5XX1 .icon-shape .label{text-anchor:middle;}#mermaid-svg-YlFKDcub2c6z5XX1 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-YlFKDcub2c6z5XX1 .rough-node .label,#mermaid-svg-YlFKDcub2c6z5XX1 .node .label,#mermaid-svg-YlFKDcub2c6z5XX1 .image-shape .label,#mermaid-svg-YlFKDcub2c6z5XX1 .icon-shape .label{text-align:center;}#mermaid-svg-YlFKDcub2c6z5XX1 .node.clickable{cursor:pointer;}#mermaid-svg-YlFKDcub2c6z5XX1 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-YlFKDcub2c6z5XX1 .arrowheadPath{fill:#333333;}#mermaid-svg-YlFKDcub2c6z5XX1 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-YlFKDcub2c6z5XX1 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-YlFKDcub2c6z5XX1 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-YlFKDcub2c6z5XX1 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-YlFKDcub2c6z5XX1 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-YlFKDcub2c6z5XX1 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-YlFKDcub2c6z5XX1 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-YlFKDcub2c6z5XX1 .cluster text{fill:#333;}#mermaid-svg-YlFKDcub2c6z5XX1 .cluster span{color:#333;}#mermaid-svg-YlFKDcub2c6z5XX1 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-YlFKDcub2c6z5XX1 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-YlFKDcub2c6z5XX1 rect.text{fill:none;stroke-width:0;}#mermaid-svg-YlFKDcub2c6z5XX1 .icon-shape,#mermaid-svg-YlFKDcub2c6z5XX1 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-YlFKDcub2c6z5XX1 .icon-shape p,#mermaid-svg-YlFKDcub2c6z5XX1 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-YlFKDcub2c6z5XX1 .icon-shape .label rect,#mermaid-svg-YlFKDcub2c6z5XX1 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-YlFKDcub2c6z5XX1 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-YlFKDcub2c6z5XX1 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-YlFKDcub2c6z5XX1 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 异常检测问题定义
异常类型?
全局异常
Global/Point Anomaly
上下文异常
Contextual Anomaly
集体异常
Collective Anomaly
数据点偏离全局分布
例:气温突然从20°跳到80°
例:信用卡单笔消费$50000
在特定上下文下才是异常
例:夏天的气温35°正常
冬天的气温35°异常
例:周末的网络流量高正常
单个点不异常但组合异常
例:多笔小额交易累计异常
例:APT攻击的分散行为
统计方法 Z-score
Isolation Forest
时序感知方法
STL分解残差
周期性基准线
序列异常检测
图异常检测
行为画像方法
多数教程只讲第一种(全局异常),但工业场景中后两种更常见。
二、统计方法:简单但有明确的物理含义
统计方法是异常检测的基线。简单、可解释、计算快------在数据量小或特征简单时,往往是最好的选择。
2.1 Z-score(标准分数法)
python
import numpy as np
import pandas as pd
from scipy import stats
def zscore_anomaly_detection(data, threshold=3.0):
"""
Z-score 异常检测
假设:数据近似正态分布
原理:数据点距均值超过 threshold 个标准差即为异常
注意事项:
1. 均值和标准差对极端值极其敏感(异常影响了检测器本身)
2. 非正态分布场景下,Z=3 的阈值不适用
3. 解决方案:用中位数和 MAD 替代均值和标准差(鲁棒 Z-score)
"""
z_scores = np.abs(stats.zscore(data))
return z_scores > threshold
def robust_zscore(data, threshold=3.5):
"""
鲁棒 Z-score(Modified Z-score)
用中位数绝对偏差(MAD)替代标准差,对异常值不敏感
公式:M_i = 0.6745 * (x_i - median) / MAD
0.6745 是正态分布中标准差与 MAD 的比值
阈值 3.5 来自 Iglewicz & Hoaglin (1993)
"""
median = np.median(data)
mad = np.median(np.abs(data - median))
if mad == 0:
# MAD 为 0 时退化为绝对偏差
mad = np.mean(np.abs(data - median))
modified_z_scores = 0.6745 * np.abs(data - median) / mad
return modified_z_scores > threshold
# 对比演示:异常值对普通 Z-score 的污染效应
np.random.seed(42)
normal_data = np.concatenate([
np.random.normal(0, 1, 100),
[10, 11, 12] # 注入 3 个明显异常值
])
# 普通 Z-score:均值被拉向异常值,导致阈值升高,漏检
z_scores_standard = np.abs(stats.zscore(normal_data))
# 鲁棒 Z-score:中位数不受异常值影响
is_anomaly_robust = robust_zscore(normal_data)
print(f"普通 Z-score 检测到的异常数:{(z_scores_standard > 3).sum()}")
print(f"鲁棒 Z-score 检测到的异常数:{is_anomaly_robust.sum()}")
2.2 IQR 方法与 Grubbs 检验
python
def iqr_anomaly_detection(data, multiplier=1.5):
"""
四分位距(IQR)异常检测
箱线图的经典判定规则:
- 下界 = Q1 - multiplier * IQR
- 上界 = Q3 + multiplier * IQR
- multiplier=1.5:轻度异常(箱线图须线)
- multiplier=3.0:极端异常(Tukey 原始定义)
优点:直观,对正态假设依赖弱
缺点:只适用于单变量,无法捕获多维异常
"""
Q1, Q3 = np.percentile(data, [25, 75])
IQR = Q3 - Q1
lower_bound = Q1 - multiplier * IQR
upper_bound = Q3 + multiplier * IQR
return (data < lower_bound) | (data > upper_bound)
def grubbs_test(data, alpha=0.05):
"""
Grubbs 检验(适合单变量、接近正态分布的数据)
只检测单个最极端的异常值
假设检验框架:H0=没有异常值,H1=存在一个异常值
适用场景:仪器测量数据的异常值剔除(误差分析)
不适用:大量异常值、非正态分布
"""
n = len(data)
mean = np.mean(data)
std = np.std(data, ddof=1)
# 找出距均值最远的点
g_stat = np.max(np.abs(data - mean)) / std
# 临界值(简化版,精确版需查表)
t_alpha = stats.t.ppf(1 - alpha / (2 * n), n - 2)
g_critical = ((n - 1) / np.sqrt(n)) * np.sqrt(t_alpha**2 / (n - 2 + t_alpha**2))
return g_stat > g_critical, g_stat, g_critical
2.3 多变量统计:马氏距离
python
def mahalanobis_anomaly_detection(X, threshold_percentile=97.5):
"""
马氏距离异常检测
解决多变量场景中特征相关性的问题:
- 欧氏距离:对所有方向等距,无法区分「沿主变化方向的偏离」vs「垂直方向的偏离」
- 马氏距离:考虑协方差结构,沿主轴方向的偏离被缩放
物理意义:距离协方差椭球中心的标准化距离
注意:需要数据量 >> 特征数量(否则协方差矩阵奇异)
"""
mean = np.mean(X, axis=0)
cov = np.cov(X.T)
try:
cov_inv = np.linalg.inv(cov)
except np.linalg.LinAlgError:
# 协方差矩阵奇异(特征高度相关或样本量不足)
cov_inv = np.linalg.pinv(cov)
# 计算每个样本的马氏距离
diff = X - mean
mahal_dist = np.sqrt(np.einsum('ij,jk,ik->i', diff, cov_inv, diff))
# 卡方分布阈值(自由度=特征数)
n_features = X.shape[1]
threshold = np.sqrt(stats.chi2.ppf(threshold_percentile / 100, n_features))
return mahal_dist > threshold, mahal_dist
三、Isolation Forest:不是「找密集」而是「找孤立」
大多数异常检测算法的思路是:「建模正常数据的分布,偏离分布的是异常」。Isolation Forest 反其道而行:直接刻画「异常点容易被孤立」这一特性。
3.1 核心思想:路径长度 = 异常程度
直觉实验:
- 对一个数据集随机切割(随机选特征、随机选切割值)
- 正常点:藏在密集区域,需要很多次切割才能孤立它
- 异常点:孤立在稀疏区域,很少几次切割就能孤立它
衡量指标:把一个点孤立所需的平均路径长度
路径越短 → 越容易孤立 → 越可能是异常
#mermaid-svg-bv9IRmGliGH1anNd{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-bv9IRmGliGH1anNd .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-bv9IRmGliGH1anNd .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-bv9IRmGliGH1anNd .error-icon{fill:#552222;}#mermaid-svg-bv9IRmGliGH1anNd .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-bv9IRmGliGH1anNd .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-bv9IRmGliGH1anNd .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-bv9IRmGliGH1anNd .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-bv9IRmGliGH1anNd .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-bv9IRmGliGH1anNd .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-bv9IRmGliGH1anNd .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-bv9IRmGliGH1anNd .marker{fill:#333333;stroke:#333333;}#mermaid-svg-bv9IRmGliGH1anNd .marker.cross{stroke:#333333;}#mermaid-svg-bv9IRmGliGH1anNd svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-bv9IRmGliGH1anNd p{margin:0;}#mermaid-svg-bv9IRmGliGH1anNd .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-bv9IRmGliGH1anNd .cluster-label text{fill:#333;}#mermaid-svg-bv9IRmGliGH1anNd .cluster-label span{color:#333;}#mermaid-svg-bv9IRmGliGH1anNd .cluster-label span p{background-color:transparent;}#mermaid-svg-bv9IRmGliGH1anNd .label text,#mermaid-svg-bv9IRmGliGH1anNd span{fill:#333;color:#333;}#mermaid-svg-bv9IRmGliGH1anNd .node rect,#mermaid-svg-bv9IRmGliGH1anNd .node circle,#mermaid-svg-bv9IRmGliGH1anNd .node ellipse,#mermaid-svg-bv9IRmGliGH1anNd .node polygon,#mermaid-svg-bv9IRmGliGH1anNd .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-bv9IRmGliGH1anNd .rough-node .label text,#mermaid-svg-bv9IRmGliGH1anNd .node .label text,#mermaid-svg-bv9IRmGliGH1anNd .image-shape .label,#mermaid-svg-bv9IRmGliGH1anNd .icon-shape .label{text-anchor:middle;}#mermaid-svg-bv9IRmGliGH1anNd .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-bv9IRmGliGH1anNd .rough-node .label,#mermaid-svg-bv9IRmGliGH1anNd .node .label,#mermaid-svg-bv9IRmGliGH1anNd .image-shape .label,#mermaid-svg-bv9IRmGliGH1anNd .icon-shape .label{text-align:center;}#mermaid-svg-bv9IRmGliGH1anNd .node.clickable{cursor:pointer;}#mermaid-svg-bv9IRmGliGH1anNd .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-bv9IRmGliGH1anNd .arrowheadPath{fill:#333333;}#mermaid-svg-bv9IRmGliGH1anNd .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-bv9IRmGliGH1anNd .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-bv9IRmGliGH1anNd .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-bv9IRmGliGH1anNd .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-bv9IRmGliGH1anNd .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-bv9IRmGliGH1anNd .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-bv9IRmGliGH1anNd .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-bv9IRmGliGH1anNd .cluster text{fill:#333;}#mermaid-svg-bv9IRmGliGH1anNd .cluster span{color:#333;}#mermaid-svg-bv9IRmGliGH1anNd div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-bv9IRmGliGH1anNd .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-bv9IRmGliGH1anNd rect.text{fill:none;stroke-width:0;}#mermaid-svg-bv9IRmGliGH1anNd .icon-shape,#mermaid-svg-bv9IRmGliGH1anNd .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-bv9IRmGliGH1anNd .icon-shape p,#mermaid-svg-bv9IRmGliGH1anNd .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-bv9IRmGliGH1anNd .icon-shape .label rect,#mermaid-svg-bv9IRmGliGH1anNd .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-bv9IRmGliGH1anNd .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-bv9IRmGliGH1anNd .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-bv9IRmGliGH1anNd :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 孤立树结构
根节点
随机特征
随机切割值
左子树(继续切割)
右子树(继续切割)
...
叶节点(孤立)
路径长度=3
异常点(稀疏区)
少量切割
即可孤立
路径长度短
→ 高异常分
正常点(密集区)
需要多次切割
才能孤立
路径长度长
→ 低异常分
3.2 实现细节与工程陷阱
python
from sklearn.ensemble import IsolationForest
import numpy as np
def isolation_forest_with_analysis(X, contamination=0.1, n_estimators=100):
"""
孤立森林异常检测,附带详细分析
contamination:预期的异常比例(直接影响阈值)
n_estimators:树的数量(通常 100 足够,增加不显著提升性能)
工程陷阱:
1. contamination 不是越小越好------设置过小会漏检
2. max_samples 默认 256,对大数据集够用但可调
3. random_state 要设置,否则结果不可复现
"""
iso_forest = IsolationForest(
n_estimators=n_estimators,
contamination=contamination,
max_samples='auto', # min(256, n_samples)
random_state=42,
n_jobs=-1
)
# predict:1=正常,-1=异常
predictions = iso_forest.fit_predict(X)
# score_samples:越负越异常(负的路径长度归一化分数)
anomaly_scores = iso_forest.score_samples(X)
return predictions, anomaly_scores, iso_forest
# 陷阱演示:contamination 参数的影响
np.random.seed(42)
X_normal = np.random.normal(0, 1, (1000, 2))
X_anomaly = np.random.uniform(-5, 5, (50, 2)) # 真实异常比例 4.8%
X = np.vstack([X_normal, X_anomaly])
# 案例一:contamination 设置过低(漏检)
iso_low = IsolationForest(contamination=0.01, random_state=42)
pred_low = iso_low.fit_predict(X)
# 案例二:contamination 设置合理
iso_right = IsolationForest(contamination=0.05, random_state=42)
pred_right = iso_right.fit_predict(X)
print(f"contamination=0.01:检测到 {(pred_low==-1).sum()} 个异常")
print(f"contamination=0.05:检测到 {(pred_right==-1).sum()} 个异常")
3.3 Isolation Forest 的两个鲜为人知的局限
局限一:对高密度区域内的局部异常不敏感
场景:某工厂生产线,99% 的数据是正常操作(高密度),
1% 是轻微偏离(局部异常,偏离值只有正常范围的 120%)
0.01% 是严重故障(全局异常,偏离值 500%)
Isolation Forest 的行为:
- 能很好地检测严重故障(全局异常,密度极低)
- 对轻微偏离(局部异常)效果较差
------因为轻微偏离在局部也是「孤立的」但在全局密度看起来和正常区域差不多
解决方案:使用 LOF(专门设计用于局部异常)或 HBOS
局限二:高维诅咒(Curse of Dimensionality)
当特征维度很高(>50)时:
- 随机切割的效果退化
- 路径长度的区分能力下降(所有点的路径长度趋于相似)
实际检验方法:
1. 检查 score_samples 的分布------如果所有分数集中在 [-0.5, -0.4],说明区分度低
2. 先用 PCA 降维到 10-20 维,再用 Isolation Forest
经验规则:原始特征 > 20 维时,考虑先降维
四、LOF:局部密度视角下的异常
LOF(Local Outlier Factor,局部异常因子)的核心洞察:异常不是绝对的,而是相对于邻域的。
4.1 LOF 的思路:与邻居比较密度
核心定义:
lof(p) = 平均(邻居的局部密度) / p 自身的局部密度
lof ≈ 1:p 与邻居密度相近,是正常点
lof >> 1:p 的密度远低于邻居,是局部异常点
lof << 1:p 的密度远高于邻居(可能是密集簇的核心点)
为什么要用「局部」密度:
- 不同区域的数据密度差异很大
- 高密度区域内的异常点,其全局密度仍然比稀疏区域的正常点高
- 只有与邻居比较,才能发现「在自己圈子里格格不入」的点
4.2 LOF 完整实现与参数解析
python
from sklearn.neighbors import LocalOutlierFactor
import numpy as np
import matplotlib.pyplot as plt
def lof_anomaly_detection(X, n_neighbors=20, contamination=0.1):
"""
LOF 局部异常因子检测
n_neighbors (k):定义「局部邻域」的大小
- k 太小:噪声敏感,对单个异常点过度响应
- k 太大:局部性减弱,退化为全局方法,失去 LOF 的优势
- 经验值:20~50,数据量越大可以适当增大
contamination:预期异常比例(影响阈值)
重要限制:
- LOF 是 transductive(直推式)的,没有 predict() 方法
- 新样本预测需要设置 novelty=True
"""
# novelty=False:用于离群点检测(训练数据本身可能有异常)
lof = LocalOutlierFactor(
n_neighbors=n_neighbors,
contamination=contamination,
metric='minkowski', # 欧氏距离
n_jobs=-1
)
# fit_predict 同时完成训练和预测(1=正常,-1=异常)
predictions = lof.fit_predict(X)
# 负因子分数:越负表示越异常(-lof 分)
lof_scores = lof.negative_outlier_factor_
return predictions, lof_scores, lof
# LOF vs Isolation Forest 对比:局部异常场景
def create_local_anomaly_dataset():
"""创建包含局部异常的数据集(LOF 的强项)"""
np.random.seed(42)
# 高密度簇
cluster1 = np.random.normal([0, 0], [0.3, 0.3], (200, 2))
# 低密度区域
cluster2 = np.random.normal([5, 5], [1.5, 1.5], (100, 2))
# 局部异常:在高密度簇附近,但偏离该簇
local_anomaly = np.array([[1.2, 0.1], [0.1, 1.3], [-1.1, 0.2]])
# 全局异常:远离所有簇
global_anomaly = np.array([[10, 10], [-8, 3]])
X = np.vstack([cluster1, cluster2, local_anomaly, global_anomaly])
# 真实标签(最后 5 个是异常)
y_true = np.concatenate([np.zeros(300), np.ones(5)])
return X, y_true
X_test, y_true = create_local_anomaly_dataset()
# LOF 结果
pred_lof, scores_lof, _ = lof_anomaly_detection(X_test, contamination=5/305)
# Isolation Forest 结果
pred_iso, scores_iso, _ = isolation_forest_with_analysis(X_test, contamination=5/305)
from sklearn.metrics import f1_score
print(f"LOF F1 分数(局部异常场景):{f1_score(y_true, (pred_lof == -1).astype(int)):.3f}")
print(f"Isolation Forest F1 分数:{f1_score(y_true, (pred_iso == -1).astype(int)):.3f}")
4.3 LOF 的参数敏感性分析
python
def lof_parameter_sensitivity(X, k_range=range(5, 50, 5)):
"""
k(n_neighbors)参数对 LOF 结果的影响分析
实际工程建议:
1. 对业务标注的「确认异常案例」,用不同 k 跑一遍,选 F1 最高的
2. 如果没有标注数据,选 k=20 作为默认值,观察 lof_scores 的分布
3. scores 双峰明显 → k 合适;单峰尖锐 → k 可能太大
"""
results = {}
for k in k_range:
lof = LocalOutlierFactor(n_neighbors=k, novelty=False)
pred = lof.fit_predict(X)
scores = lof.negative_outlier_factor_
results[k] = {
'n_anomalies': (pred == -1).sum(),
'score_range': (scores.min(), scores.max()),
'score_std': scores.std()
}
return results
五、HBOS 与 COPOD:轻量级的工程选择
当数据量极大(百万级以上)时,LOF 的 O(n²) 复杂度不可接受。两个轻量级方案:
5.1 HBOS(Histogram-based Outlier Score)
python
class HBOS:
"""
基于直方图的异常分数(HBOS)
核心思想:每个特征独立建立密度直方图,异常分数 = 各特征密度之积的负对数
假设:特征之间条件独立(类似朴素贝叶斯)
优势:
- 时间复杂度 O(n),适合大规模数据
- 对高维数据表现稳定
- 可解释性好(哪个特征贡献了异常分)
劣势:
- 忽略特征相关性(无法检测多维联合异常)
- 对特征分布假设较强
"""
def __init__(self, n_bins=10, alpha=0.1):
self.n_bins = n_bins
self.alpha = alpha # Laplace 平滑参数,避免零概率
self.histograms_ = []
def fit(self, X):
self.histograms_ = []
for feature_idx in range(X.shape[1]):
feature_data = X[:, feature_idx]
counts, bin_edges = np.histogram(feature_data, bins=self.n_bins, density=True)
# Laplace 平滑
counts = counts + self.alpha
counts = counts / counts.sum()
self.histograms_.append((counts, bin_edges))
return self
def score_samples(self, X):
"""
计算异常分数(越高越异常)
"""
log_density = np.zeros(X.shape[0])
for feature_idx, (counts, bin_edges) in enumerate(self.histograms_):
feature_data = X[:, feature_idx]
# 找到每个样本落在哪个 bin
bin_indices = np.digitize(feature_data, bin_edges[:-1]) - 1
bin_indices = np.clip(bin_indices, 0, len(counts) - 1)
# 累加 log 密度(越低的密度 → 越高的异常分)
log_density += np.log(counts[bin_indices] + 1e-10)
# 返回异常分(取负,越高越异常)
return -log_density
def fit_predict(self, X, threshold_percentile=95):
self.fit(X)
scores = self.score_samples(X)
threshold = np.percentile(scores, threshold_percentile)
return np.where(scores > threshold, -1, 1)
六、场景化对比:什么场景用什么方法
这是实际项目中最重要的问题,也是多数教程回避的问题。
6.1 方法选型决策框架
#mermaid-svg-KRsgdOBcpqQqUmHA{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-KRsgdOBcpqQqUmHA .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-KRsgdOBcpqQqUmHA .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-KRsgdOBcpqQqUmHA .error-icon{fill:#552222;}#mermaid-svg-KRsgdOBcpqQqUmHA .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-KRsgdOBcpqQqUmHA .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-KRsgdOBcpqQqUmHA .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-KRsgdOBcpqQqUmHA .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-KRsgdOBcpqQqUmHA .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-KRsgdOBcpqQqUmHA .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-KRsgdOBcpqQqUmHA .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-KRsgdOBcpqQqUmHA .marker{fill:#333333;stroke:#333333;}#mermaid-svg-KRsgdOBcpqQqUmHA .marker.cross{stroke:#333333;}#mermaid-svg-KRsgdOBcpqQqUmHA svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-KRsgdOBcpqQqUmHA p{margin:0;}#mermaid-svg-KRsgdOBcpqQqUmHA .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-KRsgdOBcpqQqUmHA .cluster-label text{fill:#333;}#mermaid-svg-KRsgdOBcpqQqUmHA .cluster-label span{color:#333;}#mermaid-svg-KRsgdOBcpqQqUmHA .cluster-label span p{background-color:transparent;}#mermaid-svg-KRsgdOBcpqQqUmHA .label text,#mermaid-svg-KRsgdOBcpqQqUmHA span{fill:#333;color:#333;}#mermaid-svg-KRsgdOBcpqQqUmHA .node rect,#mermaid-svg-KRsgdOBcpqQqUmHA .node circle,#mermaid-svg-KRsgdOBcpqQqUmHA .node ellipse,#mermaid-svg-KRsgdOBcpqQqUmHA .node polygon,#mermaid-svg-KRsgdOBcpqQqUmHA .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-KRsgdOBcpqQqUmHA .rough-node .label text,#mermaid-svg-KRsgdOBcpqQqUmHA .node .label text,#mermaid-svg-KRsgdOBcpqQqUmHA .image-shape .label,#mermaid-svg-KRsgdOBcpqQqUmHA .icon-shape .label{text-anchor:middle;}#mermaid-svg-KRsgdOBcpqQqUmHA .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-KRsgdOBcpqQqUmHA .rough-node .label,#mermaid-svg-KRsgdOBcpqQqUmHA .node .label,#mermaid-svg-KRsgdOBcpqQqUmHA .image-shape .label,#mermaid-svg-KRsgdOBcpqQqUmHA .icon-shape .label{text-align:center;}#mermaid-svg-KRsgdOBcpqQqUmHA .node.clickable{cursor:pointer;}#mermaid-svg-KRsgdOBcpqQqUmHA .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-KRsgdOBcpqQqUmHA .arrowheadPath{fill:#333333;}#mermaid-svg-KRsgdOBcpqQqUmHA .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-KRsgdOBcpqQqUmHA .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-KRsgdOBcpqQqUmHA .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-KRsgdOBcpqQqUmHA .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-KRsgdOBcpqQqUmHA .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-KRsgdOBcpqQqUmHA .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-KRsgdOBcpqQqUmHA .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-KRsgdOBcpqQqUmHA .cluster text{fill:#333;}#mermaid-svg-KRsgdOBcpqQqUmHA .cluster span{color:#333;}#mermaid-svg-KRsgdOBcpqQqUmHA div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-KRsgdOBcpqQqUmHA .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-KRsgdOBcpqQqUmHA rect.text{fill:none;stroke-width:0;}#mermaid-svg-KRsgdOBcpqQqUmHA .icon-shape,#mermaid-svg-KRsgdOBcpqQqUmHA .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-KRsgdOBcpqQqUmHA .icon-shape p,#mermaid-svg-KRsgdOBcpqQqUmHA .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-KRsgdOBcpqQqUmHA .icon-shape .label rect,#mermaid-svg-KRsgdOBcpqQqUmHA .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-KRsgdOBcpqQqUmHA .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-KRsgdOBcpqQqUmHA .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-KRsgdOBcpqQqUmHA :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 有(<10%异常标注)
完全无标注
有(大量异常样本)
<1万
1万~100万
>100万
有(多密度区域)
无(均匀分布)
<20维
20~100维
>100维
异常检测任务
是否有标注数据?
半监督方法
One-Class SVM
Deep SVDD
数据量?
转化为分类问题
XGBoost/LightGBM
- 不平衡数据处理
是否有明显的
簇结构?
特征维度?
HBOS / 统计方法
(速度优先)
LOF
局部异常检测
Isolation Forest
或统计方法
Isolation Forest
(默认选择)
先PCA降维
再Isolation Forest
深度异常检测
Autoencoder
重构误差
6.2 工业场景的四种异常类型详解
场景一:设备故障预测(时序+全局异常)
python
class IndustrialEquipmentAnomalyDetector:
"""
工业设备传感器数据异常检测
数据特点:
- 时序性强(当前值依赖历史值)
- 多传感器联合(温度/振动/电流/压力相关)
- 异常通常是全局异常(数值超过正常运行区间)
核心挑战:
- 季节性变化:冬天正常温度比夏天低,阈值不能固定
- 运行模式:设备有多种运行状态(待机/满载/维护)
- 慢漂移:设备老化导致基线缓慢变化,不是突变
"""
def __init__(self, window_size=100, n_estimators=100):
self.window_size = window_size
self.iso_forest = IsolationForest(
n_estimators=n_estimators,
contamination=0.02, # 设备故障率通常 <2%
random_state=42
)
def create_features(self, sensor_data):
"""
时序特征工程:
1. 滚动统计(均值、标准差、最大最小值)
2. 差分特征(捕捉突变)
3. 频域特征(振动信号的频率特性)
"""
df = pd.DataFrame(sensor_data)
features = pd.DataFrame()
for col in df.columns:
# 滚动统计
features[f'{col}_mean'] = df[col].rolling(self.window_size).mean()
features[f'{col}_std'] = df[col].rolling(self.window_size).std()
features[f'{col}_max'] = df[col].rolling(self.window_size).max()
features[f'{col}_min'] = df[col].rolling(self.window_size).min()
# 一阶差分(捕捉突变)
features[f'{col}_diff'] = df[col].diff()
# 二阶差分(捕捉加速度变化)
features[f'{col}_diff2'] = df[col].diff().diff()
return features.dropna()
def fit(self, normal_sensor_data):
"""只用正常数据训练(没有故障样本时的通用方案)"""
features = self.create_features(normal_sensor_data)
self.iso_forest.fit(features)
self.feature_columns = features.columns.tolist()
return self
def predict(self, new_sensor_data):
features = self.create_features(new_sensor_data)
scores = self.iso_forest.score_samples(features[self.feature_columns])
predictions = self.iso_forest.predict(features[self.feature_columns])
return predictions, scores
场景二:金融欺诈检测(局部异常+对抗性异常)
python
class FinancialFraudDetector:
"""
金融交易异常检测
金融欺诈的特殊性:
1. 欺诈行为会刻意模仿正常行为(对抗性)
2. 每个用户的正常行为模式不同(局部性)
3. 欺诈模式会随时间演化(分布漂移)
关键视角:
- 不是「这笔交易金额大」就是欺诈
- 而是「这笔交易对于这个用户来说不寻常」
正确的异常定义:相对于用户历史行为的偏离程度
"""
def __init__(self, user_history_window=90):
"""
user_history_window:用于建立用户基线的历史天数
"""
self.user_models = {} # 每个用户一个模型
self.user_history_window = user_history_window
def build_user_features(self, transactions):
"""
用户行为特征(相对特征,不是绝对特征)
绝对特征(错误示范):金额 = $1000
相对特征(正确示范):金额 / 用户平均金额 = 5.2x
"""
features = {
'amount_ratio': transactions['amount'] / transactions['user_avg_amount'],
'time_since_last': transactions['time_since_last_txn_hours'],
'location_is_new': transactions['location_seen_before'].map({True: 0, False: 1}),
'hour_of_day_unusual': self._hour_unusualness(
transactions['hour'], transactions['user_typical_hours']
),
'device_is_new': transactions['device_seen_before'].map({True: 0, False: 1}),
'velocity_1h': transactions['txn_count_last_1h'],
'amount_z_score': self._per_user_zscore(transactions['amount'],
transactions['user_id']),
}
return pd.DataFrame(features)
def _per_user_zscore(self, amounts, user_ids):
"""
每个用户独立计算 Z-score(LOF 的用户级简化版本)
"""
z_scores = pd.Series(index=amounts.index, dtype=float)
for user_id in user_ids.unique():
mask = user_ids == user_id
user_amounts = amounts[mask]
mean = user_amounts.mean()
std = user_amounts.std()
if std > 0:
z_scores[mask] = (user_amounts - mean) / std
else:
z_scores[mask] = 0.0
return z_scores
def _hour_unusualness(self, current_hours, typical_hours_list):
"""
当前时间是否在用户常见交易时间之外
"""
# 简化版:基于历史时间分布的熵
return current_hours.apply(
lambda h: 0 if h in [6, 7, 8, 12, 13, 18, 19, 20] else 1
)
场景三:网络入侵检测(上下文异常+集体异常)
python
class NetworkIntrusionDetector:
"""
网络流量异常检测
网络入侵的异常特征:
1. 上下文异常:大量 SYN 包在午夜(正常业务时间内无异常)
2. 集体异常:APT 攻击的分散低速扫描(单包看正常,整体看异常)
3. 序列异常:先扫描端口,再尝试登录,再传输数据(顺序反常)
纯粹的 Isolation Forest 或 LOF 无法检测集体异常和序列异常!
需要引入时间窗口聚合特征
"""
def __init__(self, time_window='5min'):
self.time_window = time_window
def aggregate_window_features(self, raw_packets_df):
"""
时间窗口内的聚合特征(将集体异常转化为点异常)
关键思路:把一段时间内的行为模式转化为单个特征向量
"""
features = raw_packets_df.resample(self.time_window).agg({
'bytes': ['sum', 'mean', 'std', 'max'],
'packets': ['sum', 'mean'],
'src_ip': 'nunique', # 源 IP 多样性(扫描 → 多 IP)
'dst_port': 'nunique', # 目标端口多样性(端口扫描 → 多端口)
'protocol': lambda x: (x == 'TCP').sum() / len(x), # TCP 比例
'syn_flag': 'sum', # SYN 包数量(SYN flood)
'failed_connections': 'sum', # 连接失败数(密码暴力破解)
})
features.columns = ['_'.join(col) for col in features.columns]
return features.fillna(0)
七、评估:没有标注时怎么办
异常检测最让人头疼的问题:往往没有大量标注的异常数据。
7.1 有限标注场景的评估
python
from sklearn.metrics import roc_auc_score, average_precision_score
import numpy as np
def evaluate_with_limited_labels(anomaly_scores, y_true_limited):
"""
利用少量标注数据评估(即使标注不完整也能用)
y_true_limited:部分标注,-1=确认正常,0=未标注,1=确认异常
只评估有标注的样本,避免「未标注不一定是正常」的问题
"""
labeled_mask = y_true_limited != 0
scores_labeled = anomaly_scores[labeled_mask]
labels_labeled = y_true_limited[labeled_mask]
# 将标签转为 0/1(-1正常→0,1异常→1)
labels_binary = (labels_labeled == 1).astype(int)
metrics = {
'ROC-AUC': roc_auc_score(labels_binary, scores_labeled),
'PR-AUC': average_precision_score(labels_binary, scores_labeled),
'n_labeled': labeled_mask.sum(),
'n_anomaly_labeled': labels_binary.sum()
}
return metrics
def threshold_selection_without_labels(anomaly_scores, method='knee'):
"""
无标注场景的阈值选择策略
方法一:肘部法则------分数曲线斜率突变处
方法二:业务驱动------根据能处理的告警量反推阈值
方法三:统计方法------scores 分布的 μ + 3σ
实践建议:
- 先选一个宽松阈值(多查几个),人工确认后逐步收紧
- 绝对不要追求「100% 精确率」,误报总比漏报好(风控场景)
"""
if method == 'business':
# 业务驱动:假设每天能处理 50 个告警
n_daily_alerts = 50
n_samples = len(anomaly_scores)
contamination_estimate = n_daily_alerts / n_samples
return np.percentile(anomaly_scores, (1 - contamination_estimate) * 100)
elif method == 'statistical':
mean = np.mean(anomaly_scores)
std = np.std(anomaly_scores)
return mean + 3 * std
elif method == 'knee':
# 肘部法则:找分数排序曲线的拐点
sorted_scores = np.sort(anomaly_scores)[::-1]
# 简化版:找最大二阶导数位置
first_diff = np.diff(sorted_scores)
second_diff = np.diff(first_diff)
knee_idx = np.argmax(np.abs(second_diff)) + 2
return sorted_scores[knee_idx]
7.2 异常检测的评估指标选择
ROC-AUC 的局限:
- 在极端不平衡场景(异常率 <1%)下,ROC-AUC 过于乐观
- 即使精确率很低(大量误报),ROC-AUC 仍然可能很高
推荐使用 PR-AUC(精确率-召回率曲线下面积):
- 对正样本(异常)更敏感
- 在不平衡场景下更有区分度
- 反映了「你找到的异常中,有多少是真的」
工业场景的业务指标:
- 安全场景:误报率(FPR)<5%,漏报率(FNR)<1%(宁可多报,不能漏)
- 设备维护:在故障前 N 天内检测到 = 有效预警
- 金融风控:人工审核通过率(减少误报带来的用户体验损失)
八、完整工程化实战:电商用户行为异常检测
python
import pandas as pd
import numpy as np
from sklearn.ensemble import IsolationForest
from sklearn.neighbors import LocalOutlierFactor
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
class EcommerceAnomalySystem:
"""
电商平台用户行为异常检测系统
检测目标:
1. 刷单行为(短时间内异常多的购买)
2. 账号被盗(登录地点/设备突变)
3. 恶意评论刷评(异常评论频率)
4. 价格爬虫(异常高频的商品详情访问)
系统设计原则:
- 多算法集成:Isolation Forest + LOF + 规则引擎
- 分层检测:先快速规则过滤,再模型精判
- 可解释输出:告知用户被标记的原因
"""
def __init__(self):
self.models = {}
self.scalers = {}
self.rules = []
def add_rule(self, name, condition_fn, reason):
"""添加业务规则(速度快,先于模型运行)"""
self.rules.append({'name': name, 'condition': condition_fn, 'reason': reason})
def build_user_behavior_features(self, events_df):
"""
从事件流构建用户行为特征向量
特征维度涵盖:
- 频率特征:单位时间内各类操作的频次
- 多样性特征:访问商品/类别的多样性
- 时间特征:操作时间分布(夜间活动异常)
- 序列特征:操作之间的时间间隔
"""
user_features = events_df.groupby('user_id').agg(
# 频率特征
total_events=('event_type', 'count'),
purchase_count=('event_type', lambda x: (x == 'purchase').sum()),
view_count=('event_type', lambda x: (x == 'view').sum()),
cart_count=('event_type', lambda x: (x == 'add_to_cart').sum()),
# 转化率特征
purchase_rate=('event_type', lambda x: (x == 'purchase').mean()),
cart_to_purchase=('event_type', lambda x: (
(x == 'purchase').sum() / max((x == 'add_to_cart').sum(), 1)
)),
# 多样性特征
unique_items=('item_id', 'nunique'),
unique_categories=('category', 'nunique'),
unique_devices=('device_id', 'nunique'),
unique_ips=('ip_address', 'nunique'),
# 时间特征
night_activity_ratio=('hour', lambda x: (x.between(0, 6)).mean()),
session_count=('session_id', 'nunique'),
avg_session_duration=('session_duration', 'mean'),
# 速度特征(单位:次/小时)
events_per_hour=('event_type', lambda x: len(x) / max(
events_df.loc[x.index, 'active_hours'].max(), 1
)),
).reset_index()
return user_features
def fit(self, normal_user_features):
"""用正常用户数据训练检测器"""
feature_cols = [c for c in normal_user_features.columns if c != 'user_id']
X = normal_user_features[feature_cols].values
# 标准化
self.scaler = StandardScaler()
X_scaled = self.scaler.fit_transform(X)
# Isolation Forest(全局异常)
self.iso_forest = IsolationForest(
n_estimators=200,
contamination=0.05,
random_state=42
)
self.iso_forest.fit(X_scaled)
# LOF(局部异常,对「局部异常用户群」更敏感)
self.lof = LocalOutlierFactor(
n_neighbors=30,
contamination=0.05,
novelty=True # 支持对新样本预测
)
self.lof.fit(X_scaled)
self.feature_cols = feature_cols
return self
def predict(self, new_user_features, ensemble_method='vote'):
"""
集成预测:规则引擎 + Isolation Forest + LOF
ensemble_method:
- 'vote':多数投票(精度/召回均衡)
- 'any':任一模型报警即标记(高召回,多误报)
- 'all':所有模型都报警才标记(高精度,多漏报)
"""
X = new_user_features[self.feature_cols].values
X_scaled = self.scaler.transform(X)
# 规则引擎
rule_flags = self._apply_rules(new_user_features)
# 模型预测
iso_pred = self.iso_forest.predict(X_scaled) # 1=正常, -1=异常
lof_pred = self.lof.predict(X_scaled) # 1=正常, -1=异常
iso_anomaly = (iso_pred == -1).astype(int)
lof_anomaly = (lof_pred == -1).astype(int)
rule_anomaly = rule_flags.astype(int)
# 集成
ensemble_votes = iso_anomaly + lof_anomaly + rule_anomaly
if ensemble_method == 'vote':
final_pred = (ensemble_votes >= 2).astype(int)
elif ensemble_method == 'any':
final_pred = (ensemble_votes >= 1).astype(int)
else: # 'all'
final_pred = (ensemble_votes == 3).astype(int)
# 生成可解释输出
results = new_user_features[['user_id']].copy()
results['is_anomaly'] = final_pred
results['iso_forest_flag'] = iso_anomaly
results['lof_flag'] = lof_anomaly
results['rule_flag'] = rule_anomaly
results['confidence'] = ensemble_votes / 3 # 0~1
return results
def _apply_rules(self, df):
"""业务规则:比模型更快,优先处理明显案例"""
flags = pd.Series(False, index=df.index)
for rule in self.rules:
flags |= rule['condition'](df)
return flags
# 使用示例
system = EcommerceAnomalySystem()
# 添加业务规则
system.add_rule(
name='高频购买',
condition_fn=lambda df: df['purchase_count'] > 100,
reason='24小时内购买次数超过100次'
)
system.add_rule(
name='多设备异常',
condition_fn=lambda df: df['unique_devices'] > 5,
reason='24小时内使用超过5个不同设备'
)
小结
异常检测问题定义先于算法选择。在选择 Isolation Forest 或 LOF 之前,需要先明确:
- 全局异常 vs 局部异常 → Isolation Forest vs LOF
- 点异常 vs 集体异常 → 单点方法 vs 时间窗口聚合
- 静态阈值 vs 动态阈值 → 简单统计 vs 时序感知方法
- 数据量 → 大规模场景优先 HBOS 或 Isolation Forest,小规模精度要求高用 LOF
核心要点回顾:
- 统计方法:可解释、快速、适合单变量,使用鲁棒 Z-score 而非普通 Z-score
- Isolation Forest:最通用,适合全局异常,对高维数据先降维
- LOF:专门用于局部异常,对 k 值敏感,小数据集首选
- 评估无标注:用 PR-AUC 替代 ROC-AUC,结合业务约束设定阈值
- 工程设计:多算法集成 + 规则引擎,分层检测,输出可解释
能读到这里,说明对异常检测有真正的兴趣。欢迎点赞收藏,原创内容需要积累,每一个认可都有价值。
本系列更多文章: