🤖 第8章 支持向量机分类
📄 一句话概括

60 个正常点加 10 个深埋的错标点: 普通 SVM 的边界被拖歪 16.93 度,干净点准确率从 100.0% 掉到 98.3%; Ramp 把它拉回 0.00 度,w 和 b 与「只用干净点」的参照解一模一样。 CCCP 三轮:支持向量从 33 个掉到 3 个,||w|| 从 0.685 涨到 2.153。 但那 10 个错标点仍然全错(间隔从负 6.06 到负 7.43)——Ramp 不是把它们改对, 是不再为它们让步。

📝 本节和前面几节的关系

8.5 把 SVM 看成了「hinge 加 L2」。 hinge 有个毛病:它在负方向上没有上限,一个 margin 等于负 6 的点罚 7,梯度还是满的。 一个标错类的点就能一直拽着边界,这和第 6 章平方损失被异常点拖走是同一个病。 这一节的办法和第 6 章一样:换损失,不换模型。 只是这次换完之后目标不再凸,需要一套新的迭代求解办法。

一、hinge 为什么会被拖歪

先把「拖歪」这件事说清楚。 假设有个点标成负类、位置却在正类的核心区。 对 hinge 来说,它的 margin 是个很大的负数(Demo 里是负 6 到负 7),损失是 1 减 margin, 也就是 7 到 8,梯度一直是满的。 于是这个点在优化里拥有「无限话语权」:只要把边界往它那边挪一点,它的损失就线性下降。 十个这样的点一起拉,边界就被拽歪了。 实测是 16.93 度,干净点准确率掉了 1.7 个百分点。 注意这不是「C 太大」的问题。 把 C 调小可以减轻,但代价是所有正常点也被放松了,间隔变宽、泛化变差。 C 是一个全局旋钮,它没办法「只对异常点放松、对正常点收紧」。 要区分对待,就得改损失函数本身。

二、ramp:给损失封顶

ramp 损失的定义是:hinge 减去「负 margin 的那一段」。 写出来是 max(0, 1 减 m) 减去 max(0, 负 m)。 在 m 大于等于 1 时两者都是 0,差是 0;在 0 到 1 之间,第二项是 0,差就是 hinge; 在 m 小于 0 时,两项相减正好等于 1。 所以 ramp 就是「hinge 在 1 处封顶」。 Demo 那张损失表上,margin 等于负 1 时 hinge 是 2.00、ramp 是 1.00; margin 负 0.25 时 hinge 1.25、ramp 还是 1.00。 封顶之后,那些离谱的点的损失不再增长,梯度变成零——它们不再有话语权。 代价是这个损失不凸了。 hinge 是凸的,减掉一个凸函数之后就不再凸,于是可能有好几个局部极小。 凸优化的那套保证(唯一全局最优、任意初值收敛到同一点)全部失效。 求解靠的是 CCCP:把「凸 减 凸」拆开,每轮把那个被减掉的凸函数在当前解处线性化, 于是每轮变成一个普通的加权 hinge 问题。 而 ramp 这个特例下,线性化的结果特别干净:已经被判到错误一侧的点,权重直接变成 0。

三、实测:16.93 度被拉回 0.00 度

三个解摆在一起。第一行是参照——那 10 个脏点干脆不参与:

解 w b 与参照夹角 干净点准确率 全体准确率 支持向量
只用干净点 (1.777, 1.215) 1.402 0.00 度 100.0% 85.7% 3
普通 SVM (0.654, 0.205) 负 0.116 16.93 度 98.3% 84.3% 33
Ramp SVM (1.777, 1.215) 1.402 0.00 度 100.0% 85.7% 3
第二行是问题所在:10 个脏点把边界拧了 16.93 度,
w 从 (1.777, 1.215) 变成 (0.654, 0.205)——方向完全变了。
干净点准确率从 100.0% 掉到 98.3%,支持向量从 3 个涨到 33 个。
第三行是答案:Ramp 把边界拉回来了,而且不是「差不多拉回来」,
是 w 和 b 与参照解一模一样到小数点后三位。
因为它做的其实就是「把那 10 个点踢出去再重解」,而踢出去之后剩下的就是那 60 个干净点。
支持向量从 33 个掉到 3 个,这个信号很直白:问题变简单了。
33 个支持向量里有一大部分是「两边在互相拉扯」的证据——边界被夹在正常点和脏点之间,
两边都顶着它。

四、实测:CCCP 三轮在做什么

| 轮次 | 权重为 0 的点 | 判错的点数 | 支持向量 | ||w|| | |---:|---:|---:|---:|---:| | 1 | 0 | 11 | 33 | 0.685 | | 2 | 11 | 10 | 3 | 1.191 | | 3 | 10 | 10 | 3 | 2.153 | 第一轮权重全是 1,就是普通 SVM,解完有 11 个点判错。 第二轮把这 11 个点的权重清零,支持向量立刻从 33 个掉到 3 个,||w|| 从 0.685 涨到 1.191。 第三轮清零的点从 11 个减到 10 个——有一个点在第二轮里被分对了、权重被放回来; 这一轮 ||w|| 定在 2.153,之后再不动。 「每轮只能降不能升」是有保证的。 CCCP 每轮最小化的那个加权 hinge,是原目标的一个上界,而这个上界在当前解处正好等于原目标。 所以一轮走完,新解的原始目标不会比旧解差。 这是「凸 减 凸」这套办法能用的根本理由。 但保证只到「不升」为止,保证不了「到全局最优」。 CCCP 是局部方法:初值不同,可能落到不同的局部极小。 实务上从普通 SVM 的解出发是个稳妥的选择,因为这个初值已经把它带到了一个合理的区域。 最后必须把这一句说透:那 10 个脏点一个都没救回来。 它们的间隔从负 6.06 到负 7.43,全是判错的。 全体错数从 11 个变成 10 个,少的那个是干净点,不是脏点。 Ramp 干的事不是「把它们改对」,是「不再为它们让步」。

五、动手 Demo:Ramp 损失:普通 SVM 的边界被拖歪 16.93 度,Ramp 拉回 0.00 度

造两份数据:60 个正常点(两团,中心在 (负 1.4, 负 1.0) 与 (1.4, 1.0),标准差 0.75), 外加 10 个标成负类、位置却深埋在正类核心区的点。 先只用那 60 个干净点解一个参照解,再用全部 70 个点解普通 SVM,最后用 CCCP 解 Ramp SVM, 三者对比方向和准确率。然后打印 CCCP 每一轮的权重与支持向量个数,看它是怎么把脏点踢出去的。

def cccp_ramp(X, y, C, rounds=6):
    wt = np.ones(len(y))
    for _ in range(rounds):
        a, b, _ = svm_fit(X, y, C=C * wt)       # 加权 hinge
        bad = (y * (X @ w + b)) < 0.0           # 判到错误一侧
        wt2 = np.where(bad, 0.0, 1.0)           # 权重清零
        if np.array_equal(wt2, wt):
            break
        wt = wt2
    return a, b

实跑输出:

--------------------------------------------------------------------
Ramp 损失:普通 SVM 的边界被拖歪 16.93 度,Ramp 拉回 0.00 度
--------------------------------------------------------------------
  数据                     60 个正常点(两团,标准差 0.75),外加 10 个标成负类、位置却深埋在正类里的点
  ramp 损失                hinge 减掉「负 margin 的那一段」,等价于 hinge 在 1 处封顶
  求解                     CCCP:每轮把已经判错一侧的点权重清零,再解一个加权 hinge

  三个解摆在一起(第一行是参照:那 10 个脏点直接不参与):

  解           w                     b   与参照夹角   干净点准确率   全体准确率   支持向量
  ---------------------------------------------------------------------------------------
  只用干净点   (1.777, 1.215)    1.402         0.00         100.0%        85.7%          3
  普通 SVM     (0.654, 0.205)   -0.116        16.93          98.3%        84.3%         33
  Ramp SVM     (1.777, 1.215)    1.402         0.00         100.0%        85.7%          3

  **第二行是问题所在:10 个脏点把边界拧了 16.93 度,干净点上的准确率从 100.0% 掉到 98.3%。**
  **第三行是答案:Ramp 把边界拉回 0.00 度,w 和 b 与参照解一模一样((1.777, 1.215) 对 (1.777, 1.215)),干净点准确率回到 100.0%。**
  **支持向量从 33 个掉到 3 个:问题变简单了,因为脏点被踢出了优化。**

  CCCP 每一轮发生了什么(每轮都在解一个新的加权 hinge):

  轮次   权重为 0 的点   判错的点数   支持向量   ||w||
  ---------------------------------------------------
     1               0           11         33   0.685
     2              11           10          3   1.191
     3              10           10          3   2.153

  **第一轮权重全是 1,所以「权重为 0」那一格是 0,解完有 11 个点判错。**
  **第二轮就把这 11 个点的权重清零了(那一格变成 11),支持向量立刻从 33 个掉到 3 个,||w|| 从 0.685 涨到 1.191。**
  **第三轮权重又调了一次:清零的点从 11 个减到 10 个,有一个点在第二轮里被分对了,权重被放回来;||w|| 这一轮定在 2.153。**
  **每轮都在往下走,不会来回跳——这是 CCCP 的保证:它每轮最小化的那个加权 hinge,是原目标的一个上界,而这个上界在当前解处正好等于原目标,所以一轮只能降不能升。**
  权重对照:

  干净点的权重             1(60 个点,全部保留)
  脏点的权重               0(10 个点,全部清零)

  **这里有个最容易理解错的地方:那 10 个脏点仍然是判错的。**
  脏点的间隔               -6.60、-6.47、-6.43、-7.43、-6.06
  全体错数                 Ramp 10 个,普通 SVM 11 个
  其中干净点               Ramp 错 0 个,普通 SVM 错 1 个
  其中脏点                 Ramp 错 10 个,普通 SVM 错 10 个

  **间隔全是 -6.7 左右的大负数(标签乘输出),那 10 个脏点一个没救回来。**
  **全体错数从 11 个变成 10 个,少的那个是干净点,不是脏点:脏点 10 个一个不少,全都还错着。**

  **Ramp 干的事不是「把它改对」,而是「不再为它让步」——损失函数在这里封了顶,它再离谱也最多罚 1,导数直接变成 0,于是它没法再把边界往自己这边拽。**
  **这一点值得记住:鲁棒损失不是把异常点变正常,是把异常点的影响力截断。异常点还在那儿,只是不再有投票权。**
  要说明:**这 10 个脏点是我手放的,位置特意放在正类核心区(离原点 2.2 到 3.0),这样才能把边界明显拽歪;放在边界附近只会挪一挪截距,看不出角度变化。**

C=C * wt 这一行是全部机巧所在。 逐样本 C 等于 0 的点,在 SMO 里等价于「乘子只能取 0」,也就是完全不参与。 所以「把权重清零」不需要改求解器,只要把 C 数组对应位置设成 0。 这也是为什么 8.2 的求解器我一开始就写成支持逐样本 C——不然后面这一步得重写一遍。 bad = (y * (X @ w + b)) < 0.0 这里用的是「间隔小于零」,不是「损失大于某阈值」。 因为 ramp 的封顶点在 margin 等于 0:小于 0 之后损失平在 1、导数为零。 阈值写错成「间隔小于 1」的话,会连边界上的正常点一起踢掉, 那模型就退化成「只用分得很开的点训练」,边界会跑偏。 if np.array_equal(wt2, wt): break 这个提前退出不能省。 因为那些脏点会一直判错,权重会一直是 0,如果不比较权重就只在「判错数等于零」时退出, 那永远退不出来,白白跑满 6 轮。 我第一版就是这么写的,结果每轮都在重复同一个解。 注意这个实现没有对「全部样本被清零」做保护。 如果数据里某一类全是脏点,权重会全零,SMO 会因为没有可行点而直接返回全零解。 这在实践中意味着「ramp 在小样本加高噪声时会崩」,是个真实的风险。 要说明:这 10 个脏点是我手放的,位置特意放在正类核心区(离原点 2.2 到 3.0)。 放在边界附近只会挪一挪截距,看不出角度变化——这也说明「异常点的影响有多大, 取决于它离边界多远」。

30 秒实验

  1. 把脏点从 10 个减到 3 个,重跑。普通 SVM 还会被拖歪吗?Ramp 还有必要吗?
  2. 把脏点改成「标成正类、位置却在负类里」,重跑。结论会对称吗?不对称的话,为什么?
  3. 把 C 从 10 降到 1,重跑普通 SVM 和 Ramp。 普通 SVM 被拖歪的角度会变小吗?这说明 C 和 Ramp 能互相替代吗?

常见误区

  1. 「Ramp 损失会把异常点改对」。 不会。 它只是不再为异常点让步,异常点仍然被判错。 实测里那 10 个脏点的间隔从负 6.06 到负 7.43,一个都没救回来。 鲁棒损失截断的是影响力,不是错误本身。
  2. 「鲁棒损失不凸,所以不能用」。 能用,只是没了全局最优的保证。 CCCP 每轮解一个凸子问题,能保证目标不升;从普通 SVM 的解出发,实践上很稳。 「不保证全局最优」不等于「不能用」,很多方法都不保证。
  3. 「把 C 调小就能抗噪,不需要 Ramp」。 C 调小是全局放松,正常点也被放松了,间隔会变宽、模型会变钝。 Ramp 是「只对判错一侧的点放松」,正常点一个都不受影响。 实测里 Ramp 的解和「只用干净点」的解完全一样,C 调小做不到这一点。
  4. 「异常点检测和鲁棒学习是一回事」。 目标不同。 异常点检测要「把这个点找出来」(第 12 章),鲁棒学习要「让模型不受它影响」。 Ramp 顺手能起到一点检测作用(权重被清零的点就是它认定的异常),但它不是为检测设计的。
自测三问(点开对答案) 1. 为什么 ramp 损失能让错得离谱的点失去话语权? —— **因为它在 margin 小于 0 之后封顶在 1,导数变成零。** **hinge 在那里导数还是满的,一个 margin 负 6 的点会产生和 margin 负 1 的点一样大的梯度。** **ramp 把这段截掉,异常点再离谱也不产生新的拉力,于是它对解没有影响。** 2. CCCP 每轮把「已判错的点权重清零」,这个做法为什么是合理的? —— **因为 ramp 等于 hinge 减去 max(0, 负 margin),** **而后者对 margin 的导数在 margin 小于 0 时是常数。** **在当前解处把它线性化,得到的就是「被减掉一项之后,那些点的 hinge 权重变成零」。** **所以「清零」不是拍脑袋的启发式,是线性化的直接结果。** 3. Ramp 的解和「只用干净点」的解完全一样,这说明了什么? —— **说明 CCCP 第一轮之后就把那 10 个点彻底排除了,之后的优化只在 60 个干净点上进行。** **而「只用干净点」就是同一个问题,所以解必然相同。** **这也说明 ramp 在这里的效果是「精确的剔除」而不是「折中的削弱」——** **它没有给异常点留一点残余影响。**

第 8 章到这儿结束了。 从「为什么要最大化间隔」一路走到「损失怎么封顶」,中间经过了间隔、对偶、SMO、稀疏、核映射、 正则视角。 下一章换一个完全不同的思路:不追求一个最好的模型,而是把很多个不太好的模型拼起来用。

相关笔记