作りながら学ぶLLM入門:前処理
概要
- この記事の対象者:LLMの内部処理をコードレベルで具体的に理解し、自分で簡易的なモデルを動かしてみたいエンジニアや研究者。
- この記事の内容:Raschka著『作りながら学ぶLLM入門』第2章をベースに、トークン化からサブワード分割、特殊トークン付与、データローダー作成、埋め込みまでの前処理工程をPythonコード付きで解説。
- この記事を読んでできること:前処理の各ステップを自力で実装し、英語・日本語を問わずLLMの学習データを準備するパイプラインを構築できる。
序説
(長いので、お急ぎの方はスキップしてください)
MCP、AIエージェント等盛り上がりを見せてますが、
そもそもLLMってなんで動いているんでしょうか??
Transformerっていうのが内部にあって、
確率的分布に従って、
RLHFで人間のフィードバックで学習させてetc、、、
理論的な説明はよく見かけます。
ですが、どこか抽象的で、結局のところ具体的に何をしているのかがイメージしづらいですよね。
そこで、作ってみればブラックボックスだと思っていたものが少しずつ「見える化」されてくるのではと思ってます。
あと、自分で作ったLLMを動かしてみたくないですか??
めちゃ強いLLMを作りたいとかではなく、めちゃ弱いLLMを作ってみたいのです。
個人的に、「最強のLLMを作りたい!」というよりは、
むしろ「めちゃくちゃ弱いLLMでもいいから、自分で動かしてみたい」という気持ちが強いです。
そして最終的には、「やっぱりGoogleやOpenAIのLLMはすごいんだな」と実感したい願望があります。
そんな動機から、以下の書籍を参考にしてLLMの勉強(という名の遊び)を始めました
数あるLLM関連の本の中でも、この本は「コードで学ぶ」というスタイルが徹底されており、机上の理論だけで終わらない点がいいなと思いました。
ありがたいことにコードも付録でついていましたが、英語で公開されています。
https://github.com/tyukei/LLMs-from-scratch/blob/main/ch02/01_main-chapter-code/ch02.ipynb
「せっかくなら英語のNotebookを日本語に訳して、自分なりの解釈も加えてみよう」と思い、
以下のように和訳&コメント付きのNotebookを作成しました
日本語訳Notebookでは、なるべく初学者でも理解しやすいようにコメントや補足を入れています。
少しでも「自分でもできそう!」と感じてもらえたら嬉しいです。
ぜひColabで開いて、一緒に遊んでみましょう!
ざっくりイメージ
LLMを作るには、まずデータセットを準備する必要があります。
文章で入力して、文章を返していますが、
実際にはmodelに入れる際、計算できるよう数値に直す必要があります。
その数値に直すことを前処理と言います。
前処理として、文章を単語に分ける→IDに直す→ベクトルに直すという流れです。
文章から単語にするには、英語の場合スペースで分けてあげれば良いです。
日本語のようにスペースで分かれてない場合は工夫が必要です。(詳しくは3. サブワード分割)
単語からIDに直すのは、エンコードと言います。
事前にappleは1,bananaは2みたいに決めておき、単純に辞書型を用いて置き換えてあげます。
最後にIDからベクトルを作成します。
このIDと位置(文章の頭から何番目か)を元に、ランダムな値を作成しベクトルを作ります。
作りながら学ぶLLM入門:第2章 前処理
近年、大規模言語モデル(LLM: Large Language Model)は劇的な進化を遂げ、あらゆる自然言語処理タスクで目覚ましい成果を上げています。チャットボットや検索エンジン、ドキュメント生成、プログラミング支援など応用範囲は多岐にわたります。しかし、LLMのトレーニングを自分で一からやろうと思うと、実に多くのステップが必要です。特に、膨大なテキストデータを「どのように前処理し、モデルに読み込ませるか」という部分は、LLMにおける初歩的かつ重要な関門です。
本記事では、Sebastian Raschka著『作りながら学ぶLLM入門』の第2章の内容をベースにしつつ、LLMの前処理工程について順を追って解説します。書籍付属ノートブックを元にしながら、以下の話題を取り上げます。
- 前処理の重要性と全体像
- トークン化(Tokenization)の基礎
- 特殊トークン(BOS, EOS, UNK, など)の付与
- BPE(Byte Pair Encoding)などのサブワード分割手法
- PyTorchを使ったシンプルなデータセット/データローダーの作成
- 単語埋め込みと位置埋め込みの概念
今回は「前処理」(レベル1-1 ステージの作業)を中心に進めます。コード例にはPythonを用い、文章中で必要に応じて補足コメントも追加しました。さらに、記事中では英語テキストを例にしていますが、日本語を含む多言語のトークナイザではどのような工夫があるかにも触れています。LLMを支える屋台骨とも言える前処理の技術を、ぜひ一緒に掘り下げていきましょう。
1. 前処理の全体像
LLMを構築するフローは、大まかに以下のステップで進行します。
-
テキストデータの収集
ウェブサイト、電子書籍、論文やニュース記事など、さまざまなソースからテキストデータを大量に取得します。 -
前処理(クリーニングやトークン化など)
生のテキストデータは、改行・記号・HTMLタグが含まれていたり、機械学習で使えない文字コードが含まれる場合があります。まずは不要なものを除去・統一し、その後モデルが扱える形(=数値の列)に変換するプロセスが必須です。 -
データローダーやミニバッチの作成
学習時には、大量のテキストをまとめて一気に処理するのではなく、一定サイズのかたまり(バッチ)に分割して読み込みます。この仕組みを実装するためにDataset
クラスやDataLoader
クラス(PyTorchなど)を使います。 -
モデル構造(アーキテクチャ)の実装
Transformerなどの構造を用いて、大規模言語モデルを組み上げます。 -
トレーニング(学習)
大量のテキストデータから損失を逆伝播してパラメータを更新し、モデルを賢くしていきます。 -
評価と推論
学習が終わったモデルを使い、テキスト生成や要約などの推論を行い、モデルの性能を検証します。
本記事では、上記ステップのうち「2. 前処理」と「3. データローダー作成」の基礎を中心に解説します。ここでの処理がうまくいくかどうかが、後々の学習効率やモデル性能、さらには生成される文章の品質に大きく影響します。
2. トークン化の基礎:文字列から数値へ
2.1 なぜ数値に変換する必要があるのか
コンピュータは文字列を直接「意味のまとまり」としては理解できません。文章をそのまま与えても、それを扱うことは非常に難しいです。そこで、まずは文章を「単語やサブワード、文字単位などの粒度で分割」し、それぞれに一意のID(整数)を割り当てます。この操作がトークン化(Tokenization)です。
トークン化によって得られた整数列をネットワークへ入力すると、モデルは「単語ID → ベクトル埋め込み」を通じて文章を数値ベクトルとして捉え、学習・生成を行うことが可能になります。これが自然言語処理の基礎的なアプローチです。
2.2 まずは単純なトークン化
英語テキストであれば、スペースやカンマ、ピリオド、クオートなどの記号で切り分けるだけでもそれなりに単語単位のトークン化が実現できます。Pythonのre.split()
を使えば、正規表現のパターンを指定してテキストを分割可能です。下記の例では、カンマやピリオド、感嘆符、疑問符、さらに--
のような連続ハイフンなども含めて切り分けています。
import re
text = "Hello, world. Is this-- a test?"
result = re.split(r'([,.:;?_!"()\']|--|\s)', text)
# 空文字などを除去
result = [item.strip() for item in result if item.strip()]
print(result)
# ['Hello', ',', 'world', '.', 'Is', 'this', '--', 'a', 'test', '?']
2.3 辞書(ボキャブラリ)とトークンIDの対応
トークンのリストが得られたら、次にやるべきは「各トークンを整数IDに変換する」ことです。たとえば
{"Hello": 0, "world": 1, ".": 2, ",": 3, ...}
のように、トークンをキーとし、連番をバリューとする辞書(Pythonのdict
)を作り上げます。実際には出現するすべてのトークンを列挙してソートし、それぞれに順番でIDを振ることもあります。こうした対応関係を**語彙(ボキャブラリ)**と呼びます。
そして、テキスト全体をスキャンして得られたユニークなトークンの集合をvocab
として保持し、各トークンに対応するIDを割り当てれば、以下の例のように「文字列 → トークンID」の変換(encode)を行えるクラスを作れます。
class SimpleTokenizerV1:
def __init__(self, vocab):
self.str_to_int = vocab
self.int_to_str = {i: s for s, i in vocab.items()}
def encode(self, text):
# 正規表現でトークン分割
preprocessed = re.split(r'([,.:;?_!"()\']|--|\s)', text)
preprocessed = [item.strip() for item in preprocessed if item.strip()]
# トークン→ID
ids = [self.str_to_int[s] for s in preprocessed]
return ids
def decode(self, ids):
# ID→トークン
text = " ".join([self.int_to_str[i] for i in ids])
# 句読点の前の余計なスペースを除去
text = re.sub(r'\s+([,.?!"()\'])', r'\1', text)
return text
2.4 特殊トークンの活用(UNK, EOS, BOSなど)
実際のデータでは辞書に存在しない単語(未知語)が登場することが避けられません。たとえば「Hello」が辞書に無ければ、その単語をどう処理するのか? この問題に対処するためにしばしば導入されるのが、UNKトークン Unknown Tokenです。
-
[UNK] や
<|unk|>
といった特別な文字列を単語の代わりに使い、未知の語を一括して表現する - テキストの先頭にBOS (Begin of Sequence)、末尾に**EOS (End of Sequence)**トークンを加える
- PAD (Padding) をバッチ処理の際に利用し、長さが異なる系列を同じ長さにそろえる
など、多くのモデルでは特殊トークンが重要な役割を果たします。GPT系のモデル(GPT-2/3など)は<|endoftext|>
というEOS相当のトークンだけを用いて、PADやUNKトークンは代わりにサブワード分割で対応しています。特にGPT-2の場合は<|endoftext|>
をパディングにも流用し、UNKトークンの代わりにBPE(Byte Pair Encoding)トークナイザで未登録単語をサブワード列へと落とし込むデザインとなっています。
class SimpleTokenizerV2:
def __init__(self, vocab):
self.str_to_int = vocab
self.int_to_str = { i:s for s,i in vocab.items()}
def encode(self, text):
preprocessed = re.split(r'([,.:;?_!"()\']|--|\s)', text)
preprocessed = [item.strip() for item in preprocessed if item.strip()]
# 語彙に無い単語はUNKに置き換え
preprocessed = [
item if item in self.str_to_int
else "<|unk|>" for item in preprocessed
]
ids = [self.str_to_int[s] for s in preprocessed]
return ids
def decode(self, ids):
text = " ".join([self.int_to_str[i] for i in ids])
text = re.sub(r'\s+([,.:;?!"()\'])', r'\1', text)
return text
こうしておけば、新たに出会った単語をすべて「未知語」としてモデルに処理させることができます。ただし、実際のLLMの多くはBPEやWordPieceなどのサブワード分割によって未知語を細かく刻み、可能な限りUNKを出さないようにします。この手法を使うことで、未知語が全く学習データに無かったとしても、サブワード単位での埋め込みからある程度は推測可能になります。
3. サブワード分割(Byte Pair Encoding など)
3.1 なぜサブワード化が必要か
語彙を単語単位で作ろうとすると、単語があまりにも多様すぎるために辞書が膨大になり、また未知語問題(UNK)が頻繁に発生します。特に英語以外でも、ドイツ語やフランス語、日本語や中国語などは語形変化や結合語が多く、単語単位の辞書で全パターンを網羅するのはほぼ不可能です。そこで考案されたのが「サブワード」という中間レベルの単位です。
-
Byte Pair Encoding (BPE)
もっとも有名なサブワード分割の1つがBPEです。出現頻度が高い文字の組み合わせをサブワード(トークン)として登録し、それ以外は小さい単位に分割します。 -
WordPiece
BERTが採用したサブワード分割の方式で、BPEに類似したアイデアです。 -
SentencePiece
Googleが開発したトークナイザで、BPEとUnigram言語モデルに基づく分割など複数手法を提供します。日本語のトークナイザとしてもよく使われます。
tiktoken
ライブラリ
3.2 OpenAIのOpenAIのGPT系モデルでは、内部でBPEベースのトークナイザが動いています。その機能の一部を切り出してOSS化したのがtiktoken
ライブラリです。このライブラリは非常に高速かつ簡単に扱えるため、GPT-2/3/ChatGPTスタイルのBPEトークナイザを試すうえで便利です。
import tiktoken
tokenizer = tiktoken.get_encoding("gpt2")
text = "Hello, do you like tea? <|endoftext|> In the sunlit terracesof someunknownPlace."
# GPT-2の特殊トークンも考慮する
encoded = tokenizer.encode(text, allowed_special={"<|endoftext|>"})
print(encoded)
# 例: [15496, 11, 466, 345, ... 50256, ...]
decoded = tokenizer.decode(encoded)
print(decoded)
# "Hello, do you like tea? <|endoftext|> In the sunlit terracesof someunknownPlace."
tiktoken
を利用すると、未知の単語も複数のサブワードに分解されるため、UNKトークンは使われません。GPT-2では辞書サイズが約50257あり、そのうち1つが<|endoftext|>
に割り当てられています。
4. スライドウィンドウを使ったデータサンプリング
4.1 LLMの学習データとは?
LLMを学習するとき、単語やサブワードの並び(列)を**「一部をモデルに入力して、次のトークンを正しく当てる」**というタスクで訓練します。例えば言語モデルが
The cat sat on the ...
のような文脈を読み込んだとき、次の単語として「mat」を予測するよう学習をします。これを大量のテキストで繰り返し、予測能力を高めるのです。
4.2 入力とターゲットの対応
具体的には、ある長さN
の単語列があったとき、モデルの「入力(x)」として先頭からN-1個の単語列を与え、「正解(y)」として先頭からN-1個を1個分右にずらしたものを用意します。こうすると、モデルが各単語から次の単語を予測するような学習を実現できます。
これは**「i番目の単語を見て(入力)、i+1番目の単語を当てる(出力)」**という形式です。
たとえば下記のようなトークン列:
enc_text = [10, 15, 20, 25, 30, ...] # トークンID列
先頭から長さ4個を取り出して入力x = [10, 15, 20, 25]
とし、ターゲットy = [15, 20, 25, 30]
を作る、といった具合です。
4.3 ストライドとバッチ化
大量のトークン列を処理する際には、長いテキストをいくつかの「チャンク」に切り分けます。各チャンクの長さをmax_length
とし、さらにチャンク同士がどのくらい重複するか(あるいはしないか)をstride
で決めます。オーバーラップを増やすことで、データをより多く確保する狙いがある反面、重複学習による計算量増大や過学習のリスクもあるため、ここはモデルサイズやデータ量と相談です。
PyTorchを使って、以下のようなクラスを作ると便利です。
import torch
from torch.utils.data import Dataset, DataLoader
class GPTDatasetV1(Dataset):
def __init__(self, txt, tokenizer, max_length, stride):
self.input_ids = []
self.target_ids = []
token_ids = tokenizer.encode(txt, allowed_special={"<|endoftext|>"})
for i in range(0, len(token_ids) - max_length, stride):
input_chunk = token_ids[i:i + max_length]
target_chunk = token_ids[i+1:i+max_length+1]
self.input_ids.append(torch.tensor(input_chunk))
self.target_ids.append(torch.tensor(target_chunk))
def __len__(self):
return len(self.input_ids)
def __getitem__(self, idx):
return self.input_ids[idx], self.target_ids[idx]
このクラスは、テキスト全体をBPEで符号化した後、スライドウィンドウ(幅max_length
)でチャンクを切り出し、入力とターゲットのペアを作ります。そしてDataLoader
を使えば以下のようにバッチ読み込みが可能です。
def create_dataloader_v1(txt, batch_size=4, max_length=256,
stride=128, shuffle=True, drop_last=True,
num_workers=0):
tokenizer = tiktoken.get_encoding("gpt2")
dataset = GPTDatasetV1(txt, tokenizer, max_length, stride)
dataloader = DataLoader(dataset,
batch_size=batch_size,
shuffle=shuffle,
drop_last=drop_last,
num_workers=num_workers)
return dataloader
これによって
-
バッチサイズ:例えば
batch_size=8
なら、1回のイテレーションで8個の(入力, 出力)ペアが返ってくる。 - max_length:一度にモデルへ入力するトークン数の上限。
- stride:テキストを切り出すときに次の開始位置をどれだけずらすか。
などのパラメータを切り替えられます。
5. 埋め込みの基礎
5.1 埋め込みとは?
前処理の最終段階として、「トークンIDをモデルが扱う連続ベクトル表現に変換する」工程を見ておきましょう。これがしばしば埋め込み(Embedding)と呼ばれるものです。埋め込みを行うと、単なる整数のIDが高次元の実数ベクトルに対応づけられます。これにより、モデル内部で類似単語は類似ベクトルとして扱われ、微分可能なパラメータとして学習時に更新されるようになります。
PyTorchの場合、nn.Embedding(num_embeddings, embedding_dim)
というレイヤーを用いて実装します。例えば語彙サイズがvocab_size = 50000
で、埋め込み次元がoutput_dim = 256
なら、nn.Embedding(50000, 256)
によって50000 x 256
のパラメータ行列が作成され、トークンIDを添字として該当行を取り出すイメージです。
import torch
# 例: vocab_size=6, output_dim=3の場合
vocab_size = 6
output_dim = 3
torch.manual_seed(123)
embedding_layer = torch.nn.Embedding(vocab_size, output_dim)
# IDのリストを埋め込み
input_ids = torch.tensor([2, 3, 5, 1])
output_vecs = embedding_layer(input_ids)
print(output_vecs.shape) # torch.Size([4, 3])
これが各単語(あるいはサブワード)を連続ベクトルに変換する仕組みの根幹です。GPT-2などではさらに次元数が768、1024、あるいは1万を超えるような巨大な埋め込みベクトルが使われます。
5.2 位置埋め込み
Transformerベースのモデルでは、入力系列の順序情報を追加で与える必要があります。なぜなら、Transformerは自己注意機構(self-attention)によって単語同士の関係を学習しますが、埋め込みベクトルそれ自体に「何番目の単語か」という順序の手がかりがないからです。
GPT-2は絶対位置埋め込みを用いています。たとえば文長がmax_length = 1024
だとすると、各位置0〜1023に対応するベクトルを保持しておき、トークンの埋め込みベクトルと**加算(または結合)**して使用します。
# 位置埋め込み層
pos_embedding_layer = torch.nn.Embedding(context_length, output_dim)
pos_ids = torch.arange(context_length) # 0, 1, 2, ..., context_length-1
pos_vecs = pos_embedding_layer(pos_ids) # 形状: (context_length, output_dim)
# トークン埋め込みに位置ベクトルを加算
input_embeddings = token_embeddings + pos_embeddings
BERTでは位置を正弦波でエンコード(sin/cos)する手法も採用されましたが、GPT-2やGPT-3は単純に訓練可能なパラメータとして位置埋め込みを保持しています。
6. 日本語テキストの場合
英語はスペースや句読点で比較的簡単に単語単位に分割できますが、日本語は単語の間に空白を入れないため、そのままでは単語分割が困難です。昔ながらの**形態素解析(MeCab、Juman、Kuromojiなど)**を行ってトークナイズする手段が一般的でした。しかしLLMの台頭とともに、サブワード分割によるアプローチが標準的になってきています。日本語であってもBPEやSentencePieceを使用し、
"私はカレーが好きなのは、私"
↓ BPEなどによるサブワード化
["▁私は", "カレー", "が", "好き", "な", "の", "は", "、", "私"]
のように(SentencePieceならスペースは先頭にアンダーバーで表現する)サブワードに分割する方法が多いです。このようにして作られたサブワード配列に対して、後述の埋め込み層を適用することで、日本語でもうまく文脈を学習できるわけです。
7. まとめ
以上、LLMの前処理として
- 生テキストのクリーニング
- トークン化(単語分割・サブワード分割)
- 辞書作成とトークンIDへの変換
- 不明単語用トークンや特殊トークンの付与
- スライドウィンドウを使った入力・ターゲットデータの生成
- バッチ化のためのデータローダー作成
- 埋め込み・位置埋め込み
までを概観しました。
LLM構築における第2章「前処理」の重要ポイントをまとめました。最終的にLLMが自然な文章生成や意味理解をできるかどうかは、前処理によるトークン化やサブワード分割が大きく左右します。
8. 補足情報
-
BPE以外のサブワード手法
WordPiece(BERTが採用)、Unigram言語モデル(SentencePiece)なども実力派です。日本語タスクではSentencePieceが広く使われています。 -
既存トークナイザの再利用
Hugging Faceのtransformers
ライブラリには、多数の事前学習モデルと対応するトークナイザが用意されています。独自にBPEを実装する手間を省き、既存トークナイザを使うと効率的です。 -
トークン数の上限
GPT-2が50257トークン、GPT-3が約50,000トークン弱、BERTのWordPieceが3万〜10万規模など、モデルごとに大きく異なります。非常に大きい辞書を持つとメモリを消費しますが、未知語を減らして幅広く対応できるメリットがあります。 -
トークナイザ自体の学習
BPE辞書の構築などは、トークナイザ自体を最初に学習させる作業が必要です。多量のテキストを読み込み、頻出の文字列ペアを統計的にマージして、最適なサブワードリストを作ります。 -
サンプルコードでのエラー対策
ノートブック環境でインターネット経由のファイルダウンロードを行う場合はSSL関連のエラーが起こることがあります。その際はPythonのバージョンや証明書設定をチェックしてください。 -
日本語固有の注意点
絵文字や機種依存文字、全角スペースなどが混在する場合、Unicode正規化処理を事前に行うことが推奨されます。また「。」「、」「!」「?」などを正規表現で細かく切り出すか、SentencePieceへ丸投げするか、といった設計上の判断も必要です。
参考リンク
結言
BPEで一句
Discussion