Optimisation and gradient descent variants

From batch descent to Adam, what momentum and adaptive scaling really change, how schedules help, and how to diagnose a diverging run.

Batch, stochastic and mini-batch

Full-batch gradient descent takes an exact step per epoch; stochastic descent takes one per example and is far noisier. Mini-batch sits between: enough examples for a useful GPU kernel and a gradient that points roughly downhill, plus noise that helps escape saddles.

import numpy as np

rng = np.random.default_rng(0)
X = rng.normal(size=(1000, 5))
true_w = rng.normal(size=(5,))
y = X @ true_w + 0.1 * rng.normal(size=1000)

def grad(w, Xb, yb):
    r = Xb @ w - yb
    return 2 * Xb.T @ r / len(yb)

w = np.zeros(5)
lr = 0.05
batch = 32

for epoch in range(50):
    order = rng.permutation(len(X))          # shuffle every epoch
    for i in range(0, len(X), batch):
        idx = order[i:i + batch]
        w -= lr * grad(w, X[idx], y[idx])

print(np.round(w, 3), np.round(true_w, 3))
  • Gradient noise falls as 1 / sqrt(batch_size), so quadrupling the batch halves the noise — and you can roughly double the learning rate in exchange.
  • Larger batches give diminishing returns past a point; the wall-clock gain is limited by how many steps you can afford, not by how clean each step is.
  • Always shuffle. Contiguous sorted batches (all class A, then all class B) produce large gradient swings that look like a learning-rate problem but are actually a data-ordering problem.
  • The loss you track during training is a running estimate. Compare a smoothed curve, or evaluate on a fixed held-out batch, before concluding that training has stalled.

Momentum, RMSProp and Adam

# momentum: accumulate velocity, damped
def sgd_momentum(w, grads, lr=0.1, beta=0.9, state=None):
    state = state or {"v": np.zeros_like(w)}
    state["v"] = beta * state["v"] + grads
    return w - lr * state["v"], state

# Adam: keep a decaying average of the gradient and of its square
def adam(w, grads, lr=1e-3, b1=0.9, b2=0.999, eps=1e-8, t=1, state=None):
    state = state or {"m": np.zeros_like(w), "v": np.zeros_like(w)}
    state["m"] = b1 * state["m"] + (1 - b1) * grads
    state["v"] = b2 * state["v"] + (1 - b2) * grads ** 2
    m_hat = state["m"] / (1 - b1 ** t)          # bias correction
    v_hat = state["v"] / (1 - b2 ** t)
    return w - lr * m_hat / (np.sqrt(v_hat) + eps), state
OptimiserKeepsStrengthWatch out for
SGDnothingSimple, often best final generalisationSlow on ill-conditioned problems
SGD + momentumvelocityFast through ravines and plateausMomentum overshoot if the rate is too high
RMSPropsquared gradient averagePer-parameter scaling, works without tuningSensitive to the decay and epsilon
Adamfirst and second momentsReliable default on sparse and noisy gradientsCan generalise slightly worse; decays badly without weight decay
AdamWmoments plus decoupled decayCorrect weight decay, the modern defaultTwo hyperparameters instead of one to set
⚠️
Weight decay in plain Adam is added to the gradient, so it is scaled by the same adaptive denominator and behaves inconsistently across parameters. AdamW decouples it. If you intend real L2 regularisation, use AdamW, not Adam with a weight_decay flag.

Schedules and diagnosing divergence

# linear warmup then cosine decay — the common transformer schedule
def lr_at(step, total, base_lr=1e-3, warmup=100):
    if step < warmup:
        return base_lr * step / max(1, warmup)
    progress = (step - warmup) / max(1, total - warmup)
    return 0.5 * base_lr * (1 + np.cos(np.pi * progress))

import matplotlib.pyplot as plt
steps = np.arange(2000)
plt.plot(steps, [lr_at(s, 2000) for s in steps])
plt.xlabel("step"); plt.ylabel("learning rate"); plt.yscale("log")
SymptomDiagnosisFix
Loss is nan within a few stepsRate far too high, or a log/sqrt of a non-positive valueCut the rate 10x, clip gradients, guard the loss
Loss spikes then recoversOccasional bad batchGradient clipping, warmup, skip the batch
Loss oscillates around a floorRate near the stability limitLower the rate or increase the batch
Loss decreases then worsensRate too high late in trainingAdd decay, or use a plateau schedule
Loss perfectly flatRate too low, or dead unitsRaise the rate 10x, check for zero gradients

Do a learning-range test before a long run: sweep the rate upward over a few hundred steps and record the loss. Loss falls, bottoms out, then rises sharply — pick a value an order of magnitude below the divergence point.

FAQ

Which optimiser should I start with?
AdamW at 1e-3 with a small weight decay for most problems; SGD with momentum and a schedule when you are squeezing out the last fraction of accuracy on a vision model. Change the learning rate before changing the optimiser.
Why does my loss go down then up?
Almost always the learning rate is too high for the region the model has reached, or the schedule never decayed. Confirm by plotting the loss against step and the learning rate against step on the same axis.

Multivariable calculus for training Numerical stability and floating point

Last refreshed 2026-09-18.