文字を切り刻む「BPE(Byte Pair Encoding)」の物理――なぜAIは単語でも文字でもなく「トークン」で世界を見るのか?

2026-11-05 / シリーズ「LLM・AIの内臓」第2話 / LLM, トークナイザ, BPE, 自然言語処理, Transformer, Python, 深層学習

1. 素朴な疑問:なぜAIは「文字」や「単語」をそのまま理解できないのか?

OpenAIやAnthropicのAPIを使って開発をしていると、誰もが一度は遭遇する違和感があります。

「英語の『apple』はたった1トークンなのに、日本語で『りんご』と書くと2〜3トークン消費される」 「『こんにちは』と5文字打っただけなのに、なぜか3トークンとしてカウントされ、API代が英語の何倍も跳ね上がる」

そもそも、なぜ現代のAIは人間のように「1文字」単位で文章を読まないのでしょうか? あるいは、国語辞典のように「1単語」単位で文章を区切らないのでしょうか?

なぜ「ト・ー・ク・ン」という、文字とも単語ともつかない中途半端な塊に、わざわざ文章を切り刻んでからニューラルネットワークに入力しなければならないのでしょうか?

生テキスト:『unbelievable』 方式1:文字単位(Character-level) ['u', 'n', 'b', 'e', 'l', 'i', 'e', 'v', 'a', 'b', 'l', 'e'](12トークン) 長すぎてAttentionの計算量が大爆発! 方式2:単語単位(Word-level) ['unbelievable'](1トークン) 未知の単語(新語・typo)に出会った瞬間に死ぬ! 方式3:サブワード(BPE:現代LLMの最適解) ['un', 'believ', 'able'](3トークン) 意味の最小部品に分解し、長さと語彙数を両立!

結論から言えば、「文字単位」も「単語単位」も、Transformerという計算機の物理制約(メモリと計算量)の前では、どちらも破綻したからです。

モデルが世界を認識する最初の網膜であり、入力文字列をテンソルへと橋渡しするゲートキーパー、それが**「BPE(Byte Pair Encoding:バイト対符号化)」**です。 今回は、このトークナイザの背後にある数学的トレードオフと、未知語を完全に根絶したバイトレベルの物理を徹底解剖します。


2. 両極端の破綻:文字単位 vs 単語単位の「物理の壁」

文章をコンピュータに入力可能な整数の配列(ID列)へ変換するとき、工学的には2つの極端な選択肢が存在します。

破綻①:単語単位(Word-level)――「語彙数の爆発」と「未知語の絶望」

最も直感的なのは、「英語ならスペース区切り、日本語なら形態素解析(MeCabなど)で単語に分ける」アプローチです。 一見自然に見えますが、ディープラーニングのモデル設計において、これは破滅的な計算コストをもたらします。

  1. 語彙数(Vocabulary Size)の無限膨張: 世の中には無数の単語が存在します。派生語(run, runs, running, ran)、スラング、専門用語、打ち間違い(typo)。これらをすべて辞書に登録しようとすると、語彙数は数百万〜数千万語へと膨れ上がります。
  2. 最終出力層(LM Head)のメモリ破綻: 第1話で触れた通り、モデルの最終層は「隠れ層ベクトル(例: 4096次元)」から「語彙数(Vocab Size)」の次元へ射影する巨大な行列計算を行います。語彙数が100万語になれば、この出力層の重みだけで数ギガバイトのGPUメモリが消し飛び、語彙全体にかける Softmax の計算でモデルが窒息します。
  3. 未知語(OOV:Out-of-Vocabulary)問題: 辞書に載っていない新語に出会った瞬間、モデルはそれを [UNK](Unknown:未知語)という単一の記号で塗りつぶすしかありません。「最新のiPhone 17」と入力しても、モデルには「最新の [UNK] [UNK]」としか見えなくなり、意味が完全に崩壊します。

破綻②:文字単位(Character-level)――「シーケンス長の爆発」と「$O(N^2)$ の壁」

「では、辞書をアルファベット26文字+数字+記号だけに絞ればいいではないか」 これなら語彙数はわずか100〜200個程度で済み、未知語も原理的に存在しなくなります。

しかし、ここで立ちはだかるのが、**Transformerの自己注意機構(Self-Attention)が持つ「計算量の物理限界」**です。

【Attentionの計算量】
計算コスト ∝ (シーケンス長 N)²

文字単位にすると、単語単位に比べて文章の長さ(トークン長 $N$)が5倍から10倍に跳ね上がります。 長さが10倍になれば、モデル内部で計算されるアテンション行列のサイズは $10^2 =$ 100倍に膨れ上がります。1冊の本や長いプロンプトを読み込ませようとした瞬間、GPUのVRAMは一瞬でオーバーフロー(OOM:Out of Memory)を引き起こすのです。

単語単位 語彙数:数百万(大) 系列長:短い ★出力層とSoftmaxが死ぬ サブワード(BPE) 語彙数:3万〜10万(適正) 系列長:適正な長さに圧縮 ★両者のスイートスポット! 文字単位 語彙数:数百(小) 系列長:極めて長い ★AttentionのO(N²)で死ぬ バランス バランス

この「語彙数の爆発」と「系列長の爆発」という2つの地獄のちょうど真ん中に位置する、奇跡のスイートスポット(語彙数32,000〜100,000程度)を自動的に削り出すアルゴリズムこそが、サブワード分割技術なのです。


3. BPEの数理:1994年のデータ圧縮アルゴリズムの華麗なる転生

BPE(Byte Pair Encoding)は、もともとAIのために開発された技術ではありません。 1994年にフィリップ・ゲイジ(Philip Gage)が発表した、**「可逆データ圧縮アルゴリズム」**でした。ZIP圧縮などと同系列の技術です。

2016年、エディンバラ大学のリコ・センリッヒ(Rico Sennrich)らが「この圧縮アルゴリズムは、機械翻訳の未知語問題を解決する最高の前処理になる」と気づき、自然言語処理の世界へ持ち込みました。

BPEの学習アルゴリズム(ボキャブラリの構築)

BPEが辞書を作るルールは、息を呑むほどシンプルです。 「コーパス(大量のテキスト)の中で、最も頻繁に隣り合って出現する2つの文字(ペア)を見つけ出し、それを新しい1つの文字としてマージする」。これを目標の語彙数に達するまで繰り返すだけです。

具体例を見てみましょう。訓練テキストの中に以下の単語が出現したとします。

"low": 5回
"lower": 2回
"newest": 6回
"widest": 3回
初期状態:全文字をバラバラに分解 'l o w' / 'l o w e r' / 'n e w e s t' / 'w i d e s t' ステップ1:最頻出ペアをマージ 隣り合うペアを全カウント:('e', 's') が 6+3=9回で1位 → 新記号 'es' を登録 ステップ2:さらなるマージ 次に頻出する ('es', 't') が9回で1位 → 合体させて 'est' を登録 ステップ3:次の頻出ペアへ ('l', 'o') が7回で合体 → 'lo' を登録、さらに ('lo', 'w') が合体して 'low' に… 語彙辞書の完成 頻出語は丸ごと1トークンに、稀な語は意味のあるサブワードに分解される
  1. 初期語彙: まず、テキストに登場する個別の文字 ['d', 'e', 'i', 'l', 'n', 'o', 'r', 's', 't', 'w'] だけを辞書に入れます。
  2. ステップ1: すべての隣り合う2文字のペアをカウントします。
    • ('e', 's') のペアが newest(6回)と widest(3回)で合計9回出現し、最頻出となります。
    • 辞書に新しいトークン 'es' を追加し、テキスト内の 'e' と 's' を 'es' へ合体させます。
  3. ステップ2: 再度カウントすると、今度は ('es', 't') が合計9回出現します。
    • 辞書に 'est' を追加し、合体させます。
  4. ステップ3: 次に頻出する ('l', 'o')(7回)が合体して 'lo' になり、さらに ('lo', 'w') が合体して 'low' になります。

この処理を数万回繰り返すと何が起きるでしょうか?

  • 超頻出する単語(the, is, haveなど): 何度も合体が進み、丸ごと「1トークン」として辞書に登録される。
  • 滅多に出ない単語(unbelievableなど): 途中で合体が止まり、'un' + 'believ' + 'able' のような「意味のある接頭辞・語幹・接尾辞」のパーツ(サブワード)として綺麗に分解される。

人間が文法規則を手作業で教えたわけではありません。 ただ**「頻出する並びを圧縮した」という統計的結果が、言語学的な「形態素(意味の最小単位)」と奇跡的に一致した**のです。

触って試す:トークン分割の体験

✂️ トークン分割の体験(簡易BPE辞書)

文を選ぶと、下の「簡易辞書」でどうトークンに割れるかを表示します。辞書にある塊は青いチップ=1トークンに、辞書にない文字はUTF-8のバイト(赤いチップ)に分解されます。これは仕組みを学ぶための簡易辞書であり、実際のモデルの辞書とは異なります。

まずは文を選んでみてください。


4. 完全実装:Pythonでゼロから作る「最小のBPEトークナイザ」

外部ライブラリ(Hugging Faceのtokenizersやtiktokenなど)を一切使わず、Pythonの標準辞書と文字列操作だけでBPEの「学習(Train)」と「分割(Tokenize)」を完全自作します。

ファイル名を min_bpe.py として保存してください。

from collections import defaultdict

class SimpleBPE:
    def __init__(self, vocab_size=50):
        self.target_vocab_size = vocab_size
        self.merges = {}      # マージルールの記録: (token1, token2) -> merged_token
        self.vocab = set()    # 語彙辞書

    def _get_stats(self, word_freqs):
        """隣接するすべてのトークンペアの出現頻度をカウントする"""
        pairs = defaultdict(int)
        for word, freq in word_freqs.items():
            symbols = word.split()
            for i in range(len(symbols) - 1):
                pairs[(symbols[i], symbols[i + 1])] += freq
        return pairs

    def _merge_vocab(self, pair, word_freqs):
        """最頻出ペアを合体させ、単語リストを更新する"""
        new_word_freqs = {}
        bigram = " ".join(pair)
        replacement = "".join(pair)

        for word, freq in word_freqs.items():
            # スペース区切りの並びの中から目的のペアを置換
            # 例: "l o w </w>" の中の "l o" を "lo" へ置換
            new_word = word.replace(bigram, replacement)
            new_word_freqs[new_word] = freq
        return new_word_freqs

    def train(self, corpus: list[str]):
        """コーパスからBPEマージルールを学習する"""
        # 単語末尾に終端記号 '</w>' を付与し、文字単位にスペースで分断
        word_freqs = defaultdict(int)
        for sentence in corpus:
            for word in sentence.strip().split():
                spaced_word = " ".join(list(word)) + " </w>"
                word_freqs[spaced_word] += 1

        # 初期語彙の登録(一文字単位の記号)
        for word in word_freqs:
            for char in word.split():
                self.vocab.add(char)

        print(f"[*] 初期語彙数(ユニーク文字数): {len(self.vocab)}")

        # 目標の語彙数に達するまでマージを繰り返す
        step = 0
        while len(self.vocab) < self.target_vocab_size:
            pairs = self._get_stats(word_freqs)
            if not pairs:
                break

            # 最頻出ペアを見つける
            best_pair = max(pairs, key=pairs.get)
            best_freq = pairs[best_pair]

            if best_freq < 2:  # 頻度が1以下の場合はマージ打ち切り
                break

            # ペアを合体
            word_freqs = self._merge_vocab(best_pair, word_freqs)
            merged_token = "".join(best_pair)
            self.merges[best_pair] = merged_token
            self.vocab.add(merged_token)

            step += 1
            print(f"  Step {step:02d}: ペア {best_pair} (出現数: {best_freq}) -> '{merged_token}' を語彙に追加")

        print(f"[+] 学習完了! 最終語彙数: {len(self.vocab)}\n")

    def tokenize(self, text: str) -> list[str]:
        """学習したマージルールを適用して、新しいテキストをトークンへ分割する"""
        tokens = []
        for word in text.strip().split():
            # 文字単位に分断
            parts = list(word) + ["</w>"]

            # 学習したマージルールを順番に適用
            for pair, merged in self.merges.items():
                i = 0
                while i < len(parts) - 1:
                    if parts[i] == pair[0] and parts[i + 1] == pair[1]:
                        parts[i:i + 2] = [merged]
                    else:
                        i += 1
            tokens.extend(parts)
        return tokens


# -------------------------------------------------------------
# 実験:BPEの学習と未知語のトークナイズ
# -------------------------------------------------------------
if __name__ == "__main__":
    # 小さな学習コーパス
    corpus = [
        "low low low low low",
        "lower lower",
        "newest newest newest newest newest newest",
        "widest widest widest"
    ]

    tokenizer = SimpleBPE(vocab_size=20)
    print("=== フェーズ1: BPEの学習プロセス ===")
    tokenizer.train(corpus)

    print("=== フェーズ2: テキストのトークン分割テスト ===")
    
    test_words = ["low", "lowest", "newer", "wider"]
    for w in test_words:
        res = tokenizer.tokenize(w)
        print(f"入力: '{w:<8}' -> トークン分割: {res}")

実行結果

=== フェーズ1: BPEの学習プロセス ===
[*] 初期語彙数(ユニーク文字数): 11
  Step 01: ペア ('e', 's') (出現数: 9) -> 'es' を語彙に追加
  Step 02: ペア ('es', 't') (出現数: 9) -> 'est' を語彙に追加
  Step 03: ペア ('est', '</w>') (出現数: 9) -> 'est</w>' を語彙に追加
  Step 04: ペア ('l', 'o') (出現数: 7) -> 'lo' を語彙に追加
  Step 05: ペア ('lo', 'w') (出現数: 7) -> 'low' を語彙に追加
  Step 06: ペア ('n', 'e') (出現数: 6) -> 'ne' を語彙に追加
  Step 07: ペア ('ne', 'w') (出現数: 6) -> 'new' を語彙に追加
  Step 08: ペア ('new', 'est</w>') (出現数: 6) -> 'newest</w>' を語彙に追加
[+] 学習完了! 最終語彙数: 19

=== フェーズ2: テキストのトークン分割テスト ===
入力: 'low'      -> トークン分割: ['low', '</w>']
入力: 'lowest'   -> トークン分割: ['low', 'est</w>']
入力: 'newer'    -> トークン分割: ['new', 'e', 'r', '</w>']
入力: 'wider'    -> トークン分割: ['w', 'i', 'd', 'e', 'r', '</w>']

テスト結果の lowest に注目してください。 訓練データの中には lowest という単語は1回も登場していません。 にもかかわらず、トークナイザはそれを未知語(エラー)にすることなく、学習済みの部品である ['low', 'est</w>'] へと鮮やかに分解しました。

これがサブワードの威力です。過去に見たことのない新しい単語が来ても、モデルは「知っているパーツの組み合わせ」として文脈を解釈できるのです。


5. Byte-level BPEの革命:未知語を「物理的にゼロ」にする

初期のBPEには、まだ1つ大きな弱点が残っていました。 それは**「学習データに一度も出現しなかった珍しい文字(キリル文字、絵文字、特殊記号など)」**が入力されたとき、初期語彙にその文字自体が存在しないため、結局 [UNK] エラーになってしまう問題です。

これを恒久的に葬り去ったのが、GPT-2でOpenAIが導入した**「Byte-level BPE(BBPE)」**です。

どんな珍しいUnicode文字でも 例:絵文字 🚀 や難解な漢字など、あらゆる文字が入力されうる UTF-8のバイト列へ分解(物理の基底) 0x00 〜 0xFF(10進数で 0 〜 255)のバイト列になる 初期語彙:固定256個のバイト値 ★この世の全デジタルデータは、この256個の組み合わせで表現可能! BPEによる頻出バイトペアのマージ よく使われる英単語・漢字は、数バイトまとめて1トークンへ昇華 未知語([UNK])という概念が完全消滅! 最悪でもバイト単位に分解できるため、表現できない入力が存在しない

すべてを「0〜255の数値」からスタートさせる

Byte-level BPEの発想はシンプルを極めています。 「文字(Character)」からスタートするのをやめろ。コンピュータの根源である「生バイト(0x00〜0xFF、計256種類)」からスタートしろ。

世界中のあらゆる文字、あらゆる言語、絵文字、プログラムのバイナリコード、果ては画像データに至るまで、デジタルの世界に存在するデータはすべて「UTF-8のバイト列(0〜255の整数の並び)」として表現できます。

初期語彙に**「256個のバイト値」**をあらかじめ固定で登録しておけば、どんな文字が入力されても、最悪の場合「バイト単位」にまで分解して表現できます。 これにより、LLMの世界から「未知語(Out-of-Vocabulary)」という概念そのものが物理的に完全消滅したのです。


6. なぜ日本語は英語よりトークン数が多く、API代が高いのか?

ここで、冒頭の疑問に戻りましょう。 「なぜ日本語は英語よりトークン数を消費し、割高になってしまうのか?」

その理由は、**「UTF-8のバイト表現の差」と「訓練コーパスの言語比率の偏り」**という2重の物理にあります。

英語:『cat』 UTF-8: [0x63, 0x61, 0x74](3バイト) 『cat』の3バイト丸ごと ★たった1トークンにマージ済み! 英語が訓練データの9割を占めるため 日本語:『猫』 UTF-8: [0xE7, 0x8C, 0xAB](3バイト) 1文字なのにバラバラに ★2〜3トークンに細切れに解体される! 初期GPTモデルではマージが進まなかったため

① UTF-8のバイト長の物理

  • 英語のアルファベット(ASCII文字):**1文字あたり「1バイト」**で表現されます。
  • 日本語のひらがな・カタカナ・漢字:UTF-8では**1文字あたり「3バイト」**を消費します。

スタートラインの時点で、日本語は英語の3倍のバイト数を抱えています。

② BPEマージの偏り(言語格差)

GPT-3やGPT-4の初期モデルの学習コーパスは、90%以上が英語のテキストでした。 限られた語彙枠(GPT-3では約50,000語)の中で頻出ペアのマージ合戦を行うと、上位の枠はほとんど英語の単語やフレーズで埋め尽くされます。

その結果:

  • 英語なら「internationalization(20文字)」のような長い単語でも、頻出するため「たった1〜2トークン」に圧縮される。
  • 日本語はマージが十分に進まず、漢字1文字が辞書に登録されずにバイト単位にバラバラにされ、**「漢字1文字で2〜3トークン」**も消費してしまう。

同じ意味を伝えているのに、日本語のプロンプトは英語の2倍〜3倍のトークン長になり、API利用料も2倍〜3倍請求され、モデルが一度に覚えられる文脈ウィンドウ(コンテキスト長)も実質3分の1に縮んでしまっていたのです。

逆襲:語彙数拡大(Llama 3やGPT-4oの進化)

近年の最新モデル(GPT-4o、Llama 3など)は、この言語不平等を是正するため、語彙サイズ(Vocab Size)を一気に拡大しました。

  • GPT-3 / GPT-4:約 50,000 語彙
  • Llama 3 / GPT-4o:約 128,000 〜 200,000 語彙

語彙枠を10万語以上も拡張したことで、日本語や中国語、アラビア語などの非英語圏の頻出単語や漢字も、丸ごと「1トークン」として辞書に大量登録されました。その結果、最新モデルでは日本語のトークン消費効率が従来比で約30%〜40%も改善し、英語との格差が急速に縮まりつつあります。


7. テンソルの視点:文字列が「整数の配列」になる瞬間

トークナイザが完了した瞬間、データはどのように変化しているのでしょうか。 ここで、本連載の鉄則である**「テンソルの形状(Shape)」**を確認します。

【入力テキスト】
"Hello, world!"

【トークナイズ処理(BPE)】
tokens = ["Hello", ",", " world", "!"]

【IDマッピング(辞書の引き当て)】
token_ids = [15496, 11, 995, 0]
生テキスト文字列(str) 例:"Hello, world!" BPEトークナイザ(tiktoken / tokenizers) 文章をトークンに分割し、辞書でIDに引き当てる トークンID列(1次元の整数リスト) ["Hello", ",", " world", "!"] → [15496, 11, 995, 0] 入力テンソル X Shape: [Batch_Size, Seq_Len](例:1つの文で長さ4なら [1, 4]) 第3話:埋め込み層(Embedding)へ突入! 整数のIDが数千次元のベクトルへ展開される

トークナイザを抜けたデータは、もはや「文字」でも「言葉」でもありません。 ただの整数値が並んだ2次元テンソル Shape: [Batch_Size, Seq_Len] です。

モデルは「H-e-l-l-o」という文字の形を眺めているのではなく、辞書の中の「15496番」というインデックス番号を受け取っているだけなのです。


8. まとめ:トークナイザはニューラルネットの「外側」にある

BPEトークナイザの真実を総括すると、以下のようになります。

  1. 計算量の調停者: 文字単位の「系列長爆発(Attentionの$O(N^2)$)」と、単語単位の「語彙数爆発(Softmaxの肥大化・未知語)」の絶妙な妥協点として生まれた。
  2. 圧縮アルゴリズムの転用: 頻出する隣接ペアを反復マージすることで、頻出単語は1トークンにまとめ、稀な単語はサブワードに分解する。
  3. Byte-levelの絶対防御: 初期語彙をUTF-8の256バイト値からスタートさせることで、未知語(OOV)という概念をこの世から抹消した。
  4. モデルの外にある静的ルール: トークナイザ自身はニューラルネットワークではなく、重みも持たない「純粋なルールベースの文字列処理プログラム」である。

トークナイザという「前処理のレンズ」が文字を切り刻み、[Batch, Seq_Len] という整数のグリッドを作り出す。 この整数のID列が、いかにして数千次元の高次元空間に解き放たれ、「意味」という幾何学的な座標を獲得するのか?

次回、**「第3話:埋め込み(Embedding)――意味を高次元空間の『座標』に写す」**へと進みましょう。


📝 3行で頭に入る『試験・実務の要点まとめ』

  • BPEの存在理由: 単語単位の「語彙数爆発・未知語(OOV)」と文字単位の「系列長伸長によるAttention計算量爆発($O(N^2)$)」を回避するため、統計的に意味のあるサブワードへ分割する。
  • Byte-level BPEの原理: 文字ではなくUTF-8の生バイト(256通り)を基底とすることで、どんな未知文字・絵文字でも分解表現可能にし、未知語エラー([UNK])を原理的に排除した。
  • 日本語割高の物理: UTF-8で漢字・仮名が3バイト消費することに加え、初期モデルの語彙枠が英語に偏重していたため細切れに解体され、トークン消費量とAPIコストが増大していた。

確認クイズ

📝 確認クイズ(3問)