Build a Large Language Model (From Scratch) · Chapter 6
分類任務微調Fine-tuning for classification
第 5 章結尾費心載入的 OpenAI GPT-2 權重,在這一章正式派上用場:把「會接話」的預訓練模型,改造成「會判斷」的垃圾簡訊分類器。做法出乎意料地省力——換一顆只有 2 個輸出的分類頭、凍結絕大多數參數、只微調最後幾層,5 個 epoch(筆電上 5.65 分鐘)就拿到 95.67% 的測試準確率。頁面中的關鍵數值都與書中執行輸出一致。
本章定位:讓模型學會「做選擇題」
整本書的三階段地圖裡,本章正式進入第三階段:微調——把第二階段練好(或載入)的 foundation model,變成能解特定任務的專才:
資料處理 · attention · 架構 ✓→ STAGE 2(ch5)
預訓練 · 載入 OpenAI 權重 ✓→ STAGE 3 開始!(本章)
分類微調 classification fine-tuningch7 則是另一條路:指令微調 instruction fine-tuning→ 垃圾簡訊分類器
test accuracy 95.67%
本章的 8 個步驟——會隨你往下讀自動打勾(也可以點擊跳到該節):
微調不是重新教模型講話,而是借力:預訓練已經讓它讀懂文字(第 5 章花 69 萬美元等級的成果,我們免費載入),現在只要教它「讀完之後,在 spam / not spam 兩格裡打一個勾」。所以本章的所有改動都小得驚人:換一顆 768→2 的輸出頭、解凍最後幾層、跑 5 個 epoch——其餘 94% 的參數一動不動。
🏋️ 訓練 training=forward 算預測 → 算 loss → backward → 更新權重——6.5–6.7 改造與微調模型屬於這邊;💬 推論 inference=權重固定、只做 forward——6.8 拿微調好的模型判斷新簡訊屬於這邊。各節標題旁會掛上對應的標籤。
暖身:本章的三個關鍵數字
Different categories of fine-tuning(兩種微調路線)
(只會「接下一個字」)→選一條微調路線classification(本章)或 instruction(第 7 章)→output:專才分類器
或 通才助理
微調 LLM 最常見的兩條路:classification fine-tuning 與 instruction fine-tuning。點選切換,比較兩者的能力、資料需求與成本:
Preparing the dataset(準備資料集)
5,572 則簡訊(.tsv)→undersample 平衡 → label 轉 0/1 → 切 70/10/20random_state=123 · pandas DataFrame→output:train.csv 1,045
validation.csv 149 · test.csv 300
資料用的是公開的 SMS Spam Collection:5,572 則真實英文簡訊,每則標好 "ham"(正常)或 "spam"(垃圾)。用 pd.read_csv 載入後先看類別分佈——問題立刻浮現:
ham 有 4,825 則、spam 只有 747 則。直接拿去訓練,模型會發現一招懶人必勝法:「全部猜 ham」就有 86.6% 準確率——數字漂亮、實際上完全沒學到分辨能力。書中選最簡單的解法 undersampling:從 ham 隨機抽 747 則(random_state=123 保證可重現),湊成 747 + 747 = 1,494 則的平衡資料集,此時亂猜的基準線就是誠實的 50%。
接著把字串標籤轉成整數——"ham" → 0、"spam" → 1。書中提醒:這和文字轉 token ID 是同一件事,只是「詞彙表」從 50,257 個字縮成 2 個類別:
balanced_df["Label"] = balanced_df["Label"].map({"ham": 0, "spam": 1})
最後用 random_split() 把 1,494 則切成 70% / 10% / 20%(訓練 / 驗證 / 測試)。切換視角,看同一份資料的兩種數法(■ ≈ 10 則簡訊 / 1 個 batch):
Creating data loaders(建立 data loaders)
+ 標籤 0/1→SpamDataset:tokenize → pad 到 120padding token = 50256(<|endoftext|>)→output:batch [8, 120]
+ label [8]
第 2 章的滑動視窗把長文切成等長片段;簡訊卻是天生長短不一的,要組成 batch 只有兩條路:全部截短成最短那則(省算力但丟資訊),或全部補長到最長那則(書中的選擇——保留完整內容)。補長用的填充物是老朋友 <|endoftext|>,它的 token ID 可以用 tiktoken 驗證:
print(tokenizer.encode("<|endoftext|>", allowed_special={"<|endoftext|>"})) # → [50256]
點選書中圖 6.6 的三則例句,看 tokenize → pad 的實際效果(灰色塊=50256 填充):
SpamDataset 類別把這套流程包起來:找出訓練集最長的序列=120 tokens,全部 pad 到 120;驗證/測試集也 pad 到同樣的 120,超過的用 encoded_text[:max_length] 截斷。之後照舊交給 DataLoader(batch_size=8、train 開 shuffle 與 drop_last):
Initializing a model with pretrained weights(載入預訓練權重)
(第 5 章的 download_and_load_gpt2)→GPTModel(BASE_CONFIG) + load_weights_into_gpt第 5 章寫好的工具直接重用→output:會講人話的 GPT-2
(但還不會「聽指令」)
資料備妥,換模型登場。config 和第 5 章 5.5 節收尾時完全一致——點任一行看說明:
載入權重後先做個小體檢——用第 4、5 章的生成工具確認權重沒搬壞:
先別急著微調:直接「用問的」行不行?
微調前先做一個對照實驗:把分類任務寫成指令塞給模型,看它能不能直接回答。點選兩種輸入對照:
Adding a classification head(加上分類頭)🏋️ 訓練
out_head:768 → 50257→凍結全部 → 換 out_head:768 → 2 → 解凍最後 1 個 block + final_normrequires_grad 控制哪些參數會被更新→output:分類版 GPT
可訓練參數只剩 5.7%
改造只有三行邏輯:①全部凍結(requires_grad=False——backward 不會算它們的梯度、optimizer 不會動它們);②換頭——把 768→50257 的 out_head 換成 768→2 的新 Linear(新層預設 requires_grad=True);③選擇性解凍——書中實驗發現多放開幾層效果更好,於是把最後一個 transformer block 和 final LayerNorm 也設回可訓練:
for param in model.parameters():
param.requires_grad = False # ① 全部凍結
torch.manual_seed(123)
model.out_head = torch.nn.Linear( # ② 換頭:768 → 2(新層自動可訓練)
in_features=BASE_CONFIG["emb_dim"], out_features=2)
for param in model.trf_blocks[-1].parameters():
param.requires_grad = True # ③ 解凍最後一個 block
for param in model.final_norm.parameters():
param.requires_grad = True # 與 final LayerNorm
切換三種凍結策略、再點任一層,看誰是 trainable(亮色=requires_grad=True):
關鍵問題:模型輸出 [1, 4, 2],該用哪一格?
換頭後餵一句 "Do you have time"(4 個 token),輸出從 [1, 4, 50257] 變成 [1, 4, 2]——每個 token 位置都有一組 2 維 logits,但分類只需要一個答案。點下面任一個 token,看它的「視野」(書中圖 6.12 的真實 attention 權重)與它那格的真實 logits:
第 3 章的 causal mask 規定:每個 token 只能注意自己和前面的字。所以位置 1 只讀過一個字、位置 2 讀過兩個……只有最後一個 token 的 attention 覆蓋整句,它是唯一「把全文讀完」的位置。拿它的輸出接分類頭,等於請讀完全文的人來作答——這就是程式裡 outputs[:, -1, :] 的由來。
Calculating the classification loss and accuracy(分類 loss 與 accuracy)🏋️ 訓練
[batch, 2]→softmax → argmax(accuracy)
cross entropy(loss)第 5 章的老公式,50257 類 → 2 類→output:微調前基準線
accuracy ≈ 46–49%
怎麼把 2 維 logits 變成「spam / not spam」的判斷?和第 5 章預測下一個字一模一樣:softmax 轉機率、argmax 挑最大——只是候選從 50,257 個縮成 2 個。點選書中圖 6.14 的兩個例子,看還沒微調的模型怎麼答:
accuracy(答對比例)人類看得懂,卻是不可微分的階梯函數——沒有梯度、沒法 backward。所以訓練時照舊用 cross entropy 當代理目標:5.1.2 推導的六步(softmax → 取正確類別機率 → log → 平均 → 取負)一個字都不用改,只是「正確答案」從 5 萬字裡的一個 token,變成 0/1 兩類裡的一類。loss 降,accuracy 自然升。
def calc_loss_batch(input_batch, target_batch, model, device):
input_batch = input_batch.to(device)
target_batch = target_batch.to(device)
logits = model(input_batch)[:, -1, :] # 唯一的改動:只取最後一個 token 那格
return torch.nn.functional.cross_entropy(logits, target_batch)
微調前先量基準線(torch.no_grad(),各抽 10/5 個 batch 估計):
Fine-tuning the model on supervised data(在監督資料上微調)🏋️ 訓練
+ 只有 5.7% 可訓練的模型→train_classifier_simple × 5 epochsAdamW(lr=5e-5, weight_decay=0.1)· eval_freq=50→output:97%+ 的分類器
(M3 MacBook Air 5.65 分鐘)
訓練迴圈幾乎照抄第 5 章的 train_model_simple:取 batch → forward 算 loss → backward → AdamW 更新,只差兩處——記帳單位從 tokens_seen 換成 examples_seen(看過幾則簡訊),每個 epoch 結尾從「生成樣本肉眼檢查」換成「算一次 train/val accuracy」:
torch.manual_seed(123) optimizer = torch.optim.AdamW(model.parameters(), lr=5e-5, weight_decay=0.1) num_epochs = 5 # optimizer 收到全部參數也沒關係——凍結的參數沒有梯度,永遠不會被動到
按「下一個 epoch」逐輪觀察:上圖 loss、下圖 accuracy(所有資料點都是書中列印的真實數值,無內插),右側主控台同步輸出書中的訓練紀錄:
訓練完,用完整資料集(不再抽樣)算最終成績:
Using the LLM as a spam classifier(上場當分類器)💬 推論
最後把整條流程包成 classify_review()——它做的每一步都和訓練時的 SpamDataset 一致(同樣的 tokenizer、同樣 pad 到 120),差別只在最後把 0/1 翻譯回人話。選一則書中的真實例句、按播放:
→ 截斷 → pad(50256)
→
unsqueeze(0) 補 batch 維
+分類頭 768→2
→
最後一格 · 形狀 [1, 2]
→
收工前把成果存檔(第 5 章教的 state_dict),下次直接載入、不用重練:
torch.save(model.state_dict(), "review_classifier.pth") # 之後: model_state_dict = torch.load("review_classifier.pth", map_location=device) model.load_state_dict(model_state_dict)
本章小結
- 兩種微調:classification(固定類別、省資料省算力、專才)vs instruction(任意指令、需大量資料、通才)——本章走前者。
- 資料:SMS Spam Collection 5,572 則 → undersample 平衡成 747+747 → label ham=0/spam=1 → 切 70/10/20(1,045/149/300)。
- Data loader:pad 到訓練集最長的 120 tokens(填 50256)、超長截斷;batch 形狀 [8, 120]+標籤 [8];130/19/38 個 batch。
- 模型改造:載入第 5 章的 GPT-2 124M 權重 → 全部凍結 → out_head 換成 768→2 → 解凍最後一個 block+final_norm(可訓練 7,090,946 ≈ 5.7%)。
- 取最後一個 token:causal mask 下只有最後一格讀過整句 →
logits = model(x)[:, -1, :]。 - Loss / accuracy:cross entropy 與第 5 章同一條公式(50257 類 → 2 類);accuracy 不可微,只當監控指標。微調前 46–49%(≈亂猜)。
- 微調實測:AdamW(lr=5e-5, weight_decay=0.1)× 5 epochs、5.65 分鐘 → train 97.21% / val 97.32% / test 95.67%,train/val 曲線貼合、無明顯 overfitting。
- 實際使用:classify_review() 完整重演訓練時的前處理;兩則書中例句分別判為 "spam" 與 "not spam",全對。
知識檢核
// 七題,作答後立即看解析
頁面中的計算皆為瀏覽器即時運算 · 快捷鍵 T 切換主題