Backpropagation
& Training
The algorithm that made deep learning possible, derived step by step. You'll understand not just what backpropagation does, but why the gradient formulas look the way they do — and what happens when gradients go wrong.
The Chain Rule, Revisited and Applied
You've seen the chain rule before in Tier 1's calculus module. Now it becomes load-bearing. Backpropagation is nothing but the chain rule applied systematically to a composition of functions — which is exactly what a neural network is.
If y = f(g(x)), the derivative dy/dx is:
Or with three functions: if y = f(g(h(x)))
dy/dx = (dy/dg) · (dg/dh) · (dh/dx)
The chain rule for a neural network is exactly this, applied to every layer. The loss L is a function of the output, which is a function of layer L-1's activations, which is a function of layer L-2's activations, and so on back to the weights in layer 1.
It helps to think of a neural network as a computational graph: a directed graph where each node is an operation (multiply, add, ReLU) and each edge is a value flowing through. Forward pass: data flows left to right. Backward pass: gradients flow right to left.
Forward pass
Compute each node's output from its inputs. Store these intermediate values — you'll need them in the backward pass to compute local gradients.
Backward pass
Starting from the loss, compute each node's gradient using the chain rule: multiply the incoming upstream gradient by the local gradient at that node.
Local gradient
The derivative of a node's output w.r.t. its input, holding everything else constant. For a multiply node (z = wx), the local gradient w.r.t. w is x.
Upstream gradient
The gradient flowing in from the layer above (closer to the loss). The chain rule says: total gradient = upstream gradient × local gradient.
# Consider: L = (wx + b)² — a tiny "network" with one weight
# Forward: store intermediate values
w, x, b = 2.0, 3.0, 1.0
z = w * x # 6.0
a = z + b # 7.0
L = a ** 2 # 49.0 ← the "loss"
# Backward: chain rule from L back to w
dL_da = 2 * a # dL/da = 2a = 14.0 (power rule)
da_dz = 1.0 # da/dz = 1 (add gate passes gradient unchanged)
dz_dw = x # dz/dw = x = 3.0 (multiply gate)
dL_dw = dL_da * da_dz * dz_dw # 14 × 1 × 3 = 42.0
# Verify with finite difference (numerical gradient check)
eps = 1e-5
L_plus = ((w + eps) * x + b) ** 2
L_minus = ((w - eps) * x + b) ** 2
numerical_grad = (L_plus - L_minus) / (2 * eps)
print(f"Analytical: {dL_dw:.4f}") # 42.0000
print(f"Numerical: {numerical_grad:.4f}") # 42.0000 ✓
# They match — the chain rule derivation is correct
Backpropagation, Derived Step by Step
Backpropagation was formalized by Rumelhart, Hinton, and Williams in 1986, though the underlying calculus was known earlier. The word itself is often used loosely to mean "training a neural network," but precisely it means: efficiently computing the gradient of the loss with respect to all parameters by applying the chain rule backward through the computational graph.
Consider a single layer: a = f(Wx + b) where f is ReLU. During the backward pass, we receive the gradient of the loss w.r.t. the layer's output: ∂L/∂a. We need to compute the gradients w.r.t. W, b, and x (to pass further backward).
Backward (given ∂L/∂a from above):
∂L/∂z = ∂L/∂a · ReLU'(z) ← chain rule through activation
∂L/∂W = ∂L/∂z · xᵀ ← chain rule through linear layer
∂L/∂b = ∂L/∂z ← bias gradient
∂L/∂x = Wᵀ · ∂L/∂z ← pass gradient to previous layer
- 1∂L/∂z: The incoming gradient ∂L/∂a tells us how the loss changes with the layer's output. The ReLU derivative gates this: neurons that were inactive (z ≤ 0) block the gradient — they contribute nothing to the backward pass.
- 2∂L/∂W: The weight gradient. This is what we subtract (scaled by learning rate) to update W. Its shape must match W's shape — (output_dim × input_dim). The outer product xᵀ achieves this: each weight's gradient is proportional to both the upstream signal and the input that fed it.
- 3∂L/∂b: The bias gradient is just ∂L/∂z — the bias contributes identically to every output, so its gradient is the sum of ∂L/∂z across the batch dimension.
- 4∂L/∂x: This is the gradient we pass to the previous layer. It's Wᵀ multiplied by ∂L/∂z. The transpose appears because during the forward pass W mapped from input to output; the backward pass maps gradients from output back to input.
import numpy as np
class LinearReLU:
def __init__(self, in_dim, out_dim):
self.W = np.random.randn(in_dim, out_dim) * 0.01
self.b = np.zeros(out_dim)
def forward(self, x):
self.x = x # cache for backward
self.z = x @ self.W + self.b # linear: (batch, out_dim)
self.a = np.maximum(0, self.z) # ReLU
return self.a
def backward(self, dL_da, lr=0.01):
# Chain rule: dL/dz = dL/da * ReLU'(z)
dL_dz = dL_da * (self.z > 0) # gate the gradient
# Gradients w.r.t. parameters
dL_dW = self.x.T @ dL_dz / len(self.x) # avg over batch
dL_db = dL_dz.mean(axis=0)
# Gradient to pass to the previous layer
dL_dx = dL_dz @ self.W.T
# Update parameters
self.W -= lr * dL_dW
self.b -= lr * dL_db
return dL_dx # pass backward
# Tiny forward + backward test
layer = LinearReLU(3, 4)
x = np.random.randn(8, 3) # batch=8, 3 features
output = layer.forward(x)
print("Output shape:", output.shape) # (8, 4)
# Fake upstream gradient (same shape as output)
fake_upstream = np.random.randn(*output.shape)
grad_x = layer.backward(fake_upstream)
print("Grad input shape:", grad_x.shape) # (8, 3) — matches input ✓
Gradient Descent Variants: SGD, Momentum, Adam
Backpropagation computes the gradients. The optimizer decides what to do with them. Vanilla gradient descent — subtract learning_rate × gradient — works but is slow and sensitive to the learning rate. The progression from SGD to momentum to Adam reflects a history of solving those problems one by one.
| Optimizer | Key idea | When to use | Main weakness |
|---|---|---|---|
| SGD | Pure gradient step: θ ← θ − η∇L | Theory, baselines | Slow convergence, sensitive to lr |
| SGD + Momentum | Accumulate gradient history; damp oscillations | CV, ResNet-style training | Still needs careful lr tuning |
| Adam | Adaptive lr per parameter + momentum | Default for most DL | May not generalize as well as SGD |
| AdamW | Adam + decoupled weight decay | Transformers, LLMs | More hyperparameters |
Think of a ball rolling down a loss surface. Vanilla gradient descent moves the ball one step at a time based only on the current slope — it zigzags in ravines. Momentum adds inertia: the ball accumulates velocity in the direction it's been moving, so it rolls faster in consistent directions and dampens oscillations across directions.
SGD + Momentum:
v ← β·v − η·∇L
θ ← θ + v
β = 0.9 is the typical value
Adam (Adaptive Moment Estimation) tracks both the mean of the gradients (first moment, like momentum) and the variance of the gradients (second moment). Parameters with consistently large gradients get smaller effective learning rates; parameters with small or noisy gradients get larger rates. Each parameter finds its own learning rate automatically.
v ← β₂·v + (1−β₂)·∇L² (second moment: gradient variance)
m̂ = m/(1−β₁ᵗ) (bias correction)
v̂ = v/(1−β₂ᵗ) (bias correction)
θ ← θ − η · m̂ / (√v̂ + ε)
import numpy as np
class Adam:
def __init__(self, lr=1e-3, beta1=0.9, beta2=0.999, eps=1e-8):
self.lr = lr
self.b1, self.b2, self.eps = beta1, beta2, eps
self.m = {} # first moments (per parameter)
self.v = {} # second moments
self.t = 0 # time step
def update(self, params, grads):
self.t += 1
updated = {}
for key in params:
if key not in self.m:
self.m[key] = np.zeros_like(params[key])
self.v[key] = np.zeros_like(params[key])
g = grads[key]
# Update biased first and second moment estimates
self.m[key] = self.b1 * self.m[key] + (1 - self.b1) * g
self.v[key] = self.b2 * self.v[key] + (1 - self.b2) * g**2
# Bias-corrected estimates
m_hat = self.m[key] / (1 - self.b1**self.t)
v_hat = self.v[key] / (1 - self.b2**self.t)
# Parameter update
updated[key] = params[key] - self.lr * m_hat / (np.sqrt(v_hat) + self.eps)
return updated
# Usage
optimizer = Adam(lr=0.001)
params = {'W1': np.random.randn(4, 2), 'b1': np.zeros(4)}
grads = {'W1': np.random.randn(4, 2), 'b1': np.random.randn(4)}
params = optimizer.update(params, grads)
Learning Rate Scheduling
The learning rate is the single most important hyperparameter in neural network training. Too large: training diverges or oscillates wildly. Too small: training converges impossibly slowly or gets stuck in poor local minima. Learning rate scheduling — changing the learning rate during training — is how you get both fast early progress and precise final convergence.
Step decay
Multiply lr by a factor (e.g. 0.1) at fixed epochs (e.g. epoch 30, 60, 90). Simple, interpretable, commonly used with ResNets. The training curve shows "jumps" downward at each step.
Cosine annealing
Smoothly decrease lr following a cosine curve. Often combined with "warm restarts" (SGDR) — periodically reset lr high to help escape local minima. Widely used in vision and NLP.
Warmup
Start lr very small, linearly increase to target over the first N steps. Critical for large models and Adam — avoids unstable early gradients corrupting the second-moment estimates.
LR range test
Sweep lr from 1e-7 to 10 over ~100 iterations, plot loss vs lr. The ideal lr is just before the loss starts to increase. Use this to find a good initial lr without extensive grid search.
import numpy as np
def cosine_schedule(epoch, total_epochs, lr_min=1e-6, lr_max=1e-3):
"""Smoothly decrease lr from lr_max to lr_min using cosine curve."""
return lr_min + 0.5 * (lr_max - lr_min) * (1 + np.cos(np.pi * epoch / total_epochs))
def linear_warmup_cosine(step, warmup_steps, total_steps, lr_max=1e-3):
"""Warmup then cosine decay — standard transformer schedule."""
if step < warmup_steps:
return lr_max * step / warmup_steps # linear warmup
progress = (step - warmup_steps) / (total_steps - warmup_steps)
return lr_max * 0.5 * (1 + np.cos(np.pi * progress)) # cosine decay
# Visualize the schedule
total = 100
lrs_cosine = [cosine_schedule(e, total) for e in range(total)]
lrs_warmup = [linear_warmup_cosine(s, warmup_steps=10, total_steps=total) for s in range(total)]
print(f"Epoch 0: lr = {lrs_warmup[0]:.6f}") # starts near 0
print(f"Epoch 10: lr = {lrs_warmup[10]:.6f}") # peak after warmup
print(f"Epoch 50: lr = {lrs_warmup[50]:.6f}") # halfway down cosine
print(f"Epoch 99: lr = {lrs_warmup[99]:.6f}") # near 0 at end
Vanishing and Exploding Gradients
These two problems defined the "AI winter" of the 1990s-2000s and made training deep networks seem impossible. Understanding them is not just history — they still appear in practice, especially when you go outside standard architectures or use activations like sigmoid in deep networks.
During backpropagation, gradients are multiplied as they pass through each layer. If those multipliers are consistently less than 1 (as they are for sigmoid's max derivative of 0.25), the product shrinks exponentially with depth. In a 20-layer network, the gradient reaching layer 1 might be 0.25²⁰ ≈ 10⁻¹² — effectively zero. Layer 1 never learns.
If each term in the product is 0.25 (sigmoid max):
For L = 20 layers: 0.25²⁰ ≈ 10⁻¹²
The mirror image: if multipliers are consistently greater than 1 (large weights, linear layers), gradients grow exponentially. A gradient of 2²⁰ = 10⁶ at layer 1 causes weight updates so large they destroy any useful structure the network has learned. Parameters go to NaN.
Diagnosing vanishing
Plot gradient norms per layer during training. If early layers have near-zero gradient norms while late layers train normally, you have vanishing gradients. Training loss improves but then plateaus.
Diagnosing exploding
Loss becomes NaN or explodes to infinity. Gradient norms are enormous. Weights go to ±infinity. Usually happens early in training with a learning rate that's too high or without gradient clipping.
Fix: gradient clipping
If the gradient norm exceeds a threshold (e.g. 1.0), rescale it. Universally used for RNNs and transformers. In PyTorch: torch.nn.utils.clip_grad_norm_(params, 1.0).
Fix: residual connections
The key innovation of ResNet. Add skip connections that bypass layers: output = F(x) + x. The gradient can flow directly through the identity path, bypassing the multiplicative chain.
import numpy as np
def sigmoid(x): return 1 / (1 + np.exp(-x))
def sigmoid_grad(x): s = sigmoid(x); return s * (1 - s)
# Simulate gradient flow through 20 sigmoid layers
n_layers = 20
x = np.random.randn(100) # initial activations
grad = np.ones(100) # gradient starts as 1 at the output
grad_norms_sigmoid = []
for _ in range(n_layers):
grad = grad * sigmoid_grad(x) # chain rule through sigmoid
grad_norms_sigmoid.append(np.linalg.norm(grad))
x = sigmoid(x)
# Same thing with ReLU
x = np.random.randn(100)
grad = np.ones(100)
grad_norms_relu = []
for _ in range(n_layers):
grad = grad * (x > 0) # ReLU gradient: 1 or 0
grad_norms_relu.append(np.linalg.norm(grad))
x = np.maximum(0, x)
print("Sigmoid gradient norms through 20 layers:")
print(f" Layer 1: {grad_norms_sigmoid[0]:.6f}")
print(f" Layer 10: {grad_norms_sigmoid[9]:.6f}")
print(f" Layer 20: {grad_norms_sigmoid[19]:.8f}") # ≈ 0.000000xx
print("\nReLU gradient norms through 20 layers:")
print(f" Layer 1: {grad_norms_relu[0]:.4f}")
print(f" Layer 20: {grad_norms_relu[19]:.4f}") # stays more stable