LLMの中身を理解するため、tiny-jp-llmという小さな日本語LLMをゼロから作るプロジェクトを始めます。
作り始める前に、LLMが文章を受け取ってから言葉を返すまでの流れを調べました。
LLMが文章を返すプロセス
文章
↓ Tokenizerで分割する
トークン
↓ 語彙から対応する番号を調べる
トークンID
↓ Embeddingで変換する
ベクトル [0.12, -0.34, 0.91, ...]
↓ Transformer内のAttentionでそれまでのトークンの関係を計算する
次に続きそうなトークンID
↓ Tokenizerで対応するトークンへ戻す
トークン
↓ つなげる
文章それぞれの役割をざっと整理すると、次のようになります。
| 項目 | 説明 |
|---|---|
Tokenizer | 文章をトークンに分割し、トークンIDへ変換します。例えば、てみたがトークンで、587がそのIDです。 |
Embedding | トークンIDをベクトルへ変換します。ベクトルは、[0.12, -0.34, 0.91, ...]のように複数の小数を並べたものです。この値は学習によって変わります。 |
Transformer | Embeddingで変換したベクトルを受け取り、それまでの文章をもとに次のトークンを予測するモデル本体です。 |
Attention | Transformerの内部で、前に出てきたどのトークンを見るかを計算します。 |
tiny-jp-llmでは、この入口から出口までを順番に作ります。
最終的にはMacで学習し、短い日本語を生成させる予定です。
今回は、その入口になるトークナイザーから作ります。
トークナイザーを作ってみる
トークナイザーから作ろうと決めて調べてみると、文章の分け方は一つではないことがわかりました。
1文字ずつ分ける方法もあれば、単語ごとに分ける方法もあります。単語より細かい、よく出る文字のまとまりで分ける方法もあります。このまとまりをサブワードと呼ぶそうです。
サブワードの作り方にもさまざま種類があります。今回は、その中からByte-level BPE(バイト単位のBPE)を選びました。
BPEについては後述しますが、OpenAIのGPT-2や、OpenAIモデル向けのtiktokenで使用されていて、実際のLLMで使われている仕組みを題材に勉強したかったので、BPEを選びました。
tiktokenは、OpenAI公式のオープンソース実装で、BPEを少量のテキストで学習したり、結合の様子を表示したりする学習用モジュールまで入っていたため参考にしました。
今回は完成済みのライブラリをそのまま使わず、頻出するペアを数える処理からPythonで実装します。
今回はNext Developers Blogで公開されている388件の記事タイトルを学習させました。
今回作るもの
今回は、文章を数値の並びへ変換し、その数値から元の文章へ戻せるところまで作ります。
PythonでBPEを試してみた
↓ encode
[80, 121, 116, 104, 111, 110, ...]
↓ decode
PythonでBPEを試してみた最初は1バイトずつ数値へ変換します。これだけでも元の文章へ戻せますが、日本語は1文字が複数のバイトに分かれるので、数値の数がかなり増えます。
そこで、記事タイトルの中でよく隣り合うバイトを探し、一つのトークンへまとめます。この結合ルールを作るのが、今回BPEでやりたいことです。
学習後は、同じ文章をより少ないトークンで表せるようになります。語彙数を256、400、800、1,200と変えて、どのくらいトークン数が減るのかも確認します。
まだ文章を生成するLLMは作りません。今回は、その前段にある文章と数値を変換する部分だけです。
今回の環境
手もとの環境は次のとおりです。
- PC: MacBook Pro
- チップ: Apple M2 Pro
- メモリ: 16GB
- macOS: Tahoe 26.5
- Python: 3.10.11
今回のトークナイザーはPython標準ライブラリだけで動きます。
実験後のファイルはこちらです。
tiny-jp-llm/
├── data/
│ └── next_titles.txt
├── artifacts/
│ ├── experiment-summary.json
│ ├── tokenizer-256.json
│ ├── tokenizer-400.json
│ ├── tokenizer-800.json
│ └── tokenizer-1200.json
├── docs/
│ └── roadmap.md
├── scripts/
│ ├── fetch_next_titles.py
│ └── run_tokenizer_experiment.py
├── src/
│ └── tiny_jp_llm/
│ ├── __main__.py
│ ├── cli.py
│ ├── corpus.py
│ ├── experiments.py
│ └── tokenizer.py
└── tests/
├── test_cli.py
├── test_corpus.py
├── test_experiments.py
└── test_tokenizer.pydata/next_titles.txtとartifacts/のJSONは、スクリプトを実行したあとに生成されます。最初から用意したデータや学習済みモデルではありません。
tokenizer.pyがBPEの本体です。学習データの取得をcorpus.py、語彙数の比較をexperiments.pyへ分けました。
scripts/の2ファイルは、記事から実行しやすくするための入口です。実際の処理はsrc/tiny_jp_llm/に置いています。
NXSW/tiny-jp-llm: A tiny Japanese LLM from scratch.
https://github.com/NXSW/tiny-jp-llm
LLMは文章をそのまま読んでいない
どうやら、LLMへ渡した文章はそのまま読まれているわけではないようです。
文章はトークンという単位に分割され、それぞれが数値のIDへ変換されます。
この分割に使われる方法の一つが、Byte Pair Encodingです。
ざっくり言うと、頻繁に隣り合うトークンを見つけて、一つの新しいトークンへまとめる処理を繰り返します。
仕組みだけを見るため、いったん1文字を1トークンとして考えます。
例えば、最初は次のように分かれているとします。
し / て / み / た学習データにてとみの並びが多ければ、次のように結合します。
し / てみ / たさらにてみとたの並びが多ければ、こうなります。
し / てみた最初のトークンで迷った
コードを書き始めたところで、一度手が止まりました。
BPEは隣り合うトークンを結合します。そのため、1回目の結合をする前に、文章を何らかの単位へ分けておく必要があります。
先ほどのしてみたなら、最初はし / て / み / たと1文字ずつ分ければよいと思っていました。
ただ、学習データに出てきた文字だけへ数値のIDを割り当てると、そこにない文字を扱えません。
記事タイトルに猫が一度も出てこなければ、猫に割り当てるIDがないからです。この文字とIDの対応表を語彙(Vocabulary)と呼ぶそうです。
調べていると、語彙にない文字を<unk>へ置き換える方法が出てきました。unknownを表す特別なトークンです。
ただ、これだと猫も絵文字も同じ<unk>になります。元の文章へ戻せません。
元の文章へ戻せる方法を探して、tiktokenのコードをもう一度見ました。文字列を最初にUTF-8へ変換し、バイト単位で扱っています。
同じ方法なら、学習データにない文字も扱えそうです。まずはあをUTF-8へ変換してみました。
3個の数値になりました。
list("あ".encode("utf-8"))
# [227, 129, 130]1バイトが取れる値は0から255までです。
この256種類を最初から用意しておけば、学習時に見たことがない漢字や絵文字もバイトの並びにできます。これなら<unk>は要りません。
というわけで、最初の語彙は256種類のバイトにしました。src/tiny_jp_llm/tokenizer.pyのBytePairTokenizerを初期化する部分です。
self.vocab = {
token_id: bytes([token_id])
for token_id in range(256)
}この256種類から始めて、頻出するペアを新しいトークンとして追加していきます。
なお、今回作るのはBPEの結合を確認するための小さな実装です。
実際のtiktokenには、BPEの前に文章をある程度のまとまりへ分ける処理や、文章の区切りなどを表す特別なトークンがあります。今回はどちらも入れていません。
公開記事のタイトルを取得する
まず、BPEの結合ルールを作るための文章を用意します。
今回は、Next Developers Blogで公開されている記事タイトルを使いました。してみたやAWSなど、このブログでよく使う並びがどうまとまるのか見たかったためです。
WordPress REST APIから取得し、1行に1タイトルで保存します。
この処理はscripts/fetch_next_titles.pyへ書きました。APIを呼び出している部分はこちらです。
import json
import urllib.parse
import urllib.request
API_URL = "https://www.next.inc/wp-json/wp/v2/posts"
def fetch_page(page: int):
query = urllib.parse.urlencode({
"per_page": 100,
"page": page,
"_fields": "title",
})
request = urllib.request.Request(
f"{API_URL}?{query}",
headers={"User-Agent": "tiny-jp-llm/0.1"},
)
with urllib.request.urlopen(request, timeout=30) as response:
posts = json.load(response)
total_pages = int(response.headers["X-WP-TotalPages"])
return posts, total_pages一度に取得できるのは100件までなので、最初のレスポンスから総ページ数を取り、残りも順番に取得します。
posts, total_pages = fetch_page(1)
for page in range(2, total_pages + 1):
page_posts, _ = fetch_page(page)
posts.extend(page_posts)実行前に仮想環境を作りました。PYTHONPATHには、今回作ったsrc/tiny_jp_llmをPythonから読み込めるようにするため、srcを指定しています。
cd tiny-jp-llm
python3 -m venv .venv
source .venv/bin/activate
export PYTHONPATH="$PWD/src"
python3 scripts/fetch_next_titles.py取得できたタイトルは388件でした。

文字数は12,106文字です。UTF-8へ変換すると27,329バイトになりました。

頻出するペアを探す
ここからはsrc/tiny_jp_llm/tokenizer.pyへBPEの処理を書きます。次のコードは、BytePairTokenizer.train()の中で使う処理です。
まず、各タイトルをUTF-8のバイト列へ変換します。
タイトルをまたいだペアは数えたくないため、文章ごとに別の配列として持ちます。
documents = [
list(text.encode("utf-8"))
for text in texts
]この時点では、配列に入っている0から255の数値がそれぞれ1バイトです。
続いて、隣り合う数値のペアを数えます。Counterは、同じ値が何回出たかを数えてくれるPython標準ライブラリのクラスです。
from collections import Counter
@staticmethod
def _pair_counts(documents):
counts = Counter()
for tokens in documents:
counts.update(zip(tokens, tokens[1:]))
return counts最も多かったペアへ新しいトークンIDを割り当てます。
next_token_idは256から始め、結合するたびに1ずつ増やします。
counts = cls._pair_counts(documents)
best_pair, frequency = min(
counts.items(),
key=lambda item: (-item[1], item[0]),
)
rule = MergeRule(
left=best_pair[0],
right=best_pair[1],
result=next_token_id,
frequency=frequency,
)MergeRuleには、結合する左右のトークンID、新しく作るトークンID、その時点での出現回数を保存します。
少し分かりにくかったのがmin()の部分です。出現回数へマイナスを付けているため、回数が最も多いペアが選ばれます。同じ回数ならトークンIDの小さいペアを選び、実行するたびに結果が変わらないようにしました。
選んだペアを、すべてのタイトルで新しいIDへ置き換えます。置換は左から順番に行い、一度使ったトークンは重ねて使わないようにしました。
@staticmethod
def _merge_pair(tokens, pair, result):
merged = []
index = 0
while index < len(tokens):
if (
index + 1 < len(tokens)
and tokens[index] == pair[0]
and tokens[index + 1] == pair[1]
):
merged.append(result)
index += 2
else:
merged.append(tokens[index])
index += 1
return mergedこの処理を、各タイトルへ適用します。
documents = [
cls._merge_pair(tokens, best_pair, next_token_id)
for tokens in documents
]この結合を、指定した語彙数へ到達するまで繰り返します。
学習が終わると、結合ルールが順番に並びます。新しい文章を分割するときは、文章を同じくUTF-8へ変換し、学習した順番でルールを適用します。
def encode(self, text):
tokens = list(text.encode("utf-8"))
for rule in self.merges:
tokens = self._merge_pair(
tokens,
(rule.left, rule.right),
rule.result,
)
return tokens語彙数を変えて学習する
ここでは、頻出するペアを探して結合ルールを作る処理をBPEの学習と呼ぶそうです。文章を生成するLLM本体は、まだ学習していません。
結合を何回繰り返せばよいのかは、まだ分かりませんでした。
そこで、結合なしの256種類を出発点にして、語彙数を400、800、1,200まで増やした結果を並べることにしました。語彙数は、トークナイザーが使えるトークンの種類数です。ペアを一つ結合するたびに、新しいトークンが1種類増えます。
1回しか現れないペアまで覚えないよう、今回は同じペアが2回以上ある場合だけ結合しました。この比較処理はscripts/run_tokenizer_experiment.pyへ書いています。
それぞれの結合ルールはartifacts/tokenizer-*.jsonへ、比較結果はartifacts/experiment-summary.jsonへ保存します。
PYTHONPATH=src python3 scripts/run_tokenizer_experiment.py結果はこちらです。
| 語彙数 | 結合回数 | 全タイトルのトークン数 | 1トークンあたりのバイト数 | 学習時間 |
|---|---|---|---|---|
| 256 | 0 | 27,329 | 1.000 | 0.000秒 |
| 400 | 144 | 14,640 | 1.867 | 0.746秒 |
| 800 | 544 | 9,714 | 2.813 | 2.190秒 |
| 1,200 | 944 | 7,756 | 3.524 | 3.379秒 |
語彙数256では、1バイトがそのまま1トークンです。
語彙数を800まで増やすと、27,329バイトあったタイトルが9,714トークンになりました。
1トークンあたりのバイト数が大きいほど、一つのトークンに長い並びがまとまり、全体のトークン数が減っています。
語彙数800で作られた語彙の中身も確認しました。人間が読めるものだけ抜き出すと、ブログでよく見る並びが出ていました。
てみ
てみた
AWS
する
ストてみたは、結合ルールが作られた時点で81回出現していました。
てみたやAWSが一つのトークンになり、記事タイトルを使った影響がそのまま出ました。
Amazon Bedrockが一つの塊になった
学習データにそのままの形では入っていない、次の文章を分割してみます。記事タイトルで作った結合ルールを、別の文章にも使えるかを見るためです。
Amazon Bedrockで生成AIを試してみた語彙数400では23トークンでした。
A / m / a / z / on / ␠ / B / e / d / ro / c / k /
で / <e7 94> / <9f> / <e6> / <88> / <90> / AI /
を / <e8 a9> / <a6> / してみた<e7 94>のような表示は、まだ日本語1文字になっていない途中のバイト列です。e7と94は、それぞれ1バイトを16進数で表示しています。16進数は、0から9に加えてaからfを使って数値を表す書き方です。
語彙数800では7トークンまで減りました。
Amazon␠ / Bedrock / で / 生成 / AI / を試 / してみた␠は、Amazonの後ろの空白も同じトークンに入っていることを表しています。
語彙数1,200では4トークンです。
Amazon Bedrock / で / 生成AI / を試してみたこの文章では、語彙を増やすほど人間が見てもそれらしい単位になりました。
ただし、これはBPEがAmazon Bedrockの意味を理解したわけではありません。
記事タイトルの中で、そのバイト列が繰り返し隣り合っていたためです。
日本語1文字の途中で分割された
別の文章も試します。
Pythonで小さな日本語LLMを作ってみた語彙数800で18トークンになりました。
P / y / t / h / on / で / <e5 b0> / <8f> / さ / な /
日 / 本 / 語 / L / L / M / を作 / ってみたここで少し引っかかりました。
小が一つのトークンにならず、<e5 b0>と<8f>へ分かれています。
UTF-8で小はe5 b0 8fという3バイトです。その途中までしか結合されていません。
トークンは、必ずしも人間が読める文字や単語になるわけではありませんでした。
今回はByte-level BPEなので、1文字の途中を表すバイト列がトークンになることもあります。
未学習の絵文字も元へ戻せた
最後に、語彙数800のトークナイザーへ、学習データにはない絵文字を含む文章を渡します。
未学習の文字列も元に戻せます🚀ロケットの絵文字は、次の4トークンへ分かれました。
<f0> / <9f> / <9a> / <80>人間には読めませんが、連結すると元のUTF-8になります。
src/tiny_jp_llm/tokenizer.pyのdecode()では、各トークンが持つバイト列をつなぎ、最後にUTF-8の文章へ戻しています。
data = b"".join(
self.vocab[token_id]
for token_id in token_ids
)
return data.decode("utf-8")関数を書いただけでは、本当に元へ戻せるか分かりません。
そこで、語彙数800で学習したトークナイザーを使い、コマンドから文章をトークンIDへ変換してみます。
PYTHONPATH=src python3 -m tiny_jp_llm tokenizer encode \
--model artifacts/tokenizer-800.json \
--text "未学習の文字列も元に戻せます🚀"結果はこちらです。
{
"text": "未学習の文字列も元に戻せます🚀",
"utf8_bytes": 46,
"token_count": 27,
"token_ids": [
283, 170, 399, 166, 231, 191, 146, 260, 276,
135, 399, 151, 304, 151, 492, 385, 131, 267,
406, 187, 715, 401, 278, 240, 159, 154, 128
],
"decoded": "未学習の文字列も元に戻せます🚀"
}出力された27個のトークンIDを、今度はdecodeへ渡します。
PYTHONPATH=src python3 -m tiny_jp_llm tokenizer decode \
--model artifacts/tokenizer-800.json \
283 170 399 166 231 191 146 260 276 \
135 399 151 304 151 492 385 131 267 \
406 187 715 401 278 240 159 154 128デコード結果はこちらです。
{
"token_ids": [
283, 170, 399, 166, 231, 191, 146, 260, 276,
135, 399, 151, 304, 151, 492, 385, 131, 267,
406, 187, 715, 401, 278, 240, 159, 154, 128
],
"text": "未学習の文字列も元に戻せます🚀"
}最初に入力した文章と一致しました。
学習データに存在しない文字でも、256種類のバイトへ戻れば表現できます。
文字単位ではなくバイト単位から始めた理由を、ここでようやく実感できました。
元の文章へ戻せるかテストする
手元で試した3つの文章だけでは不安だったので、tests/にもテストを書きました。
確認したのは、学習にない日本語や絵文字を元へ戻せること、頻出するペアが実際に結合されること、JSONへ保存した結合ルールを読み直しても同じ結果になることです。コーパスの保存、語彙数の比較、CLIからの実行も含めて7件にしました。
PYTHONPATH=src python3 -m unittest discover -s tests -v実行結果はこちらです。
test_train_encode_and_decode (test_cli.CliTest) ... ok
test_fetch_next_titles_writes_all_pages (test_corpus.CorpusTest) ... ok
test_read_documents_skips_blank_lines (test_corpus.CorpusTest) ... ok
test_experiment_saves_models_and_summary (test_experiments.BpeExperimentTest) ... ok
test_frequent_pair_is_merged (test_tokenizer.BytePairTokenizerTest) ... ok
test_japanese_text_round_trips (test_tokenizer.BytePairTokenizerTest) ... ok
test_saved_model_can_be_loaded (test_tokenizer.BytePairTokenizerTest) ... ok
----------------------------------------------------------------------
Ran 7 tests in 0.012s
OK
コマンドで試した1文だけではなく、未学習の日本語と絵文字、保存したトークナイザーの読み直しでも、元の文章へ戻せることを確認できました。
作ってみて分かったこと
トークナイザーは、感覚的には文章を単語へ分ける処理だと思っていましたが、実際に作ってみるとすこし違ったようです。
BPEが見ているのは意味ではなく、学習データで繰り返し現れる並びです。
Amazon Bedrockやしてみたがまとまったのは、今回の記事タイトルに何度も登場したからです。
一方で、学習データにあまり出てこない文字は、UTF-8の途中で分かれることもありました。
それでもバイト列を失っていないため、元の文章へ完全に戻せます。
今回は、文章をトークンIDへ変換し、そこから元の文章へ戻すところまでできました。
AIエージェントと対話しながらBPEトークナイザーを作ってみましたが、普段使っているLLMの裏側を知れて楽しかったです。
ただし、まだ次のトークンを予測するモデルはありません。
次は、このトークン列を使って予測する側へ進みます。いきなりTransformerを作るのは難しそうなので、まずは直前のトークンだけから次を予測するBigramモデルを作ってみます。
Bigramは、隣り合う2つのトークンが何回出たかを数え、直前のトークンから次に続きやすいトークンを選ぶ小さなモデルです。
参考
ここまで読んでいただき、ありがとうございます。もしこの記事の技術や考え方に少しでも興味を持っていただけたら、ネクストのエンジニアと気軽に話してみませんか。
- 選考ではありません
- 履歴書不要
- 技術の話が中心
- 所要時間30分程度
- オンラインOK