第 20 课:学习率调度与收尾 —— warmup、cosine decay 与全项目总结
第 20 课:学习率调度与收尾 —— warmup、cosine decay 与全项目总结
代码位置:src/train.rs(
LRScheduler、train_gpt) 调用处:src/main.rs(demo_gpt里的训练配置) 配套代码:src/optim.rs(AdamW,第 17 课)
1. 本课要搞懂的问题
- 学习率为什么不能"一步到位"?固定 lr 训练会出什么问题?
- warmup 是干什么的?"前 50 步 lr 从 0 线性升到 max" 在代码里怎么写的?
- cosine decay 长什么样?为什么末期要用越来越小的学习率?
LRScheduler的lr()/step()到底怎么算、怎么用?- 训练日志里的
lr 0.002947 → … → 0.000300是怎么一步步算出来的? - 21 课全部学完,我手里有什么?下一步该往哪走?
2. 为什么需要学习率调度
第 6 课我们学过梯度下降:θ = θ - lr·g,学习率 lr 决定每一步走多大。那"全程用一个固定的 lr"行不行?看看两种极端:
| 策略 | 优点 | 缺点 |
|---|---|---|
| 固定大 lr(如 0.01) | 前期下降快 | 后期在最优解附近来回震荡,loss 下不去,甚至发散 |
| 固定小 lr(如 0.0001) | 后期能精细收敛 | 前期爬得太慢,训练时间翻几倍 |
| 动态 lr(本课) | 前期稳、中期快、后期细 | 多两个超参数要调(warmup 步数、总步数) |
理想的 lr 轨迹是一条"倒 U 形":
lr
│ ╭────────────────╮
│ ╱ ╲
│ ╱ ╲
│ ╱────╱ ╲
│ ╱ ╲
└──────────────────────────────────▶ step
0 warmup 结束 训练结束
(lr 达到 max) (lr 降到 min)
- 初期:lr 从很小开始(warmup),保护刚起步、还很脆弱的参数;
- 中期:lr 维持在最大值附近,快速下降;
- 后期:lr 逐渐变小(cosine decay),在损失曲面的"沟底"精细收敛,不会跳过最优解。
这就是本课 LRScheduler 做的事情:先 warmup 线性爬升,再 cosine 曲线衰减。
3. Warmup:先热个身
3.1 公式
warmup 阶段(step < warmup_steps),学习率从 0 线性升到最大值:
lr(step) = max_lr × (step + 1) / warmup_steps
注意分子里的 step + 1:step 从 0 开始数,step + 1 保证第 0 步的 lr 是 max_lr / warmup_steps(而不是 0),
到第 warmup_steps - 1 步时 lr 正好是 max_lr × warmup_steps / warmup_steps = max_lr。
3.2 为什么训练初期要用小学习率
| 原因 | 解释 |
|---|---|
| 初始参数是随机的 | Linear::new 用均匀分布初始化,此时梯度方向"噪声很大",大步子容易把参数推飞 |
| AdamW 的二阶动量还没"热" | 第 17 课:AdamW 的 v 从 0 开始,前几步偏差修正后 √v̂ 很小,有效步长会被放大——初期 Adam 反而更激进,需要小 lr 压住 |
| 大模型训练的标配 | GPT-3、LLaMA 等真实大模型训练都用 warmup,是公认的稳定性技巧 |
直觉:刚上冰面的新手滑冰,先小步慢走找平衡(warmup),站稳了再加速(大 lr)。
4. Cosine decay:按余弦曲线收尾
4.1 公式
warmup 结束后,学习率按半个余弦周期从 max_lr 衰减到 min_lr:
progress = (step - warmup_steps) / (total_steps - warmup_steps) // 0 → 1
cosine = 0.5 × (1 + cos(π × progress)) // 1 → 0
lr(step) = min_lr + (max_lr - min_lr) × cosine
4.2 关键点
| progress | cos(π·progress) | cosine 系数 | lr 取值 |
|---|---|---|---|
| 0(刚出 warmup) | cos 0 = 1 | 1 | max_lr |
| 0.25 | cos(π/4) ≈ 0.707 | 0.854 | min + 0.854·(max-min) |
| 0.5(半程) | cos(π/2) = 0 | 0.5 | (max_lr + min_lr) / 2 |
| 0.75 | cos(3π/4) ≈ -0.707 | 0.146 | min + 0.146·(max-min) |
| 1(训练结束) | cos π = -1 | 0 | min_lr |
余弦曲线在两端(progress ≈ 0 和 ≈ 1)斜率平缓、在中间(progress ≈ 0.5)斜率最陡,翻译成 lr 就是:
- 刚离开 warmup 时 lr 在 max 附近"多停留一会儿"(给足中期快速下降的时间);
- 训练后期 lr 平缓地逼近
min_lr(在沟底精细搜索,不震荡)。
为什么末期要小 lr?损失曲面在高维空间里像"沟壑",大 lr 会在沟底来回弹跳;小 lr 才能贴着沟底慢慢走到最低点。 而且末期 loss 下降本来就慢,用小 lr 不会拖慢多少,却能让最终 loss 更漂亮。
我们的代码里 min_lr 取的是 max_lr × 0.1(见 5.3 节),即最终学习率衰减到最大值的十分之一。
5. LRScheduler 实现讲解
5.1 结构体
src/train.rs 第 20 课小节:
/// 学习率调度器:warmup + cosine decay
pub struct LRScheduler {
warmup_steps: usize,
total_steps: usize,
max_lr: f32,
min_lr: f32,
step: usize,
}
impl LRScheduler {
pub fn new(warmup_steps: usize, total_steps: usize, max_lr: f32, min_lr: f32) -> Self {
LRScheduler {
warmup_steps,
total_steps,
max_lr,
min_lr,
step: 0,
}
}
// ...
}
| 字段 | 含义 |
|---|---|
warmup_steps |
warmup 持续多少步(前 N 步线性爬升) |
total_steps |
训练总步数(cosine 衰减的终点) |
max_lr |
学习率峰值 |
min_lr |
学习率下限(衰减终点) |
step |
当前已走到的步数计数(内部状态,从 0 开始) |
5.2 lr():按阶段算当前学习率
pub fn lr(&self) -> f32 {
if self.step < self.warmup_steps {
// 线性 warmup
self.max_lr * (self.step as f32 + 1.0) / self.warmup_steps.max(1) as f32
} else {
// cosine 衰减:从 max_lr 平滑降到 min_lr
let progress = (self.step - self.warmup_steps) as f32
/ (self.total_steps - self.warmup_steps).max(1) as f32;
let progress = progress.min(1.0);
let cosine = 0.5 * (1.0 + (std::f32::consts::PI * progress).cos());
self.min_lr + (self.max_lr - self.min_lr) * cosine
}
}
公式 ↔ 代码对照表:
| 公式 | Rust 代码 | 说明 |
|---|---|---|
lr = max_lr·(step+1)/warmup_steps |
self.max_lr * (self.step as f32 + 1.0) / self.warmup_steps.max(1) as f32 |
warmup 分支。.max(1) 防止 warmup_steps = 0 时除零 |
progress = (step-warmup)/(total-warmup) |
(self.step - self.warmup_steps) as f32 / (self.total_steps - self.warmup_steps).max(1) as f32 |
cosine 分支的进度,范围 [0,1] |
progress = min(progress, 1) |
let progress = progress.min(1.0); |
万一总步数被改小、step 超过 total_steps,progress 封顶 1,lr 停在 min_lr 不再下降 |
cosine = 0.5(1+cos(π·progress)) |
0.5 * (1.0 + (std::f32::consts::PI * progress).cos()) |
半个余弦周期,从 1 平滑到 0 |
lr = min_lr + (max_lr-min_lr)·cosine |
self.min_lr + (self.max_lr - self.min_lr) * cosine |
在 [min_lr, max_lr] 之间插值 |
注意分支边界:当
step == warmup_steps时走 cosine 分支,此时progress = 0、cosine = 1,lr 恰好等于max_lr—— warmup 的终点和 cosine 的起点无缝衔接,lr 曲线没有断点。
5.3 step() 与在训练循环里的用法
pub fn step(&mut self) {
self.step += 1;
}
step() 每训练一步调用一次,把内部计数 +1,从而推动 lr() 前进。在 train_gpt 里和 AdamW 配合:
// src/train.rs train_gpt
let mut opt = AdamW::new(cfg.max_lr, params.clone(), cfg.weight_decay);
let mut scheduler = LRScheduler::new(cfg.warmup_steps, cfg.steps, cfg.max_lr, cfg.min_lr);
// ...
for step in start_step..cfg.steps {
// ... 前向、反向、梯度裁剪 ...
let cur_lr = scheduler.lr(); // 先取当前步的学习率(scheduler.step() 之后会变成下一步的)
opt.lr = cur_lr; // 把"当前步的学习率"写进优化器(AdamW.lr 是 pub 字段)
opt.step(); // 优化器用这个 lr 更新参数
opt.zero_grad();
scheduler.step(); // 步数 +1,为下一步准备新的 lr
// ...
}
| 代码 | 作用 |
|---|---|
AdamW::new(cfg.max_lr, params.clone(), cfg.weight_decay) |
优化器初始 lr 就是 max_lr,权重衰减来自 TrainConfig |
LRScheduler::new(cfg.warmup_steps, cfg.steps, cfg.max_lr, cfg.min_lr) |
min_lr 来自 TrainConfig(demo 里取 max_lr × 0.1 = 3e-4) |
let cur_lr = scheduler.lr(); opt.lr = cur_lr; |
每步先取当前步的 lr 再塞给优化器——AdamW.lr 是 pub 字段,可以直接赋值 |
scheduler.step() |
计数器前进,下一轮循环 lr() 就是新的学习率 |
配合逻辑一句话:调度器负责"算 lr",优化器负责"用 lr 更新参数",两者通过 opt.lr = scheduler.lr() 对接。
6. 训练日志解读:lr 0.002947 → 0.000300
6.1 我们的训练配置
src/main.rs 的 demo_gpt 用 TrainConfig 传参(演示 3):
let tcfg = config::TrainConfig {
seed: 42,
batch_size: 8,
steps: 600,
max_lr: 3e-3,
warmup_steps: 50,
eval_every: 100,
..config::TrainConfig::default()
};
train::train_gpt(&model, &tokenizer, &loader, &tcfg, None, None, &mut rng);
| 参数 | 值 | 含义 |
|---|---|---|
steps |
600 | 训练总步数 |
batch_size |
8 | 每步 8 个样本 |
max_lr |
3e-3 = 0.003 |
学习率峰值 |
warmup_steps |
50 | 前 50 步线性爬升 |
eval_every |
100 | 每 100 步打印一次日志 |
min_lr(默认) |
3e-4 |
TrainConfig::default() 提供,恰好 = max_lr × 0.1 |
于是 train_gpt 内部:min_lr = max_lr × 0.1 = 0.0003,调度器为 LRScheduler::new(50, 600, 0.003, 0.0003)。
6.2 日志打印的时机(避免看错)
train_gpt 里 cur_lr 在 scheduler.step() 之前取值并用于更新:
let cur_lr = scheduler.lr(); // 取"本步"的学习率(scheduler 还没 +1)
opt.lr = cur_lr;
opt.step();
opt.zero_grad();
scheduler.step(); // 之后才 +1(为下一步准备计数)
let last = step + 1 == cfg.steps;
if (step + 1) % cfg.eval_every == 0 || last {
println!(
"step {:>5} | lr {:.6} | train_loss {:.4}",
step + 1, // 日志从 1 开始数(显示 100、200、…)
cur_lr, // 打印的是"本步"的 lr
loss.item()
);
}
注意两个细节:一是日志里的 step 是 step + 1(循环变量从 0 开始,打印时 +1,显示 100、200、…);
二是 scheduler.step() 虽然在打印之前执行,但打印的 cur_lr 是本步开头就取好的——所以日志里的 lr 就是"本步实际用于参数更新的学习率",不存在错位(旧的实现把打印放在 scheduler.step() 之后、显示的是下一步的 lr,本轮修复后已对齐)。
6.3 三个关键值怎么来的
① 第一个打印点 0.002947(step 100)
demo 配置 eval_every = 100,所以第 100 次迭代(循环变量 step=99,scheduler 计数=99)才第一次打印:
progress = (99 - 50) / (600 - 50) = 49/550 ≈ 0.089
cosine = 0.5 × (1 + cos(π × 0.089)) ≈ 0.9805
lr = min_lr + (max_lr - min_lr) × cosine = 0.0003 + 0.0027 × 0.9805 ≈ 0.002947 ✓
② warmup 段为什么看不到
eval_every = 100 > warmup_steps = 50,warmup 段(step 0-49)没有任何打印点,所以日志里看不到 0.00006 起步的线性爬升。把 eval_every 改成 10 就能看到:step 10/20/30/40 的 lr 依次是 0.0006 → 0.0012 → 0.0018 → 0.0024(每步增加 max_lr / warmup_steps = 0.003 / 50 = 0.00006,10 步就是 0.0006),然后平滑进入 cosine 段。
③ 最后 0.000300(step 600)
最后一步(循环变量 step=599,计数=599)progress 封顶到 1,cosine = 0.5 × (1 + cos π) = 0:
lr = min_lr + (max_lr - min_lr) × 0 = min_lr = 0.0003 ✓
6.4 真实日志序列(cargo run --release -- demo 原样输出)
| 日志 step | scheduler 计数 | 阶段 | lr(打印值) | 计算过程 |
|---|---|---|---|---|
| 100 | 99 | cosine | 0.002947 | progress = 49/550 ≈ 0.089,lr ≈ 0.00295 |
| 200 | 199 | cosine | 0.002540 | progress = 149/550 ≈ 0.271,lr ≈ 0.00254 |
| 300 | 299 | cosine | 0.001850 | progress = 249/550 ≈ 0.453,lr ≈ 0.00185 |
| 400 | 399 | cosine | 0.001096 | progress = 349/550 ≈ 0.635,lr ≈ 0.00110 |
| 500 | 499 | cosine | 0.000518 | progress = 449/550 ≈ 0.816,lr ≈ 0.00052 |
| 600 | 599 | cosine 末 | 0.000300 | progress ≈ 0.998(接近 1),落到 min_lr |
这条曲线正是第 4.2 节那张表:先平、中陡、末缓。对比 loss 日志:前期 loss 快速下降(lr 大), 后期 loss 缓慢逼近平台(lr 小、精细收敛)——这就是"调度"在训练曲线上留下的痕迹。
小技巧:把
eval_every改成 10,就能看到 warmup 段每 10 步 +0.0006 的线性爬升, 以及 cosine 段更密的 lr 变化——对照 6.3 的公式验证每一步。
7. 全项目 21 课总结回顾
对照 docs/00-学习计划.md 的六个阶段,我们把"从零写一个 LLM"拆成了 21 课。回顾一下每阶段的成果:
阶段一:地基 —— 张量与自动微分(第 1-4 课)
| 课 | 主题 | 成果 |
|---|---|---|
| 01 | 张量 Tensor | Tensor{data, shape},行优先存储、逐元素运算、矩阵乘法(src/tensor.rs) |
| 02 | 自动微分 Autograd | 计算图 + backward,链式法则自动求梯度 |
| 03 | 张量运算扩展 | 广播、sum/mean、softmax、广播减法 |
| 04 | 模块化重构 | 张量库独立成模块,为上层打基础 |
里程碑:只靠标准库就实现了深度学习框架的"张量 + 自动微分"核心,cargo test 一路绿灯。
阶段二:神经网络基础(第 5-7 课)
| 课 | 主题 | 成果 |
|---|---|---|
| 05 | 线性层与激活函数 | Linear、ReLU/Sigmoid/Tanh,参数管理与初始化(src/layers.rs) |
| 06 | 损失函数与优化器 | 交叉熵、MSE、SGD(src/loss.rs、src/optim.rs) |
| 07 | 第一个 MLP | 2→4→2 的 MLP 100% 学会 XOR(demo_xor),验证了反向传播正确 |
里程碑:一个从零手写的神经网络成功收敛,证明自动微分没写错。
阶段三:分词器(第 8 课)
| 课 | 主题 | 成果 |
|---|---|---|
| 08 | BPE 分词器 | 字节级 BPE:词表 256 起步、迭代合并 400 次,"the garden" 被压缩成高频子词(src/tokenizer.rs) |
里程碑:文本 → token id 的通路打通,CharTokenizer 为小语料演示服务。
阶段四:Transformer 架构(第 9-12 课)
| 课 | 主题 | 成果 |
|---|---|---|
| 09 | 注意力机制 | 缩放点积注意力 softmax(Q·Kᵀ/√d_k)、因果掩码 |
| 10 | 多头注意力 | MultiHeadAttention:拆头、并行打分、拼接投影(src/attention.rs) |
| 11 | 位置编码与归一化 | 正弦位置编码、LayerNorm、残差连接、pre-norm |
| 12 | 完整 GPT 模型 | GPT:token embedding + 位置编码 + N 层 Block + 输出头,GPTConfig::tiny(n_embd=64, n_head=4, n_layer=2, block_size=32) |
里程碑:GPT 的骨架完整成形——注意力找相关性、MLP 加工信息、归一化稳住数值、残差保梯度畅通。
阶段五:训练与推理(第 13-16 课)
| 课 | 主题 | 成果 |
|---|---|---|
| 13 | 训练循环 | train_gpt:采样→前向→反向→梯度裁剪(clip_grad_norm)→更新→清零(src/train.rs) |
| 14 | 数据加载 | DataLoader:随机切窗口,x/y 平移一位配对(src/data.rs) |
| 15 | 推理与采样 | temperature / top-k / top-p 采样,sample_token + generate(src/sample.rs) |
| 16 | 训练小 GPT | 600 步训练后能生成有模有样的英文短文本 |
里程碑:cargo run 一次跑通"训练 → 生成",模型真的会"写"了。
阶段六:进阶优化(第 17-21 课)
| 课 | 主题 | 成果 |
|---|---|---|
| 17 | AdamW 优化器 | 一阶/二阶动量、偏差修正、解耦权重衰减(src/optim.rs) |
| 18 | KV Cache | 推理时缓存历史 K/V,逐步生成只算新 token(KVCache,src/attention.rs) |
| 19 | RoPE 旋转位置编码 | rotary_pair 一次旋转 Q/K + build_cos_sin_tab 预计算查表(src/rope.rs) |
| 20 | 学习率调度与收尾 | LRScheduler:warmup + cosine decay(src/train.rs) |
| 21 | GPU 加速(可选) | wgpu 计算着色器加速 matmul/scale/add/relu,失败自动回退 CPU(src/gpu.rs,--features gpu) |
一路走下来的核心收获
| 能力 | 对应课程 | 一句话总结 |
|---|---|---|
| 张量与自动微分 | 1-4 | 一切框架魔法的底层原理 |
| 神经网络与优化 | 5-7、17 | 前向/反向/更新三板斧 + AdamW |
| 文本处理 | 8 | BPE 让模型学会"拆词" |
| Transformer | 9-12 | 注意力 + 归一化 + 残差 + 位置编码 |
| 工程化训练 | 13-16、18、20 | 数据、训练循环、采样、KV cache、lr 调度 |
| 现代技巧 | 17-21 | AdamW / KV cache / RoPE / 调度 / GPU,都是真实大模型在用的东西 |
验收标准(来自学习计划):
cargo run正常输出 XOR 正确率、BPE 词表、训练日志与两段生成文本; MLP 在 XOR 上收敛;小 GPT 生成通顺短文本——全部达成。
8. 后续展望:从这里出发
21 课只是"入门级 GPT"的终点,却是真实 LLM 世界的起点。按性价比排序,值得继续的方向:
8.1 数据和规模
| 方向 | 现状 | 下一步 |
|---|---|---|
| 语料 | CORPUS 只是一小段英文故事(约 700 字符) |
换成更大语料(如 WikiText、OpenWebText 子集),模型才能学到真实语言结构 |
| 模型规模 | GPTConfig::tiny:64 维、2 层 |
增大 n_embd/n_layer/block_size(可开 --features gpu 加速) |
| 训练步数 | 600 步 × batch 8 | 上万步 + 更大 batch,loss 才能逼近语料的理论下限 |
8.2 直接能做的代码级改进
- RoPE 已是完成态:第 19 课已把它接进
MultiHeadAttention(只旋转 Q/K、KV cache 缓存"已旋转的 K"),下一步可以直接试 8.3 的长度外推技巧; - RMSNorm:把 LayerNorm 的均值项去掉,更快且效果相当(LLaMA 在用);
- GQA / MQA:多头共享 KV,配合 KV cache 大幅省显存;
- 更好的分词器:加大 BPE 合并次数 / 词表,或换 SentencePiece 风格。
8.3 架构前沿(了解即可)
- MoE(混合专家):把每个 Block 的 MLP 换成多个"专家" + 一个路由器,每个 token 只激活少数专家。 参数量大幅增加但计算量可控(Mixtral、DeepSeek 系列就是代表);
- 长度外推:RoPE 训练长度外会失效,用 NTK-aware scaling、YaRN 等方法把上下文"撑"长;
- 稀疏注意力 / Flash Attention:优化注意力本身的计算与 IO,长上下文必备。
8.4 真正的并行(从单卡到多卡 GPU)
我们现在是单线程 CPU(可选单卡 GPU)、零依赖的教学实现,真实训练要靠并行:
| 并行方式 | 切分维度 | 一句话 |
|---|---|---|
| 数据并行 | batch | 每张卡算一份 batch 的梯度,然后 all-reduce 求和,大家参数保持一致 |
| 张量并行 | 矩阵 | 把单个 Linear 的权重按列/按行切到多张卡,一起算一次前向 |
| 流水线并行 | 层 | 把 12 层切成几段,卡 1 算 1-4 层、卡 2 算 5-8 层…… |
想实战的话,可以先把本项目移植到 candle / burn 等 Rust 深度学习框架上(它们封装了 GPU 和 autograd),
或者用 rayon 给张量运算加上多线程——那是"工程优化"的另一个故事了。
学习这条路没有终点:从手写张量到训练小 GPT,你走完了框架背后的 90% 黑盒;剩下的 10% 是工程与规模。
9. 动手练习
- 改 warmup 步数:把
main.rs里的warmup_steps从 50 改成 0(注意lr()里.max(1)会兜底,不会除零)和 200,分别训练,观察 loss 曲线差异——warmup 太短初期是否震荡?太长是否拖慢中期? - 改 min_lr:把
TrainConfig::default()里的min_lr(3e-4)改成3e-5和1.5e-3,对比最终 loss。想想 min_lr 设太高会有什么后果(后期"精细收敛"没了)。 - 绘制 lr 曲线:写个 10 行小程序(或用 6.4 的公式在表格/脚本里算一遍),以 step 为横轴画出 600 步的 lr 曲线,确认它是"先线性爬升、再余弦衰减"的倒 U 形。
- 对比固定 lr:把
train_gpt改成固定opt.lr = cfg.max_lr(不调scheduler),训练 600 步对比 loss——体会调度带来的"同样的步数,更好的收敛"。 - 看懂日志:把
eval_every从 100 改成 10,重新cargo run,观察 warmup 段的 lr 爬升——它每步增加max_lr/warmup_steps = 0.003/50 = 0.00006,所以每 10 步增加 0.0006(0.00006 → 0.00066 → 0.00126 → …),确认和公式一致。 - 给 21 课写总结:按第 7 节的表格,自己列一份"我掌握了什么"清单,找出最薄弱的一课回去重读——这是收尾课最重要的作业。
10. 本课总结(也是全项目的结语)
- 为什么需要调度:固定大 lr 后期震荡、固定小 lr 前期太慢,动态 lr 兼顾"稳、快、细"。
- warmup:前
warmup_steps步lr = max_lr × (step+1)/warmup_steps线性爬升,保护训练初期的随机参数和 AdamW 未热身的动量。 - cosine decay:
lr = min_lr + (max_lr - min_lr) × 0.5 × (1 + cos(π·progress)),从max_lr平滑衰减到min_lr(本项目取max_lr × 0.1)。 LRScheduler:lr()按step计数选分支算 lr,step()推进一步;在train_gpt里通过opt.lr = scheduler.lr()喂给 AdamW。- 日志解读:日志显示 step 100→600,lr 从
0.002947一路衰减到0.000300(min_lr);warmup 段(step 0-49)因eval_every=100没有打印点;打印的是本步实际用于更新的cur_lr,没有错位。 - 21 课全回顾:地基(张量/自动微分)→ 神经网络 → 分词器 → Transformer → 训练推理 → 现代技巧(AdamW/KV cache/RoPE/调度/GPU),
cargo run达成全部验收标准。 - 展望:更大语料与模型、RMSNorm/GQA、MoE、真正的并行(数据/张量/流水线)、长度外推——每一步都能在这个项目上继续长出来。
最后一句:一个能跑的、完全由你手写的小 GPT,就是打开大模型世界最好的钥匙。 21 课结束,学习才刚刚开始。祝玩得开心!