BPE・WordPiece・SentencePieceを比較して理解する

トークナイゼーション(Tokenization)は、テキストを機械学習モデルが処理できる単位に分割する処理です。大規模言語モデル(LLM)の時代において、効率的なトークナイゼーションは性能を大きく左右する重要な前処理です。

本記事では、現代のNLPで主流となっているサブワードトークナイゼーション、特にBPE、WordPiece、SentencePieceについて、アルゴリズムの詳細と実装を解説します。

本記事の内容

  • トークナイゼーションの基礎と必要性
  • サブワードトークナイゼーションの登場背景
  • Byte Pair Encoding(BPE)のアルゴリズム
  • WordPieceのアルゴリズム
  • SentencePieceの特徴
  • Pythonでの実装と可視化

前提知識

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

トークナイゼーションとは

基本的な役割

トークナイゼーションは、テキストをトークンと呼ばれる単位に分割する処理です。分割されたトークンは、それぞれ一意のID(整数)に変換され、モデルへの入力となります。

テキスト: "I love natural language processing"
     ↓ トークナイゼーション
トークン: ["I", "love", "natural", "language", "processing"]
     ↓ ID変換
ID列: [1, 45, 892, 156, 2341]

従来のアプローチとその問題点

1. 単語レベルのトークナイゼーション

スペースや句読点で分割する最もシンプルな方法です。

text = "I love natural language processing"
tokens = text.split()  # ["I", "love", "natural", "language", "processing"]

問題点:OOV(Out-of-Vocabulary)問題: 語彙に含まれない未知語を扱えない – 語彙サイズの爆発: 英語だけでも数十万〜数百万の単語が存在 – 語形変化の非効率: “run”, “runs”, “running”, “ran” を全て別単語として扱う

2. 文字レベルのトークナイゼーション

各文字を1トークンとして扱う方法です。

text = "hello"
tokens = list(text)  # ["h", "e", "l", "l", "o"]

問題点:系列長の増大: 単語レベルの5〜10倍の長さになる – 意味の喪失: 文字単位では意味情報が乏しい – 学習の困難: 文字から単語・フレーズの意味を学習する負担が大きい

サブワードトークナイゼーションの登場

サブワードトークナイゼーションは、単語と文字の間の粒度でテキストを分割する手法です。

利点:語彙サイズの制御: 数万程度の語彙で十分 – OOV問題の解消: 未知語もサブワードの組み合わせで表現可能 – 語形変化の効率的処理: 共通の語幹を共有できる

例えば、”unhappiness” は以下のように分割できます。

"unhappiness" → ["un", "happi", "ness"]

これにより、”happy”, “unhappy”, “happiness” 等が共通のサブワード “happi” を共有し、意味的な関連性を捉えやすくなります。

Byte Pair Encoding(BPE)

概要

BPE(Byte Pair Encoding)は、元々はデータ圧縮アルゴリズムとして提案されましたが、2016年にSennrichらがニューラル機械翻訳に応用しました。GPTシリーズはBPE(およびその変種)を使用しています。

アルゴリズム

学習フェーズ(語彙構築):

  1. 全ての単語を文字単位に分割し、単語末尾に特殊記号(例: </w>)を付加
  2. 初期語彙を全ての文字で初期化
  3. 以下を指定回数(マージ回数)繰り返す: – コーパス全体で最も頻度の高い連続するトークンペアを見つける – そのペアを1つのトークンにマージ – マージルールを保存

エンコードフェーズ(トークナイゼーション):

学習したマージルールを順番に適用して、テキストをトークン列に変換します。

具体例

コーパス: {"low": 5, "lower": 2, "newest": 6, "widest": 3}

ステップ1: 初期化

各単語を文字に分割し、末尾に </w> を付加します。

l o w </w>      : 5
l o w e r </w>  : 2
n e w e s t </w>: 6
w i d e s t </w>: 3

初期語彙: {l, o, w, e, r, n, s, t, i, d, </w>}

ステップ2: 最頻ペアの発見とマージ

各ペアの出現頻度を計算します。

ペア 頻度
e s 6 + 3 = 9
s t 6 + 3 = 9
t 6 + 3 = 9
l o 5 + 2 = 7
o w 5 + 2 = 7

最頻ペア e s をマージ(同率の場合は任意に選択)。

l o w </w>       : 5
l o w e r </w>   : 2
n e w es t </w>  : 6
w i d es t </w>  : 3

語彙に es を追加。

ステップ3: 繰り返し

次の最頻ペア es t をマージ。

l o w </w>       : 5
l o w e r </w>   : 2
n e w est </w>   : 6
w i d est </w>   : 3

語彙に est を追加。

次の最頻ペア est </w> をマージ。

l o w </w>       : 5
l o w e r </w>   : 2
n e w est</w>    : 6
w i d est</w>    : 3

語彙に est</w> を追加。

このプロセスを繰り返すことで、頻出するサブワードが語彙に追加されていきます。

数式による定式化

コーパス $\mathcal{C}$ における各単語 $w$ の出現頻度を $c(w)$ とします。現在の分割状態で、連続するトークンペア $(a, b)$ の頻度は以下で計算されます。

$$ f(a, b) = \sum_{w \in \mathcal{C}} c(w) \cdot \text{count}_{w}(a, b) $$

ここで $\text{count}_{w}(a, b)$ は、単語 $w$ の現在のトークン列において、ペア $(a, b)$ が出現する回数です。

各ステップで、以下を満たすペア $(a^*, b^*)$ をマージします。

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

Pythonでの実装

import re
from collections import defaultdict


def get_stats(vocab):
    """連続するトークンペアの頻度を計算"""
    pairs = defaultdict(int)
    for word, freq in vocab.items():
        symbols = word.split()
        for i in range(len(symbols) - 1):
            pairs[(symbols[i], symbols[i + 1])] += freq
    return pairs


def merge_vocab(pair, vocab):
    """指定されたペアをマージした新しい語彙を返す"""
    new_vocab = {}
    bigram = re.escape(' '.join(pair))
    pattern = re.compile(r'(?<!\S)' + bigram + r'(?!\S)')
    for word, freq in vocab.items():
        new_word = pattern.sub(''.join(pair), word)
        new_vocab[new_word] = freq
    return new_vocab


def learn_bpe(corpus, num_merges):
    """BPEの語彙を学習"""
    # 初期化: 単語を文字に分割し、末尾に</w>を付加
    vocab = {}
    for word, freq in corpus.items():
        word_tokens = ' '.join(list(word)) + ' </w>'
        vocab[word_tokens] = freq

    merge_rules = []

    for i in range(num_merges):
        pairs = get_stats(vocab)
        if not pairs:
            break
        best_pair = max(pairs, key=pairs.get)
        vocab = merge_vocab(best_pair, vocab)
        merge_rules.append(best_pair)
        print(f"Step {i + 1}: Merge '{best_pair[0]}' + '{best_pair[1]}' -> '{''.join(best_pair)}'")

    return vocab, merge_rules


# 使用例
corpus = {'low': 5, 'lower': 2, 'newest': 6, 'widest': 3}
vocab, merge_rules = learn_bpe(corpus, num_merges=10)

print("\nFinal vocabulary:")
for word, freq in vocab.items():
    print(f"  {word}: {freq}")

print("\nMerge rules:")
for i, rule in enumerate(merge_rules):
    print(f"  {i + 1}. {rule[0]} + {rule[1]} -> {''.join(rule)}")

実行結果:

Step 1: Merge 'e' + 's' -> 'es'
Step 2: Merge 'es' + 't' -> 'est'
Step 3: Merge 'est' + '</w>' -> 'est</w>'
Step 4: Merge 'l' + 'o' -> 'lo'
Step 5: Merge 'lo' + 'w' -> 'low'
Step 6: Merge 'n' + 'e' -> 'ne'
Step 7: Merge 'ne' + 'w' -> 'new'
Step 8: Merge 'new' + 'est</w>' -> 'newest</w>'
Step 9: Merge 'low' + '</w>' -> 'low</w>'
Step 10: Merge 'w' + 'i' -> 'wi'

Final vocabulary:
  low</w>: 5
  low e r </w>: 2
  newest</w>: 6
  wi d est</w>: 3

WordPiece

概要

WordPieceは、Googleが音声認識システムのために開発し、後にBERTで採用されたサブワードトークナイゼーション手法です。BPEと似ていますが、マージするペアの選択基準が異なります。

BPEとの違い

BPEは頻度に基づいてマージするペアを選択しますが、WordPieceは尤度の増加量に基づいて選択します。

具体的には、言語モデルの学習においてコーパスの尤度が最も増加するペアを選択します。

マージ基準の数式

ペア $(a, b)$ をマージして新しいトークン $ab$ にするとき、尤度の変化は以下で近似できます。

$$ \text{score}(a, b) = \frac{f(ab)}{f(a) \cdot f(b)} $$

ここで: – $f(ab)$: ペア $(a, b)$ の出現頻度 – $f(a)$, $f(b)$: 各トークンの出現頻度

この式は、独立に出現するよりも連続して出現する頻度が高いペアを優先することを意味します。

直感的な理解:

  • $f(a)$ と $f(b)$ がともに高くても、$f(ab)$ が低ければスコアは低い
  • $f(a)$ と $f(b)$ が低くても、$f(ab)$ が相対的に高ければスコアは高い

これにより、単独では珍しいが、組み合わせると頻出するペア(意味のある単位)が優先的にマージされます。

Pythonでの実装

def get_wordpiece_stats(vocab):
    """WordPiece用のペアスコアを計算"""
    # トークンの頻度を計算
    token_freq = defaultdict(int)
    for word, freq in vocab.items():
        for token in word.split():
            token_freq[token] += freq

    # ペアのスコアを計算
    pair_scores = {}
    for word, freq in vocab.items():
        symbols = word.split()
        for i in range(len(symbols) - 1):
            pair = (symbols[i], symbols[i + 1])
            pair_freq = get_pair_freq(vocab, pair)
            # WordPieceスコア: f(ab) / (f(a) * f(b))
            score = pair_freq / (token_freq[pair[0]] * token_freq[pair[1]])
            pair_scores[pair] = score

    return pair_scores


def get_pair_freq(vocab, pair):
    """特定のペアの頻度を計算"""
    freq = 0
    for word, word_freq in vocab.items():
        symbols = word.split()
        for i in range(len(symbols) - 1):
            if (symbols[i], symbols[i + 1]) == pair:
                freq += word_freq
    return freq


def learn_wordpiece(corpus, num_merges):
    """WordPieceの語彙を学習"""
    # 初期化
    vocab = {}
    for word, freq in corpus.items():
        word_tokens = ' '.join(list(word)) + ' </w>'
        vocab[word_tokens] = freq

    merge_rules = []

    for i in range(num_merges):
        scores = get_wordpiece_stats(vocab)
        if not scores:
            break
        best_pair = max(scores, key=scores.get)
        vocab = merge_vocab(best_pair, vocab)
        merge_rules.append(best_pair)
        print(f"Step {i + 1}: Merge '{best_pair[0]}' + '{best_pair[1]}' "
              f"(score: {scores[best_pair]:.4f})")

    return vocab, merge_rules


# 使用例
corpus = {'low': 5, 'lower': 2, 'newest': 6, 'widest': 3}
vocab, merge_rules = learn_wordpiece(corpus, num_merges=5)

WordPieceのエンコード

WordPieceでは、単語の先頭以外のサブワードには ## プレフィックスが付きます。

"unhappiness" → ["un", "##happi", "##ness"]
"playing" → ["play", "##ing"]

これにより、サブワードが単語の先頭かどうかを区別できます。

def encode_wordpiece(word, vocab):
    """WordPieceエンコード"""
    tokens = []
    start = 0
    while start < len(word):
        end = len(word)
        found = False
        while start < end:
            substr = word[start:end]
            if start > 0:
                substr = '##' + substr
            if substr in vocab:
                tokens.append(substr)
                found = True
                break
            end -= 1
        if not found:
            tokens.append('[UNK]')
            start += 1
        else:
            start = end
    return tokens


# 使用例(実際の語彙が必要)
# tokens = encode_wordpiece("unhappiness", vocab)

SentencePiece

概要

SentencePieceは、Googleが開発したトークナイゼーションライブラリです。BPEやUnigram言語モデルベースのトークナイゼーションを実装しています。

主な特徴

1. 言語非依存

事前のトークナイゼーション(スペースでの分割など)を必要とせず、生のテキストから直接学習できます。これにより、日本語や中国語のようにスペースで単語が区切られない言語にも適用できます。

2. 可逆性

トークン列から元のテキストを完全に復元できます。スペースは特殊記号 (U+2581)に置換されます。

"Hello world" → ["▁Hello", "▁world"]
"こんにちは世界" → ["▁", "こんにちは", "世界"]

3. サブワードの正則化(Subword Regularization)

学習時に、同じ単語を複数の異なるサブワード分割でランダムにエンコードすることで、モデルの汎化性能を向上させます。

Unigramモデル

SentencePieceは、BPEに加えてUnigramモデルによるトークナイゼーションもサポートしています。

Unigramモデルでは、各サブワード $x_i$ の出現確率 $p(x_i)$ を仮定し、文 $X = (x_1, x_2, \ldots, x_n)$ の尤度を以下で計算します。

$$ P(X) = \prod_{i=1}^{n} p(x_i) $$

対数尤度は:

$$ \log P(X) = \sum_{i=1}^{n} \log p(x_i) $$

Unigramの学習アルゴリズム:

  1. 十分に大きな初期語彙(全ての文字 + 頻出部分文字列)を構築
  2. EMアルゴリズムで各サブワードの確率 $p(x)$ を推定
  3. 各サブワードを語彙から除いた場合の尤度の減少量を計算
  4. 尤度減少が小さいサブワード(重要度が低い)を削除
  5. 目標の語彙サイズになるまで2-4を繰り返す

SentencePieceの使用例

import sentencepiece as spm

# 学習(コーパスファイルから)
# spm.SentencePieceTrainer.train(
#     input='corpus.txt',
#     model_prefix='mymodel',
#     vocab_size=8000,
#     model_type='bpe'  # または 'unigram'
# )

# 使用例(事前学習済みモデルを使用)
# sp = spm.SentencePieceProcessor(model_file='mymodel.model')

# エンコード
# tokens = sp.encode("Hello world", out_type=str)
# print(tokens)  # ['▁Hello', '▁world']

# デコード
# text = sp.decode(tokens)
# print(text)  # "Hello world"

簡易的なSentencePiece風の実装

def preprocess_for_sentencepiece(text):
    """スペースを特殊記号に置換"""
    # 先頭にスペースを追加(単語境界を明示)
    text = ' ' + text
    # スペースを▁に置換
    text = text.replace(' ', '▁')
    return text


def postprocess_from_sentencepiece(tokens):
    """トークン列から元のテキストを復元"""
    text = ''.join(tokens)
    text = text.replace('▁', ' ')
    # 先頭のスペースを除去
    text = text.strip()
    return text


# 使用例
text = "Hello world"
processed = preprocess_for_sentencepiece(text)
print(f"Processed: '{processed}'")  # "▁Hello▁world"

# トークン化後(仮想的なトークン列)
tokens = ['▁Hello', '▁world']
restored = postprocess_from_sentencepiece(tokens)
print(f"Restored: '{restored}'")  # "Hello world"

トークナイザの比較

特徴 BPE WordPiece SentencePiece
マージ基準 頻度 尤度増加 BPE or Unigram
使用モデル GPT, RoBERTa BERT T5, ALBERT, mT5
言語依存 あり(事前分割必要) あり なし
可逆性 部分的 部分的 完全
日本語対応 前処理が必要 前処理が必要 ネイティブ対応

トークナイゼーションの可視化

import matplotlib.pyplot as plt
import numpy as np


def visualize_tokenization(text, tokens, figsize=(12, 3)):
    """トークナイゼーションの結果を可視化"""
    fig, ax = plt.subplots(figsize=figsize)

    # カラーマップ
    colors = plt.cm.Set3(np.linspace(0, 1, len(tokens)))

    # 各トークンを描画
    x = 0
    for i, token in enumerate(tokens):
        width = len(token) * 0.5
        rect = plt.Rectangle((x, 0), width, 1, facecolor=colors[i],
                             edgecolor='black', linewidth=1)
        ax.add_patch(rect)
        ax.text(x + width / 2, 0.5, token, ha='center', va='center',
               fontsize=10, fontweight='bold')
        x += width

    ax.set_xlim(0, x)
    ax.set_ylim(0, 1)
    ax.axis('off')
    ax.set_title(f'Tokenization: "{text}"', fontsize=12)
    plt.tight_layout()
    plt.show()


# 使用例
text = "unhappiness"
tokens_char = list(text)
tokens_subword = ["un", "happi", "ness"]
tokens_word = [text]

print("Character-level tokens:", tokens_char)
print("Subword-level tokens:", tokens_subword)
print("Word-level tokens:", tokens_word)

まとめ

本記事では、自然言語処理におけるトークナイゼーションについて解説しました。

  • トークナイゼーションの役割: テキストをモデルが処理できる単位に分割し、IDに変換する
  • サブワードの利点: OOV問題の解消、語彙サイズの制御、形態論的な知識の獲得
  • BPE: 頻度に基づいて最頻ペアをマージするアルゴリズム。GPTで採用
  • WordPiece: 尤度増加に基づいてマージするアルゴリズム。BERTで採用
  • SentencePiece: 言語非依存で可逆的なトークナイゼーション。T5で採用

トークナイゼーションは、LLMの性能を左右する重要な前処理です。適切なトークナイザの選択と設定は、下流タスクの性能に直接影響します。

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