Pyxisの開発: トークナイザー
前回の記事 でTransformer本体ができた。ただしこの時点でPyxisが受け取れるのはトークンIDの列だけで、テキストを渡すことはできない。今回はその入口にあたるトークナイザーを組み込んだ。対応するPRは #23 。
BPEそのものについては embeddingの記事 で一度書いたので、今回はその先、実際のトークナイザーファイルの中に何が入っていて、テキストがどういう順序でトークンIDになるのかを見ていく。
トークナイザーは訓練済みのデータ
トークナイザーというと文字列を分割するロジックを思い浮かべるが、モデルと一緒に配布される
tokenizer.json
の中身は
全部データ
だった。
BPEは「訓練データ中で隣り合う頻出ペアを繰り返しマージする」というアルゴリズムだが、これはトークナイザーを作るとき(訓練時)に済ませておく処理で、推論時に走らせるわけではない。推論時に必要なのは、その訓練の結果として得られた2つのテーブル。
- vocab : トークン文字列 → トークンIDの対応表
- merges : どのペアをどの順番でマージするか、という規則の列
Qwen3-1.7Bの
tokenizer.json
を実際に覗いてみると、vocabが151643エントリ、mergesが151387エントリ入っていた。ファイルサイズは11MBほどある。
このファイルには他に
normalizer
、
pre_tokenizer
、
decoder
といった項目もあるが、中身はこんな調子。
"normalizer": {"type": "NFC"}
"decoder": {"type": "ByteLevel", "add_prefix_space": false, "trim_offsets": false, "use_regex": false}
処理の名前と、必要ならそのパラメータが書いてあるだけで、処理そのものは書かれていない。
{"type": "NFC"}
は「Unicode正規化のNFCをかけろ」という指示であって、NFCが何をするかはこのファイルの外にある。つまり
tokenizer.json
が持っているのは、どの処理を使えという指示と、訓練で得られたテーブルだけ。処理の中身は、これを読む側が実装しておく必要がある。
mergesの先頭はこうなっている。
["Ġ", "Ġ"], ["ĠĠ", "ĠĠ"], ["i", "n"], ["Ġ", "t"], ["ĠĠĠĠ", "ĠĠĠĠ"], ["e", "r"]
Ġ
は半角スペースを表す記号(後述)。スペース2個、スペース4個、
in
、
t
、スペース8個、
er
という順に並んでいる。訓練データ中でこの順に頻出だった、ということがそのまま規則の順序になっている。インデント由来と思われるスペースの連結が上位を占めているのが、いかにも実データという感じで面白い。
マージを追ってみる
「マージ」というのは、隣り合った2つの要素をくっつけて1つにする操作のこと。訓練時は「データ中で最も頻出するペアをマージする」を繰り返し、マージしたペアを順にmergesへ書き足していく。Qwen3では151387回繰り返した結果があのテーブル、ということになる。
一方、推論時はこのテーブルを引く側になる。入力に対して mergesに載っているマージだけを適用していく 、という手順になる。
- 入力をバラバラの要素に分解する
- 隣り合うペアのうち、mergesに載っているものを探す
- 複数見つかったら、mergesの中で順位が最も早いものをマージする
- 2に戻る。どのペアも載っていなければ終了
like
で追ってみる。Qwen3のmergesを実際に引くと、
(l,i)
が486番目、
(i,k)
が1323番目、
(k,e)
が184番目、
(li,ke)
が4547番目だった。
l i k e
↓ (k,e) が184番目で最も早い
l i ke
↓ (l,i) が486番目
li ke
↓ (li,ke) が4547番目
like
3回のマージで1トークンになった。ここで注意したいのは、左から順に処理しているわけではないこと。最初にくっつくのは
li
ではなく
ke
で、あくまでmergesの順位が早い順に適用される。
(i,k)
も1323番目に載ってはいるが、先に
(k,e)
と
(l,i)
が適用されて
i
と
k
が隣り合わなくなるので、出番が来ないまま終わる。
ここで
like
が1トークンまで到達できたのは、必要なマージが3つとも載っていたから。載っていないペアに当たれば、そこでマージが尽きて複数トークンのまま終わる。
byte-level BPE
embeddingの記事でBPEを説明したときに「最初は1文字ずつの辞書から始めて」と書いたが、現代のLLMのトークナイザーは正確には文字単位ではなく バイト単位 から始める。byte-level BPEと呼ばれる方式で、GPT-2で採用されて以降、広く使われている。
まず前提として、トークンの種類は無制限に増やせるわけではない。トークナイザーを作るときに「何種類まで」という上限を決めていて、これを
vocab_size
と呼ぶ。Qwen3-1.7Bなら151936で、トークンIDは0から151935の範囲に収まる(先ほどのvocabの151643に特殊トークン26個を足しても151669にしかならないが、残りは128の倍数に切り上げるための余白で、実際には使われていない)。
なぜ上限が要るかというと、この数がそのままモデルのサイズに効いてくるため。モデルはトークンIDを受け取ると、
embeddingテーブル
からそのIDに対応するベクトルを引いて計算を始める。このテーブルはトークン1つにつき1行を持つ
vocab_size × hidden_dim
の行列なので、トークンの種類が増えればその分だけ行が増える。Qwen3-1.7Bは
hidden_dim
が2048なので、151936行 × 2048列で約3.1億個の数値がこのテーブルに入っていることになる。この「訓練で学習された数値ひとつひとつ」がパラメータで、モデル名の「1.7B」もパラメータの総数(17億)を指している。
3.1億がどれくらいかというと、Transformerブロック1層分(アテンション+FFNで約5000万)の6層分に相当する。ブロックは全部で28層なので、embeddingテーブル1枚でその2割強を別に抱えている勘定。トークンの種類を増やすコストはそれなりに重い。
その前提で文字単位のBPEを考えると、初期辞書にUnicodeの全文字を入れる必要がある。Unicodeで割り当て済みのコードポイントは29万個以上あり、そのうち文字(Letter)だけでも14万個ある。15万の枠のうち14万を初期辞書が占めてしまい、マージして語彙を育てる余地がほとんど残らない。かといって一部の文字だけを入れると、辞書にない文字が来たときに表現できず、
<unk>
(unknown)という「知らない文字」トークンに潰すしかない。
byte-level BPEでは、テキストをUTF-8のバイト列に変換してから、その256種類のバイトを初期辞書にする。どんな文字列も最終的にはバイトの列なので、初期辞書が256個あればあらゆる入力を表現できる。絵文字だろうが未知の言語だろうが、必ず何らかのトークン列になり、
<unk>
が原理的に発生しない。
ここでひとつ問題がある。バイト値の中には制御文字(改行、タブなど)や、そもそも単体では正しいUTF-8にならないバイトが含まれていて、これらをそのまま辞書のキーとしてJSONに書き出すのは扱いにくい。そこでGPT-2では、256個のバイトそれぞれを「印字可能なUnicode文字」に1対1で対応づけてから辞書に載せる、という方式を取っている。
tokenizer.json
の中で見かける
Ġ
はこれ。半角スペース(バイト値0x20)に対応づけられた文字で、スペースそのものではない。同様に
Ċ
が改行に対応する。日本語のトークンが
ãģĵãĤĵãģ«ãģ¡ãģ¯
のような文字化けした見た目になっているのも同じ理由で、これは「こんにちは」のUTF-8バイト列を1バイトずつこの対応表で変換した結果。文字化けしているのではなく、そういう表記法になっている。
前処理の正規表現
もうひとつ、BPEの前段に pre-tokenizer という処理が入る。
先ほどのマージの手順を、テキスト全体に対して無制限にかけると困ったことが起きる。
I like this
をバラすと
I ␣ l i k e ␣ t h i s
となるが、ここでは
like
の
e
と次の
␣
も「隣り合うペア」になってしまう。訓練データに
like this
が頻出していればこのペアがマージされ、単語の境界をまたいだ塊が1トークンになりうる。
これを防ぐのがpre-tokenizer。BPEにかける前に、正規表現でテキストを大まかに区切っておく。
[I] [␣like] [␣this]
そしてマージは、この区切られた塊の中だけで行う。塊が違えば隣り合っているとみなさないので、
e
と
␣
のペアはそもそも候補に上がらず、
like this
という1トークンは原理的に生成されない。
区切っておく利点は他にもある。
like this
や
like that
のような組み合わせを辞書に入れ始めると際限がなく、15万しかない語彙の枠を食い潰してしまう。また、
like
が単独では1トークン、
like this
の中では別トークンの一部、となるとモデルから見た単語の同一性も崩れる。
Qwen3の
tokenizer.json
に入っている正規表現がこれ。
(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}| ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+
パイプ区切りで、先頭から順に:
-
'sや'llなどの英語の短縮形 -
直前に記号を1つ許した文字の連続(
\p{L}はUnicodeの「文字」カテゴリ)— スペース+単語がここで1つの塊になる -
数字1桁(
\p{N}) - スペースを1つ許した記号の連続
- 改行、末尾のスペース、その他の空白
3番目が「数字1桁」なのがポイントで、これによって数字は必ず1桁ずつのトークンに分割される。
2026
は
2
,
0
,
2
,
6
の4トークン、
1234567
なら7トークンになる。
Qwen Technical Report
にある「数字を1桁ずつに分割するようにした」というのは、辞書側ではなくこの正規表現で実現されている。
2番目のパターンで単語の直前のスペースが単語側に取り込まれるので、英語では「スペース+単語」が1トークンになりやすい。
Ġworld
のようなトークンが辞書にあるのはそのため。
実際に分割してみる
実際のQwen3のトークナイザーで試してみた結果。
'Hello'
→ ['Hello'] → [9707]
'Hello, Pyxis!'
→ ['Hello', ',', 'ĠPy', 'xis', '!'] → [9707, 11, 5355, 7191, 0]
'こんにちは'
→ ['ãģĵãĤĵãģ«ãģ¡ãģ¯'] → [89015]
'日本語のトークン化'
→ ['æĹ¥æľ¬', 'èªŀ', 'ãģ®', 'ãĥĪãĥ¼', 'ãĤ¯', 'ãĥ³', 'åĮĸ']
→ [101059, 102819, 15767, 137084, 28120, 15698, 32108]
Hello
は1トークンだが、
Pyxis
は
ĠPy
+
xis
の2トークンに割れている。
(ĠPy, xis)
というペアがmergesに載っていないためで、そこでマージが尽きて止まっている。頻出する単語ほど訓練時に深くマージされてテーブルを辿りきり1トークンに収まるが、珍しい単語は途中で尽きて複数トークンのまま残る、というBPEの性質がそのまま出ている。
ĠPy
の先頭に
Ġ
が付いているのは、前段の正規表現で直前のスペースが取り込まれたため。
日本語は「こんにちは」が1トークンなのに対して、「日本語のトークン化」は9文字で7トークンに割れている。トークンごとに区切るとこうなる。
日本 / 語 / の / トー / ク / ン / 化
「日本」は2文字で1トークンにまとまっているのに、「トークン」の4文字は
トー
ク
ン
と3トークンに分解されている。日本語の扱いはこれ以外にも調べていて面白かったので、
別記事
にまとめた。
特殊トークン
BPEのvocabは151643エントリだが、その後ろに特殊トークンが26個、
tokenizer.json
の
added_tokens
として別途定義されている。IDは151643から151668まで。
{"id": 151643, "content": "<|endoftext|>", "special": true}
{"id": 151644, "content": "<|im_start|>", "special": true}
{"id": 151645, "content": "<|im_end|>", "special": true}
<|im_start|>
と
<|im_end|>
はチャット形式のマークアップで、会話のロール(system/user/assistant)の区切りを表す。OpenAIのChatMLに由来する形式で、Qwen3のchat templateもこれを使っている。このうち
<|im_end|>
(151645)は、モデルが「発話が終わった」ことを示すために出力するトークンでもある。生成ループはこれが出たら打ち切ればいい、という合図になるもので、EOS(end of sequence)トークンと呼ばれる。次回の生成ループで使うことになる。
26個の顔ぶれを眺めると、このモデルが何を想定して作られているかが透けて見えて面白い。
<tool_call>
/
</tool_call>
はfunction calling用、
<think>
/
</think>
は推論モードの思考部分を囲むもの、
<|fim_prefix|>
などのFIM(Fill-In-the-Middle)系はコード補完用、
<|repo_name|>
と
<|file_sep|>
はリポジトリ単位でコードを学習させるときの区切り。
<|vision_start|>
や
<|image_pad|>
といったマルチモーダル用のトークンまで入っている(Qwen3-1.7B自体はテキストのみのモデルだが、シリーズで語彙を共通化しているのだと思われる)。
これらはBPEのマージを経由せず、テキスト中に現れたら丸ごと1トークンに変換される。vocabとは別枠の
added_tokens
に置かれていて、encodeの前段で文字列マッチで拾われるためだ。
ここで気になるのが、ユーザーの入力に
<|im_end|>
という文字列が紛れ込んだらどうなるのか。試してみた。
'こんにちは<|im_end|>さようなら'
→ ['こんにちは', <|im_end|>, 'さ', 'よう', 'なら']
→ [89015, 151645, 29713, 124038, 125028]
普通に特殊トークン151645になった。つまりユーザーが入力欄に
<|im_end|>
と打ち込むだけで、モデルから見ると会話の区切りが挿入されたのと区別がつかない。ユーザー入力をそのままencodeに渡す作りだと、会話の構造を外から壊せてしまうことになる。
今回使ったHuggingFaceの
tokenizers
(後述)には
set_encode_special_tokens
というメソッドがあり、
true
を渡すと特殊トークンの文字列を素通しするようになる。
set_encode_special_tokens(true) にした場合
→ ['こんにちは', '<', '|', 'im', '_end', '|', '>', 'さ', 'よう', 'なら']
<|im_end|>
がただの記号の列に分解され、151645にはならなくなった。名前から挙動を読み取りにくいが、「特殊トークンも(普通のテキストとして)エンコードする」という意味らしい。デフォルトは
false
、つまり特殊トークンとして解釈するほうなので、信用できない入力を扱うなら明示的に切り替える必要がある。
実装
さて、ここまで書いておいて何だが、今回自前で実装したのはこれだけ。
use std::io;
use std::path::Path;
pub struct PyxisTokenizer {
inner: tokenizers::Tokenizer,
}
impl PyxisTokenizer {
pub fn load(path: &Path) -> io::Result<Self> {
let inner = tokenizers::Tokenizer::from_file(path).map_err(io::Error::other)?;
Ok(Self { inner })
}
pub fn encode(&self, text: &str) -> Vec<u32> {
self.inner.encode(text, false).unwrap().get_ids().to_vec()
}
pub fn decode(&self, token_ids: &[u32]) -> String {
self.inner.decode(token_ids, true).unwrap()
}
}
HuggingFaceの tokenizers crateの薄いラッパー。21行。
Pyxisは「LLMの推論を自分で実装して理解する」というのが目的なので、本来ならBPEも自分で書くべきところだけど、ここは既存のcrateに任せることにした。理由は、トークナイザーは推論の本体ではなく前処理・後処理であること、そして
tokenizer.json
のフォーマット(normalizer、pre_tokenizer、decoderの組み合わせ)を正確に再現するのが、得られる理解に対して手間が大きすぎると判断したため。ここでの1バイトのズレは全く違うトークンIDになって、モデル本体が正しくても出力が壊れる。デバッグしたい対象はそこじゃない、という気持ちがある。
encode
の第2引数
false
は
add_special_tokens
で、
<|im_start|>
などを自動で付けないという指定。今回は素のテキストをそのまま推論に流したいので付けない。チャット形式で使うときはテンプレートを別途組み立てる必要があるが、それは後回し。
decode
の第2引数
true
は
skip_special_tokens
で、こちらは逆に特殊トークンを出力から除去する指定。生成結果に
<|im_end|>
のような文字列がそのまま混ざるのを防ぐ。
unwrap()
しているのは、
tokenizers
のエラー型が
Box<dyn Error>
で
io::Error
に素直に変換できないため。ここは後で見直すかもしれない。
依存は
default-features = false
にして
onig
だけを有効にしている。デフォルトでは
onig
に加えて
progressbar
(indicatif)と
esaxx_fast
(C++実装のsuffix array、トークナイザーの訓練で使う)が有効になるが、どちらも推論には要らないので切った。
テスト
テストはこう書いた。
#[test]
fn decode_roundtrips_with_encode() {
let Some(tokenizer) = load_test_tokenizer() else {
return;
};
let input = "Hello, Pyxis!";
let token_ids = tokenizer.encode(input);
let output = tokenizer.decode(&token_ids);
assert_eq!(output, input);
}
実際のQwen3-1.7Bの
tokenizer.json
を読み込んで、encodeしてdecodeすると元に戻ることを確認している。byte-level BPEは情報を落とさない変換なので、往復すれば必ず元通りになるはず、という性質のテスト。
load_test_tokenizer()
がOptionを返していて、ファイルが無ければテストを素通りさせている。11MBのモデルファイルをリポジトリに置くわけにもいかず、手元にモデルをダウンロードしていない環境ではスキップしたいため。ただしこれは「テストが通っている」のか「テストが走っていない」のかが区別できない書き方なので、あまり良くはない。CIを整備するときに考え直す予定。
これで入口(テキスト → トークンID)ができた。あとは出口、つまりTransformerの出力ベクトルからトークンIDを選ぶ処理を書けば、テキストからテキストへの推論がつながる。次回は出力ヘッドとサンプリングを実装する。