LLMにおける未知語への対処〜「麺」はなぜ2バイトと1バイトに分割されるか〜
はじめに
大規模言語モデル(LLM)の性能が向上し、日常的に様々なタスクで活用されるようになった。LLMの内部動作を理解することは、その活用方法や限界を知る上で非常に重要である。本記事では、LLMが「未知語」をどのように処理しているのかについて解説する。
特に日本語と英語の違いや、実際のトークナイゼーション(文章を小さな単位に分割する処理)の仕組みについて、具体例を交えながら説明する。
LLMにおけるトークナイゼーション
BPEの基本
LLM(Large Language Models)では、テキストデータを処理する際に「トークナイゼーション」という処理を行う。多くのLLMでは、BPE(Byte Pair Encoding)と呼ばれる手法が採用されている。
BPEは、頻出する文字列のパターンを一つのトークンとして扱うことで、効率的にテキストを表現する手法である。例えば、英語では「ing」や「tion」のような頻出する文字列が単一のトークンとして扱われることがある。
英語における未知語への対応
英語の場合、アルファベットは26文字(大文字小文字を区別すると52文字)しかない。これに数字や記号を加えても、必要な文字種は比較的少ない。
そのため、英語のBPEシステムでは、最悪の場合でも単語を個々の文字(a, b, c...)に分解することで、どんな未知語も処理することができる。つまり、英語では高々26文字(実際には記号なども含めてもう少し多い)を用意していれば、理論上はあらゆる単語をトークン化できるのである。
日本語における課題
一方、日本語の場合は事情が異なる。日本語には、ひらがな(46文字程度)、カタカナ(46文字程度)に加えて、数千種類の漢字が存在する。もし文字単位でトークンを割り当てると、非常に多くのトークンIDが必要になる。
例えば「鬱」や「悉」のような一般的でない漢字に対しては、その文字専用のトークンが辞書になければ処理できないことになる。
バイトレベルのエンコーディング
この問題を解決するため、実際のLLMのトークナイザーでは、文字単位ではなくバイト単位でのエンコーディングが行われている。つまり、テキストはまず UTF-8 などのエンコーディングでバイト列に変換され、その後 BPE が適用される。
例えば UTF-8 では、1文字が1〜4バイトで表現される。英語のアルファベットは1バイト、日本語の文字は通常3バイトで表現される。各バイトは0〜255の値を取るため、最大でも256種類のベースとなるトークンがあれば、理論上はどんな文字も表現できることになる。
実際には、効率化のためにBPEによって頻出するバイトパターンはまとめられるが、未知の文字列が登場した場合は、最終的にはバイトレベルに分解されて処理される。
具体例:cl100k_baseトークナイザーによる処理
OpenAIのGPT-4oで使用されている o200k_base
トークナイザーを例に、実際のトークン化過程を見てみよう。以下のPythonコードで、「今日は担々麺を食べた。」という日本語文をトークン化してみる。
import tiktoken
# o200k_baseエンコーディングを読み込む
encoding = tiktoken.get_encoding("o200k_base")
rare_text = "今日は担々麺を食べた。"
rare_tokens = encoding.encode(rare_text)
print(f"元のテキスト: {rare_text}")
print(f"トークンID列: {rare_tokens}")
print("")
for token in rare_tokens:
decoded = encoding.decode([token])
# トークンのバイト表現を取得
bytes_data = encoding.decode_single_token_bytes(token)
hex_bytes = ' '.join([f'0x{byte:02X}' for byte in bytes_data])
print(f"トークンID: {token:>5}, デコード結果: {decoded}, バイト表現: {hex_bytes}")
このコードを実行すると、次のような結果が得られる:
元のテキスト: 今日は担々麺を食べた。
トークンID列: [170411, 43860, 68975, 27036, 118, 7277, 25971, 55078, 5598, 788]
トークンID: 170411, デコード結果: 今日は, バイト表現: 0xE4 0xBB 0x8A 0xE6 0x97 0xA5 0xE3 0x81 0xAF
トークンID: 43860, デコード結果: 担, バイト表現: 0xE6 0x8B 0x85
トークンID: 68975, デコード結果: 々, バイト表現: 0xE3 0x80 0x85
トークンID: 27036, デコード結果: �, バイト表現: 0xE9 0xBA
トークンID: 118, デコード結果: �, バイト表現: 0xBA
トークンID: 7277, デコード結果: を, バイト表現: 0xE3 0x82 0x92
トークンID: 25971, デコード結果: 食, バイト表現: 0xE9 0xA3 0x9F
トークンID: 55078, デコード結果: べ, バイト表現: 0xE3 0x81 0xB9
トークンID: 5598, デコード結果: た, バイト表現: 0xE3 0x81 0x9F
トークンID: 788, デコード結果: 。, バイト表現: 0xE3 0x80 0x82
この例から、以下のことが読み取れる:
-
一般的な単語のトークン化:
- 「今日は」は頻出するフレーズのため、1つのトークン(170411)として扱われている
-
未知語の分解:
- 「麺」という文字は、2つのトークン(27036, 118)に分解されている
- これは「麺」という文字が比較的頻度が低く、単一のトークンとして登録されていないためである
- UTF-8のバイト列(0xE9 0xBA 0xBA)が2つのトークンに分割されている様子が確認できる
このように、トークナイザーは文字やフレーズの出現頻度に応じて、適切なトークン分割を行っている。頻出するパターンは1つのトークンとしてまとめられ、珍しい文字はバイトレベルに分解されることで、効率的かつ柔軟なテキスト処理を実現している。
補足:麺(3バイト文字)が2バイトと1バイトに分かれる理由
BPEでまとめられているということは、すなわちその表現が頻出であることを意味する。
では、なぜ「麺」の最初の2バイト(0xE9 0xBA)が1つのトークンとしてまとめられているのだろうか?
まず、以下のように0xE9 0xBAから始まる3バイト文字を全て出力した。
(※ここでは3バイト文字だけ注目した。)
# 0xE9 0xBAから始まる3バイト文字を全て出力
characters = []
for third_byte in range(256):
try:
# 3バイトの組み合わせを作成
bytes_data = bytes([0xE9, 0xBA, third_byte])
# UTF-8としてデコード
character = bytes_data.decode('utf-8')
characters.append(character)
except UnicodeDecodeError:
continue
print(f"見つかった文字数: {len(characters)}")
print("文字一覧:")
# 20文字ごとに改行して表示
for i in range(0, len(characters), 20):
print(''.join(characters[i:i+20]))
すると、以下のように、一般的に使われる文字を一定含むことがわかります。
(特に「麦」系の漢字がまとまって入っているのが面白いですね。すごく大雑把ですが部首的な概念がLLMに取り込まれていることになります。)
見つかった文字数: 64
文字一覧:
麀麁麂麃麄麅麆麇麈麉麊麋麌麍麎麏麐麑麒麓
麔麕麖麗麘麙麚麛麜麝麞麟麠麡麢麣麤麥麦麧
麨麩麪麫麬麭麮麯麰麱麲麳麴麵麶麷麸麹麺麻
麼麽麾麿
一方、2バイト目と3バイト目が0xBAである3バイト文字を全て出力してみる。
# 2バイト目と3バイト目が0xBAである3バイト文字を全て出力
characters = []
for first_byte in range(256):
try:
# 3バイトの組み合わせを作成
bytes_data = bytes([first_byte, 0xBA, 0xBA])
# UTF-8としてデコード
character = bytes_data.decode('utf-8')
characters.append(character)
except UnicodeDecodeError:
continue
print(f"見つかった文字数: {len(characters)}")
print("文字一覧:")
# 20文字ごとに改行して表示
for i in range(0, len(characters), 20):
print(''.join(characters[i:i+20]))
すると、そもそも種類も少なく、各文字の利用頻度も低い傾向があります。
(「人」だけはメジャーだが、おそらく文字ごとエンコーディング対象になっているだろう。)
見つかった文字数: 15
文字一覧:
຺Ẻ⺺㺺人庺溺纺躺麺꺺뺺캺ﺺ
ということで、「0xE9 0xBA」の結びつきが「0xBA 0xBA」よりも強いと判断されて、
「麺」のようなトークン構成になったのだと推察される。
Discussion