Deep Learning · Module 11 of 26 · 9 concepts · about 19 min

Normalization & Regularization

Stabilising activations and limiting memorisation without leaking validation information.

At work: Making models train reliably and hold up on real customers' data, not just the training set.

BatchNorm, LayerNorm, dropout and weight decay

What it is

Normalization controls activation scale and can make optimisation much less sensitive to the changing distributions produced by earlier layers. BatchNorm normalizes each feature channel using batch statistics and maintains running statistics for inference, which suits many CNNs; LayerNorm normalizes across features within each example, so its behaviour does not depend on the other examples in a batch and is standard in Transformers. Dropout randomly removes activations during training to discourage co-adaptation, while weight decay continuously penalises large weights. These techniques solve different problems and can coexist, but none repairs bad labels or leakage.

Why it matters

These techniques alter optimisation and generalisation in different ways. Knowing which is doing what prevents 'regularisation' from becoming a bag of rituals. In particular, small or uneven batches can make BatchNorm statistics noisy, while LayerNorm has no running-batch state; the architecture and batch regime should guide the choice.

In the real world: Standard tools that make models train faster and generalise better to new customers' data.

Visual example

Deep learning

Dropout changes behaviour by mode; normalization uses stable statistics at evaluation.

Training versus inference

  1. train modedropout randomly masks activations
  2. BatchNorm trainuses current batch statistics
  3. eval modedropout passes all activations
  4. BatchNorm evaluses accumulated running statistics

model.eval() changes layer behaviour; it does not disable gradients by itself.

import torch
import torch.nn as nn

block = nn.Sequential(
    nn.Linear(256, 512, bias=False),   # bias is redundant before BatchNorm
    nn.BatchNorm1d(512),               # optimisation: stabilise the scale
    nn.ReLU(),
    nn.Dropout(0.2),                   # generalisation: break co-adaptation
)
optimizer = torch.optim.AdamW(block.parameters(), lr=3e-4, weight_decay=0.01)

Leaving dropout active at inference. It intentionally makes predictions stochastic during training and must be disabled with model.eval() for normal evaluation and serving.

Batch normalization, worked out

What it is

nn.BatchNorm1d(f) normalizes each of the f features independently, using the mean and variance of that feature *across the batch*: x̂ = (x − μ_feature) / sqrt(σ²_feature + eps). It then applies two learned per-feature parameters, γ (scale) and β (shift), so the layer can undo the normalization if that is what the loss prefers — normalization constrains the optimisation path, it does not constrain what the network can represent. For a 4-D image tensor (N, C, H, W), BatchNorm2d treats each channel as one feature and averages over N × H × W positions, which is why its statistics come from N·H·W numbers rather than N.

Why it matters

It removes a specific obstacle: without it, a change to layer 3's weights shifts the distribution layer 4 receives, so layer 4 spends its updates chasing a moving target. Normalizing between them decouples the layers, which in practice permits noticeably larger learning rates and much less sensitivity to the initialization scale of module 10.

In the real world: Common in image models for defect detection and product tagging.

Visual example

BatchNorm1d(2)

BatchNorm computes statistics down each column — one mean and variance per feature, across the batch.

activations, batch of 4
feature 0feature 1
ex 01100
ex 13300
ex 25500
ex 37700
(x − μ_feature) / σ_feature, per column
normalized
feature 0feature 1
ex 0-1.34-1.34
ex 1-0.45-0.45
ex 20.450.45
ex 31.341.34

Feature 0

μ = (1 + 3 + 5 + 7) / 4 = 4

σ² = ((−3)² + (−1)² + 1² + 3²) / 4 = 5 -> σ = 2.236

x̂₀ = (1 − 4) / 2.236 = −1.34

then y = γ·x̂ + β, with γ and β learned per feature

Why this matters

The two columns started 100× apart and end identical. That is the whole mechanism: every feature reaches the next layer on the same scale, whatever the layer that produced it has drifted to.

Common misconception

Wrong idea: BatchNorm normalizes each example's features to mean 0.

Correct idea: it normalizes each feature across the batch. LayerNorm is the one that works within an example.

import torch
import torch.nn as nn

x = torch.tensor([[1.0, 100.0], [3.0, 300.0], [5.0, 500.0], [7.0, 700.0]])
bn = nn.BatchNorm1d(2)
bn(x)
# feature 0: mean 4, std 2.24 -> [-1.34, -0.45, 0.45, 1.34]
# feature 1: mean 400, std 224 -> the SAME [-1.34, -0.45, 0.45, 1.34]
# the 100x scale difference between the two features is gone

Adding a bias to the layer before a BatchNorm. Subtracting the batch mean deletes any constant offset, so that bias has no effect on the output and only wastes parameters and gradient — hence nn.Conv2d(..., bias=False) in front of every BatchNorm in standard architectures.

Practise it: Batch Norm Behaves Differently at Inference

BatchNorm's train/eval split

What it is

In train() mode BatchNorm uses the current batch's statistics, and updates a running mean and variance as a side effect of the forward pass. In eval() mode it uses those accumulated running statistics instead, so a prediction depends only on the input and not on whichever other examples happened to share its batch. This is not an optimisation: it is required for inference to be deterministic and for a batch of one to work at all. It also means BatchNorm's buffers are part of the model's state — state_dict() includes running_mean and running_var, and a checkpoint saved without them predicts differently.

Why it matters

This is one of the most common real bugs in deep-learning code, and it is silent. Validating without model.eval() scores each example against its batch-mates, which makes the metric depend on batch composition and shuffling; serving without it makes a single request's prediction depend on nothing meaningful at all. It ties directly to the .train()/.eval() boundary from module 9 — BatchNorm is the layer that makes that boundary consequential.

In the real world: Forgetting to switch modes makes a deployed image model give different answers for the same photo.

Visual example

Mode matters

The same input produces a different prediction depending on the mode — by design, and it is why eval() is mandatory.

BatchNorm behaviour by mode
modestatistics usedprediction depends on
train()this batch's μ, σ²the other examples in the batch
eval()running μ, σ²the input alone
train(), batch of 1σ² = 0 -> divide by epsunstable or an error
import torch

model.train()
for batch in train_loader:      # uses batch stats, updates running stats
    ...

model.eval()                     # switch to running stats
with torch.no_grad():            # separate concern: no autograd graph
    for batch in val_loader:
        logits = model(batch.x)

# running buffers travel with the weights
list(model.state_dict())[-2:]    # ['bn.running_mean', 'bn.running_var']

Assuming torch.no_grad() puts the model in eval mode. They are unrelated: no_grad stops gradient tracking, while eval() changes what BatchNorm and dropout *compute*. A validation loop with only no_grad() is still scoring in training mode.

LayerNorm versus BatchNorm

What it is

LayerNorm normalizes over the *feature* axis within a single example: for one token's 768-dimensional vector it computes that vector's own mean and variance and standardises it. Nothing about the calculation involves any other example, so there are no running statistics, no train/eval difference in what it computes, and a batch of size 1 behaves exactly like a batch of size 512. BatchNorm normalizes over the batch axis per feature; LayerNorm over the feature axis per example. Those are perpendicular directions through the same tensor.

Why it matters

It is why every Transformer (module 18) uses LayerNorm. Sequence models are trained with variable-length inputs, batch sizes of one during autoregressive generation, and heavy gradient accumulation — all regimes where BatchNorm's batch statistics are either noisy or undefined. LayerNorm also never couples two examples in a batch, so one bad example cannot shift another's activations, and training-time behaviour matches inference-time behaviour exactly.

In the real world: Why language models use LayerNorm: it works the same for one request or a thousand.

Visual example

Which axis

Both standardise to mean 0 and variance 1. They differ entirely in which numbers go into the mean.

LayerNorm: across a row

(one μ and σ² per example, computed across its 3 features)

BatchNorm: down a column

(one μ and σ² per feature, computed across the 4 examples)

a (batch=4, features=3) activation tensor
f0f1f2
ex 0220200
ex 1440400
ex 2660600
ex 3880800

LayerNorm's statistics never leave the example, so batch size — even 1 — cannot change them.

Common misconception

Wrong idea: Transformers use LayerNorm because it performs better.

Correct idea: they use it because it is defined for batch size 1 and variable-length sequences, and it never couples two examples in a batch to each other.

import torch
import torch.nn as nn

x = torch.randn(2, 5, 768)              # (batch, tokens, model_dim)
ln = nn.LayerNorm(768)                   # normalizes the last axis
out = ln(x)
out[0, 0].mean(), out[0, 0].std()        # ~0, ~1 for that ONE token

# batch of one behaves identically — BatchNorm cannot claim this
torch.allclose(ln(x[:1]), out[:1], atol=1e-6)   # True

Passing the wrong normalized_shape. nn.LayerNorm(5) on a (2, 5, 768) tensor is a shape error, and nn.LayerNorm([5, 768]) normalizes across tokens *and* features together — which mixes positions and is almost never what a sequence model wants.

Dropout and inverted scaling

What it is

nn.Dropout(p) zeroes each activation independently with probability p on every training forward pass, so the network cannot rely on any particular unit being present and must spread a feature across several units. That zeroing reduces the expected sum entering the next layer by a factor of (1 − p), which would make training and inference see systematically different magnitudes. PyTorch uses *inverted* dropout: it divides the surviving activations by (1 − p) during training, so the expected value is preserved and eval() mode can simply pass everything through unchanged with no rescaling at all.

Why it matters

The 1/(1−p) factor is the detail that makes the train/eval switch free, and knowing it prevents a whole class of confusion — notably 'why are my eval activations larger than my train activations' (they are not) and hand-written dropout that scales at the wrong time. It also explains why p=0.5 on a narrow layer is destructive: halving a 16-unit layer leaves 8 noisy units, while halving a 2048-unit layer is a mild perturbation.

In the real world: Stops a model memorising its training data, so it holds up on real customer inputs.

Visual example

Dropout(p=0.5)

Inverted dropout keeps the expected activation constant, which is what lets eval mode be a plain identity.

hidden activations
h0h1h2h3
1111
mask, then divide survivors by (1 − p)
train-mode output
h0h1h2h3
0220

Why divide by (1 − p)

E[output] = (1 − p) · x / (1 − p) = x -> unchanged

sum before: 4.0 sum after: 0 + 2 + 2 + 0 = 4.0

so eval() can pass activations through untouched

A different random mask is drawn on every forward pass, so two training passes over the same batch do not agree — which is the point.

import torch
import torch.nn as nn

drop = nn.Dropout(p=0.5)
x = torch.ones(1, 8)

drop.train()
drop(x)      # e.g. [0., 2., 2., 0., 0., 2., 2., 0.] — survivors doubled by 1/(1-p)

drop.eval()
drop(x)      # [1., 1., 1., 1., 1., 1., 1., 1.] — identity, no rescaling needed

Putting dropout immediately before the output layer of a regression model, or between a convolution and its BatchNorm. In both places it injects noise exactly where the model needs a stable signal; dropout belongs after the activation of a wide hidden layer.

Weight decay for neural networks

What it is

Weight decay adds a penalty on the size of the weights, pulling every weight slightly toward zero at each step unless the loss gradient actively opposes it. This is the same L2 idea as Ridge regression from the ML syllabus, applied to a network's weight matrices. Two deep-learning-specific details matter. First, use AdamW rather than Adam: classical L2 inside Adam gets divided by the adaptive per-parameter scale, so the effective decay varies unpredictably per parameter, while AdamW applies it as a separate decoupled step. Second, exclude biases and normalization parameters — shrinking a BatchNorm γ toward zero attenuates the layer's output for no regularisation benefit.

Why it matters

It is the cheapest regulariser available: one hyperparameter, no extra compute, and no change to the forward pass. But it is routinely applied wrongly, and the two wrong applications — plain Adam and decaying every parameter — both quietly weaken it rather than failing loudly, so the tuning sweep that follows explores a distorted axis.

In the real world: A simple way to keep a model from overfitting a company's limited data.

import torch
import torch.nn as nn

decay, no_decay = [], []
for name, param in model.named_parameters():
    if param.ndim == 1 or name.endswith(".bias"):   # norms and biases
        no_decay.append(param)
    else:
        decay.append(param)

optimizer = torch.optim.AdamW(
    [{"params": decay, "weight_decay": 0.01},
     {"params": no_decay, "weight_decay": 0.0}],
    lr=3e-4,
)

Adding an L2 term to the loss by hand *and* setting weight_decay on the optimiser. You now have twice the penalty you configured, and only one of the two is visible in your logged loss.

Data augmentation as a regulariser

What it is

Augmentation regularises the *data* rather than the model: each epoch shows a randomly transformed version of every example, so the model never sees the identical input twice and cannot memorise pixel-exact training images. Crops, flips, colour jitter and random erasing for vision; mixup and cutmix, which blend two examples and their labels; token dropping or paraphrase for text; time warping and noise for audio. The transformations must preserve the label — and must be applied to the training set only, because a validated or tested model should be measured on real inputs.

Why it matters

It is the one regulariser that adds information rather than only removing capacity: it encodes what you know about the task's invariances. Telling the model 'a flipped cat is still a cat' is a genuine prior, which is why augmentation often beats dropout and weight decay on small image datasets. It is also the regulariser most often applied incorrectly, with transforms that silently destroy the label.

In the real world: Rotating and recolouring a few thousand product photos makes the model work on real, messy customer uploads.

from torchvision import transforms

train_tf = transforms.Compose([
    transforms.RandomResizedCrop(224, scale=(0.6, 1.0)),
    transforms.RandomHorizontalFlip(),
    transforms.ColorJitter(0.2, 0.2, 0.2),
    transforms.ToTensor(),
    transforms.Normalize(MEAN, STD),
])
eval_tf = transforms.Compose([                 # deterministic: no randomness
    transforms.Resize(256), transforms.CenterCrop(224),
    transforms.ToTensor(), transforms.Normalize(MEAN, STD),
])

A label-destroying transform. RandomHorizontalFlip on digit or text images turns a 2 into something that is not a 2; an aggressive crop can remove the tumour from the scan. Every augmentation needs the question 'is the label still true after this?' answered explicitly.

Early stopping for deep nets

What it is

Early stopping tracks a validation metric each epoch, keeps the checkpoint that scored best, and halts once it has not improved for patience epochs. The ML syllabus introduced it as capacity control; a deep net adds two practical wrinkles. Validation curves are noisy, so patience=1 stops on ordinary fluctuation and wastes a run — a patience of 5-10 evaluations is normal. And because the final epoch is often not the best one, you must actually restore the saved best weights; a training loop that stops and then evaluates whatever is in memory reports the wrong model.

Why it matters

It does the work of a regulariser with no hyperparameter search over model capacity: the model is free to be large, and training simply stops at the epoch where it began to memorise rather than generalise. It is also the natural pairing for a warmup-and-decay schedule (module 8), where the last epochs make small refinements and the exact stopping point matters least.

In the real world: Saves GPU spend and gives a model that generalises better.

import copy

best, best_state, waited = float("inf"), None, 0
for epoch in range(max_epochs):
    train_one_epoch(model, train_loader)
    val = evaluate(model, val_loader)        # model.eval() lives in here
    if val < best - 1e-4:
        best, best_state, waited = val, copy.deepcopy(model.state_dict()), 0
    else:
        waited += 1
        if waited >= 8:
            break
model.load_state_dict(best_state)            # restore the BEST, not the last

Early stopping on the test set. The stopping epoch is then a decision fitted to test data, and the reported test score is optimistic by exactly as much as that decision helped. Stop on validation; touch test once, at the end.

Reading the train/validation gap

What it is

Two curves and their gap say which of this module's tools to reach for. Training loss falling while validation loss flattens and then rises is memorisation, and the gap is its size — the case regularisation addresses. Both curves high and close together is underfitting, where adding dropout makes things strictly worse. Both falling together with a small stable gap is a healthy run that should simply continue. The gap is a ratio to judge in context, not a number with a universal threshold: a 0.05 gap is large when the loss is 0.1 and negligible when it is 3.

Why it matters

It decides direction. Regularisation removes effective capacity, so applying it to an underfitting model is actively harmful — and 'the model is not doing well, add dropout' is a common reflex. Making the diagnosis explicit before choosing the tool is the same discipline as module 10's triage, one layer up.

In the real world: A big gap tells the team they need more data or more regularisation before launch.

Visual example

Regularisation effect

The same architecture, with and without regularisation. The training loss gets worse; the validation loss is what improves.

train (no regularisation)val (no regularisation)train (dropout + decay + augment)val (dropout + decay + augment)
unregularised run memorises0510152000.20.40.60.811.21.4epochlossbest unregularised val — stop hereregularised val keeps improving

Why this matters

It makes the trade explicit: regularisation deliberately gives up training fit to buy validation fit, and the two curves converging is the visible sign that it worked.

The unregularised train loss is the lowest curve on the plot and the worst model on the plot. Training loss is not the score.

history = {"train": [], "val": []}
for epoch in range(epochs):
    history["train"].append(train_one_epoch(model, train_loader))
    history["val"].append(evaluate(model, val_loader))

gap = history["val"][-1] - history["train"][-1]
print(f"train {history['train'][-1]:.3f}  val {history['val'][-1]:.3f}  gap {gap:.3f}")
# widening gap  -> augment, dropout, weight decay, or more data
# both high, no gap -> more capacity, longer schedule, better features

Comparing a train loss that includes dropout noise and weight decay against a clean validation loss and calling the difference the generalisation gap. The training number is measured under a harder, noisier objective; for a real comparison, evaluate the training set in eval() mode.