Catastrophic cancellation
See how subtracting near-equal numbers destroys precision, and reformulate to avoid it.
The single most common way naive numerical code goes silently wrong is catastrophic cancellation: subtracting two nearly-equal floating-point numbers and losing most of the significant digits[1]. The arithmetic operation is exact in IEEE-754, but the input error gets amplified by the magnitude of the operands divided by the magnitude of the result.
The mechanism
Suppose and are floats with relative error . Their absolute errors are and , of comparable size when . The subtraction is computed exactly, but the input errors propagate: , while the true result is small. The relative error of is then
If and agree to digits (), the relative error of grows by a factor of , losing digits of precision. With double precision (~16 digits) and , , the difference has at most one correct digit.
A worked example: the quadratic formula
Solve with , via the standard formula. The two roots are . For the root using , , so the numerator is : massive cancellation. The other root, using , is fine.
The fix uses Vieta's identity : compute the well-conditioned root first, then get the other from . No subtraction of near-equal numbers, no cancellation.
A second example:
Near , , so . Computing directly subtracts two nearly-equal numbers when is small. The half-angle identity avoids the cancellation: is well-defined and small, and squaring preserves precision.
The general repair pattern is algebraic rearrangement: rewrite the formula so the subtraction of near-equal quantities becomes a multiplication, a division, or a use of a stable library function.
Cancellation in summation
Adding many positive numbers does not cancel, but adding numbers of mixed sign can. Computing a mean where is near zero by construction is a textbook case: the running sum is much larger than the final value. Compensated summation (Kahan summation) keeps a separate running error term and corrects it each step, recovering most of the lost precision[2].
Check it on arrays
import numpy as np
# 1) Quadratic formula: catastrophic vs Vieta
b, c = 1e7, 1.0
naive_minus = (-b - np.sqrt(b**2 - 4*c)) / 2 # well-conditioned root
naive_plus = (-b + np.sqrt(b**2 - 4*c)) / 2 # cancels!
vieta_plus = c / naive_minus # stable form
print(f"naive +root: {naive_plus:.6e}")
print(f"Vieta +root: {vieta_plus:.6e}")
print(f"check: x1*x2 = c? {naive_minus * vieta_plus:.6e} (should be {c})")
# 2) 1 - cos(x) near zero: naive vs half-angle identity
for x in [1e-4, 1e-6, 1e-8]:
naive = 1 - np.cos(x)
stable = 2 * np.sin(x/2)**2
truth = x**2 / 2 # leading Taylor term
print(f"x={x:.0e}: naive={naive:.4e} stable={stable:.4e} truth~{truth:.4e}")
# 3) Cancellation in summation: mean of shifted data
rng = np.random.default_rng(0)
big = 1e10
data = big + rng.normal(size=10_000)
mu = data.mean()
naive = np.mean(data - mu) # should be ~0
print("naive mean of (data - mu):", naive, " (~0 ideal)")
# Subtract the bulk first, then sum
shifted = data - big
mu_shift = shifted.mean()
recovered = np.mean(shifted - mu_shift)
print("after shifting bulk out:", recovered)The naive quadratic formula loses ~7 digits on the cancelling root; the half-angle identity preserves precision while collapses to zero; and shifting the bulk out of the data restores significance.
Exercises
Exercise 1. The numbers and are each known to relative error . Using the cancellation bound , estimate how many significant digits survive in .
Solution
Step 1. The exact difference is , while .
Step 2. Substitute into the bound:
Step 3. A relative error of means about correct digits remain. The subtraction itself is exact in IEEE-754, but the inherited input error of the operands, originally in the 16th digit, is now in the 7th digit of the much smaller result: roughly digits are lost, matching .
Exercise 2. Derive a cancellation-free form of for large , and explain why it is stable.
Solution
Step 1. For large the two square roots are nearly equal, so subtracting them directly cancels. Multiply by the conjugate over itself:
Step 2. The numerator simplifies to , giving
Step 3. The rewritten form has no subtraction of near-equal numbers: it is a division of by a sum of two positive quantities. The cancellation of near-equal roots became an addition, which never loses significance, so every digit is retained.
Exercise 3 (code). Solve with , . Compute the small root with the naive and with the Vieta form where is the well-conditioned root. Show the naive root is wrong and the Vieta root agrees with to full precision.
Solution
import numpy as np
b, c = 1e8, 1.0
x1 = (-b - np.sqrt(b**2 - 4*c)) / 2 # well-conditioned root, ~ -1e8
naive = (-b + np.sqrt(b**2 - 4*c)) / 2 # cancels
vieta = c / x1 # stable small root
# For |b| huge and c = 1, the small root is approximately -c/b = -1e-8.
approx = -c / b
assert abs(vieta - approx) < 1e-20 # Vieta matches to full precision
assert abs(naive - approx) > 1e-9 # naive root is far off
print("naive +root:", naive, " Vieta +root:", vieta, " -c/b:", approx)The naive root comes out near , off in the first digit, while the Vieta root is exactly, because the well-conditioned root carries no cancellation and the division inherits its precision.
The next lesson tackles the most common cancellation problem in machine learning: exponentials in softmax.
References
- [1]Higham, N. J. (2002). Accuracy and Stability of Numerical Algorithms (2nd ed.). SIAM. Cancellation and its repair by reformulation (doi:10.1137/1.9780898718027).↩
- [2]Goldberg, D. (1991). "What every computer scientist should know about floating-point arithmetic". ACM Computing Surveys, 23(1), 5-48. Discusses cancellation and Kahan compensated summation (doi:10.1145/103162.103163).↩