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

160 个有重叠的点,SMO 迭代 71 轮收敛: 对偶目标 19.676798,原始目标 19.676816,两者差 1.8e-05;KKT 残差 1.7e-05。 对偶目标逐轮单调不减(从 0.104 涨到 19.677),而 KKT 残差不单调——中途反弹到 3.22, 最后才落到 1.7e-05。 C 从 0.1 调到 100,||w|| 从 1.194 涨到 3.164(2.6 倍), 训练准确率却在 95.0% 到 95.6% 之间没动。

📝 本节和前面几节的关系

8.1 把问题写成了「约束下最小化 ||w|| 平方的一半」。这一节讲怎么真把它解出来。 路有两条:一条是直接解约束问题,一条是转成对偶、只调乘子。 后者更常用,因为它把约束变成了「乘子落在 0 到 C 之间」这种简单的盒子约束。 讲完之后你会发现,整个求解过程只需要两样东西:一个判断「错在哪」的指标(KKT 残差), 和一个判断「解得对不对」的指标(对偶目标与原始目标是否相等)。

一、拉格朗日:把约束吸收进目标

约束优化不好解,标准做法是给每条约束配一个乘子, 把「约束下最小化」改写成「无约束下最小化一个联合函数」。 这个联合函数叫拉格朗日函数,乘子就是每个样本的「话语权」。 为什么转成对偶是划算的?因为转完之后,原始变量 (w, b) 可以解析地消掉。 对 w 求导等于零,得到「w 等于乘子乘标签再乘样本的和」——这一条极其重要: 它说明解一定是训练样本的线性组合。 对 b 求导等于零,得到「乘子乘标签求和等于零」——这就是那个等式约束。 消掉 (w, b) 之后,问题只剩下乘子:在盒子 0 到 C 里、并且乘子乘标签求和为零的条件下, 最大化一个二次函数。 这就是对偶问题。样本只以「核矩阵」的形式出现,这一点在 8.4 会变成核技巧的入口。 顺带看清一件事:目标函数里根本没有 b。 b 是解完之后由 KKT 条件反推出来的——那些「正好卡在间隔上」的点给出 b 的等式, 取它们的平均最稳。Demo 里那个 b 就是这么算的。

二、SMO:一次动两个乘子

对偶问题里有个讨厌的东西:等式约束「乘子乘标签求和为零」。 只动一个乘子,这个和立刻就变了,约束被破坏。 动两个就行——一个加、一个减,加权和不变。 这就是 SMO 这个名字的来历:序列最小优化,每次只优化一个最小的子问题,两个变量。 两个变量的子问题有解析解,不用迭代。 因为在这种约束下,可行域是一条线段,目标沿线段是个一元二次函数,顶点算出来、 再截断到线段两端就行。 Demo 里的写法是「沿约束方向走一个步长 t,再把 t 截断到可行区间」。 选哪两个乘子?这一节用的是最有效的启发式: 第一个挑「违反 KKT 最狠」的那个;第二个挑「能让目标降得最多」的那个。 违反程度用梯度衡量,因为 KKT 条件本质上是「梯度在可行方向上的投影为零」。 这里有个坑我踩过。 第一版我拿「标签乘误差」的正负去划分候选集。 结果乘子全零时误差等于负标签,那个乘积恒等于负 1。 于是四十个样本全被判进同一组,另一组空掉,一轮都动不了,直接返回全零解。 正确做法是按「标签符号加上是否在盒子边界上」分组,排序才用梯度。

三、实测:迭代轨迹

把 SMO 「只跑前 k 轮」的结果一个个记下来,就得到轨迹:

迭代轮数 支持向量 对偶目标 KKT 残差
1 2 0.104495 2.1662e+00
3 6 0.880047 2.2432e+00
8 8 2.962329 1.4895e+00
20 15 10.827474 1.2475e+00
50 24 19.471827 5.2350e-01
71 23 19.676798 1.7333e-05
对偶目标那一列逐个比较,没有一次下降。
这是 SMO 的硬保证:每一步都在可行方向上做精确的一维极小,走一步只能让目标变大或者原地不动。
所以「目标变小了」可以直接当 bug 的信号。
KKT 残差那一列反而不单调:从 2.17 先降到 2.08,然后反弹到 2.24,
后面还有一次从 1.25 跳到 1.50。
原因是 SMO 每步只看「当前最违反的那一对」,修好这一对可能把另一对弄得更违反。
这和对偶目标的单调性不矛盾——目标是大范围在涨,残差是在局部来回磨。
注意最后 21 轮发生了什么事:目标只从 19.471827 涨到 19.676798,变了 0.2;
残差却从 5.24e-01 掉到 1.73e-05,掉了四个数量级。
所以判断收敛只能看残差,不能看目标值。
拿「目标不再变化」当停止条件,会在第 50 轮误判成收敛,而那时残差还有 0.52。
这个坑在别的迭代算法里也常见,而且更隐蔽。

四、实测:对偶等于原始

强对偶说:对偶问题的最大值等于原始问题的最小值。 所以解对的时候,两个目标函数的值应该相等。 Demo 里对偶 19.676798、原始 19.676816,差 1.8e-05。 这个差在浮点误差的量级,但它不是零——因为求解是按 KKT 残差 1e-05 的精度停的。 精度越松,两个目标差得越多。 这也是一个实用的自检:两个目标差得离谱,一定是解错了,不用怀疑数据。 于是我们有了两条互相独立的验收标准。 一条是 KKT 残差:所有点都满足「该紧的紧、该松的松」。 另一条是对偶间隙:两边的最优值落在同一点。 两条都过,才敢说解对了;只过一条,可能是巧了。 换个 C 再看,五个 C 全部通过这两条检验:

| C | 轮数 | 支持向量 | ||w|| | 对偶 | 原始 | 间隙 | 准确率 | |---:|---:|---:|---:|---:|---:|---:|---:| | 0.1 | 88 | 36 | 1.1940 | 2.8089 | 2.8089 | 1.1e-06 | 95.6% | | 0.5 | 118 | 25 | 1.5423 | 10.5583 | 10.5583 | 2.8e-05 | 95.6% | | 1 | 71 | 23 | 1.8056 | 19.6768 | 19.6768 | 1.8e-05 | 95.0% | | 10 | 366 | 19 | 3.1030 | 173.3594 | 173.3596 | 2.5e-04 | 95.6% | | 100 | 3105 | 18 | 3.1638 | 1690.1482 | 1690.1484 | 1.7e-04 | 95.6% | 间隙那一列五行都在 1e-04 以下,说明五个 C 全部解对了。 ||w|| 从 1.194 涨到 3.164,涨了 2.6 倍,也就是间隔窄了 2.6 倍。 可准确率那一列在 95.0% 到 95.6% 之间晃,最大差 0.6 个百分点。 间隔窄了 2.6 倍,训练集上的对错几乎没动。 这说明 C 管的是「边界有多硬」,不是「分得有多准」。 把 C 调大不会让模型更聪明,只会让它更不肯放过训练集。 顺便记一个实用细节:C 从 10 到 100,迭代轮数从 366 涨到 3105,慢了八倍多。 C 越大收敛越慢,因为乘子的活动范围变大,很多点会挤在上界附近来回挪。

五、动手 Demo:求解方法:SMO 迭代 71 轮,对偶目标 19.676798,KKT 残差 1.7e-05

用第 7 章那份两团有重叠的数据(160 个点,中心在 (负 1.1, 负 1.1) 与 (1.1, 1.1),标准差 0.95), C 取 1。 先按「只跑前 k 轮」把迭代轨迹打出来,逐行核对对偶目标是不是单调不减、KKT 残差是不是也在单调降。 再比较对偶目标和原始目标是否相等,最后把 C 从 0.1 扫到 100,看间隔和准确率怎么变。

for k in (1, 3, 8, 20, 50, 71):                  # 只跑前 k 轮
    a, b, it = svm_fit(X, y, C=1.0, maxit=k)
    print(it, n_sv(a), dual_obj(K, y, a), kkt_violation(X, y, a, b, 1.0))

a, b, it = svm_fit(X, y, C=1.0)                  # 跑到底
print(dual_obj(K, y, a), primal_obj(X, y, a, b, 1.0))

实跑输出:

--------------------------------------------------------------------
求解方法:SMO 迭代 71 轮,对偶目标 19.676798,KKT 残差 1.7e-05
--------------------------------------------------------------------
  数据                     160 个点、二维,两团中心 (负 1.1, 负 1.1) 与 (1.1, 1.1),标准差 0.95(有重叠,间隔不可能全满足)
  对偶问题                 在 0 到 C 之间调 160 个乘子 a,让对偶目标最大
  SMO 的做法               一次只动两个乘子,因为它们要满足 a 乘标签求和为 0;动一个就破坏约束,动两个刚好补回来

  SMO 的迭代轨迹(每一行都是「从头只跑这么多轮」的结果):

  迭代轮数   支持向量    对偶目标     KKT 残差
  -------------------------------------------
         1          2    0.104495   2.1662e+00
         2          4    0.408743   2.0839e+00
         3          6    0.880047   2.2432e+00
         5          7    1.402589   3.2226e+00
         8          8    2.962329   1.4895e+00
        12         11    6.467020   1.3028e+00
        20         15   10.827474   1.2475e+00
        30         21   16.593178   1.5005e+00
        50         24   19.471827   5.2350e-01
        71         23   19.676798   1.7333e-05

  **对偶目标这一列是单调不减的:逐行核对,没有一次下降。**
  **这是 SMO 的硬保证,不是经验:每一步都是在可行方向上的精确一维极小,走一步只能让目标变大或者原地不动。**
  **KKT 残差那一列不单调:中途还反弹到 3.2226e+00,最后才落到 1.7333e-05。**
  **这跟对偶目标正相反。目标单调是数学保证,残差不单调是因为 SMO 每步只看当前最违反的那一对点,修好一对可能把另一对弄得更违反。**
  所以判断收敛看残差,不看目标值:

  50 轮的目标              19.471827
  71 轮的目标              19.676798
  目标只动了               0.204971
  残差却从                 5.2350e-01 掉到 1.7333e-05

  **最后这 21 轮里,目标只涨了 0.204971,肉眼已经看不出区别;残差却掉了四个数量级。**
  **要是拿「目标值不再变化」当停止条件,会在第 50 轮就误判成收敛,而那时残差还有 5.2350e-01。**
  **支持向量个数一路涨到 23,然后就不动了。它不像目标值那样平滑,是阶梯式往上跳的。**

  再看「对偶目标」和「原始目标」是不是真相等:

  对偶目标 = a 求和 减 二分之一 a 转置 Q a
  原始目标 = ||w|| 平方的一半 加 C 乘 hinge 之和

  对偶目标                 19.67679831
  原始目标                 19.67681655
  两者之差                 1.824e-05
  ||w||                    1.805570

  **差 1.824e-05,在浮点误差的量级。这就是强对偶:两边的最大值和最小值落在同一点上。**
  **所以要判断解对不对,有两条互相独立的路:一条看 KKT 残差,一条看两个目标是否相等。两条都过,才算真解出来。**

  换个 C 再看(C 是每个乘子的上限,也就是「一个点最多能有多大的话语权」):

    C   轮数   支持向量    ||w||        对偶        原始      间隙   准确率
  ------------------------------------------------------------------------
  0.1     88         36   1.1940      2.8089      2.8089   1.1e-06    95.6%
  0.5    118         25   1.5423     10.5583     10.5583   2.8e-05    95.6%
    1     71         23   1.8056     19.6768     19.6768   1.8e-05    95.0%
   10    366         19   3.1030    173.3594    173.3596   2.5e-04    95.6%
  100   3105         18   3.1638   1690.1482   1690.1484   1.7e-04    95.6%

  **间隙那一列每一行都在 1e-4 以下,说明五个 C 全部解对了。**
  **||w|| 随着 C 变大一路涨:1.1940 到 3.1638,也就是间隔一路变窄。**
  **C 小的时候模型宁可让一批点违反约束,也不肯为了迁就它们把边界拧歪;C 大了就反过来。这就是「软间隔」里那个参数的实物含义。**
  **注意准确率那一列:五个 C 全在 95.0% 到 95.6% 之间,最大差 0.6 个百分点;同期 ||w|| 从 1.1940 涨到 3.1638,涨了 2.6 倍。**
  **间隔窄了 2.6 倍,训练集上的对错却几乎没动。**
  **这说明 C 管的是「边界有多硬」,不是「分得有多准」。把 C 调大并不会让模型更聪明,只会让它更不肯放过训练集。**
  **参数调大不等于结果变好,这一点在第 4 章模型选择里已经反复出现过了。**

「只跑前 k 轮」这个技巧不需要给求解器加日志。 因为求解是从乘子全零开始的,结果完全确定,所以 maxit 等于 k 得到的就是第 k 轮的状态。 代价是重复计算,但 k 只有 10 个、样本只有 160 个,总共也就几百轮运算。 dual_obj 算的是「乘子之和 减 二分之一 乘子向量转置乘核矩阵再乘乘子向量」。 注意里面乘了标签:代码里是 v = a * y 然后 v @ K @ v。 漏掉这个 y 不会报错,但目标值会完全不对,而且 KKT 检查照样能通过——因为解本身是同一组解, 变的只是目标函数的算法。 所以这两个指标要一起看,单独看一个容易被这种错骗过去。 primal_obj 里有两项:||w|| 平方的一半,加上 C 乘 hinge 之和。 第二项用的 np.maximum(0.0, 1.0 - y * s) 就是 hinge,和 7.2 那个写法一模一样。 这就是 8.5 要展开的事:原始目标其实就是「正则加损失」。 那个 1.8e-05 的间隙不是「差一点就相等」,是「停在了残差 1e-05 的精度上」。 把 tol 收紧到 1e-07,间隙会跟着变小;但轮数会明显增加。 这是一个典型的精度与时间的交换,SMO 本身没有「精确解」,只有「够用的解」。 要说明:这一节的 SMO 是我手写的(见脚本里的 _svm40.py),不是调库。 手写的原因是要能拿到每一轮的中间状态,也要能支持 8.6 的「逐样本 C」。 真要用的时候当然应该调库——手写版在 C 等于 100 时要三千多轮,库里的实现会快一个量级。

30 秒实验

  1. 把 tol 从 1e-04 收紧到 1e-07,重跑 C 等于 1 那一组。 轮数会变成多少?对偶间隙会缩小到多少?这个交换划算吗?
  2. 把 C 设成 1e06,看轮数怎么爆。为什么会慢?这时乘子都挤在哪里?
  3. 把 160 个点复制一份(变成 320 个,每个点两份相同),重解。 w 会变吗?乘子会怎么分?这对「支持向量个数」意味着什么?

常见误区

  1. 「SMO 每次优化一个乘子」。 必须两个。 因为有个等式约束「乘子乘标签求和为零」,动一个就破坏它,动两个才能一增一减保持平衡。 名字里的「最小」指的是「最小的子问题」,不是「一个变量」。
  2. 「对偶目标单调上升,所以 KKT 残差也单调下降」。 两回事。 目标单调是数学保证(每步都是精确一维极小),残差不单调是因为选点只看当前最违反的那一对。 实测里残差从 1.90 反弹到 2.24,这是正常现象。
  3. 「迭代到目标值不再变化就可以停了」。 这会导致早停。 实测:第 50 轮到第 71 轮,目标只变了 0.2,残差却掉了四个数量级。 停止条件必须建在 KKT 残差上。
  4. 「对偶问题只是推导工具,最后还是要回到原始问题」。 反了。 实际求解是在对偶上做的:盒子约束比原来的线性约束好处理得多, 而且样本只以核矩阵形式出现——这是 8.4 能换成核的前提。 原始问题在核化之后甚至写不出来(w 是无穷维的)。
自测三问(点开对答案) 1. 为什么 SMO 必须同时动两个乘子,动一个不行? —— **因为对偶问题带一个等式约束:所有乘子乘标签的和为零。** **只改一个乘子,这个和就变了,解立刻跑到可行域外。** **改两个、一个增一个减,加权和保持不变,才留在可行域里。** **这也解释了为什么两个变量的子问题能解析求解:可行域缩成一条线段。** 2. 为什么判断收敛要看 KKT 残差,而不能看目标函数值? —— **因为目标函数在接近最优时会变得非常平,变化量小到看不出区别。** **实测中最后 21 轮目标只动了 0.2,而残差掉了四个数量级。** **KKT 残差衡量的是「有没有点违反了最优性条件」,它有明确的零点,所以能做停止判据;** **目标值没有明确的「够好」标准。** 3. C 从 1 加到 100,间隔窄了 2.6 倍,为什么准确率没提高? —— **因为 C 控制的是「肯为训练集违反约束付多大代价」,不是模型的表达能力。** **C 大只是让边界更贴训练集,而这份数据的重叠是真实存在的,贴得再紧也消不掉那部分误差。** **所以准确率基本不动,代价是间隔变窄、泛化余量变小、迭代变慢。**

求解过程里有个现象很扎眼:160 个乘子里绝大多数一直是 0。 这一节只在旁边提了一句,下一节专门讲它——为什么会有这么多零,它们意味着什么, 以及能不能把对应的样本直接扔掉。

相关笔记