🤖 第12章 异常检测
📄 一句话概括

不学边界,直接估「这个点在测试集里比在参照集里常见多少倍」。 实现方式是训练一个分类器区分参照点和测试点——几率就是密度比。 正常点中位数 0.865(理论上应该正好是 1),紧密小簇 76.7 到 85.2 倍。 零散异常只有 1.3 到 1.9,和正常点的 90 分位 2.891 混在一起。 AUC:全部 0.920、零散异常 0.816、紧密小簇 1.000。 和 LOF 正好反过来(0.966 和 0.821)——两条偏置方向相反。

📝 本节和前面几节的关系

前两节的分数都不好用:LOF 是个没有量纲的比值,单类 SVM 只能判里外。 这一节换一个「天生可排序、而且能解释」的量。 密度比的想法是:正常数据的分布是已知的(有一大堆参照样本),那就直接问「新点在这里, 比参照集里常见多少倍」。 倍数大就是异常。这个量没有上界,天然能排序,而且能直接读成人话。

一、换一个问法

设参照集的密度是 p 下标零,待测数据的密度是 p。 密度比 w(x) 等于 p(x) 除以 p 下标零(x)。 正常点两边的密度差不多,w 约等于 1; 异常点在参照集里几乎不出现,p 下标零 接近零,所以 w 会很大。 和 LOF 比,这个量是「全局」的:分母是整批参照样本算出来的,不是邻居。 和单类 SVM 比,它是「连续」的:不像那个被压成同一常数的下界。 这两个性质正是前两节各自缺的那一半。 它还有一个别人没有的好处:可解释。 「这个点在测试集里出现的概率,是它在参照集里的 85 倍」——这句话业务方听得懂,也能拿去写报告。 LOF 的 6.14 和单类 SVM 的 负 0.06 都做不到这一点。

二、怎么估:借一个分类器

直接拟合密度比要解一个函数估计问题,容易出问题(比如拟合出来的比值是负数)。 有个漂亮的办法:训练一个二分类器,把参照点标 0、测试点标 1,让它区分两者。 关键的一步推导:P(测试 给 x) 正比于「测试密度 乘 测试点数」, P(参照 给 x) 正比于「参照密度 乘 参照点数」。 两式一除,得到「几率 等于 密度比 乘 样本数比」,于是浓度比就能反解出来。 实测的换算是「密度比 等于 exp(f) 乘 参照点数 除以 测试点数」,f 是分类器的对数几率。 这个推导把「估密度比」和「训练一个二分类器」变成了同一件事的两种说法。 于是第 10 章的 Logistic 回归在这里被原样借了过来——那里的输出是概率,这里要的是它的几率。 剩下的实现就是第 10 章那套:高斯核基展开加牛顿法,加一点 ℓ2 惩罚。 实测 40 个核中心、带宽 2.5、惩罚系数 0.001,牛顿法几轮就收敛。 分数直接是 exp(f) 乘 360 除以 222,所以它一定能排序、一定能读成倍数。

三、实测

点的类型 密度比 怎么读
正常点中位数 0.865 约等于 1,两边一样常见
正常点 90 分位 2.891 正常范围的上沿
零散异常 1.3 到 1.9,另有一个 39.0 五个混在正常范围
紧密小簇 76.7 到 85.2 极高
正常点的中位数是 0.865,理论上应该正好是 1。
偏一点是因为核基只有 40 个中心、还加了惩罚——拟合不完美,这个偏差是正常的。
要紧的是它稳定:90 分位才 2.891,说明正常点基本都待在 1 附近。
紧密小簇那一行非常干净:76.7 到 85.2 倍,六个点彼此也差不多。
这是它比单类 SVM 强的地方——单类 SVM 只能告诉你「在外面」,而这里能说「常见 85 倍」。
但零散异常那一行就露怯了。
六个点里只有一个到了 39.0 倍(它确实离得特别远),剩下五个在 1.3 到 1.9 之间,
和正常点的 90 分位 2.891 完全混在一起。
AUC 也印证了:零散异常 0.816,明显低于 LOF 的 0.966。
原因很清楚:密度比是个全局量,它问「这一带有没有比参照集更密」。
而那五个点是「局部孤立、全局正常」——它们待在参照集本来就稀疏的区域里,
整个区域的密度比都不高,所以它们自己也淹没在背景里。
LOF 恰好相反:它只看邻居,所以这种「稀疏区里的孤点」正是它的拿手戏。

四、三条思路的偏置方向

方法 AUC 全部 AUC 零散 AUC 紧密簇 前 12 命中 要训练吗
局部异常因子 0.905 0.966 0.821 4 个 不用
单类 SVM 0.996 0.968 0.995 10 个 要
密度比 0.920 0.816 1.000 7 个 要
先看那个不太舒服的事实:单类 SVM 在这一份数据上三个指标全面最好。
别急着说它更先进——这份数据的两类异常有一个共同点:都离参照集很远。学边界的方法正好对口。
换一份「参照集内部混进不协调点」的数据,排序完全可能翻过来。
更值得看的是 LOF 和密度比那一对:它们的强项正好相反。
LOF 是 0.966 对 0.821,密度比是 0.816 对 1.000——一条是「零散高、簇低」,另一条正好倒过来。
这不是巧合,是两种基准的必然结果:LOF 的基准是邻居(相对量),
密度比的基准是整个参照集(全局量)。
所以「该用哪个」的答案不是一个名字,而是一个问题:你担心哪一种异常?
担心「内部混进了不协调的点」,用局部的;担心「整体多了一批不该有的东西」,用全局的。
两类都担心,就两个都跑,把两个分数拼成一个特征向量交给人看——这是工程上最常见的做法,
也是这一章想留下的最实用的一句话。

五、异常检测的通用纪律

第一条:没有标签,就没有「正确率」。 所有评价都只能基于「你觉得哪些是异常」这个假设,所以 AUC 这类指标本身也是靠假设算出来的。 这一章所有数字都建立在「我知道哪 12 个点是异常」之上——真实场景里你不知道,只能靠抽查。 第二条:阈值必须人来定,模型只负责排序。 模型给出分数,人决定「多少分以上要报警」。 这个决定取决于「漏一个的代价」和「误报一个的代价」哪个更大——那是业务问题,不是算法问题。 第三条:参数极其敏感,而且没有验证集可依。 LOF 的 k、单类 SVM 的 nu 和 h、密度比的 h 和惩罚系数,全都影响结果。 在没有标签的情况下,调参只能靠「看起来对不对」和「抽查一部分」。 这也是为什么实践里常常几个方法一起跑、看它们是否互相印证。 第四条:先用简单的。 很多真实问题的异常,用「超出三倍标准差」「离最近邻特别远」就能抓住大半。 复杂方法的价值在于「密度不均匀」「异常成簇」这些简单方法失效的场合—— 而你得先确认自己确实在那种场合。

六、动手 Demo:密度比:正常点中位数 0.865,最异常的点是 85.2 倍

同一份数据:参照集 360 个正常点,测试集 222 个点(含 12 个异常)。 把参照点标 0、测试点标 1,用高斯核基展开加 ℓ2 惩罚训练一个 Logistic 回归,牛顿法求解; 再把对数几率换算成密度比(乘上参照点数除以测试点数)。 看正常点和两类异常的分数分布,算 AUC 和前 12 命中,最后把三种方法的成绩摆在一张表里对比偏置方向。

def ratio_by_clf(Xref, Xte, h=2.5, ncen=40, lam=1e-3):
    C = pick_centers(Xref, Xte, ncen)          # 中心从两边一起挑
    Pr = [1, K(Xref, C, h)]                    # 参照点的特征
    Pt = [1, K(Xte, C, h)]                     # 测试点的特征
    A = np.vstack([Pr, Pt])
    t = np.r_[np.zeros(len(Xref)), np.ones(len(Xte))]
    b = newton_logistic(A, t, lam)             # 和第 10 章同一套
    return b, C

w = np.exp(A_te @ b) * (len(Xref) / len(Xte))  # 几率换算成密度比

实跑输出:

--------------------------------------------------------------------
密度比:正常点中位数 0.865,最异常的点是 85.2 倍
--------------------------------------------------------------------
  思路                     估 w(x) 等于「测试集的密度」除以「参照集的密度」,越大越异常
  实现                     训练一个分类器区分「参照点」和「测试点」,几率就是密度比
  换算                     密度比等于 exp(f) 乘 参照点数 除以 测试点数

  **这一步是关键:分类器输出的是一个几率,而几率本身就是一个密度比。**
  **因为 P(测试 给 x) 正比于「测试密度 乘 测试点数」,P(参照 给 x) 同理,两式一除,样本数比就露出来了。**
  **所以「估密度比」和「训练一个二分类器」是同一件事的两种说法。**
  **这也把第 10 章的 Logistic 回归借过来了:那里的输出是概率,这里要的是它的几率。**

  分数的分布:

  点的类型         密度比                               怎么读
  ---------------------------------------------------------------------------
  正常点中位数     0.865                                约等于 1,两边一样常见
  正常点 90 分位   2.891                                正常范围的上沿
  零散异常         1.3、1.9、1.9、1.8、1.3、39.0        五个混在正常范围
  紧密小簇         76.7、79.3、78.1、85.2、84.4、84.8   极高

  **正常点的中位数是 0.865——理论上应该正好是 1,偏一点是因为核基只有 40 个中心,拟合不完美。**
  **紧密小簇那六个点是 76.7、79.3、78.1、85.2、84.4、84.8,最大 85.2 倍。**
  **这个数字是可直接解释的:「这个点在测试集里出现的概率,是它在参照集里的 85 倍」。**
  **上一节的单类 SVM 给不出这种话,它只能说「在边界外」。**

  **但零散异常那一行就露怯了:1.3、1.9、1.9、1.8、1.3、39.0。**
  **其中最高的 39.0 倍那个点确实离得特别远;剩下五个在 1.3 到 1.9 之间,和正常点的 90 分位 2.891 完全混在一起。**
  **原因很简单:密度比是个全局量,它问「这里有没有比参照集更密」,而那几个点是「局部孤立、全局正常」——它们待在参照集本来就稀疏的地方,整个区域的密度比都不高。**

  考哪一类           AUC   按异常分数取前 12 个,命中几个
  ------------------------------------------------------
  两类异常合起来   0.920                             7 个
  只看零散异常     0.816                                —
  只看紧密小簇     1.000                                —

  三种方法摆在一起(同一份数据、同一套指标):

  方法           AUC 全部   AUC 零散   AUC 紧密簇   前 12 命中   要训练吗
  ----------------------------------------------------------------------
  局部异常因子      0.905      0.966        0.821            4   不用
  单类 SVM          0.996      0.968        0.995           10   要
  密度比            0.920      0.816        1.000            7   要

  **单类 SVM 在这份数据上三个指标全面最好(0.996、0.968、0.995)。**
  **别急着说它更先进——这份数据的两类异常有一个共同点:都离参照集很远。学边界的方法正好对口。**
  **更值得看的是 LOF 和密度比那一对:它们的强项正好反过来。**
  **LOF 是 0.966 对 0.821,密度比是 0.816 对 1.000——一条是「零散高、簇低」,另一条正好倒过来。**
  **这不是巧合:LOF 的基准是「邻居的密度」,所以它是相对量,一小簇异常点搬到哪里都「不孤立」;**
  **密度比的参照是「整个参照集」,所以它是全局量,搬走的一整簇一眼就看出,而藏在稀疏区里的孤点反而淹没在背景里。**
  **换句话说:选哪种方法,取决于你担心的是哪一类异常。**
  **担心「内部混进了不协调的点」,用局部的;担心「整体多了一批不该有的东西」,用全局的。**
  **两类都担心,那就两个都跑,把分数拼起来看——这也是工程上最常见的做法。**
  要说明:**这三个数都是在同一份人造数据上量的,而且参数是我挑过的。**
  **换一份数据,排序完全可能翻过来——AUC 的绝对值不能迁移,只有「哪个方法适合哪类异常」这个结构是可以迁移的。**

np.exp(A @ b) 就是几率:分类器输出的概率 p 满足 p 除以 (1 减 p) 等于 exp(f)。 所以这一行代码同时做了两件事:把对数几率变回几率,再乘样本数比变成密度比。 np.clip 那一步不能省——f 一旦大到 700,exp 直接溢出成 inf,后面全废。 核中心是从「参照点加测试点」一起挑的,不是只从参照点挑。 因为要拟合的比值在异常点附近变化最剧烈,中心必须覆盖那片区域; 只从参照点挑,异常点附近一个中心都没有,那里就拟合不出来。 这个细节很小,但它决定了方法能不能用。 ℓ2 惩罚在这里是必需的,不是可选的。 41 个特征、582 个样本,看起来是超定的,但核基之间高度相关,不加惩罚解会剧烈震荡。 这一条和第 10 章最小二乘概率分类那个「条件数 1.385e+18」是同一个病。 分类器训练用的是「参照 加 测试」全部样本,这一点和常规的监督学习不同。 它要估的是「这两批数据之间的密度比」,所以两批都得看见。 这也意味着每来一批新的待测数据,模型就得重训一次——在流式场景里这是个实实在在的成本。 为什么不用「直接最小二乘拟合密度比」那条路?我试过。 闭式解出来之后,在远离所有核中心的地方 w 会趋于零——而异常点恰恰就在那里,于是分数全反了。 核基展开没有外推能力,这是核方法的通病; 换成分类器那条路就绕开了它,因为分类器在「没有参照点」的地方会自信地说「这是测试点」。 要说明:中位数 0.865 而不是 1,说明这个估计是有偏的。 偏差来自三处:核中心只有 40 个、加了 ℓ2 惩罚、以及有限样本。 所以这个量的绝对值不能当真,能信的是「相对大小」——85 倍和 1.3 倍的差别是真的, 0.865 是不是精确等于 1 不重要。

30 秒实验

  1. 把核中心数从 40 改成 10 和 100,重跑。 正常点的中位数会靠近 1 吗?紧密小簇的倍数会变吗?计算时间呢?
  2. 把带宽从 2.5 改成 0.8 和 5.0,重跑。哪个设置下零散异常开始被抓住?代价是什么?
  3. 把参照集故意缩小到 60 个点(只留主簇),重跑。 密度比会怎么变?这说明参照集的覆盖范围为什么重要?

常见误区

  1. 「密度比就是概率」。 它是两个密度的比值,可以大于 1,没有上界,也不归一化。 实测最异常的点是 85.2 倍——这不是「85.2% 的概率」,是「常见 85 倍」。 想当概率用得另做校准,那是第 10 章的事。
  2. 「估密度比一定要直接拟合比值函数」。 训练一个二分类器再换算几率,是等价而且更稳的做法。 实测直接拟合那条路在远离核中心的地方会退化到零,而异常点恰好在那里,结果分数完全反了。 分类器那条路不会有这个问题,因为它在没有参照点的地方会自信地判成「测试点」。
  3. 「密度比是全局方法,所以它一定比 LOF 全面」。 实测它漏掉了零散异常(AUC 0.816 对 LOF 的 0.966)。 全局量的代价就是「藏在稀疏区里的孤点会被背景淹没」。 两种基准各有盲区,没有谁全面。
  4. 「AUC 0.92 说明这个方法在这类问题上就是这个水平」。 AUC 的绝对值完全依赖数据——这份数据是我造的、参数是我挑的。 能迁移的只有「哪种基准适合哪类异常」这个结构,不是具体数字。 把别人论文里的 AUC 当成自己场景的预期,是最常见的错误之一。
自测三问(点开对答案) 1. 为什么密度比可以用一个分类器间接算出来? —— **因为「P(测试 给 x) 除以 P(参照 给 x)」按贝叶斯公式展开,** **等于「测试密度除以参照密度」乘「测试点数除以参照点数」。** **左边那个比值就是分类器给出的几率,右边只有密度比是未知的,反解即可。** **所以「估密度比」和「训练一个二分类器」是同一件事的两种说法。** 2. 为什么它在零散异常上反而不如 LOF? —— **因为它的分母是整批参照样本算出来的全局密度。** **那五个点待在参照集本来就稀疏的区域,整个区域的密度比都不高,** **所以它们自己的密度比也被背景压平了。** **LOF 的分母是「邻居的密度」,只跟身边比,所以稀疏区里的孤点在它眼里依然突兀。** 3. 如果要用它上线,最需要注意什么? —— **三件事。** **一是参照集必须能代表「正常」,它脏了后面全歪。** **二是每来一批新数据都要重训(因为模型是拿「参照 加 待测」一起训的),** **流式场景要先算清楚这个成本。** **三是阈值要人定,模型只负责排序——「85 倍」是不是要报警,取决于漏报和误报哪个更贵。**

第 12 章把「找出不一样的东西」讲完了,三种基准各有一个盲区。 从这里开始正式进入无监督学习:没有标签,也没有「正常参照集」,只有一堆数据本身。 下一章要问的是另一个问题——这么多维,能不能压到两维来看?压掉的那些维度里丢了什么?

相关笔记