📗 第6章 提高泛化能力的方法
📄 一句话概括

预处理改的是数据的摆放方式,不是数据本身。 这一节量了三件事:零均值化、标准化、白化,还量了它们的「条件数」。 实测结果和教材的口气不太一样:原始像素 0.9150,PCA 白化 0.9475,ZCA 白化反而只有 0.8788。 这份数据的条件数只有 357.7,本来就不算歪,没什么好掰的。 把数据人为放大 25 倍后条件数涨到 53087,原样喂只有 0.7275,只除标准差就回到 0.9225。 所以预处理的真正作用是「让学习率不敏感」,不是「提升精度」。

📝 本节和前面几节的关系

上一节改数据量和数据质量,这一节改数据的样子。 同样一批像素,缩放、平移、去相关之后,梯度在不同方向上被放大的倍数不一样, 训练起来难度天差地别。 这一节要引入一个量:条件数。 它把「数据歪不歪」从一个形容词变成一个数,后面就能拿它跟准确率放在同一张表里看关系。

一、什么叫「数据歪」

拿 64 个像素当 64 个坐标轴,训练数据就是空间里的一团点。 如果这团点在一个方向上摊得极开、在另一个方向上挤成一条线,就叫「歪」。 量它的办法是对协方差矩阵做特征分解,拿最大特征值除以最小特征值,得到条件数。 条件数越大,说明各方向尺度差得越悬殊。 为什么这件事要紧:梯度下降每走一步,在各个方向上走的距离是同一个学习率乘梯度。 尺度大的方向容易走过头,尺度小的方向半天挪不动。 条件数就是「这种不平衡有多严重」的度量。

二、四种处理,条件数各是多少

对同一批数据做四种处理,各自算条件数:

数据形态 条件数
原始像素 357.7
减均值 357.7
减均值再除标准差 251.4
PCA 白化(降到 32 维) 1.0
ZCA 白化(保持 64 维) 1.0
减均值一头一尾没变,因为它只是把整团点平移,形状没动。
除标准差把每个坐标轴各自拉伸,条件数只从 357.7 降到 251.4——轴与轴之间的倾斜还在。
两种白化都直接把条件数压到 1。
这件事可以验算:ZCA 白化后协方差矩阵的非对角元绝对值最大只有 2.70e-05,而原始数据是 0.2213。
协方差基本变成了单位阵,这就是「白化」这个词的字面意思。

三、可是准确率没跟着涨

条件数压到 1 了,下游任务应该变好吧?实测并不是:

输入 维度 测试准确率
原始像素 64 0.9150
减均值 64 0.9150
减均值再除标准差 64 0.9225
PCA 白化 32 0.9475
ZCA 白化 64 0.8788
原始 0.9150,ZCA 白化 0.8788,反而低了 3.6 个百分点。
PCA 白化最高,可它是把 64 维压到 32 维,分数里有一部分是降维去噪挣的,不全算白化的功劳。
ZCA 为什么输:白化要求每个方向的方差都变成 1,而那些特征值极小的方向本来就只装着噪声。
把噪声方向的方差拉成 1,等于给噪声发了个放大器。

四、把数据真的掰歪,白化才显出价值

上一张表说明这份数据不够歪。那就人为把它掰歪:每三个像素挑一个放大 25 倍。

数据形态 条件数 测试准确率
拉歪,原样喂 53087 0.7275
拉歪,只除标准差 251 0.9225
拉歪,再 ZCA 白化 1 0.8712
条件数从 357.7 涨到 53087,原样喂掉到 0.7275。
因为放大 25 倍的那些像素把 tanh 一把推进饱和区,导数接近 0,学不动。
这一局只除标准差就够用:0.9225,和没拉歪之前一模一样。
白化反而略低,还是那个原因——它顺手把噪声方向也放大了。
所以预处理不是越彻底越好,做到「不歪」就够,多做一步可能是负收益。

五、白化省的其实是调学习率

再换个角度看:固定数据形态,只扫学习率。

学习率 原始像素 ZCA 白化
0.02 0.2525 0.6300
0.10 0.9050 0.8762
0.50 0.9463 0.8775
2.00 0.9563 0.8812
学习率 0.02 那一行,白化领先了三十八个百分点——原始数据根本推不动。
学习率给到 0.1 以上,原始数据追上并反超。
原因是白化把整体尺度压到了 1,同样写 0.1 的实际步长比原来小。
所以白化的价值可以这么表述:它让「学习率」这个超参数变得不敏感。
如果你愿意花时间慢慢调,它在不少任务上换不来分数。

六、动手 Demo:条件数、白化、以及那个会算出 1e8 的坑

数据 400 张训练、800 张测试,都用平移 1 格加 1 个噪点。 网络是 64 到 32 到 16 到 10,统一用学习率 0.1、1500 步——这样每一步的比较才是同一起跑线。 代码里 safe_std 这个函数不是凑数的,它是这次踩坑之后加的,稍后解释。

def safe_std(A):
    u"""方差为 0 的维度不缩放:直接加 1e-8 会把「0 减 0」变成 1e8。"""
    s = A.std(0)
    return np.where(s > 1e-6, s, 1.0)


def cond(X):
    u"""条件数:协方差最大特征值除以最小特征值。"""
    lam = np.linalg.eigvalsh(np.cov(X.T))
    return float(lam[-1] / max(lam[0], 1e-12))


def whiten(Xc, keep=None, eps=1e-6):
    u"""白化:keep 给整数就是 PCA 白化,给 None 就是 ZCA 白化。"""
    C = Xc.T @ Xc / len(Xc)
    lam, U = np.linalg.eigh(C)
    lam, U = lam[::-1], U[:, ::-1]        # eigh 是升序,这里翻成降序
    if keep is not None:
        lam, U = lam[:keep], U[:, :keep]  # 丢掉小特征值方向,顺带降维
        P = U / np.sqrt(lam + eps)
    else:
        P = (U / np.sqrt(lam + eps)) @ U.T
    Z = Xc @ P
    return Z / np.sqrt((Z * Z).mean()), P, lam


mu, sd = Xtr.mean(0), safe_std(Xtr)
Z_tr, P_z, _ = whiten(Xtr - mu)   # 白化矩阵只用训练集算
Z_te = ((Xte - mu) @ P_z)         # 测试集必须套同一个矩阵
Z_te = Z_te / np.sqrt((Z_tr * Z_tr).mean())

实跑输出:

--------------------------------------------------------------------
预处理:减均值、标准化、白化,各自管什么
--------------------------------------------------------------------
[1] 预处理要解决的问题:不同像素的「量纲」和「相关性」不齐
  64 个像素里,笔画点亮的像素几乎总是 1,空白像素几乎总是 0
  有些像素几乎不变,有些像素跟着别的像素一起变
  这会让梯度在不同方向上被放大或缩小得不一样,训练就走得歪

  先量一下「歪」的程度。协方差矩阵的最大特征值除以最小特征值,
  叫条件数:它越大,说明数据在一个方向上摊得极开,在另一个方向上挤成一团。
  数据形态              条件数
  原始像素              357.7
  减均值                357.7
  减均值除标准差        251.4
  PCA 白化(32 维)     1.0
  ZCA 白化(64 维)     1.0

  减均值不改变条件数——它只是把整团数据平移,形状没变
  除标准差只把每个坐标轴各自拉伸,轴之间的倾斜还在
  白化才真正把协方差掰成单位阵,条件数掉到 1 附近

[2] 「掰成单位阵」这句话可以直接验算
  ZCA 白化后,协方差矩阵对角元全在 1 附近,最大 1.002527,最小 1.002395
  非对角元(也就是「像素之间还相关吗」)绝对值最大 2.70e-05
  原始数据的非对角元绝对值最大 0.2213,白化后基本清零

[3] 但预处理好不好,要看它对下游任务有没有用
  同一个网络(64-32-16-10)、同一个学习率 0.1、同样 1500 步,只换输入形态:
  输入                  维度    测试准确率
  原始像素              64      0.9150
  减均值                64      0.9150
  减均值除标准差        64      0.9225
  PCA 白化              32      0.9475
  ZCA 白化              64      0.8788

  这张表跟教材的口气不太一样:白化没有全面胜出
  原始像素 0.9150,ZCA 白化 0.8788,反而低了 3.6 个百分点
  PCA 白化最高,可它是把 64 维压到 32 维,分数里有一部分是降维去噪挣的
  为什么 ZCA 会输:白化要把每个方向的方差都拉成 1,
  那些特征值极小的方向本来就只有噪声,拉成 1 等于把噪声喂大
  根子在条件数:这份数据只有 358,本来就不算歪,没什么好掰的

[4] 那把数据人为掰歪,白化的价值就露出来了
  把每三个像素挑一个放大 25 倍,人为制造「各方向尺度悬殊」:
  数据形态                  条件数        测试准确率
  拉歪,原样喂              53087         0.7275
  拉歪,只除标准差          251           0.9225
  拉歪,再 ZCA 白化         1             0.8712

  条件数从 358 涨到 53087,原样喂进去掉了近二十个百分点
  因为放大 25 倍的那些像素把 tanh 一把推进饱和区,导数接近 0,学不动
  这一局 rescale 就够用:只除标准差回到 0.9225,跟没拉歪之前一模一样
  白化反而略低——它把那些极小的特征值方向也拉成 1,顺手把噪声喂大了
  所以「预处理有用」真正的前提是:数据本身歪,或者优化器步长没配好
  数据不歪的时候,多做一步白化可能白忙,甚至会掉分

[5] 白化省的其实是「调学习率」这件事
  固定数据形态,只扫学习率(都只跑 800 步):
  学习率    原始像素      ZCA 白化
  0.02      0.2525        0.6300
  0.10      0.9050        0.8762
  0.50      0.9463        0.8775
  2.00      0.9563        0.8812

  学习率 0.02 那一行,白化领先了三十多个百分点——原始数据根本推不动
  学习率给到 0.1 以上,原始数据就追上并反超了
  原因是白化把整体尺度压到了 1,同样写 0.1 的实际步长比原来小
  结论:白化不是「提升精度」的偏方,它是「让学习率不敏感」的手段
  如果你愿意慢慢调学习率,它在不少任务上换不来分数

[6] 代价与注意
  白化矩阵是从训练集算出来的,测试集必须用同一个矩阵,不能自己再算一遍
  减均值和除标准差也一样:mu 和 sd 只能来自训练集
  这一步做错,测试集的信息就漏进训练过程,分数会虚高
  另外 ZCA 白化的特征值里有极小的值,取倒数会放大噪声
  所以要加一个小常数 eps 兜底,或者干脆只保留前 k 个主成分(PCA 白化)
  下一节换激活函数:不改数据,只换门的形状

whiten 里那句「eigh 是升序,这里翻成降序」值得留意。 np.linalg.eigh 返回的特征值从小到大排,而 PCA 白化要保留的是最大的那几个,所以必须先反转, 否则保留的正好是要丢的。 safe_std 处理的是这篇最有价值的一个坑。 最直觉的写法是 sd = A.std(0) + 1e-8,防止除零。 可如果某个像素在训练集里恒为 0,它的标准差就是 0; 一旦增强时被平移点亮,(1 - 0) / 1e-8 就是 1e8。 这个值进网络,tanh 会把它压成 1,看着没事;换成 ReLU 就直接把整个网络冲垮。 最后两行的顺序也容易写错:白化矩阵必须只用训练集算,测试集套同一个矩阵。 如果测试集自己再算一遍,测试集的分布信息就漏进训练过程,分数会虚高——这就是常说的数据泄漏。

30 秒实验

  1. 把 whiten 的 eps 从 1e-6 改到 1e-2 再跑。 ZCA 白化的准确率会明显回升——因为小特征值方向不再被放大到夸张。
  2. 把测试集的标准化改成「用测试集自己的均值和标准差」。 准确率会虚高几个点,但那是假的:真实场景里你拿不到测试集的分布。
  3. 把放大倍数从 25 改成 5 再跑「拉歪」那段。 条件数会小一个量级,原样喂的准确率也会好看很多,白化的优势随之缩小。

常见误区

  1. 「白化一定能提升精度。」 实测 ZCA 白化 0.8788,比原始像素的 0.9150 还低。 白化改的是「尺度」,不是「信息量」;数据本来不歪的时候,它只会顺手放大噪声。
  2. 「除标准差时加个 1e-8 就安全了。」 这次就踩了这个坑:恒为 0 的像素被 (1 - 0) / 1e-8 算成 1e8,tanh 把它压住了, ReLU 直接崩。 正确做法是判断方差是否为 0,为 0 就不缩放,而不是加一个极小数。
  3. 「预处理就是把数据标准化,一行代码的事。」 标准化只处理「每个轴自己的尺度」,不处理「轴与轴之间的倾斜」。 这份数据上,除标准差把条件数从 357.7 降到 251.4,白化才降到 1。 要不要做到白化,得看数据歪到什么程度。
自测三问(点开对答案) 1. **为什么减均值不改变条件数?** —— **因为条件数看的是协方差矩阵的特征值之比,而协方差不随平移改变。** **把所有点减掉同一个向量,整团点只是挪了位置,形状一模一样。** 2. **白化把条件数压到 1,为什么准确率不一定涨?** —— **因为条件数影响的是「优化好不好走」,不是「数据里有多少信息」。** **学习率配得当时,优化本来就走得动;这时候白化只改了尺度,没加信息。** **只有当优化走不动时(学习率太小、数据太歪),白化才真正值钱。** 3. **`sd = std + 1e-8` 这种写法,什么时候会出事?** —— **当某一维的方差恰好是 0,而测试或增强数据在这一维上不是 0 的时候。** **除数接近 0,商就会爆炸。** **判断方差为 0 时直接不缩放,比加一个小数安全得多。**

数据这一头讲完了。 下一节换网络内部:激活函数那个「门」的形状,决定梯度从后往前传时还剩多少。

相关笔记