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

让它真的训得动

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

读完这一章,你会明白

  • He / Xavier 初始化各解决什么、配什么激活,以及为什么要设随机种子;
  • 梯度消失爆炸分别是什么、本章哪些零件在对症下药;
  • 梯度裁剪的两种模式、学习率调度器家族,以及 warmup + cosine 为什么常用;
  • 一个 minibatch 在 Train 里逐步发生什么,以及训练不稳时先拧哪几个旋钮。

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

Lw前面某层 = δ末层 · (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)
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=0cos0=1,lr 还等于 base_lr(刚过峰值,先缓降、多学一会); progress=0.5cos(π/2)=0,lr 正好落在 base_lrmin_lr正中间(下降最快); progress=1cos(π)=−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_stepst_max 都是从 batch 大小和训练轮数推出来的,只有 base_lrmin_lr 需要你挑。

为了让算术清爽,下面换一组整数示例走一遍(规律和上面的真实取值完全一样,只是把数字凑圆好心算):base_lr=0.1warmup_steps=100(总步数的 10%)、t_max=1000min_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. 把它们拼成一个“现代训练循环”

训练栈第 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是同一回事。

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

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

你看到的现象优先试什么对应本章
损失 NaN / 突然发散 学习率 ÷10;开 set_gradient_clip_norm;加长 warmup §4 裁剪 · §5 调度
损失几乎不降 查数据是否归一化;换 He 初始化;学习率 ×2 或 ×5 试 §1 初始化 · §7 数据尺度
开头几步就炸 加 warmup;clip 设 1.0;确认没用全 0 初始化 §1 · §4 · §5
后期 loss 震荡不降 换 cosine 退火;略降 base_lr §5 调度
训练降、验证不降 多半是过拟合 → 去第 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) → 优化器更新;§8 表列了不稳时先拧的旋钮。

动手与思考

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

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

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

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

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

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

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