∂L/∂W³ ∂L/∂W² ∂L/∂W¹
Tier 3 · Deep Learning & Neural Networks

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.

📚 5 Lessons 🔢 Full Derivation ⚡ Optimizer Lab ⏱ ~4 hours
Lesson 2.1

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.

🔗
The key insight A neural network is a function composed of other functions: output = f_L(f_{L-1}(... f_1(x))). The chain rule tells us how to differentiate a composed function. Backpropagation is just running the chain rule efficiently from output to input.
Chain rule: quick review

If y = f(g(x)), the derivative dy/dx is:

dy/dx = (dy/dg) · (dg/dx)

Or with three functions: if y = f(g(h(x)))
dy/dx = (dy/dg) · (dg/dh) · (dh/dx)
Each factor is the derivative of one function w.r.t. its input

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.

Computational graph thinking

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.

Pythonchain rule on a small computational graph
# 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
Gradient checking The numerical gradient (finite difference) is the gold standard for verifying your backpropagation code. If the analytical and numerical gradients differ by more than ~1e-5, you have a bug. PyTorch's autograd does this automatically — but knowing how to check manually is how you debug custom layers.
Lesson 2.2

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.

⚙️
What makes it efficient Naively, you'd compute dL/dW for each parameter independently — O(n_params) forward passes. Backprop computes all gradients in a single backward pass by reusing intermediate computations. For a network with millions of parameters, this is the difference between training being feasible or not.
Deriving backprop for one layer

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).

Forward: z = Wx + b, a = ReLU(z)

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
ReLU'(z) = 1 where z > 0, else 0
  • 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.
Pythonbackprop through a linear + ReLU layer
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 ✓
⚠️
The most common bug Getting the transpose wrong — using W instead of Wᵀ or xᵀ instead of x in the gradient expressions. The shape check is your friend: dL_dW must have the same shape as W, and dL_dx must have the same shape as x. If shapes don't match, you've made an error.
Lesson 2.3

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.

The optimizer landscape
OptimizerKey ideaWhen to useMain weakness
SGDPure gradient step: θ ← θ − η∇LTheory, baselinesSlow convergence, sensitive to lr
SGD + MomentumAccumulate gradient history; damp oscillationsCV, ResNet-style trainingStill needs careful lr tuning
AdamAdaptive lr per parameter + momentumDefault for most DLMay not generalize as well as SGD
AdamWAdam + decoupled weight decayTransformers, LLMsMore hyperparameters
Momentum: the physics intuition

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.

Standard SGD: θ ← θ − η · ∇L

SGD + Momentum:
v ← β·v − η·∇L
θ ← θ + v

β = 0.9 is the typical value
v accumulates a weighted average of past gradients. β controls how much history to retain.
Adam: adaptive learning rates

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.

m ← β₁·m + (1−β₁)·∇L (first moment: gradient mean)
v ← β₂·v + (1−β₂)·∇L² (second moment: gradient variance)
m̂ = m/(1−β₁ᵗ) (bias correction)
v̂ = v/(1−β₂ᵗ) (bias correction)

θ ← θ − η · m̂ / (√v̂ + ε)
Typical: β₁=0.9, β₂=0.999, ε=1e-8, η=1e-3
PythonAdam optimizer from scratch
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)
Practical guidance Start with Adam(lr=1e-3). If you're training a vision model following a paper that uses SGD + momentum + learning rate schedule, use that — it often generalizes better for image classification. For transformers, AdamW with a warmup schedule is standard. Don't try to outsmart the papers on optimizer choice for established architectures.
Lesson 2.4

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.

📈
Why scheduling works At the start of training, weights are random and loss is high — large steps help escape the rough initial landscape quickly. Late in training, weights are near a good minimum — large steps overshoot it, causing oscillation. A schedule that starts large and decreases gives you the best of both phases.
📉

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.

Pythoncosine annealing schedule in NumPy
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
Rule of thumb for schedule choice Training a CNN from scratch for image classification: cosine annealing or step decay. Training a transformer: linear warmup + cosine decay. Fine-tuning a pretrained model: very small constant lr (1e-5 to 1e-4) or a brief warmup with rapid decay. Never train a large model without a warmup phase — the early gradient estimates are too noisy for a cold start with full lr.
Lesson 2.5

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.

Vanishing gradients

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.

∂L/∂W¹ = ∂L/∂aᴸ · ∏ᵢ (∂aⁱ/∂aⁱ⁻¹)

If each term in the product is 0.25 (sigmoid max):
For L = 20 layers: 0.25²⁰ ≈ 10⁻¹²
The gradient signal disappears before reaching early layers
Exploding gradients

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.

Pythondetecting vanishing gradients in a deep sigmoid network
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
Modern solutions in brief: ReLU (not sigmoid) for hidden layers · Batch normalization (Module 4) · Residual connections (Module 5, ResNet) · Careful initialization (Xavier, He) · Gradient clipping for RNNs. These aren't just tricks — they're architectural choices driven directly by the need to maintain well-scaled gradients through many layers.