训练它:让模型学会写唐诗
用三万四千首唐诗训练上一课搭好的 GPT,在笔记本 CPU 上跑七分钟,看它从乱码一步步学会五言七言。再只给它三百首诗,看它怎样把诗背下来而不是学会作诗。
- 约 50 分钟
- 难度:深入
- 实测:2026-09-15 torch 2.14,Apple M4 CPU,固定随机种子
模型搭好了,现在训练它。这一课的代码 train.py 只有一百多行,结构和第 08 模块的训练循环一样:取一批数据,算损失,反向传播,更新参数。
python train.py
数据:错开一位
语言模型的训练数据不需要人工标注。一段文字本身就是题目和答案:
def get_batch(ids, generator=None):
"""随机截取 BATCH 段长度为 block_size 的文字。目标 y 就是 x 往后错一位:每个位置都要预测下一个字。"""
starts = torch.randint(len(ids) - cfg.block_size - 1, (BATCH,), generator=generator)
x = torch.stack([ids[s:s + cfg.block_size] for s in starts])
y = torch.stack([ids[s + 1:s + cfg.block_size + 1] for s in starts])
return x, y
所有诗用换行符连成一长串,随机截取 32 段,每段 128 个字作为输入 x;把每段往后错一位,就是答案 y。比如输入是"白日依山尽,",答案就是"日依山尽,黄":第 1 个位置看到"白"要猜"日",第 2 个位置看到"白日"要猜"依"。上一课说过,因果掩码保证每个位置都没法偷看答案。
换行符在这里有特殊的意义:它表示一首诗结束,下一首开始。模型会学到"句号之后接换行",生成时遇到换行就可以停下来,一首诗就写完了。
最后 1000 首诗留作验证集,训练时不看。这样就能用第 08 模块第 6 课的办法,看模型是在学习还是在背诵。
训练集 34135 首诗,1547255 个词元;验证集 1000 首;词表 6289 个字符
模型:4 层,4 个头,向量维度 128,共 1,614,720 个参数
训练循环的几个细节
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=0.1)
for step in range(1, args.steps + 1):
for group in optimizer.param_groups:
group["lr"] = lr_at(step - 1)
x, y = get_batch(train_ids)
_, loss = model(x, y)
optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) # 梯度裁剪:防止偶尔一步梯度太大把训练带飞
optimizer.step()
和第 08 模块比,多了两样东西。
学习率会变化。前 100 步从 0 慢慢升到 0.001,这叫预热:训练刚开始时参数是随机的,梯度的方向很不可靠,步子太大容易走偏。之后学习率按余弦曲线慢慢降到 0.0001,训练后期步子小一些,可以更精细地找到损失低的地方。
def lr_at(step, peak=1e-3, warmup=100):
"""学习率:前 100 步从 0 慢慢升上去(预热),然后按余弦曲线降到峰值的十分之一。"""
if step < warmup:
return peak * (step + 1) / warmup
progress = (step - warmup) / max(1, args.steps - warmup)
return peak * (0.1 + 0.9 * 0.5 * (1 + math.cos(math.pi * progress)))
梯度裁剪。如果某一步算出的梯度特别大(碰上一批特殊的数据),就把它按比例缩小,使整体的大小不超过 1。第 08 模块第 2 课看到过,一步迈得太大,训练可能直接发散。
"预热 + 余弦下降 + 梯度裁剪 + AdamW",是训练 Transformer 最常见的一套组合,大模型的训练基本也是这样,只是具体的数值不同。
看它学会写诗
训练 3000 步,每隔一段时间看一次损失,并让它写两首诗:
训练前:训练损失 8.779,验证损失 8.778(随便猜的话是 ln(6289) = 8.747)
训练前随便写的: 耦呌同捐睢寮办猬囊斵樬劫鞑顷饼遁瞩赴冈譀嗾雅踯牧写禧氛迹樯毁尤平螵蔻郓祐菼灺嵊匦
第 300 步(27 秒):训练损失 5.778,验证损失 5.818
将南。
门不独。到李斜,功觅青。谁将风花不可碧,不和山。
第 1000 步(97 秒):训练损失 4.777,验证损失 4.879
山里怅望两间道,千里长亭寺断肠。曾梳白兰草,试向玉关鱼。
此日醉前年,相思高至兹。云如汉陵子,暮水九重宫。
第 2000 步(249 秒):训练损失 4.410,验证损失 4.583
秋江野鸟过高楼,野树猿声怨见人。处处秋风满孤照,沧海无端行处闻。
春山度滟月,多少漫为秋。药罢已开葬,松阴不道开。
第 3000 步(407 秒):训练损失 4.280,验证损失 4.488
江山古馆响幽幽,山鸟无人见白头。此时猩猩争得语,又将杯酒醉参差。
分明人在泪,明月更经过。小谷闲烟树,红潭古石床。归来扶白首,立向卧青山。独有安行处,如何却得还。
训练用了 408 秒,模型存到 .cache/gpt.pt
在我的电脑上(Apple M4,只用 CPU),3000 步用了不到 7 分钟。一步处理 32×128 = 4096 个字,3000 步一共看了约 1200 万个字,相当于把训练集过了大约 8 遍。
它学会的东西是分阶段出现的:
- 训练前:一串随机的字,损失 8.78,就是随便猜。
- 300 步:学会了用标点,写出的都是常用字,但句子长短不一。
- 1000 步:大部分句子是五个或七个字,逗号句号交替。但第一首前两句七言、后两句五言,还不知道一首诗要统一。
- 2000 步:格式基本都对了,开始有"秋江野鸟过高楼,野树猿声怨见人"这样意象连贯的句子。
- 3000 步:第一首是完整的七言绝句,"江山古馆响幽幽,山鸟无人见白头";第二首是一首完整的五言律诗,八句。
它没有学会的也很明显:意思经常接不上("此时猩猩争得语"),平仄和押韵也不讲究。161 万个参数、7 分钟的训练,能做到这样已经不错了。
整个训练过程中,训练损失和验证损失都在下降,而且一直很接近(最后是 4.28 和 4.49)。按照第 08 模块第 6 课的说法,这说明它没有明显的过拟合:它在没见过的诗上表现得几乎一样好。
它是在作诗还是在背诗
这门课前面几个模块反复讲过:生成的东西看起来好,不代表模型真的学会了,它可能只是把训练数据背了下来。所以训练结束后,脚本做了一个检查:生成 100 首诗,看有多少句和训练集里的某一句一模一样。
train_sentences = {s for p in train_poems for s in p.replace("。", ",").split(",") if s}
torch.manual_seed(1)
generated = sample(100)
sentences = [s for p in generated for s in p.replace("。", ",").split(",") if s]
copied = sum(s in train_sentences for s in sentences)
生成 100 首诗,共 576 句,其中 0 句(0%)和训练集里的某一句一模一样
整首和训练集里某一首一模一样的:0 首
576 句,没有一句是照抄的。它学到的是唐诗的"写法":格式、常用的字词、哪些字常在一起出现,然后组合出新的句子。
只给它 300 首诗
第 08 模块第 6 课说过,数据太少时模型会背诵。现在用同一个模型,只给它 300 首诗试试:
python train.py --poems 300 --steps 1500 --out small.pt
训练集 300 首诗,13612 个词元;验证集 1000 首;词表 6289 个字符
第 150 步(22 秒):训练损失 5.079,验证损失 6.584
第 500 步(70 秒):训练损失 0.421,验证损失 8.864
第 1000 步(145 秒):训练损失 0.103,验证损失 9.817
雕鹗途程在碧天,彩衣东去复何言。二千宾客旧知己,十二山河新故园。吟看桂生溪月上,醉听鲲化海涛翻。好期圣代重相见,莫学袁生老竹轩。
第 1500 步(230 秒):训练损失 0.071,验证损失 9.984
生成 100 首诗,共 593 句,其中 454 句(77%)和训练集里的某一句一模一样
整首和训练集里某一首一模一样的:40 首
训练损失降到了 0.07,比用全部数据训练时的 4.28 低得多。可验证损失一路上涨到 9.98,比训练前随便猜的 8.78 还要高。
第 1000 步写出的那首七律,工整得漂亮,因为它是一字不差地从训练集里背出来的。最后的检查也证实了这一点:77% 的句子是照抄的,100 首里有 40 首整首照抄。
161 万个参数,只有 1.3 万个字要学,模型完全有能力把它们全部记下来。记下来是让训练损失降低最省事的办法。对没见过的诗,它反而比什么都没学时更差:它对自己背过的内容太"自信"了,碰到别的诗,给正确答案的概率低得可怜。
同样的模型、同样的代码,只是数据从 3.4 万首变成 300 首,结果就从"学会写诗"变成了"背诗"。这是第 08 模块第 6 课的结论在语言模型上的重演,也是大模型需要海量数据的原因。
损失 4.49 是什么意思
最后验证损失是 4.49。交叉熵是 -log(正确字的概率),所以平均来说,模型给正确的下一个字的概率大约是 e 的 -4.49 次方,约 1.1%。
听起来很低,但要知道:写诗时下一个字本来就有很多种合理的选择。"白日依山尽,黄河入海"后面,模型给"无""间""多""深"都分了一些概率(下一课会看到具体的数字)。原诗是"流",但写成别的字也不一定错。损失不可能降到 0,这和第 08 模块第 1 课"数据有随机波动,损失永远降不到 0"是一个道理。
一个衡量标准是和随便猜比:随便猜是从 6289 个字里平均地选,损失 8.75。4.49 相当于把选择的范围从 6289 个字缩小到了大约 e 的 4.49 次方,约 89 个字。
练习
- 把
--steps改成 6000,损失还会继续下降吗?生成的诗有没有变好? - 把训练的诗分别设成 1000、3000、10000 首(
--poems),各训练 1500 步,记下验证损失和"照抄的句子"比例,画成一张表。 - 去掉学习率的预热和下降(
lr_at直接返回 0.001),同样训练 3000 步,比较最后的验证损失。
自测
1. 训练语言模型时,输入和答案分别是什么?
从文字里截取一段作为输入,把它往后错一位作为答案。每个位置都要根据自己和前面的字,预测下一个字。一段 128 个字的文字,同时提供了 128 道预测题。
2. 学习率预热和梯度裁剪分别是为了解决什么问题?
预热:训练刚开始时参数是随机的,梯度的方向不可靠,一开始就用大的学习率容易走偏,所以先用小的学习率,慢慢升上去。梯度裁剪:偶尔某一步的梯度特别大,直接用它更新会让参数跳得太远,训练可能发散,所以把过大的梯度按比例缩小。
3. 只用 300 首诗训练时,为什么训练损失很低,验证损失却比随便猜还高?
模型的参数足够多,可以把 300 首诗全部背下来,训练损失因此很低。但它学到的是这 300 首诗本身,而不是作诗的规律。它对背过的内容非常自信,碰到没见过的诗,给正确答案的概率很低,交叉熵就比随便猜还高。生成的诗有 77% 的句子是照抄的,也证实了这一点。
提问与讨论
这一课没看懂的地方,在这里问。看到别人的问题,也欢迎你来回答。
提问 +3 积分,回答别人 +6 积分。内容经审核后公开。
正在加载讨论…