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_pairs は Counter で隣り合ったすべての組を数え、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 を使っている理由です。
練習問題
- 学習データを 2000 首から 5000 首に増やして(少し遅くなります)、テストセットで 1 トークンあたり平均何文字になったか見てください。
- 学習の前に読点、句点、改行をそれぞれ別に切り離し、各句の内部だけで集計してマージするようにしてください。学んだ 2 文字以上のトークンは、どんなものに変わりましたか。
- バイトレベルの BPE の最初の語彙表は 256 個のバイトです。文字から始める(最初の語彙表を学習データにあるすべての異なる文字にする)ようにしたら、どんな利点と欠点があるでしょうか。
確認テスト
1. BPE の学習では、各ステップで何をしていますか?いつ止まりますか?
学習データの中で隣り合ったトークンの組がそれぞれ何回現れるかを数え、最も多い組を新しいトークン一つにまとめて語彙表に加え、データの中でも置き換えます。この過程を、あらかじめ決めた回数だけマージするまで、つまり語彙表が望む大きさになるまで繰り返します。
2. エンコードのとき、マージのルールを学んだ順番どおりに適用する必要があるのはなぜですか?
後のマージのルールは前のマージの結果の上で学ばれたもので、それがまとめる対象は、前のマージで得られた新しいトークンかもしれないからです。元の順番どおりでなければ、後のルールはまとめる対象を見つけられず、切り分けた結果が学習のときと食い違ってしまいます。
3. この課の BPE がテストセットで文字よりも多いトークンに切り分けたのはなぜですか?本物のトークナイザーにこの問題がないのはなぜですか?
学習データが 2000 首の詩しかなく、多くの漢字が十分な回数現れなかったので、完全な文字にまとめられず、2~3 個のバイトレベルの断片に分解するしかなかったからです。本物のトークナイザーは膨大なデータで学習し、十数万回マージするので、常用漢字もよく使われる語も完全なトークンになります。
質問と議論
このレッスンでつまずいたところは、ここで質問してください。他の人の質問に答えるのも歓迎です。
質問で 3 ポイント、回答で 6 ポイント。審査を通過すると公開されます。
議論を読み込んでいます…