Andrej Karpathy「Let's build GPT」解説シリーズ 第3動画
はじめに
この回では、Andrej Karpathy氏の「Let's build the GPT Tokenizer」をもとに、LLMの学習に不可欠なTokenizerを実装しながら、その仕組みと重要性を深掘りしていきます。
なぜTokenizerが重要なのか?
過去、LLMを使っていたとき、時々不可解な挙動に出会うことがあったかと思います。例えば、
- How many letters "l" are there in the word ".DefaultCellStyle"? と質問した際に、lの数を4ではなく3と答えてしまう。
- 簡単な計算問題を間違えてしまう。
これらの多くは、Tokenizerに起因します。LLMはテキストをそのまま見ているのではなく、Tokenizerによって分割された「トークン」の列として認識します。この分割のされ方が、モデルの理解の仕方を決定づけているのです。
例えば、下の画像のようにGPT-4oのTokenizerは賢く、コードのインデント(例:
が4つ)を1つのトークンとして扱えます。一方、古いGPT-3のTokenizerは単なる空白の連続として見ています。このように、Tokenizerの精度はコーディング能力に影響を与える要因の一つです。
Byte Pair Encoding (BPE)
前回までの動画では、character-level tokenizerを使用していましたが、LLMの学習データを1文字1文字学習するのは効率的ではありません。より効率的なアプローチとして、Tokenizerの基本的なアイデアは、データ圧縮に似ています。例えば aaabdaaabac
という文字列があったとしましょう。
- 最も頻繁に出現するペアは
aa
です。これを新しいトークンZ
で置き換えます。ZabdZabac
- 次に頻出する
ab
をY
で置き換えます。ZYdZYac
- さらに
ZY
をX
で置き換えます。XdXac
このように、頻出するバイトペアを次々と新しいトークンにマージしていくアルゴリズムが、Byte Pair Encoding (BPE) です。LLMは、この圧縮されたトークン列を扱うことで、限られたコンテキストサイズの中でより多くの情報を効率的に処理できるようになります。
3つのTokenizer
1. 基本的なBPE
まずは、PythonでBPEの基本を実装してみましょう。UTF-8エンコードされたバイト列からスタートし、最も頻繁なペアをマージしていきます。
def get_stats(ids):
# バイトペアの出現回数を数える
counts = {}
for pair in zip(ids, ids[1:]):
counts[pair] = counts.get(pair, 0) + 1
return counts
def merge(ids, pair, idx):
# 指定されたペアを新しいIDに置き換える
newids = []
i = 0
while i < len(ids):
if i < len(ids) - 1 and ids[i] == pair[0] and ids[i+1] == pair[1]:
newids.append(idx)
i += 2
else:
newids.append(ids[i])
i += 1
return newids
# --- トレーニング処理 ---
text = "Unicode! 🅤🅝🅘🅒🅞🅓🅔‽ 🇺🇳🇮🇨🇴🇩🇪! 😄 ..." # 長いテキスト
tokens = text.encode("utf-8")
ids = list(tokens)
vocab_size = 276 # 目標の語彙サイズ
num_merges = vocab_size - 256 # 256は基本バイト数
merges = {}
for i in range(num_merges):
stats = get_stats(ids)
pair = max(stats, key=stats.get)
idx = 256 + i
ids = merge(ids, pair, idx)
merges[pair] = idx
print(f"圧縮率: {len(tokens) / len(ids):.2f}X")
# 圧縮率: 1.37X
このシンプルな実装で、テキストをトークン列に圧縮するBPEの基本を実現できます。
2. GPTシリーズ:正規表現による事前分割
しかし、単純なBPEでは、dog
と .
がくっついて dog.
という新しいトークンが生まれるなど、意味的な区切りが失われがちです。GPT-2では、この問題に対処するために正規表現を使ってテキストをあらかじめ意味のありそうな塊に分割(pre-tokenization)します。
import regex as re
# GPT-2で使われる正規表現パターン
gpt2pat = re.compile(r"""'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+""")
text = "Hello've world123 how's are you!!!?"
print(re.findall(gpt2pat, text))
# 出力: ['Hello', "'ve", ' world', '123', ' how', "'s", ' are', ' you', '!!!?']
このパターンは、よくある短縮形、単語、数値、記号などをうまく分割してくれます。この分割された塊の中でBPEを適用することで、より意味のあるトークン分割が可能です。
3. SentencePiece:言語に依存しないTokenizer
Googleが開発したSentencePiece
は、さらに強力なアプローチを取ります。
- 言語非依存: スペースで単語を区切らない日本語など、多くの言語を統一的に扱えます。
- Unicodeネイティブ: 絵文字や特殊記号も問題なく扱えます。
Unicodeとは?
Unicodeは世界中の文字を統一的に扱うための文字符号化標準で、日本語のひらがな・カタカナ・漢字、絵文字、数学記号など、あらゆる文字に固有の番号(コードポイント)を割り当てています。従来の文字レベルTokenizerでは、これらの多様な文字を個別に処理する必要がありましたが、SentencePieceはUnicodeを直接扱えるため、言語や文字種に関係なく一貫した処理が可能です。
-
Byte Fallback: 語彙にない未知の文字が出てきても、それをUTF-8のバイト列に分解して処理できるため、
<unk>
(未知語)トークンが原理的に発生しません。
import sentencepiece as spm
# SentencePieceモデルのトレーニング
options = dict(
input="toy.txt",
model_prefix="tok400",
model_type="bpe",
vocab_size=400,
byte_fallback=True, # 未知語をバイトに分解
# ... その他設定
)
spm.SentencePieceTrainer.train(**options)
# 学習済みモデルでエンコード
sp = spm.SentencePieceProcessor()
sp.load('tok400.model')
# 「안녕하세요」(こんにちは)という語彙にない韓国語もエンコードできる
ids = sp.encode("SentencePiece is nice, 안녕하세요")
print([sp.id_to_piece(idx) for idx in ids])
# 出力: ['▁SentencePiece', '▁is', '▁n', 'i', 'ce', ',', '▁', '<0xEC>', '<0x95>', '<0x88>', '<0xEB>', '<0x85>', '<0x95>', '<0xED>', '<0x95>', '<0x98>', '<0xEC>', '<0x84>', '<0xB8>', '<0xEC>', '<0x9A>', '<0x94>']
byte_fallback=True
のおかげで、韓国語の部分が未知語にならず、UTF-8のバイト列に対応するトークンに分解されていることがわかります。
ところでなぜ、niceはn, i, ceに分割されたのに、SentencePieceは分割されなかったのでしょうか?それは、SentencePieceのモデル学習に使われたtoy.txtが以下のような内容だからです。
"SentencePiece is an unsupervised text tokenizer and detokenizer mainly for Neural Network-based text generation systems where the vocabulary size is predetermined prior to the neural model training. SentencePiece implements subword units (e.g., byte-pair-encoding (BPE) [Sennrich et al.]) and unigram language model [Kudo.]) with the extension of direct training from raw sentences. SentencePiece allows us to make a purely end-to-end system that does not depend on language-specific pre/postprocessing."
このtoy.txtの中に何度もSentencePieceという単語が出てきたため、tokenizerがこれを1トークンとして学習しました。このようにtokenizerは単語を学習していきます。
GPT-2とGPT-4のTokenizer
tiktoken
で見るGPT-2とGPT-4の違い
OpenAIの公式Tokenizerライブラリtiktoken
を使うと、モデルごとの違いを簡単に確認できます。
import tiktoken
# GPT-2のTokenizer
enc_gpt2 = tiktoken.get_encoding("gpt2")
print(enc_gpt2.encode(" hello world!!!"))
# 出力: [220, 220, 220, 220, 31373, 1917, 29991] (空白が4つのトークンに)
# GPT-4のTokenizer
enc_gpt4 = tiktoken.get_encoding("cl100k_base")
print(enc_gpt4.encode(" hello world!!!"))
# 出力: [262, 24748, 1917, 12340] (先頭の空白が1つのトークンに)
GPT-4のTokenizerは複数の空白を効率的に1つのトークンとして扱えるため、特にプログラムコードのようなインデントが重要なテキストの処理能力が向上しています。
Tokenizer設計の重要な考慮点
ここまでBPEやSentencePieceの仕組みを見てきましたが、実際にTokenizerを設計する際には、アルゴリズム以外にも重要な設計判断があります。これらの判断が、最終的なLLMの性能に大きく影響するのです。
-
特殊トークン:
<|endoftext|>
のような特殊トークンは、モデルにテキストの区切りを教えるために不可欠です。これがないと、モデルはどこで文章を区切ればいいのか学習できません。他にも、チャットモデルでは<|im_start|>
や<|im_end|>
といったトークンで、システムプロンプトやユーザーの発話を区別します。 - 語彙サイズ: 語彙サイズを大きくすれば圧縮率は上がりますが、LLMは各トークンをベクトル(埋め込み)に変換するため、語彙サイズが大きいほど埋め込み行列の行数(=語彙数)が増え、メモリ消費や計算量が増大します。小さすぎると、1つの単語が多くのトークンに分割され、コンテキストを圧迫します。Tokenizerが単語を学習すればするほど良いというわけではありません。
-
正規化: Unicodeには見た目が同じでも内部表現が違う文字(例:
é
とe
+´
)があります。これらを前処理で正規化しないと、同じ単語が別のトークンとして扱われてしまいます。
まとめ
今回は、LLMのを学習する際に必要なTokenizerの仕組みについて以下のことを学びました。
- BPEというシンプルな圧縮アルゴリズムが基本であること。
- 正規表現による事前分割や、SentencePieceのような高度なライブラリによって、より賢いトークン分割が実現されている点。
- **Tokenizerの設計(空白の扱い、特殊トークン、byte fallback)**が、モデルの能力に直接的な影響を与えるという事実。
Tokenizerは単なる前処理ではなく、LLMの性能を左右する非常に重要な要素だと言えます。次回は、本格的にTokenizerを学習させ、いよいよGPT-2モデルの再現に挑みます。
(この記事は研究室インターンで取り組みました:https://kojima-r.github.io/kojima/)