Hayami Kaito Web
~/tech

トークナイザーのしくみ:文字・単語から BPE・SentencePiece まで

published

ChatGPT などの大規模言語モデル(LLM)は、文章を文字のまま読んでいるわけではありません。まず文章を トークンと呼ばれる短い単位に区切り、それぞれに番号を付けてから計算します。この区切りと番号付けをするのが トークナイザーです。LLM の料金や「一度に読める長さ」がトークンの数で決まるのも、そのためです。

アテンションと Transformer のしくみ では、分かりやすいようにトークンを「単語」と呼び、「よく使われる BPE は、よく一緒に現れる文字の組を順につなげて語彙を作る」と一言だけ触れました。この記事では、そのトークナイザーが どう語彙を作り、どう文章を区切るのかを説明します。

結論を先に書くと、今のトークナイザーは次の考え方でできています。

  • 単語で区切ると知らない単語を扱えず、文字で区切ると系列が長くなりすぎる。その中間の「よく出る文字の並び」(サブワード)を単位にする
  • BPE は、いちばん多く隣り合うトークンの組を 1 つにつなげる操作をくり返して語彙を作る。知らない単語も、知っている部品の組み合わせで表せる
  • 語彙を大きくするほど系列は短くなるが、埋め込みの表が大きくなり、めったに出ないトークンは学習が進まない。数万〜数十万語が使われる
  • 文字ではなく UTF-8 のバイトから始めれば、どんな文字も表せて「未知の文字」がなくなる
  • Unigram 言語モデルは、確率がいちばん高くなる区切り方を選ぶ。区切り方をわざと揺らして学習する(サブワード正則化)こともできる

説明の途中に出てくる計算例は、Python で実際に動かした結果です。

環境

計算例を動かした環境です。標準ライブラリだけで動きます。

項目バージョン
Python3.13

文章をどの単位で区切るか

単語で区切ると、知らない単語が出てくる

いちばん素直なのは、単語を 1 トークンにする方法です。英語なら空白で区切れますが、語彙は無限に増えます。"low"・"lower"・"lowest" は別の単語になり、学習の文章に出てこなかった単語は、すべて「未知語」(<unk>)という 1 つの記号にまとめるしかありません。固有名詞・新語・打ち間違いに弱くなります。

日本語には空白がないので、単語に区切るには形態素解析器(MeCab など)で先に分かち書きをする必要もあります。

文字で区切ると、系列が長くなりすぎる

反対に、1 文字を 1 トークンにすれば、未知語はほぼなくなります。ただし、同じ文章を表すのに必要なトークンの数がずっと多くなります。Transformer のアテンションの計算量はトークンの数の 2 乗で増えるので、系列が長くなるのは大きな負担です。また、1 文字ずつでは「意味のまとまり」をモデルが自分で組み立てなければならず、学習も難しくなります。

サブワード:よく出る並びはまとめ、まれな単語は部品に分ける

そこで、よく出る文字の並びは 1 トークンにまとめ、まれな単語は小さな部品に分けて表すのが サブワードの考え方です。"lowest" は "low" と "est" に、"unhappiness" は "un"・"happi"・"ness" のように分けます。部品の組み合わせなので、学習で見たことのない単語も表せます。

BPE:いちばん多い組をつなげていく

語彙を学習する手順

BPE(Byte Pair Encoding、バイト対符号化)は、もとは 1994 年に発表されたデータ圧縮の方法です。2016 年に、機械翻訳の研究で単語を部品に分ける方法として使われ、広まりました。語彙は次の手順で作ります。

  1. 学習用の文章の単語を、1 文字ずつのトークンに分ける。単語の終わりには、終わりを表す記号を付ける
  2. 隣り合うトークンの組を数え、いちばん多い組を 1 つのトークンにつなげる
  3. つなげた組を「結合の規則」として記録し、2 に戻る。決めた回数だけくり返す

2016 年の論文と同じ、4 つの単語の例で動かしてみます。単語の終わりの記号は _ にしています。

from collections import Counter

# 単語の出現回数(Sennrich らの論文の例と同じ形)
words = {"low": 5, "lower": 2, "newest": 6, "widest": 3}
# 単語を文字に分け、末尾に単語の終わりを表す記号 _ を付ける
vocab = {tuple(w) + ("_",): n for w, n in words.items()}

def count_pairs(vocab):
    pairs = Counter()
    for symbols, n in vocab.items():
        for a, b in zip(symbols, symbols[1:]):
            pairs[a, b] += n
    return pairs

def merge(vocab, pair):
    new = {}
    for symbols, n in vocab.items():
        out, i = [], 0
        while i < len(symbols):
            if i + 1 < len(symbols) and (symbols[i], symbols[i + 1]) == pair:
                out.append(symbols[i] + symbols[i + 1])
                i += 2
            else:
                out.append(symbols[i])
                i += 1
        new[tuple(out)] = n
    return new

merges = []
for step in range(10):
    pairs = count_pairs(vocab)
    best = max(pairs, key=pairs.get)  # いちばん多く隣り合う組
    merges.append(best)
    vocab = merge(vocab, best)
    print(step + 1, best, pairs[best], [" ".join(s) for s in vocab])

実行結果:

1 ('e', 's') 9 ['l o w _', 'l o w e r _', 'n e w es t _', 'w i d es t _']
2 ('es', 't') 9 ['l o w _', 'l o w e r _', 'n e w est _', 'w i d est _']
3 ('est', '_') 9 ['l o w _', 'l o w e r _', 'n e w est_', 'w i d est_']
4 ('l', 'o') 7 ['lo w _', 'lo w e r _', 'n e w est_', 'w i d est_']
5 ('lo', 'w') 7 ['low _', 'low e r _', 'n e w est_', 'w i d est_']
6 ('n', 'e') 6 ['low _', 'low e r _', 'ne w est_', 'w i d est_']
7 ('ne', 'w') 6 ['low _', 'low e r _', 'new est_', 'w i d est_']
8 ('new', 'est_') 6 ['low _', 'low e r _', 'newest_', 'w i d est_']
9 ('low', '_') 5 ['low_', 'low e r _', 'newest_', 'w i d est_']
10 ('w', 'i') 3 ['low_', 'low e r _', 'newest_', 'wi d est_']

左から、何回目の結合か、つなげた組、その組が隣り合っていた回数、結合したあとの各単語の区切りです。最初に "e" と "s" がつながります。"newest"(6 回)と "widest"(3 回)の両方に出てくるので、合わせて 9 回といちばん多いからです。続いて "est"、単語の終わりまで含めた "est_" ができ、「最上級の語尾」という部品が自然に見つかりました。5 回目には "low" が、8 回目にはよく出る単語 "newest_" が丸ごと 1 トークンになっています。

知らない単語を区切る

文章を区切るときは、文字に分けたあと、学習で記録した結合の規則を、同じ順に当てはめます。上のコードの続きで、学習に出てこなかった単語を区切ってみます。

def encode(word):
    symbols = tuple(word) + ("_",)
    for pair in merges:          # 学習した順に結合を当てはめる
        symbols = tuple(merge({symbols: 1}, pair))[0]
    return list(symbols)

for w in ["lowest", "newer", "wider"]:
    print(w, encode(w))

実行結果:

lowest ['low', 'est_']
newer ['new', 'e', 'r', '_']
wider ['wi', 'd', 'e', 'r', '_']

"lowest" は学習の単語にありませんが、"low" と "est_" という 2 つの部品で表せました。"newer" と "wider" も、知っている部品と 1 文字ずつのトークンで表せています。結合の回数を増やせば "er_" のような部品も覚えます。このように、BPE では どんな単語も、最悪でも 1 文字ずつに分ければ表せるので、未知語がなくなります(文字そのものを知らない場合は後で説明します)。

実際のトークナイザーの実装では、規則を 1 つずつ全体に当てはめるのではなく、文章の中で「いちばん先に学習した規則」が当てはまる組から順につなげて、同じ結果を速く求めます。

語彙の大きさと系列の長さ

日本語の文で語彙を増やしてみる

日本語のように空白で区切らない文章でも、BPE はそのまま使えます。文全体を 1 文字ずつに分け、よく隣り合う組をつなげていきます。「私は」「東京で」「本を読みます」のような部品を組み合わせた 2,000 文で学習し、別に作った 200 文を区切って、語彙の大きさと 1 文あたりのトークン数の関係を調べます。

import random
from collections import Counter

# 日本語の小さなコーパス(空白で区切らない)
random.seed(0)
subjects = ["私は", "彼は", "猫が", "犬が", "先生は", "学生が"]
places = ["東京で", "学校で", "公園で", "図書館で", "家で"]
actions = ["本を読みます", "ご飯を食べます", "友達と話します", "走ります", "勉強します", "寝ます"]
def sentence():
    return random.choice(subjects) + random.choice(places) + random.choice(actions) + "。"
train = [sentence() for _ in range(2000)]
test = [sentence() for _ in range(200)]

def merge_seq(seq, pair):
    out, i = [], 0
    while i < len(seq):
        if i + 1 < len(seq) and (seq[i], seq[i + 1]) == pair:
            out.append(seq[i] + seq[i + 1]); i += 2
        else:
            out.append(seq[i]); i += 1
    return out

def train_bpe(texts, n_merges):
    seqs = Counter(tuple(t) for t in texts)  # 同じ文はまとめて数える
    merges = []
    for _ in range(n_merges):
        pairs = Counter()
        for s, n in seqs.items():
            for p in zip(s, s[1:]):
                pairs[p] += n
        if not pairs:
            break
        best = max(pairs, key=pairs.get)
        merges.append(best)
        seqs = Counter({tuple(merge_seq(list(s), best)): n for s, n in seqs.items()})
    return merges

def encode(text, merges):
    seq = list(text)
    for p in merges:
        seq = merge_seq(seq, p)
    return seq

merges = train_bpe(train, 60)
print(["".join(p) for p in merges[:12]])
base = len(set("".join(train)))
for n in [0, 10, 30, 60]:
    m = merges[:n]
    avg = sum(len(encode(t, m)) for t in test) / len(test)
    print(base + n, round(avg, 1))
print(encode("猫が図書館で本を読みます。", merges))
print(encode("猫が駅で本を読みました。", merges))

実行結果:

['ます', 'ます。', 'します。', '東京', '東京で', '公園', '公園で', '家で', '学校', '学校で', '図書', '図書館']
40 11.6
50 7.8
70 4.5
100 2.3
['猫が図書館で', '本を読みます。']
['猫が', '駅', 'で', '本を読み', 'ま', 'し', 'た', '。']

1 行目は、最初に学習した 12 個の結合でできたトークンです。どの文にも出てくる「ます」「ます。」が最初にでき、続いて「東京で」「公園で」「図書館」のような場所の言葉がまとまりました。日本語の単語の区切りを教えていないのに、よく出る並びとして単語らしい単位が見つかっています。

2〜5 行目は、語彙の大きさ(40 種類の文字 + 結合の数)と、200 文を区切ったときの 1 文あたりの平均トークン数です。文字だけ(40 語)なら 1 文が 11.6 トークンですが、結合を 60 回して 100 語にすると 2.3 トークンまで短くなりました。語彙を増やすほど、系列は短くなります。

語彙を増やしすぎると何が困るか

6 行目では、「猫が図書館で」「本を読みます。」が丸ごと 1 トークンになっています。この小さなコーパスでは同じ組み合わせがくり返し出てくるので、結合を増やすと、単語を超えて よく出るフレーズまで丸覚えしていきます。

7 行目の「猫が駅で本を読みました。」は、学習の文章にない「駅」「ました」を含む文です。「本を読み」までは知っている部品で表せましたが、「ました」は「ま」「し」「た」に分かれました。学習の文章で「ます。」ばかり見ていたので、そこに語彙を使ってしまったのです。

語彙の大きさには、次のような釣り合いがあります。

語彙を良いこと困ること
大きくする系列が短くなり、計算が軽く、一度に長い文章を読める埋め込みと出力層の表が大きくなる。まれにしか出ないトークンは学習の回数が少なく、うまく覚えられない
小さくする表が小さく、どのトークンも十分に学習される系列が長くなり、計算が重い。意味のまとまりをモデルが組み立てる必要がある

実際の LLM では、GPT-2 が約 5 万語、LLaMA が 3 万 2 千語、LLaMA 3 が約 12 万 8 千語の語彙を使っています。多くの言語を扱うモデルほど、語彙を大きくする傾向があります。

バイトから始めて未知の文字をなくす

UTF-8 では 1 文字が 1〜4 バイト

BPE は、学習の文章に出てきた文字から語彙を始めます。そのため、学習で一度も見なかった文字(めずらしい漢字や絵文字)は、やはり表せません。漢字・絵文字まで含めると文字の種類は十数万あるので、すべてを最初の語彙に入れるのも大変です。

そこで、GPT-2 以降の多くのモデルは、文字ではなく UTF-8 のバイトから始めます(バイトレベル BPE)。UTF-8 は、どんな文字も 1〜4 個のバイト(0〜255 の数)で表す文字コードです(くわしくはサイト内アプリの 文字コード で確かめられます)。

for s in ["cat", "猫", "é", "🐈"]:
    b = s.encode("utf-8")
    print(s, len(s), len(b), list(b))
print(bytes([0xE7, 0x8C, 0xAB]).decode("utf-8"))

実行結果:

cat 3 3 [99, 97, 116]
猫 1 3 [231, 140, 171]
é 1 2 [195, 169]
🐈 1 4 [240, 159, 144, 136]
猫

英字は 1 文字が 1 バイト、「猫」は 3 バイト、絵文字は 4 バイトです。最後の行のとおり、3 つのバイトを並べ直せば「猫」に戻ります。最初の語彙に 256 種類のバイトをすべて入れておけば、どんな文字列もバイトの並びとして必ず表せるので、未知語は完全になくなります。よく出るバイトの組(「猫」の 3 バイトなど)は、BPE の結合で 1 トークンにまとまっていきます。

言語によってトークンの数が違う

バイトレベル BPE は便利ですが、学習の文章に少ない言語には不利です。英語中心の文章で学習した語彙では、日本語の文字の結合があまり作られず、1 文字が 2〜3 トークンに分かれることがあります。同じ内容の文章でも、言語によってトークン数、つまり料金や読める長さが変わるのはこのためです。

1 文字ずつ見ていないことは、LLM の得意・不得意にも表れます。たとえば英単語に含まれる特定の文字の数を数える問題をよく間違えるのは、モデルが受け取るのが「文字」ではなく、いくつかの文字をまとめたトークンの番号だからだと考えられています。

Unigram 言語モデル:確率で区切り方を選ぶ

区切り方は 1 通りではない

BPE は結合の規則を順に当てはめるので、区切り方は 1 通りに決まります。しかし、同じ語彙でも、文章の区切り方はふつう何通りもあります。2018 年に発表された Unigram 言語モデルの方法は、トークンごとに出現する確率を持たせ、区切ったトークンの確率の積がいちばん大きくなる区切り方を選びます。

「東京都に住む」を、次の語彙で区切ってみます。

import math, random

# Unigram 言語モデルの語彙:トークンとその出現確率
probs = {"東京": 0.05, "京都": 0.05, "東": 0.02, "京": 0.02, "都": 0.03, "に": 0.08, "住": 0.02,
         "住む": 0.04, "む": 0.02, "東京都": 0.03, "都に": 0.01}
logp = {t: math.log(p) for t, p in probs.items()}

def all_segmentations(text):
    if not text:
        yield []
        return
    for k in range(1, min(4, len(text)) + 1):
        if text[:k] in logp:
            for rest in all_segmentations(text[k:]):
                yield [text[:k]] + rest

text = "東京都に住む"
segs = sorted(all_segmentations(text), key=lambda s: -sum(logp[t] for t in s))
print(len(segs))
for s in segs[:4]:
    print(s, round(sum(logp[t] for t in s), 2))

実行結果:

12
['東京都', 'に', '住む'] -9.25
['東京', '都に', '住む'] -10.82
['東京', '都', 'に', '住む'] -12.25
['東', '京都', 'に', '住む'] -12.65

この語彙では区切り方が 12 通りあり、確率の対数の合計が大きい順に 4 つを並べました。いちばん確率が高いのは「東京都 / に / 住む」です。トークンの数が少ないほど、確率を掛け合わせる回数が少ないので、確率は高くなりやすくなります。一方、「東 / 京都 / に / 住む」のような、意味のずれた区切り方も候補には含まれています。

ビタビアルゴリズムで最良の区切り方を速く求める

区切り方の数は、文章が長くなると爆発的に増えます。そこで、「文章の先頭から i 文字目までを区切ったときの最良の確率」を前から順に求めていく ビタビアルゴリズム(動的計画法)を使います。上のコードの続きです。

def viterbi(text):
    # best[i] = text[:i] を区切ったときの対数確率の最大値と、そのときの最後のトークンの開始位置
    best = [(0.0, None)] + [(-math.inf, None)] * len(text)
    for end in range(1, len(text) + 1):
        for start in range(max(0, end - 4), end):
            t = text[start:end]
            if t in logp and best[start][0] + logp[t] > best[end][0]:
                best[end] = (best[start][0] + logp[t], start)
    tokens, end = [], len(text)
    while end > 0:
        start = best[end][1]
        tokens.append(text[start:end]); end = start
    return tokens[::-1], best[-1][0]

print(viterbi(text))

実行結果:

(['東京都', 'に', '住む'], -9.251162366496438)

すべての区切り方を並べたときと同じ「東京都 / に / 住む」が求まりました。計算量は文章の長さに比例するだけなので、長い文章でも速く区切れます。

語彙そのものは、大きめの候補から始め、各トークンを取り除いたときに文章全体の確率がどれだけ下がるかを計算し、下がり方の小さいトークンから削っていくことで決めます。BPE が「小さな語彙からつなげて増やす」のに対し、Unigram は「大きな語彙から削って減らす」方法です。

サブワード正則化:区切り方をわざと揺らす

確率で区切り方を選べることには、もう 1 つ利点があります。学習のときに、いちばん良い区切り方だけでなく、確率に比例してほかの区切り方もときどき選ぶのです。上のコードの続きです。

# サブワード正則化:確率に比例して区切り方をサンプリングする
random.seed(0)
w = [math.exp(sum(logp[t] for t in s)) for s in segs]
for _ in range(5):
    print(random.choices(segs, weights=w)[0])

実行結果:

['東京', '都に', '住む']
['東京都', 'に', '住む']
['東京都', 'に', '住む']
['東京都', 'に', '住む']
['東京都', 'に', '住む']

ほとんどは「東京都 / に / 住む」ですが、ときどき「東京 / 都に / 住む」が選ばれました。同じ文章をいろいろな区切り方で見せると、モデルは区切り方の違いに強くなり、打ち間違いや表記の揺れにも強くなります。これを サブワード正則化と呼び、過学習と正則化のしくみ で説明したデータ拡張と同じように、過学習を防ぐ効果があります。

SentencePiece:空白も 1 つの文字として扱う

BPE や Unigram を英語に使うときは、先に空白で単語に区切ってから学習するのがふつうでした。しかし、そうすると日本語や中国語のように空白のない言語には、別の分かち書きの道具が必要になります。

2018 年に Google の研究者が公開した SentencePiece は、空白も「▁」という 1 つの文字に置き換えて、文章全体をそのまま学習するライブラリです。"Hello world" は "▁Hello▁world" という 1 つの文字列として扱われ、空白の位置もトークンの一部になります。そのため、トークンをつなげて "▁" を空白に戻すだけで、元の文章を正確に復元できます。言語ごとの前処理がいらないので、多くの言語を扱うモデルで広く使われています。SentencePiece は、BPE と Unigram のどちらの方法でも学習できます。

方法語彙の作り方区切り方主な利用例
BPE多い組をつなげて増やす結合の規則を順に当てはめるGPT-2 以降の GPT・LLaMA 3
WordPieceつなげたときに尤度がよく上がる組をつなげる長いトークンから順に当てはめるBERT
Unigram大きな語彙から削って減らす確率が最大の区切り方(ビタビ)T5・ALBERT
SentencePieceBPE か Unigram を、空白を含めた文章全体に使う学習した方法に従うT5・LLaMA・Gemma

トークナイザーの手法の流れ

この記事で紹介した手法を年代順に並べます。

年手法・出来事ポイント
1994BPE(ゲージ)データ圧縮の方法として、多いバイトの組を 1 つの記号に置き換える
2012WordPiece(音声検索)日本語・韓国語の音声検索のために、単語を部品に分ける
2016BPE による単語の分割(センリッチら)・GNMT機械翻訳で、まれな単語をサブワードで表す
2018Unigram 言語モデル・サブワード正則化・SentencePiece確率で区切り方を選び、空白も含めた文章全体から学習する
2019GPT-2 のバイトレベル BPEUTF-8 のバイトから始め、未知の文字をなくす
2024LLaMA 3 の語彙を約 12 万 8 千語に多言語と効率のために語彙を大きくする

まとめ

  • トークナイザーは文章をトークンに区切って番号を付ける。LLM の料金や読める長さはトークン数で決まる
  • 単語で区切ると知らない単語を扱えず、文字で区切ると系列が長くなる。その中間のサブワードを単位にする
  • BPE は、いちばん多く隣り合う組をつなげる操作をくり返して語彙を作り、学習した順に結合を当てはめて区切る。知らない単語も部品の組み合わせで表せる
  • 語彙を大きくするほど系列は短くなるが、表が大きくなり、まれなトークンは学習が進まない
  • UTF-8 のバイトから始めれば未知の文字はなくなるが、学習の文章に少ない言語はトークン数が多くなりやすい
  • Unigram 言語モデルは確率が最大の区切り方をビタビアルゴリズムで求める。区切り方を揺らすサブワード正則化で、表記の揺れに強くできる
  • SentencePiece は空白も 1 つの文字として扱い、空白のない言語にも前処理なしで使える

参考

  • P. Gage, "A New Algorithm for Data Compression", The C Users Journal 1994
  • M. Schuster and K. Nakajima, "Japanese and Korean Voice Search", ICASSP 2012
  • R. Sennrich et al., "Neural Machine Translation of Rare Words with Subword Units", ACL 2016(arXiv:1508.07909)
  • Y. Wu et al., "Google's Neural Machine Translation System: Bridging the Gap between Human and Machine Translation", 2016(arXiv:1609.08144)
  • T. Kudo, "Subword Regularization: Improving Neural Network Translation Models with Multiple Subword Candidates", ACL 2018(arXiv:1804.10959)
  • T. Kudo and J. Richardson, "SentencePiece: A simple and language independent subword tokenizer and detokenizer for Neural Text Processing", EMNLP 2018(arXiv:1808.06226)
  • A. Radford et al., "Language Models are Unsupervised Multitask Learners", 2019
  • A. Grattafiori et al., "The Llama 3 Herd of Models", 2024(arXiv:2407.21783)