ブラウザで回す最小GPT:Karpathy氏のmicrogptをJSに写した理由と詰まりどころ
この記事のゴールは、言語モデル全体の置き場所をはっきりさせることだ。
やることの核心は一つで、語彙に並んだ記号のうち 「次に来る1トークン」 の確率分布を出すこと。
注意機構や MLP はそのための中身で、長い文章は 1トークンずつ選んでつなぐのが基本形だ。
実験ページ(フルUI)
一枚の Python だけで完結する microgpt.py を、手元のタブで訓練からサンプル生成まで触れるようにしたのが midori321 の実験ページ です。
損失の折れ線やデータ件数の切り替えは、記事用の小デモには載せきれないので、本番の操作はそちらへ飛んでください。
Andrej Karpathy 氏の microgpt Gist は、外部ライブラリ無しで「トランスフォーマー型の言語モデル+スカラー自動微分+Adam」までが一続きに読める、圧縮された設計図だと感じた。
Python ならそのまま保存して走らせられる。自分の周りでは、URL を渡して「今この端末で動かしてみて」と言いやすいのがブラウザだったので、同じ数式の流れを JavaScript に写し、midori321 として載せた。
この記事の前提: 「トランスフォーマー」「アテンション(自己注意)」という語をどこかで見たことがあると読みやすい。
初見でも、上の「次の1トークン」デモと、下の分解デモを先に触れば用語は後追いで足りるようにしてある。
原版が固定しているハイパーパラメータ
Gist 上で明示されている値は次のとおり(コメント付きがそのまま microgpt.py にある)。
| 変数 | 値 | 意味(gist コメントの要約) |
|---|
n_layer | 1 | 層の深さ |
n_embd | 16 | 埋め込み幅 |
block_size | 16 | 注意窓の最大長(最長の名前は 15 文字、と注記あり) |
n_head | 4 | ヘッド数(head_dim = n_embd // n_head) |
重み初期化はガウス(標準偏差 0.08)、学習率 0.01、Adam の beta1=0.85, beta2=0.99, eps=1e-8、推論の temperature=0.5 なども gist 本文どおりだ。
なぜ「スカラーだけ」のオートグラドなのか
行列演算を BLAS に任せれば速い。
それでも gist はわざとスカラーの Value を積み上げる。勾配がどの加算・乗算を通って流れたかを、目で追える粒度に落としているからだと解釈した。ブラウザ版もそのまま踏襲している。
行列一発の matmul にすると速くなる反面、説明の解像度は下がる。教育用デモでは後者を選んだ。
埋め込みと位置、RMSNorm、MLP までの往復
トークン ID から wte でベクトルを取り、wpe で位置を足す。
続く RMSNorm は、LayerNorm の親戚だがバイアス無しの簡素形だ。残差接続の手前でもう一度効くので、勾配経路が単純にならないことに注意が要る。
注意ブロックのあとに残差で足し戻し、MLP では中間を ReLU で折り曲げてから戻す。アーキテクチャのコメントは gist に次の一行がある。
# Follow GPT-2, blessed among the GPTs, with minor differences:
# layernorm -> rmsnorm, no biases, GeLU -> ReLU
層数 1・幅 16 という極小サイズで、上の流れをなぞっている。
語彙はデータが決める(BOS の置き方)
gist では、データに出てくる文字だけが語彙になり、ソート済みの一意文字列から ID 0… を振る。
BOS(系列の特別トークン)の ID は len(uchars) で、語彙サイズは len(uchars) + 1(BOS 分の +1)だ。
uchars = sorted(set(''.join(docs)))
BOS = len(uchars)
vocab_size = len(uchars) + 1
文書の並びを shuffle しても、文字の集合そのものは通常かわらない(連結文字列の集合は順序に依存しない)。一方、訓練でどの文書をいつ引くかはシャッフルに依存する。
JS 移植では mulberry32 と Fisher–Yates を使い、Python の random.seed(42) + random.shuffle とは乱数列が一致しないので、ステップごとの損失曲線をビット単位で揃えることはしていない(後半のデモで比較)。
グラフを別ライブラリに頼らず勾配を流す
Value は子ノードと局所勾配を持ち、損失から backward() を呼ぶとトポロジカル順に葉へ伝播する。
PyTorch 等のテンソル自動微分と原理は同じで、ここでは教育用にスカラーだけに絞っている。
gist の backward の骨格は次の形だ(後ろから子へ local_grad * v.grad を足す)。
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
softmax の前に最大値を引く意味
logits が大きいと exp が発散し、数値が壊れる。
gist の softmax は「最大 logit を引いてから exp」という定番の安定化だ。
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]
ブラウザ版も同じ式にして、後段の負の対数尤度まで静かに繋げている。
注意ウェイトは「過去のキーへの視線の配分」
クエリとキーの内積をスケールし、softmax して値ベクトルを重み付き和する、という骨格は通常のトランスフォーマーと同じだ。
実装では各ステップで K・V のリストに append し、まだ存在しない未来のトークンは参照できない。行列で下三角マスクを敷くのと同型の因果注意になる。
KV がステップごとに伸びる様子は、keys[li].length を見ると追いやすい。
推論では、新トークンごとに位置だけ進み、過去の K・V はそのまま再利用される。ミニ実装は配列に順に積む形なので、データ構造がそのままコードに出る。
@alert[note]
ミニモデルかつ短い訓練では、損失が思ったほど下がらないか、下がっても生成はまだ「名前っぽい」に留まることが多い。
教材としてのゴールは SOTA ではなく、ループが回ることの確認だと割り切っている。
@endalert
短い訓練を iframe の中で回す
記事用はステップ数を絞った版にしている。
trainMicroGpt は 8 ステップごとに setTimeout(0) で yield し、UI スレッドを長時間占有しないようにしてある(実験ページも同じ)。
学習ループの核は gist と同型で、1 文書を [BOS] + 文字列 + [BOS] にして、各位置で次トークンを予測し、平均損失を backward している。
const tokens = [BOS, ...[...doc].map((ch) => uchars.indexOf(ch)), BOS];
const n = Math.min(blockSize, tokens.length - 1);
// … 各 posId で gpt → softmax → -log p(正解) …
if (step % 8 === 0) {
await new Promise((r) => setTimeout(r, 0));
}
計測メモ: 上記と同じ forward/backward/Adam を Node.js で 80 ステップだけ走らせ、performance.now() の差分は手元で 約 1764 ms(2026-03-22・単発)。
ブラウザでは描画や DevTools で変動するので、重さの目安程度に見てほしい。
試行錯誤のログ(ステップ数と体感)
- 最初は 1000 ステップ固定だけにしていた。内蔵30件だけの語彙では、更新が回りすぎても見た目の変化が薄く、タブが固まったように感じる時間が長かった。
- 120〜400 ステップ帯を選べるようにした。損失曲線が「下がり始めたかどうか」が判別しやすくなり、待ち時間との折り合いがついた。
names.txt 全件モードを足したあと、同じ 1000 ステップでは「データに対して更新が薄い」側に寄ることが分かった。実ページのヒント文に、300件以上では長めのステップも選べる旨を追記した。全件+数千ステップはノートPCでは現実的でないことも、自分で確認した。
Adam と学習率の線形減衰
gist どおり、一次・二次モーメントに beta1=0.85, beta2=0.99、バイアス補正つき Adam。
学習率は lr_t = learning_rate * (1 - step / num_steps) で線形に減らす(num_steps が分母の原版と同じ形)。
ミニモデルでは「最適化の勝負」より、式どおり動いているかの確認が主目的だ。
温度は「尖らせ具合」(gist の推論)
推論では logits を温度 T で割ってから softmax する。gist では次の形だ。
probs = softmax([l / temperature for l in logits])
コメントでは temperature = 0.5 とあり、# in (0, 1] と書かれている(大きくすると分布がならみ、小さくすると尖る、という使い分けの目安)。
inferSamples でも同じ操作をしている。
損失が「そこそこ高い」ことの意味を遊びで掴む
語彙サイズ V の一様予測なら、期待損失は ln(V) 近辺になる。
学習が進むとその下に入り込むが、次文字に複数の正解があり得るデータでは頭打ちが早い。
実ページの説明にもある通り、perplexity を exp(loss) とみなす目安は便利だが、ミニ構成では数字一発勝負にしない方がよい。
前向きから更新までを一列に並べる
文書トークン列を先頭から処理し、各位置で損失を積み、平均してから backward、Adam で重みを動かす。
コードを追うときは、この順番を頭に置くと迷子になりにくい。
再現性の話:シャッフルと乱数列
Python 版は random.shuffle と random.seed(42)。
JS 側は mulberry32 と Fisher–Yates。乱数の消費順序が異なるので、数値を Python と完全一致させることは狙っていない。
偏った並べ替え(sort にランダム比較を渡す類)を避ける意図は共通だ。
項目1
標準ライブラリの random と Value クラス
項目2
ブラウザの Math と同型の Value、乱数は Mulberry32 系
urllib で names.txt 取得
fetch で名前リストを引っ張るとき
gist は urllib.request.urlretrieve で names.txt を取得する。
ブラウザ版は同じ URL を fetch する。raw.githubusercontent.com は多くの環境で CORS が通るが、オフラインやポリシー次第では失敗するので、実ページでは内蔵30件へフォールバックする。
原典と、自分が参照した周辺
派生実装の索引として、awesome-microgpts(GitHub) も参照しやすい(公式というよりコミュニティのまとめ)。
@alert[info]
gist のコメント欄には C++/Rust など高速化版や派生実装への言及が集まっている。
速度を競う前に、スカラー版で計算グラフの形を追う価値は依然として高い。
@endalert
書き終えて残った感覚
この記事用の iframe と、実験ページのデモを組み立てているとき、コードを写すだけでは済まないところが次々に出てきた。
数値の安定化、シャッフル、メインスレッドを切る間隔、説明文の言い回し。触っていくほど「ここは読者が詰まりそうだ」という細部に目が行く。
データ件数を増やしたり、ステップ数を伸ばしたりすると、損失の下がり方やサンプルの質まで様子が変わる。
収束の度合いを言い切ろうとして調べれば調べるほど、データ量・語彙・学習率・初期値が絡み合っていて、短い結論に還元しにくいことに気づく。わかったつもりでいた箇所が、また曖昧に戻っていく。
それでもログを眺め続けるのは、ミニ構成だからこそ全体を一度なめられるからだと思う。
負の対数尤度を位置ごとに平均する理由
各位置で「正解の次トークン」にだけ高い確率が欲しい。
多クラス分類の交差エントロピーと同型で、-log p(正解) を足して平均する。gist では次のように書かれている。
loss_t = -probs[target_id].log()
loss = (1 / n) * sum(losses)
系列が短い名前だと n が小さく、ステップごとのノイズが損失曲線にそのまま出やすい。
だからフルページ側では複数ステップ分を折れ線に描き、単発の上下動ではなく傾きを見るようにした。
もう一段だけ深い数式に近づく人向けのメモ
注意スコアを q·k / sqrt(d_head) で割るのは、内積の分散が次元に比例するため、softmax が極端に尖りすぎるのを抑える定番スケーリングだ(gist では head_dim**0.5 で割っている)。
RMSNorm の 1e-5 はゼロ除算回避に加え、極端に小さなノルムでの暴れ対策にも効く。
論文を追うほど意味がはっきりするが、ミニコードでは「定数がこうなっている」として先に受け入れ、あとから理屈を足しても遅くはない。
ヘッドを四つに割る意味(n_embd=16 の場合)
n_head=4 なら各ヘッドの次元は 4。
実装では Q を先頭から連続スライスし、ヘッドごとに過去の K・V 列との内積を取り、小さなベクトルを連結して attn_wo に渡している。
パラメータ数は増えるが、同じ埋め込み次元の中で並列の「見方」を増やすイメージだ。四分割は原版に合わせた選択だ。
ソースを読み、AIに突っ込み、デモに落としたあとで残ったこと
最初は microgpt の gist を上から順に追い、Value の子リンクと backward の流れだけでも手で辿った。
行間に省略されている前提が見えないときは、該当箇所を AI に貼って質問し、返答をそのまま信じずに microgpt-demo.js の行と突き合わせた。
筋が通った部分だけを抜き出し、記事用の iframe デモに最小形で載せると、数式のラベルが急に手触りを帯びる。
派手な生成より、この往復のあとで初めて自分の言葉で説明できるようになった箇所が、いちばん残った。
接頭辞の logit ランキングが「まだノイズ」に見えるとき
実験ページのデモ C は、学習前のランダム初期重みでもボタン一つで表が出る。
最初はどの文字も似たような確率に近く、順位が毎回意味を持つわけではない。
数十〜数百ステップ回したあとで同じ接頭辞を叩き直すと、名前らしい偏りが見え始めることがある。
逆に言えば、ミニ構成では「ランキングがきれいに並ぶ」まで持っていくのは難しく、曲線とサンプル文字列の方が学習の兆候としては分かりやすい場面も多い。
GeLU ではなく ReLU を使う理由(この gist の範囲)
原版コメントは上で引用したとおり、GPT-2 系に寄せつつ GeLU を ReLU に置き換えている。
厳密な再現性より、活性化の分岐(0 で切れる)が勾配の流れにどう効くかを短いコードで追いやすくするためだと理解した。
大規模モデルの細部まで合わせるなら別実装になるが、今回の目的は「一周を自分の力で追えること」に置いた。
パラメータ数が語彙とともに伸びることの実感
内蔵30件だけのときと、names.txt 全件を読み込んだときでは、出現文字の種類が増え、埋め込み行列 wte と lm_head の行数が増える。
ステップあたりの計算量とメモリはおおよそ比例して重くなる。
だから実ページでは「全件+長ステップ」を選ぶ前に、件数とステップの組み合わせを説明文で制限した。小さく動かしてから徐々に増やす方が、ボトルネックも把握しやすい。
バイアス無しの線形層にしたときの印象
トランスフォーマー実装によっては注意や MLP にバイアス項を足すが、この gist では省かれている(上記 GPT-2 コメントの no biases)。
パラメータ数は減る一方、表現力は行列のランクに寄り切りになる。
ミニサイズでは「バイアスが足りないせいで学習が詰まる」より先に、そもそも容量とデータが足りない側の壁に当たりやすい。教育用コードとして、項数を減らして眺めやすくする意図が強いと捉えた。