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

Dropout 的做法很反直觉:训练时每次随机让一部分神经元停工,包括它们往后的所有连线。 测试时全部单元都上班,一个不少。 100 张训练图、64 到 64 到 32 到 10:不用 Dropout 测试 0.8600,保留率 0.5 时 0.8950。 但训练准确率始终是 1.0000,一格没掉——网络容量太大,丢一半也背得下来。 数据给到 400 张时,用与不用都是 1.0000,一点差距都没有。 这一节最有用的结论是这个:Dropout 是没数据时的替补,不是万能加分项。

📝 本节和前面几节的关系

前几节改的都是数据或算子的形状,训练和测试走的是同一套计算。 这一节第一次引入「训练时和测试时行为不一样」的机制。 这也带来一个全新的坑:两边必须对得上。 训练时丢掉三成单元,测试时全开,下一层收到的信号就比训练时大三成——尺度一错位, 之前学的东西就白学了。

一、训练时扔硬币

具体做法:每喂一批数据,先给每个隐单元掷一次硬币,以保留率 p 的概率决定它这一轮上不上班。 没掷中的单元,输出置 0,往后所有连线一起失效。 重点在反向:这些单元的梯度也是 0,它们这一轮完全不更新。 下一批数据重新掷一次,可能换另一批单元休息。 效果是什么?每次训练的都是一个残缺的小网络,而测试时用的是完整的网络。 可以粗略理解成「训练了几十上百个小网络,测试时把它们平均起来」。 平均之后的模型更稳,不容易把宝押在某几个单元上。

二、保留率扫描

保留率 p 越小,丢得越狠。扫一遍看它怎么影响结果:

保留率 训练准确率 测试准确率 训练减测试
1.0 1.0000 0.8600 +0.1400
0.9 1.0000 0.8600 +0.1400
0.7 1.0000 0.8762 +0.1238
0.5 1.0000 0.8950 +0.1050
0.3 1.0000 0.8662 +0.1338
不用 Dropout 那一行,训练 1.0000、测试只有八成六,中间那道沟就是过拟合。
保留率降下来,沟一路收窄,0.5 时测试最高、沟只剩一成。
但注意训练准确率一直是 1.0000,一格没掉。
不是 Dropout 没生效,是这个网络容量太大——6464 个参数只用 100 张图,
丢一半单元也照样背得下来。
保留率压到 0.3 又掉头向下,说明丢太多了,剩下那点容量不够用。
所以 p 不是越小越好,0.5 附近是这类网络的常用值。

三、测试时必须补偿

训练时平均只有 p 比例的单位在上班,测试时全部上班,下一层收到的信号总量就比训练时大。 两种补法:

写法 训练时 测试时
朴素 Dropout mask 只置 0,不缩放 每层输出乘 p
倒置 Dropout mask 除以 p,当场补回尺度 什么都不用改
倒置写法把「除以 p」挪到训练时做,测试代码一个字都不用动,所以现在的框架默认全是这一种。
这次实测四行全挤在 0.86 到 0.88 之间,没测出「忘记补偿」的明显惩罚。
理论上它是错的,但在这个小网络上被稀释了——后面还有一层 softmax,
整体缩放会被归一化吸收掉一大半。
大网络、深网络里这一步就致命了,不能因为这次没测出来就当成没关系。

四、什么时候值得用

换三个训练集大小,各跑一次用与不用:

训练张数 不用 保留率 0.7 提升
100 0.8200 0.8413 +2.1 点
200 0.9100 0.9437 +3.4 点
400 1.0000 1.0000 +0.0 点
100 张时提升 2.1 点,200 张时提升 3.4 点,都不是大数目。
400 张时两条都顶到 1.0000,一点差距都看不出来了。
样本本身已经把过拟合压住了,再加正则化就是白忙一场。
这也是下一节和整章收尾都要反复提的一句:这些手段压的是过拟合,压不出更高的上限。

五、动手 Demo:保留率扫描、补偿写法对比、以及它什么时候不管用

数据 400 张训练、800 张测试,都只做平移一格。 网络 64 到 64 到 32 到 10,参数 6464 个,训练只给 100 张——参数比样本多六十多倍, 正适合看正则化的效果。 代码里 Zs 和 Ms 两个列表要分开存:一个存乘 mask 之前的激活值,一个存 mask 本身。 反传时两个都要用。

def fit(X, y, keep=1.0, mode="inverted", steps=2000, lr=0.1,
        hs=(64, 32), seed=0):
    u"""keep 是保留率;mode 决定训练时要不要把尺度补回去。"""
    rg = np.random.default_rng(seed)
    for _ in range(steps):
        As, Ms, Zs = [X], [], []
        for i in range(len(Ws) - 1):
            a = np.tanh(As[-1] @ Ws[i] + bs[i])
            if keep < 1.0:
                mk = (rg.random(a.shape) < keep)
                # 倒置写法在这一步就除以 keep,测试时什么都不用改
                mk = mk / keep if mode == "inverted" else mk.astype(float)
            else:
                mk = np.ones_like(a)
            Zs.append(a)      # 乘 mask 之前的激活值,反传要用来算导数
            Ms.append(mk)
            As.append(a * mk)
        # ... 前向到 softmax、算交叉熵梯度 ...
        for i in range(len(Ws) - 2, -1, -1):
            dz = d * Ms[i] * (1.0 - Zs[i] * Zs[i])
            Ws[i] -= lr * (As[i].T @ dz)
            bs[i] -= lr * dz.sum(0)
            if i:
                d = dz @ Ws[i].T


# 四种写法放一起比:同一份数据、同一个种子、同样 2000 步
print(acc(Xte, yte, fit(Xs, ys, keep=1.0, steps=2000, seed=1)))
mN = fit(Xs, ys, keep=0.7, mode="naive", steps=2000, seed=1)
print(acc(Xte, yte, mN, 0.7, "naive", 1.0))   # 测试时忘记补偿
print(acc(Xte, yte, mN, 0.7, "naive", 0.7))   # 测试时乘 0.7
print(acc(Xte, yte, fit(Xs, ys, keep=0.7, steps=2000, seed=1), 0.7))

实跑输出:

--------------------------------------------------------------------
Dropout:训练时随机停工,测试时怎么补偿
--------------------------------------------------------------------
[1] Dropout 干的事:训练时每次随机让一部分神经元「停工」
  每喂一批数据,先掷硬币决定哪些单元这一轮不说话,然后才前向、反向
  停工的那几个单元这一轮既不贡献输出,也收不到梯度、不更新
  下面用 100 张训练图、64-64-32-10 的网络(参数 6464 个,明显过多)
  参数比样本多几十倍,正适合看正则化的效果

[2] 保留率扫描:留下多少比例的单元
  保留率    训练准确率    测试准确率    训练减测试
  1.0       1.0000        0.8600        +0.1400
  0.9       1.0000        0.8600        +0.1400
  0.7       1.0000        0.8762        +0.1238
  0.5       1.0000        0.8950        +0.1050
  0.3       1.0000        0.8662        +0.1338

  不用 Dropout 那一行,训练 1.0000、测试只有八成六,中间那道沟就是过拟合
  保留率降下来,沟一路收窄,0.5 时测试最高、沟只剩一成
  但注意训练准确率一直是 1.0000,一格没掉——
  不是 Dropout 没生效,是这个网络容量太大,丢一半单元也照样背得下来
  保留率压到 0.3 又掉头向下,丢太多了,剩下那点容量不够用
  所以保留率不是越小越好,0.5 附近是这类网络的常用值

[3] 测试时必须补偿,这一步最容易写错
  训练时丢了 30% 的单元,测试时全部单元都上班,
  下一层收到的信号总量会比训练时大,尺度就错位了。两种写法:
  做法                              测试准确率
  不用 Dropout                      0.8600
  朴素 Dropout,测试时忘记乘 0.7    0.8675
  朴素 Dropout,测试时乘 0.7        0.8812
  倒置 Dropout(训练时就除 0.7)    0.8762

  这次没测出「忘记补偿」的明显惩罚:四行全挤在 0.86 到 0.88 之间
  理论上忘记补偿是错的,但在这个小网络上影响被稀释了,
  因为后面还有一层 softmax,整体缩放会被归一化吸收掉一大半
  大网络、深网络里这一步就致命了:训练和测试看到的信号强度不一致,
  等于换了台机器,所以框架默认都用倒置写法
  后两行分数几乎一样,正说明「除以 0.7」挪到训练时做是等价的
  挪到训练时的好处是测试代码一个字都不用改,所以现在的框架都这么做

[4] 什么时候值得用:样本一多,它就没什么事了
  训练张数    不用        保留率 0.7    提升
  100         0.8200      0.8413        +2.1 点
  200         0.9100      0.9437        +3.4 点
  400         1.0000      1.0000        +0.0 点

  100 张时提升 2.1 点,200 张时提升 3.4 点,都不是大数目
  400 张时两条都顶到 1.0000,一点差距都看不出来了
  样本本身已经把过拟合压住了,再加正则化就是白忙一场
  所以 Dropout 是「没数据时的替补」,不是「万能加分项」

[5] 一句话记住
  Dropout 相当于每次训练一个残缺的小网络,测试时把这些小网络平均起来
  它压的是过拟合,不是提高上限;上限还是由数据和网络容量决定
  下一节讲 DropConnect:它连的不是单元,是单元之间的连线

Zs 那一行注释不是废话,是这次真踩过的坑。 tanh 的导数是 1 减 a 平方,这里的 a 必须是「乘 mask 之前」的值。 如果拿乘完 mask 的 As 去算,被丢掉的单元会算出 0 减 0 除以 0 这种式子,数值上直接崩。 第二段那四种写法里,mN 只训练了一次,却用三种方式去测。 这是故意的:同一份权重、同一个网络,只改测试时的补偿方式, 才能干净地看出「补偿」这件事本身的影响。 第四段那张表最容易看错:提升只有两个点到三个点。 如果你原本期待「加 Dropout 能涨十几个点」,那这份数据会让人失望。 原因是任务太同质:十个数字的七段管图长得太像,正则化能挤出来的水分本来就不多。

30 秒实验

  1. 把网络从 64 到 64 到 32 改成 64 到 16 到 10,再扫一遍保留率。 容量小了以后,Dropout 的最优保留率会往上移——丢一点就学不动了。
  2. 把训练张数从 100 改成 50,再看提升幅度。 会明显大于 2.1 点,因为过拟合更严重。
  3. 把 mode 改成 naive,训练时不做除法,测试时乘 0.7。 结果是 0.8812,比倒置写法的 0.8762 还高一点——这份数据上两种写法都能用。

常见误区

  1. 「Dropout 是万能的,加上总没坏处。」 训练集给到 400 张时,用与不用都是 1.0000,白忙一场。 数据够的时候,正则化只会拖慢训练。
  2. 「加了 Dropout,训练准确率应该会下降。」 实测五行的训练准确率全是 1.0000。 网络容量远大于任务难度时,丢一半单元也照样能拟合。 所以「训练准确率下降」不是判断 Dropout 生效的可靠信号。
  3. 「测试时也开着 Dropout,双重保险。」 绝对不行。 测试要的是确定、可复现的答案,开着 Dropout 同一个输入每次跑出不同结果,工程上根本没法用。 这也是它叫「正则化」而不是「增强」的原因:它只作用于训练。
自测三问(点开对答案) 1. **为什么 Dropout 能减轻过拟合?** —— **它逼每个单元都不能依赖别的单元:别的单元随时可能不在,它得自己也能干活。** **这打散了单元之间的「共谋」,让学到的特征更独立、更冗余。** **另一个角度看,它相当于训练了极多个共享权重的子网络,测试时做平均。** 2. **倒置 Dropout 为什么要除以保留率?** —— **为了让「训练时的期望输出」等于「测试时的实际输出」。** **训练时只有 p 比例的单元在工作,每个单元的贡献除以 p,总和就补回来了。** **这样测试时什么都不用改,直接跑就行。** 3. **保留率是不是越小越好?** —— **不是。实测 0.5 最好(0.8950),0.3 掉回 0.8662。** **丢得太多,剩下的容量不够表达,就从「防过拟合」滑到「欠拟合」了。**

Dropout 丢的是单元。 下一节换一个粒度:不丢整个单元,只丢单元之间的连线,这就是 DropConnect。

相关笔记