Skip to content

Derivative as a limit, finite differences, step-size choice

ModuleM01.1 · build · Python · Pass 2 · 2 to 3 h
You buildpython/tinyllm/num/diff.py: forward_diff, central_diff, richardson
Contractcourse/contracts/py/tinyllm/num/diff.pyi
Testscourse/tests/M01.1/test_diff.py (what they check: section 4)
Needsnothing to call. Reading: M00.1 (exponents and logs), M00.4 (polynomials)
Used byM04.1 builds numerical_grad (the heart of gradcheck) from one central_diff per coordinate · M01.2 differentiates with central_diff when you give Newton no derivative · later M02.1 and M09.3 reuse the error analysis of section 2
MilestoneMS-P2 (the Pass 2 gate)
Optional depthOpenStax, Calculus Volume 1 (free), sections 2.2 and 3.1 to 3.2 (limits and the derivative); Sauer, Numerical Analysis, section 5.1 (numerical differentiation and its rounding error); Nocedal and Wright, Numerical Optimization, section 8.1 (finite-difference gradients)
  • The derivative f′(x)=lim⁡h→0f(x+h)−f(x)hf'(x) = \lim_{h \to 0} \frac{f(x+h) - f(x)}{h} is a limit, and a computer evaluates the quotient at one h>0h > 0: the result is an approximation whose error you can predict (test_hand_example).
  • The forward difference has truncation error proportional to hh; the central difference f(x+h)−f(x−h)2h\frac{f(x+h) - f(x-h)}{2h} cancels the hh term and has error proportional to h2h^2 (test_forward_error_slope_is_one, test_central_error_slope_is_two).
  • Rounding adds an error of about ε∣f∣/h\varepsilon |f| / h that grows as hh shrinks, so the best step balances the two: h≈εh \approx \sqrt{\varepsilon} for forward, h≈ε3h \approx \sqrt[3]{\varepsilon} for central, times max⁡(1,∣x∣)\max(1, |x|) (test_default_step_is_accurate, test_default_step_scales_with_x).
  • Divide by the step actually taken, (x+h)−(x−h)(x+h) - (x-h), not by 2h2h: x+hx + h is rounded (test_divides_by_the_step_actually_taken).
  • Richardson extrapolation combines central differences at h,h/2,h/4,…h, h/2, h/4, \ldots to cancel h2,h4,…h^2, h^4, \ldots in turn (test_richardson_levels_raise_the_order).
Terminal window
ol start M01.1 # stubs python/tinyllm/num/diff.py into your repo
ol tests M01.1 # read the test catalog first: rung R0, you write no tests here
ol check M01.1 # exit code is the verdict
ol diff M01.1 # after passing: your code against the reference

Pass 2 replaces the tracer’s counted bigram with a model trained by gradient descent, and gradient descent needs derivatives of a loss with respect to every weight. In L0.1 and L0.2 you will write an autograd engine that computes those derivatives by the chain rule, and every backward rule you write there can be wrong in ways that still train a little: a transposed gradient, a missing factor of 2, a sign. The only independent check is the definition of the derivative itself, evaluated numerically: nudge one input, watch the output move, divide. That check is gradcheck (M04.1), and it is built on the function you write here. It is only as trustworthy as its step size: too large and the formula is inaccurate, too small and floating-point rounding swamps the answer. This module derives the right step from first principles and implements the three difference formulas the rest of the course uses.

SymbolMeaningType / shape
ffa function of one real variable, evaluated in float64Callable[[float], float]
xxthe point where we want the derivativefloat
hhthe step, h>0h > 0float
f′(x)f'(x)the derivative of ff at xxfloat
D+(h)D_+(h)forward difference f(x+h)−f(x)h\frac{f(x+h) - f(x)}{h}float
D0(h)D_0(h)central difference f(x+h)−f(x−h)2h\frac{f(x+h) - f(x-h)}{2h}float
O(hp)O(h^p)“at most a constant times hph^p” as h→0h \to 0
ε\varepsilonfloat64 machine epsilon, 2−52≈2.2×10−162^{-52} \approx 2.2 \times 10^{-16}: the gap between 1 and the next float
Rk(h)R_k(h)Richardson value after kk levelsfloat

The slope of the straight line through (x,f(x))(x, f(x)) and (x+h,f(x+h))(x + h, f(x + h)) is the difference quotient f(x+h)−f(x)h\frac{f(x+h) - f(x)}{h}: rise over run. As hh shrinks the second point slides toward the first, and if the slopes settle on one number, that number is the derivative f′(x)f'(x), the slope of the tangent line:

f′(x)=lim⁡h→0f(x+h)−f(x)h.f'(x) = \lim_{h \to 0} \frac{f(x+h) - f(x)}{h} .

“Settles on” has a precise meaning (the limit): for every tolerance you name, there is a step size below which every quotient is within that tolerance of f′(x)f'(x). For f(x)=x2f(x) = x^2: (x+h)2−x2h=2xh+h2h=2x+h\frac{(x+h)^2 - x^2}{h} = \frac{2xh + h^2}{h} = 2x + h, which tends to 2x2x. The usual rules follow from the definition the same way: (xn)′=nxn−1(x^n)' = n x^{n-1}, (ex)′=ex(e^x)' = e^x (the property that defines ee, M00.1), (sin⁡x)′=cos⁡x(\sin x)' = \cos x, and the chain rule (f(g(x)))′=f′(g(x)) g′(x)(f(g(x)))' = f'(g(x))\, g'(x), which M04.2 generalizes and every backward pass applies.

A program picks one hh and computes one quotient. Two questions follow: how wrong is the quotient for a given hh, and which hh makes it least wrong? Both answers come from comparing ff near xx with a polynomial. If ff is smooth (has enough continuous derivatives), then for small hh

f(x+h)=f(x)+f′(x) h+12f′′(x) h2+16f′′′(x) h3+⋯f(x + h) = f(x) + f'(x)\, h + \tfrac{1}{2} f''(x)\, h^2 + \tfrac{1}{6} f'''(x)\, h^3 + \cdots

This is Taylor’s formula; M02.1 proves it and bounds the dots. Here you only need the first few terms, which you can check on x3x^3: (x+h)3=x3+3x2h+3xh2+h3(x+h)^3 = x^3 + 3x^2 h + 3x h^2 + h^3, and indeed f′=3x2f' = 3x^2, 12f′′=3x\frac{1}{2} f'' = 3x, 16f′′′=1\frac{1}{6} f''' = 1.

Subtract f(x)f(x) and divide by hh:

D+(h)=f(x+h)−f(x)h=f′(x)+12f′′(x) h+O(h2).D_+(h) = \frac{f(x+h) - f(x)}{h} = f'(x) + \tfrac{1}{2} f''(x)\, h + O(h^2) .

The forward difference is off by about 12f′′(x)h\frac{1}{2} f''(x) h: first order, error proportional to hh. Halve hh and the error halves. Now write the expansion at −h-h as well, f(x−h)=f(x)−f′(x) h+12f′′(x) h2−16f′′′(x) h3+⋯f(x - h) = f(x) - f'(x)\, h + \frac{1}{2} f''(x)\, h^2 - \frac{1}{6} f'''(x)\, h^3 + \cdots, and subtract it from the one at +h+h. The even powers cancel:

D0(h)=f(x+h)−f(x−h)2h=f′(x)+16f′′′(x) h2+O(h4).D_0(h) = \frac{f(x+h) - f(x-h)}{2h} = f'(x) + \tfrac{1}{6} f'''(x)\, h^2 + O(h^4) .

The central difference is second order: halve hh and the error drops by 4. On a log-log plot of error against hh, the forward difference is a line of slope 1 and the central difference a line of slope 2; that slope is what the two slope tests measure. The error formula dropped is called truncation error, because it comes from truncating the Taylor series. The central difference is exact on quadratics (f′′′=0f''' = 0), and the forward difference is exact only on straight lines.

Every float64 operation rounds its exact result to the nearest representable number, with relative error at most ε/2\varepsilon / 2. So a computed f(x+h)f(x + h) is off by about ε∣f(x)∣\varepsilon |f(x)| (more if ff itself is a long computation). The numerator f(x+h)−f(x−h)f(x+h) - f(x-h) is a difference of two nearly equal numbers when hh is small: their leading digits cancel and the rounding errors do not. That error, about ε∣f∣\varepsilon |f|, is then divided by 2h2h. Total error of the central difference:

E0(h)≈16∣f′′′∣ h2⏟truncation+ε ∣f∣/h⏟rounding.E_0(h) \approx \underbrace{\tfrac{1}{6} |f'''|\, h^2}_{\text{truncation}} + \underbrace{\varepsilon\, |f| / h}_{\text{rounding}} .

The first term falls as hh shrinks, the second rises. Measured on exe^x at x=1x = 1, where e=2.71828…e = 2.71828\ldots:

hh10−110^{-1}10−210^{-2}10−310^{-3}10−410^{-4}10−510^{-5}10−610^{-6}10−810^{-8}10−1010^{-10}10−1210^{-12}
error of D0D_04.5×10−34.5 \times 10^{-3}4.5×10−54.5 \times 10^{-5}4.5×10−74.5 \times 10^{-7}4.5×10−94.5 \times 10^{-9}5.6×10−115.6 \times 10^{-11}9.1×10−119.1 \times 10^{-11}5.2×10−95.2 \times 10^{-9}9.0×10−79.0 \times 10^{-7}1.2×10−41.2 \times 10^{-4}

Down to 10−410^{-4} the error falls by 100 per decade (slope 2); below about 10−510^{-5} rounding takes over and it rises again. A step of 10−1210^{-12} is worse than a step of 10−210^{-2}.

Minimize E0(h)=ah2+b/hE_0(h) = a h^2 + b / h with a=∣f′′′∣/6a = |f'''|/6 and b=ε∣f∣b = \varepsilon |f|: the derivative 2ah−b/h22ah - b/h^2 is zero at h⋆=(b/2a)1/3h^\star = (b / 2a)^{1/3}. When ff and its derivatives are of size 1, that is h⋆≈ε3≈6.1×10−6h^\star \approx \sqrt[3]{\varepsilon} \approx 6.1 \times 10^{-6}, and the error there is about ε2/3≈3.7×10−11\varepsilon^{2/3} \approx 3.7 \times 10^{-11}: two thirds of the 16 digits survive. The same argument for the forward difference, E+(h)≈12∣f′′∣h+ε∣f∣/hE_+(h) \approx \frac{1}{2}|f''| h + \varepsilon |f| / h, gives h⋆≈ε≈1.5×10−8h^\star \approx \sqrt{\varepsilon} \approx 1.5 \times 10^{-8} and an error of about ε\sqrt{\varepsilon}, half the digits. The step must also follow the scale of xx. Floats near xx are about ε∣x∣\varepsilon |x| apart, so at x=108x = 10^8 a step of 6×10−66 \times 10^{-6} moves xx by only 6×10−146 \times 10^{-14} relative and the quotient is mostly rounding. A step proportional to ∣x∣|x| keeps the relative perturbation fixed; max⁡(1,∣x∣)\max(1, |x|) keeps it from vanishing at x=0x = 0. The contract’s defaults are therefore

h+=ε max⁡(1,∣x∣),h0=ε3 max⁡(1,∣x∣).h_+ = \sqrt{\varepsilon}\, \max(1, |x|), \qquad h_0 = \sqrt[3]{\varepsilon}\, \max(1, |x|) .

One more floating-point detail: x+hx + h is itself rounded, so the step the hardware took is (x+h)−x(x + h) - x, not hh. Dividing by the step actually taken makes f(x)=xf(x) = x come out as exactly 1. With x=0.1x = 0.1 and h=10−5h = 10^{-5}, dividing by 2h2h gives 0.99999999999960.9999999999996 instead.

The central difference has an error series with only even powers: D0(h)=f′+c2h2+c4h4+⋯D_0(h) = f' + c_2 h^2 + c_4 h^4 + \cdots, where the cc‘s do not depend on hh. Then D0(h/2)=f′+c2h2/4+c4h4/16+⋯D_0(h/2) = f' + c_2 h^2/4 + c_4 h^4/16 + \cdots, and the combination

R1(h)=4 D0(h/2)−D0(h)4−1=f′+O(h4)R_1(h) = \frac{4\, D_0(h/2) - D_0(h)}{4 - 1} = f' + O(h^4)

cancels the h2h^2 term exactly. Repeating with weight 4k4^k at level kk cancels h2kh^{2k}: with D[0][j]=D0(h/2j)D[0][j] = D_0(h / 2^j) and D[k][j]=4kD[k−1][j+1]−D[k−1][j]4k−1D[k][j] = \frac{4^k D[k-1][j+1] - D[k-1][j]}{4^k - 1}, level LL has error O(h2L+2)O(h^{2L+2}) and is exact on polynomials of degree up to 2L+22L + 2. Because the truncation error shrinks so fast, Richardson can use a large hh (like 0.1), where rounding is negligible, and still reach about 12 digits. This is the method behind adaptive differentiation libraries.

Take f(x)=x3f(x) = x^3 at x=2x = 2, so f′(2)=3⋅4=12f'(2) = 3 \cdot 4 = 12, with h=0.1h = 0.1. The function values: f(2.1)=9.261f(2.1) = 9.261, f(2)=8f(2) = 8, f(1.9)=6.859f(1.9) = 6.859, f(2.05)=8.615125f(2.05) = 8.615125, f(1.95)=7.414875f(1.95) = 7.414875.

QuantityComputationValueError
D+(0.1)D_+(0.1)(9.261−8)/0.1(9.261 - 8) / 0.112.610.61=3⋅2⋅0.1+0.120.61 = 3 \cdot 2 \cdot 0.1 + 0.1^2
D0(0.1)D_0(0.1)(9.261−6.859)/0.2(9.261 - 6.859) / 0.212.010.01=h20.01 = h^2 (here 16f′′′=1\frac{1}{6} f''' = 1)
D0(0.05)D_0(0.05)(8.615125−7.414875)/0.1(8.615125 - 7.414875) / 0.112.00250.0025=(h/2)20.0025 = (h/2)^2
R1(0.1)R_1(0.1)(4⋅12.0025−12.01)/3=36/3(4 \cdot 12.0025 - 12.01) / 3 = 36 / 3120

The forward error 0.610.61 is the 12f′′h=3xh=0.6\frac{1}{2} f'' h = 3xh = 0.6 of section 2.3 plus the next term h2=0.01h^2 = 0.01. The central error is exactly h2h^2 because x3x^3 has no terms beyond h3h^3, and quartering it at h/2h/2 is what lets Richardson remove it completely. These numbers are the first test, test_hand_example.

def forward_diff(f: Callable[[float], float], x: float, h: Optional[float] = None) -> float: ...
def central_diff(f: Callable[[float], float], x: float, h: Optional[float] = None) -> float: ...
def richardson(f: Callable[[float], float], x: float, h: float, levels: int = 2) -> float: ...

h = None means the default step of section 2.5; a given h is used as is (gradcheck passes its own). Each function returns a Python float, divides by the step actually taken, and raises ValueError for a non-finite x, a step that is not finite and positive, or levels < 0.

TestKINDChecksWhy it matters downstream
test_hand_exampleunit, smokesection 3: 12.61, 12.01, 12you and the tests agree on the definitions
test_returns_python_floatunita Python float even when f returns numpy scalarsgradcheck stores results in float64 arrays
test_central_error_slope_is_twopropertylog-log slope of the central error is 2the truncation order of section 2.3
test_forward_error_slope_is_onepropertylog-log slope of the forward error is 1the step rule differs per formula
test_default_step_is_accurategoldendefault central error at most 10−910^{-9} on five smooth functions at 26 pointsM04.1’s tolerance assumes this accuracy
test_forward_default_step_is_accurategoldendefault forward error at most 10−710^{-7}the ε\sqrt{\varepsilon} rule
test_default_step_scales_with_xboundaryddxlog⁡x\frac{d}{dx}\log x at x=108x = 10^8 to 7 digitsweights and logits are not all near 1
test_default_step_at_zeroboundarya nonzero step at x=0x = 0activations are checked at their kink
test_divides_by_the_step_actually_takenboundaryf(x)=xf(x) = x gives exactly 1exactness on linear maps
test_given_step_is_used_as_isunith = 0.5 at x=100x = 100 gives 30000.25gradcheck’s eps means what it says
test_richardson_levels_raise_the_orderpropertyon exe^x at 0.5 with h=0.1h = 0.1: errors 2.7×10−32.7 \times 10^{-3}, 3.4×10−73.4 \times 10^{-7}, 5×10−125 \times 10^{-12}, 10−1410^{-14} for levels 0 to 3extrapolation works level by level
test_richardson_exact_on_polynomialspropertyexact on a quartic (1 level) and a sextic (2 levels)the O(h2L+2)O(h^{2L+2}) claim
test_richardson_default_levels_is_twounitthe default is two levelscontract defaults
test_rejects_bad_stepsboundaryh=0h = 0, negative, nan, inf raise ValueErrorbugs surface at the call
test_rejects_bad_points_and_levelsboundarynan or inf xx, negative levels raise ValueErrornan never travels into a gradient check
PitfallSymptomCaught by
1. a one-sided difference where the central one was meanterror 10−610^{-6} instead of 10−1110^{-11}; slope 1 on the log-log plottest_central_error_slope_is_two, test_hand_example (mutant s01)
2. an absolute step, ignoring the size of xxthe derivative of log⁡\log at 10810^8 is off by 3 percenttest_default_step_scales_with_x (mutant s02)
3. a step proportional to ∣x∣\lvert x \rvert aloneh=0h = 0 at x=0x = 0: division by zerotest_default_step_at_zero (mutant s03)
4. dividing by 2h2h instead of the step actually takenf(x)=xf(x) = x differentiates to 0.99999999999960.9999999999996test_divides_by_the_step_actually_taken (mutant s05)
5. the wrong step rule for the formula (ε\sqrt{\varepsilon} for central, ε3\sqrt[3]{\varepsilon} for forward)central error 6×10−96 \times 10^{-9} instead of 10−1010^{-10}; forward error 3×10−63 \times 10^{-6} instead of 10−810^{-8}test_default_step_is_accurate (mutant s04), test_forward_default_step_is_accurate (mutant s08)
6. Richardson weights 2k2^k instead of 4k4^kthe extrapolation removes nothing; worse than level 0test_richardson_exact_on_polynomials (mutant s06)
7. Richardson steps that grow (h⋅2jh \cdot 2^j) instead of shrinkthe hand example gives 12.05test_hand_example (mutant s07)
rescaling a step the caller gavegradcheck’s eps silently becomes 100⋅100 \cdot eps at x=100x = 100test_given_step_is_used_as_is (mutant s09)
accepting a nan or inf stepa nan derivative instead of an errortest_rejects_bad_steps (mutant s10)
DirectionModuleHow it uses this
BackM00.1exponents and logarithms, exe^x and its defining property (reading)
BackM00.4polynomials, the shape of a truncated Taylor series (reading)
ForwardM04.1numerical_grad calls central_diff(along, x_i, eps) once per coordinate of every input: the frozen-step gradient behind gradcheck
ForwardM01.2newton(f, None, x0) uses central_diff as its slope
ForwardM02.1Taylor’s formula with a proven remainder: the error terms of section 2.3 made rigorous
ForwardM09.3condition numbers and tolerance budgets generalize the rounding analysis of section 2.4
ForwardL0.2F.gradcheck_all() checks every op of your autograd library through M04.1, and so through this file

If you skip this module, ol check M04.1 stops with BLOCKED ... needs M01.1: build it, or pass --ref-deps.

Your pieceProduction equivalentWhat it addsWhere to look
central_diff with h=10−6h = 10^{-6}PyTorch torch.autograd.gradcheckthe same central difference per element, float64, eps 10−610^{-6}; also checks complex inputs and forward-mode gradientstorch/autograd/gradcheck.py (_compute_numerical_gradient)
forward_diff with ε\sqrt{\varepsilon}SciPy scipy.optimize.approx_fprime, scipy.differentiate.derivativeforward steps for optimizers; adaptive step and order selectionscipy/optimize/_numdiff.py
richardsonnumdifftoolsRichardson tables with automatic step search and error estimatesnumdifftools/limits.py
(not built)complex-step differentiationf′(x)≈Im⁡f(x+ih)/hf'(x) \approx \operatorname{Im} f(x + ih)/h has no subtraction, so h=10−200h = 10^{-200} works and the result is exact to roundingMartins, Sturdza, and Alonso, “The complex-step derivative approximation” (2003)
finite differencesautomatic differentiationexact derivatives by the chain rule, no step at all: what your L0.1 autograd and M08.1 dual numbers doM08.1, L0.1