先修:07 二进制状态与计数;09 指数与对数;分数约分与位值记数

把0.1与0.2相加,结果当然是0.3。可是在常见的Python浮点环境里,下面两行会给出另一个样子:

print(0.1 + 0.2)         # 0.30000000000000004
print(0.1 + 0.2 == 0.3)  # False

这段示例在Python 3.12.14、底数为2且有效精度为53位的环境中得到上述输出。它展示了一个现象,但不能代替解释:机器究竟存了什么,又在哪一步与纸上的实数运算分开?

前面的最短路、拟合与优化证明,主要在约定的数学对象中进行。程序最终却要用有限位数承载这些对象。从这一章开始,我们把表示方式也纳入问题。

二进制的小数点后面是什么

十进制小数每往右一位,位权除以10。二进制同样如此,只是每次除以2:第一位代表1/2,第二位代表1/4,第三位代表1/8,依次继续。

例如二进制0.1011₂的含义是

1/2+0/4+1/8+1/16=11/16=0.6875。

右下角的2提醒我们按二进制读。数字还是同一个数字,只是写法换了。二进制0.1₂表示的是1/2,不能与十进制0.1混读。

若小数点后只写k个二进制位,把各项通分到2ᵏ,就一定能写成m/2ᵏ,其中m是整数。反过来,只要一个正分数约分后的分母是2的某次幂,就能把分子写成二进制,再放好小数点,得到有限展开。

因此,一个约成最简形式的分数p/q能有限地写成二进制小数,当且仅当q是2的非负整数次幂。必要性也能严格说清:若p/q=m/2ᵏ,就有p2ᵏ=mq;因为p与q互质,q必须整除2ᵏ,所以不能含有2以外的质因子。

1/10的分母含有5,便不满足条件。它的二进制展开是0.0001100110011……,不能用有限小数位写完。多加一些位,只能换来更接近的近似,不会把一个无限展开变成有限展开。

二进制位权分别为二分之一、四分之一、八分之一和十六分之一;11/16可以有限表示,而1/10的约分分母含5

图1:判断能否有限展开,关键是约分后的分母。图中的四位只是示例,证明覆盖任意有限位数。

这并不是说计算机无法保存精确的1/10。程序可以保存分子1与分母10,也可以选择十进制定点或十进制浮点格式。遇到限制的是“有限二进制小数”这种表示,不是所有可能的数据结构。[1]

浮点数把有限位数放在哪里

如果只固定小数点后几位,很小的数和很大的数会难以兼顾。浮点表示把一个数分成符号、有效数字和指数。普通的正二进制正规数可以写成

1.b₁b₂…b₍p−1₎ × 2ᵉ。

每个b都是0或1;p表示有效二进制位数,包括最前面的1;e控制小数点的尺度。这里的p只表示精度,不再表示上一节分数的分子。指数也只能使用格式允许的有限范围。[2]

先看一个只保留3个有效二进制位的教学格式,暂不触及指数边界。在[1,2)中,它能表示1.00₂、1.01₂、1.10₂、1.11₂,即1、1.25、1.5、1.75,相邻间隔为0.25。

把指数增加1,来到[2,4),对应格点变成2、2.5、3、3.5,间隔扩大到0.5。在[4,8)中,间隔又变成1。有效位数没变,绝对间距却随数量级改变。

三个有效二进制位的教学格式在1到2之间间隔四分之一,在2到4之间间隔二分之一,在4到8之间间隔1

图2:三行采用同一数轴比例。每行只展开对应的数量级区间,右端点同时属于下一个区间;这种不均匀格点正是浮点表示的特征。

常见的IEEE 754 binary64格式,为正规数提供53个有效二进制位。这里说的是有效位,不是“小数点后53位”,更不是53位十进制小数。[1] Python可以用sys.float_info.radix与mant_dig检查运行环境的底数和精度。[3]

还要分清“有限展开”与“装得进指定格式”。1+2⁻⁶⁰当然有有限二进制展开,但在1附近,binary64没有足够的有效位把这两项同时留下。另一个数即使只需要很少有效位,也可能因为指数过大或过小而超出范围。前一节的分母判据,没有替我们免除位数和范围限制。

落不进格点时,怎样选择

本章先采用舍入到最近格点的规则。若一个实数位于两个相邻格点之间,选距离较近的那个;恰在中点时,常用规则选择末位有效数字为偶数的格点,二进制中也就是末位为0。[2]

在刚才的3位教学格式中,1.1位于1与1.25之间,离1更近,于是保存为1。1.125恰在正中间,按中点取偶规则也选1:它的有效位100末位为0,另一个格点1.25的有效位101末位为1。

每一个浮点格点代表的值都是确定的。近似发生在把想表达的实数映射到格点时。选择最近值,可以约束这一次映射的误差,却不能保证许多步之后的最终误差仍然那么小。

把实数z经最近舍入后的值记为fl(z)。在精度为p的二进制正规区间[2ᵉ,2ᵉ⁺¹)中,相邻格点间距为2^(e−p+1)。最近舍入的绝对误差至多是这个间距的一半,即2^(e−p)。再除以z≥2ᵉ,就得到

|fl(z)−z|/|z| ≤ 2⁻ᵖ。

负数可以按对称的绝对值情况处理。因此,若精确结果处于正规范围,采用最近舍入,且没有溢出等异常,可以写成

fl(z)=z(1+δ),其中|δ|≤u,u=2⁻ᵖ。

u叫单位舍入误差。binary64中p=53,于是u=2⁻⁵³。这是特定条件下一次舍入的相对误差界,不是任何程序的最终误差界。[2]

Python的sys.float_info.epsilon在这种环境中则是2⁻⁵²:它表示1与比1大的下一个可表示数之间的差,正好是这里u的两倍。[3] 两个量常在资料里使用相近名称,核对定义比只认“epsilon”这个词更可靠。

若发生下溢,出现靠近0的非正规数,前述相对界不能原样套用;溢出可能得到无穷大,未定义运算还可能产生NaN。0也不能放进相对误差的分母。讨论这些边界时,需要回到具体格式的规则,而不能继续假装所有结果都是有限非零正规数。

再看0.1与0.2

可以让Python直接报告0.1所存的精确分数:

print((0.1).as_integer_ratio())
# (3602879701896397, 36028797018963968)

分母就是2⁵⁵。这个分数非常接近1/10,却不与它相等。[1] 一个不需要长小数的检验是:若相等,就应有分子乘10等于分母;实际左边比右边多2。

通常只显示“0.1”,是因为显示程序选择了一个简短、能重新读回同一浮点值的十进制写法。屏幕上的简短文字与底层保存的精确分数,承担着不同角色。

接着,十进制0.2也先映射为附近的二进制浮点值;加法对这两个已保存的值计算,再把结果舍入回允许的格点。右侧用于比较的0.3,也有自己的输入转换。因此,两边走过的路径并不相同,最终可能落在相邻格点上。

输入的十进制实数先转换为有限二进制格点,保存值相加后再次舍入,最后格式化为十进制输出

图3:输入转换、算术舍入和显示格式是三个不同环节。缩短显示文本,不会倒过来改变先前保存或运算的值。

也正因为有这些环节,不能把前面实数证明中的每一步,未经检查就当作浮点操作的逐位保证。一个整数范围内很可靠的等式,跨到精度不够的数量级后,也可能表现不同。例如在上述环境里,float(253)+1仍等于float(253):这一带相邻格点已经相差2,增加1落在中点后又被舍回。

程序没有偷偷把实数1改成0,而是这次相加后的精确结果无法落入当前格式,舍入掩去了改动。迭代停止条件、累计次数与阈值比较,都需要知道自己使用的到底是哪一种数。

换一种表示,要换得完整

若任务确实要求按十进制数值进行精确表示,可以明确从十进制字符串构造数值:

from decimal import Decimal
print(Decimal("0.1") + Decimal("0.2") == Decimal("0.3"))  # True
print(Decimal(0.1) == Decimal("0.1"))                  # False

第二行构造函数收到的已经是二进制float近似,它会保留那个值,而不会猜出原来想写的是1/10。[4] 选择新的容器,并不能自动恢复已经丢掉的信息。

十进制浮点也有精度和范围设置;1/3在十进制里仍然不能有限展开。保存有理数的分子、分母可以保留这类精确关系,但中间整数和计算成本可能增长。表示方式各有适用任务,增加精度也不会把问题本身的不确定性消掉。

对于本来就带测量误差的数据、数值拟合或模型预测,精确相等未必是合适的验收问题。是否“足够接近”,还要看单位、数量级和容许误差,不能统一塞一个与任务无关的小常数。第38章会把这些条件放回完整的数值结果检查中。

这一章先解释误差从哪里进入。下一章要进一步问:同样微小的输入变化,为什么有的问题几乎不受影响,有的答案却大幅摆动?那时需要区分机器的表示精度与问题自身的敏感程度。

顺手试一下

题目1: 3/16、7/40、5/8中,哪些能写成有限二进制小数?能的话写出展开,不能的话说明分母中的障碍。“能有限展开”是否保证任意浮点格式都能精确保存?

**提示:**先约分,看分母是否只含质因子2,再分别检查位数与指数范围。

**参考答案:**3/16=0.0011₂,5/8=0.101₂,都可以有限展开;7/40已约分,分母含5,不能。有限展开仍可能长于格式的有效位数,或超出指数范围,所以不能保证任意格式都能装下。

题目2: 在3个有效二进制位的教学格式中,考虑[4,8)区间。5.6按最近舍入保存为什么?绝对误差与相对误差各是多少?是否超过本章u=1/8的界?

**提示:**这一段的格点是4、5、6、7;相对误差用实际想表示的5.6作分母。

**参考答案:**5.6离6更近,保存为6。绝对误差为0.4;相对误差为0.4/5.6=1/14≈0.07143,小于1/8。这只是一次舍入,并未保证后续整个算法的误差也不超过1/8。

参考与延伸

[1] Python官方教程,Floating-Point Arithmetic: Issues and Limitations。核对0.1的binary64表示、精确整数比与显示行为。本文代码在Python 3.12.14的二进制53位环境复核;底层参数可自行检查。

[2] David Goldberg,What Every Computer Scientist Should Know About Floating-Point Arithmetic,Oracle收录的原论文修订版,D.3节。核对浮点格式、最近舍入与相对误差条件。本文将单位舍入误差记为u,避免与不同资料的epsilon定义混用。

[3] Python官方文档,sys.float_info。核对radix、mant_dig与epsilon的具体含义。

[4] Python官方文档,decimal。核对从字符串与从float构造十进制数的不同结果及有限精度语义。


作者:Jiancheng Wang · 数学之美(独立原创)