S099 · Optimizers — SGD, Momentum, Adam, RMSprop
The four optimisers you'll ever use in practice — plain SGD, SGD-with-momentum, RMSprop, and Adam. Learn the update equations, when each one shines, and why Adam is the default that quietly wins 90% of the time. Includes a numpy implementation of all four on the same loss surface so you can see them race.
🎯 Implement SGD, momentum, RMSprop, and Adam on the same loss and reason about learning-rate schedules — so you can pick and tune an optimiser without cargo-culting.
Why this session exists
The gradient tells you which way is downhill. The optimiser decides how far and how confidently to step. It's the difference between a model that trains to state-of-the-art in 3 hours and one that plateaus at 90% of that in 30. Adam is the answer 90% of the time — but the other 10% (and every "why is my loss oscillating?" bug) requires knowing what's inside. This session opens the box.
- Write the update rule for SGD, momentum, RMSprop, and Adam from memory.
- Explain the two problems momentum fixes and the two problems RMSprop fixes.
- Understand why Adam is the default and what its two hyperparameters (β1, β2) do.
- Diagnose ‘loss oscillating’, ‘loss stuck’, and ‘loss NaN’ from optimiser first principles.
- Choose a learning rate schedule (constant, step, cosine, warmup) for a given problem.
Prerequisites
- S097 · MLP forward pass — you have a model to optimise.
- S098 · Backpropagation — you have gradients to step with.
- Basic notion of moving averages helps intuition (session S045 has this).
(a) Intuition · 5 min
You drop a ball on a hillside with hills, valleys, and ridges. The ball's next move depends on: the local slope (gradient), how fast it's already moving (momentum), and whether it's on smooth terrain or a rocky bit (adaptive step size).
Plain SGD ignores momentum entirely — it takes a step, stops, checks the slope, takes another step. In a long narrow valley, it zig-zags across the walls forever.
SGD with momentum keeps track of the ball's velocity: a fast-moving ball rolls smoothly through the zig-zags.
Adam adds one more trick: adapt the step size per parameter based on how bumpy that parameter's landscape has been recently.
Formally: at each step we have the gradient g. SGD updates w ← w − lr·g. That's it. Momentum keeps a moving average v of past gradients and steps in the direction of v instead: v ← β·v + (1−β)·g; w ← w − lr·v. RMSprop divides the step by a moving average of squared gradients — parameters with large recent gradients get smaller steps. Adam combines both: momentum + RMSprop + bias correction.
The three problems optimisers solve
- Slow ravines — long thin valleys where SGD zig-zags. Momentum fixes: velocity smooths across oscillations.
- Different parameter scales — some weights need big steps, some need tiny. RMSprop / Adam adapt lr per parameter.
- Noisy mini-batches — small batches make gradients noisy. Momentum's moving average averages out the noise.
- Sparse gradients (embeddings, NLP) — some parameters only appear rarely. Adam's per-parameter learning rate keeps them alive.
- Warmup / decay — the right learning rate at start is not the right learning rate at the end. Schedules handle this.
The evolution
- 1847Cauchy · gradient descentFirst formalisation. Still exactly the same math today.
- 1964Polyak · heavy-ball momentumFirst momentum-based optimiser. Predates neural networks by decades.
- 1983Nesterov accelerated gradientLookahead momentum — provably faster than heavy-ball for convex problems.
- 2011AdaGrad · Duchi et al.Per-parameter learning rates for sparse features. Aggressive decay.
- 2012RMSprop · Hinton (Coursera lecture)Moving-average variant of AdaGrad that doesn't decay to zero.
- 2014Adam · Kingma & BaRMSprop + momentum + bias correction. Becomes the default within 18 months.
(b) Visual walkthrough · 15 min
The update rules, side by side
The four optimisers on the same loss surface
Zig-zags in narrow valleys. Slow in flat regions. Fastest per step but many more steps needed.
Smooths oscillations. Rolls through narrow valleys. Standard for image classification (ResNet, EfficientNet all use SGD-M).
Adapts step per parameter. Great for RNNs and non-stationary problems. Nearly obsolete today — Adam does the same and more.
The safe default. Momentum + adaptive step + bias correction. Almost always converges reasonably.
Adam with decoupled weight decay. Fixes a subtle bug in Adam's L2 regularisation. The transformer world's default since 2019.
Choosing an optimiser
The default for everything
- Best out-of-the-box performance
- Robust to learning-rate mis-tuning
- Standard for transformers, NLP, LLMs
- AdamW is the transformer standard (fixed weight decay)
Best for CNN vision (ResNet, EfficientNet)
- Often reaches lower final loss than Adam on vision tasks
- Requires careful LR schedule (step decay or cosine)
- Standard for ImageNet-scale training
- The recipe: lr=0.1, momentum=0.9, decay lr by 10× at plateau
Historical, mostly deprecated
- Adam does the same thing plus momentum
- Occasionally used in RL for policy gradients
- Skip unless you have a specific reason
Baselines and pedagogy only
- Great for teaching
- Sometimes wins on very small models with lots of tuning
- Not competitive on most real problems
- Use only if you want to eliminate optimiser as a variable
Learning rate schedules
LR schedules — pick one, always
"Adam is adaptive, so it tunes the learning rate for me. That's the whole point — I can leave the learning rate at the default and let the optimiser handle it."
Adam adapts the relative step size per parameter by dividing by a running estimate of gradient magnitude. It does not know the correct global scale, and the base learning rate still multiplies everything. Adam makes the learning rate less catastrophic to get wrong, not unnecessary to set — the difference between 3e-4 and 3e-2 is still the difference between convergence and divergence, and learning-rate schedule remains the single highest-impact hyperparameter in deep learning.
The myth is sticky because Adam's default genuinely works across a remarkable range of problems, so most people never observe a failure and conclude the parameter is inert. The name reinforces it: "adaptive moment estimation" gets shortened to "adaptive", which is heard as "self-tuning". And the per-parameter normalisation does something real and visible — it rescues problems where SGD would need careful tuning — so the partial truth is repeatedly confirmed and never contradicted until you hit a problem where the default scale is wrong.
Hold everything constant and sweep only the base learning rate under Adam:
for lr in [1e-5, 1e-4, 3e-4, 1e-3, 1e-2, 1e-1]:
model = make_model()
opt = torch.optim.Adam(model.parameters(), lr=lr)
losses = train_n_steps(model, opt, n=300)
print(lr, losses[0], losses[-1])
# 1e-5 -> barely moves (underfits within budget)
# 3e-4 -> converges cleanly
# 1e-1 -> diverges or NaN
# 'adaptive' did not save you at either extremeWhy does momentum accelerate convergence, and why specifically in ravines — loss surfaces that are steep in one direction and nearly flat in another? Averaging past gradients sounds like it should merely smooth things, not speed them up.
- 1Consider a quadratic bowl that is far steeper along one axis than another — the condition number of its curvature is large. This is not pathological; it is the normal shape of neural loss surfaces, especially with unscaled inputs.forced by · different parameters influence the loss at wildly different scales, which is exactly what an ill-conditioned Hessian means
- 2Plain gradient descent takes a step proportional to the gradient. The steep direction has a large gradient, so it takes large steps there; the flat direction has a small gradient and takes tiny steps.forced by · the step is the same scalar learning rate times the gradient, with no per-direction adjustment
- 3Now the learning rate is trapped. It must be small enough to avoid overshooting and oscillating across the steep direction, and that same small value makes progress along the flat direction — which is where the minimum actually lies — glacially slow.forced by · one scalar must simultaneously satisfy a stability constraint from the steepest curvature and a speed requirement from the flattest
- 4Observe the structure of the resulting path: in the steep direction the gradient alternates sign as it oscillates back and forth, while in the flat direction it points consistently the same way.forced by · oscillation means overshooting past the valley floor and coming back
- 5So take an exponentially weighted average of past gradients. Alternating-sign components largely cancel; consistent-sign components accumulate and grow toward roughly 1/(1−β) times a single gradient. Momentum damps exactly the oscillation and amplifies exactly the progress.forced by · averaging is destructive interference for sign-flipping terms and constructive for consistent ones
Therefore momentum is not smoothing for its own sake — it is a sign-based filter that exploits the geometric difference between oscillation and consistent descent. With β = 0.9 the effective step along a consistent direction is roughly 10× a single gradient step.
And note what this predicts: any mechanism that equalises the scale across directions should reduce the need for momentum and permit larger learning rates. That is precisely what input normalisation, BatchNorm, and LayerNorm do, and precisely why they let you train at learning rates that would diverge without them. It also predicts Adam's second component — dividing by the RMS of recent gradients directly rescales each direction, attacking the conditioning problem rather than merely coping with it.
SGD is a massless particle that instantly moves in the direction of steepest local descent — so it follows every wiggle and stalls in ravines. Momentum gives it mass: it accumulates velocity, so it coasts through small bumps and builds speed along consistent slopes.
Adam adds a second idea: divide each parameter's step by the RMS of its own recent gradients. Parameters with consistently large gradients get proportionally smaller steps and vice versa, so every axis moves at a comparable rate. Momentum fixes where you are heading; the RMS normalisation fixes how far each axis moves. Adam is simply both at once.
- Adam = momentum (first moment) + per-parameter RMS normalisation (second moment) + bias correction, since both running averages start at zero and would otherwise be biased low early on.
- Learning-rate schedule beats optimiser choice. Warmup then cosine or step decay is worth more than swapping SGD for Adam on most problems.
- Adam stores two extra state tensors per parameter, so optimiser state costs roughly 2× the model size in memory — often the reason a model trains with SGD but OOMs with Adam.
- Adam's normalisation breaks L2 weight decay, because the decay term gets divided by the same RMS. AdamW applies decay directly to the weights instead, which is why it is the default in modern training.
Fire this the moment you see: loss going NaN in early steps · training loss oscillating without descending · a flat loss curve that never moves · Adam used with weight_decay instead of AdamW · a fixed learning rate with no schedule · a transformer trained without warmup · optimiser state doubling your memory footprint unexpectedly.
Which optimiser do you train with: SGD with momentum, Adam/AdamW, or SGD after an Adam warmup?
Use AdamW with a warmup-then-cosine schedule as the default. It is robust, it is what modern architectures were developed against, and it removes an entire class of tuning problems so you can spend your attention on data and features — which almost always move the metric more than the optimiser does.
The genuinely important discipline is not which optimiser but the schedule and the diagnosis. Plot the loss curve from step one: a flat curve means the learning rate is too low or gradients are not flowing, oscillation means it is too high, and an early NaN usually means a bad initialisation, a missing normalisation, or a numerically unstable loss. Those readings tell you far more than any optimiser comparison, and they cost nothing.
(c) Hands-on · 25 min
Implement all four optimisers in numpy and race them on a synthetic anisotropic quadratic (the classic "narrow valley" problem) plus a small MLP on a toy classification task. Save as optimizer_lab.py, uv run optimizer_lab.py.
"""optimizer_lab.py — implement SGD, Momentum, RMSprop, Adam side-by-side."""
from __future__ import annotations
import numpy as np
RNG = np.random.default_rng(0)
# ---------------- optimizers ----------------
class SGD:
def __init__(self, params: dict, lr: float = 0.1) -> None:
self.params, self.lr = params, lr
def step(self, grads: dict) -> None:
for k in self.params:
self.params[k] -= self.lr * grads[k]
class Momentum:
def __init__(self, params: dict, lr: float = 0.1, beta: float = 0.9) -> None:
self.params, self.lr, self.beta = params, lr, beta
self.v = {k: np.zeros_like(v) for k, v in params.items()}
def step(self, grads: dict) -> None:
for k in self.params:
self.v[k] = self.beta * self.v[k] + grads[k]
self.params[k] -= self.lr * self.v[k]
class RMSprop:
def __init__(self, params: dict, lr: float = 0.01, beta: float = 0.9, eps: float = 1e-8) -> None:
self.params, self.lr, self.beta, self.eps = params, lr, beta, eps
self.s = {k: np.zeros_like(v) for k, v in params.items()}
def step(self, grads: dict) -> None:
for k in self.params:
self.s[k] = self.beta * self.s[k] + (1 - self.beta) * grads[k] ** 2
self.params[k] -= self.lr * grads[k] / (np.sqrt(self.s[k]) + self.eps)
class Adam:
def __init__(self, params: dict, lr: float = 1e-3, b1: float = 0.9, b2: float = 0.999,
eps: float = 1e-8) -> None:
self.params, self.lr = params, lr
self.b1, self.b2, self.eps = b1, b2, eps
self.m = {k: np.zeros_like(v) for k, v in params.items()}
self.v = {k: np.zeros_like(v) for k, v in params.items()}
self.t = 0
def step(self, grads: dict) -> None:
self.t += 1
for k in self.params:
self.m[k] = self.b1 * self.m[k] + (1 - self.b1) * grads[k]
self.v[k] = self.b2 * self.v[k] + (1 - self.b2) * grads[k] ** 2
m_hat = self.m[k] / (1 - self.b1 ** self.t) # bias correction
v_hat = self.v[k] / (1 - self.b2 ** self.t)
self.params[k] -= self.lr * m_hat / (np.sqrt(v_hat) + self.eps)
# ---------------- race on narrow ellipsoid ----------------
def ellipsoid_race():
"""Minimise f(x, y) = 0.5·(a·x² + b·y²). Very stretched → SGD zig-zags."""
a, b = 1.0, 20.0
def loss(p): return 0.5 * (a * p["x"] ** 2 + b * p["y"] ** 2)
def grad(p): return {"x": a * p["x"], "y": b * p["y"]}
print("\n=== narrow-valley race (a=1, b=20) — steps to loss < 1e-3 ===")
for name, opt_cls, lr in [
("SGD", SGD, 0.05),
("Momentum", Momentum, 0.05),
("RMSprop", RMSprop, 0.5),
("Adam", Adam, 0.3),
]:
p = {"x": np.array(-5.0), "y": np.array(-1.0)}
opt = opt_cls(p, lr=lr) if opt_cls in (SGD,) else opt_cls(p, lr=lr)
for step in range(1, 2001):
opt.step(grad(p))
if loss(p) < 1e-3:
print(f" {name:<10s}: converged in {step:>4d} steps (final={loss(p):.2e})")
break
else:
print(f" {name:<10s}: did NOT converge in 2000 (final={loss(p):.2e})")
# ---------------- MLP race on classification ----------------
def relu(z): return np.maximum(0, z)
def relu_grad(z): return (z > 0).astype(z.dtype)
def softmax(z):
z = z - z.max(axis=1, keepdims=True)
e = np.exp(z); return e / e.sum(axis=1, keepdims=True)
def init_mlp(sizes):
p = {}
for i in range(len(sizes) - 1):
fin, fout = sizes[i], sizes[i + 1]
p[f"W{i+1}"] = RNG.normal(0, np.sqrt(2.0 / fin), (fin, fout))
p[f"b{i+1}"] = np.zeros(fout)
return p
def forward_backward(p, X, y_oh):
z1 = X @ p["W1"] + p["b1"]; a1 = relu(z1)
z2 = a1 @ p["W2"] + p["b2"]; probs = softmax(z2)
n = X.shape[0]
loss = -float(np.mean(np.sum(y_oh * np.log(probs + 1e-12), axis=1)))
dz2 = (probs - y_oh) / n
grads = {
"W2": a1.T @ dz2, "b2": dz2.sum(0),
}
da1 = dz2 @ p["W2"].T
dz1 = da1 * relu_grad(z1)
grads["W1"] = X.T @ dz1
grads["b1"] = dz1.sum(0)
return loss, grads, probs
def mlp_race():
from sklearn.datasets import make_classification
X, y = make_classification(n_samples=1500, n_features=10, n_informative=6,
n_classes=3, random_state=0)
y_oh = np.zeros((y.shape[0], 3), dtype=np.float32); y_oh[np.arange(y.shape[0]), y] = 1.0
print("\n=== MLP race — final loss after 50 epochs, batch=64 ===")
for name, opt_cls, lr in [
("SGD", SGD, 0.1),
("Momentum", Momentum, 0.1),
("RMSprop", RMSprop, 0.005),
("Adam", Adam, 0.003),
]:
p = init_mlp([10, 32, 3])
opt = opt_cls(p, lr=lr)
for _ in range(50):
idx = RNG.permutation(len(y))
for start in range(0, len(y), 64):
b = idx[start:start + 64]
_, grads, _ = forward_backward(p, X[b], y_oh[b])
opt.step(grads)
loss, _, probs = forward_backward(p, X, y_oh)
acc = float((probs.argmax(1) == y).mean())
print(f" {name:<10s}: loss={loss:.4f} acc={acc:.4f} (lr={lr})")
if __name__ == "__main__":
ellipsoid_race()
mlp_race()Anatomy of the script
Anatomy of the script
Modify Adam to skip bias correction (comment out the m_hat, v_hat lines and use m, v directly). Re-run the ellipsoid race. You'll see convergence takes 2-3× longer because the first ~200 steps are effectively at 10× smaller lr. This is why Kingma & Ba added bias correction — it makes Adam trainable from step 1 without a warmup.
(d) Production reality · 15 min
New DL practitioner copies an ImageNet training recipe (SGD + momentum, lr=0.1) for their NLP task. Loss doesn't move. They increase lr — loss NaN. They panic and switch to Adam with the same lr=0.1. Loss still NaN.
Standard transformer recipe includes LR warmup: for the first N steps (~4000 for original transformer, ~2000-10000 today), linearly ramp lr from 0 to lr_max. This lets Adam's running averages stabilise before big steps.
Then decay via inverse-square-root, cosine, or step schedule. Every major LLM does this.
SGD + momentum + cosine LR schedule is the standard recipe for ImageNet-scale vision training. Adam is often 1-2 points worse. NLP and RL usually go the other way — Adam wins.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
If you can't teach these three to a friend without notes, redo the session:
- What is momentum and what does it fix?
- What is Adam doing that plain SGD is not?
- When would you not use Adam, and what would you use instead?
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.