AI / ML Theory
Chapter 2 · 26 min

2.8 — Backprop by hand on a tiny network

Goal: Walk through one forward and one backward pass numerically on a 2 → 2 → 1 sigmoid MLP, with one training example.

1. Why this session exists

Sessions 2.5–2.7 derived the three identities. This one runs them. Take a 2-input, 2-hidden, 1-output network with sigmoid activations, pick a single example, and compute every zz, aa, δ\delta, ∂L/∂W\partial L / \partial W, ∂L/∂b\partial L / \partial b by hand. Once the numbers come out right, the abstraction is yours.

This is the kind of exercise to do with a calculator and a notebook the first time, then re-check by writing a 15-line PyTorch script that backprops the same example and matches your numbers to 4 decimals.


2. The network

Widths (d0,d1,d2)=(2,2,1)(d_0, d_1, d_2) = (2, 2, 1). Sigmoid at both the hidden and output layers. Binary cross-entropy loss. One example.

Parameters (chosen for clean arithmetic):

W(1)=(0.100.200.300.40),b(1)=(0.000.00),W^{(1)} = \begin{pmatrix} 0.10 & 0.20 \\ 0.30 & 0.40 \end{pmatrix}, \quad b^{(1)} = \begin{pmatrix} 0.00 \\ 0.00 \end{pmatrix}, W(2)=(0.50−0.60),b(2)=0.10.W^{(2)} = \begin{pmatrix} 0.50 & -0.60 \end{pmatrix}, \quad b^{(2)} = 0.10.

One example: x=(1.0, 2.0)x = (1.0,\, 2.0), label y=1y = 1.


3. Forward pass

Layer 1 pre-activation:

z(1)=W(1)x+b(1)=(0.10⋅1+0.20⋅20.30⋅1+0.40⋅2)=(0.501.10).z^{(1)} = W^{(1)} x + b^{(1)} = \begin{pmatrix} 0.10 \cdot 1 + 0.20 \cdot 2 \\ 0.30 \cdot 1 + 0.40 \cdot 2 \end{pmatrix} = \begin{pmatrix} 0.50 \\ 1.10 \end{pmatrix}.

Layer 1 activation:

a(1)=σ(z(1))=(σ(0.50)σ(1.10))≈(0.62250.7503).a^{(1)} = \sigma(z^{(1)}) = \begin{pmatrix} \sigma(0.50) \\ \sigma(1.10) \end{pmatrix} \approx \begin{pmatrix} 0.6225 \\ 0.7503 \end{pmatrix}.

(Using σ(0.50)=1/(1+e−0.50)≈0.6225\sigma(0.50) = 1 / (1 + e^{-0.50}) \approx 0.6225, and similarly σ(1.10)≈0.7503\sigma(1.10) \approx 0.7503.)

Output pre-activation:

z(2)=W(2)a(1)+b(2)=0.50⋅0.6225+(−0.60)⋅0.7503+0.10≈0.3113−0.4502+0.10=−0.0389.z^{(2)} = W^{(2)} a^{(1)} + b^{(2)} = 0.50 \cdot 0.6225 + (-0.60) \cdot 0.7503 + 0.10 \approx 0.3113 - 0.4502 + 0.10 = -0.0389.

Output activation (prediction):

y^=σ(z(2))=σ(−0.0389)≈0.4903.\hat y = \sigma(z^{(2)}) = \sigma(-0.0389) \approx 0.4903.

Loss (BCE, label y=1y = 1):

L=−log⁡y^≈−log⁡(0.4903)≈0.7126.L = -\log \hat y \approx -\log(0.4903) \approx 0.7126.

4. Backward pass

Output delta (sigmoid + BCE, from 2.5):

δ(2)=y^−y≈0.4903−1=−0.5097.\delta^{(2)} = \hat y - y \approx 0.4903 - 1 = -0.5097.

Layer-2 gradients (a 1×21 \times 2 row of weights and a scalar bias, from 2.7):

∂L∂W(2)=δ(2)(a(1))⊤≈(−0.5097)⋅(0.6225, 0.7503)≈(−0.3173, −0.3824).\frac{\partial L}{\partial W^{(2)}} = \delta^{(2)} (a^{(1)})^\top \approx (-0.5097) \cdot (0.6225,\ 0.7503) \approx (-0.3173,\ -0.3824). ∂L∂b(2)=δ(2)≈−0.5097.\frac{\partial L}{\partial b^{(2)}} = \delta^{(2)} \approx -0.5097.

Layer-1 delta (recursion from 2.6):

δ(1)=((W(2))⊤δ(2))⊙σ′(z(1)).\delta^{(1)} = \bigl((W^{(2)})^\top \delta^{(2)}\bigr) \odot \sigma'(z^{(1)}).

First, (W(2))⊤δ(2)(W^{(2)})^\top \delta^{(2)}:

(W(2))⊤δ(2)=(0.50−0.60)⋅(−0.5097)≈(−0.25490.3058).(W^{(2)})^\top \delta^{(2)} = \begin{pmatrix} 0.50 \\ -0.60 \end{pmatrix} \cdot (-0.5097) \approx \begin{pmatrix} -0.2549 \\ 0.3058 \end{pmatrix}.

And σ′(z(1))=a(1)⊙(1−a(1))\sigma'(z^{(1)}) = a^{(1)} \odot (1 - a^{(1)}):

σ′(z(1))≈(0.6225⋅0.37750.7503⋅0.2497)≈(0.23500.1874).\sigma'(z^{(1)}) \approx \begin{pmatrix} 0.6225 \cdot 0.3775 \\ 0.7503 \cdot 0.2497 \end{pmatrix} \approx \begin{pmatrix} 0.2350 \\ 0.1874 \end{pmatrix}.

Hadamard product:

δ(1)≈(−0.2549⋅0.23500.3058⋅0.1874)≈(−0.05990.0573).\delta^{(1)} \approx \begin{pmatrix} -0.2549 \cdot 0.2350 \\ 0.3058 \cdot 0.1874 \end{pmatrix} \approx \begin{pmatrix} -0.0599 \\ 0.0573 \end{pmatrix}.

Layer-1 gradients:

∂L∂W(1)=δ(1)x⊤≈(−0.05990.0573)⋅(1.0, 2.0)=(−0.0599−0.11980.05730.1146).\frac{\partial L}{\partial W^{(1)}} = \delta^{(1)} x^\top \approx \begin{pmatrix} -0.0599 \\ 0.0573 \end{pmatrix} \cdot (1.0,\ 2.0) = \begin{pmatrix} -0.0599 & -0.1198 \\ 0.0573 & 0.1146 \end{pmatrix}. ∂L∂b(1)=δ(1)≈(−0.05990.0573).\frac{\partial L}{\partial b^{(1)}} = \delta^{(1)} \approx \begin{pmatrix} -0.0599 \\ 0.0573 \end{pmatrix}.

5. The first SGD update

With learning rate α=0.1\alpha = 0.1,

W(1)←W(1)−0.1⋅∂L∂W(1)≈(0.105990.211980.294270.38854).W^{(1)} \leftarrow W^{(1)} - 0.1 \cdot \frac{\partial L}{\partial W^{(1)}} \approx \begin{pmatrix} 0.10599 & 0.21198 \\ 0.29427 & 0.38854 \end{pmatrix}. b(1)←b(1)−0.1⋅δ(1)≈(0.00599−0.00573).b^{(1)} \leftarrow b^{(1)} - 0.1 \cdot \delta^{(1)} \approx \begin{pmatrix} 0.00599 \\ -0.00573 \end{pmatrix}. W(2)←W(2)−0.1⋅(−0.3173, −0.3824)≈(0.5317, −0.5618).W^{(2)} \leftarrow W^{(2)} - 0.1 \cdot (-0.3173,\ -0.3824) \approx (0.5317,\ -0.5618). b(2)←b(2)−0.1⋅(−0.5097)≈0.1510.b^{(2)} \leftarrow b^{(2)} - 0.1 \cdot (-0.5097) \approx 0.1510.

6. Verification in PyTorch

The following PyTorch snippet should reproduce the numbers above to 4 decimals.

import torch
import torch.nn as nn

torch.set_default_dtype(torch.float64)

x = torch.tensor([[1.0, 2.0]])
y = torch.tensor([[1.0]])

W1 = torch.tensor([[0.10, 0.20], [0.30, 0.40]], requires_grad=True)
b1 = torch.tensor([0.0, 0.0], requires_grad=True)
W2 = torch.tensor([[0.50, -0.60]], requires_grad=True)
b2 = torch.tensor([0.10], requires_grad=True)

z1 = x @ W1.T + b1
a1 = torch.sigmoid(z1)
z2 = a1 @ W2.T + b2
y_hat = torch.sigmoid(z2)
L = nn.functional.binary_cross_entropy(y_hat, y)
L.backward()

print("L =", L.item())          # ≈ 0.7126
print("dL/dW2 =", W2.grad)      # ≈ [[-0.3173, -0.3824]]
print("dL/db2 =", b2.grad)      # ≈ -0.5097
print("dL/dW1 =", W1.grad)      # ≈ [[-0.0599, -0.1198], [0.0573, 0.1146]]
print("dL/db1 =", b1.grad)      # ≈ [-0.0599, 0.0573]

If your numbers match to four decimals, you've correctly internalised every identity from 2.5–2.7.


7. What this exercise teaches

  • Backprop is not magic. It is the chain rule applied carefully.
  • Each δ\delta has a clean shape: same as the corresponding zz.
  • Each ∂L/∂W\partial L / \partial W is an outer product, same shape as the weight.
  • Numerical mistakes show up immediately when the PyTorch reference disagrees with your hand-computed values.

8. Takeaways

  • Pick a small net, one example, and run the full forward + backward by hand.
  • Match every number to a reference implementation.
  • After this exercise, the rest of Chapter 2 (vanishing/exploding, init, autodiff) is layered on a foundation you have actually computed.

Self-test

(a) Redo the example with y=0y = 0 instead of y=1y = 1. Predict whether each gradient flips sign before computing, then verify.

(b) Replace the hidden activation with ReLU. Recompute δ(1)\delta^{(1)}, noting which entries of g′(z(1))g'(z^{(1)}) become 00 or 11, and observe how the gradient sparsifies.

(c) Add a second example x2=(−1.0, 0.5)x_2 = (-1.0,\, 0.5), label y2=0y_2 = 0, forming a batch of size B=2B = 2. Compute the batched gradients and verify they equal 12\tfrac{1}{2} of the sum of per-example gradients.

(d) Write the 15-line PyTorch reference and confirm your numbers match. Common pitfalls: zero the gradients between examples, and use float64 to match the manual arithmetic.