第16章 microgpt とは何か(出典と位置づけ)

16.1 この章で学ぶこと

この章で学ぶ内容は以下のとおりです。

表16-1: この章で学ぶこと

学ぶこと

ポイント

microgpt の目的

依存ゼロの標準ライブラリだけで GPT 型モデルを学習・推論する教育実装

公開者と一次ソース

Andrej Karpathy が GitHub Gist と解説ページで公開

本書での使い方

microgpt.py を読みながら GPT の仕組みを追い、Mojo への移植につなげる

16.2 microgpt とは

microgpt は、PyTorch などの外部ライブラリに頼らず、標準ライブラリだけで GPT 型の言語モデルを学習と推論できるようにした、単一ファイルの Python 実装です。

ファイル先頭の説明文がその本質を一言で表しています。

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.

この 2 文を噛み砕くと次のようになります。

“The most atomic way”(これ以上削れない最小構成):これ以上分解できないほど小さく、かつ完全であることを意味します。 大きなフレームワークが提供する抽象化を剥ぎ取り、GPT の本質だけを残した実装です。

“pure, dependency-free Python”(純粋・依存ゼロの Python)import mathimport structimport os など標準ライブラリのみで動作します。 NumPy の行列演算も PyTorch の自動微分も使わず、すべてのロジックを Python のリストと基本演算で実装します。

“This file is the complete algorithm. Everything else is just efficiency.”:GPT の学習と推論のアルゴリズムとして必要なものはこの 1 ファイルに完結しています。 PyTorch や CUDA が追加するのは速度であってアルゴリズムそのものではない、という宣言です。

microgpt に含まれるもの

../_images/microgpt_structure.jpg

図16-2: microgpt.py の構成

表16-2: microgpt の特徴

特徴

詳細

依存ゼロ

import するのは Python 標準ライブラリのみ(NumPy・PyTorch 不要)

単一ファイル

学習から推論まで 1 ファイルに収まる(251 行)

教育目的

大規模フレームワークの裏側で起きていることを「読み切れる分量」で追える

実務向きではない

速度・拡張性・本番運用は考慮しない。アルゴリズムの骨格だけを示す

Mojo 移植の出発点

依存がないため、標準ライブラリなしの Mojo にそのまま対応させやすい

16.3 誰が公開しているか

公開者は Andrej Karpathy です。 深層学習と言語モデルの教育コンテンツで広く知られており、ファイル内のクレジット(@karpathy)からも確認できます。

Andrej Karpathy について

Karpathy はスタンフォード大学で深層学習(CS231n)を担当し、OpenAI の創業メンバーの一人、Tesla の AI ディレクターを経て、2024 年からは独立して教育コンテンツの発信に注力しています。 「複雑なものを最小の実装で理解させる」スタイルの教育実装を多数公開しており、microgpt はその系譜に属します。

実装名

内容

micrograd

自動微分エンジンを 100 行で実装

minbpe

BPE トークナイザーを最小実装

nanoGPT

GPT の学習実装(NumPy と PyTorch 使用)

microgpt

GPT の学習と推論を標準ライブラリのみで実装

microgpt は nanoGPT をさらに小さくし、外部依存をゼロにしたバージョンです。 nanoGPT で GPU 高速化のために PyTorch が必要だった部分を、純粋な Python の演算に置き換えています。

一次ソースと本書内のファイル

表16-3: リソース一覧

リソース

場所

ソースコード(Gist)

https://gist.github.com/karpathy/8627fe009c40f57531cb18360106ce95

解説ページ

https://karpathy.ai/microgpt.html

本書プロジェクト内

src/part3/microgpt.py(章ごとに参照する行番号はこのファイルに対応)

本書では src/part3/microgpt.py を一次ソースとして扱います。 行番号はこのファイルに対応しており、microgpt.py を開きながら各章を読み進める構成になっています。

16.4 本書での位置づけ

本書では microgpt を「読んで理解する → Mojo に移植する → 高速化する」という 3 段階で扱います。

学習の流れ

../_images/microgpt_learning_flow.jpg

図16-4: 本書の学習の流れ(第3部の読み進め方)

表16-4: 各章での位置づけ

何をするか

第17章(仕様)

入出力・学習と生成の流れを俯瞰する。「何が入って何が出るか」を先に把握する

第18章(構造)

コード全体を 7 ブロック(B1〜B7)として地図化する。どこに何があるかを先に知ることで、細部を読むときに迷子にならない

第19〜24章(コードリーディング)

B1(前処理)から B7(推論ループ)まで、ブロックごとに行単位でコードを読む

第25章(Mojo 移植)

microgpt.py の各ブロックを Mojo に書き直す。第2部で学んだ structSIMDcomptime を実際に使う

第26〜28章(MAX 統合)

MAX(Modular AI eXecution platform)を使って Mojo 実装をさらに高速化する

第29〜30章(比較)

同じアルゴリズムを PyTorch・MLX で実装し、設計思想・速度・コード量を比較する

なぜ Python 実装を先に読むか

Mojo への移植を始める前に Python 実装を丁寧に読む理由は 2 つあります。

  1. アルゴリズムと実装を分離して理解する:GPT の仕組み(アルゴリズム)と Mojo の言語機能(実装手段)を混同しないようにするため。Python コードでアルゴリズムを先に把握し、その後「Mojo ではこう書く」というマッピングに集中できます。

  2. 依存ゼロの対称性:microgpt は NumPy も PyTorch も使わないため、Mojo への移植でも「対応するライブラリがない」という問題が生じません。math.exp(x) → Mojo の math.exp(x) のように 1 対 1 に近い形で対応できます。

16.5 microgpt.py の全文

本書が一次ソースとして扱う microgpt.py の全文を以下に示します。学習と推論のすべてがこの 1 ファイルに収まっている点に注目してください。今の段階では細部を理解する必要はありません。「これだけの分量で GPT が動く」という全体の規模感をつかんでおくことが目的です。

第19章以降では、このコードを 7 ブロック(B1〜B7)に分け、行番号をたどりながら 1 つずつ読み解いていきます。

注記:本書版で原典に加えた変更点

以下のコードは、読者の理解を助けるために一次ソース(16.3 のリソース一覧で示した Gist)へ次の変更を加えています。いずれも原文とは一致しません。

  • コメントの日本語化:原典の英語コメントを日本語に翻訳し、必要に応じて補足を加えています。

  • 説明用 docstring の追加linearsoftmaxrmsnormgpt などの関数に、処理内容を説明する docstring を筆者が追加しています。

  • 学習ログの表示形式の調整:原典は学習の進捗を同じ行に上書き表示しますが、本書版では各ステップのログが残るよう改行表示に変更しています(出力の見た目だけの違いで、計算結果には影響しません)。

これら以外のアルゴリズム(GPT 本体・自動微分・Adam・トークナイザー)は原典のままで、変更していません。

  1"""
  2The most atomic way to train and run inference for a GPT in pure, dependency-free Python.
  3This file is the complete algorithm.
  4Everything else is just efficiency.
  5
  6@karpathy
  7"""
  8
  9# =============================================================================
 10# 最小GPTの概要
 11# =============================================================================
 12# 本ファイルは、依存関係なしの純粋なPythonで実装された最小限のGPTである。
 13# 構成要素:
 14#   1. データセット: 名前リストなどのテキスト
 15#   2. トークナイザー: 文字→整数IDの変換
 16#   3. Value: 自動微分(Autograd)の計算グラフ
 17#   4. GPTモデル: 1層のTransformer(Attention + MLP)
 18#   5. Adam最適化: パラメータ更新
 19#   6. 推論: 次のトークンを確率的に生成
 20# =============================================================================
 21
 22import os       # os.path.exists
 23import math     # math.log, math.exp
 24import random   # random.seed, random.choices, random.gauss, random.shuffle
 25random.seed(42)  # 再現性のため乱数シードを固定
 26
 27# -----------------------------------------------------------------------------
 28# データセットの準備
 29# -----------------------------------------------------------------------------
 30# docs: 文書のリスト(例: 名前のリスト)。各行が1つの文書
 31if not os.path.exists('input.txt'):
 32    import urllib.request
 33    names_url = 'https://raw.githubusercontent.com/karpathy/makemore/988aa59/names.txt'
 34    urllib.request.urlretrieve(names_url, 'input.txt')
 35docs = [line.strip() for line in open('input.txt') if line.strip()]
 36random.shuffle(docs)  # 学習時の順序をランダム化
 37print(f"num docs: {len(docs)}")
 38
 39# -----------------------------------------------------------------------------
 40# トークナイザー
 41# -----------------------------------------------------------------------------
 42# 文字列を整数列(トークンID)に変換する。文字ベースのトークナイザー。
 43uchars = sorted(set(''.join(docs)))  # データセット内の全ユニーク文字 → ID 0..n-1
 44BOS = len(uchars)                    # 文の開始/終了を示す特殊トークン(Beginning of Sequence)
 45vocab_size = len(uchars) + 1         # 語彙サイズ(文字数 + BOS)
 46print(f"vocab size: {vocab_size}")
 47
 48# -----------------------------------------------------------------------------
 49# Value: 自動微分(Autograd)の実装
 50# -----------------------------------------------------------------------------
 51# 計算グラフを構築し、backward()で連鎖律により勾配を逆伝播する。
 52# PyTorchのTensorの最小版。スカラー値のみを扱う。
 53class Value:
 54    __slots__ = ('data', 'grad', '_children', '_local_grads')  # メモリ最適化
 55
 56    def __init__(self, data, children=(), local_grads=()):
 57        self.data = data                # 順伝播で計算されたスカラー値
 58        self.grad = 0                   # 損失に対するこのノードの勾配(逆伝播で計算)
 59        self._children = children       # 計算グラフ上の子ノード
 60        self._local_grads = local_grads # 子ノードに対する局所勾配(連鎖律用)
 61
 62    def __add__(self, other):
 63        # 加算: d(a+b)/da=1, d(a+b)/db=1
 64        other = other if isinstance(other, Value) else Value(other)
 65        return Value(self.data + other.data, (self, other), (1, 1))
 66
 67    def __mul__(self, other):
 68        # 乗算: d(a*b)/da=b, d(a*b)/db=a
 69        other = other if isinstance(other, Value) else Value(other)
 70        return Value(self.data * other.data, (self, other), (other.data, self.data))
 71
 72    def __pow__(self, other): return Value(self.data**other, (self,), (other * self.data**(other-1),))
 73    def log(self): return Value(math.log(self.data), (self,), (1/self.data,))
 74    def exp(self): return Value(math.exp(self.data), (self,), (math.exp(self.data),))
 75    def relu(self): return Value(max(0, self.data), (self,), (float(self.data > 0),))  # ReLU: max(0,x)
 76    def __neg__(self): return self * -1
 77    def __radd__(self, other): return self + other
 78    def __sub__(self, other): return self + (-other)
 79    def __rsub__(self, other): return other + (-self)
 80    def __rmul__(self, other): return self * other
 81    def __truediv__(self, other): return self * other**-1
 82    def __rtruediv__(self, other): return other * self**-1
 83
 84    def backward(self):
 85        # トポロジカルソートで計算グラフを逆順に辿り、連鎖律で勾配を伝播
 86        topo = []
 87        visited = set()
 88        def build_topo(v):
 89            if v not in visited:
 90                visited.add(v)
 91                for child in v._children:
 92                    build_topo(child)
 93                topo.append(v)
 94        build_topo(self)
 95        self.grad = 1  # 損失自身の勾配は1(dL/dL=1)
 96        for v in reversed(topo):
 97            for child, local_grad in zip(v._children, v._local_grads):
 98                child.grad += local_grad * v.grad  # 連鎖律: ∂L/∂child += (∂v/∂child) * (∂L/∂v)
 99
100# -----------------------------------------------------------------------------
101# モデルパラメータの初期化
102# -----------------------------------------------------------------------------
103n_layer = 1     # Transformerの層数(深さ)
104n_embd = 16     # 埋め込み次元(ネットワークの幅)
105block_size = 16 # コンテキスト長の上限(注意窓の最大長。最長名前は15文字)
106n_head = 4      # マルチヘッドアテンションのヘッド数
107head_dim = n_embd // n_head  # 各ヘッドの次元(n_embdをn_headで分割)
108matrix = lambda nout, nin, std=0.08: [[Value(random.gauss(0, std)) for _ in range(nin)] for _ in range(nout)]
109# パラメータ辞書: wte=トークン埋め込み, wpe=位置埋め込み, lm_head=言語モデル出力層
110state_dict = {'wte': matrix(vocab_size, n_embd), 'wpe': matrix(block_size, n_embd), 'lm_head': matrix(vocab_size, n_embd)}
111for i in range(n_layer):
112    # Attention: Q/K/V/O の4つの線形変換(Query, Key, Value, Output)
113    state_dict[f'layer{i}.attn_wq'] = matrix(n_embd, n_embd)
114    state_dict[f'layer{i}.attn_wk'] = matrix(n_embd, n_embd)
115    state_dict[f'layer{i}.attn_wv'] = matrix(n_embd, n_embd)
116    state_dict[f'layer{i}.attn_wo'] = matrix(n_embd, n_embd)
117    # MLP: 2層の全結合(中間層は4倍に拡張)
118    state_dict[f'layer{i}.mlp_fc1'] = matrix(4 * n_embd, n_embd)
119    state_dict[f'layer{i}.mlp_fc2'] = matrix(n_embd, 4 * n_embd)
120params = [p for mat in state_dict.values() for row in mat for p in row]  # 全パラメータを1次元リストに平坦化
121print(f"num params: {len(params)}")
122
123# -----------------------------------------------------------------------------
124# モデルアーキテクチャ
125# -----------------------------------------------------------------------------
126# トークン列とパラメータから「次に来るトークン」のlogitsを出力する関数。
127# GPT-2に準拠。相違点: LayerNorm→RMSNorm、バイアスなし、GeLU→ReLU
128
129def linear(x, w):
130    """線形変換: y = Wx。入力xと重みwから出力ベクトルを計算"""
131    return [sum(wi * xi for wi, xi in zip(wo, x)) for wo in w]
132
133def softmax(logits):
134    """ソフトマックス: logitsを確率分布に変換。数値安定性のためmaxで引いてからexp"""
135    max_val = max(val.data for val in logits)
136    exps = [(val - max_val).exp() for val in logits]
137    total = sum(exps)
138    return [e / total for e in exps]
139
140def rmsnorm(x):
141    """RMSNorm: 二乗平均の平方根で正規化。LayerNormの簡略版(平均を引かない)"""
142    ms = sum(xi * xi for xi in x) / len(x)
143    scale = (ms + 1e-5) ** -0.5
144    return [xi * scale for xi in x]
145
146def gpt(token_id, pos_id, keys, values):
147    """
148    GPTの順伝播。現在のトークンと位置から、次トークンのlogitsを出力。
149    keys, values: 過去のK/Vをキャッシュ(推論時の効率化、学習時は因果マスク用)
150    """
151    tok_emb = state_dict['wte'][token_id]   # トークン埋め込み
152    pos_emb = state_dict['wpe'][pos_id]     # 位置埋め込み
153    x = [t + p for t, p in zip(tok_emb, pos_emb)]  # トークン+位置の結合埋め込み
154    x = rmsnorm(x)  # 初期正規化(残差接続経由で勾配が流れるため冗長ではない)
155
156    for li in range(n_layer):
157        # --- 1) マルチヘッドアテンションブロック ---
158        x_residual = x
159        x = rmsnorm(x)
160        q = linear(x, state_dict[f'layer{li}.attn_wq'])  # Query
161        k = linear(x, state_dict[f'layer{li}.attn_wk'])  # Key
162        v = linear(x, state_dict[f'layer{li}.attn_wv'])  # Value
163        keys[li].append(k)
164        values[li].append(v)
165        x_attn = []
166        for h in range(n_head):
167            hs = h * head_dim
168            q_h = q[hs:hs+head_dim]
169            k_h = [ki[hs:hs+head_dim] for ki in keys[li]]
170            v_h = [vi[hs:hs+head_dim] for vi in values[li]]
171            # スケール付き内積: attn = softmax(QK^T / sqrt(d_k))
172            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))]
173            attn_weights = softmax(attn_logits)
174            # 重み付き和: output = attn @ V
175            head_out = [sum(attn_weights[t] * v_h[t][j] for t in range(len(v_h))) for j in range(head_dim)]
176            x_attn.extend(head_out)
177        x = linear(x_attn, state_dict[f'layer{li}.attn_wo'])  # ヘッド結合
178        x = [a + b for a, b in zip(x, x_residual)]  # 残差接続
179
180        # --- 2) MLPブロック ---
181        x_residual = x
182        x = rmsnorm(x)
183        x = linear(x, state_dict[f'layer{li}.mlp_fc1'])
184        x = [xi.relu() for xi in x]
185        x = linear(x, state_dict[f'layer{li}.mlp_fc2'])
186        x = [a + b for a, b in zip(x, x_residual)]  # 残差接続
187
188    logits = linear(x, state_dict['lm_head'])  # 語彙サイズ次元のlogits
189    return logits
190
191# -----------------------------------------------------------------------------
192# Adam最適化のバッファ
193# -----------------------------------------------------------------------------
194learning_rate, beta1, beta2, eps_adam = 0.01, 0.85, 0.99, 1e-8
195m = [0.0] * len(params)  # 第1モーメント(勾配の移動平均)
196v = [0.0] * len(params)  # 第2モーメント(勾配の二乗の移動平均)
197
198# -----------------------------------------------------------------------------
199# 学習ループ
200# -----------------------------------------------------------------------------
201num_steps = 1000  # 学習ステップ数
202for step in range(num_steps):
203
204    # 1文書を取得し、トークン化。前後にBOSを付与
205    doc = docs[step % len(docs)]
206    tokens = [BOS] + [uchars.index(ch) for ch in doc] + [BOS]
207    n = min(block_size, len(tokens) - 1)  # 予測する位置数
208
209    # 順伝播: 各位置で次トークンを予測し、負の対数尤度(交差エントロピー)を計算
210    keys, values = [[] for _ in range(n_layer)], [[] for _ in range(n_layer)]
211    losses = []
212    for pos_id in range(n):
213        token_id, target_id = tokens[pos_id], tokens[pos_id + 1]
214        logits = gpt(token_id, pos_id, keys, values)
215        probs = softmax(logits)
216        loss_t = -probs[target_id].log()  # 正解トークンの負の対数尤度
217        losses.append(loss_t)
218    loss = (1 / n) * sum(losses)  # 文書全体の平均損失
219
220    # 逆伝播: 全パラメータに対する勾配を計算
221    loss.backward()
222
223    # Adam更新: 勾配に基づいてパラメータを更新
224    lr_t = learning_rate * (1 - step / num_steps)  # 線形学習率減衰
225    for i, p in enumerate(params):
226        m[i] = beta1 * m[i] + (1 - beta1) * p.grad
227        v[i] = beta2 * v[i] + (1 - beta2) * p.grad ** 2
228        m_hat = m[i] / (1 - beta1 ** (step + 1))  # バイアス補正
229        v_hat = v[i] / (1 - beta2 ** (step + 1))
230        p.data -= lr_t * m_hat / (v_hat ** 0.5 + eps_adam)  # Adam更新式
231        p.grad = 0  # 次ステップ用に勾配をリセット
232
233    print(f"step {step+1:4d} / {num_steps:4d} | loss {loss.data:.4f}")
234
235# -----------------------------------------------------------------------------
236# 推論(生成)
237# -----------------------------------------------------------------------------
238temperature = 0.5  # (0,1]の範囲。低いほど確定的、高いほど多様な出力
239print("\n--- inference (new, hallucinated names) ---")
240for sample_idx in range(20):
241    keys, values = [[] for _ in range(n_layer)], [[] for _ in range(n_layer)]
242    token_id = BOS  # BOSから開始
243    sample = []
244    for pos_id in range(block_size):
245        logits = gpt(token_id, pos_id, keys, values)
246        probs = softmax([l / temperature for l in logits])  # temperatureで分布を調整
247        token_id = random.choices(range(vocab_size), weights=[p.data for p in probs])[0]  # 確率的サンプリング
248        if token_id == BOS:
249            break  # BOSが来たら終端
250        sample.append(uchars[token_id])
251    print(f"sample {sample_idx+1:2d}: {''.join(sample)}")

16.6 まとめ

本章で扱った内容を以下にまとめます。

表16-5: まとめ

要素

内容

microgpt

標準ライブラリのみで学習・推論まで行う教育向け単一ファイル実装

公開者

Andrej Karpathy(GitHub Gist + karpathy.ai で公開)

本書での役割

Python で GPT を理解してから Mojo へ移植するための出発点