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

上一节的 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 秒实验

  1. 把第一段的 x 从 [1, 0] 改成 [1, 1](先除以根号 2 归一化),重跑。 看垂直方向还会不会纹丝不动——这次它会跟着变,因为那个方向不再正交。
  2. 把 arow 里的 S = S - beta * np.outer(Sx, Sx) 改成 S = S + beta * np.outer(Sx, Sx)。 这是故意写反的版本,方差会越学越大。 跑一遍看错误数涨到多少——这个反向实验能让你记住减号不是随手写的。
  3. 把 arow 里的 mu = mu + beta * Sx 改成 mu = mu + beta * x,也就是漏掉协方差。 重跑第一段,看 mu 冲到多少、第八次是不是已经超过 1。 方差还在缩、步子却没跟着缩,这就是第一版实现踩过的坑。

常见误区

  1. 「AROW 比 PA 新,所以一定更好」。 实测它在有噪数据上确实更好(136.9 对 160.5),但要付两样东西: 每步都要算 Sx 和一次特征分解,而且协方差不加下限就会塌。 386 次更新里 231 次靠钳位兜底,这不是可以忽略的实现细节。
  2. 「协方差矩阵会收敛到数据的真实协方差」。 它收敛到的是「模型对自己有多确定」,方向恰好和数据分布有关,但含义完全不同。 而且它单调递减、永不回升,真实协方差是数据的属性、不随训练步数变。
  3. 「r 越大越好,因为正则越强越稳」。 r 大恰恰是不稳:r 等于 20 时干净数据平均错 87.3,是 r 等于 0.2 的 33.0 的两倍多。 这里的 r 是「信任数据的程度」,调大等于让方差缩得更快、beta 更猛。
  4. 「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 章两节都在讲「标签来了怎么改权重」。 下一章换一个更省标签的问题:如果一百个样本里只有一个有标签,剩下九十九个能不能也用上?

相关笔记