0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

microgptを日本語データに差し替えると何が起きる?文字単位Tokenizerの罠を最小実験で確認

0
Last updated at Posted at 2026-02-23

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

スクリーンショット 2026-02-23 202632.png

fig1_loss_curve.png

図1: loss曲線 + ランダム基準 log(V)


fig2_dataset_stats.png

図2: データセット統計(語彙サイズ / 平均長)


スクリーンショット 2026-02-23 202658.png

今回の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_headvocab_size 個のlogitを出す
  • softmaxvocab_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で腹落ち → 実務へ)

  1. 入力テキストを正規化(NFKCなど)して文字種を減らす
    • 全角/半角、濁点結合、類似記号…で語彙が膨らみます
  2. Tokenizerを選ぶ
    • microgptは文字単位で最小実装
    • 実務の日本語は、だいたい サブワード(SentencePiece/BPE)か byte-level が現実的
  3. コンテキスト長を合わせるblock_size / モデル側の最大長)
    • “平均”ではなく、必要な依存距離で決める(ログなら数百〜、会話なら数千〜)
  4. 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つ)

  1. lossの絶対値だけ見て「日本語は学習できない」と誤解する
     → V が違うと基準が違います。必ず log(V) と一緒に見る(図1の破線)。

  2. 漢字・記号・絵文字を雑に混ぜて vocab_size が爆増し、急に遅くなる
     → 文字単位Tokenizerは文字種がそのままコスト。
     正規化/フィルタ/サブワード化が現実解。

  3. block_size のまま長文を入れて、学習が“途中で切れている”ことに気づかない
     → microgptは min(block_size, ...) で切ります。
     長文なら block_size を上げる or 実務基盤へ移行(PyTorch + efficient attention)。

参考(公式doc/論文/関連)

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?