Starting Right: Initialization and Normalization
Stack twenty layers and the signal either dies out or blows up, unless the starting weights have exactly the right size. Work out that size, then meet normalization layers, which keep every layer's signal in range whatever the weights do.
Inspect Architecture: Normalization layers1. The idea
Chapter 8 showed that a network's starting weights have to be random, or every hidden unit stays a copy of every other. It left open how large that randomness should be. For a network with one or two layers almost any reasonable size works, which is why the question hasn't come up. For deep networks it decides whether training works at all.
The reason is compounding. Each layer multiplies its inputs by weights, adds them up and passes the result through a ReLU. If the weights are a little too small, the signal shrinks by some factor at every layer; if they're a little too large, it grows. Over twenty layers of width 64, starting weights of spread 0.05 shrink the signal to 3 × 10⁻¹², and weights of spread 1 blow it up to 3 × 10¹⁴. The gradients on the way back suffer the same fate, so the first layer's gradient is either 10⁻¹⁰ or 10¹⁵. Neither network can learn.
A short calculation gives the factor: each layer multiplies the signal's size by √(n·σ²/2), where n is the number of inputs to each unit, σ the spread of the weights, and the 2 comes from ReLU zeroing half of its inputs. Setting the factor to 1 gives σ = √(2/n), known as He initialization. With it the signal stays near 1 across all twenty layers. On a 10-layer network trained on Chapter 9's days with plain gradient descent, spread 0.01 never gets past predicting the class frequencies, spread 1 produces NaN in the first epoch, and He trains without trouble.
The other fix is to stop relying on the starting weights altogether. A normalization layer standardizes the signal inside the network, the way Chapter 7 standardized the inputs: subtract the mean, divide by the spread, then let the network rescale with two learnable numbers per unit. Layer normalization, the version used in transformers and in this course's capstone, rescues even the spread-1 network. It has one catch, which follows from its own strength: since it ignores the weights' scale, very small weights get very large effective steps.
2. The math
z = Σⱼ wⱼ·hⱼ (n terms) weights independent, mean 0, spread σ
E[z²] = n · σ² · E[h²]z symmetric around 0 ⇒ E[ReLU(z)²] = ½ · E[z²]
per layer: E[h_l²] = (n · σ² / 2) · E[h_(l−1)²]RMS factor per layer √(n·σ²/2) after 20 layers (factor²⁰ vs measured)
σ = 0.05: 0.283 1.1 × 10⁻¹¹ vs 2.9 × 10⁻¹²
σ = 1/√n: 0.707 9.8 × 10⁻⁴ vs 2.6 × 10⁻⁴
σ = √(2/n): 1 1 vs 0.27
σ = 1: 5.66 1.1 × 10¹⁵ vs 3.0 × 10¹⁴σ = √(2 / n_in) width 64: σ = 0.177
He's RMS after 20 layers, over 10 random draws:
width 64: 0.27 to 1.29 width 512: 0.68 to 1.42σ = √(1 / n_in) or √(2 / (n_in + n_out)) to balance both directions
with ReLU it loses half the mean square per layer: 0.707²⁰ ≈ 10⁻³dh_(l−1) = Wᵀ · (dh_l ⊙ ReLU′(z_l)) ⇒ the same factor √(n·σ²/2) per layer
first layer's gradient size: σ = 0.05: 1.5 × 10⁻¹⁰ Xavier: 0.0054
He: 3.9 σ = 1: 7.8 × 10¹⁴10 hidden layers of 32, plain gradient descent η = 0.1, 500 epochs
final training loss for 3 random starts:
σ = 0.01: 1.044, 1.044, 1.044 (= predicting the class frequencies)
σ = 1: NaN in the first epoch, every time
He: 0.001, 0.001, 0.001for each example: μ = mean of its features, s² = their variance
ĥ = (h − μ) / √(s² + ε) output = γ ⊙ ĥ + β
γ, β: one learnable gain and shift per feature, starting at 1 and 0LN(c·W h) = LN(W h) for any c > 0
⇒ the gradient with respect to W scales as 1/c
weights × 10: same output, gradient 28.59 → 2.859
relative step size ∝ η / c²σ = 1 with LN: 0.005, 0.015, 0.001 (trains: the explosion is gone)
He with LN: 0.007, 0.009, 0.002
σ = 0.01 with LN: 0.777, 1.044, 1.044 (unreliable)• Take one unit in layer l: z = Σⱼ wⱼ·hⱼ over its n inputs. Assume the weights are independent of each other and of the inputs, with mean 0 and variance σ².
• Each term has mean E[wⱼ]·E[hⱼ] = 0 and mean square E[wⱼ²]·E[hⱼ²] = σ²·E[h²]. Independent zero-mean terms add their variances, so E[z²] = n·σ²·E[h²].
• z is a sum of symmetric terms, so it's symmetric around 0. ReLU keeps the positive half and zeroes the rest, so E[ReLU(z)²] = ½·E[z²]. Note that it's the mean square that halves; the mean of ReLU(z) is not zero, which is why we track RMS rather than standard deviation.
• Putting the two together, E[h_l²] = (n·σ²/2)·E[h_(l−1)²]. After L layers the mean square has been multiplied by (n·σ²/2)ᴸ, which stays put only if n·σ²/2 = 1, that is σ = √(2/n). ∎
• The backward pass gives the same condition with n replaced by the number of outputs. For square layers like these the two agree; when they differ, frameworks let you choose which to preserve (fan-in or fan-out).
• Let a loss depend on W only through a normalized layer, L(W) = F(LN(W h)). Scaling the weights by c > 0 scales W h and its spread by c, and LN divides both out (up to the tiny ε), so L(c·W) = L(W).
• Differentiate both sides with respect to W. On the left the chain rule brings out a factor c: c · ∇L(c·W) = ∇L(W). So ∇L(c·W) = ∇L(W) / c. The code shows it: weights ten times larger, gradient ten times smaller.
• A gradient step changes W by η·∇L, which for weights of scale c has size proportional to η/c. Relative to the weights themselves, that's η/c².
• So under normalization the weights' scale acts as a learning-rate control: small weights take relatively enormous steps, large weights tiny ones. ∎ With σ = 0.01 and η = 0.1 the relative steps start roughly 10⁴ times larger than for weights of size 1, which is why that run thrashes.
• A second consequence: a gradient step on scale-invariant weights is perpendicular to them, so their size can only grow, slowly reducing the effective learning rate. Weight decay (Chapter 11) counteracts that, which is one reason the two are used together.
3. How it works
4. The code (python)
# Chapter 12: how big should the starting weights be, and how normalization keeps signals in range.
# Uses the Tensor class from Chapter 6, saved as tensor.py (see Chapter 7).
import numpy as np
from tensor import Tensor
# === 1. Push a signal through 20 ReLU layers ======================================
n, depth = 64, 20
X = np.random.default_rng(0).normal(size=(256, n)) # 256 random inputs, 64 features each
scales = [("σ = 0.05", 0.05), ("σ = 1/√n (Xavier)", 1 / np.sqrt(n)),
("σ = √(2/n) (He)", np.sqrt(2 / n)), ("σ = 1", 1.0)]
print("starting scale per-layer factor RMS after layer 1, 5, 10, 20 first-layer gradient")
for name, sigma in scales:
rng = np.random.default_rng(1)
Ws = [Tensor(rng.normal(0, sigma, (n, n))) for _ in range(depth)]
h, rms = Tensor(X), []
for W in Ws:
h = (h @ W.T).relu()
rms.append(float(np.sqrt(np.mean(h.data ** 2))))
(h.sum() / len(X)).backward() # any scalar will do: we only want the gradient's size
factor = np.sqrt(n * sigma ** 2 / 2)
print(f"{name:20} {factor:8.3f} {' '.join(f'{rms[i]:9.3g}' for i in (0, 4, 9, 19))}"
f" {np.sqrt((Ws[0].grad ** 2).sum()):9.3g}")
# He keeps the signal near 1, but each layer's factor is a little random at finite width
for width in [64, 512]:
Xw = np.random.default_rng(0).normal(size=(256, width))
finals = []
for seed in range(10):
rng, h = np.random.default_rng(seed), Xw
for _ in range(depth):
h = np.maximum(0, h @ rng.normal(0, np.sqrt(2 / width), (width, width)).T)
finals.append(np.sqrt(np.mean(h ** 2)))
print(f"He, width {width}: RMS after 20 layers over 10 random draws: {min(finals):.2f} to {max(finals):.2f}")
# === 2. Layer normalization, from Chapter 6's operations =============================
def layer_norm(h, gain, shift, eps=1e-5):
"""Standardize each row (one example's features) to mean 0, variance 1, then rescale and shift."""
average = Tensor(np.ones((h.shape[1], 1)) / h.shape[1]) # (features × 1): a row mean as a product
mean = h @ average
centred = h - mean
var = (centred * centred) @ average
return centred * (var + eps) ** -0.5 * gain + shift
h = Tensor(np.random.default_rng(2).normal(3.0, 5.0, size=(4, 8)))
out = layer_norm(h, Tensor(np.ones(8)), Tensor(np.zeros(8)))
print("\nlayer norm: row means", out.data.mean(axis=1).round(6) + 0.0, " row variances", out.data.var(axis=1).round(4))
# Normalization ignores the weights' scale, so their gradient shrinks as they grow
x = Tensor(np.random.default_rng(3).normal(size=(16, 8)))
W0 = np.random.default_rng(4).normal(size=(8, 8))
for c in [1.0, 10.0]:
W = Tensor(c * W0)
y = layer_norm(x @ W.T, Tensor(np.ones(8)), Tensor(np.zeros(8)))
loss = (y * Tensor(np.arange(8.0))).sum() # an arbitrary scalar of the output
loss.backward()
print(f"weights × {c:4}: loss {float(loss.data):.6f}, gradient size {np.sqrt((W.grad ** 2).sum()):.5f}")
# === 3. Training a deep network: Chapter 9's 150 days, 10 hidden layers of 32 ========
def make_days(n_days, seed):
r = np.random.default_rng(seed)
X = np.round(r.random((n_days, 3)), 2)
labels = np.where(X[:, 1] > 0.5, 2, np.where(X[:, 2] > 0.6, 1, 0))
noisy = r.random(n_days) < 0.1
labels[noisy] = r.integers(0, 3, noisy.sum())
return X, labels
X_days, labels = make_days(150, 9)
X_days = (X_days - X_days.mean(axis=0)) / X_days.std(axis=0) # standardized inputs (Chapter 7)
Y = np.eye(3)[labels]
def cross_entropy(Z):
E = (Z + (-Z.data.max(axis=1, keepdims=True))).exp()
P = E / (E @ Tensor(np.ones((3, 1))))
return -(Tensor(Y) * P.log()).sum() / len(Y)
def train_deep(sigma, normalize, seed, hidden=10, width=32, lr=0.1, epochs=500):
"""Plain gradient descent, full batch, so that only the starting scale differs between runs."""
rng = np.random.default_rng(seed)
sizes = [3] + [width] * hidden + [3]
layers, params = [], []
for fan_in, fan_out in zip(sizes, sizes[1:]):
s = np.sqrt(2 / fan_in) if sigma == "he" else sigma
W, b = Tensor(rng.normal(0, s, (fan_out, fan_in))), Tensor(np.zeros(fan_out))
layers.append((W, b))
params += [W, b]
norms = [(Tensor(np.ones(width)), Tensor(np.zeros(width))) for _ in range(hidden)] if normalize else []
for gain, shift in norms:
params += [gain, shift]
def forward():
h = Tensor(X_days)
for i, (W, b) in enumerate(layers):
h = h @ W.T + b
if i < hidden:
if normalize:
h = layer_norm(h, *norms[i])
h = h.relu()
return h
for epoch in range(epochs):
with np.errstate(all="ignore"):
loss = cross_entropy(forward())
if not np.isfinite(loss.data):
return "NaN at epoch %d" % epoch
for p in params:
p.grad = np.zeros_like(p.data)
loss.backward()
for p in params:
p.data = p.data - lr * p.grad
return f"{float(cross_entropy(forward()).data):.3f}"
print("\n10 hidden layers, plain gradient descent (η = 0.1), training loss after 500 epochs, 3 random starts")
print(f"(predicting the class frequencies scores {-(Y.mean(axis=0) * np.log(Y.mean(axis=0))).sum():.3f})")
for name, sigma, normalize in [("σ = 0.01", 0.01, False), ("σ = 1", 1.0, False), ("He", "he", False),
("σ = 1 with layer norm", 1.0, True), ("He with layer norm", "he", True),
("σ = 0.01 with layer norm", 0.01, True)]:
print(f" {name:26}", " ".join(train_deep(sigma, normalize, seed) for seed in range(3)))
5. Practice
Work these out on paper (or in Python) and type the number. Answers are checked with a small tolerance for rounding.
6. Go further
- Replace ReLU with tanh in the 20-layer experiment and measure the RMS per layer for Xavier and He spreads. Which one keeps the signal steady now? Then try σ = 1 with tanh: why doesn't the signal blow up the way it did with ReLU, and what happens to the gradients instead?
- Implement batch normalization: like layer_norm, but take the mean and variance of each feature over the batch (a product with a row of ones on the left). During training use the batch's statistics; keep running averages (0.9 old + 0.1 new) for evaluation. Train the 10-layer network with σ = 1 and batch norm, and evaluate it on the validation days from Chapter 11. What goes wrong if you use the batch statistics at evaluation time on a batch of one day?
- Find out how deep plain He initialization can go: train networks of 10, 20 and 40 hidden layers of width 32 on the 150 days with plain gradient descent, with and without layer norm. Where does each version stop training reliably?
7. Check yourself
In the catalog
Finished this chapter?