Deep Learning with TensorFlow and PyTorch

Loss Functions and Optimizers (SGD, Adam)


You are building a classifier and you want it to make as few mistakes as possible. So the obvious thing to optimise is the number of mistakes. Count them, and tell the optimiser to make that count go down.

Try it and nothing happens. Not "it trains badly" — literally nothing happens. The weights do not move at all.

The reason is worth sitting with, because it explains why the field's loss functions look the way they do. Nudge a weight by 0.0001. Almost certainly, no prediction flips from right to wrong or wrong to right, so the mistake count is unchanged and the gradient is exactly zero. Nudge it enough and one prediction flips, and the count jumps by a whole unit — an infinite slope at a single point. The error count as a function of the weights is a staircase: flat everywhere, with vertical cliffs. Gradient descent on a staircase has nothing to descend.

A loss function is not a measure of how good the model is. It is a differentiable stand-in for how good the model is, chosen so that gradient descent has a slope to follow.

Accuracy is the thing you care about. Cross-entropy is the thing you optimise. They are correlated but not identical, and confusing them is the source of a good deal of confusion later on.

Each optimiser adds one fix to the one before itSGD: step along the current gradientMomentum: keep a running directionAdam: scale each parameter by its own historyAdamW: decouple weight decay from that scaling
The decay bug lasted years because L2-in-the-loss gets divided by Adam's per-parameter scale and stops decaying.

Regression losses, and the outlier that hijacks your model

For predicting a continuous number, the two standard candidates are squared error and absolute error:

MSE=1n∑i(y^i−yi)2MAE=1n∑i∣y^i−yi∣\text{MSE} = \frac{1}{n}\sum_i (\hat{y}_i - y_i)^2 \qquad \text{MAE} = \frac{1}{n}\sum_i |\hat{y}_i - y_i|

They look interchangeable. They are not, and one bad data point shows why. Suppose you predict 3.0 for five houses whose true prices (in hundreds of thousands) are 3.0, 3.1, 2.9, 3.0 and 30.0. That last one is a data-entry error — someone typed an extra zero.

ExampleError (y^−y)(\hat{y}-y)Contribution to MSEMSE gradient 2(y^−y)2(\hat{y}-y)MAE gradient sign(y^−y)\text{sign}(\hat{y}-y)
10.00.000.00
2−0.10.01−0.2−1
3+0.10.01+0.2+1
40.00.000.00
5 (typo)−27.0729.00−54.0−1
Total729.02sum of magnitudes = 54.4sum of magnitudes = 3

Under MSE, the single corrupted example supplies 99.3% of the gradient. The model will spend its capacity chasing a typo. Under MAE, it supplies 33% — still more than its share of one-fifth, but not a takeover.

That is the trade-off in one table. MSE punishes large errors disproportionately, which is right when large errors really are disproportionately bad and your data is clean. MAE treats all errors in proportion, which is right when outliers are noise rather than signal. MAE's weakness is its constant gradient: as the model gets close to correct, the gradient stays at ±1\pm 1 instead of shrinking, so the final convergence is jittery.

Huber loss takes both: quadratic near zero (smooth convergence), linear far away (outlier resistance).

Lδ(e)={12e2∣e∣≤δδ(∣e∣−12δ)∣e∣>δL_\delta(e) = \begin{cases} \tfrac{1}{2}e^2 & |e| \le \delta \\ \delta\left(|e| - \tfrac{1}{2}\delta\right) & |e| > \delta \end{cases}

Python
import torch.nn as nnnn.MSELoss()                        # squared errornn.L1Loss()                         # absolute errornn.HuberLoss(delta=1.0)             # quadratic inside delta, linear outside

One practical note that catches people out: if your target is house price in pounds, MSE values will be in the order of 101010^{10} and the gradients will be enormous. Either scale the target (predict price in units of £100,000, or predict log⁡(price)\log(\text{price})) or expect to use a much smaller learning rate. The loss value being large is not itself a problem; the gradients being large is.

Classification losses, and why squared error is wrong here

Cross-entropy measures the distance between two probability distributions — what the model believes and what is actually true:

L=−∑cyclog⁡y^cL = -\sum_{c} y_c \log \hat{y}_c

For single-label classification only one ycy_c is 1, so this collapses to −log⁡(probability assigned to the correct class)-\log(\text{probability assigned to the correct class}). Read that as: how surprised was the model by the right answer?

Probability given to the true classLoss −ln⁡p-\ln pInterpretation
0.990.010Confident and right — almost free
0.700.357Right but hesitant — mild penalty
0.500.693Coin flip. For 2 classes this is the "no information" baseline
0.102.303Confidently wrong — heavy penalty
0.014.605Very confidently wrong — very heavy

That ln⁡\ln curve is the point: the penalty grows without bound as the model becomes confidently wrong. A model that says "99% certain" and is wrong should be punished far more than one that says "51% certain" and is wrong, and cross-entropy encodes exactly that.

Now the reason not to use MSE on classification. Suppose the true label is 1 and a sigmoid output currently says 0.01 — as wrong as it is possible to be. What gradient reaches the pre-activation zz?

LossGradient w.r.t. zzValue at y^=0.01, y=1\hat{y}=0.01,\ y=1
MSE2(y^−y)⋅σ′(z)=2(y^−y)y^(1−y^)2(\hat{y}-y)\cdot\sigma'(z) = 2(\hat{y}-y)\hat{y}(1-\hat{y})2(−0.99)(0.01)(0.99)=−0.01962(-0.99)(0.01)(0.99) = -0.0196
Cross-entropyy^−y\hat{y} - y−0.99-0.99

MSE's gradient is 50 times smaller — and it is smallest exactly when the model is most wrong, because σ′\sigma' has collapsed in the saturated region. A model using MSE on a sigmoid output learns most slowly from its worst mistakes. Cross-entropy's σ′\sigma' term cancels algebraically against the log⁡\log, leaving the beautifully simple y^−y\hat{y} - y: the gradient is just the error, and it is largest when the model is most wrong.

Picking the right one

TaskLossPyTorchKeras
Regression, clean dataMSEnn.MSELoss()"mse"
Regression, outliers presentHuber or MAEnn.HuberLoss()"huber"
Binary classificationBinary cross-entropynn.BCEWithLogitsLoss()BinaryCrossentropy(from_logits=True)
Multi-class, one labelCategorical cross-entropynn.CrossEntropyLoss()SparseCategoricalCrossentropy(from_logits=True)
Multi-labelBCE on each outputnn.BCEWithLogitsLoss()BinaryCrossentropy(from_logits=True)
Severe class imbalanceWeighted or focal lossnn.CrossEntropyLoss(weight=w)pass class_weight to fit

For heavy imbalance — fraud at 0.1% of transactions, say — plain cross-entropy lets the 99.9% majority dominate the average, and the model learns to predict "not fraud" always. Class weights fix this bluntly by multiplying each class's loss by a factor. Focal loss, −(1−y^)γlog⁡y^-(1-\hat{y})^{\gamma}\log \hat{y}, does it more surgically: the (1−y^)γ(1-\hat{y})^\gamma factor shrinks the contribution of examples the model already gets right, so training concentrates on the hard cases.

Gradient descent, and the one hyperparameter that matters most

Given a loss and its gradient, the update rule is one line:

w←w−η∇wLw \leftarrow w - \eta \nabla_w L

Everything hinges on η\eta, the learning rate. Take the simplest possible loss, L(w)=w2L(w) = w^2, whose gradient is 2w2w, starting at w=1w = 1. The update becomes w←w(1−2η)w \leftarrow w(1 - 2\eta), so behaviour depends entirely on the multiplier (1−2η)(1-2\eta):

η\etaMultiplier per stepww after 1, 2, 3 stepsBehaviour
0.010.980.980, 0.960, 0.941Converges, but slowly — hundreds of steps
0.10.800.800, 0.640, 0.512Healthy convergence
0.50.000.000, 0.000, 0.000Perfect for this toy problem, luck in general
1.0−1.00−1.000, 1.000, −1.000Oscillates forever, never converges
1.1−1.20−1.200, 1.440, −1.728Diverges to infinity, then NaN

Two orders of magnitude in η\eta separate "too slow to be useful" from "explodes". This is why learning rate is the first hyperparameter to tune and usually the only one that matters much.

How much data per step

VariantExamples per updateGradient qualityUpdates per epoch (60k examples)Practical verdict
Batch (full)All 60,000Exact1Too slow; often will not fit in memory
Stochastic (single)1Extremely noisy60,000Wastes hardware; no parallelism
Mini-batch32–512Noisy but usable1,875 at batch 32What everyone actually uses

The noise in mini-batch gradients is not purely a cost. It acts as a mild regulariser, letting the optimiser rattle out of narrow, sharp minima that tend to generalise poorly. Very large batches produce cleaner gradients and often worse test accuracy for exactly this reason.

Momentum: remembering which way you were going

Plain gradient descent has a characteristic pathology. In a valley that is steep across and shallow along — which describes most loss landscapes — the gradient points mostly across the valley, so the optimiser bounces from wall to wall while creeping slowly towards the minimum.

Momentum accumulates a running average of past gradients:

v←βv+∇wL,w←w−ηvv \leftarrow \beta v + \nabla_w L, \qquad w \leftarrow w - \eta v

The side-to-side components alternate in sign and cancel out in the average. The along-the-valley component has a consistent sign and accumulates. With β=0.9\beta = 0.9 the effective step in a consistent direction grows to roughly 1/(1−β)=101/(1-\beta) = 10 times the plain gradient step. That is where the speed-up comes from.

Adam: a learning rate for every parameter

One global learning rate is a compromise. Some parameters — the weights on a rare feature — see gradients only occasionally and need large steps; others see large gradients constantly and need small ones. Adam maintains per-parameter statistics and scales each step accordingly.

Python
import numpy as npdef adam_step(w, grad, state, lr=1e-3, b1=0.9, b2=0.999, eps=1e-8):    state["t"] += 1    t = state["t"]    # 1st moment: running mean of the gradient (direction, i.e. momentum)    state["m"] = b1 * state["m"] + (1 - b1) * grad    # 2nd moment: running mean of the SQUARED gradient (magnitude)    state["v"] = b2 * state["v"] + (1 - b2) * grad ** 2    # Bias correction: both averages start at 0 and are far too small early on    m_hat = state["m"] / (1 - b1 ** t)    v_hat = state["v"] / (1 - b2 ** t)    # Parameters with historically large gradients get smaller steps    return w - lr * m_hat / (np.sqrt(v_hat) + eps)

The bias correction deserves a concrete look, because it is the part people skip and then wonder why their from-scratch Adam trains oddly. At step 1, with mm initialised to zero and β1=0.9\beta_1 = 0.9:

m1=0.9×0+0.1×g=0.1gm_1 = 0.9 \times 0 + 0.1 \times g = 0.1g

The estimate of the average gradient is ten times too small, purely because the average started at zero. Dividing by (1−β11)=0.1(1 - \beta_1^1) = 0.1 restores it to gg. By step 100, β1100≈0.00003\beta_1^{100} \approx 0.00003 and the correction is negligible — it only matters for the first hundred or so steps, which are precisely the steps that set the trajectory of the whole run.

AdamW, and a weight-decay bug that lasted years

Adding L2 regularisation by tacking λw\lambda w onto the gradient works fine with plain SGD. With Adam it does not, because that penalty term then gets divided by v^\sqrt{\hat{v}} along with everything else — so parameters with large gradients receive less regularisation, which is the opposite of the intent. AdamW decouples the two: it applies the decay directly to the weights, outside the adaptive scaling.

Python
import torch# Wrong for Adam: weight_decay is folded into the gradientopt = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-2)# Right: decoupled decayopt = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-2)

If you are using weight decay at all, use AdamW. It is a one-word change and it reliably improves generalisation.

Which optimiser, and what to set the learning rate to

OptimiserTypical starting LRConvergesFinal qualityUse when
SGD0.01–0.1SlowlyGood, with tuningRarely, on its own
SGD + momentum 0.90.01–0.1ModerateOften the best availableVision models, long training budgets, you can tune a schedule
RMSProp0.001FastGoodRecurrent networks
Adam0.001FastGoodDefault for anything new
AdamW0.001FastBest of the adaptive familyTransformers, and any time you use weight decay

A defensible default: AdamW at 10−310^{-3}, betas left alone, weight decay 0.01. Get a model training, get a baseline number, and only then consider whether a tuned SGD-with-momentum schedule is worth the effort. It sometimes reaches a slightly better final score on vision tasks, but it takes real tuning to get there and Adam gets you 95% of the way with none.

Schedules

A constant learning rate is a compromise between "large enough to make progress early" and "small enough to settle at the end". Schedules stop compromising:

Python
from torch.optim.lr_scheduler import CosineAnnealingLR, ReduceLROnPlateau# Smoothly anneal from the initial LR down to ~0 over the whole run.# Currently the most reliable general-purpose choice.sched = CosineAnnealingLR(opt, T_max=num_epochs)# Or react to the data: cut the LR by 10x whenever validation loss stalls.sched = ReduceLROnPlateau(opt, mode="min", factor=0.1, patience=5)for epoch in range(num_epochs):    train_one_epoch(model, opt)    val = validate(model)    sched.step()          # for CosineAnnealingLR    # sched.step(val)     # for ReduceLROnPlateau -- it needs the metric

A characteristic sight in real training curves is a sudden drop in loss exactly when the schedule cuts the learning rate. That is not the model suddenly understanding something; it is the optimiser finally being able to settle into a minimum it had been orbiting.

Reading a training run through this lens

When training goes wrong, the loss curve tells you which of these components to suspect, and the diagnosis is usually fast.

Loss is nan within a few steps. Almost always the learning rate, occasionally a log⁡(0)\log(0) from applying softmax twice. Divide the learning rate by ten and see if it survives. If it does, you found it.

Loss falls then plateaus far above zero. Either the model lacks capacity, or the learning rate is now too large for the region you are in. Add a schedule before you add layers — it is cheaper and it is more often the answer.

Loss oscillates without trending down. Learning rate too high, or batch size too small so the gradient estimate is dominated by noise. Try halving the learning rate first.

Loss barely moves from its initial value. Learning rate too small by orders of magnitude, or inputs not normalised, or — the embarrassing one — the optimiser is holding a different set of tensors from the ones used in the forward pass: it was built for an earlier copy of the model, or the layers live in a plain Python list and were never registered.

Training loss looks perfect and the model is useless in production. The loss was never the thing you cared about. Check the metric you actually care about — precision at a fixed recall, say — alongside the loss on every validation pass. A model can drive cross-entropy down beautifully by becoming very confident about the majority class while being worthless on the minority class you actually built it for.