灾难性抵消 · 两个相近的数相减
累积误差是慢性病,灾难性抵消是急性发作:两个相近的数一相减,有效数字当场湮灭,相对误差瞬间放大千万倍。这一章用求根公式的经典翻车现场演示它,并教你怎么重排公式把它绕开。
灾难性抵消 · 两个相近的数相减
上一章的累积误差是「慢性病」——攒很多次才出问题。这一章的灾难性抵消(catastrophic cancellation) 是「急性发作」:一步就能让结果错得面目全非。 它是数值计算的头号杀手,却又极其隐蔽。
症状:相近的数相减,有效数字湮灭
问题出在两个非常接近的数相减。假设你有两个数,各自准确到 16 位有效数字,但它们前 15 位都相同:
相减后,前 15 位那些相同的、可信的数字全抵消没了,只剩最后一位——而那一位恰恰是精度最差、最不可信的。原本 16 位有效数字,一减只剩 1 位,相对误差瞬间放大了 10¹⁵ 倍。数字没变多,可信度崩了。
翻车现场:求根公式
最经典的例子是初中就会背的求根公式 。照着写:
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)两个根,大的那个 没问题;可小的那个根应该是 ,算出来却是 ——错了约 25%! 为什么?看小根那一支:,当 时,,于是 ——两个近乎相等的大数相减,灾难性抵消,精度全丢。
解药:重排公式,绕开减法
抵消不是算不出,是公式写错了。绕开它的思路:别让两个相近的数相减。 对求根公式,诀窍是用韦达定理 ——先用「不抵消」的那一支算出一个根,另一个根用乘除关系反推:
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):让 和 取相同符号再相加——同号相加只会越加越大,永远不会抵消。先稳稳算出一个根 ,另一个根用 得到,躲开了那个危险的减法。同样的输入,换个写法,小根从错 25% 变成精确。公式没变、数学等价,数值行为天差地别——这就是数值计算的「艺术」。
它藏在很多常见计算里
灾难性抵消防不胜防,几个高频「案发地点」记一下:
- 方差的朴素公式 :当数据均值大、方差小时,两个大数相减,方差算出来可能是负的(!)。改用两遍算法(先求均值再求偏差平方和)或 Welford 在线算法。
- (x 很小时):,相减抵消。改用恒等式 。
- (x 很大时):两个相近的根号相减。改成有理化后的 (变成相加,不抵消)。
- 数值微分 : 很小时 ,抵消——这是数值微分那章「天生不稳」的根源。
规律:凡是「两个相近的量相减」,就亮红灯。 看到就想:能不能用恒等式、有理化、或换个等价公式,把这个减法消掉?
小结与下一章
灾难性抵消是「两个相近的数相减,有效数字当场湮灭,相对误差爆炸」。求根公式是最经典的翻车现场:朴素写法小根错 25%,用韦达定理 + copysign 绕开减法即精确。它藏在方差、、、数值微分等无数地方——看到「相近的数相减」就警觉,重排公式绕开它。
抵消是「算法」层面的误差放大——换个写法能救。但还有一种放大是问题本身自带的,再好的算法也救不了。区分这两者,是误差分析的最后一块拼图。👉 条件数与稳定性:是问题病态,还是算法不稳。
参考文献
- [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. —— 稳定方差算法。