Backpropagation
The algorithm that enables deep learning — efficiently computing gradients through neural networks
The Problem: Computing Gradients
To train a neural network with gradient descent, we need gradients of the loss with respect to every weight:
The challenge: A network with 1 million weights needs 1 million partial derivatives. How do we compute them efficiently?
Naive Approach: Numerical Gradients
Perturb each weight and measure the change in loss:
Problem: Requires 2 forward passes per weight. For 1M weights, that's 2M forward passes per update — far too slow.
The Solution: Backpropagation
Backpropagation computes all gradients in one forward pass + one backward pass, regardless of network size.
Computation Graphs
Neural networks can be represented as directed acyclic graphs (DAGs) where:
- Nodes = operations (add, multiply, activation)
- Edges = data flow
Example: Simple Network
For
x ──┬──→ [*w₁] ──→ [+b₁] ──→ [σ] ──→ [*w₂] ──→ [+b₂] ──→ [σ] ──→ y
│ │
└─── w₁ h₁Each node has:
- Forward: compute output from inputs
- Backward: compute gradient of loss w.r.t. inputs, given gradient w.r.t. output
The Chain Rule
Backpropagation is the chain rule applied systematically through the computation graph.
Single Variable Chain Rule
If
Multivariate Chain Rule
If
Key insight: Gradients sum when paths merge, and multiply along paths.
Backpropagation Algorithm
Forward Pass
Compute activations layer by layer, caching intermediate values:
def forward(x, weights, biases):
cache = {'a0': x}
a = x
for l, (W, b) in enumerate(zip(weights, biases)):
z = a @ W + b
cache[f'z{l+1}'] = z
a = relu(z) # or other activation
cache[f'a{l+1}'] = a
return a, cacheBackward Pass
Propagate gradients from output to input using cached values:
def backward(y_pred, y_true, cache, weights):
grads = {}
L = len(weights)
# Output layer gradient
dL_da = y_pred - y_true # for softmax + cross-entropy
for l in reversed(range(1, L+1)):
# Gradient through activation
dL_dz = dL_da * relu_derivative(cache[f'z{l}'])
# Gradient for weights and biases
grads[f'dW{l}'] = cache[f'a{l-1}'].T @ dL_dz
grads[f'db{l}'] = np.sum(dL_dz, axis=0)
# Propagate to previous layer
dL_da = dL_dz @ weights[l-1].T
return grads
Full Derivation: 2-Layer Network
Consider: Input
Forward Pass
Loss
Cross-entropy:
Backward Pass
Step 1: Output layer gradient (softmax + cross-entropy simplifies nicely)
Step 2: Output weight gradients
Step 3: Propagate to hidden layer
Step 4: Through activation
Step 5: Hidden weight gradients
The Pattern
For any layer
Forward Mode vs Reverse Mode Autodiff
| Mode | Direction | Efficiency |
|---|---|---|
| Forward mode | Input → Output | O(1) for one input, all outputs |
| Reverse mode | Output → Input | O(1) for one output, all inputs |
Why Reverse Mode for Neural Networks?
Neural networks have:
- Many inputs (millions of weights)
- One output (scalar loss)
Forward mode: O(n) passes for n weights Reverse mode (backprop): O(1) passes regardless of weight count
Implementation Details
Gradient Checking
Verify backprop implementation by comparing to numerical gradients:
def gradient_check(f, x, epsilon=1e-7):
"""Check analytical gradient against numerical gradient."""
analytical_grad = f.backward(x)
numerical_grad = np.zeros_like(x)
for i in range(x.size):
x_plus = x.copy()
x_plus.flat[i] += epsilon
x_minus = x.copy()
x_minus.flat[i] -= epsilon
numerical_grad.flat[i] = (f(x_plus) - f(x_minus)) / (2 * epsilon)
error = np.linalg.norm(analytical_grad - numerical_grad)
error /= np.linalg.norm(analytical_grad) + np.linalg.norm(numerical_grad)
return error < 1e-5 # Should be very smallNumerical Stability
Problem: Softmax can overflow for large inputs
Solution: Subtract max before exponentiating
def stable_softmax(z):
z_shifted = z - np.max(z, axis=-1, keepdims=True)
exp_z = np.exp(z_shifted)
return exp_z / np.sum(exp_z, axis=-1, keepdims=True)Vectorized Implementation
Always work with batches for efficiency:
# Bad: loop over samples
for i in range(batch_size):
grad_W += x[i:i+1].T @ delta[i:i+1]
# Good: vectorized
grad_W = x.T @ delta # (input_dim, batch) @ (batch, output_dim)Why Cache Activations?
To compute
Options:
- Recompute: Run forward pass again (slow)
- Cache: Store during forward pass (memory-intensive)
Trade-off: Training uses ~2x memory of inference because we cache activations.
Gradient checkpointing: Recompute some activations to trade compute for memory — useful for very large models.
Interview Questions
Q1: "Walk through backpropagation for a 2-layer network."
Forward pass:
(linear) (activation) (linear) (output) Loss:
Backward pass:
(output gradient) (output weight gradient) (propagate back) (through activation) (hidden weight gradient) Update:
Q2: "Why do we cache activations during forward pass?"
We cache activations because we need them for gradient computation.
The gradient w.r.t. weights at layer
is: We need
(the previous layer's activation) to compute this. Trade-offs:
- Cache everything: Training uses ~2x inference memory
- Recompute: Slower, but saves memory
- Gradient checkpointing: Hybrid — cache some, recompute others
This is why training large models requires much more memory than inference.
Q3: "What's the difference between forward and reverse mode autodiff?"
Forward mode propagates derivatives input → output:
- Efficient for: Few inputs, many outputs
- Complexity: O(n) passes for n inputs
Reverse mode (backpropagation) propagates derivatives output → input:
- Efficient for: Many inputs, few outputs
- Complexity: O(m) passes for m outputs
Neural networks: Millions of weights (inputs to loss), one scalar loss (output). Reverse mode computes all gradients in one backward pass — that's why we use backpropagation.
Forward mode would require millions of passes. The efficiency difference is the reason deep learning is computationally feasible.
Key Takeaways
Backpropagation is the chain rule applied systematically through a computation graph.
Reverse mode autodiff computes all gradients in O(1) backward passes — essential for efficiency.
Cache activations during forward pass for use in backward pass (trade memory for speed).
Gradient checking validates implementations by comparing to numerical gradients.
The pattern:
— gradients flow backward, multiplying by weights and activation derivatives.