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

让它真的训得动

前向、损失、反向、梯度下降——“学习”的原理已经讲完了。但把原理变成 真能训练成功的模型,中间还隔着一整套工程技巧。 这一章我们把现代训练栈拼齐:从初始化起步, 接上第 8 章选好的优化器, 再配学习率调度和梯度裁剪,看清它们在一个 minibatch 里怎么串起来, 最后认识深层网络的“稳定器”——批归一化。

读完这一章,你会明白

  • He / Xavier 初始化各解决什么、配什么激活,以及为什么要设随机种子;
  • 梯度消失和爆炸分别是什么、本章哪些零件在对症下药;
  • 梯度裁剪的两种模式、学习率调度器家族,以及 warmup + cosine 为什么常用;
  • 一个 minibatch 在 Train 里逐步发生什么;
  • 批归一化(BatchNorm)怎么用一批数据的均值、方差稳住每层输入,为什么训练和推理要区别对待,以及训练不稳时先拧哪几个旋钮。

1. 起点很重要:参数初始化

训练前,所有权重得有个初始值。你可能想:全设成 0 不是最干净吗? 恰恰是最糟的。如果一层里每个神经元的权重都一样,它们收到的输入一样、 算出的输出一样、反传回来的梯度也一样,于是永远保持相同—— 这一层等于只有一个神经元在工作。这叫对称性问题,必须用随机初始化打破。

但随机也有讲究:初始值太大,信号逐层放大直到“爆炸”;太小,信号逐层衰减直到“消失”。 诀窍是让每层输出的“波动幅度”大致稳定。仓库里两种常用方案:

初始化随机范围(均匀分布)通常搭配代码开关
He limit = √(6 / fan_in) ReLU / Leaky ReLU / GELU 等“半关半开”的激活(第 7 章) PARAM_INIT_HE
Xavier limit = √(6 / (fan_in + fan_out)) Sigmoid / Tanh 等两端饱和的激活 PARAM_INIT_XAVIER

fan_in 是本层每个神经元收到的输入个数;fan_out 是本层神经元个数。输入越多,随机范围越小,避免一层求和后数值过大。

src/deeplearning/param_init/he_param_init.cpp · Xavier 同理(分母换成 fan_in+fan_out)
double limit = std::sqrt(6.0 / (double)fan_in);                 1
std::uniform_real_distribution<double> dis(-limit, limit);      2

for (auto &w : weight[i])
  for (auto &w_ : w)
    w_ = dis(gen);                                              3
  1. 按 fan_in 缩放随机范围——这是“别让信号一层层爆炸/消失”的核心。
  2. 在 [−limit, +limit] 上均匀随机,每个权重都不一样,打破对称性。
  3. 偏置也用同一分布随机化。配 MNIST 时 ReLU + He 是常见组合(第 23 章)。
别忘了随机种子

初始化、打乱 batch 都依赖随机数。net.set_random_seed(42) 会让同一份代码、同一份数据 每次跑出相同结果,方便对比“改了学习率/调度器到底有没有用”。种子会透传给参数初始化器。

2. 训练栈里挂上优化器

“梯度算出来之后参数该怎么挪”——这是优化器的活。 第 8 章已经把 SGD → Momentum → RMSProp → Adam → AdamW 的演进、公式和仓库里的统一接口 (BeforeStep + CalcChangeValue)讲透了;这里不再复述,只关心在训练栈里通常怎么选、怎么配。

实务上的默认答案大多是 AdamW:Adam 那套“方向 + 自适应步长 + 偏差校正”省心又稳, 再把 weight decay(权重衰减,第 10 章详讲)从梯度里解耦出来, 正则力度更可控,训 Transformer、训大模型几乎都是它。 不确定用啥时先 AdamW;想看 SGD / Momentum / Adam 在同任务上谁收敛更快,跑 optimizer_bench demo(第 8 章)。

训练栈里的优化器两行配置(C++ 接口)
net.set_optimizer_function(OPTIMIZER_ADAMW);       1
net.optimizer_function()->set_weight_decay(1e-4); 2
  1. 一行换优化器——和激活函数一样是可插拔策略(第 8 章)。
  2. weight_decay 只作用在权重上,bias 不衰减;AdamW 把衰减独立加在更新量末尾,而不是混进梯度里被 √v 削弱。
AdamW 在代码里只多这一笔

自适应步长照 Adam 算;差别是 weight decay 单独叠上去。完整推导见 第 8 章 AdamW 小节:

if (weight_pos != -1 && weight_decay_ != 0.0)
  adaptive += learning_rate * weight_decay_ * param_value;

3. 梯度消失与梯度爆炸

这是深层网络最有名的两个“训不动”故障。它俩其实同根同源——都出自反向传播那一长串乘法,只是一个越乘越小、一个越乘越大。 先把根源讲透,后面为什么要初始化、裁剪、热身就全顺理成章了。

根源:反传其实是「一长串数」在连乘

回忆第 6 章的 δ 递推:误差每往回穿过一层,就要乘一次 “这一层的权重 × 这一层激活函数的导数”。所以当它传到很前面的某个权重时,那个梯度长这样:

∂L∂w前面某层 = δ末层 · (w·f′) · (w·f′) · … · (w·f′) · out 网络有几层,中间就有几个「权重 × 激活导数」在连乘

一串数连乘,结果几乎只由“每个因子的平均大小 r”决定,而且是指数级的:穿过 L 层就约等于 rL。 于是只有三种命运:

类比:连续打折 vs 利滚利

每层乘 0.8,像每过一关打八折:一两关看不出,连着穿 30 层就只剩原来的千分之一(消失)。 每层乘 1.5,像利滚利:30 层后翻了近 20 万倍(爆炸)。 差别不在“单层乘多少”,而在“连乘多少次”——网络越深,两头越极端。

一个数字例子:深度一上来,差距有多离谱

假设每层因子 r 大致稳定,看梯度乘到第 5 / 10 / 30 层还剩多少倍:

每层因子 r×5 层×10 层×30 层结局
0.25(Sigmoid 最好情况)≈1e−3≈1e−6≈9e−19梯度消失
0.80.330.11≈1e−3慢慢消失
1.01.01.01.0刚好稳定
1.11.62.6≈17温和放大
1.57.658≈1.9e5梯度爆炸

同样“每层只差一点点”,乘到 30 层就是天壤之别——这就是深网络对初始化和尺度格外敏感的原因。

套到具体元凶上:谁把 r 推离了 1

有了“因子连乘”这个根,常见诱因就都能归成一句话——它们要么把 r 压到小于 1,要么把 r 顶到大于 1:

反向传播:误差信号沿层往回传 输入层 隐藏层 隐藏层 输出层 反向:信号逐层衰减 → 消失 或某层突然放大 → 爆炸(NaN)

初始化管“起跑姿势”,激活函数管“坡度”,裁剪和 warmup 管“别一脚踩空”。几样配合,训练才稳。

4. 防止训练“踩空”:梯度裁剪

上一节说的梯度爆炸,实战里最常见的急救手段是梯度裁剪: 给梯度设一个上限,防止某一步把参数推飞。本仓库支持两种模式,可以单独开,也可以先按值、再按范数连着用:

模式做法直觉接口
按全局范数 把所有梯度拼成一个大向量,若 L2 长度超阈值,整体等比缩小 方向不变,只把“这一脚”收短;Transformer / RNN 最常用 set_gradient_clip_norm(1.0)
按分量 每个梯度分量单独 clamp 到 [−thresh, +thresh] 更“硬”,适合个别分量偶发尖峰 set_gradient_clip_value(0.5)

为什么是「按范数缩放」,而不是「一刀砍平」?

关键要先想清楚:梯度爆炸时,梯度的方向通常还是对的(它依然指向“损失下降最快”的那边),坏的只是偶尔某一步的长度突然冲到天上,一步就把参数推飞、损失变 NaN。所以我们想要的是——只把这一脚的“步子”收短,别改它的方向。

「按全局范数」正好做到这点。它把所有参数的梯度拼成一个大向量 g,算它的总长度(L2 范数)‖g‖;只有当 ‖g‖ 超过阈值 c 时,才整体乘一个小于 1 的同一个系数:

若 ‖g‖ > c:   g ← g · c‖g‖ 每个分量都乘同一个系数 c/‖g‖ < 1 → 向量整体等比缩短,方向完全不变,新长度恰好 = c

因为每个分量乘的是同一个数,向量的“指向”丝毫不动,只是长度被拉回 c——这就是它和“把每个分量硬砍到 ±阈值”(按分量)最大的区别:后者会扭曲方向(不同分量砍的比例不同),前者不会。所以序列模型(RNN / Transformer)几乎都用按范数。

半径 = 阈值 c 原始梯度 g(太长, 会把参数推飞) 裁剪后:同方向, 长度收回到 c

把阈值想成一个半径为 c 的圆:梯度没超出就原样用;一旦冲出圆(爆炸),就沿原方向拽回到圆边上——方向不变,只是不再“过冲”。

代入数字算一遍

设阈值 c = 1.0,某一步所有梯度拼起来的总长度 ‖g‖ = 5.0(爆了)。那么缩放系数 = c/‖g‖ = 1.0/5.0 = 0.2, 把每一个梯度分量都乘 0.2。于是新长度 = 5.0 × 0.2 = 1.0,正好压回阈值;而所有分量等比例缩小,方向一模一样。 反过来,如果某步 ‖g‖ = 0.3 < 1.0(没爆),条件不满足,原样不动——裁剪只在“真的过大”时才出手,平时完全不干预。

src/deeplearning/neural_network.cpp · ClipGradients(by-norm,精简)
double norm = std::sqrt(sq_sum);                 1
if (norm > grad_clip_norm_ && norm > 0.0) {
  double scale = grad_clip_norm_ / norm;        2
  // 所有梯度 *= scale, 整体缩回阈值以内
}
  1. 先把累加梯度除以 batch 大小得到平均梯度,再算全局 L2 范数。
  2. 超阈值就整体乘以 scale——方向不变,只是变短。Train 里在 ApplyGradient 之前自动调用。

5. 学习率不必一成不变:调度器

第 5 章我们用固定学习率,但更聪明的做法是让它随训练进程变化。 仓库里 lr_scheduler/ 提供一整族调度器,都和 Train 配合:每步开头调 GetLR(step) 更新当前学习率。

调度器曲线形状什么时候用
StepDecayLR每隔固定步数乘 γ 阶梯下降经典 CV、想“训一段降一档”
ExponentialDecayLR每步按指数衰减想快速降下来
CosineAnnealingLR余弦平滑降到 min_lr后期小步精修
WarmupCosineLR先线性热身,再余弦退火默认推荐;MNIST demo、大模型预训练常见
先说清“step(步)”是什么

这里的 step 指一次参数更新——即“取一个 minibatch → 前向 → 反向 → 更新一次权重”算 1 步, 不是一个 epoch。把整份数据过一遍(一个 epoch)通常包含很多步。下面所有“第几步”都是这个意思。

下面展开本仓库和 MNIST demo 最常用的 warmup + cosine。它把整个训练分成前后两段, 分界点就是一个你自己设的步数 warmup_steps:

所以“什么时候开始退火”答案很干脆:第 warmup_steps 步。在这一步之前是热身(升),到达这一步时学习率正好冲到峰值 base_lr,从这一步往后就一路余弦下降,直到第 t_max 步落到 min_lr。

学习率 lr step(第几次参数更新) base_lr min_lr ① 热身(线性升) ② 退火(余弦降) 峰值 = base_lr 0 warmup_steps t_max

黄线=热身(线性升到峰值 base_lr),黄色竖虚线处(第 warmup_steps 步)开始退火,蓝线=余弦平滑降到 min_lr。

两段各用一个公式,其实就是把上图翻译成算式(base_lr=峰值学习率,min_lr=最低学习率,t_max=总步数):

余弦这段之所以是“两头慢、中间快”:progress=0 时 cos0=1,lr 还等于 base_lr(刚过峰值,先缓降、多学一会); progress=0.5 时 cos(π/2)=0,lr 正好落在 base_lr 与 min_lr 的正中间(下降最快); progress=1 时 cos(π)=−1,lr 降到 min_lr 并停住(小步精修、稳稳落底)。

这四个参数怎么定? 它们不是拍脑袋的魔法数,每个都有来路——下面给出经验法则,并标上本仓库两个 demo 的真实取值:

三个“步数”参数其实是算出来的,不用猜

steps_per_epoch = ⌈样本数 / batch_size⌉(一个 epoch 要跑多少个 minibatch), 再 t_max = 总步数 = epochs × steps_per_epoch,warmup_steps 取其中一小截。 MNIST 就是 ⌈60000 / 64⌉ = 938 步/epoch,训 5 个 epoch 共 4690 步——所以 warmup_steps、t_max 都是从 batch 大小和训练轮数推出来的,只有 base_lr、min_lr 需要你挑。

为了让算术清爽,下面换一组整数示例走一遍(规律和上面的真实取值完全一样,只是把数字凑圆好心算):base_lr=0.1、warmup_steps=100(总步数的 10%)、t_max=1000、min_lr=0.01(= base_lr × 0.1):

第几步 step处于哪一段学习率 lr怎么来的
0热身·第 1 步0.0010.1 × 1/100,几乎从 0 起步
50热身·爬升中0.0510.1 × 51/100,直线往上
100热身结束 = 退火起点0.100冲到峰值 base_lr,从这步开始退火
325退火·前段≈ 0.087progress=0.25,刚过峰值先缓降
550退火·中点0.055progress=0.5,正好是两者中间值
775退火·后段≈ 0.023progress=0.75,越降越慢
1000退火终点 = t_max0.010progress=1,落到 min_lr,不再降
src/deeplearning/lr_scheduler/warmup_cosine_lr.cpp · GetLR(精简)
if (step < warmup_steps_)
  return base_lr_ * (step + 1) / warmup_steps_;          1

double progress = (double)(step - warmup_steps_) / rest; 2
double cos_val = std::cos(kPi * progress);
return min_lr_ + 0.5 * (base_lr_ - min_lr_) * (1.0 + cos_val);  3
  1. 热身段:前 warmup_steps_ 步,学习率从接近 0 线性升到 base_lr_。
  2. 进度:热身结束后,算出“退火走了百分之多少”。
  3. 退火段:用余弦把学习率从 base_lr_ 平滑降到 min_lr_。训练时每一步由 Train 自动调用。

6. 一个 minibatch 里发生什么

第 5 章看过 Train 的骨架;加上本章零件后,每一步其实是下面这一圈。 初始化只在建网时做一次;其余都在循环里转:

打乱 index · 组 batch scheduler→GetLR(step) 更新学习率 ForwardPropagationBatch BackPropagationBatch ClipGradients(可选) ApplyGradient → 优化器更新参数

黄框是本章新增的两步;蓝框是第 3–6 章学过的前向/反向;绿框是第 8 章优化器落地。

src/deeplearning/neural_network.cpp · Train(含本章零件,精简)
if (lr_scheduler_ != nullptr)
  learning_rate_ = lr_scheduler_->GetLR(i);           1

ForwardPropagationBatch(batch_data);                 2
ResetGradients();
BackPropagationBatch(batch_target);                    3

if (grad_clip_norm_ > 0.0 || grad_clip_value_ > 0.0)
  ClipGradients(B);                                    4
ApplyGradient(B);                                      5
  1. 学习率调度:每步按当前 step 取学习率;没挂调度器就用固定 learning_rate_。
  2. 前向:算这一批的预测和损失(第 3、第 4 章)。
  3. 反向:累加每个参数的梯度(第 6 章)。
  4. 梯度裁剪:可选;在更新前把梯度收进安全范围。
  5. 更新:BeforeStep + CalcChangeValue,由优化器把参数往损失更低的方向挪(第 8 章)。

7. 批归一化 BatchNorm:用这一批的统计量稳住每层输入

§3 说过,梯度能稳稳穿过很多层,靠的是每层那个连乘因子 r ≈ 1。 好初始化只能保证开局时尺度合适;训练一开始,前面层的权重每步都在变,后面层收到的输入尺度也跟着漂—— 可能越来越大,把激活推进饱和区;也可能越缩越小。网络越深,这种漂移层层叠加得越明显。

既然上一节看到每一步手里都有一整批样本, 就可以顺手用这批样本的统计量,在每层把数值重新拉回“均值 0、方差 1”——这就是 批归一化(BatchNorm,BN)。它通常夹在“线性变换(或卷积)”和“激活函数”之间: net → BN → 激活。

类比:按全班成绩换算“标准分”

这次卷子出难了,全班都考 40 来分;下次出简单了,全班都 90 多。只看原始分,很难判断谁学得好。 老师的办法是换算标准分:减去本班平均分、再除以本班标准差——不管卷子难易,分数都落在同一把尺子上。 BN 对每个神经元做的就是这件事,“本班”就是当前这个 minibatch。

对某一个神经元,设这一批 B 个样本在它这里的输入是 x1 … xB:

μB = 1B Σ xi
σB2 = 1B Σ (xi − μB)2
yi = γ · xi − μB√(σB2 + ε) + β μB、σB2 是这一批在该神经元上的均值和方差;ε 防止除以 0;γ、β 每个神经元一对,和权重一起训练

关键是统计的方向。把一批数据排成“样本 × 特征”的表,BN 是竖着算的: 同一个特征(同一个神经元),跨这一批所有样本求均值和方差。

这一批特征 1特征 2特征 3
样本 1120.35
样本 214−0.13
样本 3140.54
样本 4160.16

BN 拿高亮的这一列(特征 1 在 4 个样本上的值)算统计量,每一列各算各的;第 18 章的 LayerNorm 则是横着拿一行(一个样本自己的所有特征)来算。

把特征 1 这一列手算一遍(先设 γ=1、β=0,忽略极小的 ε):

步骤算式结果
① 批均值 μB(12 + 14 + 14 + 16) / 414
② 批方差 σB2[(−2)² + 0² + 0² + 2²] / 42
③ 标准差√2≈ 1.41
④ 标准化(x − 14) / 1.41[−1.41, 0, 0, 1.41]

原来整体偏在 14 附近的一列数,被拉回以 0 为中心、方差为 1 的范围,后面的激活函数就不会一直工作在同一个偏离的区间里。

那 γ、β 干什么用?强行把每层都压成“均值 0、方差 1”未必最好,有时网络就需要偏大的均值或更宽的分布。 有了这两个可学习参数,网络可以自己决定要不要“撤销”归一化:比如把 γ 学成 √2、β 学成 14, √2 × [−1.41, 0, 0, 1.41] + 14 就又回到了 [12, 14, 14, 16]。顺带一提,β 已经承担了“平移”的作用, 所以紧挨在 BN 前面的那层通常不再需要自己的偏置——第 25 章的 AlphaZero 网络里,所有卷积都设了 use_bias=false,就是这个原因。

为什么这一招管用?

原论文把它的作用解释为减少“内部协变量偏移”(internal covariate shift:前面层一更新,后面层的输入分布就跟着变); 后来的研究认为,更主要的原因可能是它让损失曲面更平滑、梯度更可预测。解释还有争论,效果却是公认的。

训练和推理,BN 的行为不一样

推理时往往一次只来一个样本,根本凑不出“一批”来算均值方差;就算凑得出,一张图的预测结果也不该取决于它和谁分在一批。 所以训练时 BN 会顺手记一份滑动平均(running mean / running variance),常见写法是 running_μ ← 0.9 × running_μ + 0.1 × μ_B;推理时改用这份固定的统计量,不再看当前 batch。

这带来两个常见的坑:一是忘了切到推理模式,预测结果会随 batch 组成而飘;二是复制或保存模型时只复制了可训练参数,漏了 running 统计量—— 第 25 章的 AlphaZero 训练就真踩过这个坑,并行线程里的网络副本输出整体漂移。 另外 batch 太小(比如只有 2、4 个样本)时,批统计量本身噪声很大,BN 的效果会明显变差。

对比项BatchNormLayerNorm
统计方向同一特征,跨一批样本(竖着)同一样本,跨它的所有特征(横着)
依赖 batch 大小依赖;batch 太小统计不准不依赖
训练 / 推理不一致:推理改用 running 统计量一致:始终只看自己
典型用途CNN、图像;第 25 章的 ResNet 每个卷积后都跟一个 BNTransformer、序列;第 18 章的每个 block 里都有

在卷积网络里,BN 按通道统计:同一通道在整批所有图片、所有像素位置上的值一起算均值方差,每个通道一对 γ/β。

本仓库的 MLP 为什么没有 BN

NeuralNetwork 里没有实现 BatchNorm:MNIST 那种三层小网络,配 He 初始化 + Adam + warmup 已经足够稳。 BN 真正的主场是很深的卷积网络;想看它在真实 C++ 代码里怎么用、running 统计量怎么存盘,去读第 25 章。

8. 把它们拼成一个“现代训练循环”

训练栈第 0 步:先把数据摆正

再漂亮的初始化/优化器,也救不了没归一化的输入。MNIST 像素若是 0~255 直接喂进去, 大数值会让梯度失控。惯例是除以 255 压到 [0,1](第 11 章)。 先确认数据尺度合理,再调本章旋钮。

零件齐了,配置一个网络大概长这样(概念上和 PyTorch / JAX 训练脚本一一对应):

现代训练栈的典型配置(C++ 接口)
net.set_random_seed(42);                  // 可复现
net.set_param_init_function(PARAM_INIT_HE);     // 好的初始化
net.set_activate_function(ACTIVATE_GELU);       // 平滑激活
net.set_optimizer_function(OPTIMIZER_ADAMW);    // 自适应 + 解耦正则
net.optimizer_function()->set_weight_decay(1e-4);
net.set_gradient_clip_norm(1.0);                // 防爆炸
net.set_lr_scheduler(
    std::make_shared<WarmupCosineLR>(1e-3, 500, 10000));  // 热身+退火
net.Train(data, target, callback, 10000, 64);
  1. 上面每一行都对应本章某一节;读懂它们,读任何框架的训练脚本都不再是死记参数名。
  2. 最后一行 Train(..., 10000, 64) 里,10000 是总 step 数,64 是 batch size——和第 8 章 mini-batch是同一回事。

9. 训练不稳?先拧本章这几个旋钮

真上手训练十有八九会“不对劲”。完整排查见第 11 章清单; 下面这张迷你表只列本章能直接动的开关,按顺序试往往就够:

你看到的现象优先试什么对应本章
损失 NaN / 突然发散 学习率 ÷10;开 set_gradient_clip_norm;加长 warmup §4 裁剪 · §5 调度
损失几乎不降 查数据是否归一化;换 He 初始化;学习率 ×2 或 ×5 试 §1 初始化 · §8 数据尺度
开头几步就炸 加 warmup;clip 设 1.0;确认没用全 0 初始化 §1 · §4 · §5
后期 loss 震荡不降 换 cosine 退火;略降 base_lr §5 调度
网络一加深就训不动、收敛很慢 在线性/卷积层和激活之间加 BatchNorm(batch 别太小);或加残差连接 §7 批归一化
训练降、验证不降 多半是过拟合 → 去第 10 章加正则,不是本章主战场 —
黄金第一步:先过拟合 10 条样本

拿 10 条数据让模型死记到损失≈0。如果连这都做不到,先怀疑代码/数据管道, 而不是急着调 scheduler。第 11 章也强调这一招。

小结

  • 初始化:不能全 0;He 配 ReLU 系,Xavier 配 Sigmoid/Tanh;set_random_seed 保证可复现。
  • 优化器:演进见第 8 章;训练栈默认 AdamW + weight_decay。
  • 梯度病:消失靠好 init/激活;爆炸靠 clip + warmup + 归一化。
  • 学习率调度:一族调度器,warmup+cosine 是 MNIST / 大模型的常见默认。
  • 一步训练:打乱 → GetLR → 前向 → 反向 → (clip) → 优化器更新;§9 表列了不稳时先拧的旋钮。
  • 批归一化:用当前 batch 的均值、方差把每层输入拉回稳定尺度,γ/β 可学;推理时改用 running 统计量;序列模型改用 LayerNorm。

动手与思考

问题 1:为什么把所有权重初始化为 0 是个坏主意?

会导致同一层所有神经元完全对称:输入、输出、梯度都一样,更新后仍然一样,等于整层只有一个神经元在学。必须用随机初始化打破这种对称性。

问题 2:梯度爆炸和梯度消失,分别优先用本章哪几招?

爆炸:梯度裁剪、warmup、调小学习率、检查输入归一化。消失:合适的 He/Xavier 初始化、选不易饱和的激活(ReLU/GELU),深层结构还可靠残差连接——裁剪对消失帮助不大。

问题 3:Train 里 scheduler、clip、优化器分别在什么时机介入?

每步开头 GetLR 更新学习率;前向/反向算完梯度后、ApplyGradient 前做裁剪;最后由优化器的 BeforeStep + CalcChangeValue 真正改参数。初始化只在建网时做一次。

问题 4:BatchNorm 训练时用当前 batch 的均值和方差,推理时为什么不能照做?它改用什么?

推理时常常一次只有一个样本,凑不出批统计量;而且一个样本的预测不该取决于它和谁分在一批。所以训练时 BN 顺手维护 running mean / running variance 这份滑动平均,推理时改用它。复制或保存模型时也要带上这份统计量,否则输出会整体漂移。

训练能跑稳了,但“训练集上表现很好、没见过的数据上却拉垮”怎么办? 下一章我们讲正则化与泛化:过拟合到底是什么,以及 weight decay、Dropout、early stopping 这些防过拟合的常用招。