损失曲线揭示模型先学会什么
一句话总结
预训练就是把“猜下一个令牌”重复几百万次。MiniMind 的 train_pretrain.py 用 AdamW、余弦学习率、梯度裁剪这些标准部件来运转这个循环。这个模块会把这个循环缩小到一台 CPU 的规模,亲手写出来,并读懂损失从 6.9 降到 1 左右的曲线——在哪里急速下降,在哪里变慢,学习率过大又会怎样崩坏。
为什么需要它
训练循环在多数情况下看上去就是 loss.backward(); optimizer.step() 两行。但真正把一个模型完整训练过一次的人都知道,这两行周围的决定才决定结果。学习率一大,损失下降之后会突然跳起来;不固定随机种子,就没法复现昨天的结果;不测验证损失,就分不清是在背还是在学。损失曲线是所有这些现象显露出来的唯一窗口。MiniMind 的 README 写道,64M 模型的预训练用一张 3090 只要一个多小时,而要把这一个小时用好,就得会读最初几分钟的曲线。
工作原理
把 MiniMind 的循环提炼一下,要点如下。
for step, (input_ids, labels) in enumerate(loader, start=1):
lr = get_lr(epoch * iters + step, epochs * iters, learning_rate)
for g in optimizer.param_groups: g["lr"] = lr
res = model(input_ids, labels=labels)
loss = (res.loss + res.aux_loss) / accumulation_steps # aux_loss 는 MoE 균형 손실, 밀집 모델은 0
scaler.scale(loss).backward()
if step % accumulation_steps == 0:
scaler.unscale_(optimizer)
torch.nn.utils.clip_grad_norm_(model.parameters(), grad_clip) # 기본 1.0
scaler.step(optimizer); scaler.update(); optimizer.zero_grad(set_to_none=True)
学习率。get_lr 是 lr × (0.1 + 0.45 × (1 + cos(π·t/T)))。一开始保持 lr 原值,到结束时沿着余弦曲线降到 0.1·lr。它的特点是不会降到 0。默认的 lr 是 5e-4,而这门课程的小模型取 3e-3,大得多——模型越小,越经得起大的学习率。
梯度累积与裁剪。默认批次 32、累积 8,实际上是看了 256 个样本才走一步。clip_grad_norm_ 在整个梯度向量的长度超过 1 时把它缩到 1——免得偶尔出现的一个异常批次把权重毁掉。在 GPU 上使用 bfloat16 的 autocast,在 CPU 上是 nullcontext(),所以用 fp32 运行。
读曲线。我们的模型从 ln 1024 ≈ 6.93 出发,几十步内就降到 3 以下。在这段急速下降的区间里,模型学到的是 哪些令牌出现得频繁。完全不看上下文、只知道频率的模型,它的损失就是一元语法熵(在我们的语料上约 4.6 奈特,nat)。曲线降到这个值以下,就意味着开始根据前面的令牌缩小下一个令牌的范围。再往后就很慢了——句型、助词、村庄与特产的搭配这类较长的规则,学得很慢。
可复现性。权重初始化和抽取批次的随机种子,都得固定。MiniMind 的 setup_seed 会一次性固定 random、numpy、torch。如果 CPU 相同、线程数相同,20 步的损失连一位都不会不同。
在现场相遇的样子
在把训练跑上几个小时之前,先用几分钟的短训练确认几件事。第一个损失是否接近 ln(词表),几十步内是否急速下降,验证损失是否跟得上训练损失。如果第一个损失不对劲,就是数据或标签错了;如果完全不降,就是学习率太小或梯度没有流动。反过来,如果损失降着降着猛地跳起来,或者贴在某个值上纹丝不动,就是学习率太大——这个实验里会把 lr 提到 0.05 亲眼看一看。
损失低也不一定就是好模型。我们的语料是套模板做出来的句子,下一个令牌几乎是确定的,所以损失能降到 1 左右。如果是真实语料,同样大小的模型降不到这么低。这些数字只能在相同的数据、相同的分词器范围内比较。
下一项实验要做什么
仿照 MiniMind 的循环写出 train.py,用相同的随机种子运行两次,看是否连一位都不差。训练 300 步,留下检查点和损失记录,把曲线的形状提取成数字,与一元语法熵对比,看学习率 0.05 会毁掉什么,并用训练好的模型做一次续写。