异常检测深度实战:工业场景的 Isolation Forest/LOF/统计方法的场景化对比

文章目录

    • 一、异常的三种形态与对应算法框架
    • 二、统计方法:简单但有明确的物理含义
      • [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,结合业务约束设定阈值
  • 工程设计:多算法集成 + 规则引擎,分层检测,输出可解释

能读到这里,说明对异常检测有真正的兴趣。欢迎点赞收藏,原创内容需要积累,每一个认可都有价值。

本系列更多文章: