Python 的浮点数损失精度问题(为什么说双精度浮点数有15位十进制精度)

孤街浪徒 提交于 2019-12-29 21:59:02

【推荐】2019 Java 开发者跳槽指南.pdf(吐血整理) >>>

本篇讨论的现象可以从下面这段脚本体现出来:


   
>>> x = 0.0 >>> for i in range( 10 ): x += 0.1 print (x) 0.1 0.2 0.30000000000000004 0.4 0.5 0.6 0.7 0.7999999999999999 0.8999999999999999 0.9999999999999999 >>>

即:为什么有几行的输出看起来不对?

因为 Python 中使用双精度浮点数来存储小数。在 Python 使用的 IEEE 754 标准(52M/11E/1S)中,8字节64位存储空间分配了52位来存储浮点数的有效数字,11位存储指数,1位存储正负号,即这是一种二进制版的科学计数法格式。虽然52位有效数字看起来很多,但麻烦之处在于,二进制小数在表示有理数时极易遇到无限循环的问题。其中很多在十进制小数中是有限的,比如十进制的 1/10,在十进制中可以简单写为 0.1 ,但在二进制中,他得写成:0.0001100110011001100110011001100110011001100110011001…..(后面全是 1001 循环)。因为浮点数只有52位有效数字,从第53位开始,就舍入了。这样就造成了标题里提到的”浮点数精度损失“问题。 舍入(round)的规则为“0 舍 1 入”,所以有时候会稍大一点有时候会稍小一点。

Python 的浮点数类型有一个 .hex()方法,调用能够返回该浮点数的二进制浮点数格式的十六进制版本。这话听着有点绕,其实是这样的:本来浮点数应该有一个 .bin() 方法,用来返回其二进制浮点数格式。如果该方法存在的话,它看起来就像这样(p-4表示乘以 2-4,或者可以简单理解为小数点 左移 4 位):


   
>>> ( 0.1 ).bin() # 本方法其实并不存在 ' 1.1001100110011001100110011001100110011001100110011010p-4 '

但是这个字符串太长了,同时因为每 4 位二进制字符都可以换算成 1 位十六进制字符,于是Python就放弃了给浮点数提供 .bin() 方法,改为提供 .hex() 方法。这个方法将上面输出字符串的 52 位有效数字无损转换成了 13 位十六进制数字,所以实际存在的方法其实是这样的(注:二进制浮点数中小数点前的“1”不包含于那 52 位有效数字之中):


   
>>> ( 0.1 ).hex() ' 0x1.999999999999ap-4 '

前面的 0x 代表十六进制。p-4 没变,所以需要注意,这里的 p-4 还是二进制版的,也就是说在展开本格式的时候,你不能把小数点往左移 4 位,那样就相当于二进制左移 16 位了。前面提到过,小数点前这个“1”是不包含于 52 位有效数字之中的,但它确实是一个有效的数字呀,这是因为,在二进制浮点数中,第一位肯定是“1”,(是“0”的话就去掉这位,并在指数上-1)所以就不保存了,这里返回的这个“1”,是为了让人看懂而加上的,在内存的 8 位空间中并没有它。所以 .hex() 方法在做进制转换的时候,就没有顾虑到这个“1”,直接把 52 位二进制有效数字转换掉就按着原来的格式返回了。因此这个 .hex() 方法即使名义上返回的是一个十六进制数,它小数点前的那一位也只会是“1”,看下面示例:


   
>>> float.fromhex( ' 0x1.8p+1 ' ) == float.fromhex( ' 0x3.0p+0 ' ) True

一般我们用十六进制科学计数法来表示 3.0 这个数时,都会这么写“0x3.0p+0”。但是 Python 会这么写“0x1.8p+1”,即“1.1000”小数点右移一位变成“11.000”——确实还是 3.0 。就是因为这个 1 是直接遗传自二进制格式的。而我一开始没有理解这个 .hex() 的意义,还画蛇添足地自定义了一个 hex2bin() 方法,后来看看真是没必要啊~

而为了回应人们在某些状况下对这个精度问题难以忍受的心情(雾),Python 提供了另一种数字类型——Decimal 。他并不是内建的,因此使用它的时候需要 import decimal 模块,并使用 decimal.Decimal() 来存储精确的数字。这里需要注意的是:使用非整数参数时要记得传入一个字符串而不是浮点数,否则在作为参数的时候,这个值可能就已经是不准确的了:


   
>>> Decimal( 0.1 ) == Decimal( ' 0.1 ' ) False

在进一步研究到底损失了多少精度,或者说,八字节双精度浮点数最多可以达到多少精度的问题之前,先来整理一下小数和精度的概念。本篇中讨论的小数问题仅限于有理数范围,其实有理数也是日常编程中最常用到的数。有理数(rational number)一词派生于“比(ratio)”,因此并不是“有道理”的意思,而是指分数。有理数的内容扩展自自然数,由自然数通过有理运算(+ – * /)来得到的数系称为有理数,因此可以看到它较自然数扩充了:零、负整数和分数的部分。有理数总可以写成 p/q 的形式,其中 p、q 是整数且 q ≠ 0,而且当 p 和 q 没有大于 1 的公因子且 q 是正数的时候,这种表示法就是唯一的。这也就是有理数被称为 rational number 的原因,说白了就是分数。实际上 Python 的 float 类型还有一个 .as_integer_ratio() 的方法,就可以返回这个浮点数的最简分数表示,以一个元组的形式:


   
>>> ( 0.5 ).as_integer_ratio() ( 1 , 2 )

然后为了对有理数套用更直观的“位值法”表示形式,人们又开始用无限小数的形式表示有理数。而其中从某一位开始后面全是 0 的特殊情况,被称为有限小数(没错,无限小数才是本体)。但因为很多时候我们只需要有限位有效数字的精度就够用了,所以我们会将有理数保存到某一位小数便截止。后面多余小数的舍入方式便是“四舍五入”,这种方式较直接截断(round_floor)的误差更小。在二进制中,它表现为“0 舍 1 入”。当我们舍入到某一位以后,我们就可以说该数精确到了那一位。如果仔细体会每一位数字的含义就会发现,在以求得有限小数位下尽可能精确的值为目的情况下,直接截断的舍入方式其实毫无意义,得到的那最后一位小数也并不准确。例如,将 0.06 舍入成 0.1 是精确到小数点后一位,而把它舍入成 0.0 就不算。因此,不论是在双精度浮点数保留 52 位有效数字的时候,还是从双精度浮点数转换回十进制小数并保留若干位有效数字的时候,对于最后一位有效数字,都是需要舍入的。

插一句题外话:如何判断一个有理数在写成某种进制的小数时是否具有有限长度。就是看这个有理数的分母的质因子,是否全部包含于进制的质因子之中。举个栗子,1/2 这个数在二进制、四进制和八进制...中都是有限小数,但在三进制、五进制和七进制...中都是无限小数。同样道理,十进制下 1/n (n为整数,0<n<10) 这种形式的有理数只有 1/2,1/4,1/8 和 1/5 是有限小数。因此,曾经还有过数学家提议人类使用十二进制代替十进制的事情,因为 12 的质因子(2,3)比 10 的质因子(2,5)更小,也就可以表示更多的有限小数。

下图是一个(0,1)之间的数轴,上面用二进制分割,下面用十进制分割。比如二进制的 0.1011 这个数,从小数点后一位一位的来看每个数字的意义:开头的 1 代表真值位于 0.1 的右侧,接下来的 0 代表真值位于 0.11 的左侧,再接下来的 1 代表真值位于 0.101 的右侧,最后的 1 代表真值位于 0.1011 的右侧(包含正好落在 0.1011 上这种情况,这就是四舍五入的来源)。使用 4 位二进制小数表示的 16 个不同的值,除去 0,剩下的 15 个数字正好可以平均分布在(0,1)这个区间上,而十进制只能平均分布 9 个数字。显然 4 位二进制小数较于 1 位十进制小数将此区间划分的更细,即精度更高。

未标题-1_thumb[4]

把 0.1 的双精度版本(0x1.999999999999ap-4)展开成十进制。这里使用了 Decimal 类型,在给他赋值的时候,他会完整存储参数,但是要注意的是,使用 Decimal 进行运算是会舍入的,保留的位数由上下文决定。使用 decimal 模块的 getcontext() 方法可以得到上下文对象,其中的 prec 属性就是精度。下面还使用了 print() 方法,这是为了好看:


   
>>> print (Decimal( 0.1 )) 0.1000000000000000055511151231257827021181583404541015625

得到的这个十进制浮点数有效数字足有 55 位。虽然从二进制到十进制这个过程是完全精确的,但因为在存储这个二进制浮点数的时候进行了舍入,所以这个 55 位的十进制数,较于最初的 0.1 并不准确。至于到底能精确到原十进制数的哪一位,可以这么算: 2**53 = 9007199254740992 ≈ 10**16 ,(这里 53 算上了开头的“1”),即转换后的十进制小数的第 16 位有效数字很可能是精确的(第 15 位肯定是精确的)。换句话说,如果要唯一表示一个 53 位二进制数,我们需要一个 17 位的十进制数(但即使这样,也不代表对应的十进制和二进制数“相等”,他们只不过在互相转换的时候在特定精度下可以得到相同的的值罢了。就像上面例子中显示的,精确表示”0.1“的双精度版本,需要一个 55 位的十进制小数)。

不过可以看到,如果要保证转换回来的十进制小数与原值相等,那么只能保证到 15 位,第 16 位只是“很可能是精确的”。而且第 15 位的精确度也要依赖于第 16 位的舍入。实际上在 C++ 中,double 类型的十进制小数就是保留 15 位的(我从别处看来的,C++我自己并不熟悉)。所以如果 Python 的 float 类型的 __str__() 和 __repr__() 方法选择返回一个 15 位的小数,那么就不会出现本文讨论的第一个问题了。不论是早期的“0.10000000000000001”还是本文中出现的“0.30000000000000004”或者“0.7999999999999999”,我们可以看到它的不精确都是因为打印了过多位的有效数字——16 或 17 。假如强制 round 到 15 位的话:


   
a = 0.1 for i in range( 10 ): print (round(a, 15 )) a += 0.1 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1.0

我们再来看一下他的 16、17 位到底保存了什么:


   
>>> a = 0.0 >>> for i in range( 10 ): a += 0.1 print (a) print ( ' %.17f ' % a) print ( ' - ' * 19 ) 0.1 0.10000000000000001 ------------------- 0.2 0.20000000000000001 ------------------- 0.30000000000000004 0.30000000000000004 ------------------- 0.4 0.40000000000000002 ------------------- 0.5 0.50000000000000000 ------------------- 0.6 0.59999999999999998 ------------------- 0.7 0.69999999999999996 ------------------- 0.7999999999999999 0.79999999999999993 ------------------- 0.8999999999999999 0.89999999999999991 ------------------- 0.9999999999999999 0.99999999999999989 -------------------

上面短横线对齐的是第 17 位。虽然在这里第 16 位全部是精确的,但如果为了保证 100% 的准确率的话,还是需要舍入到第 15 位。另外一个细节,上面的例子其实有一个问题,就是使用 0.1++ 这种方式的时候,实际累加的是一个不精确的数字,所以有可能造成误差的放大。不过这里依然没有改正,是因为 0.5 那行,突然恢复真值了。这也不是因为后面藏了其他数字没有显示出来,我们来看一下:


   
>>> ' %.60f ' % ( 0.1 + 0.1 + 0.1 + 0.1 + 0.1 ) ' 0.500000000000000000000000000000000000000000000000000000000000 ' >>> print (Decimal( 0.1 + 0.1 + 0.1 + 0.1 + 0.1 )) 0.5

这里使用了一个格式限定符的示例。它的作用类似于 print Decimal。区别仅在于 Decimal 自己知道应该显示多少位,而格式化限定符不知道。(一般双精度浮点数转换过来不超过 100 位)。因为不打算继续深究了,所以就当这个“0.5”是个意外吧~如果想避免误差叠加,可以写成“i/10”的格式。

所以对于两种,不像十六进制和二进制般正好是指数关系的进制,永远都无法在各自的某一位上具有相同的精度。即 2m = 10n 这个等式没有使 m,n 同时为整数的解。但至少还可以构建一个精度包含的关系,比如上面 24 > 101 ,那么我们就说“4 位二进制精度高于 1 位十进制精度”从而通过 4 位二进制数转储 1 位十进制数的时候,总是精确的,反之则不然。同理根据这个不等式:1015 < 253 <1016 ,双精度浮点数的精度最高也就蕴含(不是等价)到十进制的 15 位了。另外虽然这种转化看起来浪费了很大的精度(第 16 位在很大概率上也是精确的)。有趣的是,210 = 1024,却和 103 = 1000 离的很近。因此一般我们可以通过这个 10:3 的比例来近似推导精度关系。

最后,因为浮点数的这点特性,在涉及到钱的地方都是不用浮点数的,他们会用定点数。即使用 ...xxxxx . xx 的格式存储数字,精确到小数点后第二位,即“分”。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!