Hugging Face Tokenizers実践ガイド — 高速トークナイザーの使い方とカスタム訓練

ChatGPTやBERTに文章を入力するとき、モデルは文字列をそのまま受け取っているわけではありません。内部では「トークナイゼーション」と呼ばれる前処理が行われ、テキストが整数IDの列に変換されています。この変換の品質と速度は、NLPシステム全体の性能に直結します。

たとえば、100万件のレビューデータをBERTでファインチューニングする場面を考えてみてください。Pythonの純粋な実装でトークナイズすると数十分かかる処理が、Hugging Face Tokenizersライブラリを使えば数十秒で完了します。なぜこれほどの差が生まれるのでしょうか? 答えは「Rustで書かれたバックエンド」にあります。

Hugging Face Tokenizersは、以下のような場面で不可欠なツールです。

  • 大規模データの前処理: 数百万〜数十億のテキストデータを実用的な時間でトークナイズする必要がある場面で、Rustの並列処理が真価を発揮します
  • ドメイン特化モデルの構築: 医療文書、ソースコード、化学式など、汎用トークナイザーでは適切にトークン化できないテキストに対して、独自のトークナイザーを訓練できます
  • Transformerモデルとのシームレスな連携: Hugging Face Transformersライブラリとネイティブに統合され、AutoTokenizerから一行でロードできます

本記事の内容

  • トークナイゼーションの基本とサブワード分割の必要性
  • Hugging Face Tokenizersライブラリのアーキテクチャ(5段パイプライン)
  • BPE・WordPiece・Unigramの3つのモデルの理論と違い
  • カスタムトークナイザーの訓練をPythonで実装
  • 特殊トークンの設定とテンプレート処理
  • バッチ処理による性能最適化と速度ベンチマーク
  • AutoTokenizerとの連携と保存・ロード

前提知識

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

画像なし
トークナイゼーションの基礎
トークナイゼーションの概念、文字レベル・単語レベル・サブワードレベルの違い、BPEの基本的な仕組みを解説します。
画像なし
Hugging Face Transformersの基礎
Hugging Face Transformersライブラリの全体像、AutoModel・AutoTokenizerの使い方を解説します。
画像なし
BERTのアーキテクチャ
BERTの事前学習・ファインチューニングの仕組みとTransformerエンコーダの構造を解説します。

HuggingFace Tokenizers 5段パイプライン全体図

Hugging Face Tokenizersは「正規化→事前分割→モデル→後処理→デコード」の5段パイプラインで構成されています。各段が独立したコンポーネントとして設計されているため、BEATの[CLS][SEP]テンプレートやGPT-2のByteLevel方式など、モデルごとに異なる設定を柔軟に組み合わせることができます。この図を頭に置きながら読み進めると、各セクションでどの段の話をしているかが一目でわかります。

なぜ「高速」トークナイザーが必要なのか

Python実装の限界

トークナイゼーションは一見単純な文字列操作に見えますが、実際のNLPパイプラインではボトルネックになりがちです。Transformerモデルの学習では、エポックごとに全学習データのトークナイズが必要になります。100万件の文書データをPythonのfor文で逐次処理していては、GPUがトークナイズの完了を待ち続ける「GPUアイドル問題」が生じます。

具体的な数値で考えてみましょう。英語のWikipedia全文は約40億トークンに相当します。Pythonの純粋な実装(1秒あたり約1万トークン)では、トークナイズだけで約4.6日かかります。しかし、RustベースのTokenizersライブラリ(1秒あたり約100万トークン)を使えば、わずか約67分で完了します。この差は約100倍にもなります。

Rustバックエンドの仕組み

Hugging Face Tokenizersライブラリは、コアの処理をRustで実装し、PythonからはPyO3を通じてバインディングを提供するという設計を採用しています。この設計が高速な理由は3つあります。

第一に、メモリ安全な低レベル最適化です。Rustはガベージコレクタを持たず、コンパイル時にメモリ管理の安全性を保証します。これにより、Pythonのような動的言語で生じるオーバーヘッドを排除しつつ、C/C++のような手動メモリ管理のバグも回避しています。

第二に、ネイティブな並列処理です。Rustのrayonクレートを活用し、バッチ処理時に複数のテキストを自動的にCPUの全コアで並列にトークナイズします。Pythonのmultiprocessingと異なり、プロセス間通信のオーバーヘッドがなく、共有メモリで効率的にデータを受け渡します。

第三に、ゼロコピーの文字列操作です。トークナイズ処理では大量の部分文字列操作が発生しますが、Rustの所有権システムにより、不要な文字列のコピーを最小限に抑えています。

この高速性は「Fast Tokenizer」として知られ、Hugging Face TransformersのAutoTokenizerでデフォルトで使用されています。use_fast=True(デフォルト)で高速版、use_fast=FalseでPython版に切り替えることができます。

では、この高速なトークナイザーの内部は、どのようなパイプラインで構成されているのでしょうか。次のセクションで、5段パイプラインの全体像を見ていきましょう。

トークナイゼーション・パイプラインの全体像

5段パイプライン

Hugging Face Tokenizersライブラリの最も重要な設計思想は、トークナイゼーションを5つの独立したステージに分解している点です。料理に例えるなら、「食材の下処理 → 切り分け → 調理 → 盛り付け → 仕上げ」のように、各工程が明確に分離されています。この設計により、各ステージを自由に組み合わせてカスタムトークナイザーを構築できます。

5つのステージを順に見ていきましょう。

1. Normalizer(正規化)

入力テキストに対する前処理を行います。Unicode正規化(NFC、NFKC)、小文字化、アクセント記号の除去などがこのステージの役割です。たとえば、BERTのcasedモデルは大文字小文字を区別するため正規化を最小限に留めますが、uncasedモデルは全て小文字に変換します。

Normalizer テキスト正規化のステップ

上図は正規化の典型的な3ステップを示しています。NFD分解でアクセント付き文字を「基底文字+結合文字」に分離し、小文字化で統一表記に揃え、最後にアクセント記号(結合文字)だけを除去します。NFC・NFKC・NFDの3形式を使い分けることで、全角半角の統一や合成済み文字の制御など、モデルの要件に応じた正規化が実現できます。

2. Pre-tokenizer(事前トークン化)

テキストを「単語」レベルの単位に分割します。最も単純なのは空白で分割するWhitespaceですが、句読点での分割、数字の分離なども指定できます。このステージの出力は、次のModelステージへの入力となる「単語」のリストです。重要なのは、最終的なサブワード分割はここでは行わないという点です。

3. Model(モデル)

トークナイゼーションの中核です。Pre-tokenizerが出力した各「単語」をサブワードに分割します。BPE、WordPiece、Unigramの3つのアルゴリズムが選択可能で、それぞれ異なる方法でサブワードの語彙を構築し、分割を行います。このステージについては、後のセクションで詳しく解説します。

4. Post-processor(後処理)

モデルが出力したトークン列に、特殊トークン([CLS][SEP]<s></s>など)を追加します。BERTなら入力の先頭に[CLS]を、末尾に[SEP]を付与します。2文入力の場合のテンプレートもここで定義します。

5. Decoder(デコーダ)

トークンIDの列を元のテキストに復元するステージです。BPEのĠプレフィックスやWordPieceの##プレフィックスを適切に処理し、人間が読める文字列に戻します。

パイプラインの図解

以下のPythonコードで、パイプラインの各ステージがどのようにテキストを変換するかを可視化してみましょう。

from tokenizers import Tokenizer
from tokenizers.models import BPE
from tokenizers.pre_tokenizers import Whitespace
from tokenizers.normalizers import NFD, Lowercase, StripAccents, Sequence as NormalizerSequence

# パイプラインの各ステージを個別に確認
# 1. Normalizer
normalizer = NormalizerSequence([NFD(), Lowercase(), StripAccents()])
text = "Héllo Wörld! Café"
normalized = normalizer.normalize_str(text)
print(f"入力:     {text}")
print(f"正規化後: {normalized}")

# 2. Pre-tokenizer
pre_tokenizer = Whitespace()
pre_tokenized = pre_tokenizer.pre_tokenize_str(normalized)
print(f"事前トークン化: {pre_tokenized}")

# 3. Model以降は訓練済みトークナイザーで確認
from tokenizers import pre_tokenizers
from tokenizers.pre_tokenizers import ByteLevel

# ByteLevel Pre-tokenizerの動作確認
byte_level_pre = ByteLevel(add_prefix_space=False)
sample = "Hello world!"
byte_result = byte_level_pre.pre_tokenize_str(sample)
print(f"\nByteLevel事前トークン化:")
print(f"入力: {sample}")
print(f"結果: {byte_result}")

このコードを実行すると、各ステージの変換結果が確認できます。Normalizerステージでは"Héllo""hello"に変換され、アクセント記号の除去と小文字化が行われています。Pre-tokenizerステージでは、空白を区切りとして単語ごとに分割され、各単語がテキスト中のどの位置(オフセット)に対応するかの情報も保持されます。このオフセット追跡は、後に固有表現抽出(NER)などで元のテキストとの対応を取る際に不可欠な機能です。

ByteLevelのPre-tokenizerでは、全てのバイトをUnicode文字にマッピングする方式を採用しています。GPT-2が採用したこの方式は、未知のUnicode文字が出現しても常にトークナイズできるという利点があります。

各ステージの役割が把握できたところで、パイプラインの中核であるModelステージに焦点を当てましょう。BPE、WordPiece、Unigramの3つのアルゴリズムは、それぞれどのようにサブワードの語彙を構築するのでしょうか。

サブワードトークナイゼーションの3つのモデル

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

トークナイゼーションの最もシンプルなアプローチは、テキストを単語単位で分割する方法です。しかし、単語レベルの分割には致命的な問題があります。自然言語には膨大な数の単語が存在し、さらに活用形、複合語、新語が次々と生まれます。語彙サイズが際限なく膨らむか、知らない単語を全て[UNK](未知語トークン)に変換するしかありません。

逆に、文字レベルで分割すれば未知語の問題は解消しますが、系列長が爆発的に増加し、モデルが単語の意味を文字の列から学習する必要があるため効率が悪くなります。

サブワード分割は、この2つの極端のバランスを取る手法です。頻出する単語はそのまま一つのトークンとして残し、稀な単語はより小さな意味のある単位に分割します。例えば、"tokenization""token" + "ization"のように分割されるかもしれません。"token"は頻出するためそのまま残り、"-ization"という接尾辞は他の単語("optimization""normalization")とも共有できます。

では、この「適切な分割」をどのように見つけるのでしょうか。3つの代表的なアルゴリズムを見ていきましょう。

BPE(Byte Pair Encoding)

BPEは、ボトムアップのアプローチでサブワード語彙を構築します。直感的に言えば、「よく隣り合う文字のペアを繰り返し結合して、頻出パターンを一つのトークンにまとめていく」手法です。

アルゴリズムは以下のステップで進みます。

  1. 初期語彙として、訓練コーパス中の全ての文字(バイト)を登録する
  2. コーパス中で最も頻出する隣接トークンペアを見つける
  3. そのペアを一つの新しいトークンとして語彙に追加する
  4. コーパス中の該当ペアを全て新しいトークンに置換する
  5. 語彙サイズが目標に達するまでステップ2〜4を繰り返す

この過程をもう少し形式的に書くと、語彙 $V$ と、学習されたマージルール(結合規則)の順序付きリスト $M = [(a_1, b_1), (a_2, b_2), \dots]$ がBPEモデルの成果物です。新しいテキストをトークナイズする際は、マージルールを学習された順序で適用していきます。

GPT-2、GPT-3、GPT-4、RoBERTaなど、多くの現代的なモデルがBPEを採用しています。

BPE マージステップによる語彙拡張

上図はBPEのマージが進む様子を4ステップで示しています。初期状態では全文字が個別トークンですが、マージを重ねるごとに頻出パターンがひとつのトークンにまとまっていきます。3回のマージで"new"というトークンが誕生するように、コーパス中で共起頻度の高いペアが優先的に統合されます。語彙サイズが目標に達するまでこの操作を繰り返すことで、頻出単語は1〜数トークン、希少単語は細かいサブワードに分割される語彙が自然に形成されます。

WordPiece

WordPieceは、BERTで使用されているアルゴリズムです。BPEと似ていますが、マージするペアの選択基準が異なります。

BPEが単純な頻度(出現回数)でペアを選ぶのに対し、WordPieceは尤度の増加量を基準にします。具体的には、ペア $(a, b)$ をマージするかどうかを、以下のスコアで判断します。

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

ここで $P(ab)$ はペア $ab$ がコーパス中に出現する確率、$P(a)$ と $P(b)$ はそれぞれ個別のトークンの出現確率です。この式は点別相互情報量(Pointwise Mutual Information)の指数形に相当し、「$a$ と $b$ が独立に出現するよりも、一緒に出現する頻度がどれだけ高いか」を測ります。

つまり、WordPieceは個別には珍しいが、一緒に出現すると特徴的なペアを優先的にマージします。例えば、"un""##happy"がそれぞれ単独では珍しくても、常にセットで出現するなら高いスコアを得ます。

WordPieceの特徴的な表記として、単語の先頭以外のサブワードには##プレフィックスが付きます。例えば "playing""play" + "##ing" に分割される形です。

Unigram

Unigramモデルは、BPEやWordPieceとは正反対のトップダウンアプローチを取ります。大きな初期語彙から出発して、不要なトークンを削除していく方式です。

Unigramは、各サブワード $x_i$ に確率 $p(x_i)$ を割り当て、テキスト $X$ のトークナイゼーション $\bm{x} = (x_1, x_2, \dots, x_M)$ の確率を、各トークンの確率の積としてモデル化します。

$$ P(\bm{x}) = \prod_{i=1}^{M} p(x_i) $$

最適なトークナイゼーションは、この確率を最大化する分割として求められます。

$$ \bm{x}^* = \arg\max_{\bm{x} \in S(X)} P(\bm{x}) $$

ここで $S(X)$ はテキスト $X$ の全ての可能な分割の集合です。この最適化はビタビアルゴリズム(動的計画法)で効率的に解けます。

語彙の構築は以下の手順で行います。

  1. 十分に大きな初期語彙(全てのサブストリングや頻出パターン)を用意する
  2. EMアルゴリズムで各トークンの確率 $p(x_i)$ を推定する
  3. 各トークンを語彙から除いた場合の損失(対数尤度の減少量)を計算する
  4. 損失が最も小さいトークン(つまり最も不要なトークン)を一定割合除去する
  5. 語彙サイズが目標に達するまでステップ2〜4を繰り返す

Unigramの利点は、トークナイゼーションが確率的であるため、1つのテキストに対して複数の分割候補をサンプリングできることです。これは学習時の正則化(Subword Regularization)に活用されます。SentencePiece、XLNet、ALBERTなどがUnigramを採用しています。

3つのモデルの比較

3つのアルゴリズムの特徴を整理しましょう。

特性 BPE WordPiece Unigram
構築方向 ボトムアップ(結合) ボトムアップ(結合) トップダウン(削除)
マージ基準 頻度 尤度スコア
確率モデル なし なし あり(ユニグラム言語モデル)
複数分割のサンプリング 不可 不可 可能
代表的モデル GPT-2/3/4, RoBERTa BERT, DistilBERT XLNet, ALBERT, T5

BPE WordPiece Unigram 3つのモデル比較

上図は3つのアルゴリズムの性質を並べて整理したものです。BPEとWordPieceはどちらもボトムアップで語彙を構築しますが、BPEが純粋な頻度を使うのに対し、WordPieceはPMI(点別相互情報量)型のスコアを採用するため、単独では珍しいが必ずセットで現れるペアを優先的にマージします。Unigramは唯一確率モデルを持ち、学習時に複数の分割候補をサンプリングして正則化に使えるという独自の強みがあります。実際のモデル選定では、BEATファミリーにはWordPiece、GPT系にはBPE、T5/ALBERTにはUnigramというパターンが標準的です。

理論を押さえたところで、いよいよPythonで実際にカスタムトークナイザーを構築してみましょう。Hugging Face Tokenizersライブラリを使えば、数十行のコードで独自のトークナイザーを訓練できます。

BPEトークナイザーの訓練

環境準備

まず、ライブラリをインストールします。

pip install tokenizers datasets matplotlib

訓練データの準備とBPEトークナイザーの構築

BPEトークナイザーをゼロから構築する全体像を確認しましょう。5段パイプラインの各コンポーネントを明示的に設定し、訓練データから語彙を学習させます。

from tokenizers import Tokenizer
from tokenizers.models import BPE
from tokenizers.trainers import BpeTrainer
from tokenizers.pre_tokenizers import ByteLevel
from tokenizers.normalizers import NFC, Lowercase, Sequence as NormalizerSequence
from tokenizers.processors import ByteLevel as ByteLevelProcessor
from tokenizers.decoders import ByteLevel as ByteLevelDecoder

# 1. BPEモデルで空のトークナイザーを作成
tokenizer = Tokenizer(BPE(unk_token="<unk>"))

# 2. Normalizerの設定: Unicode NFC正規化 + 小文字化
tokenizer.normalizer = NormalizerSequence([NFC(), Lowercase()])

# 3. Pre-tokenizerの設定: ByteLevel(GPT-2方式)
tokenizer.pre_tokenizer = ByteLevel(add_prefix_space=False)

# 4. Post-processorの設定
tokenizer.post_processor = ByteLevelProcessor(trim_offsets=False)

# 5. Decoderの設定
tokenizer.decoder = ByteLevelDecoder()

# トレーナーの設定
trainer = BpeTrainer(
    vocab_size=8000,           # 語彙サイズ
    min_frequency=2,           # 最低出現頻度
    special_tokens=["<unk>", "<s>", "</s>", "<pad>", "<mask>"],
    show_progress=True
)

# 訓練データの準備(サンプルテキスト)
training_texts = [
    "自然言語処理は人工知能の重要な分野です。",
    "トークナイゼーションはテキストをトークンに分割する処理です。",
    "深層学習モデルはトークンIDの列を入力として受け取ります。",
    "BPEは最も広く使われているサブワードトークナイゼーション手法です。",
    "Transformerモデルの性能はトークナイザーの品質に大きく依存します。",
    "機械学習では大量のテキストデータを効率的に前処理する必要があります。",
    "サブワード分割は未知語の問題を効果的に解決します。",
    "ニューラルネットワークは言語の構造を自動的に学習できます。",
    "事前学習済みモデルをファインチューニングすることで高精度を達成できます。",
    "Hugging Faceのライブラリは自然言語処理の民主化に貢献しています。",
] * 100  # 訓練データを増やす

# イテレータから訓練
tokenizer.train_from_iterator(training_texts, trainer=trainer)

# 結果の確認
print(f"語彙サイズ: {tokenizer.get_vocab_size()}")

# テスト
test_text = "自然言語処理のトークナイゼーション"
output = tokenizer.encode(test_text)
print(f"\n入力テキスト: {test_text}")
print(f"トークン:     {output.tokens}")
print(f"トークンID:   {output.ids}")

このコードを実行すると、日本語テキストからBPEの語彙が構築されます。語彙サイズ8000では、頻出する日本語の文字列パターン(「自然言語処理」「トークナイゼーション」など)が一つまたは少数のトークンにまとめられ、稀なパターンはより細かいサブワードに分割されます。ByteLevelのPre-tokenizerを使用しているため、未知のUnicode文字が出現しても必ずトークナイズ可能です。語彙サイズが小さい(8000)ため、日本語のような文字種の多い言語では各文字がバイト単位に分解されることも多いですが、頻出パターンは効率的に統合されていることが確認できます。

訓練過程の可視化

BPEのマージが語彙をどのように拡張していくかを可視化してみましょう。

import matplotlib.pyplot as plt
import numpy as np
from tokenizers import Tokenizer
from tokenizers.models import BPE
from tokenizers.trainers import BpeTrainer
from tokenizers.pre_tokenizers import Whitespace

# 異なる語彙サイズでの分割結果を比較
vocab_sizes = [300, 500, 1000, 2000, 4000, 8000]
test_sentence = "The tokenization process efficiently handles unknown words through subword splitting"

# 英語のサンプルコーパス
english_corpus = [
    "The transformer model uses self-attention mechanisms for sequence processing.",
    "Natural language processing has been revolutionized by deep learning approaches.",
    "Tokenization is a fundamental preprocessing step in modern NLP pipelines.",
    "Subword tokenization effectively handles out-of-vocabulary words.",
    "The attention mechanism allows the model to focus on relevant parts of the input.",
    "Pre-trained language models can be fine-tuned for various downstream tasks.",
    "Byte pair encoding is one of the most popular subword tokenization algorithms.",
    "The vocabulary size affects both model performance and computational efficiency.",
    "Machine learning models require numerical inputs rather than raw text strings.",
    "Transfer learning enables knowledge sharing across different NLP tasks.",
] * 200

avg_tokens_list = []
vocab_actual = []

for vs in vocab_sizes:
    tok = Tokenizer(BPE(unk_token="<unk>"))
    tok.pre_tokenizer = Whitespace()
    trainer = BpeTrainer(
        vocab_size=vs,
        min_frequency=2,
        special_tokens=["<unk>", "<pad>"]
    )
    tok.train_from_iterator(english_corpus, trainer=trainer)

    encoded = tok.encode(test_sentence)
    avg_tokens_list.append(len(encoded.tokens))
    vocab_actual.append(tok.get_vocab_size())

# 可視化
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))

# 左: 語彙サイズ vs トークン数
ax1.plot(vocab_actual, avg_tokens_list, 'o-', color='#00d4ff', linewidth=2, markersize=8)
ax1.set_xlabel('Vocabulary Size', fontsize=12)
ax1.set_ylabel('Number of Tokens', fontsize=12)
ax1.set_title('Effect of Vocabulary Size on Token Count', fontsize=13)
ax1.set_xscale('log')
ax1.grid(True, alpha=0.3)
ax1.set_facecolor('#1a1a2e')
fig.patch.set_facecolor('#0d0d1a')
ax1.tick_params(colors='white')
ax1.xaxis.label.set_color('white')
ax1.yaxis.label.set_color('white')
ax1.title.set_color('white')
for spine in ax1.spines.values():
    spine.set_color('#333')

# 右: 各語彙サイズでのトークン分割結果
token_texts = []
for vs in [300, 1000, 8000]:
    tok = Tokenizer(BPE(unk_token="<unk>"))
    tok.pre_tokenizer = Whitespace()
    trainer = BpeTrainer(vocab_size=vs, min_frequency=2, special_tokens=["<unk>", "<pad>"])
    tok.train_from_iterator(english_corpus, trainer=trainer)

    short_text = "tokenization"
    encoded = tok.encode(short_text)
    token_texts.append((vs, encoded.tokens))

y_positions = [0.7, 0.5, 0.3]
ax2.set_xlim(0, 1)
ax2.set_ylim(0, 1)
ax2.set_facecolor('#1a1a2e')

for (vs, tokens), y_pos in zip(token_texts, y_positions):
    label = f"vocab={vs}: "
    token_str = " | ".join(tokens)
    ax2.text(0.05, y_pos, label, fontsize=11, color='#ffaa00',
             verticalalignment='center', fontfamily='monospace')
    ax2.text(0.35, y_pos, token_str, fontsize=11, color='#00d4ff',
             verticalalignment='center', fontfamily='monospace')

ax2.set_title('Subword Splits of "tokenization"', fontsize=13, color='white')
ax2.set_xticks([])
ax2.set_yticks([])
for spine in ax2.spines.values():
    spine.set_color('#333')

plt.tight_layout()
plt.savefig('bpe_vocab_analysis.png', dpi=150, bbox_inches='tight',
            facecolor='#0d0d1a')
plt.show()

左のグラフからは、語彙サイズが大きくなるほど同じ文が少ないトークン数で表現されることがわかります。これは対数的な関係に近く、語彙サイズを2倍にしてもトークン数の削減は線形には増えません。右の表示では、語彙サイズが300の場合は"tokenization"が多くの細かいサブワードに分割されますが、語彙サイズ8000では1つまたは少数のトークンにまとめられることが確認できます。語彙サイズの選択は「語彙の埋め込みパラメータ数」と「系列長(=計算コスト)」のトレードオフであり、実務では30,000〜50,000程度がよく使われます。

語彙サイズのトレードオフ パラメータ数 vs 系列長

上図の左パネルは語彙サイズと平均トークン数の対数的な反比例関係を示しており、実用ゾーン(30k〜50k)に向かって収穫が逓減することがわかります。右パネルでは埋め込み行列のパラメータ数(赤)と平均系列長(青)を両軸でプロットしています。語彙サイズを増やすほど埋め込みパラメータが増え(メモリ・計算コスト増)、一方で系列長が短くなります(Attention計算コスト減)。この2つの相反するコストのバランスが、実務で30k〜50kが「ゴールデンゾーン」とされる理由です。

BPEの構築が理解できたところで、次はWordPieceとUnigramについても同様にPythonで実装し、違いを実感しましょう。

WordPieceトークナイザーの訓練

WordPieceの訓練もBPEとほぼ同じインターフェースで行えます。異なるのはModelとTrainerのクラスです。

from tokenizers import Tokenizer
from tokenizers.models import WordPiece
from tokenizers.trainers import WordPieceTrainer
from tokenizers.pre_tokenizers import Whitespace
from tokenizers.normalizers import BertNormalizer

# WordPieceモデルで空のトークナイザーを作成
wp_tokenizer = Tokenizer(WordPiece(unk_token="[UNK]"))

# BERTと同じ正規化を使用
wp_tokenizer.normalizer = BertNormalizer(
    clean_text=True,
    handle_chinese_chars=True,
    strip_accents=True,
    lowercase=True
)

# 空白で事前トークン化
wp_tokenizer.pre_tokenizer = Whitespace()

# トレーナーの設定
wp_trainer = WordPieceTrainer(
    vocab_size=8000,
    min_frequency=2,
    special_tokens=["[UNK]", "[CLS]", "[SEP]", "[PAD]", "[MASK]"],
    continuing_subword_prefix="##"
)

# 訓練(BPEと同じコーパスを使用)
english_corpus = [
    "The transformer model uses self-attention mechanisms for sequence processing.",
    "Natural language processing has been revolutionized by deep learning approaches.",
    "Tokenization is a fundamental preprocessing step in modern NLP pipelines.",
    "Subword tokenization effectively handles out-of-vocabulary words.",
    "The attention mechanism allows the model to focus on relevant parts of the input.",
    "Pre-trained language models can be fine-tuned for various downstream tasks.",
    "Byte pair encoding is one of the most popular subword tokenization algorithms.",
    "The vocabulary size affects both model performance and computational efficiency.",
    "Machine learning models require numerical inputs rather than raw text strings.",
    "Transfer learning enables knowledge sharing across different NLP tasks.",
] * 200

wp_tokenizer.train_from_iterator(english_corpus, wp_trainer)

# テスト
test_text = "Tokenization preprocessing is fundamentally important"
wp_output = wp_tokenizer.encode(test_text)

print(f"語彙サイズ: {wp_tokenizer.get_vocab_size()}")
print(f"\n入力:     {test_text}")
print(f"トークン: {wp_output.tokens}")
print(f"ID:       {wp_output.ids}")

# ##プレフィックスの確認
print("\n--- WordPieceの##プレフィックス ---")
for token in wp_output.tokens:
    if token.startswith("##"):
        print(f"  {token} ← 単語の途中のサブワード")
    else:
        print(f"  {token} ← 単語の先頭")

このコードの出力を見ると、WordPiece特有の##プレフィックスが確認できます。"preprocessing"のような長い単語が"pre" + "##process" + "##ing"のように分割される場合、##が付いたトークンは「前のトークンの続きであり、独立した単語の開始ではない」ことを示しています。この仕組みにより、デコード時に単語の境界を正確に復元できます。BERTが採用するこの方式は、BPEのバイトレベルマッピングとは異なるアプローチで同じ問題を解決しています。

続いて、Unigramトークナイザーも構築して3つのモデルを比較してみましょう。

Unigramトークナイザーの訓練

from tokenizers import Tokenizer
from tokenizers.models import Unigram
from tokenizers.trainers import UnigramTrainer
from tokenizers.pre_tokenizers import Metaspace
from tokenizers.normalizers import NFC

# Unigramモデルで空のトークナイザーを作成
uni_tokenizer = Tokenizer(Unigram())

# 正規化
uni_tokenizer.normalizer = NFC()

# SentencePiece風のPre-tokenizer(▁で単語境界を表現)
uni_tokenizer.pre_tokenizer = Metaspace()

# トレーナーの設定
uni_trainer = UnigramTrainer(
    vocab_size=8000,
    special_tokens=["<unk>", "<s>", "</s>", "<pad>"],
    unk_token="<unk>"
)

# 訓練
english_corpus_extended = [
    "The transformer model uses self-attention mechanisms for sequence processing.",
    "Natural language processing has been revolutionized by deep learning approaches.",
    "Tokenization is a fundamental preprocessing step in modern NLP pipelines.",
    "Subword tokenization effectively handles out-of-vocabulary words.",
    "The attention mechanism allows the model to focus on relevant parts of the input.",
    "Pre-trained language models can be fine-tuned for various downstream tasks.",
    "Byte pair encoding is one of the most popular subword tokenization algorithms.",
    "The vocabulary size affects both model performance and computational efficiency.",
    "Machine learning models require numerical inputs rather than raw text strings.",
    "Transfer learning enables knowledge sharing across different NLP tasks.",
    "Recurrent neural networks process sequential data one step at a time.",
    "Convolutional layers extract local features from input representations.",
    "Gradient descent optimizes model parameters by minimizing the loss function.",
    "Regularization techniques prevent overfitting by constraining model complexity.",
    "Data augmentation increases training set diversity without collecting new data.",
] * 200

uni_tokenizer.train_from_iterator(english_corpus_extended, uni_trainer)

# テスト
test_text = "Tokenization preprocessing is fundamentally important"
uni_output = uni_tokenizer.encode(test_text)

print(f"語彙サイズ: {uni_tokenizer.get_vocab_size()}")
print(f"\n入力:     {test_text}")
print(f"トークン: {uni_output.tokens}")
print(f"ID:       {uni_output.ids}")

Unigramの出力を見ると、(U+2581)が単語の先頭に付与されているのが特徴的です。これはSentencePieceのMetaspace方式で、WordPieceの##とは逆のアプローチ — 「単語の途中」ではなく「単語の先頭」をマーキングする方式です。Unigramの場合、確率モデルに基づく最適な分割が行われるため、BPEやWordPieceとは異なるサブワードの切れ目になることがあります。

3つのモデルの分割結果を比較

3つのモデルが同じテキストをどのように分割するか、並べて比較してみましょう。

import matplotlib.pyplot as plt
import matplotlib
matplotlib.rcParams['font.family'] = ['Arial', 'Helvetica', 'DejaVu Sans']

# 3つのトークナイザーで同じテキストをエンコード
test_sentences = [
    "tokenization",
    "preprocessing",
    "fundamentally",
    "computational",
    "revolutionized"
]

fig, ax = plt.subplots(figsize=(16, 8))
ax.set_facecolor('#1a1a2e')
fig.patch.set_facecolor('#0d0d1a')

models = {
    "BPE": None,     # 前のセクションで構築済みと仮定
    "WordPiece": wp_tokenizer,
    "Unigram": uni_tokenizer
}

# BPEトークナイザーを再構築
from tokenizers import Tokenizer as Tok
from tokenizers.models import BPE as BPEModel
from tokenizers.trainers import BpeTrainer as BPETr
from tokenizers.pre_tokenizers import Whitespace as WS

bpe_tok = Tok(BPEModel(unk_token="<unk>"))
bpe_tok.pre_tokenizer = WS()
bpe_tr = BPETr(vocab_size=8000, min_frequency=2, special_tokens=["<unk>", "<pad>"])
bpe_tok.train_from_iterator(english_corpus, bpe_tr)

tokenizers_dict = {
    "BPE": bpe_tok,
    "WordPiece": wp_tokenizer,
    "Unigram": uni_tokenizer
}

colors = {"BPE": "#00d4ff", "WordPiece": "#ff6b6b", "Unigram": "#51cf66"}

y_base = len(test_sentences) - 1
row_height = 1.0

for i, word in enumerate(test_sentences):
    y = (len(test_sentences) - 1 - i) * (row_height * 3.5) + 1.5

    # 単語ラベル
    ax.text(0.5, y + row_height * 2, f'"{word}"', fontsize=13,
            color='white', fontweight='bold', fontfamily='monospace')

    for j, (name, tok) in enumerate(tokenizers_dict.items()):
        y_row = y + (2 - j) * row_height * 0.7
        encoded = tok.encode(word)
        tokens = encoded.tokens

        # モデル名
        ax.text(0.5, y_row, f"{name}:", fontsize=10, color=colors[name],
                fontweight='bold')

        # トークン表示
        token_str = " | ".join(tokens)
        ax.text(4.0, y_row, token_str, fontsize=10, color=colors[name],
                fontfamily='monospace')

        # トークン数
        ax.text(15.0, y_row, f"({len(tokens)} tokens)", fontsize=9,
                color='#888888')

ax.set_xlim(0, 17)
ax.set_ylim(0, len(test_sentences) * 3.5 + 1)
ax.set_xticks([])
ax.set_yticks([])
ax.set_title('Comparison of BPE, WordPiece, and Unigram Tokenization',
             fontsize=14, color='white', pad=20)
for spine in ax.spines.values():
    spine.set_color('#333')

plt.tight_layout()
plt.savefig('tokenizer_comparison.png', dpi=150, bbox_inches='tight',
            facecolor='#0d0d1a')
plt.show()

この比較から興味深い違いが読み取れます。同じ語彙サイズ(8000)で訓練しても、3つのモデルは同じ単語を異なる位置で分割します。BPEは頻度ベースのマージルールに従うため、コーパス中での共起頻度が直接分割に影響します。WordPieceは尤度スコアに基づくため、より「統計的に意味のある」境界で分割される傾向があります。Unigramは確率的に最適な分割を選ぶため、言語モデルとしての整合性が高い分割になります。ただし、小さなコーパスでは差が顕著に出ないこともあり、大規模コーパスでの訓練時に各モデルの特性がより明確になります。

3つのモデルの理論と実装が理解できたところで、次は実用的なカスタムトークナイザーの構築に進みます。特殊トークンの設定やテンプレート処理など、実際のTransformerモデルと組み合わせるために必要な設定を見ていきましょう。

特殊トークンとテンプレート処理

特殊トークンの役割

Transformerモデルでは、入力テキストに特殊トークンを付与して、モデルにテキストの構造を伝えます。主要な特殊トークンとその役割を整理しましょう。

特殊トークン 用途 使用モデル
[CLS] / <s> 分類タスクの文表現を集約する先頭トークン BERT / RoBERTa
[SEP] / </s> 文の区切りを示す末尾トークン BERT / RoBERTa
[PAD] / <pad> バッチ内の系列長を揃えるためのパディング 全モデル共通
[MASK] / <mask> マスク言語モデルの穴埋め位置を示す BERT / RoBERTa
[UNK] / <unk> 語彙に存在しないトークンの代替 全モデル共通

テンプレート処理の設定

Post-processorは、Modelステージの出力に特殊トークンを自動挿入する仕組みです。BERT風のテンプレートを設定してみましょう。

from tokenizers import Tokenizer
from tokenizers.models import WordPiece
from tokenizers.trainers import WordPieceTrainer
from tokenizers.pre_tokenizers import Whitespace
from tokenizers.processors import TemplateProcessing

# WordPieceトークナイザーを構築
tokenizer = Tokenizer(WordPiece(unk_token="[UNK]"))
tokenizer.pre_tokenizer = Whitespace()

trainer = WordPieceTrainer(
    vocab_size=8000,
    special_tokens=["[UNK]", "[CLS]", "[SEP]", "[PAD]", "[MASK]"],
    continuing_subword_prefix="##"
)

corpus = [
    "The quick brown fox jumps over the lazy dog.",
    "Machine learning models process text as numerical tokens.",
    "Attention mechanisms enable transformers to capture long-range dependencies.",
    "Fine-tuning pre-trained models yields state-of-the-art results.",
    "Natural language understanding requires both syntax and semantics.",
] * 500

tokenizer.train_from_iterator(corpus, trainer)

# BERT風テンプレートの設定
# 単一文: [CLS] text [SEP]
# 文ペア: [CLS] text_A [SEP] text_B [SEP]
tokenizer.post_processor = TemplateProcessing(
    single="[CLS] $A [SEP]",
    pair="[CLS] $A [SEP] $B:1 [SEP]:1",
    special_tokens=[
        ("[CLS]", tokenizer.token_to_id("[CLS]")),
        ("[SEP]", tokenizer.token_to_id("[SEP]")),
    ],
)

# 単一文のエンコード
text = "Attention is all you need"
output = tokenizer.encode(text)
print("--- 単一文エンコード ---")
print(f"入力:       {text}")
print(f"トークン:   {output.tokens}")
print(f"ID:         {output.ids}")
print(f"Type IDs:   {output.type_ids}")

# 文ペアのエンコード
text_a = "What is attention"
text_b = "Attention is a mechanism"
output_pair = tokenizer.encode(text_a, text_b)
print("\n--- 文ペアエンコード ---")
print(f"テキストA:  {text_a}")
print(f"テキストB:  {text_b}")
print(f"トークン:   {output_pair.tokens}")
print(f"ID:         {output_pair.ids}")
print(f"Type IDs:   {output_pair.type_ids}")
print(f"Attn Mask:  {output_pair.attention_mask}")

この出力で注目すべきはType IDs(Token Type IDs)です。単一文の場合は全て0ですが、文ペアの場合はテキストAが0、テキストBが1になります。BERTのNSP(Next Sentence Prediction)タスクでは、この情報を使って2つの文を区別します。TemplateProcessing$B:1という記法が、テキストBのToken Type IDを1に設定することを意味しています。

特殊トークンとBERTテンプレート処理

上図の上段は単一文エンコードの構造で、[CLS][SEP](赤)が両端に自動挿入されています。下段は文ペアエンコードで、テキストA(水色)とテキストB(緑)が[SEP]を挟んで並び、Type IDが0と1に分かれています。##付きトークン(紫)は単語の途中のサブワードであることを示しており、デコード時に前のトークンと結合して元の単語を復元します。分類タスクでは[CLS]の最終隠れ状態を、QA・NLIなどの文ペアタスクではType IDを活用することが重要です。

attention_maskは、パディングトークンを無視するためのマスクです。ここでは全て1(有効なトークン)ですが、バッチ処理でパディングが追加されると0の部分が現れます。

パディングとトランケーションの設定

バッチ処理に不可欠なパディングとトランケーションの設定も確認しましょう。

from tokenizers import Tokenizer
from tokenizers.models import WordPiece
from tokenizers.trainers import WordPieceTrainer
from tokenizers.pre_tokenizers import Whitespace
from tokenizers.processors import TemplateProcessing

# 前のセクションで構築したトークナイザーを想定
# パディングの有効化
tokenizer.enable_padding(
    pad_id=tokenizer.token_to_id("[PAD]"),
    pad_token="[PAD]",
    length=15  # 固定長にする場合(Noneで最大長に合わせる)
)

# トランケーションの有効化
tokenizer.enable_truncation(max_length=15)

# バッチエンコード
texts = [
    "Short text",
    "A somewhat longer piece of text for testing",
    "Medium length text"
]

outputs = tokenizer.encode_batch(texts)

print("--- パディング・トランケーション ---")
for text, output in zip(texts, outputs):
    print(f"\n入力: {text}")
    print(f"トークン:     {output.tokens}")
    print(f"Attn Mask:    {output.attention_mask}")
    # パディング部分を確認
    pad_count = output.attention_mask.count(0)
    print(f"パディング数: {pad_count}")

パディングの結果を見ると、短いテキストには末尾に[PAD]トークンが追加され、attention_maskの対応する位置が0になっています。一方、長すぎるテキストはmax_length=15で切り詰められています。attention_mask1の位置だけがSelf-Attentionの計算に含まれるため、パディングトークンがモデルの予測に影響を与えることはありません。

パディング戦略 固定長 vs 動的パディング

上図の左パネル(固定長パディング)では、短いテキストほど赤いPADトークンが多く占める様子がわかります。右パネル(動的パディング)では、バッチ内の最長テキストに合わせるだけなのでPADが大幅に削減されます。この例では固定長比で大幅なトークン数の節約が実現しており、これが訓練のGPU演算削減に直接つながります。さらに高度な最適化として、テキストを長さ順にソートしてからバッチを組む「Smart Batching」を組み合わせると、バッチ内のPADをほぼゼロにできます。

特殊トークンの設定ができたところで、次はバッチ処理の性能最適化について詳しく見ていきましょう。Rustバックエンドの並列処理性能を最大限に引き出す方法を解説します。

バッチ処理と性能最適化

並列バッチエンコーディング

Hugging Face Tokenizersの最大の強みの一つが、バッチ処理の並列化です。encode_batchメソッドは内部でRustの並列処理を活用し、複数のテキストを同時にトークナイズします。

import time
import numpy as np
import matplotlib.pyplot as plt
from tokenizers import Tokenizer
from tokenizers.models import BPE
from tokenizers.trainers import BpeTrainer
from tokenizers.pre_tokenizers import ByteLevel
from tokenizers.processors import ByteLevel as ByteLevelProcessor
from tokenizers.decoders import ByteLevel as ByteLevelDecoder

# BPEトークナイザーを訓練
tokenizer = Tokenizer(BPE(unk_token="<unk>"))
tokenizer.pre_tokenizer = ByteLevel(add_prefix_space=False)
tokenizer.post_processor = ByteLevelProcessor(trim_offsets=False)
tokenizer.decoder = ByteLevelDecoder()

trainer = BpeTrainer(
    vocab_size=5000,
    min_frequency=2,
    special_tokens=["<unk>", "<s>", "</s>", "<pad>"]
)

# 訓練コーパス
corpus = [
    "Natural language processing enables computers to understand human language.",
    "Deep learning models have achieved remarkable results across various tasks.",
    "Transfer learning reduces the need for large task-specific datasets.",
    "The attention mechanism is the core component of the transformer architecture.",
    "Tokenization converts raw text into numerical representations for neural networks.",
] * 500

tokenizer.train_from_iterator(corpus, trainer)

# ベンチマーク用テキスト生成
np.random.seed(42)
sample_texts = [
    "This is a sample sentence for benchmarking tokenization speed and throughput. " * np.random.randint(1, 5)
    for _ in range(10000)
]

# 1. 逐次処理(1つずつエンコード)
start = time.perf_counter()
for text in sample_texts:
    tokenizer.encode(text)
sequential_time = time.perf_counter() - start

# 2. バッチ処理(全てまとめてエンコード)
start = time.perf_counter()
tokenizer.encode_batch(sample_texts)
batch_time = time.perf_counter() - start

print(f"テキスト数:   {len(sample_texts)}")
print(f"逐次処理:     {sequential_time:.3f}秒")
print(f"バッチ処理:   {batch_time:.3f}秒")
print(f"速度向上比:   {sequential_time / batch_time:.1f}x")

# バッチサイズごとの処理時間を計測
batch_sizes = [100, 500, 1000, 2000, 5000, 10000]
batch_times = []
throughputs = []

for bs in batch_sizes:
    texts = sample_texts[:bs]

    # 3回計測して中央値を取る
    times = []
    for _ in range(3):
        start = time.perf_counter()
        tokenizer.encode_batch(texts)
        elapsed = time.perf_counter() - start
        times.append(elapsed)

    median_time = np.median(times)
    batch_times.append(median_time)
    throughputs.append(bs / median_time)

# 可視化
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))
fig.patch.set_facecolor('#0d0d1a')

# 左: バッチサイズ vs 処理時間
ax1.plot(batch_sizes, batch_times, 'o-', color='#00d4ff', linewidth=2, markersize=8)
ax1.set_xlabel('Batch Size', fontsize=12, color='white')
ax1.set_ylabel('Processing Time (sec)', fontsize=12, color='white')
ax1.set_title('Batch Size vs Processing Time', fontsize=13, color='white')
ax1.grid(True, alpha=0.3)
ax1.set_facecolor('#1a1a2e')
ax1.tick_params(colors='white')
for spine in ax1.spines.values():
    spine.set_color('#333')

# 右: バッチサイズ vs スループット
ax2.plot(batch_sizes, throughputs, 's-', color='#51cf66', linewidth=2, markersize=8)
ax2.set_xlabel('Batch Size', fontsize=12, color='white')
ax2.set_ylabel('Throughput (texts/sec)', fontsize=12, color='white')
ax2.set_title('Batch Size vs Throughput', fontsize=13, color='white')
ax2.grid(True, alpha=0.3)
ax2.set_facecolor('#1a1a2e')
ax2.tick_params(colors='white')
for spine in ax2.spines.values():
    spine.set_color('#333')

plt.tight_layout()
plt.savefig('batch_performance.png', dpi=150, bbox_inches='tight',
            facecolor='#0d0d1a')
plt.show()

この性能ベンチマークからは、バッチ処理が逐次処理と比べて大幅に高速であることがわかります。Rustのrayonによる自動並列化が、CPUの全コアを活用して複数テキストを同時にトークナイズするためです。スループット(1秒あたりの処理テキスト数)のグラフでは、バッチサイズが一定以上になるとスループットが安定する(飽和する)ことが確認できます。これはCPUコア数による並列度の上限に達するためであり、この飽和点を超えるバッチサイズでは、メモリ効率だけを考慮すれば十分です。

Rustバックエンド バッチ処理スループット

上図の左パネルは逐次処理とバッチ処理の処理時間を比較しています。件数が増えるほど差が広がり、バッチ処理の優位性は1万件規模で特に顕著になります。右パネルのスループット曲線は、バッチサイズが増えるほど1件あたりのオーバーヘッドが下がり、一定以上のバッチサイズで飽和することを示しています。この飽和点がCPUのコア数と内部キューサイズに依存しており、実務では「メモリに収まる最大バッチサイズ」でencode_batchを呼ぶのが最も効率的です。

動的パディング戦略

実際のTransformerモデル訓練では、パディングの戦略も性能に大きく影響します。固定長パディングでは、短いテキストに大量の無駄な[PAD]が付与され、GPU計算の無駄になります。これを回避する動的パディング(バッチ内最大長に合わせるパディング)を見てみましょう。

from tokenizers import Tokenizer
from tokenizers.models import BPE
from tokenizers.trainers import BpeTrainer
from tokenizers.pre_tokenizers import ByteLevel
from tokenizers.processors import ByteLevel as ByteLevelProcessor
from tokenizers.decoders import ByteLevel as ByteLevelDecoder
import numpy as np

# トークナイザー構築(省略形)
tokenizer = Tokenizer(BPE(unk_token="<unk>"))
tokenizer.pre_tokenizer = ByteLevel(add_prefix_space=False)
tokenizer.post_processor = ByteLevelProcessor(trim_offsets=False)
tokenizer.decoder = ByteLevelDecoder()

trainer = BpeTrainer(
    vocab_size=5000, min_frequency=2,
    special_tokens=["<unk>", "<s>", "</s>", "<pad>"]
)
corpus = ["This is sample text for training the tokenizer. " * 3] * 1000
tokenizer.train_from_iterator(corpus, trainer)

# 動的パディング: バッチ内の最大長に合わせる
tokenizer.enable_padding(
    pad_id=tokenizer.token_to_id("<pad>"),
    pad_token="<pad>",
    direction="right",
    length=None  # Noneでバッチ内最大長に自動調整
)

# テスト用バッチ
texts = [
    "Short.",
    "A medium length sentence for testing.",
    "This is the longest sentence in this batch and determines the padding length for all others."
]

outputs = tokenizer.encode_batch(texts)

print("--- 動的パディングの結果 ---")
for text, output in zip(texts, outputs):
    n_tokens = sum(output.attention_mask)
    n_pad = len(output.attention_mask) - n_tokens
    total = len(output.tokens)
    print(f"入力: {text[:40]:40s} | 有効トークン: {n_tokens:2d} | PAD: {n_pad:2d} | 合計: {total}")

# 固定長パディングとの比較
print("\n--- 固定長パディング(max_length=50)との比較 ---")
tokenizer.enable_padding(
    pad_id=tokenizer.token_to_id("<pad>"),
    pad_token="<pad>",
    length=50
)

outputs_fixed = tokenizer.encode_batch(texts)
total_tokens_fixed = sum(len(o.tokens) for o in outputs_fixed)

# 動的パディングに戻す
tokenizer.enable_padding(
    pad_id=tokenizer.token_to_id("<pad>"),
    pad_token="<pad>",
    length=None
)
outputs_dynamic = tokenizer.encode_batch(texts)
total_tokens_dynamic = sum(len(o.tokens) for o in outputs_dynamic)

print(f"固定長パディング: 合計 {total_tokens_fixed} トークン")
print(f"動的パディング:   合計 {total_tokens_dynamic} トークン")
print(f"削減率:           {(1 - total_tokens_dynamic / total_tokens_fixed) * 100:.1f}%")

この比較結果から、動的パディングがいかに効率的かがわかります。固定長パディング(max_length=50)では短いテキストに大量の[PAD]が付与されますが、動的パディングではバッチ内の最長テキストに合わせるだけで済みます。実際のTransformer訓練では、この差が累積して計算コストに顕著に影響します。さらに高度な最適化として、テキストの長さでソートしてからバッチを作る「Smart Batching」手法もあり、これにより同じバッチ内のテキスト長のばらつきを最小化できます。

バッチ処理の最適化がわかったところで、次は構築したカスタムトークナイザーをHugging Face Transformersエコシステムと連携させる方法を見ていきましょう。

AutoTokenizerとの連携

AutoTokenizer PreTrainedTokenizerFast 連携フロー

上図はカスタムトークナイザーがTransformersエコシステムに統合されるまでの2つのフロー(上段:訓練・保存フロー、下段:推論フロー)を示しています。訓練フローではTokenizerオブジェクトをJSONに保存し、PreTrainedTokenizerFastでラップしてHub(またはローカル)に保存します。推論フローではAutoTokenizer.from_pretrained()で読み込むだけで、input_idsattention_maskをPyTorchテンソルとしてGPUに渡せる状態になります。このシームレスな連携が、Hugging Faceエコシステムを「書いたらすぐ使える」ものにしている設計の核心です。

トークナイザーの保存とロード

構築したカスタムトークナイザーは、JSON形式で保存して後から再利用できます。さらに、Hugging Face TransformersのPreTrainedTokenizerFastでラップすることで、AutoTokenizerからシームレスにロードできるようになります。

from tokenizers import Tokenizer
from tokenizers.models import BPE
from tokenizers.trainers import BpeTrainer
from tokenizers.pre_tokenizers import ByteLevel
from tokenizers.processors import TemplateProcessing
from tokenizers.decoders import ByteLevel as ByteLevelDecoder
import json
import os

# BPEトークナイザーを構築・訓練
tokenizer = Tokenizer(BPE(unk_token="<unk>"))
tokenizer.pre_tokenizer = ByteLevel(add_prefix_space=False)
tokenizer.decoder = ByteLevelDecoder()

trainer = BpeTrainer(
    vocab_size=5000, min_frequency=2,
    special_tokens=["<unk>", "<s>", "</s>", "<pad>", "<mask>"]
)

corpus = [
    "Natural language processing with transformers.",
    "Tokenization is the first step in any NLP pipeline.",
    "Custom tokenizers can be trained on domain-specific data.",
    "The Hugging Face ecosystem provides powerful tools for NLP.",
    "Pre-training and fine-tuning are the two phases of transfer learning.",
] * 500

tokenizer.train_from_iterator(corpus, trainer)

# テンプレートの設定
tokenizer.post_processor = TemplateProcessing(
    single="<s> $A </s>",
    pair="<s> $A  $B:1 </s>:1",
    special_tokens=[
        ("<s>", tokenizer.token_to_id("<s>")),
        ("</s>", tokenizer.token_to_id("</s>")),
    ],
)

# JSON形式で保存
save_dir = "./my_custom_tokenizer"
os.makedirs(save_dir, exist_ok=True)
tokenizer.save(os.path.join(save_dir, "tokenizer.json"))
print(f"トークナイザーを {save_dir}/tokenizer.json に保存しました")

# 再ロード
loaded_tokenizer = Tokenizer.from_file(os.path.join(save_dir, "tokenizer.json"))
test = loaded_tokenizer.encode("Test tokenization")
print(f"ロード後のテスト: {test.tokens}")

保存されたJSONファイルには、5段パイプラインの全てのコンポーネント(Normalizer、Pre-tokenizer、Model、Post-processor、Decoder)の設定と、学習された語彙(BPEの場合はマージルールも含む)が格納されます。このファイル一つでトークナイザーを完全に再現できるため、バージョン管理やチームでの共有も容易です。

PreTrainedTokenizerFastでラップする

Hugging Face Transformersの各種パイプライン(pipeline()Trainerなど)と連携するには、PreTrainedTokenizerFastでラップする必要があります。

from tokenizers import Tokenizer
from transformers import PreTrainedTokenizerFast

# 保存済みのトークナイザーを読み込み
# (前のコードで保存した tokenizer.json を使用)
fast_tokenizer = PreTrainedTokenizerFast(
    tokenizer_file="./my_custom_tokenizer/tokenizer.json",
    bos_token="<s>",
    eos_token="</s>",
    unk_token="<unk>",
    pad_token="<pad>",
    mask_token="<mask>"
)

# Transformers形式で使用
encoded = fast_tokenizer(
    "Custom tokenizer with Transformers integration",
    padding="max_length",
    max_length=20,
    truncation=True,
    return_tensors="pt"  # PyTorchテンソルで返す
)

print("--- PreTrainedTokenizerFast出力 ---")
print(f"input_ids:      {encoded['input_ids']}")
print(f"attention_mask:  {encoded['attention_mask']}")
print(f"shape:           {encoded['input_ids'].shape}")

# デコード
decoded = fast_tokenizer.decode(encoded['input_ids'][0], skip_special_tokens=True)
print(f"\nデコード結果: {decoded}")

# バッチエンコード
texts = [
    "First sentence",
    "Second longer sentence for testing",
    "Third"
]
batch_encoded = fast_tokenizer(
    texts,
    padding=True,
    truncation=True,
    max_length=20,
    return_tensors="pt"
)
print(f"\nバッチ input_ids shape: {batch_encoded['input_ids'].shape}")

# save_pretrainedで保存(Transformers標準形式)
fast_tokenizer.save_pretrained("./my_custom_tokenizer_hf")
print("\nHugging Face形式で保存しました")

PreTrainedTokenizerFastでラップすることで、return_tensors="pt"によるPyTorchテンソルの直接返却、padding/truncationの柔軟な指定、save_pretrained()/from_pretrained()による標準的な保存・読み込みが可能になります。出力のinput_idsテンソルの形状は(batch_size, seq_length)であり、そのままTransformerモデルのforward()に渡せます。attention_maskも同じ形状で、パディング位置が0になっているため、モデルはこのマスクを使ってパディングを無視できます。

既存のAutoTokenizerとの比較

最後に、自作のトークナイザーと事前学習済みトークナイザーの分割結果を比較して、ドメイン特化の効果を確認しましょう。

from transformers import AutoTokenizer
import matplotlib.pyplot as plt

# 事前学習済みトークナイザーのロード
bert_tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
gpt2_tokenizer = AutoTokenizer.from_pretrained("gpt2")

# 技術文書の比較テスト
technical_texts = [
    "The self-attention mechanism computes scaled dot-product attention.",
    "Backpropagation through time handles sequential dependencies.",
    "Convolutional neural networks extract hierarchical features.",
]

print("=" * 70)
print("トークナイザー比較: 事前学習済み vs カスタム")
print("=" * 70)

for text in technical_texts:
    print(f"\n入力: {text}")

    # BERT
    bert_tokens = bert_tokenizer.tokenize(text)
    print(f"  BERT ({len(bert_tokens)} tokens): {bert_tokens[:10]}...")

    # GPT-2
    gpt2_tokens = gpt2_tokenizer.tokenize(text)
    print(f"  GPT-2 ({len(gpt2_tokens)} tokens): {gpt2_tokens[:10]}...")

# トークン数の統計を可視化
fig, ax = plt.subplots(figsize=(10, 5))
fig.patch.set_facecolor('#0d0d1a')
ax.set_facecolor('#1a1a2e')

test_texts = [
    "Machine learning model training pipeline",
    "Natural language processing and understanding",
    "Transformer architecture with multi-head attention",
    "Gradient descent optimization algorithm",
    "Recurrent neural network sequence modeling",
]

bert_counts = [len(bert_tokenizer.tokenize(t)) for t in test_texts]
gpt2_counts = [len(gpt2_tokenizer.tokenize(t)) for t in test_texts]

x = range(len(test_texts))
width = 0.35

bars1 = ax.bar([i - width/2 for i in x], bert_counts, width,
               label='BERT (WordPiece)', color='#ff6b6b', alpha=0.8)
bars2 = ax.bar([i + width/2 for i in x], gpt2_counts, width,
               label='GPT-2 (BPE)', color='#00d4ff', alpha=0.8)

ax.set_xlabel('Test Sentences', fontsize=12, color='white')
ax.set_ylabel('Number of Tokens', fontsize=12, color='white')
ax.set_title('Token Count Comparison: BERT vs GPT-2', fontsize=13, color='white')
ax.set_xticks(list(x))
ax.set_xticklabels([f'Text {i+1}' for i in x], color='white')
ax.tick_params(colors='white')
ax.legend(fontsize=10)
for spine in ax.spines.values():
    spine.set_color('#333')

# 各バーの上にトークン数を表示
for bar in bars1:
    height = bar.get_height()
    ax.text(bar.get_x() + bar.get_width()/2., height + 0.1,
            f'{int(height)}', ha='center', va='bottom', color='white', fontsize=9)
for bar in bars2:
    height = bar.get_height()
    ax.text(bar.get_x() + bar.get_width()/2., height + 0.1,
            f'{int(height)}', ha='center', va='bottom', color='white', fontsize=9)

plt.tight_layout()
plt.savefig('tokenizer_count_comparison.png', dpi=150, bbox_inches='tight',
            facecolor='#0d0d1a')
plt.show()

この比較結果からは、BERTとGPT-2でトークン数に差が生じることがわかります。BERTのWordPieceは##プレフィックスで単語内のサブワードを表現するため、GPT-2のBPE(ByteLevel)とは異なる分割結果になります。一般的に、語彙サイズが大きいほど(BERTは30,522、GPT-2は50,257)トークン数は少なくなる傾向がありますが、語彙の構築に使われたコーパスの違いも影響します。ドメイン特化トークナイザーを訓練する場合、対象ドメインのテキストでBPEやWordPieceを訓練することで、そのドメインの専門用語を効率的にトークン化できるようになります。

実践的なカスタムトークナイザー構築例

ここまでの知識を統合して、実践的なカスタムトークナイザーの構築例を見ていきましょう。プログラミング言語のソースコードに特化したトークナイザーを作成します。ソースコードはインデント、記号、キャメルケースなど、自然言語とは異なる特性を持つため、汎用トークナイザーでは非効率な分割が生じがちです。

from tokenizers import Tokenizer, pre_tokenizers, normalizers
from tokenizers.models import BPE
from tokenizers.trainers import BpeTrainer
from tokenizers.pre_tokenizers import (
    ByteLevel, Digits, Sequence as PreTokenizerSequence
)
from tokenizers.processors import TemplateProcessing
from tokenizers.decoders import ByteLevel as ByteLevelDecoder

# ソースコード特化トークナイザー
code_tokenizer = Tokenizer(BPE(unk_token="<unk>"))

# Pre-tokenizer: 数字を個別に分割 + ByteLevel
code_tokenizer.pre_tokenizer = PreTokenizerSequence([
    Digits(individual_digits=True),  # 数字を1桁ずつ分割
    ByteLevel(add_prefix_space=False)
])

code_tokenizer.decoder = ByteLevelDecoder()

# 特殊トークンにコード関連のものを追加
special_tokens = [
    "<unk>", "<s>", "</s>", "<pad>", "<mask>",
    "<|endoftext|>",  # テキスト終端
    "<|code|>",       # コード開始マーカー
    "<|indent|>",     # インデントマーカー
]

trainer = BpeTrainer(
    vocab_size=10000,
    min_frequency=2,
    special_tokens=special_tokens,
    show_progress=True
)

# Python風のコードコーパス
code_corpus = [
    "def train_model(data, epochs=10):",
    "    model = NeuralNetwork(layers=[64, 32, 16])",
    "    optimizer = Adam(lr=0.001)",
    "    for epoch in range(epochs):",
    "        loss = model.forward(data)",
    "        loss.backward()",
    "        optimizer.step()",
    "    return model",
    "",
    "class TransformerBlock(nn.Module):",
    "    def __init__(self, d_model=512, n_heads=8):",
    "        super().__init__()",
    "        self.attention = MultiHeadAttention(d_model, n_heads)",
    "        self.feed_forward = FeedForward(d_model)",
    "        self.norm1 = LayerNorm(d_model)",
    "        self.norm2 = LayerNorm(d_model)",
    "",
    "    def forward(self, x, mask=None):",
    "        attended = self.attention(x, x, x, mask)",
    "        x = self.norm1(x + attended)",
    "        fed_forward = self.feed_forward(x)",
    "        x = self.norm2(x + fed_forward)",
    "        return x",
    "",
    "import numpy as np",
    "import torch",
    "from torch import nn",
    "from transformers import AutoModel, AutoTokenizer",
    "",
    "def compute_attention(query, key, value, mask=None):",
    "    d_k = query.size(-1)",
    "    scores = torch.matmul(query, key.transpose(-2, -1))",
    "    scores = scores / np.sqrt(d_k)",
    "    if mask is not None:",
    "        scores = scores.masked_fill(mask == 0, -1e9)",
    "    weights = torch.softmax(scores, dim=-1)",
    "    return torch.matmul(weights, value)",
] * 200

code_tokenizer.train_from_iterator(code_corpus, trainer)

# テスト: コードスニペットのトークナイズ
test_code = "def forward(self, x, mask=None):"
output = code_tokenizer.encode(test_code)
print(f"語彙サイズ: {code_tokenizer.get_vocab_size()}")
print(f"\n入力: {test_code}")
print(f"トークン: {output.tokens}")
print(f"トークン数: {len(output.tokens)}")

# デコードの確認
decoded = code_tokenizer.decode(output.ids)
print(f"デコード: {decoded}")
print(f"完全復元: {decoded == test_code}")

# 別のコード例
test_code2 = "scores = torch.matmul(query, key.transpose(-2, -1))"
output2 = code_tokenizer.encode(test_code2)
print(f"\n入力: {test_code2}")
print(f"トークン: {output2.tokens}")
print(f"トークン数: {len(output2.tokens)}")

ソースコード特化トークナイザーの結果を見ると、defselfreturnimportなどのPythonキーワードが1トークンにまとめられていることが確認できます。Digits(individual_digits=True)を指定しているため、512のような数値は512と個別の桁に分割されます。これはソースコード処理で一般的なアプローチで、モデルが数値の各桁を個別に学習できるようにする効果があります。デコード結果が元のコードと完全に一致することも重要なポイントで、トークナイズ→デコードの往復変換(roundtrip)が保証されています。

オフセットマッピングの活用

トークナイザーが保持するオフセット情報を使えば、各トークンが元のテキストのどの位置に対応するかを追跡できます。これは固有表現抽出(NER)や質問応答タスクで不可欠な機能です。

from tokenizers import Tokenizer
from tokenizers.models import BPE
from tokenizers.trainers import BpeTrainer
from tokenizers.pre_tokenizers import Whitespace

# シンプルなトークナイザーで確認
tokenizer = Tokenizer(BPE(unk_token="<unk>"))
tokenizer.pre_tokenizer = Whitespace()
trainer = BpeTrainer(
    vocab_size=5000, min_frequency=2,
    special_tokens=["<unk>", "<pad>"]
)
corpus = [
    "The transformer architecture was introduced in the paper Attention Is All You Need.",
    "BERT uses bidirectional self-attention for language understanding.",
    "Tokenization maps text to integer sequences for neural network processing.",
] * 500
tokenizer.train_from_iterator(corpus, trainer)

# オフセットの確認
text = "Tokenization is fundamental"
output = tokenizer.encode(text)

print("--- オフセットマッピング ---")
print(f"入力テキスト: {text}")
print(f"{'トークン':15s} {'ID':5s} {'開始':5s} {'終了':5s} {'元テキスト':15s}")
print("-" * 50)

for token, token_id, (start, end) in zip(output.tokens, output.ids, output.offsets):
    original = text[start:end]
    print(f"{token:15s} {token_id:<5d} {start:<5d} {end:<5d} {original:15s}")

オフセットマッピングの出力から、各トークンが元のテキストの何文字目から何文字目に対応するかが正確にわかります。サブワード分割が行われた場合でも、複数のトークンが元の一つの単語の異なる部分に対応していることが確認できます。この情報は、モデルの予測結果を元のテキストにマッピングする際に不可欠です。例えば、NERモデルがB-PERタグを予測した場合、そのトークンに対応する元テキストの範囲をオフセットから特定して、固有表現の文字列を抽出します。

オフセットマッピング トークンと元テキストの対応

上図は"Hugging Face Tokenizer maps text to IDs."という文を例に、各トークンのオフセット(文字位置)とNERタグを一覧しています。Hugging(B-ORG)とFace(I-ORG)が固有表現として検出されており、オフセット情報(0〜7、8〜12)を使えば元のテキストからその範囲を正確に切り出せます。##izerのようなWordPieceサブワードはTokenizerの後半部分(文字位置18〜24)に対応しており、前のトークンTokenと合わせることで元の単語を復元できます。このオフセット追跡機能がFast Tokenizerが遅いSlowTokenizerより優先される理由の一つです。

Hugging Face Datasetsとの統合

大規模データセットを効率的にトークナイズするには、Hugging Face datasetsライブラリとの統合が有効です。datasetsmapメソッドとTokenizersのバッチ処理を組み合わせることで、メモリ効率の良い並列処理が実現できます。

from datasets import Dataset
from transformers import PreTrainedTokenizerFast
from tokenizers import Tokenizer
from tokenizers.models import BPE
from tokenizers.trainers import BpeTrainer
from tokenizers.pre_tokenizers import ByteLevel
from tokenizers.decoders import ByteLevel as ByteLevelDecoder
from tokenizers.processors import TemplateProcessing
import time

# トークナイザーの構築
tokenizer = Tokenizer(BPE(unk_token="<unk>"))
tokenizer.pre_tokenizer = ByteLevel(add_prefix_space=False)
tokenizer.decoder = ByteLevelDecoder()

trainer = BpeTrainer(
    vocab_size=5000, min_frequency=2,
    special_tokens=["<unk>", "<s>", "</s>", "<pad>"]
)

corpus = [
    "The quick brown fox jumps over the lazy dog.",
    "Machine learning is transforming the world of natural language processing.",
    "Deep neural networks can learn complex patterns from data.",
    "Transfer learning enables efficient model adaptation to new tasks.",
    "Attention mechanisms are the foundation of modern language models.",
] * 500

tokenizer.train_from_iterator(corpus, trainer)

tokenizer.post_processor = TemplateProcessing(
    single="<s> $A </s>",
    special_tokens=[
        ("<s>", tokenizer.token_to_id("<s>")),
        ("</s>", tokenizer.token_to_id("</s>")),
    ],
)

# PreTrainedTokenizerFastでラップ
fast_tokenizer = PreTrainedTokenizerFast(
    tokenizer_object=tokenizer,
    bos_token="<s>",
    eos_token="</s>",
    unk_token="<unk>",
    pad_token="<pad>",
)

# サンプルデータセットの作成
texts = [
    f"This is sample document number {i}. It contains text for testing tokenization. "
    f"The transformer model processes this text efficiently."
    for i in range(5000)
]
dataset = Dataset.from_dict({"text": texts})

# トークナイズ関数
def tokenize_function(examples):
    return fast_tokenizer(
        examples["text"],
        padding="max_length",
        truncation=True,
        max_length=64
    )

# バッチ処理でトークナイズ
start = time.perf_counter()
tokenized_dataset = dataset.map(
    tokenize_function,
    batched=True,           # バッチ処理を有効化
    batch_size=1000,        # バッチサイズ
    num_proc=1,             # プロセス数(マルチプロセス可能)
    remove_columns=["text"] # 元のテキスト列を削除
)
elapsed = time.perf_counter() - start

print(f"データセットサイズ: {len(dataset)}")
print(f"トークナイズ時間:   {elapsed:.2f}秒")
print(f"スループット:       {len(dataset)/elapsed:.0f} texts/sec")
print(f"\nカラム: {tokenized_dataset.column_names}")
print(f"サンプル input_ids: {tokenized_dataset[0]['input_ids'][:10]}...")
print(f"サンプル長:         {len(tokenized_dataset[0]['input_ids'])}")

datasets.map()batched=Trueを指定すると、テキストがバッチ単位でトークナイズ関数に渡されます。内部でTokenizersのencode_batchが呼ばれるため、Rustの並列処理の恩恵を受けられます。num_procを増やすとPython側のマルチプロセスも活用でき、さらにスループットが向上します。remove_columns=["text"]で元のテキスト列を削除することで、メモリ使用量を抑えています。この方法は、Hugging Face Trainerでの学習パイプラインと直接接続でき、大規模なデータセットでも効率的に前処理できます。

まとめ

本記事では、Hugging Face Tokenizersライブラリの仕組みと実践的な使い方を解説しました。

  • 5段パイプライン(Normalizer → Pre-tokenizer → Model → Post-processor → Decoder)により、トークナイゼーションの各工程を柔軟にカスタマイズできます。このモジュラー設計がライブラリの最大の強みです
  • 3つのサブワードモデル(BPE、WordPiece、Unigram)はそれぞれ異なるアプローチで語彙を構築します。BPEは頻度ベースのボトムアップ結合、WordPieceは尤度スコアに基づくボトムアップ結合、Unigramは確率モデルに基づくトップダウン削除を行います
  • Rustバックエンドによる高速化は、Pythonの純粋な実装と比較して約100倍の速度向上をもたらします。バッチ処理ではCPU全コアの並列化が自動的に行われます
  • カスタムトークナイザーの訓練train_from_iteratorで手軽に行え、ドメイン特化のテキスト(ソースコード、医療文書など)に最適化された語彙を構築できます
  • PreTrainedTokenizerFastでラップすることで、Hugging Face Transformersエコシステムとシームレスに統合し、Trainerpipelineから直接利用できます

トークナイザーの選択と設計は、Transformerモデルの性能に直接影響する重要な工程です。汎用の事前学習済みトークナイザーでも多くのタスクで十分な性能を発揮しますが、特殊なドメインや言語を扱う場合には、本記事で解説したカスタムトークナイザーの構築が有効です。

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

画像なし
トークナイゼーションの基礎
BPE・WordPiece・Unigramの理論的な詳細と、サブワード分割の数学的背景を解説します。
画像なし
Hugging Face Transformersの基礎
AutoModel・AutoTokenizerの使い方から、ファインチューニングのパイプラインまで解説します。
画像なし
BERTのアーキテクチャ
BERTの事前学習・ファインチューニングの仕組みとTransformerエンコーダの構造を解説します。