Demo: Gradient Descent, SGD, and Adam on One Stretched Bowl
The Optimization lecture left us with a bowl that curves \(\kappa = 15\) times harder in one direction than in another, and a single learning rate that cannot serve both.
Here we rebuild that bowl out of data. Each of \(n = 300\) noisy targets \(\mathbf{t}^{(i)} \in \mathbb{R}^2\) contributes its own quadratic loss, and the full loss is their average, so a batch of \(B\) points sees a bowl centered slightly off from the real one. Three ways to walk downhill: full-batch gradient descent, stochastic gradient descent on batches of \(5\), and Adam on the same batches of \(5\).
import numpy as npimport matplotlib.pyplot as pltimport scienceplotsplt.style.use(["science", "no-latex"])TEAL, CARDINAL, GRAY ="#009090", "#9c1b33", "#c9c9c9"rng = np.random.default_rng(12)# a bowl 15 times steeper in one direction than the other, rotated off the axeskappa =15.0theta = np.pi /6R = np.array([[np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)]])A = R @ np.diag([1.0, kappa]) @ R.T # curvatures 1 and 15n, sigma_t =300, 0.5targets = rng.normal(0, sigma_t, size=(n, 2)) # the 300 noisy targets t^(i)# loss_i(w) = (1/2)(w - t^(i))^T A (w - t^(i)), so the gradient over a set of# points is A times (w minus that set's average target)def full_grad(w):return A @ (w - targets.mean(0))def batch_grad(w, B): idx = rng.choice(n, size=B, replace=False)return A @ (w - targets[idx].mean(0))true_min = targets.mean(0)print(f"true minimum at {true_min.round(3)}")
true minimum at [0.005 0.016]
Run all three from the same starting point \(\mathbf{w}^{(0)} = (3.5, 3.0)\), for the same 60 steps.
Adam keeps two running averages, exactly as in the reading: the velocity v (a smoothed gradient) and s (a smoothed squared gradient), each divided by the weight used so far to correct the bias toward zero.
w0 = np.array([3.5, 3.0])alpha =1.9/ kappan_steps =60# full-batch gradient descentw = w0.copy(); gd_path = [w.copy()]for _ inrange(n_steps): w = w - alpha * full_grad(w) gd_path.append(w.copy())gd_path = np.array(gd_path)# stochastic gradient descent, batch size 5w = w0.copy(); sgd_path = [w.copy()]for _ inrange(n_steps): w = w - alpha * batch_grad(w, 5) sgd_path.append(w.copy())sgd_path = np.array(sgd_path)# Adam, same tiny batchesw = w0.copy(); v, s = np.zeros(2), np.zeros(2)beta1, beta2, eps, alpha_adam =0.9, 0.999, 1e-8, 0.3adam_path = [w.copy()]for t inrange(1, n_steps +1): g = batch_grad(w, 5) v = beta1 * v + (1- beta1) * g s = beta2 * s + (1- beta2) * g **2 w = w - alpha_adam * (v / (1- beta1 ** t)) / (np.sqrt(s / (1- beta2 ** t)) + eps) adam_path.append(w.copy())adam_path = np.array(adam_path)grid = np.linspace(-4, 4, 200)G1, G2 = np.meshgrid(grid, grid)loss = A[0, 0] * G1 **2+2* A[0, 1] * G1 * G2 + A[1, 1] * G2 **2fig, ax = plt.subplots(figsize=(6, 6))ax.contour(G1, G2, loss, levels=15, colors=[GRAY], linewidths=0.8)ax.plot(*gd_path.T, color=TEAL, linewidth=1.3, marker="o", markersize=2.5, label="Gradient descent (full batch)")ax.plot(*sgd_path.T, color=CARDINAL, linewidth=1.0, marker="o", markersize=2.5, alpha=0.85, label="SGD (batch $B=5$)")ax.plot(*adam_path.T, color="black", linewidth=1.3, marker="o", markersize=2.5, label="Adam (batch $B=5$)")# a white disk clears the converging paths so the minimum's cross stays readableax.scatter([0], [0], color="white", marker="o", s=320, edgecolors="none", zorder=6)ax.scatter([0], [0], color="black", marker="x", s=150, linewidths=2.2, zorder=7)ax.set_xlim(-4, 4); ax.set_ylim(-4, 4); ax.set_aspect("equal")ax.set_xlabel("$w_1$"); ax.set_ylabel("$w_2$")ax.legend(frameon=False, loc="lower left")plt.show()
The paths show the shape of each method: gradient descent zig-zags across the valley, SGD zig-zags harder because every step aims at a batch’s bowl, Adam does not zig-zag at all.
Distance to the true minimum, step by step, says who actually arrived.
Gradient descent distance after 60 steps: 0.0020
SGD distance after 60 steps: 0.5899
Adam distance after 60 steps: 0.1770
What if the batches were bigger?
Only the full-batch run keeps descending; the two stochastic runs flatten out at a noise floor. Near the minimum each stochastic step is a random move of length roughly \(\alpha\sigma/\sqrt{B}\), so the floor shrinks as \(\sigma/\sqrt{B}\) does. Change batch_size and rerun. (A single 60-step run is itself a noisy measurement of a noise floor, so expect the drop to be rougher than \(1/\sqrt{B}\) exactly.)
Punchline: conditioning and batch noise are two different problems with two different fixes. Momentum and Adam’s per-parameter scaling attack the conditioning, which is why Adam’s path is smooth; only a larger batch or a smaller learning rate lowers the noise floor, which is why Adam still stops short of the minimum.