MicroGPT

4,192 個數字,怎麼學會取名字

一個 200 行、零依賴的純 Python GPT — 拆到只剩加法和乘法
BOS Tokenizer Embedding Attention + MLP lm_head 抽樣生成時的隨機 e 抽到的字母回頭當下一輪輸入(自迴歸)
4,192
全部參數
200
行純 Python
1
層 transformer
16
維向量
27
vocab(a–z+BOS)
← → 翻頁 ・ n 講者備註 ・ f 全螢幕 ・ 貫穿全篇的例子:生成 emma

開場它到底在做什麼

模型要做的,從頭到尾只有一件事 看過的字母 e m 模型 下一個字母的機率(27 個) a41% m22% e9% ⋮ z0.1% 它不會「決定」答案,它只給 27 個比例。 真正抽出一個字母的,是外面那一行 random.choices 訓練和推論都會呼叫同一個 gpt():本篇多數篇幅在於「拿現成的數字算一次」; 「學習」是在這個函數外面繞一圈迴圈、回頭修那些數字。 資料的形式、以及如何轉成數字——見圖 1。
microgpt_en.py・全篇只有這個函數簽名輸入 → 輸出
108	def gpt(token_id, pos_id, keys, values):
	# ★ 輸入:目前這個字母、它的位置
⋯
143	    logits = linear(x, state_dict['lm_head'])
144	    return logits
	# ★ 輸出:27 個字母各自的原始分數(還不是機率)
全篇的任務:輸入前文 → 輸出下一個字母的 27 個機率(softmax(logits) 負責把分數轉成機率)

圖 0這個模型有多小

整個模型的全部知識 = 5 組參數 每個矩陣就是一張數字表。表格大小相乘=格子數,全部加起來就是下面那個 4,192。 wte 27 × 16・字母 → 向量(查表) 432 wpe 16 × 16・位置 0–15 的向量 256 lm_head 27 × 16・向量 → 字母(反查表) 432 attention Wq Wk Wv Wo・各 16 × 16 1,024 MLP 16 → 64 → 16 2,048 合計 4,192 個數字,全部可以手算 432+256+432+1,024+2,048 而且只有一層——一層就已經包含 attention、MLP、殘差的全部;多層只是同一件事再做一次。 對照組 GPT-2 small 124,000,000 × 30,000・無法按比例繪製 現代大模型 100,000,000,000+
microgpt_en.py・參數初始化num params: 4192
75	n_layer = 1     # depth of the transformer …
76	n_embd = 16     # width of the network …
77	block_size = 16 # maximum context length …
78	n_head = 4      # number of attention heads
79	head_dim = n_embd // n_head # derived dimension …
	# ★ 五個超參數:層數・寬度・上限格數・頭數・每頭 4 維
80	matrix = lambda nout, nin, std=0.08:
	↪ [[Value(random.gauss(0, std)) for _ in range(nin)]
	↪  for _ in range(nout)]
	# ★ matrix:造一個矩陣;Value=數字+一張便條
81	state_dict = {'wte': matrix(vocab_size, n_embd),
	↪ 'wpe': matrix(block_size, n_embd),
	↪ 'lm_head': matrix(vocab_size, n_embd)}
	# ★ state_dict:知識收納櫃;vocab_size=27
82	for i in range(n_layer):
83	    state_dict[f'layer{i}.attn_wq'] =
	↪         matrix(n_embd, n_embd)
⋯
87	    state_dict[f'layer{i}.mlp_fc1'] =
	↪         matrix(4 * n_embd, n_embd)
⋯
89	params = [p for mat in state_dict.values()
	↪ for row in mat for p in row] # flatten …
	# ★ params:攤平成一條 list 的 4,192 個數字
90	print(f"num params: {len(params)}")

暖身 0程式裡只有兩種數字

① 學來的(參數) ② 算出來的(中間值) wte wpe lm_head Wq Wk Wv Wo fc1 fc2 x q k v logits probs 共 4,192 個,就是前一張那張表 跑幾次都不變 住在 state_dict 裡 只有「訓練」會改它 每一輪從頭算,算完就丟 每個名字都重新算一遍 通常算完就丟;k、v 仍是中間值 cache 僅是讓它們跨步保留一段時間 模型「會」的東西 =它的知識 模型「正在想」的東西 =它的思考過程 判斷任一新變數的方式只有一個問題:它屬於左邊還是右邊? state_dict[...] 開頭的都是左邊,其他幾乎都是右邊。訓練=把左邊調好;推論=拿左邊算出右邊。特例是 keys/values:屬於右邊,卻會跨輪保留(見圖 9 的 KV cache)。

暖身 1每次呼叫,都沿著一個 16 維 x 前進

其形式為一個裝著 16 個小數的 Python list: x = [ 0.13, -0.82, 0.55, … , -0.07 ] 這 16 個數字,濃縮了 「這個字母+它前面所有字母」到此刻的理解 加完三次料、進出口(lm_head)之前: 「此刻的意義」與「對下一個字母的預測」 已同時濃縮在這 16 個數字裡 ——圖 7 說明:這兩件事實為同一件事 每一步的動詞都是「+」——三個加號 整個程式=對同一個 x 不停「加料」(生成 emma 走到第 3 步): 出生・wte 查表 「代表字母 m」 查表 + wpe 知道位置 「第 3 格的 m」 查表 + 一份「前文的料」 拌入前文 「前文為 e、m」 attention + 一份「加工的料」 推論完成 「下一個很可能是 a」 MLP 後續每一張圖回答的都是同一個問題: 「這一步往 x 裡加了什麼料?」 這個 x 從頭到尾,只會被三種運算對待

暖身 2① 相加:向量+向量 — 把兩份理解疊在一起

這個模型從頭到尾只有三種運算,以下三頁各介紹一種,皆以同一個例子示範。 「代表字母 m」 [ 0.3, -0.1, 0.7, … ] ← wte 的一列 + 「位置為第 3 格」 [ 0.1, 0.4, -0.2, … ] ← wpe 的一列 「第 3 格的 m」 [ 0.4, 0.3, 0.5, … ] 兩張便利貼疊起來 相加的第二個用途:把「修正量」疊上去(attention/MLP 算出來的料) x + 修正量 更新後的 x 新的 x = 舊的 x + 修正量 (這叫「殘差」) 關鍵是舊的 x 一定會出現在等號右邊——所以它不會被洗掉,只是被疊上東西 加法只會疊、不會變——要產生「正在尋找母音」這類新理解,須靠第二種運算:相乘
microgpt_en.py・相加的三次上場zip=兩個 list 並排走
111	x = [t + p for t, p in zip(tok_emb, pos_emb)] # …
	# ★ x、tok_emb、pos_emb:主角本人+兩張便利貼
⋯
134	    x = [a + b for a, b in zip(x, x_residual)]
	# ★ 左邊是新的 x,右邊的 x_residual 是舊的 x——舊的沒被丟掉
⋯
141	    x = [a + b for a, b in zip(x, x_residual)]
	# ★ 一模一樣的一行:attention 之後一次,MLP 之後一次

暖身 3a② 相乘(一):兩個向量的內積

兩個向量,各 16 個數字。這一頁只做一件事:把它們的內積算完一次。 x = [ 0.4, 0.3, 0.5, … ] ← 主角,那個「第 3 格的 m」 w = [ 0.2, -0.1, 0.9, … ] ← 一個「問句」,例如「是否為母音?」   它裡面的數字是訓練學出來的,不是人寫的 內積=逐項相乘,再全部加總 0.4×0.2 + 0.3×(−0.1) + 0.5×0.9 + … 0.08 −0.03 0.45 0.73 剩下 13 項 1.23 ← 只有一個數字 兩個 16 維向量進去,出來一個數 Python 就是這一行 sum(wi * xi for wi, xi in zip(w, x)) 逐項相乘 zip:兩個 list 並排走,每次取出一組 (wi, xi) sum(…)=把它們全部加起來 這個數字代表什麼 「x 與這個問句的相容度分數」 大正數 → 強烈支持・接近 0 → 影響較小 負數 → 反向影響 另一個因素:向量本身數字越大, 內積也跟著變大(圖 4a 會用到) 一個內積只產生一個數字,但主幹上需要的是 16 個數字——因此須執行 16 次。

暖身 3b② 相乘(二):矩陣就是一疊向量

Python 裡沒有「矩陣」這種型別——矩陣即巢狀 list(list 內含 list),一列一個向量。 w = [ [ 0.2, -0.1, 0.9, … ], [ 0.7, 0.3, -0.4, … ], ⋮ [-0.5, 0.8, 0.1, … ] ] ← 第 0 列,16 個數字 ← 第 1 列 ← 第 15 列 每一列自己就是一個 16 維向量——這裡總共 16 列 (列數由矩陣本身決定:fc1 有 64 列、lm_head 有 27 列。須留意:暖身 3a 的 w 是「一個」向量,此處的 w 是「一疊」) x 與「每一列」各做一次內積 x(16 個數) x · 第 0 列← 即暖身 3a 的結果 1.23 x · 第 1 列 −1.20  ⋮ ⋮ x · 第 15 列 0.34 有幾列,出來就是幾個數字 這個矩陣 16 列,所以又是一個 16 維向量 程式:外層跑每一列,內層即前述那一行 def linear(x, w): return [sum(wi * xi for wi, xi in zip(wo, x)) for wo in w] 跟一列做內積 wo 逐一取出每一列(小寫 wo=「其中一列」,跟右邊大寫的 Wo 無關) matrix = lambda nout, nin, …: [[… for _ in range(nin)] for _ in range(nout)] 造矩陣也是巢狀 list:外層跑 nout 次=幾列,內層跑 nin 次=一列有幾個數字 全篇只用 7 次,動作都一樣 Wq Wk Wv Wo ← attention fc1 fc2 ← MLP lm_head ← 出口 差別只在那些列裡裝什麼數字

暖身 4a③ 非線性 — 神經網路裡的 if(主角 relu)

relu=一道門檻:不夠強的訊號一律歸零 −1.2 +3.4 −0.8 +0.1 relu 0 +3.4 0 +0.1 負的一律歸零,正的原樣通過——沒有 if,程式只是一條算式 為什麼一定要有這一步 相加是疊、相乘是換角度——這兩種都是線性的 非線性是唯一能製造「分情況」的地方 MLP 若移除 relu,兩次線性轉換可合併成一次——中間那一層完全失去作用 softmax 同樣具非線性,但它是配角;「只有三種運算」仍然成立 三種運算到此為止:① 相加 ② 相乘(內積/矩陣) ③ 非線性(relu) 後續每一個步驟,皆可歸入其中一種
microgpt_en.py・非線性relu 的微分:0 或 1
50	def relu(self): return Value(max(0, self.data),
	↪     (self,), (float(self.data > 0),))
	# ★ self.data:數值本體;括號=便條
⋯
139	x = [xi.relu() for xi in x]   # 16 個數字各自過門檻
便條是 0 或 1・未開啟的偵測器不會被調整
	# relu 的 _local_grads=(1,) 或 (0,)
	# 0 → 這一輪梯度完全不往回傳(圖 9.5)

暖身 4brmsnorm — 零參數的音控台

rmsnorm=音控台:只調音量、不動方向 [ 3.2, -1.6, 4.8, … ] ← 加料疊久了、乘過幾個矩陣,音量會漂 ÷ RMS=4(16 個數字的均方根,示意) [ 0.8, -0.4, 1.2, … ] ← 方向沒變,音量回到標準 它上工三次,每次都在做內積之前 進主幹前 L112 attention 之前 L117 MLP 之前 L137 共同的模式:先把音量統一,再拿去跟別的向量比對 若不做:音量一路變大,內積出來的分數就沒有共同尺度,「像不像」的比較會失準 softmax 也會被極端值主導,比例幾乎全部集中在同一格 零參數:4,192 個數字裡沒有半個屬於它——它不是知識,是水電 rmsnorm 與 softmax 都是配角——「只有三種運算」仍然成立
microgpt_en.py・rmsnorm三行、零參數
103	def rmsnorm(x):   # ★ rmsnorm:音控台
104	    ms = sum(xi * xi for xi in x) / len(x)   # ms:平均音量
105	    scale = (ms + 1e-5) ** -0.5   # scale:調回標準的倍率
106	    return [xi * scale for xi in x]   # 只動音量,不動方向
上工的三個位置・都緊接在 linear 之前
112	    x = rmsnorm(x)   # 進主幹前
⋯
117	    x = rmsnorm(x)   # attention 之前
⋯
137	    x = rmsnorm(x)   # MLP 之前

暖身 5Value:讓訓練有可能發生的水電

一般的數字 0.13 一個 Value 0.13 .data + 一張便條: 「來源為何,對各來源的微分值多少」 _children / _local_grads 前向(算出答案) 與普通小數完全一樣,可逕行忽略 反向(訓練) 順著便條回頭走,才問得出 「每個參數往哪動一點,loss 會變小」 (訓練時手算一次) Value 不是模型的一部分,它是讓訓練有可能發生的基礎設施。 後續內容中,皆可將其視為普通的 float 閱讀。
microgpt_en.py・僅需這幾行L30–72・全檔 1/3
30	class Value:
⋯
33	def __init__(self, data, children=(),
	↪              local_grads=()):
34	    self.data = data      # ★ 數值本體
	# 前向只用得到 .data
⋯
39	def __add__(self, other):
⋯
41	    return Value(self.data + other.data,
	↪                 (self, other), (1, 1))
	# ★ 加法:算出和,順手寫下便條
	#   「來源為這兩個,微分值都是 1」
純 Python、零依賴的代價:PyTorch 內建的那張便條,此處以 40 行自行實作。

圖 0.5a前 100 行,都還沒進入 Transformer

基礎準備(infrastructure)——都是水電,沒有一行是 Transformer L9–27 ① 資料 + tokenizer 文字 → 整數 見圖 2 L30–72 ② Value / autograd 讓數字帶著便條 見暖身 5 L75–90 ③ state_dict + params 4,192 個知識 見圖 0 L94–106 ④ 三個工具函數 linear/softmax/rmsnorm 見暖身 3b・4b L108–144 ⑤ def gpt(...) 模型本體,只有三十幾行——本篇全部聚焦於此 L150–199 ⑥ 訓練迴圈/推論迴圈 在 gpt() 外面繞圈(見圖 9.5、圖 10) 「200 行的 GPT」不等於模型有 200 行。 前 100 行都是準備,模型本體只有中間那三十幾行——那才是要看懂的部分。

圖 0.5b怎麼呼叫 gpt():四個參數是什麼

logits = gpt( token_id, pos_id, keys, values ) token_id 現在手上這「一個」字母的編號 0–26 pos_id 當前的格數位置 0–15 keys, values 前文留下的 k、v 唯一跨輪保存的中間值 (見圖 9) 回傳 logits = 27 個分數,下一個字母各一個 (見圖 7) ★ 最該點破的一件事 在這個實作裡,gpt() 每次只接收一個 token。 「前面的 em」則保存在 keys/values 之中。 一次呼叫 = 一個字母進去、27 個分數出來; 前文不在參數列,前文在 cache 裡。
microgpt_en.py・簽名與回傳模型本體 L108–144
108	def gpt(token_id, pos_id, keys, values):
	# ★ gpt:模型本體,僅此一個函數
109	    tok_emb = state_dict['wte'][token_id]
110	    pos_emb = state_dict['wpe'][pos_id]
⋯
144	    return logits
	# 27 個分數,如此而已
⋯
194	logits = gpt(token_id, pos_id, keys, values)
	# ★ 外面的迴圈這樣呼叫它
對照暖身 0 的「兩種數字」:keys/values 仍是算出來的中間值,只因 cache 而跨步保留。

圖 1Overview — 一次前向,回傳下一字的 27 個分數

em Tokenizer 字元 → id [e]=4 Embedding wte[id] + wpe[pos] Attention + MLP 這一格就叫「Block」 lm_head 16 → 27 分數 27=候選字母數 抽樣 溫度・加權隨機 生成流程唯一的隨機選擇 m 抽到的字母回頭當下一輪的輸入(自迴歸) 每輪 append 讀回全部 每層各一份 KV cache 過去每個字母的 k, v 跨輪留下來的狀態,不是流過去的資料 形狀 字母 整數 16 16 16 27 抽樣 1 個字母 中間全程都是 16 維
microgpt_en.py・推論迴圈整條流水線僅 11 行
189	for sample_idx in range(20):   # ★ sample_idx:第幾個名字
190	    keys, values = [[] for _ in range(n_layer)],
	↪         [[] for _ in range(n_layer)]
	# ★ keys、values:KV cache 本體——只進不出(見圖 9)
191	    token_id = BOS   # ★ token_id:現在手上這個字母的編號
192	    sample = []   # ★ sample:已經生出來的字母們
193	    for pos_id in range(block_size):
194	        logits = gpt(token_id, pos_id, keys, values)
	# ★ gpt:整個模型=一個函數;logits:27 個分數
195	        probs = softmax([l / temperature for l in logits])
	# ★ probs:27 個機率;temperature 見圖 8
196	        token_id = random.choices(range(vocab_size),
	↪             weights=[p.data for p in probs])[0]
197	        if token_id == BOS:
198	            break
199	        sample.append(uchars[token_id])

圖 2Tokenizer — 僅是字元編號

訓練資料裡出現過的所有字元,排序後編號: a 0 b 1 c 2 e 4 ⋯ z 25 + BOS 26 → vocab = 27 26 個字母+1 個特殊符號 BOS = Beginning Of Sequence,字面是「開頭」,但這份程式把它當成「分隔線」用 emma [ 4, 12, 12, 0 ] 一個字母一個 token——沒有 BPE、沒有 subword 加上開頭與結尾——故意用同一個符號(一條分隔線,兩邊都用它): BOS 26 e 4 m 12 m 12 a 0 BOS 26 ▲ 「開始了」 ▲ 「結束了」 「模型怎麼知道要停?」 停止也是一個模型須學會預測的 token——生成時抽到 BOS 即結束。 因此 BOS 會同時出現在最前面與最後面——兩者是同一個 26 號。
microgpt_en.py・Tokenizer僅 sorted(set(…)) 一行
24	uchars = sorted(set(''.join(docs))) # unique characters …
	# ★ uchars:字元排序表;docs:全部 32,033 個名字
25	BOS = len(uchars) # token id for a special Beginning …
	# ★ BOS:開頭兼結尾的特殊符號=26(vocab_size=27 的由來)
26	vocab_size = len(uchars) + 1 # total number of unique …
訓練時・BOS 首尾包夾
157	tokens = [BOS] + [uchars.index(ch) for ch in doc] + [BOS]
	# ★ tokens:一筆名字變成的編號串 [26, 4, 12, 12, 0, 26]
生成時・抽到 BOS 即結束
196	token_id = random.choices(range(vocab_size),
	↪     weights=[p.data for p in probs])[0]
197	if token_id == BOS:
198	    break

圖 3Embedding — 從符號進入語意空間

token id = 4(「e」) 位置 pos = 1 wte(27 列 × 每列 16 個數字) 第 4 列=「e」的向量 ⋮ wpe(16 列 × 每列 16 個數字) 第 1 列=位置 1 的向量 ⋮ [ 16 個數字 ] [ 16 個數字 ] + 相加兩份意思疊在一起 rmsnorm ← 音控台:把整串數字的音量  拉回標準,方向不變 進入主幹(16 維) residual stream 之後每一步,都只是往這條線上「加」東西 這裡是「符號」變成「意義」的轉折點 wpe 是模型唯一取得位置資訊的管道——若移除它,emma 和 amme 完全相同。 兩者相加並不會互相混淆:16 個數字的空間夠大,字母的意思和位置的意思可以放在不同方向上; 之後每個矩陣還是問得出各自要的那一件事。這也是訓練要學的事情之一。
microgpt_en.py・gpt() 的第一站兩次查表+一次相加
108	def gpt(token_id, pos_id, keys, values):
109	    tok_emb = state_dict['wte'][token_id] # token embedding
110	    pos_emb = state_dict['wpe'][pos_id] # position embedding
	# 前述兩張便利貼——此處是它們的來源
111	    x = [t + p for t, p in zip(tok_emb, pos_emb)] # joint …
	# 「相加」正式上場
112	    x = rmsnorm(x) # note: not redundant due to backward …
	# 音控台(rmsnorm)正式上工

圖 3bcontext length 上限,就是 wpe 矩陣的列數

wpe 是一個只有 block_size = 16 列的矩陣: pos 0 [ 16 個數字 ] pos 1 [ 16 個數字 ] ⋮ pos 15 [ 16 個數字 ] block_size = 16 (最長的名字=15 個字母, 再加開頭那個 BOS=16 格) pos 16 ✗ 沒有這一列 查不到 → 拋出 IndexError 不是效果變差,是物理上不存在 位置資訊有兩種提供方式: 學出來的 就是普通參數,跟 wte 一樣訓練 GPT-2・本專案 寫死的公式 用 sin/cos 算出來,不訓練 原始 Transformer 論文
microgpt_en.py「context 128K」的實際構成
77	block_size = 16 # maximum context length
	↪ of the attention window
	↪ (note: the longest name is 15 characters)
矩陣大小・16 列,一列一個位置
81	state_dict = {'wte': matrix(vocab_size, n_embd),
	↪ 'wpe': matrix(block_size, n_embd),
	↪ 'lm_head': matrix(vocab_size, n_embd)}
	# wpe 僅有 block_size=16 列
使用方式即查表・pos_id 超過 15 會拋出 IndexError
110	    pos_emb = state_dict['wpe'][pos_id] # position …
	# pos_id 超過 15 → 這一行拋出 IndexError

圖 4Transformer Block — 一橫一縱

這一格的 x(16 維) Attention —— 橫向 「需要前面哪些資訊?」 跨時間位置讀取:唯一能向其他 token 拿資訊的地方 現在 x 裡面混進了前面 token 的意義 MLP —— 縱向 「由這些資訊可推得什麼?」 只加工目前位置的表示,不直接讀其他 token 更新後的 x(16 維) 兩個動作交替,就是整個 Transformer 的骨架 搬運 Attention=決定向何處取得資訊 加工 MLP=對取得的資訊進行加工
microgpt_en.py・一個 Block 的全部119–132 見圖 4a–4c
114	for li in range(n_layer):
115	    # 1) Multi-head Attention block
116	    x_residual = x
117	    x = rmsnorm(x)
118	    q = linear(x, state_dict[f'layer{li}.attn_wq'])
	# q、k、v 的定義見圖 4a-0,此處先看結構
⋯
133	    x = linear(x_attn, state_dict[f'layer{li}.attn_wo'])
	# ★ x_attn:四個 head 的輸出接起來(16 維,拼法見圖 4b-0)
134	    x = [a + b for a, b in zip(x, x_residual)]
135	    # 2) MLP block
136	    x_residual = x
137	    x = rmsnorm(x)
138	    x = linear(x, state_dict[f'layer{li}.mlp_fc1'])
139	    x = [xi.relu() for xi in x]
140	    x = linear(x, state_dict[f'layer{li}.mlp_fc2'])
141	    x = [a + b for a, b in zip(x, x_residual)]

圖 4・時間軸把位置攤開看 — 正在算第 4 格

橫向・跨時間位置讀取 前面三格早就算完了,只有最右邊這格在算 BOS e m m ◀ 現在只算  這一格 k,v k,v k,v q,k,v 前三格的 k,v 直接沿用(不重算) q、k、v 的定義見圖 4a-0——本頁的重點在於「哪些位置更新、哪些未更新」。 把前面每一格的意義 按比例併入當前位置 縱向・只加工目前位置 合併完成後,僅餘一根箭頭 BOS e m m ・ ・ ・ ← 前三格這一輪完全沒動 MLP 更新後的 x(16) MLP 只處理自身這一個 16 維向量——並未取得其他位置的資料
microgpt_en.py橫向讀取全部・縱向僅處理自身
橫向・讀回過去每一格的 k, v
121	keys[li].append(k)
122	values[li].append(v)
⋯
129	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))]
130	attn_weights = softmax(attn_logits)
131	head_out = [sum(attn_weights[t] * v_h[t][j]
	↪     for t in range(len(v_h)))
	↪     for j in range(head_dim)]
縱向・可用的只有 x 這一個向量
138	x = linear(x, state_dict[f'layer{li}.mlp_fc1'])
139	x = [xi.relu() for xi in x]
140	x = linear(x, state_dict[f'layer{li}.mlp_fc2'])

圖 4a-0先認識 q、k、v — 同一個 x 的三種角度

rmsnorm 後的 x=這一格「m」此刻的理解 三個不同的 16×16 矩陣 (相乘連用三次) × Wq × Wk × Wv q(16) 「所要尋找的內容」 搜尋詞・用完即丟 k(16) 「自身的特徵」 書背標題 v(16) 「被選中時提供的內容」 書的內容 k、v 存進 KV cache——前文每一格都留過一份 之後各切 4 段 每個 head 取 4 個(見圖 4b-0) q、k 只決定權重;真正被取回來的是 v。 q · k 決定取用比例 v 圖 4a 以實際數字走完:打分數 → 分比例 → 按比例取回 (x 的來源:wte+wpe+前文,見圖 3、圖 4)
microgpt_en.py・三張紙條全檔最長的一行,已拆解
三張紙條・都是同一個 x 自己算出來的
118	q = linear(x, state_dict[f'layer{li}.attn_wq'])
119	k = linear(x, state_dict[f'layer{li}.attn_wk'])
120	v = linear(x, state_dict[f'layer{li}.attn_wv'])
	# ★ q/k/v:搜尋詞/書背標題/書的內容
① 打分數・全檔最長的一行,已拆解:對過去每格 t,q 與其 k 內積再 ÷√4
129	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))]
	# ★ q_h、k_h:q、k 切給這個 head 的 4 維

圖 4a一個 head 只做三件事

概念示意,非 checkpoint 實際權重・已有「e m」,正在決定第 3 個字母・head 0・每個向量 4 維 ① 打分數 當前的 q 與每一格前文的 k 各比對一次 q(「m」的搜尋詞・4 個數字) k(BOS) k(e) k(m) 2.7 0.5 −1.2 分數越大=相關性越高 (÷√4=÷2:避免分數過大) ② 分比例 softmax:把三個分數擠成加起來剛好 100% 的一組比例 0.85 0.13 0.02 多看一格,即須少看另一格——可分配的總量固定為 1 ③ 按比例拿 v(BOS) 取 85% v(e) 取 13% v(m) 取 2%,全部加總 0.85·v(BOS) + 0.13·v(e) + 0.02·v(m) head_out(4 個數字) 過去 3 個或 300 個字母, 出來永遠是 4 個數字 在這組概念示意值中,head 0 主要取用 BOS 那一格。
microgpt_en.py・head 內部三步,各一行
⋯ 118-120:q、k、v = 圖 4a-0 的三張紙條
① 打分數
129	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_logits:對頻分數(2.7/0.5/−1.2)
② 分比例
130	attn_weights = softmax(attn_logits)
	# ★ attn_weights:比例,加總=1(0.85/0.13/0.02)
③ 按比例拿
131	head_out = [sum(attn_weights[t] * v_h[t][j]
	↪     for t in range(len(v_h)))
	↪     for j in range(head_dim)]
	# ★ head_out:按比例搬回來的 4 個數字

圖 4b-0實際上有四組:切開、各執行一遍、再拼回

圖 4a 的那三步,實際上同時執行四遍。 q、k、v 各 16 個數字 0-3 4-7 8-11 12-15 只是切開,沒有新東西 head 0 ① 打分數 ② 分比例 ③ 按比例拿 head 1 ① 打分數 ② 分比例 ③ 按比例拿 head 2 ① 打分數 ② 分比例 ③ 按比例拿 head 3 ① 打分數 ② 分比例 ③ 按比例拿 每一組各執行一次上述三步——四組之間互不交換資訊 4 │ 4 │ 4 │ 4 concat(串接)→ 16 個數字——不是逐項相加 切、跑、拼——僅此三個動作 參數一個都沒多:Wq/Wk/Wv 還是 16×16。
microgpt_en.py・multi-head切、跑、拼
79	head_dim = n_embd // n_head
	# ★ head_dim:16 ÷ 4 = 4
切・僅這一行切片,不含其他處理
124	for h in range(n_head):
125	    hs = h * head_dim   # ★ hs:切片起點(0、4、8、12)
126	    q_h = q[hs:hs+head_dim]
127	    k_h = [ki[hs:hs+head_dim] for ki in keys[li]]
128	    v_h = [vi[hs:hs+head_dim] for vi in values[li]]
	# ki/vi:cache 裡每一格前文的 k、v
跑・129-131 = 圖 4a 那三步,完全相同
拼・[4 │ 4 │ 4 │ 4] concat → 16,不是相加
132	    x_attn.extend(head_out)
	# ★ x_attn:四個 head 接起來的 16 個數字

圖 4b為什麼要四個 head:同時保留四套注意力分布

概念示意,非 checkpoint 實際權重・同一時刻、同一層,四個 head 各產生一套分布 BOS e m m head 0 集中於前一個字母 head 1 集中於開頭—— 判斷在名字中的位置 head 2 集中於第一個字母 head 3 分散——整體長度感 (單頭只有這一種可選;四頭是「其中一個」這樣做) 一個 head 可以同時看多格,但一次只有一套注意力分布 內積(逐項相乘再加總)本來就是「四段各自加總、再相加」。切 head = 不做最後那一次相加,讓四段各自分自己的比例。 單頭:先全部加起來,再分比例 一組比例 → 所有特徵共用同一套取用方式 四頭:不加,四段各自分比例 四組比例 → 四個子空間各自決定如何取用前文
microgpt_en.py・multi-head本圖=這個 for 迴圈執行四遍
無新增程式碼・圖 4b-0 的切、跑、拼,執行滿四輪
124	for h in range(n_head):
⋯
132	    x_attn.extend(head_out)

圖 4cAttention 接線圖 — 四個 head 如何回到主幹

主幹 x(16) skip:原封不動繞過去 rmsnorm 音控台:把整串數字的 音量拉回標準,方向不變 Wq Wk Wv q(16) k(16) v(16) k、v 分別寫入 KV cache k, v 的歷史 前面每一格算過的 k、v 予以保存,下一輪不需重算 切、跑、拼 = 圖 4b-0 四個 head 各執行一遍那三步,彼此互不交換資訊 [4 │ 4 │ 4 │ 4] concat → 16 讀歷史 KV Wo ← 唯一讓四個 head 交流的地方 + 殘差:算出來的東西「加」回原本的 x ——旁路的作用即在於此,原本的 x 不會被覆蓋 進入 MLP
microgpt_en.py・attention 區塊全文124–132 見圖 4b-0
116	x_residual = x
117	x = rmsnorm(x)
118	q = linear(x, state_dict[f'layer{li}.attn_wq'])
119	k = linear(x, state_dict[f'layer{li}.attn_wk'])
120	v = linear(x, state_dict[f'layer{li}.attn_wv'])
121	keys[li].append(k)   # 這一格的 k、v 存檔
122	values[li].append(v)
123	x_attn = []
124	for h in range(n_head):
125	    hs = h * head_dim
126	    q_h = q[hs:hs+head_dim]   # 「切」僅這一行
⋯
132	    x_attn.extend(head_out)
133	x = linear(x_attn, state_dict[f'layer{li}.attn_wo'])
	# Wo:唯一讓四個 head 交流的地方
134	x = [a + b for a, b in zip(x, x_residual)]
	# 旁路在這裡 ⊕ 回來

圖 5MLP — 64 個軟性特徵偵測器與門檻

主幹 x(16) 原封不動繞過去 rmsnorm fc1(16 → 64) 64 個「偵測器」 每列與 x 做內積: 軟性問「有沒有 X?」 −1.2 +3.4 −0.8 +0.1 ⋯ relu ── 門檻 負值 → 0(關閉) 正值 → 保留強度 0  +3.4  0  +0.1 ⋯ fc2(64 → 16) 64 條「回應」 fc2 組合回應,形成 16 維修正方向 + 一個偵測器可以像這樣 「前面連續兩個子音?」 一經觸發,即往主幹加上 「該輪到母音了」這個方向 relu 讓兩次線性轉換無法預先合併,增加依輸入分情況的表達力
microgpt_en.py・MLP升維 → 篩選 → 降維
relu 的全部・微分不是 0 就是 1
50	def relu(self): return Value(max(0, self.data),
	↪     (self,), (float(self.data > 0),))
升維 4 倍的兩個矩陣・64 個軟性特徵偵測器
87	state_dict[f'layer{i}.mlp_fc1'] = matrix(4 * n_embd, n_embd)
88	state_dict[f'layer{i}.mlp_fc2'] = matrix(n_embd, 4 * n_embd)
	# fc1=64 個偵測分數;fc2=把啟動結果寫回 16 維
MLP 區塊本體・三行+殘差
136	x_residual = x
137	x = rmsnorm(x)
138	x = linear(x, state_dict[f'layer{li}.mlp_fc1'])
139	x = [xi.relu() for xi in x]
140	x = linear(x, state_dict[f'layer{li}.mlp_fc2'])
141	x = [a + b for a, b in zip(x, x_residual)]

圖 6殘差 — 每個子層都把修正加回主幹

沒有任何一段「取代」主幹——每個修正都依序從最新的 x 算出,再加回去 每一段都可以棄權(輸出 ≈ 0 → 原封不動流過),所以層數多不會傷害模型 embedding lm_head 16 線道的匯流排 讀 +加回去 attn0 算修正量 讀 +加回去 mlp0 算修正量 每一段都是:讀匯流排 → 算修正量 → 加回去,絕不覆蓋 展開後,輸入 lm_head 的向量字面上即為: x_final = 正規化後的嵌入(字母+位置)     + attention 的修正     + MLP 的修正 增加層數即在這條匯流排上再插入兩個加號——架構完全不需更動
microgpt_en.py全篇的加號都是這一行
此處的 (1, 1)・加法寫下的便條——圖 9.5 會用到
39	def __add__(self, other):   # ★ other:另一個加數
40	    other = other if isinstance(other, Value)
	↪         else Value(other)
41	    return Value(self.data + other.data,
	↪         (self, other), (1, 1))
	# 便條上寫 (1, 1)——訓練時梯度得以原樣通過
整個 Block =掛在迴圈上的兩個加號
114	for li in range(n_layer):   # 多層=多跑一圈、多兩個加號
⋯
134	    x = [a + b for a, b in zip(x, x_residual)]
⋯
141	    x = [a + b for a, b in zip(x, x_residual)]

圖 7lm_head — 向量變回字母

最終的主幹向量 x(16 個數字) 與 27 個候選向量逐一內積(⊙) 每個候選 16 維;各得到一個 logit 看板「a」 ⊙ +1.8 看板「e」 ⊙ +0.4 看板「m」 ⊙ +3.1 ⋮ 看板「z」 ⊙ −2.0 誰跟目前 x 的相容度最高,誰的 logit 就最高——是讀出,不是再思考 回到暖身 3a 的內積:內積大小由兩件事決定 ① 方向關係  ② 兩邊的數值尺度 尺度會放大正分也會放大負分;它不是固定加上的 bias 或「先天票數」 它不再進行推論——它只負責「輸出」 純線性・單一位置・沒有 relu・讀不到其他 token。翻譯只需要一本字典。 正因為這一層極為簡單,前面各層才必須做足工作——所有壓力都要穿過這一次內積。
microgpt_en.py・出口架構上不具備推論能力
兩個同形狀的矩陣(27 × 16)・轉換方向相對,但權重彼此獨立
81	state_dict = {'wte': matrix(vocab_size, n_embd),
	↪ 'wpe': matrix(block_size, n_embd),
	↪ 'lm_head': matrix(vocab_size, n_embd)}
	# lm_head 與 wte 同形,但不是同一份權重
「翻譯」的全部・僅一行內積,沒有 relu
94	def linear(x, w):
95	    return [sum(wi * xi for wi, xi in zip(wo, x)) for wo in w]
⋯
143	logits = linear(x, state_dict['lm_head'])
	# 最後一個矩陣:27 個候選向量,各自產生一個分數
144	return logits

圖 8抽樣 — 此生成流程唯一的隨機選擇

27 個 logits ÷ temperature 0.5 → 拉大差距・保守 1.5 → 壓平分佈・隨機除 0.5 → 分數差距加倍(差 1.3 變差 2.6)softmax 看的是差距,不是倍數 softmax 把 27 個分數擠成 加起來剛好 100% 的比例 加權隨機抽一個 生成流程唯一的隨機選擇 一個字母 只有 BOS 時——模型認為第一個字母是(示意) a m j k ⋮ x ← 幾乎沒有名字以 x 開頭 看過 e, m, m, a 之後 BOS 「名字已結束」——機率極高 n l 母音子音之分、名字長度,均未經任何人指定 此分佈完全是「猜下一個字母」的副產品——即「學習」的具體呈現
microgpt_en.py・抽樣隨機只在 choices 一行
溫度・僅是將分數相除
187	temperature = 0.5 # in (0, 1], control the …
抽籤・按 softmax 出來的比例加權隨機
193	for pos_id in range(block_size):
194	    logits = gpt(token_id, pos_id, keys, values)
195	    probs = softmax([l / temperature for l in logits])
	# 溫度僅是將分數相除
196	    token_id = random.choices(range(vocab_size),
	↪         weights=[p.data for p in probs])[0]
	# 這段生成流程唯一的隨機選擇,僅此一行
197	    if token_id == BOS:
198	        break

圖 9自迴歸 — 把全部串起來

輪 1 輪 2 輪 3 輪 4 輪 5 BOS e m m a pos 0 pos 1 pos 2 pos 3 pos 4 GPT GPT GPT GPT GPT e m m a BOS 抽到 BOS → 停止 抽出的字母作為下一輪輸入 KV cache 逐輪增長——既有的部分不需重算: 輪 1 k₀ v₀ 輪 2 k₀ v₀ k₁ v₁ 輪 3 k₀ v₀ k₁ v₁ k₂ v₂ 輪 4 k₀ v₀ k₁ v₁ k₂ v₂ k₃ v₃ 輪 5 k₀ v₀ k₁ v₁ k₂ v₂ k₃ v₃ k₄ v₄ 新算(只有最右邊這組) 沿用既有值,不重算 最終輸出:emma 每輪 forward 回傳分數,抽樣才選出一個 token,再交給下一輪 舊 k/v 不重算;但新的 q 仍會對 cache 裡全部歷史位置打分
microgpt_en.py・生成一個名字圖 1 的流程,此處著重狀態
每輪只把「新的」k, v 加進 cache(gpt() 內部)
121	keys[li].append(k)   # 每輪只把「新的」加進 cache
122	values[li].append(v)
一輪一個字母・cache 每個名字歸零重來
190	keys, values = [[] for _ in range(n_layer)],
	↪     [[] for _ in range(n_layer)]
	# 每個名字歸零重來
191	token_id = BOS
192	sample = []
193	for pos_id in range(block_size):
194	    logits = gpt(token_id, pos_id, keys, values)
195	    probs = softmax([l / temperature for l in logits])
196	    token_id = random.choices(range(vocab_size),
	↪         weights=[p.data for p in probs])[0]
197	    if token_id == BOS:
198	        break
199	    sample.append(uchars[token_id])

圖 9.5從生成回到學習 — 便條怎麼往回傳

① 前向 以一個僅有兩步的迷你算式示範 w = 2 × x = 3 y = 6 ← 中間值 y = 6 + c = 1 L = 7 ← 視為 loss 算的同時,兩步各留下一張便條(_local_grads): 乘法 → (3, 2) =對方的數值:w 動 1 格,y 動 3 格 加法 → (1, 1) =誰動 1 格,L 就動 1 格 ② 反向 從 L 出發,將便條上的數字一路乘回去 L.grad = 1 起點:L 對自己,1 倍 c.grad = 1 × 1 = 1 走過加法的便條 (1, 1) y.grad = 1 × 1 = 1 走過加法的便條 (1, 1) w.grad = 1 × 3 = 3 走過乘法的便條 (3, 2) x.grad = 1 × 2 = 2 走過乘法的便條 (3, 2) 每個節點只做一件事: 便條上的數字 × 自己的 grad, 交給來源。走完即全部完成。 w.grad = 3 的意思:w 從 2 挪到 2.001,L 就從 7 變成 7.003。 這個數字即梯度。欲使 L 變小,即往反方向微調——訓練僅此而已。 真實模型只需反向走訪同一張計算圖一次,就能累積 4,192 個參數的梯度。 加法的便條全是 1,乘 1=原樣通過——這即是圖 6 那條「殘差高速公路」的實際意義。
microgpt_en.py・便條與回傳前面保留的說明,此處補完
每種運算寫下的便條・即括號內的值
39	def __add__(self, other):   # a + b
41	    return Value(self.data + other.data,
	↪     (self, other), (1, 1))
	# ★ 加法:兩邊都是 1
43	def __mul__(self, other):   # a * b
45	    return Value(self.data * other.data,
	↪     (self, other), (other.data, self.data))
	# ★ 乘法:便條寫「對方的數值」
50	def relu(self): … (float(self.data > 0),)
	# ★ relu:1 或 0——關閉的路徑不傳遞任何值
backward() 的核心僅三行・L59–72
69	self.grad = 1   # ★ 從 loss 出發
70	for v in reversed(topo):   # 從後往前走一遍
71	    for child, local_grad in
	↪         zip(v._children, v._local_grads):
72	        child.grad += local_grad * v.grad
	# ★ 上方那張表,即是這一行執行五次
topo=先把節點依先後排好,確保回傳時上游的 grad 已經算完。

圖 10訓練 — 唯一的目標是猜下一個字母

訓練資料一筆:「emma」・每一題皆以真實前一字為輸入,不使用模型抽樣結果 BOS e m m a BOS ? ? ? ? ? 每個位置各計算一次:「下一個字母是什麼?」 一筆「emma」= 5 個學習訊號,不是 1 個 loss = −log P(正確答案) 每個位置各算一次,再平均 猜對的機率 越低,罰越重 1 → 0 0.01 → 4.6 loss.backward() 同一張計算圖反向走一遍,得到 4,192 個梯度 Adam — 4,192 個數字各微調一小步 梯度的移動平均 ÷ √梯度平方的移動平均 更新後進入下一個 training step 訓練從頭到尾只有一個任務——猜下一個字母 鏈式法則一路穿過 lm_head、MLP、attention、embedding,調整全部參數。 母音子音、名字長度……全部是此過程的附帶產物。
microgpt_en.py・訓練一步的全部Adam 的機制並不特殊
156	doc = docs[step % len(docs)]   # ★ step:第幾步
157	tokens = [BOS] + [uchars.index(ch) for ch in doc] + [BOS]
158	n = min(block_size, len(tokens) - 1)   # ★ n:幾個學習訊號
⋯
163	for pos_id in range(n):
164	    token_id, target_id = tokens[pos_id],
	↪         tokens[pos_id + 1]
	# ★ target_id:正確答案=下一個字母
165	    logits = gpt(token_id, pos_id, keys, values)
166	    probs = softmax(logits)
167	    loss_t = -probs[target_id].log()
	# ★ loss_t/loss:誤差大小(正解機率取 −log)
168	    losses.append(loss_t)
169	loss = (1 / n) * sum(losses) # final average loss …
⋯
172	loss.backward()   # 求出每個參數往哪個方向可使 loss 變小
⋯
175	lr_t = learning_rate * (1 - step / num_steps) # …
	# ★ lr_t/num_steps:步幅逐步遞減/共 1,000 步
176	for i, p in enumerate(params):   # ★ p:4,192 個逐一走訪
177	    m[i] = beta1 * m[i] + (1 - beta1) * p.grad
	# ★ m/v:Adam 兩組統計量(與前述 v 無關);p.grad=梯度
178	    v[i] = beta2 * v[i] + (1 - beta2) * p.grad ** 2
⋯
181	    p.data -= lr_t * m_hat / (v_hat ** 0.5 + eps_adam)
182	    p.grad = 0
整個 Transformer,三句話
Attention 搬運 決定「去哪裡拿資訊」
MLP 加工 決定「取得資訊後如何加工」
殘差 疊加 兩者都只是往主幹上添加,而不是覆蓋
BOS Tokenizer Embedding Attention + MLP lm_head 抽樣生成時的隨機 a 自迴歸:抽出的字母作為下一輪輸入 上述三句所指即為此區塊
訓練:正確下一字 → loss → backward → Adam → 更新 4,192 個參數
多層 Transformer = 同一件事再做一次 · 現今的大模型 = 同一件事放大數個量級
4,192 個數字・200 行純 Python——從頭到尾只有加法、乘法,和一道門檻
補充・延伸主題

三個延伸方向

真實權重的樣貌

解析訓練完成的 checkpoint:wpe 的長度與夾角、四個 head 在同一時刻的實際注意力分佈。

scripts/dump_position.py
scripts/dump_attention.py

換成中文資料集

一字一 token 的 tokenizer 在中文上 vocab 增至 700+,block_size 反而變小——同一份程式碼,參數分佈截然不同。

microgpt.py(金庸人名)

延長訓練的結果

loss 持續下降,但模型開始記住訓練資料——生成結果直接命中訓練集原文,即 overfitting 的具體表現。

EXPERIMENTS.md

Q & A