5  Backpropagation

Recall the update rule that ended Chapter 1: new parameters come from old parameters by stepping against the gradient, \(\vect{w}^{(t+1)} = \vect{w}^{(t)} - \eta\,\nabla_{\vect{w}}\loss\). For linear regression we could write that gradient down by hand. But a modern network has millions, sometimes billions, of knobs, tangled through layer after layer of composed functions \(\rightarrow\) computing each partial derivative naively, one at a time, is hopeless.

Backpropagation is the algorithm that computes all of them, exactly, in one backward sweep whose cost is a small constant multiple of the graph’s elementary operations. It is not a new kind of calculus. It is the chain rule, organized. By the end of this chapter you will have derived it, implemented it by hand, rebuilt it as a tiny scalar autograd engine, and then checked that torch.autograd agrees with you to machine precision.

5.1 One neuron, one chain

Start with the smallest possible case: one neuron on one training example. The previous activation \(a^{(l-1)}\) is multiplied by a weight \(w\), giving the pre-activation \(z = w\,a^{(l-1)} + b\); the activation function produces \(a = \sigma(z)\); and \(a\) feeds a loss \(L\).

Now turn the knob \(w\) a little. How much does \(L\) change? Follow the chain: turning \(w\) changes \(z\); the change in \(z\) changes \(a\); the change in \(a\) changes \(L\). The chain rule multiplies these local sensitivities:

\[ \frac{\partial L}{\partial w} = \frac{\partial L}{\partial a}\cdot \frac{\partial a}{\partial z}\cdot \frac{\partial z}{\partial w} . \tag{5.1}\]

Code: draw the computation graph
import matplotlib.pyplot as plt
import matplotlib.patches as mp

fig, ax = plt.subplots(figsize=(7.2, 2.4))
nodes = {r"$w$": 0, r"$z = w a^{(l-1)} + b$": 1, r"$a=\sigma(z)$": 2, r"$L$": 3}
for label, i in nodes.items():
    ax.add_patch(mp.FancyBboxPatch((i * 2.1, 0), 1.5, 0.7, boxstyle="round,pad=0.08",
                                   fc="#E8EEF7", ec="#232D4B", lw=1.4))
    ax.text(i * 2.1 + 0.75, 0.35, label, ha="center", va="center", fontsize=11)
fwd = dict(arrowstyle="-|>", color="#5379AA", lw=1.8)
bwd = dict(arrowstyle="-|>", color="#E57200", lw=1.8)
lbl = [r"$\frac{\partial z}{\partial w}$", r"$\frac{\partial a}{\partial z}$",
       r"$\frac{\partial L}{\partial a}$"]
for i in range(3):
    ax.annotate("", xy=(2.1 * (i + 1) - 0.1, 0.55), xytext=(2.1 * i + 1.6, 0.55),
                arrowprops=fwd)
    ax.annotate("", xy=(2.1 * i + 1.6, 0.12), xytext=(2.1 * (i + 1) - 0.1, 0.12),
                arrowprops=bwd)
    ax.text(2.1 * i + 1.85, -0.28, lbl[i], ha="center", fontsize=12, color="#E57200")
ax.set_xlim(-0.3, 8.3); ax.set_ylim(-0.7, 1.1); ax.axis("off")
plt.tight_layout(); plt.show()
Figure 5.1: Computation as a graph: the forward pass (top, blue) computes and caches values; the backward pass (bottom, orange) multiplies local derivatives along the same edges, in reverse.

Two things about this picture generalize to any network, however deep:

  1. The forward pass caches. Computing \(L\) produces the intermediate values (\(z\), \(a\)) along the way; we keep them, because the backward pass will need them.
  2. The backward pass reuses. Each edge contributes one local derivative, and the derivative of \(L\) with respect to anything is the product of local derivatives along the path back to it. Nothing is recomputed \(\rightarrow\) the whole sweep costs about as much as one forward pass.

5.2 The game of blame

For a full layer of neurons the bookkeeping threatens to become a tangled mess of sums. The cure is to pick the right intermediate quantity and track only that. At the output of the network the error is obvious: it is just the gap to the target. What is not obvious is how much of that error is the fault of a weight buried ten layers deep. Backpropagation sends this blame backward, from where it is obvious to where it is not.

NoteThe blame signal

Define, for each layer \(l\), the blame signal

\[ \delta^{(l)} \;:=\; \frac{\partial L}{\partial \vect{z}^{(l)}} , \]

the sensitivity of the loss to that layer’s pre-activations. It answers: how much did each neuron’s \(z\) contribute to the final loss?

Everything now unfolds in four short equations. With \(\odot\) denoting elementwise (Hadamard) product:

1. Start where blame is obvious (output layer \(L\)):

\[ \delta^{(L)} = \frac{\partial L}{\partial \vect{a}^{(L)}} \odot \sigma'(\vect{z}^{(L)}) . \tag{5.2}\]

For the half-squared-error convention \(L=\tfrac12\|\vect{a}^{(L)}-\vect{y}\|^2\), \(\partial L / \partial \vect{a}^{(L)}\) is exactly the prediction error \(\vect{a}^{(L)} - \vect{y}\). Other conventions contribute a known scale: mean squared error, for example, contributes \(2/n\) for \(n\) scalar outputs. In every case the output error is then gated by how sensitive the activation was to its input.

2. Propagate blame one layer back:

\[ \delta^{(l)} = \Bigl( (\matr{W}^{(l+1)})^\top \delta^{(l+1)} \Bigr) \odot \sigma'(\vect{z}^{(l)}) . \tag{5.3}\]

Blame flows backward through the same weights the signal flowed forward through, hence the transpose; then the local gate \(\sigma'(\vect{z}^{(l)})\) scales it. This recursion runs from the last layer to the first.

Code: compute and draw the blame flow
import torch
import matplotlib.pyplot as plt

torch.manual_seed(6050)
sizes = [3, 4, 4, 1]
Ws = [torch.randn(sizes[i + 1], sizes[i]) for i in range(3)]

x = torch.randn(3)                       # forward pass, cached
zs, a = [], x
for W in Ws[:-1]:                        # hidden layers: sigmoid
    zs.append(W @ a)
    a = torch.sigmoid(zs[-1])
zs.append(Ws[-1] @ a)                    # identity output, as in our 2-layer build

delta = [None] * 3                       # backward pass: eqs. 1 and 2
sp = lambda z: torch.sigmoid(z) * (1 - torch.sigmoid(z))
delta[2] = zs[2] - 1.0                               # eq. 1 (identity output, y = 1)
delta[1] = (Ws[2].T @ delta[2]) * sp(zs[1])          # eq. 2
delta[0] = (Ws[1].T @ delta[1]) * sp(zs[0])          # eq. 2 again

fig, ax = plt.subplots(figsize=(7, 3.2))
blames = [torch.full((3,), float("nan"))] + delta    # input layer: no blame needed
vmax = max(d.abs().max() for d in delta)
for col, (n_units, bl) in enumerate(zip(sizes, blames)):
    ys = torch.linspace(-1, 1, n_units + 2)[1:-1]
    mag = bl.abs() if col > 0 else torch.zeros(n_units)
    ax.scatter([col] * n_units, ys, s=200 + 2200 * mag / vmax,
               c=mag, cmap="Oranges", vmin=0, vmax=float(vmax),
               edgecolors="#232D4B", zorder=3)
    if col > 0:                                       # backward arrows
        prev = torch.linspace(-1, 1, sizes[col - 1] + 2)[1:-1]
        for yj in ys:
            for yi in prev:
                ax.plot([col, col - 1], [yj, yi], color="#B8B8B8",
                        lw=0.5, zorder=1)
ax.annotate("blame flows this way", xy=(0.4, 1.25), xytext=(2.2, 1.25),
            arrowprops=dict(arrowstyle="-|>", color="#E57200", lw=2),
            color="#E57200", va="center")
ax.set_xticks(range(4))
ax.set_xticklabels(["input", r"$\delta^{(1)}$", r"$\delta^{(2)}$", r"$\delta^{(3)}$"])
ax.set_yticks([]); ax.set_ylim(-1.4, 1.6)
for s in ax.spines.values(): s.set_visible(False)
plt.tight_layout(); plt.show()
Figure 5.2: Equations 1 and 2 in action on a real network (sizes 3–4–4–1, random weights, one example). Node size and color show each neuron’s blame \(|\delta_j|\): it starts at the output, where the error is obvious, and flows backward through \(W^\top\), shrinking wherever a sigmoid gate is nearly closed.

3. Convert blame into weight gradients:

\[ \frac{\partial L}{\partial \matr{W}^{(l)}} = \delta^{(l)} \, (\vect{a}^{(l-1)})^\top . \tag{5.4}\]

4. And into bias gradients:

\[ \frac{\partial L}{\partial \vect{b}^{(l)}} = \delta^{(l)} . \tag{5.5}\]

Equation 5.4 deserves a pause, because its shapes tell the story. With \(m\) neurons in layer \(l\) and \(n\) in layer \(l-1\): \(\delta^{(l)}\) is \((m \times 1)\), and \((\vect{a}^{(l-1)})^\top\) is \((1 \times n)\). Their outer product is an \(m \times n\) matrix, exactly the shape of \(\matr{W}^{(l)}\), and entry \((i,j)\) is the blame of neuron \(i\) times the activation of neuron \(j\) that fed it. The update for a weight is blame out times signal in:

Code: draw the outer product
d2 = delta[1]                              # layer-2 blame        (4,)
a1 = torch.sigmoid(zs[0])                  # layer-1 activations  (4,)
G = torch.outer(d2, a1)                    # eq. 3

fig, axes = plt.subplots(1, 3, figsize=(7.6, 2.6),
                         gridspec_kw={"width_ratios": [1, 4, 4.6]})
panels = [(d2[:, None], r"$\delta^{(2)}$"), (a1[None, :], r"$(a^{(1)})^\top$"),
          (G, r"$\partial L/\partial W^{(2)} = \delta^{(2)}(a^{(1)})^\top$")]
for ax, (M, title) in zip(axes, panels):
    ax.imshow(M, cmap="Oranges", vmin=-G.abs().max(), vmax=G.abs().max())
    for (i, j), v in [((i, j), M[i, j]) for i in range(M.shape[0])
                      for j in range(M.shape[1])]:
        ax.text(j, i, f"{v:.2f}", ha="center", va="center", fontsize=8)
    ax.set_title(title, fontsize=11); ax.set_xticks([]); ax.set_yticks([])
plt.tight_layout(); plt.show()
Figure 5.3: Equation 3, pictured with the blame and activations from the network above: a \((4 \times 1)\) blame column times a \((1 \times 4)\) activation row yields the full \((4 \times 4)\) gradient matrix in one stroke — entry \((i,j)\) is blame out \(\times\) signal in.

No messy summations: the matrix form performs them all at once, and it maps directly onto the parallel matrix multiplies GPUs are built for. The same four equations govern a toy network and a billion-parameter model; only the matrix sizes change.

5.3 Build it, then check it against autograd

Let us implement the four equations on a two-layer network, with no autograd anywhere, and then ask torch.autograd to differentiate the same network. If our blame algebra is right, the two answers must agree to machine precision.

Code
import torch

_ = torch.manual_seed(6050)
Code
n, d, h = 32, 5, 8                            # batch, input dim, hidden dim
X = torch.randn(n, d)
y = torch.randn(n)

W1, b1 = torch.randn(h, d) * 0.3, torch.zeros(h)
W2, b2 = torch.randn(1, h) * 0.3, torch.zeros(1)

# forward pass -- cache everything the backward pass will need
Z1 = X @ W1.T + b1                            # (n, h)
A1 = torch.sigmoid(Z1)                        # (n, h)
Z2 = A1 @ W2.T + b2                           # (n, 1)
L = ((Z2.squeeze() - y) ** 2).mean()

# backward pass -- the four equations (batch-averaged)
sig_prime = A1 * (1 - A1)                     # sigma'(z) = sigma(z)(1 - sigma(z))
delta2 = 2 * (Z2.squeeze() - y)[:, None] / n  # eq. 1 (identity output: no gate)
delta1 = (delta2 @ W2) * sig_prime            # eq. 2: blame through W^T, gated
grads = {                                     # eqs. 3 & 4: blame out x signal in
    "W2": delta2.T @ A1, "b2": delta2.sum(0),
    "W1": delta1.T @ X,  "b1": delta1.sum(0),
}

# same network, autograd's turn
params = {k: v.clone().requires_grad_(True) for k, v in
          {"W1": W1, "b1": b1, "W2": W2, "b2": b2}.items()}
A1_ = torch.sigmoid(X @ params["W1"].T + params["b1"])
L_ = (((A1_ @ params["W2"].T + params["b2"]).squeeze() - y) ** 2).mean()
L_.backward()

for k in grads:
    gap = (grads[k] - params[k].grad).abs().max()
    print(f"{k}: max |ours - autograd| = {gap:.2e}")
W2: max |ours - autograd| = 0.00e+00
b2: max |ours - autograd| = 0.00e+00
W1: max |ours - autograd| = 3.73e-09
b1: max |ours - autograd| = 1.86e-09

The largest discrepancy is \(3.73\times10^{-9}\), consistent with float32 rounding. The blame equations are what autograd computes; the framework simply builds the computation graph for us as we go and then walks it backward, caching and reusing exactly as we did.

5.4 A tiny scalar autograd engine

To remove the last trace of magic, let us build the graph ourselves. Each Value node remembers how it was made (its parents and the local derivative rule); calling backward() walks the graph in reverse topological order, accumulating blame into every node’s grad.

This teaching implementation is adapted from Andrej Karpathy’s micrograd, released under the MIT License. We use only the scalar reverse-mode pattern needed for this derivation; the upstream copyright and license notice are preserved in the repository’s third-party notices.

Code
class Value:
    """A scalar that remembers its history -- a node in the computation graph."""

    def __init__(self, data: float, parents=(), backward_rule=lambda: None):
        self.data = data
        self.grad = 0.0
        self._parents = parents
        self._backward_rule = backward_rule

    def __add__(self, other: "Value | float") -> "Value":
        other = other if isinstance(other, Value) else Value(other)
        out = Value(self.data + other.data, (self, other))
        def rule():                      # d(out)/d(self) = d(out)/d(other) = 1
            self.grad += out.grad
            other.grad += out.grad
        out._backward_rule = rule
        return out

    def __mul__(self, other: "Value | float") -> "Value":
        other = other if isinstance(other, Value) else Value(other)
        out = Value(self.data * other.data, (self, other))
        def rule():                      # product rule, one side at a time
            self.grad += other.data * out.grad
            other.grad += self.data * out.grad
        out._backward_rule = rule
        return out

    def sigmoid(self) -> "Value":
        s = 1 / (1 + 2.718281828459045 ** (-self.data))
        out = Value(s, (self,))
        def rule():                      # sigma' = s (1 - s), the gate
            self.grad += s * (1 - s) * out.grad
        out._backward_rule = rule
        return out

    def backward(self) -> None:
        order, seen = [], set()
        def topo(v):                     # children before parents
            if v not in seen:
                seen.add(v)
                for p in v._parents:
                    topo(p)
                order.append(v)
        topo(self)
        self.grad = 1.0                  # dL/dL = 1: blame starts here
        for v in reversed(order):
            v._backward_rule()
Code
w, x, b = Value(0.7), Value(2.0), Value(-0.5)
a = ((w * x) + b).sigmoid()             # one neuron, forward
loss = (a + (-0.3)) * (a + (-0.3))      # squared error vs target 0.3
loss.backward()

# analytic check: dL/dw = 2(a - y) * sigma'(z) * x
z = 0.7 * 2.0 - 0.5
s = 1 / (1 + 2.718281828459045 ** (-z))
print(f"micro-autograd: dL/dw = {w.grad:.6f}")
print(f"by hand:        dL/dw = {2 * (s - 0.3) * s * (1 - s) * 2.0:.6f}")
micro-autograd: dL/dw = 0.337801
by hand:        dL/dw = 0.337801

In a few dozen lines, it is the real thing: PyTorch’s autograd is this idea with tensors instead of scalars, a compiled graph walker, and thousands of derivative rules.

5.5 torch.autograd in practice: five rules

Autograd is doing exactly what we just built by hand, and knowing that mechanics tells you why each of its rules exists. These five cover nearly every autograd bug you will meet; the live-session notebook walks through each in more depth.

Rule 1 — by default, only leaf tensors retain gradients. A leaf is a tensor you created directly (your parameters); a non-leaf is the result of an operation (activations, losses). After backward(), leaves have .grad populated while intermediate blame is used and released. Call .retain_grad() on a non-leaf before backward() only when you explicitly need its gradient for inspection.

Rule 2 — gradients accumulate. backward() adds into .grad. Zero them before each step (optimizer.zero_grad()), or every update uses the sum of all past gradients.

Code
w = torch.randn(3, requires_grad=True)      # leaf: our parameter
y_hat = (w * 2).sum()                       # non-leaf: result of an operation

y_hat.backward()
print(f"leaf grad:      {w.grad.tolist()}")            # populated
print(f"non-leaf grad:  {y_hat.grad}")                 # None -- rule 1

(w * 2).sum().backward()                    # backward again, WITHOUT zeroing
print(f"after 2nd pass: {w.grad.tolist()}")            # doubled -- rule 2
leaf grad:      [2.0, 2.0, 2.0]
non-leaf grad:  None
after 2nd pass: [4.0, 4.0, 4.0]

Rule 3 — parameter updates happen outside the graph. For a raw leaf tensor, the update w = w - lr * w.grad inside autograd’s view records the update and rebinds w to a non-leaf, so its .grad will not be retained by default on the next pass. Assigning a plain tensor over a registered nn.Parameter may instead fail loudly. The safe idiom from the live session covers both cases:

with torch.no_grad():                # temporarily stop recording
    w -= lr * w.grad                 # update in place; w stays a leaf

Rule 4 — in-place operations can corrupt the tape. Methods ending in _ (add_, relu_) overwrite values that the backward pass may still need from the forward cache. Inside no_grad() parameter updates they are safe; anywhere else, prefer creating a new tensor.

Rule 5 — the graph lives until you let it go. Every forward pass builds a fresh graph, freed after backward(). But any tensor you keep (say, appending loss to a list for plotting) keeps its whole graph alive with it. Store loss.item() (a plain float) or loss.detach() instead: detach() returns the same value with the history cut.

WarningKeep updates outside the graph

Rules 1 and 3 combine into a classic raw-tensor bug: rebinding w = w - lr * w.grad creates a new non-leaf. Its .grad then comes back None by default on the next pass. A registered nn.Parameter assignment can raise an error instead. In either case, update the existing parameter under torch.no_grad().

5.6 The gate, and the problem with deep chains

Look again at Equation 5.3. Every backward step multiplies by \(\sigma'(\vect{z}^{(l)})\). That derivative acts as a gate on the blame: wide open, and the signal passes; nearly closed, and it dies.

Now examine the sigmoid’s gate. From \(\sigma'(z) = \sigma(z)(1-\sigma(z))\), its maximum value, at \(z = 0\), is exactly \(0.25\); away from zero the neuron saturates and the gate approaches \(0\). The sigmoid gate is at best a quarter open, and often nearly closed.

Code: plot the two gates
import matplotlib.pyplot as plt

z = torch.linspace(-6, 6, 300)
sig = torch.sigmoid(z)
fig, axes = plt.subplots(1, 2, figsize=(7.4, 2.9), sharey=True)
axes[0].plot(z, sig * (1 - sig), color="#232D4B")
axes[0].axhline(0.25, ls="--", color="#E57200", lw=1)
axes[0].text(2.1, 0.26, "ceiling: 0.25", color="#E57200", fontsize=9)
axes[0].set_title(r"sigmoid gate $\sigma'(z)$"); axes[0].set_xlabel("$z$")
axes[1].plot(z, (z > 0).float(), color="#232D4B")
axes[1].set_title(r"ReLU gate"); axes[1].set_xlabel("$z$")
plt.tight_layout(); plt.show()
Figure 5.4: Two gates. The sigmoid’s derivative never exceeds 0.25 and vanishes under saturation; ReLU’s is fully open (exactly 1) for every active neuron.

Here is the consequence, and it is one of deep learning’s most important failure modes. The chain rule multiplies local Jacobians across layers. The activation-derivative part of a sigmoid path contributes at most \(0.25\) per layer, so that part is bounded by \(0.25^k\) after \(k\) gates. Ten gates in, its ceiling is below one in a million. The weight matrices and branching paths also scale the full network Jacobian, so \(0.25^k\) is not a bound on the complete gradient. It isolates why repeated sigmoid gates strongly favor vanishing signals.

The fix is almost embarrassingly simple. \(\mathrm{ReLU}(z) = \max(0, z)\) has derivative \(1\) for \(z > 0\) and \(0\) for \(z < 0\). At the kink \(z=0\), PyTorch uses derivative \(0\); exact zeros can occur with zero biases or sparse activations. The gate is either fully open or fully closed. For every neuron that was active in the forward pass, the activation derivative contributes a factor of exactly one. The surrounding weight matrices can still scale the full signal, but the active ReLU does not shrink it: a gradient superhighway through the network. This, more than anything, is why ReLU and its variants are the default in deep networks.

Do not take the argument on faith; measure it. We build the same 30-layer network twice (sigmoid versus ReLU), give both copies the same He-scaled weights and input batch, and record how much gradient reaches each layer. We compute the diagnostic in float64 so the norm itself does not underflow; then we ask the practical float32 question: would a learning-rate-\(0.1\) update actually change the first layer’s stored weights?

Code: the depth experiment
from torch import nn

def gradient_diagnostic(
    activation: type[nn.Module], depth: int = 30, width: int = 64
) -> dict[str, object]:
    torch.manual_seed(6050)
    layers = []
    for _ in range(depth):
        layers += [nn.Linear(width, width), activation()]
    net = nn.Sequential(*layers, nn.Linear(width, 1)).double()
    with torch.no_grad():
        for layer in net:
            if isinstance(layer, nn.Linear):
                nn.init.normal_(layer.weight, std=(2 / layer.in_features) ** 0.5)
                nn.init.zeros_(layer.bias)

    out = net(torch.randn(128, width, dtype=torch.float64)).mean()
    out.backward()
    linears = [layer for layer in net if isinstance(layer, nn.Linear)][:-1]
    first = linears[0]
    grad32 = first.weight.grad.float()
    weight32 = first.weight.detach().float()
    changed = ((weight32 - 0.1 * grad32) != weight32).sum().item()
    return {
        "norms": [layer.weight.grad.norm().item() for layer in linears],
        "first_max": first.weight.grad.abs().max().item(),
        "first_nonzero": grad32.count_nonzero().item(),
        "first_changed": changed,
        "first_size": grad32.numel(),
    }

diagnostics = {}
plt.figure(figsize=(6.2, 3.4))
for act, color in [(nn.Sigmoid, "#232D4B"), (nn.ReLU, "#E57200")]:
    diagnostics[act.__name__] = gradient_diagnostic(act)
    plt.semilogy(diagnostics[act.__name__]["norms"], "o-", ms=3,
                 color=color, label=act.__name__)
plt.xlabel("layer (0 = closest to input)")
plt.ylabel(r"$\|\partial L / \partial W^{(l)}\|$")
plt.legend(); plt.tight_layout(); plt.show()

for name, result in diagnostics.items():
    print(f"{name:7s}: max|g0|={result['first_max']:.2e}; "
          f"nonzero={result['first_nonzero']}/{result['first_size']}; "
          f"changed={result['first_changed']}/{result['first_size']}")
Figure 5.5: Gradient reaching each layer of two matched 30-layer MLPs (float64 norm, log scale). With the same He-scaled weights and inputs, repeated sigmoid gates leave a nonzero but unusably small first-layer signal; ReLU preserves enough signal for a float32 update to change every first-layer weight.
Sigmoid: max|g0|=9.47e-17; nonzero=4096/4096; changed=0/4096
ReLU   : max|g0|=2.54e-02; nonzero=4096/4096; changed=4096/4096

Read the slopes, then read the update audit. The sigmoid first-layer entries are not mathematically or representationally zero: all 4,096 survive the float32 cast. They are so small, however, that subtracting \(0.1g\) changes none of the stored float32 weights. The ReLU copy changes all 4,096. That distinction matters: a displayed float32 norm can underflow while individual entries remain nonzero, and nonzero gradients can still be too small to move a parameter at its current magnitude. ReLU is not magic, but its open gates make the signal usable in this matched experiment.

5.7 Initialization: setting the signal’s energy

One more lever lives in this chapter, because it falls out of the same variance bookkeeping. Think of the network as a pipeline and the variance of the signal as its energy. If each layer multiplies the variance by more than one, activations explode after enough layers; by less than one, they vanish, and by Equation 5.3 the gradients follow. A good initialization keeps the energy roughly constant in both directions.

The analysis is one line. For \(z = \sum_{i=1}^{n_{\text{in}}} w_i x_i\) with independent, zero-mean inputs and weights, \(\var(z) = n_{\text{in}} \var(w) \var(x)\). Keeping \(\var(z) = \var(x)\) demands

\[ \var(w) = \frac{1}{n_{\text{in}}} \quad \text{(forward)}, \qquad \var(w) = \frac{1}{n_{\text{out}}} \quad \text{(backward)}. \]

Xavier initialization splits the difference for symmetric activations like tanh and sigmoid, \(\var(w) = 2/(n_{\text{in}} + n_{\text{out}})\). He initialization accounts for ReLU discarding half its input distribution by doubling the forward recipe, \(\var(w) = 2/n_{\text{in}}\). PyTorch applies sensible defaults for you; the difference between a good and a careless choice is smooth training versus a dead or exploding network:

Code: signal energy vs depth
from collections.abc import Callable

def signal_std(
    scale_fn: Callable[[torch.Tensor, int], torch.Tensor],
    depth: int = 40, width: int = 256
) -> list[float]:
    x = torch.randn(512, width)
    stds = []
    for _ in range(depth):
        W = scale_fn(torch.randn(width, width), width)
        x = torch.relu(x @ W)
        stds.append(x.std().item())
    return stds

torch.manual_seed(6050)
schemes = {
    "naive  (std = 0.05)": lambda W, n: W * 0.05,
    "naive  (std = 0.12)": lambda W, n: W * 0.12,
    "He  (std = sqrt(2/n))": lambda W, n: W * (2 / n) ** 0.5,
}
plt.figure(figsize=(6.2, 3.4))
for (name, fn), color in zip(schemes.items(), ["#5379AA", "#722F37", "#E57200"]):
    plt.semilogy(signal_std(fn), color=color, label=name)
plt.xlabel("layer"); plt.ylabel("std of activations")
plt.legend(); plt.tight_layout(); plt.show()
Figure 5.6: Signal energy through 40 ReLU layers. Careless scales lose or amplify the signal exponentially; He initialization holds it steady.

Initialization gives a stable start. Keeping the signal stable during training, as the weights move, is the job of normalization layers; and for very deep networks even that is not enough, and gradients get an architectural bypass: skip connections that add a block’s input straight to its output. Both belong to later chapters (normalization and the residual idea in Chapter 9, and layer normalization where it becomes essential, inside the Transformer of Chapter 14). What you should carry from here is the invariant they all serve: keep the signal, forward and backward, at constant energy.

5.8 Okay, so — the chain rule, organized

  1. The graph. Any network is a computation graph; the forward pass computes and caches, the backward pass multiplies local derivatives in reverse.
  2. The blame signal. \(\delta^{(l)} = \partial L / \partial \vect{z}^{(l)}\) turns the tangle into four equations: start at the output (Equation 5.2), propagate through \(\matr{W}^\top\) and the gate (Equation 5.3), then read off weight and bias gradients (Equation 5.4, Equation 5.5). The backward sweep costs a small constant multiple of the forward graph’s operations, for every gradient at once.
  3. We verified it: our hand-built equations match torch.autograd to \(10^{-8}\), and a tiny scalar Value class reproduces the machinery end to end.
  4. The gate rules the depth. The sigmoid-derivative product along a path is bounded above by \(0.25^k\) after \(k\) gates; ReLU’s open gate is a gradient superhighway. Initialization sets the signal’s energy so neither direction explodes or dies at the start.

Backpropagation is the engine under every remaining chapter. The next time a deep model fails to learn, your first questions now have names: is the gradient reaching the early layers? what is gating it? what is its energy?

Sources and further reading

Exercises

  1. (Pencil.) Derive the four blame equations for a network whose output layer uses the identity activation and squared-error loss (the network of our verification cell). Confirm that your \(\delta^{(2)}\) matches the delta2 line in the code.
  2. (Pencil.) In Equation 5.3, why does the blame flow through \((\matr{W}^{(l+1)})^\top\) rather than \(\matr{W}^{(l+1)}\)? Argue from shapes: what are the dimensions of \(\delta^{(l)}\), \(\delta^{(l+1)}\), and \(\matr{W}^{(l+1)}\)?
  3. (Code.) Extend the Value class with tanh and use it to train the one-neuron model from the check cell for 50 steps (write the update loop yourself). Verify the loss decreases.
  4. (Code.) In the depth experiment, give the sigmoid network Xavier initialization (nn.init.xavier_normal_ on each linear layer). How much of the vanishing does good initialization recover at depth 30? What does that tell you about why both fixes, initialization and ReLU, became standard?
  5. (Code.) Comment out optimizer.zero_grad() in any training loop from Chapter 1 and describe what happens to the loss curve. Explain it with one sentence about .grad accumulation.