S101 · Regularization in DL — Dropout, BatchNorm, Weight Decay
The three-headed toolkit that keeps deep networks from memorising the training set. Learn dropout (random neuron muting), batch normalisation (per-layer whitening), and weight decay (L2 by the back door) — including the subtle bug that made AdamW replace Adam and the layer-norm shift that unlocked transformers.
🎯 Add dropout, batch normalisation, and weight decay to a PyTorch MLP — and understand WHY each one helps, when it hurts, and how they interact.
Why this session exists
A neural network with enough parameters will memorise any training set — that's the double-edged sword of a universal approximator. Regularisation is how we tell the model "don't memorise; generalise." Without it, every deep model overfits so badly that it's useless. This session covers the three techniques that appear in every serious architecture — dropout, batchnorm/layernorm, and weight decay — plus the subtle bug that made AdamW replace Adam in every transformer since 2019.
- Explain dropout as ‘training an ensemble for free’ and know when NOT to use it.
- Distinguish BatchNorm, LayerNorm, and GroupNorm — and know which one belongs in a CNN vs a transformer.
- Explain why weight decay and L2 regularisation are the same thing for SGD but NOT the same for Adam.
- Add all three to a PyTorch MLP and measure their impact on train vs test loss.
- Diagnose overfitting from a train/test loss curve and pick the right regulariser for the pattern.
Prerequisites
- S099 · Optimisers — you know Adam and SGD.
- S100 · PyTorch fundamentals — nn.Module + training loop.
- S097 · MLP forward pass — you have a model to regularise.
(a) Intuition · 5 min
Imagine you're teaching a study group of 100 students, but each exam you randomly select only 50 to actually take it. The remaining 50 have to trust the group's collective wisdom, not just their own notes. Over many exams, students learn to rely less on personal memory and more on general principles that transfer.
That's dropout. Every training batch, half the neurons are "on holiday" and the rest have to compensate. The network can no longer rely on any single neuron and instead learns robust, distributed representations.
Weight decay = don't let any weight get too big. In practice: subtract a small fraction of each weight from itself every step. Result: simpler solutions win over complex ones.
BatchNorm = at each layer, normalise the pre-activations to zero-mean unit-variance. Result: gradients flow more evenly, training is faster and less sensitive to lr. Also acts as a mild regulariser via batch-statistic noise.
Three different mechanisms, one common goal: force the network to generalise.
When you need which
- Train loss ≪ test loss (overfitting) → add dropout, more data, or weight decay.
- Train loss and test loss both high (underfitting) → less regularisation, more capacity.
- Loss plateaus at high value from step 1 → add BatchNorm (or better init).
- Loss oscillates or diverges → too much regularisation, or optimiser issue (S099).
- Transformer / attention model → use LayerNorm not BatchNorm, use dropout in FFN + attention, use AdamW not Adam.
The story of when we learned to regularise
- 1995L2 weight decay in classical MLRidge regression, decades-old technique carried into neural nets.
- 2012Dropout · Hinton et al.AlexNet uses it to win ImageNet. Deep learning suddenly generalises.
- 2015BatchNorm · Ioffe & SzegedySpeeds up training and adds mild regularisation. Enables ResNet-152.
- 2016LayerNorm · Ba, Kiros, HintonBatch-independent normalisation. Enables RNNs and, later, transformers.
- 2017AdamW · Loshchilov & HutterDecouples weight decay from Adam. Every transformer since 2019 uses it.
- 2020Weight standardisation, RMSNorm, DropPathThe regularisation zoo keeps growing. Newer variants trade tiny gains for simpler math.
(b) Visual walkthrough · 15 min
The three regularisers in a picture
Three mechanisms, three places in the network. Layered together they turn a memoriser into a generaliser.
Dropout — the training-time / inference-time asymmetry
For each forward pass, sample a Bernoulli(1−p) mask; multiply pre-activation by the mask. Roughly p fraction of neurons output 0 for this batch.
Multiply the surviving activations by 1/(1−p). PyTorch does this ‘inverted dropout’ automatically. This is why inference needs no rescaling.
In eval mode, dropout is a NO-OP. All neurons fire, no scaling. This is why model.eval() matters.
Each training forward pass sees a different sub-network. Over many batches, the network learns representations robust to any sub-network being ‘on duty’.
BatchNorm vs LayerNorm vs GroupNorm
Normalise over the batch dimension
- For each feature, subtract batch mean, divide by batch std
- Adds learnable γ and β to recover expressiveness
- Tracks running mean/var for inference
- Standard in CNNs (ResNet, EfficientNet)
- Bad for tiny batches, RNNs, and transformers
Normalise over the feature dimension
- For each example, subtract feature mean, divide by feature std
- Batch-independent — works at batch size 1
- Standard in transformers (BERT, GPT, LLaMA)
- Also used in RNNs
- Same γ, β affine as BN
Middle ground for vision at small batch
- Splits channels into groups, normalises within each group
- Batch-independent like LayerNorm
- Used in detection / segmentation models where batch size is tiny
- Rare in NLP
The modern transformer default
- LayerNorm without the mean subtraction
- Slightly faster, similar accuracy
- Used in LLaMA, Mistral, Gemma
- Just one line different from LayerNorm
Weight decay — where it lives depends on the optimiser
Weight decay: two subtly different implementations
"Dropout randomly turns off neurons during training, so it's a way to add noise and prevent the network from relying too much on any one unit. More dropout means more regularisation, so if I'm overfitting I should turn it up."
The description is right; the prescription is not. Dropout is one regulariser among several that interact, and turning it up past a point simply underfits — the network cannot form the co-adapted feature combinations it legitimately needs. More importantly, dropout's value has collapsed in modern architectures: batch normalisation, weight decay, augmentation, and sheer data volume do much of the same work, and dropout combined with BatchNorm can actively hurt, because dropout changes the activation statistics that BatchNorm estimated during training.
The myth is sticky because dropout was genuinely transformative when it appeared, in an era of smaller datasets and networks without normalisation layers — the improvements were large and reproducible, so it entered every tutorial as a default component. The mental model "noise ⇒ regularisation ⇒ more is better" is also monotone and satisfying, whereas the truth is a U-curve like every other capacity control. And the failure is quiet: an over-dropped network just trains to a mediocre score with no error, so nothing tells you the knob went too far.
Sweep the rate and confirm the U — and check that eval mode actually disables it:
for p in [0.0, 0.1, 0.3, 0.5, 0.7, 0.9]:
m = make_model(dropout=p)
tr, va = train(m)
print(p, tr[-1], va[-1])
# train loss rises monotonically with p
# val loss falls then rises -- the optimum is NOT the largest p
m.eval()
with torch.no_grad():
a, b = m(x), m(x)
print(torch.allclose(a, b)) # True in eval, False in train
# forgetting model.eval() makes validation noisy AND wrongWhy does dropout scale activations by 1/(1−p) during training rather than simply zeroing units and leaving the rest alone? The scaling looks like an implementation detail. It is required for correctness.
- 1At inference, dropout is disabled and every unit is active. So a downstream neuron receives the sum of all its inputs.forced by · predictions must be deterministic; you cannot ship a model that gives a different answer each call
- 2During training, each input is present with probability (1−p). So the expected value of the sum a neuron receives is (1−p) times the sum it will receive at inference.forced by · expectation is linear, so dropping each term independently scales the expected sum by the keep probability
- 3That means the network is trained under one input distribution and evaluated under a different one, with all activations systematically inflated at test time by a factor of 1/(1−p). At p = 0.5 every downstream pre-activation doubles.forced by · the weights were fitted to the smaller expected magnitudes seen during training
- 4The consequences are severe and compound with depth: saturating activations move into their flat regions, normalisation statistics are wrong, and the output distribution shifts — so a network that trained perfectly produces degraded or nonsensical predictions.forced by · each layer's scale error multiplies into the next
- 5Therefore one side must be rescaled. Divide surviving activations by (1−p) during training (inverted dropout) and the expected sum matches inference exactly, with zero cost at inference time.forced by · correcting at train time keeps the deployed forward pass simple and fast, which is where the operation runs most often
Therefore the 1/(1−p) factor is not a tweak — it is what makes training and inference describe the same function in expectation. Inverted dropout puts the cost on the training path deliberately.
And note what this predicts: any technique whose behaviour differs between training and inference must carry a similar reconciliation. BatchNorm is exactly that case — it normalises by batch statistics while training and by accumulated running averages at inference, which is why model.eval() is mandatory and why forgetting it produces the classic bug where validation accuracy is inexplicably terrible while training accuracy looks fine.
Signal is the structure repeated across many examples; noise is idiosyncratic to individual examples. Any constraint that costs more to accommodate noise than signal will preferentially preserve signal. That single idea unifies the whole toolbox.
Weight decay makes large, sharply-tuned weights expensive. Dropout makes any feature that only works alongside one specific partner unreliable. Augmentation asserts that certain transformations must not change the label, so example-specific quirks stop being learnable. Early stopping exploits the empirical ordering that networks fit broad structure before memorising outliers. Different mechanisms, one principle.
- More data beats every regulariser and adds no bias. Augmentation is the cheap synthetic version, and it is usually the highest-leverage intervention available.
- Weight decay is the reliable default. Use AdamW, not Adam with
weight_decay— Adam's per-parameter normalisation divides the decay term and breaks it. - Early stopping is regularisation by iteration budget, essentially free. Always monitor validation and keep the best checkpoint rather than the last.
- Batch/layer normalisation regularise as a side effect (batch noise) and mainly help optimisation. Combining them with dropout in the same block frequently hurts.
Fire this the moment you see: dropout stacked on top of BatchNorm in the same block · a missing model.eval() at validation · dropout applied to the input layer at a high rate · weight_decay passed to plain Adam · no augmentation on an image or audio task · training run to a fixed epoch count with no early stopping · a large train/validation gap addressed by adding capacity.
Your deep model overfits. Do you add augmentation, add explicit regularisation (dropout/weight decay), or shrink the model?
The ordering that works: augmentation first where the domain permits it, weight decay always, early stopping always, and dropout only where it demonstrably helps — which in modern normalised architectures is less often than its reputation suggests. Shrink the model when it is genuinely oversized rather than as a general remedy, since capacity is usually not the real problem.
The judgement worth internalising: overfitting means your data is small relative to your capacity, so the highest-value response is almost always to attack the data side. An hour spent on augmentation or collecting more labels typically beats a day of hyperparameter search, and it produces a model that is more robust in production rather than merely better on your validation split.
(c) Hands-on · 25 min
Take the MNIST MLP from S100, add regularisation, and measure the effect on train vs test loss curves. Save as regularization_lab.py, uv run regularization_lab.py.
"""regularization_lab.py — dropout + BN + weight decay, measured.
Trains four variants of the same MLP on MNIST:
A. Vanilla — no regularisation
B. + Dropout
C. + BatchNorm
D. + Dropout + BN + AdamW weight_decay
Reports final train and test loss to show the generalisation gap.
"""
from __future__ import annotations
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
class MLP(nn.Module):
def __init__(self, use_dropout: bool = False, use_bn: bool = False,
dropout_p: float = 0.3) -> None:
super().__init__()
self.use_dropout = use_dropout
self.use_bn = use_bn
self.fc1 = nn.Linear(784, 256)
self.fc2 = nn.Linear(256, 128)
self.fc3 = nn.Linear(128, 10)
if use_bn:
self.bn1 = nn.BatchNorm1d(256)
self.bn2 = nn.BatchNorm1d(128)
if use_dropout:
self.drop = nn.Dropout(p=dropout_p)
def _block(self, x, linear, bn_layer):
x = linear(x)
if self.use_bn:
x = bn_layer(x)
x = F.relu(x)
if self.use_dropout:
x = self.drop(x)
return x
def forward(self, x):
x = x.view(x.size(0), -1)
x = self._block(x, self.fc1, self.bn1 if self.use_bn else None)
x = self._block(x, self.fc2, self.bn2 if self.use_bn else None)
return self.fc3(x)
def make_loaders(batch: int = 128):
tfm = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,)),
])
root = "./data"
tr = datasets.MNIST(root, train=True, download=True, transform=tfm)
te = datasets.MNIST(root, train=False, download=True, transform=tfm)
return DataLoader(tr, batch_size=batch, shuffle=True, num_workers=2), \
DataLoader(te, batch_size=1024, shuffle=False, num_workers=2)
def run_epoch(model, loader, criterion, optimizer=None) -> tuple[float, float]:
train = optimizer is not None
model.train() if train else model.eval()
total_loss, correct, n = 0.0, 0, 0
with torch.set_grad_enabled(train):
for x, y in loader:
x, y = x.to(DEVICE), y.to(DEVICE)
logits = model(x)
loss = criterion(logits, y)
if train:
optimizer.zero_grad()
loss.backward()
optimizer.step()
total_loss += loss.item() * x.size(0)
correct += (logits.argmax(1) == y).sum().item()
n += x.size(0)
return total_loss / n, correct / n
def train_variant(name: str, model: MLP, train_ld, test_ld,
*, epochs: int = 10, lr: float = 1e-3, wd: float = 0.0) -> None:
model = model.to(DEVICE)
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=wd)
for ep in range(1, epochs + 1):
tr_l, tr_a = run_epoch(model, train_ld, criterion, optimizer)
te_l, te_a = run_epoch(model, test_ld, criterion)
if ep == epochs:
gap = te_l - tr_l
print(f" [{name:<28s}] train_loss={tr_l:.4f} test_loss={te_l:.4f} "
f"test_acc={te_a:.4f} gap={gap:+.4f}")
if __name__ == "__main__":
torch.manual_seed(0)
train_ld, test_ld = make_loaders(batch=128)
print("\nFinal-epoch metrics — LOWER gap = better generalisation:\n")
train_variant("A · vanilla", MLP(False, False), train_ld, test_ld, wd=0.0)
train_variant("B · + dropout", MLP(True, False), train_ld, test_ld, wd=0.0)
train_variant("C · + BN", MLP(False, True), train_ld, test_ld, wd=0.0)
train_variant("D · + dropout + BN + wd", MLP(True, True), train_ld, test_ld, wd=0.05)Anatomy of the script
Anatomy of the script
Run the vanilla MLP with epochs=50 (instead of 10). You should see train_loss keep dropping while test_loss starts to rise around epoch 20-30 — the classic overfitting curve. Now repeat with dropout+BN+wd and observe: test_loss stops dropping too but does NOT rise. That's regularisation preventing the model from over-memorising even with more training budget. This shape of curve is the visual signature of what regularisation buys you.
(d) Production reality · 15 min
Early transformer trainers used torch.optim.Adam(..., weight_decay=0.01). Reported final loss was higher than a variant with no weight decay at all — the regularisation was making things worse.
Root cause (Loshchilov & Hutter 2017): in Adam, adding wd·w to the gradient means parameters with large historical gradients get weakly regularised (their step size is divided by √v, so the wd term is scaled down too). Weight decay was inconsistent per-parameter.
Every transformer uses LayerNorm (per-example normalisation, not per-batch). LayerNorm is batch-independent, works at any batch size (including 1), and doesn't require running statistics.
Modern trend: RMSNorm (LayerNorm without mean subtraction) is slightly faster and just as effective. LLaMA / Mistral / Gemma all use it.
Overfitting is NOT the only cause of a train-test gap. It might be: (1) a distribution shift between train and test; (2) test set has different class balance; (3) too little data — regularisation can't fix that, more data can; (4) label noise in test set. Dropout is a hammer; not every gap is a nail.
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 does dropout do at train vs at inference?
- Why do CNNs use BatchNorm and transformers use LayerNorm?
- What's the difference between Adam-with-L2 and AdamW?
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.