サブワード分割手法の比較 — Unigram・BPE・WordPieceの学習アルゴリズムと性能を徹底比較

「ChatGPTに日本語で話しかけると、英語より料金が高くなる」——この現象の背後には、サブワード分割(subword tokenization)の仕組みが深く関わっています。テキストをどのような単位に分割するかによって、同じ内容でもトークン数が大きく変わり、モデルの学習効率や推論コストに直結します。

現在の大規模言語モデルでは、BPE(Byte Pair Encoding)、WordPiece、Unigram Language Model という3つのサブワード分割手法が主流です。GPT系モデルはBPEを、BERTはWordPieceを、T5やmBARTはUnigramを採用しています。3つとも「サブワードに分割する」という目的は同じですが、学習アルゴリズムの設計思想はまったく異なります。BPEは頻度に基づく貪欲なマージ、WordPieceは尤度を最大化するマージ、Unigramは確率モデルからの語彙削減——それぞれの違いを理解することで、タスクや言語に最適なトークナイザを選択できるようになります。

サブワード分割の比較を理解すると、以下のことが可能になります。

  • 言語モデルの設計判断: どの手法がどのタスク・言語に適しているかを理論的に判断できる
  • 多言語モデルの語彙設計: 日本語・中国語など形態の異なる言語をどう扱うかの指針が得られる
  • カスタムトークナイザの構築: ドメイン特化型モデルのために、根拠をもって手法と語彙サイズを選べる

サブワード分割の全体像: 単語・文字・サブワードの3方式比較

単語分割・文字分割・サブワード分割の3つのアプローチを並べると、それぞれがトレードオフの異なる点に位置することがわかります。単語分割は語彙が膨大になり未知語が頻発し、文字分割はOOVを防げる一方で系列長が爆発します。サブワード分割はその中間を狙い、語彙サイズと系列長の両方を実用的な水準に保ちます。では、「適切なサブワード」をどうやって見つけるかが次の核心です。

本記事の内容

  • なぜサブワード分割が必要か — 単語分割と文字分割の限界
  • BPEの学習アルゴリズムと頻度ベースマージ
  • WordPieceの尤度最大化とPMIに基づくマージ基準
  • Unigramモデルの周辺尤度最大化とEMアルゴリズム
  • 3手法の数学的定式化の統一的な比較
  • SentencePieceフレームワークと多言語対応
  • 語彙サイズがトークン効率に与える影響
  • 日本語・多言語での課題と対策
  • Pythonでの3手法スクラッチ実装と比較実験

前提知識

この記事を読む前に、以下の記事を読んでおくと理解が深まります。

なぜサブワード分割が必要か

自然言語をモデルに入力するとき、最初に決めなければならないのが「テキストをどの粒度で分割するか」です。この判断は一見些細に思えますが、モデルの性能・計算コスト・多言語対応力を根本的に左右します。

単語レベル分割の限界

英語の場合、空白で区切る単語レベルの分割は直感的です。しかし、英語のWikipediaだけでも異なり語数は数百万に達し、すべてを語彙に入れると埋め込み行列 $\bm{E} \in \mathbb{R}^{|\mathcal{V}| \times d}$ のパラメータ数が爆発します。さらに「play」「playing」「played」「plays」がすべて別のトークンとして扱われるため、形態的な類似性を活かせません。

最も深刻なのは未知語(OOV: Out-of-Vocabulary)問題です。訓練コーパスに出現しなかった単語は [UNK] トークンに置き換えられ、情報が完全に失われます。専門用語や固有名詞が頻出するドメインでは、この問題が致命的になります。

文字レベル分割の限界

一方、文字(またはバイト)レベルの分割では語彙サイズは極めて小さく(英語で数十程度)、OOVは原理的に発生しません。しかし、「transformer」が12個のトークンに分割されるため系列長が爆発します。Transformerの自己注意の計算量は系列長 $T$ に対して $O(T^2)$ であるため、計算コストの増大は深刻です。また、個々の文字からは単語の意味を直接捉えることが難しく、モデルがより多くの文脈を必要とします。

サブワードの着想

サブワード分割は、単語レベルと文字レベルの中間を狙うアプローチです。核心的なアイデアは次の通りです。

  • 頻出する語: そのまま1トークンとして保持(例: “the” → the
  • 稀な語: より小さなサブワード単位に分割(例: “tokenization” → token + ization
  • 未知語: 文字レベルまで分解すれば必ず表現できる

この仕組みにより、語彙サイズを $|\mathcal{V}| \approx 30{,}000 \sim 50{,}000$ に抑えつつ、OOVを実質的にゼロにできます。さらに「tokenize」と「tokenization」が共通のサブワード「token」を共有するため、形態的な情報が自然に活用されます。

では、サブワード語彙をどのように構築するのでしょうか。ここからが3つの手法の本質的な違いです。まずは最も直感的なBPEから見ていきましょう。

OOV問題と各手法のフォールバック戦略: 語彙サイズとOOV率の関係

左図からは、語彙サイズを増やすほど単語レベルのOOV率が下がる一方、サブワード分割では語彙サイズに関わらずOOV率が実質ゼロに近いことが読み取れます。右図では「ニューラルネット」という語に対して各手法がどのように分割するか(単語レベル=[UNK]、BPE=文字ペアへ分解、WordPiece=##プレフィックス付き、Unigram+SentencePiece=意味単位で分割)が示されており、手法ごとの未知語への対処の違いが直感的に理解できます。

BPEの学習アルゴリズム

基本的な発想

BPE(Byte Pair Encoding)は、もともと1994年にGageがデータ圧縮のために提案した手法を、Sennrich et al.(2016)がニューラル機械翻訳のサブワード分割に応用したものです。

BPEの発想はきわめてシンプルです。「コーパス中で最も頻繁に隣り合う2つの記号を、1つの新しい記号に置き換える」——これを繰り返すだけで、頻出パターンが自然にサブワードとして抽出されます。データ圧縮における「よく出るパターンを短い記号で表す」という原理を、語彙構築に転用したわけです。

アルゴリズムの詳細

BPEの学習手順を形式的に記述します。

初期化: コーパスの各単語を文字の列に分解し、語末マーカー </w> を付加します。初期語彙 $\mathcal{V}_0$ は全ての個別文字と </w> の集合です。

反復マージ($K$ 回繰り返し):

ステップ1: コーパス中の全てのトークン列において、隣接する2つのトークンのペア $(a, b)$ の出現頻度を数え上げます。

ステップ2: 最も頻出するペアを選択します。

$$ (a^*, b^*) = \arg\max_{(a,b)} \operatorname{count}(a, b) $$

ここで $\operatorname{count}(a, b)$ は、コーパス全体でトークン $a$ の直後にトークン $b$ が出現する回数です。

ステップ3: コーパス中の全ての $(a^*, b^*)$ の出現を、新しいトークン $a^*b^*$(2つを連結したもの)に置換します。

ステップ4: $a^*b^*$ を語彙 $\mathcal{V}$ に追加し、マージルール $(a^*, b^*) \to a^*b^*$ を記録します。

最終的な語彙サイズは $|\mathcal{V}_0| + K$ となります。 $K$ がハイパーパラメータであり、語彙サイズを直接制御できるのがBPEの利点の一つです。

BPEの数学的性質

BPEは確率モデルを持たない決定的な手法です。マージの選択基準は純粋に頻度の大小であり、尤度や確率分布の概念を使いません。このシンプルさゆえに実装が容易で、学習も高速です。

一方で、BPEの弱点は貪欲(greedy)な手法であることです。各ステップで局所的に最適なペアを選ぶため、大域的に最適な語彙が得られる保証はありません。たとえば、ステップ $k$ でのマージが将来のステップに与える影響を考慮しないため、最終的な語彙の品質がコーパスの統計的偶然に左右されることがあります。

エンコード手順

学習済みのBPEで新しいテキストをトークン化する際は、学習時に記録したマージルールを優先度順(学習順)に適用します。最初に学習されたマージから順番に、テキスト中の該当ペアを結合していきます。このため、エンコード結果は常に一意に定まります。

BPEのマージステップ: 文字ペアを逐次合体する過程

この図は「lowest」(x1)と「lower」(x2)を例に、BPEが初期の文字列から出発して3ステップでどのようにサブワードを形成していくかを追ったものです。Step1では「e」と「r」が最頻ペアのためマージされて「er」になります。Step2では「l」と「o」が結合し「lo」、Step3では「lo」と「w」が結合して「low」が完成します。このように局所的な頻度判断を積み重ねるだけで、意味のある部分文字列(「low」「er」)が自然に語彙に入ってくるのがBPEの直感的な美しさです。

BPEは確率モデルを持たず、決定的にトークン化を行います。しかし、「頻度が最大のペアを選ぶ」という基準は、本当にトークン化として最適なのでしょうか。WordPieceは、この問いに対してより原理的な回答を提示します。

WordPieceの尤度最大化ベースマージ

BPEとの本質的な違い

WordPieceはGoogleが開発した手法で、BERTのトークナイザとして広く知られています。アルゴリズムの全体的な枠組み——文字から出発して逐次マージしていく——はBPEと同じですが、マージするペアの選択基準が根本的に異なります。

BPEが「最も頻繁に出現するペアを選ぶ」のに対し、WordPieceは「マージすることで言語モデルの尤度が最も増加するペアを選ぶ」のです。この違いは一見小さく見えますが、結果として構築される語彙の性質を大きく変えます。

マージ基準の導出

WordPieceのマージ基準を数学的に導出しましょう。テキストコーパス $\mathcal{D}$ に対する unigram 対数尤度を次のように定義します。

$$ \mathcal{L} = \sum_{w \in \mathcal{D}} \log P(w) $$

ここで $P(w)$ はトークン $w$ の unigram 確率です。

$$ P(w) = \frac{\operatorname{count}(w)}{\sum_{w’} \operatorname{count}(w’)} $$

いま、隣接するトークン $a$ と $b$ をマージして新しいトークン $ab$ を作ることを考えます。マージ前の対数尤度への寄与($a$ と $b$ が隣接している箇所のみ)は次の通りです。

$$ \mathcal{L}_{\text{before}} = \operatorname{count}(ab) \cdot \log P(a) + \operatorname{count}(ab) \cdot \log P(b) $$

$\operatorname{count}(ab)$ はトークン $a$ の直後にトークン $b$ が出現する回数を表します。マージ後は、これらの隣接出現がすべて $ab$ に置換されるため、寄与は次のように変わります。

$$ \mathcal{L}_{\text{after}} = \operatorname{count}(ab) \cdot \log P(ab) $$

隣接していない文脈での $a$, $b$ の出現は変化しないとする近似のもとで、尤度の変化量 $\Delta\mathcal{L}$ を計算します。

$$ \Delta\mathcal{L} = \mathcal{L}_{\text{after}} – \mathcal{L}_{\text{before}} $$

展開すると次のようになります。

$$ \Delta\mathcal{L} \approx \operatorname{count}(ab) \cdot \left[\log P(ab) – \log P(a) – \log P(b)\right] $$

対数の性質を使って整理すると、1件あたりの尤度増加量は次の式で表されます。

$$ \frac{\Delta\mathcal{L}}{\operatorname{count}(ab)} = \log \frac{P(ab)}{P(a) \cdot P(b)} $$

これは自己相互情報量(Pointwise Mutual Information, PMI)と同じ形です。WordPieceのマージ基準は次のように表されます。

$$ (a^*, b^*) = \arg\max_{(a,b)} \log \frac{P(ab)}{P(a) \cdot P(b)} $$

PMIの直感的意味

PMI の値 $\log \frac{P(ab)}{P(a) \cdot P(b)}$ が何を意味するのか、直感的に考えてみましょう。

  • $P(ab) > P(a) \cdot P(b)$ のとき(PMI > 0): $a$ と $b$ の共起は偶然の出現頻度から期待される以上に多い。つまり $a$ と $b$ は「意味のあるまとまり」を形成している可能性が高い
  • $P(ab) = P(a) \cdot P(b)$ のとき(PMI = 0): $a$ と $b$ の共起は統計的に独立。偶然の並びに過ぎない
  • $P(ab) < P(a) \cdot P(b)$ のとき(PMI < 0): $a$ と $b$ は独立な場合より共起しにくい

BPEは単純に $\operatorname{count}(a, b)$ を見るため、$a$ と $b$ がともに高頻度であれば偶然の共起であってもマージされやすくなります。一方、WordPieceのPMI基準は偶然を超えた共起を検出するため、より意味のあるサブワードを構築できます。

たとえば、英語のコーパスで “t” と “h” はそれぞれ非常に高頻度の文字です。BPEでは “th” が早期にマージされますが、これは “th” が英語の頻出文字列だからです。WordPieceでも同様にマージされますが、その根拠は「”t” と “h” の共起が個別の出現頻度から期待される以上に多い」という統計的な意味づけを持ちます。

エンコード手順

WordPieceのエンコードは、BPEのマージルール適用とは異なり、最長一致法(greedy longest-match-first)を使います。入力単語に対して、語彙中の最長のサブワードから順にマッチングを試み、先頭から貪欲に切り出します。単語の先頭以外の位置から始まるサブワードには ## プレフィックスが付加されます。

例: “unaffable” → un, ##aff, ##able

BPE vs WordPiece: 頻度ベースとPMIベースのマージ基準の違い

左のBPEグラフでは「e+s」「t+h」のような高頻度ペアが上位に来ており、絶対頻度の大小がそのままマージ順に反映されます。一方、右のWordPieceグラフでは「pl+ay」「i+on」のように、個別には高頻度でなくても共起が統計的に有意なペアがPMIスコアで浮き上がってきます。BPEが「よく出るペア」を優先するのに対して、WordPieceは「偶然では説明できないほど一緒に出るペア」を優先するという設計思想の違いが、このグラフから直接読み取れます。

BPEとWordPieceはどちらも「小さいものを結合して大きくする」ボトムアップのアプローチでした。これに対して、Unigramモデルはまったく逆の発想——「大きな語彙から不要なものを削る」トップダウンのアプローチをとります。

Unigramモデルの周辺尤度最大化

逆方向の発想

Kudo(2018)が提案したUnigram Language Modelは、BPEやWordPieceとは根本的に異なる設計思想を持っています。BPEとWordPieceが「文字から出発してマージで語彙を拡大する」のに対し、Unigramは「大きな初期語彙から出発して不要なトークンを削減する」のです。

この逆方向のアプローチには重要な利点があります。Unigramモデルは明示的な確率モデルを持ち、各サブワードに確率が割り当てられます。このため、テキストに対する分割の「良さ」を定量的に評価でき、さらに同じテキストに対して複数の分割候補を確率的にサンプリングすることも可能です。

確率モデルの定式化

テキスト $X = (x_1, x_2, \dots, x_N)$ に対して、サブワード分割 $\bm{s} = (s_1, s_2, \dots, s_M)$ を考えます。ここで $s_1 s_2 \cdots s_M = X$(連結すると元のテキストになる)という制約があります。

Unigramモデルでは、各サブワードが独立に出現すると仮定し、分割 $\bm{s}$ の確率を次のように定義します。

$$ P(\bm{s}) = \prod_{i=1}^{M} p(s_i) $$

ここで $p(s_i)$ はサブワード $s_i$ のunigram確率で、$\sum_{s \in \mathcal{V}} p(s) = 1$ を満たします。

この仮定は「サブワードの出現が文脈に依存しない」ことを意味します。たとえば「un」の後に「able」が来やすいという情報は無視します。一見粗い近似ですが、語彙構築の段階ではこの単純さが計算効率と頑健性の面で有利に働きます。

テキスト $X$ に対する最適な分割は、全ての有効な分割の中で確率が最大のものです。

$$ \bm{s}^* = \arg\max_{\bm{s} \in \mathcal{S}(X)} P(\bm{s}) = \arg\max_{\bm{s} \in \mathcal{S}(X)} \sum_{i=1}^{M} \log p(s_i) $$

ここで $\mathcal{S}(X)$ はテキスト $X$ の全ての有効な分割の集合です。テキストの長さが $n$ のとき、分割の候補は指数的に多く存在しますが、動的計画法(Viterbiアルゴリズム)を使えば $O(n^2)$ で最適分割を求めることができます。

周辺尤度の定義

Unigramモデルの学習では、コーパス全体 $\mathcal{D} = \{X_1, X_2, \dots, X_D\}$ に対する周辺尤度を最大化します。周辺尤度は、各テキストの全分割にわたる確率の和として定義されます。

$$ \mathcal{L} = \sum_{d=1}^{D} \log P(X_d) = \sum_{d=1}^{D} \log \left( \sum_{\bm{s} \in \mathcal{S}(X_d)} P(\bm{s}) \right) $$

内側の和を展開すると次のようになります。

$$ \mathcal{L} = \sum_{d=1}^{D} \log \left( \sum_{\bm{s} \in \mathcal{S}(X_d)} \prod_{i=1}^{|\bm{s}|} p(s_i) \right) $$

この周辺尤度を直接最大化するのは困難です。log の中に和があるため、解析的な最適解を求めることができません。そこで、EMアルゴリズム(Expectation-Maximization)を使って反復的に最適化を行います。

EMアルゴリズムによる学習

EMアルゴリズムは、不完全データ(ここではどの分割が「正解」かが不明)のもとでパラメータを推定するための汎用的な手法です。E(期待)ステップとM(最大化)ステップを交互に繰り返します。

Eステップ: 現在のパラメータ $\{p^{(t)}(s)\}$ のもとで、各分割の事後確率を計算します。テキスト $X_d$ に対する分割 $\bm{s}$ の事後確率は次の式で与えられます。

$$ q(\bm{s} \mid X_d) = \frac{P(\bm{s})}{\sum_{\bm{s}’ \in \mathcal{S}(X_d)} P(\bm{s}’)} = \frac{\prod_{i} p^{(t)}(s_i)}{\sum_{\bm{s}’} \prod_{j} p^{(t)}(s’_j)} $$

この事後確率を用いて、各サブワード $s$ の期待出現回数を計算します。

$$ c(s) = \sum_{d=1}^{D} \sum_{\bm{s} \in \mathcal{S}(X_d)} q(\bm{s} \mid X_d) \cdot \operatorname{count}(s \text{ in } \bm{s}) $$

この式の意味を噛み砕いて説明すると、全テキスト $X_d$ について、全ての可能な分割 $\bm{s}$ を事後確率で重みづけし、各分割中でサブワード $s$ が何回出現するかを足し上げるということです。

Mステップ: 期待出現回数を用いてパラメータを更新します。更新式は単純な正規化です。

$$ p^{(t+1)}(s) = \frac{c(s)}{\sum_{s’ \in \mathcal{V}} c(s’)} $$

この更新により、期待出現回数が多いサブワードほど高い確率を持つようになります。

Viterbi近似

実際の実装では、全ての分割を列挙して期待出現回数を正確に計算するのは計算量的に困難です。テキストの長さが $n$ のとき、有効な分割の数は最大で $2^{n-1}$ 個にもなります。

そこで実用的にはViterbi近似を用います。これは、Eステップで全分割の事後確率を使う代わりに、最も確率の高い分割(Viterbi分割)のみを使う近似です。

テキスト $X = x_1 x_2 \cdots x_n$ に対して、位置 $j$ までの最適分割の対数確率を $\operatorname{dp}[j]$ と定義すると、漸化式は次のようになります。

$$ \operatorname{dp}[j] = \max_{i < j, \, x_{i+1} \cdots x_j \in \mathcal{V}} \left( \operatorname{dp}[i] + \log p(x_{i+1} \cdots x_j) \right) $$

初期条件は $\operatorname{dp}[0] = 0$ です。この漸化式は「位置 $i$ までの最適分割に、語彙中のサブワード $x_{i+1} \cdots x_j$ を追加する」ことに対応します。$\operatorname{dp}[n]$ が最適分割の対数確率を与え、バックトレースにより最適な分割を復元できます。

語彙の削減プロセス

EMアルゴリズムでサブワード確率を推定した後、語彙を目標サイズまで縮小します。各トークン $s$ を語彙から除いた場合の尤度の減少量を計算します。

$$ \operatorname{loss}(s) = \mathcal{L} – \mathcal{L}_{-s} $$

$\operatorname{loss}(s)$ が小さいトークン(除いても尤度がほとんど下がらないトークン)から順に削除していきます。一般的には、現在の語彙の一定割合(たとえば10%〜20%)を一度に削除し、残った語彙で再度EMアルゴリズムを実行するという手順を繰り返します。

ただし、個別の文字は必ず保持します。これにより、どんな入力テキストでも文字レベルまで分解すれば必ず表現できるため、OOVが完全に排除されます。

Unigramモデル: サブワード確率分布とViterbi最適分割トレリス

左図ではサブワードに確率が割り当てられており、意味のあるまとまり(「play」「un」「able」)が高い確率を持つことが確認できます。右図のトレリスは「unplayable」に対するViterbi探索の様子を示しており、緑の経路(un → play → able)が最大対数確率として選ばれる最適分割です。灰色の「unplay」や「playable」も有効な分割候補ですが、サブワード確率の積が小さいため排除されます。

EMアルゴリズムの対数尤度収束と語彙削減サイクル

左図はEMアルゴリズムの各反復における対数尤度の推移で、語彙削減が行われるステップ(赤破線)で一時的な低下が見られますが、その後EM反復で回復していく様子が確認できます。右図は語彙削減のサイクルを示しており、EMステップ(緑)と削減ステップ(黄)が交互に繰り返されることで語彙が5000から2000へと段階的に絞り込まれていきます。この「EM → 削減 → EM」のサイクルがUnigramモデルの語彙構築の本質です。

ここまで3つの手法を個別に見てきました。次は、これらを統一的な視点から数学的に比較し、設計思想の違いを明確にしましょう。

3手法の数学的比較

マージ基準の統一的な整理

3つの手法のマージ(またはスコアリング)基準を並べると、その設計思想の違いが鮮明になります。

BPE — 頻度最大化:

$$ \operatorname{score}_{\text{BPE}}(a, b) = \operatorname{count}(a, b) $$

最も単純な基準です。共起の絶対頻度のみを見るため、高頻度のトークン同士のペアが優先されます。

WordPiece — PMI最大化:

$$ \operatorname{score}_{\text{WP}}(a, b) = \log \frac{P(ab)}{P(a) \cdot P(b)} $$

偶然の共起を超えた「意味のあるまとまり」を検出します。$P(a)$ と $P(b)$ で割ることにより、個別頻度の影響を正規化しています。

Unigram — 尤度減少量最小化:

$$ \operatorname{score}_{\text{Uni}}(s) = \mathcal{L} – \mathcal{L}_{-s} $$

トークン $s$ の「不可欠さ」を測ります。語彙から除いても尤度がほとんど下がらないトークンが低スコアとなり、削除対象になります。

構築方向の違い

この3つの手法は、語彙を構築する方向が異なります。

特性 BPE WordPiece Unigram
構築方向 ボトムアップ(マージ) ボトムアップ(マージ) トップダウン(削減)
初期語彙 全文字集合 全文字集合 全サブストリング
選択基準 頻度最大 PMI最大 尤度減少最小
確率モデル なし(決定的) 暗黙的(マージ基準にのみ使用) 明示的(サブワード確率)
エンコード マージルール適用(一意) 最長一致法(一意) Viterbi分割(確率的サンプリング可能)
計算量(学習) $O(K \cdot N)$ $O(K \cdot N)$ $O(I \cdot D \cdot n^2)$

ここで $K$ はマージ回数、$N$ はコーパスのトークン総数、$I$ はEM反復回数、$D$ はテキスト数、$n$ は平均テキスト長です。

確率モデルの有無がもたらす差

BPEは確率モデルを持たないため、与えられたテキストに対する分割は常に一意です。同じマージルールを同じ順番で適用するだけなので、分割に曖昧性はありません。

WordPieceもエンコード時は最長一致法で一意に分割しますが、マージの選択基準にはunigramモデルの確率が暗黙的に使われています。つまり、語彙構築の段階では確率的な判断を行いますが、エンコードは決定的です。

Unigramモデルは明示的な確率モデルを持つため、テキストに対する分割確率 $P(\bm{s})$ を計算でき、さらに確率に基づいて複数の分割をサンプリングすることができます。この性質は、モデルの正則化に利用できます。学習時にランダムな分割を使うことで、モデルがトークン化のゆらぎに頑健になるのです(subword regularization)。

この確率的サンプリング能力は、Unigramモデルの最大の強みの一つです。BPEやWordPieceでは同じテキストが常に同じトークン列になりますが、Unigramでは異なるトークン列が得られるため、データ拡張の効果が期待できます。

3手法の設計思想の比較: 構築方向・マージ基準・確率モデル

この図は3手法の設計思想を一目で対比できるように整理したものです。BPEとWordPieceはともにボトムアップで文字からマージを繰り返しますが、マージ基準が「絶対頻度」か「PMI」かで異なります。Unigramはトップダウンで大きな語彙から削減するという逆方向のアプローチであり、明示的な確率モデルを持つため確率的なサンプリング(subword regularization)が可能という独自の強みを持ちます。採用モデルの欄からも、各手法が異なるモデルファミリーで選ばれていることが確認できます。

では、これらの手法を統合的に利用できるフレームワークはないのでしょうか。SentencePieceがまさにその役割を果たします。

SentencePieceと多言語対応

SentencePieceの設計思想

SentencePiece(Kudo & Richardson, 2018)は、BPEやUnigramを言語非依存に実装するためのフレームワークです。従来のトークナイザは英語を前提として設計されており、「まず空白で単語に分割してから、各単語をサブワードに分割する」という二段階の処理を行います。しかし、日本語や中国語のように空白で単語が区切られない言語では、この前提が成り立ちません。

SentencePieceは、この問題を根本的に解決します。テキストをUnicode文字列としてそのまま受け取り、空白も含めてすべてを等しく扱います。空白を特殊記号 (U+2581, LOWER ONE EIGHTH BLOCK)に置換することで、空白の位置情報を保持しつつ、デコード時に元のテキストを完全に復元できます(可逆変換)。

SentencePieceの主要な特徴

生テキスト入力: 事前のトークナイゼーション(MeCabなどの形態素解析)を必要としません。どの言語のテキストでも、前処理なしにそのまま入力できます。

Unicode正規化: NFKC(Normalization Form KC)正規化により、全角・半角の統一や異体字の正規化を自動的に行います。これにより「AI」と「AI」が同じトークンとして扱われます。

アルゴリズムの選択: バックエンドのサブワード分割アルゴリズムとして、BPEまたはUnigramのいずれかを選択できます。WordPieceは直接サポートされていませんが、これはWordPieceがGoogle内部のアルゴリズムとして実装の公開が限定的だったためです。

バイトフォールバック: 語彙に含まれない文字に対して、UTF-8のバイト列にフォールバックする機能を持ちます。これにより、OOVを完全に排除できます。絵文字や特殊記号など、どんな文字でも必ず表現可能です。

主要モデルでの採用状況

モデル トークナイザ アルゴリズム 語彙サイズ
GPT-2 Byte-level BPE BPE 50,257
GPT-4 cl100k_base BPE 100,256
BERT WordPiece WordPiece 30,522
T5 SentencePiece Unigram 32,000
LLaMA SentencePiece BPE 32,000
mBART SentencePiece Unigram 250,000
XLM-RoBERTa SentencePiece Unigram 250,002

多言語モデル(mBART、XLM-RoBERTa)では語彙サイズが25万程度と大きいのが特徴的です。これは多数の言語をカバーするために必要な語彙が増えるためですが、語彙サイズの増大は埋め込み行列のパラメータ数の増加を意味します。

語彙サイズの選択は、モデルの性能とコストのトレードオフに直接影響します。次に、このトレードオフを定量的に分析しましょう。

主要言語モデルの語彙サイズとトークナイゼーションアルゴリズム

この棒グラフから、英語中心のモデル(GPT-2: 5万、BERT: 3万)と多言語対応モデル(mBART: 25万、XLM-RoBERTa: 25万)の語彙サイズに大きな差があることが見て取れます。多言語モデルが大きな語彙を必要とする理由は、各言語の頻出サブワードを語彙に収めるためです。また、GPT-4がGPT-2の2倍(10万)の語彙を持つことで、日本語や中国語のトークン効率が大幅に改善されています。

語彙サイズの影響

トークン効率と語彙サイズのトレードオフ

語彙サイズ $|\mathcal{V}|$ を大きくすると、1つの単語がより少ないトークンで表現できるため、系列長が短くなり推論が高速化します。一方で、埋め込み行列 $\bm{E} \in \mathbb{R}^{|\mathcal{V}| \times d}$ のパラメータ数が増え、低頻度トークンの埋め込みが十分に学習されないという問題が生じます。

この関係を定量的に考えてみましょう。語彙サイズ $|\mathcal{V}|$ のとき、テキスト全体の平均トークン数を $\bar{T}(|\mathcal{V}|)$ とします。語彙サイズを増やすと $\bar{T}$ は減少しますが、減少の速度は逓減します。これは、語彙を増やしても追加されるトークンは低頻度のものが多く、テキスト全体のトークン数への寄与は小さいためです。

一般に、自然言語の単語頻度はZipfの法則に従います。ランク $r$ の単語の出現確率は $P(r) \propto r^{-\alpha}$($\alpha \approx 1$)で近似できます。この法則のもとでは、語彙サイズを $|\mathcal{V}|$ から $2|\mathcal{V}|$ に倍増しても、カバレッジの改善はわずかです。

圧縮率という指標

語彙の効率を測る指標として、圧縮率(compression ratio)がよく使われます。

$$ \text{圧縮率} = \frac{\text{元のテキストの文字数}}{\text{トークン化後のトークン数}} $$

圧縮率が高いほど、1トークンあたりの情報量が多く、効率的なトークン化が行われていることを意味します。英語のBPE(語彙サイズ50k)では圧縮率が約4(1トークンあたり約4文字)ですが、日本語では圧縮率が約1.5〜2(1トークンあたり1.5〜2文字)程度にとどまることが多く、同じ内容でも日本語の方がトークン数が多くなります。

この圧縮率の差が、冒頭で述べた「日本語だとAPI料金が高くなる」現象の原因です。語彙の多くが英語由来のサブワードで占められているモデルでは、日本語テキストは細かく分割されてしまうのです。

語彙サイズとトークン効率のトレードオフ: Zipfの法則と3手法の比較

左図はZipfの法則に基づくカバレッジのシミュレーションで、語彙サイズを増やすほどカバレッジが向上しますが、5000語を超えると改善が急激に鈍化することが読み取れます。これが「語彙サイズの効果は逓減する」という現象を定量的に示しています。右図では3手法それぞれについてマージ回数(語彙サイズパラメータ)を増やすとトークン数が減少していく様子が確認でき、同じパラメータ水準でUnigramが最も少ないトークン数(高い効率)を示す傾向があります。

SentencePieceの言語非依存設計と日英の圧縮率比較

左図ではSentencePieceが空白を「▁」という特殊記号に置き換えることで日本語テキストを前処理なしに扱い、かつ完全可逆変換を実現するしくみが示されています。右図の言語別圧縮率では、英語の4.0に対して日本語が1.8と約45%の効率しか得られないことが視覚的に明確です。アラビア語ではさらに低い1.5となっており、英語中心語彙のモデルを非ラテン文字圏の言語に適用するコスト上の不利が浮き彫りになります。

語彙サイズの選択がどの程度影響するかは、後ほどPythonの比較実験で定量的に確認します。まずはその前に、日本語特有の課題をもう少し深く掘り下げましょう。

日本語・多言語での課題

空白区切りがない言語

英語やドイツ語では単語が空白で区切られていますが、日本語・中国語・タイ語などでは空白区切りがありません。「今日は天気がいい」という文を空白で分割しようとしても、1つの長い文字列がそのまま残ります。

従来のBPEやWordPieceは「まず空白で単語に分割し、各単語をサブワードに分割する」という前提で設計されています。空白区切りがない言語では、この前提が崩れるため、以下のような問題が発生します。

  • 語彙の不均衡: 英語中心のコーパスで学習した語彙では、日本語の漢字やひらがなが1文字ずつに分割されやすい
  • トークン効率の低下: 「東京大学」が「東」「京」「大」「学」の4トークンに分割されると、意味のある単位が保持されない
  • 文脈理解の負担増: 個々の文字からは意味が読み取りにくいため、モデルがより長い文脈を処理する必要がある

文字体系の多様性

日本語は特に困難な言語の一つです。ひらがな、カタカナ、漢字、アルファベット、数字が混在するため、文字種だけでも数千に及びます。漢字は単体でも意味を持つ表語文字であり、英語のアルファベットとは根本的に性質が異なります。

たとえば「機械学習」をBPEでトークン化する場合、学習コーパスに日本語が少なければ「機」「械」「学」「習」の4トークンに分割されます。しかし「機械」と「学習」はそれぞれ意味のあるまとまりであり、2トークンに分割する方が意味的に適切です。この問題は語彙サイズを大きくするか、日本語コーパスの比率を上げることである程度緩和できますが、完全な解決は困難です。

対策とアプローチ

SentencePieceの活用: 前述の通り、SentencePieceは空白に依存しないため、日本語でも前処理なしにサブワード分割が可能です。T5やmBARTはSentencePiece(Unigramモデル)を採用しており、多言語でのトークン効率が比較的良好です。

語彙サイズの拡大: GPT-4のcl100k_base(語彙サイズ約100k)は、GPT-2(50k)と比較して多言語でのトークン効率が大幅に改善されています。語彙サイズを大きくすることで、日本語の頻出語をそのまま語彙に含める余地が生まれます。

多言語バランスの調整: 学習コーパスにおける各言語の比率を調整することで、特定の言語に偏った語彙が構築されるのを防ぎます。XLM-RoBERTaでは、低資源言語の比率を指数的にブーストする手法(温度付きサンプリング)が採用されています。

ここまでの理論的な議論を踏まえて、実際にPythonで3手法を実装し、同じコーパスでの挙動の違いを定量的に比較してみましょう。

Pythonでの3手法スクラッチ実装

共通のコーパスとユーティリティ

まず、比較実験のための共通コーパスとユーティリティを準備します。英語の短いコーパスを用いて、3手法の挙動の違いを直接観察します。

import numpy as np
import matplotlib.pyplot as plt
from collections import Counter, defaultdict

# 比較用の共通コーパス
corpus = [
    "the cat sat on the mat",
    "the cat ate the fish",
    "the dog sat on the mat",
    "the dog chased the cat",
    "a new cat sat on a new mat",
    "the newest cat chased the oldest dog",
    "lower the newest rating",
    "the lower rating is newer",
    "playing with the newest toy",
    "the newest player is playing",
    "unplayable games are lower rated",
    "the newest unplayable game",
]

# 単語頻度の集計(末尾マーカー付き)
def get_word_freqs(corpus):
    word_freqs = Counter()
    for sentence in corpus:
        for word in sentence.strip().split():
            word_freqs[word] += 1
    return word_freqs

word_freqs = get_word_freqs(corpus)
print("=== コーパスの単語頻度(上位15件) ===")
for word, freq in word_freqs.most_common(15):
    print(f"  {word}: {freq}")
print(f"  異なり語数: {len(word_freqs)}")
print(f"  総単語数: {sum(word_freqs.values())}")

このコーパスは小規模ですが、「newest」「newer」「playing」「unplayable」など形態的な変化を含む語を意図的に入れています。これにより、各手法がどのようなサブワードを抽出するかの違いが見やすくなります。

BPEの実装

class BPETokenizer:
    """BPEトークナイザ"""

    def __init__(self, num_merges=30):
        self.num_merges = num_merges
        self.merges = []
        self.vocab = set()
        self.merge_history = []  # マージ履歴を記録

    def _get_split_words(self, corpus):
        """コーパスを文字に分解(語末マーカー付き)"""
        word_freqs = Counter()
        for sentence in corpus:
            for word in sentence.strip().split():
                word_freqs[word] += 1
        split_words = {}
        for word, freq in word_freqs.items():
            chars = tuple(list(word) + ["</w>"])
            split_words[chars] = freq
        return split_words

    def _get_pair_freqs(self, split_words):
        """隣接ペアの頻度を計算"""
        pair_freqs = Counter()
        for chars, freq in split_words.items():
            for i in range(len(chars) - 1):
                pair_freqs[(chars[i], chars[i+1])] += freq
        return pair_freqs

    def _merge_pair(self, pair, split_words):
        """指定ペアをマージ"""
        new_split = {}
        merged = pair[0] + pair[1]
        for chars, freq in split_words.items():
            new_chars = []
            i = 0
            while i < len(chars):
                if (i < len(chars) - 1
                    and chars[i] == pair[0]
                    and chars[i+1] == pair[1]):
                    new_chars.append(merged)
                    i += 2
                else:
                    new_chars.append(chars[i])
                    i += 1
            new_split[tuple(new_chars)] = freq
        return new_split

    def train(self, corpus):
        """BPEを学習"""
        split_words = self._get_split_words(corpus)
        self.vocab = set()
        for chars in split_words:
            for c in chars:
                self.vocab.add(c)

        for step in range(self.num_merges):
            pair_freqs = self._get_pair_freqs(split_words)
            if not pair_freqs:
                break
            best_pair = max(pair_freqs, key=pair_freqs.get)
            best_freq = pair_freqs[best_pair]
            split_words = self._merge_pair(best_pair, split_words)
            merged = best_pair[0] + best_pair[1]
            self.merges.append(best_pair)
            self.vocab.add(merged)
            self.merge_history.append({
                'step': step + 1,
                'pair': best_pair,
                'merged': merged,
                'freq': best_freq
            })

    def encode(self, word):
        """単語をBPEでエンコード"""
        tokens = list(word) + ["</w>"]
        for pair in self.merges:
            i = 0
            while i < len(tokens) - 1:
                if tokens[i] == pair[0] and tokens[i+1] == pair[1]:
                    tokens = tokens[:i] + [pair[0]+pair[1]] + tokens[i+2:]
                else:
                    i += 1
        return tokens

WordPieceの実装

class WordPieceTokenizer:
    """WordPieceトークナイザ(PMIベース)"""

    def __init__(self, num_merges=30):
        self.num_merges = num_merges
        self.vocab = set()
        self.merges = []
        self.merge_history = []

    def _get_split_words(self, corpus):
        """コーパスを文字に分解"""
        word_freqs = Counter()
        for sentence in corpus:
            for word in sentence.strip().split():
                word_freqs[word] += 1
        split_words = {}
        for word, freq in word_freqs.items():
            chars = tuple(list(word) + ["</w>"])
            split_words[chars] = freq
        return split_words

    def _get_pair_scores(self, split_words):
        """PMIベースのスコアを計算"""
        pair_freqs = Counter()
        token_freqs = Counter()

        for chars, freq in split_words.items():
            for i in range(len(chars)):
                token_freqs[chars[i]] += freq
                if i < len(chars) - 1:
                    pair_freqs[(chars[i], chars[i+1])] += freq

        # PMIスコア: count(ab) / (count(a) * count(b))
        pair_scores = {}
        for pair, freq in pair_freqs.items():
            a, b = pair
            score = freq / (token_freqs[a] * token_freqs[b])
            pair_scores[pair] = score
        return pair_scores

    def _merge_pair(self, pair, split_words):
        """指定ペアをマージ"""
        new_split = {}
        merged = pair[0] + pair[1]
        for chars, freq in split_words.items():
            new_chars = []
            i = 0
            while i < len(chars):
                if (i < len(chars) - 1
                    and chars[i] == pair[0]
                    and chars[i+1] == pair[1]):
                    new_chars.append(merged)
                    i += 2
                else:
                    new_chars.append(chars[i])
                    i += 1
            new_split[tuple(new_chars)] = freq
        return new_split

    def train(self, corpus):
        """WordPieceを学習"""
        split_words = self._get_split_words(corpus)
        self.vocab = set()
        for chars in split_words:
            for c in chars:
                self.vocab.add(c)

        for step in range(self.num_merges):
            pair_scores = self._get_pair_scores(split_words)
            if not pair_scores:
                break
            best_pair = max(pair_scores, key=pair_scores.get)
            best_score = pair_scores[best_pair]
            split_words = self._merge_pair(best_pair, split_words)
            merged = best_pair[0] + best_pair[1]
            self.merges.append(best_pair)
            self.vocab.add(merged)
            self.merge_history.append({
                'step': step + 1,
                'pair': best_pair,
                'merged': merged,
                'score': best_score
            })

    def encode(self, word):
        """最長一致法でエンコード"""
        tokens = []
        chars = word + "</w>"
        start = 0
        while start < len(chars):
            end = len(chars)
            found = False
            while start < end:
                substr = chars[start:end]
                if substr in self.vocab:
                    tokens.append(substr)
                    found = True
                    break
                end -= 1
            if not found:
                tokens.append(chars[start])
                start += 1
            else:
                start = end
        return tokens

Unigramモデルの実装

class UnigramTokenizer:
    """Unigramトークナイザ(EM + Viterbi)"""

    def __init__(self, target_vocab_size=50, max_piece_length=8):
        self.target_vocab_size = target_vocab_size
        self.max_piece_length = max_piece_length
        self.vocab = {}  # token -> log_prob
        self.char_set = set()

    def _initialize_vocab(self, corpus):
        """初期語彙を構築(全サブストリング)"""
        substring_freqs = Counter()
        char_set = set()

        for sentence in corpus:
            for word in sentence.strip().split():
                word_with_end = word + "</w>"
                for i in range(len(word_with_end)):
                    for j in range(i+1,
                                   min(i+self.max_piece_length+1,
                                       len(word_with_end)+1)):
                        substring_freqs[word_with_end[i:j]] += 1
                for c in word_with_end:
                    char_set.add(c)

        total = sum(substring_freqs.values())
        self.vocab = {}
        for substr, freq in substring_freqs.most_common(
                self.target_vocab_size * 5):
            self.vocab[substr] = np.log(freq / total)

        for c in char_set:
            if c not in self.vocab:
                self.vocab[c] = np.log(1e-10)
        self.char_set = char_set

    def _viterbi_decode(self, text):
        """Viterbiアルゴリズムで最適分割"""
        n = len(text)
        dp = [-float('inf')] * (n + 1)
        dp[0] = 0.0
        backpointer = [0] * (n + 1)

        for j in range(1, n + 1):
            for i in range(max(0, j - self.max_piece_length), j):
                piece = text[i:j]
                if piece in self.vocab:
                    score = dp[i] + self.vocab[piece]
                    if score > dp[j]:
                        dp[j] = score
                        backpointer[j] = i

        # バックトレース
        tokens = []
        j = n
        while j > 0:
            i = backpointer[j]
            tokens.append(text[i:j])
            j = i
        tokens.reverse()
        return tokens, dp[n]

    def train(self, corpus, n_iterations=10):
        """Unigramモデルを学習"""
        self._initialize_vocab(corpus)

        word_freqs = Counter()
        for sentence in corpus:
            for word in sentence.strip().split():
                word_freqs[word + "</w>"] += 1

        self.log_likelihoods = []

        for iteration in range(n_iterations):
            # Eステップ: Viterbi近似で期待出現回数を計算
            token_counts = Counter()
            total_log_prob = 0.0

            for word, freq in word_freqs.items():
                tokens, log_prob = self._viterbi_decode(word)
                total_log_prob += log_prob * freq
                for token in tokens:
                    token_counts[token] += freq

            self.log_likelihoods.append(total_log_prob)

            # Mステップ: 確率を更新
            total_count = sum(token_counts.values())
            for token in self.vocab:
                if token in token_counts:
                    self.vocab[token] = np.log(
                        token_counts[token] / total_count)
                else:
                    self.vocab[token] = np.log(1e-10)

            # 語彙の削減
            if len(self.vocab) > self.target_vocab_size:
                scored = []
                for token in self.vocab:
                    if len(token) == 1 or token in self.char_set:
                        continue
                    count = token_counts.get(token, 0)
                    scored.append((token, count))

                scored.sort(key=lambda x: x[1])
                n_remove = max(1, int(len(self.vocab) * 0.1))
                n_remove = min(n_remove,
                               len(self.vocab) - self.target_vocab_size)

                for token, _ in scored[:n_remove]:
                    if len(self.vocab) <= self.target_vocab_size:
                        break
                    del self.vocab[token]

    def encode(self, word):
        """Viterbi分割でエンコード"""
        tokens, _ = self._viterbi_decode(word + "</w>")
        return tokens

ここまでで3つのトークナイザのクラスを定義しました。次はこれらを同じコーパスで学習させ、挙動の違いを比較実験で確認します。

学習と比較実験

# 3手法を同じコーパスで学習
bpe = BPETokenizer(num_merges=25)
bpe.train(corpus)

wp = WordPieceTokenizer(num_merges=25)
wp.train(corpus)

unigram = UnigramTokenizer(target_vocab_size=40, max_piece_length=8)
unigram.train(corpus, n_iterations=15)

# テスト単語でエンコード結果を比較
test_words = ["newest", "playing", "unplayable", "lower",
              "chased", "rating", "cat", "the"]

print("=" * 70)
print(f"{'単語':<14} {'BPE':<22} {'WordPiece':<22} {'Unigram':<22}")
print("=" * 70)
for word in test_words:
    bpe_tokens = bpe.encode(word)
    wp_tokens = wp.encode(word)
    uni_tokens = unigram.encode(word)
    # 表示用に </w> を除去して見やすくする
    bpe_str = " + ".join(t.replace("</w>", "_") for t in bpe_tokens)
    wp_str = " + ".join(t.replace("</w>", "_") for t in wp_tokens)
    uni_str = " + ".join(t.replace("</w>", "_") for t in uni_tokens)
    print(f"{word:<14} {bpe_str:<22} {wp_str:<22} {uni_str:<22}")

この比較表から、3つの手法が同じ単語に対して異なる分割を生成することが確認できます。BPEは頻度の高いペアから順にマージするため、コーパス中の頻出パターンを反映した分割になります。WordPieceはPMIに基づくため、偶然の共起ではなく「意味のあるまとまり」が優先されます。Unigramは確率モデルに基づく最適分割のため、コーパス全体の尤度を最大化するような分割になります。

特に注目すべきは「unplayable」のような複合語です。BPEでは頻度ベースで細かく分割されがちですが、Unigramモデルは「un」「play」「able」のような意味のある単位を抽出する傾向があります(コーパスに十分な例がある場合)。

マージ順序の比較可視化

3手法のマージ順序(または語彙構築の過程)を可視化して、アルゴリズムの振る舞いの違いを直接確認しましょう。

fig, axes = plt.subplots(1, 2, figsize=(14, 6))

# BPEのマージ順序
ax = axes[0]
bpe_steps = [h['step'] for h in bpe.merge_history[:15]]
bpe_labels = [f"{h['pair'][0]}+{h['pair'][1]}" for h in bpe.merge_history[:15]]
bpe_freqs = [h['freq'] for h in bpe.merge_history[:15]]
bars = ax.barh(range(len(bpe_steps)), bpe_freqs, color='#4FC3F7', edgecolor='white')
ax.set_yticks(range(len(bpe_steps)))
ax.set_yticklabels(bpe_labels, fontsize=9)
ax.set_xlabel('Pair Frequency', fontsize=11)
ax.set_title('BPE: Merge Order (by Frequency)', fontsize=13)
ax.invert_yaxis()
ax.grid(axis='x', alpha=0.3)

# WordPieceのマージ順序
ax = axes[1]
wp_steps = [h['step'] for h in wp.merge_history[:15]]
wp_labels = [f"{h['pair'][0]}+{h['pair'][1]}" for h in wp.merge_history[:15]]
wp_scores = [h['score'] for h in wp.merge_history[:15]]
bars = ax.barh(range(len(wp_steps)), wp_scores, color='#FF8A65', edgecolor='white')
ax.set_yticks(range(len(wp_steps)))
ax.set_yticklabels(wp_labels, fontsize=9)
ax.set_xlabel('PMI Score', fontsize=11)
ax.set_title('WordPiece: Merge Order (by PMI)', fontsize=13)
ax.invert_yaxis()
ax.grid(axis='x', alpha=0.3)

plt.tight_layout()
plt.show()

このグラフからは、BPEとWordPieceのマージ順序の違いが明確に見て取れます。BPEでは「t」+「h」や「e」+「」のように高頻度文字のペアが早期にマージされます。一方、WordPieceでは高頻度だが統計的に独立なペアは後回しにされ、共起が有意に高いペアが優先されます。たとえば、「e」と「s」はそれぞれ高頻度ですが、「es」の共起がたまたま高いだけであれば、PMIスコアは相対的に低くなります。この違いが最終的な語彙の質に影響を与えます。

トークン数の比較

同じテキストを3手法でトークン化したときの総トークン数を比較し、トークン効率の違いを定量的に評価します。

# 各手法でコーパス全体のトークン数を計算
def count_total_tokens(tokenizer, corpus):
    total = 0
    for sentence in corpus:
        for word in sentence.strip().split():
            tokens = tokenizer.encode(word)
            total += len(tokens)
    return total

bpe_total = count_total_tokens(bpe, corpus)
wp_total = count_total_tokens(wp, corpus)
uni_total = count_total_tokens(unigram, corpus)

# 元の文字数
total_chars = sum(len(word) for sentence in corpus
                  for word in sentence.strip().split())

methods = ['BPE', 'WordPiece', 'Unigram']
token_counts = [bpe_total, wp_total, uni_total]
vocab_sizes = [len(bpe.vocab), len(wp.vocab), len(unigram.vocab)]
compression = [total_chars / tc for tc in token_counts]

fig, axes = plt.subplots(1, 3, figsize=(15, 5))

# トークン数の比較
colors = ['#4FC3F7', '#FF8A65', '#81C784']
axes[0].bar(methods, token_counts, color=colors, edgecolor='white', width=0.6)
axes[0].set_ylabel('Total Token Count', fontsize=11)
axes[0].set_title('Total Tokens (Entire Corpus)', fontsize=13)
axes[0].grid(axis='y', alpha=0.3)
for i, v in enumerate(token_counts):
    axes[0].text(i, v + 1, str(v), ha='center', fontsize=11, fontweight='bold')

# 語彙サイズの比較
axes[1].bar(methods, vocab_sizes, color=colors, edgecolor='white', width=0.6)
axes[1].set_ylabel('Vocabulary Size', fontsize=11)
axes[1].set_title('Final Vocabulary Size', fontsize=13)
axes[1].grid(axis='y', alpha=0.3)
for i, v in enumerate(vocab_sizes):
    axes[1].text(i, v + 0.5, str(v), ha='center', fontsize=11, fontweight='bold')

# 圧縮率の比較
axes[2].bar(methods, compression, color=colors, edgecolor='white', width=0.6)
axes[2].set_ylabel('Compression Ratio (chars/token)', fontsize=11)
axes[2].set_title('Compression Ratio', fontsize=13)
axes[2].grid(axis='y', alpha=0.3)
for i, v in enumerate(compression):
    axes[2].text(i, v + 0.05, f'{v:.2f}', ha='center', fontsize=11,
                 fontweight='bold')

plt.tight_layout()
plt.show()

この3つのグラフから、各手法のトークン効率を定量的に比較できます。一般的に、語彙サイズが同程度の場合、Unigramモデルは確率モデルに基づく最適分割を行うためトークン効率が良い傾向があります。BPEは単純な頻度ベースのマージのため、必ずしも最適なトークン数にはなりません。WordPieceはPMI基準により「意味のあるまとまり」を捉えやすいですが、エンコード時の最長一致法がボトルネックになることがあります。

ただし、小規模なコーパスでの結果は大規模コーパスでの振る舞いと異なる場合があるため、これらの数値は傾向を掴むための参考値として解釈してください。

語彙サイズとトークン効率の関係

語彙サイズを変化させたとき、各手法のトークン数がどのように変わるかをシミュレーションします。

# 語彙サイズを変えてBPEのトークン数を測定
merge_counts = [5, 10, 15, 20, 25, 30, 35, 40]
bpe_token_series = []
wp_token_series = []

for n_merge in merge_counts:
    # BPE
    bpe_tmp = BPETokenizer(num_merges=n_merge)
    bpe_tmp.train(corpus)
    bpe_token_series.append(count_total_tokens(bpe_tmp, corpus))

    # WordPiece
    wp_tmp = WordPieceTokenizer(num_merges=n_merge)
    wp_tmp.train(corpus)
    wp_token_series.append(count_total_tokens(wp_tmp, corpus))

# Unigramは語彙サイズで制御
uni_vocab_sizes = [20, 25, 30, 35, 40, 50, 60, 80]
uni_token_series = []

for vs in uni_vocab_sizes:
    uni_tmp = UnigramTokenizer(target_vocab_size=vs, max_piece_length=8)
    uni_tmp.train(corpus, n_iterations=15)
    uni_token_series.append(count_total_tokens(uni_tmp, corpus))

fig, ax = plt.subplots(figsize=(10, 6))

# BPEとWordPieceは初期語彙 + マージ数 = 語彙サイズ
# 簡易的にマージ数を横軸に
ax.plot(merge_counts, bpe_token_series, 'o-',
        color='#4FC3F7', linewidth=2, markersize=8, label='BPE')
ax.plot(merge_counts, wp_token_series, 's-',
        color='#FF8A65', linewidth=2, markersize=8, label='WordPiece')
ax.plot(uni_vocab_sizes, uni_token_series, '^-',
        color='#81C784', linewidth=2, markersize=8, label='Unigram')

ax.set_xlabel('Vocabulary Size Parameter\n'
              '(num_merges for BPE/WP, target_vocab_size for Unigram)',
              fontsize=11)
ax.set_ylabel('Total Token Count', fontsize=11)
ax.set_title('Vocabulary Size vs Token Count', fontsize=14)
ax.legend(fontsize=12)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

このグラフは、語彙サイズ(またはマージ回数)を増やすにつれてトークン数が減少する様子を示しています。語彙サイズが小さいうちはトークン数の減少が急激ですが、語彙サイズが大きくなるにつれて減少率が鈍化します。これはZipfの法則の帰結として説明できます。高頻度の語を語彙に含めることで大幅にトークン数を削減できますが、低頻度語を追加してもトークン数への貢献は小さいのです。

3手法を比べると、同じ語彙サイズパラメータに対してUnigramモデルが最もトークン効率が良い傾向があります。これは、Unigramモデルが確率モデルに基づいてコーパス全体の尤度を最大化する語彙を構築するためです。BPEとWordPieceは局所的な貪欲法であるため、大域的な最適性は保証されません。

Unigramモデルの収束性可視化

Unigramモデルの特徴であるEMアルゴリズムの収束過程を可視化します。

# 対数尤度の推移を可視化
fig, ax = plt.subplots(figsize=(8, 5))

iterations = list(range(1, len(unigram.log_likelihoods) + 1))
ax.plot(iterations, unigram.log_likelihoods, 'o-',
        color='#81C784', linewidth=2, markersize=8)
ax.set_xlabel('EM Iteration', fontsize=12)
ax.set_ylabel('Log-Likelihood', fontsize=12)
ax.set_title('Unigram Model: EM Convergence', fontsize=14)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

このグラフは、EMアルゴリズムの各反復における対数尤度の推移を示しています。EMアルゴリズムは対数尤度を単調に増加(または不変)させることが理論的に保証されているため、収束に向かう滑らかな曲線が観察されるはずです。初期の数反復で大きく改善し、その後は緩やかに収束していく典型的なパターンを確認できます。

語彙削減が行われる反復では対数尤度が一時的に下がることがありますが、その後のEM反復で回復します。この「削減 → 回復」のサイクルが、Unigramモデルの語彙構築プロセスの本質です。

3手法の分割結果の詳細比較

最後に、形態的に興味深い単語について、3手法の分割結果を詳細に比較します。

# 分割結果の詳細比較
detailed_words = ["newest", "playing", "unplayable",
                  "lower", "chased", "rated"]

fig, axes = plt.subplots(len(detailed_words), 1,
                          figsize=(12, len(detailed_words) * 1.2))

colors_map = {'BPE': '#4FC3F7', 'WordPiece': '#FF8A65',
              'Unigram': '#81C784'}

for idx, word in enumerate(detailed_words):
    ax = axes[idx]

    bpe_t = bpe.encode(word)
    wp_t = wp.encode(word)
    uni_t = unigram.encode(word)

    results = {
        'BPE': [t.replace('</w>', '') for t in bpe_t if t != '</w>'],
        'WordPiece': [t.replace('</w>', '') for t in wp_t if t != '</w>'],
        'Unigram': [t.replace('</w>', '') for t in uni_t if t != '</w>'],
    }

    y_positions = {'BPE': 0.7, 'WordPiece': 0.4, 'Unigram': 0.1}

    for method_name, tokens in results.items():
        y = y_positions[method_name]
        x = 0.15
        ax.text(0.0, y, f'{method_name}:', fontsize=9,
                va='center', fontweight='bold',
                color=colors_map[method_name],
                transform=ax.transAxes)
        for token in tokens:
            width = len(token) * 0.04 + 0.02
            rect = plt.Rectangle((x, y - 0.12), width, 0.24,
                                  facecolor=colors_map[method_name],
                                  alpha=0.3, edgecolor=colors_map[method_name],
                                  transform=ax.transAxes, clip_on=False)
            ax.add_patch(rect)
            ax.text(x + width/2, y, token, fontsize=9, ha='center',
                    va='center', transform=ax.transAxes,
                    fontweight='bold')
            x += width + 0.01

    ax.set_xlim(0, 1)
    ax.set_ylim(0, 1)
    ax.set_title(f'"{word}"', fontsize=11, fontweight='bold', loc='left')
    ax.axis('off')

plt.suptitle('Subword Segmentation Comparison', fontsize=14,
             fontweight='bold', y=1.02)
plt.tight_layout()
plt.show()

この可視化は、各手法が同じ単語をどのように分割するかを直感的に示しています。「newest」のような頻出語は3手法とも比較的大きなサブワードに分割する傾向があります。一方、「unplayable」のような複合的な語では手法ごとの違いが顕著になります。

BPEは学習コーパスでの出現頻度に忠実な分割を行うため、コーパスの統計に強く依存します。WordPieceは共起の有意性に基づくため、頻度が低くても強く結びついた文字列をまとめます。Unigramは確率モデルの観点から最適な分割を求めるため、全体的なバランスの取れた分割になる傾向があります。

まとめ

本記事では、サブワード分割の3つの主要な手法——BPE、WordPiece、Unigramモデル——を数学的に定式化し、学習アルゴリズムの違いと性能をPythonでの比較実験を通して検証しました。

  • BPEは最頻ペアの逐次マージという単純明快なアルゴリズムで、実装が容易で学習が高速です。確率モデルを持たない決定的な手法であり、GPT系モデルで広く採用されています
  • WordPieceは尤度増加量(PMI)に基づくマージ基準を持ち、偶然の共起を超えた「意味のあるまとまり」を検出します。BERTで採用されていますが、エンコード時は最長一致法を使う点がBPEと異なります
  • Unigramモデルは明示的な確率モデルとEMアルゴリズムに基づく唯一の手法で、大きな初期語彙からトップダウンで削減していきます。確率的なサンプリングによるsubword regularizationが可能である点が最大の強みです
  • SentencePieceは言語非依存のフレームワークとしてBPEとUnigramを統合的に利用でき、空白区切りのない日本語や中国語でも前処理なしに対応できます
  • 語彙サイズの選択はトークン効率と埋め込み品質のトレードオフであり、Zipfの法則に従う自然言語ではサイズの増加に対するトークン数の削減は逓減します
  • 日本語を含む多言語対応では、文字体系の多様性と空白区切りの欠如が課題となり、SentencePieceの活用と適切な語彙サイズの選択が重要です

次のステップとして、以下の記事も参考にしてください。

画像なし
Hugging Face Tokenizers実践ガイド — 高速トークナイザーの使い方とカスタム訓練
HuggingFaceのTokenizersライブラリを使ってBPE/WordPiece/Unigramを高速に実装・学習する実践ガイド。カスタムコーパスでのトークナイザ訓練手順も解説。
画像なし
BERTology — Probingタスクで事前学習モデルの内部知識を探る
WordPieceで分割されたトークン列がBERT内部でどのように処理されるかを、Probingタスクを通じて探る。サブワードが言語知識の表現にどう関わるかを分析する。