浮点数是“接近”的约定 — 求和、比较、ID、方差、百分比
一句话总结
float 并不是算错,而是按固定的方式近似地记录。问题在于这种“近似”与服务规则相冲突的地方:求和结果随顺序变化,== 变成假,64 位 ID 在浏览器里变成相邻的 ID,方差变成负数,百分比之和变成 99。本模块会逐一用数字确认这些地方。
为什么需要它
会收到这样的反馈:结算批处理的合计每次重跑都相差几韩元。这是因为并行拆分相加的顺序每次都不一样。在管理界面点击某个订单,打开的却是另一个订单。服务器把 ID 作为 JSON 数字发出,浏览器把它当 double 读取。响应时间仪表盘的标准差偶尔是 NaN。用 epoch 毫秒求平方的平均值时,对负方差开了平方根。这些情况都不会抛出异常。
工作原理
0.1 不是 0.1。 Python 教程写道,在几乎所有平台上,float 都是 53 位精度的 IEEE 754 binary64,并用 Decimal.from_float(0.1) 展示了存为 0.1 的实际值是 0.1000000000000000055511151231257827021181583404541015625。按 decimal 文档,Decimal(float) 会把二进制值无损地转成十进制,所以这是查看 float 实际值最诚实的窗口。float.hex() 用尾数和指数展示同一个值。
求和取决于顺序。 每加一次,结果都要舍入到 53 位,所以在大值旁边,小值的低位会被截掉。因此同一个数列,从前往后加、从后往前加、排序后再加,会得到不同的值。math.fsum 会带着多个部分和并追踪丢失的位。要当心的一点是内置的 sum()。按 functions 文档,自 3.12 起,float 求和改用了更精确、更接近交换律的算法。所以本实验把“从前往后加的和”定义为 for 循环中的 +=。这也意味着,从其他语言或 3.11 及以下版本迁移过来的代码,数字可能会不同。
比较不用 ==,而用 math.isclose。默认值是 rel_tol=1e-09、abs_tol=0.0,文档写道,与 0 比较时,isclose(x, 0) 对非零的 x 始终为假,所以要给出合适的 abs_tol。在余额应为 0 的检查里,如果漏掉 abs_tol,就会因为一个 1e-16 的余额而失败。
超过 2^53 的整数。 MDN 的 Number.MAX_SAFE_INTEGER 是 9007199254740991(2^53 − 1),并写道 MAX_SAFE_INTEGER + 1 === MAX_SAFE_INTEGER + 2 为真。RFC 8259 第 6 节也说,使用 binary64 的各个实现之间,在 [−(2^53)+1, 2^53−1] 范围内的整数上,值是完全一致的。超出这个范围就没有这样的约定。像 Snowflake 那样在 2^60 附近增长的 64 位 ID,如果作为 JSON 数字发出,接收方一旦按 double 读取,相近的 ID 就会变成同一个值。解决办法是把 ID 作为字符串发出。Python 一侧的 json.loads 会把整数读成 int,毫无问题,所以服务器测试发现不了。
舍入。 round() 在两个候选同样接近时,会舍入到偶数一侧,文档解释说,round(2.675, 2) 得到 2.67 而不是 2.68,不是 bug,而是因为 2.675 无法用 float 精确表示。要把舍入模式固定为策略,就直接从字符串构造 Decimal,再用 quantize(..., rounding=ROUND_HALF_UP)。逐行舍入后相加的值,与对合计只舍入一次的值不同,这不是误差,而是契约的差别。
方差。 教科书公式 E[x²] − E[x]² 是两个大数之差。Algorithms for calculating variance 词条写道,当标准差相对于均值较小时,这种抵消尤其严重,并举例说,把 4、7、13、16 平移 10^9,这个公式得到 −170.67(真值为 30)。同一词条介绍的 Welford(1962)方法,一次更新均值和偏差平方和,避开了这种抵消。
百分比。 如果把每个份额分别舍入,合计就会变成 99 或 101。最大余数法(Hamilton 法)先分配份额的整数部分,再按小数部分从大到小,把剩下的名额一个一个分出去。
在现场相遇的样子
- 如果服务器日志里的订单 ID 与界面上显示的 ID 只在末尾两三位不同,先怀疑 double。在 2^60 附近,相邻 double 的间隔是 256,所以末位会在这个幅度内被抹平。
- 对于“账单的行合计与总计相差几美分”的咨询,与其说是计算错误,更多是没有确定“在哪里舍入”的契约问题。金额的整数最小单位、舍入策略、分摊,“银行现场的语言”课程的 fde-bank-money 有深入讲解;剩下的 1 韩元给哪个品目,则由“订单100笔,结算却只有98笔”课程的 dk-com-allocate 深入讲解。
- 监控界面里的标准差偶尔为空,可能是负方差的平方根变成 NaN 留下的痕迹。把值先减去基准点,或者改用 Welford,就会消失。
下一项实验要做什么
查看几个小数的实际值,把 3,010 行的金额流按四种顺序相加,并统计 == 与 isclose 给出不同答案的数对。统计 64 位订单 ID 在 double 中有多少对冲突,并写出把它们作为字符串发送的函数;用 Decimal 固定舍入策略,用 Welford 求方差,用最大余数法做出合计为 100 的百分比,最后把这一切汇总成一个仪表盘响应。