上一节的 PA 只记一个权重向量,它不知道自己「对哪个方向有把握」。 这一节多带一个协方差矩阵,专门记这件事。 同一个方向的样本连来八次:沿 x 的方差从 1.000000 缩到 0.312284, 垂直方向纹丝不动停在 1.000000。 这就是「只在自己被质疑过的方向上变得自信」。 第五列乘 50 倍时,PA 的平均错误从 160.5 涨到 184.2,AROW 只从 136.9 涨到 142.5。 但代价是协方差会一路塌陷:400 步后迹从 5.0 收到 0.014428,收缩 347 倍, 兜底钳位触发了 231 次。
上一节的 PA 有一个隐含假设:所有方向的不确定程度是一样的。 所以它每次沿 x_t 走的那一步,步长只由 x_t 的长度决定,和「这个方向见过多少次」无关。 这在特征尺度一致时没问题,一旦某些列特别长或者特别短,就出事了:长的那一列主导步长, 短的几乎推不动。 PA 对此毫无办法,因为它根本没有「每个方向单独对待」的存储。 这一节的做法很直接:把「每个方向有多不确定」也存下来,存成一个矩阵。 这个矩阵就是协方差矩阵,它的对角元是各个方向的方差,非对角元是方向之间的相关性。 方差大表示「这个方向我还没把握」,方差小表示「这个方向我已经很确定了」。 有了它,步长就能自动分配:不确定的方向多走,确定的方向少走。 这和第 13.1 节 PCA 用协方差矩阵描述数据形状,是同一个矩阵的两种用法——一个描述数据往哪散, 一个描述模型对哪没底。
一、只记一个向量,忘了自己有多不确定
先把 PA 的缺口说清楚。 PA 存的东西就是一个 d 维向量 w。d 等于 5 就存 5 个数,仅此而已。 它没有地方记录「第 3 个方向我见过一千次了,第 5 个方向我一次没见过」。 所以它对两者的态度完全相同。 这在贝叶斯视角下很别扭。 一个真正谨慎的学习者应该有两个东西:一个是对权重的最佳猜测(均值), 一个是对这个猜测的把握(方差)。 只有均值没有方差,等于说自己无所不知,这显然不对。 AROW 就是把第二个东西补上。它维护的是:
mu : 权重的最佳猜测,d 维向量
S : 对这个猜测的把握,d 乘 d 的协方差矩阵,初始是单位阵
初始 S 是单位阵,意思是「所有方向都一样没底」。 之后每见一个样本,S 就在这个样本的方向上缩小一点——见过的方向越来越有把握, 没见过的方向原样不动。 Demo 第一段就是这个过程的极端版:同一个方向的样本连来八次。
| 第几次 | 沿 x 的方差 | 垂直方向 | beta | mu |
|---|---|---|---|---|
| 1 | 0.500000 | 1.000000 | 0.50000 | 0.500000 |
| 2 | 0.416667 | 1.000000 | 0.33333 | 0.666667 |
| 3 | 0.375817 | 1.000000 | 0.23529 | 0.764706 |
| 5 | 0.336015 | 1.000000 | 0.12653 | 0.873473 |
| 8 | 0.312284 | 1.000000 | 0.05423 | 0.945773 |
| 三列数字各说一件事。 | ||||
| 第二列从 1 掉到 0.312284,说明沿这个方向越来越有把握。 | ||||
| 第三列一动不动停在 1.000000, | ||||
| 说明垂直方向一次都没被碰过——这是「只在自己看过的方向上变自信」最干净的证据。 | ||||
| 第四列 beta 从 0.5 衰减到 0.05423,衰减得很快。 | ||||
| beta 是这次更新的相对步长,它变小意味着「同样一个样本, | ||||
| 我越来越不当回事」——因为我已经很确定这个方向了。 | ||||
| 第五列 mu 从 0 一路逼近 1,到第八次是 0.945773。 | ||||
| 注意它是【逼近】1,不是【到达】1。 | ||||
| 和上一节 PA 一步跨到 1.0000 正好相反:AROW 越走越小心,永远差那么一点点。 | ||||
| 这不是 bug,是协方差在起作用的直接后果——它越有把握,步子越短,于是只能无限接近而到不了。 |
二、协方差矩阵怎么改
每一步做三件事,顺序不能乱:
m = y * (mu . x) # 当前间隔
Sx = S @ x # 把 x 用 S 加权
beta = (1 - m) / ((x . Sx) + 1 / r) # 相对步长
mu = mu + beta * y * Sx # 均值动
S = S - beta * (Sx 外积 Sx) # 方差缩
第一件事算间隔,和 PA 一样。 第二件事把 x 用 S 加权,这一步是关键——它把「原始方向」换成了「按不确定度重新分配过的方向」。 第三件事算相对步长。分母里有两个量:x 与 Sx 的内积,以及 1 除以 r。 前者是「这个方向现在有多不确定」,后者是一个固定的正则强度。 方向越不确定,分母越小,beta 越大,走得越远——这就是「适应」两个字的来源。 第四件事更新均值,注意乘的是 Sx 不是 x。 这是本节和 PA 最实质的差别。 PA 沿 x 走,不管这个方向熟不熟; AROW 沿 Sx 走,S 会自动把「已经很确定的方向」压扁,把「还没把握的方向」放大。 写代码时漏掉这个 S,就会变成「方差在缩、步长却没跟着缩」——第一版实现就是这么错的, 表现是 mu 冲得比正确版本快一倍。 第五件事缩方差。 减掉的是 Sx 的外积,所以只在 x 张成的那个方向上减,正交的方向一点不减。 这就是为什么第一段的垂直方向能纹丝不动停在 1.000000。 把 r 的作用说清楚:它是「你有多相信数据」。 r 大,1 除以 r 小,分母小,beta 大,更新猛,方差缩得快。 r 小则相反,更保守。 有的文献把这一项写成 1 除以 2r,那只是把 r 的定义缩了一半,扫参时按比例换算即可,结论不变。
三、尺度悬殊时它才显出本事
协方差矩阵最实在的好处,是它对特征尺度不敏感。 把数据第五列乘上不同的倍数,十个种子平均:
| 第五列 | PA 平均错误 | AROW 平均错误 |
|---|---|---|
| 原样 | 160.5 | 136.9 |
| 乘 0.02 | 159.7 | 141.4 |
| 乘 50 | 184.2 | 142.5 |
| AROW 三行的极差只有 5.6(136.9 到 142.5),PA 的极差是 23.7(159.7 到 184.2)。 | ||
| 乘 50 那一行最能说明问题:PA 掉了 23.7,AROW 只掉了 5.6。 | ||
| 原因不难推。 | ||
| PA 的步长是 (1 减 m) 除以 x 与自己的内积。 | ||
| 第五列乘 50 之后,这一项被那一列的平方(2500)彻底主导,其他四列的贡献被稀释到几乎为零。 | ||
| 于是 PA 实际上变成了一个只看第五列的分类器,而第五列根本不携带标签信息。 | ||
| AROW 这边,x 先被 S 乘过一遍。 | ||
| 那一列被反复见过之后,对应方向的方差已经被压得很小,Sx 里它的分量自然被削掉。 | ||
| 换句话说,协方差的收缩等同于一个自动的、逐方向的标准化——不需要你手工做 z-score。 | ||
| 但别把它当成万能药。 | ||
| 乘 0.02 那一行 AROW 也掉了 4.5(136.9 到 141.4), | ||
| 而且它在原样数据上的 136.9 已经不是十种子里最好的那个。 | ||
| 协方差能缓解尺度问题,缓解不了「这一列压根没用」这个问题。 |
四、r 放在哪里
r 是这一节唯一要调的数,它在两种数据上的表现如下(十个种子平均):
| r | 干净数据 | 翻错两成 |
|---|---|---|
| 0.05 | 36.4 | 133.4 |
| 0.20 | 33.0 | 135.1 |
| 1.00 | 40.9 | 154.1 |
| 5.00 | 68.7 | 173.4 |
| 20.0 | 87.3 | 188.0 |
| 两组数都是「两头差、中间好」。 | ||
| 干净数据上最好的是 r 等于 0.2(33.0), | ||
| 翻错两成时 r 等于 0.05 和 0.2 几乎打平(133.4 对 135.1)。 | ||
| r 调到 5 以上,两种数据一起崩:68.7 和 173.4。 | ||
| 为什么 r 大了会崩? | ||
| 因为 r 大意味着 beta 大、方差缩得快,几十步之后 S 就塌到接近零矩阵。 | ||
| 这时分母只剩 1 除以 r 这一项,beta 变成「不管间隔差多少都猛走一步」, | ||
| 于是它退化成一个学习率很大的感知机,噪声样本一来就被带跑。 | ||
| 最差那一列更能说明问题:干净数据上 r 等于 20 时最差的一次错了 167 个, | ||
| 而最好的一次只错 25 个。 | ||
| 同一个超参数,不同随机种子差 142 个错误——这意味着「跑一次看结果」完全不可靠。 | ||
| 实践上的建议:从 0.1 到 0.3 之间起步,扫参扫的是数量级而不是小数点。 | ||
| 而且要跑多个种子看最差情况,不能只看平均。 |
五、它会把自己越收越紧
这一节必须讲清楚代价,否则你会在真实数据上撞墙。 400 步跑完之后,协方差变成了这样:
| 量 | 值 |
|---|---|
| 五个特征值 | 1.00e-09、2.88e-03、3.08e-03、3.48e-03、4.99e-03 |
| 迹 | 0.014428(初始 5.0) |
| 收缩倍数 | 347 |
| 兜底钳位触发 | 231 次(总共更新 386 次) |
| 第一个特征值已经撞到了 1e-09 这个数值地板。 | |
| 386 次更新里有 231 次需要靠钳位把负的或者零的特征值抬回正数,否则矩阵就不是正定的, | |
| 后面的 Sx 会算出没有意义的东西。 | |
| 这不是我的实现特有的毛病,是 AROW 这个算法本身的性质: | |
| 它每次更新都只减不增,方差单调递减,而数据是有限的、同一个方向会被反复看到, | |
| 所以方差迟早会塌到零。 | |
| 协方差塌到零意味着「我百分之百确定」——在带噪声的数据上,这是纯粹的过度自信。 | |
| 所以工程上用 AROW 必须配一个下限,要么钳特征值,要么给 S 加一个「遗忘」项让它缓慢回涨。 | |
| 这一节代码里的钳位就干这件事,代价是它掩盖了「算法已经过度自信」这个事实。 | |
| 最后看它学到的方向。 | |
| mu 是 [0.436, -0.186, -0.095, 0.008, 0.007],真值是 [0.857, -0.514, 0, 0, 0]。 | |
| 夹角 13.8 度,方向抓得很准;但 mu 的长度只有真值的 0.48 倍。 | |
| AROW 学到的是【方向】,不是【长度】。 | |
| 这很正常:间隔只要够 1 它就停手, | |
| 而把权重整体放大一倍也能让间隔翻倍——长度对它来说本来就无所谓。 | |
| 一句话总结这一节: | |
| PA 只知道「要满足这个约束」,AROW 还知道「我有多确定」; | |
| 后者在尺度不一致时明显更稳,代价是一个会塌陷的矩阵和一半以上的计算量。 |
六、动手 Demo:带着一个协方差矩阵学,尺度悬殊也不怕
这段代码先让同一个方向的样本连来八次,看协方差怎么只在这个方向上收缩; 再把第五列乘上 0.02 和 50,对比 PA 与 AROW 的十种子平均错误;然后扫 r; 最后把跑完之后的协方差拆开看特征值和钳位次数。 重点看两处:第一段里垂直方向为什么一直是 1.000000; 以及最后一行「兜底钳位触发 231 次」——这是这个算法的真实成本。
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):
"""上一节那个不封顶的被动攻击,用来当对照。"""
n, d = X.shape
w = np.zeros(d)
err = 0
for t in range(n):
m = Y[t] * float(w @ X[t])
if m <= 0.0:
err += 1
if m < 1.0:
w = w + (1.0 - m) / float(X[t] @ X[t]) * Y[t] * X[t]
return err
def arow(X, Y, r=1.0):
"""适应正则化:除了均值向量,还带着一个协方差矩阵。"""
n, d = X.shape
mu, S = np.zeros(d), np.eye(d)
err, betas, trs, hits = 0, [], [float(d)], 0
for t in range(n):
x, y = X[t], Y[t]
m = y * float(mu @ x)
if m <= 0.0:
err += 1
if m < 1.0:
Sx = S @ x
beta = (1.0 - m) / (float(x @ Sx) + 1.0 / r)
mu = mu + beta * y * Sx
S = S - beta * np.outer(Sx, Sx)
S = (S + S.T) / 2.0
wv, V = np.linalg.eigh(S)
if wv.min() < 1e-9:
S = V @ np.diag(np.maximum(wv, 1e-9)) @ V.T
hits += 1
betas.append(beta)
else:
betas.append(0.0)
trs.append(float(np.trace(S)))
return err, mu, S, np.array(betas), np.array(trs), hits
print('[1] 同一个方向的样本连来八次,看协方差往哪里缩')
S = np.eye(2)
mu = np.zeros(2)
x = np.array([1.0, 0.0])
for k in range(1, 9):
m = float(mu @ x)
Sx = S @ x
beta = (1.0 - m) / (float(x @ Sx) + 1.0)
mu = mu + beta * Sx
S = S - beta * np.outer(Sx, Sx)
print(' 第 %d 次 沿 x 方差 %.6f 垂直方向 %.6f 迹 %.6f beta %.5f mu %.6f'
% (k, float(x @ S @ x), S[1, 1], float(np.trace(S)), beta, mu[0]))
print('')
print('[2] 第五列乘上不同倍数:AROW 对尺度不敏感,PA 敏感')
for tag, sc in (('原样', 1.0), ('x0.02', 0.02), ('x50', 50.0)):
ep, ea = [], []
for sd in range(1, 11):
X, Y = stream(400, 5, sd, noise=1.2)
X[:, 4] = X[:, 4] * sc
ep.append(pa(X, Y))
ea.append(arow(X, Y)[0])
print(' 第五列 %-6s PA 平均 %6.1f AROW 平均 %6.1f' % (tag, np.mean(ep), np.mean(ea)))
print('')
print('[3] r 放在哪里:十种子平均错误数')
for tag, kw in (('干净', dict(noise=0.2)), ('翻转20%', dict(noise=0.6, flip=0.2))):
for r in (0.05, 0.2, 1.0, 5.0, 20.0):
v = []
for sd in range(1, 11):
X, Y = stream(400, 5, sd, **kw)
v.append(arow(X, Y, r=r)[0])
print(' %-8s r=%-5.2f 平均 %6.1f 最好 %3d 最差 %3d'
% (tag, r, np.mean(v), min(v), max(v)))
print('')
print('[4] 跑完之后协方差变成了什么(有噪,seed=1,r=1)')
X, Y = stream(400, 5, 1, noise=1.2)
e, mu, S, betas, trs, hits = arow(X, Y, r=1.0)
wv = np.linalg.eigvalsh(S)
print(' 末态特征值 ' + ' '.join('%.2e' % v for v in wv))
print(' 迹 %.6f 初始 %.1f 收缩 %.0f 倍 兜底钳位触发 %d 次'
% (trs[-1], trs[0], trs[0] / trs[-1], hits))
wt = np.array([1.0, -0.6, 0.0, 0.0, 0.0])
wt = wt / np.linalg.norm(wt)
cos = float(mu @ wt) / float(np.linalg.norm(mu) * np.linalg.norm(wt))
print(' mu ' + ' '.join('%+.3f' % v for v in mu))
print(' 真值 ' + ' '.join('%+.3f' % v for v in wt))
print(' 两个向量的夹角 %.1f 度,mu 的长度只有真值的 %.2f 倍'
% (np.degrees(np.arccos(np.clip(cos, -1.0, 1.0))),
float(np.linalg.norm(mu))))
print(' beta 中位 %.4f 最大 %.3f 总共更新 %d/400'
% (np.median(betas), betas.max(), int((betas > 0).sum())))
实跑输出:
--------------------------------------------------------------------
适应正则化:带着一个协方差矩阵学,尺度悬殊也不怕
--------------------------------------------------------------------
[1] 同一个方向的样本连来八次,看协方差往哪里缩
第 1 次 沿 x 方差 0.500000 垂直方向 1.000000 迹 1.500000 beta 0.50000 mu 0.500000
第 2 次 沿 x 方差 0.416667 垂直方向 1.000000 迹 1.416667 beta 0.33333 mu 0.666667
第 3 次 沿 x 方差 0.375817 垂直方向 1.000000 迹 1.375817 beta 0.23529 mu 0.764706
第 4 次 沿 x 方差 0.351662 垂直方向 1.000000 迹 1.351662 beta 0.17102 mu 0.828979
第 5 次 沿 x 方差 0.336015 垂直方向 1.000000 迹 1.336015 beta 0.12653 mu 0.873473
第 6 次 沿 x 方差 0.325322 垂直方向 1.000000 迹 1.325322 beta 0.09470 mu 0.905295
第 7 次 沿 x 方差 0.317760 垂直方向 1.000000 迹 1.317760 beta 0.07146 mu 0.928542
第 8 次 沿 x 方差 0.312284 垂直方向 1.000000 迹 1.312284 beta 0.05423 mu 0.945773
[2] 第五列乘上不同倍数:AROW 对尺度不敏感,PA 敏感
第五列 原样 PA 平均 160.5 AROW 平均 136.9
第五列 x0.02 PA 平均 159.7 AROW 平均 141.4
第五列 x50 PA 平均 184.2 AROW 平均 142.5
[3] r 放在哪里:十种子平均错误数
干净 r=0.05 平均 36.4 最好 28 最差 50
干净 r=0.20 平均 33.0 最好 25 最差 43
干净 r=1.00 平均 40.9 最好 28 最差 62
干净 r=5.00 平均 68.7 最好 36 最差 144
干净 r=20.00 平均 87.3 最好 25 最差 167
翻转20% r=0.05 平均 133.4 最好 127 最差 141
翻转20% r=0.20 平均 135.1 最好 126 最差 153
翻转20% r=1.00 平均 154.1 最好 125 最差 207
翻转20% r=5.00 平均 173.4 最好 138 最差 227
翻转20% r=20.00 平均 188.0 最好 147 最差 228
[4] 跑完之后协方差变成了什么(有噪,seed=1,r=1)
末态特征值 1.00e-09 2.88e-03 3.08e-03 3.48e-03 4.99e-03
迹 0.014428 初始 5.0 收缩 347 倍 兜底钳位触发 231 次
mu +0.436 -0.186 -0.095 +0.008 +0.007
真值 +0.857 -0.514 +0.000 +0.000 +0.000
两个向量的夹角 13.8 度,mu 的长度只有真值的 0.48 倍
beta 中位 0.7390 最大 1.721 总共更新 386/400
第一段八行,每行三个关键数:沿 x 的方差、垂直方向的方差、beta。 沿 x 从 0.500000 掉到 0.312284,垂直方向从头到尾 1.000000。 这说明更新只动了 x 张成的那个方向, 正交方向一点没碰——协方差矩阵在这里表现得像一个「只在被质疑处收缩」的橡皮。 mu 那一列值得单独看:0.500000、0.666667、0.764706,到第八次是 0.945773。 它单调递增但永远到不了 1。 对比上一节 PA 的第一步直接跳到 1.0000, 差别就在 beta——AROW 的 beta 每次都在变小(0.5 到 0.05423),所以后面的步子是越来越短的。 第二段三行:原样 160.5 对 136.9,乘 0.02 是 159.7 对 141.4,乘 50 是 184.2 对 142.5。 PA 掉了 23.7 个错误,AROW 只掉 5.6 个。 第五列是个没有标签信息的噪声列,乘以 50 之后 PA 的步长被它彻底劫持, AROW 靠协方差把这个方向压了下去。 第三段是 r 的扫描。 干净数据 36.4、33.0、40.9、68.7、87.3,翻错两成 133.4、135.1、154.1、173.4、188.0。 两组都是两头差中间好,最好的都在 0.05 到 0.2 这个区间。 r 大于 5 之后两种数据一起崩——方差缩得太快,beta 失控。 第四段是这一节最该记住的一段。 末态五个特征值是 1.00e-09、2.88e-03、3.08e-03、3.48e-03、4.99e-03,迹从 5.0 收到 0.014428, 收缩 347 倍。 第一个特征值已经贴到数值地板,386 次更新里有 231 次靠钳位才没崩。 最后三行是「它到底学到了什么」:mu 与真值夹角 13.8 度,长度只有真值的 0.48 倍。 方向对、长度短,因为间隔够了它就停手,而长度对间隔来说无关紧要。
30 秒实验
- 把第一段的
x从[1, 0]改成[1, 1](先除以根号 2 归一化),重跑。 看垂直方向还会不会纹丝不动——这次它会跟着变,因为那个方向不再正交。 - 把
arow里的S = S - beta * np.outer(Sx, Sx)改成S = S + beta * np.outer(Sx, Sx)。 这是故意写反的版本,方差会越学越大。 跑一遍看错误数涨到多少——这个反向实验能让你记住减号不是随手写的。 - 把
arow里的mu = mu + beta * Sx改成mu = mu + beta * x,也就是漏掉协方差。 重跑第一段,看 mu 冲到多少、第八次是不是已经超过 1。 方差还在缩、步子却没跟着缩,这就是第一版实现踩过的坑。
常见误区
- 「AROW 比 PA 新,所以一定更好」。 实测它在有噪数据上确实更好(136.9 对 160.5),但要付两样东西: 每步都要算 Sx 和一次特征分解,而且协方差不加下限就会塌。 386 次更新里 231 次靠钳位兜底,这不是可以忽略的实现细节。
- 「协方差矩阵会收敛到数据的真实协方差」。 它收敛到的是「模型对自己有多确定」,方向恰好和数据分布有关,但含义完全不同。 而且它单调递减、永不回升,真实协方差是数据的属性、不随训练步数变。
- 「r 越大越好,因为正则越强越稳」。 r 大恰恰是不稳:r 等于 20 时干净数据平均错 87.3,是 r 等于 0.2 的 33.0 的两倍多。 这里的 r 是「信任数据的程度」,调大等于让方差缩得更快、beta 更猛。
- 「mu 学出来应该和真权重差不多长」。 实测夹角只有 13.8 度,长度却只有真值的 0.48 倍。 间隔法只要求间隔够 1,权重整体放大或缩小都不影响分类,所以长度本来就不该拿来比较。
自测三问(点开对答案)
1. 协方差矩阵在这个算法里起什么作用? —— 它记录「每个方向有多确定」。方差大的方向表示还没把握,更新时走得多; 方差小的方向表示已经很确定,更新时走得少。 Demo 第一段沿 x 的方差从 1 缩到 0.312284,垂直方向一直是 1.000000。 2. 为什么同一份数据上 AROW 对特征尺度不敏感? —— 因为更新方向是 S 乘过 x 的 Sx,而不是 x 本身。 某个方向被反复见过之后方差被压低,这一列在 Sx 里的分量就自动被削掉, 相当于做了逐方向的标准化。实测第五列乘 50 时 PA 掉 23.7 个错误,AROW 只掉 5.6 个。 3. 为什么说 AROW 会「过度自信」? —— 因为它的协方差每次更新只减不增,方差单调递减,而方向会被反复看到,所以迟早塌到数值零。 实测 400 步后迹从 5.0 收到 0.014428,收缩 347 倍,386 次更新里 231 次要靠钳位才保持正定。 工程上必须给协方差加下限或遗忘项。第 15 章两节都在讲「标签来了怎么改权重」。 下一章换一个更省标签的问题:如果一百个样本里只有一个有标签,剩下九十九个能不能也用上?
- ☐ (本章完,下一节 16.1 16.1)