最適化手法のしくみ:SGD・モーメンタム・Adam の違い
ニューラルネットワークの学習では、誤差逆伝播法で求めた勾配を使って重みを少しずつ動かします。このとき「勾配をどう使って、どれだけ動かすか」を決めるのが 最適化手法(オプティマイザー)です。いちばん基本の SGD(確率的勾配降下法)のほかに、モーメンタム・RMSProp・Adam など多くの方法があり、どれを選ぶか、学習率をいくつにするかで、学習の速さや最終的な性能が大きく変わります。
この記事は ニューラルネットワークの学習のしくみ の続きです。勾配の求め方はそちらで説明したので、この記事では「勾配が分かったあと、どう進むか」に絞ります。
結論を先に書くと、最適化手法の工夫は次の 3 つにまとめられます。
- 学習率を決める: 大きすぎると行き過ぎて発散し、小さすぎると進まない。上限は損失の面のいちばん急な向きの曲がり具合で決まる
- 過去の勾配をためて進む(モーメンタム): 細長い谷で左右に振れる動きを打ち消し、谷底に沿った向きに加速する
- 重みごとに歩幅を変える(RMSProp・Adam): 勾配の大きさの平均で割り、急な向きでは小さく、なだらかな向きでは大きく進む
説明の途中に出てくる計算例は、Python と NumPy で実際に動かした結果です。
環境
計算例を動かした環境です。
| 項目 | バージョン |
|---|---|
| Python | 3.13 |
| NumPy | 2.5 |
勾配降下法をおさらいする
勾配降下法は、損失の面を「山や谷のある地形」と見て、今いる場所の下り坂の向き(勾配の逆向き)へ少しずつ進む方法です。
新しい重み = 重み − 学習率 × 勾配
ここでは、実際のニューラルネットワークの損失のかわりに、2 つの重み x・y だけを持つ関数で考えます。実際の損失の面は何百万次元もありますが、「急な向き」と「なだらかな向き」が混ざっているという性質は同じです。
細長い谷では左右に振れる
f(x, y) = 0.005x² + 2y² という関数を考えます。最小値は原点の 0 です。y の向きは x の向きより 400 倍急で、等高線は横に細長い楕円になります。(−8, 1) から学習率 0.45 で SGD を 5 歩進めてみます。
import numpy as np
# 細長い谷:f(x, y) = 0.005 x² + 2 y²(最小値は原点で 0)
def f(p):
return 0.005 * p[0] ** 2 + 2 * p[1] ** 2
def grad(p):
return np.array([0.01 * p[0], 4 * p[1]])
p = np.array([-8.0, 1.0])
lr = 0.45
for step in range(1, 6):
p = p - lr * grad(p) # SGD:勾配の逆向きに進む
print(step, np.round(p, 3))
def steps_to_min(lr, limit=5000):
p = np.array([-8.0, 1.0])
for k in range(1, limit + 1):
p = p - lr * grad(p)
if f(p) < 1e-3:
return k
if f(p) > 1e6:
return "発散"
return "未到達"
print({lr: steps_to_min(lr) for lr in [0.1, 0.3, 0.45, 0.49, 0.51]})
実行結果:
1 [-7.964 -0.8 ]
2 [-7.928 0.64 ]
3 [-7.892 -0.512]
4 [-7.857 0.41 ]
5 [-7.822 -0.328]
{0.1: 2883, 0.3: 960, 0.45: 640, 0.49: 588, 0.51: '発散'}
y は 1 → −0.8 → 0.64 → −0.512 と符号を変えながら谷の両側を行ったり来たりしているのに、x は −8 → −7.822 とほとんど進んでいません。急な y の向きに合わせると、なだらかな x の向きには進めないのです。
最後の行は、学習率ごとに、f が 0.001 を下回るまでの歩数です。学習率を上げるほど速くなりますが、0.51 では発散しました。
学習率の上限は「いちばん急な向き」で決まる
y の向きだけを見ると、1 歩で y は (1 − 学習率 × 4) 倍になります。この倍率の大きさが 1 を超えると、振れ幅が毎回大きくなって発散します。つまり学習率は 2 ÷ 4 = 0.5 より小さくなければなりません。一般に、勾配降下法の学習率の上限は「2 ÷ いちばん急な向きの曲がり具合」です。
一方で、x の向きは 1 歩で (1 − 学習率 × 0.01) 倍にしかならないので、上限ぎりぎりの 0.49 でも 1 歩で 0.5% しか進めません。急な向きと、なだらかな向きの曲がり具合の比が大きいほど、勾配降下法は遅くなります。この比を 条件数と呼び、ここでは 400 です。実際のニューラルネットワークの損失の面は、条件数がとても大きいことが知られています。
モーメンタム:過去の勾配をためて進む
ボールが坂を転がるように
モーメンタム(慣性)は、坂を転がるボールのように、それまでの動きの勢いを残しながら進む方法です。勾配を毎回そのまま使うのではなく、「速度」に足し込んでいき、その速度で進みます。
速度 = β × 速度 + 勾配
新しい重み = 重み − 学習率 × 速度
β(ふつうは 0.9)は、前の速度をどれだけ残すかです。細長い谷では、
- y の向き: 勾配の符号が毎回入れ替わるので、ためた速度の中で打ち消し合い、振れが小さくなる
- x の向き: 勾配がいつも同じ向きなので、速度がたまって最大で 1 ÷ (1 − β) = 10 倍の速さで進む
となり、谷底に沿って加速できます。
RMSProp と Adam:重みごとに歩幅を変える
勾配の大きさで割る
もう 1 つの考え方は、重みごとに学習率を変えることです。急な向き(勾配がいつも大きい重み)では歩幅を小さく、なだらかな向き(勾配がいつも小さい重み)では歩幅を大きくすれば、条件数の問題を直接やわらげられます。
RMSProp は、重みごとに勾配の 2 乗の平均をためておき、その平方根で勾配を割ってから進みます。勾配の「大きさ」がそろうので、どの向きにも同じくらいの歩幅で進めます。2011 年の AdaGrad は勾配の 2 乗を全部足し続ける方法でしたが、学習が進むと歩幅が小さくなりすぎるため、RMSProp では古い値を少しずつ忘れる平均に変えました。
Adam:モーメンタムと RMSProp を組み合わせる
2014 年に発表された Adam は、モーメンタム(勾配の平均で向きを決める)と RMSProp(勾配の 2 乗の平均で歩幅を決める)を組み合わせた方法です。今のニューラルネットワークの学習では、Adam とその改良版がいちばんよく使われています。
3 つの方法を比べる
細長い谷で、SGD・モーメンタム・Adam が f < 0.001 にたどり着くまでの歩数を比べます。上のコードの続きです。
def run(update, limit=5000):
p, state = np.array([-8.0, 1.0]), {}
for k in range(1, limit + 1):
p = update(p, grad(p), state, k)
if f(p) < 1e-3:
return k
return "未到達"
def sgd(p, g, s, k, lr=0.45):
return p - lr * g
def momentum(p, g, s, k, lr=0.45, beta=0.9):
s["v"] = beta * s.get("v", 0) + g # 速度:過去の勾配をためる
return p - lr * s["v"]
def adam(p, g, s, k, lr=0.3, b1=0.9, b2=0.999, eps=1e-8):
s["m"] = b1 * s.get("m", 0) + (1 - b1) * g # 勾配の平均(向き)
s["v"] = b2 * s.get("v", 0) + (1 - b2) * g * g # 勾配の 2 乗の平均(大きさ)
m_hat = s["m"] / (1 - b1**k) # 最初の数歩の偏りを直す
v_hat = s["v"] / (1 - b2**k)
return p - lr * m_hat / (np.sqrt(v_hat) + eps)
for name, u in [("SGD", sgd), ("モーメンタム", momentum), ("Adam", adam)]:
print(name, run(u))
実行結果:
SGD 640
モーメンタム 65
Adam 34
同じ谷で、SGD が 640 歩かかったところを、モーメンタムは 65 歩、Adam は 34 歩でたどり着きました。学習率は、それぞれの方法でいくつか試し、速く進んだ値を使っています(SGD は上限の 0.5 に近い 0.45)。
ただし、この関数は谷が x と y の軸に沿っているので、重みごとに歩幅を変える Adam に特に有利です。谷が斜めを向いていると、重みごとの調整だけでは条件数の問題を解消できません。また、関数や学習率の選び方で順位は入れ替わります。どの方法がいちばん良いかは問題によって違い、「Adam ならいつも最良」というわけではありません。
Adam の偏りの補正
Adam のコードの m_hat・v_hat は、学習の最初の数歩の偏りを直す計算です。平均を 0 から始めているので、最初のうちは平均が本当の値より小さく出てしまいます。毎回同じ勾配 2.0 が来る場合で確かめてみます。
b1, b2 = 0.9, 0.999
g = 2.0 # 毎回同じ勾配 2.0 が来るとする
m = v = 0.0
for k in range(1, 4):
m = b1 * m + (1 - b1) * g
v = b2 * v + (1 - b2) * g * g
print(k, round(m, 3), round(m / (1 - b1**k), 3), round(v**0.5, 3), round((v / (1 - b2**k)) ** 0.5, 3))
実行結果:
1 0.2 2.0 0.063 2.0
2 0.38 2.0 0.089 2.0
3 0.542 2.0 0.109 2.0
左から、歩数、補正前の勾配の平均 m、補正後の平均、補正前の勾配の 2 乗の平均の平方根、補正後の値です。補正前の m は 0.2・0.38・0.542 と、本当の値 2.0 よりずっと小さく、2 乗の平均の平方根も 0.063 と 2.0 の 30 分の 1 ほどしかありません。(1 − β^k) で割って補正すると、どちらも最初の 1 歩から 2.0 になります。補正しないと、1 歩目の m ÷ √v は 0.2 ÷ 0.063 ≒ 3.2 になり、補正したときの 1 の 3 倍以上の歩幅で進んでしまいます。β₂ = 0.999 の v のほうが 0 から立ち上がるのが遅いので、最初の数百歩はこのずれが残ります。
鞍点:勾配が 0 でも最小値とは限らない
鞍点で止まる
損失の面には、勾配が 0 になる場所がいくつもあります。谷底(極小値)だけでなく、ある向きには谷底、別の向きには山頂になっている 鞍点(馬の鞍の形)でも勾配は 0 です。次元の多いニューラルネットワークの損失の面では、勾配が 0 の場所のほとんどが極小値ではなく鞍点だと考えられています。
f(x, y) = x² + y⁴/4 − y²/2 は、(0, 0) が鞍点で、最小値は (0, ±1) です。(1, 0) から勾配降下法で進めてみます。
import numpy as np
# 鞍点のある関数:f(x, y) = x² + y⁴/4 − y²/2
# (0, 0) は x の向きには谷底、y の向きには山頂の「鞍点」。最小値は (0, ±1) で −0.25
def grad(p):
return np.array([2 * p[0], p[1] ** 3 - p[1]])
def descend(start, noise, steps=200, lr=0.1, seed=0):
rng = np.random.default_rng(seed)
p = np.array(start)
for _ in range(steps):
g = grad(p) + noise * rng.standard_normal(2) # ミニバッチのばらつきの代わりのノイズ
p = p - lr * g
return np.round(p, 3)
print(descend((1.0, 0.0), noise=0.0))
print(descend((1.0, 0.0), noise=0.01))
print(descend((1.0, 1e-6), noise=0.0))
実行結果:
[0. 0.]
[-0.001 -0.999]
[0. 1.]
1 行目は、ノイズのない勾配降下法です。y = 0 の上では y の向きの勾配がちょうど 0 なので、x の向きにだけ進み、鞍点の (0, 0) で止まってしまいました。2 行目は、勾配に小さなノイズを加えた場合で、鞍点から転がり落ちて最小値の (0, −1) にたどり着いています。3 行目は、ノイズはなくても、出発点の y を 0.000001 だけずらした場合で、こちらも最小値の (0, 1) にたどり着きました。
鞍点は「ちょうどその上にいると動けないが、少しでもずれると転がり落ちる」場所です。実際の学習では、次に説明するミニバッチのばらつきが、このノイズの役割を果たします。
ミニバッチ:勾配の見積もりのばらつき
ミニバッチの大きさとばらつき
実際の学習では、学習用のデータ全部で勾配を計算するのではなく、ランダムに選んだ数十〜数千個のまとまり(ミニバッチ)で勾配を見積もります。全部のデータで計算するより速く、何度も更新できるからです。これが「確率的」勾配降下法(SGD)の「確率的」の意味です。
ミニバッチで計算した勾配は、本当の勾配の見積もりなので、ばらつきます。直線 y = 3x + 1 にノイズを加えた 10,000 個のデータで、まだ学習していないモデル(傾き 0・切片 0)の、傾きについての勾配を、ミニバッチの大きさを変えて 2,000 回ずつ見積もってみます。
import numpy as np
# 直線 y = 3x + 1 にノイズを加えた 10,000 個のデータ
rng = np.random.default_rng(0)
x = rng.uniform(-1, 1, 10000)
y = 3 * x + 1 + rng.normal(0, 1, 10000)
w, b = 0.0, 0.0 # まだ学習していないモデル
def grad_w(idx):
pred = w * x[idx] + b
return float((2 * (pred - y[idx]) * x[idx]).mean()) # 2 乗誤差を w で微分した平均
full = grad_w(np.arange(10000))
print(round(full, 3))
for batch in [1, 10, 100, 1000]:
gs = [grad_w(rng.choice(10000, batch, replace=False)) for _ in range(2000)]
print(batch, round(float(np.mean(gs)), 3), round(float(np.std(gs)), 3))
実行結果:
-2.003
1 -2.094 2.488
10 -1.973 0.766
100 -2.001 0.247
1000 -2.004 0.072
1 行目が全部のデータで計算した勾配 −2.003 です。2 行目からは、ミニバッチの大きさ、2,000 回の見積もりの平均、そのばらつき(標準偏差)です。どの大きさでも平均は −2.0 前後で、見積もりとして偏ってはいません。ばらつきは、大きさ 1 で 2.488、10 で 0.766、100 で 0.247 と、大きさを 10 倍にするとおよそ 1/√10 ≒ 0.32 倍になっています。
ばらつきは悪いことばかりではない
ミニバッチを大きくすると勾配は正確になりますが、1 回の更新の計算は重くなります。また、ばらつきには、鞍点から抜け出しやすくする効果があります。さらに、大きすぎるミニバッチで学習すると、学習用のデータでは損失が低くても、初めて見るデータでの性能が下がりやすいという報告もあります。ばらつきのおかげで、少しのずれで損失が大きく上がる「とがった谷」より、「広く平らな谷」に落ち着きやすいためだと考えられています。
ミニバッチを大きくするときは、学習率も大きくする(ミニバッチの大きさに比例させる)とよい、という経験則も知られています。
学習率のスケジュール
学習率を途中で変える
学習の最初から最後まで、同じ学習率を使い続けるとは限りません。今の大きなモデルの学習では、次のような 学習率のスケジュールがよく使われます。
| 段階 | 内容 | 理由 |
|---|---|---|
| ウォームアップ | 最初の数百〜数千歩で、学習率を 0 から目標の値まで少しずつ上げる | 学習の最初は重みがでたらめで勾配が大きく不安定なため、急に大きく動かすと発散しやすい |
| 減衰 | そのあと、学習率を少しずつ下げていく | 最後は小さな歩幅で、谷底の近くに落ち着かせる |
減らし方には、決まった歩数ごとに 10 分の 1 にする方法や、コサインの曲線に沿ってなめらかに下げる コサイン減衰があります。10,000 歩の学習で、1,000 歩のウォームアップのあとコサインで下げる例を計算してみます。
import math
def lr_at(step, peak=3e-4, warmup=1000, total=10000, floor=3e-5):
if step < warmup:
return peak * step / warmup # ウォームアップ:0 から直線で上げる
progress = (step - warmup) / (total - warmup)
return floor + (peak - floor) * 0.5 * (1 + math.cos(math.pi * progress)) # コサインで下げる
for s in [0, 500, 1000, 3000, 5500, 8000, 10000]:
print(s, f"{lr_at(s):.2e}")
実行結果:
0 0.00e+00
500 1.50e-04
1000 3.00e-04
3000 2.68e-04
5500 1.65e-04
8000 6.16e-05
10000 3.00e-05
学習率は 0 から始まり、1,000 歩目で最大の 0.0003 に達したあと、なめらかに下がって、最後は最大の 10 分の 1 の 0.00003 になります。
AdamW:重み減衰を正しく入れる
過学習を防ぐために、重みを少しずつ 0 に近づける 重み減衰(weight decay)がよく使われます。SGD では、重み減衰は「損失に重みの 2 乗の和を足す」(L2 正則化)のと同じになります。ところが Adam では、損失に足した項の勾配も「勾配の大きさで割る」処理を通ってしまうため、重みによって減衰の強さがばらばらになり、期待どおりに効きません。
2017 年に提案された AdamW は、重み減衰を勾配の計算から切り離し、Adam の更新とは別に、重みを直接一定の割合だけ縮めるようにしました。今の Transformer などの学習では、AdamW がいちばんよく使われています。過学習と正則化については、次の記事で詳しく説明します。
どれを選べばよいか
| 方法 | 特徴 | よく使われる場面 |
|---|---|---|
| SGD | いちばん単純。学習率の調整が必要 | 理論の説明、ほかの方法の基準 |
| SGD + モーメンタム | 細長い谷で速い。うまく調整すると、初めて見るデータでの性能が高いことがある | 画像分類の CNN の学習 |
| RMSProp | 重みごとに歩幅を変える | RNN・強化学習 |
| Adam | モーメンタム + RMSProp。初期値(学習率 0.001 など)のままでもそこそこ動く | まず試す方法として幅広く |
| AdamW | Adam に重み減衰を正しく入れたもの | Transformer・大規模言語モデル |
このサイトの 勾配降下法と最適化手法 では、細長い谷・鞍点・局所解の多い面などの等高線の上で、SGD・モーメンタム・ネステロフ・AdaGrad・RMSProp・Adam の 6 つの方法が進む道筋を並べて見られます。学習率やノイズを変えたときの違いも確かめられます。
最適化手法の流れ
この記事で紹介した手法を年代順に並べます。
| 年 | 手法 | ポイント |
|---|---|---|
| 1951 | 確率的近似法(ロビンス・モンロー) | ばらつく見積もりでも、歩幅を小さくしていけば答えに近づく |
| 1964 | 重い球の方法(ポリヤク) | モーメンタムの元になった、勢いを残して進む方法 |
| 1983 | ネステロフの加速勾配法 | 少し先の位置で勾配を計算して、勢いの行き過ぎを抑える |
| 2011 | AdaGrad | 勾配の 2 乗の合計で割り、重みごとに歩幅を変える |
| 2012 | RMSProp | 勾配の 2 乗を、古い値を忘れる平均にする |
| 2014 | Adam | モーメンタムと RMSProp を組み合わせ、偏りを補正する |
| 2016 | コサイン減衰(SGDR) | 学習率をコサインの曲線で下げる |
| 2017 | 大きなミニバッチとウォームアップ | ミニバッチに比例して学習率を上げ、最初はウォームアップする |
| 2017 | AdamW | 重み減衰を勾配の計算から切り離す |
| 2023 | Lion | 計算で自動的に探し出した、勾配の符号だけを使う方法 |
まとめ
- 最適化手法は、勾配を使って重みをどう動かすかを決める
- 学習率の上限は「2 ÷ いちばん急な向きの曲がり具合」で決まり、急な向きとなだらかな向きの比(条件数)が大きいほど SGD は遅くなる
- モーメンタムは過去の勾配をためて進み、振れを打ち消しながら谷底に沿って加速する
- RMSProp は勾配の 2 乗の平均で割り、重みごとに歩幅を変える。Adam はモーメンタムと RMSProp を組み合わせ、最初の数歩の偏りを補正する
- 鞍点では勾配が 0 になるが、少しのずれやノイズで抜け出せる
- ミニバッチで見積もった勾配のばらつきは、大きさを 10 倍にするとおよそ 1/√10 倍になる。ばらつきには鞍点から抜け出しやすくする効果もある
- 大きなモデルの学習では、ウォームアップとコサイン減衰などの学習率のスケジュールを組み合わせる
- Adam で重み減衰を使うときは、AdamW のように更新とは別に重みを縮める
参考
- H. Robbins and S. Monro, "A Stochastic Approximation Method", The Annals of Mathematical Statistics 1951
- B. T. Polyak, "Some methods of speeding up the convergence of iteration methods", USSR Computational Mathematics and Mathematical Physics 1964
- Y. Nesterov, "A method for solving the convex programming problem with convergence rate O(1/k²)", Soviet Mathematics Doklady 1983
- J. Duchi et al., "Adaptive Subgradient Methods for Online Learning and Stochastic Optimization", JMLR 2011
- T. Tieleman and G. Hinton, "Lecture 6.5 — RMSProp", COURSERA: Neural Networks for Machine Learning 2012
- Y. Dauphin et al., "Identifying and attacking the saddle point problem in high-dimensional non-convex optimization", NeurIPS 2014(arXiv:1406.2572)
- D. P. Kingma and J. Ba, "Adam: A Method for Stochastic Optimization", ICLR 2015(arXiv:1412.6980)
- I. Loshchilov and F. Hutter, "SGDR: Stochastic Gradient Descent with Warm Restarts", ICLR 2017(arXiv:1608.03983)
- N. S. Keskar et al., "On Large-Batch Training for Deep Learning: Generalization Gap and Sharp Minima", ICLR 2017(arXiv:1609.04836)
- P. Goyal et al., "Accurate, Large Minibatch SGD: Training ImageNet in 1 Hour", 2017(arXiv:1706.02677)
- A. C. Wilson et al., "The Marginal Value of Adaptive Gradient Methods in Machine Learning", NeurIPS 2017(arXiv:1705.08292)
- I. Loshchilov and F. Hutter, "Decoupled Weight Decay Regularization", ICLR 2019(arXiv:1711.05101)
- X. Chen et al., "Symbolic Discovery of Optimization Algorithms", NeurIPS 2023(arXiv:2302.06675)