学習ループ: 損失、勾配、学習率
ほぼすべてのモデルを学習させる4ステップのループ、手計算と有限差分で検証する勾配、そして遅すぎ・適切・発散の3つを1枚のチャートで示す学習率の掃引
このレッスンで学ぶこと
- 学習ループを4つのステップとして書き、2パラメータのモデルに対してnumpyで実装できる
- 平均二乗誤差の勾配を手計算し、有限差分で検証できる
- 勾配降下法が勾配と逆向きに進む理由と、学習率が何をスケールするかを説明できる
- 損失曲線を読み、学習を遅すぎる・ほぼ適切・発散のいずれかに分類できる
- バッチ、エポック、ステップを定義し、ループをPyTorchに移すと何が変わるかを述べられる
レッスン03では、勾配を計算してその逆向きに進むループでロジスティック回帰を当てはめました。このループはロジスティック回帰に固有のものではありません。モデルと損失を変えれば、畳み込みネットワークもTransformerも拡散モデルも学習でき、変わるのはパラメータベクトルの大きさと、誰が勾配を計算するかだけです。本レッスンではこのループを切り出し、2パラメータのモデルでその計算を追い、次に3つの学習率で走らせて、学習がたどる3通りの振る舞いを見ます。読み終えるころには、損失曲線を見て何が悪いかを言えるようになります。
4ステップのループ
あらゆる学習は、損失が下がらなくなるか予算が尽きるまで、同じ4つのステップを繰り返します。
順伝播は、現在のパラメータで入力のバッチにモデルを走らせ、予測を出します。損失は予測をラベルと比較し、その比較を1つの数値に縮約します。逆伝播は、各パラメータをわずかに動かしたときにその数値がどれだけ変わるかを計算します。それが勾配です。更新は、各パラメータを勾配の小さな倍数だけ、損失が下がる方向へ動かします。その倍数が学習率であり、あなたが設定する中で唯一最も重要な数値です。
勾配を手で計算する
傾きwと切片bの2つのパラメータを持ち、w * x + bを予測するモデルを、平均二乗誤差を損失として考えます。事例は2つ、xが1と2で、ラベルが1と2です。wもbも0から始めます。
順伝播はどちらにも0を予測します。誤差(予測−ラベル)は−1と−2です。損失は1と4の平均で2.5です。
逆伝播のために、損失を書き下します。(w * x + b−y)の2乗の事例平均です。wで微分すると、連鎖律により2×誤差×xの平均になります。ここでは(2×−1×1)と(2×−2×2)の平均、つまり−2と−8の平均で−5です。bについては内側の微分が1なので、勾配は2×誤差の平均、つまり−2と−4の平均で−3です。
勾配が負なら、そのパラメータを増やすと損失が減ります。だから更新は足します。学習率0.1なら、wは0+0.5、bは0+0.3になります。もう一度順伝播を走らせると、予測は0.8と1.3、誤差は−0.2と−0.7、損失は0.04と0.49の平均で0.265です。1ステップで損失は9分の1になりました。勾配が小さくなっているので次のステップの減りは小さく、そうしてwが1、bが0の近くにあるお椀の底へ下っていきます。
「勾配と逆向きに」が考え方のすべてです。勾配は上り坂、損失が高くなる方を指します。微分が測るのはそういうものだからです。それを引けば下り坂を歩きます。学習率は、一歩の大きさを決めます。
有限差分で勾配を検算する
手作業の導出には符号の誤りが付きものです。検算は、パラメータを1つだけ微小なhだけ動かし、損失がどれだけ変わったかを測り、hで割ることです。パラメータごとに順伝播が1回必要なので遅いですが、これが正解であり、新しい損失や新しいモデルで使う習慣は元が取れます。下のブロックは、解析的な勾配と有限差分を並べて計算します。
import numpy as np
# One weight, one bias, a straight-line model, mean squared error.
rng = np.random.default_rng(0)
x = rng.uniform(-1, 1, size=100)
y = 0.8 * x - 0.3 + rng.normal(0, 0.1, size=100)
def loss_and_grads(w, b):
pred = w * x + b
err = pred - y
loss = np.mean(err ** 2)
dw = np.mean(2 * err * x) # d loss / d w
db = np.mean(2 * err) # d loss / d b
return loss, dw, db
w, b = 0.0, 0.0
print("start w b:", w, b, " loss:", round(loss_and_grads(w, b)[0], 4))
loss, dw, db = loss_and_grads(w, b)
print("gradient at start dw:", round(dw, 4), " db:", round(db, 4))
# Nudge each parameter by a tiny amount and watch the loss move the way the gradient says.
h = 1e-4
print("finite difference dw:", round((loss_and_grads(w + h, b)[0] - loss) / h, 4))
print("finite difference db:", round((loss_and_grads(w, b + h)[0] - loss) / h, 4))
start w b: 0.0 0.0 loss: 0.294
gradient at start dw: -0.5386 db: 0.4613
finite difference dw: -0.5386
finite difference db: 0.4614
両者は小数点以下4桁まで一致し、dbの最後の桁の違いは、無限に小さな刻みではなく0.0001という刻みを使った有限差分そのものの誤差です。符号は、wは上がる(0.8へ)べきで、bは下がる(−0.3へ)べきだと言っており、正しいです。
ループをコードにし、3つの学習率で走らせる
いよいよループ本体です。trainは両パラメータを0から始め、20ステップ走らせ、各更新の前の損失を記録します。学習率0.05、0.5、1.1で3回走らせます。
import numpy as np
rng = np.random.default_rng(0)
x = rng.uniform(-1, 1, size=100)
y = 0.8 * x - 0.3 + rng.normal(0, 0.1, size=100)
def loss_and_grads(w, b):
err = w * x + b - y
return np.mean(err ** 2), np.mean(2 * err * x), np.mean(2 * err)
def train(learning_rate, steps=20):
w, b = 0.0, 0.0
history = []
for step in range(1, steps + 1):
loss, dw, db = loss_and_grads(w, b)
w -= learning_rate * dw
b -= learning_rate * db
history.append(round(float(loss), 4))
return w, b, history
for lr in (0.05, 0.5, 1.1):
w, b, history = train(lr)
print(f"lr {lr:<5} final w {w:7.3f} b {b:7.3f}")
print(" losses:", history)
lr 0.05 final w 0.404 b -0.226
losses: [0.294, 0.2695, 0.2476, 0.2279, 0.2101, 0.1941, 0.1795, 0.1662, 0.1541, 0.1431, 0.1329, 0.1237, 0.1151, 0.1073, 0.1001, 0.0934, 0.0873, 0.0816, 0.0763, 0.0715]
lr 0.5 final w 0.793 b -0.307
losses: [0.294, 0.111, 0.0507, 0.0262, 0.0162, 0.0122, 0.0105, 0.0099, 0.0096, 0.0095, 0.0094, 0.0094, 0.0094, 0.0094, 0.0094, 0.0094, 0.0094, 0.0094, 0.0094, 0.0094]
lr 1.1 final w 2.596 b 11.598
losses: [0.294, 0.0725, 0.0899, 0.131, 0.194, 0.2896, 0.4348, 0.6553, 0.99, 1.4983, 2.2699, 3.4414, 5.2201, 7.9205, 12.0205, 18.2453, 27.6961, 42.0448, 63.8299, 96.9052]
チャートはこの3つの履歴を対数スケールで描いています。損失には対数スケールが正しい尺度です。0.3から0.03への低下は、0.03から0.003への低下と同じだけ重要だからです。
遅すぎる
0.05では損失は着実に下がり、ステップ20でもまだ下がり続けていて、0.294から0.0715になりました。パラメータは0.8と−0.3へ向かう途中の0.404と−0.226に達しています。何も間違ってはおらず、あと100ステップあればたどり着くでしょう。しかし1ステップごとにデータ全体の順伝播と逆伝播のコストがかかり、実際のモデルではそれが1時間と1日の違いになります。滑らかで浅い坂の損失曲線は、学習率をもっと大きくできる印です。
ほぼ適切
0.5では損失はステップ11までに0.0094に落ち、そこにとどまります。この数値はノイズの床です。ラベルは標準偏差0.1のノイズで生成したので、どんなwとbでも平均二乗誤差は約0.01を下回れません。当てはめた0.793と−0.307は、ノイズ入りの100点で許される限り0.8と−0.3に近い値です。急速に下がってから平らになる曲線が健全な学習の姿で、平らになったら止めてかまいません。
発散
1.1では最初のステップが3つの中で最良で、損失を0.0725まで下げます。そこから上昇に転じ、最初はゆっくり、やがて1ステップあたり約1.5倍で増え、ステップ20で97に達し、bは11.6で、役に立つところへは向かっていません。一歩がお椀の底を飛び越えて反対側の、出発点より高い場所に着地し、その後の一歩ごとにさらに大きく飛び越えています。
このしきい値は恣意的なものではありません。二乗誤差のお椀では、ある方向に沿った勾配は底からの距離に比例して大きくなり、その比例定数はその方向のお椀の曲率です。lr×勾配の大きさの一歩は、元の距離の(1−lr×曲率)倍の位置に着地します。この係数が−1と1の間なら距離は縮みます。−1を下回れば距離は符号を反転しながら増えます。ここではbに沿った曲率は2(errの2乗の平均をbで2階微分したもの)なので、係数は1−2×1.1で−1.2となり、bの誤差は符号を反転しながら1ステップごとに20%ずつ増えます。1.0未満の学習率ならbに沿って収束したはずです。wに沿った曲率は2×xの2乗の平均で約0.67なので、wは学習率3未満で安定しており、問題ではありませんでした。
上がり続ける損失、あるいはnanになる損失は、学習率が大きすぎる印です。直し方は10で割ってやり直すこと。モデルの他の部分を変える必要はありません。
バッチ、ステップ、エポック
上のループは、すべての勾配に100事例全部を使いました。これはフルバッチ勾配降下法で、厳密ですが事例が数百万あると高価です。通常の代替手段は、たとえば32や256事例のバッチで勾配を計算することです。これは真の勾配のノイズ混じりの推定ですが、はるかに安価なので、同じ時間でずっと多くのステップを踏めます。これが確率的勾配降下法で、そのノイズは害より益のほうが大きいことが分かっています。損失面の狭い窪みからパラメータを揺すり出してくれるのです。
用語はここから導かれます。ステップ(または反復)は、1つのバッチで4つの箱を1周することです。エポックは学習セット全体を1周することで、1万事例をバッチ100で回すなら1エポックは100ステップです。学習はエポックで語られ、学習率はエポックごとに調整されることが多く、論文の損失曲線はたいていステップまたはエポックごとに描かれます。レッスン07では明示的なバッチを使うループを書きます。
モデルが大きくなると何が変わるか
ループは同じままです。変わるのは、勾配の計算方法と、一歩の踏み方です。
2パラメータの直線なら勾配はコード1行に収まります。1,000万パラメータのネットワークに手作業の導出はないので、フレームワークが自動で計算します。順伝播を書けば、逆モード自動微分(誤差逆伝播法、バックプロパゲーション)が、順伝播の約2倍のコストで、すべてのパラメータに関する損失の勾配を生成します。PyTorch学習ループのリファレンスは、同じ4ステップをloss.backward()とoptimizer.step()として示しています。
更新則にも改良が加わります。モメンタムは最近の勾配の移動平均を保持し、一貫した方向は加速し、振動する方向は打ち消し合うようにします。2015年以降の深層学習の大半で使われているオプティマイザであるAdamは、さらに各パラメータの一歩をその勾配の最近の大きさでスケールするので、勾配が小さいパラメータも動きます。学習率スケジュールは、高く始めて減衰させるか、ゼロからウォームアップして、大きな学習率の速い初期の進歩と、小さな学習率の精度の両方を得ます。これらはすべて上のループへの追加であり、どれにも大きすぎたり小さすぎたりしうる学習率があります。深層学習のハブがオプティマイザの系統を扱います。
実務ではもう1つの違いが重要です。このページの二乗誤差のお椀には底が1つしかありません。ニューラルネットワークの損失面には多くの谷、尾根、平坦な台地があり、勾配降下法が見つけるのは1つの谷であって、必ずしも最も低い谷ではありません。それでも無作為な出発点から確実に良い谷を見つけられるという事実は、この分野が立脚する経験的事実の1つであり、最も理解が進んでいないことの1つでもあります。
演習
- 学習率を0.95に変えて20ステップ走らせてください。損失は振動しながらもなお下がるはずです。発散の節の安定性係数を使って理由を説明してください。
- フルバッチの勾配を、ステップごとに無作為な10事例のバッチで計算した勾配(
rng.choice(100, size=10, replace=False))に置き換え、学習率0.5で200ステップ走らせ、20ステップごとに100事例全体の損失を描画または出力してください。フルバッチの曲線との違いを記述してください。 - 3つ目のパラメータ
cを加えてモデルをw * x + c * x ** 2 + bにし、その勾配を導出してloss_and_gradsに加え、有限差分で確認してください。それから学習し、データが2乗項なしで生成されたことを踏まえて、cがどの値付近に落ち着くかを報告してください。
まとめ
- 学習は4ステップのループです。順伝播、損失、逆伝播(勾配)、更新。直線から言語モデルまで、あらゆるモデルがこれを回します。
- 勾配は損失の上り坂を指すので、更新はそれを引きます。有限差分の検算が、手作業で導出した勾配の符号誤りを捕まえます。
- 学習率は各ステップをスケールします。小さすぎると遅いが安全。大きすぎると飛び越え、お椀の場合、学習率が2÷曲率を超えると発散します。
- 曲線を読んでください。浅く下がり続けていれば学習率を上げる、急降下して平らなら平らになったところで止める、上昇または
nanなら学習率を10分の1にする。 - バッチは勾配を安価でノイズ混じりにし、エポックはデータを1周することで、フレームワークはループを変えずに自動微分とAdamなどのオプティマイザを加えます。
さらに読む
- Deep Learning, Goodfellow, Bengio and Courville: 第4章が勾配に基づく最適化と曲率の議論を、第8章が確率的勾配降下法、モメンタム、Adamを扱います。
- Adam: A Method for Stochastic Optimization, Kingma and Ba, 2014: あらゆるPyTorchの例で出会うオプティマイザ。更新則が短いアルゴリズム枠1つにまとまっています。
- The Elements of Statistical Learning, Hastie, Tibshirani and Friedman: 11.4節が、このページで使ったのと同じ連鎖律として、小さなネットワークの誤差逆伝播法を導出します。
- numpy user guide, “NumPy fundamentals”: 上のコードのあらゆる行の背後にある配列操作とブロードキャスト。