🤖 第15章 在线学习
📄 一句话概括

被动攻击的全部内容就一句话:间隔够了就一步不动,不够就走「让间隔刚好等于 1」的那一小步。 同一个方向的样本连来八次,第一步步长 1.0000,之后七步全是 0.0000。 这就是「被动」两个字最直白的样子。 400 个样本跑完,可分数据 PA 错 37 个、事后用全部数据训一遍错 23 个; 有噪数据是 153 对 106,翻错两成标签是 158 对 115。 在线跑完总要多错四十几个,这个差就是「看不见未来」的价钱。

📝 本节和前面几节的关系

前面十四章有一个共同前提:数据全都在手上,可以反复看,可以算到收敛再交卷。 这一章把这个前提拆掉。样本一个一个来,处理完就扔,不许回头。搜索排序、广告竞价、 垃圾邮件过滤、推荐流,全是这个形态——你没法等三个月的数据攒齐了再上线。 评价标准也跟着变。批处理比的是「最后那个模型有多准」, 在线学的是「整个过程累计犯了多少错」。模型可以一直不收敛,只要它犯的错少,就是好算法。 15.1 和 15.2 是同一族:都是间隔法,都只在「间隔不够」时才更新, 和第 8 章的软间隔支持向量机是同一个思路的两个在线版本。 区别只在「更新多少」:这一节按距离算一个确定的小步,下一节按「自己有多不确定」自动调。

一、数据一个一个来,意味着什么

设定是这样的:第 t 步来一个样本 x_t,你先给出预测,然后才拿到真标签 y_t, 拿到之后允许改一次权重,改完这个样本就丢掉。 内存里只能留一个当前权重,不能留历史样本。 这个限制听起来像工程细节,其实是数学上的硬约束。 批处理方法的每一次更新都要把全部样本过一遍,在线方法没有这个条件, 所以它的每一步只能看一个样本——能看到的信息少了,理论上限就低了。 衡量指标因此换成两个: 一是累计错误数,就是整个过程一共判错多少次。 二是后悔值,等于「自己的累计错误」减去「事后挑一个最好的固定权重会犯的错误」。 后悔值衡量的是「因为不知道未来而多付的代价」,它才是在线学习真正要比的东西。 注意「事后挑一个最好的固定权重」这件事,现实中做不到——那需要先看完所有数据。 它只是一个基准,用来给在线算法打分。 本节的 Demo 里,这个基准用批量铰链损失训练得到,它把 400 个样本反复看了 400 遍。 还要提前说清一件事,免得后面看着别扭:在线学习的错误率不会一路降到很低。 数据本身带噪声,就算拿到真权重,也会有一批样本注定判错。 实测 400 步跑完错误率停在 0.383, 而事后最优固定权重是 0.265——这中间的 0.118 就是在线的天花板。

二、那一步是怎么算出来的

想法只有两句: 第一,希望更新之后这个样本被分对,而且不只分对, 还要留出一点余量——要求 y_t 乘上 w 与 x_t 的内积,至少等于 1。 第二,在满足这个要求的所有权重里,挑离原来那个最近的一个。 写成式子就是:在约束 y_t (w · x_t) 大于等于 1 之下,让 w 与旧权重的欧氏距离最小。 这是一个带一个线性约束的二次规划,解出来是一个闭式:

m   = y_t * (w_t . x_t)          # 当前间隔
tau = (1 - m) / (x_t . x_t)      # 要走的步长
w   = w_t + tau * y_t * x_t      # 只沿 x_t 方向动

为什么是除以 x_t 自己的长度平方? 因为你只沿 x_t 方向走,走多远才能让内积增加 1,取决于 x_t 有多长。 x_t 长一倍,同样走一步对内积的贡献就大一倍,所以步长要按长度的平方反比缩。 这也解释了一个现象:特征没标准化的时候,长的那一列会主导步长,短的那一列几乎推不动。 几何上更清楚:把权重沿着 x_t 的方向推到「分界面刚好越过这个样本 1 个单位」的位置, 垂直于 x_t 的那个分量完全不动。 所以 PA 的每次更新都是「只在自己被质疑的那个方向上退让,别的一概不改」。 两个词就是这么来的: 被动,指间隔已经够 1 的时候一步都不走。 攻击,指间隔不够的时候要动,而且动到刚好够为止,不多走一丝。 Demo 第一段把这件事演到极致:同一个方向的样本连来八次。

第几次 当前间隔 步长 权重
1 0.0000 1.0000 [1.0000 0.0000]
2 1.0000 0.0000 [1.0000 0.0000]
3 到 8 1.0000 0.0000 [1.0000 0.0000]
第一次间隔是 0,步长 1,一步就到位。
第二次间隔恰好 1.0000,从此以后七次全部一步不动。
标准答案已经找到了,它就不再折腾——这是它和感知机最明显的差别。
和感知机摆在一起看:
感知机 被动攻击
什么时候更新 分错才更新 间隔小于 1 就更新
步长 固定 1 (1 减 间隔) 除以 x 长度平方
要不要调参 不要 不封顶时不要,封顶时调 C
间隔够 1 时 不动 不动
差别就在中间两行。
感知机只要分对就走,哪怕只差一点点;
PA 要求留出余量,而且步长自己会缩——越接近满足,步子越小。

三、三种数据下的对局

同一份数据流,三种噪声水平,各跑 400 步:

数据 PA 错误 批量铰链 差 PA 更新次数 步长中位
可分 37 23 14 100 / 400 0.0000
有噪 153 106 47 249 / 400 0.1350
翻错两成 158 115 43 252 / 400 0.1480
先看最后一列。
可分数据上步长中位数是 0.0000——也就是说四百次里有三百次它一步没动。
这正是「被动」在真实数据上的样子:绝大多数样本它都懒得出声。
有噪数据上就不一样了,中位步长 0.1350,一半以上的样本都触发了更新。
噪声让间隔在 1 附近来回晃,它就得不停地微调。
再看第二、三列。
三种数据上 PA 都比批量方法多错十几个到四十几个。
这个差距不是实现问题,是信息问题:批量方法把 400 个样本看了 400 遍,PA 每个样本只看一眼。
所以在线学习的目标从来不是「打败批处理」,而是在「只能看一遍」的前提下尽量少错。
有一个数值得单独拎出来:可分数据上 PA 只更新了 100 次,却错了 37 个。
说明它那 100 次更新质量很高——每次都在关键方向上动了必要的一步。
而有噪时更新了 249 次,错了 153 个。
动得多不等于错得少,这是在线学习最容易被误解的地方。

四、步长封顶:唯一要调的旋钮

不封顶的 PA 有一个致命弱点:遇到一个翻错的标签,它也会老老实实去满足那个假约束。 如果这个错标签的样本恰好很长,步长会被放大成一大步,一次就把好不容易学到的方向带偏。 补救的办法是给步长加个上限,这就是 PA-I:

PA      : tau = (1 - m) / (x . x)
PA-I    : tau = min(C, (1 - m) / (x . x))
PA-II   : tau = (1 - m) / ((x . x) + 1 / (2C))

PA-I 是硬性截断,PA-II 是软化——把 1 除以 2C 这一项加进分母,样本很长的时候效果和截断差不多, 样本很短的时候又不会过度惩罚。 翻错两成标签、十个种子平均下来:

版本 平均错误 最好 最差
PA(不封顶) 166.7 153 185
PA-I C = 0.1 142.1 128 149
PA-I C = 1 165.2 158 176
PA-II C = 0.1 154.7 145 162
PA-II C = 1 164.2 153 184
PA-II C = 10 166.7 153 185
封在 0.1 明显有用:166.7 降到 142.1,最差的那次也从 185 收到 149。
封在 1 就基本没用了,165.2 和不封顶几乎一样。
C 等于 10 时两行数字一模一样——说明这个上限从来没被碰到过,等于没封。
这一节最该记住的是这个反直觉的方向:想要抗噪,C 要往【小】调,不是往大调。
C 大意味着「允许单次走很远」,而噪声样本给出的方向本来就是错的,
允许它走远等于允许它把模型带偏。
把 C 想成「单次更新最多能有多大破坏力」,就顺了。

五、错误率不会一路降到零

很多人第一次跑在线算法,会盯着错误率曲线看它降不降,然后怀疑自己写错了。

跑到第几步 累计错误 错误率
50 21 0.420
100 42 0.420
200 70 0.350
300 115 0.383
400 153 0.383
五十步的时候 0.420,四百步的时候 0.383。
降了一点点,然后就不动了,中间还从 0.350 反弹回 0.383。
这不是没学进去。
事后用全部数据训出来的最优固定权重,在同一批样本上也错了 106 个,
也就是 0.265——这是噪声定下的地板。
0.383 与 0.265 之间的差距,才是「只能看一遍」真正付出的代价。
反弹也有解释:累计错误率是个平均量,前期错得少的时候,后面连续错几个就会把它拉起来。
要看趋势应该看滑动窗口的错误率,而不是累计平均。
这一点在工程上很重要:线上的监控面板如果用累计平均,会看不出模型正在变差。
最后一句话总结这一节:
PA 不追求「最终收敛到多好」,它追求「每一步都只付必要的代价」。
这个目标和批处理完全不同,所以不要拿收敛曲线去评价它。

六、动手 Demo:间隔够了就一步不动,不够就只走一小步

这段代码做四件事:先让同一个方向的样本连来八次,看步长怎么从 1 掉到 0; 再在三种噪声水平下和批量铰链损失对局;然后把步长上限 C 从 0.1 扫到 10; 最后看累计错误率到底降不降。 重点看两处:第一段里第二次之后的步长为什么全是 0.0000; 以及封顶表里 C 等于 10 时数字和「不封顶」一模一样。

import numpy as np


def stream(n=400, d=5, seed=1, noise=0.6, flip=0.0):
    """逐样本到来的二分类数据流:真权重只看前两个坐标。"""
    rg = np.random.default_rng(seed)
    w = np.array([1.0, -0.6, 0.0, 0.0, 0.0])
    w = w / np.linalg.norm(w)
    X, Y = [], []
    for t in range(n):
        x = rg.normal(0.0, 1.0, d)
        y = 1.0 if float(w @ x) + rg.normal(0.0, noise) > 0 else -1.0
        if flip > 0.0 and rg.uniform() < flip:
            y = -y
        X.append(x)
        Y.append(y)
    return np.array(X), np.array(Y)


def pa(X, Y, C=1e6, mode='I'):
    """被动攻击:间隔不够才动,动就动让间隔恰好等于 1 的那一小步。"""
    n, d = X.shape
    w = np.zeros(d)
    err, taus, hist = 0, [], []
    for t in range(n):
        m = Y[t] * float(w @ X[t])
        if m <= 0.0:
            err += 1
        hist.append(err)
        tau = 0.0
        if m < 1.0:
            xx = float(X[t] @ X[t])
            if mode == 'II':
                tau = (1.0 - m) / (xx + 1.0 / (2.0 * C))
            else:
                tau = min(C, (1.0 - m) / xx)
            w = w + tau * Y[t] * X[t]
        taus.append(tau)
    return err, np.array(taus), np.array(hist)


def batch_hinge(X, Y, lam=1e-3, iters=400, lr=0.5):
    """批量铰链损失加 l2 正则,当作「看得见全部数据」的离线基准。"""
    n, d = X.shape
    w = np.zeros(d)
    for _ in range(iters):
        g = lam * w
        for t in range(n):
            if Y[t] * float(w @ X[t]) < 1.0:
                g = g - Y[t] * X[t] / n
        w = w - lr * g
    return w


def err_of(w, X, Y):
    return int(np.sum((X @ w) * Y < 0))


print('[1] 同一个方向的样本连来八次,看步长怎么变')
w = np.zeros(2)
x = np.array([1.0, 0.0])
for k in range(1, 9):
    m = float(w @ x)
    tau = 0.0 if m >= 1.0 else (1.0 - m) / float(x @ x)
    w = w + tau * x
    print('  第 %d 次  间隔 %+.4f  步长 %.4f  w = [%.4f %.4f]'
          % (k, m, tau, w[0], w[1]))

print('')
print('[2] 三种数据下单次对局:PA 对批量铰链')
for tag, kw in (('可分', dict(noise=0.2)), ('有噪', dict(noise=1.2)),
                ('翻转20%', dict(noise=0.6, flip=0.2))):
    X, Y = stream(400, 5, 1, **kw)
    e, taus, hist = pa(X, Y)
    wb = batch_hinge(X, Y)
    print('  %-9s PA %3d  批量铰链 %3d  差 %3d  更新 %3d/400  步长中位 %.4f'
          % (tag, e, err_of(wb, X, Y), e - err_of(wb, X, Y),
             int((taus > 0).sum()), np.median(taus)))

print('')
print('[3] 十个种子平均:步长封顶放在哪里(翻 20% 标签)')
for nm, kw in (('PA', dict(C=1e6, mode='I')),
               ('PA-I C=0.1', dict(C=0.1, mode='I')),
               ('PA-I C=1', dict(C=1.0, mode='I')),
               ('PA-II C=0.1', dict(C=0.1, mode='II')),
               ('PA-II C=1', dict(C=1.0, mode='II')),
               ('PA-II C=10', dict(C=10.0, mode='II'))):
    v = []
    for sd in range(1, 11):
        X, Y = stream(400, 5, sd, noise=0.6, flip=0.2)
        v.append(pa(X, Y, **kw)[0])
    print('  %-12s 平均 %6.1f  最好 %3d  最差 %3d' % (nm, np.mean(v), min(v), max(v)))

print('')
print('[4] 累计错误走到哪一步(有噪,seed=1)')
X, Y = stream(400, 5, 1, noise=1.2)
e, taus, hist = pa(X, Y)
for t in (50, 100, 200, 300, 400):
    print('  t=%3d  累计错误 %3d  错误率 %.3f' % (t, hist[t - 1], hist[t - 1] / (t + 0.0)))
wb = batch_hinge(X, Y)
print('  事后最优固定权重的错误 %d,PA 多错 %d'
      % (err_of(wb, X, Y), e - err_of(wb, X, Y)))

实跑输出:

--------------------------------------------------------------------
被动攻击:间隔够了就一步不动,不够就只走一小步
--------------------------------------------------------------------
[1] 同一个方向的样本连来八次,看步长怎么变
  第 1 次  间隔 +0.0000  步长 1.0000  w = [1.0000 0.0000]
  第 2 次  间隔 +1.0000  步长 0.0000  w = [1.0000 0.0000]
  第 3 次  间隔 +1.0000  步长 0.0000  w = [1.0000 0.0000]
  第 4 次  间隔 +1.0000  步长 0.0000  w = [1.0000 0.0000]
  第 5 次  间隔 +1.0000  步长 0.0000  w = [1.0000 0.0000]
  第 6 次  间隔 +1.0000  步长 0.0000  w = [1.0000 0.0000]
  第 7 次  间隔 +1.0000  步长 0.0000  w = [1.0000 0.0000]
  第 8 次  间隔 +1.0000  步长 0.0000  w = [1.0000 0.0000]

[2] 三种数据下单次对局:PA 对批量铰链
  可分        PA  37  批量铰链  23  差  14  更新 100/400  步长中位 0.0000
  有噪        PA 153  批量铰链 106  差  47  更新 249/400  步长中位 0.1350
  翻转20%     PA 158  批量铰链 115  差  43  更新 252/400  步长中位 0.1480

[3] 十个种子平均:步长封顶放在哪里(翻 20% 标签)
  PA           平均  166.7  最好 153  最差 185
  PA-I C=0.1   平均  142.1  最好 128  最差 149
  PA-I C=1     平均  165.2  最好 158  最差 176
  PA-II C=0.1  平均  154.7  最好 145  最差 162
  PA-II C=1    平均  164.2  最好 153  最差 184
  PA-II C=10   平均  166.7  最好 153  最差 185

[4] 累计错误走到哪一步(有噪,seed=1)
  t= 50  累计错误  21  错误率 0.420
  t=100  累计错误  42  错误率 0.420
  t=200  累计错误  70  错误率 0.350
  t=300  累计错误 115  错误率 0.383
  t=400  累计错误 153  错误率 0.383
  事后最优固定权重的错误 106,PA 多错 47

第一段是整节的核心,八个样本全是同一个方向 x 等于 [1, 0]。 第一步间隔是 0,步长 1.0000,权重一步变成 [1, 0]。 第二步间隔恰好 1.0000,条件「小于 1」不成立,于是步长 0.0000——之后七次一模一样。 如果换成感知机,这个故事会完全不同:感知机只看「分对没有」,间隔 1.0000 早就分对了, 也不会动。 两者在这里恰好一致,但换成间隔 0.3 的样本就分道扬镳——感知机不动,PA 会补上那 0.7 的余量。 第二段三种数据,PA 分别是 37、153、158,批量铰链是 23、106、115。 差距 14、47、43。 注意可分数据上 PA 只更新了 100 次,步长中位数 0.0000——四百次里三百次一声不吭, 这就是「被动」。 第三段是这一节最实用的一张表。 不封顶 166.7,封在 0.1 是 142.1,封在 1 是 165.2,封在 10 是 166.7。 C 等于 10 和不封顶完全同数,说明这个上限一次都没被触发过——封顶要封到「能真的截住」才有意义。 第四段那五个数 21、42、70、115、153 对应的错误率是 0.420、0.420、0.350、0.383、0.383。 它没有一路下降,最后停在 0.383。 而事后最优固定权重只错 106 个,PA 多错 47 个——这个 47 就是后悔值。 代码里有一处值得多说一句:错误是按「更新之前」那次预测统计的。 如果先更新再判错,刚被纠正的样本会显示成「预测正确」, 实测的错误数会直接变成 0——这是实现在线算法最容易踩的坑。

30 秒实验

  1. 把 stream 的 noise 从 1.2 改成 3.0,重跑第二段。 看 PA 和批量铰链各自错多少,再看那个差值变大还是变小。 噪声越大,两者越接近——因为噪声占了大头,看不看得见未来就不那么重要了。
  2. 把 pa 里的 if m < 1.0 改成 if m < 0.5。 这等于把「够用的余量」从 1 降到 0.5,更新次数会明显减少。 看错误数是升还是降——这一步决定了余量这个超参数该往哪边调。
  3. 把 batch_hinge 的 iters 从 400 改成 1,让它只扫一遍。 这样它和 PA 的信息量就一样了,再看谁错得少。 这个对照能回答一个关键问题:PA 的差距到底来自「只扫一遍」,还是来自算法本身。

常见误区

  1. 「PA 是感知机的改进版,所以错误数一定更少」。 它改进的是「更新多少」,不是「一定能赢」。 在可分数据上实测 PA 错 37 个、事后最优错 23 个——在线算法输给批处理是常态,赢才是意外。
  2. 「步长上限 C 越大越好,越大越接近理论最优」。 实测正好相反:C 等于 0.1 时平均错 142.1,C 等于 1 时 165.2, C 等于 10 时和不封顶完全一样(166.7)。 C 大等于允许单次走很远,而错标签给出的方向本来就是错的。
  3. 「在线学习跑得越久越准,错误率会一路降到很低」。 错误率停在 0.383,而事后最优固定权重是 0.265——中间那 0.118 是噪声和信息量共同定下的地板, 跑多久都下不去。 看在线曲线要用滑动窗口,累计平均会掩盖模型正在变差。
  4. 「在线学习就是单遍的随机梯度下降」。 SGD 仍然假设样本同分布、可以多轮扫,只是每轮顺序打乱; 在线学习是真正单遍、不可回头,而且分布可能中途变化。 PA 的步长由约束解出来,和 SGD 的固定学习率也不是一回事。
自测三问(点开对答案) 1. PA 的步长为什么要除以 x_t 与自己的内积? —— 因为更新只沿 x_t 方向走,而走一步对内积的贡献正比于 x_t 的长度平方。 要让内积刚好增加 1 减去当前间隔那么多,步长就得按长度平方反比缩。 这也意味着特征不标准化时,长的那一列会主导步长。 2. 「被动」和「攻击」分别指什么? —— 被动指间隔已经够 1 时一步都不走,Demo 里第二次到第八次步长全是 0.0000; 攻击指间隔不够时必须动,而且动到刚好够为止。 两者合起来就是「只在必要的时候,走最小的那一步」。 3. 为什么在线跑完的错误数会比离线批量多几十个? —— 因为信息量不同:批处理方法把 400 个样本反复看了 400 遍,在线算法每个样本只看一眼、看完就扔。 实测三种数据分别差 14、47、43 个错误,这个差距是在线设定的固有代价,不是实现错误。

PA 有一个隐含的假设:所有方向都同样不确定,所以它每次只沿 x 走, 从来不考虑「这个方向我已经很熟了」。 下一节给它配一个协方差矩阵,让它知道自己对哪个方向有把握、对哪个方向没底。

相关笔记