AI Knowledge Tree

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 aa and bb are floats with relative error ε≤εmachine/2\varepsilon \le \varepsilon_{\text{machine}}/2. Their absolute errors are ∣a∣ε|a|\varepsilon and ∣b∣ε|b|\varepsilon, of comparable size when a≈ba \approx b. The subtraction c=a−bc = a - b is computed exactly, but the input errors propagate: ∣Δc∣≤∣a∣ε+∣b∣ε≈2∣a∣ε|\Delta c| \le |a|\varepsilon + |b|\varepsilon \approx 2|a|\varepsilon, while the true result ∣c∣|c| is small. The relative error of cc is then

∣Δc∣∣c∣≈2∣a∣∣c∣ ε.\frac{|\Delta c|}{|c|} \approx \frac{2|a|}{|c|}\,\varepsilon.

If aa and bb agree to kk digits (∣c∣≈∣a∣⋅10−k|c| \approx |a| \cdot 10^{-k}), the relative error of cc grows by a factor of 10k10^k, losing kk digits of precision. With double precision (~16 digits) and a=1a = 1, b=1−10−15b = 1 - 10^{-15}, the difference has at most one correct digit.

A worked example: the quadratic formula

Solve x2+bx+c=0x^2 + bx + c = 0 with b=107b = 10^7, c=1c = 1 via the standard formula. The two roots are x=(−b±b2−4c)/2x = (-b \pm \sqrt{b^2 - 4c}) / 2. For the root using ++, b2−4c≈b\sqrt{b^2 - 4c} \approx b, so the numerator is −b+b≈0-b + b \approx 0: massive cancellation. The other root, using −-, is fine.

The fix uses Vieta's identity x1x2=cx_1 x_2 = c: compute the well-conditioned root first, then get the other from x2=c/x1x_2 = c / x_1. No subtraction of near-equal numbers, no cancellation.

A second example: 1−cos⁡x1 - \cos x

Near x=0x = 0, cos⁡x≈1−x2/2\cos x \approx 1 - x^2/2, so 1−cos⁡x≈x2/21 - \cos x \approx x^2/2. Computing 1−cos⁡x1 - \cos x directly subtracts two nearly-equal numbers when xx is small. The half-angle identity 1−cos⁡x=2sin⁡2(x/2)1 - \cos x = 2\sin^2(x/2) avoids the cancellation: sin⁡(x/2)\sin(x/2) 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 1n∑(xi−μ)\frac{1}{n}\sum (x_i - \mu) where μ=1n∑xi\mu = \frac{1}{n}\sum x_i 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].

Subtracting nearly-equal numbers destroys significant digits: 1 − cos(x) loses accuracy as x → 0, while the equivalent 2 sin²(x/2) stays exact.
Subtracting nearly-equal numbers destroys significant digits: 1 − cos(x) loses accuracy as x → 0, while the equivalent 2 sin²(x/2) stays exact.

Check it on arrays

python · numpy
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 1−cos⁡1 - \cos collapses to zero; and shifting the bulk out of the data restores significance.

Exercises

Exercise 1. The numbers a=1.000000001a = 1.000000001 and b=1.0b = 1.0 are each known to relative error ε=10−16\varepsilon = 10^{-16}. Using the cancellation bound ∣Δc∣∣c∣≈2∣a∣∣c∣ ε\frac{|\Delta c|}{|c|} \approx \frac{2|a|}{|c|}\,\varepsilon, estimate how many significant digits survive in c=a−bc = a - b.

Solution

Step 1. The exact difference is c=a−b=10−9c = a - b = 10^{-9}, while ∣a∣≈1|a| \approx 1.

Step 2. Substitute into the bound:

∣Δc∣∣c∣≈2⋅110−9⋅10−16=2×10−7.\frac{|\Delta c|}{|c|} \approx \frac{2 \cdot 1}{10^{-9}} \cdot 10^{-16} = 2 \times 10^{-7}.

Step 3. A relative error of 2×10−72 \times 10^{-7} means about 77 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 99 digits are lost, matching ∣a∣/∣c∣=109|a|/|c| = 10^9.

Exercise 2. Derive a cancellation-free form of x+1−x\sqrt{x + 1} - \sqrt{x} for large xx, and explain why it is stable.

Solution

Step 1. For large xx the two square roots are nearly equal, so subtracting them directly cancels. Multiply by the conjugate over itself:

x+1−x=(x+1−x)(x+1+x)x+1+x=(x+1)−xx+1+x.\sqrt{x + 1} - \sqrt{x} = \frac{(\sqrt{x + 1} - \sqrt{x})(\sqrt{x + 1} + \sqrt{x})}{\sqrt{x + 1} + \sqrt{x}} = \frac{(x + 1) - x}{\sqrt{x + 1} + \sqrt{x}}.

Step 2. The numerator simplifies to 11, giving

x+1−x=1x+1+x.\sqrt{x + 1} - \sqrt{x} = \frac{1}{\sqrt{x + 1} + \sqrt{x}}.

Step 3. The rewritten form has no subtraction of near-equal numbers: it is a division of 11 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 x2+bx+c=0x^2 + bx + c = 0 with b=108b = 10^8, c=1c = 1. Compute the small root with the naive (−b+b2−4c)/2(-b + \sqrt{b^2 - 4c})/2 and with the Vieta form x2=c/x1x_2 = c / x_1 where x1x_1 is the well-conditioned root. Show the naive root is wrong and the Vieta root agrees with −c/b-c/b to full precision.

Solution
python · numpy
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 −7.45×10−9-7.45 \times 10^{-9}, off in the first digit, while the Vieta root is −10−8-10^{-8} 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. [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. [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).↩