Skip to content

Partial derivatives, gradients, gradcheck

ModuleM04.1 · build · Python · Pass 2 · 2 to 3 h
You buildpython/tinyllm/num/gradcheck.py: numerical_grad, gradcheck, and its GradcheckReport
Contractcourse/contracts/py/tinyllm/num/gradcheck.pyi
Testscourse/tests/M04.1/test_gradcheck.py (what they check: section 4)
NeedsM01.1 central_diff, one call per coordinate (or --ref-deps)
Used byM04.2 builds vjp_numeric on numerical_grad · later L0.2’s F.gradcheck_all() runs every op of your autograd library through gradcheck, behind {tinyllm} gradcheck --suite all in MS-L0
MilestoneMS-P2 (the Pass 2 gate)
Optional depthOpenStax, Calculus Volume 3 (free), sections 4.3 and 4.6 (partial derivatives, the gradient); Nocedal and Wright, Numerical Optimization, section 8.1 (finite-difference gradients); the PyTorch autograd notes on gradcheck
  • A partial derivative ∂f/∂xi\partial f / \partial x_i is an ordinary derivative along one coordinate with every other coordinate frozen; the gradient ∇f\nabla f collects one per input element, in the input’s shape (test_hand_example, test_matrix_inputs_and_several_inputs).
  • A numerical gradient is one central difference per element, 2n2n evaluations of ff for nn elements, exact on quadratics (test_exact_on_quadratics).
  • An element passes when ∣a−n∣≤atol+rtol ∣n∣|a - n| \le \mathrm{atol} + \mathrm{rtol}\,|n|: absolute near zero, relative for large gradients (test_tolerance_is_atol_plus_rtol).
  • gradcheck must reject the bugs backward passes really have (a transposed gradient, a factor of 2, one forgotten element) and point at the element furthest past its tolerance (test_rejects_transposed_gradient, test_rejects_one_zeroed_coordinate_and_locates_it, test_worst_element_is_the_most_out_of_tolerance).
  • It perturbs float64 copies, so the caller’s parameters are untouched and integer or float32 inputs still get exact steps (test_inputs_are_left_unchanged, test_integer_and_float32_inputs_are_promoted).
Terminal window
ol start M04.1 # stubs python/tinyllm/num/gradcheck.py into your repo
ol tests M04.1 # read the test catalog first: rung R0, you write no tests here
ol check M04.1 # exit code is the verdict
ol check M04.1 --ref-deps # only if your M01.1 is not passing yet
ol diff M04.1 # after passing: your code against the reference

L0.1 and L0.2 are next: an autograd engine and a library of about 40 differentiable ops, each with a hand-written backward rule. A backward rule is a claim, “this array is the gradient of the loss with respect to that input”, and claims need a referee. The referee is the definition of the derivative, applied one coordinate at a time: perturb one weight by ±ϵ\pm\epsilon, rerun the forward pass, divide. Your M01.1 gives the one-coordinate version; this module turns it into the gradient of a scalar function of several arrays and into the check that compares it with an analytic gradient. MS-L0 runs {tinyllm} gradcheck --suite all through your library, so every op you write in Pass 2 passes through this file. (The course’s own tests use a frozen copy, course/tests/_lib/gradcheck.py, so a buggy gradcheck here can fail only this module, never pass a broken op elsewhere: design decision D35.)

SymbolMeaningType / shape
f(X1,…,Xm)f(X_1, \ldots, X_m)a scalar function of mm arrays (a loss)Callable[..., float]
XkX_kthe kk-th input array; Xk[i]X_k[i] one element, ii a multi-indexfloat64[...]
∂f∂Xk[i]\frac{\partial f}{\partial X_k[i]}the partial derivative with respect to that elementfloat
∇Xkf\nabla_{X_k} fthe gradient with respect to XkX_k, same shape as XkX_kfloat64[...]
ϵ\epsilonthe finite-difference step (eps), default 10−610^{-6}float
aa, nnone element of the analytic and of the numerical gradientfloat
rtol, atolrelative and absolute tolerances, defaults 10−510^{-5} and 10−710^{-7}float
eie_ithe array that is 1 at position ii and 0 elsewheresame shape as XkX_k

For a function of two numbers, f(x,y)=x2y+3yf(x, y) = x^2 y + 3y, freeze yy and differentiate in xx: ∂f∂x=2xy\frac{\partial f}{\partial x} = 2xy. Freeze xx and differentiate in yy: ∂f∂y=x2+3\frac{\partial f}{\partial y} = x^2 + 3. Formally,

∂f∂x(x,y)=lim⁡h→0f(x+h,y)−f(x,y)h,\frac{\partial f}{\partial x}(x, y) = \lim_{h \to 0}\frac{f(x + h, y) - f(x, y)}{h},

the one-variable derivative of M01.1 along the xx direction. A partial derivative measures how ff responds to one input when nothing else moves, which is exactly the question a training step asks of each weight.

The gradient lists every partial derivative: ∇f(x,y)=(2xy,  x2+3)\nabla f(x, y) = (2xy,\; x^2 + 3). For a function of arrays there is one partial derivative per element, and the gradient with respect to XkX_k is arranged in XkX_k‘s shape, so that a training step is simply Xk←Xk−η ∇XkfX_k \leftarrow X_k - \eta\,\nabla_{X_k} f. Two facts make the gradient the right object. First, for a small change Δ\Delta in the inputs, ff changes by about ∑k,i∂f∂Xk[i] Δk[i]\sum_{k,i} \frac{\partial f}{\partial X_k[i]}\,\Delta_k[i], the dot product of the gradient with Δ\Delta (a first-order Taylor expansion, M02.1). Second, among all directions of length 1 that dot product is largest along the gradient, so −∇f-\nabla f is the direction of steepest descent (M10.1). A gradient is only defined for a scalar ff; the derivative of a vector-valued function is a matrix, the Jacobian of M04.2.

For each input kk and each element ii:

nk[i]=f(…,Xk+ϵei,…)−f(…,Xk−ϵei,…)2ϵ,n_k[i] = \frac{f(\ldots, X_k + \epsilon e_i, \ldots) - f(\ldots, X_k - \epsilon e_i, \ldots)}{2\epsilon},

which is central_diff applied to the one-variable function t↦f(…,Xk with element i set to t,…)t \mapsto f(\ldots, X_k \text{ with element } i \text{ set to } t, \ldots) at t=Xk[i]t = X_k[i], with step ϵ\epsilon. That is how numerical_grad is written: it never re-derives the formula, it calls your M01.1. The error analysis carries over: truncation 16∣f′′′∣ϵ2\frac{1}{6}|f'''|\epsilon^2, rounding about ε∣f∣/ϵ\varepsilon |f| / \epsilon. With ϵ=10−6\epsilon = 10^{-6} and values of size 1, both are near 10−1010^{-10}, comfortably under the tolerances below. On a quadratic, f(x)=12x⊤Ax+b⊤xf(x) = \frac12 x^\top A x + b^\top x with gradient Ax+bAx + b, the third derivative is zero and the numerical gradient is exact up to rounding.

Three details decide whether this is trustworthy. Copies: perturb float64 copies of the inputs, never the caller’s arrays (they are the model’s parameters). Restore: put each element back before moving to the next, or every later partial derivative is taken at the wrong point. Promote: an integer array cannot hold x+10−6x + 10^{-6} (it rounds back to xx, and the gradient comes out 0), and float32 moves xx only in steps of about 6×10−8∣x∣6 \times 10^{-8}|x|, so the step actually taken is not ϵ\epsilon. The cost is 2n2n evaluations of ff for nn input elements: fine for tests on small inputs, hopeless for training, which is why backpropagation exists.

Compare elementwise, with a mixed test:

∣a−n∣≤atol+rtol ∣n∣.|a - n| \le \mathrm{atol} + \mathrm{rtol}\,|n| .

Near zero the absolute term dominates (10−710^{-7}: a numerical gradient of 10−910^{-9} is indistinguishable from 0); for large gradients the relative term does (10−5∣n∣10^{-5}|n|: at n=100n = 100 the allowance is 10−310^{-3}). A pure relative test would reject a correct analytic 0 against a numerical 10−1110^{-11}; a pure absolute test would accept a gradient of 100 that is off by 0.5 percent at a loose atol, or reject a correct one at a tight one. A nan in either gradient must fail: every comparison with nan is false, so diff > tol would let it pass; test not (diff <= tol) instead.

The report says ok, the largest absolute error, the largest relative error ∣a−n∣/max⁡(∣n∣,atol)|a - n| / \max(|n|, \mathrm{atol}), and where to look: the input and multi-index of the element whose error is the largest multiple of its own allowance. That is not the largest absolute error: an error of 5×10−35 \times 10^{-3} on a gradient of 1000 is within tolerance, an error of 2×10−42 \times 10^{-4} on a gradient of 10−310^{-3} is a bug.

f(x)=x02x1+3x1f(x) = x_0^2 x_1 + 3 x_1 at x=(1,2)x = (1, 2), with the large step ϵ=0.1\epsilon = 0.1 so you can do the arithmetic. The gradient is (2x0x1,x02+3)=(4,4)(2 x_0 x_1, x_0^2 + 3) = (4, 4).

Elementf(x+ϵei)f(x + \epsilon e_i)f(x−ϵei)f(x - \epsilon e_i)quotientanalytic
x0x_0f(1.1,2)=1.21⋅2+6=8.42f(1.1, 2) = 1.21 \cdot 2 + 6 = 8.42f(0.9,2)=0.81⋅2+6=7.62f(0.9, 2) = 0.81 \cdot 2 + 6 = 7.620.8/0.2=40.8 / 0.2 = 44
x1x_1f(1,2.1)=2.1+6.3=8.4f(1, 2.1) = 2.1 + 6.3 = 8.4f(1,1.9)=1.9+5.7=7.6f(1, 1.9) = 1.9 + 5.7 = 7.60.8/0.2=40.8 / 0.2 = 44

Both are exact even at ϵ=0.1\epsilon = 0.1: ff is quadratic in x0x_0 and linear in x1x_1, so the central difference has no truncation error (section 2.3). Note the restore: the x1x_1 row is evaluated at x0=1x_0 = 1, not at the 0.9 left over from the row before. A backward pass that returned (2,4)(2, 4) (a factor of 2 lost on x0x_0) fails at element 0 with ∣2−4∣=2>10−7+10−5⋅4|2 - 4| = 2 > 10^{-7} + 10^{-5} \cdot 4. This is test_hand_example.

@dataclass
class GradcheckReport: ok: bool; max_abs_err: float; max_rel_err: float; worst_input: int; worst_index: tuple
def numerical_grad(f, inputs: list[ArrayLike], eps: float = 1e-6) -> list[NDArray]: ...
def gradcheck(f, inputs: list[ArrayLike], analytic: list[ArrayLike],
eps: float = 1e-6, rtol: float = 1e-5, atol: float = 1e-7) -> GradcheckReport: ...

numerical_grad returns one float64 array per input, in its shape. gradcheck raises ValueError for mismatched lists or shapes (a bug in the caller, not a numeric disagreement), and reports everything else. f must return a single number.

TestKINDChecksWhy it matters downstream
test_hand_exampleunit, smokesection 3: (4,4)(4, 4) at ϵ=0.1\epsilon = 0.1, okyou and the tests agree on the definition
test_exact_on_quadraticspropertynumerical gradient of 12x⊤Ax+b⊤x\frac12 x^\top A x + b^\top x equals Ax+bAx + b to 10−810^{-8}the design’s property test
test_matrix_inputs_and_several_inputsgolden∇\nabla of sum(tanh⁡(WX))\mathrm{sum}(\tanh(WX)) is GX⊤G X^\top and W⊤GW^\top Glayers have weights and inputs
test_rejects_transposed_gradientunit, smokea transposed weight gradient is not ok, worst input 0the most common backward bug
test_rejects_gradient_off_by_twounitxx instead of 2x2x is rejected, worst at the largest element, relative error 0.5a lost factor keeps every sign right
test_rejects_one_zeroed_coordinate_and_locates_itunitone zeroed element of the second input is found at (2,1)(2, 1)the report says where to look
test_tolerance_is_atol_plus_rtolboundarythe mixed test at gradients 10−310^{-3} and 100both regimes of section 2.4
test_worst_element_is_the_most_out_of_toleranceunitthe worst element is the one furthest past its allowancenot the largest absolute error
test_nan_gradient_failsboundarya nan analytic gradient is not oknan comparisons are always false
test_inputs_are_left_unchangedunitthe caller’s arrays come back bit for bitthey are the model’s parameters
test_integer_and_float32_inputs_are_promotedboundaryint and float32 inputs give exact gradients; ff sees float64steps survive the dtype
test_default_eps_and_tolerancesunitdefault step 10−610^{-6} and toleranceswhat L0.2 relies on
test_rejects_mismatched_argumentsboundarywrong counts and shapes raise ValueErrorcaller bugs are not numeric errors
test_rejects_non_scalar_fboundarya vector-valued ff raises; a 1-element array is finegradients are for scalars
PitfallSymptomCaught by
1. combining the per-element verdicts wrongly (all instead of any)a transposed or partly zeroed gradient is reported oktest_rejects_transposed_gradient, test_rejects_one_zeroed_coordinate_and_locates_it (mutant s05)
2. not restoring an element before the next oneevery later partial derivative is taken at a shifted pointtest_hand_example (mutant s03)
2b. perturbing the caller’s arrays in placethe model’s weights change during a checktest_inputs_are_left_unchanged (mutant s09)
3. perturbing in the caller’s dtypegradient 0 for int inputs; two digits for float32test_integer_and_float32_inputs_are_promoted (mutant s10)
4. diff > tol as the failure testa nan gradient passestest_nan_gradient_fails (mutant s08)
a one-sided difference per coordinateerrors near 10−610^{-6} instead of 10−1010^{-10}; quadratics no longer exacttest_exact_on_quadratics (mutant s01)
dividing by 4ϵ4\epsilon (the ϵ\epsilon versus 2ϵ2\epsilon confusion)every numerical gradient halvedtest_hand_example (mutant s02)
differentiating or comparing only the first inputthe input gradient is never checkedtest_matrix_inputs_and_several_inputs (mutant s04), test_rejects_one_zeroed_coordinate_and_locates_it (mutant s06)
reporting the largest absolute error as the worstthe report points at a correct large gradienttest_worst_element_is_the_most_out_of_tolerance (mutant s07)
DirectionModuleHow it uses this
BackM01.1numerical_grad calls central_diff(along, x_i, eps) for every element
ForwardM04.2vjp_numeric(f, x, u) is numerical_grad of the scalar u⋅f(x)u \cdot f(x)
ForwardL0.2F.gradcheck_all() checks every op’s backward with your gradcheck, behind {tinyllm} gradcheck --suite all in MS-L0
ForwardM10.1gradient descent steps along −∇f-\nabla f
ForwardL4.1 to L7.9from rung R5 your own tests gradcheck every backward you write (DESIGN 5.12)

L0.2 joins used_by when it is authored (course/DEVIATIONS.md row B31-02).

Your pieceProduction equivalentWhat it addsWhere to look
gradcheckPyTorch torch.autograd.gradcheck and gradgradcheckcomplex inputs, sparse and batched layouts, second derivatives, a fast mode that checks random projections u⊤Jvu^\top J v instead of every elementtorch/autograd/gradcheck.py
numerical_gradJAX jax.test_util.check_gradschecks forward and reverse mode up to a chosen orderjax/_src/public_test_util.py
atol + rtol * abs(n)numpy.isclosethe same mixed test (and the same asymmetry: relative to the second argument)numpy/_core/numeric.py
element-by-element checksdirectional checksone u⊤Jvu^\top J v per random pair costs 2 evaluations instead of 2n2n; M04.2 builds the piecesPyTorch fast_mode=True