Catastrophic cancellation¶
Level: 301 · deep dive
One line: Subtracting two nearly equal numbers can destroy almost every significant figure at once, silently, leaving an answer that still looks precise — and it is the point where measurement error and machine error stop being two subjects.
The one operation that breaks the rules¶
Every other operation in this chapter loses a digit or two. Subtraction can take ten and give back one.
Two measurements, each good to five significant figures — four-thousandths of a percent, laboratory-grade:
Subtract them. The difference is 0.0001, and its uncertainty is √2 × 0.00005 = 0.00007:
Ten significant figures went in. About one came out. The relative error was amplified roughly seventeen thousand times, and the two measurements were consistent with a difference of zero.
Nothing was done wrong. No rule was broken, no rounding was sloppy. The information was simply never there: the quantity being computed is smaller than the noise in the inputs, and subtraction is the operation that reveals it.
What the program prints¶
Verified output of catastrophic_cancellation.py — regenerated by tools/run_examples.py, never hand-typed.
1. ON PAPER, WITH NO COMPUTER INVOLVED
A = 1.2345 5 s.f. +/- 0.00005 = 0.0041% relative
B = 1.2344 5 s.f. +/- 0.00005 = 0.0041% relative
A - B = 0.0001 1 s.f.
uncertainty +/- 0.0000707 = 71% relative
Ten significant figures went in. About one came out.
Relative error was amplified 17,458x.
Nothing was done wrong. The information simply was not there:
both measurements were consistent with a difference of zero.
2. THE SAME THING TO A COMPUTER
Cancellation does not CREATE error. It removes the large leading
digits that were hiding error already there.
0.1 + 0.2 is stored as 0.3000000000000000444089209850062616169452667236328125
0.3 is stored as 0.299999999999999988897769753748434595763683319091796875
the sum is wrong by 4.441E-17
which is 1.480E-14 % -- invisible
Now subtract. The true answer is exactly zero:
(0.1 + 0.2) - 0.3 = 5.551115123125783e-17
The absolute error did not change. The 0.3 that was masking it
is gone, so a rounding artifact is now 100% of the answer.
3. THE CLASSIC: THE QUADRATIC FORMULA
x^2 + bx + c = 0, with b enormous and c small.
b = 1e+08 c = 1
sqrt(b^2 - 4c) = 99999999.99999999
...which is b itself to every digit a float can hold.
naive (-b + sqrt(b^2-4c)) / 2 = -7.450580596923828e-09
stable -2c / (b + sqrt(b^2-4c)) = -1e-08
exact (50-digit arithmetic) = -1.00000000000000010e-8
naive is off by 25.4942%
stable is off by 0.0000%
Same formula, same inputs, same hardware. One subtraction moved.
4. CONDITIONING vs STABILITY -- two different faults
ill-CONDITIONED problem : small input change -> large output change.
The problem's fault. No algorithm escapes it.
un-STABLE algorithm : the problem is fine; this route through it
is not. Section 3 is this kind, and the fix
was free.
Section 1 is the first kind: no cleverness recovers digits that were
never measured. Knowing which one you have is the whole skill.
5. THE PRACTICAL RULE
Whenever you subtract two quantities that are close, stop and ask
what the difference is RELATIVE to its own uncertainty. If the
answer is 'not much', the digits on your screen are decoration.
Cancellation exposes error; it does not create it¶
Section 2 above is the sentence worth taking away, and it is the one usually got backwards.
0.1 + 0.2 in binary floating point comes out as 0.3000000000000000444…. The error — about 4.4 × 10⁻¹⁷ — is buried in the seventeenth digit, and as a share of 0.3 it is 1.5 × 10⁻¹⁴ percent. Utterly invisible; you could ship it for years.
Why
0.1was wrong before you did anything to it. This page takes for granted that0.1is not 0.1, and never explains it. The reason is that a fraction survives in binary only when its denominator is a power of two —1/2,1/4,3/4are exact;1/10and1/5repeat forever and get cut off. That is a lesson of its own, and the sibling Rust library already has a thorough one: What a float actually stores ↗. It is a Rust page, but the IEEE 754 mechanics it walks through — the repeating binary expansion, the 24- and 53-bit cuts, and the fact that the discarded tail rounds rather than truncates, so errors go in both directions — are the same on every machine and in every language.Read that one for where the error comes from; this one is what happens to it next.
Now subtract 0.3. The true answer is exactly zero. The computed answer is 5.551115123125783e-17.
The absolute error did not change. It was 4.4 × 10⁻¹⁷ before the subtraction and it is about that after. What changed is that the 0.3 which had been masking it is gone, so a rounding artifact is promoted from the seventeenth digit to the only digit. That is the whole mechanism, in measurement and in binary alike: cancellation removes the large leading digits that were hiding the error you already had.
Which is also why if (a - b) == 0 and if a == b are not the same test, and why neither is the test you want.
The quadratic formula, and a free fix¶
Section 3 is the classic, and it is worth reading closely because it separates two things that get conflated.
Solve x² + bx + c = 0 with b = 10⁸ and c = 1. The small root is about −10⁻⁸. The textbook formula computes it as (−b + √(b² − 4c)) / 2 — and √(b² − 4c) equals b to every digit a float can hold, so the numerator is a catastrophic cancellation. The result is wrong by 25%.
Multiply through by the conjugate and the same root becomes −2c / (b + √(b² − 4c)). Now it is an addition of two positive quantities. No cancellation, and the answer is correct to full precision.
Same formula. Same inputs. Same hardware. One subtraction moved, and 25% error became none.
Two different faults, and telling them apart¶
This is the skill the page is really about:
An ill-conditioned problem — a small change in the input causes a large change in the output. This is the problem's fault. No algorithm escapes it, and no amount of precision rescues it, because the sensitivity is a property of the question, not the method. The two measurements in the opening section are this kind. Their difference is genuinely unknown, and cleverness cannot recover digits that were never measured.
An unstable algorithm — the problem is perfectly well-behaved; this particular route through it is not. The quadratic formula is this kind, and the fix cost nothing.
Confusing them wastes effort in both directions: you can spend a week hardening an algorithm against an ill-conditioned problem that will defeat any algorithm, or accept a garbage answer as "inherent" when a two-line rearrangement would have fixed it. The diagnostic question is whether an independent method gets a materially different answer. If yes, your algorithm is unstable. If every method agrees on garbage, the problem is ill-conditioned and the honest output is a wide interval.
The practical rule¶
Whenever you subtract two quantities that are close, stop and ask what the difference is relative to its own uncertainty. If the answer is "not much", the digits on your screen are decoration.
Where this shows up in real code: differences of large timestamps, variance = E[x²] − E[x]² (use a two-pass or Welford algorithm instead), finite-difference derivatives with too small a step, subtracting cumulative totals, and any before/after comparison of two large near-equal aggregates.
Run it yourself¶
From the root of your clone of this repository:
See also¶
- Uncertainty propagation — where the ±0.00007 came from
- Significant figures — the rules this page is the exception to
- What a float actually stores ↗ — the binary mechanics under section 2, in the sibling Rust library