python 精度控制
想起来以前有次做客户的收益计算(一群电力客户,给他们分配不同的电量,计算不同分配方法的收益),跟同事一起,同事提供收益计算方法,我负责将计算方法包装起来,做成 web 服务,然后用它来为客户服务(挣客户的钱😸)。然鹅,并没有顺利挣到。代码已经无从考究,但是其中的一个大麻烦还记得很清楚,就是精度问题。
我们在控制变量的精度的时候,用的是 python 的round
。我们将达到精度的数据,作为初始值,放到求解器中。这些数据时我们根据业务和实际经验,手动优化的数据,目的是减小解空间大小,加快求解器出结果的速度。然而对求解器来说,初始值 0 和 0.000000001,在很多时候,有质的差别,会造成最终结果的巨大误差。
这些冒出来的小数,一直在我印象里就是鬼魅般的存在!!!所以这里好好整理下 python 的精度问题。
来个场景,稳定复现
简单解释下:因为 Python 中使用双精度浮点数来存储小数。在 Python 使用的 IEEE 754 标准(52M/11E/1S)中,8 字节 64 位存储空间分配了 52 位来存储浮点数的有效数字,11 位存储指数,1 位存储正负号,即这是一种二进制版的科学计数法格式。虽然 52 位有效数字看起来很多,但麻烦之处在于,二进制小数在表示有理数时极易遇到无限循环的问题。其中很多在十进制小数中是有限的,比如十进制的 1/10,在十进制中可以简单写为 0.1 ,但在二进制中,他得写成:0.0001100110011001100110011001100110011001100110011001…..(后面全是 1001 循环,因为 10=2*5,很难用二进制数来表示,具体原因可以往下读)。因为浮点数只有 52 位有效数字,从第 53 位开始,就舍入了。这样就造成了标题里提到的”浮点数精度损失“问题。 舍入(round)的规则为“0 舍 1 入”,所以有时候会稍大一点有时候会稍小一点。
所以 round~round~几下,精度就丢失了!
要理解这个,还需要提及到二进制是怎么保存小数的原理了
二进制保存小数的原理
难受劝退!
十进制是怎么表示小数的呢?
同理看一下二进制:
可以看到二进制是可以表示很小的数的,也可以是很大的数。那么大大小小的数加起来,就可以表示任意大小的十进制数了。虽然不一定完全相等,但是只要我小的数足够多,加起来,近似还是可以的。
###### 所以得出结论:任意一个十进制的数值都可以表示成或者近似表示成(1+12^-n+...12^-m)*2^k
有没有一种拉格朗日表达式的味道
例如:
>
8.5 = 1 2^4+1 2^-1
>
5.4 ~= 4+1+0.25+0.125+0.015625
>
4+1+0.25+0.125+0.015625 ~= ~~~~5.390625 = 12^2+12^0+12^-2+12^-3+1*2^-6
这也解释了为什么二进制是不能精确表示 1/10,因为无论加多少阶,1/10 都不能被上述样式精确表示出来。有些时候也会出现能够除尽的算式,计算机中却不能除尽。很是神奇,比如下边:
经典例子:
这就是我们计算机中,各种数的表示方法。
float 有 4 个字节 32 为,首位表示符号,接下来 8 位表示阶数 K,剩下 23 表示二进制的小数部分;
double 有 8 个字节,64 位,首位表示符号,11 位表示阶数 k,剩下表示小数部分;(python 默认的小数类型)
回到 python 的精度问题
1/10 用 2 的指数来表示,确实是表示不完的,没有一个二进制整数倍表达式,所以会有开篇引用的例子里,出现很多个二进制数。然后回到 round 函数,(0 舍 1 入的规则),是不是感觉到了一丝丝坑意。
在 python 中,一个小数,是可以看到他的二进制表示的,错了,不是二进制,而是 16 进制,不过差不多
小数点前这个“1”是不包含于 52 位有效数字之中的,但它确实是一个有效的数字呀,这是因为,在二进制浮点数中,第一位肯定是“1”,(是“0”的话就去掉这位,少一位,所以在指数上减 1)所以就不保存了,这里返回的这个“1”,是为了可读性,让人看着可信。在内存的 8 位空间中并没有它。所以 .hex() 方法在做进制转换的时候,就没有顾虑到这个“1”,直接把 52 位二进制有效数字转换掉就按着原来的格式返回了。因此这个 .hex() 方法即使名义上返回的是一个十六进制数,它小数点前的那一位也永远是“1”,看下面示例:
>
```python
float.fromhex('0x1.8p+1') == float.fromhex('0x3.0p+0')
```
>
一般我们用十六进制科学计数法来表示 3.0 这个数时,都会这么写“0×3.0p+0”。但是 Python 会这么写“0×1.8p+1”,即“1.1000”小数点右移一位变成“11.000”——确实还是 3.0 。就是因为这个 1 是直接遗传自二进制格式的。
而为了回应人们在某些状况下对这个精度问题难以忍受的心情,Python 提供了另一种数字类型——Decimal 。他并不是内建的,因此使用它的时候需要 import decimal 模块,并使用 decimal.Decimal() 来存储精确的数字。这里需要注意的是:使用非整数参数时要记得传入一个字符串而不是浮点数!!!如果传入浮点数,就相当于传入了一个近似值,一个不精确的数,这样就没有意义了。
然后为了更直观地表现,人们又开始用无限小数的形式表示有理数(分数)。而其中从某一位开始后面全是 0 的特殊情况,被称为有限小数(没错,有限小数也是由无限小数来表示的, 无限的小数,才是自然界小数的本体,有限或循环小数,只是其中的个例)。但因为很多时候我们并不需要无限长的小数位,我们会将有理数保存到某一位小数便截止了。后面多余小数的舍入方式便是“四舍五入”,这种方式较直接截断(round_floor)的误差更小。在二进制中,它表现为“0 舍 1 入”。当我们舍入到某一位以后,我们就可以说该数精确到了那一位。如果仔细体会每一位数字的含义就会发现,在以求得有限小数位下尽可能精确的值为目的情况下,直接截断的舍入方式其实毫无意义,得到的那最后一位小数也并不精确。例如,将 0.06 舍入成 0.1 是精确到小数点后一位,而把它舍入成 0.0 就不算。因此,不论是在双精度浮点数保留 52 位有效数字的时候,还是从双精度浮点数转换回十进制小数并保留若干位有效数字的时候,对于最后一位有效数字,都是需要舍入的。
这就是为什么 python 的 0 经常变得原因。以前经常发现这么一个问题:python 中的数据经过运算后,本来应该是 0 的,但是在存往 mysql(为浮点数)后,mysql 中经常就是 0.00000000000001(个数不一定对,反正就是很多 0),现在想想,极大可能就是因为精度的原因了,double 在转其他精度的过程中,发生了舍入导致在其它精度中最后一位进位了。
二进制数表达某个值时的特点
下为了更好地理解二进制数是如何表达一个数的。这里以一个小数为例子(如果能正确表达任意一个小数,应该就能表达任意一个整数了)
图是一个(0,1)之间的数轴,上面用二进制分割,下面用十进制分割,可以用来做对比,我们只介绍二进制数,十进制的表达式可能大家都比较熟悉了。
###### 0.1011 如何翻译为 10 进制
比如二进制的 0.1011 这个数,从小数点后一位一位的来看每个数字的意义:
先看开头的 0.1, 这个代表着(1/2=0.5),0.1011 去掉 0.1 后边还有值,所以说明这个值大于 0.5;
但是由于 0.1 后是 0,说明这个值在这个这里要加上 (0.5 + 0 * 0.125 = 0.5 + 0 = 0.5)
接下来的 1 代表真值位于 0.10 的右侧(0.5 + 0 + 0.125),
再接下来的 1 代表真值位于 0.101 的右侧(0.5 + 0 + 0.125 + 0.0625)。
所以 0.1011 = 0.6875。
###### 0.6875 如何翻译为 2 进制
首先:
0.6875 > 1/2 ,所以二进制的小数第一位肯定是 1,可以先写成 0.1;
之后(0.6875-0.5 = 0.1875 < 0.25), 所以第二位是 0,此时可以写成 0.10。
继续, 由于第二位是 0,没有消耗值,所以 0.1875 任然是 0.1875,此时在第三位上,为 0.125, 0.1875 > 0.125, 所以第三位为 1;
此时剩余 0.065, 而刚好第四位就等于 0.065
所以最终值为 0.1011
到现在是不是对二进制表达小数理解的比较清楚了呢?
版权声明: 本文为 InfoQ 作者【里昂】的原创文章。
原文链接:【http://xie.infoq.cn/article/cef83165005542459a321eb55】。文章转载请联系作者。
评论