第 10 章 · 学习是怎么发生的

正则化与泛化

训练能跑稳了,新问题来了:模型在训练集上考了满分,一到没见过的数据就露馅。 这叫过拟合——它把训练题“背下来”了,却没真正“学会”。这一章讲清楚什么是泛化、什么是过拟合, 以及一整套对付它的常用招:weight decay、Dropout、early stopping、数据增强。

读完这一章,你会明白

  • 泛化才是目标:训练集好不算好,没见过的数据上好才算好;
  • 怎么从训练/验证曲线一眼看出过拟合、欠拟合;
  • 偏差与方差为什么此消彼长,泛化的“甜区”在哪;
  • L2 / L1 / weight decay 为什么能让模型“更平滑、别太自信”;
  • Dropout 怎么靠“随机罢工”逼网络别依赖个别神经元,测试时为什么要关掉它,以及它在仓库代码里怎么落;
  • early stopping数据增强这两招,以及“更多数据”为什么是最强正则;
  • 大模型时代为什么 Dropout 基本退场、weight decay 反而成了标配——以及这一章的思想为什么依然不过时。

1. 泛化:考的是“没见过的题”

我们训练模型,真正想要的不是它在训练数据上表现好,而是它在将来没见过的新数据上也表现好—— 这个能力叫泛化(generalization)。就像考试:你要的是“真会做题”, 而不是“把练习册答案背下来”。练习册全对但一考新题就崩,那是白学。

所以我们要把数据分开考

为了诚实地衡量泛化,数据会被分成训练集(用来学)和测试集(用来考,训练时绝不能碰)。 通常还留一份验证集用来边训边监控。怎么分、怎么用,是下一章(第 11 章)的主题; 这一章先聚焦“怎么让模型别把训练集背下来”。

2. 过拟合 vs 欠拟合

训练中会遇到两种“没学好”:

训练轮次 损失 这里该停(early stop) 验证损失回升 = 过拟合 训练损失一直降

典型过拟合信号:训练损失(绿)一路下降,但验证损失(黄)降到某点后反而回升——模型开始背训练集了。

正则化(regularization)就是一类专治过拟合的手段。它们的共同思路只有一句话: 给模型加点“约束”或“干扰”,逼它别把训练集硬背下来,而去学更通用的规律。 在动手之前,先看一眼过拟合和欠拟合背后那对更本质的概念。

3. 偏差与方差:欠拟合和过拟合的“根”

欠拟合和过拟合,其实是同一枚硬币的两面,背后是一对此消彼长的量: 偏差(bias)方差(variance)

打靶比喻

把预测想成打靶:高偏差是每一箭都稳稳地偏向同一边(准星歪了,再稳也脱靶); 高方差是箭散得到处都是(准星没歪,但手一直在抖)。 我们真正想要的,是又准又稳——但现实里,压低一个往往会抬高另一个

模型在新数据上的总误差,可以粗略拆成三块:

总误差 ≈ 偏差2 + 方差 + 不可约噪声 前两项是我们能调的;第三项是数据本身的噪声,再强的模型也消不掉

关键在于:模型越复杂(层更深、参数更多、训练更久),偏差越小,但方差越大; 越简单则反过来。所以总误差通常是一条 U 形曲线——两头都差,最优点在中间那个“不太死板、也不太敏感”的甜区。

误差 简单 复杂 模型复杂度 → 甜区 偏差²(欠拟合) 方差(过拟合) 总误差

复杂度越高,偏差(蓝)越小、方差(黄)越大;总误差(绿)是 U 形,谷底就是泛化最好的“甜区”。

想明白这一点,正则化的本质就清楚了:它是主动往模型上加约束,牺牲一点点偏差,换来方差的大幅下降, 从而把总误差推到更靠近谷底的位置。下面第 4–7 节的四招——weight decay、Dropout、early stopping、数据增强—— 全都在做这同一件事,只是切入点不同。

4. L2 正则 / weight decay:别让权重太“嚣张”

过拟合的模型常有一个特征:某些权重变得非常大,对个别特征反应过激(“一朝被蛇咬”式的死记)。 L2 正则的办法是:在损失里加一项“所有权重的平方和”,让“权重太大”本身也成为一种“错”:

总损失 = 原损失 + λ · Σ 权重2 λ 控制惩罚力度;它等价于每步让权重“缩水”一点,所以又叫 weight decay(权重衰减)

效果是把权重往 0 拉一把,模型变得更“平滑、克制”,不会为了迁就某几个训练样本而走极端,泛化通常更好。 在代码里,它就是第 8 章优化器接口的一个开关:

src/deeplearning/optimizer/optimizer_base.h
void set_weight_decay(double wd);   // 只对 weight 生效, bias 不衰减  1
  1. 给优化器设一个 weight_decay,训练时每步就会顺带把权重往 0 收一点。注意只惩罚权重、不惩罚 bias;Adam 与 AdamW 对它的耦合方式不同(第 8 章)。
还有个近亲:L1 正则

如果把惩罚项从“权重平方和”换成“权重绝对值之和”(λ·Σ|权重|),就是 L1 正则。 两者都压小权重,但性格不同:L2 把权重均匀地往 0 挤,得到一堆“都挺小但都不为 0”的权重,更平滑; L1 则倾向于把一部分权重直接压成 0,相当于自动做“特征筛选”,得到稀疏的模型。 想让模型自己挑出少数关键特征,用 L1;只想让它整体收敛、别走极端,用 L2(也是深度学习里的默认选择)。

5. Dropout:让神经元“随机罢工”

Dropout是个又简单又神奇的招:训练时,每一步随机“关掉”一部分神经元 (比如随机让 20% 的神经元输出 0),每步关掉的还不一样。测试时再把它们全打开。

像一支“谁都可能请假”的球队

如果训练时随时有队员被随机叫去罢工,整支队就不敢过度依赖某个明星球员,每个人都得练出真本事、 学会互相配合。网络也一样:Dropout 逼它不把宝押在个别神经元上,而是学出更鲁棒、更分散的表示, 于是更不容易过拟合。相当于同时训练了无数个“缺人版”的小网络,再取平均。

那个“除以保留比例”到底在补什么?

这里有个容易被忽略、也最容易看懵的细节。先想清楚问题出在哪:下一层神经元收到的是“上一层输出的加权求和”(第 3 章),它在训练中习惯了某个大小的输入总量。可 Dropout 一开,这个总量就变了——

先看不做缩放会出什么事(丢一半为例)

假设某层有 10 个神经元,每个输出约为 1,那么下一层收到的总和 ≈ 10

  • 训练时随机丢掉一半:只剩 5 个在工作,总和 ≈ 5;
  • 测试时全开(Dropout 只训练时用),10 个都在,总和 ≈ 10

于是同一层,训练时喂给下一层的是 5、测试时却是 10——整整差了一倍。下一层的权重是照着“总和≈5”练出来的,测试时突然来个 10,数值全偏,输出就“懵”了。根源:被丢掉的神经元少贡献了一半的量。

解法叫 inverted dropout,思路特别朴素:训练时,把没被丢掉的神经元输出除以“保留比例”,提前把缺的量补回来。丢一半 → 保留比例 = 0.5 → 除以 0.5 = 乘 2:

训练时:剩下 5 个 × (每个 ×2) ≈ 10   =   测试时:10 个 × 1 ≈ 10 乘 2 把“丢掉一半”的量当场补齐,于是训练时的输出总量,和测试时全开对齐了

这就是为什么“测试时啥都不用改、直接全开”:缩放这件事已经在训练时做完了,测试时两边总量本就相等,自然不用再动手。(它叫 inverted「反转」,就是因为把缩放从“测试时缩小”反过来挪到了“训练时放大”——工程上更省事,测试/部署代码保持最干净。) 一般地,保留比例记作 p(丢弃率 = 1−p),训练时保留下来的输出都除以 p;丢得越多、p 越小、补偿的倍数越大。

一句话记住

Dropout 只在训练时开,测试/推理时关。训练时随机丢 + 给幸存者按 1/p 放大补量;测试时全员上岗、原样输出。两边总量对齐,所以切换时什么都不用改。

代码里怎么落:仓库的 inverted dropout

仓库里的实现就是上面这套,对外只有一个开关:net.set_dropout_rate(0.2)(隐藏层丢 20%)。 “测试时关”在这份代码里不是靠自觉,而是结构上保证的:只有 Train 会以训练模式走前向, Predict / CalcLoss 等推理路径根本不会采样掩码

src/deeplearning/neural_network.cpp(精简)
// 训练前向: 先给每个样本采好全部隐藏层掩码
std::mt19937 gen(DropoutSeed(rand_seed_, dropout_step_, b));  1
FillDropoutMask(gen, dropout_mask_[l][b].data(), layer_[l]);  2
// ... 前向算到该层时, 激活值立刻乘上因子再喂下一层
double a = activate_function_->Activate(z);
if (drop_layer) a *= dropout_mask_[l][b][o];                  3

// 训练反向: delta 乘回同一份掩码
double m = dropout_mask_[l][b][o];
double a = neuron_output_[l][b][o];
if (m > 0.0) a /= m;                4  // 还原丢弃前的激活值
delta = sum * DerivActivate(pre, a) * m;
  1. 掩码种子由 (rand_seed、训练步数、样本在 batch 里的槽位) 派生——相同 seed 结果可复现,而且和开几个线程训练无关
  2. FillDropoutMask 做的就是上一节那件事:以保留概率 keep = 1 − rate1/keep,否则填 0——训练时当场把缺的量补回来
  3. 掩码存的是乘性因子(0 或 1/keep),前向采的样,反向原样用回:被丢单元的梯度也是 0,等价于“这步它根本没存在过”。
  4. output 存的是补过量的值(a·m),而 sigmoid/tanh 是按 output 求导的(第 7 章),所以先用 a/m 还原再求导;ReLU 不受影响(缩放不变号)。乘回掩码后,被丢单元的偏置和“连出去的边”这一步都拿不到梯度。
亲手做一次消融

mnist demo 自带开关,两条命令对比(同一个 seed=42,唯一变量就是 dropout):

cd src
rm -f demo/mnist/mnist/demo.v2.param   # 清掉缓存, 冷启动基线
./bin/mnist                            # 基线: 隐藏层无 dropout
./bin/mnist --dropout 0.2              # 同一网络, 隐藏层丢 20%

--dropout 会使用独立的参数文件 demo.dropout.param,不会覆盖基线模型。实测 5 epochs 的结果:

784→128→64→10train 准确率test 准确率train/test 裂口
基线98.99%97.82%1.17 个点
dropout 0.298.35%97.51%0.84 个点

注意:dropout 没有把 test 刷得更高——MNIST 数据够多、任务简单,基线本就没怎么过拟合。 它做的是另一件事:把裂口从 1.17 收到 0.84,代价是训练表现略降。

那什么场景它才提分?制造一个真的会过拟合的场景:只用 4000 张图、训 30 个 epoch——

./bin/mnist --train-limit 4000 --epochs 30                # 基线
./bin/mnist --train-limit 4000 --epochs 30 --dropout 0.2  # + dropout 0.2
./bin/mnist --train-limit 4000 --epochs 30 --dropout 0.5  # + dropout 0.5
4000 图 · 30 epochstrain 准确率test 准确率test loss
基线100.00%(背完了)93.62%0.0410
dropout 0.299.82%94.21%0.0362
dropout 0.598.38%92.91%0.0413

三组对照讲完了整节课:基线 train 冲到 100%(彻底背题)、test 卡在 93.62%; dropout 0.2 双指标全胜——test 更高、test loss 低了 12%,还阻止了 100% 死记; 而 0.5 丢得太多,模型学不动了(欠拟合),反而掉分。 这就是正则化的真实性格:不是免费午餐,而是拿一点训练表现,换泛化的稳健—— 剂量也要刚刚好,裂口越大它越值,过量反而伤模型。

6. Early stopping:见好就收

回看上面那张曲线图:验证损失降到最低点后开始回升,那个拐点就是“学得刚刚好、还没开始背书”的时刻。 early stopping(早停)的做法直白得可爱:一边训一边盯验证集,一旦它连续几轮不再改善,就停, 并回退到验证表现最好的那个存档。既省算力,又正好卡在过拟合之前。

7. 数据增强,以及“更多数据”这剂猛药

还有一类正则化是在数据上做文章:

正则化,一句话收束

这些招看着五花八门,内核是同一个:故意给学习过程“添点乱”或“加点约束”,不让模型舒舒服服把训练集背下来。 weight decay 约束权重、Dropout 随机罢工、early stop 见好就收、增强/更多数据摊薄记忆——目标都只有一个:泛化

8. 大模型时代,正则化还管用吗?

这一章的招,几乎都诞生在同一个时代背景里:数据有限,要在同一批数据上反复训练很多遍(epoch)。 过拟合是那个时代的头号大敌。而到了大模型,这个前提本身变了:

但有两样东西留了下来,地位反而更高:

经典时代(本书 MNIST)大模型预训练
数据量几万~几百万样本万亿级 token
训练遍数几十上百个 epoch约 1 遍
主矛盾过拟合(高方差)欠拟合 / 算力不够
Dropout常用手段基本退场
主力手段dropout + weight decay + early stopweight decay + 数据质量 + 规模

同一套“泛化”目标,两个时代的打法。

那这一章还值得学吗?值得,三个理由: 小模型、小数据仍然是日常的大多数场景——本书的 MNIST 就是,该过拟合还是会过拟合; 大模型的微调是在小得多的数据上进行的(第 20 章后训练),过拟合立刻“回归”,weight decay、early stopping 马上又要捡起来; 最重要的不是记住哪几招,而是那个思想本身:给学习过程加约束,用一点偏差换方差——它永不过时。

一句话收束

正则化在大模型时代没有死,只是换了战场:从“防背题”变成“保稳定”,从“约束模型”变成“经营数据”。

小结

  • 泛化才是目标:要的是没见过的数据上也好,而不是把训练集背下来。
  • 过拟合 = 训练好、测试差(背题);欠拟合 = 都差(没学会)。看训练/验证曲线的“裂口”即可判断。
  • 偏差 vs 方差:欠拟合是高偏差、过拟合是高方差;总误差是 U 形,正则化用一点偏差换方差,逼近谷底“甜区”。
  • weight decay(L2):惩罚大权重,让模型更平滑克制;L1 则会把部分权重压成 0,得到稀疏模型。
  • Dropout:训练时随机关神经元(并做缩放),测试时全开;逼网络别依赖个别单元。代码上是一个开关 set_dropout_rate,mnist demo 可直接做消融。
  • early stopping:验证损失回升前收手;数据增强/更多数据是最本质的正则。
  • 大模型时代:数据海量 + 只训一遍,过拟合不再是主矛盾 → Dropout 退场、weight decay 留下;但在小数据/微调场景,经典正则化依然好使。

我们一直在说“训练集、验证集、测试集”“看验证损失”——可这些到底该怎么分、怎么读指标? 下一章我们就把评估与数据这件事讲透:数据集划分、准确率/精确率/召回、混淆矩阵、预处理与调试。