被动攻击的全部内容就一句话:间隔够了就一步不动,不够就走「让间隔刚好等于 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 秒实验
- 把
stream的noise从 1.2 改成 3.0,重跑第二段。 看 PA 和批量铰链各自错多少,再看那个差值变大还是变小。 噪声越大,两者越接近——因为噪声占了大头,看不看得见未来就不那么重要了。 - 把
pa里的if m < 1.0改成if m < 0.5。 这等于把「够用的余量」从 1 降到 0.5,更新次数会明显减少。 看错误数是升还是降——这一步决定了余量这个超参数该往哪边调。 - 把
batch_hinge的iters从 400 改成 1,让它只扫一遍。 这样它和 PA 的信息量就一样了,再看谁错得少。 这个对照能回答一个关键问题:PA 的差距到底来自「只扫一遍」,还是来自算法本身。
常见误区
- 「PA 是感知机的改进版,所以错误数一定更少」。 它改进的是「更新多少」,不是「一定能赢」。 在可分数据上实测 PA 错 37 个、事后最优错 23 个——在线算法输给批处理是常态,赢才是意外。
- 「步长上限 C 越大越好,越大越接近理论最优」。 实测正好相反:C 等于 0.1 时平均错 142.1,C 等于 1 时 165.2, C 等于 10 时和不封顶完全一样(166.7)。 C 大等于允许单次走很远,而错标签给出的方向本来就是错的。
- 「在线学习跑得越久越准,错误率会一路降到很低」。 错误率停在 0.383,而事后最优固定权重是 0.265——中间那 0.118 是噪声和信息量共同定下的地板, 跑多久都下不去。 看在线曲线要用滑动窗口,累计平均会掩盖模型正在变差。
- 「在线学习就是单遍的随机梯度下降」。 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 走, 从来不考虑「这个方向我已经很熟了」。 下一节给它配一个协方差矩阵,让它知道自己对哪个方向有把握、对哪个方向没底。
- ☐ (本节完,下一节 15.2 15.2)