强基初中数学&学Python——第201课 浮点计算“怪”的根源


  我们都是知道3个0.1相加等于0.3,那么逻辑判定0.1+0.1+0.1==0.3应该是真值(True)。实际情况是这样吗?下面测试一下。

>>> .1 + .1 + .1 == .3

False

  运算结果明确告诉我们,0.1+0.1+0.1与0.3不相等。为了说明这个问题,我们首先讨论十进制的循环小数。例如,⅓用小数表示是循环小数,例如取5位小数,即0.33333,那么0.33333 + 0.33333 + 0.33333 = 0.99999,不等于1。难道0.1用二进制表示是一个循环小数?

  不错,十进制的0.1用二进制表示确实是个循环小数,用有限位的浮点数是无法正确表示循环小数的,因此就会出现上面的“怪”现象。

  先了解二进制小数的实质,从左到右实际是一个比值为½的等比数列

1/2, 1/4, 1/8, 1/16, ..., 1/2^n, ...。

一个数在两个元素之间

1/2^(n-1)>x>1/2^n,

那么

x=1/2^n+(x-1/2^n)。

由于

1/2^n+1/2^n=1/2^(n-1),

所以

(x-1/2^n)<1/2^n。

即需要在数列的下面继续查找匹配项。

  下面推算十进制0.1的二进制表示。

  ∵ 0.1=1/10,

    1/8>1/10>1/16,

  ∴ 0.1=1/16+(1/10-1/16)

    =1/16+3/80。

  ∵ 1/32=3/96<3/80<5/80=1/16,

  ∴ 3/80=1/32+(3/80-1/32)

    =1/32+1/16(3/5-1/2)

    =1/32+1/16×1/10。

  ∴ 0.1=1/16+1/32+1/16×0.1。

  把上式不断代入上式的右侧,即套娃式:

    0.1=1/16+1/32+1/16×(1/16+1/32+1/16×(......

  用二进制来表示右侧就是

    0.00011001100110011......

  也就是说,用二进制表示十进制0.1是个循环小数。下面用反证法证明用二进制表示十进制的0.1是个无限小数。

  证明:假设能用有限位(n位)二进制小数表示十进制的0.1,那么它的最后一位1的值是

1/2^n。

 

它前面有k(k<n)位是1,即m1, m2, m3, ..., mk位是1,那么

0.1=1/2^m1+1/2^m2+1/2^m3+...+1/2^mk+1/2^n。

等式右边通分后相加,得

0.1=(1/2^n)×[2^(n-m1)+2^(n-m2)+2^(n-m3)+...+2^(n-mk)+1]。

令整数

m=2^(n-m1)+2^(n-m2)+2^(n-m3)+...+2^(n-mk)+1,

 

0.1=1/10=m/10m,

m/10m=m/2^n。

所以

10m=2^n,

两边除以2得

5m=2^(n-1)。

由于2^(n-1)只有一个质因数2,不可能含有质因数5,所以等式不成立,也就是说不能用有限位二进制小数表示十进制的0.1。又由于十进制0.1是个有理数,所以用二进制表示必然是循环小数(无限不循环是无理数)。

  由于一个有限位的十进制小数都可以表示成一个分子分母互质的分数,由上面的证明可知,只有这个分数的分母是2的n次方才能够用有限位的二进制小数表示,除此之外都是二进制循环小数。可以想象出大多数十进制有限小数用二进制表示都是循环小数。这就是浮点运算“怪”的根源。

  但是也不必过于担心浮点数的问题!Python 浮点运算中的错误是从浮点运算硬件继承而来,而在大多数机器上每次浮点运算得到的 2**53 数码位都会被作为 1 个整体来处理。这对大多数任务来说都已足够,但你确实需要记住它并非十进制运算,且每次浮点运算都可能会导致新的舍入错误。

  虽然“怪”况确实存在,但对于大多数正常的浮点运算来说,你只需简单地将最终显示的结果舍入为你期望的十进制数值即可得到你期望的结果。 str() 通常已足够,对于更精确的控制可用str.format() 方法。

  对于需要精确十进制表示的使用场景,请尝试使用 decimal 模块,该模块实现了适合会计应用和高精度应用的十进制运算。

  另一种形式的精确运算由 fractions 模块提供支持,该模块实现了基于有理数的算术运算(因此可以精确表示像 1/3 这样的数值)。

  如果你是浮点运算的重度用户则你应当了解一下 NumPy 包以及由 SciPy 项目所提供的许多其他数字和统计运算包。参见 <https://scipy.org>。

  Python 也提供了一些工具,可以在你真的 想要 知道一个浮点数精确值的少数情况下提供帮助。例如 float.as_integer_ratio() 方法会将浮点数表示为一个分数:

>>>x = 3.14159

>>>x.as_integer_ratio()

(3537115888337719, 1125899906842624)

  由于这是一个精确的比值,它可以被用来无损地重建原始值:

>>>x == 3537115888337719 / 1125899906842624

True

  float.hex() 方法会以十六进制(以 16 为基数)来表示浮点数,同样能给出保存在你的计算机中的精确值:

>>>x.hex()

'0x1.921f9f01b866ep+1'

  这种精确的十六进制表示法可被用来精确地重建浮点值:

>>>x == float.fromhex('0x1.921f9f01b866ep+1')

True

  由于这种表示法是精确的,它适用于跨越不同版本(平台无关)的 Python 移植数值,以及与支持相同格式的其他语言(例如 Java 和 C++)交换数据.

  另一个有用的工具是 math.fsum() 函数,它有助于减少求和过程中的精度损失。它会在数值被添加到总计值的时候跟踪“丢失的位”。这可以很好地保持总计值的精确度, 使得错误不会积累到能影响结果总数的程度:

>>>sum([0.1] * 10) == 1.0

False

import math

>>>math.fsum([0.1] * 10) == 1.0

True