灾难性抵消 · 两个相近的数相减

累积误差是慢性病,灾难性抵消是急性发作:两个相近的数一相减,有效数字当场湮灭,相对误差瞬间放大千万倍。这一章用求根公式的经典翻车现场演示它,并教你怎么重排公式把它绕开。

作者 David更新于 第 3 篇(共 21 篇)

灾难性抵消 · 两个相近的数相减

上一章的累积误差是「慢性病」——攒很多次才出问题。这一章的灾难性抵消(catastrophic cancellation) 是「急性发作」:一步就能让结果错得面目全非。 它是数值计算的头号杀手,却又极其隐蔽。

症状:相近的数相减,有效数字湮灭

问题出在两个非常接近的数相减。假设你有两个数,各自准确到 16 位有效数字,但它们前 15 位都相同:

a=1.2345678901234 5…b=1.2345678901234 2…a−b=0.0000000000000 3…\begin{aligned} a &= 1.2345678901234\,\mathbf{5}\ldots \\ b &= 1.2345678901234\,\mathbf{2}\ldots \\ a - b &= 0.0000000000000\,\mathbf{3}\ldots \end{aligned}

相减后,前 15 位那些相同的、可信的数字全抵消没了,只剩最后一位——而那一位恰恰是精度最差、最不可信的。原本 16 位有效数字,一减只剩 1 位,相对误差瞬间放大了 10¹⁵ 倍。数字没变多,可信度崩了。

翻车现场:求根公式

最经典的例子是初中就会背的求根公式 x=−b±b2−4ac2ax = \dfrac{-b \pm \sqrt{b^2-4ac}}{2a}。照着写:

import math
def roots_naive(a, b, c):
    d = math.sqrt(b*b - 4*a*c)
    return ((-b + d) / (2*a), (-b - d) / (2*a))

print(roots_naive(1, 1e8, 1))
# (-7.450580596923828e-09, -100000000.0)

两个根,大的那个 −108-10^8 没问题;可小的那个根应该是 −10−8-10^{-8},算出来却是 −7.45×10−9-7.45\times10^{-9}——错了约 25%! 为什么?看小根那一支:−b+b2−4ac-b+\sqrt{b^2-4ac},当 b=108b=10^8 时,b2−4ac≈108\sqrt{b^2-4ac}\approx 10^8,于是 −108+108-10^8 + 10^8——两个近乎相等的大数相减,灾难性抵消,精度全丢。

解药:重排公式,绕开减法

抵消不是算不出,是公式写错了。绕开它的思路:别让两个相近的数相减。 对求根公式,诀窍是用韦达定理 x1x2=cax_1 x_2 = \dfrac{c}{a}——先用「不抵消」的那一支算出一个根,另一个根用乘除关系反推:

def roots_stable(a, b, c):
    d = math.sqrt(b*b - 4*a*c)
    q = -(b + math.copysign(d, b)) / 2   # 让 b 和 √ 同号相加,绝不抵消
    return (q / a, c / q)                # 另一个根由 x1·x2 = c/a 反推

print(roots_stable(1, 1e8, 1))
# (-100000000.0, -1e-08)                  ← 两个根都对了

关键在 math.copysign(d, b):让 bb 和 ⋅\sqrt{\cdot} 取相同符号再相加——同号相加只会越加越大,永远不会抵消。先稳稳算出一个根 qq,另一个根用 c/qc/q 得到,躲开了那个危险的减法。同样的输入,换个写法,小根从错 25% 变成精确。公式没变、数学等价,数值行为天差地别——这就是数值计算的「艺术」。

它藏在很多常见计算里

灾难性抵消防不胜防,几个高频「案发地点」记一下:

  • 方差的朴素公式 1n∑xi2−xˉ2\frac{1}{n}\sum x_i^2 - \bar{x}^2:当数据均值大、方差小时,两个大数相减,方差算出来可能是负的(!)。改用两遍算法(先求均值再求偏差平方和)或 Welford 在线算法。
  • 1−cos⁡x1 - \cos x(x 很小时):cos⁡x≈1\cos x \approx 1,相减抵消。改用恒等式 1−cos⁡x=2sin⁡2(x/2)1-\cos x = 2\sin^2(x/2)。
  • x+1−x\sqrt{x+1} - \sqrt{x}(x 很大时):两个相近的根号相减。改成有理化后的 1x+1+x\dfrac{1}{\sqrt{x+1}+\sqrt{x}}(变成相加,不抵消)。
  • 数值微分 f(x+h)−f(x)h\frac{f(x+h)-f(x)}{h}:hh 很小时 f(x+h)≈f(x)f(x+h)\approx f(x),抵消——这是数值微分那章「天生不稳」的根源。

规律:凡是「两个相近的量相减」,就亮红灯。 看到就想:能不能用恒等式、有理化、或换个等价公式,把这个减法消掉?

小结与下一章

灾难性抵消是「两个相近的数相减,有效数字当场湮灭,相对误差爆炸」。求根公式是最经典的翻车现场:朴素写法小根错 25%,用韦达定理 + copysign 绕开减法即精确。它藏在方差、1−cos⁡x1-\cos x、x+1−x\sqrt{x+1}-\sqrt x、数值微分等无数地方——看到「相近的数相减」就警觉,重排公式绕开它。

抵消是「算法」层面的误差放大——换个写法能救。但还有一种放大是问题本身自带的,再好的算法也救不了。区分这两者,是误差分析的最后一块拼图。👉 条件数与稳定性:是问题病态,还是算法不稳。

参考文献

  • [1] David Goldberg.「What Every Computer Scientist Should Know About Floating-Point Arithmetic.」1991. —— 抵消与求根公式案例。
  • [2] Nick Higham.《Accuracy and Stability of Numerical Algorithms》, §1.7 —— 抵消的系统分析。
  • [3] Welford.「Note on a method for calculating corrected sums of squares.」1962. —— 稳定方差算法。

本页目录