訓練它:讓模型學會寫唐詩
用三萬四千首唐詩訓練上一課搭好的 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 點。內容經審核後公開。
正在載入討論…