3. 数值计算中的浮点精度异常
浮点精度异常是数值计算中最容易被忽视、也最容易在业务结果上暴露的一类问题。它不像除零错误那样立刻抛出异常,而是以“结果看起来差不多、但对不上”的方式潜伏下来:对账差一分钱、循环累加后偏差放大、条件判断在临界点走错分支。本节从浮点数的表示机制出发,梳理误差来源,给出累积误差的诊断方法,并说明 decimal 与 fractions 两个标准库模块各自适用的场景。
一、误差从哪里来:二进制表示与舍入
Python 的 float 遵循 IEEE 754 双精度格式,用 1 位符号、11 位指数、52 位尾数表示一个近似值。绝大多数十进制小数无法用有限位二进制精确表示,例如 0.1 在二进制中是无限循环小数,存储时被舍入到最近的可用值。因此 0.1 + 0.2 的结果并不是 0.3,而是 0.30000000000000004。这不是 Python 的缺陷,而是二进制浮点表示的固有属性。
误差来源可以归为四类:表示误差(十进制小数转二进制时的舍入)、运算舍入误差(每次加减乘除结果都要落回 53 位有效位)、量级差异导致的吸收(大数加小数时小数被“吃掉”)、以及累积误差(大量运算中误差逐步叠加)。前两类是单次运算的固有误差,后两类才是排障中真正需要定位的对象。
>>> 0.1 + 0.2
0.30000000000000004
>>> 1e16 + 1.0 - 1e16
0.0 # 1.0 被大数吸收,完全丢失
>>> sum([0.1] * 10)
0.9999999999999999
第三行是典型的累积误差:十个 0.1 相加,理论值 1.0,实际得到 0.9999999999999999。单次误差约 1e-17 量级,但十次叠加后偏差已经可以在 == 1.0 判断中体现出来。
二、累积误差的诊断方法
诊断累积误差的核心思路是“找一个可信参照,量化偏差随规模的变化”。常用手段有三种。
其一,与高精度结果对比。用 decimal 或 fractions 算出同一算式的精确值,再与 float 结果相减,得到绝对误差;除以精确值得到相对误差。相对误差超过 1e-12 量级时,就应怀疑算法本身存在数值不稳定,而不只是表示误差。
其二,观察误差随迭代次数的增长趋势。对同一累加过程分别取 n=10³、10⁴、10⁵ 次,记录误差。若误差近似线性增长,属于普通累积;若呈指数增长,说明算法对舍入误差有放大作用,例如递推公式中存在相减抵消。
其三,用 math.fsum 做交叉验证。math.fsum 内部使用部分和算法,能显著降低累加误差。若 sum() 与 math.fsum() 的结果差异明显,基本可以确认是累加顺序或规模导致的误差累积。
import math
from decimal import Decimal, getcontext
getcontext().prec = 50
data = [0.1] * 100000
s_plain = sum(data)
s_fsum = math.fsum(data)
s_exact = float(sum(Decimal("0.1") for _ in range(100000)))
print(s_plain, s_fsum, s_exact)
print("相对误差:", abs(s_plain - s_exact) / s_exact)
这段代码给出三个可比对象:普通累加、补偿累加、高精度参照。三者一致说明误差可接受;普通累加明显偏离而 fsum 贴近参照,则问题定位在累加环节,可考虑改用 fsum 或调整求和顺序(先小后大)。
三、decimal 与 fractions 的适用场景
decimal.Decimal 用十进制表示数值,可以精确表示 0.1、0.2 这类十进制小数,适合金额、税率、计量单位换算等“以十进制为准”的业务。它的精度由上下文 getcontext().prec 控制,舍入模式可显式指定(如 ROUND_HALF_UP 对应常见的四舍五入)。需要注意两点:一是 Decimal 必须从字符串构造,Decimal(0.1) 会把 float 的误差原样带进来;二是运算速度比 float 慢一到两个数量级,不适合大规模数值循环。
fractions.Fraction 以分子/分母的有理数形式存储,加减乘除全程精确,不会产生任何舍入,适合比例、概率、分数运算以及需要“结果必须严格相等”的校验场景。它的代价是分母可能迅速膨胀,遇到开方、三角函数等无理运算时无法直接使用,需要转成 float 或 Decimal 再算。
| 对比项 | float | decimal.Decimal | fractions.Fraction |
|---|---|---|---|
| 表示方式 | 二进制浮点 | 十进制浮点 | 有理数(分子/分母) |
| 0.1 是否精确 | 否 | 是 | 是(1/10) |
| 典型场景 | 科学计算、图形、性能敏感 | 金额、税率、计量 | 比例、概率、精确校验 |
| 性能 | 最快 | 较慢 | 慢,分母可能膨胀 |
| 比较方式 | math.isclose 容差 | 直接 == | 直接 == |
四、案例:对账差额的定位与修复
假设一个订单系统按“单价 × 数量 × 折扣”逐笔累加金额,折扣为 0.9,单价 19.99,数量 3,共 1000 笔。用 float 累加后与财务系统给出的精确总额比对,出现 0.01 元级差额。排查步骤如下:先用 Decimal 按字符串重建同一算式得到精确值,确认差额确实来自浮点累加而非业务逻辑;再把累加改为 Decimal 运算,并在最终输出时用 quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) 统一保留两位小数。修复后差额归零,且舍入规则与财务口径一致。
from decimal import Decimal, ROUND_HALF_UP
unit = Decimal("19.99")
qty = Decimal("3")
rate = Decimal("0.9")
total = sum((unit * qty * rate for _ in range(1000)), Decimal("0"))
print(total.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
Decimal;涉及“精确比例关系”的中间校验优先用 Fraction;纯科学计算保留 float,但比较时用 math.isclose(a, b, rel_tol=1e-9) 而非 ==。round(2.675, 2) 期望得到 2.68,实际得到 2.67,因为 2.675 的二进制近似值略小于 2.675。需要十进制四舍五入时,应使用 Decimal("2.675").quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)。浮点精度异常的排障关键,不在于消灭误差,而在于识别误差属于哪一类、是否落在业务可接受范围内,并选择与业务口径匹配的数值类型。把“表示误差”和“累积误差”分开看,把“十进制口径”和“二进制性能”分开选,绝大多数精度问题都能被稳定定位和修复。