BPEトークナイザを土台から作る
目標
BPEトークナイザーを、ライブラリなしでPythonだけで実装し、学習からエンコードとデコードまで、1サイクルを完成させます。
なぜ重要なのか
トークナイザーは、LLMパイプラインで最初に実行され、最も理解されていない部品です。ところが、ここで決まることが多くあります。プロンプトのコストは、文字数ではなくトークン数で課金され、コンテキスト長の制限もトークン単位です。モデルが単語のスペルに関する質問に特に弱いのも、トークンの境界が文字の境界と違うからです。
自分で作ってみると、こうした性質が抽象的な話ではなく、実装の自然な帰結であることがわかります。特に最後のステップで圧縮比を測ると、韓国語がなぜ英語よりトークンを多く食うのかを、数値で確認できます。
このラボは、モデルをダウンロードしません。インターネットもGPUも必要ありません。コーパスは、ラボのデータベースのdocsテーブルに入っている韓国語の文書30件です。
ステップ
作業ディレクトリは/root/llmです。ルールを正確に守らないと採点されません。
docsテーブルのbodyをidの昇順で1行ずつ/root/llm/corpus.txtに保存してください。30行です。- コーパスに出てくる文字の頻度を
/root/llm/char_freq.tsvに保存してください。문자<탭>빈도の形式で(プレースホルダーは文字と頻度で、間はタブです)、空白文字は数えません。並び順は頻度の降順で、同じなら文字の昇順です。 - 基本語彙を
/root/llm/vocab_base.txtに1行に1つずつ保存してください。コーパスの文字(空白を除く)に単語末尾の印の文字(_)を加えた集合を、コードポイントの昇順でソートします。 - BPEのマージルール120個を
/root/llm/merges.txtに保存してください。1行に앞토큰 뒤토큰を空白で区切って書きます(プレースホルダーは前のトークンと後ろのトークンです)。学習のルールは次のとおりです。- 各行を空白で分けて単語を作り、各単語を
문자들 + ['_']のリストで始めます(プレースホルダーは文字の並びです)。 - 各段階で、全単語にわたって隣接ペアの頻度を数え、最も頻度が大きいペアをマージします。
- 頻度が同じなら、
(앞토큰, 뒤토큰)タプルの辞書順が小さいペアを選びます(プレースホルダーは前のトークンと後ろのトークンです)。 - マージは、各単語で左から重ならないように適用します。
- 各行を空白で分けて単語を作り、各単語を
- 最終語彙を
/root/llm/vocab.tsvに토큰<탭>idの形式で保存してください(プレースホルダーはトークンで、間はタブです)。ステップ3の基本文字を順にidの0から入れ、そのあとにマージで生まれたトークンを、マージ順に付けます。 - 各文書をトークンid列に変換して
/root/llm/encoded.tsvに保存してください。1行にdocs.id<탭>id id id ...の形式で(プレースホルダーはタブです)、30行です。エンコードは、学習したマージルールを記録された順序どおりに適用します。 vocab.tsvとencoded.tsvだけで元の文を復元して、/root/llm/decoded.txtに保存してください。トークンをつなげて_を空白に置き換え、行末の空白を削除すれば、元の文と同じになるはずです。/root/llm/stats.tsvに1行で문자수<탭>토큰수<탭>압축비を保存してください(プレースホルダーは文字数、トークン数、圧縮比で、間はタブです)。文字数はコーパスの各行の長さの合計(空白を含む)、トークン数は全トークンの個数、圧縮比は文字数をトークン数で割って小数点以下3桁に丸めた値です。
参考
- コーパスの抽出:
PGPASSWORD=lab psql -tA -h 127.0.0.1 -U lab -d labdb -c "select body from docs order by id" - Pythonだけを使います。
collections.Counterは便利ですが、必須ではありません。 - よくある間違い1: マージの同点処理を忘れると、ルールの順序が変わって、以後のステップがすべてずれます。
- よくある間違い2: 単語末尾の印を外すと、デコードするときに空白を復元できません。
コーパスの取得
docsテーブルのbodyをidの昇順で1行ずつ/root/llm/corpus.txtに保存してください。30行です。
docsテーブルのbodyをid順に1行ずつファイルに書きます。psqlで結果だけを出力するオプションを使うと便利です。
文字の頻度を数える
コーパスに出てくる文字の頻度を/root/llm/char_freq.tsvに保存してください。문자<탭>빈도の形式で(プレースホルダーは文字と頻度で、間はタブです)、空白文字は数えません。並び順は頻度の降順で、同じなら文字の昇順です。
空白は数えません。並べ替えの基準が2つあることに注意してください。
基本の文字語彙を作る
基本語彙を/root/llm/vocab_base.txtに1行に1つずつ保存してください。コーパスの文字(空白を除く)に単語末尾の印の文字(_)を加えた集合を、コードポイントの昇順でソートします。
コーパスに出てくる文字に加えて、単語の末尾を表す印の文字も、語彙に入ります。
マージルール120個を学習する
BPEのマージルール120個を/root/llm/merges.txtに保存してください。1行に앞토큰 뒤토큰を空白で区切って書きます(プレースホルダーは前のトークンと後ろのトークンです)。学習のルールは次のとおりです。
- 各行を空白で分けて単語を作り、各単語を
문자들 + ['_']のリストで始めます(プレースホルダーは文字の並びです)。 - 各段階で、全単語にわたって隣接ペアの頻度を数え、最も頻度が大きいペアをマージします。
- 頻度が同じなら、
(앞토큰, 뒤토큰)タプルの辞書順が小さいペアを選びます(プレースホルダーは前のトークンと後ろのトークンです)。 - マージは、各単語で左から重ならないように適用します。
各段階ごとに、隣接ペアの頻度を数え直す必要があります。同点処理のルールを正確に守ってください。
最終的な語彙辞書を作る
最終語彙を/root/llm/vocab.tsvに토큰<탭>idの形式で保存してください(プレースホルダーはトークンで、間はタブです)。ステップ3の基本文字を順にidの0から入れ、そのあとにマージで生まれたトークンを、マージ順に付けます。
基本文字を先に入れ、そのあとにマージが起きた順に付けます。idは0から連続です。
文書をトークンid列に変換する
各文書をトークンid列に変換して/root/llm/encoded.tsvに保存してください。1行にdocs.id<탭>id id id ...の形式で(プレースホルダーはタブです)、30行です。エンコードは、学習したマージルールを記録された順序どおりに適用します。
学習のときと同じ順序でマージルールを適用すると、同じ結果になります。
id列だけで元の文を復元する
vocab.tsvとencoded.tsvだけで元の文を復元して、/root/llm/decoded.txtに保存してください。トークンをつなげて_を空白に置き換え、行末の空白を削除すれば、元の文と同じになるはずです。
トークンをつなげてから、単語末尾の印を空白に戻します。行末の余分な空白は削除します。
圧縮比を計算する
/root/llm/stats.tsvに1行で문자수<탭>토큰수<탭>압축비を保存してください(プレースホルダーは文字数、トークン数、圧縮比で、間はタブです)。文字数はコーパスの各行の長さの合計(空白を含む)、トークン数は全トークンの個数、圧縮比は文字数をトークン数で割って小数点以下3桁に丸めた値です。
文字数をトークン数で割ります。文字数には空白も含めます。