CH·2從零打造 LLM/ 處理文字資料

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 表變成向量、加上位置資訊。頁面中的關鍵數值都與書中執行輸出一致(示意值會另行標明)。

00

本章定位:幫模型「備料」

整本書的三階段地圖裡,本章是第一階段的第一步:資料準備與取樣——後面所有章節的資料,都從這條管線流出來:

STAGE 1(ch2–4)
打造 LLM 架構本章=① 資料準備與取樣 · 之後:② attention(ch3)③ 架構(ch4)
→ STAGE 2(ch5)
預訓練 pretraining
→ STAGE 3(ch6–7)
微調:分類器 / 助理

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

🔰 先建立心態

這一章做的事一句話就講完:把「一串字元」變成「一疊向量」。中間每一步都只是簡單的機械操作——切開、查表、滑窗、再查表——但每一步的形狀都要看懂,因為第 3 章的 attention、第 5 章的訓練迴圈吃的就是這裡的輸出。順帶劇透:第 5 章訓練用的 data loader,就是本章 2.6 親手寫出來的 create_dataloader_v1。

暖身:整條管線的數字流(本章全部親手算出)

# 《The Verdict》短篇小說 → GPT 的輸入,五次變身(皆為書中真實數值) ① 原始文字 20,479 個字元 ② 斷詞(2.2) → 4,690 個 token(word-level,去空白) ③ 建詞彙表(2.3)→ 排序去重 1,130 個 → 每個 token 一個 ID ④ BPE(2.5–2.6) → 換 GPT-2 斷詞器重切 → 5,145 個 token ID ⑤ 滑動視窗(2.6)→ 切成 batch,形狀 [8, 4] ⑥ embedding(2.7–2.8)→ 查表+位置 → [8, 4, 256] # ← 交棒給第 3 章
2.1

Understanding word embeddings(詞嵌入是什麼)

input:離散資料
文字 / 圖片 / 聲音
→embedding 模型把離散物件對應到連續向量空間的點→output:向量
例如 [1.78, 0.18, -2.10]

深度神經網路(包括 LLM)沒辦法直接處理原始文字——文字是類別型(categorical)資料,跟矩陣乘法、梯度這些數學運算不相容。解法:把每個詞表示成連續值向量,這個「資料 → 向量」的轉換就叫 embedding(嵌入)。影片、聲音也各有自己的 embedding 模型——但彼此不通用,文字的 embedding 模型沒辦法拿去嵌入聲音。

早期的經典做法是 Word2Vec:用「猜上下文」的任務訓練出詞向量,核心直覺是——出現在相似上下文的詞,意思也相近。把高維向量投影到 2 維,就能看到「意思相近的詞聚在一起」。滑過下面的詞試試(位置為示意,依書中圖 2.3 重繪):

第 1 維 →↑ 第 2 維
滑過任何一個詞,看它的「語意鄰居」→
🔰 那 LLM 也用 Word2Vec 嗎?

不用。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)。一步一步來。

2.2

Tokenizing text(把文字斷成 token)

input:原始文字
《The Verdict》20,479 字元
→re.split 斷詞器以空白與標點切開 · 保留大小寫 · 去空白→output:4,690 個 token
(單字與標點)

教材語料是 Edith Wharton 的公有領域短篇小說《The Verdict》。先讀進來看看規模——書中真實輸出:

# with open("the-verdict.txt") as f: raw_text = f.read() Total number of character: 20479 # raw_text[:99] ——開頭長這樣: I HAD always thought Jack Gisburn rather a cheap genius--though a good fellow enough--so it was no

怎麼把它切成 token?書中用 Python 的 re.split 走了三個版本,一版比一版完整。切換版本、或直接改文字,斷詞結果即時重算(與 Python 行為一致):

兩個容易忽略的設計決定:① 不轉小寫——大小寫能幫模型分辨專有名詞、理解句子結構;② 空白要不要留?這裡為了精簡直接丟掉,但若任務對排版敏感(例如 Python 程式碼的縮排),空白就得保留。之後的 BPE 斷詞器(2.5)就是把空白編進 token 裡的。

把定版斷詞器套到整篇小說上:

# preprocessed = re.split(r'([,.:;?_!"()\']|--|\s)', raw_text) + 去空白 len(preprocessed) = 4690 # 整篇小說共 4,690 個 token # 前 30 個 token: ['I', 'HAD', 'always', 'thought', 'Jack', 'Gisburn', 'rather', 'a', 'cheap', 'genius', '--', 'though', 'a', 'good', 'fellow', 'enough', '--', 'so', 'it', 'was', 'no', 'great', 'surprise', 'to', 'me', 'to', 'hear', 'that', ',', 'in']
2.3

Converting tokens into token IDs(token 轉成 ID)

input:4,690 個 token
(字串)
→建 vocabulary + 查表sorted(set(...)) 排序去重 → token:ID 字典→output:token ID
整數 0 – 1129

token 還是字串,模型要的是整數。做法:把全部 token 去重、按字母排序,依序編號——這張「token → 整數」的對照表就是 vocabulary(詞彙表)。《The Verdict》的 4,690 個 token 去重後剩 1,130 個:

# all_words = sorted(set(preprocessed)); vocab = {token:integer ...} vocab_size = 1130 # 前幾筆與第 50、51 筆(標點排最前面): ('!', 0) ('"', 1) ("'", 2) ... ('Her', 49) ('Hermia', 50)

查表實戰:SimpleTokenizerV1(書中真實數值)

把「斷詞+查表」包成一個類別,就是書中的 SimpleTokenizerV1。拿小說裡的一句話 encode 看看——滑過任何一個 token 或 ID,看兩邊怎麼對應:

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

encode → decode 往返:文字真的回得來嗎?

encode(文字→ID)之外還要有 decode(ID→文字),之後模型的輸出才能變回人話。按「下一步」走完一趟往返:

但 V1 有個致命弱點——vocab 是從《The Verdict》建的,沒出現過的詞它完全無能為力:

# 對訓練集以外的句子 encode(書中真實輸出) text = "Hello, do you like tea?" tokenizer.encode(text) KeyError: 'Hello' # 《The Verdict》全文沒有 "Hello" 這個詞 → 查表直接爆炸
這不是小問題。真實世界的輸入永遠會有 vocab 之外的詞(新詞、人名、錯字……)。下一節先用 <|unk|> 補丁擋著,2.5 節再看 GPT 的正解:BPE。
2.4

Adding special context tokens(加入特殊 token)

input:vocab 1,130 個→+ 兩個特殊 token<|endoftext|>=1130 · <|unk|>=1131→output:vocab 1,132 個
SimpleTokenizerV2

在 vocab 的最後面追加兩個「人造 token」:<|unk|>(unknown——查不到表的詞一律換成它)與 <|endoftext|>(文件邊界——訓練時常把多篇不相關的文件串成一長串,中間插上它,模型才知道「這裡換一篇了」):

# all_tokens.extend(["<|endoftext|>", "<|unk|>"]) → 詞彙表最後五筆(書中真實輸出) ('younger', 1127) ('your', 1128) ('yourself', 1129) ('<|endoftext|>', 1130) ('<|unk|>', 1131) # vocab_size:1130 → 1132

實測:把兩句互不相關的話用 <|endoftext|> 接起來,丟給 SimpleTokenizerV2。滑過 token 或 ID 看對應——紅色是被換成 <|unk|> 的詞:

text = "Hello, do you like tea?" + " <|endoftext|> " + "In the sunlit terraces of the palace."
滑過任何一個 token 試試 →
decode(encode(text)) → "<|unk|>, do you like tea? <|endoftext|> In the sunlit terraces of the <|unk|>."
「Hello」和「palace」不在《The Verdict》的詞彙表裡,encode 時被換成 1131——decode 回來就永遠救不回原字了。<|unk|> 是「不會爆炸」的補丁,但資訊有損。
其他常見特殊 token:[BOS](序列開頭)、[EOS](序列結尾,作用同 <|endoftext|>)、[PAD](把一個 batch 裡長短不一的文字補到等長)。GPT 全都不用——只留一個 <|endoftext|>(連 padding 都拿它充當),也不需要 <|unk|>,因為它用的 BPE 斷詞器根本不會遇到未知詞。怎麼辦到的?下一節。
2.5

Byte pair encoding(BPE 子詞斷詞)

input:任何文字
(含從沒見過的詞)
→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 自帶的空白):

tokenizer = tiktoken.get_encoding("gpt2") · 書中真實輸出
滑過任何一個 token 試試 →
tokenizer.decode(integers) → "Hello, do you like tea? <|endoftext|> In the sunlit terraces of someunknownPlace." ✓ 一字不差還原,沒有任何 <|unk|>
看懂兩件事:① 「Hello」在 V1 會 KeyError、在 V2 變 <|unk|>,在 BPE 裡卻是一個好好的 token(15496)——它的詞彙表大得多;② 從沒見過的 someunknownPlace 被拆成 ␣some + unknown + Place 三個「子詞」——查不到整個詞,就用已知的零件拼。零件拆到最細是單一字元,所以任何字串都拼得出來,永遠不需要 <|unk|>。

練習 2.1:拆解 "Akwirw ier"(書中習題)

兩個完全是亂打的「詞」,BPE 也照吃。按「解碼下一個」,逐個揭曉每個 ID 對應的子詞:

tokenizer.encode("Akwirw ier")
BPE 是怎麼練出來的?從「每個字元都是一個 token」起步,反覆把最常一起出現的相鄰字對合併成新 token:像 d + e → de(define、depend、made、hidden 都有它),再把常見子詞合併成整詞。合併多少由頻率門檻決定——常見詞最後是一個 token,罕見詞就停在子詞階段。
2.6

Data sampling with a sliding window(滑動視窗取樣)

input:BPE 後的全文
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——拖動滑桿、按「下一個視窗」實際滑滑看:

max_length = 4 stride = 1
input 視窗target(右移一格)

最後用 PyTorch 的 DataLoader 把視窗兩兩打包成 batch。書中用 batch_size=8, max_length=4, stride=4 取出第一個 batch——滑過任何一列,看 input 列與 target 列的對應(真實輸出):

Inputs([8, 4])
Targets([8, 4])
👆 滑過任何一列:target 永遠是同一列 input 右移一格
為什麼 stride=4?stride=max_length 時視窗不重疊也不跳過,資料用好用滿;stride 太小則相鄰樣本高度重疊,訓練時等於一直看重複內容,可能加重 overfitting。另外 drop_last=True 會把湊不滿 batch 的尾巴丟掉,避免訓練尾端出現 loss 突刺——這些設定第 5 章的訓練迴圈全部沿用。
2.7

Creating token embeddings(做出 token embedding)

input:token ID
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 的真實權重):

embedding_layer(torch.tensor([ ? ])) 點一個 ID:
🔰 embedding 就是「可訓練的查表」

ID 3 → 取出權重矩陣的第 4 列(index 從 0 起算)——就這樣,沒有更多魔法。它在數學上等價於「one-hot 向量 × 權重矩陣」的矩陣乘法,查表只是高效實作;也因為等價於矩陣乘法,它是可以被反向傳播優化的神經網路層。第 5 章訓練時,這張表的每個數字都會被更新。

實戰尺寸只是把表開大:詞彙表 50,257(BPE)× 每列 256 維(書中教學用;GPT-3 用 12,288 維)——光這張表就有 50257 × 256 ≈ 1,286 萬個可訓練參數(換算值)。

2.8

Encoding word positions(把位置編進向量)

input:token_embeddings
[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:

滑過任何一個 token(注意首尾兩個 ID 2)→

而 LLM 的 self-attention 本身也沒有順序概念(第 3 章會看到原因)——「A 咬 B」和「B 咬 A」不能是同一句話,所以要把位置資訊補進向量。GPT 的做法是 absolute positional embedding:再開一張表,這次用「位置」當索引查(位置 0 一列、位置 1 一列……),查出來的向量直接加到 token embedding 上,而且同樣可訓練。按「下一步」,把本章的最終輸出組出來(形狀皆為書中真實輸出):

相對式位置編碼是另一派:不記「我在第幾格」,改記「兩個 token 相距多遠」,對沒看過的長度較容易泛化。GPT 系列選的是絕對式+可訓練——讓模型自己學出最適合資料的位置表示。本頁做出的 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。
✓

知識檢核

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

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