Pure Python · Dependency-free · GPT from Scratch

MicroGPT 運作圖解

依純 Python 原始碼,完整呈現 GPT 的 Tokenizer、Autograd、Attention、KV Cache、Adam 數學邏輯

Andrej Karpathy
Andrej Karpathy
@karpathy
Former Director of AI @ Tesla · OpenAI Co-founder
The most atomic way to train and run inference for a GPT in pure, dependency-free Python.
This file is the complete algorithm. Everything else is just efficiency.」

MicroGPT 是 Andrej Karpathy 寫的一個極簡 GPT 實作,整個語言模型——從資料載入、字元級 Tokenizer、純 Python Autograd 引擎、Transformer 架構(Multi-head Attention + MLP)、KV Cache、Adam 優化器,到最終的自回歸文字生成——全部壓縮在單一 199 行的 Python 檔案中,不依賴任何第三方套件(不需要 PyTorch、NumPy、TensorFlow)。

它的目的不是效率,而是最大化可讀性與教育價值:每一行程式碼都直接對應 GPT 論文中的數學概念,是學習 Transformer 運作原理最直接的方式。


199
行程式碼
0
第三方依賴
~1.5K
訓練參數量
1000
訓練 Steps
char
Tokenizer 粒度
⚙️
純 Python Autograd
從零實作 Value 計算圖,Chain Rule 反向傳播,無需 PyTorch
👁️
Multi-Head Attention
Scaled dot-product attention,4 個並行 head,內建 KV Cache
🗄️
KV Cache
推論時 K/V 跨 token 累積,省去 O(T²) → O(T) 重複計算
📈
Adam + LR Decay
自適應梯度下降,β₁=0.85、β₂=0.99,線性學習率衰減
🎲
Temperature Sampling
τ=0.5 控制生成多樣性,以人名資料集訓練後自動生成新名字
📐
RMSNorm(非 LayerNorm)
去掉均值計算,更輕量;GPT-2 用 GeLU,這裡改用 ReLU
GPT 完整處理流程

點擊各步驟可查看說明

📝
Tokenizer
str → int[]
🔢
Embedding
token+pos
📐
RMSNorm
正規化
👁️
Multi-Head Attn
Q K V + KV Cache
🧠
MLP
FC → ReLU → FC
🎯
LM Head
logits → probs

📝 Tokenizer — 字元級別 (Character-level)

MicroGPT 使用最簡單的 字元級 tokenizer,把每個唯一字符映射到一個整數 ID,並加上特殊 BOS(Beginning of Sequence)token。

# 所有唯一字元排序後作為 vocab
uchars = sorted(set(''.join(docs)))
BOS = len(uchars)          # BOS token id = vocab 最後一個
vocab_size = len(uchars) + 1  # 總詞彙量

# 將文字轉成 tokens
tokens = [BOS] + [uchars.index(ch) for ch in doc] + [BOS]
Vocab
$\text{vocab} = \{c_0, c_1, \dots, c_{n-1}, \text{BOS}\}, \quad |\text{vocab}| = n+1$

🔢 Embedding — Token + Position

每個 token ID 從 wte(token embedding table)查向量,每個位置從 wpe(position embedding table)查向量,相加後作為輸入表示。

tok_emb = state_dict['wte'][token_id]  # shape: [n_embd]
pos_emb = state_dict['wpe'][pos_id]   # shape: [n_embd]
x = [t + p for t, p in zip(tok_emb, pos_emb)]
公式
$\mathbf{x} = \text{Emb}_{\text{token}}(\text{id}) + \text{Emb}_{\text{pos}}(\text{pos})$

📐 RMSNorm — Root Mean Square Normalization

比 LayerNorm 更輕量,不計算均值偏移,只用 RMS 進行縮放。在 MicroGPT 中作為 GPT-2 的 LayerNorm 替代。

def rmsnorm(x):
    ms = sum(xi * xi for xi in x) / len(x)   # 均方值
    scale = (ms + 1e-5) ** -0.5             # 縮放因子
    return [xi * scale for xi in x]
公式
$$\text{RMSNorm}(\mathbf{x}) = \frac{\mathbf{x}}{\sqrt{\frac{1}{d}\sum_i x_i^2 + \epsilon}}$$

👁️ Multi-Head Attention

將 embedding 投影到 Q、K、V,分頭計算 scaled dot-product attention,並利用 KV Cache 在推論時避免重複計算。

q = linear(x, state_dict[f'layer{li}.attn_wq'])
k = linear(x, state_dict[f'layer{li}.attn_wk'])
v = linear(x, state_dict[f'layer{li}.attn_wv'])
keys[li].append(k)    # ← KV Cache: 累積 K
values[li].append(v)  # ← KV Cache: 累積 V
# 每個 head 做 scaled dot-product
attn_logits = [sum(q_h[j]*k_h[t][j] for j in range(head_dim)) / head_dim**0.5
               for t in range(len(k_h))]
公式
$$\text{Attn}(Q,K,V) = \text{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}}\right) V$$

🧠 MLP Block — Feed-Forward Network

兩層線性層搭配 ReLU 激活函數(GPT-2 原版用 GeLU,MicroGPT 用 ReLU 降低依賴)。中間維度擴展為 4×n_embd。

x = linear(x, state_dict[f'layer{li}.mlp_fc1'])  # n_embd → 4*n_embd
x = [xi.relu() for xi in x]                      # ReLU 激活
x = linear(x, state_dict[f'layer{li}.mlp_fc2'])  # 4*n_embd → n_embd
x = [a + b for a, b in zip(x, x_residual)]       # Residual connection
公式
$\text{MLP}(\mathbf{x}) = W_2 \cdot \text{ReLU}(W_1 \mathbf{x}) + \mathbf{x}$

🎯 LM Head — Language Model Head

最終的線性層,將 embedding 映射回 vocab 大小的 logits,再透過 softmax 取得各 token 的機率分布。

logits = linear(x, state_dict['lm_head'])  # shape: [vocab_size]
probs = softmax(logits)                       # 機率分布
loss_t = -probs[target_id].log()             # Cross-entropy loss
Cross-Entropy Loss
$$\mathcal{L} = -\log p_{\theta}(y \mid x) = -\log \text{softmax}(\mathbf{z})_y$$
模型超參數 & 參數量
Transformer Block 架構圖
🔤 Input Token + Position
token_id, pos_id → wte + wpe
📐 RMSNorm
正規化輸入向量
👁️ Multi-Head Attention
Q · K / √d_k → softmax → · V (+ KV Cache)
➕ Residual Add
x = x_attn + x_residual
📐 RMSNorm
MLP 前再次正規化
🧠 MLP (FFN)
FC(n_embd→4n) → ReLU → FC(4n→n_embd)
➕ Residual Add
x = x_mlp + x_residual
🎯 LM Head → Logits → Softmax
linear(x, lm_head) → probs[vocab_size]
Autograd — 純 Python 自動微分

MicroGPT 不依賴任何框架,從零實作計算圖(Computation Graph)與反向傳播(Backpropagation)。

Value 節點結構

class Value:
    __slots__ = ('data', 'grad', '_children', '_local_grads')

    def __init__(self, data, children=(), local_grads=()):
        self.data        = data          # 正向傳播的純量值
        self.grad        = 0            # ∂Loss/∂self (反向傳播填入)
        self._children   = children     # 計算圖的子節點
        self._local_grads = local_grads # 對子節點的局部偏導數

基礎運算的局部梯度

加法 a + b
∂/∂a = 1, ∂/∂b = 1
Chain Rule
$\nabla a = 1 \cdot \nabla_{\text{out}}$
乘法 a × b
∂/∂a = b, ∂/∂b = a
Chain Rule
$\nabla a = b \cdot \nabla_{\text{out}}$
冪次 aⁿ
∂/∂a = n·aⁿ⁻¹
Chain Rule
$\nabla a = n a^{n-1} \cdot \nabla_{\text{out}}$
log(a)
∂/∂a = 1/a
Chain Rule
$\nabla a = \frac{1}{a} \cdot \nabla_{\text{out}}$
exp(a)
∂/∂a = exp(a)
Chain Rule
$\nabla a = e^a \cdot \nabla_{\text{out}}$
ReLU(a)
∂/∂a = 1 if a>0
Chain Rule
$\nabla a = \mathbf{1}[a>0] \cdot \nabla_{\text{out}}$

Topological Sort 反向傳播

先對計算圖做拓撲排序,再從 Loss 節點反向遍歷,用 Chain Rule 累積梯度。

def backward(self):
    topo = []
    visited = set()
    def build_topo(v):                    # DFS 建立拓撲序
        if v not in visited:
            visited.add(v)
            for child in v._children: build_topo(child)
            topo.append(v)
    build_topo(self)
    self.grad = 1                          # dLoss/dLoss = 1
    for v in reversed(topo):              # 從 Loss 往輸入反向
        for child, local_grad in zip(v._children, v._local_grads):
            child.grad += local_grad * v.grad  # Chain Rule: ∂L/∂child += local * ∂L/∂v

互動:Chain Rule 計算圖展示

loss = -log(softmax(z)[y]) 為例,點擊節點查看梯度傳播:

Scaled Dot-Product Attention
Attention 公式
$$\text{Attention}(Q, K, V) = \text{softmax}\!\left(\frac{Q K^\top}{\sqrt{d_k}}\right) V$$

MicroGPT 實作

for h in range(n_head):
    hs = h * head_dim
    q_h = q[hs:hs+head_dim]                         # Query 切片
    k_h = [ki[hs:hs+head_dim] for ki in keys[li]]  # 所有 K
    v_h = [vi[hs:hs+head_dim] for vi in values[li]] # 所有 V

    # Scaled dot-product: Q·Kᵀ / √d_k
    attn_logits = [
        sum(q_h[j] * k_h[t][j] for j in range(head_dim)) / head_dim**0.5
        for t in range(len(k_h))
    ]
    attn_weights = softmax(attn_logits)              # 各 token 的注意力分數
    head_out = [
        sum(attn_weights[t] * v_h[t][j] for t in range(len(v_h)))
        for j in range(head_dim)
    ]                                                 # 加權聚合 V

為什麼要除以 √d_k?

數學直覺
當 $d_k$ 維度大時,內積 $Q \cdot K$ 的方差為 $d_k$(標準差 $\sqrt{d_k}$),
除以 $\sqrt{d_k}$ 使 logits 方差回到 1,避免 softmax 飽和導致梯度消失。 $$\text{Var}[Q \cdot K] = d_k \Longrightarrow \text{Var}\!\left[\frac{Q \cdot K}{\sqrt{d_k}}\right] = 1$$

注意力矩陣互動視覺化

輸入一段文字,查看 causal attention mask(下三角)與模擬注意力熱力圖:

(僅顯示 causal mask 結構,權重為模擬值)
高注意力
低注意力
Causal Mask (未來 token)
Multi-Head 的意義
n_head = 4
4 個平行注意力頭
每頭 head_dim = 16/4 = 4
切分
$Q_h = Q[\,h \cdot d_{head}\;:\;(h{+}1) \cdot d_{head}]$
不同子空間
各頭獨立學習
關注不同語義關係
合併
$\text{MultiHead} = \text{Concat}(h_1, \dots, h_H) W^O$
計算量
並行 O(T² · d_k)
T = 序列長度
複雜度
$O(T^2 \cdot d_{\text{model}})$
KV Cache — 推論加速核心

在自回歸推論(auto-regressive decoding)時,每次只輸入「新 token」,過去 token 的 K、V 向量已算好並緩存,不需重算。

KV Cache 概念
第 $t$ 步:只需計算新 token 的 $Q_t, K_t, V_t$
過去的 $K_1 \dots K_{t-1}$、$V_1 \dots V_{t-1}$ 直接從 Cache 讀取
$$\text{Attention}_t = \text{softmax}\!\left(\frac{Q_t \cdot [K_1 \dots K_t]^\top}{\sqrt{d_k}}\right)[V_1 \dots V_t]$$

MicroGPT 的 KV Cache 實作

# 初始化 KV Cache(每層各一個 list)
keys   = [[] for _ in range(n_layer)]   # keys[layer] = list of k vectors
values = [[] for _ in range(n_layer)]   # values[layer] = list of v vectors

# 每次前向傳播:append 新 K, V
keys[li].append(k)     # 新 K 加入 Cache
values[li].append(v)   # 新 V 加入 Cache

# Attention 使用全部歷史 K, V
k_h = [ki[hs:hs+head_dim] for ki in keys[li]]   # 讀 Cache
v_h = [vi[hs:hs+head_dim] for vi in values[li]]  # 讀 Cache

互動演示:逐步生成 token 觀察 Cache 增長

步驟
尚未開始,請點擊「開始」
K Cache
[purple]
V Cache
[green]
有無 KV Cache 的計算複雜度比較
無 KV Cache(每次重算所有 K, V)
$$\text{每步計算量} = O(T^2 \cdot d_{\text{model}}) \quad \text{生成 T 個 token 總計} = O(T^3)$$
有 KV Cache(只算新 token 的 Q)
$$\text{每步計算量} = O(T \cdot d_{\text{model}}) \quad \text{生成 T 個 token 總計} = O(T^2)$$

KV Cache 以 記憶體(存 K、V 向量)換取 時間(省略重複投影),是現代 LLM 推論加速的關鍵技術。

軟 Softmax — 互動計算器

調整 logits 觀察 softmax 輸出如何變化:

Softmax 公式(含數值穩定)
$$\text{softmax}(z_i) = \frac{e^{z_i - \max(\mathbf{z})}}{\sum_j e^{z_j - \max(\mathbf{z})}}$$
def softmax(logits):
    max_val = max(val.data for val in logits)  # 減去 max 防止 exp 溢位
    exps    = [(val - max_val).exp() for val in logits]
    total   = sum(exps)
    return [e / total for e in exps]
RMSNorm 數學詳解
定義
$$\text{RMSNorm}(\mathbf{x})_i = \frac{x_i}{\text{RMS}(\mathbf{x})}, \quad \text{RMS}(\mathbf{x}) = \sqrt{\frac{1}{d}\sum_{i=1}^d x_i^2 + \epsilon}$$
vs LayerNorm
$$\text{LayerNorm}(\mathbf{x})_i = \frac{x_i - \mu}{\sigma + \epsilon}, \quad \mu = \frac{1}{d}\sum x_i, \quad \sigma = \sqrt{\frac{1}{d}\sum(x_i - \mu)^2}$$ RMSNorm 省去均值計算,更高效且在大型模型效果相當

為何在殘差後仍使用 RMSNorm?

程式碼中特別標注 # note: not redundant due to backward pass via the residual connection。殘差連接讓梯度可以直接流過,但前向路徑上的 norm 仍有意義——確保 attention 輸入的尺度一致。

Cross-Entropy Loss
定義
$$\mathcal{L} = -\frac{1}{T} \sum_{t=1}^{T} \log p_\theta(x_{t+1} \mid x_1, \dots, x_t)$$
losses = []
for pos_id in range(n):
    token_id, target_id = tokens[pos_id], tokens[pos_id + 1]
    logits = gpt(token_id, pos_id, keys, values)
    probs  = softmax(logits)
    loss_t = -probs[target_id].log()   # −log p(correct token)
    losses.append(loss_t)
loss = (1 / n) * sum(losses)            # 平均 loss
直覺
當模型對正確 token 的預測機率 $p \to 1$ 時,$-\log p \to 0$(損失極小)
當 $p \to 0$ 時,$-\log p \to \infty$(損失極大),驅動模型學習
Temperature Sampling
帶溫度的 Softmax
$$p_i = \text{softmax}\!\left(\frac{z_i}{\tau}\right), \quad \tau \in (0, 1]$$
temperature = 0.5   # τ ∈ (0,1],越低越確定性
probs = softmax([l / temperature for l in logits])
token_id = random.choices(range(vocab_size), weights=[p.data for p in probs])[0]

$\tau \to 0$:模型永遠選最高機率 token(貪婪);$\tau = 1$:標準分布;$\tau > 1$:更隨機/創意。

Temperature 互動展示

0.50

Adam Optimizer — 自適應梯度下降
Adam 完整公式
$$m_t = \beta_1 m_{t-1} + (1 - \beta_1) g_t \quad \text{(一階矩:梯度方向)}$$ $$v_t = \beta_2 v_{t-1} + (1 - \beta_2) g_t^2 \quad \text{(二階矩:梯度方差)}$$ $$\hat{m}_t = \frac{m_t}{1 - \beta_1^t}, \quad \hat{v}_t = \frac{v_t}{1 - \beta_2^t} \quad \text{(偏差修正)}$$ $$\theta_t = \theta_{t-1} - \frac{\eta_t}{\sqrt{\hat{v}_t} + \epsilon} \hat{m}_t$$

MicroGPT 實作

learning_rate = 0.01
beta1, beta2, eps_adam = 0.85, 0.99, 1e-8
m = [0.0] * len(params)   # 一階矩緩衝 (momentum)
v = [0.0] * len(params)   # 二階矩緩衝 (variance)

# 每個 step 更新
lr_t = learning_rate * (1 - step / num_steps)  # 線性 LR decay
for i, p in enumerate(params):
    m[i] = beta1 * m[i] + (1 - beta1) * p.grad        # 更新 m
    v[i] = beta2 * v[i] + (1 - beta2) * p.grad ** 2  # 更新 v
    m_hat = m[i] / (1 - beta1 ** (step + 1))          # 偏差修正
    v_hat = v[i] / (1 - beta2 ** (step + 1))          # 偏差修正
    p.data -= lr_t * m_hat / (v_hat ** 0.5 + eps_adam)  # 參數更新
    p.grad = 0                                          # 梯度清零

互動:模擬 Adam 更新過程

Step: 0
Learning Rate Decay
線性衰減
$$\eta_t = \eta_0 \times \left(1 - \frac{t}{T}\right), \quad t = 0, 1, \dots, T-1$$

MicroGPT 使用最簡單的線性衰減:訓練開始時 lr = 0.01,最後一步趨近於 0,防止後期震盪。

推論流程 — Auto-Regressive Decoding

訓練完成後,模型以 BOS token 為起點,逐步採樣生成新 token,直到再次出現 BOS 為止。

自回歸生成
$$x_1, x_2, \dots, x_T \sim \prod_{t=1}^{T} p_\theta(x_t \mid x_1, \dots, x_{t-1})$$
temperature = 0.5
for sample_idx in range(20):
    keys, values = [[] for _ in range(n_layer)], [[] for _ in range(n_layer)]
    token_id = BOS          # 起始 token
    sample = []
    for pos_id in range(block_size):
        logits  = gpt(token_id, pos_id, keys, values)   # 前向 (KV Cache 累積)
        probs   = softmax([l / temperature for l in logits])
        token_id = random.choices(range(vocab_size),
                     weights=[p.data for p in probs])[0]
        if token_id == BOS: break  # EOS = BOS
        sample.append(uchars[token_id])

互動:模擬推論步驟動畫

生成序列:
<BOS>
訓練 vs 推論的 KV Cache 差異
🏋️ 訓練時
每次 forward 處理整段序列
keys/values 每個 step 都重建
需要 KV 是為了 backward pass
🚀 推論時
每次只輸入「一個新 token」
KV Cache 跨 token 持續累積
無需重算舊 K、V,大幅提速
microgpt.py  199 lines · 9.5 KB · @karpathy