tiny-jp-llmは、LLMの中で使われている仕組みを一つずつ実装し、小さな日本語LLMを作ってみるプロジェクトです。
前回は、Next Developers Blogの388件の記事タイトルを使い、文章をトークンIDへ変換するバイトレベルBPEトークナイザーを作りました。頻出するバイトの並びを一つのトークンへまとめ、トークンIDから元の文章へ戻せるところまで確認しています。
コードと実行方法は、GitHubのNXSW/tiny-jp-llmへ置いています。
ただ、トークンIDへ変換できるだけでは文章を生成できません。
そこで今回は、直前の1トークンから次に続きそうなトークンを選ぶBigramモデルをPythonで作ります。
学習に使うのは、前回と同じNext Developers Blogの388件の記事タイトルです。
最後は、学習データにない新しい記事タイトルを生成してみます。
今回作るもの
今回の流れはこちらです。
記事タイトル
↓ 前回作ったトークナイザー
トークンID
↓ 隣り合う2つのIDを数える
Bigramモデル
↓ 回数に応じて次のIDを選ぶ
新しいトークンIDの並び
↓ Tokenizerで元へ戻す
記事タイトルらしき文章例えば、学習データの中でChatGPTの次に何が現れたかを数えると、次のようになりました。
ChatGPT → の 3回
ChatGPT → 空白 2回
ChatGPT → で 2回
ChatGPT → が 2回
ChatGPT → に 1回回数が多いものほど選ばれやすくします。
ChatGPTの次は必ずのにするのではなく、でやがになることもあります。
選ばれたトークンを次の入力にして、文末まで同じ処理を繰り返します。
ニューラルネットワークはまだ使いません。
今回は、隣り合うトークンを数えるだけで、どこまで文章らしいものが出るか試します。
Bigramについて調べてみる
最初に名前で少し引っかかりました。
作り方を調べてみると、Bigramは連続する2つの要素を組として扱う方法のようです。
今回はトークンを使うため、次のような組を数えます。
AWS → で
で → 生成AI
生成AI → を
を → 試してみた今回作るトークン単位のBigramでは、次を選ぶときに直前の1トークンだけを見ることになります。
例えばAWS → で → 生成AIと進んだ場合、生成AIの次を選ぶ時点では、それより前にAWSがあったことは使いません。
かなり単純ですが、トークンIDから初めて文章を生成するには分かりやすそうです。
前回のトークナイザーを使う
今回追加したファイルはこちらです。
tiny-jp-llm/
├── artifacts/
│ ├── tokenizer-800.json
│ ├── bigram.json
│ └── bigram-samples.json
├── scripts/
│ └── run_bigram_experiment.py
├── src/
│ └── tiny_jp_llm/
│ └── bigram.py
└── tests/
└── test_bigram.py前回は、となり合うバイトをどの順番で結合するか学習し、その結果をtokenizer-800.jsonへ保存しました。
このJSONには、388件の記事タイトルから作った800種類のトークンと結合ルールが入っています。今回はこれを読み込み、記事タイトルと生成結果を同じ方法でトークンIDへ変換したり、文章へ戻したりします。
最初に388件の記事タイトルを読み込み、トークンIDへ変換します。
tokenizer = BytePairTokenizer.load(
Path("artifacts/tokenizer-800.json")
)
documents = read_documents(
Path("data/next_titles.txt")
)
sequences = [
tokenizer.encode(document)
for document in documents
]今回は語彙数800のトークナイザーで固定しました。
語彙数による違いは前回確認したため、ここではBigramの動きだけを見ます。
文頭と文末をどうするか
となり合うトークンを数え始めたところで、文頭には左隣のトークンがないことに気づきました。
文章をどこで終わらせるかも必要です。
そこで、通常の語彙とは別に文頭と文末を表すIDを用意しました。
self.bos_token_id = vocab_size
self.eos_token_id = vocab_size + 1調べてみると、文頭にはBOS、文末にはEOSという特別なトークンを置く方法があるようです。それぞれBeginning of SentenceとEnd of Sentenceを表しいるようです。
プログラミングをやっているなら知っているであろうヒアドキュメントのあれです。
語彙数が800なら、BOSは800、EOSは801になります。
例えば、次のタイトルがあるとします。
AWSで試してみた数えるときだけ、前後にIDを加えます。
BOS → AWS → で → 試してみた → EOSこれで文頭に現れやすいトークンと、文章が終わりやすい場所も数えられます。
となり合うトークンを数える
Bigramの本体はsrc/tiny_jp_llm/bigram.pyへ書きました。
文章ごとにBOSから始め、となり合うトークンの回数をCounterへ追加します。
previous = model.bos_token_id
for token_id in token_ids:
transitions[previous][token_id] += 1
previous = token_id
transitions[previous][model.eos_token_id] += 1transitions[543]を見ると、トークンID543の次に現れたIDと回数が入っています。
今回のトークナイザーでは、543がChatGPTでした。
260: 3回 # の
32: 2回 # 空白
271: 2回 # で
320: 2回 # が
267: 1回 # に今回は、この隣接回数をBigramモデルとして保存することにしました。
コードでは、この処理をtrain()という名前にしました。
ただ、今回やっているのは、388件の記事タイトルから隣り合うトークンの回数表を作ることです。
この記事では、この回数表を作る処理を便宜的に Bigramモデルの学習 と呼ぶことにします。
回数に応じて次のトークンを選ぶ
次のトークンは、出現回数を重みとしてランダムに選びます。
choices = sorted(counts.items())
roll = rng.randrange(
sum(count for _, count in choices)
)
cumulative = 0
for token_id, count in choices:
cumulative += count
if roll < cumulative:
return token_id同じ結果をもう一度出せるよう、乱数にはseedを指定します。
生成はBOSから始めます。
選ばれたIDがEOSなら終了です。それ以外なら結果へ追加し、そのIDから次を選びます。
generated: list[int] = []
current = self.bos_token_id
for _ in range(max_tokens):
counts = self.transitions[current]
next_token_id = self._weighted_choice(counts, rng)
if next_token_id == self.eos_token_id:
break
generated.append(next_token_id)
current = next_token_id念のため、EOSが出ない場合に備えて最大60トークンで止めます。
記事タイトルを生成する
まず、記事タイトル388件からBigramモデルを作ります。
cd tiny-jp-llm
source .venv/bin/activate
export PYTHONPATH="$PWD/src"
python3 -m tiny_jp_llm bigram train \
--input data/next_titles.txt \
--tokenizer artifacts/tokenizer-800.json \
--model artifacts/bigram.json結果はこちらです。
{
"model": "artifacts/bigram.json",
"documents": 388,
"vocab_size": 800,
"states": 641
}641種類のトークンについて、次に何が現れたかを保存できました。

続いて、乱数シードを42に固定して20件生成します。
python3 scripts/run_bigram_experiment.py結果はartifacts/bigram-samples.jsonにも保存されます。
集計結果はこちらです。
{
"documents": 388,
"vocab_size": 800,
"states": 641,
"seed": 42,
"sample_count": 20,
"valid_utf8_count": 7,
"invalid_utf8_count": 13,
"reached_eos_count": 19,
"seen_in_training_count": 0
}20件のうち19件はEOSまで到達しました。
学習データと完全に同じタイトルは0件です。
そして、正しいUTF-8として文章へ戻せたのは7件だけでした。
生成された日本語を見る
正しいUTF-8になった7件はこちらです。
ChatGPTのメモでカスタンス問題解説
2023にHelentabをインストールしてみた
GuriverateのivOps Efff文すか?
ChatGPTがかしてみた
AWS Bedrockの開発における屆知らす
【2026に列ン3 xityterってい用方法まとめ
SQL「HP対策と竘起動開発してみたそれらしいものもありますが、だいたいおかしいです。
ChatGPTがかしてみたは、何をしたのかまったく分かりません。
ただ、ChatGPTやAWS Bedrock、インストールしてみたなど、元の記事タイトルでよく見る並びは残っています。

いきなり文字化けした
残りの13件には、次のように�が入りました。
正し祭り方と文数のよう編集口�券技要以語・外部アグラわ外部活用のす
【AWS SAA C」を不うぽースの�時のメッチファイル読��日」っていてみた
Sで「シートに最通技語・高�御用革化!日本語が不自然になることは予想していましたが、文字そのものが壊れるとは思っていませんでした。
原因を追うと、前回作ったByte-level BPEのトークンへ行き着きました。
前回の実験では、日本語1文字の途中までしか結合されていないトークンがありました。
<e5 b0> / <8f>元の文章では正しい順番で並ぶため、decode()すれば小へ戻ります。
しかし、今回作ったBigramは直前の1トークンしか見ません。
生成中に別のバイト列へつながると、UTF-8として成立しない並びになります。
今回は、デコードに失敗した結果だけerrors="replace"で表示し、壊れた箇所を�にしました。
try:
text = raw_bytes.decode("utf-8")
valid_utf8 = True
except UnicodeDecodeError:
text = raw_bytes.decode("utf-8", errors="replace")
valid_utf8 = False正しいUTF-8になるまで生成をやり直せば、見た目上は隠せます。
ただ、それではBigramが正しく生成できたことにはなりません。
今回は20件中7件だった結果を、そのまま残すことにしました。
テストする
Bigram用に3件、CLI用に1件のテストを追加しました。
確認したのは、文頭と文末を含めて回数を数えられること、同じseedなら同じ結果になること、保存したモデルを読み直せること、コマンドから学習と生成ができることです。
前回までのテストと合わせて実行します。
PYTHONPATH=src python3 -m unittest discover -s tests -v11件とも通りました。

作ってみてわかったこと
今回は、トークンIDの並びから初めて文字列を生成できました。
最初は、となり合う回数を数えるだけでも、もう少し記事タイトルらしくなると思っていました。
実際には、話のつながり以前にUTF-8まで壊れました。
今回のBigramへ保存したのは、直前の1トークンとの関係だけです。
数トークン前に何があったかも、日本語1文字を組み立てている途中かどうかもわかりません。
一方で、ChatGPTやAWS Bedrockのようなまとまりが残ったのは、前回作ったトークナイザーが効いているところだと思います。
GPTやClaudeなどのLLMに敬意が湧きますw
次は、トークンIDを複数の数値へ変換するEmbeddingを作ります。
今はトークン同士のつながりを回数で保存していますが、次は学習で変化する値として持たせてみます。
参考
ここまで読んでいただき、ありがとうございます。もしこの記事の技術や考え方に少しでも興味を持っていただけたら、ネクストのエンジニアと気軽に話してみませんか。
- 選考ではありません
- 履歴書不要
- 技術の話が中心
- 所要時間30分程度
- オンラインOK