機械学習モデルの訓練とは、損失関数を最小化するパラメータを見つけることです。しかし、数百万から数十億のパラメータを持つモデルの損失関数を、どうやって効率的に最小化するのでしょうか。解析的に解を求めるのは不可能です(線形回帰のような例外を除けば)。
この問題に対する最も基本的な答えが勾配降下法(gradient descent)です。アイデアは非常にシンプルで、「損失関数が最も急に下る方向に少しずつパラメータを動かす」というものです。山の斜面を下って谷底に向かう登山家のアナロジーで理解できます——足元の傾斜が最も急な方向に一歩ずつ進めば、いずれ谷底(最小点)にたどり着きます。
勾配降下法を理解すると、以下のような場面で適切な判断ができるようになります。
- 学習率の選び方: 大きすぎると発散し、小さすぎると収束が遅い——どう選ぶか
- バッチサイズの影響: フルバッチ・ミニバッチ・SGDの違いと使い分け
- 収束の判断: 損失が下がらなくなった原因の診断
- 最適化アルゴリズムの理解: Adam等の高度な手法を理解するための基盤
本記事の内容
- 勾配降下法の数学的な定式化
- バッチGD・SGD・ミニバッチSGDの比較
- 学習率の役割と選び方
- 収束条件と凸/非凸最適化
- Pythonによる実装と可視化
前提知識
この記事を読む前に、以下の記事を読んでおくと理解が深まります。
勾配降下法の基本原理
なぜ勾配の逆方向に進むのか
関数 $J(\bm{w})$ をパラメータ $\bm{w}$ について最小化したいとします。$\bm{w}$ を微小量 $\Delta\bm{w}$ だけ動かしたとき、$J$ の変化量はテイラー展開の1次近似で
$$ J(\bm{w} + \Delta\bm{w}) \approx J(\bm{w}) + \nabla J(\bm{w})^T \Delta\bm{w} $$
$J$ を最も効率的に減少させるには、$\nabla J(\bm{w})^T \Delta\bm{w}$ を最小化(最も負にする)$\Delta\bm{w}$ を選べばよいです。$\|\Delta\bm{w}\| = \epsilon$(一定の大きさ)の制約のもとでは、コーシー・シュワルツの不等式より
$$ \nabla J(\bm{w})^T \Delta\bm{w} \geq -\|\nabla J(\bm{w})\| \cdot \|\Delta\bm{w}\| $$
等号は $\Delta\bm{w} = -\epsilon \frac{\nabla J(\bm{w})}{\|\nabla J(\bm{w})\|}$ のとき、つまり $\Delta\bm{w}$ が勾配の逆方向を向いているときに成立します。
したがって、勾配の逆方向が関数を最も急速に減少させる方向であり、これが最急降下方向(steepest descent direction)です。
更新則
勾配降下法の更新則は
$$ \begin{equation} \bm{w}_{t+1} = \bm{w}_t – \eta \nabla J(\bm{w}_t) \end{equation} $$
ここで $\eta > 0$ は学習率(learning rate)で、各ステップでどれだけパラメータを動かすかを制御します。$\nabla J(\bm{w}_t)$ は現在のパラメータにおける損失関数の勾配です。
学習率は勾配降下法で最も重要なハイパーパラメータです。次のセクションでその役割を詳しく見ていきます。
学習率の役割
学習率が大きすぎる場合
学習率が大きすぎると、更新のステップが大きすぎて最小点を飛び越えてしまい、損失が振動したり発散したりします。
学習率が小さすぎる場合
学習率が小さすぎると、パラメータの更新量が微小で収束が非常に遅くなります。実用的な計算時間内に良い解に到達できない可能性があります。
凸二次関数での収束条件
最も単純なケースとして、$J(\bm{w}) = \frac{1}{2}\bm{w}^T\bm{A}\bm{w}$($\bm{A}$ は正定値対称行列)を考えます。勾配は $\nabla J = \bm{A}\bm{w}$ なので
$$ \bm{w}_{t+1} = (\bm{I} – \eta\bm{A})\bm{w}_t $$
$\bm{A}$ の固有値を $\lambda_1 \leq \lambda_2 \leq \cdots \leq \lambda_d$ とすると、収束条件は
$$ |1 – \eta\lambda_k| < 1 \quad \forall k \quad \Leftrightarrow \quad 0 < \eta < \frac{2}{\lambda_{\max}} $$
最適な学習率は $\eta^* = \frac{2}{\lambda_{\max} + \lambda_{\min}}$ で、このとき1ステップあたりの収束率は
$$ \rho = \frac{\kappa – 1}{\kappa + 1} $$
ここで $\kappa = \lambda_{\max}/\lambda_{\min}$ は条件数(condition number)です。$\kappa$ が大きいほど収束が遅く、これが悪条件(ill-conditioned)問題と呼ばれます。
import numpy as np
import matplotlib.pyplot as plt
# 2次元の二次関数での勾配降下法
def quadratic_gd(A, w0, lr, n_steps):
"""二次関数 J(w) = 0.5 * w^T A w の勾配降下法"""
trajectory = [w0.copy()]
w = w0.copy()
losses = [0.5 * w @ A @ w]
for _ in range(n_steps):
grad = A @ w
w = w - lr * grad
trajectory.append(w.copy())
losses.append(0.5 * w @ A @ w)
return np.array(trajectory), np.array(losses)
fig, axes = plt.subplots(1, 3, figsize=(16, 5))
# 良条件の関数 (kappa ≈ 1)
A_good = np.array([[1.0, 0.0], [0.0, 1.0]])
# 悪条件の関数 (kappa = 10)
A_bad = np.array([[10.0, 0.0], [0.0, 1.0]])
w0 = np.array([4.0, 4.0])
n_steps = 30
# (a) 学習率の影響(良条件)
ax = axes[0]
lrs = [0.05, 0.5, 0.9, 1.5]
colors = ["blue", "green", "orange", "red"]
for lr, color in zip(lrs, colors):
traj, losses = quadratic_gd(A_good, w0, lr, n_steps)
ax.plot(losses[:20], "-o", color=color, markersize=3, linewidth=1.5,
label=f"$\\eta={lr}$")
ax.set_xlabel("Iteration", fontsize=12)
ax.set_ylabel("Loss $J(\\mathbf{w})$", fontsize=12)
ax.set_title("Learning Rate Effect ($\\kappa=1$)", fontsize=13)
ax.legend(fontsize=10)
ax.grid(True, alpha=0.3)
ax.set_yscale("log")
ax.set_ylim(1e-10, 1e3)
# (b) 軌跡の可視化(悪条件)
ax = axes[1]
# 等高線の描画
w1_range = np.linspace(-5, 5, 100)
w2_range = np.linspace(-5, 5, 100)
W1, W2 = np.meshgrid(w1_range, w2_range)
Z = 0.5 * (A_bad[0,0] * W1**2 + A_bad[1,1] * W2**2)
ax.contour(W1, W2, Z, levels=20, cmap="Blues", alpha=0.7)
for lr, color, label in [(0.05, "blue", "$\\eta=0.05$ (slow)"),
(0.15, "green", "$\\eta=0.15$ (good)"),
(0.19, "orange", "$\\eta=0.19$ (oscillates)")]:
traj, _ = quadratic_gd(A_bad, w0, lr, n_steps)
ax.plot(traj[:, 0], traj[:, 1], "-o", color=color, markersize=3,
linewidth=1.5, label=label)
ax.plot(traj[0, 0], traj[0, 1], "s", color=color, markersize=8)
ax.plot(0, 0, "r*", markersize=15, label="Minimum")
ax.set_xlabel("$w_1$", fontsize=12)
ax.set_ylabel("$w_2$", fontsize=12)
ax.set_title("GD Trajectories ($\\kappa=10$)", fontsize=13)
ax.legend(fontsize=9)
ax.grid(True, alpha=0.3)
# (c) 条件数と収束速度
ax = axes[2]
kappas = np.logspace(0, 3, 100)
convergence_rate = (kappas - 1) / (kappas + 1)
ax.plot(kappas, convergence_rate, "b-", linewidth=2.5)
ax.set_xscale("log")
ax.set_xlabel("Condition number $\\kappa$", fontsize=12)
ax.set_ylabel("Convergence rate $\\rho$", fontsize=12)
ax.set_title("Convergence Rate vs Condition Number", fontsize=13)
ax.grid(True, alpha=0.3)
ax.axhline(1.0, color="red", linestyle="--", linewidth=1.5, alpha=0.5)
ax.set_ylim(0, 1.05)
plt.tight_layout()
plt.savefig("gradient_descent_basics.png", dpi=150, bbox_inches="tight")
plt.show()
このグラフから、学習率と条件数が勾配降下法の収束に与える影響が読み取れます。
-
左図(学習率の影響): $\eta = 0.5$(緑)は指数的に収束し、少ないイテレーションで最小値に到達しています。$\eta = 0.05$(青)は安全ですが収束が遅く、$\eta = 0.9$(橙)は振動しながらもゆっくり収束しています。$\eta = 1.5$(赤)は $2/\lambda_{\max} = 2$ を超えており、発散しています
-
中央図(軌跡、$\kappa=10$): 悪条件の関数では等高線が楕円形になり、最急降下方向が最適方向とずれます。$\eta = 0.15$(緑)は適切な速度で収束していますが、$\eta = 0.19$(橙)は $w_1$ 方向(固有値10)で振動しながら収束しています。$\eta = 0.05$(青)は安全ですが非常に遅いです
-
右図(収束率と条件数): 条件数が大きくなると収束率が1に近づき、1ステップあたりの改善がほとんどなくなります。条件数が100の問題では $\rho \approx 0.98$ であり、損失を半分にするのに約35イテレーション必要です
悪条件の問題に対する対策として、後述するモメンタム付きSGDやAdamなどの適応的手法が開発されています。次に、データの使い方による勾配降下法の分類を見ていきましょう。
バッチGD・SGD・ミニバッチSGD
バッチ勾配降下法(Batch GD)
全ての訓練データを使って勾配を計算する方法です。
$$ \bm{w}_{t+1} = \bm{w}_t – \eta \cdot \frac{1}{n}\sum_{i=1}^n \nabla L(y_i, \hat{f}(\bm{x}_i; \bm{w}_t)) $$
メリット: 勾配の推定が正確で、安定した更新が行われる
デメリット: データ数 $n$ が大きいとき、1回の勾配計算に全データの走査が必要で計算コストが高い。メモリにも全データを載せる必要がある
確率的勾配降下法(SGD)
ランダムに1つのデータ点だけを使って勾配を近似する方法です。
$$ \bm{w}_{t+1} = \bm{w}_t – \eta \nabla L(y_{i_t}, \hat{f}(\bm{x}_{i_t}; \bm{w}_t)) $$
ここで $i_t$ はランダムに選ばれたインデックスです。
なぜこれが正しいのかというと、$i_t$ をランダムに選ぶので
$$ E_{i_t}\left[\nabla L(y_{i_t}, \hat{f}(\bm{x}_{i_t}; \bm{w}))\right] = \frac{1}{n}\sum_{i=1}^n \nabla L(y_i, \hat{f}(\bm{x}_i; \bm{w})) = \nabla J(\bm{w}) $$
つまり、SGDの勾配は真の勾配の不偏推定量です。個々の推定はノイジーですが、期待値は正しい方向を向いています。
メリット: 1ステップの計算が $O(1)$ と非常に高速。大規模データに適する。ノイズにより局所最適解からの脱出も期待できる
デメリット: 勾配の分散が大きく、収束が不安定。最小点付近で振動する
ミニバッチSGD
バッチGDとSGDの中間で、$B$ 個のデータ点のサブセット(ミニバッチ)を使います。
$$ \bm{w}_{t+1} = \bm{w}_t – \eta \cdot \frac{1}{B}\sum_{i \in \mathcal{B}_t} \nabla L(y_i, \hat{f}(\bm{x}_i; \bm{w}_t)) $$
ミニバッチサイズ $B$ は典型的に32, 64, 128, 256などを使います。
メリット: – 勾配の分散がSGDの $1/B$ に低減される – GPUの並列計算を活用できる(行列演算として一括処理) – バッチGDよりも高速(全データを走査せずに更新できる)
デメリット: バッチサイズの選択が新たなハイパーパラメータになる
実務ではミニバッチSGDが標準的に使われます。以下のコードでこれら3つの方法の振る舞いを比較してみましょう。
import numpy as np
import matplotlib.pyplot as plt
np.random.seed(42)
# データ生成(線形回帰)
n = 200
d = 2
X = np.random.randn(n, d)
w_true = np.array([3.0, -2.0])
y = X @ w_true + np.random.randn(n) * 0.5
# 損失関数と勾配
def loss(w, X, y):
return 0.5 * np.mean((X @ w - y) ** 2)
def gradient_full(w, X, y):
return X.T @ (X @ w - y) / len(y)
def gradient_sgd(w, X, y, idx):
xi = X[idx:idx+1]
yi = y[idx:idx+1]
return xi.T @ (xi @ w - yi)
def gradient_minibatch(w, X, y, batch_indices):
X_b = X[batch_indices]
y_b = y[batch_indices]
return X_b.T @ (X_b @ w - y_b) / len(batch_indices)
# 各手法の実行
w0 = np.array([0.0, 0.0])
n_epochs = 50
# Batch GD
w = w0.copy()
losses_batch = []
for epoch in range(n_epochs):
losses_batch.append(loss(w, X, y))
grad = gradient_full(w, X, y)
w = w - 0.01 * grad
# SGD
w = w0.copy()
losses_sgd = []
w_trajectory_sgd = [w.copy()]
step = 0
for epoch in range(n_epochs):
losses_sgd.append(loss(w, X, y))
indices = np.random.permutation(n)
for idx in indices:
grad = gradient_sgd(w, X, y, idx)
w = w - 0.001 * grad
step += 1
if step % n == 0:
w_trajectory_sgd.append(w.copy())
# ミニバッチSGD
w = w0.copy()
losses_mb = []
batch_size = 32
for epoch in range(n_epochs):
losses_mb.append(loss(w, X, y))
indices = np.random.permutation(n)
for start in range(0, n, batch_size):
batch_idx = indices[start:start+batch_size]
grad = gradient_minibatch(w, X, y, batch_idx)
w = w - 0.01 * grad
fig, axes = plt.subplots(1, 2, figsize=(14, 5.5))
# (a) 損失の比較
ax = axes[0]
ax.plot(losses_batch, "b-", linewidth=2, label="Batch GD")
ax.plot(losses_sgd, "r-", linewidth=2, alpha=0.7, label="SGD")
ax.plot(losses_mb, "g-", linewidth=2, label=f"Mini-batch (B={batch_size})")
ax.set_xlabel("Epoch", fontsize=12)
ax.set_ylabel("Loss", fontsize=12)
ax.set_title("Convergence Comparison", fontsize=13)
ax.legend(fontsize=11)
ax.grid(True, alpha=0.3)
ax.set_yscale("log")
# (b) 更新ごとの勾配のノイズ
ax = axes[1]
# 1エポック内の各更新での勾配方向のばらつきを可視化
w_check = np.array([1.0, 1.0])
true_grad = gradient_full(w_check, X, y)
# SGDの勾配をサンプリング
sgd_grads = []
for _ in range(100):
idx = np.random.randint(0, n)
g = gradient_sgd(w_check, X, y, idx)
sgd_grads.append(g.flatten())
# ミニバッチの勾配をサンプリング
mb_grads = []
for _ in range(100):
idx = np.random.choice(n, batch_size, replace=False)
g = gradient_minibatch(w_check, X, y, idx)
mb_grads.append(g.flatten())
sgd_grads = np.array(sgd_grads)
mb_grads = np.array(mb_grads)
ax.scatter(sgd_grads[:, 0], sgd_grads[:, 1], color="red", alpha=0.3, s=20, label="SGD gradients")
ax.scatter(mb_grads[:, 0], mb_grads[:, 1], color="green", alpha=0.5, s=30, label=f"Mini-batch (B={batch_size})")
ax.scatter(true_grad[0], true_grad[1], color="blue", s=200, marker="*",
zorder=5, label="True gradient")
ax.set_xlabel("$\\partial J / \\partial w_1$", fontsize=12)
ax.set_ylabel("$\\partial J / \\partial w_2$", fontsize=12)
ax.set_title("Gradient Noise Comparison", fontsize=13)
ax.legend(fontsize=10)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("batch_sgd_comparison.png", dpi=150, bbox_inches="tight")
plt.show()
このグラフから、3つの勾配降下法の振る舞いの違いが明確に読み取れます。
-
左図(収束の比較): バッチGD(青)は滑らかに単調に収束しています。SGD(赤)はノイジーな振る舞いで振動しながらも最終的には良い解に到達しています。ミニバッチSGD(緑)は両者の中間で、適度なノイズを持ちながら効率的に収束しています
-
右図(勾配のノイズ): SGD(赤点)の勾配は真の勾配(青星)を中心に大きく散らばっています。ミニバッチ(緑点)の勾配はSGDよりもずっと集中しており、真の勾配に近い推定が得られています。これはミニバッチが $B$ 個のサンプルの平均をとることで分散が $1/B$ に低減されるためです
この結果は、ミニバッチSGDが「SGDの計算効率」と「バッチGDの安定性」を両立する妥協点であることを示しています。次に、学習率スケジュールによる収束の改善を見ていきましょう。
学習率スケジュール
なぜ学習率を変化させるのか
固定学習率のSGDには根本的な問題があります。収束するためには学習率を十分小さくする必要がありますが、小さい学習率では初期の収束が遅くなります。
この問題の解決策が学習率スケジュール(learning rate schedule)です。訓練の進行に応じて学習率を変化させることで、初期は大きな学習率で素早く収束し、後半は小さな学習率で精密に最適化します。
代表的なスケジュール
ステップ減衰(Step decay): 一定エポック数ごとに学習率を定数倍する
$$ \eta_t = \eta_0 \cdot \gamma^{\lfloor t / s \rfloor} $$
$\gamma = 0.1$, $s = 30$(30エポックごとに10分の1)が典型的です。
逆数減衰(Inverse decay): イテレーション数に反比例して減衰する
$$ \eta_t = \frac{\eta_0}{1 + \alpha t} $$
コサイン減衰(Cosine annealing): コサイン関数で滑らかに減衰する
$$ \eta_t = \frac{\eta_0}{2}\left(1 + \cos\frac{\pi t}{T}\right) $$
ウォームリスタート(warm restart)と組み合わせて使われることもあります。
ウォームアップ + 減衰: 最初の数エポックは学習率を0から線形に上げ(ウォームアップ)、その後に減衰させる方法です。TransformerやBERTで広く使われています。
import numpy as np
import matplotlib.pyplot as plt
n_epochs = 100
eta_0 = 0.1
# 各スケジュール
epochs = np.arange(n_epochs)
# 固定
fixed = np.full(n_epochs, eta_0)
# ステップ減衰
step_decay = eta_0 * (0.1 ** (epochs // 30))
# 逆数減衰
inverse = eta_0 / (1 + 0.05 * epochs)
# コサイン減衰
cosine = 0.5 * eta_0 * (1 + np.cos(np.pi * epochs / n_epochs))
# ウォームアップ + コサイン減衰
warmup_epochs = 10
warmup_cosine = np.where(
epochs < warmup_epochs,
eta_0 * epochs / warmup_epochs,
0.5 * eta_0 * (1 + np.cos(np.pi * (epochs - warmup_epochs) / (n_epochs - warmup_epochs)))
)
fig, axes = plt.subplots(1, 2, figsize=(14, 5.5))
# (a) スケジュールの比較
ax = axes[0]
ax.plot(epochs, fixed, "k-", linewidth=2, label="Fixed")
ax.plot(epochs, step_decay, "b-", linewidth=2, label="Step decay")
ax.plot(epochs, inverse, "r-", linewidth=2, label="Inverse decay")
ax.plot(epochs, cosine, "g-", linewidth=2, label="Cosine annealing")
ax.plot(epochs, warmup_cosine, "m-", linewidth=2, label="Warmup + Cosine")
ax.set_xlabel("Epoch", fontsize=12)
ax.set_ylabel("Learning rate $\\eta$", fontsize=12)
ax.set_title("Learning Rate Schedules", fontsize=13)
ax.legend(fontsize=10)
ax.grid(True, alpha=0.3)
# (b) 2次元Rosenbrock関数での比較
ax = axes[1]
def rosenbrock(w):
return (1 - w[0])**2 + 100 * (w[1] - w[0]**2)**2
def rosenbrock_grad(w):
g0 = -2 * (1 - w[0]) - 400 * w[0] * (w[1] - w[0]**2)
g1 = 200 * (w[1] - w[0]**2)
return np.array([g0, g1])
w0 = np.array([-1.5, 1.5])
n_steps_opt = 5000
schedules = {
"Fixed (0.001)": lambda t: 0.001,
"Cosine": lambda t: 0.5 * 0.002 * (1 + np.cos(np.pi * t / n_steps_opt)),
"Inverse": lambda t: 0.002 / (1 + 0.001 * t),
}
for name, schedule_fn in schedules.items():
w = w0.copy()
losses = []
for t in range(n_steps_opt):
losses.append(rosenbrock(w))
lr = schedule_fn(t)
w = w - lr * rosenbrock_grad(w)
ax.plot(losses[::50], linewidth=2, label=name)
ax.set_xlabel("Iteration ($\\times 50$)", fontsize=12)
ax.set_ylabel("$f(w)$ (Rosenbrock)", fontsize=12)
ax.set_title("Convergence with Different Schedules", fontsize=13)
ax.legend(fontsize=10)
ax.grid(True, alpha=0.3)
ax.set_yscale("log")
plt.tight_layout()
plt.savefig("lr_schedules.png", dpi=150, bbox_inches="tight")
plt.show()
このグラフから、学習率スケジュールの効果が読み取れます。
-
左図(スケジュールの形状): ステップ減衰(青)は30エポック間隔で急激に減少し、逆数減衰(赤)は緩やかに減少、コサイン減衰(緑)は滑らかなカーブを描きます。ウォームアップ+コサイン(紫)は最初の10エポックで線形に増加した後、コサイン減衰に移行しています
-
右図(Rosenbrock関数での収束): 固定学習率(控えめな値)は安定して収束しますが、やや遅いです。コサイン減衰と逆数減衰は初期に大きな学習率で素早く損失を下げ、後半は小さな学習率で精密に最適化しており、最終的な損失がより低くなっています
凸最適化と非凸最適化
凸関数での収束保証
損失関数 $J(\bm{w})$ が凸(任意の2点を結ぶ線分が関数のグラフの上にある)のとき、勾配降下法は大域最適解に収束することが保証されます。
凸かつリプシッツ連続な勾配($\|\nabla J(\bm{w}) – \nabla J(\bm{v})\| \leq L\|\bm{w} – \bm{v}\|$)を持つ関数に対して、$\eta \leq 1/L$ とすると
$$ J(\bm{w}_t) – J(\bm{w}^*) \leq \frac{\|\bm{w}_0 – \bm{w}^*\|^2}{2\eta t} = O(1/t) $$
さらに強凸($J(\bm{w}) – \frac{\mu}{2}\|\bm{w}\|^2$ が凸)ならば
$$ J(\bm{w}_t) – J(\bm{w}^*) \leq \left(1 – \frac{\mu}{L}\right)^t (J(\bm{w}_0) – J(\bm{w}^*)) = O\left(\left(\frac{\kappa-1}{\kappa}\right)^t\right) $$
と指数的に収束します。$\kappa = L/\mu$ は条件数です。
非凸最適化の課題
ニューラルネットワークの損失関数は一般に非凸であり、以下の課題があります。
局所最適解: 勾配降下法は大域最適解ではなく局所最適解に収束する可能性がある
鞍点: 勾配が0だが最小値でも最大値でもない点。高次元では局所最適解よりも鞍点の方が問題になることが多い
プラトー: 勾配がほぼ0の広い平坦な領域。学習が停滞する
ただし、近年の研究では、大規模ニューラルネットワークの損失関数においては「ほとんどの局所最適解は大域最適解に近い性能を持つ」ことが示唆されています。実用的には、SGDのノイズが鞍点や悪い局所最適解からの脱出を助けることも知られています。
import numpy as np
import matplotlib.pyplot as plt
# 非凸関数での勾配降下法の振る舞い
def non_convex(x):
return x**4 - 3*x**3 + 2*x + np.sin(5*x) * 0.5
def non_convex_grad(x):
return 4*x**3 - 9*x**2 + 2 + np.cos(5*x) * 2.5
fig, axes = plt.subplots(1, 2, figsize=(14, 5.5))
x_plot = np.linspace(-1.5, 4, 500)
# (a) 初期値による収束先の違い
ax = axes[0]
ax.plot(x_plot, non_convex(x_plot), "k-", linewidth=2, label="$J(x)$")
initial_points = [-1.0, 0.5, 1.5, 3.0]
colors = ["red", "blue", "green", "purple"]
for x0, color in zip(initial_points, colors):
x = x0
trajectory = [x]
lr = 0.01
for _ in range(200):
x = x - lr * non_convex_grad(x)
trajectory.append(x)
trajectory = np.array(trajectory)
ax.plot(trajectory, non_convex(trajectory), "o-", color=color, markersize=2,
linewidth=0.5, alpha=0.7)
ax.plot(x0, non_convex(x0), "s", color=color, markersize=10,
label=f"$x_0={x0}$ → $x^*={trajectory[-1]:.2f}$")
ax.plot(trajectory[-1], non_convex(trajectory[-1]), "*", color=color, markersize=15)
ax.set_xlabel("$x$", fontsize=12)
ax.set_ylabel("$J(x)$", fontsize=12)
ax.set_title("Non-convex Optimization: Initial Point Sensitivity", fontsize=13)
ax.legend(fontsize=9)
ax.grid(True, alpha=0.3)
# (b) SGDノイズによる局所最適解からの脱出
ax = axes[1]
n_runs = 50
n_steps = 500
lr = 0.01
# ノイズなし(GD)
x_gd = 1.5
traj_gd = [x_gd]
for _ in range(n_steps):
x_gd = x_gd - lr * non_convex_grad(x_gd)
traj_gd.append(x_gd)
# ノイズあり(SGD風)
final_sgd = []
for noise_scale in [0, 0.5, 1.0, 2.0]:
finals = []
for _ in range(n_runs):
x = 1.5
for t in range(n_steps):
noise = np.random.randn() * noise_scale
x = x - lr * (non_convex_grad(x) + noise)
finals.append(x)
final_sgd.append((noise_scale, finals))
bp_data = [f for _, f in final_sgd]
bp_labels = [f"$\\sigma={s}$" for s, _ in final_sgd]
bp = ax.boxplot(bp_data, labels=bp_labels, patch_artist=True)
colors_bp = ["lightblue", "lightgreen", "lightyellow", "lightsalmon"]
for patch, color in zip(bp["boxes"], colors_bp):
patch.set_facecolor(color)
ax.axhline(non_convex(np.array([-0.2])), color="red", linestyle="--", linewidth=1.5,
alpha=0.5)
ax.set_ylabel("Final $x$", fontsize=12)
ax.set_title("Effect of Gradient Noise on Final Solution", fontsize=13)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("non_convex_optimization.png", dpi=150, bbox_inches="tight")
plt.show()
このグラフから、非凸最適化における勾配降下法の課題が読み取れます。
-
左図(初期値依存性): 同じ非凸関数でも、初期値によって異なる局所最適解に収束しています。$x_0 = -1.0$(赤)と $x_0 = 3.0$(紫)は大域最適解付近に到達していますが、$x_0 = 0.5$(青)と $x_0 = 1.5$(緑)は局所最適解にトラップされています
-
右図(勾配ノイズの効果): $\sigma = 0$(ノイズなし、純粋なGD)では初期値の局所最適解に確実にトラップされますが、$\sigma$ を増やすとノイズによって局所最適解から脱出する可能性が生まれます。ただし、ノイズが大きすぎると解がばらつき、精度が低下します。適度なノイズが最適であり、これがSGDの「暗黙的な正則化」効果の一端です
勾配降下法のスクラッチ実装
最後に、勾配降下法をスクラッチで実装し、線形回帰問題に適用してみましょう。
import numpy as np
import matplotlib.pyplot as plt
np.random.seed(42)
class LinearRegressionGD:
"""勾配降下法による線形回帰"""
def __init__(self, lr=0.01, n_epochs=100, batch_size=None, method="batch"):
self.lr = lr
self.n_epochs = n_epochs
self.batch_size = batch_size
self.method = method # "batch", "sgd", "minibatch"
self.loss_history = []
def fit(self, X, y):
n, d = X.shape
self.w = np.zeros(d)
self.b = 0.0
for epoch in range(self.n_epochs):
if self.method == "batch":
# バッチGD: 全データで勾配計算
y_pred = X @ self.w + self.b
error = y_pred - y
grad_w = X.T @ error / n
grad_b = np.mean(error)
self.w -= self.lr * grad_w
self.b -= self.lr * grad_b
elif self.method == "sgd":
# SGD: 1サンプルずつ
indices = np.random.permutation(n)
for idx in indices:
xi = X[idx]
yi = y[idx]
y_pred = xi @ self.w + self.b
error = y_pred - yi
self.w -= self.lr * error * xi
self.b -= self.lr * error
elif self.method == "minibatch":
# ミニバッチSGD
indices = np.random.permutation(n)
for start in range(0, n, self.batch_size):
batch_idx = indices[start:start+self.batch_size]
X_b = X[batch_idx]
y_b = y[batch_idx]
y_pred = X_b @ self.w + self.b
error = y_pred - y_b
grad_w = X_b.T @ error / len(batch_idx)
grad_b = np.mean(error)
self.w -= self.lr * grad_w
self.b -= self.lr * grad_b
# 損失の記録
y_pred_all = X @ self.w + self.b
loss = 0.5 * np.mean((y_pred_all - y) ** 2)
self.loss_history.append(loss)
return self
def predict(self, X):
return X @ self.w + self.b
# データ生成
n = 500
X = np.random.randn(n, 3)
w_true = np.array([2.5, -1.3, 0.8])
b_true = 1.5
y = X @ w_true + b_true + np.random.randn(n) * 0.5
# 各手法の実行
models = [
("Batch GD", LinearRegressionGD(lr=0.1, n_epochs=100, method="batch")),
("SGD", LinearRegressionGD(lr=0.005, n_epochs=100, method="sgd")),
("Mini-batch (B=32)", LinearRegressionGD(lr=0.05, n_epochs=100, batch_size=32, method="minibatch")),
]
fig, ax = plt.subplots(figsize=(10, 6))
for name, model in models:
model.fit(X, y)
ax.plot(model.loss_history, linewidth=2, label=f"{name} (final={model.loss_history[-1]:.4f})")
print(f"{name}: w={model.w}, b={model.b:.4f}")
print(f"True: w={w_true}, b={b_true}")
ax.set_xlabel("Epoch", fontsize=12)
ax.set_ylabel("Loss (MSE/2)", fontsize=12)
ax.set_title("Gradient Descent Implementation Comparison", fontsize=14)
ax.legend(fontsize=11)
ax.grid(True, alpha=0.3)
ax.set_yscale("log")
plt.tight_layout()
plt.savefig("gd_implementation.png", dpi=150, bbox_inches="tight")
plt.show()
この実装の結果から、3つの手法が全て真のパラメータに近い推定値を返すことが確認できます。バッチGDは滑らかに収束し、SGDはノイジーですが最終的には同等の解に到達します。ミニバッチSGDは両者の間の振る舞いを示しています。学習率はそれぞれの手法に合わせて調整する必要がある点に注意してください。
まとめ
本記事では、勾配降下法の基礎理論と実装について解説しました。
- 勾配降下法は損失関数の勾配の逆方向にパラメータを更新する最適化手法であり、テイラー展開の1次近似から最急降下方向が導かれる
- 学習率 $\eta$ は収束速度と安定性のトレードオフを制御する最重要パラメータであり、凸二次関数では $0 < \eta < 2/\lambda_{\max}$ が収束条件
- バッチGDは安定だが計算コストが高く、SGDは高速だがノイジーであり、ミニバッチSGDが実用的な妥協点
- SGDの勾配は真の勾配の不偏推定量であり、ノイズは局所最適解からの脱出にも寄与する
- 学習率スケジュール(ステップ減衰、コサイン減衰、ウォームアップ)で訓練段階に応じた最適化が可能
- 凸関数では $O(1/t)$(凸)または指数的(強凸)な収束が保証されるが、非凸関数では初期値依存性がある
次のステップとして、以下の記事も参考にしてください。
- 最適化アルゴリズム比較 — SGD・Momentum・Adam・AdamW — 勾配降下法の発展手法
- バイアス-バリアンストレードオフ — 汎化性能の理論
- 正則化の理論 — 過学習を防ぐ手法