Build a Large Language Model (From Scratch) · Chapter 2
處理文字資料Working with text data
GPT 不吃文字,只吃數字。這一章把一篇 20,479 字元的短篇小說《The Verdict》一路加工成模型能吃的張量:先斷成 token、查表變 token ID、換上 GPT 真正使用的 BPE 子詞斷詞器、用滑動視窗切出一對對 (input, target) 訓練樣本、最後查 embedding 表變成向量、加上位置資訊。頁面中的關鍵數值都與書中執行輸出一致(示意值會另行標明)。
本章定位:幫模型「備料」
整本書的三階段地圖裡,本章是第一階段的第一步:資料準備與取樣——後面所有章節的資料,都從這條管線流出來:
打造 LLM 架構本章=① 資料準備與取樣 · 之後:② attention(ch3)③ 架構(ch4)→ STAGE 2(ch5)
預訓練 pretraining→ STAGE 3(ch6–7)
微調:分類器 / 助理
本章的 8 個步驟——會隨你往下讀自動打勾(也可以點擊跳到該節):
這一章做的事一句話就講完:把「一串字元」變成「一疊向量」。中間每一步都只是簡單的機械操作——切開、查表、滑窗、再查表——但每一步的形狀都要看懂,因為第 3 章的 attention、第 5 章的訓練迴圈吃的就是這裡的輸出。順帶劇透:第 5 章訓練用的 data loader,就是本章 2.6 親手寫出來的 create_dataloader_v1。
暖身:整條管線的數字流(本章全部親手算出)
Understanding word embeddings(詞嵌入是什麼)
文字 / 圖片 / 聲音→embedding 模型把離散物件對應到連續向量空間的點→output:向量
例如 [1.78, 0.18, -2.10]
深度神經網路(包括 LLM)沒辦法直接處理原始文字——文字是類別型(categorical)資料,跟矩陣乘法、梯度這些數學運算不相容。解法:把每個詞表示成連續值向量,這個「資料 → 向量」的轉換就叫 embedding(嵌入)。影片、聲音也各有自己的 embedding 模型——但彼此不通用,文字的 embedding 模型沒辦法拿去嵌入聲音。
早期的經典做法是 Word2Vec:用「猜上下文」的任務訓練出詞向量,核心直覺是——出現在相似上下文的詞,意思也相近。把高維向量投影到 2 維,就能看到「意思相近的詞聚在一起」。滑過下面的詞試試(位置為示意,依書中圖 2.3 重繪):
不用。LLM 的 embedding 是模型輸入層的一部分,跟著訓練一起更新——好處是向量會為「當前任務與資料」量身優化,而不是借用別人練好的通用向量。本章結尾(2.7–2.8)就會親手做出這層。維度也大得多:GPT-2 小模型每個詞 768 維、GPT-3(175B)達 12,288 維——遠超人類能想像的 2、3 維,但數學上就只是更長的一列數字。
接下來的路線:先把文字切成 token(2.2)→ token 轉成 ID(2.3–2.5)→ ID 查表變向量(2.7–2.8)。一步一步來。
Tokenizing text(把文字斷成 token)
《The Verdict》20,479 字元→re.split 斷詞器以空白與標點切開 · 保留大小寫 · 去空白→output:4,690 個 token
(單字與標點)
教材語料是 Edith Wharton 的公有領域短篇小說《The Verdict》。先讀進來看看規模——書中真實輸出:
怎麼把它切成 token?書中用 Python 的 re.split 走了三個版本,一版比一版完整。切換版本、或直接改文字,斷詞結果即時重算(與 Python 行為一致):
把定版斷詞器套到整篇小說上:
Converting tokens into token IDs(token 轉成 ID)
(字串)→建 vocabulary + 查表sorted(set(...)) 排序去重 → token:ID 字典→output:token ID
整數 0 – 1129
token 還是字串,模型要的是整數。做法:把全部 token 去重、按字母排序,依序編號——這張「token → 整數」的對照表就是 vocabulary(詞彙表)。《The Verdict》的 4,690 個 token 去重後剩 1,130 個:
查表實戰:SimpleTokenizerV1(書中真實數值)
把「斷詞+查表」包成一個類別,就是書中的 SimpleTokenizerV1。拿小說裡的一句話 encode 看看——滑過任何一個 token 或 ID,看兩邊怎麼對應:
encode → decode 往返:文字真的回得來嗎?
encode(文字→ID)之外還要有 decode(ID→文字),之後模型的輸出才能變回人話。按「下一步」走完一趟往返:
但 V1 有個致命弱點——vocab 是從《The Verdict》建的,沒出現過的詞它完全無能為力:
Adding special context tokens(加入特殊 token)
SimpleTokenizerV2
在 vocab 的最後面追加兩個「人造 token」:<|unk|>(unknown——查不到表的詞一律換成它)與 <|endoftext|>(文件邊界——訓練時常把多篇不相關的文件串成一長串,中間插上它,模型才知道「這裡換一篇了」):
實測:把兩句互不相關的話用 <|endoftext|> 接起來,丟給 SimpleTokenizerV2。滑過 token 或 ID 看對應——紅色是被換成 <|unk|> 的詞:
[BOS](序列開頭)、[EOS](序列結尾,作用同 <|endoftext|>)、[PAD](把一個 batch 裡長短不一的文字補到等長)。GPT 全都不用——只留一個 <|endoftext|>(連 padding 都拿它充當),也不需要 <|unk|>,因為它用的 BPE 斷詞器根本不會遇到未知詞。怎麼辦到的?下一節。Byte pair encoding(BPE 子詞斷詞)
(含從沒見過的詞)→tiktoken · GPT-2 BPE詞彙表 50,257 · 罕見詞拆成子詞或字元→output:token ID
永遠不會 OOV
GPT-2、GPT-3 與 ChatGPT 的原始模型用的都是 byte pair encoding。自己實作太複雜,書中直接用 OpenAI 的開源庫 tiktoken(Rust 核心,本書用 0.7.0 版)。它的 vocab 有 50,257 個 token,其中 <|endoftext|> 被排在最大的 ID:50256。拿一句故意夾帶怪詞 someunknownPlace 的話實測——滑過 token 或 ID(␣ 代表 token 自帶的空白):
someunknownPlace 被拆成 ␣some + unknown + Place 三個「子詞」——查不到整個詞,就用已知的零件拼。零件拆到最細是單一字元,所以任何字串都拼得出來,永遠不需要 <|unk|>。練習 2.1:拆解 "Akwirw ier"(書中習題)
兩個完全是亂打的「詞」,BPE 也照吃。按「解碼下一個」,逐個揭曉每個 ID 對應的子詞:
Data sampling with a sliding window(滑動視窗取樣)
5,145 個 token ID→GPTDatasetV1 + DataLoadermax_length · stride · batch_size · 滑動視窗→output:(inputs, targets)
兩個 [8, 4] tensor
LLM 的預訓練任務是「預測下一個 token」,所以訓練資料是一對對 (input, target):input 是一段 token、target 是同一段右移一格。先把整篇《The Verdict》用 BPE 重新編碼:word-level 是 4,690 個 token,BPE 則得到 5,145 個(子詞拆分讓數量變多)。書中再跳過前 50 個 token 取 enc_sample = enc_text[50:],示範 context_size=4 時的四個預測任務——按「下一步」逐一展開(真實數值):
x = [290, 4920, 2241, 287]、y = [4920, 2241, 287, 257] 這一對,訓練時會同時當 4 筆「已知前文 → 猜下一個」的題目用(上面四步就是那 4 筆)。模型看不到 target 右邊的字——這個「只准看左邊」的規則,就是第 3 章 causal attention mask 要實作的事。
親手滑一次視窗:max_length 與 stride
資料集類別 GPTDatasetV1 用兩個參數控制切法:max_length(視窗長度)與 stride(每次滑多遠)。下面的磁帶是《The Verdict》開頭經 BPE 的真實前 33 個 token ID——拖動滑桿、按「下一個視窗」實際滑滑看:
最後用 PyTorch 的 DataLoader 把視窗兩兩打包成 batch。書中用 batch_size=8, max_length=4, stride=4 取出第一個 batch——滑過任何一列,看 input 列與 target 列的對應(真實輸出):
drop_last=True 會把湊不滿 batch 的尾巴丟掉,避免訓練尾端出現 loss 突刺——這些設定第 5 章的訓練迴圈全部沿用。Creating token embeddings(做出 token embedding)
tensor([2, 3, 5, 1])→nn.Embedding(6, 3) 查表torch.manual_seed(123) · 權重隨機初始化、可訓練→output:向量
形狀 [4, 3]
ID 只是編號,真正進模型的是向量。nn.Embedding 說穿了就是一張表:每個 ID 對應一列隨機初始化的向量,訓練時由反向傳播慢慢調整。書中用迷你例子示範:詞彙表只有 6 個 token、每列 3 維。點下面任一個 ID,看查表怎麼運作(矩陣為 seed 123 的真實權重):
ID 3 → 取出權重矩陣的第 4 列(index 從 0 起算)——就這樣,沒有更多魔法。它在數學上等價於「one-hot 向量 × 權重矩陣」的矩陣乘法,查表只是高效實作;也因為等價於矩陣乘法,它是可以被反向傳播優化的神經網路層。第 5 章訓練時,這張表的每個數字都會被更新。
實戰尺寸只是把表開大:詞彙表 50,257(BPE)× 每列 256 維(書中教學用;GPT-3 用 12,288 維)——光這張表就有 50257 × 256 ≈ 1,286 萬個可訓練參數(換算值)。
Encoding word positions(把位置編進向量)
[8, 4, 256]→+ pos_embeddings [4, 256]依位置查另一張表 · broadcast 相加→output:input_embeddings
[8, 4, 256] → 進 GPT
token embedding 有個特性:同一個 ID 不管出現在句子第幾個位置,查到的向量一模一樣。用書中圖 2.17 的例子(token 序列 [2, 3, 5, 2],首尾都是 2)——滑過下面的 token:
而 LLM 的 self-attention 本身也沒有順序概念(第 3 章會看到原因)——「A 咬 B」和「B 咬 A」不能是同一句話,所以要把位置資訊補進向量。GPT 的做法是 absolute positional embedding:再開一張表,這次用「位置」當索引查(位置 0 一列、位置 1 一列……),查出來的向量直接加到 token embedding 上,而且同樣可訓練。按「下一步」,把本章的最終輸出組出來(形狀皆為書中真實輸出):
input_embeddings [8, 4, 256] 就是第 3 章 attention 機制的直接輸入。本章小結
- Embedding=把離散資料(詞)對應到連續向量空間的點;LLM 的 embedding 是輸入層的一部分、隨訓練更新(GPT-2 small 768 維、GPT-3 12,288 維)。
- 斷詞:re.split 以空白與標點切開、保留大小寫;《The Verdict》20,479 字元 → 4,690 個 word-level token。
- Vocabulary:排序去重得 1,130 個 token、各配一個 ID;SimpleTokenizerV1 能 encode/decode,但遇到 vocab 外的詞("Hello")直接 KeyError。
- 特殊 token:V2 加 <|endoftext|>(ID 1130,文件邊界)與 <|unk|>(ID 1131);GPT 實際只用 <|endoftext|>(連 padding 也用它)。
- BPE:GPT-2 的斷詞器,詞彙表 50,257;未知詞拆成子詞或字元(someunknownPlace → some+unknown+Place),永遠不會 OOV。
- Sliding window:target=input 右移一格;stride=max_length 時不重疊不遺漏;create_dataloader_v1 產出 [8, 4] 的 inputs/targets——第 5 章訓練直接用它。
- 兩張表相加:token embedding(查 ID)+ positional embedding(查位置)→ input_embeddings [8, 4, 256],交棒給第 3 章的 attention。
知識檢核
// 七題,作答後立即看解析
頁面中的計算皆為瀏覽器即時運算 · 快捷鍵 T 切換主題