Backpropagation and Gradient Descent

How a network actually learns. A loss to measure error, gradient descent to reduce it, and backpropagation — the chain rule, organised — to find every weight’s share of the blame. You will add a backward pass to the two-layer network, prove it correct, and watch it learn.

Imagine standing on a hillside in thick fog, trying to reach the valley floor. You cannot see the valley, but you can feel the slope under your feet, so you take a small step downhill, feel again, and repeat. That is gradient descent, and it is how every modern neural network is trained. The difficulty is that a network’s “hillside” has not two directions but millions or billions, one for every weight, and you need the slope in all of them at once, thousands of times over. Backpropagation is the procedure that delivers all those slopes for roughly the cost of running the network twice. It is one of the most consequential algorithms of the past half-century, and it fits in about ten lines of NumPy.

Loss: the number we make smaller

Training needs a single number that says how wrong the network is: the loss. Everything else follows from choosing it. Two choices cover most of what you will meet.

MSE = (1/n) ∑ (ŷi − yi)2mean squared error, for predicting numbers: the average squared distance between prediction ŷ and truth y
CE = −(1/n) ∑ log pi, correctcross-entropy, for classification: the average of minus the log of the probability the model gave to each example’s correct class

The maths page explained where cross-entropy comes from (maximum likelihood). Here is why it is preferred for classification. Suppose the true class is 1 and the model’s probability for it slides from good to terrible:

import numpy as np

y_true = 1.0
for y_pred in [0.9, 0.5, 0.1, 0.01]:
    mse = (y_pred - y_true) ** 2
    ce = -np.log(y_pred)             # cross-entropy when the true class is 1
    print(f"predicted {y_pred:<5} squared error {mse:.3f}   cross-entropy {ce:.3f}")
predicted 0.9   squared error 0.010   cross-entropy 0.105
predicted 0.5   squared error 0.250   cross-entropy 0.693
predicted 0.1   squared error 0.810   cross-entropy 2.303
predicted 0.01  squared error 0.980   cross-entropy 4.605

Squared error can never exceed 1 here, so a confidently wrong answer (0.01) is barely punished more than a merely wrong one (0.1). Cross-entropy keeps climbing — towards infinity as the probability approaches zero — so confident mistakes produce large losses and, as we will see, large corrective gradients. The loss is a design decision: it is the only thing the network is told about what you want.

Gradient descent and the learning rate

Collect every weight and bias of a network into one long vector θ (theta). The loss is a function of θ, and its gradient ∇L(θ) points in the direction in which the loss rises fastest. Gradient descent steps the other way:

θ ← θ − η ∇L(θ)η (eta) is the learning rate: how far to step against the gradient each time; the arrow means “replace θ with”

The method goes back to Augustin-Louis Cauchy in 1847. Its one dial is the learning rate, and the simplest possible “model” shows why it matters. Take a single weight w with loss w2 (minimum at 0, gradient 2w), start at 1.5, and take ten steps:

def loss(w):                      # a one-weight "model" with its minimum at w = 0
    return w ** 2

def grad(w):
    return 2 * w

for lr in [0.1, 0.5, 0.9, 1.1]:
    w = 1.5
    for step in range(10):
        w = w - lr * grad(w)
    print(f"learning rate {lr}: w after 10 steps = {w:10.4f}   loss = {loss(w):.4g}")
learning rate 0.1: w after 10 steps =     0.1611   loss = 0.02594
learning rate 0.5: w after 10 steps =     0.0000   loss = 0
learning rate 0.9: w after 10 steps =     0.1611   loss = 0.02594
learning rate 1.1: w after 10 steps =     9.2876   loss = 86.26

Each step multiplies w by (1 − 2η). With η = 0.1 that factor is 0.8: steady, cautious progress. With η = 0.5 it is 0, so this particular bowl is solved in one step — a coincidence of its shape, not something real networks offer. With η = 0.9 the factor is −0.8: every step overshoots to the other side of the valley, and the weight zig-zags inward, ending exactly as far along as the cautious run. With η = 1.1 the factor is −1.2 and each overshoot is bigger than the last: the loss explodes from 2.25 to 86. Try it yourself:

Push the learning rate up until the ball starts bouncing from side to side, then further until it flies off. Real training curves show the same three regimes: crawling, zig-zagging and diverging.

Real loss surfaces are not neat bowls. They have long narrow valleys, flat plateaus and saddle points, and the best learning rate differs between directions. That is why practical training uses refinements — momentum, Adam, learning-rate schedules — which Training Well takes up. All of them still need the same raw ingredient: the gradient.

The chain rule through a computation graph

To get the gradient efficiently, think of the forward pass as a computation graph: each node is one simple operation, and the loss sits at the end. Here is the smallest interesting example — one input x = 2, one weight w = 0.5, one bias b = 0.1, a tanh neuron and squared error against a target y = 1. Tap along the graph: the forward pass runs left to right, then the gradients flow back right to left.

The same computation as code, with a numerical check at the end:

import numpy as np

x, y = 2.0, 1.0                  # one training example
w, b = 0.5, 0.1                  # the parameters

# forward: record every intermediate value
z = w * x + b                    # 1.1
a = np.tanh(z)                   # squashed prediction
L = (a - y) ** 2                 # squared error

# backward: local slope times the gradient arriving from above
dL_da = 2 * (a - y)
dL_dz = dL_da * (1 - a ** 2)     # tanh'(z) = 1 - tanh(z)^2
dL_dw = dL_dz * x
dL_db = dL_dz * 1.0

print(f"z={z:.4f}  a={a:.4f}  L={L:.4f}")
print(f"dL/da={dL_da:.4f}  dL/dz={dL_dz:.4f}  dL/dw={dL_dw:.4f}  dL/db={dL_db:.4f}")

h = 1e-6                         # check dL/dw by nudging w
numeric = ((np.tanh((w + h) * x + b) - y) ** 2 - (np.tanh((w - h) * x + b) - y) ** 2) / (2 * h)
print(f"numeric dL/dw={numeric:.4f}")
z=1.1000  a=0.8005  L=0.0398
dL/da=-0.3990  dL/dz=-0.1433  dL/dw=-0.2866  dL/db=-0.1433
numeric dL/dw=-0.2866

Two things make this backpropagation rather than just “using the chain rule”. First, the backward pass reuses work: ∂L/∂z is computed once and shared by both w and b. In a big network, the gradient arriving at a layer is shared by every weight in it, so one backward sweep gives all the gradients for about the cost of one more forward pass. Nudging each weight separately, as the numeric check does, would need two forward passes per weight. Second, the forward pass must store its intermediate values (here a), because the backward pass needs them. That stored state is why training uses far more memory than running a model.

The idea has a long, multi-national history. Seppo Linnainmaa described reverse-mode automatic differentiation — the general algorithm — in a Finnish master’s thesis in 1970; Paul Werbos proposed applying it to neural networks in his 1974 Harvard thesis; and David Rumelhart, Geoffrey Hinton and Ronald Williams’ 1986 Nature paper showed convincingly that it lets hidden layers learn useful internal representations, launching the revival told in Backprop and the Connectionist Revival.

Backprop for the two-layer network

Now the real thing. Our TwoLayerNet from Neural Networks from Scratch computes, for a batch: H = tanh(XW1 + b1), then P = softmax(HW2 + b2), then the mean cross-entropy. Walking backwards through that graph gives five formulas.

∂L/∂Z2 = (P − Y) ÷ nY is the one-hot matrix of true classes and n the batch size; the softmax and cross-entropy slopes combine into this remarkably simple “prediction minus truth”
∂L/∂W2 = Hᵀ (∂L/∂Z2) ∂L/∂b2 = column sums of ∂L/∂Z2each weight’s gradient is the input it saw times the error it fed; biases add up the error over the batch
∂L/∂H = (∂L/∂Z2) W2ᵀ ∂L/∂Z1 = ∂L/∂H ⊙ (1 − H2)send the error back through the same weights, then scale it by tanh’s slope; ⊙ means element-by-element multiplication
∂L/∂W1 = Xᵀ (∂L/∂Z1) ∂L/∂b1 = column sums of ∂L/∂Z1the same pattern as layer 2, one level down

A trick that makes these formulas easy to remember: a gradient always has the same shape as the thing it is the gradient of. ∂L/∂W2 must be (16, 2) like W2. The ingredients are H, which is (200, 16), and ∂L/∂Z2, which is (200, 2). The only way to combine them into (16, 2) is Hᵀ @ ∂L/∂Z2. Shapes almost write backprop for you. Here is the class again, now with a loss and a backward method. Save it as net.py; the next three snippets import it.

# net.py
import numpy as np

def softmax(Z):
    Z = Z - Z.max(axis=1, keepdims=True)
    E = np.exp(Z)
    return E / E.sum(axis=1, keepdims=True)

class TwoLayerNet:
    def __init__(self, n_in, n_hidden, n_out, seed=42):
        rng = np.random.default_rng(seed)
        self.W1 = rng.normal(0, 1 / np.sqrt(n_in), size=(n_in, n_hidden))
        self.b1 = np.zeros(n_hidden)
        self.W2 = rng.normal(0, 1 / np.sqrt(n_hidden), size=(n_hidden, n_out))
        self.b2 = np.zeros(n_out)

    def forward(self, X):
        self.X = X
        self.H = np.tanh(X @ self.W1 + self.b1)
        self.P = softmax(self.H @ self.W2 + self.b2)
        return self.P

    def loss(self, X, y):                      # mean cross-entropy
        P = self.forward(X)
        return -np.log(P[np.arange(len(y)), y]).mean()

    def backward(self, y):                     # call after forward/loss
        n = len(y)
        dZ2 = self.P.copy()
        dZ2[np.arange(n), y] -= 1              # softmax + cross-entropy: P - onehot
        dZ2 /= n                               # because the loss is a mean
        self.dW2 = self.H.T @ dZ2              # (hidden, out)
        self.db2 = dZ2.sum(axis=0)
        dH = dZ2 @ self.W2.T                   # send the blame back a layer
        dZ1 = dH * (1 - self.H ** 2)           # through tanh
        self.dW1 = self.X.T @ dZ1              # (in, hidden)
        self.db1 = dZ1.sum(axis=0)

    def params(self):
        return [("W1", self.W1, "dW1"), ("b1", self.b1, "db1"),
                ("W2", self.W2, "dW2"), ("b2", self.b2, "db2")]

The backward method is the five formulas, line for line. Subtracting 1 at each example’s true class turns P into P − Y without building the one-hot matrix. params pairs each parameter with the name of its gradient, so a training loop can update them all uniformly.

Never trust a backward pass you have not checked. Hand-written gradients are easy to get subtly wrong — a missing transpose, a forgotten 1/n — and a wrong gradient often still trains, just badly, so the bug hides. The cure is a gradient check: compare every analytic gradient with a central-difference estimate.

import numpy as np
from sklearn.datasets import make_moons
from net import TwoLayerNet

X, y = make_moons(n_samples=200, noise=0.2, random_state=42)
net = TwoLayerNet(2, 16, 2)
net.loss(X, y)
net.backward(y)

h = 1e-5
for name, param, gname in net.params():
    analytic = getattr(net, gname)
    numeric = np.zeros_like(param)
    for i in np.ndindex(param.shape):          # nudge every single number
        old = param[i]
        param[i] = old + h; up = net.loss(X, y)
        param[i] = old - h; down = net.loss(X, y)
        param[i] = old
        numeric[i] = (up - down) / (2 * h)
    rel = np.abs(analytic - numeric).max() / (np.abs(analytic).max() + np.abs(numeric).max())
    print(f"{name}: max relative difference {rel:.2e}")
W1: max relative difference 7.74e-11
b1: max relative difference 1.60e-10
W2: max relative difference 3.83e-11
b2: max relative difference 2.14e-11

Relative differences around 10−10 mean the analytic and numeric gradients agree to about ten significant figures: the backward pass is right. As a rule of thumb, values below about 10−7 are reassuring; values near 10−2 or larger mean a bug. Gradient checks are slow (two forward passes for each of the 82 parameters here), so you run them once on a small network to validate the code, then switch them off.

Training: full batches, mini-batches and SGD

With a correct gradient, training is a loop of four steps, repeated. One pass through the whole training set is called an epoch.

LOOPOne step

First, the simplest version: full-batch gradient descent, using all 200 two-moons points for every step.

import numpy as np
from sklearn.datasets import make_moons
from net import TwoLayerNet

X, y = make_moons(n_samples=200, noise=0.2, random_state=42)
net = TwoLayerNet(2, 16, 2)
lr = 0.5

for epoch in range(2001):
    loss = net.loss(X, y)                      # forward
    net.backward(y)                            # backward
    for _, param, gname in net.params():       # gradient descent step
        param -= lr * getattr(net, gname)
    if epoch % 400 == 0:
        acc = (net.P.argmax(axis=1) == y).mean()
        print(f"epoch {epoch:>4}  loss {loss:.4f}  accuracy {acc:.3f}")
epoch    0  loss 0.7100  accuracy 0.550
epoch  400  loss 0.1096  accuracy 0.965
epoch  800  loss 0.0775  accuracy 0.970
epoch 1200  loss 0.0703  accuracy 0.970
epoch 1600  loss 0.0658  accuracy 0.970
epoch 2000  loss 0.0609  accuracy 0.970

The loss starts at 0.71 — the untrained value from the previous page — and falls fast, then slowly. Accuracy jumps from a coin flip to 96.5% in the first 400 steps, then creeps to 97%. The loss keeps falling after accuracy stops improving: the network is growing more confident on points it already gets right. Note that param -= … updates the array in place, which is why the network’s own attributes change. These are training-set figures, though, and the Builder track taught you not to trust those alone.

Full-batch descent has a scaling problem: every single step reads the entire dataset. With millions of examples that is absurdly slow. The answer, which dates back to Herbert Robbins and Sutton Monro’s stochastic approximation (1951), is to estimate the gradient from a small random mini-batch of examples. Each estimate is noisy, but it is cheap, so you can take many more steps for the same compute. This is stochastic gradient descent (SGD) — strictly, SGD uses one example per step, but in practice “SGD” almost always means mini-batches.

import numpy as np
from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split
from net import TwoLayerNet

X, y = make_moons(n_samples=1000, noise=0.2, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=42)

net = TwoLayerNet(2, 16, 2)
rng = np.random.default_rng(42)
lr, batch_size = 0.5, 32

for epoch in range(1, 31):
    order = rng.permutation(len(X_train))          # reshuffle every epoch
    for start in range(0, len(order), batch_size):
        idx = order[start:start + batch_size]      # one mini-batch
        net.loss(X_train[idx], y_train[idx])
        net.backward(y_train[idx])
        for _, param, gname in net.params():
            param -= lr * getattr(net, gname)
    if epoch in (1, 5, 10, 30):
        test_acc = (net.forward(X_test).argmax(axis=1) == y_test).mean()
        print(f"epoch {epoch:>2}  train loss {net.loss(X_train, y_train):.4f}  test accuracy {test_acc:.3f}")
epoch  1  train loss 0.3190  test accuracy 0.884
epoch  5  train loss 0.2776  test accuracy 0.904
epoch 10  train loss 0.2023  test accuracy 0.948
epoch 30  train loss 0.1054  test accuracy 0.980

Now there are 750 training points and batches of 32, so each epoch makes 24 updates instead of one. After a single epoch — one look at each training point — the training loss is already 0.32; full-batch descent needed about 35 complete passes over its data to reach the same loss. After 30 epochs (720 updates) the network classifies 98% of the 250 held-out test points correctly. Reshuffling every epoch matters: it stops the batches arriving in the same order and gives each step a fresh, unbiased sample. Batch size is a trade-off: larger batches give smoother gradients and use hardware more efficiently; smaller ones give more updates per epoch, and their noise is often thought to help find solutions that generalise.

Quick check

W2 has shape (16, 2) and the batch has 200 examples. What shape is the gradient dW2?

A gradient always has the shape of its parameter; the batch dimension is summed away by Hᵀ @ dZ2.

Why does training need much more memory than inference?

Backward needs H, P and the rest; at inference you can throw each layer’s output away once the next layer has used it.

Your gradient check reports a relative difference of 0.3 for W1 but 10⁻¹⁰ for W2. Where is the bug most likely?

W2’s gradient is right, so everything up to dZ2 is right. The error enters on the way back to layer 1.

When gradients vanish or explode

Look again at the backward pass: at each layer, the incoming gradient is multiplied by a weight matrix and by the activation’s slope. Through many layers, that is a long product. If the factors are mostly smaller than 1, the product shrinks towards zero — a vanishing gradient, and the early layers barely learn. If they are mostly larger than 1, it grows without limit — an exploding gradient, and training blows up into nan. The sigmoid is especially prone to vanishing, because its slope is never more than 0.25. Here is a 20-layer network with identical weight scales, once with sigmoids and once with ReLUs, measuring how large the weight gradient is at each layer:

import numpy as np

def gradient_sizes(activation, depth=20, width=32, seed=42):
    rng = np.random.default_rng(seed)
    x = rng.normal(size=(64, width))
    Ws, acts = [], [x]
    for _ in range(depth):                          # forward through many layers
        W = rng.normal(0, np.sqrt(1 / width), size=(width, width))
        z = acts[-1] @ W
        a = 1 / (1 + np.exp(-z)) if activation == "sigmoid" else np.maximum(0, z)
        Ws.append(W); acts.append(a)
    grad = np.ones_like(acts[-1])                   # pretend dLoss/dOutput = 1
    sizes = []
    for layer in range(depth, 0, -1):               # backward, layer by layer
        a = acts[layer]
        local = a * (1 - a) if activation == "sigmoid" else (a > 0)
        grad_z = grad * local
        sizes.append(np.abs(acts[layer - 1].T @ grad_z).mean())   # size of dLoss/dW
        grad = grad_z @ Ws[layer - 1].T
    return sizes[::-1]                              # layer 1 first

for act in ["sigmoid", "relu"]:
    s = gradient_sizes(act)
    print(f"{act:>7}: layer 20 {s[19]:.2e}  layer 10 {s[9]:.2e}  layer 1 {s[0]:.2e}")
sigmoid: layer 20 7.17e+00  layer 10 4.37e-06  layer 1 9.72e-13
   relu: layer 20 3.32e-02  layer 10 2.94e-02  layer 1 2.38e-02

In the sigmoid network, the gradient at layer 1 is about 1013 times smaller than at layer 20: the first layers effectively cannot learn. In the ReLU network the gradient stays the same order of magnitude all the way down, because the ReLU’s slope is exactly 1 wherever a unit is active. (Ignore the absolute values; they depend on the batch size and on our pretend loss. The ratio between layers is the point.)

This problem was identified by Sepp Hochreiter in his 1991 diploma thesis and analysed by Yoshua Bengio, Patrice Simard and Paolo Frasconi in 1994, and it is a big part of why deep networks were so hard to train before about 2010. Xavier Glorot and Yoshua Bengio’s 2010 study showed how sigmoid units saturate in deep networks and proposed scaling the initial weights to keep signals a steady size in both directions. Consensus Today’s standard remedies, roughly in the order they appeared, are: gated recurrent units such as the LSTM; careful initialisation; ReLU-family activations; normalisation layers; residual (skip) connections, which give the gradient a direct path around each block; and, for explosions, gradient clipping — capping the gradient’s length before each update. The page on training well covers them properly.

Frameworks do this for you

You will rarely write a backward pass by hand again. Frameworks such as PyTorch, JAX and TensorFlow implement automatic differentiation: as your forward code runs, they record the computation graph, and one call walks it backwards applying each operation’s local derivative — exactly the procedure from the chain above. Here is the one-neuron example from earlier, in PyTorch (version 2.14 was used As of 2026):

import torch

x, y = torch.tensor(2.0), torch.tensor(1.0)
w = torch.tensor(0.5, requires_grad=True)   # ask torch to track these
b = torch.tensor(0.1, requires_grad=True)

L = (torch.tanh(w * x + b) - y) ** 2        # forward: torch records the graph
L.backward()                                # backward: chain rule, automatically
print(f"L={L.item():.4f}  dL/dw={w.grad.item():.4f}  dL/db={b.grad.item():.4f}")
L=0.0398  dL/dw=-0.2866  dL/db=-0.1433

The same three numbers we computed by hand. requires_grad=True tells PyTorch to track every operation involving those tensors; L.backward() runs reverse-mode differentiation and leaves each gradient in .grad. The same two lines work unchanged for a model with billions of parameters.

So why learn the manual version? Because when training goes wrong — a loss that will not fall, gradients that turn into nan, a layer that never changes — you need to know what the framework is computing to find out why. Andrej Karpathy’s micrograd, a working autograd engine in about a hundred lines of Python, is the best next step: it shows how the recording and the backward walk are actually implemented.

Try this

Train TwoLayerNet(2, 4, 2) on the four XOR inputs (X = np.array([[0,0],[0,1],[1,0],[1,1]]), y = np.array([0,1,1,0])) with full-batch gradient descent for 2,000 steps at learning rates 0.1, 1.0 and 10. Print the loss every 500 steps. Which rate learns fastest, which more slowly, and which misbehaves? Then try n_hidden=1 and explain the result using the XOR picture from the previous page.

// Pause and reflect

  1. Explain backpropagation to a colleague in three sentences, without the words “derivative” or “gradient”. What metaphor did you reach for?
  2. The loss kept falling after accuracy stopped improving. When would that extra confidence be valuable, and when might it be a warning sign?
  3. If frameworks compute gradients automatically, which parts of this page will still matter in your day-to-day work — and which could you safely forget?

Your notes are saved privately in this browser.

Key terms on this page

Sources & further reading