Module A · Lecture 08

Backpropagation: The Core Algorithm

How a network turns a single scalar loss into a precise update for every weight — the chain rule, applied mechanically, layer by layer.

⏱ ~75 min 🧩 Builds on: Lectures 4–7 🎯 CO1
🧭 Why we're learning this now

Lecture 7 gave us the update rule \(W\leftarrow W-\eta\nabla_W L\) and simply assumed we could compute \(\nabla_W L\) whenever we needed it. We can't — not efficiently, not for a real network. This lecture closes that gap: it's the one piece of machinery that turns Lecture 6's loss function and Lecture 7's update rule into something actually trainable.

  • Explain why a naive/brute-force weight search is computationally infeasible, motivating backpropagation.
  • Derive gradients of a loss function with respect to every weight using the multivariate chain rule.
  • Trace a complete forward pass and backward pass through a small network with real numbers, by hand.
  • Write backpropagation in vectorized (matrix) form suitable for mini-batches.
  • Recognize common implementation pitfalls: shape mismatches, dead ReLUs, and forgetting the local gradient of the activation.

1. Why Backpropagation?

A neural network's job during training is to find the set of weights \(W\) that minimizes a loss function \(L(W)\). Lecture 7 showed that gradient descent does this by repeatedly moving weights in the direction opposite to the gradient: \(W \leftarrow W - \eta \nabla_W L\). That leaves one question unanswered: for a network with millions of weights spread across many layers, how do we actually compute \(\nabla_W L\) efficiently?

Backpropagation is not a different learning algorithm — it is simply an efficient, systematic application of the chain rule of calculus that computes every one of those gradients in roughly the same time as one forward pass. It was popularized for neural networks by Rumelhart, Hinton & Williams (1986) and remains the computational backbone of every deep learning framework in use today.

⚠ Without backprop

Estimating each gradient by numerically perturbing one weight at a time (\(\frac{\partial L}{\partial w_i} \approx \frac{L(w_i+\epsilon)-L(w_i)}{\epsilon}\)) would require a full forward pass per weight. A modest network with a million weights would need a million forward passes for a single update. Backpropagation gets all gradients in one backward pass.

2. Setting Up the Network

We'll use the exact notation from the course slides: input \(X\), first weight matrix \(W_1\) with bias \(b_1\), a ReLU-activated hidden layer \(A\), a second weight matrix \(W_2\) with bias \(b_2\), a sigmoid output \(Q\), and a loss \(L\) comparing \(Q\) against the true label \(y\).

A 2-input → 2-hidden (ReLU) → 1-output (sigmoid) network — the exact architecture we will hand-compute below. Click "Animate forward pass" to watch signal flow left→right.
SymbolMeaningShape (this example)
XInput features1 × 2
W1, b1Input→hidden weights, bias2 × 2, 1 × 2
Z1 = X·W1 + b1Hidden pre-activation1 × 2
A = relu(Z1)Hidden activation1 × 2
W2, b2Hidden→output weights, bias2 × 1, 1 × 1
Z2 = A·W2 + b2Output pre-activation1 × 1
Q = sigmoid(Z2)Predicted probability1 × 1
LBinary cross-entropy lossscalar

3. The Forward Pass

Every backward pass starts by first running forward and caching every intermediate value — we will need \(X\), \(Z_1\), \(A\), \(Z_2\), \(Q\) again during the backward pass.

$$Z_1 = XW_1+b_1 \qquad A=\text{ReLU}(Z_1) \qquad Z_2=AW_2+b_2 \qquad Q=\sigma(Z_2)=\frac{1}{1+e^{-Z_2}}$$

For a binary classification target \(y \in \{0,1\}\), the loss is binary cross-entropy:

$$L = -\big[y\log Q + (1-y)\log(1-Q)\big]$$

4. The Chain Rule, Formally

We want \(\partial L/\partial W_1\) and \(\partial L/\partial W_2\) (and the biases). \(L\) depends on \(W_2\) only through \(Z_2\), and on \(W_1\) through the chain \(Z_1 \to A \to Z_2\). The multivariate chain rule lets us decompose each gradient into a product of local derivatives:

$$\frac{\partial L}{\partial W_2} = \frac{\partial L}{\partial Q}\cdot\frac{\partial Q}{\partial Z_2}\cdot\frac{\partial Z_2}{\partial W_2} \qquad\qquad \frac{\partial L}{\partial W_1} = \underbrace{\frac{\partial L}{\partial Q}\cdot\frac{\partial Q}{\partial Z_2}}_{\delta_2}\cdot\frac{\partial Z_2}{\partial A}\cdot\frac{\partial A}{\partial Z_1}\cdot\frac{\partial Z_1}{\partial W_1}$$

The key algorithmic insight: \(\delta_2 = \frac{\partial L}{\partial Q}\cdot\frac{\partial Q}{\partial Z_2}\), once computed, is reused for every weight upstream of it. This is why backprop walks backward through the graph, layer by layer, instead of recomputing the whole chain for every individual weight.

Local gradients you'll reuse constantly
  • Sigmoid: \(\sigma'(z) = \sigma(z)(1-\sigma(z))\)
  • ReLU: \(\text{ReLU}'(z) = 1 \text{ if } z>0 \text{ else } 0\)
  • Sigmoid output + BCE loss combine beautifully: \(\frac{\partial L}{\partial Z_2} = Q - y\) (derived below)
Where does \(\partial L/\partial Z_2 = Q-y\) actually come from?

This shortcut gets used constantly, so let's derive it in full rather than take it on faith — it's four lines of algebra. Start from the two pieces we already have: \(L=-[y\log Q+(1-y)\log(1-Q)]\) and \(Q=\sigma(Z_2)\), and apply the chain rule \(\frac{\partial L}{\partial Z_2}=\frac{\partial L}{\partial Q}\cdot\frac{\partial Q}{\partial Z_2}\).

Piece 1 — \(\partial L/\partial Q\), by ordinary single-variable differentiation of the log terms (\(\frac{d}{dQ}\log Q=\frac1Q\), \(\frac{d}{dQ}\log(1-Q)=\frac{-1}{1-Q}\)):

$$\frac{\partial L}{\partial Q}=-\left[\frac{y}{Q}-\frac{1-y}{1-Q}\right]=-\frac{y}{Q}+\frac{1-y}{1-Q}$$

Piece 2 — \(\partial Q/\partial Z_2\) is just the sigmoid's own derivative, stated in the box above: \(Q(1-Q)\).

Multiply the two pieces and distribute \(Q(1-Q)\) into each term — watch the \(Q\)'s and \((1-Q)\)'s cancel:

$$\frac{\partial L}{\partial Z_2}=\left(-\frac{y}{Q}+\frac{1-y}{1-Q}\right)Q(1-Q) = -y(1-Q) + (1-y)Q$$

Now just expand the brackets and collect terms — this is where the simplification happens:

$$-y(1-Q)+(1-y)Q = -y+yQ+Q-yQ = -y+Q = \mathbf{Q-y}$$

The two \(yQ\) terms cancel exactly, leaving the strikingly simple \(Q-y\): the gradient flowing backward out of the output layer is literally just the prediction error. This is not a coincidence of this particular example — sigmoid activation paired with binary cross-entropy loss always produces this cancellation, which is precisely why that pairing is the standard choice for binary classifiers.

5. Backward Pass — Layer by Layer

With \(\delta_2=\partial L/\partial Z_2=Q-y\) in hand, the rest of the backward pass is mechanical: walk backward through the same equations used in the forward pass, one layer at a time, applying the chain rule at each step. Every gradient below is written out in full — the interactive recap beneath it replays the same five steps if you want to test yourself afterward.

Step 1 — δ2 = ∂L/∂Z2

Already derived above: \(\delta_2 = Q-y\). This single number (or vector, for a batch) is the seed that every other gradient in the network is built from.

Step 2 — Gradients for W2 and b2

Since \(Z_2=AW_2+b_2\), the local derivative of \(Z_2\) with respect to \(W_2\) is just \(A\) itself (transposed, so the shapes line up correctly for matrix multiplication — see Lecture 4, Section 6 for a full refresher on why):

$$\frac{\partial L}{\partial W_2}=\frac{\partial L}{\partial Z_2}\cdot\frac{\partial Z_2}{\partial W_2}=A^\top \delta_2 \qquad\qquad \frac{\partial L}{\partial b_2}=\delta_2$$

(The bias gradient is just \(\delta_2\) unchanged, because \(\partial Z_2/\partial b_2=1\) — adding a constant has a derivative of exactly 1.)

Step 3 — Propagate the error back into the hidden layer: ∂L/∂A

To keep walking backward past \(W_2\), push \(\delta_2\) back through \(Z_2\)'s dependence on \(A\) (again just \(W_2\), this time not transposed on the other side of the product):

$$\frac{\partial L}{\partial A}=\delta_2 W_2^\top$$

Step 4 — δ1 = ∂L/∂Z1 (apply ReLU's local gradient)

Recall \(A=\text{ReLU}(Z_1)\), whose derivative is 1 wherever \(Z_1>0\) and exactly 0 otherwise. We must multiply element-wise (written \(\odot\)) rather than as a matrix product, because ReLU acts independently on each entry of \(Z_1\) — there's no mixing between entries the way matrix multiplication would introduce:

$$\delta_1=\frac{\partial L}{\partial A}\odot \text{ReLU}'(Z_1)$$

In plain words: wherever a hidden unit was "off" during the forward pass (\(Z_1\le0\), so ReLU output it as 0), that unit gets zero gradient here too — it contributed nothing to the output, so it receives no blame or credit for the error.

Step 5 — Gradients for W1 and b1

Exactly the same pattern as Step 2, one layer earlier — the local gradient of \(Z_1\) with respect to \(W_1\) is the layer's own input, \(X\):

$$\frac{\partial L}{\partial W_1}=X^\top \delta_1 \qquad\qquad \frac{\partial L}{\partial b_1}=\delta_1$$

That's the whole algorithm: \(\delta_2\to(\partial W_2,\partial b_2)\to\partial A\to\delta_1\to(\partial W_1,\partial b_1)\) — five short steps, each one reusing the previous step's output, and each one requiring nothing more than a matrix multiply, a transpose, or an element-wise product. Try it yourself, one step at a time:

6. Worked Numerical Example

Now the exact same five steps, with real numbers, computed entirely by hand from start to finish. Input \(x=[0.5,\ 0.8]\), target \(y=1\), learning rate \(\eta=0.1\).

🔢 Initial parameters

$$W_1=\begin{bmatrix}0.3 & -0.1\\0.2 & 0.4\end{bmatrix}\quad b_1=[0.1,\,-0.2]\quad W_2=\begin{bmatrix}0.5\\-0.3\end{bmatrix}\quad b_2=0.05$$

Forward pass (see Lecture 4 for the row-by-column mechanics of every · below)

Z1 = x·W1 + b1 — one dot product per hidden unit:

$$Z_{1,1}=0.5(0.3)+0.8(0.2)+0.1=0.15+0.16+0.1=\mathbf{0.41}\qquad Z_{1,2}=0.5(-0.1)+0.8(0.4)-0.2=-0.05+0.32-0.2=\mathbf{0.07}$$

A = ReLU(Z1) — both values are already positive, so ReLU changes nothing: \(A=[0.41,\ 0.07]\).

Z2 = A·W2 + b2:

$$Z_2 = 0.41(0.5)+0.07(-0.3)+0.05 = 0.205-0.021+0.05 = \mathbf{0.234}$$

Q = σ(Z2):

$$Q=\sigma(0.234)=\frac{1}{1+e^{-0.234}}=\frac{1}{1+0.7913}=\frac{1}{1.7913}\approx\mathbf{0.5583}$$

Loss, with target \(y=1\) so only the first BCE term survives: \(L=-\log(0.5583)\approx\mathbf{0.5828}\).

Backward pass

Step 1 — δ2 = Q − y (the shortcut derived in Section 4):

$$\delta_2 = 0.5583-1=\mathbf{-0.4417}$$

Step 2 — ∂L/∂W2 = Aᵀ·δ2, one multiplication per hidden unit's contribution:

$$\partial W_2 = [0.41\times(-0.4417),\ \ 0.07\times(-0.4417)] = [\mathbf{-0.1811},\ \mathbf{-0.0309}] \qquad \partial b_2=\delta_2=\mathbf{-0.4417}$$

Step 3 — ∂L/∂A = δ2·W2ᵀ:

$$\partial A = -0.4417\times[0.5,\ -0.3] = [\mathbf{-0.2209},\ \mathbf{0.1325}]$$

Step 4 — δ1 = ∂A ⊙ ReLU'(Z1): both \(Z_{1,1}=0.41\) and \(Z_{1,2}=0.07\) were positive in the forward pass, so \(\text{ReLU}'(Z_1)=[1,1]\) and nothing is zeroed out here:

$$\delta_1 = [-0.2209,\ 0.1325]\odot[1,1] = [\mathbf{-0.2209},\ \mathbf{0.1325}]$$

Step 5 — ∂L/∂W1 = xᵀ·δ1 — this is an outer product: each of the 2 input features pairs with each of the 2 hidden units' error, giving a full 2×2 matrix:

$$\partial W_1 = \begin{bmatrix}0.5\times(-0.2209) & 0.5\times0.1325\\0.8\times(-0.2209) & 0.8\times0.1325\end{bmatrix}=\begin{bmatrix}\mathbf{-0.1105} & \mathbf{0.0663}\\\mathbf{-0.1767} & \mathbf{0.1060}\end{bmatrix} \qquad \partial b_1=\delta_1=[\mathbf{-0.2209},\ \mathbf{0.1325}]$$

Gradient-descent update (η = 0.1)

Every parameter moves opposite its gradient, scaled by the learning rate — \(W\leftarrow W-\eta\,\partial W\):

$$W_2^{new}=[0.5-0.1(-0.1811),\ -0.3-0.1(-0.0309)]=[\mathbf{0.5181},\ \mathbf{-0.2969}] \qquad b_2^{new}=0.05-0.1(-0.4417)=\mathbf{0.0942}$$

$$W_1^{new}=\begin{bmatrix}0.3111 & -0.1066\\0.2177 & 0.3894\end{bmatrix} \qquad b_1^{new}=[\mathbf{0.1221},\ \mathbf{-0.2133}]$$

One gradient-descent step moved every weight a small amount in the direction that increases \(Q\) toward the target \(y=1\) — exactly what we'd hope for. Run this same arithmetic a few thousand times over a real dataset, and the network's predictions converge (the code at the bottom of this page does exactly that, and prints the loss decreasing step by step).

Prefer to click through it interactively instead of re-reading? Same numbers, same order:

7. Vectorized Form (Mini-Batches)

Real training doesn't process one example at a time — it processes a batch of \(m\) examples stacked as rows of \(X\) (shape \(m\times n\)). Every equation above still holds, just with matrices instead of vectors, and gradients w.r.t. biases summed over the batch dimension:

$$\delta_2 = Q-y \quad(m\times 1) \qquad \frac{\partial L}{\partial W_2}=\frac{1}{m}A^\top\delta_2 \qquad \frac{\partial L}{\partial b_2}=\frac{1}{m}\sum_i \delta_2^{(i)}$$ $$\delta_1 = (\delta_2 W_2^\top)\odot \text{ReLU}'(Z_1) \qquad \frac{\partial L}{\partial W_1}=\frac{1}{m}X^\top\delta_1 \qquad \frac{\partial L}{\partial b_1}=\frac{1}{m}\sum_i \delta_1^{(i)}$$

Here \(\odot\) is element-wise multiplication (Hadamard product) — it appears because each hidden unit's activation function acts independently on its own pre-activation.

8. Common Pitfalls

⚠ Things that silently break backprop
  • Forgetting the activation's local gradient. \(\delta_1\) must be multiplied by \(\text{ReLU}'(Z_1)\) — skipping it back-propagates gradient through neurons that were actually off (Z1 ≤ 0).
  • Shape mismatches. If \(X\) is \((m,n)\) and \(W_1\) is \((n,h)\), then \(\partial L/\partial W_1\) must also be \((n,h)\) — always check gradient shapes match parameter shapes.
  • Dead ReLUs. If a neuron's \(Z_1\) is negative for every training example, its gradient is permanently 0 and it never updates again (see Lecture 5).
  • Not caching forward-pass values. Recomputing \(A\) or \(Z_1\) during the backward pass instead of reusing cached values wastes computation and is a common bug source when values drift due to floating point.

9. Summary

Key takeaways
  • Backpropagation = chain rule, applied systematically backward through the computation graph, reusing intermediate gradients (\(\delta\) terms).
  • Every layer needs exactly two things to compute its gradients: the incoming gradient \(\delta\) from the layer above, and its own cached forward-pass values.
  • Sigmoid output + cross-entropy loss gives the clean gradient \(Q-y\) — this is why the pairing is so common in binary classifiers.
  • In practice, no one derives these by hand — autodiff frameworks (PyTorch, TensorFlow) build the computation graph and apply exactly this algorithm automatically. Understanding it by hand is what lets you debug when training goes wrong.

10. Code: Backpropagation From Scratch

The NumPy implementation below reproduces the worked example above exactly — run it and confirm the printed gradients match the hand-derived numbers.

lecture-08-backprop.py
import numpy as np

def sigmoid(z): return 1 / (1 + np.exp(-z))
def relu(z):    return np.maximum(0, z)
def relu_grad(z): return (z > 0).astype(float)

# ---- data & parameters (matches the worked example in the lecture) ----
X  = np.array([[0.5, 0.8]])                     # (1, 2)
y  = np.array([[1.0]])                          # target
W1 = np.array([[0.3, -0.1], [0.2, 0.4]])         # (2, 2)
b1 = np.array([[0.1, -0.2]])                     # (1, 2)
W2 = np.array([[0.5], [-0.3]])                   # (2, 1)
b2 = np.array([[0.05]])                          # (1, 1)
lr = 0.1

# ---- forward pass ----
Z1 = X @ W1 + b1
A  = relu(Z1)
Z2 = A @ W2 + b2
Q  = sigmoid(Z2)
L  = -(y * np.log(Q) + (1 - y) * np.log(1 - Q))
print("Q =", Q, " Loss =", L)

# ---- backward pass ----
dZ2 = Q - y                       # sigmoid + BCE shortcut
dW2 = A.T @ dZ2
db2 = dZ2.sum(axis=0, keepdims=True)

dA  = dZ2 @ W2.T
dZ1 = dA * relu_grad(Z1)
dW1 = X.T @ dZ1
db1 = dZ1.sum(axis=0, keepdims=True)

print("dW2 =\n", dW2, "\ndW1 =\n", dW1)

# ---- gradient descent update ----
W1 -= lr * dW1; b1 -= lr * db1
W2 -= lr * dW2; b2 -= lr * db2
print("Updated W2 =", W2.ravel(), " Updated b2 =", b2.ravel())

⬇ Download lecture-08-backprop.py   More resources for this lecture →