EVERYTHING AIAI engineering, made visual
0/13 complete
LESSON 03 · DEEP LEARNING × AI · BUILD

Every weight gets
its share of the blame.

A wrong answer is a debt spread across millions of weights. Backpropagation pays it back in a single reverse sweep — carrying the chain rule backward through the graph, reusing every value the forward pass stored. This is the algorithm that makes learning possible.

75 MIN · 8 CHAPTERSPREREQ · PHASE 3 · LESSON 02
FIG. 03 / ONE GRAPH · TWO DIRECTIONS
values negative gradient edge weight
LESSON 03TYPE · BUILD~75 MINPREREQ · PHASE 3 · LESSON 02ORIGINAL LESSON ↗
THE 60-SECOND VERSIONThen draw the graph ↓
01 / ONE SWEEP, EVERY GRADIENT

Nudging every weight would take 27 days. Backprop takes seconds.

A 768×3072 hidden layer owns 2,359,296 weights, and the naive gradient needs one forward pass per weight. Backprop stores every intermediate value during the forward pass, then walks the graph once in reverse — every parameter's gradient, one sweep, no repeated forward passes.

all gradients ≈ parameter count ÷ 3 passes
02 / MULTIPLY ALONG, ADD ACROSS

One local derivative per operation is the whole engine.

Addition passes the incoming gradient through; multiplication hands each input the other factor; sigmoid multiplies by a(1 − a) using the output the forward pass already stored. Derivatives multiply along a path, and contributions from different paths add where they meet. Everything else is bookkeeping.

incoming × local, then sum the paths
03 / DEPTH MULTIPLIES

The same product that makes backprop work can kill it.

Each layer multiplies the backward signal by its local derivative. Sigmoid's derivative is at most 0.25, so ten layers pass a millionth of the gradient to the first layer; weight scales above 1 make the product explode instead. Gradient checking — nudging one weight and measuring the loss — is the independent referee that proves the backward pass is correct.

0.25¹⁰ ≈ 10⁻⁶ · trust, but verify
MENTAL MODEL IN ONE SENTENCE

Backpropagation is the chain rule wearing a bookkeeping costume: the forward pass records what every operation produced, and the backward pass walks the same graph in reverse, multiplying each incoming gradient by one local derivative and adding the contributions where paths meet.

By the end you will be able to run a backward pass by hand through addition, multiplication and sigmoid; accumulate gradients across shared paths with +=; derive and verify the gradients of a full 2-2-1 network against finite differences; train XOR from scratch with a four-line loop; and explain why sigmoid depth multiplies the signal by at most 0.25 per layer — the bridge to activation functions and initialization.

THE PROBLEM, DRAWN

2.3 million knobs.
One wrong answer.

A hidden layer with 768 inputs and 3072 outputs holds 2,359,296 weights. When the network is wrong, every one of them shares the blame — and assigning that blame is the whole problem. The answer is not a cleverer formula; it is a bookkeeping device called the computational graph.

Your network has a single hidden layer with 768 inputs and 3072 outputs: 2,359,296 weights. It made a wrong prediction. Which weights caused the error? The naive experiment is an experiment per weight: nudge one weight by a tiny amount, run the forward pass again, and watch whether the loss went up or down. That number is the weight’s gradient. Now repeat it for every weight — and every training step, for every example.

one hidden layer: 768 × 3072 = 2,359,296 weights naive gradient = 1 forward pass per weight one epoch over 1,000,000 examples at 10⁶ passes/s: naive 2,359,296 × 1,000,000 ≈ 2.36×10¹² passes ≈ 27 days backprop 3 × 1,000,000 = 3×10⁶ passes ≈ 3 seconds ratio 786,432× less work (= parameters ÷ 3)

Backpropagation computes all 2,359,296 gradients in a single backward pass. That is not an optimisation of the nudge — it is a different operation. The naive route pays one full forward pass per parameter; the backward pass pays once for the whole network and reuses everything it saw on the way forward.

One backward pass vs millions of forward passes

The naive way to get one gradient: nudge one weight, re-run the whole forward pass, measure the loss change. Repeat for every weight. Count the passes — then count what backpropagation needs.

A WEIGHT-BY-WEIGHT NUDGE · 2,359,296 PARAMETERS
Numerical (nudge every weight)27 days
2.36×10^12 passes
Backpropagation (one forward + one backward)3.0 s
3.00×10^6 passes

Bar width is logarithmic — a linear bar for backpropagation would be invisible next to 2.36×10^12 naive passes. One epoch of the naive route is 786,432× more work; the backward pass buys every gradient at once because it reuses the values the forward pass already stored.

Simplified counting model: backward ≈ 2× the cost of a forward pass, so backprop is scored as 3 forward-equivalents per example. Real frameworks batch examples and reuse hardware parallelism; the ratio between the two rows is what survives.

one pass per second at
parameters 2,359,296 examples 1,000,000 epochs 1 speed 1,000,000 passes/s naive 2.36×10^12 passes → 27 days backprop 3.00×10^6 passes → 3.0 s backprop is 786,432× less work (= parameters ÷ 3, because backward ≈ 2 forward-equivalents)

The source’s own example: a 768 → 3072 layer holds 2,359,296 weights. Nudging each one means 2.3 million forward passes to score a single step; backprop computes all 2.3 million gradients in one sweep. That difference is why deep learning is trainable at all.

The device that makes this possible is the computational graph. Every forward pass silently builds one: nodes are operations (multiply, add, sigmoid, square), and edges carry values forward. In the reverse direction the same edges carry gradients. Each operation knows exactly one extra thing — its own local derivative, the answer to “if I nudge my input by a little, how much does my output move?”

The forward pass runs left to right and stores every intermediate value it computes. The backward pass runs right to left and gives every node one job: take the gradient arriving from above, multiply by the local derivative, and hand the result to the inputs that produced it. Sigmoid needs its own output to compute a(1 − a); multiplication needs the other factor. The backward pass never recomputes them — it reuses them.

The computational graph, walked in both directions

One sigmoid neuron and a squared error. Step forward and watch values fill in; step backward and watch each node hand a gradient to the inputs it came from. Same graph, opposite directions.

step 0 / 11 — not started values so far z 0.9500 e 1.1500 a 0.7211 d -0.2789 L 0.0778 gradients so far ∂L/∂d — ∂L/∂a — ∂L/∂e — ∂L/∂z — ∂L/∂b — ∂L/∂w — ∂L/∂x — at the end: L 0.0778 → ∂L/∂w -0.0561

The forward pass stores every value it computes; the backward pass never recomputes them — it reads them. That is the whole memory-for-speed trade at the heart of backprop.

Quick check

A teammate proposes estimating gradients by nudging each weight once per step. Why does backpropagation win?

THE CHAIN RULE, EDGE BY EDGE

Derivatives
multiply along the path.

You met the chain rule in Phase 1, Lesson 05. Here it stops being a calculus exercise and becomes an algorithm: every node carries one local derivative, and the backward pass multiplies them together as it walks the graph.

The chain rule says: if y = f(g(x)), then dy/dx = f′(g(x)) · g′(x). In plain English — to find how a nudge in x moves y, multiply the rates of every stage in between. If a 1% change in x causes a 3% change in g, and a 1% change in g causes a −2% change in y, then a 1% change in x causes roughly a −6% change in y. Rates multiply along a chain; that is the entire idea.

A neural network is one long chain: weights multiply inputs, biases add, activations squash, the loss compares. Backpropagation just runs the chain rule from the end of the chain backwards. Three local derivatives cover almost everything in this lesson:

e = z + b ∂e/∂z = 1 ∂e/∂b = 1 ("addition passes the incoming gradient through unchanged") p = w · x ∂p/∂w = x ∂p/∂x = w ("each input's gradient is the other input's value") a = σ(e) ∂a/∂e = a(1 − a) ← peaks at 0.25 when a = 0.5 ("reuse the activation you already computed") backward rule: gradient to an input = incoming gradient × local derivative

These are the exact rules the source’s Value class implements: _backward for + adds the incoming gradient to both inputs, _backward for * multiplies it by the other factor, and _backward for sigmoid multiplies it by s(1 − s) — where s is the output the forward pass already stored.

The chain rule as a multiplication strip

Backprop is not one big derivative — it is a product of small local ones. Walk the chain from the loss to a weight, then ask the network numerically to confirm the product.

chain to dL/dw dL/dL = 1.00000 × 2(a − y) = -0.55777 local derivatives 2(a − y) -0.55777 (a = 0.72112, y = 1.00) a(1 − a) 0.20111 x (dz/dw) 0.50000 numeric check (h = 10⁻³) -0.05609 analytic -0.05609 difference 1.75e-9

Along one path, derivatives multiply. That is why a sigmoid’s 0.25-maximum local derivative is such bad news in a deep stack — the product shrinks with every layer, the subject of chapter 07.

Worked example A — every number in a one-neuron chain

Fixed example used by the labs and the hero: w = 1.5, x = 0.5, b = 0.2, target y = 1.0, squared error L = (a − y)². Simplified teaching neuron — one multiply, one add, one sigmoid, one square.

FORWARD z = w·x + b = 1.5 × 0.5 + 0.2 = 0.95 a = σ(0.95) = 1 / (1 + e^−0.95) = 0.72112 d = a − y = −0.27888 L = d² = 0.07778 BACKWARD (each line: incoming × local) dL/da = 2d = −0.55777 a(1 − a) = 0.72112 × 0.27888 = 0.20111 dL/dz = −0.55777 × 0.20111 = −0.11217 dL/dw = dL/dz × x = −0.11217 × 0.5 = −0.05609 dL/db = dL/dz = −0.11217 dL/dx = dL/dz × w = −0.11217 × 1.5 = −0.16826 NUMERIC CHECK (centered difference, h = 10⁻³) dL/dw ≈ −0.056086 (chain says −0.056086) dL/db ≈ −0.112172 (chain says −0.112172) dL/dx ≈ −0.168258 (chain says −0.168258)

Read the middle block as a sentence: to move L, the sigmoid stage converts a loss-side rate of −0.55777 into a z-side rate of −0.11217, and the multiply stage converts a z-side rate into a w-side rate of −0.05609 by multiplying through the other factor x = 0.5. The numeric column is the network itself answering the same question with no calculus at all — the two agree to the sixth decimal, which is the subject of chapter 05.

THE BACKWARD PASS

One sweep,
right to left.

The backward pass is a single walk through a topologically sorted graph: start at the loss with gradient 1, and let every node hand its inputs the product of incoming gradient and local derivative. A node that fed several operations collects the sum.

The source’s backward() does three things. It builds a topological order of the graph — a list where every node appears after all the nodes it depends on, built with a depth-first walk. It seeds the loss with grad = 1.0, because dL/dL is exactly 1: the loss is 100% sensitive to itself. Then it walks the list in reverse, calling each node’s backward rule once. By the time a node is processed, every path that flows through it has already delivered its gradient — which is the entire reason the order matters.

Why can’t you process nodes in any order? Because a node’s gradient is not a single number from a single parent — it is the sum of the gradients arriving from every operation that used it. If a shared node runs its backward rule early, it propagates a partial gradient onward and the rest of the graph quietly computes wrong answers. Topological order guarantees the node’s bucket is complete before it is spent.

That bucket is why the forward source code writes self.grad += other.data * out.grad and self.grad += out.grad — never =. A value used in two operations receives two contributions, and += is how the graph adds across paths while multiplying along them.

Why gradients accumulate with +=

Here x feeds two branches that rejoin. Walk the backward pass and watch x receive two separate gradient contributions. Then flip the accumulator to a plain assignment and watch half the gradient disappear.

L = (x·w1 + x·w2)² = (2.0000 + 3.0000)² = 25.0000 ∂L/∂s 10.0000 (s = 5.0000) ∂L/∂p1 10.0000 × dp1/dx = w1 = 2.0000 → 20.0000 ∂L/∂p2 10.0000 × dp2/dx = w2 = 3.0000 → 30.0000 x.grad shown 0.0000 x.grad true 50.0000 numeric check 50.0000 (centered, h = 10⁻³) += keeps both contributions.

A value used in several operations needs the sum of every path’s gradient. This is exactly why the engine writes self.grad += … and why every framework zeroes gradients before the next backward pass.

worked accumulation check — L = (x·w1 + x·w2)² w1 = 2, w2 = 3, x = 1 s = x·w1 + x·w2 = 2 + 3 = 5 L = s² = 25 dL/ds = 2s = 10 path through w1: dL/ds × ∂(x·w1)/∂x = 10 × 2 = 20 path through w2: dL/ds × ∂(x·w2)/∂x = 10 × 3 = 30 x.grad = 20 + 30 = 50 (2s·(w1 + w2) = 2 × 5 × 5) numeric check: nudge x by ±0.001 L(x + h) = (1.001 × 5)² = 25.050025 L(x − h) = (0.999 × 5)² = 24.950025 (L₊ − L₋) / 2h = 0.1 / 0.002 = 50.000 ✓ with '=' instead of '+=' the second path overwrites the first: x.grad = 30 ← the gradient loses 20, and x moves wrong
Quick check

In that example (w1 = 2, w2 = 3, x = 1), the backward pass reaches x twice with contributions 20 and 30. What should x.grad be, and why?

A REAL NETWORK'S GRADIENTS

Every formula,
one more layer deep.

The source derives the backward pass for a full two-layer network: two inputs, two sigmoid hidden units, one sigmoid output, squared error. Same rules as the single neuron — apply them layer by layer, matrices and all.

Forward pass. A layer is a weighted sum followed by an activation. The hidden layer turns the two inputs into two pre-activations z1, squashes them into activations a1, and the output layer does the same once more. Squared error compares the final activation with the target:

z1 = W1·x + b1 weighted sum, hidden units (2 numbers) a1 = σ(z1) hidden activations, elementwise z2 = W2·a1 + b2 weighted sum, output unit (1 number) a2 = σ(z2) the prediction L = (a2 − y)² squared error shapes here: W1 is 2×2, x is 2; W2 is 2, b2 is 1

Backward pass. Apply the chain rule from the loss backwards. Each line below is one link: incoming gradient × local derivative. The only new move is the transpose-like fan inversion — when the forward pass computes W2·a1, the backward pass sends the output gradient back through W2 to reach a1.

dL/da2 = 2(a2 − y) "how loss moves with the prediction" dL/dz2 = dL/da2 × a2(1 − a2) "through the output sigmoid" dL/dW2 = dL/dz2 × a1 "each output weight's share is a hidden activation" dL/db2 = dL/dz2 "a bias's local derivative is 1" dL/da1 = dL/dz2 × W2 "send the gradient back through the output weights" dL/dz1 = dL/da1 × a1(1 − a1) "through the hidden sigmoid, elementwise" dL/dW1 = dL/dz1 × x "each hidden weight's share is an input value" dL/db1 = dL/dz1 "bias again: pass the gradient straight through"
Worked example B — a full 2-2-1 network in real numbers

Simplified teaching network, fixed by hand so every number is checkable: W1 = [[0.5, −0.25], [0.25, 0.5]], b1 = [0.1, −0.1], W2 = [0.6, −0.4], b2 = 0.2, input x = [1.0, 0.5], target y = 1.0.

FORWARD z1 = [0.5×1.0 − 0.25×0.5 + 0.1, 0.25×1.0 + 0.5×0.5 − 0.1] = [0.475, 0.400] a1 = σ([0.475, 0.400]) = [0.616567, 0.598688] z2 = 0.6×0.616567 − 0.4×0.598688 + 0.2 = 0.330465 a2 = σ(0.330465) = 0.581872 L = (0.581872 − 1)² = 0.174831 BACKWARD dL/da2 = 2 × (−0.418128) = −0.836255 a2(1 − a2) = 0.243297 dL/dz2 = −0.836255 × 0.243297 = −0.203458 dL/dW2 = −0.203458 × [0.616567, 0.598688] = [−0.125446, −0.121808] dL/db2 = −0.203458 dL/da1 = −0.203458 × [0.6, −0.4] = [−0.122075, +0.081383] a1(1 − a1) = [0.236412, 0.240261] dL/dz1 = [−0.122075 × 0.236412, 0.081383 × 0.240261] = [−0.028860, +0.019553] dL/dW1 = dL/dz1 × xᵀ = [[−0.028860, −0.014430], [+0.019553, +0.009777]] dL/db1 = dL/dz1 = [−0.028860, +0.019553] NUMERIC SPOT CHECK (centered difference, h = 10⁻⁵) W2[0]: backprop −0.125446 numeric −0.125446 diff 1.1×10⁻¹² b2: backprop −0.203458 numeric −0.203458 diff 5.0×10⁻¹⁴

Follow one number end to end: the output unit’s error rate −0.203458 becomes each output weight’s gradient by multiplying by the hidden activation that fed it — a bigger activation fed more of the error, so it earns a bigger gradient. Then the same error rate rides back through the output weights to reach the hidden units, gets multiplied by each hidden sigmoid’s local derivative, and finally becomes each input weight’s gradient by multiplying by the input value. Nothing new was invented; the chain just got longer.

The gradient ledger for worked example B — all nine parameters the network owns. Every number is computed live in the gradient-check lab and verified against finite differences.
gradientformula (incoming × local)value
dL/dW2[0]dL/dz2 × a1[0]−0.125446
dL/dW2[1]dL/dz2 × a1[1]−0.121808
dL/db2dL/dz2−0.203458
dL/dW1[0][0]dL/dz1[0] × x[0]−0.028860
dL/dW1[0][1]dL/dz1[0] × x[1]−0.014430
dL/dW1[1][0]dL/dz1[1] × x[0]+0.019553
dL/dW1[1][1]dL/dz1[1] × x[1]+0.009777
dL/db1[0]dL/dz1[0]−0.028860
dL/db1[1]dL/dz1[1]+0.019553
Output-layer gradients (−0.13, −0.12, −0.20) are several times larger than hidden-layer gradients (−0.029, +0.020). That gap is the first hint of the vanishing-gradient story in chapter 07: every extra layer multiplies the signal by another derivative that is at most 0.25 for sigmoid.
TRUST, BUT VERIFY

The backward pass
can be wrong quietly.

A sign flipped, a path forgotten, a derivative copied from the wrong function — broken backprop does not crash. It trains a network that gets slightly worse forever. Finite differences are the independent referee: nudge a weight, measure the loss, and compare.

Backpropagation is arithmetic, and arithmetic written by humans is arithmetic that can be subtly wrong. The dangerous bugs are not exceptions — they are mistakes that produce plausible-looking numbers. A missing +=, a sigmoid derivative written as a(1 − a) when a is the pre-activation, a gradient sent through W instead of Wᵀ: the network still trains, just worse. You need a second opinion that does not share code with the first.

That second opinion comes from the definition of a derivative. You do not need calculus to estimate one — you can measure it. Nudge a single weight up by h, run the forward pass, and record the loss. Nudge it down by h and record the loss again. The slope between those two points is the central finite difference:

numeric estimate: (L(w + h) − L(w − h)) / 2h analytic (backprop): dL/dw ← must match "each parameter costs two forward passes — worth it once, never per step"

The estimate is independent of the backward pass: it touches only the forward pass and the loss. If backprop says −0.125446 and the two-point slope says −0.125446, the chain rule was implemented correctly for that parameter. Repeat for every parameter and you have verified the whole gradient, one weight at a time.

Worked example C — checking W2[0] in the 2-2-1 network

Use the same teaching network as worked example B: the one whose output-layer weight W2[0] = 0.6 received backprop gradient dL/dW2[0] = −0.125446. Perturb it by h = 10⁻⁵ in both directions and compute the loss at each point with the forward pass alone.

L(W2[0] + h) = 0.174829373 L(W2[0] − h) = 0.174831882 L₊ − L₋ = −0.000002509 numeric = (−0.000002509) / (2 × 10⁻⁵) = −0.125446 analytic (backprop) = −0.125446 difference ≈ 1.1 × 10⁻¹² same weight, h = 10⁻³: difference ≈ 1.4 × 10⁻⁹ (truncation: h too big) same weight, h = 10⁻⁷: difference ≈ 5.9 × 10⁻¹⁰ (roundoff: h too small)

Read the last two lines together — they are the whole practical lesson. Make h too large and the two points sit far apart on a curve, so the secant slope misses the tangent (truncation error). Make h too small and L(w + h) and L(w − h) agree in the first dozen digits, so their difference is mostly floating-point noise (roundoff error). Somewhere around h = 10⁻⁵ the two errors balance and the check agrees with backprop to twelve digits.

Gradient check: does the backward pass tell the truth?

Perturb one weight by ±h, measure how the loss changes, and compare that cheap numerical estimate with the script’s backprop value. Then slide h and watch the error trade truncation against floating point.

h = 10^-5 mode = centered param analytic numeric |diff| W1[0][0] -0.028860 -0.028860 8.8e-13 W1[0][1] -0.014430 -0.014430 1.8e-12 W1[1][0] 0.019553 0.019553 8.0e-13 W1[1][1] 0.009777 0.009777 4.0e-13 b1[0] -0.028860 -0.028860 8.8e-13 b1[1] 0.019553 0.019553 8.0e-13 W2[0] -0.125446 -0.125446 1.1e-12 W2[1] -0.121808 -0.121808 1.5e-12 b2 -0.203458 -0.203458 4.8e-14 max |difference| = 1.83e-12 at W1[0][1] ✓ agreement is float-level W2[0]: analytic -0.125446 vs numeric -0.125446

The best h here is around 10⁻⁵: small enough that the slope is accurate, large enough that subtracting two nearly equal losses keeps its digits. The U shape is the whole lesson of this chapter — and PyTorch ships the same test as torch.autograd.gradcheck.

The referee, in nine linespython
def numeric_grad(f, x, h=1e-5):
    return (f(x + h) - f(x - h)) / (2 * h)

# analytic gradient from the backward pass
loss = f(x)
loss.backward()
analytic = x.grad.item()

estimate = numeric_grad(f, x)
assert abs(analytic - estimate) < 1e-6   # a one-time audit, not a training step
Same test as the lab, expressed in PyTorch — the standard pattern for validating any custom autograd function.
Quick check

A teammate sets h = 10⁻¹⁴ for the gradient check to be 'extra precise'. What do they actually get?

PUTTING IT TOGETHER

Four lines,
and the network learns.

Everything so far has been building blocks. The training loop is the assembly: clear the old gradients, run the forward pass, run the backward pass, nudge every parameter downhill. Repeat until the loss stops arguing.

Training a network is a loop with exactly four moves. Zero the gradients so yesterday’s accumulation does not leak into today. Forward the batch and compute the loss. Backward through the graph to fill every parameter’s gradient. Update each parameter against its gradient. Nothing else is required — no hand-derived formulas per layer, no per-parameter bookkeeping. Backprop filled the gradients; the optimiser just spends them.

The source trains a Network([2, 4, 1]) on the four XOR rows with this loop. The batch is all four examples: their individual losses are summed into total_loss, and one backward pass on that sum hands every weight the gradient of the total error. Then the parameters step. That is gradient descent, with the gradients computed by the code you built in the previous chapters.

The source's training loop (Step 6)python
random.seed(42)
net = Network([2, 4, 1])

for epoch in range(1000):
    total_loss = Value(0.0)
    for inputs, target in xor_data:
        x = [Value(i) for i in inputs]
        pred = net(x)
        loss = mse_loss(pred, target)
        total_loss = total_loss + loss

    net.zero_grad()
    total_loss.backward()

    for p in net.parameters():
        p.data -= learning_rate * p.grad
Seed 42, learning rate 1.0, 1000 epochs. This is the exact loop the browser lab reimplements in TypeScript.

Train the 2-4-1 network on XOR — from scratch

No library. Every gradient in this training loop comes from the Value engine the lesson builds: forward, loss, backward, update. Watch the loss fall and the decision field bend into the XOR shape.

epoch 0 loss — (press +100 epochs, or play) predictions [0, 0] → 0.5823 target 0 ✗ [0, 1] → 0.5853 target 1 ✓ [1, 0] → 0.5859 target 1 ✓ [1, 1] → 0.5889 target 0 ✗ the engine: 1 forward, 1 backward, 1 update per epoch the source's Python run (seed 42) reaches loss 0.0034 by epoch 900

XOR is not linearly separable — a single neuron cannot do it, which is why there is a hidden layer. The network discovers the hidden layer’s own representation using nothing but the gradients backprop produced.

Worked example D — what the loop does to the loss

Same architecture and seed as the lab (2-4-1, seed 42, learning rate 1.0). The numbers below are the actual loss and the four predictions at checkpoints on the way down.

epoch total loss predictions (target) 0 1.0294 0.58 0.59 0.59 0.59 ← all four answer "maybe" 25 1.0000 0.56 0.58 0.57 0.58 ← long, flat plateau 100 0.9979 0.49 0.51 0.50 0.51 ← still nearly symmetric 200 0.8912 0.25 0.75 0.72 0.30 ← symmetry breaks 300 0.1202 0.11 0.85 0.82 0.22 ← the bend becomes an S 600 0.0090 0.03 0.95 0.95 0.06 ← confident and correct 1000 0.0035 0.02 0.97 0.97 0.04 ← 4/4 correct, loss tiny source Python run (same seed): 0.0034 by epoch 900 — the port matches

Two things are worth noticing. First, the first ~150 epochs are a plateau: the randomly initialised network answers almost 0.5 on every example (total loss ≈ 1.03), and at that symmetric point the four error signals largely cancel, so the loss crawls. Around epoch 200 the hidden units break symmetry — two outputs climb, two fall — and the loss collapses from 0.89 to 0.12 in a hundred epochs. Second, by epoch 300 the four outputs have split into two high and two low: that is the network inventing its own hidden representation. Nobody labelled the hidden units; backprop found them.

Quick check

The loop sums the losses of all four XOR examples before calling backward(). Why not call backward() four times, once per example?

WHEN THE SIGNAL DIES

Deep networks multiply.
So do their mistakes.

Backprop multiplies one local derivative per layer. That is a feature — until the factors are all 0.25 and the product is a millionth by layer ten. Depth turns multiplication into exponential decay or exponential blow-up, and the whole modern toolkit exists to keep the product near 1.

In chapter 06 the network was two layers deep, and the gradients arrived at the first layer only slightly smaller than they left the last. Stack twenty sigmoid layers and that changes completely. Each layer multiplies the backward signal by its own local derivative, and the sigmoid’s derivative, a(1 − a), is never larger than 0.25 — it peaks at 0.25 when the unit sits at its midpoint and shrinks toward zero as the unit saturates. A chain of n sigmoid layers therefore multiplies the gradient by at most 0.25ⁿ. At depth 10 that is about 10⁻⁶: the first layers receive a millionth of the learning signal, so they barely move while the last layers look like they are training fine.

The failure runs in both directions. If each layer’s effective factor is even slightly above 1 — because the weights are large, or the activation’s derivative is above 1 over the operating range — the product grows exponentially and the early gradients explode, producing wild updates and NaNs. The same chain rule that makes backprop work makes it fragile: depth multiplies.

Vanishing and exploding gradients, in one chart

A chain is only as strong as its product of local derivatives. Stack sigmoids and the product dies; stack layers whose weight scale exceeds 1 and it explodes. Same chain rule, opposite failure.

activation sigmoid weight scale 1.00 per-layer factor 1.00 × max derivative 0.25 = 0.250 after 10 layers: 9.54×10^-7 sigmoid 9.54×10^-7 tanh 1.000 relu 1.000 canonical sigmoid numbers (weight scale 1): 0.25^5 = 9.77×10^-4 0.25^10 = 9.54×10^-7 → vanishing: the first layers receive almost no signal

The model is deliberately pessimistic — every layer is assumed to sit at its maximum derivative. Real networks average lower still, so the sigmoid’s diagonal is the best case it can hope for, not the worst.

Worked example E — depth as a product of factors

Take the simplified worst-case model from the lab: every layer sits at its maximum local derivative and at a chosen weight scale s, so the factor per layer is s × max f′. The gradient arriving at layer n is the factor raised to n.

sigmoid (max f′ = 0.25), weight scale 1: 0.25⁵ = 9.77 × 10⁻⁴ five layers: three lost digits 0.25¹⁰ = 9.54 × 10⁻⁷ ten layers: a millionth 0.25²⁰ = 9.09 × 10⁻¹³ twenty layers: effectively zero in float32 tanh (max f′ = 1.0), weight scale 1: 1.0¹⁰ = 1 the signal survives — if units stay mid-range 1.5¹⁰ = 57.7 weight scale 1.5: explode by layer 10 0.8¹⁰ = 0.107 weight scale 0.8: safe but slowly shrinking ReLU (f′ = 1 or 0), weight scale 1: 1ⁿ = 1 alive units pass the gradient intact 0 × anything = 0 one dead unit erases everything behind it

The table is deliberately pessimistic — real factors vary per unit and per sample, and healthy units sit where their derivative is decent — so use it to order the failure modes, not to predict exact numbers. The ordering it gives is the historically accurate one: sigmoid networks simply could not be made deep, ReLU plus careful initialization (next lesson) made depth trainable, and residual connections made very deep networks boring.

Quick check

Your 15-layer sigmoid network's loss has stalled, and you notice the first three layers' gradients are ~10⁻⁸ while the last layers move normally. Which change is most likely to help?

CHECK YOURSELF

Five questions.
Then the terms worth keeping.

Answer before you look. The += question and the topological-order question are the two that expose whether the backward pass is a mechanism you can debug or a magic trick you memorised.

0 / 5 answered · 0 correct

01What does the chain rule say, in the context of neural networks?

02Why is backpropagation so much faster than estimating every gradient independently?

03In the backward pass, why is the accumulation written as grad += ... instead of grad = ...?

04What causes the vanishing gradient problem in deep sigmoid networks?

05Why does topological order matter in the backward pass?

Key terms, demystified

Click a card to swap the lazy description for what it actually means.

Exercises from the lesson

Four problems with exact numbers — trace a backward pass by hand, verify it against finite differences, break the accumulation on purpose, and compute where a sigmoid stack’s gradient crosses the noise floor. Try first; a worked answer is one click away.

  1. Add __sub__ and __neg__ to the Value class (a − b = a + (−1 · b)), then verify the gradients for (a − b)² with a = 3, b = 1 against a hand calculation and a finite difference.
    Show one worked answer

    Implement __neg__ as self * -1 (the multiply node already exists, so the backward rule comes for free), and __sub__ as self + (-other). Forward: (3 − 1)² = 4. Hand backward: dL/d(a−b) = 2(a−b) = 4; the add node passes 4 to both inputs; the neg node multiplies by −1 on the way to b, so b.grad = −4 and a.grad = 4. Finite-difference check at a: (3.001 − 1)² = 4.004001, so (4.004001 − 4)/0.001 ≈ 4.001 ≈ 4 when h = 10⁻³; a symmetric difference with h = 10⁻⁵ lands within 10⁻⁸ of 4. The two new methods never touched the backward machinery — composition of existing nodes is what autograd calls operator overloading.

  2. Add a relu operation to Value (output max(0, x), derivative 1 when x > 0 else 0), swap it in for sigmoid on the hidden layer, and train XOR again. Compare convergence with the sigmoid run and explain the difference.
    Show one worked answer

    Forward returns x if x > 0 else 0; backward passes the incoming gradient unchanged when the input was positive and multiplies it by 0 otherwise. With the same seed and learning rate, the ReLU run escapes the symmetric plateau far sooner — the sigmoid run in this lesson sits near loss 1.0 for the first ~150 epochs because its local derivatives are ≤ 0.25 times small outputs, while ReLU passes factor 1 through every active hidden unit. Expect the ReLU hidden layer to reach loss < 0.01 in a few hundred epochs where the sigmoid version needs roughly 600–900. The catch: watch how many hidden units output exactly 0 on all four rows — those units are dead, receive gradient 0 forever, and are the subject of Lesson 04.

  3. Add gradient clipping to the training loop — after backward(), clamp every p.grad to [−1, 1] — and train a 4-hidden-layer sigmoid network with weight scale 1.5. Compare the loss curve with and without clipping.
    Show one worked answer

    Clipping is one line inside the update loop: p.grad = max(-1, min(1, p.grad)). At weight scale 1.5 the worst-case factor per layer is 1.5 × 0.25 = 0.375, so a 4-layer stack is actually 0.375⁴ ≈ 0.0198 — vanishing, not exploding; to see blow-up you need the derivative side above 1 (tanh at mid-range) or a much larger scale. A better experiment uses tanh: factor 1.5 × 1.0 = 1.5 per layer, so layer-1 gradients are 1.5⁴ ≈ 5.06× the output gradient, and after ten layers 1.5¹⁰ ≈ 57.7×. Without clipping, those early updates overshoot and the loss spikes or turns to NaN; with clipping at [−1, 1], the 57.7× gradient becomes 1×, the update stays bounded, and the curve descends smoothly. The cost is slower progress when large gradients are legitimate — clipping trades speed for stability.

  4. After training the 2-4-1 network on XOR, print the mean absolute gradient of each parameter group and identify which layer is smallest. Why, and what would change it?
    Show one worked answer

    Order the groups output-layer-first: |dL/dW2| ≈ 0.13 per weight, |dL/db2| ≈ 0.20, then hidden layer |dL/dW1| ≈ 0.02 and |dL/db1| ≈ 0.02 — roughly an order of magnitude smaller. The cause is the product of derivatives: a weight in W1 sits behind two sigmoid stages (output-stage derivative ~0.24 and hidden-stage derivative ~0.24), while W2 sits behind one. Each extra sigmoid multiplies the signal by at most 0.25, so early layers get exponentially less gradient. What would change it: ReLU/GELU hidden units (factor 1 or near it), an initialization scaled to keep z near zero, normalization layers that keep units off the saturated tails, or residual connections that give the gradient a path around the multiplications entirely.

Terms this lesson borrows from later lessons (or outside)

You do not need to master these here. Each one gets a proper treatment in its own lesson; the one-line meaning is enough to keep reading. Orange dotted underlines in the prose point back to this list.

  • chain ruleNested functions differentiate by multiplying local slopes. Backpropagation is that rule applied to a graph instead of a formula. (Phase 1, Lesson 05)
  • gradient descentThe loop that nudges parameters downhill on the loss surface. It needs gradients; backprop is the algorithm that supplies them for arbitrarily deep networks. (Phase 1, Lesson 08)
  • matrix multiplicationThe forward pass multiplies W·x, so the backward pass multiplies by Wᵀ to send the gradient back through the same connection pattern. The shapes are the contract. (Phase 1, Lesson 02)
  • sigmoidThe squashing gate from logistic regression: σ(z) = 1/(1 + e⁻ᶻ) with derivative a(1 − a) peaking at 0.25. Its maximum derivative is the engine of the vanishing gradient. (Phase 2, Lesson 03)
  • multi-layer networkThe architecture being trained here: stacked weighted sums and activations. Lesson 02 ran its forward pass by hand; this lesson makes it learn. (Phase 3, Lesson 02)
KEEP GOING

A picture is a start.
Practice is the rest.

This lesson is a port of an open course. Everything here traces back to it — and the next step is running the code yourself.

Lesson text adapted from AI Engineering from Scratch (Phase 03, Lesson 03) and the Math Foundations Notebook reference build. The seven labs (naive-versus-backprop cost comparison, graph explorer, chain-rule stepper, accumulation checker, finite-difference gradient check with the error-versus-h curve, XOR trainer, vanishing/exploding chart), worked example A for one sigmoid neuron, worked example B for the full 2-2-1 network with its nine-gradient ledger, the h = 10⁻³/10⁻⁵/10⁻⁷ comparison, the seed-42 XOR checkpoint table, the per-activation depth table, and the residual-highway memory hook are original to this page. Every number shown is computed live by the labs or verified by hand in the prose.