你手里那把尺子
最后一章不引入新东西。它做三件事:把前十九章那些「✗ 这个直觉是错的」收成一张能贴在手边的自查表;给一张「什么时候该用什么」的决策表;然后回答一个从第 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) }; // ④
}
这四行每一行都踩了这本书讲过的坑。问:哪一行最危险?
判据不是「哪一行误差最大」,是「哪一行的失败最难被发现」。
二十条自查表
一章一条。读的时候,把每一条读成一个问句:「我上次写这样的代码时,是不是就是这么想的?」
| 章 | ✗ 听起来对的话 | ✓ 实际上 |
|---|---|---|
| 1 | 误差在小数点后十六位,无所谓 | 问它会不会被累加或放大。爱国者七个数量级 |
| 2 | double 有 15 位,15 位内的整数都精确 | 判据是 2⁵³,不是「15 位」。真正该看的是那个量级上的 ulp |
| 3 | 相消问题上更高精度就能解决 | 丢的位数只跟「前面有几位相同」有关。换写法,别换精度 |
| 4 | 误差有正有负,会互相抵消 | 方向一致时按 n 长;方向随机时按 √n 长。都不是 0 |
| 5 | 算不准是算法不好 | 先看条件数。κ=10¹⁰ 时,16 位输入最多给你 6 位 |
| 6 | 好算法给出准确答案 | 好算法给出「某个邻近问题的精确答案」。前向 ≲ κ × 后向 |
| 7 | 代回去验算对得上就是算对了 | 验算量的是后向误差。残差 4×10⁻¹⁶,答案可以错一半 |
| 8 | 求和就是一个 for 循环 | 六种写法七个数量级。先选计算路径,再选算法 |
| 9 | 求根公式是定理,不可能错 | 你写下的是路径不是公式。把式子里的减法圈出来 |
| 10 | E[x²]−E[x]² 是标准公式 | 它是为纸笔优化的。看 mean/σ 的比值 |
| 11 | softmax 只看差值,logit 多大无所谓 | 结果不依赖 X ≠ 过程不依赖 X。能待在 log 空间就别出来 |
| 12 | 浮点比较写 abs(a-b) < 1e-9 | 它把邻居判成不等,把差 400% 的判成相等。用相对判据或数格数 |
| 13 | 迭代多跑几步总是更准 | 到机器精度就不动了,甚至会变差。要有停机条件 |
| 14 | 步长小于 tol 就是收敛了 | 步长趋于 0 是必要不是充分。而且它可能永远够不着 |
| 15 | x = 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 += x | np.sum(成对)或 Neumaier;累加器提精度(8) |
| 方差 | E[x²]−E[x]² | 两遍法;只能一遍就 Welford;被卡住就先平移(10) |
| 解线性方程组 | inv(A) @ b | solve(A, b);多个右端项用 lu_factor(15) |
| 最小二乘 / 回归 | inv(AᵀA) @ Aᵀb | lstsq(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)-1、sqrt(x*x+y*y) | log1p、expm1、hypot(3, 9) |
| 二次方程 | (-b ± √d) / 2a | 算安全的那个根,另一个用 c/(a·x₁)(9) |
其实这不是一本讲浮点数的书
把这本书的核心内容抽掉「浮点」这个具体载体,剩下的是这样一句话:
任何一次「从有误差的输入得到结论」的过程,都由两个独立的因素决定结果的可信度:
- 问题有多敏感(条件数)——你改不了,只能测量它、然后决定问不问这个问题。
- 你的方法有多老实(稳定性)——你能改,而且通常改起来很便宜。
而验证的时候要分清两件事:「我的答案代回去对不对」(残差/后向误差)是你能算的,「我的答案离真相多远」(前向误差)是你想要的,两者之间隔着条件数。
这套语言在浮点之外的地方一样成立,而且那些地方几乎没人用它:
| 场景 | 条件数是什么 | 「残差小 ≠ 答案对」长什么样 |
|---|---|---|
| 民意调查 | 「谁领先」这个结论对样本的敏感度 | 抽样误差 ±3% 报得很清楚,而结论的误差没人报 |
| A/B 测试 | 两个相近转化率相减,κ 极大 | p 值很小,效应量的置信区间横跨零 |
| 机器学习 | 模型对训练集扰动的敏感度 | 训练 loss 是残差,泛化误差是前向误差 |
| 回归分析 | 共线性 = 设计矩阵的条件数 | R² 很高,系数正负随数据翻转 |
| 经济预测 | 模型对参数假设的敏感度 | 历史回测拟合得极好,往前一步就废 |
| 复盘与归因 | 反事实推断的条件数(通常极大) | 「如果当时不那样,结果会更好」——这是从两个大数里减出来的 |
所以这本书真正想留下的动作只有三个,它们和浮点无关:
- 拿到一个结论,先问它对输入有多敏感。做法:把输入扰动一点,看输出动多少。一行代码的事,几乎没人做。
- 分清「我验证的」和「我想要的」。残差、loss、单元测试、回测——这些量的都是后向的东西。它们能抓 bug,抓不出病态。
- 看到「两个大数相减」就停一下。差额、增量、变化率、修正项、净值、超额收益、提升幅度——这些词全都意味着一次相消,而你关心的正是那个被减出来的小量。
「知道这些之后,我应该到处加防御性检查:每个求和都用 Kahan,每个比较都用 ulps,每处除法都判零。」
不。这本书的目的不是让你变得多疑,是让你知道该去哪里看。绝大多数代码里的浮点运算完全没问题——double 有 15 位,而大部分业务逻辑用不到 6 位。
真正需要动手的地方有明确特征,一共就四条:① 长长的累加(n 很大,或者跑很久);② 两个相近的大数相减;③ 跨越很多数量级的量放在一个式子里;④ 结果要拿去做相等判断或者喂给下一步迭代。
没有这四条特征的地方,直接写 a + b 就好。数值意识的价值不在于处处小心,而在于在正确的十分之一处小心,其余九成放心地不管。
正确答案是 B:第 5 行那个一遍法方差公式。它是四行里唯一一个能产生负数的——而 Math.sqrt(负数) 是 NaN,NaN 会安静地污染后面所有东西,还会让 std > threshold 这类判断全部返回 false。
toFixed(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),但还不是共识。
- 《意外》(信息论)——第 11 章说「概率的正确表示是 log 概率」,那本书说「信息量 = −log p」。两条理由(不溢出 / 可加)其实是同一条:概率的信息全在指数里。
- 《当真》(PostgreSQL)——第 17 章的
numeric。那本书讲「PG 把类型当真」,这里给出了最贵的那个例子。 - 《后悔》(博弈论)——它的引擎全程跑 BigInt 精确有理数,正是这本书第 7 章那个实验用的手段:要证明「错不在算法」,就得先造一个零舍入的算法。
- 《同时》(并发)——第 19 章的并行归约不确定性。那本书讲「并发·异步·同步」三组正交概念,这里补上第四个:并行还会改变浮点结果。
- 《打分》(强化学习)和《重跑》(Compose)——第 18 章的混合精度、第 14 章的收敛判据,在训练循环里天天出现。
- 《上链》(Web3)——第 19 章说以太坊虚拟机不提供浮点。那不是能力问题,是共识对确定性的硬要求。
- 《证伪》(测试)——第 12 章的比较判据、第 19 章的跨平台断言。数值代码的测试要断言容差和状态,不能断言等号。
- 《进光》(摄影)——那本书讲「相机上没有一个旋钮只做一件事」,这本书讲「位数是预算,指数和尾数在抢」。是同一种思维方式:找出那个必须被分配的守恒量。
这一栏出现了二十次。它每一次说的都是同一件事,现在可以把它压成一句:
按定义、按公式、按最自然的顺序,把式子照字面写下来。 先问:这个式子里,我真正关心的那个量,是不是以「两个大数之差」的形式出现的?是,就重写到它不是为止。二十章里的每一次改写,都是这一个动作的不同外形:
- 有理化(3)、韦达定理(9)、三角恒等式(3)—— 把减法搬到分母上,或者搬走。
- 平移法(10)、差分 GPS(3)、中心化(16)—— 先减掉一个基准,在小量上算。
- log 空间(11)、log1p / expm1(9)—— 换一个刻度更合适的坐标。
- 补偿求和(8)、Welford(10)、master weights(18)—— 把被抹掉的那一点单独存着。
- 整数分(17)、bf16(18)、QR(16)—— 换一个格子。
五种手法,一个目的:让你要的那个小量,从一开始就有自己的位置,而不是从别的东西里减出来。
这一章的一句话
抹零每一步都在发生,它本身微不足道;而这本书从头到尾只在教一件事——判断它会不会被放大,以及在会的地方,换一种写法。
回到第 1 章那把尺子。它一直在那里:真值落在两根刻度之间,被推到最近的那一根,中间那一小段被抹掉。二十章下来,这张图没有变过,变的只是你现在知道那一小段什么时候会长大。