CH·6從零打造 LLM/ 分類任務微調

Build a Large Language Model (From Scratch) · Chapter 6

分類任務微調Fine-tuning for classification

第 5 章結尾費心載入的 OpenAI GPT-2 權重,在這一章正式派上用場:把「會接話」的預訓練模型,改造成「會判斷」的垃圾簡訊分類器。做法出乎意料地省力——換一顆只有 2 個輸出的分類頭、凍結絕大多數參數、只微調最後幾層,5 個 epoch(筆電上 5.65 分鐘)就拿到 95.67% 的測試準確率。頁面中的關鍵數值都與書中執行輸出一致。

00

本章定位:讓模型學會「做選擇題」

整本書的三階段地圖裡,本章正式進入第三階段:微調——把第二階段練好(或載入)的 foundation model,變成能解特定任務的專才:

STAGE 1(ch2–4)
資料處理 · 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% 的參數一動不動。

🔰 複習:訓練 vs 推論(本頁會隨時標註)

🏋️ 訓練 training=forward 算預測 → 算 loss → backward → 更新權重——6.5–6.7 改造與微調模型屬於這邊;💬 推論 inference=權重固定、只做 forward——6.8 拿微調好的模型判斷新簡訊屬於這邊。各節標題旁會掛上對應的標籤。

暖身:本章的三個關鍵數字

# 這三個數字本章都會親手算出來(書中真實輸出) 微調前 accuracy 46.25% / 45.00% / 48.75% # train/val/test——2 類亂猜的水準(≈50%) 微調後 accuracy 97.21% / 97.32% / 95.67% # 5 epochs、筆電 5.65 分鐘 可訓練參數 7,090,946 / 124,441,346 # 只動 5.7%:最後 1 個 block + final_norm + 新分類頭
6.1

Different categories of fine-tuning(兩種微調路線)

input:預訓練 foundation model
(只會「接下一個字」)
→選一條微調路線classification(本章)或 instruction(第 7 章)→output:專才分類器
或 通才助理

微調 LLM 最常見的兩條路:classification fine-tuning 與 instruction fine-tuning。點選切換,比較兩者的能力、資料需求與成本:

一句話區分:分類微調後的模型是「單選題機器」——只能從訓練時見過的固定類別裡挑一個(spam / not spam),其他什麼都說不了;指令微調後的模型能理解任意指令並生成自由文字。專才比通才好練得多——這正是本章先做分類的原因。
6.2

Preparing the dataset(準備資料集)

input:SMS Spam Collection
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 載入後先看類別分佈——問題立刻浮現:

df["Label"].value_counts() · 按按鈕執行 create_balanced_dataset(df)
🔰 為什麼一定要先平衡?

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):

train 70%validation 10%test 20%不足一個 batch 被丟棄(drop_last=True)
比第 5 章多了一份「測試集」?第 5 章只切 train/val;這裡多切 20% 的 test set——val 用來在訓練過程中監控與調參,test 則只在最後動用一次,當作模型從沒見過的期末考。這也是 6.7 結尾 val 97.32% 略高於 test 95.67% 的原因:開發過程多少會「偏心」val。
6.3

Creating data loaders(建立 data loaders)

input:長短不一的簡訊
+ 標籤 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):

# 書中真實輸出(seed 123) print(train_dataset.max_length) → 120 # 訓練集最長簡訊的 token 數(模型上限是 1024,綽綽有餘) Input batch dimensions: torch.Size([8, 120]) # 8 則簡訊 × 各 120 個 token ID Label batch dimensions torch.Size([8]) # 8 個 0/1 標籤——不再是「右移一格的 token」! 130 training batches # 1,045 則 ÷ 8,drop_last=True → 130 個滿 batch(丟 5 則) 19 validation batches # 149 則 ÷ 8,最後一個 batch 只有 5 則 38 test batches # 300 則 ÷ 8,最後一個 batch 只有 4 則
和第 2 章 data loader 的最大差別在 target:預訓練的 target 是「整句右移一格」(形狀 [8, 120]),分類的 target 是每則一個類別標籤(形狀 [8])。輸入端長得一模一樣,監督訊號完全不同——這就是「自監督 → 監督式」的轉換。
6.4

Initializing a model with pretrained weights(載入預訓練權重)

input:OpenAI GPT-2 124M 權重
(第 5 章的 download_and_load_gpt2)
→GPTModel(BASE_CONFIG) + load_weights_into_gpt第 5 章寫好的工具直接重用→output:會講人話的 GPT-2
(但還不會「聽指令」)

資料備妥,換模型登場。config 和第 5 章 5.5 節收尾時完全一致——點任一行看說明:

👆 點一行 config,看它的意思(注意 drop_rate 與 qkv_bias)

載入權重後先做個小體檢——用第 4、5 章的生成工具確認權重沒搬壞:

# generate_text_simple(model, "Every effort moves you", max_new_tokens=15)——書中真實輸出 Every effort moves you forward. The first step is to understand the importance of your work # ✓ 通順=權重載入成功

先別急著微調:直接「用問的」行不行?

微調前先做一個對照實驗:把分類任務寫成指令塞給模型,看它能不能直接回答。點選兩種輸入對照:

失敗在意料之中——這個模型只做過預訓練(接下一個字),沒做過 instruction fine-tuning,所以它把指令當成「待接龍的文字」原樣複讀。兩條出路:①第 7 章的指令微調,讓它學會聽懂指令;②本章的做法——根本不靠它「回答」,直接在模型上接一顆分類頭讀出答案。後者簡單得多。
6.5

Adding a classification head(加上分類頭)🏋️ 訓練

input:GPTModel
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:

🔰 為什麼是「最後一個 token」?(第 3 章伏筆回收)

第 3 章的 causal mask 規定:每個 token 只能注意自己和前面的字。所以位置 1 只讀過一個字、位置 2 讀過兩個……只有最後一個 token 的 attention 覆蓋整句,它是唯一「把全文讀完」的位置。拿它的輸出接分類頭,等於請讀完全文的人來作答——這就是程式裡 outputs[:, -1, :] 的由來。

6.6

Calculating the classification loss and accuracy(分類 loss 與 accuracy)🏋️ 訓練

input:最後一格 logits
[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 的兩個例子,看還沒微調的模型怎麼答:

🔰 loss 也是第 5 章的老朋友(5.1.2 伏筆回收)

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 估計):

# 書中真實輸出(微調前,seed 123) Training accuracy: 46.25% Training loss: 2.453 Validation accuracy: 45.00% Validation loss: 2.583 Test accuracy: 48.75% Test loss: 2.322 # 2 類亂猜的期望值是 50%——新分類頭是隨機初始化的,表現在亂猜水準完全合理。 # 訓練的任務:把 loss 壓下去,讓 accuracy 衝上去。
6.7

Fine-tuning the model on supervised data(在監督資料上微調)🏋️ 訓練

input:130 個 train batch
+ 只有 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(所有資料點都是書中列印的真實數值,無內插),右側主控台同步輸出書中的訓練紀錄:

訓練完,用完整資料集(不再抽樣)算最終成績:

# 書中真實輸出(full dataset) Training accuracy: 97.21% Validation accuracy: 97.32% Test accuracy: 95.67% # ← 最誠實的數字:完全沒參與訓練與調參的期末考
和第 5 章的訓練曲線對照著看:第 5 章 train/val 曲線嚴重分岔(train 0.39 vs val 6.45,教科書級 overfitting);本章兩條線幾乎貼著走。原因有三:①資料是 1,045 則各自不同的簡訊而不是反覆背 5,145 個 token;②只跑 5 個 epoch;③ 94% 的參數被凍結,模型想背也背不動。val 97.32% 略高於 test 95.67% 屬正常現象——開發過程對 val 略有「應試偏心」。
6.8

Using the LLM as a spam classifier(上場當分類器)💬 推論

input:任何一則新簡訊→classify_reviewtokenize → 截斷 → pad 到 120 → 最後一格 logits → argmax→output:"spam" 或 "not spam"

最後把整條流程包成 classify_review()——它做的每一步都和訓練時的 SpamDataset 一致(同樣的 tokenizer、同樣 pad 到 120),差別只在最後把 0/1 翻譯回人話。選一則書中的真實例句、按播放:

tokenize
→ 截斷 → pad(50256)
→
tensor [1, 120]
unsqueeze(0) 補 batch 維
GPT(凍結)
+分類頭 768→2
→
logits[:, -1, :]
最後一格 · 形狀 [1, 2]
argmax
→
👆 按「播放」跑一遍:簡訊 → token → 模型 → 標籤

收工前把成果存檔(第 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)
下一站:這個分類器很能幹,但它永遠只會答這一題。第 7 章走另一條路——instruction fine-tuning:不換頭、保留 50,257 維的生成能力,改用「指令+回應」的資料教模型聽懂人話,變成什麼都能聊的助理。
▣

本章小結

  • 兩種微調: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",全對。
✓

知識檢核

// 七題,作答後立即看解析

從零打造 LLM · 第 6 章互動教材 | 講解為原創文字,程式碼與數值對應 Sebastian Raschka《Build a Large Language Model (From Scratch)》Ch.6 的公開程式與執行輸出,非原文翻譯
頁面中的計算皆為瀏覽器即時運算 · 快捷鍵 T 切換主題