← Tensor by Tensor
§ Math Fundamentals for AI
Tensor by Tensor · Complete Guide

Backpropagation

Fourteen lessons that connect high-school mathematics to AI. Start with the plain-language explanation and a small example. Then read the full formulas, derivations, and Python code at your own pace.

How to study a lesson

Read the opening example. Try the question before revealing its answer. Then work through the detailed notes below it. You do not need to understand every proof on the first reading.

Symbols: a subscript such as xi identifies one entry. Σ means add a collection of terms. ∈ means “belongs to”. The symbol ≈ means “approximately equal”, not exactly equal. A parameter is an adjustable number in a model.

01.08 · Backpropagation in Python

Introduction

A network may contain many chained calculations, but the loss still needs one gradient for every parameter. Backpropagation caches the forward values, then walks from loss to input while reusing local derivatives.

Learning goal

Implement reverse-mode differentiation for a tiny network and explain each cached value and gradient update.

Before you start

Chain rule, derivatives, Python functions, and following values through a calculation graph.

Lesson plan

  1. Run the forward calculation and save the intermediate values needed later.
  2. Implement the reverse pass by multiplying and accumulating local derivatives.
  3. Trace a computation graph and explain the practical memory-versus-recomputation trade-off clearly.

Predict signs before code. If a larger weight makes an already-too-large prediction even larger, its loss gradient should be positive. Gradient descent will subtract that value. If the program reports the opposite sign, inspect the error definition and local derivative.

From chain rule to algorithm

Backpropagation is the efficient algorithm for computing gradients in neural networks. It applies the chain rule right-to-left, reusing intermediate values from the forward pass.

The four backprop equations

For a feedforward network with $z^{(\ell)} = W^{(\ell)}a^{(\ell-1)} + b^{(\ell)}$ and $a^{(\ell)} = \sigma(z^{(\ell)})$, define the error signal $\delta^{(\ell)} = \nabla_{z^{(\ell)}} L$:

  1. Output layer: $\delta^{(L)} = \nabla_{a^{(L)}} L \odot \sigma'(z^{(L)})$
  2. Backpropagate error: $\delta^{(\ell)} = (W^{(\ell+1)})^\top \delta^{(\ell+1)} \odot \sigma'(z^{(\ell)})$
  3. Weight gradient: $\nabla_{W^{(\ell)}} L = \delta^{(\ell)} (a^{(\ell-1)})^\top$
  4. Bias gradient: $\nabla_{b^{(\ell)}} L = \delta^{(\ell)}$

Implementation from scratch

Here's a minimal backprop implementation for a two-layer network:

import numpy as np

def sigmoid(z): return 1 / (1 + np.exp(-z))
def sigmoid_grad(a): return a * (1 - a)

# Forward pass
def forward(x, W1, b1, W2, b2):
    z1 = W1 @ x + b1
    a1 = sigmoid(z1)
    z2 = W2 @ a1 + b2
    a2 = sigmoid(z2)
    cache = (x, z1, a1, z2, a2)
    return a2, cache

# Backward pass
def backward(y_true, cache, W2):
    x, z1, a1, z2, a2 = cache
    m = x.shape[1]  # batch size
    
    # Output layer error
    # Squared error, summed over outputs and averaged over the batch.
    dz2 = 2 * (a2 - y_true) * sigmoid_grad(a2)  # shape: (n_out, m)
    dW2 = (1/m) * dz2 @ a1.T                   # shape: (n_out, n_hid)
    db2 = (1/m) * np.sum(dz2, axis=1, keepdims=True)
    
    # Hidden layer error
    da1 = W2.T @ dz2                            # shape: (n_hid, m)
    dz1 = da1 * sigmoid_grad(a1)               # element-wise
    dW1 = (1/m) * dz1 @ x.T                    # shape: (n_hid, n_in)
    db1 = (1/m) * np.sum(dz1, axis=1, keepdims=True)
    
    return dW1, db1, dW2, db2

Step-by-step walkthrough

Example · One training step

Given input $\mathbf{x} = [1, 0]^\top$, target $y = 1$, and random initial weights:

  1. Forward: compute $z_1, a_1, z_2, a_2$ layer by layer
  2. Loss: $L = (a_2 - y)^2$
  3. Backward: compute $\delta^{(2)}, \delta^{(1)}$, then all gradients
  4. Update: $W \leftarrow W - \eta \nabla_W L$ for each parameter

Computational graphs

Modern frameworks (PyTorch, JAX) build a computational graph during the forward pass, recording every operation. Backward traversal of this graph applies the chain rule automatically:

import torch

x = torch.tensor([1.0, 0.0], requires_grad=True)
W1 = torch.randn(4, 2, requires_grad=True)
b1 = torch.randn(4, requires_grad=True)
W2 = torch.randn(1, 4, requires_grad=True)
b2 = torch.randn(1, requires_grad=True)

# Forward — PyTorch records the graph
h = torch.sigmoid(W1 @ x + b1)
y = torch.sigmoid(W2 @ h + b2)
loss = (y - 1.0)**2

# Backward — gradients computed automatically
loss.backward()
print(W1.grad)  # ∂L/∂W1
print(W2.grad)  # ∂L/∂W2
Gradient checking

Always verify your backprop implementation with numerical gradients:

$$\frac{\partial L}{\partial \theta_i} \approx \frac{L(\theta+\varepsilon e_i) - L(\theta-\varepsilon e_i)}{2\varepsilon}$$

Try several small values of $\varepsilon$ and use double precision. Differences depend on rounding, the step size, and whether the function is smooth at the test point. Large discrepancies need investigation, but one universal tolerance cannot identify every bug.

Memory and efficiency

Backprop requires storing all intermediate activations $a^{(\ell)}$ from the forward pass. This is why deep networks are memory-intensive. Techniques to reduce memory:

  • Gradient checkpointing: recompute some activations during backward pass
  • Mixed precision: use FP16 for activations, FP32 for gradients
  • Activation recomputation: trade compute for memory