Lesson 01 · 約 20 分鐘
LLM 原理
從 Transformer 到 RLHF
01 ・ 大型語言模型(LLM)是怎麼運作的
搭配程式:
code/01_attention_from_scratch.py(注意力)、code/02_tiny_gpt.py(完整迷你 GPT)
0. 一句話核心
下面是完整配方。

上圖是這份講義的骨架(同一張圖也有向量檔
diagrams/fig1_llm_pipeline.svg,可放大不糊)。
1. 資料(Data)
- 從網路、書、論文、程式碼(GitHub)等抓海量文字,再清洗、去重、過濾品質。
- 前沿模型吃的是「數兆(trillions)個 token」這種等級。
- 資料品質和資料量一樣重要——垃圾進、垃圾出。
2. 斷詞(Tokenization)
- 用 BPE(Byte-Pair Encoding)這類演算法,把文字切成 token(約等於詞片,例如
playing→play+ing)。 - 每個 token 給一個整數 ID。文字 → 一串數字。詞表大小通常是幾萬到二十萬。
- 為什麼不用「字」或「整個詞」?token 是兩者的折衷:比字有意義、又能用有限詞表涵蓋無限詞彙。
3. 架構:Transformer
資料變成數字後,送進模型。Transformer 的流程:
- Embedding:每個 token ID 查表變成一個向量(例如 4096 維)。再加上位置資訊(讓模型知道字的順序,常用 RoPE 等)。
- 疊 N 層相同的 Transformer Block(N 從幾十到上百)。每層做兩件事:
- 自注意力(Self-Attention):讓每個字去「看」其他相關的字(見下節,這是心臟)。
- 前饋網路(FFN / MLP):每個位置各自做一次非線性轉換,大量「知識」存在這裡。
- 兩者外面都包殘差連接(residual)+ LayerNorm,讓深層網路訓練得穩、梯度好傳。
- 輸出:最後一層的向量,經一個線性層映射回「詞表大小的分數」,再 softmax → 下一個 token 的機率分佈。
現代變體:MoE(Mixture-of-Experts)——有很多個 FFN「專家」,每個 token 只啟用其中幾個。參數量很大、但每次計算量不變。前沿模型常用。
3.1 注意力(Attention)——心臟
每個 token 用三個學來的矩陣,算出三個向量:
- Query (Q) =「我在找什麼」
- Key (K) =「我有什麼」
- Value (V) =「我能提供的內容」
token i 對 token j 的注意力權重,是把它們的 Q、K 點積、除以 √d、再 softmax:
點積越大 → 越相關 → 權重越高。然後 token i 的新表示 = 「所有 token 的 V 用這些權重加權平均」。
- 多頭(multi-head):同時做好幾組,各看一種關係(語法、指代、長程依賴…)。
- 因果遮罩(causal mask):第 i 個字只准看第 1..i 個字(看不到未來),這就是 GPT 一個字一個字往下生成的關鍵。
訊號與系統的角度:這像一個**「資料相依的濾波器」——它在做加權求和(和卷積同源),但這個「核」不是固定的,而是依輸入內容(Q·K)當場算出來的。它強的地方在於:任何一個字都能一步**直接連到任意距離的另一個字(不像 RNN 要一格一格傳),所以長程關係抓得準,又能大規模平行訓練。
👉 跑 code/01_attention_from_scratch.py,你會看到一個 4 字序列的因果注意力權重矩陣(下三角、每列加總為 1),把上面的公式變成看得見的數字。
4. 預訓練(Pretraining):預測下一個 token
- 目標:給 token 1..t,預測 token t+1。
- 損失:交叉熵(cross-entropy)= −log P(真正的下一個字)。猜得越準、損失越小。
- 最佳化:反向傳播(backprop) 算出損失對每個參數的梯度,優化器(AdamW) 沿梯度反方向更新參數——這就是梯度下降:
θ ← θ − η · ∇L (η 是學習率,∇L 是損失對參數的梯度)
在數兆 token 上跑很多遍。神奇的是:為了把文字預測準,模型被迫學會文法、事實、推理、翻譯、寫程式——因為這些是「產生那些文字的世界」的一部分。
- 縮放定律(Scaling Laws):表現會隨「資料量 × 參數量 × 算力」可預測地變好。這就是為什麼各大實驗室拚命加大規模。
👉 code/02_tiny_gpt.py 的訓練迴圈,就是這一步的縮小版:你會看到 loss 一路下降。
5. 後訓練(Post-training):把「會接話的怪物」變成助理
預訓練完的模型只會「接話」,不聽指令、也不安全。要再:
- SFT(監督式微調 / 指令微調):用人工整理的「指令 → 好回答」配對微調,教它當助理、聽指令。
- 偏好學習(RLHF / RLAIF):
- 給模型看兩個回答、由人(或 AI)選比較好的;
- 訓練一個獎勵模型(reward model) 來預測這個偏好;
- 再用強化學習(PPO,或更簡單直接的 DPO)讓模型往高分回答靠。這調的是語氣、有用性、誠實、安全。
- Constitutional AI(Anthropic 的方法):不只靠人類回饋,讓模型對照一套寫好的「原則(憲法)」自我批評、自我修正,並用 AI 產生的偏好來訓練(RLAIF)。這就是把怪物塑造成 Claude 的關鍵一步。
6. 推理(Inference):實際使用
- 給提示詞 → 模型吐出下一字的機率分佈 → 取樣(temperature 控制隨機度、top-k / top-p 控制候選範圍)→ 接上 → 再算下一字。一個字一個字生成(自迴歸)。
- Context window:一次能看的 token 數。
- KV cache:把過去算過的 K、V 存起來,讓生成更快。
👉 code/02_tiny_gpt.py 的 generate() 就是這段:訓練後它會生出一段「機器人故事風」的文字。
7. 「手搓」的現實
- 好消息:你真的可以做一個迷你版。
02_tiny_gpt.py就是——幾百行、單機(CPU 也行)就能訓練出一個會生字的 Transformer。這是完整機制,只是縮小。著名的 nanoGPT(Andrej Karpathy)也是同一套。 - 從迷你到 Claude 不是換了想法,而是堆四件事:
- 規模:數兆 token、上萬張 GPU、一次訓練上億美元;
- 資料品質:清洗、配比、過濾的工夫;
- 後訓練手藝:SFT / RLHF / Constitutional AI 的細節;
- 基礎設施工程:分散式訓練、穩定性、效率。
- 換句話說:配方是公開的;護城河是執行力 + 算力 + 資料 + 對齊的 know-how。
8. 你可以動手的下一步
- 先跑
01_attention_from_scratch.py,把注意力看懂。 - 在自己電腦
pip install torch,跑02_tiny_gpt.py,看 loss 下降、看它生成。 - 把
data/input.txt換成更大的文字(例如公開領域的莎士比亞全集),模型會寫得更好。 - 玩超參數:把
n_layer、n_embd、max_iters調大,觀察效果與訓練時間的取捨(這就是縮放定律的直覺)。
🛠 跟著做
共 4 支 · 在本機 venv 跑 · 還沒裝環境?
numpy📄 原始碼
"""
01_attention_from_scratch.py
================================
用最少的程式(只靠 numpy)把 Transformer 的心臟——「自注意力 self-attention」——拆開給你看。
對照講義 docs/01_LLM_原理.md 的「注意力」一節。
核心公式(縮放點積注意力, scaled dot-product attention):
Attention(Q, K, V) = softmax( Q · Kᵀ / sqrt(d_k) ) · V
直覺:
- Query (Q) = 「我在找什麼」
- Key (K) = 「我有什麼」
- Value (V) = 「我能提供的內容」
每個 token 用自己的 Q 去和所有 token 的 K 做點積(算相關度),
正規化成權重後,對所有 token 的 V 做加權平均,得到「融合了上下文」的新表示。
執行:python3 01_attention_from_scratch.py
(不需要 GPU,不需要安裝任何東西,只要 numpy)
"""
import numpy as np
np.random.seed(0)
def softmax(x, axis=-1):
"""數值穩定版 softmax:先減去最大值再取 exp,避免溢位。"""
x = x - np.max(x, axis=axis, keepdims=True)
e = np.exp(x)
return e / np.sum(e, axis=axis, keepdims=True)
def scaled_dot_product_attention(Q, K, V, mask=None):
"""
Q, K, V 形狀都是 (T, d):T 個 token、每個是 d 維向量。
回傳 (輸出 (T, d), 注意力權重 (T, T))。
"""
d_k = Q.shape[-1]
scores = Q @ K.T / np.sqrt(d_k) # (T, T):每個 token 對每個 token 的相關度
if mask is not None:
# 因果遮罩:把「未來」的位置設成 -infinity,softmax 後權重會變 0,
# 確保第 i 個字只能看到第 1..i 個字(這就是 GPT 一個字一個字往下生成的關鍵)。
scores = np.where(mask == 0, -1e9, scores)
weights = softmax(scores, axis=-1) # (T, T):每一列加總為 1
output = weights @ V # (T, d):用權重對 Value 做加權平均
return output, weights
def multi_head_attention(x, n_heads, Wq, Wk, Wv, Wo, causal=True):
"""
多頭注意力:把 d 維切成 n_heads 份,每份各做一次注意力(各看一種關係),最後拼回來再線性轉換。
x: (T, d_model)。Wq/Wk/Wv: (d_model, d_model)。Wo: (d_model, d_model)。
"""
T, d_model = x.shape
d_head = d_model // n_heads
# 1) 把輸入投影成 Q, K, V(這三個投影矩陣就是「學來的參數」)
Q = x @ Wq # (T, d_model)
K = x @ Wk
V = x @ Wv
# 2) 切成多個 head
def split_heads(m):
return m.reshape(T, n_heads, d_head).transpose(1, 0, 2) # (n_heads, T, d_head)
Qh, Kh, Vh = split_heads(Q), split_heads(K), split_heads(V)
# 因果遮罩(下三角為 1)
mask = np.tril(np.ones((T, T))) if causal else None
# 3) 每個 head 各做一次注意力
head_outs = []
head_weights = []
for h in range(n_heads):
out, w = scaled_dot_product_attention(Qh[h], Kh[h], Vh[h], mask)
head_outs.append(out)
head_weights.append(w)
# 4) 把所有 head 拼回 (T, d_model),再經過輸出投影 Wo
concat = np.concatenate(head_outs, axis=-1) # (T, d_model)
output = concat @ Wo
return output, np.stack(head_weights) # 權重 shape: (n_heads, T, T)
def demo():
# 一個小例子:4 個 token,模型維度 d_model = 8,2 個 head
T, d_model, n_heads = 4, 8, 2
tokens = ["the", "cat", "sat", "down"]
# 假裝這是斷詞 + embedding 之後的結果:每個 token 一個 8 維向量(這裡用亂數代表)
x = np.random.randn(T, d_model)
# 學來的權重(這裡用亂數代表;真實模型是訓練出來的)
Wq = np.random.randn(d_model, d_model) * 0.3
Wk = np.random.randn(d_model, d_model) * 0.3
Wv = np.random.randn(d_model, d_model) * 0.3
Wo = np.random.randn(d_model, d_model) * 0.3
out, weights = multi_head_attention(x, n_heads, Wq, Wk, Wv, Wo, causal=True)
print("=" * 60)
print("輸入序列:", tokens)
print("輸入 x 形狀 (T, d_model):", x.shape)
print("輸出 形狀 (T, d_model):", out.shape)
print("=" * 60)
print("第 1 個 head 的注意力權重矩陣 (T x T):")
print("(第 i 列 = 第 i 個字分配給每個字的注意力;因為有因果遮罩,右上角為 0)\n")
header = " " + "".join(f"{t:>8}" for t in tokens)
print(header)
for i, t in enumerate(tokens):
row = "".join(f"{weights[0, i, j]:8.3f}" for j in range(T))
print(f"{t:>8} {row}")
print("\n觀察:'down'(最後一個字)可以看到前面全部的字,")
print(" 'the'(第一個字)只能看到自己 → 權重 1.000。")
print("這就是『因果』自注意力:預測下一個字時,只准看過去。")
if __name__ == "__main__":
demo()
torchnumpy📄 原始碼
"""
02_tiny_gpt.py
================================
一個「迷你 GPT」:用 PyTorch 從頭把 docs/01_LLM_原理.md 講的東西實作出來——
embedding → 多層 Transformer(含因果自注意力)→ 預測下一個字 → 取樣生成。
這就是 Claude / GPT 的完整機制,只是縮到能在你筆電上跑。
需要先安裝 PyTorch(這台沙盒沒網路,所以請在你自己的電腦執行):
pip install torch
然後:
python3 02_tiny_gpt.py
它會讀 data/input.txt(沒有的話用內建文字),訓練幾分鐘(CPU 也可以),最後生成一段文字。
有 GPU 會自動使用。想更快可把下面的 max_iters 調小。
對照:
- token/char embedding ...... docs/01 的「斷詞」「embedding」
- CausalSelfAttention ........ docs/01 的「注意力」公式 softmax(QKᵀ/√d)V(和 01_attention_from_scratch.py 同一套)
- 訓練 loss = cross_entropy .. docs/01 的「預訓練:預測下一個 token」
- generate() 取樣 ............ docs/01 的「推理」
"""
import os
import math
import torch
import torch.nn as nn
from torch.nn import functional as F
# ---------------- 超參數(刻意設小,方便在 CPU 上跑)----------------
block_size = 64 # 一次最多看幾個字(context window)
n_embd = 128 # 每個字的向量維度
n_head = 4 # 注意力的頭數
n_layer = 4 # 疊幾層 Transformer
dropout = 0.1
batch_size = 32
max_iters = 2000 # 訓練步數(想更快可改 500)
eval_interval = 200
eval_iters = 50
learning_rate = 3e-4
device = "cuda" if torch.cuda.is_available() else "cpu"
torch.manual_seed(1337)
# ---------------- 讀資料、建立字元詞表 ----------------
HERE = os.path.dirname(os.path.abspath(__file__))
data_path = os.path.join(HERE, "data", "input.txt")
if os.path.exists(data_path):
with open(data_path, "r", encoding="utf-8") as f:
text = f.read()
else:
text = ("hello world. this is a tiny language model learning to predict "
"the next character. the more it reads, the better it writes. ") * 200
chars = sorted(list(set(text)))
vocab_size = len(chars)
stoi = {ch: i for i, ch in enumerate(chars)} # 字 -> 整數 ID
itos = {i: ch for i, ch in enumerate(chars)} # 整數 ID -> 字
encode = lambda s: [stoi[c] for c in s]
decode = lambda l: "".join(itos[i] for i in l)
data = torch.tensor(encode(text), dtype=torch.long)
n = int(0.9 * len(data))
train_data, val_data = data[:n], data[n:]
def get_batch(split):
"""隨機取一批 (輸入 x, 目標 y):y 是把 x 往右移一格(也就是『下一個字』)。"""
d = train_data if split == "train" else val_data
ix = torch.randint(len(d) - block_size, (batch_size,))
x = torch.stack([d[i:i + block_size] for i in ix])
y = torch.stack([d[i + 1:i + 1 + block_size] for i in ix])
return x.to(device), y.to(device)
# ---------------- 模型零件 ----------------
class CausalSelfAttention(nn.Module):
"""因果(單向)多頭自注意力:實作 softmax(QKᵀ/√d)·V,並遮住未來。"""
def __init__(self):
super().__init__()
assert n_embd % n_head == 0
self.c_attn = nn.Linear(n_embd, 3 * n_embd) # 一次算出 Q、K、V
self.c_proj = nn.Linear(n_embd, n_embd) # 輸出投影
self.attn_drop = nn.Dropout(dropout)
self.resid_drop = nn.Dropout(dropout)
# 下三角遮罩(buffer 不是參數,不會被訓練)
self.register_buffer("mask", torch.tril(torch.ones(block_size, block_size))
.view(1, 1, block_size, block_size))
def forward(self, x):
B, T, C = x.shape
q, k, v = self.c_attn(x).split(n_embd, dim=2) # 各 (B, T, C)
hd = C // n_head
# 切成多頭:(B, n_head, T, head_dim)
q = q.view(B, T, n_head, hd).transpose(1, 2)
k = k.view(B, T, n_head, hd).transpose(1, 2)
v = v.view(B, T, n_head, hd).transpose(1, 2)
# 注意力分數 = Q·Kᵀ / sqrt(head_dim)
att = (q @ k.transpose(-2, -1)) / math.sqrt(hd) # (B, n_head, T, T)
att = att.masked_fill(self.mask[:, :, :T, :T] == 0, float("-inf")) # 遮住未來
att = F.softmax(att, dim=-1)
att = self.attn_drop(att)
y = att @ v # 用權重對 V 加權平均
y = y.transpose(1, 2).contiguous().view(B, T, C) # 拼回 (B, T, C)
return self.resid_drop(self.c_proj(y))
class MLP(nn.Module):
"""每個位置各自做的前饋網路(很多知識存在這裡)。"""
def __init__(self):
super().__init__()
self.net = nn.Sequential(
nn.Linear(n_embd, 4 * n_embd),
nn.GELU(),
nn.Linear(4 * n_embd, n_embd),
nn.Dropout(dropout),
)
def forward(self, x):
return self.net(x)
class Block(nn.Module):
"""一層 Transformer:LayerNorm → 注意力 → 殘差;LayerNorm → MLP → 殘差。"""
def __init__(self):
super().__init__()
self.ln1 = nn.LayerNorm(n_embd)
self.attn = CausalSelfAttention()
self.ln2 = nn.LayerNorm(n_embd)
self.mlp = MLP()
def forward(self, x):
x = x + self.attn(self.ln1(x)) # 殘差連接:讓梯度好傳、訓練更穩
x = x + self.mlp(self.ln2(x))
return x
class TinyGPT(nn.Module):
def __init__(self):
super().__init__()
self.tok_emb = nn.Embedding(vocab_size, n_embd) # 字 -> 向量
self.pos_emb = nn.Embedding(block_size, n_embd) # 位置 -> 向量
self.drop = nn.Dropout(dropout)
self.blocks = nn.Sequential(*[Block() for _ in range(n_layer)])
self.ln_f = nn.LayerNorm(n_embd)
self.head = nn.Linear(n_embd, vocab_size) # 向量 -> 每個字的分數
def forward(self, idx, targets=None):
B, T = idx.shape
pos = torch.arange(T, device=idx.device)
x = self.drop(self.tok_emb(idx) + self.pos_emb(pos)) # 加總字向量與位置向量
x = self.blocks(x)
x = self.ln_f(x)
logits = self.head(x) # (B, T, vocab_size)
loss = None
if targets is not None:
# 交叉熵:比較「模型猜的下一字分佈」和「真正的下一字」
loss = F.cross_entropy(logits.view(-1, vocab_size), targets.view(-1))
return logits, loss
@torch.no_grad()
def generate(self, idx, max_new_tokens, temperature=1.0, top_k=None):
"""從目前的字往後一個一個生成(自迴歸)。"""
for _ in range(max_new_tokens):
idx_cond = idx[:, -block_size:] # 只保留最近 block_size 個字
logits, _ = self(idx_cond)
logits = logits[:, -1, :] / temperature # 只看最後一個位置的預測
if top_k is not None:
v, _ = torch.topk(logits, top_k)
logits[logits < v[:, [-1]]] = float("-inf")
probs = F.softmax(logits, dim=-1)
next_id = torch.multinomial(probs, num_samples=1) # 依機率抽一個字
idx = torch.cat([idx, next_id], dim=1)
return idx
@torch.no_grad()
def estimate_loss(model):
model.eval()
out = {}
for split in ["train", "val"]:
losses = torch.zeros(eval_iters)
for k in range(eval_iters):
x, y = get_batch(split)
_, loss = model(x, y)
losses[k] = loss.item()
out[split] = losses.mean().item()
model.train()
return out
def main():
print(f"裝置:{device} 字元數(詞表大小):{vocab_size} 資料長度:{len(data)} 字")
model = TinyGPT().to(device)
n_params = sum(p.numel() for p in model.parameters())
print(f"模型參數量:{n_params/1e3:.1f}K\n開始訓練(目標:讓 loss 下降)...")
optimizer = torch.optim.AdamW(model.parameters(), lr=learning_rate)
for it in range(max_iters + 1):
if it % eval_interval == 0:
losses = estimate_loss(model)
print(f"step {it:5d} | train loss {losses['train']:.3f} | val loss {losses['val']:.3f}")
x, y = get_batch("train")
_, loss = model(x, y)
optimizer.zero_grad(set_to_none=True)
loss.backward() # 反向傳播:算梯度
optimizer.step() # 梯度下降:更新參數
print("\n===== 生成結果(訓練後)=====")
context = torch.zeros((1, 1), dtype=torch.long, device=device)
out = model.generate(context, max_new_tokens=500, temperature=0.8, top_k=20)
print(decode(out[0].tolist()))
if __name__ == "__main__":
main()
torchnumpy📄 原始碼
"""
02b_tiny_gpt_exercise.py ──「填空練習版」
================================
這是 02_tiny_gpt.py 的練習版:核心三段被挖空成 TODO,由你親手補上。
目的:把「讀得懂」升級成「寫得出來」——這才是路線圖第 3 站真正的里程碑。
(與其一直切出去看影片跟著別人手寫,不如在這裡自己寫、再對答案。)
玩法:
1. 找到三個「✏️ TODO」,照上面的提示把程式碼補完(每個約 1~5 行)。
(三個都要補;執行時不一定照 1→2→3 的順序報錯,把三個都寫完再跑即可。)
2. 執行:python3 02b_tiny_gpt_exercise.py
- 沒寫完會在該處 raise,告訴你還差哪裡。
- 寫對了會開始訓練、loss 下降、最後生成文字。
3. 卡住了,對照解答:02_tiny_gpt.py(同一支的完整版)。
需要 PyTorch:pip install torch
"""
import os
import math
import torch
import torch.nn as nn
from torch.nn import functional as F
# ---------------- 超參數 ----------------
block_size = 64
n_embd = 128
n_head = 4
n_layer = 4
dropout = 0.1
batch_size = 32
max_iters = 2000
eval_interval = 200
eval_iters = 50
learning_rate = 3e-4
device = "cuda" if torch.cuda.is_available() else "cpu"
torch.manual_seed(1337)
# ---------------- 讀資料、建立字元詞表 ----------------
HERE = os.path.dirname(os.path.abspath(__file__))
data_path = os.path.join(HERE, "data", "input.txt")
if os.path.exists(data_path):
with open(data_path, "r", encoding="utf-8") as f:
text = f.read()
else:
text = ("hello world. this is a tiny language model learning to predict "
"the next character. the more it reads, the better it writes. ") * 200
chars = sorted(list(set(text)))
vocab_size = len(chars)
stoi = {ch: i for i, ch in enumerate(chars)}
itos = {i: ch for i, ch in enumerate(chars)}
encode = lambda s: [stoi[c] for c in s]
decode = lambda l: "".join(itos[i] for i in l)
data = torch.tensor(encode(text), dtype=torch.long)
n = int(0.9 * len(data))
train_data, val_data = data[:n], data[n:]
def get_batch(split):
d = train_data if split == "train" else val_data
ix = torch.randint(len(d) - block_size, (batch_size,))
x = torch.stack([d[i:i + block_size] for i in ix])
y = torch.stack([d[i + 1:i + 1 + block_size] for i in ix])
return x.to(device), y.to(device)
class CausalSelfAttention(nn.Module):
def __init__(self):
super().__init__()
assert n_embd % n_head == 0
self.c_attn = nn.Linear(n_embd, 3 * n_embd)
self.c_proj = nn.Linear(n_embd, n_embd)
self.attn_drop = nn.Dropout(dropout)
self.resid_drop = nn.Dropout(dropout)
self.register_buffer("mask", torch.tril(torch.ones(block_size, block_size))
.view(1, 1, block_size, block_size))
def forward(self, x):
B, T, C = x.shape
q, k, v = self.c_attn(x).split(n_embd, dim=2)
hd = C // n_head
q = q.view(B, T, n_head, hd).transpose(1, 2)
k = k.view(B, T, n_head, hd).transpose(1, 2)
v = v.view(B, T, n_head, hd).transpose(1, 2)
# ─────────────────────────────────────────────────────────
# ✏️ TODO 1(心臟):實作 softmax(Q·Kᵀ / sqrt(head_dim)) · V,並遮住未來。
# 步驟提示:
# a) att = q @ k 的轉置(最後兩維互換) / math.sqrt(hd) # (B, n_head, T, T)
# b) 用 self.mask[:, :, :T, :T] == 0 把「未來」位置 masked_fill 成 float("-inf")
# c) att = F.softmax(att, dim=-1),再過 self.attn_drop
# d) y = att @ v,得到加權平均後的 (B, n_head, T, hd)
# (對答案見 02_tiny_gpt.py 的 CausalSelfAttention.forward)
raise NotImplementedError("✏️ 請完成 TODO 1:縮放點積注意力 + 因果遮罩")
# ─────────────────────────────────────────────────────────
y = y.transpose(1, 2).contiguous().view(B, T, C)
return self.resid_drop(self.c_proj(y))
class MLP(nn.Module):
def __init__(self):
super().__init__()
self.net = nn.Sequential(
nn.Linear(n_embd, 4 * n_embd),
nn.GELU(),
nn.Linear(4 * n_embd, n_embd),
nn.Dropout(dropout),
)
def forward(self, x):
return self.net(x)
class Block(nn.Module):
def __init__(self):
super().__init__()
self.ln1 = nn.LayerNorm(n_embd)
self.attn = CausalSelfAttention()
self.ln2 = nn.LayerNorm(n_embd)
self.mlp = MLP()
def forward(self, x):
# ✏️ TODO 2(殘差連接):補上兩條殘差。
# 提示:x = x + 注意力(先過 ln1 的 x);再 x = x + mlp(先過 ln2 的 x)。
# 為什麼是「x + ...」而不是直接覆蓋?殘差讓深層網路梯度好傳、訓練穩定。
raise NotImplementedError("✏️ 請完成 TODO 2:兩條殘差連接")
return x
class TinyGPT(nn.Module):
def __init__(self):
super().__init__()
self.tok_emb = nn.Embedding(vocab_size, n_embd)
self.pos_emb = nn.Embedding(block_size, n_embd)
self.drop = nn.Dropout(dropout)
self.blocks = nn.Sequential(*[Block() for _ in range(n_layer)])
self.ln_f = nn.LayerNorm(n_embd)
self.head = nn.Linear(n_embd, vocab_size)
def forward(self, idx, targets=None):
B, T = idx.shape
pos = torch.arange(T, device=idx.device)
x = self.drop(self.tok_emb(idx) + self.pos_emb(pos))
x = self.blocks(x)
x = self.ln_f(x)
logits = self.head(x)
loss = None
if targets is not None:
# ✏️ TODO 3(訓練目標):用交叉熵比較「模型猜的下一字分佈」與「真正的下一字」。
# 提示:F.cross_entropy(logits.view(-1, vocab_size), targets.view(-1))
# 為什麼要 view 攤平?cross_entropy 想要 (N, C) 的 logits 與 (N,) 的目標。
raise NotImplementedError("✏️ 請完成 TODO 3:交叉熵損失")
return logits, loss
@torch.no_grad()
def generate(self, idx, max_new_tokens, temperature=1.0, top_k=None):
for _ in range(max_new_tokens):
idx_cond = idx[:, -block_size:]
logits, _ = self(idx_cond)
logits = logits[:, -1, :] / temperature
if top_k is not None:
v, _ = torch.topk(logits, top_k)
logits[logits < v[:, [-1]]] = float("-inf")
probs = F.softmax(logits, dim=-1)
next_id = torch.multinomial(probs, num_samples=1)
idx = torch.cat([idx, next_id], dim=1)
return idx
@torch.no_grad()
def estimate_loss(model):
model.eval()
out = {}
for split in ["train", "val"]:
losses = torch.zeros(eval_iters)
for k in range(eval_iters):
x, y = get_batch(split)
_, loss = model(x, y)
losses[k] = loss.item()
out[split] = losses.mean().item()
model.train()
return out
def main():
print(f"裝置:{device} 詞表大小:{vocab_size} 資料長度:{len(data)} 字")
model = TinyGPT().to(device)
n_params = sum(p.numel() for p in model.parameters())
print(f"模型參數量:{n_params/1e3:.1f}K\n開始訓練(目標:讓 loss 下降)...")
optimizer = torch.optim.AdamW(model.parameters(), lr=learning_rate)
for it in range(max_iters + 1):
if it % eval_interval == 0:
losses = estimate_loss(model)
print(f"step {it:5d} | train loss {losses['train']:.3f} | val loss {losses['val']:.3f}")
x, y = get_batch("train")
_, loss = model(x, y)
optimizer.zero_grad(set_to_none=True)
loss.backward()
optimizer.step()
print("\n===== 生成結果(訓練後)=====")
context = torch.zeros((1, 1), dtype=torch.long, device=device)
out = model.generate(context, max_new_tokens=500, temperature=0.8, top_k=20)
print(decode(out[0].tolist()))
if __name__ == "__main__":
main()
📄 原始碼
"""
05_bpe_tokenizer.py
================================
從零做一個 BPE(Byte-Pair Encoding)斷詞器——docs/01 講的「斷詞」這一步。
真實的 GPT/Claude 都用 BPE 把文字切成 token。
做法(位元組級,能處理任何語言,包含中文):
1. 先把文字用 UTF-8 編成位元組(0~255 的整數)。
2. 重複:找出「出現最多次的相鄰一對」,把它合併成一個新 token(新 ID 從 256 開始),記下這條合併規則。
3. 做指定次數後,就得到一張詞表與一組合併規則。
4. encode:照合併規則把文字壓成 token;decode:反過來還原。
執行:python3 05_bpe_tokenizer.py (純 Python,不需任何套件)
參考:Andrej Karpathy 的 minbpe。
"""
def get_stats(ids):
"""統計每個相鄰 pair 出現幾次。"""
counts = {}
for a, b in zip(ids, ids[1:]):
counts[(a, b)] = counts.get((a, b), 0) + 1
return counts
def merge(ids, pair, new_id):
"""把序列中所有的 pair 換成 new_id。"""
out, i = [], 0
while i < len(ids):
if i < len(ids) - 1 and ids[i] == pair[0] and ids[i + 1] == pair[1]:
out.append(new_id)
i += 2
else:
out.append(ids[i])
i += 1
return out
def train_bpe(text, num_merges):
"""訓練:回傳 merges(合併規則)與 vocab(id → bytes)。"""
ids = list(text.encode("utf-8"))
merges = {} # (a,b) -> new_id
vocab = {i: bytes([i]) for i in range(256)} # 前 256 個是原始位元組
for k in range(num_merges):
stats = get_stats(ids)
if not stats:
break
pair = max(stats, key=stats.get) # 出現最多次的相鄰對
new_id = 256 + k
ids = merge(ids, pair, new_id)
merges[pair] = new_id
vocab[new_id] = vocab[pair[0]] + vocab[pair[1]]
return merges, vocab
def encode(text, merges):
"""把文字壓成 token id 序列:依合併規則的順序,能合就合。"""
ids = list(text.encode("utf-8"))
while len(ids) >= 2:
stats = get_stats(ids)
# 找出「最早被學到」的那個可合併的 pair(min merge index)
pair = min(stats, key=lambda p: merges.get(p, float("inf")))
if pair not in merges:
break # 沒有可再合併的了
ids = merge(ids, pair, merges[pair])
return ids
def decode(ids, vocab):
"""把 token id 還原成文字。"""
data = b"".join(vocab[i] for i in ids)
return data.decode("utf-8", errors="replace")
def main():
text = (
"機器學習很有趣,機器學習能讓電腦從資料中學習。"
"深度學習是機器學習的一種,深度學習用神經網路。"
"tokenization tokenization is the first step. "
"the model reads tokens, the model predicts tokens. "
) * 3
print(f"原始文字長度:{len(text)} 個字")
raw_bytes = len(text.encode("utf-8"))
print(f"UTF-8 位元組數:{raw_bytes}")
num_merges = 60
merges, vocab = train_bpe(text, num_merges)
print(f"\n學了 {len(merges)} 條合併規則,詞表大小 = {len(vocab)}")
print("\n前 8 條學到的合併規則(被合併的兩段 → 合併後的字串):")
for i, (pair, nid) in enumerate(list(merges.items())[:8]):
piece = vocab[nid].decode("utf-8", errors="replace")
print(f" {i+1}. {pair} → id {nid} 代表「{piece}」")
sample = "機器學習很有趣"
ids = encode(sample, merges)
print(f"\n編碼範例:「{sample}」")
print(f" → token ids: {ids}")
print(f" → token 數:{len(ids)}(原本 {len(sample.encode('utf-8'))} 個位元組,被壓縮了)")
print(f" → 還原 decode:「{decode(ids, merges and vocab)}」(應與原句相同)")
# 整段壓縮率
all_ids = encode(text, merges)
print(f"\n整段壓縮:{raw_bytes} 位元組 → {len(all_ids)} 個 token"
f"(壓縮率約 {raw_bytes/len(all_ids):.2f}×)")
print("\n重點:BPE 學會把『常一起出現的片段』併成一個 token,")
print(" 所以常見字詞用更少 token 表示——這就是 GPT 處理文字的第一步。")
if __name__ == "__main__":
main()