Skip to content

Dual numbers and forward-mode autodiff

ModuleM08.1 · build · Python · Pass 2 · 2 to 3 h
You buildpython/tinyllm/autograd/dual.py: Dual (arithmetic, powers, @, indexing), dual_exp, dual_log, dual_tanh, dual_erf, derivative, jvp
Contractcourse/contracts/py/tinyllm/autograd/dual.pyi
Testscourse/tests/M08.1/ (what they check: section 4)
NeedsM01.3 activation derivatives (the tests compare against them) · M04.2 numeric JVP · reading: M02.1 Taylor series (or --ref-deps)
Used byM08.2 the forward-mode oracle for reverse mode · L0.2 the derivative oracle for every elementwise op
MilestoneMS-P2 (Pass 2 gate: every math module of the pass checks green, then your autograd bigram trains)
Optional depthBaydin, Pearlmutter, Radul, and Siskind, Automatic Differentiation in Machine Learning: a Survey (JMLR 2018), sections 2 and 3.1; Griewank and Walther, Evaluating Derivatives (SIAM, 2nd ed.), ch. 3
  • A dual number a+bεa + b\varepsilon with ε2=0\varepsilon^2 = 0 turns Taylor’s theorem into an identity: f(a+bε)=f(a)+f′(a) b εf(a + b\varepsilon) = f(a) + f'(a)\, b\, \varepsilon, so evaluating a function on x+εx + \varepsilon returns its value and its exact derivative together (test_hand_example).
  • One rule per primitive is enough; the chain rule happens by itself when operations compose (test_arithmetic_rules, test_chain_rule_composition).
  • With a vector tangent, one forward pass computes a Jacobian-vector product JvJ v; a full Jacobian costs one pass per input (test_jvp_matches_numeric_jvp, test_jvp_is_linear_in_v).
  • Dual derivatives have no step size and match closed-form derivatives to rounding, which makes them the oracle for every elementwise op in your autograd (test_matches_activation_derivatives).
Terminal window
ol start M08.1 # stubs dual.py into your repo, contract alongside
ol tests M08.1 # read the test catalog first: rung R0, you write no tests here
ol check M08.1 # exit code is the verdict
ol check M08.1 --ref-deps # only if your M01.3 or M04.2 is not passing yet
ol diff M08.1 # after passing: your code against the reference

This pass replaces the tracer’s count table with a bigram trained by your own autograd (L0.1 to L0.5), and an autograd engine is a pile of derivative rules: one per op, each easy to get subtly wrong. So far you have two ways to check a derivative, and neither is good enough as an oracle. The closed forms of M01.3 cover only the functions you differentiated by hand. The central differences of M04.1 and M04.2 work for anything, but carry a step-size error near 10−1010^{-10} and cannot tell a correct rule from one that is off by 10−1110^{-11}. Dual numbers give a third way: write the forward computation once, run it on a number that carries its own derivative, and read off the exact derivative of the composition. L0.2 uses this to check each elementwise op’s backward, and M08.2 checks reverse mode against it.

SymbolMeaningType / shape
ε\varepsilona formal symbol with ε2=0\varepsilon^2 = 0 (and ε≠0\varepsilon \ne 0)
a+bεa + b\varepsilona dual number: value aa, tangent bbDual(val, eps)
f,f′f, f'a differentiable function and its derivative
x∈Rnx \in \mathbb{R}^nan input pointfloat64[n]
v∈Rnv \in \mathbb{R}^na tangent directionfloat64[n]
Jf(x)∈Rm×nJ_f(x) \in \mathbb{R}^{m \times n}the Jacobian of f:Rn→Rmf: \mathbb{R}^n \to \mathbb{R}^m, Jij=∂fi/∂xjJ_{ij} = \partial f_i / \partial x_jfloat64[m, n]
Jf(x) vJ_f(x)\, vthe Jacobian-vector product (JVP)float64[m]
WWa constant matrixfloat64[m, n]
uuunit roundoff of float64, about 1.1×10−161.1 \times 10^{-16}scalar

The algebra. Dual numbers add and multiply like polynomials in ε\varepsilon, then drop every ε2\varepsilon^2:

(a+bε)+(c+dε)=(a+c)+(b+d)ε,(a + b\varepsilon) + (c + d\varepsilon) = (a + c) + (b + d)\varepsilon, (a+bε)(c+dε)=ac+(ad+bc)ε+bd ε2=ac+(ad+bc)ε.(a + b\varepsilon)(c + d\varepsilon) = ac + (ad + bc)\varepsilon + bd\,\varepsilon^2 = ac + (ad + bc)\varepsilon.

The tangent of a product is the product rule. Division follows by multiplying with the conjugate c−dεc - d\varepsilon, since (c+dε)(c−dε)=c2(c + d\varepsilon)(c - d\varepsilon) = c^2:

a+bεc+dε=(a+bε)(c−dε)c2=ac+bc−adc2ε,\frac{a + b\varepsilon}{c + d\varepsilon} = \frac{(a + b\varepsilon)(c - d\varepsilon)}{c^2} = \frac{a}{c} + \frac{bc - ad}{c^2}\varepsilon,

the quotient rule. A plain number cc is the dual number c+0εc + 0\varepsilon: a constant has tangent 0.

Why it computes derivatives. Taylor’s theorem (M02.1) expands a smooth ff around aa:

f(a+h)=f(a)+f′(a) h+12f′′(a) h2+…f(a + h) = f(a) + f'(a)\,h + \tfrac12 f''(a)\,h^2 + \dots

Put h=bεh = b\varepsilon. Every term from h2h^2 on contains ε2=0\varepsilon^2 = 0, so

f(a+bε)=f(a)+f′(a) b εexactly.f(a + b\varepsilon) = f(a) + f'(a)\, b\, \varepsilon \quad \text{exactly.}

No step size, no truncation: the tangent is the derivative times the input tangent, and floating point adds only the rounding of each operation.

One rule per primitive. Read off each function’s dual rule from its derivative:

FunctionDual rule
exe^xea+ea b εe^a + e^a\, b\,\varepsilon
log⁡x\log xlog⁡a+(b/a) ε\log a + (b / a)\,\varepsilon
tanh⁡x\tanh xtanh⁡a+(1−tanh⁡2a) b ε\tanh a + (1 - \tanh^2 a)\, b\,\varepsilon
erf x\mathrm{erf}\, xerf a+2πe−a2 b ε\mathrm{erf}\, a + \tfrac{2}{\sqrt\pi} e^{-a^2}\, b\,\varepsilon
xkx^k, constant kkak+kak−1 b εa^k + k a^{k-1}\, b\,\varepsilon
cxc^x, constant c>0c > 0ca+caln⁡c  b εc^a + c^a \ln c\; b\,\varepsilon
xyx^y, both dualac+ac(dln⁡a+c b/a)εa^c + a^c\left(d \ln a + c\,b/a\right)\varepsilon for x=a+bεx = a + b\varepsilon, y=c+dεy = c + d\varepsilon

The chain rule comes free. If f(a+ε)=f(a)+f′(a)εf(a + \varepsilon) = f(a) + f'(a)\varepsilon, then feeding that into gg gives g(f(a))+g′(f(a)) f′(a) εg(f(a)) + g'(f(a))\, f'(a)\,\varepsilon: the derivative of g∘fg \circ f. Every program built from the primitives is differentiated by running it.

Vectors and Jacobian-vector products. Give every input its own tangent: x+vεx + v\varepsilon with v∈Rnv \in \mathbb{R}^n. The multivariable Taylor expansion has the same shape, f(x+vε)=f(x)+Jf(x) v εf(x + v\varepsilon) = f(x) + J_f(x)\, v\,\varepsilon, so the output tangent is the JVP. A linear map passes tangents through itself: W(x+vε)=Wx+(Wv)εW(x + v\varepsilon) = Wx + (Wv)\varepsilon. To build the whole Jacobian you run once per basis vector eje_j, getting column jj each time: forward mode costs one pass per input. It is ideal for few inputs and many outputs; a loss with millions of parameters and one output is the opposite case, and the reason M08.2 builds reverse mode.

Arrays of dual numbers. Store a vector of dual numbers as two arrays of one shape, val and eps, and apply every rule elementwise. Indexing and slicing take the same entries from both arrays; @ maps the tangent by the same matrix.

numpy on the left. np.float64(2.0) * d asks numpy first. numpy’s scalar and array types try to treat d as an element of an object array, and the tangent is lost (or you get an object array back). Setting the class attribute __array_ufunc__ = None tells numpy to return NotImplemented for any operation with a Dual, and Python then calls Dual.__rmul__. Every reflected method (__radd__, __rsub__, __rmul__, __rtruediv__, __rpow__, __rmatmul__) must keep the operand order: c−xc - x has tangent −b-b, and c/xc / x has tangent −cb/a2-cb/a^2.

Exact versus approximate. A central difference (f(x+h)−f(x−h))/2h(f(x + h) - f(x - h)) / 2h has truncation error about h2∣f′′′∣/6h^2 |f'''|/6 and rounding error about u∣f∣/hu |f| / h, balanced near h=10−5h = 10^{-5} at an error near 10−1010^{-10} (M01.1). A dual derivative is as accurate as evaluating ff itself, about uu relative. That is why the tests compare dual derivatives with M01.3’s closed forms at a relative tolerance of 10−1210^{-12}, but with M04.2’s numeric JVP only at 10−610^{-6}.

f(x)=xex+3f(x) = x e^x + 3 at x=1x = 1. Seed the input with tangent 1: x=1+εx = 1 + \varepsilon.

stepvaluetangentrule
xx11seed
exe^xe=2.718282e = 2.718282e⋅1=2.718282e \cdot 1 = 2.718282exp
x⋅exx \cdot e^x1⋅e=2.7182821 \cdot e = 2.7182821⋅e+1⋅e=5.4365641 \cdot e + 1 \cdot e = 5.436564product
+3+ 35.7182825.7182825.4365645.436564constant

So f(1)=e+3=5.718282f(1) = e + 3 = 5.718282 and f′(1)=2e=5.436564f'(1) = 2e = 5.436564. By hand, f′(x)=ex+xexf'(x) = e^x + x e^x, which is 2e2e at 1. Same number, from one evaluation.

A quotient, (x+1)/(x−1)(x + 1)/(x - 1) at x=3x = 3. Numerator 4+ε4 + \varepsilon, denominator 2+ε2 + \varepsilon; by the quotient rule the tangent is (1⋅2−4⋅1)/22=−0.5(1 \cdot 2 - 4 \cdot 1)/2^2 = -0.5. The function is 2 there, and its derivative −2/(x−1)2=−0.5-2/(x-1)^2 = -0.5.

A JVP. f(x1,x2)=(x1x2,  x1+x22)f(x_1, x_2) = (x_1 x_2,\; x_1 + x_2^2) at (2,3)(2, 3) along v=(1,0)v = (1, 0): x1=2+εx_1 = 2 + \varepsilon, x2=3+0εx_2 = 3 + 0\varepsilon. Then x1x2=6+3εx_1 x_2 = 6 + 3\varepsilon and x1+x22=11+εx_1 + x_2^2 = 11 + \varepsilon, so Jv=(3,1)J v = (3, 1): the first column of J=[x2x112x2]=[3216]J = \begin{bmatrix} x_2 & x_1 \\ 1 & 2x_2 \end{bmatrix} = \begin{bmatrix} 3 & 2 \\ 1 & 6 \end{bmatrix}.

The first example is the first test case in section 4, test_hand_example; the quotient is a row of test_arithmetic_rules.

python/tinyllm/autograd/dual.py
class Dual:
__array_ufunc__ = None
def __init__(self, val, eps=0.0) -> None # floats, or float64 arrays of one shape
# + - * / ** @ with Dual, numbers, or arrays on either side; unary -; d[idx]
def dual_exp(x) -> Dual; def dual_log(x) -> Dual; def dual_tanh(x) -> Dual; def dual_erf(x) -> Dual
def derivative(f: Callable[[Dual], Any], x: float) -> float
def jvp(f: Callable[[Dual], Any], x: ArrayLike, v: ArrayLike) -> tuple[NDArray, NDArray]

derivative seeds Dual(x, 1.0) and returns the output’s tangent (0.0 when f ignores its input). jvp seeds Dual(x, v) and returns (f(x), J v) as float64 arrays. numpy has no erf; use math.erf, elementwise for arrays (np.vectorize).

TestKINDChecksWhy it matters downstream
test_hand_exampleunitf(1)=e+3f(1) = e + 3, f′(1)=2ef'(1) = 2e for xex+3x e^x + 3you and the test agree on what a dual number carries
test_arithmetic_rulesunit13 primitives and reflected forms against known derivativeseach rule is right on its own
test_pow_rulesunitx3x^3, x0.5x^{0.5}, x−1x^{-1}, 2x2^x, xxx^xthree different rules behind **
test_matches_activation_derivativesdifferentialsigmoid, tanh, SiLU, both GELUs, softplus at 1001 points in [−40,40][-40, 40] against M01.3the exact oracle L0.2 relies on
test_jvp_matches_numeric_jvpdifferentialJvJ v of a map R4→R3\mathbb{R}^4 \to \mathbb{R}^3 against M04.2’s jvp_numericvector forward mode is right
test_numpy_operand_on_the_leftboundarynp.float64(2) * d, 1 - d, array + d stay Dualnumpy scalars appear everywhere in real code
test_constant_function_has_zero_derivativeboundarya constant gives 0, jvp gives zeros, no nested Dualfunctions that ignore their input
test_chain_rule_compositionpropertyddxtanh⁡(xex)\frac{d}{dx}\tanh(x e^x) at 40 pointscomposition is the chain rule
test_jvp_is_linear_in_vpropertylinearity in vv; basis vectors give the Jacobian’s columnsthe cost model of forward mode
test_vector_duals_index_and_matmulunitslices, W @ x, x @ W.T move val and tangent togetherlayers are matrices
PitfallSymptomCaught by
1. keeping one term of the product rulef′(1)=ef'(1) = e instead of 2e2e in the worked exampletest_hand_example (mutant s01)
2. reflected operators that swap the order4−x4 - x gets slope +1+1; 6/x6/x gets the wrong signtest_arithmetic_rules (mutants s03, s04, m01)
3. no __array_ufunc__ = Nonenp.float64(2) * d silently loses the tangenttest_numpy_operand_on_the_left (mutant s08)
4. W @ x that maps the value but not the tangentJVPs of layers are wrong while scalar tests passtest_vector_duals_index_and_matmul (mutant s12)
5. a sign in the quotient rule, 1+tanh⁡21 + \tanh^2, erf′=e−x2/π\mathrm{erf}' = e^{-x^2}/\sqrt\piwrong slopes for division, tanh, and GELUtest_arithmetic_rules (mutants s02, s07, s14), test_matches_activation_derivatives (mutants s05, s06)
6. kxkk x^k instead of kxk−1k x^{k-1}; cxc^x without ln⁡c\ln cpower and exponential slopes offtest_pow_rules (mutants s10, s11)
7. seeding the input tangent with 0every derivative is 0test_constant_function_has_zero_derivative (mutant s09)
8. indexing the value but not the tangentslices carry another entry’s derivativetest_vector_duals_index_and_matmul (mutant s13)
DirectionModuleHow it uses this
BackM01.3its closed-form derivatives are what the dual derivatives must match
BackM04.2its jvp_numeric approximates the same JVP by central differences
BackM02.1Taylor’s theorem is why f(a+bε)=f(a)+f′(a)bεf(a + b\varepsilon) = f(a) + f'(a)b\varepsilon; erf as a series
ForwardM08.2reverse mode is checked against Dual on the same expressions
ForwardL0.2each elementwise op’s backward is checked against its dual derivative

If you skip this module, ol check M08.2 stops with M08.2 needs M08.1: build it, or rerun with --ref-deps.

Your pieceProduction equivalentWhat it addsWhere to look
DualPyTorch forward-mode ADdual tensors (fwAD.make_dual, unpack_dual) and torch.func.jvp over every optorch/autograd/forward_ad.py
jvpJAX jax.jvpforward mode by tracing, composable with vmap and with reverse mode (forward-over-reverse Hessian-vector products, M08.4)jax/_src/interpreters/ad.py
arrays of dualsJulia ForwardDiff.jlchunked tangents: up to 12 directions per pass, so a gradient costs n/12n / 12 passessrc/dual.jl