Deep Learning with TensorFlow and PyTorch

Overfitting and Regularization


Train a network with two million parameters on 500 examples. Watch the training loss slide down to 0.001 and training accuracy hit 100%. Then evaluate on data it has not seen: 61%.

Now run the experiment that makes this impossible to explain away. Take the same 500 examples and randomly shuffle the labels — so a photograph of a dog is labelled "aeroplane", a cat is labelled "ship", and there is no pattern whatsoever connecting inputs to outputs. Train the same network.

It still reaches 100% training accuracy.

There was nothing to learn. The labels were noise. The network memorised 500 arbitrary input-output pairs, which it has more than enough capacity to do — two million parameters against 500 examples is 4,000 parameters per example. A model with that much room does not need to find a pattern. It can simply store the answers.

Low training loss proves the model has enough capacity. It proves nothing at all about whether the model learned anything general.

The gap between what the model does on data it has seen and data it has not is the only thing that matters, and regularisation is the collective name for every technique that closes it.

Validation loss per epoch, and where to stop0.620.440.350.310.30.330.390.4701234567best:stop herememorisingTraining loss keeps falling across all eight epochs; only the validation curve turns.
Overfitting has a visible moment — the epoch validation loss stops falling while training loss carries on.

Bias and variance, with a concrete picture

Suppose the truth is a gentle curve and you have twenty noisy samples of it. Three models:

ModelTraining errorTest errorWhat it is doing
Straight line (degree 1)High (0.42)High (0.45)Too rigid to follow a curve. High bias.
Degree-4 polynomialLow (0.08)Low (0.11)Follows the shape, ignores the noise. Right.
Degree-19 polynomialZero (0.00)Enormous (3.8)Passes exactly through all twenty points, wiggling violently between them. High variance.

The degree-19 fit is worth picturing. With twenty parameters and twenty points, it can and does hit every point exactly. Between the points it swings wildly, because nothing constrains it there. Move one training point slightly and the entire curve reorganises. That instability is what "variance" means: the fitted function depends heavily on the particular sample you happened to draw.

Two terms, two very different remedies:

High bias (underfitting)High variance (overfitting)
Training errorHighVery low
Validation errorHigh, close to trainingMuch higher than training
More data helps?NoYes, a lot
Bigger model helps?YesNo, makes it worse
More regularisation helps?No, makes it worseYes

Diagnose before you treat. Adding dropout to an underfitting model is an actively harmful move, and it is a common one, because "add regularisation" has become a reflex.

Underfitting, and how to recognise it

Signs: training loss plateaus high and validation loss sits right next to it. The two curves are close together, which people sometimes read as a good sign. It is not — it means the model is equally bad everywhere.

CauseFix
Model too smallMore layers, wider layers
Trained too brieflyMore epochs
Learning rate far too lowRaise it by 10× and watch
Too much regularisationReduce dropout, reduce weight decay
Features do not contain the signalBetter features; no architecture fixes missing information
Inputs not normalisedStandardise to roughly zero mean, unit variance

The biggest lever is more data

Every regularisation technique below is a way of compensating for not having enough data. If you can get more, that beats all of them, and it is not close.

When you cannot collect more, you can often manufacture more. Data augmentation applies label-preserving transformations: a photograph of a cat rotated eight degrees is still a photograph of a cat, but it is a different array of pixels, so the model cannot memorise it as a fixed input.

Python
import torchvision.transforms as Ttrain_tf = T.Compose([    T.RandomResizedCrop(224, scale=(0.7, 1.0)),    T.RandomHorizontalFlip(),    T.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2),    T.RandomRotation(10),    T.ToTensor(),    T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),])# Validation and test get NO random augmentation -- only the deterministic partseval_tf = T.Compose([    T.Resize(256), T.CenterCrop(224), T.ToTensor(),    T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),])

Augmenting the validation set is a real and common mistake. It makes your validation metric noisy and pessimistic, so you cannot compare runs reliably.

The transformations must be label-preserving, and that is domain-specific. A horizontal flip is fine for a cat and wrong for a handwritten digit — a flipped 2 is not a 2, and a flipped 6 is not a 9 in any useful sense. For text, synonym replacement and back-translation work; for audio, time-shifting and adding background noise. For tabular data there is no general recipe, which is one reason tabular deep learning is harder than it looks.

L2 regularisation, or weight decay

Add a penalty proportional to the squared size of the weights:

Ltotal=Ldata+λ2∑iwi2L_{\text{total}} = L_{\text{data}} + \frac{\lambda}{2}\sum_i w_i^2

The gradient of that penalty is simply λw\lambda w, so the update becomes:

w←w−η(∇Ldata+λw)=w(1−ηλ)⏟shrink−η∇Ldataw \leftarrow w - \eta\left(\nabla L_{\text{data}} + \lambda w\right) = \underbrace{w(1 - \eta\lambda)}_{\text{shrink}} - \eta \nabla L_{\text{data}}

Every step multiplies each weight by (1−ηλ)(1 - \eta\lambda) before applying the data gradient. That is where the name "weight decay" comes from, and the arithmetic is instructive. With η=0.01\eta = 0.01 and λ=0.01\lambda = 0.01, the factor is 0.99990.9999. Over 10,000 steps, a weight receiving no useful gradient shrinks to 0.999910000≈0.370.9999^{10000} \approx 0.37 of its starting value. A weight that is useful gets pushed back up by the data gradient every step and survives.

So L2 is a continuous negotiation: justify your magnitude or fade away. The effect is to prefer many small weights over a few large ones, which produces smoother functions that change less abruptly as the input changes.

Python
# PyTorch -- use AdamW, which decays weights correctly alongside Adam's scalingopt = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-2)# Do NOT decay biases or normalisation parameters -- they are not the problemdecay, no_decay = [], []for name, p in model.named_parameters():    (no_decay if p.ndim <= 1 else decay).append(p)opt = torch.optim.AdamW([    {"params": decay,    "weight_decay": 1e-2},    {"params": no_decay, "weight_decay": 0.0},], lr=1e-3)
Python
from tensorflow.keras import layers, regularizerslayers.Dense(128, activation="relu",             kernel_regularizer=regularizers.l2(1e-4))   # weights only, not bias

Typical values for λ\lambda span 10−510^{-5} to 10−210^{-2}. Search it in log space; the difference between 10−410^{-4} and 10−310^{-3} is meaningful, the difference between 10−410^{-4} and 2×10−42 \times 10^{-4} is not.

L1 regularisation and sparsity

Penalise absolute size instead of squared size:

Ltotal=Ldata+λ∑i∣wi∣L_{\text{total}} = L_{\text{data}} + \lambda \sum_i |w_i|

The gradient of ∣w∣|w| is sign(w)\text{sign}(w) — a constant pull of the same size regardless of how small ww already is. L2's pull, λw\lambda w, shrinks as the weight shrinks, so it asymptotes towards zero without arriving. L1's constant pull drives weights exactly to zero and holds them there.

L1L2
Penaltyλ∑∣w∣\lambda\sum|w|λ2∑w2\frac{\lambda}{2}\sum w^2
Gradientλ sign(w)\lambda\,\text{sign}(w) — constantλw\lambda w — proportional
Effect on weightsMany become exactly 0All become small, few become 0
Useful forFeature selection, model compressionGeneral smoothing — the usual default

In deep learning L2 is the default by a wide margin. L1 earns its place when you specifically want sparsity — to identify which of 5,000 input features actually matter, or to prune a model for deployment.

Dropout

During each training step, randomly set a fraction pp of a layer's activations to zero. Different units are dropped every step.

Why this helps: if a unit can rely on a specific partner unit always being present, the two co-adapt into a fragile pair that only works together. Dropout makes any given partner unavailable half the time, so every unit must contribute something individually useful. The result is redundant, distributed representations rather than brittle specialised ones. A second reading: with nn units there are 2n2^n possible dropout masks, so training is approximately training an ensemble of exponentially many networks that share weights — and ensembles generalise better than their members.

The scaling detail that must be right

If you drop half the units, the sum arriving at the next layer is halved, so the next layer sees a different input scale at training time than at inference time. Modern implementations use inverted dropout: scale the surviving activations up by 1/(1−p)1/(1-p) during training, so the expected value is unchanged and inference needs no adjustment at all.

With p=0.5p = 0.5 and a layer producing [2,4,6,8][2, 4, 6, 8], suppose the mask keeps units 2 and 4:

StageValuesMean
Original[2, 4, 6, 8]5.0
After mask[0, 4, 0, 8]3.0 — wrong scale
After scaling by 1/(1−0.5)=21/(1-0.5) = 2[0, 8, 0, 16]6.0 for this mask; 5.0 in expectation over all masks

Both frameworks do this for you, which is precisely why model.eval() matters: it switches dropout off entirely, and the inverted scaling means no other correction is needed.

Python
model = nn.Sequential(    nn.Linear(784, 512), nn.ReLU(), nn.Dropout(0.5),    nn.Linear(512, 256), nn.ReLU(), nn.Dropout(0.3),    nn.Linear(256, 10),                     # NEVER dropout before the output)
WhereTypical rateNote
Wide dense hidden layers0.3–0.5The original and best-supported use
Convolutional layers0.0–0.1, or noneConvolutions already share weights heavily; batch norm usually does the job
Input layer0.0–0.2Rarely worth it; augmentation is better
Output layerNeverRandomly zeroing class scores is pure noise
Small model already underfitting0Adding dropout here makes things strictly worse

Early stopping: free, and everyone should use it

Validation loss typically falls, reaches a minimum, and then rises while training loss keeps falling. Everything after that minimum is the model memorising. Early stopping watches validation loss and stops when it stops improving — then restores the weights from the best epoch, not the last one.

Python
best_val, patience, wait = float("inf"), 10, 0for epoch in range(500):    train_one_epoch(model, opt, train_loader)    val = evaluate(model, val_loader)    if val < best_val - 1e-4:              # a real improvement, not noise        best_val, wait = val, 0        torch.save(model.state_dict(), "best.pt")    else:        wait += 1        if wait >= patience:            print(f"stopping at epoch {epoch}; best val {best_val:.4f}")            breakmodel.load_state_dict(torch.load("best.pt"))   # restore the BEST, not the last
Python
from tensorflow.keras.callbacks import EarlyStoppingEarlyStopping(monitor="val_loss", patience=10,              min_delta=1e-4, restore_best_weights=True)

Two settings carry the weight. patience too small (say 2) stops on a normal fluctuation and leaves performance on the table; 10–20 is a sensible range. And restore_best_weights must be on — without it you keep the weights from the last epoch, which by construction are worse than the best ones you already had.

A default recipe

These techniques stack, and a reasonable starting configuration for a moderate-sized problem is:

Python
model = nn.Sequential(    nn.Linear(n_features, 256),    nn.BatchNorm1d(256), nn.ReLU(), nn.Dropout(0.3),    nn.Linear(256, 128),    nn.BatchNorm1d(128), nn.ReLU(), nn.Dropout(0.3),    nn.Linear(128, n_classes),)opt = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-2)sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=epochs)# plus data augmentation, plus early stopping on validation loss

One interaction to be aware of: batch normalisation and dropout together can hurt. Batch norm normalises using batch statistics, dropout changes the variance of what it sees, and at inference the two disagree about what the activation scale should be. The common resolution is to put dropout after the normalisation and activation, as above, and to use a lower rate than you would without batch norm — or to skip dropout entirely in convolutional networks, where batch norm alone regularises well.

Turning a symptom into an action

When you sit down in front of a disappointing model, run this procedure rather than reaching for a favourite technique.

First, compare training and validation loss. That single comparison splits the world in two, and the two halves have opposite treatments. If both are high, you are underfitting and every regularisation technique on this page will make things worse. If training is low and validation is much higher, you are overfitting and they will help.

If overfitting, work in order of expected value. More data first, if it exists. Then augmentation, which is nearly as good and costs only code. Then early stopping, which is free and never hurts. Then weight decay, tuned in log space. Then dropout. Only then consider shrinking the model — capacity plus regularisation almost always beats less capacity, because a large regularised model can represent the right function and simply chooses not to overreach, whereas a small model cannot represent it at all.

Change one thing at a time and record the result. Adding dropout, weight decay and augmentation together and seeing an improvement tells you nothing about which one worked, and one of them may be hurting while the others carry it.

Watch for the gap that never closes. If you have applied everything and validation loss is still far above training loss, the problem may not be regularisation at all — it may be that your validation set is drawn from a different distribution than your training set. Photographs taken with a different camera, transactions from a different quarter, users from a different country. No amount of dropout fixes a distribution shift, and diagnosing it as overfitting will send you in circles for a week.