python的精度控制

liangsqrt

python精度问题

想起来以前有次做客户的收益计算(一群电力客户,给他们分配不同的电量,计算不同分配方法的收益),跟同事一起,同事提供收益计算方法,我负责将计算方法包装起来,做成web服务,然后用它来为客户服务(挣客户的钱😸)。然鹅,并没有顺利挣到。代码已经无从考究,但是其中的一个大麻烦还记得很清楚,就是精度问题。

我们在控制变量的精度的时候,用的是python的round。我们将达到精度的数据,作为初始值,放到求解器中。这些数据时我们根据业务和实际经验,手动优化的数据,目的是减小解空间大小,加快求解器出结果的速度。然而对求解器来说,初始值0和0.000000001,在很多时候,有质的差别,会造成最终结果的巨大误差。

这些冒出来的小数,一直在我印象里就是鬼魅般的存在!!!所以这里好好整理下python的精度问题。

来个场景,稳定复现

x = 0.0  #important
for i in range(10):
    x+=0.01
    print(x)
  
'''
0.01
0.02
0.03
0.04
0.05
0.060000000000000005
0.07
0.08
0.09
0.09999999999999
''' 
简单解释下:因为 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~几下,精度就丢失了!

要理解这个,还需要提及到二进制是怎么保存小数的原理了

二进制保存小数的原理

难受劝退!

十进制是怎么表示小数的呢?

# 直观 相当于拆分成什么十分位、百分位、千分位来表示
125.456  =  1*10^2+2*10^1+5*10^0+4*10^-1+5*10^-2+6*10^-3
# 指数
1.25456E2 = (1+2*10^-1+5*10^-2+4*10^-3+5*10^-4+6*10^-5)*10^2

同理看一下二进制:

# 二进制  = 十进制    不要在意为什么二进制有个小点
0.1 = 1*2^-1(0.5)
0.01 = 1*2^-2(0.25)
0.001 = 1*2^-3(0.125)
0.0001 = 1*2^-4 (0.0625)
0.00001 = 1*2^-5 (0.03125)

# 其实任意一个数上边这样表示的二进制数据,我们都可以转换成十进制数,比如
10001.101 = 1*2^4+1*2^0+1*2^-1+1*2^-3
# 上边这个更容易理解为, 看看左边末尾的2^4,是不是很像我们的十进制提取出10的倍数的表示。
# 是不是有点反直觉,不是1.445352 * 2^4这样的表达式。细想一下,这种表达式应该是10进制下才有的吧!
1.0001101*2^4 = ( 1+1*2^-4+1*2^-5+1*2^-7 ) * 2^4

可以看到二进制是可以表示很小的数的,也可以是很大的数。那么大大小小的数加起来,就可以表示任意大小的十进制数了。虽然不一定完全相等,但是只要我小的数足够多,加起来,近似还是可以的。

所以得出结论:任意一个十进制的数值都可以表示成或者近似表示成(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都不能被上述样式精确表示出来。有些时候也会出现能够除尽的算式,计算机中却不能除尽。很是神奇,比如下边:

经典例子:

#!/bin/python
a = 3240.0
b = 8.0
d = (a*(b/100))/(1+(b/100))
print(d)

'''
239.99999999999997  # 嗯,除不尽了,头大吧
'''

这就是我们计算机中,各种数的表示方法。

  • float有4个字节32为,首位表示符号,接下来8位表示阶数K,剩下23表示二进制的小数部分;
  • double有8个字节,64位,首位表示符号,11位表示阶数k,剩下表示小数部分;(python默认的小数类型)

回到python的精度问题

1/10 用2的指数来表示,确实是表示不完的,没有一个二进制整数倍表达式,所以会有开篇引用的例子里,出现很多个二进制数。然后回到round函数,(0舍1入的规则),是不是感觉到了一丝丝坑意。

在python中,一个小数,是可以看到他的二进制表示的,错了,不是二进制,而是16进制,不过差不多

a = 1.2
a.hex()
"""
0x1.3333333333333p+0
"""
b = 0.00000041
b.hex()
"""
0x1.b83bf11ce33aap-22
"""

# 0x 开头代表 后边的数据,是一个16进制数

# p-4表示 * 2^ -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')

一般我们用十六进制科学计数法来表示 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进制
  1. 比如二进制的 0.1011 这个数,从小数点后一位一位的来看每个数字的意义:
  2. 先看开头的0.1, 这个代表着(1/2=0.5),0.1011去掉0.1后边还有值,所以说明这个值大于0.5;
  3. 但是由于0.1后是0,说明这个值在这个这里要加上 (0.5 + 0 * 0.125 = 0.5 + 0 = 0.5)
  4. 接下来的 1 代表真值位于 0.10的右侧(0.5 + 0 + 0.125),
  5. 再接下来的 1 代表真值位于 0.101的右侧(0.5 + 0 + 0.125 + 0.0625)。
  6. 所以0.1011 = 0.6875。
0.6875 如何翻译为2进制

首先:

  1. 0.6875 > 1/2 ,所以二进制的小数第一位肯定是1,可以先写成0.1;
  2. 之后(0.6875-0.5 = 0.1875 < 0.25), 所以第二位是0,此时可以写成0.10。
  3. 继续, 由于第二位是0,没有消耗值,所以0.1875任然是0.1875,此时在第三位上,为0.125, 0.1875 > 0.125, 所以第三位为1;
  4. 此时剩余0.065, 而刚好第四位就等于0.065
  5. 所以最终值为0.1011

img

到现在是不是对二进制表达小数理解的比较清楚了呢?

阅读 318
1 声望
0 粉丝
0 条评论
你知道吗?

1 声望
0 粉丝
宣传栏