microgptを触って「動いた!」までは良いけど、日本語データに差し替えた瞬間に 遅くなる / lossが下がりにくい / 出力が変 に見えて困ることがあります。
この記事では、Karpathyの microgpt(200行のGPT) をそのまま使い、ダミーデータで「日本語にすると何が変わるか」を最小実験で腹落ちさせます。
結論は「日本語だから難しい」ではなく、ほぼ Tokenizer(=語彙サイズ)とコンテキスト長(=block_size) の話です。
TL;DR(結論)
- microgptは 文字単位(character-level)Tokenizer。日本語は「文字の種類」が増えやすく、語彙サイズ(vocab_size)が増える → 学習が遅く/難しく見える
-
lossの絶対値は比較できない:vocabが増えると「ランダム当て」のloss基準が上がる(
log(vocab_size)) -
block_size=16は「英語の短い名前」前提。日本語の文・長めのテキストは 途中で切れる(モデルが“見られる”長さの上限) - 最小の一歩は (1) 文字種を減らす(ひらがな化/正規化)→ (2) block_sizeを合わせる → (3) lossを
log(V)と一緒に見る です
まず最小で動かす(コピペ1セルで主要結果が出る)
このセル1つで、以下が一気に出ます。
- ✅ microgpt.py をダウンロード(固定リビジョンで再現性確保)
- ✅ 英語ダミーデータ(a-z)と、日本語ダミーデータ(ひらがな)で 2回学習
- ✅ 図1:loss曲線(+ ランダム基準
log(V)を破線で表示)→fig1_loss_curve.png - ✅ 図2:語彙サイズと平均長の比較 →
fig2_dataset_stats.png - ✅ それぞれの生成サンプルを表示(Qiitaに貼れる)
【Colab 1セル】microgptを日本語ダミーデータに差し替えて比較
# =========================
# microgpt: 日本語差し替え最小実験(ダミーデータ)
# =========================
import re, sys, math, random, subprocess
from pathlib import Path
import urllib.request
import matplotlib.pyplot as plt
# --- 1) microgpt.py を取得(固定リビジョンで再現性を確保)
# ※このURLはKarpathyのgist raw(特定コミット)です
MICROGPT_RAW = "https://gist.githubusercontent.com/karpathy/8627fe009c40f57531cb18360106ce95/raw/14fb038816c7aae0bb9342c2dbf1a51dd134a5ff/microgpt.py"
urllib.request.urlretrieve(MICROGPT_RAW, "microgpt.py")
# --- 2) 実験用に軽めの設定へパッチ(microgpt本体はそのまま、数字だけ差し替え)
NUM_STEPS = 250 # 学習ステップ
BLOCK_SIZE = 16 # コンテキスト長(microgptのデフォルト)
TEMPERATURE = 0.8 # 生成のランダム性
code = Path("microgpt.py").read_text(encoding="utf-8")
code = re.sub(r"^num_steps\s*=.*$", f"num_steps = {NUM_STEPS} # number of training steps", code, flags=re.M)
code = re.sub(r"^block_size\s*=.*$", f"block_size = {BLOCK_SIZE} # maximum context length of the attention window", code, flags=re.M)
code = re.sub(r"^temperature\s*=.*$", f"temperature = {TEMPERATURE} # sampling temperature", code, flags=re.M)
Path("microgpt.py").write_text(code, encoding="utf-8")
# --- 3) ダミーデータ生成(英語: a-z / 日本語: ひらがな)
random.seed(0)
# 英語っぽい名前(a-z): 子音と母音をざっくり交互にして「学習できる規則」を仕込む
vowels = list("aeiou")
cons = [c for c in "abcdefghijklmnopqrstuvwxyz" if c not in vowels]
def en_name():
L = random.randint(3, 8)
s = []
next_is_vowel = (random.random() < 0.4)
for _ in range(L):
s.append(random.choice(vowels if next_is_vowel else cons))
# ほぼ交互(たまに崩す)
if random.random() < 0.9:
next_is_vowel = not next_is_vowel
# 末尾は母音で終わりやすくする
if random.random() < 0.4:
s[-1] = random.choice(list("aeiy"))
return "".join(s)
# 日本語っぽい名前(ひらがな): 文字種を“ひらがなだけ”に限定(ここが重要)
# ※漢字混じりだと語彙が増えやすく、microgpt(文字単位)では一気に重くなるため、最初はひらがなで腹落ちさせる
kana = list("あいうえお"
"かきくけこ"
"さしすせそ"
"たちつてと"
"なにぬねの"
"はひふへほ"
"まみむめも"
"やゆよ"
"らりるれろ"
"わ"
"ん")
kana_start = [c for c in kana if c != "ん"]
kana_end = list("こみなりた")
def ja_name():
L = random.randint(3, 8)
s = [random.choice(kana_start)]
for _ in range(L-1):
if random.random() < 0.05:
s.append("ん") # たまに「ん」
else:
s.append(random.choice(kana))
if random.random() < 0.35:
s[-1] = random.choice(kana_end) # 末尾に寄せる
return "".join(s)
N_DOCS = 4000
en_docs = [en_name() for _ in range(N_DOCS)]
ja_docs = [ja_name() for _ in range(N_DOCS)]
# --- 4) microgpt を実行してメトリクスを抜き出す
def run_microgpt(docs, label):
Path("input.txt").write_text("\n".join(docs) + "\n", encoding="utf-8") # ここを差し替えるだけでOK
out = subprocess.check_output([sys.executable, "microgpt.py"], text=True)
out = out.replace("\r", "\n") # \r(キャリッジリターン)を改行扱いにして解析しやすくする
num_docs = int(re.search(r"num docs:\s*(\d+)", out).group(1))
vocab_size = int(re.search(r"vocab size:\s*(\d+)", out).group(1))
num_params = int(re.search(r"num params:\s*(\d+)", out).group(1))
losses = [float(m.group(1)) for m in re.finditer(
r"step\s+\d+\s*/\s*\d+\s*\|\s*loss\s*([0-9]+\.[0-9]+)", out
)]
samples = [m.group(1) for m in re.finditer(r"^sample\s+\d+:\s*(.*)$", out, flags=re.M)]
return {
"label": label,
"num_docs": num_docs,
"vocab_size": vocab_size,
"num_params": num_params,
"losses": losses,
"samples": samples,
}
def dataset_stats(docs):
V = len(set("".join(docs))) + 1 # +1 は BOS
lens = [len(d) for d in docs]
return {
"vocab_size": V,
"avg_len": sum(lens)/len(lens),
"max_len": max(lens),
"rand_loss": math.log(V), # microgptは自然対数(nats)
}
res_en = run_microgpt(en_docs, "EN(dummy a-z)")
res_ja = run_microgpt(ja_docs, "JA(dummy hiragana)")
st_en = dataset_stats(en_docs)
st_ja = dataset_stats(ja_docs)
print("=== SUMMARY ===")
print(f"[EN] docs={res_en['num_docs']} V={res_en['vocab_size']} params={res_en['num_params']} rand_loss=log(V)={st_en['rand_loss']:.3f}")
print(f"[JA] docs={res_ja['num_docs']} V={res_ja['vocab_size']} params={res_ja['num_params']} rand_loss=log(V)={st_ja['rand_loss']:.3f}")
print(f"block_size={BLOCK_SIZE}, num_steps={NUM_STEPS}, temperature={TEMPERATURE}")
# --- 5) 図1: loss曲線 + ランダム基準 log(V)
plt.figure(figsize=(8, 4))
plt.plot(res_en["losses"], label=f"EN loss (V={res_en['vocab_size']})")
plt.plot(res_ja["losses"], label=f"JA loss (V={res_ja['vocab_size']})")
plt.axhline(st_en["rand_loss"], linestyle="--", label=f"EN random baseline log(V)={st_en['rand_loss']:.2f}")
plt.axhline(st_ja["rand_loss"], linestyle="--", label=f"JA random baseline log(V)={st_ja['rand_loss']:.2f}")
plt.xlabel("step")
plt.ylabel("loss (nats)")
plt.title("microgpt: swapping dataset to Japanese changes vocab_size and the baseline")
plt.legend(fontsize=8)
plt.tight_layout()
plt.savefig("fig1_loss_curve.png", dpi=200)
plt.show()
# --- 6) 図2: データセット統計(語彙サイズ / 平均長)
fig, axes = plt.subplots(1, 2, figsize=(10, 4))
labels = ["EN", "JA"]
axes[0].bar(labels, [st_en["vocab_size"], st_ja["vocab_size"]])
axes[0].set_title("vocab size (incl BOS)")
axes[0].set_ylabel("V")
axes[1].bar(labels, [st_en["avg_len"], st_ja["avg_len"]])
axes[1].set_title("avg doc length")
axes[1].set_ylabel("chars per doc")
fig.suptitle("dataset stats")
fig.tight_layout()
fig.savefig("fig2_dataset_stats.png", dpi=200)
plt.show()
# --- 7) 生成サンプル(Qiita本文に貼れる)
def print_samples(title, samples, n=12):
print(f"\n--- {title} samples (first {n}) ---")
for i, s in enumerate(samples[:n], 1):
print(f"{i:2d}: {s}")
print_samples("EN", res_en["samples"])
print_samples("JA", res_ja["samples"])
print("\nSaved: fig1_loss_curve.png, fig2_dataset_stats.png")
図1: loss曲線 + ランダム基準 log(V)
図2: データセット統計(語彙サイズ / 平均長)
今回のJAは「日本語の名前データ」ではなく「ひらがなのランダム列(+弱い語尾バイアス)」なので、生成も“ランダム列っぽく”見えるのが正常です。microgptは意味ではなく、学習データの統計を真似しているだけです。
用語解説
-
microgpt:Andrej Karpathyが公開した「依存ゼロの純Pythonで、学習と推論まで含むGPTを最小実装」したアートプロジェクト。
input.txt(1行=1ドキュメント)を学習して、似た文字列を生成します(公式解説:https://karpathy.github.io/2026/02/12/microgpt/)。 -
Tokenizer(ここでは文字単位):microgptは
uchars = sorted(set(''.join(docs)))で「登場する文字の集合」を作り、文字→IDを割り当てます。
→ 日本語は文字種類が増えやすい(ひらがな/カタカナ/漢字/記号/全角半角…)のでvocab_sizeが増えがち。 - vocab_size(語彙サイズ):モデルが扱う「記号の種類数」。microgptは「ユニーク文字数 + BOS(区切り)」です。
-
loss(交差エントロピー):次の文字を当てる難しさ。microgptは自然対数(nats)。
完全ランダム当ての基準はlog(vocab_size)(均等確率なら-log(1/V)=log(V))。 -
block_size(コンテキスト長):モデルが「過去の何文字まで見て予測できるか」の上限。microgptはデフォルト
16。
最小実験の解説(なぜその結果になるか)
この実験で起きている本質は、だいたい次の3つです。
1) 日本語にすると vocab_size が増える → “同じ学習でも” lossの見え方が変わる
microgptは 文字単位なので、データに現れる文字の種類がそのまま語彙になります。
- 英語ダミー:主に
a-z(+ BOS) - 日本語ダミー:ひらがな(+ BOS)
- 実務の日本語:さらに 漢字・記号・全角半角・絵文字 などが混ざり、語彙が増えがち
ここで重要なのが lossの絶対値です。
語彙が増えると、最初の「ランダム当て」の基準自体が上がります。
- ランダム基準:
loss_random = log(V) - だから Vが大きい日本語の方が lossが大きく見えて当たり前(性能が悪化したとは限らない)
図1では、各データセットに 破線で log(V) を描いています。
見るべきは「英語 vs 日本語のlossの高さ」ではなく、それぞれが log(V) からどれだけ下がったかです。
2) 語彙が増えると、microgptは“遅く”なる(理由はシンプル)
microgptはPyTorchではなく、純Pythonでスカラー計算をしています。
そのため、語彙が増えると以下が効きます。
- 出力層
lm_headがvocab_size個のlogitを出す -
softmaxがvocab_size回のexp・sumをする
→ 1トークンあたりの計算が O(vocab_size) で増える
つまり、日本語(特に漢字混じり)にすると 「モデル構造は同じでも、1ステップが重くなる」 が起きやすいです。
3) block_size=16 のまま長い日本語を入れると、モデルは“途中までしか見ない”
microgptの学習は n = min(block_size, len(tokens) - 1) です。
つまり、ドキュメントが長くても 最初の16文字ぶんしか学習しない(それ以降は切り捨て)になります。
- 短い名前(英語のnames.txt)→ 問題になりにくい
- 日本語の文/ログ/会話 → すぐ
block_sizeを超える
→ 「長距離の依存」が学べないので、出力がそれっぽくならない
対処は後述しますが、まずは「自分のデータの平均長・最大長」と block_size をセットで見るのが第一歩です。
実務に持ち帰る(監視項目/設計手順/判断フロー)
microgptは教育用ですが、日本語LLMの設計で毎回効くチェックが、この最小実験に詰まっています。
監視すると効く指標(最低限)
-
vocab_size(文字単位なら「ユニーク文字数 + 1」) -
log(vocab_size)(ランダム基準loss) - 文字列長の分布(平均/最大/上位1%)
-
block_sizeと「切り捨てが起きていないか」
設計手順(microgptで腹落ち → 実務へ)
-
入力テキストを正規化(NFKCなど)して文字種を減らす
- 全角/半角、濁点結合、類似記号…で語彙が膨らみます
-
Tokenizerを選ぶ
- microgptは文字単位で最小実装
- 実務の日本語は、だいたい サブワード(SentencePiece/BPE)か byte-level が現実的
-
コンテキスト長を合わせる(
block_size/ モデル側の最大長)- “平均”ではなく、必要な依存距離で決める(ログなら数百〜、会話なら数千〜)
-
lossは
log(V)とセットで見る- データが変わるとVが変わり、lossの絶対値は動く。比較の作法を固定する
判断フロー(迷ったらこれ)
-
vocab_sizeが数十〜100程度(ひらがな中心)で、文も短い → 文字単位でも「学習の雰囲気」は掴める -
vocab_sizeが数百〜数千(漢字・記号多め)になりそう → 文字単位は教育用に割り切り、実務はサブワードへ - 長文(> block_size)を入れたい → まず
block_sizeを上げる。ただし計算も増えるので、実務は最初から実装基盤を変える(PyTorch等)
チェックリスト(明日から使えるToDo)
-
自分の日本語データで「ユニーク文字数」を数えた(まず
vocab_sizeを把握) -
log(vocab_size)を計算して、lossの基準を理解した -
ドキュメント長の分布(平均/最大)を出し、
block_sizeの妥当性を見た - 正規化(NFKCなど)で文字種がどれだけ減るか試した
- 実務ではサブワード(SentencePiece/BPE)を検討する前提に切り替えた
-
次の一歩:自分のデータを
input.txtに1行1ドキュメントで入れて、同じセルを再実行した
よくある落とし穴(3つ)
-
lossの絶対値だけ見て「日本語は学習できない」と誤解する
→Vが違うと基準が違います。必ずlog(V)と一緒に見る(図1の破線)。 -
漢字・記号・絵文字を雑に混ぜて
vocab_sizeが爆増し、急に遅くなる
→ 文字単位Tokenizerは文字種がそのままコスト。
正規化/フィルタ/サブワード化が現実解。 -
block_sizeのまま長文を入れて、学習が“途中で切れている”ことに気づかない
→ microgptはmin(block_size, ...)で切ります。
長文ならblock_sizeを上げる or 実務基盤へ移行(PyTorch + efficient attention)。
参考(公式doc/論文/関連)
- Karpathy: microgpt 解説(ブログ)
https://karpathy.github.io/2026/02/12/microgpt/ - microgpt.py(公式Gist)
https://gist.github.com/karpathy/8627fe009c40f57531cb18360106ce95 - microgpt(HTML版)
https://karpathy.ai/microgpt.html - SentencePiece(日本語でもよく使われるサブワードTokenizer)
https://github.com/google/sentencepiece



