A Thousandfold Range In, 1.000 Out: What BatchNorm Pays for It
Part 14’s lede and its final section both said “BatchNorm is the next section”, and part 15’s closing already gave away the mechanism — “subtract the mean and divide by the standard deviation at every layer’s output, regardless of what happened upstream”. This part delivers on it.
In one line: initialisation only governs step 0; BatchNorm governs every step.
And it is the one operation in the whole network where samples affect each other — which is both its power and the source of all its trouble. Those two things take up roughly half this part each.
Kaiming predicts, BatchNorm measures
Part 14’s last section already explained why Kaiming stops holding: it assumes the input statistics are
known and stable, but layer 2’s input is layer 1’s output, so as soon as upstream moves, everything
computed downstream is void. Part 15 traced it to the root — the formula rests on “W independent of
the data” and “zero mean”, and both start decaying the moment training begins.
This part won’t repeat either. Just one contrast in framing:
| What it does | Does it decay | |
|---|---|---|
| Kaiming | predicts — works out σ_W before training and hopes it keeps holding | yes |
| BatchNorm | measures — takes a reading every step and forces the values into place | no |
How it satisfies two goals that were in conflict
Part 14 left a promise unpaid: “avoiding saturation wants z small, avoiding decay wants z large.
Kaiming can only compromise — what actually solves both at once is BatchNorm.”
Kaiming has exactly one knob, σ_W, and that knob controls both things at once, so a compromise is all
it can offer. BatchNorm splits it into two independent knobs:
- Every step,
zis pulled toσ = 1on the spot — non-saturation becomes unconditional, whateverσ_Whappens to be - Then
γputs back the width for whichever neurons genuinely need it — how much variance is needed is something the network learns
The compromise disappears because “guaranteeing the scale” and “deciding the scale” are no longer the same parameter’s job.
Two steps, one job each
A word first. In this series “normalisation” has meant softmax’s divide by the total (making a set of numbers sum to 1). BatchNorm does something else — subtract the mean, divide by the standard deviation, so a set of numbers has mean 0 and standard deviation 1. Below this is called standardising, and BatchNorm is abbreviated BN.
ẑ = (z − μ_batch) / σ_batch| Symbol | Shape | What it is |
|---|---|---|
z | (32, 200) | the weighted sum, before standardising |
μ | (1, 200) | the mean of each neuron over these 32 samples |
σ | (1, 200) | the same, for the standard deviation |
μ and σ are 200 numbers, not one. The next section is entirely about that.
“Standardising” looks like one step. It is two, and only the second one fixes anything.
⚠️ The note this is based on says saturation is “still 47%” after subtracting the mean. That does not hold. Sliding pushes points across the saturation line, and centring is in fact the minimum of the saturated fraction for a fixed
σ(48.2% atμ=1.8, 41.4% once centred).But its conclusion is right, and more valuable: subtracting the mean cannot change
σ, andσis what sets the saturated fraction — what takes 41.4% down to 0.8% is dividing by the standard deviation.
Subtracting the mean has its own separate justification: tanh is symmetric about the origin, and sitting to one side uses only half of it.
Both steps are affine, so neither changes the shape of the distribution. A bimodal input comes out bimodal; only the centre moves to 0 and the width becomes 1. (
zis already close to normal — that is part 15’s central limit theorem, and has nothing to do with BN.)
Why the target is μ=0, σ=1
| Reason | Detail |
|---|---|
| matches the activation | at σ=1, 99.7% falls within ±3, and ±3 is exactly where tanh saturates |
| survives stacking | σ=1 is the only fixed point — any other value diverges exponentially with depth |
| numerically safe | float32 has its best precision near 1 |
The second is the important one: part 14’s table of “ratio 1.5 per layer → 57× after ten” is exactly this.
Which axis
z.mean(0) # along the sample axis → (200,) ✅z.mean(1) # along the neuron axis → (32,) ❌z.mean() # everything as one pile → scalar ❌Statistics go per neuron, across every sample in the batch. The 200 neurons have learned entirely different features; averaging them together means nothing.
This is also the difference between W and z
| What the two axes mean | Can the shape be ignored | |
|---|---|---|
W | both are “a weight”, from one distribution | ✅ flatten it, W.std() |
z | sample × neuron | ❌ must be std(0) |
Part 15’s closing line that “the standard deviation of a matrix is a lazy phrase” holds only for W —
because randn generates each element independently, so it slices the same way in every direction.
z does not; its two axes are two different things.
Measured:
before BN, std per column: [3.712, 6.196, 3.600, 6.854, 5.480] ← all differentafter BN, std per column: [1.0, 1.0, 1.0, 1.0, 1.0 ] ← levelledγ and β: a learnable undo switch
Standardising alone would take away the network’s right to decide its own output distribution, so a learnable restoration follows:
y = γ · ẑ + β| Parameter | Shape | Initial value | Role |
|---|---|---|---|
bngain (γ) | (1, 200) | all 1 | scale |
bnbias (β) | (1, 200) | all 0 | shift |
Initially y = 1·ẑ + 0 = ẑ, which is doing nothing at all. But they are parameters, and training
pushes them.
They are ordinary parameters, with no magic
| Step | W1 | bngain |
|---|---|---|
| initialisation | randn * 0.3 | ones |
in parameters | ✅ | ✅ |
requires_grad | True | True |
| derivative | xᵀ · dz | Σ(dy · ẑ), structurally identical |
| update | p.data += -lr * p.grad | the same line of code |
The derivatives (summed over the 32 samples, since one batch shares one set of γβ):
∂L/∂γⱼ = Σᵢ (∂L/∂yᵢⱼ) · ẑᵢⱼ ∂L/∂βⱼ = Σᵢ (∂L/∂yᵢⱼ)γ corresponds to a weight, β to a bias, and the rules are copied straight from a linear layer — they are part 13’s two expressions.
Measured: they really do get trained away
bngain: mean 1.216 range [0.02, 3.08]bnbias: mean −0.016 range [−0.87, +1.59]They start as all 1s and all 0s, and by the end γ spans a factor of 154. That answers directly whether forced standardising breaks training:
| Neuron | γ | Meaning |
|---|---|---|
| one of them | 0.02 | the network switched it off (its output is nearly constant) |
| another | 3.08 | it needs three times the standard width |
γ close to 0 amounts to automatic pruning — some model-compression methods work precisely by finding channels with
γ ≈ 0and deleting those neurons.
Why it isn’t wasted work
Standardising is not a lock; it is a change of parameterisation:
| What decides the distribution | |
|---|---|
| without BN | W, indirectly — move W and the mean, variance and shape all change |
| with BN | γβ control scale and position directly; W only shapes |
Downstream layers always see a standard input distribution, and are never forced to relearn just because something upstream was nudged.
⚠️ The mechanism is still disputed. Part 14 named internal covariate shift; that was the 2015 original paper’s explanation. A 2018 MIT paper refuted it, arguing that what actually helps is BatchNorm smoothing the loss surface, with little to do with distributional drift.
Its effectiveness is agreed on; its mechanism is not settled. This section is about what it does, not why it works — the latter has no answer yet.
b1 disappears
((z + b) − mean(z + b)) / std(z + b) = (z − mean(z)) / std(z)Whatever constant b1 adds is subtracted right back out by the mean, so it has no effect on the
result and its gradient is 0. Shifting is bnbias’s job now.
A layer with BN needs no bias. In ResNet’s
Conv → BN → ReLUarrangement the convolutions all carrybias=False— for this reason, not as some optimisation trick.
Sample coupling
Only BN breaks sample independence in the whole network:
| Operation | Between samples |
|---|---|
x @ W1, tanh, softmax | independent — row 0 does not affect row 1 |
| each sample’s loss | independent; .mean() merges only at the last step |
| BatchNorm | ❌ coupled — it mixes samples together mid-chain |
Measured:
# inference mode: the same sample twicea = forward(Xtr[:1], training=False)b = forward(Xtr[:1], training=False)torch.allclose(a, b) # True ✅
# training mode: the same sample in different batchesr1 = forward(cat([Xtr[:1], Xtr[100:131]]), training=True)[0,0] # −3.8143r2 = forward(cat([Xtr[:1], Xtr[500:531]]), training=True)[0,0] # −4.3548Same sample, same parameters, different company — the output differs by 0.54.
⚠️ In a medical setting that is unacceptable: the same X-ray, sent in alongside a different set of X-rays, produces a different diagnosis.
But the bug turns out to be a feature
The random noise that coupling introduces acts as regularisation — which is why models using BN often need less dropout, or none. A side effect introduced to stabilise training quietly solves a different problem.
What about inference
Part 11 gave a definition of evaluation: evaluation = forward only, compute the loss once, update nothing — three differences from the training loop: no sampling, no backward, no update. That definition needs one more line here — BatchNorm is the first thing in this series that makes the forward pass itself differ between training and inference.
With one sample, BN simply breaks
At inference there is often exactly one sample (generate one name, a user uploads one scan):
z1 = [−1.406, −2.973, 0.255, 2.261]z.mean(0) = [−1.406, −2.973, 0.255, 2.261] ← the mean is the value itselfz.std(0) = [nan, nan, nan, nan] ← one number has no standard deviation| Case | Result |
|---|---|
ddof=1 (PyTorch’s default) | denominator n−1 = 0 → all nan |
ddof=0 | (z−z)/1e-5 → all 0; no error, but the model is useless |
BN is the only place in the whole chain that jams. x @ W1, tanh and softmax all handle a single
sample perfectly well — because they are per-sample independent. This is a direct consequence of the
previous section.
The fix: keep a running statistic while training
bnmean_running = torch.zeros((1, n_hidden)) # not a parameter, a recordbnstd_running = torch.ones((1, n_hidden))
with torch.no_grad(): # ❗ stays out of the graph bnmean_running = 0.999 * bnmean_running + 0.001 * zmean bnstd_running = 0.999 * bnstd_running + 0.001 * zstdEach step keeps 99.9% of the history and mixes in 0.1% of the current value. Expanded, it is a weighted average:
| How many steps ago | Weight |
|---|---|
| current | 1.000 |
| 100 steps | 0.905 |
| 1000 steps | 0.368 (decayed to 1/e) |
| 3000 steps | 0.050 |
The effective window is 1/(1−0.999) = 1000 steps = 32,000 samples, far steadier than a single
batch of 32.
A plain average is not used because it would remember the values from early training — at step 1 the model had just been initialised and that distribution is long obsolete. An EMA forgets old data automatically and drifts along with training.
Same trick as part 11’s smoothing of the loss curve: a single observation is noisy, so take a moving average to get a stable estimate.
At inference, fixed values
def forward(X, training=True): global bnmean_running, bnstd_running z = C[X].view(X.shape[0], -1) @ W1
if training: zmean = z.mean(0, keepdim=True) zstd = z.std(0, keepdim=True) with torch.no_grad(): bnmean_running = 0.999 * bnmean_running + 0.001 * zmean bnstd_running = 0.999 * bnstd_running + 0.001 * zstd else: zmean, zstd = bnmean_running, bnstd_running # ← fixed
z = bngain * (z - zmean) / (zstd + 1e-5) + bnbias return torch.tanh(z) @ W2 + b2At inference BN degenerates into a fixed affine transform, and samples become independent again — that 0.54 difference from the previous section disappears.
This is what model.train() / model.eval() are
| Mode | What BN does |
|---|---|
model.train() | uses the current batch’s statistics, and updates the running values |
model.eval() | uses the running values, updates nothing |
❗ Forgetting to call
model.eval()is one of the most common bugs in deep learning. Symptoms: a suspiciously low validation loss, the same sample predicting differently twice, results changing when the batch size changes. Dropout responds to the same two switches.
Four sets of numbers survive training, per BN layer
| How it is obtained | In parameters? | PyTorch’s name | |
|---|---|---|---|
| γ, β | learned by backpropagation | ✅ | parameter |
| running mean/var | collected during the forward pass | ❌ | buffer |
But both must be saved — state_dict() contains both kinds. Save γβ without the running values and
inference is completely broken after loading.
BN does not win on this network
| Version | val | Parameters |
|---|---|---|
| hand-tuned 0.2 | 2.0994 | 11897 |
| Kaiming | 2.1087 | 11897 |
| Kaiming + BN | 2.1140 | 12097 |
| BN + running | 2.1106 | 12097 |
The standardising itself works: z.std goes from 1.597 to 0.984 and saturation drops to 0.8%.
But the four versions differ by at most 0.0146, entirely inside the noise.
The parameter accounting is worth doing properly. Two numbers here look like they disagree:
γ and β, 200 each → +400b1 becomes useless here → −20011897 − 200 + 400 = 12097 → net +200“The cost is 400 parameters” and “the table only shows +200” are both right, about different things — the “b1 disappears” section above is what settles the account.
Why it doesn’t win
The problems it exists to solve — signal decay in deep networks, initialisation being hard to tune, distributions drifting between layers — none of them exist in a 2-layer network, while the cost (an extra statistic every step, batch noise, a second set of numbers to keep for inference) is paid immediately.
That is the third time this episode that a technique designed for depth pays nothing on a shallow network. The first two were Kaiming losing to a hand-picked 0.2, and widening the hidden layer making things worse.
Its real value is elsewhere
Three initialisations spanning a factor of a thousand, measured:
| σ_W | z.std before BN | z.std after BN |
|---|---|---|
| 0.01 | 0.054 | 1.000 |
| 1.0 | 5.296 | 1.000 |
| 10.0 | 53.084 | 1.000 |
A thousandfold difference in, identical out. That is the whole meaning of “rather than getting it
right, force it” — part 14 spent an entire article tuning σ_W, and BN turns that into something that
does not need tuning.
A corollary: after BN, the length of
W1carries no meaning, only its direction does — the scale gets eaten by the standardising. It is also why weight decay works differently on layers that have BN.
Two side effects
① It is a form of regularisation — the noise from sample coupling behaves like dropout, as above.
② It fails when the batch is too small — at batch = 2 the statistics are extremely noisy and BN
does more harm than good.
The second is the direct reason LayerNorm exists: it standardises along the feature axis (the horizontal direction marked ❌ in the figure above), which is independent of batch size and introduces no sample coupling.
The price is giving up cross-sample statistics as a source of information — but for sequence models each position’s features should be handled independently anyway, so that price is not really paid. Transformers use LayerNorm throughout, never BatchNorm.
The accounting here is clear: BN takes away sample independence and returns initialisation no
longer mattering. On a 2-layer network that trade is a bad one — the problems it solves have not shown
up yet, and the cost is paid in full. But every item in that cost — running statistics, model.eval(),
a batch that cannot be too small — is not a patch. Each one follows necessarily from the single decision
to let samples affect each other.
In part 14’s checklist, step ② was checking the initial loss and step ③ the per-layer σ ratio. After BN both of those answers are guaranteed unconditionally — the checklist has not become wrong, its first two rungs have simply been made redundant.