Gradient Checking
How gradcheck.py estimates gradients with central differences, how it compares them with the hand-written backward code, what the tests assert, and how to read a failure.
gradcheck.py
This whole page is test-only mathematics. gradcheck.py is not part of the pipeline: neither training nor generation imports it. Only tests/test_gradcheck.py does.
The project has no autograd. Every backward function is written by hand, so the tests need another way to tell whether a gradient is right. They estimate each gradient from the loss alone, using only forward evaluations, and compare the two.
Central differences
What is it?
Gradient checking
numerical_grad estimates a gradient without using any backward code. For each entry of x, it raises the entry by h, evaluates f, lowers it by h, evaluates f again, and divides the change by 2h. It then puts the entry back. Only tests call it.
Why does the model need it?
This project has no autograd, so every backward function is written by hand and could be wrong. Central differences use only forward evaluations, so they give an estimate that does not depend on the backward code being checked.
Mathematics
The default step is h = 1e-5 (gradcheck.py:3). check_params has its own h argument with the same default, and the tests leave both at their defaults.
In general, a central difference is more accurate than a one-sided one, because its leading error term cancels. The error from the formula shrinks as h gets smaller, but round-off grows, because fp - fm becomes a tiny difference between two nearly equal numbers. So h should be neither too large nor too small. This is a general explanation. The repository only fixes one value, and it does not study this trade-off.
What does each symbol mean?
| Symbol | Meaning | In code | Shape |
|---|---|---|---|
| A function of x that returns one number | f | scalar | |
| The array being varied. It is changed in place and restored | x | any | |
| The step. The default is 1e-5 | h | scalar | |
| One at entry i and zero elsewhere. The code writes this by editing x[idx] directly | idx | index into x | |
| The two evaluations | fp, fm | scalar | |
| The estimated gradient | g | same as x |
Tiny numerical example
Try h = 0.1, then h = 1e-13. For x * x * x, the error grows in both directions. For x * x, the formula's error is exactly 0, so only round-off shows.
| Step | Code | Value |
|---|---|---|
| Input | x | [1.0000, -2.0000, 3.0000] |
| Analytic gradient | 2 * x | [2.0000, -4.0000, 6.0000] |
| Central difference | numerical_grad(f, x) | [2.0000, -4.0000, 6.0000] |
| f(x) | Analytic | Central difference | Relative error |
|---|---|---|---|
x * x | 2x = 3 | 3.000000000019653 | 6.55e-12 |
x * x * x | 3x^2 = 6.75 | 6.75000000014414 | 2.14e-11 |
Tensor shapes
g starts as np.zeros_like(x), so it has the shape of x, whatever that is. The only caller, test_numerical_grad_on_quadratic, passes a vector of 3 numbers. No test runs numerical_grad on a model parameter. That is the job of check_params, below.
Actual implementation
def numerical_grad(f, x, h = 1e-5): g = np.zeros_like(x) for idx in np.ndindex(*x.shape): old = x[idx] x[idx] = old + h fp = f(x) x[idx] = old - h fm = f(x) x[idx] = old g[idx] = (fp - fm) / (2 * h) return gCode and math connection
| Equation | Role | Lines | Code |
|---|---|---|---|
| Central difference for one entry | Computes it | gradcheck.py:12 | g[idx] = (fp - fm) / (2 * h) |
| Central difference for one entry | Written inline | gradcheck.py:31 | w = max(w, relative_error(grads[name][idx], (fp - fm) / (2 * h))) |
| Central difference for one entry | Tests it | tests/test_gradcheck.py:18 | g = numerical_grad(lambda v: float((v ** 2).sum()), x) # d/dx sum(x^2) = 2x |
Where is it used?
- Source
gradcheck.py, lines 3-14,numerical_grad- PDF reference
Section 4.4, Eq. 37, p. 8
- Used by
Not called by the pipeline.
Tested by:
tests.test_ gradcheck. test_ numerical_ grad_ on_ quadratic - Tests it:
tests/test_gradcheck.py, line 18 (test_numerical_grad_on_quadratic), for Central difference for one entry
- Tests it:
- Status
- Implemented
Implemented in
| Equation | Role | File | Function | Lines | GitHub |
|---|---|---|---|---|---|
| Central difference for one entry | Computes it | gradcheck.py | numerical_grad | 12 | GitHub: gradcheck.py, line 12 |
| Central difference for one entry | Written inline | gradcheck.py | check_params | 31 | GitHub: gradcheck.py, line 31 |
Relative error
What is it?
Gradient checking
relative_error compares two numbers. It divides their absolute difference by the larger of their two sizes, or by a small constant eps if that is larger still.
Why does the model need it?
In general, gradients can differ in size by many orders of magnitude, so a single absolute limit would suit some entries and not others. A relative measure allows one limit for all of them. The eps guard keeps the division defined when both numbers are 0.
Mathematics
The epsilon guard. If a and b are both 0, the denominator would be 0 without eps. With it, the result is 0 / 1e-12, which is 0. This happens in practice: at any entry the loss does not depend on, both the gradient and the central difference are exactly 0.
The guard only helps below 1e-12. If both numbers are near 0, but one is above eps, the result is close to 1. The last row of the example shows this, and it is why the test skips one kind of parameter, as described under What the tests assert.
What does each symbol mean?
| Symbol | Meaning | In code | Shape |
|---|---|---|---|
| First number. check_params passes the gradient from the backward code | a | scalar | |
| Second number. check_params passes the central difference | b | scalar | |
| The guard on the denominator. The default is 1e-12 | eps | scalar |
Tiny numerical example
| a | b | Result | Note |
|---|---|---|---|
1 | 1.0001 | 0.00009999 | An ordinary pair. |
2.5 | 2.5 | 0 | Equal numbers give 0. |
1 | -1 | 2.000 | 2 is the largest value this formula can give. |
0 | 0 | 0 | The guard keeps this at 0 rather than 0 / 0. |
1e-13 | 0 | 0.1000 | Both are below eps, so eps is the denominator. |
1e-17 | 4e-12 | 1.000 | Both are near 0, but b is above eps, so the result is close to 1. |
Tensor shapes
Both arguments are scalars, and so is the result. check_params calls it once for each sampled entry.
Actual implementation
def relative_error(a, b, eps = 1e-12): return abs(a - b) / max(abs(a), abs(b), eps)Code and math connection
| Equation | Role | Lines | Code |
|---|---|---|---|
| Relative error with the epsilon guard | Computes it | gradcheck.py:17 | return abs(a - b) / max(abs(a), abs(b), eps) |
| Relative error with the epsilon guard | Calls it | gradcheck.py:31 | w = max(w, relative_error(grads[name][idx], (fp - fm) / (2 * h))) |
Where is it used?
Its only caller is check_params, which only a test calls, so the pipeline never reaches it. No test calls it directly.
- Source
gradcheck.py, lines 16-17,relative_error- PDF reference
Section 4.4, Eq. 38, p. 8
- Used by
Called by:
gradcheck.check_ params - Calls it:
gradcheck.py, line 31 (check_params), for Relative error with the epsilon guard
- Calls it:
- Status
- Implemented
Implemented in
| Equation | Role | File | Function | Lines | GitHub |
|---|---|---|---|---|---|
| Relative error with the epsilon guard | Computes it | gradcheck.py | relative_error | 17 | GitHub: gradcheck.py, line 17 |
Checking every parameter
What is it?
Gradient checking
check_params goes through the parameter dictionary one name at a time. For each name it picks a few random entries, compares the gradient from the backward code with a central difference at each one, and keeps the worst relative error. It returns a dictionary from parameter name to worst error.
Why does the model need it?
It checks the whole forward and backward pass against the loss, for every parameter. Sampling keeps the test quick, because each sampled entry costs two evaluations of the loss. Reporting by name says which tensor disagrees.
Mathematics
For each name, the function draws samples_per_param random entries, 5 by default. Each entry takes one random integer per axis (gradcheck.py:24). It nudges that entry in place, runs the loss twice, restores the entry, and keeps the largest relative error so far.
What does each symbol mean?
| Symbol | Meaning | In code | Shape |
|---|---|---|---|
| A key of the parameter dictionary, such as pos_emb or b0.wq | name | string | |
| The parameter with that name | W | that parameter's own shape | |
| Samples for each name. The default is 5 | samples_per_param | scalar | |
| A random entry, one random integer per axis. Samples can repeat, because they are drawn with replacement | idx | tuple, one integer per axis | |
| The loss, as a function of the parameters | loss_fn | scalar | |
| The gradient from the backward code, computed once before the call | grads[name] | same as W | |
| The step. The default is 1e-5, the same as numerical_grad | h | scalar | |
| The value returned for that name. It starts at 0.0 | worst[name] | scalar |
Tiny numerical example
The example is in Reading a failure, where its columns can be compared.
Tensor shapes
grads[name] has the shape of the parameter with that name, because loss_and_grads builds grads with np.zeros_like. Each idx has one integer per axis of that shape.
W = pos_emb(Tmax, d)(32, 32)grads["pos_emb"](Tmax, d)(32, 32)
Tmax = context_len = 32. d = emb_dim = 32.
Actual implementation
def check_params(loss_fn, params, grads, rng, samples_per_param = 5, h = 1e-5): worst: dict[str, float] = {} for name, W in params.items(): w = 0.0 for _ in range(samples_per_param): idx = tuple(int(rng.integers(s)) for s in W.shape) old = W[idx] W[idx] = old + h fp = loss_fn(params) W[idx] = old - h fm = loss_fn(params) W[idx] = old w = max(w, relative_error(grads[name][idx], (fp - fm) / (2 * h))) worst[name] = w return worstCode and math connection
| Equation | Role | Lines | Code |
|---|---|---|---|
| Worst relative error for one parameter name | Computes it | gradcheck.py:31 | w = max(w, relative_error(grads[name][idx], (fp - fm) / (2 * h))) |
| Worst relative error for one parameter name | Computes it | gradcheck.py:32 | worst[name] = w |
| Worst relative error for one parameter name | Tests it | tests/test_gradcheck.py:26 | worst = check_params(loss_fn, params, grads, rng) |
Where is it used?
- Source
gradcheck.py, lines 19-33,check_params- PDF reference
Section 4.4, p. 9
- Used by
Not called by the pipeline.
Tested by:
tests.test_ gradcheck. test_ every_ tensor_ passes - Tests it:
tests/test_gradcheck.py, line 26 (test_every_tensor_passes), for Worst relative error for one parameter name
- Tests it:
- Status
- Implemented
Implemented in
| Equation | Role | File | Function | Lines | GitHub |
|---|---|---|---|---|---|
| Worst relative error for one parameter name | Computes it | gradcheck.py | check_params | 31 | GitHub: gradcheck.py, line 31 |
| Worst relative error for one parameter name | Computes it | gradcheck.py | check_params | 32 | GitHub: gradcheck.py, line 32 |
What the tests assert
tests/test_gradcheck.py builds a small model, smaller than the one in config.py, and passes check_params a function that returns only the loss:
Code listing: tests.test_gradcheck.tiny_setup
def tiny_setup(): cfg = ModelConfig(vocab_size=7, context_len=8, emb_dim=4, num_heads=2, ff_dim=8, num_layers=2) rng = np.random.default_rng(0) params = init_params(cfg, rng) # float64 ids = rng.integers(7, size=5) targets = rng.integers(7, size=5) return cfg, params, ids, targets, rngCode listing: tests.test_gradcheck.test_every_tensor_passes
def test_every_tensor_passes(): cfg, params, ids, targets, rng = tiny_setup() loss_fn = lambda p: loss_and_grads(p, ids, targets, cfg)[0] _, grads = loss_and_grads(params, ids, targets, cfg) worst = check_params(loss_fn, params, grads, rng) for name, err in worst.items(): if name.endswith(".bk"): continue # true gradient is 0, checked below assert err < 1e-4, (name, err)The file has three limits, and these are all of them:
| Test | Compares | Limit |
|---|---|---|
test_every_tensor_passes | Worst relative error for every name except those ending .bk | below 1e-4 |
test_key_bias_gradient_is_zero | Largest absolute value in the gradient of each b{l}.bk | below 1e-12 |
test_numerical_grad_on_quadratic | numerical_grad of a sum of squares against 2 * x | atol=1e-8 (np.allclose) |
The last one also applies the default relative tolerance of np.allclose.
Why .bk is skipped. The key bias adds the same amount to every score in a row, and softmax ignores that, so its true gradient is 0. Both the backward code and the central difference then give only round-off, and as the Relative error section shows, the ratio of two such numbers says nothing useful. So a separate test checks the backward value directly:
assert np.abs(grads[f"b{l}.bk"]).max() < 1e-12Limits of these checks.
- Each name gets only 5 random entries, so a mistake in a single entry of a large tensor can go unnoticed.
- The test runs one setup with one seed, on the tiny model, not at the sizes in
config.py. - The test
test_numerical_grad_on_quadraticchecksnumerical_graditself.check_paramsrepeats the same formula in its own code and does not callnumerical_grad.
Reading a failure
A failed assertion prints the name and the error, because of the second part of assert err < 1e-4, (name, err). The name is a key of the parameter dictionary, and it tells you which tensor to look at:
| Name in the report | What it is | Where its gradient is filled |
|---|---|---|
tok_emb | Token table | transformer.py:56 |
pos_emb | Position table | transformer.py:57 |
b{l}.ln1_g, b{l}.ln1_b | First LayerNorm of block l | transformer.py:150 |
b{l}.wq, b{l}.bq, b{l}.wk, b{l}.bk, b{l}.wv, b{l}.bv | Query, key and value of block l | transformer.py:106-108 |
b{l}.wo, b{l}.bo | Attention output of block l | transformer.py:95-97 |
b{l}.ln2_g, b{l}.ln2_b | Second LayerNorm of block l | transformer.py:145 |
b{l}.ff1_w, b{l}.ff1_b | First feed-forward layer | transformer.py:125 |
b{l}.ff2_w, b{l}.ff2_b | Second feed-forward layer | transformer.py:120 |
lnf_g, lnf_b | Final LayerNorm | transformer.py:183 |
head_w, head_b | Head | transformer.py:178 |
The pattern of names says more than one name does. Backward runs from the head to the embeddings, so a wrong gradient that is passed along affects everything computed after it:
- One name fails. Look at the line that fills that tensor. In the second column below, only
b1.wqis above the limit. - Many names fail together. Go through the names in backward order, and look between the last one that passes and the first one that fails. In the third column, the value
block_backwardreturns for block 1 is wrong. Block 1 still passes, because its gradients are filled before it returns. Block 0 and both embedding tables fail.
check_params on the hand-picked tiny model of tiny_setup, with 38 names and 380 loss evaluations per column. This is not the trained model. The second and third columns scale a gradient by 1.01 on purpose, in the script that makes this table only. Values far below the limit are round-off and can differ between machines. The test skips the rows marked .bk.| Name | Correct backward | One tensor wrong: grads["b1.wq"] | Block output wrong: what block 1 returns |
|---|---|---|---|
tok_emb | 1.1e-8 | 1.1e-8 | 9.9e-3 above |
pos_emb | 2.2e-9 | 2.2e-9 | 9.9e-3 above |
b0.ln1_g | 5.0e-10 | 5.0e-10 | 9.9e-3 above |
b0.ln1_b | 1.1e-9 | 1.1e-9 | 9.9e-3 above |
b0.wq | 3.9e-8 | 3.9e-8 | 9.9e-3 above |
b0.bq | 4.2e-10 | 4.2e-10 | 9.9e-3 above |
b0.wk | 5.5e-10 | 5.5e-10 | 9.9e-3 above |
b0.bk (skipped) | 1.0e+0 | 1.0e+0 | 1.0e+0 |
b0.wv | 6.1e-9 | 6.1e-9 | 9.9e-3 above |
b0.bv | 9.5e-10 | 9.5e-10 | 9.9e-3 above |
b0.wo | 9.1e-10 | 9.1e-10 | 9.9e-3 above |
b0.bo | 1.8e-9 | 1.8e-9 | 9.9e-3 above |
b0.ln2_g | 6.4e-10 | 6.4e-10 | 9.9e-3 above |
b0.ln2_b | 5.8e-10 | 5.8e-10 | 9.9e-3 above |
b0.ff1_w | 1.1e-8 | 1.1e-8 | 9.9e-3 above |
b0.ff1_b | 1.0e-9 | 1.0e-9 | 9.9e-3 above |
b0.ff2_w | 3.5e-9 | 3.5e-9 | 9.9e-3 above |
b0.ff2_b | 9.2e-10 | 9.2e-10 | 9.9e-3 above |
b1.ln1_g | 1.6e-9 | 1.6e-9 | 1.6e-9 |
b1.ln1_b | 5.0e-10 | 5.0e-10 | 5.0e-10 |
b1.wq | 1.9e-8 | 9.9e-3 above | 1.9e-8 |
b1.bq | 3.8e-9 | 3.8e-9 | 3.8e-9 |
b1.wk | 1.3e-8 | 1.3e-8 | 1.3e-8 |
b1.bk (skipped) | 2.8e-5 | 2.8e-5 | 2.8e-5 |
b1.wv | 4.5e-10 | 4.5e-10 | 4.5e-10 |
b1.bv | 5.1e-10 | 5.1e-10 | 5.1e-10 |
b1.wo | 5.3e-9 | 5.3e-9 | 5.3e-9 |
b1.bo | 7.0e-10 | 7.0e-10 | 7.0e-10 |
b1.ln2_g | 2.7e-9 | 2.7e-9 | 2.7e-9 |
b1.ln2_b | 9.6e-8 | 9.6e-8 | 9.6e-8 |
b1.ff1_w | 1.3e-9 | 1.3e-9 | 1.3e-9 |
b1.ff1_b | 6.8e-10 | 6.8e-10 | 6.8e-10 |
b1.ff2_w | 5.8e-9 | 5.8e-9 | 5.8e-9 |
b1.ff2_b | 1.9e-8 | 1.9e-8 | 1.9e-8 |
lnf_g | 2.9e-10 | 2.9e-10 | 2.9e-10 |
lnf_b | 2.4e-10 | 2.4e-10 | 2.4e-10 |
head_w | 7.5e-10 | 7.5e-10 | 7.5e-10 |
head_b | 5.6e-10 | 5.6e-10 | 5.6e-10 |
The .bk rows hold round-off, as explained above, so their values are not a failure.
The assertion stops at the first failure. test_every_tensor_passes loops over worst in the order init_params created the names, and the first failure ends the test. With the third column, the report names only tok_emb, the first name in the dictionary, even though the mistake is elsewhere. To see the pattern, print the whole dictionary that check_params returns.
A check can only point to where a gradient disagrees with the loss. It cannot say whether the mistake is in the forward or the backward code, because the loss comes from the forward code, and the check treats it as correct.