LEARN · NEURAL NETWORKS FROM THE GROUND UP
Why backpropagation still matters
Backpropagation is the engine behind modern deep learning. Whether you’re training a tiny multilayer perceptron or a frontier-scale language model, every optimization step depends on efficiently computing gradients.
The good news is that backpropagation is not magic. It is simply the chain rule applied systematically across a computational graph.
In this guide, you’ll:
-
Build intuition for backpropagation.
-
Hand-compute gradients through a tiny two-layer network.
-
Verify every result with PyTorch Autograd.
-
Learn how reverse-mode automatic differentiation works.
-
See why gradient checking remains one of the most valuable debugging tools in machine learning.
By the end, you’ll understand not just what Autograd computes, but why the numbers are correct.
The core idea: the chain rule
Suppose a computation looks like this:
x │ ▼ a │ ▼ b │ ▼ loss
If the loss depends on b, which depends on a, which depends on x, then:
d(loss)/d(x) = d(loss)/d(b) × d(b)/d(a) × d(a)/d(x)
Every derivative is a local derivative.
Backpropagation simply multiplies these local derivatives together while moving backward through the computation graph.
Instead of recomputing derivatives independently for every parameter, reverse-mode automatic differentiation reuses intermediate results. That’s why models with millions—or even billions—of parameters can be trained efficiently.
Computational graphs: what Autograd actually records
Every differentiable operation becomes a node in a computational graph.
Our tiny network looks like this:
Input x │ Linear (w1, b1) │ z1 │ ReLU │ a1 │ Linear (w2, b2) │ Prediction ŷ │ Squared Error │ Loss
During the forward pass, every node computes a value.
During the backward pass, every node receives a gradient from the node above and computes gradients for its own inputs.
Think of every edge as carrying two things:
-
A value during the forward pass.
-
A gradient during the backward pass.
That repeated application of the chain rule is the entire essence of backpropagation.
Our tiny neural network
We’ll deliberately keep the network small enough to compute every gradient by hand.
Architecture:
Input ↓ Linear ↓ ReLU ↓ Linear ↓ Prediction ↓ Squared Error Loss
Parameters:
-
x = 2
-
target = 1
-
w1 = 3
-
b1 = 1
-
w2 = 4
-
b2 = −2
Step 1: Forward pass
Hidden pre-activation:
z1 = w1 × x + b1 = 3 × 2 + 1 = 7
Apply ReLU:
a1 = max(0, z1) = 7
Output:
ŷ = w2 × a1 + b2 = 4 × 7 − 2 = 26
We’ll use the squared-error loss with the common 0.5 scaling:
Loss = 0.5 × (ŷ − target)²
Substituting the numbers:
Loss = 0.5 × (26 − 1)²
= 0.5 × 25²
= 312.5
Why multiply by 0.5?
Without the scaling:
Loss = (ŷ − target)²
the derivative becomes:
2 × (ŷ − target)
Multiplying by 0.5 cancels the extra factor of 2:
dLoss/dŷ = ŷ − target
The optimization behaves identically—the gradients are simply scaled consistently.
Step 2: Start from the loss
Always begin at the end.
Loss = 0.5 × (ŷ − target)²
Differentiate:
dLoss/dŷ = ŷ − target = 25
This is the first gradient flowing backward.
Step 3: Gradients of the second layer
Our output equation is:
ŷ = w2 × a1 + b2
For the weight:
dŷ/dw2 = a1 = 7
Therefore:
dLoss/dw2 = 25 × 7 = 175
For the bias:
dŷ/db2 = 1
So:
dLoss/db2 = 25
Now propagate the gradient toward the hidden layer:
dŷ/da1 = w2 = 4
Therefore:
dLoss/da1 = 25 × 4 = 100
Step 4: Through the ReLU
ReLU is defined as:
ReLU(x) = max(0, x)
Its derivative is:
-
1 when the input is positive
-
0 when the input is negative
Since:
z1 = 7
the derivative is:
dReLU/dz1 = 1
Therefore:
dLoss/dz1 = 100 × 1 = 100
ReLU acts like a gradient gate:
-
Positive activations pass gradients through unchanged.
-
Negative activations block gradients entirely.
This explains why “dead ReLU” neurons can stop learning if they never activate.
Step 5: First-layer gradients
Recall:
z1 = w1 × x + b1
For the weight:
dz1/dw1 = x = 2
Therefore:
dLoss/dw1 = 100 × 2 = 200
For the bias:
dz1/db1 = 1
Therefore:
dLoss/db1 = 100
Finally, the gradient with respect to the input:
dz1/dx = w1 = 3
Therefore:
dLoss/dx = 100 × 3 = 300
Gradients with respect to inputs are useful for:
-
Saliency maps
-
Feature attribution
-
Adversarial examples
-
Input optimization
Final gradient summary
| Quantity | Gradient |
|---|---|
| w1 | 200 |
| b1 | 100 |
| w2 | 175 |
| b2 | 25 |
| x | 300 |
Every one of these values should match what Autograd computes.
Verify everything with PyTorch Autograd
The following example works with the current stable PyTorch release and reproduces the manual calculations exactly.
import torch # Inputs x = torch.tensor(2.0, requires_grad=True) target = torch.tensor(1.0) # Parameters w1 = torch.tensor(3.0, requires_grad=True) b1 = torch.tensor(1.0, requires_grad=True) w2 = torch.tensor(4.0, requires_grad=True) b2 = torch.tensor(-2.0, requires_grad=True) # Forward pass z1 = w1 * x + b1 a1 = torch.relu(z1) y_hat = w2 * a1 + b2 loss = 0.5 * (y_hat - target) ** 2 # Backward pass loss.backward() print(f"Loss = {loss.item():.1f}") print(f"dw1 = {w1.grad.item()}") print(f"db1 = {b1.grad.item()}") print(f"dw2 = {w2.grad.item()}") print(f"db2 = {b2.grad.item()}") print(f"dx = {x.grad.item()}")
Expected output:
Loss = 312.5 dw1 = 200.0 db1 = 100.0 dw2 = 175.0 db2 = 25.0 dx = 300.0
Every gradient exactly matches the hand calculation.
What happens when you call backward()?
Calling:
loss.backward()
starts at the loss with an initial gradient of 1.
Autograd then traverses the graph in reverse topological order.
Each operation already knows its own local derivative:
-
Addition contributes a derivative of 1.
-
Multiplication remembers both operands.
-
ReLU remembers which inputs were positive.
-
Matrix multiplication computes gradients with respect to both inputs.
Autograd combines these local derivatives using the chain rule and accumulates the results into each tensor’s .grad field.
The forward pass builds the graph. The backward pass walks it in reverse.
Why gradients accumulate
One subtle behavior surprises almost everyone at first.
Gradients accumulate by default.
loss.backward() loss.backward()
After the second call, every gradient doubles.
This is intentional. During training, gradients from multiple losses or mini-batches can be accumulated before updating the model.
A typical training loop clears gradients first:
optimizer.zero_grad(set_to_none=True) loss = model(inputs) loss.backward() optimizer.step()
Using set_to_none=True is still the recommended approach because it avoids unnecessary memory writes and allows PyTorch to allocate gradient buffers lazily.
A practical debugging workflow
When a model refuses to learn, gradients are often the first thing to inspect.
A reliable workflow is:
-
Confirm every trainable parameter has
requires_grad=True. -
Verify that no tensor was accidentally detached from the graph.
-
Print gradient norms after
backward(). -
Watch for exploding or vanishing gradients.
-
Reduce the model to the smallest reproducible example.
-
Run a numerical gradient check if you’ve written a custom operation.
Many difficult training bugs become obvious once you inspect where gradients stop flowing.
Common mistakes
Forgetting the chain rule
The derivative of one operation is almost never the final answer.
Every upstream gradient must be multiplied through the computation graph.
Ignoring activation functions
Activations determine how gradients flow.
For example:
-
ReLU blocks negative activations.
-
Sigmoid saturates for very large positive or negative values.
-
Tanh can also produce very small gradients in saturated regions.
Activation choice often has a larger effect on optimization than newcomers expect.
Mixing forward and backward reasoning
The forward pass computes predictions.
The backward pass computes sensitivities.
Keeping those mental models separate makes debugging much easier.
Assuming larger gradients are always better
Very small gradients slow learning.
Very large gradients can make optimization unstable.
Healthy training usually means gradients stay within a reasonable range throughout the network.
Cherry on the cake: gradient checking has caught real framework bugs
It might seem like numerical gradient checking is only for students learning calculus—but major deep learning frameworks still rely on it during development.
PyTorch’s own test suite routinely uses torch.autograd.gradcheck, which compares analytical gradients against finite-difference approximations before new differentiable operators are accepted. Over the years, contributors have uncovered subtle bugs in custom C++ and CUDA kernels where the forward computation looked correct, but the backward pass silently produced incorrect gradients. These issues often escaped ordinary unit tests because the outputs were correct while only the derivatives were wrong.
The surprising part is that this technique continues to catch regressions even in a mature framework. Gradient correctness is difficult enough that every new differentiable operator is expected to prove its backward implementation numerically before it can be trusted.
The lesson is simple: if you’re writing a custom autograd.Function, a C++ extension, or a Triton kernel, make gradient checking part of your test suite from day one.
Key takeaways
-
Backpropagation is the chain rule applied across a computation graph.
-
Reverse-mode automatic differentiation makes gradient computation efficient for models with many parameters.
-
Every operation contributes a local derivative during the backward pass.
-
ReLU either passes or blocks gradients depending on its input.
-
The 0.5 factor in squared-error loss simplifies the derivative without changing the optimization objective.
-
PyTorch Autograd reproduces exactly the same gradients you can compute by hand.
-
Numerical gradient checking remains one of the best ways to validate custom differentiable code.
Where to go next
A great follow-up exercise is to extend this example:
-
Add a second hidden neuron.
-
Replace ReLU with GELU or Tanh.
-
Compute the gradients by hand first.
-
Verify every result with Autograd.
-
Finally, try
torch.autograd.gradcheckon your own custom function to see numerical verification in action.
The fastest way to build intuition is to alternate between pencil-and-paper derivations and runnable code. Try the exercise yourself, compare your manual gradients with Autograd, and keep experimenting—once the chain rule clicks, you’ll understand the foundation of every modern neural network.