たった 50,000 種類で多言語対応？ LLM のトークン化を理解しよう

## はじめに

人生もエンジニアもまだまだ駆け出しのひよこ🐣です。  
最近、大規模言語モデル (LLM) を使った仕事が増えてきたのですが，とても不思議なことがあります。GPT-3 が出力できる語彙数が 50,000 種類しかないのに、多言語に対応できる理由についてです。
広辞苑だけでも 25 万語程度の単語があるのに、世界中の言語を扱うことができる GPT-3 トークン数が 50,000 種類はさすがに少なすぎると思いませんか？ この記事では、この「たった 50,000 トークン」で多言語対応を実現するトークン化の仕組みやその背後にある課題について学んでみたいと思います。

## LLM とトークンの種類

LLM の教科書を読むと、かならず最初に登場するのが「言語モデルとは？」と銘打たれた次のような式です：

$$
P(x_{n+1} | x_1, x_2, ..., x_n)
$$

この式は、過去の入力単語列 $( x_1, x_2, ..., x_n )$ に基づいて次の単語 $x_{n+1}$を予測する条件付き確率を示しています。LLM は、単に次に来そうな最も「それらしい」単語をこの条件付き確率に従って出力しているだけなので、別に意志や知性があるわけではないのだよ、という意見は良く耳にするところです。確かにそれはそうなのですが、私はこの式を初めて見たとき「あれ？」っと思いました。ChatGPT のように多言語の出力が可能な場合、一体何種類くらいの $x$ が必要なんだろうと疑問を持ったのです。

いろいろ調べてみると、**今の LLM では単語単位ではなくトークン単位で分割している**ということ、入力と出力で使われるトークンの種類は同一なこと、そして GPT-3 では **このトークン数がたった 50,000 程度しかない** ということがわかりました。ChatGPT は 80 種類以上の言語を理解するといわれています。それなのに、入出力の種類がたった 50,000 種類しかないなんて、ちょっと信じられませんでした。GPT-4 のトークン数は公表されていませんが、100,000 程度といわれていおり GTP-3 に比べて飛躍的に増えている、という実感はありません。GPT-3 と比べると増加していますが、それでも無限に近い組み合わせの言語と文法をカバーするには驚くべき小ささです。

---

## トークン化とは何か？

トークン化は、入力されたテキストを「LLM が処理しやすい単位に分解する」技術です。たとえば、以下の日本語の例文を考えてみます：

> こんにちは、世界！

この文は、例えばモデル内部では次のようにトークン（単位）に分割されます。

- `こんにちは`
- `、`
- `世界`
- `！`

これらのトークンは数値（整数）に変換され、モデルに入力されます。モデルはこの数値列を処理して次の単語やフレーズを予測するわけです。トークン化の方法にはさまざまな手法があり、上記の例のようにトークンを単語単位とする場合もあれば、部分単語や文字をトークンとして扱う方法もあります。現在では、部分単語や文字をトークンとする手法が主流となっているようです。

## トークンの背後にある課題

トークン化の具体的な実装を考えるとき、次のような課題が浮かび上がります：

### **全ての言語をカバーする必要性**

GPT のようなモデルは、英語、日本語、中国語、アラビア語、さらには絵文字やプログラミングコードまで理解する必要があります。しかしながら、

- 中国語の漢字は **87,000 文字以上**（常用漢字と人名漢字で 2,999 字）
- アラビア語も拡張セットを含めて **1,400文字以上**

これらをすべてトークン化しようとすると、50,000 ト ークンではまったく足りません。

### **頻度の最適化**

トークン化では、以下のように学習データの頻度に基づいて効率的にトークンを割り当てます。

- 英語の `the` のような高頻度単語は 1 つのトークンに割り当てられます。
- 一方、低頻度な日本語の専門用語や珍しい漢字は、複数のトークンに分解される可能性があります。

これにより、高頻度な単語を効率よく処理しつつ、低頻度の単語にも対応しています。

### **未知トークンの処理**

トークン化されていない未知の単語が出現した場合、部分単語や文字単位に分解されます。たとえば、美味しい果物「桜桃」(サクランボですね) が登場した場合、

- 未知トークンとして処理されることもあります。
- 部分単語として「桜」と「桃」に分解される場合もあります。

このアプローチにより、未知語への対応力を高めています。試してみたところ桜桃はそれほど頻度が高くないようで一つのトークンには割り当てられておらず、桜と桃に分解されていました。

## 50,000 トークンで多言語対応を可能にする工夫

たった 50,000 トークンで多言語対応を実現するために、以下のような工夫が施されています：

### **頻度に基づくトークン化**
　
学習データで頻出する単語やフレーズを優先的に 1 トークン化します。以下に例を示します。

- `the` → 1 トークン
- `こんにちは` → 1 トークン
- `extraordinary` → `extra` + `ordinary` のように部分単語に分割される場合もあります。

「桜桃」はトークン化されないかもしれませんが、「焼肉」であれば 1 トークン割り当てられるかもしれません。

>🐣 でも桜桃でサクランボって日本の情緒がありますよね

### **多言語間のトークン共有**

一般的に、以下のように特定のトークン（例：数字や記号）は全言語で共通して使用されます。

- 数字 `123` は英語でも日本語でも同じトークンとして扱われます。
- 記号（例：`$` や `.`）も共有されます。

これにより、トークン数を効率的に節約しています。

### **効率化されているのはどこなのか？**
結局トークン化で率化されているのはどこなのでしょうか？
トークン化には、入力のトークン数を削減するという目的と、トークンの種類数を削減するという異なる目的があります。例えば、the に 1 トークンを割り振れば、入力のトークン数は削減できます。しかし、これとは別に t、th、e といった部分単位もトークン化されている場合、それらがトークンセットに含まれるため、トークンの種類数という観点ではむしろ増えてしまうわけです。

また、「the と t と th が全部トークン化されているとき、どのように処理されるのか？」という疑問が湧きます。色々な方法があるようですが、以下のような処理が一般的のようです。

#### 優先順位付け
まず最も長い一致を優先します。このため the 全体がトークン化可能であれば、部分的な t や th ではなく、the が 1 トークンとして選ばれます。

#### 曖昧性への対応
一方で、then のように the が単語の一部として現れる場合、the で切ると残りの n が孤立してしまい、文脈的に不自然な分割になる可能性があります。そのため、このようなケースでは、then を 1 トークンとして扱うか、または分割を避けます。

#### 例外処理
モデルの学習時に頻繁に登場する単語 (例：the や then) については、優先的にトークンとして登録することで曖昧性の頻出を回避します。

これらの処理により、トークン化の効率性を保ちながら、適切な文脈の解釈を可能にしています。ただし、いずれにしても入力トークン数の削減と種類数の削減はトレードオフの関係にあるため、どちらを優先するかは結局モデル設計者が決めなくてはなりません。

>🐣 深堀するとエントロピーが顔を出しそうな話題ですよね

## Tokenizer の実装方法

入力文をトークン化する方法は Tokenizer と呼ばれます。

> 🐣 なんとなくトークンはカタカナでもいいのですが、トークナイザは許されない気がするのでこちらは Tokenizer と表記します

LLM の Tokenizer には、以下のような技術が使用されています。Tokenizer の本質は最初**どのように分割**して、その後**どのように結合**するかなので、そこに焦点を当てて説明します。

### **BPE（Byte Pair Encoding）**

- **どう分割するか？**  
  BPE は文字レベルからスタートします。最初はすべてを**文字単位**に分割し、単語や文章の意味は考慮されません。

- **どう結合するか？**  
  頻出する文字ペアを順次結合し、新しいサブワードを作成します。例えば、頻繁に現れる「こ」と「ん」を結合して「こん」を作ります。

- **例**  
  初期：`["こ", "ん", "に", "ち", "は"]`  
  ペア結合：`["こん", "に", "ちは"]`

- **利点**  
とにかく単純で応用範囲が広い！

### **WordPiece**

- **どう分割するか？**  
  BPE と同じように単語を**文字単位**で分割を開始します。

- **どう結合するか？**  
  頻出部分を優先して結合しますが、結合時の評価には単純な頻度ではなく**尤度**を使用します。具体的には、その結合によって得られる新しいサブワードの出現確率を、それを構成する文字の出現確率の積で割った値（対数尤度比）を計算します。この値が最大となるペアを結合します。

>🐣 尤度を確率って言ったり確率を確立って書くと激怒する先生いたなー

- **利点**  
  頻度だけでなく**言語モデルの尤度**に基づいて結合されるため、より自然な分割が可能になります。例えば「playing」を分割する場合、単なる頻度では「play + ing」より「pl + aying」のような不自然な分割になる可能性がありますが、WordPieceでは尤度を使用することでより自然な分割を選択できます。

### **SentencePiece**

- **どう分割するか？**  
  **文字列全体を一つのシーケンス**として扱います。空白や記号を特別扱いせず、事前の分割処理を必要としません。SentencePiece は、**BPE** や **Unigram** などのアルゴリズムを使って、文全体を一度にサブワードに分割します。

- **どう結合するか？**  
  **BPE** を使う場合、頻出するペアを結合する点は BPE と同様です。**Unigram** を使う場合は、サブワードの候補を減らしながら最も効率的な語彙を決定します。

- **利点**  
  空白処理をしないため、**日本語のような空白のない言語**にも適しています。SentencePiece は、言語に依存しない柔軟なトークン化が可能です。

ちなみに昔は日本語の分割と言えば形態素解析の独壇場だったそうですが、日本語に特有で他言語への応用が困難なため、多言語対応が可能な SentencePiece が主流になっているということです。

### **GPTでの採用**

- GPT-3 では BPE を使用していますことが公表されています。
- GPT-4 では GPT-3 からの大幅な性能の向上から BPE ではなく改良された独自の Tokenizer を使用しているのでは、といわれています。
- 最近の多言語モデルでは語彙の割り当てを動的に実行する Dynamic Vocabulary Allocation が主流となっているようです。この手法は以下のような特徴があるようなのでまた勉強してみたいところです。
    - 言語ごとの頻度や特徴に応じてトークンセットを最適化
    - 同じ Tokenizer で異なる言語間のバランスを柔軟に調整

- GPT と並んで人気の LLaMA 2 では、BPE ベースの SentencePiece が用いられています。また、LLaMA 2 のトークン数は、約 32,000 種類しかありません。このコンパクトな語彙サイズでありながら、LLaMA 2 は GPT-3 を上回る性能を発揮することが知られています。

> 🐣 『32,000 種類しか』 って表現もおかしな話ですが

**参考文献**
- Liu, Y., Ji, T., Sun, C., Wu, Y., & Wang, X. (2024). [Generation with Dynamic Vocabulary](https://arxiv.org/abs/2410.08481). *arXiv preprint arXiv:2410.08481*.

## 50,000 トークンの限界と課題

とはいえ、50,000 トークンには以下のような限界もあります：

1. **未知トークンの発生**：  
   - 珍しい漢字や専門用語はトークン化されない場合があります。

2. **多言語間のトレードオフ**：  
   - トークン数には偏りがあるため、一部の言語では効率が悪化する可能性があります。
  
とはいえこの先地球上の言語表現がいきなり増えることはないでしょうから、とりあえずはこれで何とかなっていくのかもしれません。

## 実際に Tokenizer で BPE ベースの SentencePiece を動かしてみる
ここでは、実際に `transformers` ライブラリを使って、Tokenizer を試してみましょう。使用するのは、`rinna/japanese-gpt-1b` という日本語向けの小型 GPT モデルで BPE ベースの SentencePiece を使用しています。
以下、私の Colab 環境で実行してみたコードです。

### コード
```python
from transformers import AutoTokenizer
import os

# モデル情報
MODEL_NAME = "rinna/japanese-gpt-1b"
LOCAL_PATH = "./models/japanese-gpt-1b"

def create_tokenizer(model_name, local_path):
    # モデルが未ダウンロードの場合のみダウンロード
    if not os.path.exists(local_path):
        tokenizer = AutoTokenizer.from_pretrained(
            model_name
        )
        tokenizer.save_pretrained(local_path)   
    # ローカルのモデルを読み込む
    return AutoTokenizer.from_pretrained(
        local_path
    )

# Tokenizer の作成
tokenizer = create_tokenizer(MODEL_NAME, LOCAL_PATH)

# テスト用文字列
texts = ["桜桃は美味しい", "蜜柑は美味しい","焼肉は美味しい"]

print("分割結果:")
for text in texts:
    tokens = tokenizer.tokenize(text)
    print(f"{text}: {tokens}")
```
### 実行結果の例

上記のコードを実行すると、以下のようなトークン化結果が得られます（環境やバージョンによって異なる場合があります）：

```plaintext
分割結果:
桜桃は美味しい: ['▁桜', '桃', 'は', '美味しい']
蜜柑は美味しい: ['▁', '蜜', '柑', 'は', '美味しい']
焼肉は美味しい: ['▁', '焼肉', 'は', '美味しい']
```

まず、'▁' は Sentenc epiece の仕様で、単語の先頭を示す特殊なマーカーです。
これは日本語のような単語間に空白がない言語を扱う場合には必須となります。
桜桃や蜜柑は登場頻度が低いので分割されていますが、焼肉は連結されてトークン化はされています。

>🐣 私も焼肉大好きです！ 

次に、'▁' の解釈ですが、単体で文頭や単語の開始位置にどれくらい現れるかの頻度によって変わります。おそらく桜は密や焼肉よりも学習データ内での出現頻度が高く、しかも先頭を担うことが多いので '▁' と連結しているというわけです。

>🐣 さすが桜は日本の心ですね

テスト用文字列をいろいろ変えて試して見て下さい。

---
## まとめ

GPT の「たった 50,000 トークン」の背後には、トークン化という高度なアルゴリズムが存在します。このアルゴリズムの工夫により：

- 頻出単語を優先的に 1 トークン化
- 未知語を部分単語や文字単位で処理
- 多言語間でトークンを効率的に共有

が実現されています。また、 LLaMA 2 のように 50,000 以下のトークン数でありながら高い性能を発揮する LLM もあります。

これまでトークン化を担う Tokenizer は、ちょっと地味な役割な印象でしたが、実際にはモデルの性能を支える重要な要素です。今後は感謝しつつ Tokenizer に対する理解をしっかりと深めていこうと思いました。

>🐣 結局 LLM でも社会でも、「縁の下の力持ち」が一番偉い！ということですね