トークナイザ — モデルが見ているのは文字ではない
一言でいうと
モデルが見ているのは、文字でも単語でもなく、トークンidの列です。その変換ルールを決めるのがトークナイザーで、どのルールを使うかが、コスト、コンテキスト上限、多言語の品質を同時に左右します。トークン数は文字数では見積もれないので、必ずそのモデルのトークナイザーで数えます。
なぜ必要なのか
月末にAPIの請求書を受け取ったチームがあるとします。同じお知らせ文を英語版と韓国語版で要約させたところ、文字数は同じくらいなのに、韓国語版の入力トークンが数倍と記録されています。同じ日にコンテキスト上限に引っかかって、韓国語の文書だけ後ろの部分が切れたという報告も入ります。どちらも原因は同じです。モデルが韓国語の文をより細かく刻んで見ているからです。なぜそうなるのかを知るには、トークナイザーがどのように作られるかから見る必要があります。
最初の試みは、単語単位でした。問題は、語彙が無限だということです。辞書にない単語はすべて未知トークンになり、情報が失われます。韓国語のように助詞や語尾が付く言語では、同じ語幹の変化形だけでも語彙が爆発します。
2番目の試みは、文字単位でした。語彙は小さくなりますが、列が長くなりすぎます。コンテキスト長が限られたモデルでは、長さがそのままコストになり、遠く離れた関係を学習するのも難しくなります。
BPE(Byte Pair Encoding)は、その中間を見つけた答えです。文字から始めて、よく隣り合って出てくるペアを1つにまとめることを繰り返します。よく使われる単語は、まるごと1つのトークンになり、まれな単語は断片として残ります。語彙サイズを固定しながら、未知トークンを事実上なくします。
どう動くのか
学習の手順は、驚くほど単純です。
- コーパスを単語に分け、各単語を文字のリストにします。単語の境界を失わないように、末尾に特別な印を付けます。
- 全体で最もよく隣り合うペアを探します。頻度が同じなら、決めておいたルール(例: 辞書順)で1つを選びます。
- そのペアを1つのトークンにまとめ、このマージルールを順番どおりに記録します。
- 目標の語彙サイズになるまで、2–3を繰り返します。最終的な語彙サイズは、基本の文字数にマージの回数を足した値です。
同点のルールは些細に見えますが、決定的です。同点を適当に決めると、同じコーパスで学習しても、実行のたびにマージの順序が変わり、その後のすべてのルールとidがずれます。トークナイザーは、再現可能でなければ使えない部品です。
エンコードは、学習したマージルールを記録された順序どおりに適用することです。順序がなぜ重要かは、小さな例で示します。マージルールが次の順序で学習されたとします。
merges (in order): 1) e r 2) w e
word: l o w e r _
apply 1 then 2: l o w er _
apply 2 then 1: l o we r _
同じ2つのルールでも、適用順序を変えるだけで、結果のトークンが変わります。学習のときにerが先に作られたという事実は、「このコーパスではerでまとめるほうが多かった」という情報なので、エンコードもその順序に従って初めて、学習時と同じ分割になります。デコードは逆に簡単です。トークンをつなげて、単語末尾の印を空白に戻すだけです。
実際のモデルのトークナイザーは、この骨組みにいくつかを足します。
- バイトレベルBPE。Unicodeの文字全体を基本語彙にすると大きすぎるので、256個のバイト値を基本語彙にします。どんな文字列もバイトで表現できるので、未知トークンは生じません。GPT-2の語彙50,257個は、バイト256個、マージ50,000個、文末の特殊トークン1個でできています。
- SentencePiece。空白を
▁という文字に置き換えて語彙の中に入れます。空白で単語を分けない言語にもそのまま使え、デコードはトークンをつなげたあと、▁を空白に置き換えれば終わりです。 - WordPiece。BERT系が使う方式で、単語の途中の断片に
##を付けて示し、マージするペアを頻度ではなく尤度を基準に選びます。
ここで、韓国語のコストの問題が説明できます。UTF-8で、ハングルの音節1つは3バイトです(例: ハングルの音節가はEA B0 80)。バイトレベルのトークナイザーが、学習コーパスで韓国語を十分に見ていなかった場合、ハングルの音節を1つにまとめるマージルールが少ししか作られず、その音節は、最悪の場合バイト3つ、つまりトークン3つとして残ります。英語の単語がまるごと1トークンになる間に、韓国語は音節1つが複数のトークンになるのです。トークナイザーを変えることもできません。語彙が変わると埋め込み行列が変わり、モデルを学習し直す必要があるからです。
現場で何が問題になるのか
別のトークナイザーで数えてしまう。コストの見積もりやコンテキストの予算を計算するとき、便利なライブラリ1つですべてのモデルのトークンを数えることが多いです。モデルごとに語彙が違うので数値が間違い、症状は「予算内だと計算したのに、出力が切れる」として現れます。トークン数は、呼び出すそのモデルのトークナイザーで数えるか、APIがレスポンスで返す使用量を記録して補正します。
Unicode正規化が混ざる。ハングルは、同じ文字を2通りで表現できます。完成形の音節1つ(NFC)で書くことも、子音字と母音字(初声・中声・終声)をつなげて(NFD)書くこともできます。画面では同じに見えますが、コードポイントの数が2、3倍になり、トークン数もそれに応じて増え、文字列の比較や検索が黙って失敗します。古いmacOSのファイルシステムを経たファイル名や、一部の文書変換ツールから出たテキストが、このような形です。「同じ文なのに、トークン数が特に多い」ときは、まず正規化を疑います。
特殊トークンの文字列が入力に混ざる。ユーザーが貼り付けたテキストに、文末の印のような特殊トークンの文字列が入っていると、それを普通の文字として扱うか、制御トークンとして扱うかが問題になります。制御トークンとして解釈されると、入力がそこで途切れたかのように動作することがあります。そのためtiktokenは、デフォルトでこのような入力に出会うとエラーを出します。エラーを無効にする前に、なぜ止められているのかを理解する必要があります。
モデルが文字を数えられないことに驚く。トークンの境界は、文字の境界と違います。「この単語の3番目の文字」や桁数の計算でモデルがミスをするのは、モデルが愚かだからではなく、そもそもその単位を見ていないからです。こうしたことは、モデルに任せずにコードで処理します。
空白と書式がトークンを食う。インデント、連続した改行、繰り返されるマークダウン記号が、思ったより多くのトークンを占めます。プロンプトを減らすときは、文字数ではなくトークン数を見て、どの部分がたくさん食っているかを、トークンを直接出力して確認します。
どう確認するのか
トークナイザーを作ったり変えたりしたら、3つを必ず見ます。
import unicodedata
text = open("corpus.txt", encoding="utf-8").read()
print(unicodedata.is_normalized("NFC", text)) # False means mixed forms
print(len(text), len(text.encode("utf-8"))) # characters vs bytes
# round trip: decode(encode(x)) must equal x for every line
# compression: characters / tokens, compare across languages
1つ目は、往復チェックです。エンコードしてからデコードした結果が、元の文と1文字も違ってはいけません。単語末尾の印を忘れると、空白を復元できないので、ここですぐわかります。1行でも違えば、その行をdiffで比べて、どのトークンでずれたかを探します。
2つ目は、圧縮比です。文字数をトークン数で割った値です。値が大きいほど、トークン1つが多くの文字を持つという意味です。同じ内容の英語版と韓国語版を並べて測れば、韓国語のコストがなぜ高くなるのかを、数値で説明できます。
3つ目は、マージルールの先頭部分を読むことです。最初の数十個のマージは、コーパスで最もよくある断片です。助詞や語尾のように、予想した断片が見えるか、おかしな断片(空白文字、制御文字)が混ざっていないかを、目で確認します。おかしな断片が見えたら、前処理が間違っています。
続けて読むこと
すぐあとの理論でテキストをベクトルに変える埋め込みを扱ったあと、最初のラボでBPEを土台から実装します。文字の頻度を数え、基本語彙を作り、同点のルールまで守ってマージルール120個を学習し、そのルールを記録された順序どおりに適用して文書をエンコードしたあと、id列だけで元の文を復元する往復チェックと圧縮比の計算までを行います。ライブラリなしで、Pythonだけを使います。