第 20 课:学习率调度与收尾 —— warmup、cosine decay 与全项目总结

2026-09-21 干徒
RustLLM训练

第 20 课:学习率调度与收尾 —— warmup、cosine decay 与全项目总结

代码位置:src/train.rsLRSchedulertrain_gpt) 调用处:src/main.rsdemo_gpt 里的训练配置) 配套代码:src/optim.rsAdamW,第 17 课)

1. 本课要搞懂的问题

  1. 学习率为什么不能"一步到位"?固定 lr 训练会出什么问题?
  2. warmup 是干什么的?"前 50 步 lr 从 0 线性升到 max" 在代码里怎么写的?
  3. cosine decay 长什么样?为什么末期要用越来越小的学习率?
  4. LRSchedulerlr() / step() 到底怎么算、怎么用?
  5. 训练日志里的 lr 0.002947 → … → 0.000300 是怎么一步步算出来的?
  6. 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 + 1step 从 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 = 0cosine = 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.lrpub 字段,可以直接赋值
scheduler.step() 计数器前进,下一轮循环 lr() 就是新的学习率

配合逻辑一句话:调度器负责"算 lr",优化器负责"用 lr 更新参数",两者通过 opt.lr = scheduler.lr() 对接。

6. 训练日志解读:lr 0.002947 → 0.000300

6.1 我们的训练配置

src/main.rsdemo_gptTrainConfig 传参(演示 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_gptcur_lrscheduler.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()
    );
}

注意两个细节:一是日志里的 stepstep + 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.rssrc/optim.rs
07 第一个 MLP 2→4→2 的 MLP 100% 学会 XORdemo_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 + generatesrc/sample.rs
16 训练小 GPT 600 步训练后能生成有模有样的英文短文本

里程碑cargo run 一次跑通"训练 → 生成",模型真的会"写"了。

阶段六:进阶优化(第 17-21 课)

主题 成果
17 AdamW 优化器 一阶/二阶动量、偏差修正、解耦权重衰减(src/optim.rs
18 KV Cache 推理时缓存历史 K/V,逐步生成只算新 token(KVCachesrc/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. 动手练习

  1. 改 warmup 步数:把 main.rs 里的 warmup_steps 从 50 改成 0(注意 lr().max(1) 会兜底,不会除零)和 200,分别训练,观察 loss 曲线差异——warmup 太短初期是否震荡?太长是否拖慢中期?
  2. 改 min_lr:把 TrainConfig::default() 里的 min_lr3e-4)改成 3e-51.5e-3,对比最终 loss。想想 min_lr 设太高会有什么后果(后期"精细收敛"没了)。
  3. 绘制 lr 曲线:写个 10 行小程序(或用 6.4 的公式在表格/脚本里算一遍),以 step 为横轴画出 600 步的 lr 曲线,确认它是"先线性爬升、再余弦衰减"的倒 U 形。
  4. 对比固定 lr:把 train_gpt 改成固定 opt.lr = cfg.max_lr(不调 scheduler),训练 600 步对比 loss——体会调度带来的"同样的步数,更好的收敛"。
  5. 看懂日志:把 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 → …),确认和公式一致。
  6. 给 21 课写总结:按第 7 节的表格,自己列一份"我掌握了什么"清单,找出最薄弱的一课回去重读——这是收尾课最重要的作业。

10. 本课总结(也是全项目的结语)

  • 为什么需要调度:固定大 lr 后期震荡、固定小 lr 前期太慢,动态 lr 兼顾"稳、快、细"。
  • warmup:前 warmup_stepslr = max_lr × (step+1)/warmup_steps 线性爬升,保护训练初期的随机参数和 AdamW 未热身的动量。
  • cosine decaylr = min_lr + (max_lr - min_lr) × 0.5 × (1 + cos(π·progress)),从 max_lr 平滑衰减到 min_lr(本项目取 max_lr × 0.1)。
  • LRSchedulerlr()step 计数选分支算 lr,step() 推进一步;在 train_gpt 里通过 opt.lr = scheduler.lr() 喂给 AdamW。
  • 日志解读:日志显示 step 100→600,lr 从 0.002947 一路衰减到 0.000300min_lr);warmup 段(step 0-49)因 eval_every=100 没有打印点;打印的是本步实际用于更新的 cur_lr,没有错位。
  • 21 课全回顾:地基(张量/自动微分)→ 神经网络 → 分词器 → Transformer → 训练推理 → 现代技巧(AdamW/KV cache/RoPE/调度/GPU),cargo run 达成全部验收标准。
  • 展望:更大语料与模型、RMSNorm/GQA、MoE、真正的并行(数据/张量/流水线)、长度外推——每一步都能在这个项目上继续长出来。

最后一句:一个能跑的、完全由你手写的小 GPT,就是打开大模型世界最好的钥匙。 21 课结束,学习才刚刚开始。祝玩得开心!

Rust 大语言模型 学习指南共 22 章
1从零用 Rust 实现大语言模型 —— 学习计划2第 1 课:张量 Tensor —— 一切的基础3第 2 课:自动微分 Autograd —— 让模型学会「自我修正」4第 3 课:张量运算扩展 —— 广播、归约、softmax、批量矩阵乘法5第 4 课:模块化重构 —— 项目结构分层与 Rc<RefCell> 架构6第 5 课:线性层与激活函数 —— 神经网络的「积木」7第 6 课:损失函数与优化器 —— 让模型知道「错在哪、怎么改」8第 7 课:第一个 MLP —— 教会神经网络算 XOR9第 8 课:BPE 分词器 —— 让模型「读懂」文字10第 9 课:注意力机制 —— 让 token 互相「看」11第 10 课:多头注意力 —— 让模型「多角度」看世界12第 11 课:位置编码与归一化 —— 让序列带上「位置感」13第 12 课:完整 GPT 模型 —— 把积木拼成能预测下一个词的模型14第 13 课:训练循环 —— 让模型真正开始学习15第 14 课:数据加载 —— 文本如何变成训练样本16第 15 课:推理与采样 —— 让模型「创造性」地生成17第 16 课:训练小 GPT —— 看 loss 从 1.46 一路降到 0.1618第 17 课:AdamW 优化器 —— 给梯度下降装上「惯性」和「自适应步长」19第 18 课:KV Cache —— 让逐 token 生成不再重复计算20第 19 课:RoPE 旋转位置编码 —— 把「相对位置」揉进注意力21第 20 课:学习率调度与收尾 —— warmup、cosine decay 与全项目总结本篇22第 21 课:GPU 加速训练与推理