Example: numerical validation of \(d(X^2) = X\,(dX) + (dX)\,X\)
In the previous page we derived the differential of matrix-valued matrix functions symbolically. That derivation produced formulas such as \[ \begin{align*} d(X^3) &= (dX)X^2 + X(dX)X + X^2(dX), \\\\ d(X^{-1}) &= -X^{-1}(dX)X^{-1}. \end{align*} \]
Symbolic derivations like these are short but error-prone. Miss a term or flip a factor, and the whole downstream optimization is silently wrong. A basic hygiene step of computational mathematics is therefore to validate each derivation numerically before trusting it.
In this page we work through the simpler case \(f(X) = X^2\), for which, expanding \((X + dX)^2 - X^2\) as in the cubic case, the differential is \[ df = f'(X)[dX] = X(dX) + (dX)X, \] and we verify it against finite differences.
Finite differences
For a differentiable function \(f\), the differential \(df\) at \(X\) (matrices being identified with the vectors of their entries) is the linear part of the increment, that is, \(df = f'(X)[dX]\) for the unique linear map \(f'(X)\) that satisfies \[ f(X + dX) - f(X) = df + o(\|dX\|). \]
Two standard finite-difference approximations of \(df\) are \[ \begin{align*} \text{Forward difference:} &\quad f(X + dX) - f(X), \\\\ \text{Backward difference:} &\quad f(X) - f(X - dX). \end{align*} \] Each equals \(df\) up to an \(o(\|dX\|)\) term. So if \(dX = tV\) for a fixed direction \(V\) with \(f'(X)[V] \neq 0\), the difference from \(df\), divided by \(\|df\|\), tends to \(0\) as \(t \to 0\).
When comparing a computed approximation against a reference value, we measure quality by relative error in a norm (we use the Frobenius norm throughout): \[ \text{relative error} = \frac{\|\,\text{true} - \text{approx}\,\|_F}{\|\,\text{true}\,\|_F}. \] A small absolute difference does not necessarily mean a small error, since scale matters. Relative error normalizes by the size of the quantity being approximated.
Choosing the perturbation scale \(\|dX\|\)
Taking \(dX\) ever smaller does not make a finite-difference approximation ever more accurate. Two error sources compete:
- Truncation error from the \(o(\|dX\|)\) remainder. For \(C^2\) functions the remainder is \(O(\|dX\|^2)\) (by the Taylor error bound with \(k = 1\), applied to each entry of \(f\) on a closed ball around \(X\)), and its relative size (against \(\|df\| = \Theta(\|dX\|)\) along a fixed direction as above) is \(O(\|dX\|)\). The \(C^2\) assumption covers \(f(X) = X^2\) here, whose remainder is exactly \((dX)^2\).
- Rounding error from subtracting two nearly equal floating-point entries in \(f(X + dX) - f(X)\). Its relative size is \(O(\varepsilon_{\text{mach}} / \|dX\|)\), a consequence of the floating-point rounding model that we take as given, where \(\varepsilon_{\text{mach}} \approx 2.2\times 10^{-16}\) for IEEE 754 double precision.
Balancing the two (minimizing \(\|dX\| + \varepsilon_{\text{mach}}/\|dX\|\)) gives optimal \(\|dX\| \approx \sqrt{\varepsilon_{\text{mach}}} \approx 1.5 \times 10^{-8}\) for one-sided differences, with corresponding best achievable relative error \(\approx \sqrt{\varepsilon_{\text{mach}}}\). This is why we set the perturbation scale to \(10^{-8}\) in the code below (relative to \(\|X\|\), which is of order one for the test matrices used there). Any smaller and rounding dominates. Any larger and the quadratic truncation term does.
Good practice for test inputs
Draw test matrices from a continuous distribution (here, np.random.randn) rather than
hand-picking "nice" integer-valued matrices. Integer matrices can accidentally satisfy identities
(commuting \(X\) and \(dX\), exact zero entries, singular matrices) that mask bugs in the
general-position code path. For Gaussian entries, as drawn here, each of these coincidences occurs
with probability zero.
Implementation and run
import numpy as np
# Random test matrix X in R^{2x2}
X = np.random.randn(2, 2)
# Perturbation dX at the sqrt(eps_machine) scale
dX = np.random.randn(2, 2) * 1e-8
# f(X) = X^2
f_X = X @ X
# Forward difference: f(X + dX) - f(X)
f_approx = (X + dX) @ (X + dX) - f_X
# Backward difference: f(X) - f(X - dX)
b_approx = f_X - (X - dX) @ (X - dX)
# Analytical differential: df = X dX + dX X
exact = X @ dX + dX @ X
# Relative errors in Frobenius norm
f_rel_err = np.linalg.norm(f_approx - exact, 'fro') / np.linalg.norm(exact, 'fro')
b_rel_err = np.linalg.norm(b_approx - exact, 'fro') / np.linalg.norm(exact, 'fro')
print(f"Input matrix X:\n{X}\n")
print(f"Perturbation dX:\n{dX}\n")
print(f"Forward difference f(X + dX) - f(X):\n{f_approx}\n")
print(f"Backward difference f(X) - f(X - dX):\n{b_approx}\n")
print(f"Analytical df = X dX + dX X:\n{exact}\n")
print(f"Relative error (forward) : {f_rel_err:.2e}")
print(f"Relative error (backward): {b_rel_err:.2e}")
Typical output shows relative errors around \(10^{-8}\). That size is consistent with the optimal accuracy predicted by the balance of truncation and rounding errors discussed above. If one of these errors suddenly spiked to \(10^{-2}\) or larger, that would most likely point to one of three causes. The analytical formula may contain a bug, the perturbation scale may be inappropriate, or the direction \(dX\) may be unlucky, with \(df\) nearly vanishing along it. Rerunning with a fresh random \(dX\) separates the unlucky direction from the other two causes. If the spike persists at a sound scale, the validation has most likely caught a bug in the formula.
Why numerical validation matters
Machine-learning frameworks compute gradients with automatic differentiation, not by hand. Suppose, however, that one writes a custom operator, such as a new layer type, a new loss function, or a physics-informed constraint. The framework's autodiff covers it only if it is built from the framework's registered primitives. A custom operator outside those primitives (external code, a hand-written kernel) usually needs a hand-written derivative, and a hand-written derivative needs finite-difference validation before it goes into training.
The same logic applies to gradient checks in scientific-computing libraries and to the testing of hand-written compute kernels. The technique on this page is to perturb the input, compare against a finite difference, and measure the relative error. That recipe is the idea behind these checks. A common refinement takes the average of the forward and backward differences (the central difference), whose relative truncation error is \(O(\|dX\|^2)\) for \(C^3\) functions. The step that balances this truncation error against rounding then scales like \(\varepsilon_{\text{mach}}^{1/3}\) rather than \(\sqrt{\varepsilon_{\text{mach}}}\).