
2026/03/01 10:39
**MicroGPT(マイクロ GPT)**
RSS: https://news.ycombinator.com/rss
要約▶
日本語訳:
MicroGPTは、外部ライブラリを一切使用せずにトレーニングおよび実行できる、コンパクトな純粋Python実装のGPTスタイル言語モデルです。コード量は約200行で、自前の
Valueクラスに基づく自動微分システム(スカラー逆伝播用の操作記録:加算、乗算、べき乗、対数、指数、ReLUなど)、トークナイズ処理、およびトレーニングループを含みます。
モデルはGPT‑2アーキテクチャに従いますが、以下のような簡略化が施されています:RMSNorm(バイアスなし)、ReLU活性化、小規模MLP出力ヘッド。パラメータ数は約4 192で、
state_dictに格納されます。これには埋め込みテーブル(wte、wpe)、注意重み(attn_wq/k/v/wo)、MLP重み(mlp_fc1/2)、および出力射影(lm_head)が含まれます。注意はトークンを一つずつ処理し、位置間でキー/バリューキャッシュを維持します。
トレーニングでは、GitHubからダウンロードした約32 000件の名前(1行に1件)のデータセットを使用します。各名前はBOSトークンで両端が囲まれた「文書」として扱われ、語彙数は27トークン(小文字26字+BOS)になります。トレーニングループは1 000ステップ実行されます。各ステップでは単一の文書をトークナイズし、順次モデルに渡し、位置ごとのクロスエントロピー損失を計算します。その後シーケンス全体で平均化し、
loss.backward()で逆伝播を行い、Adam(線形学習率減衰)でパラメータ更新します。損失は毎ステップ表示され、初期値は約3.36(ランダム)からトレーニング後に約2.37まで低下し、モデルが名前のパターンを学習したことを示しています。
トレーニング完了後、推論ではBOSから開始し、ロジットを繰り返し生成し、温度スケーリング済みソフトマックスでサンプリングします。別のBOSが出るか最大長に達するまでトークンを取得し、新しい名前をサンプルします。著者は段階的に機能を追加したスクリプト(
train0.py〜train5.py)を公開しており、システムの進化を示しています。
MicroGPTは教育ツールとして、またGPUや重い依存関係なしでCPU上で変圧器メカニクスを実験するための低リソース代替手段として機能します。さらに、スケーリング、バッチング、混合精度、および専用ハードウェアが大規模言語モデルでパフォーマンス向上にどのように寄与できるかを示しています。
本文
microgpt – 純粋Pythonで実装した200行程度のシングルファイルGPT
概要
microgpt.py には 学習 と 推論 に必要なアルゴリズムコンポーネントがすべて収録されています。
- データセット読み込み
- キャラクタトークナイザー
- スカラー自動微分 (
)Value - 埋め込み + マルチヘッド注意機構 + MLP ブロック(RMSNorm, ReLU)
- 線形学習率減衰付き Adam オプティマイザ
- 交差エントロピー損失を用いたトレーニングループ
- 温度サンプリングによる推論ループ
外部ライブラリは一切不要で、Python 標準ライブラリだけで動作します。
1. データセット
if not os.path.exists('input.txt'): import urllib.request names_url = 'https://raw.githubusercontent.com/karpathy/makemore/master/names.txt' urllib.request.urlretrieve(names_url, 'input.txt') docs = [l.strip() for l in open('input.txt').read().strip().split('\n') if l.strip()] random.shuffle(docs) print(f"num docs: {len(docs)}")
32 k 程度の名前(1行に1つ)がデータセットです。
各名前が学習時の「ドキュメント」として扱われます。
2. トークナイザー
uchars = sorted(set(''.join(docs))) # ユニーク文字列 BOS = len(uchars) # BOS(Beginning‑of‑Sequence)トークンID vocab_size = len(uchars) + 1 # +1 は BOS 用 print(f"vocab size: {vocab_size}")
- 各文字を整数 ID にマッピング
はドキュメントの区切りに使われます。BOS
3. 自動微分 (Value
)
Valueclass Value: __slots__ = ('data', 'grad', '_children', '_local_grads') def __init__(self, data, children=(), local_grads=()): self.data = data; self.grad = 0 self._children = children self._local_grads = local_grads # 算術演算 ------------------------------------------------------------- def __add__(self, other): other = other if isinstance(other, Value) else Value(other) return Value(self.data + other.data, (self, other), (1, 1)) def __mul__(self, other): other = other if isinstance(other, Value) else Value(other) return Value(self.data * other.data, (self, other), (other.data, self.data)) # … plus pow, log, exp, relu, neg, radd, sub など … def backward(self): topo = []; visited = set() def build(v): if v not in visited: visited.add(v) for c in v._children: build(c) topo.append(v) build(self) self.grad = 1 for v in reversed(topo): for child, local_grad in zip(v._children, v._local_grads): child.grad += local_grad * v.grad
すべての演算は新しい
Value を生成し、親ノードと局所勾配を記録します。backward() は逆順トップロジカルに走査して勾配を集計します。
4. パラメータ
n_embd = 16 # 埋め込み次元 n_head = 4 # 注意ヘッド数 n_layer = 1 # 層数 block_size= 16 # 最大シーケンス長 head_dim = n_embd // n_head def matrix(nout, nin, std=0.08): return [[Value(random.gauss(0, std)) for _ in range(nin)] for _ in range(nout)] state_dict = { 'wte': matrix(vocab_size, n_embd), 'wpe': matrix(block_size, n_embd), 'lm_head': matrix(vocab_size, n_embd) } for i in range(n_layer): state_dict[f'layer{i}.attn_wq'] = matrix(n_embd, n_embd) state_dict[f'layer{i}.attn_wk'] = matrix(n_embd, n_embd) state_dict[f'layer{i}.attn_wv'] = matrix(n_embd, n_embd) state_dict[f'layer{i}.attn_wo'] = matrix(n_embd, n_embd) state_dict[f'layer{i}.mlp_fc1'] = matrix(4*n_embd, n_embd) state_dict[f'layer{i}.mlp_fc2'] = matrix(n_embd, 4*n_embd) params = [p for mat in state_dict.values() for row in mat for p in row] print(f"num params: {len(params)}")
総パラメータ数は約 4 192 個です。
5. ヘルパー関数
def linear(x, w): return [sum(wi * xi for wi, xi in zip(wo, x)) for wo in w] def softmax(logits): max_val = max(v.data for v in logits) exps = [(v - max_val).exp() for v in logits] total = sum(exps) return [e / total for e in exps] def rmsnorm(x): ms = sum(v * v for v in x) / len(x) scale = (ms + 1e-5)**-0.5 return [v * scale for v in x]
6. モデル (gpt
)
gptdef gpt(token_id, pos_id, keys, values): tok_emb = state_dict['wte'][token_id] # トークン埋め込み pos_emb = state_dict['wpe'][pos_id] # 位置埋め込み x = [t + p for t, p in zip(tok_emb, pos_emb)] x = rmsnorm(x) for li in range(n_layer): # ---------- 注意 ---------- x_residual = x x = rmsnorm(x) 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); values[li].append(v) x_attn = [] for h in range(n_head): hs = h * head_dim q_h = q[hs:hs+head_dim] k_h = [ki[hs:hs+head_dim] for ki in keys[li]] v_h = [vi[hs:hs+head_dim] for vi in values[li]] 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) head_out = [sum(attn_weights[t]*v_h[t][j] for t in range(len(v_h))) for j in range(head_dim)] x_attn.extend(head_out) x = linear(x_attn, state_dict[f'layer{li}.attn_wo']) x = [a + b for a, b in zip(x, x_residual)] # ---------- MLP ---------- x_residual = x x = rmsnorm(x) x = linear(x, state_dict[f'layer{li}.mlp_fc1']) x = [v.relu() for v in x] x = linear(x, state_dict[f'layer{li}.mlp_fc2']) x = [a + b for a, b in zip(x, x_residual)] logits = linear(x, state_dict['lm_head']) return logits
この関数は 1 トークンずつ 処理し、KV キャッシュ(
keys, values)を保持して各位置が過去全てに注意できるようにしています。
7. 学習ループ
learning_rate, beta1, beta2, eps_adam = 0.01, 0.85, 0.99, 1e-8 m = [0.0] * len(params) # 一次モーメントバッファ v = [0.0] * len(params) # 二次モーメントバッファ num_steps = 1000 for step in range(num_steps): doc = docs[step % len(docs)] tokens = [BOS] + [uchars.index(ch) for ch in doc] + [BOS] n = min(block_size, len(tokens)-1) keys, values = [[] for _ in range(n_layer)], [[] for _ in range(n_layer)] 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() losses.append(loss_t) loss = (1/n) * sum(losses) # 平均交差エントロピー loss.backward() lr_t = learning_rate * (1 - step / num_steps) for i, p in enumerate(params): m[i] = beta1 * m[i] + (1-beta1) * p.grad v[i] = beta2 * v[i] + (1-beta2) * p.grad**2 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 print(f"step {step+1:4d} / {num_steps:4d} | loss {loss.data:.4f}")
- 損失:次のトークンを予測する交差エントロピー
- オプティマイザ:Adam + 線形学習率減衰
8. 推論(サンプリング)
temperature = 0.5 # 小さいほど決定的に近い print("\n--- inference (new, hallucinated names) ---") for sample_idx in range(20): keys, values = [[] for _ in range(n_layer)], [[] for _ in range(n_layer)] token_id = BOS sample = [] for pos_id in range(block_size): logits = gpt(token_id, pos_id, keys, values) 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 sample.append(uchars[token_id]) print(f"sample {sample_idx+1:2d}: {''.join(sample)}")
BOS から始めて次トークンをサンプルし、再度入力に戻すことで名前を生成します。新しい
BOS が出るかブロックサイズに達すると停止します。
9. 実行
python train.py # ラップトップで約1分程度で学習完了
典型的な出力例:
num docs: 32033 vocab size: 27 num params: 4192 step 1 / 1000 | loss 3.3660 ... step 1000 / 1000 | loss 2.3705 --- inference (new, hallucinated names) --- sample 1: kamon sample 2: ann ... sample 20: anton
10. コードベースの進化
| ファイル | 追加内容 |
|---|---|
| train0.py | バイグラムカウント(NN・勾配なし) |
| train1.py | MLP + 手動勾配 + SGD |
| train2.py | 自動微分 () |
| train3.py | 位置埋め込み + 単一ヘッド注意 + RMSNorm + 残差 |
| train4.py | マルチヘッド注意 + 層ループ(完全 GPT) |
| train5.py | Adam オプティマイザ – 現在の |
各ステップは前段階を基に拡張され、コードは読みやすくロジックも明確です。
11. 本番 LLM と比べて欠けている点
- データ:32 k 名 vs. 数兆トークンのウェブページ
- トークナイザー:単文字 vs. BPE(約10万語彙)
- 自動微分:Python のスカラー
vs. GPU/TPU 上のテンソル演算Value - モデルサイズ:4 k パラメータ vs. 数百億パラメータ
- 学習:1 ドキュメント / ステップ、バッチ小、混合精度なし、勾配集約なし
- 最適化:単純 Adam vs. 大規模スケジュール・重み減衰・ウォームアップ等
- 推論:ピュア Python ループ vs. GPU カーネル、KV キャッシュページング、スペキュレーティブデコーディング、量子化
これらのギャップがあるものの、**「次トークンを予測し、サンプルして繰り返す」**という本質は ChatGPT などと同一です。
12. よくある質問(簡潔版)
| 質問 | 回答 |
|---|---|
| モデルは「理解」できる? | いいえ、確率分布を学習するだけです。 |
| なぜ動くのか? | 勾配が損失を減らす方向へパラメータを更新し、統計的パターンを捉えるから。 |
| ChatGPT とどう関係ある? | 同じ次トークンループですが、スケールとポストトレーニング(SFT/RLHF)が異なるだけです。 |
| 生成物は「幻覚」か? | 確率分布からサンプルするため、存在しない名前を作り出すことがあります。 |
| なぜ遅いのか? | スカラー Python 演算は GPU テンソル演算に比べ何百万倍も遅いです。 |
| 名前生成を改善できる? | 学習時間を伸ばす、モデルを大きくする、データセットを増やすなどで可能です。 |
| データを変えても動くのか? | はい。コードは同じままで新しいパターンを学びます。 |
ぜひ実験してみてください!
ハイパーパラメータを変更したり、データセットを差し替えたり、アーキテクチャを拡張したりすることで、自分だけの小さな GPT を作ることができます。すべては
microgpt.py に自前で収められています。