跳到内容

第 10 卷 / 第 12 章 / 第 2 课

12.2 浮点数与舍入:有限比特怎样近似实数 ​

数学里的实数连续而无限,计算机的存储有限。浮点数用固定数量的比特挑选一组可表示值,其他实数只能舍入到附近。

本课目标 ​

  • 理解符号、有效数和指数的作用;
  • 解释 0.1 + 0.2 的结果而不把它叫作语言 bug;
  • 正确处理 NaN、无穷大、上溢和下溢;
  • 根据问题选择容差比较、十进制定点或更高精度。

1. 浮点表示像科学计数法 ​

二进制浮点数可概括为:

(−1)s×significand×2exponent.

IEEE 754 常见的 binary32 使用 1 位符号、8 位指数域和 23 位尾数字段;正规数还有一个隐含的前导 1,因此有效精度通常说是 24 个二进制位。binary64 使用 1 位符号、11 位指数域和 52 位尾数字段,有 53 位有效精度。

本课 Python 示例以普通 binary64 float、最近偶数舍入为前提;nextafter 和 ulp 需要 Python 3.9+。可表示成有限二进制小数不等于一定放得进 binary64,还要满足有效位和指数范围。

指数扩大可表示范围,有效数决定相对精度。可表示数在实数轴上并不等距:数量级越大,相邻浮点数之间的绝对间隔通常越大。

除正规数外,标准还包含:

  • +0 与 -0;
  • 次正规数,用较低精度逐渐接近零;
  • +∞ 与 -∞;
  • NaN,表示某些无效或未定义结果。

这些特殊值会参与后续运算,系统必须决定检测、传播还是拒绝。

2. 为什么十进制 0.1 不能精确表示 ​

有限二进制小数只能精确表示分母约分后为 2k 的有理数。十进制 0.1=1/10 的分母含因子 5,在二进制中会无限循环,只能舍入成附近的浮点数。

python
from decimal import Decimal

print(0.1 + 0.2)  # 0.30000000000000004
print(0.1 + 0.2 == 0.3)  # False
print(Decimal.from_float(0.1))
print(Decimal("0.1") + Decimal("0.2"))  # 0.3

Decimal.from_float(0.1) 展示 binary64 值的精确十进制形式;Decimal("0.1") 则从十进制文本构造,不先经过二进制浮点舍入。

这是有限二进制表示的必然结果,不是 Python 独有现象,也不表示 IEEE 754 算错了。十进制也有同类限制,例如有限位十进制无法精确表示 1/3。

3. 每一步运算都可能舍入 ​

通常的浮点运算模型是:先得到精确数学结果,再按当前舍入规则映射到可表示值。常见默认模式是“舍入到最近值,正中间时取偶数最低位”,它减少总向同一侧打破中点平局带来的偏差,但不保证任意计算无偏。

误差类型包括:

  • 表示误差:输入本身不可精确表示;
  • 舍入误差:运算结果落在两个可表示值之间;
  • 上溢:绝对值超过有限范围,可能得到无穷大;
  • 下溢:结果过小,进入次正规范围或舍入为零。

机器 epsilon 描述 1 附近可分辨的相对间隔,不是适用于所有数量级、所有算法的通用比较阈值。ULP(末位单位)会随数值大小变化。

下面区分 1 上下两侧的间隔,以及最小次正规数。binary64 能连续精确表示从 -2^53 到 2^53 的整数,超过这个范围后会跳过某些整数:

python
import math
import sys

assert (sys.float_info.radix, sys.float_info.mant_dig) == (2, 53)
print(math.ulp(1.0) == 2.0**-52)                # True
print(1.0 - math.nextafter(1.0, 0.0) == 2.0**-53) # True
print(math.nextafter(0.0, 1.0) == 2.0**-1074)   # True
print(float(2**53 + 1) == float(2**53))         # True

4. 相等比较要看问题语义 ​

浮点数不是永远不能用 ==。若比较的是同一份标识性计算结果、整数可精确范围内的值或协议定义的特殊常量,精确比较可能合理。对经过独立数值路径得到的近似实数,通常使用绝对容差与相对容差组合:

|a−b|≤max(abs_tol,rel_tol⋅max(|a|,|b|)).
python
import math

result = 0.1 + 0.2
print(math.isclose(result, 0.3, rel_tol=1e-12, abs_tol=1e-15))

绝对容差负责零附近,相对容差负责不同数量级。阈值应来自测量精度、算法误差和业务容忍度,不能机械复制 1e-9。

对有限值,上式对应 math.isclose 的判据。rel_tol 应非负且小于 1,abs_tol 非负;和零比较时通常需要正的绝对容差。同号无穷只与自身接近,NaN 与任何值都不接近。容差接近通常不具传递性,不适合直接充当集合键的等价关系。

NaN 与任何值比较都不相等,包括它自己。检测 NaN 应使用 math.isnan,不要写 x == float("nan")。

边界策略还要跟到序列化出口。Python 标准库 json 默认允许输出非标准 JSON 的 NaN,严格接口可显式拒绝:

python
import json
import math

value = float("nan")
print(value == value, math.isnan(value))  # False True
print(json.dumps({"value": value}))     # {"value": NaN}
try:
    json.dumps({"value": value}, allow_nan=False)
except ValueError:
    print("non-finite JSON value rejected")

这项行为由 Python JSON 文档 说明。其他语言或解析器可能直接拒收,不能把本地序列化成功当作互操作成功。

5. 金额、计数和科学计算的选择不同 ​

  • 计数:优先整数,注意范围与溢出;
  • 货币:常用最小货币单位的整数,或规则明确的十进制定点/十进制类型;
  • 科学计算:binary64 常是合理起点,但误差预算决定是否需要更高精度;
  • 机器学习:低精度可换吞吐和显存,但常需损失缩放、混合精度和稳定算子;
  • 协议与存储:必须明确格式、舍入、特殊值和跨语言兼容性。

Decimal 不是自动“完全精确”。它仍有上下文精度和舍入规则,对无限十进制结果也必须近似;它解决的是十进制语义和可控舍入,而不是消除有限表示。

同样,IEEE 754 的特殊值规则不等于 Python API 必然静默传播:普通 1.0 / 0.0 抛 ZeroDivisionError,math.sqrt(-1.0) 抛 ValueError,而常见 binary64 上 1e308 * 2.0 得到 inf。异常策略必须按实际运行时和库确认。

6. 运算顺序会改变结果 ​

浮点加法通常不满足结合律:

(a+b)+c≠a+(b+c).
python
a = 1e16
b = -1e16
c = 1.0

print((a + b) + c)  # 1.0
print(a + (b + c))  # 0.0 (binary64)

在第二种顺序中,b + c 的小量可能在舍入时丢失。并行归约改变求和顺序,所以浮点程序即使输入相同,也可能因线程划分不同产生末位差异。需要位级可复现时,必须额外约束算法和执行环境。

常见误区 ​

  • 双精度有 52 位十进制小数:52 是尾数字段的二进制位数,不是十进制小数位数。
  • epsilon 是全局最小正数:机器 epsilon、最小正规数和最小次正规数是不同概念。
  • Decimal 消除所有误差:它也受有限精度与舍入上下文控制。
  • 浮点异常一定抛异常:许多环境会产生 NaN 或无穷大并继续计算。

练习 ​

  1. 找出哪些简单分数可被有限二进制小数精确表示,并说明规律。
  2. 用 math.nextafter 观察 1、1e10 和 1e-10 附近的浮点间隔。
  3. 为一个金额结算模块比较“分为单位的整数”和 Decimal 两种设计。
  4. 构造一个 NaN 进入排序、聚合或序列化流程的测试,记录运行时行为。

小结 ​

浮点数以有限比特换取巨大的动态范围,代价是离散表示、舍入和特殊值。不必害怕小数;应让数值类型、比较规则和误差容忍度与问题语义一致。

下一课继续追问:同样使用 binary64,为什么一种公式稳定,另一种数学上等价的公式却会溢出或丢掉全部有效数字?

Built with VitePress | Software Systems Atlas