2. 浮点精度陷阱

在数值计算排障中,浮点精度问题是最容易被低估、也最容易误判的一类异常。它往往不表现为程序崩溃或明显报错,而是以“结果差一点点”“累加越多偏得越远”“两个数学上相等的量比较却不相等”的形式出现。本节从最经典的 0.1 + 0.2 出发,梳理舍入误差、机器精度与灾难性抵消三条主线,并给出可落地的识别与规避策略。

一、0.1+0.2 到底发生了什么

IEEE 754 双精度浮点数用 64 位表示:1 位符号、11 位阶码、52 位尾数(隐含 1 位有效位,实际有效精度 53 位二进制)。十进制小数 0.1 和 0.2 在二进制下都是无限循环小数,无法用有限位精确表示,存储时必然发生舍入。以 Python 为例:

>>> 0.1 + 0.2
0.30000000000000004
>>> 0.1 + 0.2 == 0.3
False
>>> from decimal import Decimal
>>> Decimal(0.1)
Decimal('0.1000000000000000055511151231257827021181583404541015625')

可以看到,参与运算的并不是“0.1”这个数学值,而是它最接近的双精度近似值。两个近似值相加后再舍入到最近的可表示数,最终得到 0.30000000000000004。这不是 bug,而是浮点表示体系的固有属性。排障时的第一条判断原则是:只要输入是十进制小数、运算涉及加减乘除,就必须预期存在末位量级的偏差。

二、机器精度与“可接受误差”的量化

机器精度(machine epsilon)通常指 1.0 与下一个可表示浮点数之间的间距,双精度约为 2.22×10⁻¹⁶。它决定了相对误差的下限:任何一次基本运算的结果,相对误差大致在 ε 量级。但实际误差会随运算次数累积,量级估计可用“条件数 × ε”来把握。工程上更实用的做法是设定容差而非追求精确相等:

import math
def almost_equal(a, b, rel_tol=1e-9, abs_tol=1e-12):
    return math.isclose(a, b, rel_tol=rel_tol, abs_tol=abs_tol)

almost_equal(0.1 + 0.2, 0.3)   # True

注意 rel_tol 与 abs_tol 要同时给:当比较值接近 0 时相对容差失效,必须靠绝对容差兜底。容差取值应结合业务精度,例如金额计算用 1e-2 的分单位容差,科学计算按有效数字位数反推。

三、累加偏差:误差如何被放大

单次舍入误差很小,但循环累加会让误差线性甚至更快增长。典型场景是把 0.1 累加一万次:

s = 0.0
for _ in range(10000):
    s += 0.1
print(s)          # 1000.0000000000159 附近
print(s - 1000)   # 约 1.6e-11

误差量级约为 n×ε×量级,累加次数越多、累加值越大,绝对偏差越明显。更隐蔽的是“大数吃小数”:当累加值已达 1e16 量级时,再加 1.0 会被直接舍掉,因为 1.0 小于该量级下的 ulp。规避手段有三类:一是用 math.fsum 做精确求和;二是采用 Kahan 补偿求和,用补偿项记录被舍去的低位;三是改变算法结构,先排序或分组,把量级相近的数放在一起加。

def kahan_sum(values):
    total, c = 0.0, 0.0
    for v in values:
        y = v - c
        t = total + y
        c = (t - total) - y
        total = t
    return total

四、灾难性抵消:减法才是精度杀手

相比加法,减法更容易造成有效数字的严重丢失。当两个相近的大数相减时,高位相互抵消,结果只剩下原本处于低位、已被舍入污染的部分,相对误差被急剧放大。经典例子是求解二次方程 x² − 1e8·x + 1 = 0 的小根:

import math
b = -1e8
disc = math.sqrt(b*b - 4*1*1)
x1 = (-b + disc) / 2      # 大根,正常
x2 = (-b - disc) / 2      # 小根,灾难性抵消
# 改用数值稳定形式:x2 = c / (a * x1)
x2_stable = 1.0 / x1

直接公式中小根由两个接近 1e8 的数相减得到,有效数字几乎全部丢失;而利用韦达定理 x1·x2 = c/a 改写后,小根由除法得到,精度恢复正常。排障时若发现“结果对某些输入正常、对另一些输入突然失真”,应优先检查是否存在相近数相减。

浮点精度陷阱的三条主线与规避路径 十进制输入 0.1、0.2、1e8… 二进制表示舍入 53 位有效位 · ε≈2.2e-16 ① 单次舍入误差 0.1+0.2 ≠ 0.3 ② 累加偏差 n 次累加误差线性增长 ③ 灾难性抵消 相近大数相减,有效位丢失 容差比较 isclose / rel+abs 补偿求和 fsum / Kahan 公式改写 韦达定理 / 稳定形式 排障判断顺序: ① 结果是否只在末位偏差 → 舍入误差,改用容差比较;② 偏差随迭代次数增大 → 累加偏差,改用 fsum/Kahan; ③ 偏差随输入量级突变 → 检查相近数相减,改写公式或提升中间精度。

五、识别与规避的实操清单

面对一个疑似浮点精度异常的计算结果,可按以下顺序排查,避免盲目调参:

  • 看偏差形态:偏差是否稳定在 1e-16 相对量级?若是,属于正常舍入,不应视为故障,只需调整比较方式。
  • 看随规模变化:偏差是否随迭代次数、样本量、累加项数增长?若是,定位到累加路径,替换为 math.fsum 或 Kahan 求和。
  • 看输入敏感性:是否只在某些输入下失真,且这些输入恰好使中间量接近相等?若是,检查减法与除法,尝试代数等价改写。
  • 看量级跨度:是否同时存在 1e16 与 1.0 量级的数参与同一运算?若是,考虑归一化、换单位或使用 decimal/fractions。
  • 看业务要求:金额、计数等场景应直接使用定点数或整数分单位,从源头绕开二进制浮点。
对于必须精确的十进制场景(如财务结算),优先使用 Python 的 decimal.Decimal 并显式设置精度上下文,例如 getcontext().prec = 28;对于科学计算,接受浮点但用容差与稳定算法控制误差传播。
不要用 == 直接比较两个浮点计算结果,也不要用“打印出来看起来相等”作为判断依据。打印默认只显示约 17 位有效数字,可能掩盖真实差异;应显式计算差值并与容差比较。

总结来看,浮点精度陷阱的排障核心不是“消除误差”,而是“识别误差来源并把它控制在业务可接受范围内”。舍入误差是表示层的固有属性,靠容差比较应对;累加偏差是算法层的误差传播,靠补偿求和与重排应对;灾难性抵消是公式层的数值不稳定,靠代数改写应对。三条路径对应三类不同的修复动作,先判形态、再定路径,才能避免把正常舍入误判为程序缺陷,也避免把真实的数值不稳定当成“精度不够”而一味提高位数。