Visualized

ゼロから作る日本語 LLM
GPT-2 の推論・学習の可視化から Modal での事前学習まで

テキストが入ってから、次の一語が出てくるまで。 GPT-2 small (117M) の推論と学習を、事前に走らせたトレースを使って段階的に眺め、 最後に日本語コーパスからフルスクラッチで学習します。

こんにちは!逆瀬川ちゃん (@gyakuse) です!

これからStudy LLMというシリーズでLLMの基礎〜発展、Agentに至るまで連載をしていきます。

きょうは GPT-2 を題材に現代のLLMの基礎を再学習していければと思います!まずは推論からはじめ、そしてどのように学習しているかを眺め、最後に実際にモデルをトレーニングしてみることで、現代のLLMの基盤が分かるようになると思います。これを読んだかたがLLMを事前学習から作れるようになる、というのが今回の目標です。後述しますが、Modalを使うことで無料でできるようになっています。

GPT-2 は 2019 年の OpenAI のモデルで、いまの Claude Opus 4.7 (2026-04) や GPT-5.4 (2026-03), Gemini 3.1 Pro (2026-02) のようなフロンティアモデルと比べるとかなり小さいです (small 版で 117M パラメータしかありません) し、もはや古典のような扱いを受けることもあると思います。 ただし Decoder-Only な Transformer というアーキテクチャ自体は、GPT-2 と 2026 年の最新モデルであんまり変わっていません。 GPT-2 のサイズで中身を一度丁寧に追いかけておけば、論文を読んだりニュースを読むとき、LLMを使っていてその挙動が気になるときの助けになると思うのです。

想定する読者像は任意の人間ですが、数式や事前知識として要求している事柄がいくらか入っているので、わからないときは本文をコピペして任意のLLMサービスで聞いて読み進めるとよいでしょう。

この記事でやること:

  • Part 1 (推論編): 入力テキストから次のトークンが決まるまでのステップを図と実データで追います
  • Part 2 (学習編): 次トークン予測というタスクと、それを GPU 上で回す仕掛けを眺めます
  • Part 3 (実践編): 実際にコードに落とし、日本語コーパスから GPT-2 small (110M) を Modal A100 で学習します
  • 各セクションの下に、数式と数値例が載った付録を用意しています (気になるところだけ開いてもらえれば OK です)

なお、Part 1 と Part 2 の実データは、4 つのプロンプトを HuggingFace transformers の gpt2 small に流して事前計算したものです。下のプロンプト切り替えに連動して、関連する図とヒートマップが選んだプロンプトのトレースに差し替わります。Part 3 では自分でフルスクラッチ学習したモデルの生成サンプルなどを貼っています。

プロンプトを選ぶ

まずはどの入力で中身を眺めるかを決めます。以下のボタンで切り替えると、以降のセクションの図やヒートマップが選んだプロンプトのトレースに差し替わります。とりあえず先頭のままでも OK です。

推論の全体像 — 1 回の forward で 1 トークン

GPT-2 が入力文字列から次の 1 トークンを出すまでにやっていることは以下のとおりです。先に流れを見ておきます。

  1. 分割: 文字列を整数の列 (トークン ID) に切る。ニューラルネットは文字を直接食えないため
  2. ベクトル化: 各 ID を 768 次元ベクトルに変換し、位置情報も足す。語の意味と語順を 1 本のベクトルに乗せるため
  3. 文脈を取り込む: 各位置が他の位置の情報を集めながら、同じ処理 (Attention + MLP) を 12 回重ねる。次のトークンを当てるには自分だけでなく前後の文脈が必要なため
  4. 確率化: 最終位置のベクトルを語彙サイズ (50,257) に戻し、softmax で確率分布にしてから 1 トークン選ぶ
  5. ループ: 出たトークンを入力の末尾に足してまた 1 に戻る。長文を生成するには、これを何十〜何百回と繰り返す

① Tokenize — 文字列をトークン列に

テキストはまず BPE (Byte Pair Encoding) で分割されます。 GPT-2 の語彙は 50,257 個、各トークンは整数 ID を持ちます。

クリックすると、そのトークンの ID・バイト列・先頭スペースの扱いが下に出ます。

付録なぜ BPE を使うのか — 過去の分割手法との比較

文字列をトークンに切る方法は、歴史的にいくつも試されてきました。 同じ文章が 3 流派でどう見えるか比べてみます。

文字分割は語彙が小さく (アルファベットなら数十) OOV (Out-Of-Vocabulary, 未知語) が絶対に出ませんが、系列長が伸びて Transformer の計算量 O(n2)O(n^2) がすぐ重くなってしまいます。 単語分割は系列は短いものの、語彙が爆発し (数百万)、学習時に見ていない単語があると扱えません (これが OOV です)。活用形が全部別トークンになるのも非効率です。

BPE はその折衷案です (Sennrich+ 2016)。大規模コーパスで「よく一緒に出るバイト (文字) のペア」を繰り返し統合していくことで、 高頻度な単語 (" the", " of") は 1 トークンに、低頻度な語は複数トークンに自然に分かれます。 どんな文字列でも必ずバイト列にバラせるので OOV が原理的に存在しないのが嬉しいところです。

GPT-2 は厳密には byte-level BPE を使っています。Unicode 文字ではなく UTF-8 バイトから始めるので、絵文字でも中国語でも同じ枠組みで扱えます。 後続の GPT-3, GPT-4, LLaMA 3, 2026 年時点の Gemma 4 や Qwen3.6-35B-A3B など、OSS/クローズドを問わずフロンティアモデルは基本的に BPE 系 (あるいは同じく sub-word 単位の sentencepiece) を使い続けています。

付録50,257 という語彙サイズはどこから来たのか

GPT-2 の語彙が 50,257 個、というのはなんとも中途半端な数字に見えます。切りのいい 50,000 でもなく、2 の累乗の 65,536 でもありません。 実はこの数字、3 つの足し算で綺麗に説明がつきます。

   256   ← 1 バイトで表現できる全パターン (0x00 〜 0xFF)
 50,000   ← 学習で得た BPE merges の数
      1   ← 特殊トークン <|endoftext|>
─────────
 50,257

最初の 256 は byte-level BPE のセーフティネットです。どんな文字列も最悪 1 バイトずつバラせば必ず表現できる、という安全装置で、語彙の低 ID 側はこの「生のバイト」に充てられます。実際に GPT-2 のトーカナイザで ID 0, 1, 2 をデコードすると、それぞれ !, ", # (バイトでいうと 0x21, 0x22, 0x23) が出てきます。

次の 50,000 は、OpenAI が WebText 上で BPE を 50,000 回マージして増やした語彙です。「マージ回数 = 新しく作るトークン数」なので、回数を増やせば語彙もそのぶん増える、という単純な関係です。この 50,000 という数字は論文本文で明示されていますが、特別深い根拠があるわけではなく、WebText のサイズと当時の計算資源から選ばれた実用的な落としどころ、という感じです (Radford+ 2019)。

最後の 1 個は <|endoftext|> で、語彙末尾の ID 50256 に 1 つだけ置かれています。学習時には「WebText 文書の区切り」、推論時には「生成を止める合図」を兼ねる特殊トークンです。GPT-2 はこの特殊トークンを 1 個しか持たない、というのが地味に大事なポイントで、GPT-3.5 以降の chat モデルで <|im_start|>, <|im_end|>, tool 呼び出し用トークンなどがどんどん増えていくのと対照的です。

語彙を実際に覗いてみる

語彙の中身は ID の位置によってはっきりと性質が変わります。低い ID は生のバイト、真ん中あたりは WebText で頻出するサブワード、最後が特殊トークン、という並びです。

IDtoken性質
0!生のバイト (0x21)
257" a" (先頭スペース込み)最頻出級なので早い段階で merge された
262" the"同上
286" of"同上
15496"Hello"そこそこ頻出する英単語
50255" gazed"末尾付近は低頻度の珍しい merge
50256<|endoftext|>最後に 1 個だけ足された特殊トークン

低 ID にバイト、大きい ID に長い merge、という並び順は BPE の学習履歴そのものを反映しています。" the" が ID 262 に座っているのは偶然ではなく、WebText 上で最も早くマージされるべきペアだった、という証拠です。

一方で英語以外の文字列は、この語彙の中ではかなり贅沢にトークンを使います。GPT-2 のトーカナイザで「東京」をエンコードすると 4 トークン ([30266, 109, 12859, 105])、「日本」も 4 トークン、絵文字「🎉」も 3 トークンになります。UTF-8 で 1 文字 3 バイトある漢字が、WebText にほとんど出てこないので merge されずに生のバイトのまま残った結果です。英語テキストがほぼ 1 単語 1 トークンで済むのとはだいぶ違う世界で、この偏りが「日本語モデルには日本語専用の tokenizer を用意したほうが効率がいい」と言われる一番の理由です (実際 Part 3 では SentencePiece で日本語特化トーカナイザを 0 から学習します)。

vocab サイズの流派の変遷

では語彙サイズは何を基準に決めればいいのか、というとこれが結構バラバラで、時期とモデルによって流派があります。

モデルリリースvocab サイズtokenizer
GPT-2201950,257byte-level BPE (50K merges + 1 特殊)
GPT-3202050,257GPT-2 と完全に同一
LLaMA / LLaMA-2202332,000SentencePiece BPE
GPT-4o2024200,019tiktoken o200k_base
LLaMA-32024128,256tiktoken 系
Qwen3.6-35B-A3B (OSS)2026248,320BPE (Qwen2Tokenizer)
Gemma 4-31B (OSS)2026262,144SentencePiece

vocab を増やすと 1 トークンあたりの情報密度が上がります。特に日本語・中国語のような「1 文字が UTF-8 で 3 バイト」な言語では、小さい vocab だと 1 文字が 3 トークンに分解されてしまう (さっきの「東京 → 4 トークン」がまさにこれです) のが、vocab を広げて非 ASCII の merge をたくさん持てば 1 文字 1 トークンに近づきます。

一方で埋め込み行列 WE のパラメータ数は vocab に比例します。GPT-2 の 768 次元で計算してみると、50,257 vocab で約 38.6M、128,256 vocab だと約 98.5M、200,019 vocab に至っては約 153.6M のパラメータが vocab 表だけで消費される計算です。小さいモデルにとってはこの重さが致命的で、たとえば Part 3 で日本語コーパスから学習する 110M モデルでは SentencePiece の 32K vocab を選んでいます。本体 110M のうちに vocab 表で 150M を足すとモデル本体より vocab 表のほうが重くなってしまう、というバランスの悪さを避けるためです。

vocab scaling law と「BPE 以後」の議論 (2024-2026)

「言語カバー」と「モデル総パラメータ予算」のトレードオフは 2026 年時点でも活発に議論されていて、2024-2026 にかけて流れが 2 つの方向に分岐しています。

1 つ目は「vocab はまだ全然足りていない」派です。Tao+ 2024 は IsoFLOPs 実験から vocab の scaling law を導出し、たとえば LLaMA2-70B は実際の 32K ではなく 216K vocab が計算予算に対して最適だった (7 倍の過小設計!) と予測しました (Tao+ 2024)。翌年の Huang+ 2025 は入力 vocab と出力 vocab を分離した上で「input vocab を 128 倍に広げると 400M モデルが 1B モデルと同性能に追いつく」ことを示し、vocab を「幅」「深さ」と並ぶ第 3 のスケール軸として位置付け直しました (Huang+ 2025)。プロダクションの採用例もこの方向に足並みを揃えていて、2024 年以降のフロンティアはほぼ全部 100K 超え、上の表で見たように Gemma 4 や Qwen3.6 は 250K 前後まで来ています。

2 つ目は「そもそも BPE ごと捨ててしまおう」派です。Meta が 2024 年末に出した Byte Latent Transformer (BLT) は固定トーカナイザを使わず、バイト列を「次バイトのエントロピー」で動的にパッチ分割するアーキテクチャで、8B パラメータ / 4T bytes のスケールで LLaMA 3 相当の性能 + 推論効率優位を達成しています (Pagnoni+ 2024)。「バイトから入ってトークン境界をモデル自身に学ばせる」という方針は後続の研究にも波及していて、BPE の設計そのものが研究レベルでは問い直されつつあります。

2026 年 4 月時点でこの 2 派の勝負はまだ決着していません。商用の開発現場は BPE 大 vocab 側が優勢で、OSS フロンティアが軒並み 100K〜262K まで来ている状況ですが、理論・研究側は byte-level 回帰の勢いも強い、というのが現状の景色です。GPT-2 が採った「50K merges + 1 特殊」という 2019 年の設計は、どちらの視点から見ても「もう古いバランス」と再解釈されつつある、と考えておくとちょうどいい解像度になります。

② Embed — ID をベクトルに

学習済みの 埋め込み が 2 枚あります:

  • WE: 50,257 × 768 — 各語彙 ID に対する 768 次元ベクトル
  • WP: 1,024 × 768 — 各位置 (0〜1023) に対する 768 次元ベクトル

各トークンについて、この 2 枚から ID に対応する行と位置に対応する行をそれぞれ引いてきて、足し合わせます。 これだけで「その語が」「その位置にある」という 2 つの情報が 1 本のベクトルに乗ります。

token embed WE[id]
position embed WP[pos]
x0 入力表現

縦は位置 (0〜N)、横は次元 (768 を 64 にブロック平均して表示) を示します。 色は 赤が正, 青が負。 行をクリックすると、その位置の足し算を下で詳しく見られます。

位置 0 の足し算を拡大:

WE[id]
WP[pos]
x0

付録実際の数値で追う — 途中式つき計算例

現在選択中のプロンプトで、先頭トークンの埋め込み計算を手で追ってみます。 表示用に 768 次元を 64 次元にブロック平均した値を使いますが、操作の手順は本物と同じです。

補足: ルックアップは行列積と等価

「ID で行を取る」という操作、実は数学的には one-hot ベクトルと WE の行列積そのものです:

WE[i]  =  eiWE W_E[\,i\,] \;=\; \mathbf{e}_i^\top \, W_E

ここで ei は i 番目だけ 1 で残り 50,256 個が 0 のベクトルです。 つまり「50,257 次元の one-hot と 768 次元の埋め込みの行列積」として定義できます。 ただし 50,256 個の 0 を真面目に掛けるのは無駄なので、実装は単に i 行目を読むだけで済みます。これを embedding lookup と呼びます。

この「one-hot × 行列 = 行の選択」という見方は Attention や最終層の unembed (WET との積) にも効くので覚えておくと得です。

付録なぜ位置情報を足すのか — 位置エンコーディングの設計論

Self-Attention は「集合に対する演算」です。入力トークンの順序を入れ替えても、 そのままでは出力も対応して入れ替わるだけで、語順の情報が失われます。 つまり "The cat sat""Sat the cat" を区別できません。

解決策は、位置に依存したベクトルを埋め込みに足すことです。これで各トークンは「その語が」「その位置にある」という複合的な表現になります。 足し算なので 768 次元の中で、語の意味を表す成分と位置を表す成分が線形結合の形で共存します。

  • Sinusoidal (Vaswani+ 2017, 原論文 "Attention Is All You Need"): sin/cos の周期関数で各次元に位置を符号化。学習不要、理論上は任意長に外挿可能
  • 学習可能 (GPT-2 が採用): WP を重みとして学習。柔軟だが、学習で見た長さを超えられない (後述)
  • 相対位置 (T5 Raffel+ 2020, ALiBi Press+ 2022): 絶対位置ではなく「何トークン離れているか」を使う。系列長を伸ばしやすい
  • RoPE (Su+ 2021): Q/K を位置に応じて回転させる。相対位置の性質を持ちつつ絶対位置も保て、学習外の長さにも比較的効く。LLaMA 系以降、2026 年時点で OSS モデル (Gemma 4 の p-RoPE や Qwen3.6-35B-A3B の RoPE+YaRN など) までほぼ全てがこの系統を採用

GPT-2 の最大文脈は 1024 トークン

WP の行数は 1024 です。位置 0, 1, ..., 1023 に対応する行だけしか用意されていません。 位置 1024 を引こうとしても、そんな行はないので処理できません。これに加えて、config.jsonn_ctx = 1024、causal mask の形状、KV cache の確保サイズも全て 1024 に揃っているため、複数箇所がまとめて 1024 を前提にしている、と言ったほうが正確です (openai/gpt-2 src/model.py)。これが「GPT-2 は 1024 トークンしか扱えない」の正体で、small/medium/large/XL 全バージョン共通の制約になっています。

Sinusoidal や RoPE のように関数で位置を計算する方式なら、理屈の上では任意の長さに延ばせます。 そこから学習で見た範囲を超える長さにも (ある程度) 使えるようになり、2026 年の Claude Opus 4.7 や Gemini 3.1 Pro の 1M トークン文脈、さらには OSS の Qwen3.6-35B-A3B が YaRN で native 約 262K を 約 1.01M まで伸ばす、といった long-context 化の流れが生まれていきました。

YaRN がどうやって RoPE の周波数を伸ばしているのか、その仕組みを数式レベルで追いたい方は、ABEJA Tech Blog の YaRN の解説記事 が丁寧でおすすめです。

モデルリリース最大文脈位置方式
GPT-12018512学習可能
GPT-220191,024学習可能
GPT-320202,048学習可能
LLaMA-220234,096RoPE
— 以降、位置方式が RoPE 系に置き換わり長文脈化が加速 —
GPT-5.42026-03272K 既定 / 最大 1M非公開
Claude Opus 4.72026-041M非公開
Gemini 3.1 Pro2026-021M非公開
Gemma 4 (OSS)2026-04128K / 256Kp-RoPE
Qwen3.6-35B-A3B (OSS)2026-04約 262K native / 約 1.01M (YaRN)RoPE + YaRN

「なぜ昔の GPT は短文しか扱えなかった?」の答えは、要するに WP が有限の行列だったからです。 関数ベースの位置エンコーディングに置き換わったことで、この制約が段階的に外れていきました。

③ Attention — 過去を振り返る

ここからが Transformer の心臓部です。② で作ったベクトル列が入ってきて、同じ形のベクトル列が出ていきます。ただし中身は書き換わっていて、各位置のベクトルに「過去の位置の情報」が混ざり込んだ状態になります。

各位置でやるのは 1 つだけです。自分と自分より前の位置のベクトルを、相性のいい位置ほど大きな重みで、合計が 1 になるように加重平均する。それだけです。未来は見ません (これを causal と呼びます)。具体的な重み分布は、このあとヒートマップで本物のデータを動かせます。

どうやって重みを決めるか: Q/K/V

相性を測るために、各位置のベクトルから Query (Q)、Key (K)、Value (V) という 3 つのベクトルを作ります。役割はこんなイメージです:

  • Q (Query): いま自分は何を探しているか
  • K (Key): 自分は何を提供できるか (看板)
  • V (Value): 実際に渡す情報の中身

「位置 5 が位置 2 をどれだけ見るか」は、5 の Q と 2 の K の内積で決まります。その値を softmax で合計 1 の重みに直して、その重みで全位置の V を加重平均する。これが Attention の正味の中身です。

なお各位置での Q/K/V は、元のベクトルに別々の行列を掛けるだけで作ります。その行列自体もデータから学ばれるので、「何を探すか」「何を看板に出すか」「何を渡すか」まで人間が決めずに済むのが Attention のご利益です。式 1 行の正体や dk\sqrt{d_k} で割るスケーリングの話は付録「Attention の式とスケーリングの意味」で扱います。

マスクで未来を隠す

位置 i が位置 i+1 以降を見てしまうと、答え合わせが見えてしまってカンニングになります。そこで attention 行列の右上三角を softmax 前に −∞ で埋めて、softmax 後の重みを 0 にします。これが causal mask で、下のヒートマップの右上が真っ白なのはその効果です。

1 層で 12 並列 (Multi-Head)

GPT-2 small は、この Q/K/V の仕組みを 1 層あたり 12 個並列で持ちます。12 個のヘッドがそれぞれ別の「注目の切り口」を学んでくれるので、あるヘッドは直前の単語を、別のヘッドは主語と動詞の対応を、といった分業が自然に発生します (Elhage+ 2021)。Head ごとの具体的な次元の割り方や、全位置を 1 回の行列積でまとめて処理できる並列性の話は付録に寄せています。

では実データで眺めていきます。プロンプト全体の N × N ヒートマップと、その下で行 1 つを棒グラフに拡大したもの、2 つを並べます。Layer / Head を動かすと、ヘッドごとに注目パターンの性格がはっきり変わるのが見えます:

薄=0 濃=1

行 i 列 j のマスは「位置 i が位置 j をどれだけ見るか」を表します。行の合計は 1、右上三角は causal マスクで全部 0 です。

位置 - (i=0) の attention 重み分布 — 上のヒートマップの i 行目を拡大したもの。赤 = 自分、青 = 過去、薄グレー = 未来 (causal mask で 0)
付録実際の数値で追う — Attention の重みはこう決まる

現在の Layer/Head で、特定の位置 (行) がどう重みを配分しているかを、裏の softmax 計算まで逆算しながら眺めます。

付録なぜ Attention が必要になったか — 2014 から 2017 までの流れ

GPT-2 が当然のように Attention を使っていますが、これは 2019 年時点ですでに 5 年越しの発明品です。なぜこの仕組みが Transformer の心臓に座ったのか、を理解するには、その前にあった RNN 時代の悩みを一緒に見ておくと腑に落ちます。

Attention 以前: RNN の「1 本のホース問題」

2014 年頃の機械翻訳の主流は Sequence-to-Sequence (seq2seq) でした (Sutskever+ 2014)。仕組みはシンプルで、英文をまず RNN (LSTM) に 1 単語ずつ食わせて、最後に出てきた隠れ状態 1 本 (数百〜千次元のベクトル) に「英文の意味」を全部詰め込み、それを別の RNN に渡してフランス語を 1 単語ずつ吐き出させる、という構成です。

この設計には根本的な欠陥がありました。「意味を詰め込む 1 本のベクトル」は固定長なので、英文が 5 単語でも 50 単語でも同じ器に入れなければいけません。長い文ほど情報が溢れて翻訳精度が落ちる、という実害が観測されていて、ベンチマーク上も文長が伸びるほど BLEU スコアがずるずると下がる、という典型的な失敗モードがありました。1 本のホースで川を流そうとして詰まる、みたいなイメージです。

Bahdanau 2014: 「最後のベクトル 1 本」を諦める

この問題に最初に切り込んだのが Bahdanau+ 2014 です (Bahdanau+ 2014)。アイデアは、「最後の 1 本」に情報を圧縮するのではなく、エンコーダ側が各単語ごとに出した隠れ状態を全部保持しておいて、デコーダが単語を 1 つ生成するたびに「いまどの入力単語が関連しているか」を都度計算して重み付き和で取り出す、というものでした。

この「どれくらい関連しているか」を出す機構が、最初の Attention です。論文中では alignment model と呼ばれていて、「decoder が出力単語 i を生成しているとき、encoder の入力単語 j にどれだけ注目するか」を学習可能な重みとして出力する作りでした。翻訳精度が長文で劇的に改善し、ボトルネック問題が消えたことが目に見える成果になりました。翌年の Luong+ 2015 (Luong+ 2015) が global/local attention などの派生を整理して、Attention は seq2seq の標準装備になっていきます。

Self-Attention: 外向きから内向きへ

ここまでの Attention は「encoder と decoder のあいだ」で使うものでした。これを「同じ系列の中」で使い始めたのが Self-Attention です。Cheng+ 2016 (Cheng+ 2016) や Lin+ 2017 (Lin+ 2017) あたりで「文中の単語同士で関連度を計算する」アイデアが現れ、RNN の隠れ状態に Self-Attention を組み合わせた構成がいくつも提案されます。ただこの時点ではまだ「RNN の補助として Attention を添える」という位置付けでした。

2017 "Attention Is All You Need": RNN を捨てる

Transformer の論文は、この流れの最終形として出てきました (Vaswani+ 2017)。「Self-Attention だけで seq2seq を作れば、RNN そのものを取り除いても翻訳精度が出る」という大胆な提案で、タイトル Attention Is All You Need がそれをそのまま言い切っています。

これが勝ち筋になった理由は 2 つあります。

  • GPU で並列化できる: RNN は定義上「時刻 t の計算は t-1 が終わるまで始められない」という時系列依存があり、GPU で並列化しにくい構造でした。Self-Attention は系列の全位置を同時に行列積 1 発で計算できるので GPU がフル稼働でき、学習速度が桁違いに上がります
  • 長距離依存が 1 ホップで繋がる: RNN では「50 単語離れた情報」は隠れ状態を 50 回経由する必要があり、途中で勾配が消えたり情報が薄れたりしました。Self-Attention なら任意の 2 位置間が attention 行列の 1 エントリで直接結ばれるので、距離に関係なく同じコストで情報を運べます

GPT-2 が 2019 年に出てくる頃には、この Transformer 流のやり方を decoder-only 構成にして「次のトークンを予測する」だけに特化したモデルが盛んに試されていました。上の図の ③ で見ている Masked Self-Attention は、まさにこの系譜 (Bahdanau 2014 → Vaswani 2017 → GPT-2) の直系の子孫で、2026 年の Claude Opus 4.7 や GPT-5.4 に至るまでこの骨格は変わっていません。「ここ 10 年の LLM 進化のほぼ全部が、Attention の上に積み上がっている」と言っても大げさではありません。

付録Attention の式とスケーリングの意味

上で見た attention 行列は、実は次の式から出てきます:

Attn(Q,K,V)=softmax ⁣(QKdk+M)V \text{Attn}(Q, K, V) = \text{softmax}\!\left(\frac{Q K^\top}{\sqrt{d_k}} + M\right) V
  • Q (Query): 今いる位置が何を探しているか
  • K (Key): 他の位置が何を提供できるか
  • V (Value): 他の位置が実際に渡す情報

Q と K の内積で「相性の良さ」を測り、softmax で確率化して、それを V の重み付き平均にするだけの操作です。シンプルですね。

なぜ dk\sqrt{d_k} で割るのかと言うと、dkd_k 次元の独立成分の内積は分散が dkd_k に比例して大きくなるからです。 放っておくと softmax が尖りすぎて勾配がほぼ 0 になってしまい、学習が進まなくなります。dk\sqrt{d_k} で割ると分散が 1 程度に整い、勾配が健全に流れます。GPT-2 small では dk=64d_k = 64 なので 64=8\sqrt{64} = 8 で割る、という単純な話です。

マスク MM は右上三角を -\infty で埋めた行列です。softmax 後に未来のトークンの重みを 0 にして、自己回帰性 (過去しか見ない) を保証します。ヒートマップで右上が真っ白なのはこれが理由です。

Multi-Head の具体 (12 並列の割り方)

Multi-Head Attention は、1 層のなかで上の式をまるごと 12 本走らせる構造です。GPT-2 small は 768 次元を 64 次元ずつ 12 個に割り当てて、各ヘッドがそれぞれ別の WQ,WK,WVW_Q, W_K, W_V を学びます。最後に 12 ヘッドぶんの出力 (それぞれ 64 次元) を横に連結して 768 次元に戻し、もう 1 つの射影 WOW_O を掛けて整えます。

つまり、1 ヘッドあたり WQ,WK,WVW_Q, W_K, W_V の 3 つに加えて、Multi-Head をまとめ戻すための WOW_O を 1 つ、計 4 つの行列が 1 層あたりのパラメータになります。どれも学習で決まるので、「何を探すか」「何を看板にするか」「何を渡すか」「どうまとめ直すか」までデータから学ばれます。ヘッドごとに「直前を見るヘッド」「主語と動詞の対応を見るヘッド」のような分業が自然に起きることが解析で知られています (Elhage+ 2021)。

なぜ GPU 時代に Attention が勝ったか

この式のいいところは、全位置の出力が 1 回の行列積で一気に出せる点です。QKQ K^\top は N × N の行列 1 本なので、N 個の位置を順に処理する必要がありません。RNN は定義上「時刻 t は t-1 が終わってからでないと始まらない」ので GPU が暇になりがちでしたが、Attention は GPU がフルに回るので学習速度が桁違いに上がりました。歴史的な経緯は付録「なぜ Attention が必要になったか」で掘り下げています。

④ 12 層を積む — ブロックは何をしているのか

③ で眺めた Attention は、実は 1 ブロックの中にあるパーツの 1 つでした。ヒートマップで Layer を 0 〜 11 で切り替えられたのも、この 12 個のブロックの中身を切り替えていたからです。ここからはカメラを引いて、1 ブロックの構造と、それを 12 個積むことで GPT-2 の本体になる全体像を眺めます。

1 ブロックのしていること

1 ブロックは 2 ステップだけです:

  1. Attention: ③ で見たとおり、各位置が過去の位置のベクトルを加重平均して情報を集めます
  2. MLP: 各位置で独立にベクトルを変換します。位置同士のやりとりはなく、ここでは「集めた情報を加工する」ことに専念します

ひとことで言うと、Attention が位置横断の情報収集、MLP が各位置での変換です。この 2 ステップを交互に繰り返すのが Transformer の基本形になります。

そしてもう 1 つ大事なのが、どちらのステップも結果を上書きしない、という点です。出力を元の入力に足し込むだけで済ませます。これを残差接続 (residual connection, He+ 2016) と呼びます。上書きしないおかげで深く積んでも信号が壊れにくくなります。

1 ブロックの中身
x
LayerNorm
Attention位置横断で混ぜる
residual
+
LayerNorm
MLP各位置で独立変換
residual
+
xℓ+1
これを 12 個縦に積む
Embedtoken + position
Block 1
Block 2
10 層
Block 12
LayerNorm
WE を掛けて語彙空間に戻す
logits (50,257)

ひとことで言うと、GPT-2 は 768 次元のベクトル列を、Attention (位置横断で情報を集約) と MLP (各位置で変換) の 2 ステップで 12 回編集するだけの装置です。これで文章が書けているのは、改めて驚異的だなと思います。ブロックが「上書きせず足し込むだけ」という性質の深い含意は付録「残差ストリームという見方」で扱います。

付録残差ストリームという見方 — ブロックの役割分担

ブロックがベクトルに「書き足す」だけ、という性質は、見方を変えると強力な含意を持ちます。入力 x0 から最終 x12 まで流れる 768 次元のベクトルを残差ストリーム (residual stream) と呼ぶことがあり、各ブロックはこの共有バスに対して「読んで、書き込む」役割を担います (Elhage+ 2021)。

  • Attention は残差ストリームから Q / K / V を読み、結果を残差ストリームに足して書き戻す
  • MLP も残差ストリームから読んで、加工結果を足して書き戻す
  • 各ブロックの仕事は独立で、ゼロから表現を作り直さない

このモデルの下では、学習中にある層が「何もしない (出力 0)」になってもブロックを素通りするだけで破綻しません。だから安全に深く積めて、途中のブロックが自然に分業を覚える (構文を捉える層、固有名詞を扱う層、次語を準備する層など) ことが解析で知られています。

MLP の内訳

本文では「各位置で独立に変換する」とだけ書いた MLP ですが、中身は 2 層の全結合です:

MLP(x)=W2GELU(W1x) \text{MLP}(x) = W_2 \,\text{GELU}(W_1 x)

GPT-2 small では W1 が 768 → 3072、W2 が 3072 → 768 です。間に挟まる GELU は非線形な関数で、これがないと全体がただの大きな行列積に潰れてしまって、層を深くしても何も増えません。中間の 3072 次元は入力の 4 倍で、Transformer の慣習的な設定になっています。

付録層ごとにベクトルがどう変わるか — ノルムで眺める

各ブロックが残差ストリームに足し込んでいく様子を、ベクトルの 長さ (L2 ノルム) で観察してみます。

下の図の読み方:

  • 各マスが「あるトークン × ある層」のベクトルの長さ
  • 左右の並びがトークンの並び順 (プロンプトの先頭 → 末尾)
  • 上下の並びが層の深さ (下が Embed 直後、上に行くほど後の層)
  • マスの濃さが値の大きさ。色が濃い = ベクトルが長い。数字は生の値

見どころ:

  • Embed は値が小さい (~1 程度)。埋め込み行列そのままの大きさ
  • 層を上がるほど値が大きくなる。各ブロックが足し込みを続けるので、残差ストリームはどんどん「太って」いく
  • 最上段の「最終 (+LN)」だけ桁が違う。これは最後に LayerNorm が入って値を正規化するから。この正規化済みベクトルに WET を掛けて logits を出す
  • 先頭トークン (一番左の列) が異様に大きいのは known phenomenon。最初の位置は過去の文脈がないので attention が発散しやすく、後段に行くほど異常値が溜まる。実用上の問題はあまりなく、Dettmers+ 2022 の量子化研究などで詳しく解析されている

色は値の大きさを log スケールで表示しています (桁違いのレンジを潰すため)。数字は生の値です。

⑤ Logits → 確率 → サンプリング

12 ブロックを抜けた 768 次元のベクトルを、最後に語彙サイズ (50,257) に戻します。これを ② の埋め込み WE の転置で行うのが GPT-2 の流儀で、出てくるのが語彙ごとのロジット (確率に変える前の生の値) です。softmax を通せば確率分布になり、そこから 1 語を選ぶ、という流れです。

ただ生の確率のままサンプリングすると稀な語が顔を出して文が崩れます。そこで分布を整えるフィルタが 3 つあり、下のスライダーで挙動を動かせます。中身の計算例や使い分けは付録に寄せています。

温度を下げると尖って greedy に近づき、上げると一様分布に近づきます。 top-k/top-p は「上位だけ残して再正規化」するフィルターです。

付録実際の数値で追う — logits → 確率 → 温度・top-p

ロジット (モデルの生の出力) から確率への変換、そして温度・top-p をかけたときに分布がどう変わるかを、今のプロンプトの上位トークンで計算してみます。

付録サンプリング手法の選び方 — greedy / top-k / top-p の使い分け

語彙全体 (50,257 個) から softmax(logits / T) で確率を取り、そのまま引けば「正直な」サンプリング。でもそれだと ロングテールのゴミトークン が稀に顔を出して文を壊します。これを抑えるのが各種フィルター:

  • greedy (argmax): 常に最確率を選ぶ。再現性 100%。ただし同じ文脈で同じ続きしか出ず、ループに陥りやすく、多様性はゼロ
  • temperature: logits を T で割ってから softmax。T < 1 で尖って greedy 寄りに、T > 1 で平たくなり冒険的になる。形を変えるだけで語彙を絞らない
  • top-k (Fan+ 2018): 上位 k 個だけ残して再正規化。単純だが、分布が尖っているときも平たいときも k が固定という欠点あり
  • top-p (nucleus, Holtzman+ 2020): 累積確率が p に達する最小集合を残す。分布が尖ればトークン数は少なく、平たければ多く、適応的。2026 年時点でもこれが事実上の標準

実運用では temperature=0.7, top_p=0.9 あたりが出発点としてよく使われます。 コード生成など「正解が狭い」タスクでは T=0 (greedy) が定番で、 詩や雑談など「多様性が欲しい」タスクでは T=1.0, top_p=0.95 のような設定が使われます。

⑥ 生成ループ — 一語ずつ伸ばす

一語生成したら、それを入力の末尾にくっつけてもう一度モデルに食わせます。 これを繰り返すのが自己回帰生成 (autoregressive generation) です。 下は greedy (各ステップで最確率を採用) のトレースになります。

各ステップで候補 20 個の確率が出ており、一番濃いのが採用された語です。

付録KV cache — 推論を高速化する仕掛け

上の生成ループを素直に実装すると、毎ステップで全トークンの Attention を計算し直すことになります。 ステップ tt では Q/K/V を tt 個ぶん再計算して、その t×tt \times t の attention 行列も毎回作り直しになります。 各ステップ単独で見ると attention は O(t2d)O(t^2 \cdot d)、K/V/Q/O 射影は O(td2)O(t \cdot d^2) かかります。

でも新しく計算が必要なのは、最後に追加された 1 トークンぶんの K/V だけです。 過去のトークンに対する K と V は前ステップで既に計算済みで、値も変わりません (causal self-attention は過去しか見ないので)。 ならば覚えておいて使い回せばいい、というのが KV cache の発想です。これで各ステップは 「新トークンの Q を、cache 済みの K_{1..t} に attend させる」だけで済むので、 attention は O(td)O(t \cdot d)、射影は O(d2)O(d^2) まで落ちます。

  (no cache)                       (with KV cache)
  step 1: recompute K₁V₁           step 1: compute K₁V₁, save
  step 2: recompute K₁V₁,K₂V₂      step 2: compute only K₂V₂, append
  step 3: recompute K₁V₁,K₂V₂,K₃V₃ step 3: compute only K₃V₃, append

  per-step attn cost: O(t²)        per-step attn cost: O(t)
  N トークン生成の合計  : O(N³)       N トークン生成の合計  : O(N²)

よく「KV cache を入れると O(N²) → O(N) になる」と表現されますが、これはあくまで K/V 射影 (1 トークン分の x → K, V) の累積コストを各ステップで再計算するか 1 回で済ませるかの比較です。decode 全体では、cache を入れても新トークンが prefix 全体に attend する必要があるので、attention の合計コストは O(N2)O(N^2) のままになります (HF cache explanation, Pope et al. 2022)。

メモリは 2×nlayer×T×dmodel×B2 \times n_\text{layer} \times T \times d_\text{model} \times B 消費します (K と V の 2 枚ぶん)。 GPT-2 small なら 2×12×1024×768×4B=72MiB75MB/request2 \times 12 \times 1024 \times 768 \times 4\,\text{B} = 72\,\text{MiB} \approx 75\,\text{MB/request} (fp32, batch=1 前提)。MHA なので n_head × d_head = d_model ですが、GQA/MQA では分子が n_kv_head × d_head に置き換わります。大規模モデルだとこれが何 GB にもなるので、 推論時のメモリボトルネックになります。2026 年時点で広く使われている高速推論エンジン (vLLM, SGLang) の仕事は、このメモリ管理とスケジューリングをいかに効率化するかに尽きます。

ちなみに学習時には KV cache は使いません。Teacher forcing で全位置を並列に計算するため、 再利用する前ステップがそもそも存在しないからです。KV cache はあくまで推論固有の最適化になります。

学習の全体像 — forward → loss → backward → update の繰り返し

推論が「入力 → forward → 次のトークン」で終わるのに対して、学習は 1 ステップが 4 段階に分かれていて、それをひたすら回し続けるループです。

  1. forward: 推論と同じ forward pass で、各位置の次トークン確率分布を出す
  2. loss: 出た分布と「本当の次のトークン」を比較して、cross-entropy でズレを数値化する。正解に尖った分布ほど loss が小さい
  3. backward: loss を下げるには各重みをどう動かせばいいかを、勾配として逆伝播で計算する
  4. update: 勾配を使って AdamW optimizer が重みを少しだけ動かす。これを何千〜何億ステップ繰り返す

1 ステップ単位では「確率分布を正解に近づくよう微調整する」だけの地味な操作です。でも、これを何兆トークンぶん繰り返した結果として、モデルが文法や世界知識を持っているように振る舞うようになる、というのが LLM 事前学習の骨格です。

⑦ 学習タスク — 次のトークンを当てるだけ

GPT-2 がやっていることは、たった一つです。文の途中までを見て、次に来るトークンを当てる。これを膨大なテキストで何千億回と繰り返すと、勝手に文法も世界知識も論理推論も身についてくる、というのが 2019 年当時の驚きでした (Radford+ 2019)。具体的にこの「当てる」をどう数値化するか (cross-entropy) は ⑨ で扱います。

データ: WebText という独自コーパス

GPT-2 の学習データは OpenAI が独自に集めた WebText です。Reddit で 3 karma 以上の投稿からリンクされた外部ページだけを集める、という作り方になっていて、「人間がある程度は読む価値があると判断した文章」を自動でフィルタした格好になっています。規模は 8M ドキュメント / 40GB / 約 10B トークンで、2026 年の LLM と比べると相当小さいです (LLaMA 3 で 15T、LLaMA 4 Scout は約 40T トークン規模)。

それでも「自己回帰予測だけ」でここまで文章を書けるモデルが作れると示したのが GPT-2 の功績でした。データ規模やパラメータ数の細かい数字、必要な FLOPs などは付録「学習スケールを数字で」にまとめています。

付録学習スケールを数字で — パラメータ数, FLOPs, トークン数

GPT-2 の規模感を、計算量と照らして追いかけてみます。

WebText の詳細

  • 約 45M リンクをクロールし、重複や Wikipedia を除外して最終 8M ドキュメント / 40GB のテキスト (この 3 つは論文 §2.1 の数字です)
  • 学習時は byte-level BPE でおおむね 10B (100 億) トークン規模 (注: 論文に総トークン数の明示はなく、これはコミュニティの推定値です。再現版 OpenWebText の BPE 換算が約 9B、GPT-3 論文 Table 2.2 で WebText2 ≒ 19B tokens とされていて、その元になった WebText は概ね半分の 10B 前後と見積もられています)
  • context_length = 1024 なので、文書はこの長さにスライドして切り分ける (1 サンプル = 1024 トークン)

各バリアントのパラメータ数

モデルn_layerd_modeln_headparams
small1276812117M
medium241,02416345M
large361,28020762M
XL481,600251.5B

パラメータの内訳 (GPT-2 small の場合):

Embed WE:50,257×768=38.6M  (最大の重み!)Pos WP:1,024×768=0.79M1 ブロックあたり:Attention:4×(768×768)=2.36M  (Q,K,V,O)MLP:768×3072+3072×768=4.72MLayerNorm×2:768×2×2=3.1K合計7.08M12 ブロック:12×7.08M=85.0M最終 LayerNorm=1.5K総計124M  (報告値 117M) \begin{aligned} \text{Embed}\ W_E &: 50{,}257 \times 768 && = 38.6\text{M}\ \ (\text{最大の重み!}) \\[2pt] \text{Pos}\ W_P &: 1{,}024 \times 768 && = 0.79\text{M} \\[4pt] \text{1 ブロックあたり:} & \\ \quad \text{Attention} &: 4 \times (768 \times 768) && = 2.36\text{M}\ \ (Q, K, V, O) \\[2pt] \quad \text{MLP} &: 768 \times 3072 + 3072 \times 768 && = 4.72\text{M} \\[2pt] \quad \text{LayerNorm} \times 2 &: 768 \times 2 \times 2 && = 3.1\text{K} \\[2pt] \quad \text{合計} & && \approx 7.08\text{M} \\[4pt] \text{12 ブロック} &: 12 \times 7.08\text{M} && = 85.0\text{M} \\[2pt] \text{最終 LayerNorm} & && = 1.5\text{K} \\[4pt] \hline \text{総計} & && \approx 124\text{M}\ \ (\text{報告値 117M}) \end{aligned}

なお Embed と unembed (WET) は重み共有 (tied weights) なので、 語彙関連の重みは片方の 38.6M しか数えません。tie しないと +38.6M で 163M になる計算です。 「報告値 117M」と「実測値 124M」の差は weight tying の有無ではなく、OpenAI が当初 README で 117M と呼称した一方、公開重み (pytorch_model.bin) を数えると 124M になる、という表記のブレに由来します (openai/gpt-2#209)。

事前学習に必要な FLOPs

経験則 (Kaplan+ 2020, Hoffmann+ 2022) として、1 token 学習 ≒ 6 × N FLOPs (N はパラメータ数) が知られています。 forward で 2N, backward で 4N、合計 6N FLOPs、という内訳です。

GPT-2 small (117M) を 10B トークン学習する場合をラフに当てはめてみます (念のため再掲すると、10B はコミュニティでよく引用される推定値で、公式値ではありません。以下の FLOPs / tokens-per-param もそれを前提にした概算です):

FLOPs6×117M×10B=7.02×1018=7.02 exaFLOPs \begin{aligned} \text{FLOPs} &\approx 6 \times 117\text{M} \times 10\text{B} \\ &= 7.02 \times 10^{18} = 7.02\ \text{exaFLOPs} \end{aligned}

これを V100 1 枚 (FP16 の理論 125 TFLOPS 1.25×1014\approx 1.25 \times 10^{14} FLOP/s) にぶつけると:

理論最短時間=7.02×10181.25×10145.6×104 秒15.6 時間実効効率 30% として約 2 日 (1 GPU) \begin{aligned} \text{理論最短時間} &= \frac{7.02 \times 10^{18}}{1.25 \times 10^{14}} \approx 5.6 \times 10^{4}\ \text{秒} \approx 15.6\ \text{時間} \\[2pt] \text{実効効率 30\% として} &\Rightarrow \text{約}\ 2\ \text{日}\ (1\ \text{GPU}) \end{aligned}

実際の学習に使われた GPU 台数や所要時間は Radford+ 2019 では明示されていません。この推定は「理論最短の 15.6 時間 × 実効効率 30% を 1 枚想定」なので、数百枚の V100 で数時間〜数日レンジに収まる感覚、くらいに捉えてください。

データはどれぐらい薄めか?

WebText:10B tokensパラメータ:117M (small)  1.5B (XL)tokens / param=85 (small)  6.7 (XL) \begin{aligned} \text{WebText} &: 10\text{B tokens} \\[2pt] \text{パラメータ} &: 117\text{M (small)}\ \sim\ 1.5\text{B (XL)} \\[2pt] \text{tokens / param} &= 85\ (\text{small})\ \sim\ 6.7\ (\text{XL}) \end{aligned}

Chinchilla の推奨は 20 tokens / param 程度なので、GPT-2 XL は「データ不足」でモデルが過剰 (under-trained) ということになります。これがその後の「もっとデータ」路線 (LLaMA など) につながります。

⑧ Teacher forcing — 並列化の鍵

次トークン予測を素直に実装すると、「1 語予測 → それを入力に戻して次を予測 → …」という逐次計算になりそうです。 でもそれでは GPU が暇になってしまいます。ここで使う工夫が teacher forcing です:

  • 学習中は、モデルが生成した語ではなく「正解の語」 xt を次の入力として使う
  • こうすると、全位置の予測を 1 回の forward pass で同時に出せる。長さ T のサンプルなら T−1 個の予測が並列化される

入力列を x1..T-1、教師 (ラベル) 列を x2..T と 1 トークンずらすと、 各位置 t が「次は何か?」を独立に答える形になります:

「未来を覗いてカンニング」できないのは、③ で出てきた masked self-attention のおかげです。 位置 t の attention は x1..t までにしか重みを置けないので、xt+1 以降の情報はモデルに見えません。 だから teacher forcing で正解列を流し込んでも、各位置の予測は正当に保たれます。

ちなみに推論時には teacher forcing は使えません (正解がそもそも無いため)。 推論では 1 語ずつ生成し、それを入力の末尾に追加して再 forward、を繰り返します (⑥ の生成ループ)。 この非対称性が、「学習は全位置並列でサクサク、推論は逐次でじわじわ」という挙動の理由になっています。

付録どれだけ速くなる? — 並列化の効用を数値で

「全位置並列」と「逐次」でどれくらい差が出るか、単純化して見積もってみます。

sequence length T=1024, batch=1 の例

素直に逐次 (teacher forcing なし) だと、1 step あたり 1 トークンしか処理しないので T 回 forward を回すことになります:

計算量TO(T2d)12 層=1024×10242×768×121.0×1013 FLOPs \begin{aligned} \text{計算量} &\approx T \cdot O(T^2 \cdot d) \cdot 12\ \text{層} \\ &= 1024 \times 1024^2 \times 768 \times 12 \\ &\approx 1.0 \times 10^{13}\ \text{FLOPs} \end{aligned}

Teacher forcing で 1 回の forward で全 T 位置を同時処理すると:

計算量1O(T2d)12 層9.7×109 FLOPs \begin{aligned} \text{計算量} &\approx 1 \cdot O(T^2 \cdot d) \cdot 12\ \text{層} \\ &\approx 9.7 \times 10^{9}\ \text{FLOPs} \end{aligned}

理論上は約 1000 倍 (T\approx T 倍) の高速化になります。

実際には GPU の並列度や memory bandwidth 律速などで単純に T 倍にはなりませんが、 T のオーダーの差が出るのは確かです。 これが「長いシーケンスでも現実的な時間で学習できる」理由になっています。

損失 gradient の並列性

さらに嬉しいことに、各位置 tt の CE 損失 LtL_t は独立に計算できます:

Ltotal=1T1tLtLtotalθ=1T1tLtθ(各位置の勾配の平均、和でもOK、定数倍) \begin{aligned} L_\text{total} &= \frac{1}{T-1} \sum_t L_t \\[2pt] \frac{\partial L_\text{total}}{\partial \theta} &= \frac{1}{T-1} \sum_t \frac{\partial L_t}{\partial \theta} \quad (\text{各位置の勾配の平均、和でもOK、定数倍}) \end{aligned}

つまり 1 回の backward で T-1 個のサンプル分の勾配がまとめて貯まります。 バッチサイズ 8 で seq 1024 なら、実効的には 8 × 1023 ≈ 8,000 サンプル/step の学習をしている計算になります。 これが「大きな context_length で学習した方がお得」の一因でもあります。

exposure bias という副作用

Teacher forcing には弱点もあります。学習中はずっと正解列を入力に使うので、 モデルは「自分が間違った予測を出したあと」の状況を経験しません。 ところが推論時に自分が間違うと、その間違った出力を自分の入力にしてしまうので、 学習で見たのと違う分布に陥って崩壊しやすくなります。これを exposure bias と呼びます。

RLHF や DPO のようなポストトレーニング手法の主目的は人間の選好にモデルを合わせることで、exposure bias の補正そのものが狙いではありません。ただし RLHF はモデル自身の生成に対して報酬を与えるため、結果として exposure bias を軽減する副次効果を持ちます。exposure bias という概念自体は Ranzato+ 2016 で命名・定式化されました。

⑨ Loss — Cross-Entropy を最小化する

⑧ で「各位置で予測を並列に出す」ところまで来ました。次はその予測と正解のズレを、1 つの数値 (loss) に落とし込みます。ここで使うのが cross-entropy で、やっていることは「正解トークンの予測確率を対数にして、マイナスを付ける」だけです。

確率が 100% 振れていれば −log 1 = 0、50% なら約 0.69、1% なら約 4.6 になります。自信をもって外したときほどペナルティが重くなるのが特徴で、学習の初期に大きな信号を与えてくれます。

これを全位置・全バッチで平均したものが最終的な loss です。式と実数で追いたい方は下の付録をどうぞ。

付録実際の数値で追う — cross-entropy loss の計算例

まず式で書き起こすと、各位置 tt の cross-entropy は次の形です:

CEt=logpθ(xt+1xt) \text{CE}_t = -\log p_\theta(x_{t+1} \mid x_{\le t})

これをバッチの全位置で平均すると、最終的な loss になります:

L=1Nbatch1T1t=1T1CEt \mathcal{L} = \frac{1}{N}\sum_{\text{batch}} \frac{1}{T-1}\sum_{t=1}^{T-1} \text{CE}_t

下は現在のプロンプトで、モデルの予測分布を使って loss がどう決まるかを実数で追ったものです。

付録なぜ cross-entropy? / 他の損失ではダメなのか

cross-entropy は「正解トークン ID の対数確率にマイナスを付けたもの」です。 これは最尤推定 (MLE) そのもので、KL ダイバージェンスの最小化とも等価です。モデルが返す確率分布を真の分布に近づけるための自然な定式化になっています。

MSE (回帰損失) はどうかと言うと、語彙 50,257 次元の one-hot と予測ベクトルの 2 乗誤差を取ることもできますが、 確率を直接モデリングしないので softmax との相性が悪く、勾配も立ちにくいのです。 cross-entropy は softmax と合わせると勾配が p − y という気持ちのいい形になり、学習が安定します (詳細は ⑩ で)。

label smoothing (正解に 0.9、それ以外に 0.1/|V| を割り振る) を使うと過信を防げますが、GPT-2 のオリジナル学習では使われていません。 2026 年のフロンティアモデルでも RLHF や DPO 系のポストトレーニングに重心が移っており、事前学習では素の cross-entropy がそのまま使われることが多いです。

⑩ 勾配と更新 — loss を下げる方向に重みを動かす

⑨ で「正解とのズレ」が数値になりました。あとは loss が下がる方向に重みを動かすだけです。1 ステップは 3 段階です:

  1. forward: プロンプトを流して loss を計算する (⑦⑨ でやった内容)
  2. backward: 各重みについて「少し動かしたら loss がどう変わるか」(= 勾配) を求める
  3. update: 勾配と逆方向に、重みをほんの少し動かす

勾配は「こっちに動かせば loss が下がる」の矢印

勾配が +0.3 なら「重みを増やすと loss が増える」ので減らす方向へ、−0.5 なら逆に増やす方向へ。GPT-2 small では約 1 億 2 千万個ぶんの重み全部について、これを一気にやります。これで 1 ステップだけ「いまより少しマシ」な重みになります。

backward pass: 上から順に掛け算で降ろすだけ

素朴に「重みを微小に動かして loss の差分を見る」をやると、1 億個ぶんで forward を 2 億回走らせることになって現実的ではありません。そこで使うのが連鎖律 (chain rule) で、これを使うと全層の勾配が forward と同じオーダー (実装上はだいたい 2 倍) の計算量で一気に求まります。

中身を直感だけで言うと、loss から始めて、上から順に「ローカルな微分」を 1 回ずつ掛けながら下の層に流すだけです。各層は「ひとつ上から渡された勾配」を受け取り、重みの勾配をその場に溜めつつ、入力側の勾配だけを下の層に渡します。数式で追いたい方は付録「backward の数式: 連鎖律と residual」をどうぞ。

図で見るとこんな感じです。上が loss、下が入力側 (embed) で、▼ が backward の勾配の流れになります:

residual が勾配を守る

深いモデルでは、連鎖律で勾配が「0 に近い数字の何層もの掛け算」になり、下の層まで届かず消えてしまう (vanishing gradient) 問題があります。ここで効くのが ④ で見た residual connection です。残差接続があると勾配にも「+1 の通り道」がくっつくので、ブロックが何層あっても信号が embed まで届きます。12 層積んでも学習できるのはこれのおかげで、Transformer を深くできる直接の根拠になっています (数式は付録参照)。

重みを更新する — AdamW

勾配が手に入ったら、あとは引き算するだけです。一番素朴な SGD なら「勾配に学習率を掛けて引く」で終わりです。実運用ではその改良版の AdamW (Loshchilov & Hutter 2019) が使われます。AdamW は 3 つの工夫を載せています:

  • モメンタム: 過去の勾配の移動平均を使う。ガタつく方向は打ち消され、一貫した方向には慣性がつく
  • 適応学習率: パラメータごとに、過去の勾配の大きさで割って歩幅を調整する
  • 重み減衰 (weight decay): 毎ステップ重みを 0 にごくわずかに引き寄せる。過学習を抑える

さらに学習率自体もステップで動かすのが定番で、warmup で徐々に上げてから cosine でゆっくり下げます。AdamW の更新式や学習率の具体的な数値例は付録「AdamW の更新式」付録「学習率スケジュール」にまとめています。

以上が学習ループの 1 ステップで、forward → loss → backward → AdamW をひたすら繰り返します。これを数十万回回すうちに、初期はランダムだった重みが「文章を書ける」状態に収束していきます。GPT-2 XL (1.5B) でも当時 V100 数百台 × 数週間で済んだというのが 2019 年当時の衝撃でした (2026 年のフロンティア LLM は数千 GPU × 数ヶ月、LLaMA 4 Scout で約 40T トークン、Gemma 4 や Qwen3.6 系も trillions of tokens 規模)。

付録backward の数式 — 連鎖律と residual

本文で「上から順に掛け算で降ろすだけ」と書いた backward の中身を、数式で追います。ベースになるのは高校数学の合成関数の微分、いわゆる連鎖律 (chain rule) です。

(fg)(x)=f(g(x))g(x) (f \circ g)'(x) = f'(g(x)) \cdot g'(x)

2 層のモデルで書いてみます。入力 xxh=g(x)h = g(x) を経て L=f(h)L = f(h) になる系です:

forward:x g h f Lbackward:Lh=f(h)(f の微分だけで出る)Lx=Lhg(x)(上流の勾配にローカルな微分を掛けるだけ) \begin{aligned} \text{forward:}\quad & x \xrightarrow{\ g\ } h \xrightarrow{\ f\ } L \\[6pt] \text{backward:}\quad & \frac{\partial L}{\partial h} = f'(h) && (\text{\(f\) の微分だけで出る}) \\[2pt] & \frac{\partial L}{\partial x} = \frac{\partial L}{\partial h} \cdot g'(x) && (\text{上流の勾配にローカルな微分を掛けるだけ}) \end{aligned}

大事なのは、L/x\partial L / \partial x を出すのに一度求めた L/h\partial L / \partial h を使い回せる点です。何層あっても同じで、loss から始めて、1 層ぶんの連鎖律を繰り返し適用するうちに全層の勾配が埋まります。

GPT-2 の forward は embed → Block 1 → ... → Block 12 → unembed → loss という積み重ねなので、この逆順に 1 層ずつ連鎖律を効かせるだけで backward が終わります。各層は「ひとつ上から渡された勾配 L/(自分の出力)\partial L / \partial (\text{自分の出力})」を受け取り、それにローカルな微分を掛けて L/(自分の入力)\partial L / \partial (\text{自分の入力})L/(自分の重み)\partial L / \partial (\text{自分の重み}) を出し、入力側の勾配だけをひとつ下の層に渡します。重みの勾配はその場で溜めておき、あとで AdamW に渡されて更新に使われます。

residual 接続が勾配を守る仕組み

残差接続があるブロックでは、出力 x+1x_{\ell+1} は入力 xx_\ell に block の結果を足す形になります:

x+1=x+block(x) x_{\ell+1} = x_\ell + \text{block}(x_\ell)

これに連鎖律をかけると、勾配はこう書けます:

Lx=Lx+1(1恒等路+blockx) \frac{\partial L}{\partial x_\ell} = \frac{\partial L}{\partial x_{\ell+1}} \cdot \Bigl(\,\underbrace{1}_{\text{恒等路}}\,+\,\frac{\partial\,\text{block}}{\partial x_\ell}\Bigr)

右辺カッコの中の「+1」がポイントで、block 側の微分が小さくても、恒等路の +1 が残ることで、L/x\partial L / \partial x_\ell が上から来た L/x+1\partial L / \partial x_{\ell+1} とほぼ同じ大きさで下に届きます。12 層積んでも勾配が消えないのはこれのおかげです。

更新は SGD が最も素朴

勾配が揃ったら、あとは引き算です。一番素朴なのが SGD (Stochastic Gradient Descent):

w    wηLw w \;\leftarrow\; w \,-\, \eta \cdot \frac{\partial L}{\partial w}

学習率 η\eta (例: 10410^{-4}) を勾配に掛けて引くだけです。実運用では SGD そのままではなく、モメンタムや適応学習率を足した AdamW を使うのが一般的で、その詳細は付録「AdamW の更新式」に続きます。

付録softmax + CE の勾配 p − y を導出してみる

なぜ softmax + cross-entropy だと勾配が p − y というきれいな形になるのか。 chain rule でたどれば 3 行で出ます。

記号

  • zRVz \in \mathbb{R}^V: ロジット (語彙 VV 次元)
  • pi=ezi/kezkp_i = e^{z_i} / \sum_k e^{z_k}: softmax の出力
  • yy: 正解の one-hot (正解 ID が ii^* のとき yi=1y_{i^*} = 1、他は 0)
  • L=iyilogpi=logpiL = -\sum_i y_i \log p_i = -\log p_{i^*}: cross-entropy

導出

Lzj=(logpi)zj=1pipizj \frac{\partial L}{\partial z_j} = \frac{\partial (-\log p_{i^*})}{\partial z_j} = -\frac{1}{p_{i^*}} \cdot \frac{\partial p_{i^*}}{\partial z_j}

softmax の偏微分は、j=ij = i^* のときだけ場合分けになります:

pizj={pi(1pi)(j=i)pipj(ji) \frac{\partial p_{i^*}}{\partial z_j} = \begin{cases} p_{i^*}\,(1 - p_{i^*}) & (j = i^*) \\ -\,p_{i^*} \cdot p_j & (j \neq i^*) \end{cases}

これを上の式に代入します:

Lzj={1pipi(1pi)=pi1(j=i)1pi(pipj)=pj(ji) \frac{\partial L}{\partial z_j} = \begin{cases} -\dfrac{1}{p_{i^*}} \cdot p_{i^*}\,(1 - p_{i^*}) = p_{i^*} - 1 & (j = i^*) \\[6pt] -\dfrac{1}{p_{i^*}} \cdot (-\,p_{i^*} \cdot p_j) = p_j & (j \neq i^*) \end{cases}

上下をまとめると、気持ちのいい形で終わります:

 Lzj=pjyj  \boxed{\ \frac{\partial L}{\partial z_j} = p_j - y_j\ }

正解のロジットは「確率を上げろ」という力を受け (pi1<0p_{i^*} - 1 < 0 なので、負の勾配で ziz_{i^*} を増やす方向)、 他のロジットは「確率を下げろ」という力を受けます (pj>0p_j > 0 なので zjz_j を減らす方向)。 実装では torch.nn.functional.cross_entropy の中身がこの式で、数値安定化 (log-sum-exp) も一緒にやってくれます。

付録AdamW の更新式を 1 ステップ分追う

AdamW は 4 つの状態をパラメータごとに持ちます。1 ステップの更新は:

ハイパーパラメータは η\eta (学習率), β1=0.9\beta_1 = 0.9, β2=0.95\beta_2 = 0.95, ε=108\varepsilon = 10^{-8}, λ\lambda (重み減衰) です。1 ステップの更新を分解するとこうなります:

gt=Lw(勾配を取る)mt=β1mt1+(1β1)gt(モメンタム)vt=β2vt1+(1β2)gt2(2 乗勾配の EMA)m^t=mt1β1t,v^t=vt1β2t(バイアス補正)wwη(m^tv^t+εAdam 部分+λwweight decay(分離)) \begin{aligned} g_t &= \frac{\partial L}{\partial w} && \text{(勾配を取る)} \\[2pt] m_t &= \beta_1\,m_{t-1} + (1 - \beta_1)\,g_t && \text{(モメンタム)} \\[2pt] v_t &= \beta_2\,v_{t-1} + (1 - \beta_2)\,g_t^{\,2} && \text{(2 乗勾配の EMA)} \\[2pt] \hat m_t &= \frac{m_t}{1 - \beta_1^{\,t}}, \quad \hat v_t = \frac{v_t}{1 - \beta_2^{\,t}} && \text{(バイアス補正)} \\[2pt] w &\leftarrow w - \eta \cdot \Bigl(\underbrace{\frac{\hat m_t}{\sqrt{\hat v_t} + \varepsilon}}_{\text{Adam 部分}} + \underbrace{\lambda\,w}_{\substack{\text{weight decay} \\ \text{(分離)}}}\Bigr) \end{aligned}

肝は、5 行目の λw が (m̂ / √v̂) と同じ分母で割られていないという点です。 素の Adam は L2 正則化を loss に混ぜ込んで実装しますが、そうすると Adam の適応学習率で、 事実上の重み減衰の強さがパラメータごとに違ってしまいます。 AdamW はこれを分離 (decoupled) して、どのパラメータにも同じ λ を効かせます (Loshchilov & Hutter 2019)。

具体的な数値例

t=1t = 1 ステップ目。w=0.5, g=0.1, η=104, λ=0.1w = 0.5,\ g = 0.1,\ \eta = 10^{-4},\ \lambda = 0.1 として代入していきます:

m1=0.90+0.10.1=0.01v1=0.950+0.050.01=5×104m^1=0.0110.9=0.1,v^1=5×10410.95=0.01update=104(0.10.01+ε+0.10.5)=104(1.0+0.05)=1.05×104wnew=0.51.05×1040.4999 \begin{aligned} m_1 &= 0.9 \cdot 0 + 0.1 \cdot 0.1 = 0.01 \\[2pt] v_1 &= 0.95 \cdot 0 + 0.05 \cdot 0.01 = 5 \times 10^{-4} \\[2pt] \hat m_1 &= \tfrac{0.01}{1 - 0.9} = 0.1, \quad \hat v_1 = \tfrac{5 \times 10^{-4}}{1 - 0.95} = 0.01 \\[2pt] \text{update} &= 10^{-4} \cdot \bigl(\tfrac{0.1}{\sqrt{0.01} + \varepsilon} + 0.1 \cdot 0.5\bigr) \\ &= 10^{-4} \cdot (1.0 + 0.05) = 1.05 \times 10^{-4} \\[2pt] w_\text{new} &= 0.5 - 1.05 \times 10^{-4} \approx 0.4999 \end{aligned}

1 ステップあたりの更新はご覧の通り微小です。これを何十万ステップも繰り返してじわじわ収束させます。

付録学習率スケジュール — warmup → cosine decay

学習率 η は固定値ではなく、ステップごとに変化させるのが現代的 LLM 学習の標準です。 定番は warmup + cosine decay:

以下は GPT-2 原典の再現ではなく、現代的な小規模 GPT 学習レシピ (nanoGPT の train.py defaults と GPT-3 論文 Appendix B の数値を参考にした例) として書きます:

max_lr=2.5×104warmup=2,000 stepstotal=6×105 stepsmin_lr=max_lr×0.1=2.5×105 \begin{aligned} \text{max\_lr} &= 2.5 \times 10^{-4} & \text{warmup} &= 2{,}000\ \text{steps} \\[2pt] \text{total} &= 6 \times 10^{5}\ \text{steps} & \text{min\_lr} &= \text{max\_lr} \times 0.1 = 2.5 \times 10^{-5} \end{aligned}

ステップ tt における学習率は、warmup と cosine decay の 2 段階で決まります:

ηt={max_lrtwarmup(t<warmup, 線形で上げる)min_lr+12(max_lrmin_lr)(1+cos(πρ))(twarmup, cosine で下げる) \eta_t = \begin{cases} \text{max\_lr} \cdot \dfrac{t}{\text{warmup}} & (t < \text{warmup},\ \text{線形で上げる}) \\[10pt] \text{min\_lr} + \tfrac{1}{2}(\text{max\_lr} - \text{min\_lr})\bigl(1 + \cos(\pi \rho)\bigr) & (t \ge \text{warmup},\ \text{cosine で下げる}) \end{cases}

ここで ρ=(twarmup)/(totalwarmup)\rho = (t - \text{warmup}) / (\text{total} - \text{warmup}) です。

各ステップでの値

step       0:   η = 0
step   1,000:   η = 1.25e-4      (warmup 途中, 50%)
step   2,000:   η = 2.5e-4       (warmup 完了, 最大)
step  50,000:   η ≈ 2.47e-4      (cosine でゆっくり下降)
step 300,000:  η ≈ 1.37e-4      (中盤, 約 55%)
step 600,000:  η = 2.5e-5        (最小, 10%)

なぜこの形?

  • warmup: 初期は勾配の統計 (v_t) がまだ貯まっていないので、いきなり大きな学習率で更新すると発散しがち。ゼロから徐々に上げることで安定化する
  • cosine decay: 後半は最適解近くで微調整するために学習率を下げる。線形 decay でもよいが、cosine のほうが滑らかで中盤は学習率を高く保てるため経験的に性能が良い
  • min_lr > 0: 完全に 0 にせず少し残すことで、最後まで動きを保つ

2026 年の事前学習では WSD (Warmup-Stable-Decay) など亜種もよく使われますが、warmup+cosine はいまだに定番の一角です。

⑪ 実践編とは — 自分で GPT-2 を作る

Part 1 と Part 2 の話は、結局のところ全部「人の作ったモデルを覗き見る」話でした。Attention のヒートマップも、cross-entropy の数値例も、HuggingFace から落としてきた gpt2 small (英語) を後ろから観察したものです。これはこれで仕組みを理解する近道なのですが、自分で動かして初めて分かることが結構あります。たとえば次のようなところ:

  • iter 0 のとき loss が ln(vocab_size) ぴったりから始まること (一様分布の cross-entropy そのもの)
  • warmup の 500 iter を抜けたあたりで loss が一段下がる気持ちよさ
  • iter 500 で生成すると「お風呂にする。 16年、朝起きたとき」みたいな崩壊文だったのに、iter 4000 では「歯周病で歯槽膿漏が起きました」とちゃんと文が成立してくる段階変化
  • 110M というサイズで Chinchilla 最適に学習しても、固有名詞や事実関係はほぼ出鱈目になること (これがスケールが効く理由)

なので実践編では、Part 1 / Part 2 の各セクションがコードのどこに対応するかを示しながら、4 つのステップ (トークナイザ学習 → データ準備 → 学習 → 公開) を一通り走らせます。読み終わったあと、自分の手元で modal run --detach scripts/03_train.py と打てば同じものが回せる、という状態を目標にします。

まず最終成果物を先に出しておきます。本稿で作ったものはそれぞれ次の場所に公開してあります:

パイプラインの全体像と実測値はこんな感じです。Modal に投げる関数 4 本だけで、ローカルマシンは原則「コマンドを叩く」だけの役割になります。

# ステップ 実行場所 所要 生成物
1 SentencePiece 学習 Modal (16 CPU, 64 GB) 20 分 tokenizer.model (800 KB)
2 FineWeb tokenize Modal (16 CPU) 22 分 train.bin 4.6 GB / val.bin 10 MB
3 GPT-2 small 学習 Modal (A100 80 GB) 4 時間 10 分 checkpoint + W&B ログ

合計でだいたい 5 時間、コストは Modal の A100 80GB ($2.50/hr) を中心に 10〜13 ドルくらいに収まります。

⑫ インフラ — Modal でクラウド GPU を関数単位で借りる

さて、5 時間で 110M を回す、と言いましたが、これは A100 80 GB を 1 枚使った場合の話です。手元の RTX 3090 (24 GB) で同じ設定を動かすと、bfloat16 が出ないとか TF32 が遅いとかを考慮しても素直に 7〜10 倍くらい遅くなります。1 週間 GPU を専有することになって、その間 LLM が叩けなくなるのは結構つらいです。

なので今回は Modal で A100 を借りました。Modal は「Python 関数にデコレータを付けるだけでクラウド GPU 上で実行されて、終わったら課金が止まる」というサービスで、事前学習みたいな「数時間〜数日だけ走らせて終わり」のワークロードと相性が良いです。料金もシンプルで、現在 (2026-04) は次のようになっています:

GPU / リソース 単価 (秒課金) 時間あたり換算
A100 80GB$0.000694/s約 $2.50/hr
H100$0.001097/s約 $3.95/hr
H200$0.001261/s約 $4.54/hr
L40S / L4 / A10 / T4$0.000542 〜 $0.000164/s約 $0.59〜$1.95/hr
CPU / メモリ$0.0000131/core/s / $0.00000222/GiB/s(従量)

嬉しいのが、Starter プラン (個人用、$0/月) に、月 $30 ぶんの無料コンピュートクレジットがついてくる点です (Modal Pricing)。A100 で換算すると約 12 時間ぶんあるので、smoke test (5 分 ≈ $0.21) を何十回やっても月内では無料で終わります。本番の 4 時間学習 ($10) も、月あたり 1 回なら実質 $0 で収まります。なお公式ページには繰り越しに関する記述はありませんが、実運用では月ごとに $30 がリセットされる挙動と理解しておいてください。

では実際にコードを見ます。今回の学習スクリプトはだいたいこんな形でした:

import modal

app = modal.App("gpt2-jp-train", image=modal.Image.debian_slim().pip_install(
    "torch==2.5.1", "wandb", "sentencepiece", "huggingface-hub", "hf-transfer",
).add_local_dir("src/gpt2_jp", remote_path="/root/gpt2_jp"))

data_volume = modal.Volume.from_name("gpt2-jp-data", create_if_missing=True)
ckpt_volume = modal.Volume.from_name("gpt2-jp-ckpt", create_if_missing=True)

@app.function(
    gpu="A100-80GB",
    volumes={"/data": data_volume, "/ckpt": ckpt_volume},
    secrets=[modal.Secret.from_name("huggingface"),
             modal.Secret.from_name("wandb")],
    timeout=60 * 60 * 10,
)
def train_fn(...):
    from gpt2_jp.trainer import TrainConfig, train
    cfg = TrainConfig.from_yaml("/root/configs/small.yaml")
    cfg.train_bin = "/data/train.bin"
    cfg.out_dir = "/ckpt/out"
    return train(cfg, ...)

要点は次の 4 つだけです:

  • modal.Image: 必要な pip パッケージとローカルディレクトリを指定すると、Modal 側でコンテナ image をビルドしてくれる
  • gpu="A100-80GB": 関数を実行する GPU の指定 (H100 や L40S も指定できる)
  • modal.Volume: tokenizer 学習で作った train.bin を、別の関数 (学習) からも読めるように共有する永続ストレージ
  • modal.Secret: HuggingFace の token と W&B の API key を環境変数として安全に注入する

ローカルからは modal run --detach scripts/03_train.py と打つだけで、Modal が image を build → A100 を確保 → 関数を実行 → checkpoint を Volume に保存、まで全部やってくれます。--detach を付けると、ローカルの ssh が切れても Modal 側のジョブは生き続けるので、寝ているあいだに学習が終わります。

学習ログの可視化と実験管理は Weights & Biases (W&B) に流します。これは wandb.init(project=..., name=...) で run を作ると、loss も lr も生成サンプルも全部 web 上のダッシュボードで見られるサービスです。今回は run の URL を public にしてあるので、後半の 結果のセクション で実物を貼ります。

重みの配布は HuggingFace Hub です。学習が終わったら state_dict を Hub に push しておけば、誰でも GPT2LMHeadModel.from_pretrained("sakasegawa/gpt2-jp-small") で読めます。

⑬ 日本語トークナイザを学習する

インフラの話が終わったので、ここからはパイプラインを順に作っていきます。最初は (Part 1 の最初のセクションと同じく) トークナイザです。

結論から言うと、GPT-2 のオリジナル BPE をそのまま日本語に使うのは無理筋です。理由は単純で、英語前提の語彙だと日本語が 3〜4 倍に水増しされてしまうからです。Part 1 の付録でちらっと触れた byte-level BPE を思い出してください。日本語の漢字 1 文字は UTF-8 で 3 バイトに分かれるので、語彙の中に「日」「本」みたいな漢字パーツが入っていないと、漢字 1 文字 = トークン 3〜4 個になります。

実測してみると、GPT-2 BPE と自前 SentencePiece (32k vocab) の差はだいたい次のようになります。比較対象に英語の文も 1 つ混ぜています:

例文 SentencePiece (このリポ) GPT-2 BPE SP 優位
日本の首都は東京です。 6 tokens 17 tokens 2.83×
吾輩は猫である。名前はまだ無い。 11 tokens 24 tokens 2.18×
人工知能(AI)の発展により、世界は大きく変わりつつある。 13 tokens 42 tokens 3.23×
東京特許許可局長許可局長の話をしよう。 11 tokens 41 tokens 3.73×
The quick brown fox jumps over the lazy dog. 20 tokens 10 tokens 0.50× (英語は BPE 優位)

この差はそのままコンテキスト長と学習コストに効いてきます。英語の GPT-2 が 1024 トークンで原稿用紙 2 枚弱を扱うのと同じ感覚で日本語を扱うには、日本語側のトークナイザを別に用意したほうが今回の学習設計では効率的です (同じ 1024 トークンのコンテキストに 2〜3 倍の日本語を詰め込めるうえ、同じ学習予算でカバーできるコーパス量も比例して増えます)。

アルゴリズムの選択: SentencePiece unigram

日本語向けに自前のサブワードを切る選択肢はいくつかあります (BPE, unigram, WordPiece など)。今回は SentencePiece の unigram モデルを選びました。rinna さんの japanese-gpt2-medium など、過去の日本語 GPT 系でも SentencePiece が採用されてきた流れに沿った選択でもあります。理由は:

  • 日本語は単語境界が明示されないので、空白前トークン化を前提とする手法 (普通の BPE) より、生テキストをそのまま食わせられる SentencePiece の方が素直
  • unigram は確率的に「もっともらしい分割」を選ぶので、形態素境界に近い切れ目になりやすい (BPE は頻度ベースなので「いです」のような半端な切れ目が混ざることがある)
  • byte_fallback=True を立てておけば、語彙にない文字 (絵文字など) はバイト単位にフォールバックして、未知語問題が原理的に起こらない

vocab size は 32,000 にしました。GPT-2 オリジナルの 50,257 よりだいぶ小さいですが、日本語と英語と数字とバイトフォールバック (256 個) が入って語彙の頻度分布が滑らかなあたりが、110M モデルの埋め込み行列とのバランスでちょうど良かったです。

学習コーパスと実装

トークナイザ学習用のコーパスは Wikipedia 日本語版 (2023-11-01 ダンプ) と 青空文庫 (clean 版) を組み合わせました。ジャンルが偏らないこと、現代語と文語の両方をカバーすること、を狙って選んでいます。コードの中身はだいたい次の通りです:

import sentencepiece as spm

spm.SentencePieceTrainer.Train(
    input="/tmp/corpus_sample.txt",
    model_prefix="/tmp/tokenizer",
    vocab_size=32000,
    model_type="unigram",
    character_coverage=0.9995,
    input_sentence_size=500_000,
    shuffle_input_sentence=True,
    num_threads=16,
    normalization_rule_name="nmt_nfkc",
    user_defined_symbols=["<|endoftext|>", "<|pad|>"],
    byte_fallback=True,
    split_digits=True,
    max_sentencepiece_length=16,
)

character_coverage=0.9995 は「上位 99.95% の文字までは語彙でカバーする (残りは byte fallback)」という意味で、漢字の長い裾野を持つ日本語ではこの値が標準です。split_digits=True は数字を 1 文字ずつにバラす設定で、これを入れておくと 2026 が 1 トークンに固有化されたりせず、数字の汎化が効きやすくなります。

Modal で 64 GB マシンを借りて回しました。実時間で 17 分くらいです。生成された tokenizer.model は 800 KB の小さなファイルなので、後段の学習スクリプトでも HuggingFace push の際にも、これをそのまま同梱します。

⑭ 学習データを準備する — 2.3B トークン

トークナイザができたので、次は事前学習用のコーパスを tokenize して、ディスクに連続した整数列として並べます。ここで「何トークン使うか」を決める必要があるのですが、これは 1 つ前のサブトピックとしてすぐ片付くので、Chinchilla の話を短く挟みます。

何トークン食わせるか: Chinchilla 則

大雑把に言うと、2022 年までの事前学習では「同じ計算予算なら、パラメータをなるべく大きくすれば勝ち」という空気でした。ここに待ったをかけたのが DeepMind の Hoffmann et al. (2022) の Chinchilla 論文です。論文のメッセージはシンプルで、「計算予算 CC が与えられたとき、パラメータ数 NN と学習トークン数 DD は、ほぼ等しい割合で一緒に増やすのが最適」というもの。具体的には次の対応が経験的に成り立つ、と示しました:

Copt6×N×D(学習 FLOPs, Part 2 の経験則)N:D1:20(tokens per param) \begin{aligned} C_\text{opt} &\approx 6 \times N \times D && (\text{学習 FLOPs,}\ \text{Part 2 の経験則}) \\[2pt] N : D &\approx 1 : 20 && (\text{tokens per param}) \end{aligned}

言い換えると、N=110MN = 110\text{M} のパラメータを持つモデルを「与えられた計算量で最大限賢く」するには、だいたい 110M×20=2.2B110\text{M} \times 20 = 2.2\text{B} トークンを食わせるのが筋、という指針です。これより少ないと underfit (データが足りず賢くなりきらない)、多いと overfit ではなく単に計算効率が悪くなる (同じ計算を使って、NN を大きくした方が loss が下がった)、というのがこの論文の主張の核でした。

なので今回も Chinchilla 則を素直に採用します。目標は 110M×20=2.2B110\text{M} \times 20 = 2.2\text{B} トークン。キリの良いところで 2.3B トークンに切りました。

コーパス: FineWeb-2-edu-japanese

2.3B トークンを何から取るか、が次の問題です。ここは hotchpotch/fineweb-2-edu-japanesesample_10BT サブセットをそのまま使わせてもらいました。このデータセットは セコンさん (@hotchpotch) が 2025-02-20 の記事で公開したもので、HuggingFace 公式の多言語 web コーパス FineWeb-2 の日本語版 (376M 件) を、DeepSeek で作った「教育的価値スコア」の教師データで分類器 (fineweb-2-edu-japanese-classifier) を学習させ、その分類器でスコア 2.5 以上の文書だけを残して作られています (結果およそ 120M 件 / 89.3B トークン)。

英語には元々同系列の FineWeb-Edu があって、「Common Crawl をそのまま食わせるより、教育文書っぽく抽出したサブセットの方が同じトークン数でも性能が伸びる」というのは先行研究で知られていました。日本語には該当するものが長らく無かったので、今回の 110M スケールだと一番ありがたい選択肢になっています。

実装上は sample_10BT を streaming でロードして、先頭 500 万トークンを val にとってから、残りを train として 2.3B トークンに到達するまで詰め込みます。tokenize は SentencePiece の Encode(batch, num_threads=16) でバッチ並列化してあるので、Modal の 16 CPU で 22 分程度です。

from datasets import load_dataset
import numpy as np
import sentencepiece as spm

sp = spm.SentencePieceProcessor()
sp.Load("/root/tokenizer/tokenizer.model")
eos_id = sp.PieceToId("<|endoftext|>")

ds = load_dataset(
    "hotchpotch/fineweb-2-edu-japanese",
    name="sample_10BT", split="train", streaming=True,
)

# 文書ごとに <|endoftext|> を挟みながら uint16 で連続書き込み
with open("/vol/train.bin", "wb") as f:
    for batch in chunks(ds, 2000):
        ids_list = sp.Encode([row["text"] for row in batch],
                             num_threads=16, out_type=int)
        for ids in ids_list:
            ids.append(eos_id)
            f.write(np.asarray(ids, dtype=np.uint16).tobytes())
        if total_tokens >= 2_300_000_000:
            break

トークン ID を uint16 (2 バイト) で書いているのは、語彙サイズが 32,000 で 16 ビットに収まるからです。これで 2.3B トークン = 4.6 GB に収まり、A100 のホストメモリにも余裕で乗ります。学習側はこのファイルを numpy.memmap で開き、ランダムなオフセットから block_size=1024 の窓を切り出してミニバッチを作ります。Part 2 の x = batch[:, :-1], y = batch[:, 1:] の話そのままです。

⑮ モデル実装 — nanoGPT 流の最小構成

データができたので、ようやくモデル本体です。実装は karpathy/nanoGPT をベースにしました。nanoGPT は GPT-2 の最小実装として有名で、モデル定義本体が約 300 行に収まっています (README 曰く「~300-line GPT model definition」)。Part 1 の図がそのままコードに対応していて読みやすいです。

まず config と Block の構造はこんな感じです (Part 1 で何度も見た「LayerNorm → Attention → 残差 → LayerNorm → MLP → 残差」がそのまま出てきます):

@dataclass
class GPTConfig:
    block_size: int = 1024
    vocab_size: int = 32000
    n_layer: int = 12
    n_head: int = 12
    n_embd: int = 768
    dropout: float = 0.0
    bias: bool = False  # PaLM 以降の慣習で bias を外す。原典 GPT-2 (TF) は bias 込みで学習されているので、HF GPT-2 形式に export するときは zero bias を埋める

class Block(nn.Module):
    def __init__(self, cfg):
        super().__init__()
        self.ln_1 = LayerNorm(cfg.n_embd, bias=cfg.bias)
        self.attn = CausalSelfAttention(cfg)
        self.ln_2 = LayerNorm(cfg.n_embd, bias=cfg.bias)
        self.mlp  = MLP(cfg)

    def forward(self, x):
        x = x + self.attn(self.ln_1(x))   # 残差 + attention
        x = x + self.mlp(self.ln_2(x))    # 残差 + MLP
        return x

Attention は F.scaled_dot_product_attention(..., is_causal=True) を呼ぶだけです。PyTorch 2 系では入力 dtype (fp16/bf16)・head_dim・GPU 世代 (SM 8.0+) などの条件を満たしていれば、自動で FlashAttention-2 や cuDNN attention の融合カーネルに dispatch されます (PyTorch SDPA docs)。条件が合わない場合 (CPU 実行や fp32) は math backend に fallback します。Flash Attention は大雑把に言うと「QK/dkQK^\top / \sqrt{d_k} の巨大な行列をわざわざ HBM (GPU のグローバルメモリ) に書き出さず、SRAM (GPU 上の高速な小さいキャッシュ) に載る小さなブロック単位でそのまま softmax まで済ませる」実装です (Dao et al. 2022)。普通に q @ k.transpose(-2, -1) → mask → softmax... @ v と書くと、シーケンス長 N×NN \times N の巨大な中間テンソルを HBM に往復させることになって、計算そのものよりメモリ帯域がボトルネックになります。Flash Attention はその往復を消してしまうので、概ね 1.5〜3 倍速くなり、使うメモリも減る、というのが仕組みです (公式 docs は backend ごとに浮動小数点演算の融合順序が変わるため bit-exact ではないと明記しているので、結果は fp16/bf16 の許容誤差内で一致する程度に思っておくのが安全です)。今回の 結果のセクション の 156K tok/s という数字は、これを踏んだうえでの値です。

実装としてはこの 1 行の裏に、Part 1 で散々眺めた causal mask 付きの softmax(QK/dk)V\text{softmax}(QK^\top / \sqrt{d_k})\,V が全部圧縮されている格好です。

小さな工夫: weight tying と c_proj 補正

モデル全体の組み立てで、地味だけど重要な工夫が 2 つあります:

self.transformer.wte = nn.Embedding(cfg.vocab_size, cfg.n_embd)
self.lm_head = nn.Linear(cfg.n_embd, cfg.vocab_size, bias=False)
self.transformer.wte.weight = self.lm_head.weight  # ← weight tying

for pn, p in self.named_parameters():
    if pn.endswith("c_proj.weight"):
        nn.init.normal_(p, mean=0.0, std=0.02 / math.sqrt(2 * cfg.n_layer))

1 つ目の weight tying は、入口の embedding (wte) と出口の lm_head の重みを同じテンソルとして共有する話です。「ID → ベクトル」と「ベクトル → ID」が逆向きの操作だから、同じ行列を使い回せばいい、という発想で、これでパラメータが V×dmodel=32000×76825M|V| \times d_\text{model} = 32000 \times 768 \approx 25\text{M} 個も節約できます。32k vocab で全 110M のうちの 22% に相当するので、これがあるかないかで非埋め込みパラメータ数が大きく変わります。

2 つ目の c_proj 補正は、各 Block の出口の重み (attention の c_proj と MLP の c_proj) だけ初期化の標準偏差を 0.02/2L0.02 / \sqrt{2L} に下げる、という GPT-2 流のトリックです。Block を素直に積むと残差ストリームの分散が層ごとに増えていって学習初期に発散しやすいのですが、出口を細めに初期化しておくと層数 LL が大きくても入口での分散がほぼ保たれます。これが入っていないと iter 100 あたりで loss が NaN に飛ぶことが結構あります。

パラメータ数を集計すると、最終的にこんな内訳になります:

埋め込み (wte, tied):32,000×76824.6M位置埋め込み (wpe):1024×7680.8M12 ブロック:12×(4×7682+8×7682)85M最終 LN + lm_head:(lm_head は wte と共有)0M合計110.3M非埋め込み (wpe 除く)109.5M \begin{aligned} \text{埋め込み}\ (\text{wte, tied}) &: 32{,}000 \times 768 && \approx 24.6\text{M} \\[2pt] \text{位置埋め込み}\ (\text{wpe}) &: 1024 \times 768 && \approx 0.8\text{M} \\[2pt] \text{12 ブロック} &: 12 \times (4 \times 768^2 + 8 \times 768^2) && \approx 85\text{M} \\[2pt] \text{最終 LN + lm\_head} &: (\text{lm\_head は wte と共有}) && \approx 0\text{M} \\[4pt] \hline \text{合計} & && \approx 110.3\text{M} \\[2pt] \text{非埋め込み (wpe 除く)} & && \approx 109.5\text{M} \end{aligned}

Chinchilla 換算で「110M×202.2B110\text{M} \times 20 \approx 2.2\text{B} トークン」と言ったのは、この非埋め込みパラメータを基準にした数字です。

⑯ 学習ループ — 4 時間で 4,386 iters

モデルもデータも揃ったので、いよいよ学習ループです。Part 2 (学習タスク から 勾配と更新 まで) で見てきた「forward → loss → backward → AdamW」を素直にコードに落とすだけなのですが、A100 の VRAM (80 GB) を使い切るためのちょっとした工夫が入ります。

バッチ構成と gradient accumulation

まずバッチ構成です。A100 80 GB に batch_size=16, block_size=1024 のミニバッチを bfloat16 で乗せると、forward / backward 込みでだいたい 60 GB くらい使います。これで 1 ステップあたり 16×1024=16,38416 \times 1024 = 16{,}384 トークン。残念ながらこれだと「512 sequences で 1 ステップ」という GPT-2 オリジナルのバッチに届きません。

そこで gradient accumulation を 32 回に設定します。1 つの optimizer step を打つ前に、バッチ 16 を 32 回ぶん forward+backward して勾配をまとめて貯める手法です。これで:

有効バッチ=16 seq×32 accum=512 seq有効バッチ (tokens)=512×1024=524,288 tokens/step合計 step 数=2.3×109524,2884,386 steps \begin{aligned} \text{有効バッチ} &= 16\ \text{seq} \times 32\ \text{accum} = 512\ \text{seq} \\[2pt] \text{有効バッチ (tokens)} &= 512 \times 1024 = 524{,}288\ \text{tokens/step} \\[2pt] \text{合計 step 数} &= \frac{2.3 \times 10^9}{524{,}288} \approx 4{,}386\ \text{steps} \end{aligned}

コードはこんな感じです:

scaler = torch.amp.GradScaler(enabled=False)  # bfloat16 では不要
ctx = torch.amp.autocast(device_type="cuda", dtype=torch.bfloat16)

for it in range(max_iters):
    lr = get_lr(it, warmup=500, decay_iters=4386,
                max_lr=6e-4, min_lr=6e-5)
    for pg in optimizer.param_groups:
        pg["lr"] = lr

    for micro in range(grad_accum_steps):  # 32 回
        x, y = train_ds.get_batch(batch_size, block_size, device)
        with ctx:
            _, loss = model(x, y)
            loss = loss / grad_accum_steps
        loss.backward()                      # 勾配を貯めていく

    torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
    optimizer.step()
    optimizer.zero_grad(set_to_none=True)

ポイントを 4 つ並べておきます:

  • autocast(dtype=bfloat16) で、行列積など重い演算だけ bf16 に落とす (LayerNorm や softmax は fp32 のまま)。A100 / H100 / RTX 4090 で速い
  • bfloat16 は表現範囲が float32 と同じ (指数 8 ビット) なので、勾配スケーリング (GradScaler) が要らない。fp16 と違ってここがすごく楽
  • loss = loss / grad_accum_steps で割っておかないと、貯まる勾配が 32 倍になる。意外と忘れがち
  • clip_grad_norm_(., 1.0) は GPT-2 原論文では明示されていないものの、GPT-3 の付録で明記され、以降 LLaMA や nanoGPT を含め「GPT-2 系の学習デファクト」として受け継がれています。Attention の確率分布が一時的に偏ったときの暴走を防ぐのが目的

学習率スケジュール

学習率は Part 2 の 勾配と更新 — backward と AdamW の付録でやった warmup + cosine decay をそのまま使います。今回のスケールに合わせて:

max_lr=6×104min_lr=6×105warmup=500 stepstotal=4,386 steps \begin{aligned} \text{max\_lr} &= 6 \times 10^{-4} & \text{min\_lr} &= 6 \times 10^{-5} \\[2pt] \text{warmup} &= 500\ \text{steps} & \text{total} &= 4{,}386\ \text{steps} \end{aligned}

max_lr が 6e-4 なのは、有効バッチサイズ 524k tokens (約 0.5M) というスケールに合わせた経験則の選択です (バッチを大きくすると最適 lr もある程度大きくできる)。warmup 500 は全体の約 11% で、これも GPT-2 系の標準的な値です。

eval とサンプル生成

250 step ごとに「val 100 batch で平均 loss を計算」する eval を回しています。本当はもっと長い split を全部舐めるのが綺麗ですが、4,386 step に対して eval だけで 30 分以上かかると目も当てられないので、100 batch (約 1.6M トークン) のサンプリング推定にしました。

500 step ごとには 4 つのプロンプト (むかしむかし、あるところに, 日本の首都は, 人工知能とは, 吾輩は猫である。) で実際に 80 トークン生成して、jsonl と W&B Table に書き出します。これがあるおかげで「数値の loss」と「実際の生成」を後で並べて見ることができて、次の 結果のセクション で示すように、学習の段階性がすごくよく見えます。

⑰ 結果 — loss curve と生成の段階変化

ここまで仕込んだら、あとは modal run --detach scripts/03_train.py を叩いて寝ます。本番の前に --smoke-test で 30 iter だけ回してエラーがないことを確認しておくのがおすすめです (5 分で終わります)。本番は実時間で 4 時間 10 分でした。

Loss curve

iter 0 で loss は 10.5532 から始まりました。これは ln(32000)10.3735\ln(32000) \approx 10.3735 のすぐ近くで、Part 2 の Loss — Cross-Entropy を最小化する で見た通り「初期化直後のモデルが語彙上ほぼ一様な分布を吐いている」ことを意味します (差 0.18 は init 標準偏差や label smoothing なしのバラつきの範囲です)。逆に言うと、ここで ln(V) から桁単位でずれていたら、初期化・loss の reduction (mean/sum)・label shift のどこかがおかしいので、生成を始める前にチェックを挟むのが安心です。

ここから 4,386 iter のあいだ loss がどう動いたかを下に貼ります (train 線は同梱の training_log.jsonl を 10 iter 刻みでそのまま、val 線は EVAL_POINTS として 250 iter ごとに 100 batch ぶんを平均した値を app.js に同梱しています。下表の数値も EVAL_POINTS 由来です):

2.3B トークン (4,386 iter) ぶんの train loss と eval loss。warmup 500 step (灰色帯) を抜けたあと一気に loss が落ちて、3.0 付近に張り付いていきます。

数値で書くと、節目はだいたいこうなりました:

iter train loss val loss tokens 備考
010.5510.550.5Mln(32000)\approx \ln(32000), 一様分布
2505.605.60131Mwarmup 完了直後
5004.674.68262M初の生成サンプル
10003.623.64524M急降下フェーズ終わり
20003.273.291.05B逓減フェーズ
30003.113.151.57Bcosine decay 中盤
40003.043.042.10Bval loss の最良点
43863.033.052.30B最終

best val loss は 3.0386 (iter 4000) でした。ここで perplexity=exp(3.04)20.9\text{perplexity} = \exp(3.04) \approx 20.9 なので、平均すると次のトークンを「20 個くらいの候補のなかから 1 つ選ぶくらいの自信」で出している状態です。32k vocab に対しての 20 強なので、まあまあ言語モデルとして機能しはじめている雰囲気を感じます。

実際の W&B run はこちらに置いてあります (上のグラフは jsonl から自前で描いたものですが、W&B 側だと train/lr や train/tokens_per_sec も同時に見られます): wandb.ai/.../runs/cpn804ot

生成サンプルの段階変化

loss の数字よりも面白いのは、500 step ごとに走らせた生成サンプルがどう変わっていくか、です。同じプロンプト (むかしむかし、あるところに日本の首都は) に対する出力を、iter ごとに並べて見てみます:

プロンプト: むかしむかし、あるところに
  1. iter 500 (val 4.68) むかしむかし、あるところに、なに。 14年の月を過ぎたとき、お風呂にする。 16年、朝起きたとき、お風呂で、ふさわしいときは、いつまであげてください。
  2. iter 1500 (val 3.40) むかしむかし、あるところに隠れているのは、 やっぱり、あるところの「めちゃめちゃ」 「あ、あんまり気が付かない」 という場合です。
  3. iter 3000 (val 3.15) むかしむかし、あるところに落ちて、自分の体をねじると、体のバランスが崩れる。 すると、肩甲骨が開いて、肩こりがとれて肩こりにつながる。
  4. iter 4000 (val 3.04) むかしむかし、あるところにきます。 ・歯を抜いて噛み合わせが良くなったけど、噛み合わせが違和感がありました。 ・歯周病で歯槽膿漏が起きました。
プロンプト: 日本の首都は
  1. iter 500 日本の首都は、日本よりは日本文化(日本文化文化文化に関する研究) ... 文化文化文化文化文化文化文化文化文化文化文化文化文化文化文化文化文化文化文化
  2. iter 1500 日本の首都は東・南・西・北・北・北の5・7都市。 首都は東・南に分かっています。 首都は東・東・東、北、北・北、北といろいろあります。
  3. iter 2500 日本の首都は広島、長崎、長崎の3県だけだ。 その広島の北西部に位置する「広島」は広島にある首都で、その首都である広島は、1954年に建てられ...
  4. iter 4386 日本の首都は東京です! 1950年の東京の人口は約3,800万人でした。 1960年の東京の人口比率は3,835.147%でした。

iter 500 の段階では「同じトークンの繰り返し」と「文として成立していない助詞並び」が支配的で、これは先ほどの表で iter 500 の eval loss が 4.68 だったこと (perplexity=exp(4.68)108\text{perplexity} = \exp(4.68) \approx 108) と辻褄があいます。「次のトークンが 100 個の候補から 1 つ」というレベルだと、まだ単語の連鎖がまともに学習されていないわけです。

iter 1500〜2500 になると、文単位での日本語の「形」は出てきます。ただし内容は支離滅裂で、「広島 = 首都」とか「東京の人口比率は 3,835.147%」とか、明らかに事実と違うことを自信たっぷりに書きます。これは 110M というサイズで「事実」を覚えるには容量が足りないことを直接見ているところで、より大きいモデルが必要な理由でもあります。

iter 4000〜4386 だと、最初のフレーズに引きずられて文法的に自然に続きが書けるようになっています。「日本の首都は東京です!」までは合っていて、その後の数字は適当ですが、文として読めなくはない、というレベルになりました。これくらいまで行ければ、110M の事前学習モデルとしてはまずまず合格だと思います。

⑱ 公開 — HuggingFace と W&B

モデルが学習できたので、ここから公開作業です。「自分の checkpoint をローカルに置いて満足する」だと再現性も拡張性もないので、最終的には HuggingFace Hub に置いて誰でも from_pretrained で読める状態にします。

HuggingFace に push する

nanoGPT 風の state_dict をそのまま置くと transformers 側からは形が合いません。理由は、nanoGPT の attention・MLP 射影が nn.Linear (weight.shape = (out, in)) なのに対し、HF の GPT2LMHeadModel は内部で Conv1D (weight.shape = (in, out)) を使っていて、向きが逆だからです (huggingface/transformers/.../modeling_gpt2.py)。さらに bias=False で学習しているので Conv1D / LayerNorm の bias 用にゼロを埋める必要があります。tokenizer 側も tokenizer.model (SentencePiece) を 1 ファイル置いただけだと AutoTokenizer が tokenizer class を解決できないので、最低限 tokenizer_config.json を 1 つ足してやる必要があります。

まとめると、HF 互換に push する前に必要な手続きはこの 3 つです (学習レポ scripts/04_push_to_hub.py に Modal ジョブとしてまとめてあります):

TRANSPOSE = ("attn.c_attn.weight", "attn.c_proj.weight",
             "mlp.c_fc.weight",  "mlp.c_proj.weight")

sd = torch.load("ckpt_best.pt", map_location="cpu", weights_only=False)["model"]
sd = {k.removeprefix("_orig_mod."): v for k, v in sd.items()}
sd = {k: v for k, v in sd.items()
      if not k.endswith(("attn.bias", "attn.masked_bias"))}
for k in list(sd):
    if any(k.endswith(s) for s in TRANSPOSE):
        sd[k] = sd[k].t().contiguous()  # ← Linear → Conv1D の向きに直す

cfg = GPT2Config(vocab_size=32000, n_positions=1024, n_embd=768,
                 n_layer=12, n_head=12,
                 bos_token_id=1, eos_token_id=1,  # SP の <|endoftext|> = 1
                 tie_word_embeddings=True)
model = GPT2LMHeadModel(cfg)
model.load_state_dict(sd, strict=False)  # bias 系の missing は zero 埋めで OK
model.save_pretrained(out_dir)            # ← model.safetensors と config.json

# SentencePiece を AutoTokenizer から読めるように LlamaTokenizer でラップする。
# 特殊トークンは SP 側に最初から入っている ID をそのまま使う
# (id 0: , id 1: <|endoftext|>, id 2: <|pad|>)
tok = LlamaTokenizer(vocab_file="tokenizer.model",
                     bos_token="<|endoftext|>", eos_token="<|endoftext|>",
                     unk_token="", pad_token="<|pad|>",
                     add_bos_token=False, add_eos_token=False, legacy=False)
tok.save_pretrained(out_dir)  # ← tokenizer_config.json などを書き出す

HfApi().upload_folder(folder_path=out_dir,
                      repo_id="sakasegawa/gpt2-jp-small")

ポイントは 3 つです:

  • nanoGPT の Linear 重みを HF Conv1D の向きに転置する。対象は attn.c_attn, attn.c_proj, mlp.c_fc, mlp.c_proj の 4 種類だけ (nanoGPT 自身の from_pretrained でも逆方向で同じ転置が走っています)
  • tokenizer.model 単体だと AutoTokenizer が tokenizer class を解決できないので、LlamaTokenizer (SentencePiece backend) でラップして save_pretrained する。これで tokenizer_config.jsonspecial_tokens_map.json が生成され、利用側は AutoTokenizer.from_pretrained 1 行で読めるようになる。特殊トークンは SP の語彙に最初から入っている ID (0: <unk>, 1: <|endoftext|>, 2: <|pad|>) を再利用するのが大事で、新しいトークンを足してしまうと埋め込み行列のサイズを超えて IndexError になります
  • README.md に「学習データ」「ハイパーパラメータ」「使い方」「val loss」を書いておく。これがそのままモデルカードになる

公開した repo はこちらです: huggingface.co/sakasegawa/gpt2-jp-small。読み込みは普通の from_pretrained で済みます:

import torch
from transformers import GPT2LMHeadModel, AutoTokenizer

model = GPT2LMHeadModel.from_pretrained("sakasegawa/gpt2-jp-small")
tok   = AutoTokenizer.from_pretrained("sakasegawa/gpt2-jp-small")

ids = tok("日本の首都は", return_tensors="pt").input_ids
y = model.generate(ids, max_new_tokens=50, top_k=40, do_sample=True)
print(tok.decode(y[0], skip_special_tokens=True))

W&B の学習ログ

loss curve と生成サンプルの Table は W&B に上げていて、誰でも読めるように public にしてあります。学習中の挙動をそのまま眺めたい方はどうぞ:

⑲ 振り返りと次の一歩

4 時間と 17 ドル弱で、val loss 3.04 / perplexity 20.9 の日本語 GPT-2 small ができあがりました。これが何を意味するかというと:

  • Part 1 で図にした構造は、本当に 200 行ちょっとの PyTorch コードに落ちて学習が回る
  • Part 2 で見た「次トークン予測 + cross-entropy + AdamW + warmup+cosine」は、110M スケールでも素直に動いて loss を 10.55 → 3.04 に下げる
  • Chinchilla 最適 (110M × 20 = 2.2B) だと、文法的に成立する日本語は出るが、事実を「書く」レベルには届かない

最後の点について、少しだけ補足します。今回の生成サンプルは「文として成立してきた」けれど、「日本の首都は広島」みたいな事実誤りが平気で出てきました。これは Chinchilla 最適でも、110M というサイズでは知識としての密度が不足している、という直接の証拠です。100B 以上の事前学習データを 1B モデルに食わせる、いまの「Chinchilla 超え」のレシピは、ここを腕力で押し切っている格好です。

ここから先に進むなら、たぶん次の順で広げていくと面白いと思います:

  • 同じ 110M で Chinchilla の 5〜10 倍 (10B〜20B トークン) まで学習を伸ばしてみる。事実の密度がどう変わるかを観察する
  • medium (350M) や large (760M) にスケールアップする (A100 80 GB の 1 枚でも一応収まる)
  • 事前学習後に SFT (instruction tuning) を 1 〜 2 epoch 入れる。生成サンプルの「役立ち感」が一気に変わる
  • vLLM や SGLang に gpt2-jp-small を載せて、KV cache 込みで推論ベンチマークを取ってみる (Part 1 の 生成ループ — 一語ずつ伸ばす で触れた話の実測)

ここまで読んでくださってありがとうございました。記事や実装に間違いを見つけたら、リポジトリの issue か @gyakuse までこっそり教えてください。

References

本文・付録で参照した論文とリソースの一覧です。

Transformer と GPT-2 本体

トークナイゼーションと位置エンコーディング

Attention / Residual / 内部解析

サンプリング

学習 / スケーリング / 最適化

推論エンジン (KV cache 関連)

Part 3 (実践編) で使ったもの

2026 年時点のフロンティアモデル (本文の比較対象)

本記事の成果物

Traces are pre-computed with HuggingFace transformers on gpt2 (small, 117M).