Dual Numbers for Automatic Differentiation

1. Scalar derivatives

Dual numbers are a key part of forward-mode differentiation. However, I find the introduction of them unintuitive. Usually, as done in [1], they are introduced such that

𝑓(π‘₯+πœ€)=𝑓(π‘₯)+𝑓′(π‘₯)πœ€,

(1)

where πœ€2=0. This is not incorrect. However, I think it is more natural to think of dual numbers, at least in the context of automatic differentiation, as a number type that simply tracks both 𝑓(π‘₯) and 𝑓′(π‘₯) at the same time by introducing an πœ€. This is sort of similar to how a complex number can be seen to track an π‘Ž and 𝑏 at the same time by introducing an 𝑖. However, for complex numbers the aim is to be able to solve π‘₯2=βˆ’1. For dual numbers the intent, as noted before, is to track 𝑓(π‘₯) and 𝑓′(π‘₯) at the same time. We therefore define how a function acts on a dual number by the composition rule

𝑓(𝑔(π‘₯)+𝑔′(π‘₯)πœ€)≔𝑓(𝑔(π‘₯))+(𝑓(𝑔(π‘₯)))β€²πœ€=𝑓(𝑔(π‘₯))+𝑓′(𝑔(π‘₯))𝑔′(π‘₯)πœ€,

(2)

of which (1) is the special case 𝑔(π‘₯)+𝑔′(π‘₯)πœ€=π‘₯+πœ€. In the same way we define multiplication and division of dual numbers by the product and quotient rules

(𝑓(π‘₯)+𝑓′(π‘₯)πœ€)(𝑔(π‘₯)+𝑔′(π‘₯)πœ€)≔𝑓(π‘₯)𝑔(π‘₯)+(𝑓(π‘₯)𝑔(π‘₯))β€²πœ€=𝑓(π‘₯)𝑔(π‘₯)+(𝑓′(π‘₯)𝑔(π‘₯)+𝑓(π‘₯)𝑔′(π‘₯))πœ€,𝑓(π‘₯)+𝑓′(π‘₯)πœ€π‘”(π‘₯)+𝑔′(π‘₯)πœ€β‰”π‘“(π‘₯)𝑔(π‘₯)+(𝑓(π‘₯)𝑔(π‘₯))β€²πœ€=𝑓(π‘₯)𝑔(π‘₯)+𝑓′(π‘₯)𝑔(π‘₯)βˆ’π‘“(π‘₯)𝑔′(π‘₯)𝑔(π‘₯)2πœ€,

(3)

and addition and subtraction by the sum and difference rules

(𝑓(π‘₯)+𝑓′(π‘₯)πœ€)+(𝑔(π‘₯)+𝑔′(π‘₯)πœ€)≔(𝑓(π‘₯)+𝑔(π‘₯))+(𝑓(π‘₯)+𝑔(π‘₯))β€²πœ€=(𝑓(π‘₯)+𝑔(π‘₯))+(𝑓′(π‘₯)+𝑔′(π‘₯))πœ€,(𝑓(π‘₯)+𝑓′(π‘₯)πœ€)+(𝑐+0πœ€)≔(𝑓(π‘₯)+𝑐)+(𝑓(π‘₯)+𝑐)β€²πœ€=(𝑓(π‘₯)+𝑐)+𝑓′(π‘₯)πœ€,(𝑓(π‘₯)+𝑓′(π‘₯)πœ€)βˆ’(𝑔(π‘₯)+𝑔′(π‘₯)πœ€)≔(𝑓(π‘₯)βˆ’π‘”(π‘₯))+(𝑓(π‘₯)βˆ’π‘”(π‘₯))β€²πœ€=(𝑓(π‘₯)βˆ’π‘”(π‘₯))+(𝑓′(π‘₯)βˆ’π‘”β€²(π‘₯))πœ€.

(4)

Now an initial complaint would be: What if 𝑓′(π‘₯) (or 𝑔′(π‘₯)) is not knownβ€”which is usually the case when forward-mode differentiation is applied to a general function 𝑓? The answer is that we don’t need to know them as long as the derivatives of the basic operations that make up 𝑓 are known. That is, we can simply evaluate 𝑓(π‘₯+πœ€), which by (2) should give us the derivative of 𝑓 at π‘₯ as the coefficient of πœ€. In the code example below we define the following composition rules for the elementary functions sin and exp as

sin(π‘Ž+π‘πœ€)=sin(π‘Ž)+𝑏cos(π‘Ž)πœ€,exp(π‘Ž+π‘πœ€)=exp(π‘Ž)+𝑏exp(π‘Ž)πœ€.

(5)

Note that in general 𝑏 does not have to come from the derivative of π‘Ž with respect to some variable.

ExampleΒ 1: Evaluating a function on a dual number

Example 1 (Evaluating a function on a dual number)Β Β  Let 𝑓(π‘₯)=sin(π‘₯β‹…π‘₯)+π‘₯ and π‘₯=2. Evaluating 𝑓(2+πœ€) one operation at a time using the rules above gives

π‘₯=2+πœ€,π‘₯β‹…π‘₯=4+(1β‹…2+2β‹…1)πœ€=4+4πœ€,sin(π‘₯β‹…π‘₯)=sin(4)+4cos(4)πœ€,sin(π‘₯β‹…π‘₯)+π‘₯=(sin(4)+2)+(4cos(4)+1)πœ€.

(6)

The coefficient of πœ€ is 𝑓′(2)=2β‹…2cos(22)+1=4cos(4)+1, even though 𝑓′ was never written down.

ExampleΒ 2: Scalar derivative in Julia

Example 2 (Scalar derivative in Julia)Β Β  Julia lets us define the arithmetic of a new type directly, so the scalar case takes only a few lines of code.

julia
1
# A small, deliberately incomplete forward-mode AD implementation.
2
struct Dual{T<:Real}
3
value::T
4
deriv::T
5
end
6
# Promote mixed inputs such as Dual(2, 1.0) to a common type
7
Dual(value::Real, deriv::Real) = Dual(promote(value, deriv)...)
8
9
# Product rule: (f * g)' = f' * g + f * g'
10
Base.:*(f::Dual, g::Dual) = Dual(
11
f.value * g.value,
12
f.deriv * g.value + f.value * g.deriv,
13
)
14
Base.:*(f::Dual, g::Real) = f * Dual(g, zero(g))
15
Base.:*(f::Real, g::Dual) = Dual(f, zero(f)) * g
16
17
# Quotient rule: (f / g)' = (f' * g - f * g') / g^2
18
Base.:/(f::Dual, g::Dual) = Dual(
19
f.value / g.value,
20
(f.deriv * g.value - f.value * g.deriv) / g.value^2,
21
)
22
23
# Composition rule: (f ∘ g)' = (f' ∘ g) * g'
24
Base.sin(f::Dual) = Dual(sin(f.value), cos(f.value) * f.deriv)
25
Base.exp(f::Dual) = Dual(exp(f.value), exp(f.value) * f.deriv)
26
27
# Addition rules: (f + g)' = f' + g', (f + c)' = f', (c + f)' = f'
28
Base.:+(f::Dual, g::Dual) = Dual(f.value + g.value, f.deriv + g.deriv)
29
Base.:+(f::Dual, c::Real) = f + Dual(c, zero(c))
30
Base.:+(c::Real, g::Dual) = Dual(c, zero(c)) + g
31
32
# Subtraction rules: (-f)' = -f', (f - g)' = f' - g'
33
Base.:-(f::Dual) = Dual(-f.value, -f.deriv)
34
Base.:-(f::Dual, g::Dual) = f + (-g)
35
36
# The derivative is the epsilon coefficient of f(x + 1*epsilon)
37
derivative(f, x) = f(Dual(x, one(x))).deriv
38
39
# Dual is not a Number, so Base's generic ^ does not apply to it and
40
# f_scalar(x) = sin(x^2) + x would throw a MethodError
41
f_scalar(x) = sin(x * x) + x
42
@assert isapprox(derivative(f_scalar, 2.0), 4cos(4.0) + 1)
43
44
println("f'(2) = ", derivative(f_scalar, 2.0))

2. Jacobian-vector products

For a multivariate function 𝑓:β„π‘›β†’β„π‘š the simplest extension keeps a single πœ€ and seeds each input π‘₯𝑗 with a direction 𝑣𝑗. The dual numbers then track the function value and a directional derivative (a Jacobian-vector product) at the same time, i.e.,

𝑓(π‘₯+πœ€π‘£)=𝑓(π‘₯)+(𝐽𝑓(π‘₯)𝑣)πœ€,

(7)

where 𝐽𝑓(π‘₯) is the Jacobian. This definition follows directly by looking at the 𝑖th entry as

𝑓(π‘₯+πœ€π‘£)𝑖=𝑓𝑖(π‘₯+πœ€π‘£)=𝑓𝑖(π‘₯)+(βˆ‘π‘—=1π‘›πœ•π‘“π‘–(π‘₯)πœ•π‘₯𝑗𝑣𝑗)πœ€.

(8)

A thing to note here is that the sum is computed automatically by the dual number arithmetic, and hence we never form the Jacobian. From a scaling perspective, this is a big win as the Jacobian is π‘šΓ—π‘› in size, and hence can be very large.

The price is that one evaluation gives one product 𝐽𝑓(π‘₯)𝑣. Its cost is a small multiple of the cost of evaluating 𝑓, but the full Jacobian requires 𝑛 evaluations, one for each unit vector 𝑣=𝑒𝑗. Forward mode is therefore well suited when 𝑛 is small compared to π‘š. In the opposite case reverse mode (backpropagation) is preferred. It computes one vector-Jacobian product π‘€βŠ€π½π‘“(π‘₯) per evaluation, also at a small multiple of the cost of evaluating 𝑓, so the full Jacobian requires π‘š evaluations, one for each unit vector 𝑀=𝑒𝑖. The price is that the intermediate values of 𝑓 must be stored for the backward pass. The common case of a scalar loss of many parameters (π‘š=1) needs only a single evaluation; see Backpropagation as a Linear Solve.11 In the notation of that post, the Jacobian of a network is π‘ƒπΏβˆ’1π‘€βŠ€, where 𝐿 is block lower triangular. Forward mode multiplies 𝑣 from the right, 𝑃(πΏβˆ’1(π‘€βŠ€π‘£)), so the solve with 𝐿 is a forward substitution that follows the layers in order, exactly as the dual numbers do. Reverse mode multiplies π‘€βŠ€ from the left, ((π‘€βŠ€π‘ƒ)πΏβˆ’1)π‘€βŠ€, so the solve becomes the backward substitution πΏβŠ€πœ†=π‘ƒβŠ€π‘€.

ExampleΒ 3: Jacobian-vector product in Julia

Example 3 (Jacobian-vector product in Julia)Β Β  Let

𝑓(π‘₯1,π‘₯2)=(π‘₯1π‘₯2+sin(π‘₯1),exp(π‘₯2)).

(9)

At π‘₯=(2,3) in the direction 𝑣=(1,βˆ’1), (7) gives

𝐽𝑓(2,3)𝑣=[3+cos(2)20exp(3)][1βˆ’1]=[1+cos(2)βˆ’exp(3)].

(10)

julia
1
@isdefined(Dual) || include("dual_numbers.jl")
2
3
function jvp(f, x, v)
4
@assert length(x) == length(v)
5
seeded = [Dual(xi, vi) for (xi, vi) in zip(x, v)]
6
result = f(seeded)
7
return map(z -> z.value, result), map(z -> z.deriv, result)
8
end
9
10
f_vector(x) = (x[1] * x[2] + sin(x[1]), exp(x[2]))
11
x, v = [2.0, 3.0], [1.0, -1.0]
12
value, deriv = jvp(f_vector, x, v)
13
@assert all(isapprox.(value, (6 + sin(2.0), exp(3.0))))
14
@assert all(isapprox.(deriv, (1 + cos(2.0), -exp(3.0))))
15
16
println("f($x) = ", value)
17
println("Jf($x)$v = ", deriv)

Cite this post

bibtex
@misc{paltorp2026dualnumbers,
author = {Paltorp, Mikkel},
title = {{Dual Numbers for Automatic Differentiation}},
year = {2026},
month = sep,
url = {https://mipals.github.io/pdf/2026/09-25-dual-numbers/dual-numbers.pdf},
note = {Blog post}
}

Bibliography