Backpropagation is often introduced as the mysterious engine powering deep learning, but at its core, it is simply reverse-mode automatic differentiation applied to a computation graph. It is not a neural-network-specific trick; rather, it is a disciplined, highly optimized way to move local gradients backward through program operations using the chain rule of calculus.
While forward propagation computes the model’s prediction by traversing the graph from inputs to the loss, backpropagation traverses the graph in reverse. In this article, we rigorously work through a two-layer Multi-Layer Perceptron (MLP): x -> W1x+b1 -> ReLU -> W2h+b2 -> softmax cross-entropy, expanding on both the mathematical theory and the engineering realities of implementing it from scratch.
1. The Computation Graph Unveiled
To compute derivatives systematically, we decompose the complex neural network into a Directed Acyclic Graph (DAG) of primitive operations. Each node represents a simple mathematical operation (like matrix multiplication or ReLU), and edges represent the flow of tensors. Crucially, each node must be capable of doing two things: calculating its forward output, and calculating its local Vector-Jacobian Product (VJP) during the backward pass.
graph TD
x["Input x"] --> z1["z1 = x @ W1 + b1"]
W1["Weights W1"] --> z1
b1["Bias b1"] --> z1
z1 --> h["h = ReLU(z1)"]
h --> logits["logits = h @ W2 + b2"]
W2["Weights W2"] --> logits
b2["Bias b2"] --> logits
logits --> p["p = Softmax(logits)"]
p --> L["Loss = CrossEntropy(p, target)"]
target["Target y"] --> L
classDef fwd fill:#e1f5fe,stroke:#039be5,stroke-width:2px;
classDef param fill:#fce4ec,stroke:#d81b60,stroke-width:2px;
class z1,h,logits,p,L fwd;
class W1,b1,W2,b2 param;
The forward pass must cache intermediate values (like x, z1, and h) because they are required to compute the local gradients during the backward pass. This is why training neural networks is heavily memory-bound compared to inference.
2. The Softmax Cross-Entropy Shortcut
In theory, you could calculate the Jacobian of the Cross-Entropy loss with respect to the Softmax probabilities, and then multiply that by the Jacobian of the Softmax with respect to the logits. In practice, doing this explicitly is a recipe for numerical disaster and wasted compute.
When you combine Softmax and Cross-Entropy, the mathematical terms elegantly cancel out, resulting in a beautifully simple gradient with respect to the logits:
dL/dlogits = p - one_hot(target)
For example, if the target is class 1 and the model predicts probabilities [0.1, 0.7, 0.2], the gradient is simply [0.1, 0.7 - 1.0, 0.2] = [0.1, -0.3, 0.2]. The negative sign correctly pushes the correct logit higher, while the positive signs penalize the incorrect logits. This algebraic simplification is why production frameworks like PyTorch fuse these operations into CrossEntropyLoss.
3. Mathematical Derivations of the MLP
Once we have the gradient of the loss with respect to the logits (dlogits), we propagate it backward using the chain rule. Notice that we never instantiate full Jacobian matrices; instead, we compute Vector-Jacobian Products efficiently using matrix transposes.
# Layer 2 gradients
dW2 = h^T dlogits
db2 = sum(dlogits, axis=0)
dh = dlogits W2^T
# Layer 1 gradients
dz1 = dh * ReLU'(z1) # Element-wise multiplication
dW1 = x^T dz1
db1 = sum(dz1, axis=0)
By checking the norm of these gradients (e.g., norm_dW1=0.999823, norm_dW2=0.993682), we can verify that the network isn’t suffering from vanishing or exploding gradients.
4. Real-World Numpy Implementation
Translating the math into executable code reveals how frameworks actually operate under the hood. Here is a batched, robust implementation using NumPy:
import numpy as np
def relu(x):
return np.maximum(0, x)
def relu_backward(dout, cache_x):
return dout * (cache_x > 0).astype(float)
def softmax(x):
# Subtract max for numerical stability
exps = np.exp(x - np.max(x, axis=-1, keepdims=True))
return exps / np.sum(exps, axis=-1, keepdims=True)
# 1. Forward Pass
x = np.random.randn(32, 10) # Batch size 32, features 10
W1 = np.random.randn(10, 64) * 0.1
b1 = np.zeros((1, 64))
W2 = np.random.randn(64, 5) * 0.1
b2 = np.zeros((1, 5))
targets = np.random.randint(0, 5, size=(32,))
z1 = x @ W1 + b1
h = relu(z1)
logits = h @ W2 + b2
probs = softmax(logits)
# 2. Backward Pass
# Softmax-CE gradient
batch_size = x.shape[0]
dlogits = probs.copy()
dlogits[np.arange(batch_size), targets] -= 1
dlogits /= batch_size # Average over batch
# Layer 2
dW2 = h.T @ dlogits
db2 = np.sum(dlogits, axis=0, keepdims=True)
dh = dlogits @ W2.T
# Layer 1
dz1 = relu_backward(dh, z1)
dW1 = x.T @ dz1
db1 = np.sum(dz1, axis=0, keepdims=True)
print(f"Gradient norms: dW1={np.linalg.norm(dW1):.4f}, dW2={np.linalg.norm(dW2):.4f}")
5. Personal Experience / Engineer’s Perspective
From years of writing and debugging custom CUDA kernels and deep learning architectures, here are my takeaways on backpropagation in the wild:
The Memory Wall: Beginners often think training is slow because of math, but it’s actually constrained by memory bandwidth. During the forward pass, we have to stash the activations (like
z1andh) in HBM because the backward pass needs them. This is why techniques like Gradient Checkpointing (recomputing activations on the fly) exist—they trade FLOPs to save VRAM.
- Broadcasting Bugs: In NumPy and PyTorch, silent broadcasting is a silent killer. If you compute
db1 = dlogitswithout summing over the batch axis, the tensor shapes might accidentally broadcast later, producing garbage gradients without throwing an error. Always usekeepdims=True. - Gradient Checking: When writing a custom C++ or CUDA backward pass, your first step should always be writing a finite-difference gradient checker. Compare your analytical gradient against
(f(x + h) - f(x - h)) / 2h. If they don’t match up to 1e-4, your backprop is wrong. - Numerical Stability: Never compute
np.exp(logits)directly. Always subtract the maximum logit first. A logit of1000will overflow float32 instantly, resulting inNaNgradients that poison the entire network.
6. Visualizing the Flow
When watching the animation, do not only watch arrow direction. Observe which forward values each node must store and reuse during the backward pass. The next article studies how these gradients move parameters and why optimizers take different paths to the minima.
7. Backpropagation Verification Matrix
When reproducing this article, split the forward and backward pass into auditable stages. The table below turns “is the formula correct?” into visible evidence, so a decreasing loss is not mistaken for proof that every gradient is correct.
| Stage | Values to cache or inspect | Common symptom when it fails |
|---|---|---|
| Forward cache | x, z1, h, logits, and probs. |
The ReLU mask cannot be reconstructed, or gradient shapes are only “fixed” by broadcasting. |
| Softmax-CE | dlogits = probs - one_hot(target), averaged over the batch. |
The loss moves but gradients are too large and training is overly sensitive to batch size. |
| Matrix gradients | dW2 = h.T @ dlogits and dW1 = x.T @ dz1. |
The gradient shape does not match the parameter, or a transpose error silently prevents learning. |
| Numerical stability | Subtract max before softmax and inspect NaN, Inf, and gradient norms. |
Loss becomes NaN early, or a layer’s gradient norm collapses to zero. |