モジュール 09 · 第 1 回

BPE トークナイザーを手書きする

モジュール 01 で、モデルに見えているのはトークンだと言いました。この課ではバイトレベルの BPE トークナイザーを手書きして唐詩で学習させ、バイトから一歩ずつ漢字や常用語を組み立てていく様子と、少ないデータでの弱点をはっきり見ます。

  • 約 45 分
  • 難易度:中級
  • 検証:2026-09-15 純粋な Python、データは『全唐詩』

コードと実行結果は実際に動かしたときのまま載せているため、コメントと出力は中国語です。

モジュール 01 第 1 課で扱ったとおり、モデルは文字を理解できず、見ているのはトークンの番号の並びです。このモジュールでは GPT をゼロから組み立てますが、最初のステップは文字をどうトークンに切り分けるかを決めることです。

この課では、GPT シリーズも DeepSeek も通義千問も使っている方法、BPE(Byte Pair Encoding、バイトペア符号化)を手書きします。書き終えれば、モジュール 01 では覚えておくしかなかった二つのことがわかるはずです。同じ文章でもモデルによってトークン数が違うのはなぜか。珍しい漢字がいくつものトークンに切られてしまうのはなぜか。

データを用意する

このモジュールでは、ずっと唐詩をデータに使います。出典はオープンソースのプロジェクト chinese-poetry(MIT ライセンス)で、『全唐詩』の 5 万首以上を収めています。

uv add torch opencc
python prepare_poems.py

元のデータは繁体字なので、スクリプトは OpenCC で簡体字に変換します。OpenCC は語句単位で変換するので、1 文字ずつ置き換えるより正確です。そのうえで、形式の整った詩だけを残します。五言か七言で、4 句(絶句)か 8 句(律詩)、中身は漢字と読点と句点だけのものです。

《全唐诗》共 57607 首,留下格律整齐的 35135 首,共 1557776 个字符,6288 个不同的字符
  五言绝句:3656 首
  七言绝句:10087 首
  五言律诗:13865 首
  七言律诗:7527 首
前三首:
  闲却白云居,行踪出去初。窗中聊取笔,架上独留书。日背林光冷,潭澄岳影虚。长闻得药力,此说复何如。
  秋溪南岸菊霏霏,急管烦弦对落晖。红叶树深山径断,碧云江静浦帆稀。不堪孙盛嘲时笑,愿送王弘醉夜归。流落正怜芳意在,砧声徒促授寒衣。
  东城晓出静尘埃,紫画神旗向日开。锦袖半攘争捧辔,银鞍不下小传杯。马盘草上朱弓满,鴈落云中白羽回。晚向三通残皷尽,北原千骑卷行来。

1 行 1 首で、合計 3.5 万首、156 万文字です。3 首目をよく見ると、「鴈」と「皷」が「雁」と「鼓」に変換されていません。これらは異体字で、繁体字と簡体字の対応ではないので、OpenCC の繁体字から簡体字への変換では処理されません。データのクレンジングが 100% きれいにできることはめったになく、この点は後の学習にほとんど影響しないので、そのままにしてあります。

バイトから始める

コンピュータの中の文字は、最も低いレベルではバイトです。UTF-8 では、アルファベットは 1 バイト、漢字は 3 バイトです。

一个汉字在 UTF-8 里是 3 个字节:'月' → e6 9c 88

BPE はバイトから出発するので、最初の語彙表の要素は 256 個しかありません(1 バイトで表せる 0~255)。利点は、どんな文字でも表せて、「この文字は語彙表にない」という状況に決して出会わないことです。最悪でもバイトに分解されるだけです。

そして一つのことを繰り返します。学習データの中で最もよく現れる隣り合ったトークンの組を見つけ、それを新しいトークン一つにまとめる

def train(text, n_merges):
    ids = list(text.encode("utf-8"))  # 从字节开始:词表一开始就是 0~255 这 256 个字节
    merges = {}  # (a, b) -> 新编号
    vocab = {i: bytes([i]) for i in range(256)}  # 编号 -> 它代表的字节串
    for k in range(n_merges):
        pairs = count_pairs(ids)
        pair, count = pairs.most_common(1)[0]  # 出现最多的一对相邻的词元
        new_id = 256 + k
        ids = merge(ids, pair, new_id)
        merges[pair] = new_id
        vocab[new_id] = vocab[pair[0]] + vocab[pair[1]]

count_pairsCounter で隣り合ったすべての組を数え、merge はその組が現れるすべての場所を新しい番号に置き換えます。どちらも数行だけです(code/09-transformer/bpe.py を見てください)。

一回まとめるたびに語彙表にトークンが一つ増え、学習データは少し短くなります。まとめる回数が最終的な語彙表の大きさを決め、それは自分で選ぶ数です。

何を学んだか見る

2000 首の詩で 1500 回のマージを学習させます(純粋な Python はとても遅いので、データがこれ以上多いと長く待つことになります)。

训练数据:2000 首诗,89863 个字符,265591 个字节

第    1 次合并:          [80] + [82]           → [80 82]         (出现 6396 次),训练数据变成 259195 个词元
第    2 次合并:          [ef] + [bc]           → [ef bc]         (出现 6394 次),训练数据变成 252801 个词元
第    3 次合并:       [ef bc] + [8c]           → ,               (出现 6394 次),训练数据变成 246407 个词元
第    4 次合并:          [e3] + [80 82]        → 。               (出现 6394 次),训练数据变成 240013 个词元
第    5 次合并:          [e4] + [b8]           → [e4 b8]         (出现 3702 次),训练数据变成 236311 个词元
第    6 次合并:             。 + ↵              → 。↵              (出现 1999 次),训练数据变成 234312 个词元
第    7 次合并:          [e4] + [ba]           → [e4 ba]         (出现 1819 次),训练数据变成 232493 个词元
第    8 次合并:          [e5] + [a4]           → [e5 a4]         (出现 1749 次),训练数据变成 230744 个词元
……
第 1001 次合并:[ef bc 8c e8 a1] + [8c]           → ,行              (出现 26 次),训练数据变成 106108 个词元
第 1500 次合并:       [e7 a5] + [96]           → 祖               (出现 15 次),训练数据变成 96124 个词元
训练 1500 次合并,用了 27 秒,词表大小 1756

角括弧の中は、まだ完全な文字になっていないバイトです。最初の数回のマージはとても面白いものです。

  • 最初の 4 回で読点と句点が組み立てられています。どの詩にもあるので、最もよく現れるのです。1 回目と 4 回目を合わせると、まず句点の後ろの 2 バイト 80 82 をつなげ、それから先頭の e3 を加えています。
  • 6 回目では「句点 + 改行」が一つのトークンにまとめられています。どの詩も句点で終わり、続いて改行するからです。
  • 5、7、8 回目でまとめられているのは、漢字の UTF-8 エンコーディングの最初の 2 バイトです。多くの常用漢字は最初の 2 バイトが同じで、たとえば e4 b8 で始まるものには「不」「与」「世」「东」などがあるので、こうした「半分の文字」のほうが、どの完全な漢字よりも多く現れるのです。

1500 回マージした後、学習データは 26.6 万バイトから 9.6 万トークンに縮みましたが、8.99 万文字よりはまだ少し多いくらいです。

1500 个新词元里:883 个正好是一个完整的字符,172 个是两个字符以上,其余 445 个是半个汉字、或者跨了字的边界
两个字符以上的,最早学到的 30 个:
  。↵ ,不 ,一 。不 ,何 ,山 人。↵ ,风 万里 ,春 ,应 ,天 ,江 。何 ,无 千里 何处 ,清 ,白 。自 。莫 人间 ,秋 ,寒 ,月 。↵一 ,相 。一 ,云 ,日

本物の語もいくつか学んでいます。「万里」「千里」「何处」(どこ)「人间」(人の世)。しかしそれより多いのは「読点 + 1 文字」で、「,不」「,何」「,春」などです。唐詩ではどの句の最初の文字の前にも必ず句読点があるので、この組み合わせがとても頻繁に現れるからです。BPE は頻度しか見ず、言語を理解していないので、句読点とその後ろの文字が無関係だとは知りません。

実際に使われているトークナイザーは、学習の前にまずルールで文字をおおまかに切り分けます。たとえば句読点、空白、数字をそれぞれ別に切り離し、BPE は切り分けた断片の内部でだけマージします。GPT-2 は正規表現を一つ使っています。こうすれば「,不」のような句読点をまたいだトークンは学ばなくなります。

エンコードとデコード

学習で得られるのはマージのルールの一覧表です。新しい文章をエンコードするときは、まずバイトに変換し、それから学んだマージのルールを学んだ順番どおりに適用します。

def encode(text, merges):
    ids = list(text.encode("utf-8"))
    while len(ids) >= 2:
        # 在所有相邻的对里,找最早学到的那个合并规则先用上:和训练时的顺序一致
        pair = min(set(zip(ids, ids[1:])), key=lambda p: merges.get(p, float("inf")))
        if pair not in merges:
            break
        ids = merge(ids, pair, merges[pair])
    return ids


def decode(ids, vocab):
    return b"".join(vocab[i] for i in ids).decode("utf-8", errors="replace")

なぜ順番どおりなのでしょうか。後のマージのルールは前のマージの上に成り立っているからです。たとえば「,行」というトークンは、「読点の最初の 2 バイトに『行』の最初の 2 バイトを加えたもの」にさらに 8c をつなげて作られています。前のマージを先にしておかないと、後のマージにはまとめる対象がありません。

デコードはずっと簡単です。各トークンが表すバイト列をつなげて、UTF-8 としてデコードするだけです。

見たことのない詩で試す

学習で見ていない 500 首の詩でテストします。

在训练时没见过的 500 首诗上:
  68179 个字节,23059 个字符,BPE 分成 25078 个词元,平均每个词元 0.92 个字符
  训练用的 2000 首诗里有 3655 个不同的字符;测试诗里的字符,1.0% 在训练数据里一次都没出现过

例子:自君入城市,北邙无新坟。始信壶中药,不落白杨根。如何忽告归,蕣华还笑人。玉笙无遗音,怅望缑岭云。
  切成:自 | 君 | 入 | 城 | [e5 b8] | [82] | [ef bc 8c e5] | [8c] | [97] | [e9 82] | [99] | 无 | 新 | [e5 9d] | [9f] | [e3 80 82 e5] | [a7 8b] | 信 | [e5 a3] | [b6] | 中 | 药 | ,不 | 落 | 白 | 杨 | 根 | 。如 | 何 | 忽 | [e5 91] | [8a] | 归 | [ef bc 8c e8] | [95] | [a3] | 华 | 还 | 笑 | 人 | 。玉 | [e7 ac] | [99] | 无 | 遗 | 音 | [ef bc 8c e6 80] | [85] | 望 | [e7 bc] | [91] | 岭 | 云 | 。
  解码回去和原文完全一样

結果はあまりよくありません。トークンが文字より多く、1 トークンあたり平均 0.92 文字しかありません。「市」「北」「邙」「坟」「始」といった文字は、どれも 2~3 個の断片に分解されています。

原因はデータが少なすぎることです。2000 首の詩には異なる文字が 3655 個しかなく、1500 回のマージでそのうち 883 個しか完全な文字にできませんでした。数回しか現れない文字は、マージの順番が回ってきません。「市」は常用字ですが、この 2000 首の中では十分な回数現れなかったので、分解されたままなのです。

それでも一つだけよくできていることがあります。デコードすると原文とまったく同じに戻ることです。どれほど細かく切っても、情報は失われていません。英語が来ても同じように処理でき、ただ 1 バイト 1 トークンに戻るだけです。

  一句含英文的:月 | 落 | 乌 | 啼 | 霜 | 满 | 天 | 。 | H | e | l | l | o

本物のトークナイザー

本物の LLM のトークナイザーもこうして学習されていますが、データが何桁も多いのです。さまざまな言語の文章が数十から数百 GB あり、十数万回マージします。2026 年 9 月時点で、主流のモデルの語彙表はほとんどが 10 万から 20 数万の間です。データが十分に多ければ、常用漢字はどれも完全なトークンにまとめられ、よく使われる語も一つのトークンになります。

これで、モジュール 01 で見た現象も説明できます。

  • モデルによってトークナイザーは違うデータで学習されているので、同じ文章でもトークン数が違う。中国語の学習データが多いモデルほど、中国語を少ないトークンで表せる。
  • 珍しい漢字は学習データにあまり現れないのでマージされず、いくつものバイトレベルのトークンに分解される。

このモジュールで使うトークナイズ

私たちの GPT はこの BPE を使わず、最も単純な文字レベルのトークナイズを使います。1 文字が 1 トークンです。

class CharTokenizer:
    """字符级分词器:一个字符就是一个词元。换行符表示一首诗结束。"""

    def __init__(self, text):
        self.chars = sorted(set(text))
        self.index = {c: i for i, c in enumerate(self.chars)}

    def encode(self, text):
        return [self.index[c] for c in text]

    def decode(self, ids):
        return "".join(self.chars[i] for i in ids)

理由は上で見たとおりです。これほど少ないデータでは、BPE のほうが文字より細かく切ってしまいます。そして唐詩の言語の単位はもともと文字が中心なので、1 文字 1 トークンは自然です。3.5 万首すべての詩で異なる文字は合計 6288 個で、語彙表は大きくなく、どの文字も学習中に何度も現れます。

文字レベルのトークナイズの代償は、語彙表にない文字が来るとお手上げになることです。語彙表はすべての詩から作っているので、私たちの実験には影響しません。しかし学習データ以外の任意の文章は扱えず、これこそ本物の LLM がどれもバイトレベルの BPE を使っている理由です。

練習問題

  1. 学習データを 2000 首から 5000 首に増やして(少し遅くなります)、テストセットで 1 トークンあたり平均何文字になったか見てください。
  2. 学習の前に読点、句点、改行をそれぞれ別に切り離し、各句の内部だけで集計してマージするようにしてください。学んだ 2 文字以上のトークンは、どんなものに変わりましたか。
  3. バイトレベルの BPE の最初の語彙表は 256 個のバイトです。文字から始める(最初の語彙表を学習データにあるすべての異なる文字にする)ようにしたら、どんな利点と欠点があるでしょうか。

確認テスト

1. BPE の学習では、各ステップで何をしていますか?いつ止まりますか?

学習データの中で隣り合ったトークンの組がそれぞれ何回現れるかを数え、最も多い組を新しいトークン一つにまとめて語彙表に加え、データの中でも置き換えます。この過程を、あらかじめ決めた回数だけマージするまで、つまり語彙表が望む大きさになるまで繰り返します。

2. エンコードのとき、マージのルールを学んだ順番どおりに適用する必要があるのはなぜですか?

後のマージのルールは前のマージの結果の上で学ばれたもので、それがまとめる対象は、前のマージで得られた新しいトークンかもしれないからです。元の順番どおりでなければ、後のルールはまとめる対象を見つけられず、切り分けた結果が学習のときと食い違ってしまいます。

3. この課の BPE がテストセットで文字よりも多いトークンに切り分けたのはなぜですか?本物のトークナイザーにこの問題がないのはなぜですか?

学習データが 2000 首の詩しかなく、多くの漢字が十分な回数現れなかったので、完全な文字にまとめられず、2~3 個のバイトレベルの断片に分解するしかなかったからです。本物のトークナイザーは膨大なデータで学習し、十数万回マージするので、常用漢字もよく使われる語も完全なトークンになります。

質問と議論

このレッスンでつまずいたところは、ここで質問してください。他の人の質問に答えるのも歓迎です。

質問で 3 ポイント、回答で 6 ポイント。審査を通過すると公開されます。

議論を読み込んでいます…