Numerical differentiation and gradient checking
Before this lesson: lesson 03 (Taylor's theorem with remainder, the Taylor test), lesson 06 (the gradients you will check), and floating point and cancellation from linear algebra lesson 13. Time: about 60 minutes, plus the notebook.
Why this matters #
A hand-written backward pass, a custom CUDA or Triton kernel, a new loss term, the adjoint of a PDE
solver: each is a gradient that can be wrong without any error message. Training still runs; it
is only slower or worse. A finite-difference check needs nothing but evaluations of , so it is
the standard unit test for gradients (torch.autograd.gradcheck, jax.test_util.check_grads).
It is also a small, complete lesson in floating point: a formula that is exact as gives garbage at . In science codes, the same ideas appear as the gradient test of data-assimilation systems. The complex step is a standard way to get derivatives from simulation code that has no automatic differentiation.
1. Predict first #
2. The picture #
Secant slopes. The derivative is the slope of the tangent. A finite difference uses the slope of a secant through two nearby points:
- the forward difference uses the points and ;
- the central difference uses the points and .
The figure shows both secants next to the tangent.
Press "Halve h" a few times. The forward secant turns toward the tangent slowly: its error halves at each step (first order). The central secant is almost parallel to the tangent already: its error falls by 4 at each step (second order). Drag the point to a place where the curve bends strongly: the forward error grows, because it is proportional to the curvature .
The central secant is better because it is symmetric. The curvature makes the forward secant too steep on one side; with points on both sides of , the two curvature errors cancel. Only an term is left (Theorem 1 below).
Two errors with opposite trends.
- Truncation error. The secant is not the tangent. Its slope differs from by a term that shrinks like (forward) or (central). This is pure math: it exists even with exact arithmetic.
- Rounding error. In float64 every computed value of has a relative error of about (machine epsilon). For small , and agree in their leading digits. The subtraction cancels those digits, and what is left is mostly rounding noise. This is catastrophic cancellation (linear algebra lesson 13). Dividing by a small makes the noise large: this error grows like .
On a log–log plot, the total error against is a V. The right arm is truncation, with slope (forward) or (central). The left arm is rounding, with slope . The best step sits at the bottom of the V, far from "as small as possible".
Move the probe from right to left. On the right arm the error falls as falls. At the dashed lines (the optimal steps predicted in section 3) the forward and central errors turn around. Left of them, the digits of and in the readout are mostly the same: the subtraction cancels the grey digits and keeps only the pink ones, which are rounding noise. Below , rounds to and the difference is 0. The complex step subtracts nothing and stays at full accuracy. Switch to float32: the V moves up and to the right.
The complex step leaves the real axis. It evaluates , with , and keeps the imaginary part. For a real-analytic , : the derivative appears in the imaginary part, and nothing is subtracted. So there is no cancellation and no left arm.
In dimensions a finite-difference gradient moves one coordinate at a time: or evaluations of . Reverse-mode automatic differentiation gives the whole gradient for a small constant times one evaluation (lesson 05). So finite differences are a check, never a way to train.
You compute a central difference with and get an error of . Rounding is negligible here. About what error do you expect with ?
The central difference has truncation error : second order. Ten times smaller gives times smaller error. would be first order, the forward difference.
3. Definitions and main results #
Notation. is the step. is float64 machine epsilon. is a real function of a real , smooth near . We use as the size of the relative error of one computed value of . (One correctly rounded operation has error at most , the unit roundoff; a computed has several roundings, so is a fair round number.)
Theorem 1 (truncation error) #
If , then for some . If , then for some .
So the forward difference is first order, , and the central difference is second order, .
Proof.
- Taylor with the Lagrange remainder (lesson 03): .
- Subtract and divide by : this is the forward statement.
- One order more, on both sides:
- Subtract the two lines. The even terms and cancel:
- Divide by . The average of and lies between the two values, so it equals for some (intermediate value theorem).
The same expansions give the second difference for the second derivative:
Richardson extrapolation
If is smooth, only even powers of appear in the central difference: . Halving the step gives . Take the combination that cancels the term:
This is Richardson extrapolation. It turns two second-order values into one fourth-order value. It is the idea behind Romberg integration and many high-order finite-difference stencils. The rounding arm becomes worse, so the best step is larger ().
Theorem 2 (the optimal step) #
Error model. Each computed value satisfies , with near . Near , and . Then the total errors are bounded by
and the bounds are smallest at
Why this is true (intuition). One term falls and one term rises as shrinks. The sum is smallest where the two terms are of the same size: the bottom of the V.
Proof.
- Forward: the computed numerator is with . After dividing by , the rounding part is at most . Add the truncation bound from Theorem 1.
- Set the derivative to 0: gives . There the two terms are equal, each , so .
- Central: the rounding part is at most ; truncation at most .
- gives . There , so , which is the formula.
Numbers (float64, ).
| best step | best error | correct digits | |
|---|---|---|---|
| forward | about 8 (half) | ||
| central | about 10–11 (two thirds) |
In general, a formula of order has and best error . These are worst-case bounds; measured errors are often a bit smaller.
Scale. If changes on the length scale (for example or ), then and . So use a relative step , with (forward) or (central). A fixed at changes only in about its 13th digit.
In float64, for with , of order 1, which step gives the most accurate forward difference?
The best step balances truncation and rounding : , with about 8 correct digits. At the rounding term destroys the result. is close to the best step for the central difference, not the forward one.
Theorem 3 (the complex step) #
Let be analytic near and real on the real axis, so that and all its derivatives at a real are real. Then
Proof.
- Taylor series in the direction , with and :
- All derivatives at are real. So the imaginary part is , and the real part is .
- Divide the imaginary part by .
Why works. No two nearly equal numbers are subtracted. The imaginary part, about , is computed with a relative error of about for any down to underflow. The truncation term is about . So the result is accurate to machine precision, from one complex evaluation per directional derivative. A complex evaluation costs a few real ones (a complex multiply is 4 real multiplies and 2 adds).
What the code must satisfy. must be analytic, and the code must be complex-safe:
- no
abs: it returns the modulus, a real number, so the derivative of that term is lost. Usenp.where(z.real >= 0, z, -z)instead; - comparisons and
maxonly on the real part; - no complex conjugation (
np.vdot,.conj(), conjugate transposes); - no cast to real, and complex dtypes everywhere.
The complex step is close to forward-mode automatic differentiation with dual numbers (lesson 05): dual numbers use , the complex step uses , and the difference is the term.
You apply the complex step to code that computes np.abs(x) * np.exp(x) at . What do you get?
For complex input, np.abs returns , a real number. The term survives, but the term disappears silently: you get instead of . No error is raised. Use a complex-safe abs such as np.where(z.real >= 0, z, -z).
Gradients, Jacobians and directional checks #
For , the central-difference gradient is
where is the -th unit vector: evaluations of . For , the same difference of output vectors gives column of the Jacobian.
A cheaper test uses one random direction : the directional check
two evaluations for the whole gradient. Compare it with the claimed . A few random directions catch almost every bug.
The relative error #
Compare the claimed gradient with by the relative error
Gradient sizes differ by orders of magnitude between layers. An absolute difference of is a bug when and noise when . The relative error does not depend on the scale. It lies in . (Some libraries divide by ; the result differs at most by a factor of 2.)
Thresholds for a smooth function in float64 with a central difference: is fine; is almost surely a bug; in between, look closer (kinks, scale, step).
A reliable gradient check #
- float64. In float32 () the best central error is about : too coarse to separate a small bug from rounding.
- Central differences with , about to times the scale. If you are unsure, try two or three steps: a real bug does not depend on .
- Relative error with the thresholds above.
- No kinks. ReLU,
max,abs, hinge losses, sorting and top- are not differentiable everywhere. If crosses a kink, the numerical derivative is wrong (figure below). - Determinism. Turn off dropout and data augmentation, fix seeds, put batch normalization in a fixed mode: must be the same function at every evaluation.
- The same on both sides. Include every loss term (regularization, auxiliary losses) in both and .
- A generic point. Use random inputs and weights, not zeros or a symmetric initialization, where many terms vanish and bugs hide.
- Large models. Check a random subset of coordinates or a few random directions, layer by layer, on a tiny batch.
Make the step smaller than the distance to the kink. While , the points and lie on different sides of 0, and the central secant mixes the two slopes: the check fails although the analytic derivative is correct. As soon as , the check passes. Only the ratio matters, so the axes are in units of the step (for example ).
Library defaults, as a reference. torch.autograd.gradcheck uses central differences with
eps=1e-6 and passes when every entry satisfies
with atol=1e-5 and rtol=1e-3. It expects float64 inputs. SciPy's scipy.optimize.check_grad returns the
absolute norm with forward differences; divide by the norms yourself.
The Taylor test (lesson 03) avoids a single threshold: the remainder shrinks like with a correct and only like with a wrong one.
A gradient check of a smooth loss in float64, with central differences and , gives a relative error of . What is the most likely cause?
For a smooth function in float64, a correct gradient gives a relative error near . A value of is far above the noise: a bug, unless crosses a kink. Smaller makes the rounding worse ( is on the left arm of the V), and the relative error is designed to be independent of the scale.
Answers to section 1. (1) , close to (here ). The error is about : about 8 correct digits. At truncation dominates (error ); at rounding dominates (error ). (2) No difference formula can: in float64, , so the numerator is exactly 0. The complex step can: equals to all 16 digits.
4. Worked example #
(a) Exact truncation errors. Take at : , , . For a cubic every truncation error can be written exactly.
Forward. Expand . So
The leading error term is . With : , error . With : , error , about half: first order.
Central. . So
exactly . With : , error . With : , error , a quarter: second order.
Richardson. , exact, because the error was a pure term.
Complex step. Expand with and :
So
the same size of error as the central difference, with the opposite sign (Theorem 3: ). With : . With the error is .
(b) Optimal steps. A cubic at is a poor test of rounding (float64 computes almost exactly), so take at , where .
- Forward: , with error bound .
- Central: , with error bound .
Section 5 measures at (forward) and at (central): below the bounds, as expected for worst-case bounds.
(c) A gradient check by hand. at . The true gradient is . A buggy backward pass returns : a 1% error in one entry.
is quadratic in and linear in , so central differences are exact here (the term is 0): . Then
far above : a bug. With the correct and , float64 gives .
5. In code #
NumPy and SciPy check the numbers above. The notebook has these cells and the exercises.
import numpy as np
from scipy.optimize import check_grad
eps = np.finfo(float).eps # 2.2e-16 (float64 machine epsilon)
def forward(f, x, h):
return (f(x + h) - f(x)) / h
def central(f, x, h):
return (f(x + h) - f(x - h)) / (2 * h)
def complex_step(f, x, h):
return f(x + 1j * h).imag / h
# 1. The V for f = exp at x = 1 (f'(1) = e). Columns: forward, central, complex step.
for h in (1e-2, 1e-5, 1e-8, 1e-12, 1e-20):
errs = [abs(m(np.exp, 1.0, h) - np.e) for m in (forward, central, complex_step)]
print(f"h={h:.0e} " + " ".join(f"{e:.1e}" for e in errs))
print(f"theory h*: {2 * np.sqrt(eps):.1e} (forward), {(3 * eps) ** (1 / 3):.1e} (central)")
# h=1e-02 1.4e-02 4.5e-05 4.5e-05
# h=1e-05 1.4e-05 5.9e-11 4.5e-11
# h=1e-08 6.6e-09 6.6e-09 0.0e+00
# h=1e-12 4.3e-04 2.1e-04 0.0e+00
# h=1e-20 2.7e+00 2.7e+00 0.0e+00 <- x + h == x: the difference is 0
# theory h*: 3.0e-08 (forward), 8.7e-06 (central)
# 2. A gradient check with a relative error
def numerical_gradient(f, x, h=1e-6):
g = np.zeros_like(x)
for i in range(x.size):
e = np.zeros_like(x)
e[i] = h * max(1.0, abs(x[i])) # relative step
g[i] = (f(x + e) - f(x - e)) / (2 * e[i])
return g
def rel_error(a, b):
den = max(np.linalg.norm(a), np.linalg.norm(b))
return 0.0 if den == 0 else np.linalg.norm(a - b) / den
f = lambda z: z[0] ** 2 * z[1]
grad_ok = lambda z: np.array([2 * z[0] * z[1], z[0] ** 2])
grad_bug = lambda z: np.array([2 * z[0] * z[1], 1.01 * z[0] ** 2]) # 1% error in one entry
x0 = np.array([1.0, 2.0])
g_num = numerical_gradient(f, x0)
print(f"{rel_error(grad_ok(x0), g_num):.0e} {rel_error(grad_bug(x0), g_num):.1e}") # 7e-12 2.4e-03
# 3. A kink: ReLU at 1e-7 from 0, with the step 1e-6
relu = lambda z: np.maximum(z, 0.0)
print(central(relu, 1e-7, 1e-6)) # 0.55, but the true derivative is 1
# 4. SciPy's check_grad returns an absolute error (forward differences)
print(f"{check_grad(f, grad_bug, x0):.4f}") # 0.0100
Read the output. The forward error falls like down to about , and the central error like down to about ; below these steps rounding takes over. The rounding arm is noisy (single steps can be lucky), so judge the trend, not one point. The complex step is exact to the last digit at every small .
6. Where it shows up #
- Unit tests for gradients.
torch.autograd.gradcheck(andgradgradcheckfor second derivatives) andjax.test_util.check_grads, which compares forward and reverse mode with finite differences. Every custom autograd function, fused kernel or hand-written layer should pass one, including the layers of lesson 06. - Debugging training. When a model trains badly after a change in the loss or a layer, a gradient check on a tiny batch is the fastest way to rule out a wrong gradient.
- Adjoints in science codes. Hand-written adjoints of PDE solvers and of variational data assimilation are tested with the gradient test, the ratio as , and with the Taylor test. A linear operator and its adjoint (for example seismic modelling and migration) are tested with the dot-product test for random .
- Black-box simulators. When legacy code (for example Fortran without AD) must give sensitivities, finite differences or the complex step (recompile with complex types) are the practical options. Aerospace design uses the complex step for this.
- Stencils. The same Taylor analysis gives the order of accuracy of finite-difference stencils in PDE solvers and in grid-based physics-informed residuals.
7. Common mistakes #
- float32 for the check. A correct gradient fails, or a small bug hides in the noise.
- "as small as possible". is on the rounding arm of the V. Also wrong: one fixed absolute for inputs of very different sizes; use a relative step.
- Absolute error. means nothing without .
- Crossing a kink. A failure at one sample of a ReLU network may be a kink, not a bug: move the point or use a smooth activation, then check again.
- A non-deterministic . Dropout, random augmentation, or batch normalization in training mode with a changing batch make meaningless.
- Complex step with unsafe code.
np.abs(z)returns , a real number: part of the derivative silently becomes 0. Usenp.where(z.real >= 0, z, -z)instead. - One passing point as proof. A bug term can vanish at one point; check a few random points.
8. Check yourself #
- Central difference of at with : the value, the error, and the
leading-term prediction?
Answer
, error . Prediction (in fact exactly). - In float32 () with : optimal steps and best
error bounds?
Answer
Forward: , error . Central: , error . Only 3–4 digits, which is why checks run in float64. - A claimed gradient is ; the central difference gives . Relative error
and verdict?
Answer
and , so . For a smooth function in float64 this is almost surely a bug, in the third entry. - at , central difference with : what do you get, and why?
Answer
instead of 1: crosses the kink. Use a point farther than from 0, or a smaller step for this point. - Why can the complex step use while the central difference cannot?
Answer
At , rounds to , so the central numerator is exactly 0. The complex step stores in the imaginary part without any subtraction, so its relative error stays about ; its truncation error is negligible. - Why divide by and not by alone?
Answer
It is symmetric in the two gradients, bounded (by 2), and defined when but (common with ReLUs). Only "both exactly zero" needs a rule: return 0.
Write the forward and central difference formulas and their orders.
Forward , error . Central , error .
Why is the central difference second order?
In the Taylor expansions of and , the even terms (including ) cancel when you subtract, so the error starts at .
How big is the rounding error of a finite difference with step ?
About (up to a factor 2): the subtraction cancels leading digits, and dividing by magnifies the noise.
What are the optimal steps and best errors of the forward and central differences in float64 (all scales 1)?
Forward: , error . Central: , error .
Write the complex-step formula for .
, with error .
Why can the complex step use ?
It subtracts nothing, so there is no cancellation; the imaginary part is computed to relative accuracy , and the truncation error is negligible.
Which relative error do we use in a gradient check?
, set to 0 if both are zero.
A gradient check fails at one point of a ReLU network. What should you check before you look for a bug?
Whether some ReLU input is within of 0 (a kink): then crosses the kink. Move the point or use a smooth activation and check again.
Name three rules for a reliable gradient check.
Use float64; use central differences with relative to the scale; compare with a relative error (also: deterministic , generic point, no kinks).
9. Interview angle #
- "How do you know your backward pass is correct?" — A gradient check: float64, central differences, relative to the scale, relative error with thresholds, no kinks, randomness off, a few random coordinates or directions per layer.
- "Why not train with finite differences?" — evaluations per gradient ( parameters, about for an LLM) against a small constant times one evaluation for reverse mode, and at best about accuracy.
- "Why is the central difference better, and which step do you use?" — The even Taylor terms cancel, so the error is . Balancing against gives .
- "Why does a smaller step make the result worse?" — Cancellation: and share their leading digits, the difference is mostly rounding noise, and dividing by magnifies it.
- "What is the complex step?" — : error and no cancellation, so gives machine precision; it needs analytic, complex-safe code.
10. Practice #
The notebook for this lesson runs in Colab. It has five exercises, each with a check cell and a folded solution:
forward_diff,central_diff,complex_step; then reproduce the V plot.optimal_step(method, eps, F, M): the step of Theorem 2, compared with the observed minimum of the V.numerical_gradientandnumerical_jacobianwith relative steps.gradient_check: relative error of a hand-written MLP backward pass; find the planted bug.- A kink experiment: how often a gradient check of a ReLU layer fails, as a function of .
11. Watch and read #
- Driscoll and Braun, Fundamentals of Numerical Computation: §5.4 Finite differences and §5.5 Convergence of finite differences (rounding and the optimal step).
- Nick Higham, What is the complex step approximation?
- Martins, Sturdza and Alonso, "The complex-step derivative approximation", ACM Transactions on Mathematical Software 29(3), 2003: the standard reference, with the test function used in the figure.
- CS231n, Neural Networks Part 3, section "Gradient checks": practical rules for networks.
Runs in your browser with nothing to install. The notebook repeats the key demos and has exercises with checks; solutions are inside, folded.
No Google account needed: Python runs inside your browser. The first start downloads about 40 MB; your changes are saved in this browser only.