TAI BUI
Back to curriculum
BuildPython

Backpropagation from Scratch

Backpropagation is what makes learning possible. Without it, a neural network is just an expensive random number generator.

TIME: ~120 minutesPREREQUISITES: Lesson 03.02 (Multi-Layer Networks)

Learning Objectives

  • Implement a Value-based autograd engine that builds a computational graph and computes gradients via topological sort
  • Derive the backward pass for addition, multiplication, and sigmoid using the chain rule
  • Train a multi-layer network on XOR and circle classification using only your from-scratch backpropagation engine
  • Identify the vanishing gradient problem in deep sigmoid networks and explain why gradients shrink exponentially

The Problem

Take a network with a single hidden layer: 768 inputs, 3072 outputs. That's 2,359,296 weights. Say it makes a wrong prediction and you need to know which weights caused the error. Testing each weight individually means running 2.3 million forward passes, one per weight, just to see how the loss moves. Backpropagation computes all 2.3 million gradients in a single backward pass instead. That isn't a minor speedup; it's the difference between a network you can train and one you can't.

The naive approach would be: nudge one weight by a tiny amount, rerun the forward pass, and check whether the loss went up or down. That gives you the gradient for that one weight. Now repeat it for every weight in the network, across thousands of training steps and millions of data points, and you'd need geological time to train anything useful.

Backpropagation solves this with one forward pass and one backward pass, computing every gradient in that single backward pass. The mechanism is the chain rule from calculus, applied systematically across a computational graph. It's the algorithm that made deep learning practical; without it, we'd still be stuck on toy problems.

Warm-up0/3
1. The loss is L = w^2 and w is currently 3. What is dL/dw at that point, and which way should w move to lower the loss?Warm-up
A parabola of the loss L = w squared against w, from -4 to 4, with the current point at w = 3 where L = 9 marked.
The loss curve L = w² with the current weight marked.
2. In Python, what is an inner function that keeps using a local variable of its enclosing function, even after the outer function has returned?Warm-up
A file adder.py with a function make_adder(n) that defines and returns a function add(x) which returns x + n, then add5 = make_adder(5) and print(add5(10)); the terminal prints 15.
The script was run and printed 15.
3. In graph theory, what does it mean for a graph to be a directed acyclic graph?Warm-up
A graph with five nodes A to E drawn as circles joined by arrows: A to B, A to C, B to D, C to D and D to E.
A graph whose edges are arrows.
Check: The Problem0/2
1. What is backpropagation?Define
A small computational graph: x = 2 and w = 0.5 feed a multiply to z1 = 1.0, plus b = 0.1 gives z2 = 1.1, a sigmoid gives a = 0.7503, and a squared error against y = 1 gives L = 0.0624. The nodes w and b are highlighted as the parameters.
Forward values for x = 2, w = 0.5, b = 0.1, target y = 1.
2. Why is backpropagation more efficient than testing each weight's effect with a separate forward pass?Explain
A grid of twelve cells, each labelled dL/dw, standing for a corner of a 768 by 3072 weight matrix, and a card giving 768 inputs, 3072 outputs and 768 x 3072 = 2,359,296 weights.
The lesson's example layer.

The Concept

The Chain Rule, Applied to Networks

You saw the chain rule back in Phase 01, Lesson 05: if y = f(g(x)), then dy/dx = f'(g(x)) * g'(x). You multiply derivatives along the chain.

In a neural network, the "chain" is the sequence of operations from input to loss. Each layer applies weights, adds a bias, and passes the result through an activation function, and the loss function compares that final output to the target. Backpropagation traces the chain backward, computing how much each operation contributed to the error.

Computational Graphs

Every forward pass builds a graph. Each node is an operation, like multiply, add, or sigmoid, and each edge carries a value forward and a gradient backward.

Mermaid diagram

Forward pass: values flow left to right. x and w produce z1 = w*x. Add b to get z2. Sigmoid turns z2 into the activation a. Compare a to the target y with the loss function.

Backward pass: gradients flow right to left. Start with dL/da: how the loss changes with the activation. Multiply by da/dz2 (the sigmoid derivative) to get dL/dz2. That gradient splits two ways: dL/db, which equals dL/dz2 since z2 = z1 + b, and dL/dz1. From dL/dz1, dL/dw = dL/dz1 * x and dL/dx = dL/dz1 * w.

Every node in the graph has exactly one job during the backward pass: take the gradient coming from above, multiply it by its own local derivative, and pass the result down.

Forward vs Backward

Mermaid diagram

The forward pass stores every intermediate value (z, a, the inputs to each layer) because the backward pass needs them to compute gradients. That's the memory-computation tradeoff at the heart of backprop: you trade memory (storing activations) for speed (one pass instead of millions).

Gradient Flow Through a Network

For a 3-layer network, gradients chain through every layer:

Mermaid diagram

At each layer, the gradient gets multiplied by the sigmoid derivative, a * (1 - a), which maxes out at 0.25 when a = 0.5. Three layers deep, the gradient has been multiplied by at most 0.25^3 = 0.0156; ten layers deep, by 0.25^10 = 0.000001.

Vanishing Gradients

The vanishing gradient problem is this: sigmoid squashes its output between 0 and 1, its derivative never exceeds 0.25, and stacking enough sigmoid layers multiplies that small number by itself until the gradient shrinks to nothing. Early layers barely learn because they receive near-zero gradients.

sigmoid(z):     Output range [0, 1]
sigmoid'(z):    Max value 0.25 (at z = 0)

After 5 layers:   gradient * 0.25^5 = 0.001x original
After 10 layers:  gradient * 0.25^10 = 0.000001x original

This is why deep sigmoid networks are nearly impossible to train. The fix (ReLU and its variants) is the subject of Lesson 04. For now, understand that backprop itself works perfectly; the problem is what it's working through.

Deriving Gradients for a 2-Layer Network

Here's the concrete math for a network with input x, a sigmoid hidden layer, a sigmoid output layer, and MSE loss.

Forward pass:

z1 = W1 * x + b1
a1 = sigmoid(z1)
z2 = W2 * a1 + b2
a2 = sigmoid(z2)
L = (a2 - y)^2

Backward pass (applying chain rule step by step):

dL/da2 = 2(a2 - y)
da2/dz2 = a2 * (1 - a2)
dL/dz2 = dL/da2 * da2/dz2 = 2(a2 - y) * a2 * (1 - a2)

dL/dW2 = dL/dz2 * a1
dL/db2 = dL/dz2

dL/da1 = dL/dz2 * W2
da1/dz1 = a1 * (1 - a1)
dL/dz1 = dL/da1 * da1/dz1

dL/dW1 = dL/dz1 * x
dL/db1 = dL/dz1

Every gradient is a product of local derivatives traced back from the loss. That's all backpropagation is.

Check: The Concept0/13
1. What is the chain rule in the context of neural networks?Define
A chain of three boxes: x = 2 goes through g(x) = 3x + 1 to give u = 7, then through f(u) = u squared to give y = 49, with a question mark for dy/dx.
Two functions chained: g first, then f.
2. What is a computational graph?Define
A table of nine Value nodes from one neuron's forward pass with columns node, data, _op and _children: x, w, w*x, b, (w*x)+b, sigmoid, -1.0, diff and loss, with the operation and the nodes each was built from.
Printed from the lesson's Value class: x = 2.0, w = 0.5, b = 0.1, target 1.0.
3. In training a network, what is the gradient of a parameter?Define
A card listing x = 2.0, w = 0.5, b = 0.1, a = 0.7503, y = 1.0 and L = 0.0624 for one neuron, and a curve of the loss as w varies from -1 to 2 with the current w = 0.5 marked.
Loss of the neuron as w alone varies; b and x held fixed.
4. What is the forward pass?Define
A 2-4-1 network drawn as three columns of nodes joined by lines. The inputs are 0.0 and 1.0, the four hidden nodes show 0.2789, 0.3650, 0.5874 and 0.3045, and the output node shows 0.3764.
Values computed by the lesson's Network; weights are random with seed 42.
5. What is the backward pass?Define
A table of the nine nodes of the neuron's graph, x, w, w*x, b, (w*x)+b, sigmoid, -1.0, diff and loss, each with its data value and a grad of 0.0000.
Printed from the lesson's Value class: x = 2.0, w = 0.5, b = 0.1, target 1.0.
6. What is the vanishing gradient problem?Define
A bar chart on a log scale of the gradient norm of each of ten layers' weights in a 10-layer sigmoid network, layer 1 at the input end to layer 10 at the output end, with values printed above each bar.
One real torch run: width 4, XOR inputs, squared error, lesson-style initial weights.
7. What does backpropagation pay in exchange for needing only one backward pass?Explain
A forward chain x, z1, a1, z2, a2, L, and below it the five backward formulas of the 2-layer network from the lesson, each written with the quantities it multiplies.
Formulas from the lesson's derivation.
8. What single job does each node perform during the backward pass?Explain
Three boxes in a row: gradient arriving from above dL/da = -0.4995, the sigmoid node with local derivative da/dz2 = 0.1874, and the value handed to z2 shown as a question mark.
Neuron with x = 2, w = 0.5, b = 0.1, target 1.
9. What causes the vanishing gradient problem in deep sigmoid networks?Explain
Ten boxes L1 to L10 in a row with the gradient norm of each layer's weights printed under it, an arrow pointing backward from L10 to L1, and a card describing the run: Linear then sigmoid layers of width 4, squared error on XOR, with its parameter count.
One real torch run.
10. A network has a = 0.5 as its sigmoid output and a target y = 1.0, with L = (a - y)^2. Using dL/dz = 2(a - y) * a * (1 - a), what is dL/dz?Apply
A chain z, sigmoid, a = 0.5, then L = (a - y) squared with y = 1.0, and a card giving the formula dL/dz = 2(a - y) * a * (1 - a).
Setup from the stem.
11. The sigmoid derivative never exceeds 0.25. By what factor can a gradient have been multiplied at most after passing back through 3 sigmoid layers?Apply
Three boxes layer 1, layer 2, layer 3, each noting its sigmoid derivative is at most 0.25, with orange arrows pointing leftward from where a gradient g enters at layer 3 toward layer 1, labelled 'reaches layer 1 as g * ?'.
Backward direction, three layers deep.
12. In a 10-layer sigmoid network, printed gradients for the first layer are about 1e-6 times those of the last layer, and the first layer's weights barely change. Which explanation fits?Analyze
A terminal listing the gradient norm of each of ten layers of a sigmoid network from layer 1 to layer 10, the ratio layer 1 over layer 10 (1.0e-06), and the mean absolute weight change of layer 1 and layer 10 after 200 SGD steps.
One real run on XOR: width 4, squared error, the lesson's init rule (seed picked so the ratio is near the stem's 1e-6).
13. You must keep a 10-layer network, but the early layers do not learn with sigmoid hidden units. Which change targets the cause?Design
A log-scale bar chart of the mean absolute weight change of each of ten layers of a sigmoid network after 200 SGD steps, layer 1 at the input end.
One real torch run: width 4, XOR data, lr 0.5.

Build It

Step 1: The Value Node

Every number in our computation becomes a Value. It stores its data, its gradient, and how it was created (so it knows how to compute gradients backward).

python
class Value:
    def __init__(self, data, children=(), op=''):
        self.data = data
        self.grad = 0.0
        self._backward = lambda: None
        self._children = set(children)
        self._op = op
 
    def __repr__(self):
        return f"Value(data={self.data:.4f}, grad={self.grad:.4f})"

There's no gradient yet (0.0) and no backward function yet (a no-op). The _children set tracks which Values produced this one, which is what lets us topologically sort the graph later.

Step 2: Operations with Backward Functions

Each operation creates a new Value and defines how gradients flow backward through it.

python
def __add__(self, other):
    other = other if isinstance(other, Value) else Value(other)
    out = Value(self.data + other.data, (self, other), '+')
 
    def _backward():
        self.grad += out.grad
        other.grad += out.grad
 
    out._backward = _backward
    return out
 
def __mul__(self, other):
    other = other if isinstance(other, Value) else Value(other)
    out = Value(self.data * other.data, (self, other), '*')
 
    def _backward():
        self.grad += other.data * out.grad
        other.grad += self.data * out.grad
 
    out._backward = _backward
    return out

For addition, d(a+b)/da = 1 and d(a+b)/db = 1, so both inputs get the output's gradient directly. For multiplication, d(ab)/da = b and d(ab)/db = a, so each input gets the other's value times the output gradient.

The += here is critical: a Value might be used in multiple operations, and its gradient is the sum of gradients from every path that uses it.

Step 3: Sigmoid and Loss

The sigmoid derivative is sigmoid(x) * (1 - sigmoid(x)). We already computed sigmoid(x) = s during the forward pass, so the backward function should just reuse it instead of recomputing anything:

python
import math
 
def sigmoid(self):
    x = self.data
    x = max(-500, min(500, x))
    s = 1.0 / (1.0 + math.exp(-x))
    out = Value(s, (self,), 'sigmoid')
 
    def _backward():
        self.grad += (s * (1 - s)) * out.grad
 
    out._backward = _backward
    return out

To train on XOR you'll need a single scalar to call .backward() on. MSE for one output is (predicted - target)^2, and we can express the subtraction as addition with a negated Value:

python
def mse_loss(predicted, target):
    diff = predicted + Value(-target)
    return diff * diff

Step 4: Backward Pass

Topological sort ensures we process nodes in the right order: a node's gradient has to be fully accumulated before we propagate through it.

python
def backward(self):
    topo = []
    visited = set()
 
    def build_topo(v):
        if v not in visited:
            visited.add(v)
            for child in v._children:
                build_topo(child)
            topo.append(v)
 
    build_topo(self)
    self.grad = 1.0
    for v in reversed(topo):
        v._backward()

Start at the loss, with gradient 1.0 since dL/dL = 1, then walk backward through the sorted graph: each node's _backward pushes gradients to its children.

Step 5: Layer and Network

A Neuron needs to take inputs, compute a weighted sum plus bias, and apply sigmoid to it, exactly like the Value operations above but wired together into one unit. Its weight initialization scales by sqrt(2/n_inputs) to prevent sigmoid saturation in deeper networks. A Layer is a list of Neurons, and a Network is a list of Layers; the parameters() method on each collects every learnable Value so the training loop can update them later.

python
import random
 
class Neuron:
    def __init__(self, n_inputs):
        scale = (2.0 / n_inputs) ** 0.5
        self.weights = [Value(random.uniform(-scale, scale)) for _ in range(n_inputs)]
        self.bias = Value(0.0)
 
    def __call__(self, x):
        act = sum((wi * xi for wi, xi in zip(self.weights, x)), self.bias)
        return act.sigmoid()
 
    def parameters(self):
        return self.weights + [self.bias]
 
 
class Layer:
    def __init__(self, n_inputs, n_outputs):
        self.neurons = [Neuron(n_inputs) for _ in range(n_outputs)]
 
    def __call__(self, x):
        out = [n(x) for n in self.neurons]
        return out[0] if len(out) == 1 else out
 
    def parameters(self):
        params = []
        for n in self.neurons:
            params.extend(n.parameters())
        return params
 
 
class Network:
    def __init__(self, sizes):
        self.layers = []
        for i in range(len(sizes) - 1):
            self.layers.append(Layer(sizes[i], sizes[i + 1]))
 
    def __call__(self, x):
        for layer in self.layers:
            x = layer(x)
            if not isinstance(x, list):
                x = [x]
        return x[0] if len(x) == 1 else x
 
    def parameters(self):
        params = []
        for layer in self.layers:
            params.extend(layer.parameters())
        return params
 
    def zero_grad(self):
        for p in self.parameters():
            p.grad = 0.0

Step 6: Train on XOR

python
random.seed(42)
net = Network([2, 4, 1])
 
xor_data = [
    ([0.0, 0.0], 0.0),
    ([0.0, 1.0], 1.0),
    ([1.0, 0.0], 1.0),
    ([1.0, 1.0], 0.0),
]
 
learning_rate = 1.0
 
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
 
    if epoch % 100 == 0:
        print(f"Epoch {epoch:4d} | Loss: {total_loss.data:.6f}")
 
print("\nXOR Results:")
for inputs, target in xor_data:
    x = [Value(i) for i in inputs]
    pred = net(x)
    print(f"  {inputs} -> {pred.data:.4f} (expected {target})")

Watch the loss decrease: what starts as random predictions turns into correct XOR outputs, driven entirely by backpropagation computing gradients and nudging the weights in the right direction.

Step 7: Circle Classification

In Lesson 02, you hand-tuned weights for circle classification. Now let the network learn them.

python
random.seed(7)
 
def generate_circle_data(n=100):
    data = []
    for _ in range(n):
        x1 = random.uniform(-1.5, 1.5)
        x2 = random.uniform(-1.5, 1.5)
        label = 1.0 if x1 * x1 + x2 * x2 < 1.0 else 0.0
        data.append(([x1, x2], label))
    return data
 
circle_data = generate_circle_data(80)
 
circle_net = Network([2, 8, 1])
learning_rate = 0.5
 
for epoch in range(2000):
    random.shuffle(circle_data)
    total_loss_val = 0.0
    for inputs, target in circle_data:
        x = [Value(i) for i in inputs]
        pred = circle_net(x)
        loss = mse_loss(pred, target)
        circle_net.zero_grad()
        loss.backward()
        for p in circle_net.parameters():
            p.data -= learning_rate * p.grad
        total_loss_val += loss.data
 
    if epoch % 200 == 0:
        correct = 0
        for inputs, target in circle_data:
            x = [Value(i) for i in inputs]
            pred = circle_net(x)
            predicted_class = 1.0 if pred.data > 0.5 else 0.0
            if predicted_class == target:
                correct += 1
        accuracy = correct / len(circle_data) * 100
        print(f"Epoch {epoch:4d} | Loss: {total_loss_val:.4f} | Accuracy: {accuracy:.1f}%")

We use online SGD here: updating weights after each sample instead of accumulating the full batch. This breaks symmetry faster and avoids sigmoid saturation on the full loss landscape. Shuffling the data each epoch keeps the network from memorizing the order it was trained in.

No hand-tuning. The network discovers the circular decision boundary on its own. That's the power of backpropagation: you define the architecture, the loss function, and the data. The algorithm figures out the weights.

Check: Build It0/22
1. What does the learning rate control in the update w_new = w_old - lr * gradient?Define
The curve L = w squared with the start point w = 3, a green arrow to w = 2.4 labelled lr 0.1, and a dashed red arrow to w = 0 labelled lr 0.5, both from the same gradient 6.
One update from the same point with two different learning rates.
2. What is a topological sort of the computational graph?Define
Left, a small graph: a = 2.0 and b = 3.0 feed an add node (5.0), which with a node 5.0 feeds a multiply node c = 25.0. Right, the build_topo code from backward(): a topo list, a visited set, and a recursive function that visits each child and then appends the node.
Graph built with the lesson's Value class; the code is copied from backward().
3. In the backward pass, why do we use += instead of = when accumulating gradients?Explain
A graph where x = 2.0 feeds two multiply nodes, p = x * w1 with w1 = 3.0 and q = x * w2 with w2 = 4.0, and p and q feed an add node L = 14.0.
The value x is used by two operations.
4. Why does topological sort matter in the backward pass?Explain
A graph where a = 2.0 is an input to both p = a * 3.0 (6.0) and q = a + 1.0 (3.0), which feed L = p * q = 18.0, with the backward loop line 'for v in reversed(topo): v._backward()' underneath.
Node values before backward().
5. Why does Value.backward set self.grad = 1.0 on the loss before walking the graph?Explain
The backward method of the lesson's Value class with the line self.grad = 1.0 highlighted, and a terminal showing that before backward() runs, w, b and loss all print grad=0.0000.
Value reprs from the lesson's code for x = 2.0, w = 0.5, b = 0.1, target 1.0.
6. In the Value class's sigmoid method, why does the backward function reuse s instead of calling sigmoid(x) again?Explain
The sigmoid method of the lesson's Value class with the line that defines s and the line self.grad += (s * (1 - s)) * out.grad highlighted, and a card with s = 0.7503 and s * (1 - s) = 0.1874 for z2 = 1.1.
Code copied from the lesson.
7. In the Value class's __add__ and __mul__ methods, what local derivative rule does each backward function encode?Explain
Two small graphs side by side: c = a + b with a = 2.0, b = 3.0, c = 5.0 and an incoming grad of 2.0, and d = a * b with d = 6.0 and an incoming grad of 2.0; the grads of a and b are question marks.
Inputs a = 2.0 and b = 3.0 in both graphs.
8. Why does the Neuron class scale its initial weights by sqrt(2/n_inputs) instead of using a fixed range?Explain
The Neuron init lines scale = (2.0 / n_inputs) ** 0.5 and Value(random.uniform(-scale, scale)), and horizontal bars showing the weight range for n_inputs 2, 4, 8 and 100 as plus or minus 1.0000, 0.7071, 0.5000 and 0.1414 on an axis from -1 to 1.
Computed from the lesson's formula.
9. In Step 6, why are the four per-sample losses added into total_loss before calling backward()?Explain
Four boxes, one per XOR sample, each with its input, target and squared-error loss, with arrows into a single total_loss box (data 1.0674), and below it the Step 6 loop lines that build total_loss and call total_loss.backward().
Values from the lesson's own Network at its initial weights.
10. In the Step 7 circle classification code, why does the loop update weights after each individual sample instead of accumulating the loss over the full dataset first?Explain
A scatter of 80 circle-classification points in a square from -1.5 to 1.5 with the unit circle drawn, orange points inside and blue outside, a card with the data facts, and the Step 7 training loop code with the weight update inside the loop over single samples.
Data from generate_circle_data with random.seed(7).
11. In Lesson 02 you hand-tuned weights for circle classification, but in Step 7 the network finds the boundary itself. What makes that possible?Explain
The 80 circle-classification points with the network's 0.5 output contour at three moments: initial weights, after 20 epochs and after 2000 epochs, each with its accuracy on the 80 points.
Network [2, 8, 1] trained with the lesson's Step 7 loop (random.seed(7), lr 0.5).
12. With the Value class, a = Value(3.0), b = Value(4.0), c = a * b, and then c.backward() is called. What are a.grad and b.grad?Apply
A graph where a = 3.0 and b = 4.0 feed a multiply node c = a * b with data 12.0; every node shows grad 0.0 and a label says c.backward() is called.
Node values before backward().
13. With the Value class, x = Value(3.0), y = x * x, and then y.backward() is called. What is x.grad?Apply
A graph where one node x = 3.0 is joined by two arrows to a multiply node y = x * x with data 9.0, both nodes at grad 0.0, and a label y.backward().
Node values before backward().
14. With the Value class, a = Value(2.0), b = Value(3.0), c = (a + b) * Value(5.0), and then c.backward() is called. What is a.grad?Apply
A graph: a = 2.0 and b = 3.0 feed an add node (5.0), which with a node 5.0 feeds a multiply node c = 25.0; all nodes show grad 0.0, and c.backward() is called.
Node values before backward().
15. A Value z has data 0.0 and z.sigmoid() is called. During backward, the output Value's grad is 1.0. How much does the sigmoid backward function add to z.grad?Apply
A node z with data 0.0 and grad 0.0 joined by a sigmoid arrow to a node out with data 0.5 and grad 1.0.
Setup from the stem.
16. predicted = Value(0.8) and loss = mse_loss(predicted, 1.0) are built, and loss.backward() is called. What is predicted.grad?Apply
A graph for mse_loss: predicted = 0.8 and Value(-1.0) feed an add node diff = -0.2, and diff feeds a multiply node loss = diff * diff with data 0.04 by two arrows; every grad is 0.0 before backward().
Node values before backward().
17. How many entries does Network([2, 4, 1]).parameters() return, counting each neuron's weights and bias?Apply
A network drawn with two input boxes x1 and x2, four hidden circles and one output circle, each circle marked b for its bias, with a line from every input to every hidden circle and from every hidden circle to the output circle.
Each circle is one Neuron object.
18. A network's weights are initialized with large random values, so the weighted sums are huge. Sigmoid outputs sit near 0 or 1, and training barely moves the loss. Why?Analyze
A loss-versus-epoch plot of the XOR network with two curves: the lesson's initial weights fall toward 0, while the same weights multiplied by 20 stay flat near 2.0; below, a terminal with the large weighted sums z and the hidden outputs a for input [0.0, 1.0].
Real runs of the lesson's Network. The x 20 scaling is an illustrative stand-in for 'large random values'.
19. A teammate deletes net.zero_grad() from the Step 6 loop and training becomes erratic. What is the most likely cause?Analyze
A loss-versus-epoch plot for the XOR loop: with zero_grad the loss falls smoothly toward 0; with the zero_grad line deleted it jitters between about 0.95 and 1.08 for the first 15 epochs, then hovers near 0.5 with a small ripple and never improves. Below, the edited loop body with net.zero_grad() commented out.
Both curves are real runs of the lesson's Step 6 loop with seed 42; only the zero_grad call differs. The ripple after epoch 100 is real (about 0.50 to 0.52).
20. A student changes the __mul__ backward function to use = instead of +=. For x = Value(3.0) and y = x * x, what happens to x.grad after y.backward()?Analyze
The graph y = x * x with x = 3.0 joined to the multiply node by two arrows, and the edited __mul__ backward function whose two lines use = instead of +=.
Node values before backward().
21. A developer edits Value.backward to loop over topo instead of reversed(topo). What goes wrong?Analyze
A row of five boxes, topo as built for c = (a + b) * Value(5.0): a, b, a + b, 5.0, c; and the edited backward lines c.grad = 1.0, for v in topo, v._backward().
One valid result of the lesson's build_topo; sibling order can vary because _children is a set.
22. You want to add a relu method to Value, with output max(0, x) and derivative 1 if x > 0 and 0 otherwise. Which backward function fits the Value class's design?Design
The lesson's sigmoid method for reference, and below it a relu method skeleton whose _backward body is left as dots, with the output max(0, x) and the derivative 1 if x > 0 and 0 otherwise.
The sigmoid code is copied from the lesson; the relu skeleton is typed.

Use It

PyTorch does everything above in a few lines. The core idea is identical: autograd builds a computational graph during the forward pass and traces it backward to compute gradients.

python
import torch
import torch.nn as nn
 
model = nn.Sequential(
    nn.Linear(2, 4),
    nn.Sigmoid(),
    nn.Linear(4, 1),
    nn.Sigmoid(),
)
optimizer = torch.optim.SGD(model.parameters(), lr=1.0)
criterion = nn.MSELoss()
 
X = torch.tensor([[0,0],[0,1],[1,0],[1,1]], dtype=torch.float32)
y = torch.tensor([[0],[1],[1],[0]], dtype=torch.float32)
 
for epoch in range(1000):
    pred = model(X)
    loss = criterion(pred, y)
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()
 
print("PyTorch XOR Results:")
with torch.no_grad():
    for i in range(4):
        pred = model(X[i])
        print(f"  {X[i].tolist()} -> {pred.item():.4f} (expected {y[i].item()})")

loss.backward() is your total_loss.backward(). optimizer.step() is your manual p.data -= lr * p.grad. optimizer.zero_grad() is your net.zero_grad(). Same algorithm, an industrial-strength implementation: PyTorch handles GPU acceleration, mixed precision, gradient checkpointing, and hundreds of layer types, but the backward pass is still the same chain rule applied to the same computational graph.

Training runs the forward pass, then the backward pass, then updates the weights. Inference runs only the forward pass: no gradients, no updates. That distinction matters because inference is what happens in production. When you call an API like Claude or GPT, you're running inference: your prompt flows forward through the network and tokens come out the other end, but no weights change. Understanding backprop matters because it shaped every weight in that network.

Check: Use It0/4
1. What is autograd?Define
A Python session using PyTorch: x = torch.tensor(3.0, requires_grad=True), y = x * x, y.grad_fn.name() prints 'MulBackward0', then y.backward() and x.grad prints tensor(6.).
Run with CPU torch; nothing typed by hand.
2. What is the key difference between training and inference?Explain
A PyTorch session on a 2-4-1 sigmoid network: a training step with zero_grad, backward and step, after which the first weight is no longer equal to its old value (False); then an inference call under torch.no_grad(), after which the weight equals its previous value (True) and out.requires_grad is False.
Run, not typed (the # comment lines were added): the XOR model from the lesson's Use It section.
3. In the PyTorch example, which line of the from-scratch training loop does optimizer.step() replace?Apply
Two code cards: the from-scratch training loop lines (net = Network, net.zero_grad(), total_loss.backward(), a for loop updating p.data) and the PyTorch lines (model, optimizer, optimizer.zero_grad(), loss.backward(), optimizer.step()).
Lines from the lesson's two training loops.
4. You deploy a trained model behind an API that only returns predictions. What should the request path run?Design
A client sends a request to an API server holding a trained model, which returns a prediction; a terminal shows a curl call with x = [0.0, 1.0] and the response {"prediction": 0.9677}.
The request is typed, not captured; 0.9677 is the lesson's trained XOR network's output for [0.0, 1.0].

Ship It

This lesson produces:

  • outputs/prompt-gradient-debugger.md: a reusable prompt for diagnosing gradient problems (vanishing, exploding, NaN) in any neural network

Exercises

  1. Add a __sub__ method to the Value class (a - b = a + (-1 * b)). Then implement a __neg__ method. Verify that the gradients are correct by comparing with manual calculation for a simple expression like (a - b)^2.

  2. Add a relu method to Value (output max(0, x), derivative is 1 if x > 0, else 0). Replace sigmoid with relu in the hidden layers and train on XOR again. Compare convergence speed. You should see faster training, which previews Lesson 04.

  3. Implement a __pow__ method on Value for integer powers. Use it to replace mse_loss with a proper (predicted - target) ** 2 expression. Verify gradients match the original implementation.

  4. Add gradient clipping to the training loop: after calling backward(), clip all gradients to [-1, 1]. Train a deeper network (4+ layers with sigmoid) and compare loss curves with and without clipping. This is your first defense against exploding gradients.

  5. Build a visualization: after training on XOR, print the gradient of every parameter in the network. Identify which layer has the smallest gradients. This demonstrates the vanishing gradient problem you read about in the Concept section.

Key Terms

TermWhat people sayWhat it actually means
Backpropagation"The network learns"An algorithm that computes dL/dw for every weight by applying the chain rule backward through the computational graph
Computational graph"The network structure"A directed acyclic graph where nodes are operations and edges carry values (forward) and gradients (backward)
Chain rule"Multiply the derivatives"If y = f(g(x)), then dy/dx = f'(g(x)) * g'(x): the mathematical foundation of backpropagation
Gradient"The direction of steepest ascent"The partial derivative of the loss with respect to a parameter, which tells you how to change that parameter to reduce the loss
Vanishing gradient"Deep networks don't learn"Gradients shrink exponentially as they propagate through layers with saturating activations like sigmoid
Forward pass"Running the network"Computing the output from inputs by sequentially applying each layer's operations and storing intermediate values
Backward pass"Computing gradients"Traversing the computational graph in reverse, accumulating gradients at each node using the chain rule
Learning rate"How fast it learns"A scalar that controls the step size when updating weights: w_new = w_old - lr * gradient
Topological sort"The right order"An ordering of graph nodes where each node appears after all the nodes it depends on, which ensures gradients are fully accumulated before propagation
Autograd"Automatic differentiation"A system that builds computational graphs during forward computation and automatically computes gradients: what PyTorch's engine does

Further Reading

  • Rumelhart, Hinton & Williams, "Learning representations by back-propagating errors" (1986): the paper that made backpropagation mainstream and unlocked multi-layer network training
  • 3Blue1Brown, "Neural Networks" series (https://www.youtube.com/playlist?list=PLZHQObOWTQDNU6R1_67000Dx_ZCJB-3pi): the best visual explanation of backpropagation and gradient flow through networks

Code

Ships With