Deep Learning · Module 8 of 26 · 8 concepts · about 17 min

Optimizers & Learning Rates

How updates are scaled, smoothed, and scheduled over time — and why Adam's default rate is not a universal answer.

At work: Choosing how a model updates itself, which decides how fast and how cheaply it trains.

SGD, momentum, AdamW and schedules

What it is

Plain SGD follows the current batch gradient; momentum accumulates a velocity so persistent directions are amplified and contradictory batch noise is damped. Adam keeps moving estimates of both gradient direction and scale, adapting the step for each parameter, while AdamW applies weight decay separately so its regularising effect remains predictable. A schedule changes the global learning rate over time: warmup prevents unstable early updates, and decay encourages later updates to refine rather than overshoot. Optimiser state is part of a resumable training run and must be checkpointed with the model.

Why it matters

Optimiser and schedule choices can decide whether the same architecture trains at all. They are hyperparameters, so compare them on validation data rather than tuning on the test set. Start with a sensible, documented baseline such as AdamW plus warmup and decay, then change one factor at a time while recording the exact effective batch size and update count.

In the real world: Picking the optimiser and schedule decides how long and how expensive training is.

Visual example

Learning-rate schedule

Warm up to a useful step size, then reduce it as optimisation settles.

warmupdecay024681000.20.40.60.81training steplearning rate
import torch

opt = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.01)
sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=total_steps)

for xb, yb in loader:
    opt.zero_grad()
    loss_fn(model(xb), yb).backward()
    opt.step()
    sched.step()          # per step here, not per epoch — match T_max to this

Assuming AdamW makes learning-rate tuning unnecessary. AdamW is robust, not magic; its learning rate is usually the first setting to investigate when training is unstable or stalled.

SGD with momentum, mechanically

What it is

Momentum keeps one extra tensor per parameter — a velocity — and updates it before stepping: v ← βv + g, then θ ← θ − lr·v, with β typically 0.9. Because the velocity is a decaying sum of past gradients, a direction the last several batches agreed on builds up to roughly 1/(1 − β) times a single gradient, while a direction where consecutive gradients point opposite ways cancels itself out to almost nothing.

Why it matters

It fixes the geometry that plain descent handles worst: a long narrow valley, where the gradient points mostly across the valley rather than along it. Plain SGD zigzags between the walls and creeps forward; momentum cancels the crosswise component and accumulates the forward one, often converging several times faster for the same learning rate. momentum=0.9 is close enough to optimal in most settings that it is a default rather than something to tune.

In the real world: Still a strong choice for training image models to top accuracy.

Visual example

Momentum

The same β = 0.9 velocity, fed two gradient sequences: one consistent, one alternating.

v ← 0.9v + g
g consistentvg alternatingv
step 11111
step 211.9-1-0.1
step 312.7110.91
step 413.44-1-0.181

What the two columns mean

consistent: v grows toward 1/(1 − 0.9) = 10× a single gradient

alternating: v stays near zero — the disagreement cancels

this is exactly the zigzag-across-a-valley problem being damped

Why this matters

Momentum does not just 'go faster'. It amplifies agreement and suppresses disagreement, which is a filter on gradient noise as much as an accelerator.

import torch

# The same thing, written out:
velocity = {p: torch.zeros_like(p) for p in model.parameters()}
lr, beta = 0.1, 0.9

loss.backward()
with torch.no_grad():
    for p in model.parameters():
        velocity[p].mul_(beta).add_(p.grad)   # v <- 0.9v + g
        p -= lr * velocity[p]

# Identical to:
opt = torch.optim.SGD(model.parameters(), lr=0.1, momentum=0.9)

Comparing SGD and SGD+momentum at the same learning rate and concluding momentum is unstable. Momentum multiplies the effective step along a consistent direction by about tenfold at β = 0.9, so a fair comparison lowers the rate by roughly that factor.

Adam: a learning rate per parameter

What it is

Adam keeps two running averages per parameter: m, a momentum-like average of the gradient, and v, an average of the squared gradient. The update divides one by the square root of the other — θ ← θ − lr · m̂ / (√v̂ + ε) — so each parameter's step is normalised by how large its own gradients have recently been. Both averages are bias-corrected early on, because they start at zero. Defaults: betas=(0.9, 0.999), eps=1e-8, lr=1e-3.

Why it matters

It makes training far less sensitive to badly scaled parameters, which is why it is the default for almost everything today. A parameter whose gradients are consistently tiny — an embedding row for a rare token, a weight deep in an unnormalised stack — gets a step of comparable size to one with large gradients, so no part of the model is left behind by a single global learning rate. The cost is memory: two extra tensors the size of the model, so an Adam run needs roughly four times the parameter bytes before any activation is stored.

In the real world: The popular default that gets most models training well with little tuning.

Visual example

Optimizer state

What each optimiser stores per parameter — and what that costs for a 100M-parameter model.

weights + optimiser state per parameter, float32
state tensorsbytes per param100M params
SGDnone40.4 GB weights only
SGD + momentumvelocity80.8 GB
Adam / AdamWexp_avg, exp_avg_sq121.2 GB + 0.4 GB grads

Weights, gradients and Adam's two moments are four tensors of model size before a single activation is stored — which is why optimiser choice is a memory decision too.

import torch

opt = torch.optim.Adam(model.parameters(), lr=1e-3, betas=(0.9, 0.999), eps=1e-8)
opt.zero_grad(); loss.backward(); opt.step()

state = opt.state[next(iter(model.parameters()))]
print(state.keys())               # dict_keys(['step', 'exp_avg', 'exp_avg_sq'])
print(state["exp_avg"].shape)     # same shape as the parameter — two of these

Treating Adam's lr as the actual step size. Because the update is divided by √v̂, the step is closer to lr in magnitude regardless of the gradient scale — which is convenient, and also means a rate that works for one architecture can be wildly wrong for another with different gradient statistics.

Practise it: Adam, One Step at a Time

AdamW and the weight decay bug in Adam

What it is

Weight decay is meant to pull every weight gently toward zero, independent of its gradient. Plain Adam implements it by adding wd · θ to the gradient — and then divides the whole thing by √v̂, so a parameter with large gradients has its decay divided away while one with tiny gradients gets decayed hard. The intended uniform regularisation becomes an arbitrary per-parameter one. AdamW fixes it by applying the decay directly to the weights, outside the adaptive scaling: θ ← θ − lr·m̂/(√v̂ + ε) − lr·wd·θ.

Why it matters

It is a rare case where a one-line change to an optimiser measurably improved results across the board, and it is why AdamW — not Adam — is the default in every modern training recipe. It also means the weight_decay value does not transfer between the two: the same number means something different in each, so a recipe tuned for Adam(weight_decay=0.01) is not a recipe for AdamW(weight_decay=0.01). Conventionally, biases and normalisation parameters are excluded from decay entirely.

In the real world: The version of Adam used to train most modern language models, because it regularises correctly.

import torch, torch.nn as nn

decay, no_decay = [], []
for name, p in model.named_parameters():
    (no_decay if p.ndim <= 1 or "norm" in name else decay).append(p)

opt = torch.optim.AdamW(
    [{"params": decay, "weight_decay": 0.01},
     {"params": no_decay, "weight_decay": 0.0}],
    lr=3e-4,
)
print(len(opt.param_groups))   # 2 — biases and norms are not decayed

Using Adam with a non-zero weight_decay and expecting it to regularise like L2. It does not: the decay is scaled by each parameter's own gradient history. Use AdamW, or set weight_decay=0 and add an explicit penalty to the loss.

Practise it: Adam, One Step at a Time

SGD, momentum and Adam on the same problem

What it is

On a typical run the three behave predictably. Plain SGD descends steadily but slowly, and its progress depends heavily on getting the one learning rate right. Momentum reaches a similar loss in noticeably fewer steps by damping the zigzag. Adam drops fastest in the first few hundred steps because every parameter gets a usefully sized step immediately, then often flattens to a final loss close to — occasionally slightly worse than — a well-tuned SGD-with-momentum run.

Why it matters

It sets expectations for what an optimiser swap can and cannot buy. Adam buys speed to a good-enough answer and robustness to your choice of learning rate, which is why it dominates research and prototyping. Well-tuned SGD with momentum still wins the last fraction of a percent on some vision benchmarks, which is why production image models sometimes still use it. Neither buys you a better model than your data and architecture allow, and a run that fails with all three has a problem elsewhere.

In the real world: Comparing optimisers on your own data before committing weeks of GPU time.

Visual example

Convergence

Same model, same data, three update rules — each at its own tuned learning rate.

SGDSGD + momentum 0.9AdamW
05010015020025030000.511.522.5optimizer steptraining lossAdamW's early advantage

Illustrative curves. The ordering early on is typical; the final gap between momentum and AdamW is usually small and task-dependent.

import torch, torch.nn as nn

def run(make_opt, steps=300):
    torch.manual_seed(0)
    model = nn.Sequential(nn.Linear(64, 128), nn.ReLU(), nn.Linear(128, 10))
    opt, losses = make_opt(model), []
    for _ in range(steps):
        opt.zero_grad()
        loss = nn.CrossEntropyLoss()(model(X), y)
        loss.backward(); opt.step()
        losses.append(loss.item())
    return losses

curves = {
    "sgd": run(lambda m: torch.optim.SGD(m.parameters(), lr=0.05)),
    "momentum": run(lambda m: torch.optim.SGD(m.parameters(), lr=0.05, momentum=0.9)),
    "adamw": run(lambda m: torch.optim.AdamW(m.parameters(), lr=1e-3)),
}

Declaring a winner from one seed and one learning rate. Each optimiser has a different optimal rate, so a comparison at a shared rate measures which optimiser happens to like that number — tune each one's rate before comparing them.

Practise it: Adam, One Step at a Time

Schedules: step decay, cosine annealing and warmup

What it is

A schedule makes the learning rate a function of the step count. Step decay multiplies it by a factor (often 0.1) at fixed milestones. Cosine annealing sweeps it smoothly from the initial value down to near zero over the planned length of the run. Warmup does the opposite at the start: it ramps from near zero up to the target rate over the first few hundred or few thousand steps, then hands over to the decay. warmup + cosine is the standard recipe for Transformer training.

Why it matters

Early and late training want different step sizes. At the start the weights are random, the gradients are large and poorly aligned, and Adam's variance estimates are still unreliable — a full-size step then can wreck the run, which is what warmup prevents. Late in training the model is near a minimum and a large step only bounces around it, so decaying the rate is what converts 'roughly right' into 'converged'. A fixed rate has to compromise between the two and does worse than either.

In the real world: Warming up then slowly lowering the learning rate is standard when fine-tuning large models.

Visual example

Schedules

Four shapes, three of which are standard. Log the actual rate — a mis-stepped scheduler is silent.

learning rate over a run
shapeuse when
constantflata quick baseline, or a very short run
step decayflat, then ÷10 at milestonesclassic vision recipes with a known epoch budget
cosinesmooth sweep to ~0a known total step count, no milestones to guess
warmup + cosineramp up, then sweep downTransformers, large batches, Adam from scratch

Why this matters

The highlighted row is the default for anything large. Warmup protects the first few hundred steps; cosine converts the last few thousand into convergence.

import torch
from torch.optim.lr_scheduler import LambdaLR, CosineAnnealingLR, MultiStepLR

opt = torch.optim.AdamW(model.parameters(), lr=3e-4)

step_decay = MultiStepLR(opt, milestones=[30, 60, 90], gamma=0.1)
cosine = CosineAnnealingLR(opt, T_max=total_steps)

warmup_steps = 500
warmup = LambdaLR(opt, lambda s: min(1.0, (s + 1) / warmup_steps))

for step in range(total_steps):
    opt.zero_grad(); loss_fn(model(xb), yb).backward(); opt.step()
    (warmup if step < warmup_steps else cosine).step()
    print(opt.param_groups[0]["lr"])   # log it; a silent schedule is a silent bug

Calling scheduler.step() once per epoch while T_max was set in steps (or the reverse). The schedule then finishes in a fraction of the run or never gets going — and because nothing raises, the only symptom is a learning rate that was wrong for most of the training.

Batch size and learning rate move together

What it is

A larger batch gives a less noisy gradient estimate, which makes a larger step safe. The two common heuristics are linear scaling — multiply the learning rate by the same factor as the batch size — and square-root scaling, which keeps the *noise* per step constant and is often the better fit for adaptive optimisers. Both hold over roughly an order of magnitude of batch size and then break down, at which point warmup becomes necessary to survive the early steps at the higher rate.

Why it matters

It is why 'I increased the batch size to use the GPU better and training got worse' is such a common report. At a fixed learning rate, doubling the batch halves the number of updates per epoch while leaving the step size alone, so the model simply travels half as far per epoch. Scaling the rate restores the distance travelled; warmup makes the larger initial steps survivable.

In the real world: Moving training to bigger GPUs with bigger batches means adjusting the learning rate too, or quality drops.

base_batch, base_lr = 128, 1e-3

def scaled_lr(batch, rule="sqrt"):
    factor = batch / base_batch
    return base_lr * (factor if rule == "linear" else factor ** 0.5)

for batch in (128, 256, 512, 1024):
    print(batch, round(scaled_lr(batch), 5), round(scaled_lr(batch, "linear"), 5))
# 128 0.001 0.001 | 256 0.00141 0.002 | 512 0.002 0.004 | 1024 0.00283 0.008

Scaling the learning rate up without adding warmup. The larger rate is fine once the weights are sensible and destructive in the first few dozen steps, which is exactly the failure large-batch warmup was introduced to fix.

The default-learning-rate trap

What it is

lr=1e-3 is Adam's default because it is a reasonable starting point for a small, normalised network — not because it is correct in general. Transformers at scale typically train at 1e-4 to 3e-4 with warmup; fine-tuning a pretrained model usually wants 1e-5 to 5e-5, because 1e-3 will destroy the pretrained features in the first few hundred steps; SGD on a convolutional network wants something in the 0.01 to 0.1 range, two orders of magnitude away from Adam's default. The usable rate depends on the optimiser, the depth, the normalisation, and the batch size.

Why it matters

Because the default is *almost* right for small models, it survives into larger ones and then fails in ways that look like other problems: a fine-tune that forgets everything it knew, a Transformer whose loss spikes in the first thousand steps, a deep network that plateaus high. A short learning-rate sweep — three runs, one order of magnitude apart, a few hundred steps each — is the cheapest and highest-value experiment available, and it should precede any architectural change.

In the real world: Copying a learning rate from a tutorial can quietly ruin a fine-tuning job on your own data.

import torch

# The sweep worth running before anything else.
for lr in (1e-5, 1e-4, 1e-3, 1e-2):
    torch.manual_seed(0)
    model = build_model()
    opt = torch.optim.AdamW(model.parameters(), lr=lr)
    print(lr, train_for(model, opt, steps=200))

# Typical starting points, by setting:
starting_points = {
    "small MLP, AdamW": 1e-3,
    "transformer from scratch, AdamW + warmup": 3e-4,
    "fine-tuning a pretrained model": 2e-5,
    "ResNet from scratch, SGD + momentum": 0.1,
}

Fine-tuning a pretrained model at 1e-3. The first few steps move the pretrained weights further than all of pretraining's later refinement did, so the model loses the representation you chose it for — and the symptom, a loss that drops then plateaus badly, looks like a data problem.