Search Tech Journey

Find topics, journeys and posts

6-month learning plan101 / 130
back to blog
mladvanced 55m read

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.

🤖Machine LearningM12 · Deep Learning· Session 101 of 130 90 min

🎯 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.

You will be able to
  • 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

Three ways to force generalisation
🌍 Real world

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.

💻 Code world

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

A checklist for choosing regularisation
  • 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

  1. 1995
    L2 weight decay in classical ML
    Ridge regression, decades-old technique carried into neural nets.
  2. 2012
    Dropout · Hinton et al.
    AlexNet uses it to win ImageNet. Deep learning suddenly generalises.
  3. 2015
    BatchNorm · Ioffe & Szegedy
    Speeds up training and adds mild regularisation. Enables ResNet-152.
  4. 2016
    LayerNorm · Ba, Kiros, Hinton
    Batch-independent normalisation. Enables RNNs and, later, transformers.
  5. 2017
    AdamW · Loshchilov & Hutter
    Decouples weight decay from Adam. Every transformer since 2019 uses it.
  6. 2020
    Weight standardisation, RMSNorm, DropPath
    The 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

1train
Training: random mask

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.

2scale
Scale to preserve expectation

Multiply the surviving activations by 1/(1−p). PyTorch does this ‘inverted dropout’ automatically. This is why inference needs no rescaling.

3eval
Inference: no mask

In eval mode, dropout is a NO-OP. All neurons fire, no scaling. This is why model.eval() matters.

4why
Interpretation: implicit ensemble

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

BatchNorm

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
LayerNorm

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
GroupNorm

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
RMSNorm

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

L2 penalty in the loss
loss = ce_loss + wd/2 · sum(w²). The gradient acquires an extra wd·w term. Standard for classical ML.
classical
SGD with weight decay
w -= lr · (g + wd·w). Mathematically identical to L2 penalty. This is what torch.optim.SGD(weight_decay=...) does.
sgd
Adam WITH weight decay (naive)
Add wd·w to the gradient, THEN divide by √v. Result: parameters with large gradients get weakly regularised. This is the subtle bug in old Adam.
buggy
AdamW · decoupled weight decay
w -= lr · (Adam update) — lr · wd · w. Weight decay is applied OUTSIDE the adaptive step. Uniform regularisation regardless of gradient magnitude.
correct
Recommended wd values
SGD on vision: 1e-4. AdamW on transformers: 0.01-0.1. Optimise it — this is a real hyperparameter, not a constant.
tune

Common misconception
✗ What most people think

"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."

✓ What is actually true

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.

Why the myth is so sticky

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.

Prove it to yourself

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 wrong
From first principles
Start with the question

Why 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.

  1. 1
    At 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
  2. 2
    During 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
  3. 3
    That 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
  4. 4
    The 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
  5. 5
    Therefore 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

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.

Mental modelRegularisation is anything that makes fitting noise harder than fitting signal

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.
🔔 Fires when you see

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.

The tradeoff

Your deep model overfits. Do you add augmentation, add explicit regularisation (dropout/weight decay), or shrink the model?

Data augmentation
+ you gain the only option that adds genuine information, because each transform encodes a real invariance of the domain — a rotated cat is still a cat. It reduces variance without adding bias, and it typically improves robustness to distribution shift, which no other regulariser does.
− you pay requires domain knowledge to choose valid transforms, and an invalid one actively teaches the model something false (flipping a digit horizontally is not the same digit); increases per-epoch compute; and it does not generalise to tabular data, where few label-preserving transforms exist
pick when the domain has known invariances — vision, audio, and to a lesser extent text — where this should be the first move, not the last
Explicit regularisation (weight decay, dropout)
+ you gain domain-agnostic, so it works when no invariance is known; one or two hyperparameters give continuous control over the bias-variance position; and weight decay in particular is nearly free and reliably helps across almost every architecture
− you pay adds bias by construction, so too much underfits; dropout slows convergence since each step trains a different sub-network; and the knobs interact with each other and with normalisation layers in ways that are not intuitive, so the search space is larger than it looks
pick when tabular or otherwise structure-free data, or as a cheap complement to augmentation — weight decay should essentially always be on
Shrink the model
+ you gain directly reduces variance, cuts training time, inference latency, and memory all at once — the only option that improves the operational profile as well as generalisation, which matters when you must actually serve it
− you pay a blunt instrument: you lose capacity everywhere rather than discouraging specific bad solutions, and if some part of the problem genuinely needed that capacity you have traded overfitting for underfitting. It also usually means retraining from scratch and re-tuning.
pick when the model is clearly oversized for the dataset (millions of parameters, thousands of examples), or inference cost is itself a constraint you need to fix anyway
What a senior engineer actually does

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

Line 25 · self.bn1 = nn.BatchNorm1d(256)
One BN layer per hidden linear. nn.BatchNorm1d for 1D features (MLPs, tabular). nn.BatchNorm2d for CNNs, LayerNorm for transformers.
bn
Line 27 · nn.Dropout(p=0.3)
30% of activations set to 0 during training. p=0.5 was the AlexNet default; modern practice uses 0.1-0.3. Higher for over-parameterised models, lower for small ones.
dropout
Line 35 · order: Linear → BN → ReLU → Dropout
The canonical block order. BN before activation is the ImageNet standard (Ioffe & Szegedy). Dropout AFTER activation. Deviations don't help and often hurt.
order
Line 74 · torch.set_grad_enabled(train)
One-liner that turns autograd on for training and off for eval. Combined with model.train()/eval(), the full training-eval switch is 2 lines.
mode
Line 88 · torch.optim.AdamW(..., weight_decay=wd)
AdamW applies weight decay decoupled from the adaptive step. Use this, not torch.optim.Adam(..., weight_decay=...), which is the buggy version.
adamw
Line 93 · gap = te_l - tr_l
The generalisation gap — how much worse test is than train. Higher gap = more overfitting. Lower gap (or negative — test better than train, means test set is easier) = better regularisation.
metric
Try itFeel the regularisation-strength trade-off

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.

💡 Hint · Too much regularisation → underfitting. Too little → overfitting. The sweet spot is problem-dependent.

(d) Production reality · 15 min

War story Every transformer research group · 2017-2019all BERT / GPT-2 style models
🔥 What broke

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.

🧯 The fix
AdamW: apply weight decay OUTSIDE the adaptive step. `w = w − lr·(Adam-update) − lr·wd·w`. Uniform regularisation regardless of gradient magnitude. Every transformer paper since 2019 (BERT-large, GPT-3, T5, LLaMA) uses AdamW.
🎓 Lesson to steal
Adam-with-L2 and AdamW are NOT the same optimiser. If you're training a transformer and using torch.optim.Adam(weight_decay=...), switch to torch.optim.AdamW today. The one-word change often improves final loss by 1-3%.
Post-mortem
War story Meta AI Research · early transformer experiments· 2018BERT-scale training
🔥 What broke
Team applies BatchNorm in a transformer, gets terrible convergence. BN's per-batch mean/variance assumes IID batches — transformer sequences have wildly variable statistics per batch (short vs long, different tokens), and BN's running stats never stabilise.
🧯 The fix

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.

🎓 Lesson to steal
Normalisation choice is architecture-specific. CNNs → BatchNorm (batches of images have similar statistics). Transformers → LayerNorm or RMSNorm (per-example normalisation, batch-independent). Never mix them up.
Post-mortem
War story A DL practitioner · every weekuniversal
🔥 What broke
Model achieves 98% training accuracy and 88% test accuracy. Team declares ‘overfitting’, adds dropout p=0.5, retrains. Now training is 90% and test is 87%. They add MORE dropout. Now training is 82% and test is 80%. Everything is worse.
🧯 The fix

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.

🎓 Lesson to steal
Before adding regularisation, check: is the train-test gap actually shrinking with more epochs, is your test set representative, and do you have enough data? If gap is stable across epochs and test perf is good, you're not overfitting — you're just at the ‘as-good-as-this-model-gets’ point.

Where this shows up in the rest of the plan

Regularisation is a foundational skill for every deep model to come
S102 · CNNs
ResNet uses BatchNorm + weight decay 1e-4 + SGD momentum. The vision standard.
S105 · Transformers
LayerNorm + Dropout 0.1 + AdamW weight decay 0.01. The NLP standard.
S117 · Fine-tuning LLMs
Regularisation via LoRA/QLoRA (subset of weights trained) rather than dropout.
S121 · LLM evaluation
Overfitting to eval set is a real problem. Held-out final eval + rotation of test sets over time.
S128 · MLOps monitoring
Watch train vs prod loss gap in production — if it grows, retrain or add regularisation.
S130 · Capstone
Every model in the review deck must justify its regularisation choices.

(e) Recall + stretch · 10 min

Quick recall · click to reveal
★ = stretch question

Explain-out-loud test

If you can't teach these three to a friend without notes, redo the session:

  1. What does dropout do at train vs at inference?
  2. Why do CNNs use BatchNorm and transformers use LayerNorm?
  3. 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.