こんにちは、んだです!!
今回も今日とて、こちら!!!LLM 自作入門
の読書メモです。
前回の記事はこちら!!
LLMの評価指標について理解する
さて、前回でいよいよ第4章が完了し、GPTモデルの実装とテキスト生成の仕組みまで理解しました。
今回からは第5章に入ります。
テーマは「事前学習」。
まずは5.1の内容、LLMの評価指標について理解していきたいと思います。
ざっくり3行でまとめると:
- LLMの「良さ」を数値で測るには、正解トークンに割り当てた確率を使う
- クロスエントロピー損失は「正解を当てられなかった度合い」を表す指標。0に近いほど良い
- パープレキシティは「モデルが何個の候補で迷っているか」を直感的に表す指標
GPTモデルのおさらい
まずは、前章で作ったGPTモデルを初期化するところからです。
import torch from previous_chapters import GPTModel GPT_CONFIG_124M = { "vocab_size": 50257, # Vocabulary size "context_length": 256, # Shortened context length (orig: 1024) "emb_dim": 768, # Embedding dimension "n_heads": 12, # Number of attention heads "n_layers": 12, # Number of layers "drop_rate": 0.1, # Dropout rate "qkv_bias": False # Query-key-value bias } torch.manual_seed(123) model = GPTModel(GPT_CONFIG_124M) model.eval(); # Disable dropout during inference
前章と1つだけ違うのが context_length です。
context_length は「モデルが一度に見れるトークンの最大数」です。
第3章で出てきた位置エンコーディングのサイズがこの値で決まるので、たとえば256なら直近256トークンまでしか処理できません。
元のGPT-2は1024ですが、ここでは256に短縮しています。
計算コストを抑えて、ノートPCでも動くようにするためですね。
後で学習済みの重みを読み込むときには1024に戻します。
未訓練モデルでテキスト生成
前章で作った generate_text_simple を使って、テキスト生成を試してみます。
その前に、テキストとトークンIDを相互に変換する便利関数を2つ用意しておきます。
text_to_token_ids は「文字列 → トークンID列のテンソル」に変換する関数です。
token_ids_to_text はその逆で、「トークンID列 → 文字列」に戻す関数です。
この2つの関数はこの章を通して何度も使います。
import tiktoken from previous_chapters import generate_text_simple def text_to_token_ids(text, tokenizer): encoded = tokenizer.encode(text, allowed_special={'<|endoftext|>'}) encoded_tensor = torch.tensor(encoded).unsqueeze(0) # add batch dimension return encoded_tensor def token_ids_to_text(token_ids, tokenizer): flat = token_ids.squeeze(0) # remove batch dimension return tokenizer.decode(flat.tolist()) start_context = "Every effort moves you" tokenizer = tiktoken.get_encoding("gpt2") token_ids = generate_text_simple( model=model, idx=text_to_token_ids(start_context, tokenizer), max_new_tokens=10, context_size=GPT_CONFIG_124M["context_length"] ) print("Output text:\n", token_ids_to_text(token_ids, tokenizer))
Output text: Every effort moves you rentingetic wasnم refres RexMeCHicular stren
allowed_special={'<|endoftext|>'} は、<|endoftext|> という特殊トークン(「文章の終わり」を表す記号)をエンコード対象に含めるための指定です。
通常の単語だけでなく、こういった特殊な記号も扱えるようにしているわけですね。
で、出力結果は "rentingetic wasnم refres..." とデタラメですね。まだ訓練していないので当然です。
では「良いテキスト」とは何なのか、それを数値で測る方法を見ていきます。
損失(loss)とは
モデルを訓練するには、まず「今のモデルがどれだけダメか」を数値で測る必要があります。
テストの点数みたいなものですが、少し違うのは「高いほどダメ」ということ。
テストは100点が満点ですが、損失は0が最高で、数値が大きいほど予測が外れている状態です。
テストの点数:高いほど良い(100点が最高) 損失(loss):低いほど良い(0が最高)
この損失の数値を見ながら「もっと小さくしよう」とモデルの重みを調整していくのが訓練です。
では具体的に「どうやって損失を計算するか」を見ていきます。
入力とターゲットの関係
In this chapter, we implement the training loop and code for basic model evaluation to pretrain an LLM
(この章では、LLMを事前学習するための訓練ループと基本的な評価コードを実装します)
GPTの訓練は「次のトークンを当てる練習」です。
これは第4章のテキスト生成と同じ考え方ですね。
たとえば "every effort moves you" という文があるとき、モデルにはこんな問題を出します。
問題1: "every" の次は? → 正解: "effort" 問題2: "every effort" の次は? → 正解: "moves" 問題3: "every effort moves" の次は? → 正解: "you"
つまりターゲット(正解)は入力を1つずらしたものです。
コードで見てみます。
inputs = torch.tensor([[16833, 3626, 6100], # ["every effort moves", [40, 1107, 588]]) # "I really like"] targets = torch.tensor([[3626, 6100, 345 ], # [" effort moves you", [1107, 588, 11311]]) # " really like chocolate"]
inputsの1行目 [16833, 3626, 6100] は "every effort moves" のトークンIDです。
targetsの1行目 [3626, 6100, 345] は "effort moves you" のトークンIDです。
1つずれていますよね。入力の各位置に対して「その次に来るべきトークン」がターゲットになっています。
2行分あるのは「バッチ」といって、複数の文を同時に処理するためです。
1文ずつ処理するよりまとめて処理した方がGPUの計算効率が良いので、こうしています。
正解トークンの確率を見る
入力をモデルに通して、確率を計算してみます。
with torch.no_grad(): logits = model(inputs) probas = torch.softmax(logits, dim=-1) # Probability of each token in vocabulary print(probas.shape) # Shape: (batch_size, num_tokens, vocab_size)
torch.Size([2, 3, 50257])
torch.no_grad() は「勾配を計算しない」モードです。
第4章のテキスト生成でも出てきましたね。
今は評価するだけで訓練はしないので、勾配の計算を省略してメモリと時間を節約しています。
出力の形 (2, 3, 50257) は、2つの文 x 3トークン x 50257語彙です。
各位置で、辞書にある全50257トークンに対して「次に来そう度」の確率を出しています。
argmaxで最も確率の高いトークンを取り出して、正解と比べてみます。
token_ids = torch.argmax(probas, dim=-1, keepdim=True) print("Token IDs:\n", token_ids)
Token IDs:
tensor([[[16657],
[ 339],
[42826]],
[[49906],
[29669],
[41751]]])
print(f"Targets batch 1: {token_ids_to_text(targets[0], tokenizer)}") print(f"Outputs batch 1: {token_ids_to_text(token_ids[0].flatten(), tokenizer)}")
Targets batch 1: effort moves you Outputs batch 1: Armed heNetflix
正解は "effort moves you" なのに、モデルの出力は "Armed heNetflix"。全然違いますね。
では、正解トークンに割り当てられた確率がどれくらいなのか見てみます。
text_idx = 0 target_probas_1 = probas[text_idx, [0, 1, 2], targets[text_idx]] print("Text 1:", target_probas_1) text_idx = 1 target_probas_2 = probas[text_idx, [0, 1, 2], targets[text_idx]] print("Text 2:", target_probas_2)
Text 1: tensor([7.4541e-05, 3.1061e-05, 1.1563e-05]) Text 2: tensor([1.0337e-05, 5.6776e-05, 4.7559e-06])
ここで 7.4541e-05 という表記が出てきます。
これは「科学的記数法」で、7.4541 × 10のマイナス5乗 = 0.000074541 という意味です。
小さすぎる数を省スペースで書くための表記ですね。
つまり正解トークンに割り当てられた確率は0.007%とか0.001%とか、ほぼ0に近い。
50257語の中からランダムに選んでいるようなもので、正解を全く当てられていません。
訓練の目標は、この確率を1(= 100%)に近づけることです。
なぜ対数(ログ)を使うのか
ここから、確率を「損失」に変換していきます。
少し数学っぽい話が入りますが、順を追えば大丈夫です。
確率をそのまま最大化すればいいじゃないか、と思うかもしれません。
でも実際には「対数(log)」を取ってから扱います。
「対数」って何か。ざっくりいうと、「掛け算を足し算に変換してくれる道具」です。
でもLLMの文脈では、もう少し実用的な意味があります。
対数を取ると、0に近い小さな確率を「扱いやすい大きさの数」に変換できるんです。
具体例で見てみます。
確率 1.0(100%) → log(1.0) = 0 ← 完璧な予測 確率 0.5(50%) → log(0.5) ≈ -0.7 ← まあまあ 確率 0.01(1%) → log(0.01) ≈ -4.6 ← 外してる 確率 0.00007 → log(0.00007) ≈ -9.5 ← 全然ダメ
確率が高い(1に近い)ほど、対数は0に近くなります。
確率が低い(0に近い)ほど、対数は大きなマイナスになります。
元の確率は 0.00007 と 0.00005 のように差がほんのわずかですが、対数を取ると -9.5 と -9.9 のように差が見えやすくなります。
この「差がわかりやすくなる」のが対数の便利なところです。
では実際にコードで見てみます。
torch.cat は2つのテンソルを連結する関数です。
2つの文(各3トークン)の確率を1つにまとめて、合計6個の対数確率を計算しています。
# Compute logarithm of all token probabilities log_probas = torch.log(torch.cat((target_probas_1, target_probas_2))) print(log_probas)
tensor([ -9.5042, -10.3796, -11.3677, -11.4798, -9.7764, -12.2561])
全て -9 〜 -12 くらいの大きなマイナス。正解を全く当てられていないので、0からはるか遠いわけです。
クロスエントロピー損失の導出
ここから「クロスエントロピー損失」を導きます。
名前は難しそうですが、前のセクションで計算した対数確率に、あと2つの処理を加えるだけです。
まず、6個の対数確率の平均を取ります。
# Calculate the average probability for each token avg_log_probas = torch.mean(log_probas) print(avg_log_probas)
tensor(-10.7940)
平均は -10.7940。この値を0に近づけたい。
ただ、深層学習の世界では「値を大きくする(最大化)」より「値を小さくする(最小化)」で考えるのが標準的な慣習です。
なぜかというと、最適化アルゴリズム(重みの調整方法)が「最小化」前提で設計されているからです。
そこで、符号をひっくり返してプラスにします。
neg_avg_log_probas = avg_log_probas * -1 print(neg_avg_log_probas)
tensor(10.7940)
この 10.7940 を0に近づけるように訓練する。これが「クロスエントロピー損失(cross-entropy loss)」です。
「クロスエントロピー」という名前を分解すると、「クロス(cross)= 交差する」「エントロピー(entropy)= 情報量の指標」です。
ざっくりいうと「モデルの予測と実際の正解がどれだけズレているか」を測る指標、と思っておけばOKです。
名前は気にせず、計算の流れを押さえておきましょう。
整理すると:
正解トークンの確率を取り出す ↓ 対数を取る(小さい確率を扱いやすくする) ↓ 平均する(全体の良し悪しを1つの数にする) ↓ 符号を反転(最小化問題にする) ↓ = クロスエントロピー損失(0に近いほど良い)
PyTorchのcross_entropy関数
上の手順を毎回手動でやるのは面倒なので、PyTorchには cross_entropy 関数が用意されています。
一発で計算してくれます。
ただし、cross_entropy に渡すにはデータの形を整える必要があります。
cross_entropy 関数は「サンプル数 × 語彙サイズ」の形を期待します。
今のlogitsは (2, 3, 50257) つまり「2つの文 × 3トークン × 50257語彙」なので、flatten(フラット化)で平らにします。
flatten前: logits: (2, 3, 50257) ← 2つの文 × 3トークン × 50257語彙 flatten後: logits: (6, 50257) ← 6つのサンプル × 50257語彙
2つの文 × 3トークン = 6つのサンプルとして並べ直しているわけです。
targetsも同様に (2, 3) → (6,) に平らにしています。
logits_flat = logits.flatten(0, 1) targets_flat = targets.flatten() print("Flattened logits:", logits_flat.shape) print("Flattened targets:", targets_flat.shape)
Flattened logits: torch.Size([6, 50257]) Flattened targets: torch.Size([6])
loss = torch.nn.functional.cross_entropy(logits_flat, targets_flat)
print(loss)
tensor(10.7940)
さっき手計算した値と同じ10.7940になりました。
cross_entropy 関数は内部でsoftmax、対数、平均、符号反転の全てを自動でやってくれるので、logits(softmax前の生の値)とターゲット(正解のトークンID)を渡すだけでOKです。
パープレキシティ(perplexity)
クロスエントロピー損失に関連する指標として、パープレキシティ(perplexity)があります。
「パープレキシティ」は英語の "perplex"(困惑させる)が語源で、「モデルがどれだけ困惑しているか」を表す指標です。
計算は簡単で、クロスエントロピー損失に exp(指数関数)をかけるだけです。
exp は対数(log)の逆の操作です。
対数が「掛け算を足し算に変換する」なら、expは「足し算を掛け算に戻す」もの。
ここでは「対数で変換した損失を、元のスケールに戻す」くらいの理解でOKです。
perplexity = torch.exp(loss)
print(perplexity)
tensor(48725.8203)
パープレキシティは「モデルが何個の候補で迷っているか」と解釈できます。
スマホの予測変換で例えると:
パープレキシティ 2: 「お疲れ」の次は「様」か「さま」の2択で迷っている → かなり絞れている パープレキシティ 10: 「今日は」の次を10個くらいの候補で迷っている → そこそこ絞れている パープレキシティ 48725: 次の単語を48725個の候補で迷っている → 全然絞れていない(ほぼランダム)
語彙サイズが50257なので、パープレキシティ48725は「ほぼ全単語で迷っている」状態。
つまり未訓練のモデルは当てずっぽうです。
訓練が進むとパープレキシティは下がっていきます。
未訓練: パープレキシティ ≈ 48725(全然ダメ) 訓練途中: パープレキシティ ≈ 100(だいぶマシ) 十分訓練: パープレキシティ ≈ 20(かなり良い)
損失(クロスエントロピー)が0に近いほど良い、パープレキシティが1に近いほど良い、ということですね。
なぜ訓練データと検証データに分けるのか
ここから、実際に訓練するためのデータを準備していきます。
その前に「なぜデータを分けるのか」を整理しておきます。
テスト勉強に例えると:
訓練データ = 問題集(これで勉強する) 検証データ = 模試(勉強の成果を確認する)
問題集だけで勉強して、同じ問題集で自分を採点したら、答えを暗記しているだけかもしれません。
本当に理解しているかは、見たことのない問題(模試)で確かめる必要がありますよね。
これと同じで、訓練データだけで「損失が下がった!」と喜んでも、モデルが本当に学習しているのか、ただ暗記しているだけなのかわかりません。
検証データ(モデルが訓練中に一度も見ないデータ)で確認することで、本当の実力を測れるわけです。
訓練データの準備
第2章で使った短編小説のテキストを使います。
import os import requests file_path = "the-verdict.txt" url = "https://raw.githubusercontent.com/rasbt/LLMs-from-scratch/main/ch02/01_main-chapter-code/the-verdict.txt" if not os.path.exists(file_path): response = requests.get(url, timeout=30) response.raise_for_status() text_data = response.text with open(file_path, "w", encoding="utf-8") as file: file.write(text_data) else: with open(file_path, "r", encoding="utf-8") as file: text_data = file.read()
total_characters = len(text_data) total_tokens = len(tokenizer.encode(text_data)) print("Characters:", total_characters) print("Tokens:", total_tokens)
Characters: 20479 Tokens: 5145
5145トークンは実際のLLM訓練からすると極めて少ないですが、教育用としては十分です。
後で学習済みの重みも読み込みますしね。
テキストを訓練用と検証用に9:1で分割して、第2章で作ったデータローダーでバッチを作ります。
「データローダー」は、テキストデータを「入力とターゲットのペア」に切り分けて、バッチ単位でモデルに渡してくれる仕組みです。
第2章で実装しました。
from previous_chapters import create_dataloader_v1 # Train/validation ratio train_ratio = 0.90 split_idx = int(train_ratio * len(text_data)) train_data = text_data[:split_idx] val_data = text_data[split_idx:] torch.manual_seed(123) train_loader = create_dataloader_v1( train_data, batch_size=2, max_length=GPT_CONFIG_124M["context_length"], stride=GPT_CONFIG_124M["context_length"], drop_last=True, shuffle=True, num_workers=0 ) val_loader = create_dataloader_v1( val_data, batch_size=2, max_length=GPT_CONFIG_124M["context_length"], stride=GPT_CONFIG_124M["context_length"], drop_last=False, shuffle=False, num_workers=0 )
batch_size=2 は「1回に2つの文を同時に処理する」という意味です。
まとめて処理した方がGPUの計算効率が良いからですね。
データが正しく読み込まれたか確認してみます。
print("Train loader:") for x, y in train_loader: print(x.shape, y.shape) print("\nValidation loader:") for x, y in val_loader: print(x.shape, y.shape)
Train loader: torch.Size([2, 256]) torch.Size([2, 256]) torch.Size([2, 256]) torch.Size([2, 256]) torch.Size([2, 256]) torch.Size([2, 256]) torch.Size([2, 256]) torch.Size([2, 256]) torch.Size([2, 256]) torch.Size([2, 256]) torch.Size([2, 256]) torch.Size([2, 256]) torch.Size([2, 256]) torch.Size([2, 256]) torch.Size([2, 256]) torch.Size([2, 256]) torch.Size([2, 256]) torch.Size([2, 256]) Validation loader: torch.Size([2, 256]) torch.Size([2, 256])
訓練用が9バッチ、検証用が1バッチ。各バッチは (2, 256) つまり「2つの文 x 256トークン」です。
トークン数も確認しておきます。
train_tokens = 0 for input_batch, target_batch in train_loader: train_tokens += input_batch.numel() val_tokens = 0 for input_batch, target_batch in val_loader: val_tokens += input_batch.numel() print("Training tokens:", train_tokens) print("Validation tokens:", val_tokens) print("All tokens:", train_tokens + val_tokens)
Training tokens: 4608 Validation tokens: 512 All tokens: 5120
損失を計算する関数
ここで、損失を計算する便利関数を2つ作っておきます。
calc_loss_batch:1バッチ分の損失を計算する関数calc_loss_loader:データローダー全体の平均損失を計算する関数
def calc_loss_batch(input_batch, target_batch, model, device): input_batch, target_batch = input_batch.to(device), target_batch.to(device) logits = model(input_batch) loss = torch.nn.functional.cross_entropy(logits.flatten(0, 1), target_batch.flatten()) return loss def calc_loss_loader(data_loader, model, device, num_batches=None): total_loss = 0. if len(data_loader) == 0: return float("nan") elif num_batches is None: num_batches = len(data_loader) else: num_batches = min(num_batches, len(data_loader)) for i, (input_batch, target_batch) in enumerate(data_loader): if i < num_batches: loss = calc_loss_batch(input_batch, target_batch, model, device) total_loss += loss.item() else: break return total_loss / num_batches
calc_loss_batch は、さっき手動でやった処理(logitsを平坦化して cross_entropy に渡す)を関数にまとめたものです。
.to(device) はデータを計算デバイスに送る処理です。
「デバイス」というのはCPUやGPUのことで、GPUがあればGPUで計算した方が速いので、データもGPUに送る必要があります。
calc_loss_loader は、データローダーの複数バッチを順番に処理して、損失の平均を返します。
num_batches で「何バッチ分だけ計算するか」を制限できます。
全バッチ計算すると時間がかかるので、訓練中はいくつかだけ計算して大まかな傾向を見ることが多いです。
では、未訓練モデルの損失を確認してみます。
if torch.cuda.is_available(): device = torch.device("cuda") elif torch.backends.mps.is_available(): device = torch.device("mps") else: device = torch.device("cpu") print(f"Using {device} device.") model.to(device) torch.manual_seed(123) with torch.no_grad(): train_loss = calc_loss_loader(train_loader, model, device) val_loss = calc_loss_loader(val_loader, model, device) print("Training loss:", train_loss) print("Validation loss:", val_loss)
Using mps device. Training loss: 10.987583054436577 Validation loss: 10.98110580444336
デバイスの選択部分は「CUDAが使えるならGPU、Appleシリコンならmps、それ以外はCPU」と優先順位をつけています。
訓練損失も検証損失も約10.98。
未訓練なので高い値ですね。
次のセクションで実際に訓練して、この値を下げていきます。
まとめ
今回はLLMの評価指標について整理しました。
- 損失(loss)は「モデルがどれだけダメか」を表す数値。低いほど良い
- クロスエントロピー損失は「正解トークンの確率の対数を取って、符号を反転したもの」。0に近いほど良い
- 対数(log)は小さい確率を扱いやすい数値に変換してくれる
- パープレキシティは「モデルが何個の候補で迷っているか」。小さいほど良い
- 訓練データ(問題集)と検証データ(模試)を分けることで、本当に学習しているか確認できる
次はいよいよLLMの訓練に入ります。
僕から以上。あったかくして寝ろよー
参考
- Build a Large Language Model (From Scratch) - Sebastian Raschka
