3.7 Backward Propagation Check: Using Numerical Gradients to Verify Handwritten Backward
Author
jshn9515
Published
2026-06-07
Modified
2026-06-07
In the previous sections, we implemented the forward pass, backward pass, and parameter updates of an MLP from scratch with NumPy, and trained it on MNIST. But this naturally raises another question:
How do we know that our handwritten backward is actually correct?
When writing backpropagation by hand, the most error-prone parts are often small implementation details:
Writing the transpose in the wrong direction for matrix multiplication;
Forgetting to sum the bias gradient along the batch dimension;
Forgetting to divide by the batch size in cross entropy;
Using the wrong saved intermediate variable in ReLU backward;
Reversing the sign of the gradient during parameter updates.
Sometimes these errors do not crash the program, because the tensor shapes may still line up. However, they can make training worse, or even prevent the model from learning entirely.
In this section, we introduce a common checking method: gradient checking. The core idea is: instead of using the backpropagation formula, we start directly from the definition of the derivative, use a very small perturbation to approximate the gradient, and then compare it with the gradient produced by our handwritten backward.
from collections.abc import Callableimport dnnlpy.models.mlp as mlpimport numpy as npimport numpy.linalg as nplimport torchimport torch.autograd as AFfrom torch import Tensortype Func = Callable[[np.ndarray], np.ndarray]rng = np.random.default_rng(42)print('PyTorch version:', torch.__version__)
PyTorch version: 2.12.1+cpu
3.7.1 From the Definition of the Derivative to Numerical Gradients
For a single-variable function \(f(x)\), the derivative can be written as:
On a computer, we cannot really make \(\epsilon\) become 0, so we can choose a very small number, such as \(10^{-5}\), and use it to approximate the derivative.
However, in actual gradient checking, the more common choice is the central difference:
Here, func is a function whose input is an array and whose output is a scalar. numerical_gradient perturbs the elements in x one by one and estimates the gradient at each position.
def loss_fn_relu(x: np.ndarray) ->float: out = relu(x) loss = np.sum(np.square(out))return lossgrad_x_numeric = numerical_gradient(loss_fn_relu, x.copy())error = relative_error(grad_x_analytic, grad_x_numeric)print('Relative Error for ReLU input gradient:', error)
Relative Error for ReLU input gradient: 1.3184705928176506e-11
There is one detail here: ReLU is not differentiable at \(x = 0\). When doing gradient checking in practice, it is best to avoid inputs that are exactly or very close to 0, otherwise the numerical gradient may be unstable. For randomly generated continuous values, the probability of being exactly 0 is usually very small, so this example generally works fine.
3.7.5 Checking the Backward Pass of Softmax Cross Entropy
Previously, we derived that when softmax and cross entropy are combined, the gradient with respect to logits has a very concise form:
def loss_fn_logits(logits: np.ndarray) ->float: loss = loss_fn(logits, y)return lossgrad_logits_numeric = numerical_gradient(loss_fn_logits, logits.copy())error = relative_error(grad_logits_analytic, grad_logits_numeric)print('Relative Error for logits gradient:', error)
Relative Error for logits gradient: 2.492271968775219e-11
If the error here is very small, it means that the softmax + cross entropy backward implementation derived in 3.3 is correct.
3.7.6 Checking the Parameter Gradients of the Full MLP
Finally, we can apply this method to the full MLP. The structure of a two-layer MLP is:
X -> Linear -> ReLU -> Linear -> CrossEntropyLoss -> L
We can check one parameter in it, such as the weight fc1.W of the first layer. To make numerical gradient checking faster, we only use a very small model:
Similarly, we can also check fc1.b, fc2.W, and fc2.b.
In practice, however, we usually do not need to run a full gradient check every time we train. It is mainly useful right after you finish writing a backward pass, using a tiny model and a tiny dataset to check whether the implementation is correct.
3.7.7 Notes on Numerical Gradient Checking
Numerical gradient checking is useful, but it also has some limitations.
First, it is slow. This is because each parameter requires two separate forward passes:
If the model has \(N\) parameters, then numerical gradients require about \(2N\) forward passes. Therefore, it is only suitable for small models, small batches, and checking a small number of parameters. It is not suitable for frequent use on a full MNIST model.
Second, \(\epsilon\) cannot be too large or too small. If it is too large, the approximation will not be accurate enough; if it is too small, floating-point error becomes more obvious. A common choice is:
\[
\epsilon = 10^{-5}
\]
Third, it is best to use float64 for checking. float32 has lower numerical precision and is more likely to introduce errors in gradient checking.
Fourth, be careful with nondifferentiable points. For example, ReLU is not differentiable at 0. If the input happens to fall near a nondifferentiable point, the numerical gradient and analytic gradient may not match.
Finally, gradient checking can only show that near the current inputs and parameters, the gradient implementation is most likely correct. It cannot strictly prove that the code is always correct, but it is already very helpful for debugging handwritten backpropagation.
3.7.8 Gradient Check in PyTorch
In PyTorch, if we implement our own torch.autograd.Function, we can also use torch.autograd.gradcheck to perform a similar check. Its idea is the same as this section:
Approximate derivatives with numerical gradients, then compare them with the analytic gradients returned by backward.
Note that gradcheck usually requires torch.double, and the input needs to have requires_grad=True.
However, the focus of this chapter is to understand the implementation principles of backpropagation, so we first wrote a minimal gradient checker ourselves with NumPy. Once you understand this process, PyTorch’s gradcheck will feel very natural.
3.7.9 Summary
In this section, we introduced numerical gradient checking.
When writing backward by hand, a forward pass that runs successfully does not necessarily mean the gradients are correct. To verify the gradient implementation, we can start from the definition of the derivative and use the central difference to estimate the numerical gradient:
Then we compare it with the analytic gradient obtained from backward.
We checked:
The gradient of a simple squared function;
dx, dW, and db of the Linear layer;
The input gradient of ReLU;
The gradient of CrossEntropyLoss with respect to logits;
The gradient of one parameter in the full MLP.
Numerical gradient checking is slow, but it is very suitable for debugging handwritten backpropagation. At this point, we have implemented and verified the core components of a complete MLP from scratch.
In the next section, we will return to PyTorch and reimplement the same MLP with nn.Module. At that point, we will see that PyTorch’s automatic differentiation does not change these mathematical processes; it simply automates the backward passes we wrote by hand earlier.