[a,b]から[b,c]を予測します。交差エントロピー は正解の確率 の負の対数 で、真実性の保証ではありません。
区間選択→勾配 消去→順伝播 ・逆伝播 →勾配制限 →SGD 。毎更新後に重み ・設定・辞書・回数・乱数を保存します。validation は同じ検証先頭区間だけの簡易診断です。
学習の目標 — 1マスずらした正解
学習 資料をトークン番号 に変えると、長い番号の列になります。ここから13トークン の区間を切り出し、前の12個を入力、1マスずらした後ろの12個を正解として使います。
トークンが [a, b, c, d] なら、入力 [a, b, c] の正解は [b, c, d] です。
位置1は a だけを見て b を、位置2は a・b を見て c を、位置3は a・b・c を見て d を当てます。
因果アテンション のおかげで、1回の forward で12個の問題を同時に解きます。
正解の印を人がつけなくても、文章そのものが問題と正解になります。だからインターネットの文章をそのまま学習資料に使えます(自己教師あり学習)。次のトークンをうまく当てるには文法・文脈 ・形式・事実を知る必要があるので、この単純な目標が言語能力全体を引き出します。
損失 — 交差エントロピー(正解の確率の負の対数)
位置ごとに、モデル が正解トークンに与えた確率を p とすると、その位置の損失は −log(p) です。区間の損失は12個の位置の損失の平均です。
p = 1 なら損失0: 正解を確信していました。
p = 0.5 なら 0.693、p = 0.1 なら 2.303、p = 0.01 なら 4.605: 正解に与えた確率が小さいほど、損失は急に大きくなります。
不正解に大きな確率を与えても、直接の罰点は受けません。確率の合計が1なので、不正解が多くを取ると正解の p が減り、損失が大きくなります。
最初の損失は前もってわかります。学習前のモデルは語彙40個にほぼ同じ確率(1/40)を与えるので、損失は −log(1/40) = ln 40 ≈ 3.689 です。train.py が最初に表示する validation 3.7568 がこの値とほぼ同じなら、モデルは正しく初期化されています。語彙50,257個の GPT-2 なら、最初の損失は約10.8です。損失に exp をかけた値をパープレキシティ(perplexity)といい、exp(3.689) = 40 のように「いくつの中から迷っているか」と読めます。
語彙が数万個あるモデルの損失は、おおよそこう読みます。10以上ならランダムな水準、5〜7なら文法を、3〜5なら形式を、2〜3なら意味を学んでいる途中で、2より下がったら暗記(過学習 )を疑います。語彙の数によって出発点が違うので、XSLM では出発点 3.69 からどれだけ下がったかを見ます。
AIに聞いてみる
自分で確かめる lab/loss.py
import math
for p in [1.0, 0.9, 0.5, 0.1, 0.01]:
print('p =', p, ' loss =', round(0.0 - math.log(p), 4))
for vocab in [40, 50257]:
print('guess evenly among', vocab, '->', round(math.log(vocab), 4))
cd ~/xslm
mkdir -p lab
micro lab/loss.py
python3 -m lab.loss
実行結果
p = 1.0 loss = 0.0
p = 0.9 loss = 0.1054
p = 0.5 loss = 0.6931
p = 0.1 loss = 2.3026
p = 0.01 loss = 4.6052
guess evenly among 40 -> 3.6889
guess evenly among 50257 -> 10.8249
勾配降下法と学習率 — 勾配の反対方向へ少しずつ
勾配は「この数字を少し大きくすると、損失がどれだけ増えるか」です。損失を減らすには、勾配の反対方向に数字を動かします。
学習率(learning_rate)は、一度にどれだけ動かすかです。下の実験は f(w) = (w − 3)² の最も低い所 w = 3 を、0から出発して20歩で探します。
0.01: 慎重すぎて 1.0 付近までしか行けません。学習が遅いです。
0.1: 2.97 まで来て、ほぼ到着しました。
0.5: この関数では1歩でちょうど3に届きます。
1.1: 大きく跳びすぎて谷の反対側へ越えることを繰り返し、−112 まで飛び出します(発散)。
実際のモデルは数字が数千〜数十億個あり、谷の形がわからないので、学習率は実験で決めます。XSLM の 0.03 は、小さなモデルが跳ねない慎重な値です。学習率を変えると settings.json が変わるので、新しいチェックポイントで始めます。
AIに聞いてみる
自分で確かめる lab/descent.py
# f(w) = (w - 3) ** 2 is smallest at w = 3. Its gradient is 2 * (w - 3).
for lr in [0.01, 0.1, 0.5, 1.1]:
w = 0.0
for step in range(20):
w -= lr * 2 * (w - 3)
print('lr', lr, '-> w =', round(w, 4))
cd ~/xslm
mkdir -p lab
micro lab/descent.py
python3 -m lab.descent
実行結果
lr 0.01 -> w = 0.9972
lr 0.1 -> w = 2.9654
lr 0.5 -> w = 3.0
lr 1.1 -> w = -112.0128
学習ループ1周 — 6つのステップ
train.py の繰り返し 1回は、この順序です。
区間を選ぶ: 学習トークンの列からランダムな開始点を選び、13トークンを切り出します。
勾配を消す: すべてのパラメーター の grad を0にします。勾配は += で積み上がるので、前の繰り返しの値が残ると混ざります。今回の区間に出てこないトークンの埋め込み の行は計算グラフ になく、backward が触れないので、ここで消さないと古い勾配がそのまま残ります。
順伝播: model.loss が forward と損失の計算をします。
逆伝播: loss.backward() で1,248個すべての勾配を求めます。
更新: 勾配の大きさを制限してから、p.data −= 学習率 × 勾配 × 倍率 ですべての数字を動かします。
保存・記録: チェックポイントを保存し、step と loss を表示します。
学習はこの1周を決めた回数だけ回ります。始める前と終わった後には、検証資料の最初の区間で損失を測り、学習に使わなかった文章でもよくなったかを見ます。
XSLM は、資料を何周したか(epoch )の代わりに歩数(step)で数えます。区間をランダムに引くので、ある部分は何度も、ある部分はあまり引かれません。大きなモデルもたいてい step で数えます。
勾配クリッピング — 一度に遠くへ行きすぎないように
ときどき、ある区間で勾配が急にとても大きくなります。そのまま更新すると、数字が一度に遠くへ跳び、これまでの学習を台なしにします(勾配爆発)。そこで train.py は更新の前に、勾配全体の長さを測ります。
長さ = √(すべての勾配の2乗の合計)
倍率 = min(1, 1 ÷ 長さ)
長さが1以下なら倍率1でそのまま使い、1より大きければ長さがちょうど1になるよう、すべての勾配を同じ割合で縮めます。
方向はそのままで、大きさだけを縮めるのがポイントです。PyTorch では clip_grad_norm_ が同じことをします(ふつう最大の長さ 1.0)。分母の 1e-8 は、勾配がすべて0のときに0で割らないための小さな数です。
オプティマイザー — SGD と AdamW
勾配で数字を直す規則をオプティマイザー といいます。
SGD(XSLM): 新しい値 = 古い値 − 学習率 × 勾配。今の勾配だけを見て、何も記憶しません。だからチェックポイントにオプティマイザーの状態を保存する必要がありません。
モメンタム : 過去の勾配の流れ(速度)を記憶し、同じ方向ならより速く、揺れているならあまり動きません。
Adam・AdamW: 数字ごとに勾配の平均と大きさの平均を記憶し、数字ごとに合った歩幅を自分で決めます。AdamW は、数字が大きくなりすぎないよう少しずつ減らす重み減衰(weight decay)を加えます。大きな言語モデル の学習の標準で、学習率はふつう 0.0003 くらいです。
Adam はパラメーターごとに数字を2つ多く記憶するので、学習を続けるにはチェックポイントにその状態(optimizer state)も保存する必要があります。だから PyTorch のチェックポイントには、ふつうモデルの重みと一緒にオプティマイザーの状態が入ります。学習中のメモリー も、重みだけを置くときの数倍かかります。
バッチ — 複数の区間の平均の勾配
XSLM は一度に区間1つ(バッチサイズ1)で勾配を求めます。区間ごとに内容が違うので勾配が大きく揺れ、損失も上がったり下がったりします。
大きなモデルは複数の区間を一度に計算し、平均の勾配を使います。例えば256トークンの区間16個(バッチサイズ16)なら、1回の更新で4,096トークンを見ます。
平均をとると揺れが減り、更新の方向が安定します。
GPU は複数の区間を同時に計算するので、バッチを大きくしても時間はほとんど増えません。
その代わり、その分のメモリーを使います。メモリーが足りなければ(OOM )、バッチや区間の長さを減らします。
メモリーが足りないときは、小さなバッチで何回も backward して勾配を集めてから1回更新する、勾配累積(gradient accumulation)を使います。XSLM の backward は始めるときにグラフ内の勾配を0に消すので、この方法をそのままでは使えません。
過学習と学習不足 — 学習の損失と検証の損失を一緒に見る
2つの損失を一緒に見ると、モデルの状態がわかります。
両方下がる: よく学んでいます。
学習の損失だけ下がり、検証の損失は上がる: 過学習です。学習資料を暗記して、初めて見る文章にはむしろ悪くなります。
両方あまり下がらない: 学習不足 です。モデルが小さすぎるか、学習が足りないか、学習率が合っていません。
XSLM で実際に測ってみました(デスクトップの Python 、基本資料)。基本の学習率 0.03 で1,500回学習すると、検証の損失は 3.76 から 3.23 に下がりました。学習率 0.1 で3,000回学習すると、学習の損失は区間によって 1.3 まで下がりましたが、検証の損失は 4.42 に上がり、0.3 なら 5.28 まで上がりました。学習の文12組を暗記するのは簡単でも、初めて見る3組に通じる規則を学べるほど資料が多くないからです。
過学習を減らす最も確実な方法は資料を増やすことで、モデルを小さくする・dropout ・学習を早めに止める(検証の損失が最も低かったチェックポイントを使う)も使います。学習不足なら、モデルを大きくするか学習を増やします。
チェックポイント — 何をなぜ保存するのか
checkpoints/model.json は、学習を止めてから正確に続けるためのすべてを入れています。
weights: 9個の行列 の数字1,248個。
step: これまでに行った更新の回数。
random: 乱数生成器の状態。続けて学習するとき、区間の順序を途切れずにつなげます。
settings・tokenizer : モデルの大きさと番号の規則。generate.py と runner.py はこれだけを読んでモデルを作り直します。
data_hash: 学習・検証資料の SHA-256 の指紋。資料が変わったかどうかを見つけます。
だから3回ずつ2回に分けて学習した結果と、6回を一度に学習した結果は、数字1つまで同じです。自分で確かめてみてください。--steps 3 を2回実行した model.json を別の名前に移しておき、新しく --steps 6 を1回実行した結果と比べます。
保存は安全に行います。まず model.tmp にすべて書いてから、os.replace で名前を変えます。名前の変更は一瞬で起きるので、書いている途中で Ctrl+C を押したり電源が切れたりしても、model.json は前の完成版か新しい完成版のどちらかです。半分だけ書いた壊れたファイルは残りません。
PyTorch は torch.save で、重み・オプティマイザーの状態・step・設定をバイナリ ファイル(.pt)に保存します。XSLM は1歩ごとに保存しますが、大きなモデルは保存にも時間がかかるので、数百歩ごとに評価しながら保存します。
再現性 — seed で同じ結果をもう一度得る
コンピューターの乱数は、seed(種の番号)から決まった規則で作った数列です。seed が同じなら、いつも同じ数列が出ます。XSLM は seed 7 で2か所を決めます。
重みの最初の値(model.py の random.Random(seed))。
学習区間の順序(train.py の rng)。
だから同じ資料・同じ設定なら、誰がどこで実行しても同じ学習の過程をたどります。学習率1つだけを変えて比べるには、残りのランダム性が同じでなければならないので、再現性 は実験の基本です。seed を変えると違う最初の値から出発し、結果が変わります。運のよい seed 1つに頼った結論は信じにくいので、大事な比較は seed をいくつか変えて繰り返します。
ただし Python や OS の数学ライブラリ が違うと小数点の最後の桁が変わることがあり、とても長い学習では数字が少しずつずれることがあります。
学習の記録を読む — 数字が教えてくれること
train.py はこんな行を表示します。
parameters: 1248 resume: 0 — パラメーターの数と、続きから始めた歩数。初めてなら0です。
validation: 3.7568 — 学習前の検証の損失。ln (語彙の数)と近いはずです。
step: 1 loss: 3.6875 — 1歩ごとの、その区間の学習の損失。
validation: … — 学習後の検証の損失。
saved: checkpoints/model.json — 保存した場所。
1歩ごとの loss は区間が違うので上下します。数字1つではなく、何歩分もの流れを見ます。基本の20回なら 3.7 付近で少し動く程度で、数百〜数千回ならはっきり下がります。
flush =True は、出力をすぐに画面へ出せという意味です。これがないと Python が出力をためてから一度に見せるので、学習が止まったように見え、nohup でファイルに書くときも記録が遅れます。
学習がうまくいかないとき — 症状別の点検
症状から原因を絞ります。
損失が 3.7 付近でほとんど変わらない: 学習回数が少ないです。--steps を増やすか、新しいチェックポイントで学習率を少し大きくしてみます。
損失が大きく跳ねる、または大きくなるばかり: 学習率が大きすぎます。減らして新しく始めます。
ValueError : math domain error または nan: 確率が0になって log(0) を計算したか、数字が発散しました。学習率を下げ、勾配クリッピングが入っているかを見ます。
AssertionError : changed settings/tokenizer または changed data : チェックポイントと今の設定・資料が違います。古いチェックポイントを移して新しく始めます。
AssertionError: add more text: 学習か検証のトークンが13個より少ないです。raw.txt に行を足します。
遅すぎる: width・context を減らすと速くなりますが、新しいチェックポイントが必要です。まず --steps を小さく分けて実行します。
学習はできたのに Runner の答えが変わらない: Runner は起動時に一度だけ読みます。止めて起動し直します。
入力して実験
train.pyを書き、短い学習の後さらに5回実行してresumeを見ます。速度は機器によります。
cd ~/xslm
micro train.py
python3 train.py --steps 20
python3 train.py --steps 5
ターミナルで実習する
train.py 手入力する完全なソース
資料ハッシュ で変更を検出。仮ファイル とos.replaceで完全な保存を保ちます。SGDにモメンタムはありません。
import argparse
import hashlib
import json
import math
import os
import random
from pathlib import Path
from model import GPT
from tokenizer import BPE
def main():
cfg = json.loads(Path('settings.json').read_text())
parser = argparse.ArgumentParser()
parser.add_argument('--steps', type=int, default=cfg['steps'])
args = parser.parse_args()
assert args.steps > 0, '--steps must be positive'
token_state = json.loads(Path('data/tokenizer.json').read_text())
tokenizer = BPE(**token_state)
train_text = Path('data/train.txt').read_text(encoding='utf-8')
val_text = Path('data/val.txt').read_text(encoding='utf-8')
fingerprint = hashlib.sha256((train_text + '\0' + val_text).encode()).hexdigest()
train, val = tokenizer.encode(train_text), tokenizer.encode(val_text)
model = GPT(len(tokenizer.vocab), cfg['width'], cfg['context'], cfg['seed'])
rng, step = random.Random(cfg['seed']), 0
checkpoint = Path('checkpoints/model.json')
if checkpoint.exists():
saved = json.loads(checkpoint.read_text())
assert saved['settings'] == cfg and saved['tokenizer'] == token_state, 'changed settings/tokenizer: start a new checkpoint'
assert saved['data_hash'] == fingerprint, 'changed data: start a new checkpoint'
model.load(saved['weights'])
step = saved['step']
rng.setstate((saved['random'][0], tuple(saved['random'][1]), saved['random'][2]))
n = model.context
assert len(train) > n and len(val) > n, 'add more text'
print('parameters:', len(model.parameters), 'resume:', step, flush=True)
print('validation:', round(model.loss(val[:n], val[1:n + 1]).data, 4), flush=True)
checkpoint.parent.mkdir(exist_ok=True)
for _ in range(args.steps):
start = rng.randrange(len(train) - n)
window = train[start:start + n + 1]
for p in model.parameters:
p.grad = 0
loss = model.loss(window[:-1], window[1:])
loss.backward()
size = math.sqrt(sum(p.grad * p.grad for p in model.parameters))
scale = min(1.0, 1.0 / (size + 1e-8))
for p in model.parameters:
p.data -= cfg['learning_rate'] * p.grad * scale
step += 1
state = {'settings': cfg, 'tokenizer': token_state, 'data_hash': fingerprint,
'weights': model.state(), 'step': step, 'random': rng.getstate()}
temporary = checkpoint.with_suffix('.tmp')
temporary.write_text(json.dumps(state, ensure_ascii=False), encoding='utf-8')
os.replace(temporary, checkpoint)
print('step:', step, 'loss:', round(loss.data, 4), flush=True)
print('validation:', round(model.loss(val[:n], val[1:n + 1]).data, 4), flush=True)
print('saved:', checkpoint, flush=True)
if __name__ == '__main__':
main()
コードを1行ずつ読み解く
train.py 準備 — 設定・トークナイザー・資料
def main():
cfg = json.loads(Path('settings.json').read_text())
parser = argparse.ArgumentParser()
parser.add_argument('--steps', type=int, default=cfg['steps'])
args = parser.parse_args()
assert args.steps > 0, '--steps must be positive'
token_state = json.loads(Path('data/tokenizer.json').read_text())
tokenizer = BPE(**token_state)
train_text = Path('data/train.txt').read_text(encoding='utf-8')
val_text = Path('data/val.txt').read_text(encoding='utf-8')
fingerprint = hashlib.sha256((train_text + '\0' + val_text).encode()).hexdigest()
train, val = tokenizer.encode(train_text), tokenizer.encode(val_text)
model = GPT(len(tokenizer.vocab), cfg['width'], cfg['context'], cfg['seed'])
settings.json を読み、argparse で --steps オプションを受け取ります(なければ設定の steps)。tokenizer.json で BPE を作り直し、学習・検証の文章を読みます。fingerprint は、2つの文章を \0 でつないで SHA-256 で作った指紋です。文章が1文字変わるだけで、まったく違う値になります。2つの文章をトークン番号のリスト に変え、語彙の数に合わせて GPT を作ります。
train.py 続きから学習する
rng, step = random.Random(cfg['seed']), 0
checkpoint = Path('checkpoints/model.json')
if checkpoint.exists():
saved = json.loads(checkpoint.read_text())
assert saved['settings'] == cfg and saved['tokenizer'] == token_state, 'changed settings/tokenizer: start a new checkpoint'
assert saved['data_hash'] == fingerprint, 'changed data: start a new checkpoint'
model.load(saved['weights'])
step = saved['step']
rng.setstate((saved['random'][0], tuple(saved['random'][1]), saved['random'][2]))
rng は区間を選ぶ乱数生成器、step は更新の回数です。チェックポイントがあれば読み込み、設定・トークナイザー・資料の指紋が今と同じかを assert で確かめてから、重み・step・乱数の状態を復元します。JSON はタプルをリストで保存するので、setstate に渡す前に真ん中の部分を tuple に戻します。
train.py 始める前の確認
n = model.context
assert len(train) > n and len(val) > n, 'add more text'
print('parameters:', len(model.parameters), 'resume:', step, flush=True)
print('validation:', round(model.loss(val[:n], val[1:n + 1]).data, 4), flush=True)
checkpoint.parent.mkdir(exist_ok=True)
n は文脈長(12)です。学習・検証のトークンが n より多くないと、n + 1 トークンの区間を切り出せません。パラメーターの数と続きから始めた歩数を表示し、学習前の検証の損失を測ります。val[:n] が入力、val[1:n + 1] が1マスずらした正解です。checkpoints フォルダーがなければ作ります。
train.py ランダムな区間の切り出し
for _ in range(args.steps):
start = rng.randrange(len(train) - n)
window = train[start:start + n + 1]
randrange(len (train) − n) は、0以上(長さ − n)未満の開始点を引きます。その位置から n + 1 個を切り出して window にします。次の行で window[:-1] が入力12個、window[1:] が正解12個になります。
train.py 勾配の消去・順伝播・逆伝播
for p in model.parameters:
p.grad = 0
loss = model.loss(window[:-1], window[1:])
loss.backward()
すべてのパラメーターの grad を0にし、model.loss で損失を計算してから、backward で勾配を埋めます。backward はグラフ内のノードしか消さないので、今回の区間で使われなかったトークン埋め込みの行の古い勾配は、この繰り返し文が消します。
train.py 勾配クリッピングと SGD の更新
size = math.sqrt(sum(p.grad * p.grad for p in model.parameters))
scale = min(1.0, 1.0 / (size + 1e-8))
for p in model.parameters:
p.data -= cfg['learning_rate'] * p.grad * scale
step += 1
size は1,248個の勾配を1つのベクトル とみなした長さです。scale は長さが1を超えれば 1/長さ、そうでなければ1です。すべてのパラメーターの data から 学習率 × 勾配 × scale を引きます。data を直接変えるので、この計算はグラフに記録されません。最後に step を1増やします。
train.py 安全に保存して記録する
state = {'settings': cfg, 'tokenizer': token_state, 'data_hash': fingerprint,
'weights': model.state(), 'step': step, 'random': rng.getstate()}
temporary = checkpoint.with_suffix('.tmp')
temporary.write_text(json.dumps(state, ensure_ascii=False), encoding='utf-8')
os.replace(temporary, checkpoint)
print('step:', step, 'loss:', round(loss.data, 4), flush=True)
state 辞書 に、続けて学習するのに必要なすべてを入れます。model.tmp にすべて書いてから、os.replace で model.json を一度に差し替えます。その次に、今回の1歩の損失を表示します。1歩ごとに保存するので、いつ止めても最後に終えた1歩までが残ります。
train.py 仕上げの検証
print('validation:', round(model.loss(val[:n], val[1:n + 1]).data, 4), flush=True)
print('saved:', checkpoint, flush=True)
学習を終えた重みで、始めたときと同じ検証区間の損失をもう一度測ります。始めたときの値と比べると、学習に使わなかった文章でもよくなったかがわかります。区間が1つだけなので、おおまかな合図としてだけ読みます。
理解を確認
model.jsonと継続回数を確認。異なる区間のlossは上下します。
困ったとき Ctrl+Cで停止しても完了済み更新は残ります。設定や資料変更時は書き出した後、旧チェックポイント を別名にして再学習します。
よくある質問
loss が上がったり下がったりしますが、学習が壊れたのですか?
違います。1歩ごとに違う区間1つで損失を測るので、易しい区間なら低く、難しい区間なら高くなります。何歩分もの流れと、最初と最後の検証の損失を見てください。バッチを大きくすると揺れが減ります。
何回学習すればいいですか?
決まった答えはなく、検証の損失で決めます。検証の損失がもう下がらないか、上がり始めたら止めどきです。デスクトップで測ると、基本の学習率 0.03 で1,500回までは検証の損失が下がり、学習率 0.1 で3,000回ならむしろ上がりました。エミュレーター では1歩が遅いので、--steps 50 のように分けて実行しながら見守ってください。
GPU がなくても学習できますか?
できます。学習は足し算・掛け算の繰り返しにすぎないので、どんな CPU でもできます。GPU は同じ計算を数千個同時にするので速いだけです。XSLM は仮想 CPU で、大きなモデルはふつう GPU(Apple の Mac は MPS )で、同じ原理の学習をします。
学習が終わったモデルは、使いながら自然にもっと学ぶのですか?
違います。学習は train.py を実行するときだけ起こります。Runner で答えている間は、重みを読むだけで変えません。会話で聞いたことを覚えているように見えるサービスは、会話の記録をプロンプト にもう一度入れているのです。