Hayami Kaito Web
~/tech

最適化手法のしくみ:SGD・モーメンタム・Adam の違い

published

ニューラルネットワークの学習では、誤差逆伝播法で求めた勾配を使って重みを少しずつ動かします。このとき「勾配をどう使って、どれだけ動かすか」を決めるのが 最適化手法(オプティマイザー)です。いちばん基本の SGD(確率的勾配降下法)のほかに、モーメンタム・RMSProp・Adam など多くの方法があり、どれを選ぶか、学習率をいくつにするかで、学習の速さや最終的な性能が大きく変わります。

この記事は ニューラルネットワークの学習のしくみ の続きです。勾配の求め方はそちらで説明したので、この記事では「勾配が分かったあと、どう進むか」に絞ります。

結論を先に書くと、最適化手法の工夫は次の 3 つにまとめられます。

  • 学習率を決める: 大きすぎると行き過ぎて発散し、小さすぎると進まない。上限は損失の面のいちばん急な向きの曲がり具合で決まる
  • 過去の勾配をためて進む(モーメンタム): 細長い谷で左右に振れる動きを打ち消し、谷底に沿った向きに加速する
  • 重みごとに歩幅を変える(RMSProp・Adam): 勾配の大きさの平均で割り、急な向きでは小さく、なだらかな向きでは大きく進む

説明の途中に出てくる計算例は、Python と NumPy で実際に動かした結果です。

環境

計算例を動かした環境です。

項目バージョン
Python3.13
NumPy2.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 など)のままでもそこそこ動くまず試す方法として幅広く
AdamWAdam に重み減衰を正しく入れたものTransformer・大規模言語モデル

このサイトの 勾配降下法と最適化手法 では、細長い谷・鞍点・局所解の多い面などの等高線の上で、SGD・モーメンタム・ネステロフ・AdaGrad・RMSProp・Adam の 6 つの方法が進む道筋を並べて見られます。学習率やノイズを変えたときの違いも確かめられます。

最適化手法の流れ

この記事で紹介した手法を年代順に並べます。

年手法ポイント
1951確率的近似法(ロビンス・モンロー)ばらつく見積もりでも、歩幅を小さくしていけば答えに近づく
1964重い球の方法(ポリヤク)モーメンタムの元になった、勢いを残して進む方法
1983ネステロフの加速勾配法少し先の位置で勾配を計算して、勢いの行き過ぎを抑える
2011AdaGrad勾配の 2 乗の合計で割り、重みごとに歩幅を変える
2012RMSProp勾配の 2 乗を、古い値を忘れる平均にする
2014Adamモーメンタムと RMSProp を組み合わせ、偏りを補正する
2016コサイン減衰(SGDR)学習率をコサインの曲線で下げる
2017大きなミニバッチとウォームアップミニバッチに比例して学習率を上げ、最初はウォームアップする
2017AdamW重み減衰を勾配の計算から切り離す
2023Lion計算で自動的に探し出した、勾配の符号だけを使う方法

まとめ

  • 最適化手法は、勾配を使って重みをどう動かすかを決める
  • 学習率の上限は「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)