Learn machine learning from scratch

Start at chapter 1 and build up: what a model is, how it measures its own mistakes, and how it learns. Everything is worked out in plain Python, with the maths shown rather than assumed. Two shorter tracks hold reference notes on the maths and on modern architectures.

Track Progress: 0 Completed
0%
Core CourseLEVEL 2 · INTERMEDIATEChapter 12

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.

42 min Prerequisites: Chapters 7, 8 and 9 (the Tensor class saved as tensor.py)
Inspect Architecture: Normalization layers

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

Chart of the root-mean-square size of the signal after each of 20 ReLU layers of width 64, on a logarithmic scale from 10⁻¹² to above 10¹². With starting weights of spread 1 the signal grows in a straight line to about 3 × 10¹⁴. With spread 0.05 it falls in a straight line to about 3 × 10⁻¹². With Xavier's spread, 1/√n, it falls more slowly, to about 3 × 10⁻⁴. With He's spread, √(2/n), it stays close to 1, ending at 0.27, and a shaded band shows ten random draws all staying between about 0.2 and 2.2.Chart of the root-mean-square size of the signal after each of 20 ReLU layers of width 64, on a logarithmic scale from 10⁻¹² to above 10¹². With starting weights of spread 1 the signal grows in a straight line to about 3 × 10¹⁴. With spread 0.05 it falls in a straight line to about 3 × 10⁻¹². With Xavier's spread, 1/√n, it falls more slowly, to about 3 × 10⁻⁴. With He's spread, √(2/n), it stays close to 1, ending at 0.27, and a shaded band shows ten random draws all staying between about 0.2 and 2.2.
Each layer multiplies the signal's size by about √(n·σ²/2). Only He's choice makes that factor 1; anything else compounds over the layers into vanishing or exploding signals.

2. The math

One layer, one unit: how the signal's size changes
z = Σⱼ wⱼ·hⱼ (n terms) weights independent, mean 0, spread σ E[z²] = n · σ² · E[h²]
Each term wⱼhⱼ has mean 0 and mean square σ²·E[h²], and independent terms add their mean squares. So z's size depends on n and σ together: wider layers need smaller weights.
ReLU keeps half
z symmetric around 0 ⇒ E[ReLU(z)²] = ½ · E[z²] per layer: E[h_l²] = (n · σ² / 2) · E[h_(l−1)²]
ReLU zeroes the negative half of z and keeps the positive half unchanged, so it keeps exactly half of the mean square. Stack the two rules and every layer multiplies the mean square by n·σ²/2.
Twenty layers of width 64
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¹⁴
RMS means √(mean of h²). After one layer the measurements match the factors exactly. Over twenty they drift a little low, because each finite layer's actual factor is slightly random (next box).
He initialization
σ = √(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
Derived in the first derivation. It keeps the signal of order 1, not exactly 1: multiplying twenty slightly random factors wanders, less so in wider layers. Compare the other scales, which move it by 11 or 14 orders of magnitude.
Xavier initialization, for tanh and linear layers
σ = √(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⁻³
Activations like tanh are nearly linear near 0 and don't throw away half the signal, so they need half He's variance. Using Xavier with ReLU is a common mistake that shows up only in deep networks.
The gradients obey the same rule, backwards
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¹⁴
The backward pass multiplies by the same weights, transposed, and ReLU′ passes half the units. Vanishing signals mean vanishing gradients, and the early layers stop learning.
A deep network on Chapter 9's days
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.001
Training loss only: the question here is whether the network can learn at all. (Driving the training loss to 0.001 on 150 days is memorization, as Chapter 11 showed; you'd stop much earlier.)
Layer normalization
for each example: μ = mean of its features, s² = their variance ĥ = (h − μ) / √(s² + ε) output = γ ⊙ ĥ + β γ, β: one learnable gain and shift per feature, starting at 1 and 0
Chapter 7's standardizing, applied inside the network to every example's hidden vector. γ and β let the network undo it where that helps. Batch normalization does the same per feature over the batch instead, which needs running averages at test time.
Normalization ignores the weights' scale
LN(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²
Multiplying W by c multiplies the layer's output and its spread by c, which the normalization divides out. Tiny weights therefore get huge relative steps, and huge weights tiny ones. Derived in the second derivation.
The deep network, with layer normalization
σ = 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)
Normalization rescues the exploding start. It doesn't rescue the tiny one: the forward signal is fine, but weights of size 0.01 take steps far larger than themselves. In practice people use both: He initialization and normalization layers.

• 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

1
Match the spread to the layer
For a layer with n inputs followed by ReLU, draw weights from a normal distribution with spread √(2/n). For tanh or no activation, use √(1/n). Start biases at zero.
2
Check the signal once
Before training, push a batch through and print each layer's RMS. Values that drift by orders of magnitude mean the initialization is wrong.
3
Add normalization in deep networks
Put a layer norm before (or after) each hidden layer's activation, with gain 1 and shift 0 to start. It keeps the signal's scale fixed whatever happens to the weights during training.
4
Keep the weights' scale sensible
Normalization makes the effective step depend on the weights' size. Don't start them tiny, and use some weight decay so they don't drift large.
5
Then tune as usual
With signals in range, the rest of the course applies: choose an optimizer and learning rate (Chapter 10) and watch the validation loss (Chapter 11).

4. The code (python)

core_ch12.py
# 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.

P1 What spread σ does He initialization give a ReLU layer with 128 inputs? (3 decimal places.)
σ = √(2/n).
Solution. √(2/128) = √(1/64) = 0.125.
P2 A unit sums n = 100 inputs, each with mean square E[h²] = 1, using weights of spread σ = 0.2. What is E[z²]?
E[z²] = n·σ²·E[h²].
Solution. 100 · 0.04 · 1 = 4, so the unit's RMS is 2: these weights are twice as large as they should be for a linear layer.
P3 With ReLU layers of width 50 and weights of spread 0.1, what does each layer multiply the signal's RMS by? (3 decimal places.)
√(n·σ²/2).
Solution. √(50 · 0.01 / 2) = √0.25 = 0.5.
P4 Same network, 10 layers deep. By what factor has the RMS changed after the last layer? (4 decimal places.)
Multiply the per-layer factor ten times.
Solution. 0.5¹⁰ = 1/1024 ≈ 0.001. The signal reaching the output is about a thousandth of its starting size.
P5 Layer norm on the features [2, 4, 6] (with ε ignored, γ = 1, β = 0). What is the normalized value of the 6? (3 decimal places.)
Mean 4, variance ((−2)² + 0² + 2²)/3 = 8/3.
Solution. (6 − 4) / √(8/3) = 2 / 1.633 ≈ 1.225. The three values become [−1.225, 0, 1.225].
P6 A layer's weights are multiplied by 4 and it's followed by layer norm. By what factor does the gradient with respect to the weights change?
∇L(c·W) = ∇L(W) / c.
Solution. 1/4 = 0.25. The output is unchanged, so the loss is too, but the same change in output now needs a four times larger change in weights.
P7 Xavier initialization with ReLU loses half the mean square per layer. What fraction of the starting RMS is left after 20 layers? Give it as a power of 10's exponent, rounded to the nearest whole number.
RMS factor √0.5 per layer, so 0.5¹⁰ in total.
Solution. 0.5¹⁰ = 1/1024 ≈ 10⁻³. The measured value in the chapter is 2.6 × 10⁻⁴.
P8 A dataset has class frequencies 1/2, 1/4, 1/4. What cross-entropy does a model get by always predicting those frequencies? (3 decimal places.)
−Σ pₖ ln pₖ.
Solution. −(0.5 ln 0.5 + 2 · 0.25 ln 0.25) = 0.347 + 0.693 = 1.040. A network stuck at this value has learned only how common each class is, like the σ = 0.01 runs at 1.044.

6. Go further

  1. 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?
  2. 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?
  3. 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

Answer all 5 questions correctly to complete the chapter · 0 / 5 done
Q1/5 Why does the size of the starting weights matter more in deep networks than in shallow ones?
Q2/5 Where does the 2 in He's σ = √(2/n) come from?
Q3/5 A 10-layer network with σ = 0.01 has training loss stuck at 1.044, exactly the score of predicting the class frequencies. What happened?
Q4/5 What does layer normalization do to each example's hidden vector?
Q5/5 Layer norm rescued the σ = 1 network but not the σ = 0.01 one. Why not?

Finished this chapter?