Python · SQL · Web Dev · Java · AI/ML tracks launching soon — your one platform for all of IT
Advanced

Backpropagation — How Neural Networks Learn

The chain rule applied to a network of layers. Gradients flow backward, weights update, the network gets better. Understood once, never forgotten.

36–46 min March 2026
Before any formula — what problem does backprop solve?

A network makes a prediction. It is wrong. Backpropagation answers one question: which weights caused the error, and by exactly how much should each one change?

Module 41 showed the forward pass — data flows left to right through the network, layer by layer, until a prediction emerges. The prediction is compared to the true label. The difference is the loss. Now what? The network has thousands of weights. Which ones made the prediction wrong? How wrong did each one make it? How much should each one move?

This is the credit assignment problem — the hardest problem in training neural networks. If the network predicts 32 minutes for a delivery that actually took 41 minutes, which of the 5,000 weights is responsible for the 9-minute underestimate? All of them contributed — but in different amounts, through different paths.

Backpropagation solves credit assignment using the chain rule from calculus. It starts at the loss and works backwards — computing how much the loss would change if each weight changed by a tiny amount. That quantity is the gradient. Once you have the gradient for every weight, gradient descent subtracts a small fraction of it from each weight. Repeat this millions of times and the network learns.

🧠 Analogy — read this first

A manager wants to know why a project was delivered late. They start at the final delay (the loss) and trace backwards. The deployment was late because testing was late. Testing was late because development was late. Development was late because requirements were unclear. At each step they answer: how much did this step contribute to the final delay? That is the chain rule — each step's contribution multiplied together to reach the root cause.

Backpropagation traces the prediction error backwards through the network. At each layer it asks: how much did this layer's weights contribute to the final error? The answer — the gradient — tells each weight exactly how to change to reduce the error next time.

🎯 Pro Tip
Read Module 07 (Derivatives and Gradients) before this one. Backpropagation is the chain rule applied repeatedly. If you are comfortable with ∂f/∂x notation and the chain rule ∂f/∂x = (∂f/∂g)(∂g/∂x), the entire algorithm will make sense. If not, Module 07 first — 20 minutes there saves 2 hours of confusion here.
The mathematical foundation

The chain rule — the only piece of calculus backprop needs

The chain rule says: if y depends on z which depends on x, then how y changes with x equals how y changes with z multiplied by how z changes with x. Written as: ∂y/∂x = (∂y/∂z) × (∂z/∂x).

A neural network is a chain of functions. The loss depends on the output, which depends on layer 3, which depends on layer 2, which depends on layer 1, which depends on the weights. Backpropagation applies the chain rule at each link in this chain — starting from the loss and multiplying derivatives backwards through every layer until reaching the weights.

The chain of functions in a 3-layer network
W₁,b₁
Z₁=XW₁+b₁
A₁=relu(Z₁)
Z₂=A₁W₂+b₂
A₂=relu(Z₂)
ŷ=A₂W₃+b₃
L=MSE(ŷ,y)
↑ forward pass →
∂L/∂ŷ = 2(ŷ−y)/n ← start here
∂L/∂W₃ = A₂ᵀ × ∂L/∂ŷ ← output weights
∂L/∂A₂ = ∂L/∂ŷ × W₃ᵀ ← pass back
∂L/∂Z₂ = ∂L/∂A₂ × relu'(Z₂) ← through activation
∂L/∂W₂ = A₁ᵀ × ∂L/∂Z₂ ← layer 2 weights
∂L/∂A₁ = ∂L/∂Z₂ × W₂ᵀ ← pass back further
∂L/∂Z₁ = ∂L/∂A₁ × relu'(Z₁)
∂L/∂W₁ = Xᵀ × ∂L/∂Z₁ ← layer 1 weights
Each line is one application of the chain rule. The pattern repeats identically for every layer.
python
import numpy as np

# ── Chain rule on a simple example before the full network ────────────
# f(x) = sigmoid(2x + 1)  — two operations chained
# We want df/dx

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

def sigmoid_deriv(z):
    s = sigmoid(z)
    return s * (1 - s)

x = 0.5

# Forward pass — compute and cache intermediate values
z = 2 * x + 1        # linear step:   z = 2x + 1
f = sigmoid(z)        # sigmoid step:  f = sigmoid(z)

# Chain rule: df/dx = (df/dz) × (dz/dx)
df_dz = sigmoid_deriv(z)   # how much f changes with z
dz_dx = 2.0                 # how much z changes with x  (derivative of 2x+1 is 2)
df_dx = df_dz * dz_dx       # chain rule

# Verify numerically
h       = 1e-5
df_dx_numerical = (sigmoid(2*(x+h)+1) - sigmoid(2*(x-h)+1)) / (2*h)

print(f"f(x)         = sigmoid(2×{x} + 1) = {f:.6f}")
print(f"df/dz        = sigmoid'(z)        = {df_dz:.6f}")
print(f"dz/dx        = d(2x+1)/dx         = {dz_dx:.6f}")
print(f"df/dx (chain)= {df_dz:.6f} × {dz_dx:.6f}   = {df_dx:.6f}")
print(f"df/dx (numer)= {df_dx_numerical:.6f}  ← matches ✓")

# ── Now with three chained functions ─────────────────────────────────
# L = MSE(relu(Wx + b), y)  — loss through linear → relu → MSE

W, b, y = 0.8, -0.2, 1.0
x_in    = 0.5

# Forward
z_val  = W * x_in + b        # linear
a_val  = max(0, z_val)        # relu
loss   = (a_val - y) ** 2     # MSE (single sample, no mean)

# Backward — chain rule at each step
dL_da  = 2 * (a_val - y)                        # ∂L/∂a
da_dz  = 1.0 if z_val > 0 else 0.0              # ∂a/∂z (relu derivative)
dz_dW  = x_in                                    # ∂z/∂W
dz_db  = 1.0                                     # ∂z/∂b

dL_dW  = dL_da * da_dz * dz_dW   # chain: ∂L/∂W
dL_db  = dL_da * da_dz * dz_db   # chain: ∂L/∂b

print(f"
Three-function chain: L = MSE(relu(Wx+b), y)")
print(f"  Forward:   z={z_val:.3f}  a={a_val:.3f}  L={loss:.3f}")
print(f"  ∂L/∂a    = 2(a−y)          = {dL_da:.4f}")
print(f"  ∂a/∂z    = relu'(z)         = {da_dz:.4f}")
print(f"  ∂z/∂W    = x               = {dz_dW:.4f}")
print(f"  ∂L/∂W    = {dL_da:.3f} × {da_dz:.3f} × {dz_dW:.3f} = {dL_dW:.4f}")
Scaling to a real network

Backprop in matrix form — the same chain rule, but for every weight simultaneously

The chain rule example above worked on one weight. A real network has thousands. The key insight: in matrix form, the chain rule applies to entire layers simultaneously — the same equations work regardless of how wide or deep the network is. Each layer produces two outputs during backprop: the gradient for its own weights (used to update them) and the gradient to pass further backward (used by the previous layer).

The two outputs of each backward layer — memorise this pattern
Given: dA (gradient flowing in from the next layer)
dZ = dA × activation'(Z) ← through the activation function
dW = Aₚᵣₑᵥᵀ @ dZ ← gradient for THIS layer's weights
dA_prev = dZ @ Wᵀ ← gradient to pass to the PREVIOUS layer
This pattern repeats identically for every layer. The only thing that changes is the activation function derivative.
python
import numpy as np

np.random.seed(42)

# ── Full backprop from scratch — every gradient explicit ───────────────

def relu(z):       return np.maximum(0, z)
def relu_d(z):     return (z > 0).astype(float)
def sigmoid(z):    return 1 / (1 + np.exp(-np.clip(z, -500, 500)))
def sigmoid_d(z):  s = sigmoid(z); return s * (1 - s)

def init(n_in, n_out):
    return np.random.randn(n_in, n_out) * np.sqrt(2.0 / n_in)

# Network: 3 inputs → 4 hidden (relu) → 2 hidden (relu) → 1 output (sigmoid)
# Task: binary classification
W1, b1 = init(3, 4), np.zeros((1, 4))
W2, b2 = init(4, 2), np.zeros((1, 2))
W3, b3 = init(2, 1), np.zeros((1, 1))

def forward(X):
    """Forward pass — cache everything needed for backprop."""
    Z1 = X  @ W1 + b1;  A1 = relu(Z1)
    Z2 = A1 @ W2 + b2;  A2 = relu(Z2)
    Z3 = A2 @ W3 + b3;  A3 = sigmoid(Z3)   # probability output
    return A3, {'X': X, 'Z1': Z1, 'A1': A1,
                'Z2': Z2, 'A2': A2, 'Z3': Z3, 'A3': A3}

def binary_cross_entropy(y_pred, y_true):
    eps = 1e-8
    return -np.mean(y_true * np.log(y_pred + eps)
                    + (1 - y_true) * np.log(1 - y_pred + eps))

def backward(y_true, cache, lr=0.01):
    """
    Backprop — compute all gradients, update all weights.
    Returns gradients for inspection.
    """
    global W1, b1, W2, b2, W3, b3
    n  = y_true.shape[0]
    A3 = cache['A3']

    # ── Loss gradient ──────────────────────────────────────────────────
    # BCE loss: ∂L/∂A3 = -(y/A3 - (1-y)/(1-A3)) / n
    # For sigmoid output with BCE this simplifies beautifully to:
    dA3 = (A3 - y_true) / n          # (n, 1)

    # ── Layer 3 backward ──────────────────────────────────────────────
    # Z3 = A2 @ W3 + b3,  A3 = sigmoid(Z3)
    # dZ3 = dA3 × sigmoid'(Z3)   ← through activation
    # But sigmoid'(Z3) = A3(1-A3), and dA3 already equals (A3-y)/n
    # so dZ3 = dA3 (the simplification from combining BCE + sigmoid)
    dZ3 = dA3                         # (n, 1)
    dW3 = cache['A2'].T @ dZ3         # (2, 1)  ← gradient for W3
    db3 = dZ3.sum(axis=0, keepdims=True)
    dA2 = dZ3 @ W3.T                  # (n, 2)  ← pass to layer 2

    # ── Layer 2 backward ──────────────────────────────────────────────
    dZ2 = dA2 * relu_d(cache['Z2'])   # (n, 2)  ← through relu
    dW2 = cache['A1'].T @ dZ2         # (4, 2)  ← gradient for W2
    db2 = dZ2.sum(axis=0, keepdims=True)
    dA1 = dZ2 @ W2.T                  # (n, 4)  ← pass to layer 1

    # ── Layer 1 backward ──────────────────────────────────────────────
    dZ1 = dA1 * relu_d(cache['Z1'])   # (n, 4)
    dW1 = cache['X'].T  @ dZ1         # (3, 4)  ← gradient for W1
    db1_g = dZ1.sum(axis=0, keepdims=True)

    # ── Gradient descent update ────────────────────────────────────────
    W3 -= lr * dW3;  b3 -= lr * db3
    W2 -= lr * dW2;  b2 -= lr * db2
    W1 -= lr * dW1;  b1 -= lr * db1_g

    return {'dW1': dW1, 'dW2': dW2, 'dW3': dW3}

# ── Train on Brex-style loan default data ─────────────────────────────
np.random.seed(42)
n = 1000
X_raw = np.column_stack([
    np.random.normal(0, 1, n),   # credit score (standardised)
    np.random.normal(0, 1, n),   # emi-to-income ratio
    np.random.normal(0, 1, n),   # employment years
])
y_raw = ((X_raw[:, 0] < -0.3) & (X_raw[:, 1] > 0.4)).astype(float).reshape(-1, 1)

print("Training 3-layer network with manual backprop:")
print(f"{'Epoch':>7} {'Loss':>10} {'Accuracy':>10}")
print("─" * 32)

for epoch in range(300):
    # Mini-batch
    idx = np.random.choice(n, 64, replace=False)
    Xb, yb = X_raw[idx], y_raw[idx]

    # Forward + backward
    pred, cache = forward(Xb)
    grads = backward(yb, cache, lr=0.05)

    if (epoch + 1) % 60 == 0:
        pred_all, _ = forward(X_raw)
        loss = binary_cross_entropy(pred_all, y_raw)
        acc  = ((pred_all > 0.5) == y_raw).mean()
        print(f"  {epoch+1:>5}  {loss:>10.6f}  {acc:>10.4f}")

# ── Gradient magnitudes — sanity check ────────────────────────────────
print(f"
Gradient magnitudes (should be non-zero, not exploding):")
for name, g in grads.items():
    print(f"  {name}: mean={np.abs(g).mean():.6f}  max={np.abs(g).max():.6f}")
What goes wrong in deep networks

Vanishing and exploding gradients — why deep networks were hard before 2015

The chain rule multiplies gradients across layers. In a 10-layer network, the gradient for layer 1's weights is the product of 10 terms — one per layer. If each term is slightly less than 1 (like sigmoid derivatives, which top out at 0.25), the product shrinks exponentially. By layer 1, the gradient is essentially zero — weights never update. This is vanishing gradients.

The opposite happens if each term is greater than 1. The product grows exponentially — gradients explode to billions, weights update by enormous amounts, and training diverges. This is exploding gradients.

Gradient magnitude through 10 layers — sigmoid vs relu
Sigmoid — vanishing gradient
Layer 1
1.000000
Layer 2
0.220000
Layer 3
0.048000
Layer 4
0.011000
Layer 5
0.002000
Layer 6
0.000500
Layer 7
0.000100
Layer 8
0.000020
Layer 9
0.000005
Layer 10
0.000001

Each sigmoid derivative ≤ 0.25. Product of 10 = ~10⁻⁷. Layer 1 learns nothing.

ReLU — gradient preserved
Layer 1
1.000000
Layer 2
0.950000
Layer 3
0.900000
Layer 4
0.850000
Layer 5
0.810000
Layer 6
0.770000
Layer 7
0.730000
Layer 8
0.690000
Layer 9
0.660000
Layer 10
0.630000

ReLU derivative is 1 (for positive) or 0. Gradient stays meaningful through all layers.

python
import numpy as np

np.random.seed(42)

def sigmoid(z):   return 1 / (1 + np.exp(-np.clip(z, -500, 500)))
def sigmoid_d(z): s = sigmoid(z); return s * (1 - s)
def relu(z):      return np.maximum(0, z)
def relu_d(z):    return (z > 0).astype(float)
def tanh(z):      return np.tanh(z)
def tanh_d(z):    return 1 - np.tanh(z)**2

# ── Simulate gradient flow through 10 layers ──────────────────────────
n_layers  = 10
n_neurons = 32
X_test    = np.random.randn(16, n_neurons)

print("Gradient magnitude at each layer (starting from output):")
print(f"{'Layer':>8}  {'sigmoid':>12}  {'tanh':>12}  {'relu':>12}")
print("─" * 50)

for activation_name, act_fn, act_d in [
    ('sigmoid', sigmoid, sigmoid_d),
    ('tanh',    tanh,    tanh_d),
    ('relu',    relu,    relu_d),
]:
    # Simulate forward pass and track gradient magnitudes
    A = X_test.copy()
    Zs = []
    for _ in range(n_layers):
        W = np.random.randn(n_neurons, n_neurons) * 0.1
        Z = A @ W
        Zs.append(Z)
        A = act_fn(Z)

    # Simulate backward pass — gradient starts at 1.0 at output
    dA = np.ones_like(A)
    grad_norms = []
    for i in reversed(range(n_layers)):
        dZ = dA * act_d(Zs[i])
        grad_norms.insert(0, np.abs(dZ).mean())
        W  = np.random.randn(n_neurons, n_neurons) * 0.1
        dA = dZ @ W.T

    if activation_name == 'sigmoid':
        print("Sigmoid:")
        for i, g in enumerate(grad_norms):
            bar = '█' * int(min(g * 200, 30))
            print(f"  Layer {i+1:2d}: {g:.8f}  {bar}")
    elif activation_name == 'relu':
        print("
ReLU:")
        for i, g in enumerate(grad_norms):
            bar = '█' * int(min(g * 20, 30))
            print(f"  Layer {i+1:2d}: {g:.6f}  {bar}")

# ── Solutions to vanishing gradients ──────────────────────────────────
print("
Solutions to vanishing gradients:")
print("  1. Use ReLU (or LeakyReLU) instead of sigmoid/tanh in hidden layers")
print("  2. Use He initialisation: scale = sqrt(2/n_inputs)")
print("  3. Use Batch Normalisation — normalises activations between layers")
print("  4. Use residual connections (ResNets) — skip connections bypass layers")
print("  5. Use gradient clipping for exploding gradients: clip_grad_norm_(params, 1.0)")
Verifying your implementation

Gradient checking — the numerical test that proves backprop is correct

When you implement backprop manually, bugs are easy to introduce — a transposed matrix, a missing factor, a wrong sign. Gradient checking is the gold standard test: compare every analytical gradient (from backprop) to its numerical approximation (computed by slightly perturbing each weight and measuring the loss change). If they match to within 1e-5, backprop is correct.

Numerical gradient — the two-sided finite difference
∂L/∂W[i,j] ≈ (L(W[i,j]+h) − L(W[i,j]−h)) / (2h)
h = 1e-5 (small enough for accuracy, large enough to avoid float precision issues)
Two-sided difference is more accurate than one-sided: error is O(h²) vs O(h)
Do this for every weight element — expensive but definitive
python
import numpy as np

np.random.seed(42)

def relu(z):    return np.maximum(0, z)
def relu_d(z):  return (z > 0).astype(float)
def sigmoid(z): return 1/(1+np.exp(-np.clip(z,-500,500)))

# Small network for gradient checking
W1 = np.random.randn(3, 4) * 0.1;  b1 = np.zeros((1, 4))
W2 = np.random.randn(4, 1) * 0.1;  b2 = np.zeros((1, 1))

X_gc = np.random.randn(5, 3)
y_gc = np.random.randint(0, 2, (5, 1)).astype(float)

def forward_gc(W1, b1, W2, b2, X):
    Z1 = X  @ W1 + b1;  A1 = relu(Z1)
    Z2 = A1 @ W2 + b2;  A2 = sigmoid(Z2)
    return A2, {'X':X,'Z1':Z1,'A1':A1,'Z2':Z2,'A2':A2}

def loss_gc(W1, b1, W2, b2, X, y):
    pred, _ = forward_gc(W1, b1, W2, b2, X)
    eps = 1e-8
    return -np.mean(y*np.log(pred+eps) + (1-y)*np.log(1-pred+eps))

def backward_gc(cache, y):
    n   = y.shape[0]
    dA2 = (cache['A2'] - y) / n
    dW2 = cache['A1'].T @ dA2
    db2 = dA2.sum(axis=0, keepdims=True)
    dA1 = dA2 @ W2.T
    dZ1 = dA1 * relu_d(cache['Z1'])
    dW1 = cache['X'].T @ dZ1
    db1 = dZ1.sum(axis=0, keepdims=True)
    return dW1, db1, dW2, db2

# ── Analytical gradients ───────────────────────────────────────────────
_, cache = forward_gc(W1, b1, W2, b2, X_gc)
dW1_a, db1_a, dW2_a, db2_a = backward_gc(cache, y_gc)

# ── Numerical gradients ────────────────────────────────────────────────
h = 1e-5

def numerical_grad(param, param_name):
    grad = np.zeros_like(param)
    it = np.nditer(param, flags=['multi_index'])
    while not it.finished:
        idx = it.multi_index
        orig = param[idx]

        param[idx] = orig + h
        loss_plus  = loss_gc(W1, b1, W2, b2, X_gc, y_gc)
        param[idx] = orig - h
        loss_minus = loss_gc(W1, b1, W2, b2, X_gc, y_gc)
        param[idx] = orig

        grad[idx] = (loss_plus - loss_minus) / (2 * h)
        it.iternext()
    return grad

dW1_n = numerical_grad(W1, 'W1')
dW2_n = numerical_grad(W2, 'W2')
db1_n = numerical_grad(b1, 'b1')
db2_n = numerical_grad(b2, 'b2')

# ── Compare ───────────────────────────────────────────────────────────
def relative_error(a, n):
    return np.abs(a - n).max() / (np.abs(a).max() + np.abs(n).max() + 1e-10)

print("Gradient check results (relative error < 1e-5 = correct):")
for name, analytical, numerical in [
    ('dW1', dW1_a, dW1_n), ('db1', db1_a, db1_n),
    ('dW2', dW2_a, dW2_n), ('db2', db2_a, db2_n),
]:
    err = relative_error(analytical, numerical)
    status = '✓ correct' if err < 1e-4 else '✗ WRONG — bug in backprop!'
    print(f"  {name}: relative error = {err:.2e}  {status}")
What PyTorch does for you

Autograd — PyTorch builds the computational graph and runs backprop automatically

Everything you coded by hand above — caching intermediate values, applying the chain rule at each layer, computing dW and passing dA backwards — PyTorch does automatically. When you call loss.backward(), PyTorch traces the computational graph it built during the forward pass and applies the chain rule to every operation automatically. Every tensor that had requires_grad=Truegets its .grad populated.

python
import torch
import torch.nn as nn
import numpy as np

torch.manual_seed(42)

# ── Manual backprop vs PyTorch autograd — same network, same result ───

# Data
X = torch.FloatTensor([[0.5, -0.3, 0.8]])   # (1, 3)
y = torch.FloatTensor([[1.0]])               # (1, 1)

# ── Manual backprop (numpy) ────────────────────────────────────────────
np.random.seed(42)
W1_np = np.random.randn(3, 4) * 0.1
b1_np = np.zeros((1, 4))
W2_np = np.random.randn(4, 1) * 0.1
b2_np = np.zeros((1, 1))

X_np, y_np = X.numpy(), y.numpy()
Z1 = X_np @ W1_np + b1_np
A1 = np.maximum(0, Z1)
Z2 = A1  @ W2_np + b2_np
A2 = 1 / (1 + np.exp(-Z2))

dA2  = (A2 - y_np)
dW2_manual = A1.T  @ dA2
dA1  = dA2 @ W2_np.T
dZ1  = dA1 * (Z1 > 0).astype(float)
dW1_manual = X_np.T @ dZ1

# ── PyTorch autograd ───────────────────────────────────────────────────
torch.manual_seed(42)
W1_pt = torch.FloatTensor(W1_np.copy()).requires_grad_(True)
b1_pt = torch.zeros(1, 4, requires_grad=True)
W2_pt = torch.FloatTensor(W2_np.copy()).requires_grad_(True)
b2_pt = torch.zeros(1, 1, requires_grad=True)

# Forward pass — PyTorch records every operation
Z1_pt = X @ W1_pt + b1_pt
A1_pt = torch.relu(Z1_pt)
Z2_pt = A1_pt @ W2_pt + b2_pt
A2_pt = torch.sigmoid(Z2_pt)

loss_pt = nn.BCELoss()(A2_pt, y)

# Backward pass — ONE LINE replaces our entire backward() function
loss_pt.backward()

# ── Compare gradients ──────────────────────────────────────────────────
print("Manual vs PyTorch autograd gradients:")
print(f"  dW1 max difference: {np.abs(dW1_manual - W1_pt.grad.numpy()).max():.2e}")
print(f"  dW2 max difference: {np.abs(dW2_manual - W2_pt.grad.numpy()).max():.2e}")
print("  (should be near 0 — same math, different implementation)")

# ── torch.no_grad() — disable gradient tracking for inference ─────────
print("
torch.no_grad() disables the computational graph:")
with torch.no_grad():
    pred = torch.sigmoid(X @ W1_pt + b1_pt @ W2_pt + b2_pt)
    # No graph is built — faster, uses less memory
    # W1_pt.grad will NOT be updated after this block
    print(f"  Prediction (no grad): {pred.item():.4f}")

# ── optimizer.zero_grad() — why gradients must be cleared ─────────────
print("
Why optimizer.zero_grad() is required every step:")
print("  PyTorch ACCUMULATES gradients by default — .grad += new_grad")
print("  Without zero_grad(), gradients from step t are added to step t+1")
print("  This is intentional for gradient accumulation techniques")
print("  But for standard training: always call zero_grad() before backward()")
Errors you will hit

Every common backprop mistake — explained and fixed

Gradients are all zero — loss does not decrease after many epochs
Why it happens

Dead network — all ReLU neurons are producing zero output. This happens when weights are initialised too negatively (all pre-activations are negative, ReLU outputs zero, ReLU derivative is zero, no gradient flows). Also caused by a learning rate so large that weights jumped to very negative values in the first step.

Fix

Use He initialisation for ReLU networks: nn.init.kaiming_normal_(layer.weight, mode='fan_in'). Reduce learning rate by 10×. Check: print(model.layer.weight.grad.abs().max()) after the first backward() — if zero, the network is dead. Use LeakyReLU instead of ReLU: nn.LeakyReLU(0.01) passes a small fraction of negative inputs, preventing neurons from dying completely.

RuntimeError: Trying to backward through the graph a second time
Why it happens

You called loss.backward() twice on the same computational graph. PyTorch frees the intermediate values (activations cached during forward pass) after the first backward() call to save memory. The second call finds the graph already freed and raises this error. Common in custom training loops where backward() is called inside a loop.

Fix

Call loss.backward() exactly once per training step. If you genuinely need to call it twice (gradient accumulation, custom second-order methods), use loss.backward(retain_graph=True) on the first call — this keeps the graph in memory. But for standard training this should never be needed — restructure the loop so backward() is called once per step.

Gradients are correct on small inputs but become NaN on larger batches
Why it happens

Numerical overflow in the forward pass. Sigmoid or softmax with very large inputs produce exp() values that overflow float32 (max ~3.4e38). This produces inf or NaN in the activations, which propagates through backprop as NaN gradients. Also caused by log(0) in cross-entropy loss when a prediction is exactly 0 or 1.

Fix

Clip inputs to sigmoid: sigmoid(np.clip(z, -500, 500)). Add eps to log: -mean(y * log(pred + 1e-8)). In PyTorch, use nn.BCEWithLogitsLoss() instead of nn.Sigmoid() + nn.BCELoss() — it combines both in a numerically stable way using the log-sum-exp trick. Normalise inputs with StandardScaler before training.

Manual backprop gradient check fails — large relative error between analytical and numerical
Why it happens

Bug in the analytical gradient computation. Most common mistakes: wrong matrix transpose (A.T @ dZ vs dZ @ A.T), missing the activation derivative (forgetting to multiply by relu_d(Z) or sigmoid_d(Z)), dividing by n in the wrong place, or accumulating gradients incorrectly across the batch dimension.

Fix

Debug one layer at a time — freeze all other weights and check only one layer's gradient. Print the shapes at every step: dA should match A in shape, dW should match W in shape. The most common error is a transposed matrix: remember dW = A_prev.T @ dZ (input-transposed times upstream gradient) and dA_prev = dZ @ W.T (upstream gradient times weight-transposed).

What this looks like at work

Nobody hand-codes backprop in production. Here is where understanding it earns its keep anyway.

Every production model calls loss.backward() once and lets autograd handle the chain rule end to end. That does not make this module optional — it means the moments where backprop understanding actually matters are concentrated into a few specific, recurring situations instead of spread across every line of code.

Reading a broken loss curve

Loss plateaus at a suspiciously round number, decreases then suddenly spikes to NaN, or early layers barely move while the last layer overfits fast. Each pattern maps to a specific point in the backward pass — dead activations, exploding gradients, or a vanishing signal that never reaches the early layers.

Diagnosing vanishing or exploding gradients

A network trains fine at 10 layers but stalls at 40. The fix — residual connections, normalisation, better initialisation — only makes sense once you can picture the chain rule multiplying a derivative at every one of those 40 layers.

Writing a custom autograd.Function

Quantisation-aware training, custom losses with numerically tricky gradients, or research code implementing a method from a paper all require a hand-written backward pass. Autograd cannot infer one for you.

Checklist — diagnosing a suspicious training run in under 5 minutes

1. Print the gradient norm of every layer's weights right after one backward() call — iterate model.named_parameters() and print each parameter's grad norm.

2. Compare the first layer's norm to the last layer's. A ratio smaller than roughly 1e-3 points to vanishing gradients. A ratio larger than roughly 1e3, or any norm in the thousands, points to exploding gradients.

3. If gradients look healthy at every layer but the loss still will not move, the problem is almost never backprop itself — check the learning rate, the loss function choice, and whether labels are shaped and typed correctly.

The custom autograd.Function case is worth seeing in code, because it is the one situation where "PyTorch handles it automatically" stops being true. Quantisation-aware training rounds activations to a fixed set of levels during the forward pass — but rounding has zero derivative almost everywhere, so plain autograd would report that every upstream weight had exactly zero effect on the loss. Teams shipping quantised models write a custom backward pass specifically to work around this, using exactly the chain-rule mechanics this module covers.

python
import torch

# ── A custom autograd.Function — the one place backprop is hand-written ─
# Real use case: quantisation-aware training. round() has zero gradient
# almost everywhere, so naive autograd would silently zero out every
# upstream gradient. The "straight-through estimator" fixes this by
# treating round() as the identity function purely for backward purposes.

class StraightThroughRound(torch.autograd.Function):
    @staticmethod
    def forward(ctx, x):
        return torch.round(x)

    @staticmethod
    def backward(ctx, grad_output):
        # Pass the incoming gradient straight through, unchanged —
        # pretend the forward op were the identity function.
        return grad_output

ste_round = StraightThroughRound.apply

x = torch.tensor([1.2, 2.7, 3.5], requires_grad=True)
y = ste_round(x)
loss = (y - torch.tensor([1.0, 3.0, 4.0])).pow(2).sum()
loss.backward()

print(f"Forward pass (rounded values): {y.tolist()}")
print(f"Gradient w.r.t. input:         {x.grad.tolist()}")
print("
Without the custom backward(), x.grad would be all zeros —")
print("torch.round() has zero derivative almost everywhere, and plain")
print("autograd would faithfully, and unhelpfully, report exactly that.")
Misconceptions

Five things people get wrong about backpropagation

Myth: Backprop and gradient descent are the same algorithm

They solve two different problems that happen to run back to back. Backprop answers "how much does each weight contribute to the error?" — it computes gradients, nothing more. Gradient descent answers "given these gradients, how should I update the weights?" You could compute gradients with backprop and then update weights with a completely different optimiser (Adam, RMSprop, SGD with momentum) — backprop does not change. It is the gradient-computation half of training, not the whole thing.

Myth: Deeper networks always learn better because backprop can handle any depth

Mathematically backprop works at any depth — the chain rule does not care how many layers there are. Practically, depth is exactly what breaks it: each layer's derivative gets multiplied into the gradient, and with sigmoid/tanh those derivatives are consistently below 1, so a 20-layer network can vanish the gradient to numerical zero before it reaches the early layers. Every major deep learning breakthrough since 2015 — ResNets' skip connections, batch normalisation, careful initialisation — exists specifically to keep very deep networks trainable despite this, not because backprop "handles" depth for free.

Myth: Backpropagation finds the best possible weights

Backprop only supplies a direction (the gradient); gradient descent then takes a step in that direction. Neural network loss surfaces are highly non-convex, so this process is only guaranteed to reach *a* local minimum, never provably the global one. In practice, modern research suggests that in large, overparameterised networks most local minima found this way perform comparably — but that is an empirical observation about the loss landscape, not a guarantee backprop itself provides.

Myth: You need to hand-derive backprop to use PyTorch or TensorFlow

Autograd computes every gradient for you — you will rarely, if ever, write dW = A_prev.T @ dZ by hand in production code. But "rarely write it" is not "never need to understand it": every one of the errors in this module (dead ReLUs, NaN losses, gradient explosions) is invisible to autograd itself — it faithfully computes whatever gradient the math produces, including a completely broken one. Debugging those requires knowing what backprop is actually doing under the hood, which is exactly why this module exists even though nobody hand-codes it day to day.

Myth: The chain rule in backprop is special 'neural network math'

It is the identical multivariable chain rule from a calculus course — nothing about it is neural-network-specific. What backprop contributes is an efficient *algorithm* for applying that chain rule to a deeply nested composition of functions: instead of re-deriving the derivative of the whole network with respect to every weight from scratch (which would recompute the same intermediate terms over and over), it caches intermediate gradients during a single backward pass — the same idea as dynamic programming applied to calculus.

Interview prep

Backpropagation — 5 questions interviewers actually ask

Q1 — Explain backpropagation to a non-technical stakeholder

A good answer avoids equations entirely: "The network makes a prediction, we measure how wrong it was, and backpropagation is the process of tracing that error backward through the network to figure out exactly how much each individual connection contributed to the mistake — like assigning blame after a failed project by tracing the decision chain back to its source. Once we know each connection's share of the blame, we nudge it slightly to make that mistake less likely next time."

Q2 — Why do vanishing gradients happen, and how do you fix them?

They happen because the chain rule multiplies each layer's local derivative into the gradient flowing backward, and sigmoid/tanh derivatives are bounded well below 1 (sigmoid tops out at 0.25) — across many layers that product shrinks toward zero, so early layers effectively stop learning. Fixes: switch to ReLU-family activations (derivative is exactly 1 for positive inputs, so nothing shrinks), add residual/skip connections so gradients have a direct path around the multiplication, and use batch or layer normalisation to keep activations in a well-behaved range at every layer.

Q3 — What's the actual difference between backprop and gradient descent?

Backprop is a gradient-computation algorithm: given the network and the loss, it produces ∂L/∂W for every weight, using the chain rule and a single backward pass. Gradient descent is an optimisation algorithm: given those gradients, it decides how to update the weights (W ← W − learning_rate × gradient), and there are many variants (SGD, Adam, RMSprop) that all consume the same backprop-computed gradients differently. Conflating them is the single most common mix-up on this topic.

Q4 — Why does PyTorch require optimizer.zero_grad() before backward()?

PyTorch accumulates gradients into .grad by default instead of overwriting them — useful on purpose for cases like gradient accumulation across several mini-batches (simulating a larger batch size than fits in memory) or RNNs where you sometimes want gradients to add up across time steps. But for standard training, forgetting zero_grad() means each step's gradient gets added on top of the previous step's leftover gradient, silently corrupting every update after the first — one of the most common real bugs in PyTorch code, and worth mentioning unprompted in an interview.

Q5 — How would you verify a custom backward pass is implemented correctly?

Gradient checking: compute the gradient two independent ways and compare them. The analytical gradient comes from your backprop implementation. The numerical gradient comes from the definition of a derivative directly — nudge one parameter by a tiny epsilon (e.g. 1e-7), rerun the forward pass, and measure how much the loss changed: (loss(w+ε) − loss(w−ε)) / (2ε). Compute the relative error between the two gradients; below roughly 1e-5–1e-7 means the implementation is correct. This should be done once per new architecture, on a small toy network, then removed — it is far too slow to run during actual training.

What comes next

You understand how networks learn. Next: what they learn through.

Backpropagation is the learning algorithm. But the network's ability to learn depends critically on two other choices: the activation function (what non-linearity to apply at each neuron) and the loss function (what the network is trying to minimise). Module 43 covers every major activation and loss function — what each one does, when to use it, and the numerical stability pitfalls that trip up every practitioner at least once.

Next — Module 43 · Deep Learning
Activation Functions and Loss Functions

ReLU, GELU, Swish, sigmoid, softmax — and cross-entropy, MSE, Huber, focal loss. When to use each and why numerical stability matters more than you think.

Start →

🎯 Key Takeaways

  • Backpropagation solves credit assignment — given a prediction error, how much is each weight responsible? It applies the chain rule backwards through the network: start at the loss, multiply derivatives layer by layer back to the weights.
  • Each layer in the backward pass produces two things: the gradient for its own weights (∂L/∂W = A_prev.T @ dZ) and the gradient to pass further backward (∂L/∂A_prev = dZ @ W.T). This pattern repeats identically for every layer.
  • Vanishing gradients happen when derivatives multiply to near-zero across many layers — sigmoid derivatives top out at 0.25, so 10 layers gives 0.25^10 ≈ 10^-7. The fix is ReLU activation, which has derivative 1 for positive inputs and preserves gradient magnitude.
  • Gradient checking is the definitive test for correct backprop: compare analytical gradients (from backprop) to numerical gradients (finite difference). Relative error below 1e-5 means your implementation is correct. Always gradient-check before training a new network architecture.
  • PyTorch autograd builds a computational graph during the forward pass and runs backprop automatically on loss.backward(). Every tensor with requires_grad=True gets its .grad populated. Call optimizer.zero_grad() before every backward() — PyTorch accumulates gradients by default.
  • Three practical rules: use BCEWithLogitsLoss instead of Sigmoid+BCELoss for numerical stability, always clip sigmoid inputs to avoid overflow, and call optimizer.zero_grad() at the start of every training step without exception.
Share

Discussion

0

Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.

Continue with GitHub
Loading...