Deep Learning

Backpropagation

Backpropagation works out how much each weight contributed to the error, by walking backwards through the network and multiplying the effect of each step.

Read these first

On this page 6
  1. Why this had to be invented
  2. How it works
  3. Where you have already seen the result
  4. What is honestly hard here
  5. Remember this
  6. What to learn next

One lesson, three depths. Pick the one that fits you today — you can switch any time.

Beginner — No maths. Plain English.

Backpropagation is how a network works out which knobs caused the mistake, and by how much.

Think about the gears on a bicycle. You push the pedal once, the chain moves, and the rear wheel spins several times. Change to a different gear and that same pedal push produces a different amount of wheel spin.

To know how far the bicycle travels from one pedal push, you follow the effect through each stage. Each stage multiplies the movement by some amount.

A neural network is a long chain of stages like this. Backpropagation follows that chain backwards, from the mistake at the end to each individual knob.

Why this had to be invented

The previous lesson ended with a promise: the network is told which direction to move each knob. Here is why that is hard.

A knob in the first layer — the network's first row of stages — does not touch the answer directly. It feeds a neuron: one small unit that adds up its inputs and passes a single number onward. That neuron feeds another, which feeds twenty more. Only then does the answer appear.

So the question "should this knob go up or down?" has no local answer. Its effect depends on every stage it passes through afterwards.

The first idea most people reach for is to try every knob one at a time. It is also hopeless: a network with a million knobs would need a million full run-throughs for a single correction. Training would take years instead of hours.

Backpropagation gets every knob's answer in one backward walk. That single fact is why deep learning is practical at all.

How it works

Information travels forward to make a guess. Blame travels backward to fix it.

FORWARD  (making a guess)
   input  →  layer 1  →  layer 2  →  layer 3  →  guess  →  how wrong?

BACKWARD (sharing out the blame)
   input  ←  layer 1  ←  layer 2  ←  layer 3  ←──────────  the error
             "you were   "you were   "you were
              a little    somewhat    mostly
              to blame"   to blame"   to blame"

Each layer receives a message saying how much it contributed. It does two things with that message. It works out the correction for its own knobs, and it passes a modified message further back.

The message shrinks or grows as it travels, depending on the gears at each stage. That is the same multiplying-along-a-chain idea as the bicycle.

Where you have already seen the result

Every model you have used was trained this way. Think of the autocorrect on your phone, or the voice in your car's navigation. Each one had blame passed backwards through it, millions of times, before you touched it.

Backpropagation is not a model. It is the training procedure underneath nearly all of them.

What is honestly hard here

This is the hardest idea in the first half of deep learning. Almost nobody follows it on the first read, and plenty of working engineers could not derive it from scratch today.

Read it twice. That is the normal experience, not a sign you are behind.

Here is the good news, and it is genuine: you will almost never write backpropagation yourself. PyTorch and every other framework compute it for you, correctly, from the forward pass you wrote. You are learning it to understand what breaks and why — not because you will type it.

Remember this

  • Backpropagation answers: how much did each knob contribute to the error?
  • It walks backwards through the network, sharing blame stage by stage.
  • One backward walk handles every knob at once, which is why training is affordable.

What to learn next

Developer — Code and libraries.

The claim of backpropagation is that a backward pass gives the same answer as nudging every weight and re-measuring — but thousands of times faster. That claim is testable, so let us test it rather than trust it.

Setup

bash
pip install numpy

Hand-derived gradients, checked against brute force

backprop_check.py
import numpy as np

X = np.array([[0.5, -1.2]])     # one training example, two features
y = np.array([[1.0]])           # the correct answer

params = {
    "W1": np.array([[0.4, -0.7], [0.3, 0.2]]),
    "b1": np.array([[0.1, -0.1]]),
    "W2": np.array([[0.6], [-0.5]]),
    "b2": np.array([[0.05]]),
}


def forward(p):
    z1 = X @ p["W1"] + p["b1"]         # step 1: mix the inputs
    h = np.tanh(z1)                    # step 2: bend
    z2 = h @ p["W2"] + p["b2"]         # step 3: mix again
    out = 1.0 / (1.0 + np.exp(-z2))    # step 4: squash to a probability
    loss = float(-(y * np.log(out) + (1 - y) * np.log(1 - out)).mean())
    return loss, (z1, h, z2, out)


loss, (z1, h, z2, out) = forward(params)
print("FORWARD")
print("  z1 (mixed inputs) :", np.round(z1.ravel(), 6))
print("  h  (after tanh)   :", np.round(h.ravel(), 6))
print("  z2 (mixed again)  :", np.round(z2.ravel(), 6))
print("  out (probability) :", round(out.item(), 6))
print("  loss              :", round(loss, 6))

# ---- backward pass, derived by hand ----
n = len(X)
dz2 = (out - y) / n                    # sigmoid + log-loss collapse to this one clean term
gW2 = h.T @ dz2
gb2 = dz2.sum(axis=0, keepdims=True)
dh = dz2 @ params["W2"].T
dz1 = dh * (1 - h ** 2)                # (1 - tanh^2) is the slope of tanh
gW1 = X.T @ dz1
gb1 = dz1.sum(axis=0, keepdims=True)
analytic = {"W1": gW1, "b1": gb1, "W2": gW2, "b2": gb2}

print("\nBACKWARD (hand-derived)")
for k in ["W1", "b1", "W2", "b2"]:
    print(f"  d_loss/d_{k}:", np.round(analytic[k].ravel(), 6))

# ---- the honest check: nudge each number and watch the loss ----
eps = 1e-6
print("\nNUMERIC CHECK (nudge each weight by 1e-6, measure the loss change)")
worst = 0.0
for k in ["W1", "b1", "W2", "b2"]:
    num = np.zeros_like(params[k])
    for idx in np.ndindex(params[k].shape):
        saved = params[k][idx]
        params[k][idx] = saved + eps
        up, _ = forward(params)
        params[k][idx] = saved - eps
        down, _ = forward(params)
        params[k][idx] = saved
        num[idx] = (up - down) / (2 * eps)
    print(f"  numeric d_loss/d_{k}:", np.round(num.ravel(), 6))
    worst = max(worst, float(np.abs(num - analytic[k]).max()))

print(f"\nlargest disagreement: {worst:.3e}   (anything under 1e-7 means the maths is right)")
Output
FORWARD
  z1 (mixed inputs) : [-0.06 -0.69]
  h  (after tanh)   : [-0.059928 -0.597982]
  z2 (mixed again)  : [0.313034]
  out (probability) : 0.577626
  loss              : 0.548829

BACKWARD (hand-derived)
  d_loss/d_W1: [-0.126257  0.067835  0.303017 -0.162804]
  d_loss/d_b1: [-0.252514  0.13567 ]
  d_loss/d_W2: [0.025312 0.252572]
  d_loss/d_b2: [-0.422374]

NUMERIC CHECK (nudge each weight by 1e-6, measure the loss change)
  numeric d_loss/d_W1: [-0.126257  0.067835  0.303017 -0.162804]
  numeric d_loss/d_b1: [-0.252514  0.13567 ]
  numeric d_loss/d_W2: [0.025312 0.252572]
  numeric d_loss/d_b2: [-0.422374]

largest disagreement: 6.756e-11   (anything under 1e-7 means the maths is right)

The two blocks agree to eleven decimal places. The hand-derived backward pass is correct, and you did not have to take anyone's word for it.

Line by line, the parts that are not obvious

dz2 = (out - y) / n — the backward pass starts here, at the very end. Sigmoid paired with log-loss has derivatives that cancel into prediction - truth. The whole backward pass is seeded by this one subtraction.

gW2 = h.T @ dz2 — read the shapes. h.T is (2, 1) and dz2 is (1, 1), giving (2, 1), matching W2. A gradient always has the same shape as the thing it is a gradient of. If yours does not, you have a bug, and this check catches it faster than any amount of staring.

dh = dz2 @ params["W2"].T — this is the message travelling backward. Blame arrives at z2 and gets distributed to the hidden units in proportion to the weights that carried their values forward. A unit connected through a large weight gets more blame, which is the sensible outcome.

dz1 = dh * (1 - h ** 2) — the message passes back through the bend and gets scaled by the bend's local slope. This is the gear ratio from the Beginner tab, made concrete. Note that tanh saturates near its limits, where 1 - h**2 approaches zero — when that happens the message is multiplied by nearly nothing and stops travelling. That is the vanishing gradient, visible in one line of code.

(up - down) / (2 * eps) — the central difference, the definition of a derivative with a finite step. Its error shrinks as the square of eps, whereas a one-sided difference shrinks only linearly. This is the reference implementation: correct beyond doubt, and hopelessly slow.

Why the fast way wins

Count the forward passes. The numeric check runs two per parameter. This network has 9 parameters, so 18 forward passes for one gradient.

A modest network with a million parameters would need two million forward passes for a single training step. Backpropagation gets the identical answer with one forward and one backward pass, where the backward pass costs roughly twice the forward one. That is the difference between a model that trains overnight and one that never trains at all.

Common mistakes

Gradient check fails by a factor of exactly n. You divided by the batch size in one place and not the other. Make the loss an explicit mean and keep it consistent.

Gradient check fails only on the first layer. The error is in the term where the backward message crosses the activation — usually a missing or wrong local derivative.

Gradient check gives noise around 1e-2. Your eps is wrong for the precision you are using. Use 1e-6 with float64. Gradient checking in float32 is unreliable, because subtracting two nearly equal numbers destroys the significant digits.

Calling loss.backward() twice in PyTorch without zero_grad(). Gradients accumulate by design, to support gradient accumulation across mini-batches. Forget optimizer.zero_grad() and your gradients are the sum of every step so far. The loss curve looks strange and no error is ever raised.

Try it yourself

Change np.tanh to a ReLU in both the forward pass and the backward pass — the local slope becomes (z1 > 0) instead of (1 - h**2). Rerun the gradient check and confirm it still passes. Then deliberately break it: drop the (1 - h ** 2) factor and watch the disagreement jump to roughly 1e-1. That is what a real bug looks like.

What to learn next

Researcher — Mathematics and papers.

The chain rule, in the form actually used

Let the network be a composition $f = f_L \circ f_{L-1} \circ \cdots \circ f_1$ with scalar loss $\mathcal{L}$. Write $h^{(l)}$ for the output of stage $l$, and define the adjoint:

$$ \bar{h}^{(l)} \;\equiv\; \frac{\partial \mathcal{L}}{\partial h^{(l)}} $$

The backward recursion is:

$$ \bar{h}^{(l-1)} = \left( \frac{\partial h^{(l)}}{\partial h^{(l-1)}} \right)^{!\top} \bar{h}^{(l)} $$

Where:

  • $\dfrac{\partial h^{(l)}}{\partial h^{(l-1)}}$ — the Jacobian of stage $l$ with respect to its input.
  • $\bar{h}^{(L)} = \dfrac{\partial \mathcal{L}}{\partial h^{(L)}}$ — the seed, from differentiating the loss.

For an affine layer $h^{(l)} = W^{(l)} h^{(l-1)} + b^{(l)}$ followed by elementwise $\phi$, this becomes:

$$ \bar{z}^{(l)} = \bar{h}^{(l)} \odot \phi'!\left(z^{(l)}\right), \qquad \nabla_{W^{(l)}} \mathcal{L} = \bar{z}^{(l)} \left(h^{(l-1)}\right)^{!\top}, \qquad \bar{h}^{(l-1)} = \left(W^{(l)}\right)^{!\top} \bar{z}^{(l)} $$

Where $\odot$ is the elementwise (Hadamard) product and $z^{(l)}$ is the pre-activation.

Why reverse mode, and not forward mode

Both directions compute exact derivatives. The choice is purely about shape.

For $f : \mathbb{R}^{n} \to \mathbb{R}^{m}$:

  • Forward mode propagates a Jacobian-vector product (JVP). One pass yields one column of the Jacobian. Full Jacobian costs $O(n)$ passes.
  • Reverse mode propagates a vector-Jacobian product (VJP). One pass yields one row. Full Jacobian costs $O(m)$ passes.

Training has $n \sim 10^{9}$ parameters and $m = 1$ scalar loss. Reverse mode therefore costs a single pass; forward mode would cost a billion. This asymmetry, not any deeper property, is why every deep learning framework is built on reverse-mode automatic differentiation.

The cheap gradient principle — see Griewank and Walther (2008), Evaluating Derivatives — states that reverse-mode AD computes $\nabla f$ at a cost of at most $\approx 4 \times$ the cost of evaluating $f$, independent of $n$. In practice the backward pass runs at roughly $2 \times$ the forward FLOPs for dense layers.

The memory cost, which is the real constraint

Reverse mode must retain intermediate activations from the forward pass to evaluate Jacobians on the way back. Peak activation memory is:

$$ O!\left(B \sum_{l=1}^{L} d_l\right) $$

Where $B$ is batch size and $d_l$ the width of layer $l$. This scales linearly in depth, and it — not parameter count — is what exhausts GPU memory when training large models.

Gradient checkpointing (Chen et al., 2016, Training deep nets with sublinear memory cost) trades compute for memory: store only $O(\sqrt{L})$ activations and recompute the rest during the backward pass, giving $O(\sqrt{L})$ memory at roughly $1.3 \times$ the compute.

Historical record

The algorithm was derived independently several times before it reached deep learning:

  • Linnainmaa (1970) — master's thesis giving reverse-mode accumulation of rounding error, the earliest complete statement.
  • Werbos (1974) — PhD thesis applying it to neural networks.
  • Rumelhart, Hinton and Williams (1986), Learning representations by back-propagating errors, Nature 323 — the paper that made the field adopt it.

Calling it "the 1986 algorithm" is inaccurate; 1986 is when it became known, not when it was invented.

Numerical gradient checking, done properly

Use the central difference with relative error:

$$ \text{rel err} = \frac{\left| g_{\text{analytic}} - g_{\text{numeric}} \right|}{\max\left(\left|g_{\text{analytic}}\right|, \left|g_{\text{numeric}}\right|, \epsilon\right)} $$

Rules that matter:

  • Use float64. In float32, catastrophic cancellation swamps the signal.
  • Choose $h \approx 10^{-6}$. Total error is $O(h^2)$ from truncation plus $O(\epsilon_{\text{machine}}/h)$ from round-off, and $10^{-6}$ sits near the optimum for double precision.
  • Below $10^{-7}$ relative error, the gradient is correct. Above $10^{-3}$, it is wrong.
  • Check away from kinks. ReLU at exactly $z = 0$ is not differentiable and will produce a spurious failure.

torch.autograd.gradcheck implements this and should be your first move when writing a custom autograd.Function.

Known failure modes

  • Vanishing gradients — the product $\prod_l \phi'(z^{(l)}) |W^{(l)}|$ decays exponentially in $L$. Addressed by ReLU-family activations, residual connections (He et al., 2016), and normalisation layers.
  • Exploding gradients — the same product diverging. Addressed by gradient clipping (Pascanu et al., 2013, On the difficulty of training recurrent neural networks).
  • Weight transport — backpropagation requires the backward pass to use $\left(W^{(l)}\right)^{\top}$, the exact transpose of the forward weights. No biological mechanism is known for this. Lillicrap et al. (2016), Random synaptic feedback weights support error backpropagation, show that fixed random feedback matrices still permit learning, which weakens the biological objection without resolving it.

What to learn next