Deep Learning with TensorFlow and PyTorch

Forward and Backward Propagation


You have a network with 109,386 weights. It just predicted "3" for an image of an 8, and you would like it to do better. Which of those 109,386 numbers should go up, which should go down, and by how much?

There is an obvious way to find out. Take the first weight, add a tiny amount to it, run the whole network forward again, and see whether the error got smaller. If it did, that weight should go up. Then put it back and try the second weight. Then the third.

Cost the plan. One forward pass through that network is roughly 220,000 arithmetic operations (about 110,000 multiplications and as many additions). You need one per weight, so 109,386 × 220,000 ≈ 24 billion operations — to compute the gradient for a single training image. Multiply by 60,000 images, multiply by 30 passes over the data, and you are asking for about 4×10164 \times 10^{16} operations. Weeks of GPU time, for a model that trains in ninety seconds.

Backpropagation computes the exact same 109,386 numbers in about the cost of two forward passes. Not two per weight. Two, total. That factor of fifty thousand is not an optimisation; it is the difference between deep learning existing and not existing. What follows is how it works, worked through with real numbers small enough to check by hand.

One weight's share of the blame, by the chain ruleForward: inputsto predictionLoss compares itwith the labelGradientof lossw.r.t. outputMultiply backthrough each layerPer-weightgradient,then stepEvery backward quantity reuses an activation the forward pass already stored.
Backpropagation is not a second algorithm — it is the chain rule applied once per layer, right to left.

Forward propagation: what the network computes

Before you can assign blame you need to know what happened. The forward pass runs data through the layers, and it is worth writing out precisely because the backward pass is its mirror image.

For layer ll with weight matrix W(l)W^{(l)}, bias vector b(l)b^{(l)} and activation function ff:

z(l)=W(l)a(l−1)+b(l),a(l)=f ⁣(z(l))z^{(l)} = W^{(l)} a^{(l-1)} + b^{(l)}, \qquad a^{(l)} = f\!\left(z^{(l)}\right)

with a(0)=xa^{(0)} = x, the input. Two names to keep straight, because every backprop derivation depends on the distinction: zz is the pre-activation (the raw weighted sum) and aa is the activation (after the non-linearity).

In code, with a batch of examples stacked as rows:

Python
import numpy as npdef relu(z):    return np.maximum(0.0, z)def forward(x, params):    """x: (batch, 784). Returns prediction plus every intermediate value."""    cache = {"a0": x}    z1 = x  @ params["W1"] + params["b1"]      # (batch, 128)    a1 = relu(z1)    z2 = a1 @ params["W2"] + params["b2"]      # (batch, 64)    a2 = relu(z2)    z3 = a2 @ params["W3"] + params["b3"]      # (batch, 10) raw class scores    cache.update(z1=z1, a1=a1, z2=z2, a2=a2, z3=z3)    return z3, cache

Note the cache. Every intermediate value is kept, not thrown away. This is not sloppiness — the backward pass needs a(l−1)a^{(l-1)} to compute the gradient of W(l)W^{(l)}, and it needs z(l)z^{(l)} to compute the derivative of the activation. That cache is why training uses several times more memory than inference does, and it is why "reduce the batch size" is the first thing anyone suggests when you hit an out-of-memory error.

The forward pass is not just a prediction. It is also the recording of everything the backward pass will need.

The chain rule, which is the whole trick

A network is a composition of functions. The loss depends on the output, the output depends on the last layer's weights and on the previous activations, which depend on earlier weights, and so on back to the input. Calculus has exactly one tool for differentiating a composition, and it is the chain rule:

∂L∂w=∂L∂a⋅∂a∂z⋅∂z∂w\frac{\partial L}{\partial w} = \frac{\partial L}{\partial a} \cdot \frac{\partial a}{\partial z} \cdot \frac{\partial z}{\partial w}

Read it as a chain of "how much does this affect the thing in front of it". A change in ww changes zz by some rate; that change in zz changes aa by some rate; that changes the loss by some rate. Multiply the rates and you get the end-to-end rate.

The insight that makes it fast: the middle terms are shared. Every weight in layer 2 needs "how much does LL change when layer 2's output changes". Compute that quantity once, store it, and reuse it for all of layer 2's weights — and again, propagated one step further back, for all of layer 1's weights. That stored quantity is conventionally called δ\delta, and computing it layer by layer from the output backwards is the entire algorithm.

A complete numerical example

Take the smallest network that still has everything: one input, one hidden unit with ReLU, one linear output, squared-error loss.

Text
x ---[w1, b1]---> z1 ---ReLU---> a1 ---[w2, b2]---> yhat ---> L = (yhat - y)^2x  = 1.0     w1 = 0.5    b1 = 0.1y  = 1.0     w2 = -0.3   b2 = 0.2

Forward

QuantityComputationValue
z1z_10.5×1.0+0.10.5 \times 1.0 + 0.10.6
a1a_1max⁡(0,0.6)\max(0, 0.6)0.6
y^\hat{y}−0.3×0.6+0.2-0.3 \times 0.6 + 0.20.02
LL(0.02−1.0)2(0.02 - 1.0)^20.9604

Backward

Start at the loss and walk backwards. Each row uses the row above it.

GradientFormulaArithmeticValue
∂L/∂y^\partial L/\partial \hat{y}2(y^−y)2(\hat{y} - y)2(0.02−1)2(0.02 - 1)−1.96
∂L/∂w2\partial L/\partial w_2∂L/∂y^×a1\partial L/\partial \hat{y} \times a_1−1.96×0.6-1.96 \times 0.6−1.176
∂L/∂b2\partial L/\partial b_2∂L/∂y^×1\partial L/\partial \hat{y} \times 1—−1.96
∂L/∂a1\partial L/\partial a_1∂L/∂y^×w2\partial L/\partial \hat{y} \times w_2−1.96×−0.3-1.96 \times -0.30.588
∂L/∂z1\partial L/\partial z_1∂L/∂a1×ReLU′(z1)\partial L/\partial a_1 \times \text{ReLU}'(z_1)0.588×10.588 \times 1 (since z1>0z_1 > 0)0.588
∂L/∂w1\partial L/\partial w_1∂L/∂z1×x\partial L/\partial z_1 \times x0.588×1.00.588 \times 1.00.588
∂L/∂b1\partial L/\partial b_1∂L/∂z1×1\partial L/\partial z_1 \times 1—0.588

Now take one gradient-descent step with learning rate 0.1, moving each parameter against its gradient:

ParameterOldGradientNew = old − 0.1 × gradient
w1w_10.50.5880.4412
b1b_10.10.5880.0412
w2w_2−0.3−1.176−0.1824
b2b_20.2−1.960.3960

Run the forward pass again with the new values: z1=0.4824z_1 = 0.4824, a1=0.4824a_1 = 0.4824, y^=0.308\hat{y} = 0.308, and L=0.4788L = 0.4788. The loss fell from 0.9604 to 0.4788 in one step. That is the entire training algorithm, and everything else in deep learning is this loop with more parameters and better bookkeeping.

Two details in that table are worth pausing on. First, ∂L/∂w2=−1.96×0.6\partial L/\partial w_2 = -1.96 \times 0.6 — the gradient of a weight is the incoming activation times the error signal arriving from above. If the incoming activation is zero, the gradient is zero and that weight does not move at all. Second, ReLU′(z1)\text{ReLU}'(z_1) acted as a gate: it was 1 because z1z_1 was positive. Had z1z_1 been negative, the derivative would be 0 and the entire error signal would have been blocked from reaching w1w_1 and b1b_1.

The general algorithm, in matrix form

Scaled up to layers of many units, with ⊙\odot meaning element-by-element multiplication:

StepFormulaIn words
Output errorδ(L)=∇aL⊙f′(z(L))\delta^{(L)} = \nabla_{a} L \odot f'(z^{(L)})How wrong the final layer's pre-activations are
Weight gradient∂L/∂W(l)=(a(l−1))⊤δ(l)\partial L/\partial W^{(l)} = (a^{(l-1)})^{\top} \delta^{(l)}Incoming activation × outgoing error
Bias gradient∂L/∂b(l)=∑batchδ(l)\partial L/\partial b^{(l)} = \sum_{\text{batch}} \delta^{(l)}Just the error, summed over the batch
Propagate backδ(l−1)=(δ(l)(W(l))⊤)⊙f′(z(l−1))\delta^{(l-1)} = \left(\delta^{(l)} (W^{(l)})^{\top}\right) \odot f'(z^{(l-1)})Push error through the transposed weights, then through the activation's derivative

Written as NumPy, for the three-layer network from earlier:

Python
def backward(y_true, logits, cache, params):    batch = y_true.shape[0]    grads = {}    # Softmax + cross-entropy have a famously clean combined gradient:    # d(loss)/d(logits) = (predicted_probs - one_hot_true) / batch    probs = np.exp(logits - logits.max(axis=1, keepdims=True))    probs /= probs.sum(axis=1, keepdims=True)    delta3 = (probs - y_true) / batch                       # (batch, 10)    grads["W3"] = cache["a2"].T @ delta3    grads["b3"] = delta3.sum(axis=0)    delta2 = (delta3 @ params["W3"].T) * (cache["z2"] > 0)  # ReLU' is a 0/1 mask    grads["W2"] = cache["a1"].T @ delta2    grads["b2"] = delta2.sum(axis=0)    delta1 = (delta2 @ params["W2"].T) * (cache["z1"] > 0)    grads["W1"] = cache["a0"].T @ delta1    grads["b1"] = delta1.sum(axis=0)    return grads

Count the matrix multiplications: three going forward, five coming back (a weight gradient for each of the three layers, plus propagating δ\delta down through the top two — there is no need to send it into the input). Backprop costs roughly twice a forward pass — which is where the earlier claim comes from.

Why the division by batch size

Dividing δ(3)\delta^{(3)} by the batch size makes the gradient an average over the batch rather than a sum. Skip it and your effective learning rate scales with batch size: switch from batches of 32 to batches of 256 and your updates become eight times larger, which usually shows up as a loss that explodes to NaN within a few steps. It is a one-character bug with a dramatic symptom.

What autograd does instead

You will almost never write the code above. Both major frameworks record the operations performed during the forward pass and differentiate that recording automatically.

Python
import torchx = torch.tensor([1.0])w1 = torch.tensor([0.5],  requires_grad=True)b1 = torch.tensor([0.1],  requires_grad=True)w2 = torch.tensor([-0.3], requires_grad=True)b2 = torch.tensor([0.2],  requires_grad=True)y  = torch.tensor([1.0])z1   = w1 * x + b1a1   = torch.relu(z1)yhat = w2 * a1 + b2loss = (yhat - y) ** 2loss.backward()          # walks the recorded graph backwardsprint(w1.grad, b1.grad)  # tensor([0.5880]) tensor([0.5880])print(w2.grad, b2.grad)  # tensor([-1.1760]) tensor([-1.9600])

Identical to the hand computation. TensorFlow does the same thing with an explicit recorder:

Python
import tensorflow as tfw1 = tf.Variable([0.5]); b1 = tf.Variable([0.1])w2 = tf.Variable([-0.3]); b2 = tf.Variable([0.2])x = tf.constant([1.0]); y = tf.constant([1.0])with tf.GradientTape() as tape:    a1   = tf.nn.relu(w1 * x + b1)    yhat = w2 * a1 + b2    loss = tf.square(yhat - y)grads = tape.gradient(loss, [w1, b1, w2, b2])print([g.numpy() for g in grads])   # [0.588, 0.588, -1.176, -1.96]

The mistake everyone makes once. PyTorch accumulates gradients into .grad rather than replacing them. Forget optimizer.zero_grad() at the top of your training loop and step nn uses the sum of the gradients from steps 1 through nn. The loss will not error; it will simply behave as though the learning rate is growing without bound, and diverge.

Checking that a hand-written gradient is right

If you ever do implement a custom backward pass, verify it numerically before trusting it. The finite-difference method that was hopeless as a training algorithm is perfectly good as a test, because you only run it on a handful of parameters once.

Python
def gradient_check(f, params, key, index, eps=1e-5):    """Compare an analytic gradient against a central-difference estimate."""    original = params[key].flat[index]    params[key].flat[index] = original + eps    loss_plus = f(params)    params[key].flat[index] = original - eps    loss_minus = f(params)    params[key].flat[index] = original    numeric = (loss_plus - loss_minus) / (2 * eps)    return numeric# Relative error below 1e-7 means correct; above 1e-4 means a real bug.rel_err = abs(numeric - analytic) / max(1e-12, abs(numeric) + abs(analytic))

Use the central difference (f(x+ϵ)−f(x−ϵ))/2ϵ(f(x+\epsilon) - f(x-\epsilon)) / 2\epsilon, not the one-sided version — its error shrinks as ϵ2\epsilon^2 rather than ϵ\epsilon, which matters when you are fighting floating-point noise. And do not check ReLU networks at points near zero, where the derivative genuinely does not exist and the numeric estimate will disagree for legitimate reasons.

Why gradients die on the way back

Look again at the propagation rule: δ(l−1)=(δ(l)W⊤)⊙f′(z(l−1))\delta^{(l-1)} = (\delta^{(l)} W^{\top}) \odot f'(z^{(l-1)}). Going back one layer multiplies the error signal by weights and by the activation's derivative. Going back twenty layers multiplies by twenty such factors.

If each factor is typically 0.25 — which is exactly the maximum derivative of the sigmoid function — then after 20 layers the signal has been multiplied by 0.2520≈10−120.25^{20} \approx 10^{-12}. The early layers receive a gradient indistinguishable from zero and stop learning entirely. This is the vanishing gradient problem, and it is the direct reason deep sigmoid networks failed to train through the 1990s.

If each factor is typically 1.5, then 1.520≈33001.5^{20} \approx 3300 and the early layers receive a gradient thousands of times too large, producing weight updates that overshoot into numerical overflow. That is the exploding gradient problem, and it announces itself as a loss of nan.

SymptomLikely causeStandard remedy
Early layers' weights barely change; loss plateaus earlyVanishing gradientsReLU-family activations, He initialisation, residual connections, batch normalisation
Loss becomes nan or inf within a few stepsExploding gradientsGradient clipping by global norm, lower the learning rate, check input scaling
Loss oscillates wildly without divergingLearning rate too highReduce the learning rate; add a warm-up schedule

ReLU's derivative is exactly 1 for positive inputs. That single fact — no shrinking factor on the way back — is why replacing sigmoid with ReLU made deep networks trainable.

Using this when you are debugging at 2am

The reason to understand backprop, given that autograd writes it for you, is that gradients are the thing you inspect when training misbehaves. Print them.

Python
loss.backward()for name, p in model.named_parameters():    if p.grad is not None:        print(f"{name:20s} grad_norm={p.grad.norm().item():.3e}")

Reading that output tells you almost immediately what class of problem you have. Norms around 10−810^{-8} in the early layers and 10−210^{-2} in the late layers means vanishing gradients — change activations or initialisation. Norms of 10310^{3} or higher means exploding gradients — clip them. A gradient norm of exactly zero for a whole layer means that layer is either frozen, disconnected from the loss, or has been killed by dead ReLUs. And gradients that are None mean the tensor never entered the computation graph at all, usually because someone converted it to NumPy and back in the middle of the forward pass, silently severing the chain.

None of those diagnoses are available to you if backprop is a black box. They are all obvious the moment you picture the error signal being multiplied, layer by layer, on its way from the loss back to the input.