卷 V · 换格CH 20深度 20/20

你手里那把尺子

最后一章不引入新东西。它做三件事:把前十九章那些「✗ 这个直觉是错的」收成一张能贴在手边的自查表;给一张「什么时候该用什么」的决策表;然后回答一个从第 1 章就悬着的问题——这套东西真正的用途,其实不在浮点数上。

自查表决策表继续往下走

▷ 猜一下差多少

最后一次。下面这段代码算一份日报——一千个成交价(double),要出均值、标准差和手续费:

function 日报(prices) {
  let sum = 0, sumSq = 0;
  for (const p of prices) { sum += p; sumSq += p * p; }        // ①
  const n = prices.length, mean = sum / n;
  const variance = sumSq / n - mean * mean;                     // ②
  let fee = 0;
  for (const p of prices) fee += p * 0.001;                     // ③
  return { mean, std: Math.sqrt(variance), fee: fee.toFixed(2) }; // ④
}

这四行每一行都踩了这本书讲过的坑。问:哪一行最危险?

A ① 朴素累加,误差按 √n 长 B ② 一遍法方差公式 C ③ 用 double 累加手续费 D ④ toFixed(2) 的舍入

判据不是「哪一行误差最大」,是「哪一行的失败最难被发现」

二十条自查表

一章一条。读的时候,把每一条读成一个问句:「我上次写这样的代码时,是不是就是这么想的?」

✗ 听起来对的话✓ 实际上
1误差在小数点后十六位,无所谓问它会不会被累加放大。爱国者七个数量级
2double 有 15 位,15 位内的整数都精确判据是 2⁵³,不是「15 位」。真正该看的是那个量级上的 ulp
3相消问题上更高精度就能解决丢的位数只跟「前面有几位相同」有关。换写法,别换精度
4误差有正有负,会互相抵消方向一致时按 n 长;方向随机时按 √n 长。都不是 0
5算不准是算法不好先看条件数。κ=10¹⁰ 时,16 位输入最多给你 6 位
6好算法给出准确答案好算法给出「某个邻近问题的精确答案」。前向 ≲ κ × 后向
7代回去验算对得上就是算对了验算量的是后向误差。残差 4×10⁻¹⁶,答案可以错一半
8求和就是一个 for 循环六种写法七个数量级。先选计算路径,再选算法
9求根公式是定理,不可能错你写下的是路径不是公式。把式子里的减法圈出来
10E[x²]−E[x]² 是标准公式它是为纸笔优化的。看 mean/σ 的比值
11softmax 只看差值,logit 多大无所谓结果不依赖 X ≠ 过程不依赖 X。能待在 log 空间就别出来
12浮点比较写 abs(a-b) < 1e-9它把邻居判成不等,把差 400% 的判成相等。用相对判据或数格数
13迭代多跑几步总是更准到机器精度就不动了,甚至会变差。要有停机条件
14步长小于 tol 就是收敛了步长趋于 0 是必要不是充分。而且它可能永远够不着
15x = A⁻¹b 是定义,照着写看到 ⁻¹ 读成「解一个方程」。求逆丢掉后向稳定性
16κ(A)=10⁸ 不大,double 够用正规方程把它平方。你的解法会创造新的条件数
17用 double 算,显示时四舍五入钱的刻度是十进制、等距、有最小单位的。这是类型问题
18精度越高越好位数是预算。先定范围,再定精度
19编译器优化不改变语义浮点上它明确会改。可复现要专门去买
20知道这些之后,要到处加防御性检查见下面最后一节。这是这本书的最后一条错误直觉

决策表:该用什么格子

你在算什么用什么理由(第几章)
钱、票数、库存、ID、纳秒、像素、区块高度整数(存最小单位)有天然最小单位;加减精确(2, 4, 17)
需要十进制语义的账务计算Decimal / BigDecimal / PG numeric刻度对齐,舍入模式可控(17)
一般科学计算、几何、物理double默认答案,15 位够绝大多数场合
神经网络训练 / 推理bf16 存 + fp32 累加 + fp32 master weights范围比精度值钱(8, 18)
几何谓词(共线、内外、相交)double 快速路径 + 精确有理数兜底判定之间必须一致(12)
需要保证误差界(安全攸关)区间算术(Arb、MPFI)算出来的是一个包住真值的区间
共识、锁步同步、审计整数或定点,禁用浮点必须位级一致(19)
符号推导、精确常数sympy / Fraction没有抹零(7 的实验就是这么做的)

决策表:该用什么算法

要做的事别这么写这么写
求和for … s += xnp.sum(成对)或 Neumaier;累加器提精度(8)
方差E[x²]−E[x]²两遍法;只能一遍就 Welford;被卡住就先平移(10)
解线性方程组inv(A) @ bsolve(A, b);多个右端项用 lu_factor(15)
最小二乘 / 回归inv(AᵀA) @ Aᵀblstsq(QR/SVD);先标准化;必要时加正则(16)
softmax / 交叉熵log(softmax(z))log_softmax(z) / cross_entropy(logits, y)(11)
求根裸牛顿法 + 固定步数brentq,或带保护的牛顿 + 迭代上限 + 状态返回(13, 14)
比较abs(a-b) < 1e-9相对 + 绝对兜底,或者数隔几格(12)
小量log(1+x)exp(x)-1sqrt(x*x+y*y)log1pexpm1hypot(3, 9)
二次方程(-b ± √d) / 2a算安全的那个根,另一个用 c/(a·x₁)(9)

其实这不是一本讲浮点数的书

把这本书的核心内容抽掉「浮点」这个具体载体,剩下的是这样一句话:

◆ 主线(最终版)

任何一次「从有误差的输入得到结论」的过程,都由两个独立的因素决定结果的可信度:

  • 问题有多敏感(条件数)——你改不了,只能测量它、然后决定问不问这个问题。
  • 你的方法有多老实(稳定性)——你能改,而且通常改起来很便宜。

而验证的时候要分清两件事:「我的答案代回去对不对」(残差/后向误差)是你能算的,「我的答案离真相多远」(前向误差)是你想要的,两者之间隔着条件数。

这套语言在浮点之外的地方一样成立,而且那些地方几乎没人用它:

场景条件数是什么「残差小 ≠ 答案对」长什么样
民意调查「谁领先」这个结论对样本的敏感度抽样误差 ±3% 报得很清楚,而结论的误差没人报
A/B 测试两个相近转化率相减,κ 极大p 值很小,效应量的置信区间横跨零
机器学习模型对训练集扰动的敏感度训练 loss 是残差,泛化误差是前向误差
回归分析共线性 = 设计矩阵的条件数R² 很高,系数正负随数据翻转
经济预测模型对参数假设的敏感度历史回测拟合得极好,往前一步就废
复盘与归因反事实推断的条件数(通常极大)「如果当时不那样,结果会更好」——这是从两个大数里减出来的

所以这本书真正想留下的动作只有三个,它们和浮点无关:

  1. 拿到一个结论,先问它对输入有多敏感。做法:把输入扰动一点,看输出动多少。一行代码的事,几乎没人做。
  2. 分清「我验证的」和「我想要的」。残差、loss、单元测试、回测——这些量的都是后向的东西。它们能抓 bug,抓不出病态。
  3. 看到「两个大数相减」就停一下。差额、增量、变化率、修正项、净值、超额收益、提升幅度——这些词全都意味着一次相消,而你关心的正是那个被减出来的小量。
✗ 这个直觉是错的

「知道这些之后,我应该到处加防御性检查:每个求和都用 Kahan,每个比较都用 ulps,每处除法都判零。」

不。这本书的目的不是让你变得多疑,是让你知道该去哪里看。绝大多数代码里的浮点运算完全没问题——double 有 15 位,而大部分业务逻辑用不到 6 位。

真正需要动手的地方有明确特征,一共就四条: 长长的累加(n 很大,或者跑很久); 两个相近的大数相减; 跨越很多数量级的量放在一个式子里; 结果要拿去做相等判断或者喂给下一步迭代

没有这四条特征的地方,直接写 a + b 就好。数值意识的价值不在于处处小心,而在于在正确的十分之一处小心,其余九成放心地不管。

◇ 对账

正确答案是 B:第 5 行那个一遍法方差公式。它是四行里唯一一个能产生负数的——而 Math.sqrt(负数)NaN,NaN 会安静地污染后面所有东西,还会让 std > threshold 这类判断全部返回 false

A ① 朴素累加确实有问题(第 8 章),但一千个同量级的价格,误差在 √1000 ≈ 32 个 ulp 量级——相对误差 10⁻¹⁵,在这个场景下完全无害。这一行属于「知道就好,不用改」的那类。 C ③ 用 double 累加手续费是真问题(第 17 章),但它的失败是可见的:对账时会差几厘钱,而对账本来就会做。会被发现的问题,危险性远低于不会被发现的。 DtoFixed(2) 的舍入偏差(第 17 章)同样真实,同样是「差一分钱」级别,同样会在对账时暴露。 排序:② ≫ ③ ≈ ④ ≫ ①。排序的依据不是误差大小,是「失败会不会被发现」。这是整本书里最该带走的一个判断标准——它解释了为什么每一章的「✗」栏里,我都在强调那些失败不报错
⌨ 自己跑一遍

最后一个动手位,是这本书唯一一个可以用在你自己代码上的:扰动实验。它不需要知道条件数的定义,也不需要任何库。

import random

def sensitivity(f, x, rel=1e-8, trials=200):
    """把输入各自扰动 rel,看输出最多动多少倍。这就是条件数的实测版。"""
    y0, worst = f(x), 0.0
    for _ in range(trials):
        xp = [v * (1 + rel * random.uniform(-1, 1)) for v in x]
        y = f(xp)
        if y0 != 0:
            worst = max(worst, abs(y - y0) / abs(y0) / rel)
    return worst

one = lambda a: sum(v*v for v in a) / len(a) - (sum(a) / len(a)) ** 2   # 一遍法
two = lambda a: (lambda m: sum((v-m)**2 for v in a) / len(a))(sum(a)/len(a))  # 两遍法

for base, name in ((0.0, 'data = 0..49'), (1e6, 'data = 1e6+i'), (1e9, 'data = 1e9+i')):
    random.seed(1)
    d = [base + i for i in range(50)]
    print(name)
    print('   求和       ', '%.3g' % sensitivity(lambda a: sum(a), d))
    print('   两遍法方差 ', '%.3g' % sensitivity(two, d))
    print('   一遍法方差 ', '%.3g' % sensitivity(one, d))

会打出:

data = 0..49
   求和        0.266
   两遍法方差  1.1
   一遍法方差  0.917
data = 1e6+i
   求和        0.257
   两遍法方差  3.69e+04
   一遍法方差  2.71e+04
data = 1e9+i
   求和        0.257
   两遍法方差  5.4e+07        ★ 换算法也救不了
   一遍法方差  1.5e+08

这张表比预期的更值钱,因为它同时说了两件事:

  • 求和永远良态(κ ≈ 0.26,三份数据都一样)。这类计算可以放心不管。
  • 当均值远大于标准差时,「方差」这个量本身就是病态的——两遍法的 κ 是 5.4×10⁷。第 10 章说一遍法公式不好,这里补上更重要的下半句:换成两遍法只是不再算出负数,它并没有让这个问题变得可信。如果你的原始数据带 10⁻⁸ 的相对误差(任何测量数据都带),那么它的方差最多只有半位可信。

f 换成你自己的函数,x 换成你自己的输入。这二十行是这本书里最该被复制走的东西:它不告诉你答案对不对,它告诉你这个答案值不值得相信

python3 sensitivity.py

在线:python.org/shell。它对任何语言、任何函数都适用,包括不是数值计算的那些——把「输入」换成配置参数、超参数、业务假设,一样能跑。

▸ 在现实里:继续往下走

要读的:

  • Goldberg, 《What Every Computer Scientist Should Know About Floating-Point Arithmetic》(ACM Computing Surveys, 1991)。四十多页,免费,是这个领域的入门经典。第 1 到 3 章的内容在它前十页里。
  • Higham, 《Accuracy and Stability of Numerical Algorithms》(第 2 版,2002)。这本书的第 5 到 16 章,基本都是它的一个章节的科普版。它是这个领域的圣经,而且写得意外地好读。
  • Trefethen & Bau, 《Numerical Linear Algebra》。「条件数是问题的、稳定性是算法的」这个框架,讲得最清楚的就是它的第 III 部分。
  • Muller 等,《Handbook of Floating-Point Arithmetic》(第 2 版,2018)。要查具体细节时用它。
  • Kahan 的讲义(他在 Berkeley 的主页上)。IEEE 754 的主要设计者,文风尖锐,对「为什么这么设计」讲得最透。

要装的工具:

  • Herbie(华盛顿大学)。它把卷 III 那件事自动化了——输入一个浮点表达式,它搜索代数等价的改写,输出误差更小的版本。玩一次会很震撼。
  • mpmath / MPFR / Arb。任意精度 + 正确舍入。第 6、7 章的对照实验就是用 mpmath 做的。Arb 还带区间,能给出误差界。
  • Verificarlo / CADNA。用「随机舍入」跑同一份代码很多遍,从结果的离散程度估计有几位可信。不用改代码就能给你一个「这个结果能信几位」的数字。
  • CORE-MATH。正确舍入的数学函数库,正在被 glibc 逐步吸收——第 19 章那个「libm 各家不一致」的问题,长期解法就是它。

值得动手的项目:

  • 写一个正确舍入的求和(Shewchuk 的多重展开法或 math.fsum 的算法)。一百行,会让你对 Sterbenz 引理有肌肉记忆。
  • 实现 Shewchuk 的鲁棒几何谓词。这是「快速路径 + 精确兜底」这个模式最漂亮的实现。
  • 给你自己的项目跑一遍扰动实验(上面那二十行)。这是投入产出比最高的一个。

有争议的地方,照实说:

  • posit / unum(Gustafson 提出的替代格式)。支持者认为它在同样位数下精度更高、没有 NaN 的麻烦;反对者(包括 Kahan 本人,措辞相当激烈)认为它的「精度更高」是在挑选过的场景下测出来的,而它丢掉的动态范围和渐进下溢在真实工作负载里会咬人。目前没有主流硬件采用。这件事还没有定论,值得自己去看两边的论证。
  • 随机舍入(stochastic rounding)在低精度训练里的效果。它把抹零的方向随机化,从而让误差按 √n 而不是 n 长(第 4 章)——理论很干净,实测收益在不同任务上差别很大。已经有硬件支持(Graphcore IPU),但还不是共识。
◈ 换一种写法(最后一次)

这一栏出现了二十次。它每一次说的都是同一件事,现在可以把它压成一句:

按定义、按公式、按最自然的顺序,把式子照字面写下来。 先问:这个式子里,我真正关心的那个量,是不是以「两个大数之差」的形式出现的?是,就重写到它不是为止。

二十章里的每一次改写,都是这一个动作的不同外形:

  • 有理化(3)、韦达定理(9)、三角恒等式(3)—— 把减法搬到分母上,或者搬走。
  • 平移法(10)、差分 GPS(3)、中心化(16)—— 先减掉一个基准,在小量上算。
  • log 空间(11)、log1p / expm1(9)—— 换一个刻度更合适的坐标。
  • 补偿求和(8)、Welford(10)、master weights(18)—— 把被抹掉的那一点单独存着。
  • 整数分(17)、bf16(18)、QR(16)—— 换一个格子。

五种手法,一个目的:让你要的那个小量,从一开始就有自己的位置,而不是从别的东西里减出来。

这一章的一句话

抹零每一步都在发生,它本身微不足道;而这本书从头到尾只在教一件事——判断它会不会被放大,以及在会的地方,换一种写法。

回到第 1 章那把尺子。它一直在那里:真值落在两根刻度之间,被推到最近的那一根,中间那一小段被抹掉。二十章下来,这张图没有变过,变的只是你现在知道那一小段什么时候会长大

读完了。回到目录,或者看看书架上别的