🦙

Karpathy氏の200行GPT「microGPT」を1行1行読み解く

に公開

2026年2月にAndrej Karpathy(アンドレ・カーパシー)氏が公開した「microGPT」。話題になったときに触っていて、途中まで記事を書きかけにしていたの、すっかり忘れていたので今頃公開してみます。

https://x.com/karpathy/status/2021694437152157847

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

200行の外部ライブラリを使わない(PyTorchもNumPyもないです)で、GPTを訓練・推論するという意欲的なコードです。

この記事では、microGPTを動かす方法を紹介するとともに、コードの内容を少し深堀りしながら、GPT(Transformer)の仕組みを理解していきたいと思います。

なお、可視化のコードやコードの理解にはAIを活用した上で、自分なりにチェックをしています。間違いがあったら、ご指摘いただけましたら幸いです。

また、似たコンセプトの実際に1行ずつ実行して可視化しながら理解を深める記事としては、以下記事が分かりやすくクオリティ高いので、この記事は読まずに以下の記事を読んでもよいかと思います。

https://growingswe.com/blog/microgpt

論よりRUN

まずは一旦動かしてみましょう。ライブラリの依存がないため、Pythonさえ入っていれば、MacでもLinuxでもWindows(WSL2)でも簡単に動くと思います。

ターミナルで以下のコマンドを実行してください。

curl -O https://gist.githubusercontent.com/karpathy/8627fe009c40f57531cb18360106ce95/raw/microgpt.py
python3 microgpt.py

以下のように学習がはじまり、最後に学習したモデルでの推論結果が出ます。

num docs: 32033
vocab size: 27
num params: 4192
step    1 / 1000 | loss 3.3660
step    2 / 1000 | loss 3.4243
step    3 / 1000 | loss 3.1778
step    4 / 1000 | loss 3.0664

(略)

step  998 / 1000 | loss 2.4764
step  999 / 1000 | loss 2.4730
step 1000 / 1000 | loss 2.6497

--- inference (new, hallucinated names) ---
sample  1: kamon
sample  2: ann
sample  3: karai
sample  4: jaire
sample  5: vialan
sample  6: karia
sample  7: yeran
sample  8: anna
sample  9: areli
sample 10: kaina
sample 11: konna
sample 12: keylen
sample 13: liole
sample 14: alerin
sample 15: earan
sample 16: lenne
sample 17: kana
sample 18: lara
sample 19: alela
sample 20: anton

どうも、人の名前を学習して、人らしい名前を生成するプログラムのようです。

「どうしても自分のPC上じゃ動かない!」という人は、以下のサイトで「RUN」すればとりあえずブラウザで動くはずです。

https://huggingface.co/spaces/webml-community/microgpt.js

microGPTの全体像

microGPTの全体構成です。200行のコードは大きく6つのセクションに分かれます。

セクション 行数 内容
1. データ準備 ~20行 名前データセットの読み込み
2. トークナイザ ~5行 文字 ↔ 数値IDの変換
3. Autograd ~50行 自動微分エンジン(計算グラフ+逆伝播)
4. パラメータ初期化 ~15行 重み行列の生成
5. モデル定義 ~60行 Transformer(Attention + MLP)
6. 訓練+推論 ~50行 Adamで最適化 → テキスト生成

ChatGPTとアーキテクチャは同じですが、規模は全然違います。

  • microGPT: 4,192パラメータ、名前データで学習、名前を生成
  • GPT-4: 数千億パラメータ(推定)、インターネット規模のテキストで学習、対話が可能

ここからがコードの解説です。

セクション1: データ準備とトークナイザ

データセット

# input.txt がなければダウンロード(約32,000個の英語の人名)
if not os.path.exists('input.txt'):
    import urllib.request
    names_url = 'https://raw.githubusercontent.com/karpathy/makemore/refs/heads/master/names.txt'
    urllib.request.urlretrieve(names_url, 'input.txt')

# 各行を読み込んでリスト化。例: ["emma", "olivia", "ava", ...]
docs = [l.strip() for l in open('input.txt').read().strip().split('\n') if l.strip()]
random.shuffle(docs)

学習データは約32,000個の英語の人名です。このファイルで生データが確認できます。

これをdocsに読み込んでランダムに並び替えています。

トークナイザ

# 全ドキュメントから一意な文字を抽出 → ['a', 'b', ..., 'z'](26文字)
uchars = sorted(set(''.join(docs)))

BOS = len(uchars)        # BOS (Beginning Of Sequence) = 26
vocab_size = len(uchars) + 1  # 語彙サイズ = 27

ChatGPTのトークナイザ(BPE、語彙数10万以上)と比べると超シンプルですが、やっていることの本質は同じです。文字列を数値IDに変換して、モデルが処理できるようにしています。

セクション2: Autograd(自動微分エンジン)

PyTorchの torch.Tensor の超簡易版を実装しています。

Valueクラスが以下の値を持っています。

  • data : スカラー値(フォワードパスで計算)
  • grad: 勾配(バックワードパスで計算)
  • _children : 計算グラフ上の子ノード
  • _local_grads : 局所勾配
class 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):
    # 加算: f(a, b) = a + b → df/da = 1, df/db = 1
    other = other if isinstance(other, Value) else Value(other)
    return Value(self.data + other.data, (self, other), (1, 1))

def __mul__(self, other):
    # 乗算: f(a, b) = a * b → df/da = b, df/db = a
    other = other if isinstance(other, Value) else Value(other)
    return Value(self.data * other.data, (self, other), (other.data, self.data))

def __pow__(self, other): return Value(self.data**other, (self,), (other * self.data**(other-1),))
def log(self): return Value(math.log(self.data), (self,), (1/self.data,))
def exp(self): return Value(math.exp(self.data), (self,), (math.exp(self.data),))
def relu(self): return Value(max(0, self.data), (self,), (float(self.data > 0),))

演算するたびに「局所勾配」を一緒に記録しています。

微積分ですね。加算なら勾配は (1, 1)、乗算なら (b, a) です。他は以下のような感じですね。

out = a ** n  → ∂out/∂a = n·a^(n-1)
out = log(a)  → ∂out/∂a = 1/a
out = exp(a)  → ∂out/∂a = exp(a)
out = relu(a) → ∂out/∂a = 1 (a>0のとき) / 0 (それ以外)

reluは、ディープラーニングでよく用いられる活性化関数です。図にすると簡単で、下図の左のように0より大きいと比例して、0以下だと0のままという関数です。

微分すると0より大きいと1、0以下だと0となるため、計算が単純化できるのが利点です。

これらが後でバックプロパゲーションに使われます。

バックプロパゲーション

def backward(self):
    # ステップ1: トポロジカルソート(依存順に並べる)
    topo = []
    visited = set()
    def build_topo(v):
        if v not in visited:
            visited.add(v)
            for child in v._children:
                build_topo(child)
            topo.append(v)
    build_topo(self)

    # ステップ2: 出力→入力の順で勾配を伝播(連鎖律)
    self.grad = 1  # dL/dL = 1
    for v in reversed(topo):
        for child, local_grad in zip(v._children, v._local_grads):
            child.grad += local_grad * v.grad

再帰を多用するので、ちょっと分かりづらいですが、要は「子(入力側)が先、親(出力側)が後」の順にリスト化しています。リストに加えたらvisitedに入れて、同じノードを二度並べないようにしています。。

reversed(topo)で順序を子→親から親→子に逆転させて、各ノードvについて以下の計算をして、連鎖律(chain rule)により誤差を逆伝播させています。

child.grad += local_grad * v.grad

セクション3: パラメータ初期化

n_embd = 16        # 埋め込み次元
n_head = 4         # アテンションヘッド数
n_layer = 1        # レイヤー数(GPT-2は12〜48層)
block_size = 16    # 最大シーケンス長
head_dim = n_embd // n_head  # = 4(各ヘッドの次元)

重み行列をガウス分布で初期化しています。

matrix = lambda nout, nin, std=0.08: [
    [Value(random.gauss(0, std)) for _ in range(nin)]
    for _ in range(nout)
]

state_dict = {
    'wte': matrix(vocab_size, n_embd),    # トークン埋め込み (27×16)
    'wpe': matrix(block_size, n_embd),    # 位置埋め込み (16×16)
    'lm_head': matrix(vocab_size, n_embd),  # 出力射影 (27×16)
}

全パラメータ数は4,192個です。GPT-2の1.24億と比べると非常に小さいですが、構造は同じです。

セクション4: Transformerアーキテクチャ

GPT-2に準拠していますが、3つの簡略化があります。

  • LayerNorm → RMSNorm(学習可能パラメータなし)
  • GeLU → ReLU(活性化関数の簡略化)
  • バイアスなし(全ての線形層)

線形変換とソフトマックス

以下の通りです。解説は省略します。

def linear(x, w):
    """行列×ベクトル: y = Wx"""
    return [sum(wi * xi for wi, xi in zip(wo, x)) for wo in w]

def softmax(logits):
    """任意の実数ベクトル → 確率分布に変換"""
    max_val = max(val.data for val in logits)  # オーバーフロー防止
    exps = [(val - max_val).exp() for val in logits]
    total = sum(exps)
    return [e / total for e in exps]

GPT本体

def 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)

tok_embpos_embは、トークンの埋め込みベクトルと位置の埋め込みベクトルです。これらを合成したものを学習します。

なお、位置情報の埋め込みに関しては、「Attention is All You Need」論文では、sin/cosを使ったPositional Encodingという手法が使われ、最近はRoPE(Rotary Position Embedding)というトークンの相対位置を回転行列(sinやcosの成分)で表現する手法が使われたりしています。

ここから先がTransformerのコア部分です。

マルチヘッドセルフアテンション

アテンション部分です。以下の3つのベクトルQ(Qeury), K(Key), V(Value)を定義します。

  • Query: 各入力トークンが「何を探しているか」を表すベクトル
  • Key: 各要素の「ラベル」のようなベクトル
  • Value: 実際に取り出したい「情報」を表すベクトル
# Q, K, V を射影
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'])

4つのヘッドが並列に動き、それぞれ異なる「視点」で注目します。

for h in range(n_head):  # 4つのヘッド
    # アテンションスコア: Q・K^T / √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)
    # 重み付き和でValueを集約
    head_out = [
        sum(attn_weights[t] * v_h[t][j] for t in range(len(v_h)))
        for j in range(head_dim)
    ]

数式としては以下となります。

\text{Attention}(Q, K, V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V

\sqrt{d_k}/ head_dim**0.5) のスケーリングは、内積が大きくなりすぎてソフトマックスが極端な分布になるのを防いでいます。

MLP(Feed-Forward Network)

# 16次元 → 64次元 → ReLU → 16次元
x = linear(x, state_dict[f'layer{li}.mlp_fc1'])  # 拡大
x = [xi.relu() for xi in x]                       # 非線形性
x = linear(x, state_dict[f'layer{li}.mlp_fc2'])  # 縮小

アテンションで集めた情報を、MLPで処理します。次元を4倍に拡大してReLu関数をはさんで、もとに戻しているのはGPT-2と同様の構造です。

残差接続

x = [a + b for a, b in zip(x, x_residual)]

アテンションの前後、MLPの前後で入力を足し戻しています。これが勾配消失を防ぎ、深いネットワークの学習を安定させるようです。

セクション5: 訓練ループ(Adam)

for step in range(1000):
    # 1つのドキュメントをトークン化
    doc = docs[step % len(docs)]
    tokens = [BOS] + [uchars.index(ch) for ch in doc] + [BOS]

    # フォワードパス: 各位置で次のトークンを予測
    for pos_id in range(n):
        logits = gpt(token_id, pos_id, keys, values)
        probs = softmax(logits)
        loss_t = -probs[target_id].log()  # クロスエントロピー損失

    # バックワードパス
    loss.backward()

    # Adam更新
    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  # 勾配の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  # 勾配リセット

勾配をもとにパラメータをAdamという代表的な手法を用いて最適化します。

range(1000)から分かるように1000ステップの学習をしています。

セクション6: 推論

temperature = 0.5  # 低い→保守的、高い→創造的

for sample_idx in range(20):
    token_id = BOS  # 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}: {''.join(sample)}")

推論により、学習済みモデルから新しい名前を生成します。

以下が出力例です。

sample  1: kamon
sample  2: ann
sample  3: karai
sample  4: jaire
sample  5: vialan

名前のような、名前のようでないような文字列が出力されました。

可視化: モデルの学習を「見る」

コードを読むだけでなく、訓練中の内部状態を可視化してみました。

Loss曲線


訓練が進むにつれてLossが下がっていく様子。3.3から2.0前後に収束。

初期のLossは ln(27) ≈ 3.3(ランダム予測のクロスエントロピー)。つまり最初は27文字から完全にランダムに予測していて、学習が進むにつれてパターンを捉えていきます。

生成テキストの変化


学習初期は意味不明な文字列が、学習が進むにつれて名前っぽくなっていく。

Step 1では意味不明な文字の羅列ですが、Step 1000では実在してもおかしくない名前が生成されます。

パラメータ分布の変化


初期は狭いガウス分布だった重みが、学習が進むにつれて広がる

Attention重みヒートマップ


4つのヘッドが異なるパターンで過去のトークンに注目している様子。

学習初期はAttentionがほぼ均一ですが、学習が進むと各ヘッドが異なる注目パターンを学習していきます。あるヘッドは直前のトークンに集中し、別のヘッドはもっと広い範囲を見ています。

可視化コード

可視化に使ったコードは以下です。参考まで。

可視化コード
"""
microGPT 可視化スクリプト — 訓練中の内部状態を可視化

オリジナル: https://gist.github.com/karpathy/8627fe009c40f57531cb18360106ce95

=== 可視化する内容 ===
1. 訓練Loss曲線(学習の進み具合)
2. Attention重みのヒートマップ(どのトークンに注目しているか)
3. 生成テキストの変化(学習が進むにつれて名前が改善される様子)
4. パラメータ分布の変化(重みがどう変わるか)

出力: /tmp/microgpt-study/figures/ に画像を保存
"""

import os
import math
import random
import matplotlib
matplotlib.use('Agg')  # GUI不要のバックエンド
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
import numpy as np

random.seed(42)

# ============================================================================
# データ準備(オリジナルと同じ)
# ============================================================================

if not os.path.exists('input.txt'):
    import urllib.request
    names_url = 'https://raw.githubusercontent.com/karpathy/makemore/refs/heads/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)

uchars = sorted(set(''.join(docs)))
BOS = len(uchars)
vocab_size = len(uchars) + 1

# ============================================================================
# Autograd(オリジナルと同じ)
# ============================================================================

class 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))

    def __pow__(self, other): return Value(self.data**other, (self,), (other * self.data**(other-1),))
    def log(self): return Value(math.log(self.data), (self,), (1/self.data,))
    def exp(self): return Value(math.exp(self.data), (self,), (math.exp(self.data),))
    def relu(self): return Value(max(0, self.data), (self,), (float(self.data > 0),))
    def __neg__(self): return self * -1
    def __radd__(self, other): return self + other
    def __sub__(self, other): return self + (-other)
    def __rsub__(self, other): return other + (-self)
    def __rmul__(self, other): return self * other
    def __truediv__(self, other): return self * other**-1
    def __rtruediv__(self, other): return other * self**-1

    def backward(self):
        topo = []
        visited = set()
        def build_topo(v):
            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
        for v in reversed(topo):
            for child, local_grad in zip(v._children, v._local_grads):
                child.grad += local_grad * v.grad

# ============================================================================
# パラメータ初期化(オリジナルと同じ)
# ============================================================================

n_embd = 16
n_head = 4
n_layer = 1
block_size = 16
head_dim = n_embd // n_head

matrix = lambda nout, nin, std=0.08: [[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)}")

# ============================================================================
# モデル定義(Attention重みを記録する版)
# ============================================================================

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(val.data for val in logits)
    exps = [(val - max_val).exp() for val in logits]
    total = sum(exps)
    return [e / total for e in exps]

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]

# Attention重みを記録するための変数
attn_weights_log = []  # [(step, head_idx, weights_matrix), ...]

def gpt(token_id, pos_id, keys, values, record_attn=False):
    """Attention重みの記録オプション付き"""
    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)

    all_attn_weights = []  # このトークンの全ヘッドのアテンション重み

    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_vals = linear(x, state_dict[f'layer{li}.attn_wv'])

        keys[li].append(k)
        values[li].append(v_vals)

        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))]
            weights = softmax(attn_logits)

            if record_attn:
                all_attn_weights.append([w.data for w in weights])

            head_out = [sum(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)]

        x_residual = x
        x = rmsnorm(x)
        x = linear(x, state_dict[f'layer{li}.mlp_fc1'])
        x = [xi.relu() for xi 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, all_attn_weights

def generate_sample(temperature=0.5):
    """テキスト生成(推論)"""
    keys_inf, values_inf = [[] for _ in range(n_layer)], [[] for _ in range(n_layer)]
    token_id = BOS
    sample = []
    all_attn = []  # 全ポジションのアテンション重み

    for pos_id in range(block_size):
        logits, attn_w = gpt(token_id, pos_id, keys_inf, values_inf, record_attn=True)
        probs = softmax([l / temperature for l in logits])
        token_id = random.choices(range(vocab_size), weights=[p.data for p in probs])[0]
        all_attn.append(attn_w)
        if token_id == BOS:
            break
        sample.append(uchars[token_id])

    return ''.join(sample), all_attn

# ============================================================================
# 訓練ループ(記録付き)
# ============================================================================

learning_rate, beta1, beta2, eps_adam = 0.01, 0.85, 0.99, 1e-8
m_buf = [0.0] * len(params)
v_buf = [0.0] * len(params)

# --- 記録用 ---
loss_history = []
generated_samples = {}  # {step: [names]}
param_snapshots = {}    # {step: [param_values]}
attn_snapshots = {}     # {step: attention_weights}

num_steps = 1000
sample_steps = [0, 10, 50, 100, 200, 500, 999]  # これらのステップで詳細記録

print("Training started...")
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)

    record = step in sample_steps
    keys_tr, values_tr = [[] for _ in range(n_layer)], [[] for _ in range(n_layer)]
    losses = []
    step_attn = []

    for pos_id in range(n):
        token_id, target_id = tokens[pos_id], tokens[pos_id + 1]
        logits, attn_w = gpt(token_id, pos_id, keys_tr, values_tr, record_attn=record)
        if record:
            step_attn.append(attn_w)
        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_buf[i] = beta1 * m_buf[i] + (1 - beta1) * p.grad
        v_buf[i] = beta2 * v_buf[i] + (1 - beta2) * p.grad ** 2
        m_hat = m_buf[i] / (1 - beta1 ** (step + 1))
        v_hat = v_buf[i] / (1 - beta2 ** (step + 1))
        p.data -= lr_t * m_hat / (v_hat ** 0.5 + eps_adam)
        p.grad = 0

    loss_val = loss.data
    loss_history.append(loss_val)

    if step in sample_steps:
        # パラメータのスナップショット
        param_snapshots[step] = [p.data for p in params]
        # Attention重みのスナップショット
        attn_snapshots[step] = step_attn
        # テキスト生成サンプル
        saved_state = random.getstate()
        random.seed(0)  # 生成の再現性のため固定
        samples = []
        for _ in range(5):
            name, _ = generate_sample(temperature=0.5)
            samples.append(name)
        generated_samples[step] = samples
        random.setstate(saved_state)

    if (step + 1) % 100 == 0 or step in sample_steps:
        print(f"step {step+1:4d} / {num_steps:4d} | loss {loss_val:.4f}")

print("Training complete!")

# ============================================================================
# 可視化
# ============================================================================

os.makedirs('/tmp/microgpt-study/figures', exist_ok=True)

# --- 図1: Lossカーブ ---
fig, ax = plt.subplots(figsize=(10, 5))
ax.plot(range(1, num_steps + 1), loss_history, linewidth=0.8, color='#2196F3')
ax.set_xlabel('Training Step', fontsize=12)
ax.set_ylabel('Loss (Cross-Entropy)', fontsize=12)
ax.set_title('microGPT Training Loss Curve', fontsize=14, fontweight='bold')
ax.grid(True, alpha=0.3)

# サンプルステップをマーク
for s in sample_steps:
    if s < len(loss_history):
        ax.axvline(x=s+1, color='red', alpha=0.3, linestyle='--', linewidth=0.8)
        ax.annotate(f'step {s+1}', xy=(s+1, loss_history[s]),
                   xytext=(s+20, loss_history[s] + 0.1),
                   fontsize=7, color='red', alpha=0.7)

ax.set_ylim(bottom=0)
fig.tight_layout()
fig.savefig('/tmp/microgpt-study/figures/01_loss_curve.png', dpi=150)
plt.close()
print("Saved: 01_loss_curve.png")

# --- 図2: 生成テキストの変化 ---
fig, ax = plt.subplots(figsize=(10, 6))
ax.axis('off')
ax.set_title('Generated Names at Different Training Steps', fontsize=14, fontweight='bold', pad=20)

y_pos = 0.95
for step in sorted(generated_samples.keys()):
    samples = generated_samples[step]
    loss_at_step = loss_history[step] if step < len(loss_history) else 0

    ax.text(0.02, y_pos, f'Step {step+1:4d} (loss={loss_at_step:.2f}):',
           fontsize=11, fontweight='bold', fontfamily='monospace',
           transform=ax.transAxes, verticalalignment='top')
    ax.text(0.35, y_pos, '  '.join(samples),
           fontsize=11, fontfamily='monospace',
           transform=ax.transAxes, verticalalignment='top',
           color='#1565C0')
    y_pos -= 0.12

fig.tight_layout()
fig.savefig('/tmp/microgpt-study/figures/02_generated_names.png', dpi=150)
plt.close()
print("Saved: 02_generated_names.png")

# --- 図3: パラメータ分布の変化 ---
fig, axes = plt.subplots(2, 4, figsize=(16, 6))
fig.suptitle('Parameter Distribution Changes During Training', fontsize=14, fontweight='bold')

plot_steps = [s for s in sorted(param_snapshots.keys())][:8]
for idx, step in enumerate(plot_steps):
    ax = axes[idx // 4][idx % 4]
    vals = param_snapshots[step]
    ax.hist(vals, bins=50, color='#42A5F5', alpha=0.8, edgecolor='white', linewidth=0.3)
    ax.set_title(f'Step {step+1}', fontsize=10)
    ax.set_xlim(-0.5, 0.5)
    ax.set_xlabel('Value', fontsize=8)
    ax.set_ylabel('Count', fontsize=8)
    ax.tick_params(labelsize=7)

fig.tight_layout()
fig.savefig('/tmp/microgpt-study/figures/03_param_distribution.png', dpi=150)
plt.close()
print("Saved: 03_param_distribution.png")

# --- 図4: Attention重みヒートマップ ---
# 訓練の初期・中期・後期で比較
attn_vis_steps = [s for s in [0, 100, 999] if s in attn_snapshots]

if attn_vis_steps:
    fig, axes = plt.subplots(len(attn_vis_steps), n_head, figsize=(16, 4 * len(attn_vis_steps)))
    if len(attn_vis_steps) == 1:
        axes = [axes]
    fig.suptitle('Attention Weights Heatmap (per head, at different training stages)',
                fontsize=14, fontweight='bold')

    for row, step in enumerate(attn_vis_steps):
        step_attn = attn_snapshots[step]
        if not step_attn:
            continue

        # 各ポジションのアテンション重みを行列に組み立て
        seq_len = len(step_attn)
        for h in range(n_head):
            ax = axes[row][h] if len(attn_vis_steps) > 1 else axes[h]

            # アテンション行列を構築 (seq_len x seq_len)
            attn_matrix = np.zeros((seq_len, seq_len))
            for pos in range(seq_len):
                if step_attn[pos] and h < len(step_attn[pos]):
                    weights = step_attn[pos][h]
                    for t, w in enumerate(weights):
                        if t < seq_len:
                            attn_matrix[pos][t] = w

            im = ax.imshow(attn_matrix, cmap='Blues', aspect='auto', vmin=0, vmax=1)
            ax.set_title(f'Step {step+1}, Head {h+1}', fontsize=9)
            ax.set_xlabel('Key position', fontsize=8)
            if h == 0:
                ax.set_ylabel('Query position', fontsize=8)
            ax.tick_params(labelsize=7)

    fig.tight_layout()
    fig.savefig('/tmp/microgpt-study/figures/04_attention_weights.png', dpi=150)
    plt.close()
    print("Saved: 04_attention_weights.png")

# --- 図5: 全体サマリー ---
fig = plt.figure(figsize=(16, 10))
gs = gridspec.GridSpec(2, 2, figure=fig, hspace=0.35, wspace=0.3)
fig.suptitle('microGPT Training Summary\n(Karpathy, 243 lines, pure Python)',
            fontsize=16, fontweight='bold', y=0.98)

# 左上: Loss曲線
ax1 = fig.add_subplot(gs[0, 0])
ax1.plot(range(1, num_steps + 1), loss_history, linewidth=0.8, color='#2196F3')
ax1.set_xlabel('Step')
ax1.set_ylabel('Loss')
ax1.set_title('Training Loss')
ax1.grid(True, alpha=0.3)
ax1.set_ylim(bottom=0)

# 右上: パラメータ分布(初期 vs 最終)
ax2 = fig.add_subplot(gs[0, 1])
if 0 in param_snapshots and 999 in param_snapshots:
    ax2.hist(param_snapshots[0], bins=50, alpha=0.5, color='#EF5350', label='Step 1 (initial)', edgecolor='white', linewidth=0.3)
    ax2.hist(param_snapshots[999], bins=50, alpha=0.5, color='#42A5F5', label='Step 1000 (final)', edgecolor='white', linewidth=0.3)
    ax2.legend(fontsize=9)
ax2.set_xlabel('Parameter Value')
ax2.set_ylabel('Count')
ax2.set_title('Parameter Distribution: Initial vs Final')
ax2.set_xlim(-0.5, 0.5)

# 左下: 生成テキスト
ax3 = fig.add_subplot(gs[1, 0])
ax3.axis('off')
ax3.set_title('Generated Names Evolution')
y = 0.95
for step in sorted(generated_samples.keys()):
    samples = generated_samples[step]
    loss_val = loss_history[step] if step < len(loss_history) else 0
    text = f'Step {step+1:4d} (L={loss_val:.1f}): {", ".join(samples[:3])}'
    ax3.text(0.02, y, text, fontsize=8, fontfamily='monospace', transform=ax3.transAxes,
            verticalalignment='top')
    y -= 0.12

# 右下: Attention (最終ステップ、ヘッド1)
ax4 = fig.add_subplot(gs[1, 1])
if 999 in attn_snapshots and attn_snapshots[999]:
    step_attn = attn_snapshots[999]
    seq_len = len(step_attn)
    attn_matrix = np.zeros((seq_len, seq_len))
    for pos in range(seq_len):
        if step_attn[pos] and len(step_attn[pos]) > 0:
            weights = step_attn[pos][0]
            for t, w in enumerate(weights):
                if t < seq_len:
                    attn_matrix[pos][t] = w
    im = ax4.imshow(attn_matrix, cmap='Blues', aspect='auto', vmin=0, vmax=1)
    plt.colorbar(im, ax=ax4, fraction=0.046)
ax4.set_xlabel('Key position')
ax4.set_ylabel('Query position')
ax4.set_title('Attention Weights (Final, Head 1)')

fig.savefig('/tmp/microgpt-study/figures/05_summary.png', dpi=150)
plt.close()
print("Saved: 05_summary.png")

print("\n=== All figures saved to /tmp/microgpt-study/figures/ ===")
print("Files:")
for f in sorted(os.listdir('/tmp/microgpt-study/figures/')):
    print(f"  {f}")

まとめ

microGPTを丁寧に読み解いてみました。Transformerは、結構複雑なのでちゃんと理解するのは結構難しいと思いますが、こうやって200行にギュッと濃縮されたコードを追っていくと理解が深まってよいですね。

わからないところは、それこそLLMに聞いたり可視化してもらうことで、理解の助けになりました。

参考リンク

https://techfeed.io/entries/698e8a33ae04206495f569a7

https://growingswe.com/blog/microgpt

https://gigazine.net/news/20260314-microgpt-explained-interactively/

https://nyosegawa.com/series/study-llm/gpt-2/

関連記事

https://zenn.dev/karaage0703/articles/aedade69a9463f

https://zenn.dev/karaage0703/articles/defa2c7d126331

Discussion