← Back to Blog
How-To

AAD Greeks don't match bump-and-revalue: the usual causes

When an adjoint sensitivity and a bumped one disagree, the cause is usually one of five: the bump size, a kink, a branch taken at the input point, an input that became a constant, or Monte Carlo noise. Each leaves a different signature, and each can be told apart in a few minutes.

Natalija Karpichina
Natalija Karpichina
· 5 min read
AAD Greeks finite differences bump and revalue Monte Carlo model validation

You add adjoint differentiation to a pricer, compare its delta with the bumped delta the desk has used for years, and the two disagree. Sometimes in the fifth digit, sometimes by a factor of ten, sometimes one of them is exactly zero.

Neither number is automatically the right one. A bumped Greek is an approximation with an error you choose through the bump size. An AAD Greek is the exact derivative of the code that ran, which is not always the function you think you are differentiating. Almost every disagreement comes from one of five causes, and each leaves a recognisable signature.

The figures below come from one small script: a one-year European call, spot 100, strike 105, 3% rate, 20% volatility, priced in plain Python and recorded with AADC. The price is 7.128064669261754 and the AAD delta is 0.5024132586078933, which agrees with the closed form N(d1) to the last digit but one.

def bs_call(S, K, r, vol, T, m):                     # m is the maths module: math for floats, aadc.math when recording
    d1 = (m.log(S / K) + (r + 0.5 * vol * vol) * T) / (vol * m.sqrt(T))
    d2 = d1 - vol * m.sqrt(T)
    return S * m.cdf_normal(d1) - K * m.exp(-r * T) * m.cdf_normal(d2)

f = aadc.Functions()
f.start_recording()
S, r, vol = aadc.idouble(S0), aadc.idouble(r0), aadc.idouble(v0)
aS, ar, av = S.mark_as_input(), r.mark_as_input(), vol.mark_as_input()
out = bs_call(S, K, r, vol, T, aadc.math).mark_as_output()
f.stop_recording()
ws = f.create_workspace()
for h, v in ((aS, S0), (ar, r0), (av, v0)): ws.set_val(h, v)
ws.forward(); ws.set_diff(out, 1.0); ws.reverse()

1. The bump size

A one-sided bump has an error proportional to the bump; a central bump, to its square. Make the bump small enough and floating-point cancellation takes over instead. The error of the bumped delta against the AAD delta in our run:

Bump hForward difference errorCentral difference error
19.9e-03−3.4e-05
1e-21.0e-04−3.4e-09
1e-41.0e-061.5e-11
1e-61.3e-082.6e-09
1e-81.4e-07−2.1e-07
1e-101.2e-05−2.4e-05

The signature: the gap changes with h, shrinks, then grows again. If a scan over h brings the bumped value to within a few digits of the AAD value at its best point, the AAD number is right and the bump was the error. Here the central difference at h = 1e-4 agrees to 1.5e-11.

2. An input that became a constant

AAD only differentiates what it saw flowing from the marked inputs. If a value leaves the active type on the way (a float(), a call into math or NumPy that does not know the active type, a value cached before recording started), everything computed from it is a constant on the record with zero derivative.

In our variant the discount factor is computed as math.exp(-float(r) * T). The AAD rho comes back as exactly 0.0; the central bump gives 43.11326118902059, matching the AAD rho of the correct pricer (43.11326119152758).

The signature: AAD returns exactly zero, or a value that is missing one recognisable term, while the bump moves. AADC counts every active-to-passive conversion during recording (f.num_passive_warnings() was 1 here, 0 for the clean pricer), and a recording with source tracing lists the resulting constant at its line: in this run, a constant at the discount line with value 101.89678102259336, which is 105 × e^−0.03, the strike times a discount factor that should have been moving.

3. A kink at the input point

At a kink the derivative does not exist. AAD returns the slope of whichever side the code took; a central bump returns the average of the two. For the intrinsic value max(S − K, 0) at exactly S = K, AAD returned 1.0 and the central bump 0.5000000000165983. Neither is wrong; they answer different questions.

The signature: the disagreement appears only at or very near specific input values (a strike, a barrier, a cap), and is roughly half the jump in slope.

4. A branch taken at the input point

A Python if on an active value has to produce a plain bool, so the direction taken during recording is frozen. The derivative is then the derivative of the branch that was taken, and the jump between branches is not in it. A bump that straddles the threshold sees the jump; one that does not, does not. This case gets its own page: A derivative that jumps at a branch or a kink.

The signature: a frozen comparison in the recording, and a bumped value that depends sharply on the bump size near a threshold.

5. Discretisation and Monte Carlo noise

AAD differentiates the discretised model: the grid, the time steps, the paths you actually ran. A bumped Greek on the same discretisation should agree with it; a comparison against a closed form or a finer grid measures the discretisation, not AAD.

Monte Carlo makes this sharper. With 4,000 paths and a fixed seed, the pathwise AAD delta of the call was 0.5105083449178975. Bumped with the same random numbers, the delta was 0.5109080740637384 at h = 1 and 0.5106646297208695 at h = 0.01, converging on the AAD value. Bumped with fresh random numbers for the up and down prices, it was 0.5827944985497959 at h = 1 and 8.465698980709968 at h = 0.01: the difference of two noisy prices divided by a small number.

All three differ from the closed-form 0.5024 because 4,000 paths is a small sample; that is the estimator’s error, shared by AAD and by the bump with common random numbers.

The signature: the bumped value changes with the seed, and gets worse, not better, as h shrinks.

Which one is it?

  1. Re-run the bump with common random numbers and scan h over several orders of magnitude. If it converges to the AAD value, you are done: causes 1 and 5.
  2. If AAD is exactly zero, or short by one recognisable term, look for an input that left the active type: cause 2.
  3. Check the recording’s passive-warning count and its list of frozen decisions. A comparison in that list was fixed at record time: cause 4. A non-zero count with no comparison listed points back to cause 2.
  4. If the gap only appears at particular input values, look for a max, min, abs or payoff boundary nearby: cause 3.

Your agent can check this directly

aadc-mcp is a local MCP server that lets a coding agent ask the AADC engine these questions instead of reading the code and guessing. pip install aadc-mcp, register it with your MCP client, and ask the agent to run check_environment. On a script that records with marked inputs, open_session keeps the recording alive and ask_session answers stats (the frozen-decision count), leaves kind=constant (every constant that reached the price, at its line) and branches order=margin (every frozen decision, closest to flipping first). Two prompts ship with the server: add_sensitivities has the agent make a copy of your script that records itself with marked inputs and reports its passive-warning count, and validate_pricer has it list the constants, the frozen decisions and their margins, each with its file:line.

AADC runs here as Community Edition: free for non-commercial and academic use; commercial use, including evaluation inside a company, needs a licence from MatLogica.


Implemented using AADC, a commercial adjoint AD compiler (matlogica.com).

Want to see these results on your own portfolio?

Get in Touch

Interested in these opportunities?

Let's arrange a free demo for you and your team.

Book a Demo