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

只用 360 个正常点训练,异常点一个都没给它看。 两个超参数:nu 等于 0.1、带宽 1.0;乘子上限 0.02778。 对偶目标从 0.04717 单调降到 0.02874; 360 个点里 61 个乘子非零、22 个顶在上限上,而 nu 乘 n 等于 36——正好夹在 22 和 61 中间。 AUC:全部 0.996、零散异常 0.968、紧密小簇 0.995。 比 LOF 的 0.905 高一大截,关键是把那六个紧密小簇全抓出来了。

📝 本节和前面几节的关系

上一节的 LOF 输在「没有全局尺度」。 这一节给它补上一个:不比较邻居,而是学一条把整个参照集包起来的边界。 学法和第 8 章的 SVM 几乎一模一样——最大间隔、核映射、对偶、乘子。 差别只有一个:这里的「负类」不是真实样本,而是一个虚拟的原点。 所以整节内容有一大半是第 8 章的回声,新东西只有「原点」和 nu 这个参数。

一、把原点当成唯一的负类

想法很巧:既然只有一类样本,那就虚构一个「反例」——原点。 然后像第 8 章那样,找一个离原点最远、同时把大部分正常点留在自己这边的超平面。 离原点最远,等价于「把正常点包得越紧越好」;留一点松弛,是为了容忍少量离群点。 写成对偶问题之后,形式异常眼熟: 最小化 二分之一 乘 乘子向量 乘 核矩阵 乘 乘子向量, 约束是「乘子加起来等于 1」和「每个乘子落在 0 到 上界 之间」。 上界等于 1 除以 (nu 乘 样本数)。 第 8 章的箱约束还在,只是等式约束从「标签加权和为零」变成了「和为 1」。 预测也简单:分数等于「新点和各支持向量的核加权和」减去 rho,负数就算异常。 rho 是边界的「零点」,它的算法和 11.2 里那个 Z 的思路一样—— 用落在边界上的那些点(乘子既不零也不满)来定,因为只有它们的 KKT 条件是等式。 这里有个容易糊涂的地方:分数是「越大越正常」,不是「越大越异常」。 因为它是「离原点那侧的间隔」,越正说明越深入正常区域。 所以算 AUC 的时候要取负号。

二、nu 到底是什么

nu 是唯一需要调的东西,而它的含义很干净。 它同时是「顶在上限上的乘子比例的上界」和「非零乘子比例的下界」。 为什么?因为乘子总和是 1,而每个乘子不超过 1 除以 (nu 乘 n)。 如果 k 个点顶在上限,它们的和是 k 除以 (nu 乘 n),必须不超过 1,所以 k 不超过 nu 乘 n。 而要让总和凑到 1,至少要 nu 乘 n 个正乘子。 实测正好卡在这个区间里:360 个点,nu 乘 n 等于 36,实际顶在上限上的 22 个、非零的 61 个。 换成一句话:「顶在上限上的点不超过 36 个,参与的点不少于 36 个」,两头都满足。 这意味着 nu 有一个直接的现实解释:它就是「你预计有多少比例是异常」。 填 0.1 就是「我猜大约一成是脏的」。 这个解释在实践里非常好用——不用瞎调,问业务方「这批数据大概多脏」就行。 但要注意它是「比例」而不是「个数」,所以换数据量时 nu 不用改, 这一点比「取前 k 个」的做法省心。 另一个好处是它给了模型一个可解释的旋钮:nu 调大,边界放松,召回更多、误报也更多。

三、怎么解:投影梯度加线搜索

这个对偶问题的约束是「盒约束 加 一个等式」,没有第 8 章那种解析解可以两步走。 我用的办法很朴素:每步沿负梯度走一小段,走完把结果投影回可行域。 投影本身是一次二分:找一个阈值 mu,让「每个分量减去 mu 再截到 0 到 上界」的和正好等于 1。 难点在步长。 这个问题的核矩阵元素都在 0 到 1 之间,梯度是几百个核值的和,量级在零点几到几; 固定步长要么太大冲出去、要么太小爬不动。 所以加了回溯线搜索:先试一个大步长,目标没降就砍半,砍到降为止。 实测轨迹(对偶目标越小越好):

轮次 对偶目标 非零乘子
1 0.04717 39
2 0.04367 337
3 0.03993 66
100 0.02875 65
200 0.02875 61
400 0.02874 61
目标每一步都在降,这是线搜索保证的——降不下去就把步长砍半,砍到降为止。
代价是越往后越慢:头三步就从 0.04717 掉到 0.03993,后面 397 轮只再挤出 0.011。
「非零乘子」那一列值得盯着看:39、337、66、65、61、61。
第 2 轮突然跳到 337,几乎所有的点都非零了。
那不是解,那是路——梯度下降允许中途经过任意可行点,只要目标在降。
所以「看中间结果判断稀疏性」是错的,只能看收敛之后的那一个。
这类「保证下降但不保证快」的算法,收敛判据不能看样子,只能看步长或者梯度。
我的判据是「这一步的最大分量变化小于 1e-12」,
它在第 400 轮还没触发——所以严格说它没有收敛到机器精度,只是目标已经停在 0.02874 不动了。
真实项目里会用专门的 QP 求解器,或者像第 8 章那样做 SMO——成对更新天然满足等式约束,
不用投影。

四、分数的形状:只有里外,没有远近

看分数那一栏之前,先接受一件事:这份数据的分数绝对值很小,在正负零点零几的量级。 因为乘子加起来等于 1,而每个核值最多 1,所以「核加权和」最大也就是 1 的量级, 再减去 rho 等于 0.0631,就落在这个范围里了。

点的类型 分数 说明
正常点中位数 +0.0012 贴着零点,在界内
正常点 10 分位 负 0.0021 边界的下沿
零散异常 负 0.02 到 负 0.06 负数
紧密小簇 六个全是 负 0.060 负数,而且是同一个数
正常点在零附近,异常点掉到负的零点零几——相对关系很清楚,只是刻度小。
真正值得注意的是一件事:紧密小簇那六个点的分数完全一样,都是 负 0.060。
原因在核函数。
RBF 核在远离所有参照点的地方衰减到零,于是「核加权和」这一项也是零,
分数就只剩下「减 rho」这一个常数。
所以对远处的点,单类 SVM 只能回答「在外面」,不能回答「在外面多远」。
这个性质有好有坏。
好处是阈值很稳:只要出了边界,分数就是同一个下界,不会因为噪声抖来抖去。
坏处是排序失效:你要「按异常程度取前十个」,
它给不出个先后——而异常检测在工程上几乎总是要排序的(人只看得了前几个)。
要排序就得换个量。
一个常用的补救是同时看「到最近支持向量的距离」,
或者干脆用下一节的密度比——那个量天然是可排序的倍数。

五、实测对比

考哪一类 LOF 单类 SVM
两类异常合起来 0.905 0.996
只看零散异常 0.966 0.968
只看紧密小簇 0.821 0.995
按分数取前 12 个,命中 4 个 10 个
紧密小簇那一栏从 0.821 抬到 0.995,这是「全局边界」对「局部密度」的胜利。
零散异常那一栏基本持平:0.966 对 0.968。
也就是说,换成全局方法之后,LOF 原本擅长的部分没丢,原本漏掉的部分补上了。
为什么这次全局方法没吃亏?
因为参照集本身是双峰的(一个密簇、一个稀簇),而一个合适带宽的 RBF 边界恰好能把两处都包住。
如果两个簇离得更远,或者中间还夹着一片「正常但稀疏」的区域,
一个 h 就包不住了——那时边界会为了包住稀疏区而放得很松,检出率整体下降。
这也解释了单类 SVM 最大的隐忧:它只有一个带宽 h。
h 大了,边界糊成一片,什么都包进来,异常也包进来;
h 小了,边界碎成许多小球,只包住训练点附近,稍微偏一点就报异常。
真实数据尺度不一致时,这个矛盾没有好解法,只能先标准化,或者上多尺度核。

六、动手 Demo:支持向量机异常检测:61 个乘子非零,AUC 0.996,紧密小簇也抓到了

只用参照集的 360 个正常点训练,异常点一个都不给。 对偶问题是「最小化乘子的二次型,约束乘子和为 1、每个乘子不超过 1/(nu n)」, 用投影梯度加回溯线搜索求解(每步投影回可行域,目标没降就把步长砍半)。 nu 取 0.1、带宽取 1.0,跑 400 轮,记录目标和非零乘子数。 然后用「核加权和 减 rho」给测试集打分,取负号当异常分数,算 AUC 和命中最多的前 12 个。

def ocsvm_fit(X, nu=0.1, h=1.0, iters=400):
    n = len(X)
    C = 1.0 / (nu * n)                     # 乘子上限
    K = rbf(X, X, h)
    a = np.full(n, 1.0 / n)                 # 从「均匀」出发
    f = 0.5 * a @ K @ a
    for it in range(iters):
        g = K @ a
        t = 20.0
        while True:                         # 回溯线搜索
            an = proj_simplex_box(a - t * g, C)
            fn = 0.5 * an @ K @ an
            if fn < f - 1e-16:
                break
            t *= 0.5
        a, f = an, fn
    return a, rho_of(a, K, C)

实跑输出:

--------------------------------------------------------------------
单类 SVM:61 个乘子非零,AUC 0.996,紧密小簇也抓到了
--------------------------------------------------------------------
  训练数据                 只用参照集的 360 个正常点,异常点一个都没给它看
  模型                     分数等于「核加权和 减 rho」,负数就算异常
  两个超参数               nu 等于 0.1(乘子上限的倒数),带宽 h 等于 1.0
  乘子上限                 1 除以 (nu 乘 n) 等于 0.02778

  对偶问题的求解轨迹(目标越小越好,非零乘子数是解的稀疏度):

  轮次   对偶目标   非零乘子
  -------------------------
     1     0.0472         39
     2     0.0437        337
     3     0.0399         66
   100     0.0288         65
   200     0.0287         61
   300     0.0287         62
   400     0.0287         61

  **目标从 0.04717 一路降到 0.02874,每一步都在降。**
  **这不是运气,是线搜索保证的:每步先试一个步长,目标没降就把步长砍半,砍到降为止。**
  **代价是越往后越慢——第 1 步就从 0.04717 掉到 0.04367,第 3 步到 0.03993。**
  **看「非零乘子」那一列:39、337、66、65、61、62、61,头几步剧烈跳动,第 100 轮之后才稳在 61 上下。**
  **那些中间状态不是解,是算法走的路——它允许临时变成一个很稠密的解,只要目标在降。**
  这类「只保证下降、不保证快」的算法,收敛判据只能看步长或者梯度,不能看样子。

  **解的稀疏度值得单独说:360 个点里只有 61 个乘子非零,其中 22 个顶在上限上。**
  **nu 乘 n 等于 36.0,也就是「顶在上限上的点最多 nu 比例、非零的点至少 nu 比例」——实测 22 和 61 正好夹在 36 的两边。**
  **这就是 nu 这个参数的全部含义:它同时是「异常比例的上界」和「支持向量比例的下界」。**
  **所以实践里 nu 不用瞎调,直接填「你预计有多少比例是异常」就行。**

  看分数(越大越正常,负数算异常):

  点的类型         分数                                             说明
  ---------------------------------------------------------------------------------
  正常点中位数     +0.0012                                          贴着零点,在界内
  正常点 10 分位   -0.0021                                          边界的下沿
  零散异常         -0.040、-0.030、-0.020、-0.060、-0.050、-0.060   负数
  紧密小簇         -0.060、-0.060、-0.060、-0.060、-0.060、-0.060   负数

  **分数的绝对量级很小(正负零点零几),但相对关系很清楚:正常点在零附近,异常点掉到负的零点零几。**
  **rho 等于 0.0631,它就是这个边界的「零点」:分数比它大的在界内,比它小的在界外。**
  **紧密小簇那六个点的分数是 -0.060、-0.060、-0.060、-0.060、-0.060、-0.060,几乎一模一样。**
  **因为 RBF 核在远离所有参照点的地方衰减到零,分数就只剩「减 rho」这一项了。**
  **所以单类 SVM 只能告诉你「在外面」,不能告诉你「在外面多远」——同一簇远处的点会被压成同一个下界。**
  **这个性质有好有坏:好处是阈值稳;坏处是「按异常程度排个先后」这件事它做不好。**

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

  **和上一节比:LOF 在零散异常上是 0.966、紧密小簇上是 0.821;单类 SVM 是 0.968 和 0.995。**
  **紧密小簇这一栏从 0.821 抬到 0.995,这是「全局边界」对「局部密度」的胜利。**
  **零散异常那一栏基本持平:LOF 0.966,单类 SVM 0.968。**
  要说明:**这份数据里参照集本身就是双峰的(一个密簇、一个稀簇),所以「单个 RBF 边界」能把它俩都包住。**
  **如果两个簇离得更远、或者中间还夹着不该排除的正常点,一个 h 就包不住了——那时要么调 h,要么换成多个局部模型。**

proj_simplex_box 是这段代码里唯一有点技术含量的地方。 它要解的是:给定向量 v,找最近的、满足「每项在 0 到 C 之间、总和为 1」的点。 做法是二分找阈值 mu:clip(v - mu, 0, C) 的总和随 mu 单调下降,二分到总和等于 1 为止。 六十次二分足够把 mu 逼到双精度极限。 线搜索用的判据是「严格下降」,不是标准的 Armijo。 我先写了 Armijo(要求目标小于「线性模型预测值」), 结果一步都走不动——因为方向本身是用大步长算出来的,线性模型在那个方向上预测得很离谱。 改成「只要目标比现在小就行」之后立刻就动了,代价是收敛慢一些。 这个坑很典型:教科书上的线搜索判据都假设「方向是小步长下的下降方向」, 而投影之后这个假设不成立。 y = a - t * g 里 t 从 20 起试,不是从 1 起。 因为 K 的元素大多在 0.1 到 1 之间,梯度是几百项的和,量级能到几十; t 取 1 相当于几步就跨过整个可行域。 从 20 起试,砍半几次就找到合适的量级,省掉很多次无效试探。 rho 只对「自由乘子」求平均,也就是那些「不零也不满」的点。 理由和 SVM 里的截距一样:只有它们的 KKT 条件是等式,顶在上限上的点只能给出不等式, 不能用来定等号。 如果碰巧一个自由乘子都没有(退化情形),就退回「取中位数」兜底。 这段代码没有任何随机性,所以结果完全可复现——但这也意味着它对初值敏感。 我从「均匀」出发,是因为它对任何 nu 都可行(只要 n 乘 C 不小于 1)。 换成随机的初值,可能会落到不同的局部点——虽然这个问题是凸的,理论上不会, 但「没收敛到机器精度」的时候,初值还是会留下痕迹。 要说明:这个求解器是我自己写的,只跑到「目标不动」为止,没有做 KKT 校验。 真要上生产,应该用现成的 QP 库,并且检查「梯度在自由乘子上是否等于常数」。 我把这一点写出来,是因为「自己写的优化器看起来能跑」和「它真的解对了」是两件事。

30 秒实验

  1. 把 nu 从 0.1 改成 0.05、0.2、0.5,各跑一遍。非零乘子数和上限上的乘子数怎么变?AUC 怎么变?
  2. 把带宽从 1.0 改成 0.3、0.6、2.0,各跑一遍。 哪个设置下紧密小簇开始被漏掉?为什么带宽小反而不行?
  3. 在参照集里故意混进 10 个异常点(污染 3%),重跑。 AUC 掉多少?nu 调到 0.1 能不能把污染吸收掉?

常见误区

  1. 「单类 SVM 的分数越大越异常」。 正好反了:分数是「到边界内侧的间隔」,越大越正常。 要当异常分数用必须取负号。 这个符号搞反不会报错,只会让 AUC 变成 1 减原来的值, 然后你会得出「这个方法完全不能用」的结论。
  2. 「nu 是要调的超参数,得多试几组」。 nu 有直接含义:预计的异常比例。 实测它同时是「上限乘子比例的上界」和「非零乘子比例的下界」, 填 0.1 就是「我猜一成是脏的」。 该试的是带宽 h,nu 应该问业务。
  3. 「单类 SVM 能给异常程度排序」。 远处所有点的分数都被压成同一个下界(实测紧密小簇六个点全是 负 0.060)。 它能回答「在不在外面」,不能回答「在外面多远」。 要排序得换量——下一节的密度比就是个天然可排序的倍数。
  4. 「只用正常数据训练,所以它一定比有监督方法差」。 不一定。它用不上标签,但也避开了「异常样本太少、标签噪声大」这些坑。 实测在这份数据上它的 AUC 是 0.996,比「把异常当正类做二分类」的直觉预期还高—— 因为异常的定义本来就是「离正常远」,而它学的正是这个。
自测三问(点开对答案) 1. 单类 SVM 和普通 SVM 的差别只有一个吗? —— **主要差别是「负类从哪来」。** **普通 SVM 的负类是真实样本,单类 SVM 的负类是一个虚拟的原点。** **对偶形式也因此从「标签加权和为零」变成「乘子和为 1」,** **箱约束从「不超过 C」变成「不超过 1/(nu n)」。** **其余部分——核映射、最大间隔、支持向量、rho——几乎原样保留。** 2. 为什么 nu 既是上界又是下界? —— **因为乘子总和固定是 1,而每个乘子不超过 1/(nu n)。** **如果 k 个点顶在上限,它们的和是 k/(nu n),不能超过 1,所以 k 不超过 nu n。** **反过来,要让总和凑到 1,至少要 nu n 个正乘子。** **实测 360 个点、nu 等于 0.1 时,上限 36,实际顶格的 22 个、非零的 61 个,两头都满足。** 3. 为什么远处点的分数全都一样? —— **因为分数等于「核加权和 减 rho」,而 RBF 核在远离所有支持向量时衰减到零。** **于是核那一项贡献为零,剩下的只有 负 rho 这个常数。** **这带来一个实际后果:这个方法能判「里外」,不能排「远近」。** **工程上如果只需要一个阈值,这是优点(阈值稳);如果需要排序,就得换量。**

单类 SVM 学的是「边界」,能判里外,不能排远近。 下一节换一个既能判又能排的量:这个点在这里,比它在参照集里常见多少倍。 这个问法有个漂亮的性质——它可以用一个普通的二分类器间接算出来。

相关笔记