CH·5從零打造 LLM/ 在無標註資料上預訓練

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

在無標註資料上預訓練Pretraining on unlabeled data

第 4 章組好的 GPT 只會輸出亂碼——因為權重還是隨機的。這一章補上靈魂:先定義「生成得好不好」的數值指標(loss),再寫訓練迴圈實際預訓練它,接著用 temperature 與 top-k 控制生成的隨機性,最後把 OpenAI 訓練好的 GPT-2 權重直接載進我們的模型。頁面中的關鍵數值都與書中執行輸出一致(示意值會另行標明)。

00

本章定位:讓模型「學會講話」

整本書的三階段地圖裡,本章是第二階段:預訓練——把第一階段搭好的架構,變成一個能生成連貫文字的 foundation model:

STAGE 1(ch2–4)
資料處理 · attention · 架構 ✓
→ STAGE 2(本章)
預訓練 pretraining訓練迴圈 · 模型評估 · 載入預訓練權重
→ STAGE 3(ch6–7)
微調:分類器 / 助理

本章的 7 個步驟——會隨你往下讀自動打勾(也可以點擊跳到該節):

🔰 先建立心態

預訓練的邏輯一句話就講完:拿一段文字、遮住下一個字,讓模型猜;猜錯就把權重往「更會猜對」的方向調一點;重複幾百萬次。本章所有內容——loss、訓練迴圈、評估——都是在把這句話變成可以執行的程式。而「無標註資料」的妙處是:下一個字本身就是標準答案,不需要人工標記,網路上的任何文字都能拿來訓練。

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

🏋️ 訓練 training=forward 算預測 → 算 loss → backward 算梯度 → 更新權重——本章的主戲;💬 推論 inference=權重固定、只做 forward、一次吐一個字——5.1.1 的生成回顧與 5.3 的解碼策略屬於這邊。各節標題旁會掛上對應的標籤。

暖身:loss 是整章的方向盤

訓練要「往好的方向調」,前提是先能量化「現在有多差」。這個數字就是 loss(損失):愈小=模型把正確答案的機率估得愈高。先預告本章會看到的三個關鍵數字:

# 這三個數字本章都會親手算出來(真實輸出) 訓練前(隨機權重) loss ≈ 10.98 # 等於在 50257 個字裡亂猜 訓練 10 epoch 後 train loss 0.391 / val loss 6.452 # 這個巨大落差是本章的重要教材(overfitting)
5.1.1

Using GPT to generate text(先讓 GPT 開口)💬 推論

input:文字
"Every effort moves you"
→tokenizer → GPTModel(未訓練)→ 解碼text_to_token_ids · generate_text_simple · token_ids_to_text→output:文字
(亂碼,預期結果)

先把第 4 章的模型架起來。設定和上一章幾乎相同,只改一個地方——點任一行看說明:

👆 點一行 config,看它的意思(注意 context_length 那行)

接著定義兩個貫穿全章的小工具:text_to_token_ids()(文字 → ID tensor,並用 unsqueeze(0) 補上 batch 維)與 token_ids_to_text()(反向)。按播放,看一段文字的完整往返:

start_context = "Every effort moves you" · max_new_tokens = 10
"Every effort moves you"
text_to_token_ids
tiktoken(gpt2)
→
tensor([[6109, 3626,
  6100, 345]])
形狀 [1, 4]
generate_text_simple
接龍 10 個 token
→
[1, 14]
每輪 +1 個 ID
token_ids_to_text
→
Output text: "Every effort moves you rentingetic wasn? refres RexMeCHicular stren"
👆 按「播放」跑一遍:文字 → token ID → 模型生成 → 解碼回文字
亂碼在意料之中——權重是隨機的。但這裡浮現本章第一個核心問題:「連貫」「像人話」是主觀感受,訓練需要的是一個可以微分、可以優化的數字。下一節就來定義它。
5.1.2

Calculating the text generation loss(生成文字的 loss)

input:logits [2, 3, 50257]
+ targets [2, 3]
→cross entropysoftmax → 取 target 位置機率 → log → 平均 → 取負→output:loss
一個數字(10.7940)

用兩句迷你例子把 loss 一步步算出來。inputs 是輸入、targets 是「右移一格」的正確答案(第 2 章 data loader 的老朋友)。滑過任一位置,看那個位置的模型視野與它該預測的答案:

滑過上面任何一個字試試 →

訓練目標=把「正確答案格子」的機率推高

把 logits 過 softmax,每個位置得到一條「下一個字是誰」的機率分佈。書中用 7 個字的迷你詞彙表把這件事畫出來(數值為書中示意):黃框=target(正確答案)的格子,底色=該列 argmax(模型目前會選的字)。切換前後對照:

從 1/7 到 1/50257:示意圖只有 7 個字,所以未訓練的機率在 1/7 ≈ 0.14 附近;真實模型的詞彙表有 50257 個字,未訓練的機率會在 1/50257 ≈ 0.00002 附近——下面馬上看到真實數值。

六步算出 cross entropy loss(書中真實數值)

對真正的 GPTModel(詞彙表 50257)做同樣的事。反向傳播需要一個 loss 函數來衡量「差多遠」——按「下一步」逐步推導:

# PyTorch 一行就把六步全包了(先把 batch 維攤平):
logits_flat  = logits.flatten(0, 1)      # [2, 3, 50257] → [6, 50257]
targets_flat = targets.flatten()         # [2, 3] → [6]
loss = torch.nn.functional.cross_entropy(logits_flat, targets_flat)
# → tensor(10.7940) ✓ 和手算一致
為什麼要取 log?直接最大化機率也行,但機率是很多小數字的連乘,數值上既容易 underflow、優化又難;取 log 把連乘變連加、把 [0,1] 展開到 (−∞, 0],數學上更好操作。深度學習慣例是「把某個量最小化」,所以再乘 −1——這就是「負平均對數機率」= cross entropy。

Perplexity:loss 的白話翻譯

perplexity = exp(loss),可以直觀讀成「模型此刻等效在幾個字之間亂猜」。拖動 loss,看 perplexity 的爆炸性變化:

loss = 10.79 perplexity = e^loss =
5.1.3

Training and validation set losses(訓練/驗證 loss)

input:《The Verdict》全文
20,479 字元 / 5,145 tokens
→切 train / val → data loadermax_length=256 · stride=256 · batch_size=2→output:train_loss 與 val_loss
兩個監控指標

接著把 loss 從「兩句話」推廣到「整份資料集」。資料仍是第 2 章用過的公有領域短篇小說《The Verdict》:20,479 個字元、tokenize 後 5,145 個 token——小到筆電幾分鐘就能跑完一輪訓練。

🔰 為什麼要切出驗證集(validation set)?

只看訓練資料的 loss,就像只用「寫過的考古題」評估學生——背下來就能拿滿分。所以把資料切成兩份:訓練集(讓模型調權重用)和驗證集(模型從沒讀過、只用來測試)。兩個 loss 一起看,才能分辨模型是「真的學會語言」還是「把課文背起來」——這個對照正是本章結尾最大的伏筆。

拖動 train_ratio,看 5,145 個 token 怎麼被切成 256-token 的視窗、再兩兩組成 batch(書中用 0.9):

train_ratio = 0.90
train 視窗(256 tokens)val 視窗不足一個 batch 被丟棄(drop_last=True)
def calc_loss_batch(input_batch, target_batch, model, device):
    input_batch  = input_batch.to(device)     # 有 GPU 就搬去 GPU
    target_batch = target_batch.to(device)
    logits = model(input_batch)
    return torch.nn.functional.cross_entropy(  # 上一節的六步,一行搞定
        logits.flatten(0, 1), target_batch.flatten())

def calc_loss_loader(data_loader, model, device, num_batches=None):
    ...                                        # 逐 batch 呼叫上面的函數、取平均

對還沒訓練的模型跑一次(with torch.no_grad()——只是量測、不用算梯度):

# 書中真實輸出(隨機權重、seed 123) Training loss: 10.98758347829183 Validation loss: 10.98110580444336 # 補充:純亂猜的理論值 = −ln(1/50257) = ln(50257) ≈ 10.82 —— 兩者同量級, # 證明隨機權重的模型確實「毫無頭緒」。訓練的任務:把它壓下去。
💰 順帶一提:真實 LLM 的預訓練有多貴?

我們用 5,145 個 token 練習;Llama 2 7B 用了 2 兆個 token、184,320 個 A100 GPU 小時——照當時雲端價格估算約 69 萬美元。這就是為什麼 5.5 節「直接載入別人訓練好的權重」在實務上如此重要。

5.2

Training an LLM(訓練一個 LLM)🏋️ 訓練

input:train_loader + 隨機權重→train_model_simple10 epochs × 9 batches,AdamW(lr=4e-4, weight_decay=0.1)→output:會寫《The Verdict》腔調的模型
(外加兩條 loss 曲線)

零件到齊,可以組訓練迴圈了。它就是把上方管線圖跑成迴圈:取 batch → forward 算 loss → backward 算梯度 → 更新權重,每 5 步量一次 train/val loss,每個 epoch 結束生成一段樣本讓你肉眼檢查。逐行走一遍:

👆 按「下一行」逐行執行 train_model_simple
AdamW 是什麼?Adam 家族是訓練深度網路的主流 optimizer(會為每個參數自適應調整學習步伐);AdamW 改良了 weight decay 的做法——透過懲罰過大的權重來抑制模型複雜度、對抗 overfitting——是訓練 LLM 的標準配備。這裡用 lr=0.0004, weight_decay=0.1。

實際訓練 10 個 epoch(書中真實輸出)

MacBook Air 上約 5 分鐘跑完。按「下一個 epoch」逐輪觀察:左邊是 loss 曲線(實心點=書中印出的值;空心點=依書中圖 5.12 的平滑內插),下面是該輪結束時模型生成的樣本——看著它從逗號進化成句子:

把結論記成三句話:① 前兩輪 train/val 一起降=模型真的在學語言的統計規律;② 第 2 輪之後 train 續降、val 卡在 6.2–6.5=之後學到的都是「背課文」;③ 證據確鑿——第 9 輪生成的 "quite insensible to the irony" 可以在《The Verdict》原文逐字找到。這叫 memorization(記憶背誦)。
🔰 那這樣訓練還算成功嗎?

以教學目的,算:它證明了訓練迴圈正確、模型有能力學會語言結構。overfitting 嚴重是因為資料太小(5,145 tokens)卻跑了 10 個 epoch。真實 LLM 的做法相反:資料大到「每句話幾乎只看一次」(常常只訓練 1 個 epoch),自然不容易背起來。想試試大資料?書的附錄 D 提供用 Project Gutenberg 60,000 本書訓練的作法。

5.3

Decoding strategies(解碼策略:控制隨機性)💬 推論

input:最後一格 logits
[50257]
→top-k 過濾 → ÷temperature → softmax → multinomial 抽樣取代原本的 argmax→output:下一個 token
(有隨機性、但不亂來)

剛訓練好的模型有個問題:generate_text_simple 用 argmax(永遠挑機率最高的字,稱為 greedy decoding),所以同一個開頭永遠生出同一句話——而且容易整段背出訓練集原文。解法:把「挑最大」改成「按機率抽籤」(torch.multinomial),再用兩個旋鈕控制抽籤的行為。

旋鈕①:temperature scaling(把分佈捏尖或壓平)

書中用 9 個字的迷你詞彙表示範。模型看到 "every effort moves you" 之後輸出這排 logits(下方數值),temperature 就是「把 logits 除以 T 再過 softmax」。拖動 T,機率分佈即時重算(真實計算):

temperature T = 1.0
# next_token_logits("every effort moves you" 之後,書中數值) [4.51, 0.89, -1.90, 6.75, 1.63, -1.62, -1.89, 6.28, 1.79] # 最大是 forward(index 3)
對照書中的 torch 實測(T=1、seed 123、抽 1000 次):573 次量級的 forward、343 次 toward、73 次 closer、2 次 inches、其餘 0 次(實際輸出 582/343/73/2)——大致就是機率 × 1000。本頁的抽樣用瀏覽器內建的固定種子亂數,次數會與 torch 略有出入,分佈趨勢相同。

旋鈕②:top-k sampling(把長尾直接剁掉)

高溫讓輸出多樣,但也讓 "every effort moves you pizza" 這種蠢話有 ~4% 的機會被抽中。top-k 的對策:只保留 logits 最高的 k 個字,其他一律設成 −∞——softmax 後機率變 0,永遠抽不到。拖動 k 看四個步驟(k=3 為書中例子):

top_k = 3
every effort moves you ____

組裝成新的 generate()

def generate(model, idx, max_new_tokens, context_size,
             temperature=0.0, top_k=None, eos_id=None):
    for _ in range(max_new_tokens):
        idx_cond = idx[:, -context_size:]
        with torch.no_grad():
            logits = model(idx_cond)[:, -1, :]
        if top_k is not None:                       # ① 先剁長尾
            top_logits, _ = torch.topk(logits, top_k)
            logits = torch.where(logits < top_logits[:, -1],
                                 torch.tensor(float("-inf")).to(logits.device), logits)
        if temperature > 0.0:                       # ② 再調溫、抽籤
            probs = torch.softmax(logits / temperature, dim=-1)
            idx_next = torch.multinomial(probs, num_samples=1)
        else:                                       # temperature=0 → 回到 argmax(greedy)
            idx_next = torch.argmax(logits, dim=-1, keepdim=True)
        if idx_next == eos_id: break                # 遇到結束符提早收工
        idx = torch.cat((idx, idx_next), dim=1)
    return idx
# 用剛訓練好的模型跑(top_k=25, temperature=1.4, seed 123)——書中真實輸出 Output text: Every effort moves you stand to work on surprise, a one of us had gone with random- # 不再逐字背訓練集了(對照 greedy 的輸出:"know," was one of the axioms he laid...) # 文法還怪怪的正常——模型只看過 5,145 個 token。
什麼時候用什麼設定?低 T +小 k(甚至 greedy)→ 答案穩定、保守,適合事實型任務;高 T +大 k → 多樣、有創意但可能離題,適合創作型任務。temperature=0.0 完全關閉抽樣、回到確定性的 argmax。
5.4

Loading and saving model weights(權重存讀)

input:訓練到一半(或訓練完)的模型→torch.save / torch.load存 state_dict(+optimizer 的 state_dict)→output:.pth 檔
下次不用重練

訓練很貴,練完(或練到一半)當然要存檔。PyTorch 的慣例是存 state_dict——一個「層名 → 權重 tensor」的字典。點下面 checkpoint 的兩把鑰匙,看各自裝了什麼、為什麼續訓時兩個都要存:

torch.save({ ... }, "model_and_optimizer.pth") 點一把鑰匙:
# 只做推論:存模型就夠
torch.save(model.state_dict(), "model.pth")
model = GPTModel(GPT_CONFIG_124M)
model.load_state_dict(torch.load("model.pth", map_location=device))
model.eval()                          # 推論前記得關 dropout!

# 要續訓:模型 + optimizer 一起存
checkpoint = torch.load("model_and_optimizer.pth", map_location=device)
model.load_state_dict(checkpoint["model_state_dict"])
optimizer = torch.optim.AdamW(model.parameters(), lr=5e-4, weight_decay=0.1)
optimizer.load_state_dict(checkpoint["optimizer_state_dict"])
model.train();
5.5

Loading pretrained weights from OpenAI(載入 OpenAI 權重)

input:OpenAI 公開的 GPT-2 權重
(TensorFlow checkpoint)
→load_weights_into_gpt逐層對名字、檢查形狀、搬進我們的 GPTModel→output:會講人話的 GPT-2
(不用花 $690K)

OpenAI 公開了 GPT-2 系列的權重。用書中提供的 gpt_download.py 抓 124M 版本,會下載 7 個檔案:

# download_and_load_gpt2(model_size="124M", models_dir="gpt2") checkpoint 77 B # TF checkpoint 索引 encoder.json 1.04 MB # tokenizer 的詞彙表 hparams.json 90 B # 架構超參數(見下) model.ckpt.data-00000-of-00001 498 MB # 主角:所有權重 model.ckpt.index 5.21 kB model.ckpt.meta 471 kB vocab.bpe 456 kB # BPE 合併規則 settings = {'n_vocab': 50257, 'n_ctx': 1024, 'n_embd': 768, 'n_head': 12, 'n_layer': 12} params.keys() = dict_keys(['blocks', 'b', 'g', 'wpe', 'wte']) params["wte"].shape = (50257, 768) # token embedding;其餘 value 同為 numpy array

四種尺寸,同一套架構——差別只在維度與層數(點選查看;粗體為對 124M 的改動):

載入前要把我們的 config 改回和 OpenAI 一致——點每一行看原因(都是前幾章埋的伏筆):

👆 點一行看它為什麼非改不可

把權重「對名入座」:load_weights_into_gpt

OpenAI 的命名和我們不同,得逐層對應、搬運。輔助函數 assign(left, right) 會先檢查兩邊形狀一致才覆蓋——形狀不合就 raise,這是搬權重時最重要的保險。點一個部件,看它的對應關係:

驗收:它會講人話了

# generate(gpt, "Every effort moves you", max_new_tokens=25, top_k=50, temperature=1.5) Output text: Every effort moves you toward finding an ideal new way to practice something! What makes us want to be on top of that?
「能講人話」就是最好的驗收:搬權重的過程只要有一個小 bug(搬錯層、忘了轉置),模型就會胡言亂語。輸出通順的文字,代表全部 197 個權重 tensor(161 個+qkv_bias 打開後多出的 36 個)對位成功。這個載好權重的模型,就是第 6、7 章微調(fine-tuning)的起點。
▣

本章小結

  • Loss=負平均對數機率(cross entropy):softmax 取 target 機率 → log → 平均 → 取負;未訓練約 10.98 ≈ ln(50257)。perplexity = exp(loss),讀作「等效在幾個字之間亂猜」。
  • 資料切分:train 90% / val 10%;val loss 是唯一能誠實回答「有沒有真的學會」的指標。
  • 訓練迴圈八步:epoch → batch → 清梯度 → forward 算 loss → backward → AdamW 更新 → 定期評估 → 生成樣本肉眼檢查。
  • 實測 10 epochs:train 9.78 → 0.39、val 卡在 6.45——小資料多 epoch 必然 overfitting,模型逐字背出《The Verdict》。
  • 解碼策略:multinomial 抽樣取代 argmax;temperature 把分佈捏尖(<1)或壓平(>1)、top-k 把長尾剁掉;T=0 回到 greedy。
  • 存讀權重:state_dict 是「層名 → tensor」字典;續訓要連 AdamW 的 optimizer state 一起存。
  • 載入 OpenAI 權重:config 改回 context 1024、qkv_bias=True;assign() 驗形狀、attention 的 c_attn 要 split 成 Q/K/V、out_head 與 wte 共用(weight tying)。
✓

知識檢核

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

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