The Complex-Step Trick

Cover image for The Complex-Step Trick
Ilia Kuk
Ilia Kuk

Ask a computer for the derivative of exe^x at x=1x=1. Give it the familiar finite-difference formula and a very small step, say h=10−30h=10^{-30}. In ordinary double precision, the answer is zero.

Now make that same step imaginary. The answer becomes 2.7182818284590452.718281828459045, matching the usual double-precision value of ee.

You can try it with a few lines of Python:

import cmath
import math
 
x = 1.0
h = 1e-30
 
finite_difference = (math.exp(x + h) - math.exp(x)) / h
complex_step = cmath.exp(x + 1j * h).imag / h
 
print(finite_difference)  # 0.0
print(complex_step)       # 2.718281828459045

This is complex-step differentiation. For a suitable function, a single evaluation at x+ihx+ih carries the derivative in its imaginary part. That small change can recover digits that a real finite difference has already lost.

The same idea can work when the function is an entire simulation: perturb a parameter, run the calculation, and extract the imaginary part of the output. The interesting questions are why such a tiny step survives, and what can erase it along the way.

Why a smaller step isn’t always better

The familiar starting point is

f′(x)≈f(x+h)−f(x)h.f'(x) \approx \frac{f(x+h)-f(x)}{h}.

For a smooth function, a smaller hh reduces the error from approximating the derivative by a finite step. But the computer also rounds its calculations. Eventually, the difference we want becomes comparable to the rounding errors in the two function values.

In the opening example, the failure happens even earlier: adding 10−3010^{-30} to 11 produces exactly 11 in double precision. Both function evaluations receive the same input, so their difference is zero.

Using a central difference improves the approximation:

Dc(h)=f(x+h)−f(x−h)2h.D_c(h)=\frac{f(x+h)-f(x-h)}{2h}.

Its truncation error is proportional to h2h^2, compared with hh for the forward difference. Yet it still subtracts nearly equal function values. A useful model for its total error is

error⁡(h)≈C1h2+C2εmachh,\operatorname{error}(h) \approx C_1h^2+C_2\frac{\varepsilon_{\rm mach}}{h},

where εmach\varepsilon_{\rm mach} measures floating-point precision and the constants depend on the function and its scale. Decreasing hh reduces the first term while increasing the second. This is why finite-difference error curves often have a valley: there is a useful range of steps, followed by a region where taking smaller steps makes things worse.

A derivative estimate that changes wildly when you reduce the step may be running out of numerical precision. Smaller steps do not guarantee a better answer.

Put the step in the imaginary direction

Suppose ff is real-valued for real inputs and has an analytic extension near the point of interest, so we can expand it in a complex Taylor series:

f(x+ih)=f(x)+ihf′(x)−h22f′′(x)−ih36f′′′(x)+⋯ .f(x+ih) =f(x)+ihf'(x)-\frac{h^2}{2}f''(x) -i\frac{h^3}{6}f'''(x)+\cdots.

The even powers contribute to the real part; the odd powers contribute to the imaginary part. Taking the imaginary part and dividing by hh gives

Im⁡f(x+ih)h=f′(x)−h26f′′′(x)+O(h4).\frac{\operatorname{Im}f(x+ih)}{h} =f'(x)-\frac{h^2}{6}f'''(x)+\mathcal{O}(h^4).

There is our derivative estimate:

f′(x)≈Im⁡f(x+ih)h.\boxed{f'(x)\approx\frac{\operatorname{Im}f(x+ih)}{h}.}

Like the central difference, it is second-order accurate. Its numerical advantage comes from extracting a component of one function value, avoiding the subtraction between neighboring evaluations.

Complex arithmetic stores the real and imaginary components separately. In 1+i10−301+i10^{-30}, the tiny perturbation occupies the imaginary component, where it does not have to compete with the real number 11 for significant digits. A compatible implementation can carry that perturbation through the calculation and return an imaginary part approximately equal to hf′(x)hf'(x).

Complex step still has truncation error. Its advantage is that you can often make that error negligible with a tiny step, without triggering the usual finite-difference cancellation.

For many well-behaved calculations, a step such as 10−3010^{-30} works in double precision. It is not a universal prescription: underflow, internal numerical errors, and the scale of the problem still matter. The code evaluating ff must preserve the small imaginary response accurately.

Watch the error curves

For a less trivial test, consider

f(x)=esin⁡x+0.1cos⁡(7x).f(x)=e^{\sin x}+0.1\cos(7x).

We can check every numerical estimate against its exact derivative,

f′(x)=esin⁡xcos⁡x−0.7sin⁡(7x),f'(x)=e^{\sin x}\cos x-0.7\sin(7x),

evaluated at x0=0.5x_0=0.5. The relative error is

∣D−f′(x0)∣∣f′(x0)∣,\frac{|D-f'(x_0)|}{|f'(x_0)|},

where DD is the numerical estimate.

The comparison includes forward and central differences, complex step, and two ways to improve the real finite-difference approximation. A fourth-order central stencil uses four function evaluations:

D4(h)=−f(x+2h)+8f(x+h)−8f(x−h)+f(x−2h)12h.D_4(h)=\frac{-f(x+2h)+8f(x+h)-8f(x-h)+f(x-2h)}{12h}.

Richardson extrapolation instead combines central differences at two step sizes:

DR(h)=4Dc(h/2)−Dc(h)3.D_R(h)=\frac{4D_c(h/2)-D_c(h)}{3}.

For sufficiently smooth functions, both cancel the leading second-order truncation error. They can therefore reach high accuracy with larger steps. Both also retain subtraction between nearby function values.

Relative derivative error versus step size for a smooth analytic function

Read from right to left as the step decreases. The finite-difference curves reach a minimum and then rise. The complex-step curve settles near floating-point precision over a broad range of small steps.

The distinction is clearest on the left side of the plot. Higher-order formulas shift the useful range of finite-difference steps, but their errors eventually grow too. Complex step has already reached an accuracy plateau and stays there. This makes it particularly convenient when we want an accurate first derivative without searching for a narrow optimal step.

How one abs can ruin it

There is a catch worth seeing before applying the trick to a large code.

Consider g(x)=∣x∣g(x)=|x| at x0=0.5x_0=0.5. The function is perfectly smooth in a neighborhood of that point, and g′(x0)=1g'(x_0)=1. But Python's abs applied to a complex number returns its magnitude:

∣x+ih∣=x2+h2.|x+ih|=\sqrt{x^2+h^2}.

That result is real. Its imaginary part is zero, and the complex-step estimate is zero for every nonzero hh:

def g(z):
    return abs(z)
 
h = 1e-30
print(g(0.5 + 1j * h).imag / h)  # 0.0; the derivative is 1.0

Failure of complex-step differentiation when the implementation uses complex magnitude

The solid green complex-step curve stays at a relative error of one: the estimate is zero, while the true derivative is one. Central differences give accurate estimates over a range of step sizes.

The problem here is the extension used by the code. Near a positive real point, g(x)=xg(x)=x, whose analytic extension is simply g(z)=zg(z)=z. Complex magnitude computes a different function of zz and does not satisfy the assumptions behind the formula.

Accepting complex inputs is not enough. Operations such as complex magnitude, conjugation, extracting .real, or casting to a real data type can invalidate the derivative even when the original real function is smooth.

Branches, min/max, thresholds, and event detection also need attention. A fixed smooth branch can sometimes be handled correctly, but a change of branch may introduce a point where the ordinary derivative does not exist. Finite differences remain useful when only real evaluations are available; they still need care near such points.

When the function is a simulation

Suppose a parameter pp controls the growth rate in the logistic equation:

dydt=py(1−y),y(0)=0.2.\frac{dy}{dt}=py(1-y),\qquad y(0)=0.2.

Our output is the solution at T=5T=5, J(p)=y(T;p)J(p)=y(T;p), and we evaluate its derivative at p=1.2p=1.2. The numerical solution uses fourth-order Runge–Kutta with 4,000 fixed time steps. To apply complex step, we give the solver p+ihp+ih and compute

J′(p)≈Im⁡J(p+ih)h.J'(p)\approx\frac{\operatorname{Im}J(p+ih)}{h}.

The imaginary perturbation now passes through every time step. The solver's state arrays and arithmetic must support complex values throughout.

This example has a closed-form solution, so we can check the numerical derivative independently:

y(T;p)=11+Ae−pT,A=1−y(0)y(0)=4.y(T;p)=\frac{1}{1+Ae^{-pT}},\qquad A=\frac{1-y(0)}{y(0)}=4.

Differentiating that expression gives the reference used in the plot:

J′(p)=TAe−pT(1+Ae−pT)2.J'(p)=\frac{TAe^{-pT}}{(1+Ae^{-pT})^2}.

Relative error in the derivative of an ODE solution with respect to a parameter

The ODE comparison shows a broad region of small steps where complex step remains accurate, while the finite-difference errors grow.

Complex step differentiates the implemented calculation. Agreement with its derivative does not guarantee agreement with the exact differential equation: time steps, solver tolerances, and other approximation errors still matter.

This distinction is useful in practice. First check whether the differentiation method behaves consistently for a fixed calculation. Then refine the solver to see whether the derivative converges to the quantity you actually want.

Even when the mathematics supports complex step, numerical routines can introduce imaginary rounding noise that overwhelms the derivative signal at tiny steps. The implementation must preserve the small imaginary response, not merely accept complex inputs.

What about second derivatives?

Everything above concerns first derivatives. For a second derivative, the natural extension is

f′′(x)≈2[f(x)−Re⁡f(x+ih)]h2.f''(x)\approx\frac{2\left[f(x)-\operatorname{Re}f(x+ih)\right]}{h^2}.

But now the information we need sits in the real part, alongside f(x)f(x). Extracting it requires subtracting nearly equal numbers again: the small-step advantage is lost.

One way forward is to give the derivative another component to occupy. You may know quaternions as an extension of complex numbers. The multicomplex-step method uses a different algebra, with commuting imaginary units. Multicomplex numbers are defined recursively by

C0=R,Cn={z1+z2in: z1,z2∈Cn−1},\mathbb{C}_0=\mathbb{R},\qquad \mathbb{C}_n=\{z_1+z_2 i_n:\ z_1,z_2\in\mathbb{C}_{n-1}\},

where ik2=−1i_k^2=-1 and ikiℓ=iℓiki_k i_\ell=i_\ell i_k. The first new case is a bicomplex number:

z=a+bi1+ci2+di1i2,a,b,c,d∈R.z=a+b i_1+c i_2+d i_1i_2,\qquad a,b,c,d\in\mathbb{R}.

For a suitable analytic function, the coefficient of i1i2i_1i_2 now carries the second derivative:

f′′(x)=[f(x+hi1+hi2)]i1i2h2+O(h2),f''(x)=\frac{[f(x+h i_1+h i_2)]_{i_1i_2}}{h^2} +\mathcal{O}(h^2),

where [⋅]i1i2[\cdot]_{i_1i_2} means “take the coefficient of i1i2i_1i_2.” No subtraction of the function value is needed. Additional commuting units extend the idea to higher derivatives; quaternion units do not obey the multiplication rules this construction needs.

The implementation is a separate challenge. Standard complex-number support is insufficient: arithmetic, elementary functions, and the solver must all handle multicomplex values. In practice, this may mean using or writing specialized libraries. The paper below develops the method; a working implementation deserves a post of its own.

Further reading

  1. J. R. R. A. Martins, P. Sturdza, and J. J. Alonso, “The Complex-Step Derivative Approximation”, ACM Transactions on Mathematical Software 29(3), 245–262 (2003).
  2. G. Lantoine, R. P. Russell, and T. Dargent, “Using Multicomplex Variables for Automatic Computation of High-Order Derivatives”, ACM Transactions on Mathematical Software 38(3), Article 16 (2012).