卷 III · 改写CH 12深度 12/20

两个数相等,是什么意思

「浮点数不能用 == 比较,要留一个 epsilon」——这句话每个人都听过,然后每个人都写下了 Math.abs(a-b) < 1e-9,然后就再没想过这件事。这一章要说明这个写法在两个方向上都会失效,而且失效得很彻底:在大数上它永远判不等(连相邻的两个 double 都判不等),在小数上它会把相差 400% 的两个数判成相等。

隔几格相对 vs 绝对numpy 的默认参数

▷ 猜一下差多少

0.1 + 0.2 算出来是 0.30000000000000004,而 0.30.3。这是全世界最有名的浮点数例子。

问:在数轴上,这两个数中间隔着多少个 double?

A 0 个。它们其实是同一个数,只是显示不同 B 1 个。它们是紧挨着的邻居,中间一个数都没有 C 大约 2000 个 D 大约 10¹⁶ 个。毕竟差了 4×10⁻¹⁷,而 double 的精度是 10⁻¹⁶

第二问:0.1 + 0.1 + 0.1 + … + 0.1(十个)等于 1.0 吗?

答案:它们是邻居

0.3                精确值 = 0.29999999999999998⟨8897769753748434595763683319091796875⟩
0.1 + 0.2          精确值 = 0.3000000000000000⟨444089209850062616169452667236328125⟩

真值 3/10 落在这两个 double 中间。
0.3 是离它最近的那个(所以 0.3 存进去就是它)。
0.1+0.2 恰好被推到了另一边那一格。

两者相隔:1 格。中间没有任何 double。

这件事值得停一下,因为它把整个例子的调性都改了。「0.1+0.2 ≠ 0.3」这个著名的失败,其实是可能犯的最小的错误。它不是「浮点数很不准」的证据,它是「浮点数准到只能差一格」的证据。

第二问的答案更有意思:sum([0.1] * 10) === 1.0true,一格都不差。十次抹零的方向有上有下,最后正好回到刻度上。而 0.1 + 0.2 这两次就没这个运气。

所以「什么时候相等」这件事,本质上没有规律可讲——它取决于每一步被抹到了哪一边。这就是为什么需要一个比较策略。

三种比较,三种失效

写法在哪里对在哪里失效
a === b整数(< 2⁵³)、常量、位模式相同任何经过运算的小数
|a−b| < ε
绝对容差
你写 ε 时心里想的那个量级大数:永远不等。小数:全都相等。
|a−b| < ε·|b|
相对容差
绝大多数场合b 接近 0 时。还有:a 和 b 谁做分母?不对称
ulps(a,b) < N
隔几格
直接量「差了几步舍入」,量级无关跨越 0 时(+0 和 −0 之间的距离没有意义)

把这几对数放进去试:

ab|a−b|隔几格绝对容差 1e-9 判
0.1+0.20.35.55 × 10⁻¹⁷1相等 ✓
10¹⁰10¹⁰+10⁻⁶1.907 × 10⁻⁶1不等 ✗(它们是邻居)
10⁻¹²5 × 10⁻¹²4 × 10⁻¹²1.02 × 10¹⁶相等 ✗(差了 400%)
11+eps2.22 × 10⁻¹⁶1相等 ✓
10⁻³²⁰2 × 10⁻³²⁰10⁻³²⁰2024相等(次正规区)

中间两行就是绝对容差的两个失效方向。它们不是边角情况——只要你的程序处理的量跨越多个数量级(价格、距离、时间、损失值),就一定会同时踩到两头。

「隔几格」怎么算

IEEE 754 有一个非常漂亮的设计:把正 double 的位模式当成 64 位整数来读,顺序和数值顺序完全一致。于是「隔几格」就是两个整数相减。

const buf = new ArrayBuffer(8);
const f = new Float64Array(buf), u = new BigUint64Array(buf);

function ord(x) {                          // 把位模式映射成单调整数
  f[0] = x;
  const b = u[0];
  return (b & 0x8000000000000000n)          // 负数:翻转成对称的负整数
    ? 0x8000000000000000n - (b & 0x7fffffffffffffffn)
    : b;
}
function ulpsBetween(a, b) {
  const d = ord(a) - ord(b);
  return d < 0n ? -d : d;
}

ulpsBetween(0.1 + 0.2, 0.3)     // 1n
ulpsBetween(1e10, 1e10 + 1e-6)  // 1n

用它写断言,判据非常直观:「允许差 4 次舍入」就写 ulpsBetween(a,b) <= 4n。这个数字和量级无关,写一次就到处能用。

◆ 主线

实践里的推荐写法是「相对容差 + 一个小的绝对容差兜底」,两者取或:

相等  ⇔  |a − b| ≤ max( rel_tol × max(|a|, |b|),  abs_tol )
                          └────────┬────────┘   └───┬───┘
                           管一般情况,量级无关     只管 0 附近

这正是 Python math.isclose 的公式(PEP 485)。两个参数各有各的职责,缺一个就会在一头失效:

  • 只有 abs_tol ⇒ 上面那张表里中间两行的灾难。
  • 只有 rel_tol ⇒ 和 0 比较时永远不等(math.isclose(0, 1e-300) 返回 False)。

abs_tol 该填多少?填「小于这个数我就当它是 0」的那个值。这是个业务问题,不是数值问题——钱是 0.005 元,像素是 0.5,概率可能是 1e-12。

标准库的默认值,看清楚再用

函数默认参数要注意的
math.isclose(a,b)rel_tol=1e-9, abs_tol=0.0对称(用 max(|a|,|b|));和 0 比永远 False
numpy.isclose(a,b)rtol=1e-5, atol=1e-8不对称(公式是 |a−b| ≤ atol + rtol·|b|);atol=1e-8 会把 10⁻¹² 和 5×10⁻¹² 判成相等
assertEquals(a,b,δ)(JUnit)要你自己填 δ纯绝对容差,两头都会失效
toBeCloseTo(a,n)(Jest)n=2,即 |a−b| < 0.005纯绝对容差,且默认非常松

numpy.isclose 那一行是真实世界里最常见的陷阱:它的默认 atol=1e-8 意味着,任何小于 10⁻⁸ 的两个数,在它眼里都相等。算概率、算梯度、算小残差时,这个默认值会让断言变成一句空话。np.testing.assert_allclose 的默认 atol 是 0,行为更合理——写测试时用后者。

✎ 术语正名

epsilon 这个词在比较的语境里被用得最乱。它至少指三样完全不同的东西:

  • 机器 epsilon(第 2 章):2⁻⁵² = 2.22×10⁻¹⁶。硬件属性,固定值。
  • 你写在断言里的容差1e-9 之类。这是业务判断,跟机器无关。
  • 算法里的收敛阈值while (err > eps)。这是第 14 章的话题。

三者常被同一个变量名 eps 装着,然后互相污染——最典型的错误是把机器 epsilon 直接当容差用:Math.abs(a-b) < Number.EPSILON这个判据只在 1.0 附近有意义,在 1000 附近它比一格还小(永远不等),在 0.001 附近它宽得离谱。

什么时候 == 是对的

不要走到另一个极端。== 在这些场合完全正确,而且是唯一正确的写法:

  • 整数在 2⁵³ 以内。3.0 === 3.0Math.floor(x) === 0——精确,没有任何疑问。
  • 和自己比、和刚存进去的常量比。没经过运算就没抹过零。
  • 哨兵值。x === Infinityx !== x(这是标准的 NaN 检测,因为 NaN !== NaN)。
  • 循环终止在整数计数上。for (let i = 0; i !== n; i++) 安全;for (let x = 0; x !== 1; x += 0.1) 是死循环(因为累加十次不一定正好到 1——虽然上面看到 0.1×10 恰好等于 1,0.1×3 就不等于 0.3 了)。

另外两个位层的坑,顺手记下:

  • -0.0 === 0.0true,但它们的位模式不同(0x8000000000000000 vs 0x0)。Object.is(-0, 0)false1/-0-Infinity。排序、去重、当 Map 的键时要小心。
  • NaN !== NaN,所以 [NaN].includes(NaN)true(用 SameValueZero)而 [NaN].indexOf(NaN)-1(用 ===)。同一个数组,两个方法给出相反的答案。
⌨ 自己跑一遍

把「隔几格」这个工具装进手边,它比任何容差都好用:

import math, struct
def ulps(a, b):
    o = lambda x: (lambda u: u if u >= 0 else -0x8000000000000000 - u)(
        struct.unpack('<q', struct.pack('<d', x))[0])
    return abs(o(a) - o(b))

pairs = [(0.1 + 0.2, 0.3), (1e10, 1e10 + 1e-6), (1e-12, 5e-12),
         (1.0, 1.0 + 2**-52), (0.1 * 3, 0.3), (sum([0.1] * 10), 1.0)]
for a, b in pairs:
    print(f'{a!r:>24} vs {b!r:<22} 隔 {ulps(a,b):>18} 格  '
          f'|Δ|<1e-9? {abs(a-b) < 1e-9}')

import numpy as np
print('math.isclose(1e-12, 5e-12) =', math.isclose(1e-12, 5e-12))   # False  ✓
print('np.isclose  (1e-12, 5e-12) =', np.isclose(1e-12, 5e-12))     # True   ✗
print('math.isclose(0, 1e-300)    =', math.isclose(0, 1e-300))      # False

会打出:

     0.30000000000000004 vs 0.3                    隔                  1 格  |Δ|<1e-9? True
           10000000000.0 vs 10000000000.000002     隔                  1 格  |Δ|<1e-9? False
                   1e-12 vs 5e-12                  隔  10245139294026372 格  |Δ|<1e-9? True
                     1.0 vs 1.0000000000000002     隔                  1 格  |Δ|<1e-9? True
     0.30000000000000004 vs 0.3                    隔                  1 格  |Δ|<1e-9? True
                     1.0 vs 1.0                    隔                  0 格  |Δ|<1e-9? True

第二行和第三行并排看:同一个判据,把邻居判成不等,把差 400% 的判成相等。

python3 cmp.py

在线:python.org/shell(把 numpy 那两行删掉)。JS 版见上面的 ulpsBetween

▸ 在现实里

单元测试。这是绝对容差最大的产地。assertEquals(expected, actual, 1e-9) 在测「归一化后的概率」时太松(1e-9 能盖住一堆真 bug),在测「总资产」时太紧(永远失败)。换成相对容差,然后为「接近 0」的情况单独写一个绝对下限。

几何判断。「这个点在不在这条线上」「这三点共不共线」——计算几何里所有的判定都要面对这个问题,而且更严重:不同的判定之间必须一致(如果 A 在线上、B 在线上,那 AB 中点也该在线上),而容差比较不保证这一点。这就是为什么严肃的几何库(CGAL)用精确谓词:先用浮点快速算,不确定时退回精确有理数算术。这是「快速路径 + 精确兜底」这个模式的经典应用。

图形学的 z-fighting。两个共面的三角形在深度缓冲里的值差不到一个 ulp,于是渲染顺序随视角闪烁。修法不是调容差,是换表示——反向深度缓冲(reverse-Z)把精度花在近处,正好利用了第 2 章那个「刻度不均匀」的性质。

对账。「这两笔金额算不算一致」是个业务问题,不是数值问题——答案通常是「差额小于一分钱」,也就是一个明确的绝对容差 0.005 元。这是绝对容差正确的少数场合之一,因为钱本来就有一个天然的最小单位。第 17 章会说,更好的做法是干脆别用浮点。

✗ 这个直觉是错的

「浮点数不能用 == 比,要用 abs(a-b) < 1e-9。」

前半句在很多场合也是错的(整数、常量、哨兵值都该直接用 ==),后半句在两个方向上都会失效——这一章的全部内容。

这条建议之所以流传这么广,是因为它在教学例子上永远有效:教材里的数字都在 1 附近,1e-9 在那个量级上恰好合适。而真实程序里的数横跨十几个数量级。

正确的版本要多说两句,但也就两句:用相对容差处理一般情况,用一个业务上有意义的绝对容差兜住 0 附近。或者直接用「隔几格」——它天生就是量级无关的,而且能告诉你一个更有用的信息:这个差距相当于几次舍入。差 2 格是正常运算误差,差 10¹⁶ 格是算错了。

◇ 对账

正确答案是 B隔 1 格,它们是紧挨着的邻居。第二问:sum([0.1]*10) === 1.0true一格都不差

A 「它们其实是同一个数」——不是。它们是两个不同的 double,位模式不同,=== 返回 false。但这个直觉抓到了一件对的事:它们之间的差距是可能存在的最小差距。 C 「大约 2000 格」——2024 这个数出现在另一处:1e-3202e-320 之间隔 2024 格。那是次正规区,刻度是等距的,所以「相差一倍」也只隔两千格。同样是「差一倍」,在正规区隔 2⁵² 格,在次正规区隔两千格——这就是次正规数的代价:它们的相对精度很差。 D 「大约 10¹⁶ 格」——这个思路是「差 4×10⁻¹⁷,而 double 的精度是 10⁻¹⁶,所以……」,把两个不同量级的量做了除法。10¹⁶ 这个数在这一章里确实出现了,但它是 1e-125e-12 之间的距离(1.02×10¹⁶ 格)。换句话说:一对看起来「几乎相等」的数隔 1 格,一对看起来「都很小」的数隔一万万亿格。「看起来接近」和「隔得近」是两回事。
◈ 换一种写法

比较这件事,按场景选写法:

Math.abs(a - b) < 1e-9  —— 只在一个量级上正确 一般情况:Math.abs(a-b) <= Math.max(1e-9 * Math.max(Math.abs(a), Math.abs(b)), absTol) 写测试:math.isclose / np.testing.assert_allclose(rtol=…, atol=0),别用 np.isclose 的默认参数 调试数值算法:直接看隔几格。差 1–4 格是正常舍入,差 10⁶ 格说明有相消,差 10¹⁵ 格说明算错了 钱、像素、格子:别比浮点,比整数(第 17 章) 几何谓词:浮点快速判断 + 不确定时退回精确算术,而不是调容差

最后一条通则,也是这一章最该带走的:「相等」在浮点世界里不是一个数学概念,是一个你必须为自己的场景定义的业务概念。写下容差的那一刻,你是在声明「多小的差别我不在乎」——这句话没有通用答案。

这一章的一句话

绝对容差是一把固定长度的尺子,而浮点数轴的刻度横跨六百个数量级;要么用相对的尺子,要么直接数隔了几格。

卷 III 到此结束——四章,四次改写,每一次都是「同一个数学,换一条计算路径」。

卷 IV 换一个模式:不再一步算完,改成一步步逼近。这带来一个新的好处和一个新的麻烦。好处是牛顿法这样的算法能让正确位数每一步翻倍——从 0.5 位到 1.2 位到 2.8 位到 5.8 位到 11.9 位,五步撞上 double 的天花板,而二分法要走 52 步。麻烦是:既然是逼近,就得决定什么时候停——而停机判据是这本书里最容易写错的东西。