作者: Jim Wang 公众号: 巴博萨船长

在计算机编程的过程中,常常会涉及到浮点数(小数)的算术运算。稍加留意就会发现,算术运算的结果时常是不正确的。如果程序中有条件语句if加逻辑判断来控制程序执行,由于判断结果也不符合预期,程序就会流向错误的节点。经典例子就是0.1的10次求和与1进行逻辑判断得出的结果为False,遇见相似的问题会把自己急出一身汗,却也弄不个所以然来。

1
2
>>> 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1
0.9999999999999999

为什么Pyhon不认识0.1

通过上面的结果,可以大胆假设:其实Python不能正确理解0.1,因为若可以,结果就不会出问题。那么Python是不是不认识0.1呢?这是不是Python 这种编程语言的Bug(错误)?若不注意,在编程时会出现什么样的问题?出现这类问题的根本原因在哪?如何才能避免此类问题的出现呢? 希望下面的内容能不能解决上述的疑问。

首先,咱们看看浮点数在计算机里如何表示。先举个栗子,例如,小数(0.125)10,在十进制中其值等于1/10 + 2/100 + 5/1000 的值,这都很容易理解。如果将其转化为二进制小数则为(0.001)2, 是0/2 + 0/4 + 1/8.。的值,有点难懂是吧,可以参考下面的运算过程。 注意,两者在数值上相等,区别为:前者为十进制小数,后者为二进制小数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
a. 十进制小数 to 二进制小数的方法:"乘2取正, 顺序输出",即乘2取整,余数继续
2取整重复至小数部分为零或达到指定精度,第一次为最高位,最后一次为最低位。
例: 十进制小数(0.125)10的二进制小数算法如下:
0.125 * 2 = 0.25 取整 00.25
0.25 * 2 = 0.5 取整 00.5
0.5 * 2 = 1.0 取整 10
则 (0.001)2 为小数(0.125)10的表达。

b. 二进制小数 to 十进制小数,小数点后的权位表达方法为 2^(-1), 2^(-2),
2^(-3), ... , 2^(-n)。则如果将二进制小数(0.001)2转换为十进制方法如下:
0/2 + 0/4 + 1/8 = (0.125)10
感兴趣的朋友可以使用下面代码进行尝试。
>>> from __future__ import division
>>> 0.125
0.125
>>> 1/10 + 2/100 + 5/1000
0.125
>>> 0/2 + 0/4 + 1/8.
0.125
>>>

因为计算机内部的所有运算都是基于二进制的,在计算机内部,大部分的十进制的分数(大部的分数可以通过小数表达)却不能使用二进制完美地表达。这和十进分数1/3十分相似,可以取为0.3,或者精确一些取为0.33,或者再精确点取0.333,进而以此类推,在十进制下,可以无限接近,但是却不能绝对精确。相同的原因,十进制数(0.1)10不能通过一个二进制的小数准确地表达,内容如下:

1
2
3
4
5
6
7
8
小数(0.1)10 转换为二进制小数,步骤如下:
0.1 * 2 = 0.2 取整 0 余 0.2
0.2 * 2 = 0.4 取整 0 余 0.4
0.4 * 2 = 0.8 取整 0 余 0.8
0.8 * 2 = 1.6 取整 1 余 0.6
0.6 * 2 = 1.2 取整 1 余 0.2
...
然后就开始无限循环了 -_- 。

所以,在计算机内部,如果用一个二进制小数表达1/10 即 0.1 的时候,二进制的小数大概为:

1
(0.00011001100110011001100110011001100110011001100110011010)2

其值非常接近余1/10 但却不相等。一般情况下,我们会需要一个同一的标准,在IEEE 754 双精度浮点标准下,Python会用53位的精度来表达1/10。而此时如果我们将上面的二进制转换为小数应该会得到下面的值:

1
(0.1000000000000000055511151231257827021181583404541015625)10

其值非常接近于0.1却不等于0.1。这时候可能就有人会问,我在Python的交互界面下输入0.1时,交互界面打印出来的就是0.1啊,如下:

1
2
3
4
5
>>> 0.1
0.1
>>> Decimal(0.1)
Decimal('0.1000000000000000055511151231257827021181583404541015625')
>>>

其实这时候,Python的交互命令行只打印了这个值的一小部分,真实值(注意这里的真实值也与实际值1/10不相等)的近似值。如果想查看完整的小数表达,可以使用decimal模块下的Decimal()函数,如上。

小结一下,Python确实不认识0.1。其实应该说,计算机不能准确地用二进制地方法表达0.1。如果能明白这些,就应该知道,这并不是Python这种语言的Bug,也不是自己代码的Bug,这是二进制浮点数的常见情况(不能称之为问题)。而且所有的编程语言会出现这种情况,所有支持浮点运算的计算机也都存在相似的情况,只是表现方式会有所差异。

若不注意这些,在编写代码的时候会出现什么样的潜在问题呢?因为计算机不能正确的表达1/10,显而易见的问题就是我们文章一开头的求和问题。遇见这样的问题时,有人会提出Python和一些编程语言提供round()函数,用于取舍不就会得到最接近的值?那该函数是不是也能够用在此处解决这样的问题呢?我们来做个实验,这个实验和文章开头有点不一样,我们也做0.1的求和,但是不做那么多次的加法,三次足亦,如下:

第一步,我们用最简单的方法求和,求三个0.1的和,然后和0.3进行比较,发现果然和预期(为真)的不一样。要注意:计算机不能正确表达0.1,同时0.3不能被正确表达。所以两者不相等。

1
2
>>> 0.1 + 0.1 + 0.1 == 0.3
False

第二步,我们用round()函数进行提前取舍,取舍精度都为1, 然后进行求和判断。发现结果也不符合预期,可以推测的是,先取舍再求和,最终结果也不和0.3相等。需要注意的是,这个过程不能验证,只要验证了结果就是对的,有点量子论里测不准的感觉,内容如下:

1
2
>>> round(0.1, 1) + round(0.1, 1) + round(0.1, 1) == round(0.3, 1)
False

第三步, 我们不提前取舍,先求和然后取舍,进而与0.3 进行比较,此时结果就符合预期了,而且调整取舍精度也能再次得到预期的结果,如下:

1
2
3
4
>>> round(0.1 + 0.1 + 0.1, 1) == round(0.3, 1)
True
>>> round(0.1 + 0.1 + 0.1, 10) == round(0.3, 10)
True

上面的例子中不仅仅是因为,计算机里二进制浮点数不能正确表达十进制分数的;还有一个原因是内置round()函数的取舍也有问题。例如,一个小数2.675舍入到小数点后两位,按照四舍五入的原则,预期的结果应该为2.68。但是实际不是,是2.67,原因就是计算机浮点数不能正确表达十进制分数(小数)。十进制小数2.675的在计算机内转换为浮点数后如下:

1
2.67499999999999982236431605997495353221893310546875

因为,这个数的数值更接近与2.67而不是2.68,所以使用round()函数时会向下取舍。

小结,因为计算机的二进制浮点数不能正确表达十进制分数,所以编程时遇见浮点数据运算就会得到诸如此类的奇异结果。但我们不能因为会遇见这样的怪异现象,就仇视或者惧怕浮点数和有意避免浮点数运算。此问题有种官方的叫法,叫做表达错误(representation error)。

表达错误

在这个章节中我们探索一下,十进制的分(小)数在计算机里是如何转换为二进制的浮点数的。也希望借助此章节的内容,各位能够更加深入地理解为什么编程语言不能按照预期表达十进制数值。

通过前面的章节我们已经确定,计算机确实不能正确地表达1/10,而如今绝大部分的计算机都使用IEEE-754的浮点数算法,而几乎所有的平台和版本的Python会将浮点数映射为IEEE-754的双精度浮点数。即,计算机会尽量将输入的十进制小数通过方程J/2^N转化为最接近的二进制小数,其中J为一个53位的整数,N为最佳指数。

举例来说,就拿1/10来说,如果该有理数需要在Python中需要转化为计算机的双精度浮点数,通过上面的方程来转化会写作:

1
1/10 ≈ J/2^N #近似等于

转换等式,可得:

1
J ≈ 2^N / 10

因为双精度浮点数有52位来存储有效数字,而同时IEEE 754规定:在计算机内部保存有效数字时,默认这个小数(二进制)的第一位总是1,因此可以被舍去,只保存后面的内容。因此双精度浮点的有效数数字应该有53位。即在上式中J应该被定义为一个53位的整数。等式的右边应该无限接近于这个53位的整数即,应该满足大于等于2^52,小于2^53。通过计算可得当N等于56时满足条件,如下:

1
2
3
4
5
6
7
8
9
>>> 2**52
4503599627370496L
>>> 2**56 // 10
7205759403792793L
>>> 2**53
9007199254740992L
>>> 2**52 <= 2**56 // 10 < 2**53
True
>>>

接下来我们就要考虑这个等于2^56的整数是不是位最佳值,为此,需要得到该整数与10进行除法运算的商和余数,在这可以使用divmod()内置函数,如下:

1
2
3
4
>>> q, r = divmod(2**56, 10)
>>> q, r
(7205759403792793L, 6L)
>>>

在上式中其除法运算的余数大于5,即更接近10,所以该整数的最佳近似值,应该向上取整,即在原来商的基础上加1。

因此,十进制的分数1/10,在IEEE 754 规定的双精度浮点的标准下,最佳近似值如下:

1
2
>>> 7205759403792794 / 2 ** 56
0.1

由于我们在计算的时,整数J向上取整了(式子为J/2^N),也就意味着得到的浮点数应该比1/10要稍微大一点。而如果我们不向上取整,又会比1/10的实际值小一点点。反正不管怎么算,就是得能精确等于1/10,这就是为什么说Python从没见过1/10长什么样,即计算机其实不认识1/10。

我们再往下探索一下:由上可知,按照IEEE 754 标准,如果我们将最后一步中分子分母约分(因为分子为偶数,即公因数为2),则可以得到下面的内容:

1
3602879701896397 / 2 ** 55

这时候,J为3602879701896397,N为55。如果我们想看到这个浮点数所有的有效数字,可以将小数整数化,也就是说让J乘以10^55,就可以得到一个55位的整数,如下:

1
2
3
4
5
>>> 3602879701896397 * 10 ** 55 // 2 ** 55
1000000000000000055511151231257827021181583404541015625L

>>> Decimal(0.1)
Decimal('0.1000000000000000055511151231257827021181583404541015625')

由上可得,得到的整数化的有效数字,和计算机内部存储的是相同的。也就证明了,整个推到过程是正确无误的。虽然计算机可以实现到53位的精度。但是因为编程语言中print足够聪明,所以如果直接输入0.1,打印出来的内容也就是0.1。因为大部分情况下,可能不需要这个高的精度,所以很多编程语言在做浮点数的算术运算的时候,一般取的是小数点后17位的精度,即:

1
2
3
4
>>> 0.1
0.1
>>> print "%.17f" % 0.1
0.10000000000000001

总结

因为运算的每一步都需要会涉及到精确度和有效数字丢失的问题,所以编程中,浮点数的计算常常会得到不满足预期的结果,但是浮点数又是很重要的一种数据类型,又不能够舍弃。总之,当遇见代码中出现浮点算术运算问题的时候,就应该考虑到是不是遇见了上面的问题,进而就要找到合适的计算方式,对计算结果进行精度上的取舍。而如果代码中设计到大量的浮点数计算,在Python中,就可以考虑使用专门的模块来处理这些计算。

讨论

Python 的浮点数运算模块decimal,使用decimal中的Decimal函数可以允许浮点数以字符串的方式传入,这样就可以保有足够的(大于双精度浮点数的53位)有效数字,从而避免计算时精度的丢失。在本章的一开始遇见的0.1求和的问题就可以使用Decimal来解决,如下:

1
2
3
4
5
6
7
8
9
10
>>> from decimal import Decimal
>>> x = Decimal('0.0') # 注意:传入字符串。若传入浮点数,就丢失了精度
>>> for i in range(10):
... x += Decimal('0.1')
...
>>> x
Decimal('1.0')
>>> print "%.17f" % x1.00000000000000000
>>> float(x) == 1
True

Decimal模块可以解决问题,在一些涉及到高精度计算或者一些金融方面的计算时,也能够保证不丢失精度,毕竟关乎钱的问题就不能允许有误差存在,但是这样的计算会却有牵扯到浮点数与字符串之间的转换问题,性能肯定会下降。程序设计是需要考虑到所有因素,假如17位的普通精度能够满足设计要求,而且计算允许一定的误差,同时又有性能上的要求,毕竟原生的浮点数计算速度会快很多,就可以使用math模块来解决类似的问题。

关于math模块我们在这里借助一个新的例子,这个例子更为典型也十分有趣,如下:

1
2
3
>>> nums = [1.23e+18, 1, -1.23e+18]
>>> sum(nums)
0.0

求和过程丢失了一个1,感觉十分神奇。其计算结果就不是近似了,根本就是完全错误。主要原因就是函数sum()做的就是一个加法运算,该函数不在意过程中精度的损失,只是简单的求和。为了避免上面的计算错误,就可以使用math模块下的fsum()函数来处理,如下:

1
2
3
4
>>> import math
>>> nums = [1.23e+18, 1, -1.23e+18]
>>> math.fsum(nums)
1.0

使用函数math.fsum()进行计算的时候,函数会回溯每一步计算的误差,在保持精度的同时得到计算的最优解,所以结果符合预期。总的来说,decimal模块可以被用于涉及到金融领域和科学计算的工程领域。但是一般问题中设计到浮点数计算的问题,math模块足够胜任。

在程序开发上遇到问题,如果追本溯源,就肯定能够找到问题的根本原因,也能够借助一篇篇文章了解问题出现的机理,进而明白一些现实因素的局限性,也能够扩展自己知识面,也希望大家能够通过这篇文章真正理解,为什么计算机不能正确认识1/10,即明白二进制下如何表达浮点数的方法。

参考目录

IEEE754 的内容 :https://de.wikipedia.org/wiki/IEEE_754

Python表达错误 :https://docs.python.org/2/tutorial/floatingpoint.html

Python浮点计算 :http://python3-cookbook.readthedocs.io/zh_CN/latest/c03/p02_accurate_decimal_calculations.html


版权声明:
文章首发于 Jim Wang's blog , 转载文章请务必以超链接形式标明文章出处,作者信息及本版权声明。