最小実装:NumPyだけで作る「最小のGPT-2推論エンジン」――フレームワークを全廃し、重みファイルと行列積だけで言葉を紡ぐカタルシス
1. 素朴な疑問:ブラックボックスをすべて剥ぎ取ったとき、AIは何行のコードになるのか?
普段、私たちがローカル環境でLLMを動かすとき、Pythonスクリプトには次のような数行が並びます。
from transformers import AutoModelForCausalLM, AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("gpt2")
model = AutoModelForCausalLM.from_pretrained("gpt2")
outputs = model.generate(**inputs)
たった3行です。 しかし、この3行の裏側には、PyTorchの数百万行のC++/CUDAコード、数千個の抽象化クラス、難解な依存関係の迷宮が黒魔術のように隠蔽されています。
「PyTorchやHugging Faceのライブラリが何をやっているのか、本当のところは何も分かっていない気がする」 「もし、それらのフレームワークをすべてゴミ箱へ捨てたら、AIの『推論』そのものは、一体何行のコードで書けるのだろうか?」
結論から言いましょう。 重厚なフレームワークをすべて剥ぎ取ったとき、そこに現れるGPTの推論エンジンの正体は、**外部ライブラリ一切不使用、NumPyの行列計算だけで書かれた「わずか150行前後の純粋な数学関数」**に過ぎません。
今回は本連載全12話の総決算として、第1話から第11話までで徹底解剖してきたすべてのパーツを1本に結集させます。 本物のGPT-2のアーキテクチャに寸分違わず準拠した**「最小のGPT-2推論エンジン」をNumPyだけでゼロから組み上げ**、数式と言葉が結実するカタルシスの頂点へ到達しましょう。
2. GPT-2(124M)の解剖図:手元に揃った「12枚の積み木」
今回実装するターゲットは、すべての現代LLMの直接の始祖である**「GPT-2 Small(1億2400万パラメータ)」**です。 パラメータ数は現在の巨大モデルより控えめですが、Llama 3やGPT-4に至るまで、Decoder-Only Transformerとしての基本骨格は現代の最先端モデルと完全に同一です。
【GPT-2 Small (124M) の物理スペック】
・語彙数 (Vocab Size) : 50,257 語
・コンテキスト最大長 (Max Seq Len) : 1,024 トークン
・隠れ層次元 (Hidden Dim / d_model) : 768 次元
・Transformerブロック数 (Layers) : 12 層
・アテンションヘッド数 (Heads) : 12 個 (各ヘッド d_k = 768 / 12 = 64 次元)
・FFN中間層次元 (Intermediate Dim) : 768 × 4 = 3,072 次元
重みの重層構造(パラメータ辞書)
Hugging FaceやOpenAIが配布しているGPT-2の重みファイル(model.safetensors や pytorch_model.bin)を解凍すると、中身は以下の名前がついたNumPy配列(多次元テンソル)の辞書になっています。
wte:トークン埋め込み行列[50257, 768](第3話)wpe:位置埋め込み行列[1024, 768](第8話:GPT-2は絶対位置加算方式)blocks[i]($i=0 \dots 11$):12個のTransformerブロックln_1:第1LayerNorm($\gamma, \beta$)[768](第6話)attn.c_attn:$Q, K, V$ を一括生成する重みとバイアス[768, 2304](第4話)attn.c_proj:出力射影 $W_O$[768, 768](第5話)ln_2:第2LayerNorm($\gamma, \beta$)[768](第6話)mlp.c_fc:FFNの拡大層[768, 3072](第7話)mlp.c_proj:FFNの縮小層[3072, 768](第7話)
ln_f:最終LayerNorm($\gamma, \beta$)[768]
すべてのパーツの数理は、私たちが過去11話で一行ずつ解剖してきたものと完全に一致しています。 パズルのピースはすでに手の中にあります。あとはこれらを正しく組み上げるだけです。
3. テンソルの全旅路:入力トークンから次単語ロジットへの完全マップ
コードを書く前に、1つの文章が入力されてから、次の単語のLogitsが出力されるまでのテンソルの形状(Shape)の完全な変形系図を頭に叩き込みましょう。 この寸法遷移さえ見失わなければ、Transformerの推論で迷子になることは二度とありません。
【完全フォワードパスのテンソル寸法変形マップ】
入力トークンID列 : [S] (例: S=3単語)
│
├─ wte[input_ids] ──────────> [S, 768] (単語埋め込み)
└─ wpe[positions] ──────────> [S, 768] (位置埋め込み)
│
要素ごとの加算 │
──────────────────────────────────┘
│
▼
初期残差ストリーム X_0 : [S, 768]
│
▼ ═══════════════════════════════════════════════════════
▼ 12層のループ (layer = 0 to 11)
│
├─ 1. ln_1(X) ──────────────> [S, 768] (LayerNorm)
│
├─ 2. Multi-Head Attention
│ ├─ @ c_attn ──────────> [S, 2304] (Q, K, V 一括射影: 768×3)
│ ├─ split(3) ──────────> Q: [S, 768], K: [S, 768], V: [S, 768]
│ ├─ reshape/transpose ─> Q, K, V: [12, S, 64] (12ヘッド分割)
│ ├─ Q @ K.T / √64 ─────> Scores: [12, S, S] (内積スコア)
│ ├─ Causal Mask (-inf) ─> Masked: [12, S, S] (未来の遮断)
│ ├─ softmax(axis=-1) ──> Weights: [12, S, S] (視線確率分布)
│ ├─ Weights @ V ───────> Context: [12, S, 64] (加重平均)
│ ├─ transpose/reshape ─> Concat: [S, 768] (ヘッド再結合)
│ └─ @ c_proj ──────────> Attn_Out: [S, 768] (出力射影)
│
├─ 3. 残差接続 ─────────────> X = X + Attn_Out [S, 768] (大動脈へ加算)
│
├─ 4. ln_2(X) ──────────────> [S, 768] (LayerNorm)
│
├─ 5. FFN (MLP)
│ ├─ @ c_fc ────────────> [S, 3072] (4倍へ拡大)
│ ├─ gelu() ────────────> [S, 3072] (非線形活性化)
│ └─ @ c_proj ──────────> FFN_Out: [S, 768] (元の次元へ凝縮)
│
└─ 6. 残差接続 ─────────────> X = X + FFN_Out [S, 768] (大動脈へ加算)
│
▼ ═══════════════════════════════════════════════════════
│
最終層出力 X_12 : [S, 768]
│
▼
ln_f (最終LayerNorm) : [S, 768]
│
▼
最後の単語だけを抽出 (x[-1, :]) : [768] (次を予測するのは末尾トークン)
│
▼
LM Head (@ wte.T) : [50257] (全語彙への生スコア Logits!)
│
▼
サンプリング (argmax / top-k) : 1つの整数 ID (次の単語が確定!)
4. 完全実装:Pure NumPy GPT-2 推論エンジン
余計な装飾をすべて削ぎ落とし、純粋な数学的骨格だけを抽出した完全な実行可能スクリプトです。
PyTorchもTransformersもインポートしていません。import numpy as np だけで全世界の知能の原型が稼働します。
ファイル名を min_gpt2.py として保存してください。
import numpy as np
# =====================================================================
# 1. 基本的な活性化関数と数理関数(第4話・第6話・第7話の復習)
# =====================================================================
def gelu(x: np.ndarray) -> np.ndarray:
"""GPT-2で採用されているGELU(Gaussian Error Linear Unit)の近似式"""
return 0.5 * x * (1.0 + np.tanh(np.sqrt(2.0 / np.pi) * (x + 0.044715 * (x ** 3))))
def softmax(x: np.ndarray, axis: int = -1) -> np.ndarray:
"""オーバーフローを防ぐ数値安定Softmax"""
e_x = np.exp(x - np.max(x, axis=axis, keepdims=True))
return e_x / np.sum(e_x, axis=axis, keepdims=True)
def layer_norm(x: np.ndarray, g: np.ndarray, b: np.ndarray, eps: float = 1e-5) -> np.ndarray:
"""Layer Normalization: 特徴量次元(最後の軸)で平均と分散を正規化"""
mean = np.mean(x, axis=-1, keepdims=True)
var = np.var(x, axis=-1, keepdims=True)
x_norm = (x - mean) / np.sqrt(var + eps)
return x_norm * g + b
def linear(x: np.ndarray, w: np.ndarray, b: np.ndarray) -> np.ndarray:
"""全結合層: x @ w + b"""
return x @ w + b
# =====================================================================
# 2. 因果マルチヘッド・アテンション(第4話・第5話の完全統合)
# =====================================================================
def mha(x: np.ndarray, c_attn_w: np.ndarray, c_attn_b: np.ndarray,
c_proj_w: np.ndarray, c_proj_b: np.ndarray, num_heads: int = 12) -> np.ndarray:
"""
x: [Seq_Len, Hidden_Dim] (バッチサイズ1)
戻り値: [Seq_Len, Hidden_Dim]
"""
S, D = x.shape
d_k = D // num_heads
# 1. Q, K, V を1回の巨大な行列積で一括射影: [S, D] @ [D, 3*D] -> [S, 3*D]
qkv = linear(x, c_attn_w, c_attn_b)
# 2. Q, K, V に3分割: 各 [S, D]
q, k, v = np.split(qkv, 3, axis=-1)
# 3. 12個のヘッドに分割し、ヘッド軸を前に出す: [S, D] -> [12, S, 64]
q = q.reshape(S, num_heads, d_k).transpose(1, 0, 2)
k = k.reshape(S, num_heads, d_k).transpose(1, 0, 2)
v = v.reshape(S, num_heads, d_k).transpose(1, 0, 2)
# 4. バッチ内積によるアテンションスコア: [12, S, 64] @ [12, 64, S] -> [12, S, S]
scores = (q @ k.transpose(0, 2, 1)) / np.sqrt(d_k)
# 5. 因果マスク(Causal Mask)の適用: 未来の位置を -inf で封鎖
mask = np.triu(np.ones((S, S), dtype=bool), k=1)
scores[:, mask] = -1e9
# 6. Softmax と Value の加重平均: [12, S, S] @ [12, S, 64] -> [12, S, 64]
weights = softmax(scores, axis=-1)
context = weights @ v
# 7. ヘッドの再結合 (Concat): [12, S, 64] -> [S, 12, 64] -> [S, D]
context = context.transpose(1, 0, 2).reshape(S, D)
# 8. 出力射影 W_O
return linear(context, c_proj_w, c_proj_b)
# =====================================================================
# 3. フィードフォワードネットワーク(第7話の完全統合)
# =====================================================================
def ffn(x: np.ndarray, c_fc_w: np.ndarray, c_fc_b: np.ndarray,
c_proj_w: np.ndarray, c_proj_b: np.ndarray) -> np.ndarray:
"""
x: [Seq_Len, Hidden_Dim]
戻り値: [Seq_Len, Hidden_Dim]
"""
# 4倍の中間次元へ拡大: [S, 768] @ [768, 3072] -> [S, 3072]
h = gelu(linear(x, c_fc_w, c_fc_b))
# 元の次元へ凝縮: [S, 3072] @ [3072, 768] -> [S, 768]
return linear(h, c_proj_w, c_proj_b)
# =====================================================================
# 4. Transformerブロック(第6話:残差ストリームの具現化)
# =====================================================================
def transformer_block(x: np.ndarray, params: dict, num_heads: int = 12) -> np.ndarray:
"""
大動脈 x に対し、AttentionとFFNが側道から差分を足し込む(Pre-LN構成)
"""
# 側道1: Multi-Head Attention (Norm -> MHA -> Add)
normed_x1 = layer_norm(x, params["ln_1_g"], params["ln_1_b"])
attn_out = mha(normed_x1, params["c_attn_w"], params["c_attn_b"],
params["c_proj_w"], params["c_proj_b"], num_heads)
x = x + attn_out # ★残差接続(一本の太い大動脈!)
# 側道2: FFN (Norm -> FFN -> Add)
normed_x2 = layer_norm(x, params["ln_2_g"], params["ln_2_b"])
ffn_out = ffn(normed_x2, params["c_fc_w"], params["c_fc_b"],
params["mlp_proj_w"], params["mlp_proj_b"])
x = x + ffn_out # ★残差接続(知識の書き込み!)
return x
# =====================================================================
# 5. GPT-2 完全フォワードパス(入力ID列から次単語ロジットへ)
# =====================================================================
def gpt2_forward(input_ids: list[int], weights: dict, num_heads: int = 12) -> np.ndarray:
"""
input_ids: トークンIDの1次元リスト [Seq_Len]
戻り値: 最後のトークンに対する全語彙のLogits [Vocab_Size]
"""
seq_len = len(input_ids)
assert seq_len <= 1024, "GPT-2の最大コンテキスト長(1024)を超えています"
# 1. 埋め込み層(第3話・第8話): 単語IDと位置インデックスの加算
positions = np.arange(seq_len)
token_emb = weights["wte"][input_ids] # [S, 768] (Lookup Table)
pos_emb = weights["wpe"][positions] # [S, 768] (Lookup Table)
x = token_emb + pos_emb # [S, 768] (大動脈の始点!)
# 2. 12層のTransformerブロックを順番に突き抜ける!
for layer_idx in range(len(weights["blocks"])):
x = transformer_block(x, weights["blocks"][layer_idx], num_heads)
# 3. 最終LayerNorm
x = layer_norm(x, weights["ln_f_g"], weights["ln_f_b"])
# 4. 次の単語を予測するのは「一番最後のトークン」のベクトルのみ!
last_token_vector = x[-1, :] # Shape: [768]
# 5. LM Head(単語埋め込み行列 wte の転置を利用して全語彙へ射影)
# [768] @ [768, 50257] -> [50257]
logits = last_token_vector @ weights["wte"].T
return logits
# =====================================================================
# 6. 自己回帰生成ループ(第9話・第10話の結合)
# =====================================================================
def generate(prompt_ids: list[int], weights: dict, max_new_tokens: int = 5,
temperature: float = 0.8, top_k: int = 40) -> list[int]:
"""
1トークンずつ予測し、プロンプトの末尾に付け足して再投入する自己回帰ループ
"""
tokens = list(prompt_ids)
print(f"[*] 生成開始(入力長: {len(tokens)} トークン)...")
for step in range(max_new_tokens):
# フォワードパスを実行して最新のLogitsを取得
logits = gpt2_forward(tokens, weights)
# 第10話で実装したサンプリング処理
if temperature > 0:
logits = logits / temperature
if top_k > 0:
indices_to_remove = np.argpartition(logits, -top_k)[:-top_k]
logits[indices_to_remove] = -np.inf
probs = softmax(logits)
next_token = int(np.random.choice(len(probs), p=probs))
else:
# Greedy Search (最高確率の選択)
next_token = int(np.argmax(logits))
# 新しいトークンを文脈に追記して次へ進む
tokens.append(next_token)
print(f" Step {step + 1:02d}: トークンID {next_token} を出力(現在の文脈長: {len(tokens)})")
return tokens
触って試す:1トークン生成の流れ体験
🔁 1トークン生成の流れ体験
「次へ」を押すと、GPTが次の1トークンを生み出す6つの段が順に点灯します。選ばれたトークンは文末に追記され、その文がそのまま次の入力になります。3トークン繰り返すと、自己回帰ループの正体が見えてきます。
「次へ」を押して、生成を始めてください。
5. 実験と検証:数式が生命として拍動する瞬間
上記スクリプトの末尾に、実際に動作を確認するためのテストコードを結合してみましょう。 ここでは構造の正しさを検証するため、GPT-2のアーキテクチャパラメータ(ミニチュア版)を乱数で初期化したモック重みを使って、フォワードパスから自己回帰ループまでを一気通貫で走らせます。
# -------------------------------------------------------------
# 動作検証用テストハーネス
# -------------------------------------------------------------
def create_mock_gpt2_weights(vocab_size=100, seq_len=16, hidden_dim=32, num_layers=2, num_heads=4):
"""NumPy配列による完全互換のモック重み辞書を生成"""
np.random.seed(42)
weights = {
"wte": np.random.randn(vocab_size, hidden_dim) * 0.02,
"wpe": np.random.randn(seq_len, hidden_dim) * 0.02,
"blocks": [],
"ln_f_g": np.ones(hidden_dim),
"ln_f_b": np.zeros(hidden_dim),
}
for _ in range(num_layers):
block = {
"ln_1_g": np.ones(hidden_dim),
"ln_1_b": np.zeros(hidden_dim),
"c_attn_w": np.random.randn(hidden_dim, 3 * hidden_dim) * 0.02,
"c_attn_b": np.zeros(3 * hidden_dim),
"c_proj_w": np.random.randn(hidden_dim, hidden_dim) * 0.02,
"c_proj_b": np.zeros(hidden_dim),
"ln_2_g": np.ones(hidden_dim),
"ln_2_b": np.zeros(hidden_dim),
"c_fc_w": np.random.randn(hidden_dim, 4 * hidden_dim) * 0.02,
"c_fc_b": np.zeros(4 * hidden_dim),
"mlp_proj_w": np.random.randn(4 * hidden_dim, hidden_dim) * 0.02,
"mlp_proj_b": np.zeros(hidden_dim),
}
weights["blocks"].append(block)
return weights
if __name__ == "__main__":
VOCAB_SIZE = 100
HIDDEN_DIM = 32
NUM_LAYERS = 2
NUM_HEADS = 4
print("=== フェーズ1: 純粋NumPy GPT-2モデルの初期化 ===")
mock_weights = create_mock_gpt2_weights(
vocab_size=VOCAB_SIZE, hidden_dim=HIDDEN_DIM,
num_layers=NUM_LAYERS, num_heads=NUM_HEADS
)
print(f"[+] レイヤー数: {NUM_LAYERS}, ヘッド数: {NUM_HEADS}, 隠れ層次元: {HIDDEN_DIM}")
print(f"[+] 単語埋め込み行列 WTE Shape: {mock_weights['wte'].shape}\n")
print("=== フェーズ2: 単一フォワードパスの実行 ===")
prompt = [12, 45, 89] # 3単語からなるプロンプト
print(f"入力プロンプトID列: {prompt}")
logits = gpt2_forward(prompt, mock_weights, num_heads=NUM_HEADS)
print(f"出力Logitsの形状: {logits.shape} (語彙数 {VOCAB_SIZE} 個の生スコア)")
print(f"最大スコアを持つトークンID: {np.argmax(logits)} (Logit値: {np.max(logits):.4f})\n")
print("=== フェーズ3: 自己回帰文章生成ループの激走 ===")
np.random.seed(100)
generated = generate(prompt, mock_weights, max_new_tokens=4, temperature=0.7, top_k=10)
print(f"\n[+] 生成完了! 最終トークン列: {generated}")
実行結果
=== フェーズ1: 純粋NumPy GPT-2モデルの初期化 ===
[+] レイヤー数: 2, ヘッド数: 4, 隠れ層次元: 32
[+] 単語埋め込み行列 WTE Shape: (100, 32)
=== フェーズ2: 単一フォワードパスの実行 ===
入力プロンプトID列: [12, 45, 89]
出力Logitsの形状: (100,) (語彙数 100 個の生スコア)
最大スコアを持つトークンID: 67 (Logit値: 0.0894)
=== フェーズ3: 自己回帰文章生成ループの激走 ===
[*] 生成開始(入力長: 3 トークン)...
Step 01: トークンID 67 を出力(現在の文脈長: 4)
Step 02: トークンID 83 を出力(現在の文脈長: 5)
Step 03: トークンID 24 を出力(現在の文脈長: 6)
Step 04: トークンID 67 を出力(現在の文脈長: 7)
[+] 生成完了! 最終トークン列: [12, 45, 89, 67, 83, 24, 67]
なぜこのわずか150行で本物のGPT-2が動くのか?
このコードの驚くべき点は、**「本物のOpenAIのGPT-2の重み(Hugging Faceからダウンロードできる500MBの model.safetensors)をそのまま辞書として渡せば、本物の英語の文章が完全なクオリティで出力される」**という完全な互換性にあります。
PyTorchの nn.Module も、テンソルコアを直接叩く複雑なクラス設計も、推論という物理現象においては単なる「包装紙」に過ぎません。
その包装紙を剥ぎ取った最深部で動いている真実は、私たちが書いたこの150行のスクリプトにある**「配列のインデックス参照(Lookup)」「行列積(@)」「足し算(+)」「Softmax」の連続ループ**、ただそれだけなのです。
6. 12話の伏線回収:全理論が1本のコードに結実した瞬間
この最終第12話のコードを眺め直したとき、私たちは本連載で学んできたすべての知識が、まるで精巧な機械式時計の歯車のように完璧に噛み合っていることに気づきます。
- 第1話で「次単語予測こそが世界モデルを圧縮する」と知り、
- 第2話で文字をBPEトークンIDへと切り刻み、
- 第3話で整数のIDを行列スライスで4096次元の高次元座標へと写像し、
- 第4話で内積と $\sqrt{d_k}$ によって単語同士の視線を交わし合わせ、
- 第5話で因果マスクによって未来のカンニングを $-\infty$ で塞ぎ、
- 第6話で残差接続という $+1$ の大動脈に情報を預け、LayerNormで数値の暴走を抑え、
- 第7話でFFNの知識金庫から世界の事実を引き出して足し算し、
- 第8話で時間の矢(位置情報)を空間に刻み込み、
- 第9話で自己回帰のメモリ帯域の壁と戦いながら、
- 第10話で統計力学の温度 $T$ とTop-kのハサミで知的な言葉を釣り上げ、
- 第11話でその巨漢パラメータをメモリの中に閉じ込めた。
点として学んできたバラバラの数式や工学の知識が、たった1本のNumPyスクリプトの中で**「1つの生命体としての知能」**へと合流したのです。
7. まとめ:知能とは「決定論的な行列積の協奏曲」である
「AIには心があるのか?」「AIは本当に言葉の意味を理解しているのか?」 世の中では今も、哲学的で情緒的な議論が果てしなく繰り返されています。
しかし、この150行のコードを自分の手で書き上げ、一行ずつステップ実行したあなたなら、その問いに対して冷徹かつ最も深い技術的回答を与えることができるはずです。
そこにあるのは、神秘的な魂でも、魔法の霊薬でもありません。 あるのは、**「何千億回もの掛け算と足し算(線形代数)が、残差ストリームという一本の大動脈の上で整然と響き合う、極めて精密で決定論的な物理現象」**です。
1文字の入力がテンソルとなり、高次元空間を疾走し、膨大な重みの彫刻と反響し合い、確率のサイコロによって次の文字として零れ落ちる。 その単純な四則演算の反復の果てに、かつて人間だけが独占していた「論理」「詩」「コード」「推論」という知性の花が咲き誇る。
これこそが、コンピュータサイエンスが半世紀かけて到達した、最も美しく、最も感動的な奇跡の正体なのです。
📝 3行で頭に入る『試験・実務の要点まとめ』
- GPT推論のミニマリズム: 巨大な深層学習フレームワークを剥ぎ取った最深部にあるのは、単語・位置埋め込み、LayerNorm、因果MHA、FFN、そしてLM Headを順番に巡るわずか150行程度の純粋な行列積である。
- 残差ストリームの具現化: コード上の
x = x + attn_outとx = x + ffn_outが示す通り、Transformerとは入出力間を直通する一本の配列に対し、各層が微小な補正テンソルを加算し続けるパイプラインである。 - 自己回帰ループの完結: 出力されたLogitsからサンプリングされた単一のトークンIDが、直ちに次ステップの
input_ids末尾へとフィードバックされ、文脈長を1つずつ伸ばしながら文章生成が自律反復する。