第 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 是本层神经元个数。输入越多,随机范围越小,避免一层求和后数值过大。
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
- 按
fan_in缩放随机范围——这是“别让信号一层层爆炸/消失”的核心。 - 在 [−limit, +limit] 上均匀随机,每个权重都不一样,打破对称性。
- 偏置也用同一分布随机化。配 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 章)。
net.set_optimizer_function(OPTIMIZER_ADAMW); 1
net.optimizer_function()->set_weight_decay(1e-4); 2
- 一行换优化器——和激活函数一样是可插拔策略(第 8 章)。
weight_decay只作用在权重上,bias 不衰减;AdamW 把衰减独立加在更新量末尾,而不是混进梯度里被 √v 削弱。
自适应步长照 Adam 算;差别是 weight decay 单独叠上去。完整推导见 第 8 章 AdamW 小节:
if (weight_pos != -1 && weight_decay_ != 0.0)
adaptive += learning_rate * weight_decay_ * param_value;
3. 梯度消失与梯度爆炸
这是深层网络最有名的两个“训不动”故障。它俩其实同根同源——都出自反向传播那一长串乘法,只是一个越乘越小、一个越乘越大。 先把根源讲透,后面为什么要初始化、裁剪、热身就全顺理成章了。
根源:反传其实是「一长串数」在连乘
回忆第 6 章的 δ 递推:误差每往回穿过一层,就要乘一次 “这一层的权重 × 这一层激活函数的导数”。所以当它传到很前面的某个权重时,那个梯度长这样:
一串数连乘,结果几乎只由“每个因子的平均大小 r”决定,而且是指数级的:穿过 L 层就约等于 rL。 于是只有三种命运:
- 因子平均 r < 1 → rL 迅速趋近 0 → 梯度消失
- 因子平均 r > 1 → rL 迅速冲上天 → 梯度爆炸
- 因子平均 r ≈ 1 → 梯度能稳稳传过几十层(这正是好初始化、残差、归一化拼命想维持的状态)
每层乘 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.8 | 0.33 | 0.11 | ≈1e−3 | 慢慢消失 |
| 1.0 | 1.0 | 1.0 | 1.0 | 刚好稳定 |
| 1.1 | 1.6 | 2.6 | ≈17 | 温和放大 |
| 1.5 | 7.6 | 58 | ≈1.9e5 | 梯度爆炸 |
同样“每层只差一点点”,乘到 30 层就是天壤之别——这就是深网络对初始化和尺度格外敏感的原因。
套到具体元凶上:谁把 r 推离了 1
有了“因子连乘”这个根,常见诱因就都能归成一句话——它们要么把 r 压到小于 1,要么把 r 顶到大于 1:
-
梯度消失(r < 1,越传越小)
- 激活函数饱和:Sigmoid 的导数最大也只有 0.25,两端更趋近 0;Tanh 类似。于是每层先天带一个 ≤0.25 的乘数,层一多必然归零。(第 7 章:斜率≈0 就断流)
- 权重初始化太小:w 本身就小于 1,再和小小的 f′ 相乘,因子雪上加霜。
- 网络太深:哪怕每层因子有 0.9,30 层后也只剩约 4%——深度本身就是放大器。
- 怎么救:好初始化(把因子拉回 ≈1,见本章 §1 的 Xavier/He)、换 ReLU/GELU(正区间导数=1,不衰减)、残差连接(给梯度留一条 +1 的直通车道,第 18 章)。
- 梯度爆炸(r > 1,越传越大)
初始化管“起跑姿势”,激活函数管“坡度”,裁剪和 warmup 管“别一脚踩空”。几样配合,训练才稳。
4. 防止训练“踩空”:梯度裁剪
上一节说的梯度爆炸,实战里最常见的急救手段是梯度裁剪: 给梯度设一个上限,防止某一步把参数推飞。本仓库支持两种模式,可以单独开,也可以先按值、再按范数连着用:
| 模式 | 做法 | 直觉 | 接口 |
|---|---|---|---|
| 按全局范数 | 把所有梯度拼成一个大向量,若 L2 长度超阈值,整体等比缩小 | 方向不变,只把“这一脚”收短;Transformer / RNN 最常用 | set_gradient_clip_norm(1.0) |
| 按分量 | 每个梯度分量单独 clamp 到 [−thresh, +thresh] | 更“硬”,适合个别分量偶发尖峰 | set_gradient_clip_value(0.5) |
double norm = std::sqrt(sq_sum); 1
if (norm > grad_clip_norm_ && norm > 0.0) {
double scale = grad_clip_norm_ / norm; 2
// 所有梯度 *= scale, 整体缩回阈值以内
}
- 先把累加梯度除以 batch 大小得到平均梯度,再算全局 L2 范数。
- 超阈值就整体乘以
scale——方向不变,只是变短。Train里在ApplyGradient之前自动调用。
5. 学习率不必一成不变:调度器
第 5 章我们用固定学习率,但更聪明的做法是让它随训练进程变化。
仓库里 lr_scheduler/ 提供一整族调度器,都和 Train 配合:每步开头调 GetLR(step) 更新当前学习率。
| 调度器 | 曲线形状 | 什么时候用 |
|---|---|---|
StepDecayLR | 每隔固定步数乘 γ 阶梯下降 | 经典 CV、想“训一段降一档” |
ExponentialDecayLR | 每步按指数衰减 | 想快速降下来 |
CosineAnnealingLR | 余弦平滑降到 min_lr | 后期小步精修 |
WarmupCosineLR | 先线性热身,再余弦退火 | 默认推荐;MNIST demo、大模型预训练常见 |
这里的 step 指一次参数更新——即“取一个 minibatch → 前向 → 反向 → 更新一次权重”算 1 步,
不是一个 epoch。把整份数据过一遍(一个 epoch)通常包含很多步。下面所有“第几步”都是这个意思。
下面展开本仓库和 MNIST demo 最常用的 warmup + cosine。它把整个训练分成前后两段,
分界点就是一个你自己设的步数 warmup_steps:
- ① 热身(warmup),第 0 → warmup_steps 步:学习率从接近 0 线性升到峰值
base_lr。 为什么不一上来就用大步子?因为刚开始参数还是随机的、梯度很乱,一步迈太大容易直接把训练搞崩(loss 飞到 NaN)。先小步“热热身”,等方向稳了再加速。 - ② 退火(cosine),warmup_steps → t_max 步:学习率从峰值
base_lr沿余弦曲线平滑降到min_lr。 越接近终点步子越小,像快到谷底时收油门,免得在最低点附近反复横跳、稳稳落进谷里。
所以“什么时候开始退火”答案很干脆:第 warmup_steps 步。在这一步之前是热身(升),到达这一步时学习率正好冲到峰值 base_lr,从这一步往后就一路余弦下降,直到第 t_max 步落到 min_lr。
黄线=热身(线性升到峰值 base_lr),黄色竖虚线处(第 warmup_steps 步)开始退火,蓝线=余弦平滑降到 min_lr。
两段各用一个公式,其实就是把上图翻译成算式(base_lr=峰值学习率,min_lr=最低学习率,t_max=总步数):
- 热身段(
step < warmup_steps):lr = base_lr × (step+1) / warmup_steps。就是一条直线,step 越大 lr 越大,到warmup_steps时正好等于base_lr。 - 退火段(
step ≥ warmup_steps):先算“退火走了几成”progress =(step − warmup_steps) /(t_max − warmup_steps)(从 0 走到 1),再lr = min_lr + ½ ×(base_lr − min_lr)×(1 + cos(π × progress))。
余弦这段之所以是“两头慢、中间快”: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 的真实取值:
-
base_lr— 峰值,其实就是主学习率:就是你平时给优化器设的那个 lr,也是这四个里唯一真正要“调”的旋钮。拿不准先用优化器的典型值(Adam ≈ 1e-3、SGD ≈ 0.1),或做一次 LR range test,挑“最大但还没发散”的那个。
仓库里:MNIST 用1e-3(续训微调降到1e-4);transformer 用命令行--learning-rate。 -
warmup_steps— 用多少步爬到峰值:取总步数的一小截,常见 3%~10%,或干脆“1 个 epoch”。
仓库里:MNIST =steps_per_epoch(1 个 epoch = 938 步);transformer =epoch_num / 20(≈5%)。 -
t_max— 退火铺满多少步(退火终点):一般 = 整个训练的总步数,让 lr 正好在训练结束时降到底,不多不少。
仓库里:MNIST =epochs × steps_per_epoch= 5 × 938 = 4690;transformer =epoch_num。 -
min_lr— 退火终点的最低学习率:取base_lr的一个小比例,常见 ×0.1 或 ×0.01(也有人直接设 0)。
仓库里:MNIST =base_lr × 0.01= 1e-5;transformer =base_lr × 0.1。
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.001 | 0.1 × 1/100,几乎从 0 起步 |
| 50 | 热身·爬升中 | 0.051 | 0.1 × 51/100,直线往上 |
| 100 | 热身结束 = 退火起点 | 0.100 | 冲到峰值 base_lr,从这步开始退火 |
| 325 | 退火·前段 | ≈ 0.087 | progress=0.25,刚过峰值先缓降 |
| 550 | 退火·中点 | 0.055 | progress=0.5,正好是两者中间值 |
| 775 | 退火·后段 | ≈ 0.023 | progress=0.75,越降越慢 |
| 1000 | 退火终点 = t_max | 0.010 | progress=1,落到 min_lr,不再降 |
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
- 热身段:前
warmup_steps_步,学习率从接近 0 线性升到base_lr_。 - 进度:热身结束后,算出“退火走了百分之多少”。
- 退火段:用余弦把学习率从
base_lr_平滑降到min_lr_。训练时每一步由Train自动调用。
6. 一个 minibatch 里发生什么
第 5 章看过 Train 的骨架;加上本章零件后,每一步其实是下面这一圈。
初始化只在建网时做一次;其余都在循环里转:
黄框是本章新增的两步;蓝框是第 3–6 章学过的前向/反向;绿框是第 8 章优化器落地。
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
7. 把它们拼成一个“现代训练循环”
再漂亮的初始化/优化器,也救不了没归一化的输入。MNIST 像素若是 0~255 直接喂进去,
大数值会让梯度失控。惯例是除以 255 压到 [0,1](第 11 章)。
先确认数据尺度合理,再调本章旋钮。
零件齐了,配置一个网络大概长这样(概念上和 PyTorch / JAX 训练脚本一一对应):
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);
- 上面每一行都对应本章某一节;读懂它们,读任何框架的训练脚本都不再是死记参数名。
- 最后一行
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 条数据让模型死记到损失≈0。如果连这都做不到,先怀疑代码/数据管道, 而不是急着调 scheduler。第 11 章也强调这一招。
小结
动手与思考
问题 1:为什么把所有权重初始化为 0 是个坏主意?
会导致同一层所有神经元完全对称:输入、输出、梯度都一样,更新后仍然一样,等于整层只有一个神经元在学。必须用随机初始化打破这种对称性。
问题 2:梯度爆炸和梯度消失,分别优先用本章哪几招?
爆炸:梯度裁剪、warmup、调小学习率、检查输入归一化。消失:合适的 He/Xavier 初始化、选不易饱和的激活(ReLU/GELU),深层结构还可靠残差连接——裁剪对消失帮助不大。
问题 3:Train 里 scheduler、clip、优化器分别在什么时机介入?
每步开头 GetLR 更新学习率;前向/反向算完梯度后、ApplyGradient 前做裁剪;最后由优化器的 BeforeStep + CalcChangeValue 真正改参数。初始化只在建网时做一次。
训练能跑稳了,但“训练集上表现很好、没见过的数据上却拉垮”怎么办? 下一章我们讲正则化与泛化:过拟合到底是什么,以及 weight decay、Dropout、early stopping 这些防过拟合的常用招。