Python3 四舍五入问题详解

Python
426
0
0
2022-10-14
标签   Python基础

“就本质来说,浮点算术是不精确的,而且程序员们很容易滥用它,从而使计算的结果几乎全部由噪声组成”

–Donald Knuth(《程序设计艺术》( 第二卷) 半数值算法)

一. 舍入困惑

Python2中,round函数使用靠 近 最 近 和 等 距 远 离 0 ‾ \underline{靠近最近和等距远离0}

靠近最近和等距远离0

(ROUND_HALF_UP)策略,是通常习惯使用的四舍五入模式。

(Values are rounded to the closest multiple of 10 to the power minus ndigits; if two multiples are equally close, rounding is done away from 0 )

而在Python3中,round函数的取舍方式使用靠 近 最 近 和 等 距 靠 近 偶 数 ‾ \underline{靠近最近和等距靠近偶数}

靠近最近和等距靠近偶数

(ROUND_HALF_EVEN)策略,

(values are rounded to the closest multiple of 10 to the power minus ndigits; if two multiples are equally close, rounding is done toward the even choice)

同时,float类型采用双精度二进制存储(参照IEEE754标准),round函数使用的是二进制存储值,在舍入时会对结果产生影响,而round本身没有使用四舍五入规范,就造成了一些困惑。

不好理解的舍入:

    >>> round(1.205, 2)

    1.21

    ......

    >>> round(1.245, 2)

    1.25

    >>> round(1.255, 2)

    1.25

    >>> round(1.265, 2)

    1.26

    ......

    >>> round(1.295, 2)

    1.29

Numpy的round出现同样问题:

	>>> numpy.round(1.255, 2)
	1.25
	>>> numpy.round(1.265, 2)
	1.26

大多数浮点数出现“尾部乱码”与“999变异“:

	>>> format(1.215, '.52f')
	'1.2150000000000000799360577730112709105014801025390625'
	>>> round(1.215, 2)
	1.22
	>>>formatl(1.275, '.52f')
	'1.2749999999999999111821580299874767661094665527343750'
	>>> round(1.275, 2)
	1.27

似乎并不是全部:

	>>> format(1.125, '.52f')
	'1.1250000000000000000000000000000000000000000000000000'
	>>> round(1.125, 2)
	1.12
	>>> format(0.5, '.52f')
	'0.5000000000000000000000000000000000000000000000000000'
	>>> round(0.5, 0)
	0

当需要掌握精确的计算结果时,round()似乎成为头疼的问题。

本来一个简单的问题,为什么变得难以理解,难道是Python的round策略出了问题?

作为一个专业级语言平台,Python显然不会没有想到这样的问题。

其实,浮点数问题并非我们想象的那样简单。

二. 问题分析

就浮点数的精度问题,正如计算大师Knuth所说,这是一个超出我们想象的复杂问题,其结果往往使我们很颓丧,甚至不敢再相信计算机。要掌握其计算规律,确实需要一些较为耐心和深入的探讨。在浮点数计算方面,我们有很多错觉,往往会将计算机的有限计算与代数中的计算相混淆,以至于对很多结果产生迷惑。对于一个认真的程序员来说,如果想知道浮点数运算结果的确切性,需要了解计算机对浮点数的表示和处理方式。想得到比较彻底的答案,需要具体研究一下IEEE 754标准,或研读一下Kuth的《计算机程序设计艺术》。

针对Python中浮点数float的四舍五入问题, 我们需要理解两个方面:二进制表示方式、十进制舍入策略。

$\underline{浮点数类型float与round的设计}$

  1. float采用IEEE754格式,使用二进制编码存储浮点数,与其他计算机语言并无太多差异。float类型是双精度浮点数。
  2. round取舍方式是靠近最近和偶数,这个策略符合大规模计算的总体逼近优化原则,未采用通常的四舍五入策略。
  3. 对于高精度运算和十进制小数精确表示,Python提供了专门的模块decimal,并提供了可选择的舍入策略,包括四舍五入。

$\underline{float类型的二进制表示}$

  1. float采用二进制编码描述浮点数。在二进制表示中,大多数有限位十进制小数无法使用二进制进行有限位精确表示。也就是说,有限位数的十进制小数,往往会变为无限位数的二进制小数。 事实上,分母中含有非2质数因子的分数,都不能使用有限位二进制小数表示。而十进制小数分母中含有质数因子5,如果约分后分母中仍然含有因子5,就会变成无限位二进制小数。
  2. 对不能使用有限位二进制小数表示的十进制有限位小数,在系统中存储的是这些十进制浮点数的近似值。在近似值中,分为进位和截断两种类型,近似误差一般在${10}^{-17}$左右。进位近似值大于原值,截断近似值小于原值,所以对小数位较小的数值(如1.215被进位,1.275被截断),进位近似值会出现尾部的增加值(上面说的尾部乱码),截断近似值小于原值,会出现”999...“的近似值现象。
  3. 表示为二进制近似值后,Python系统在进行round计算时,使用近似值,不是使用原值。

【例1】十进制小数0.1无法使用有限位二进制精确表示,同一原值的近似值都是相等的。

从十进制到二进制 :$(0.1){10} = (0.0001100110011001100110011001100110011001100110011001...){2}$

在系统中,原值转为近似值:

	>>> a = 1.2355000000000000426325641456060111522674560546875
	>>> b = 1.2355
	>>> a == b
		True

【例2】经过二进制表示转换后,原值已经不存在,但有时系统显示会产生误会。

我们赋值给Python的一个数值,他会依据浮点数标准,将其转换为二进制存储的近似值。于是,系统内已经不是我们原来的实际数值。而对于系统来说,他把这两个数值认为是一样的。在实际运算中,他使用的是那个近似值。而容易迷惑的是,为了显示简捷,Python显示给用户的有时还是原值。

	>>> x = 0.1
	>>> print(x)
		0.1
	>>> format(x, '.20f')
		'0.10000000000000000555'
	>>> format(x, '.50f')
	    '0.10000000000000000555111512312578270211815834045410'

$\underline{内置函数round的舍入分析}$

<font face="黑体" color="Crimson">round(number, ndigits)

【values are rounded to the closest multiple of 10 to the power minus ndigits; if two multiples are equally close, rounding is done toward the even choice (so, for example, both round(0.5) and round(-0.5) are 0, and round(1.5) is 2). 】

对一个给定的浮点数$v$,这里不妨只考虑小于1的小数,即浮点数的有效尾数部分,忽略符号和指数部分。

其十进制表示为:

$v{10} = d{1} \cdots d{m} =d{1}10^{-1}+\cdots+d_{m}10^{-m}$

在Python中会使用float类型处理,使用与其最接近的二进制值存储表示:

$v{2} = b{1} \cdots b{n} = b{1}2^{-1}+\cdots+b_{n}2^{-n}$

这个二进制小数近似值$v_{2}$还原为10进制时,一般是不等于原值的,除非原值可以表示为2的有限负幂和。

如:0.125 = $0\frac{1}{2}+0\frac{1}{4}+1*\frac{1}{8} = 0.001$

$\underline {round进行小数位的舍入时, 实际上是对 v_{2}的十进制值进行处理}$

使用round对十进制数$v{10}$的第k位四舍五入时,实际上转为对$v{2}$的十进制值的处理,Python3目前的处理原则是:

(1)$v_{2}$第k+1位是0-4或6-9时,舍入是非常明确的,可以称之位4舍6入。

(2)$v{2}$第k+1位是5时,要看$v{2}$与$d{1}...d{k}$和$d{1}...(d{k}+1)$之间的距离:

  • -【1】 如果两个距离不相等,结果取最近的取值。
  • -【2】 如果两个距离相等,采用靠近偶数原则,即,如果$d{k}$是偶数就取$d{1}...d{k}$,否则取$d{1}...(d_{k}+1)$。

<font face="黑体" color=#0099ff>看下面两个浮点数保留两位小数的四舍五入:

  • 1.275 的二进制近似表示值: $v_{2}(1.275)$ = 1.274999999999999911182158029987476766109466552734375 计算1.27和1.28哪一个离$v_2(1.275)$更近, 显然1.27更近,于是:round(1.275, 2) = 1.27round(1.215, 2) = 1.22<font face="黑体" color=#0099ff>两边一样近的情况(原数值是2的幂次的有限表示)向偶数靠近, 如1.125 = 1 + 0$\frac{1}{2}$ + 0$\frac{1}{4}$ + 1$\frac{1}{8}$ = $1 + 12^{-3}$ ,结果为:
  • 1.215 的二进制近似表示值: $v_{2}(1.215)$ = 1.2150000000000000799360577730112709105014801025390625 由于$v_2(1.215)$与1.22比1.21更近,结果便是:
    >>> round(1.125, 2)
    1.2
    >>> round(1.5, 0)
    2.0
    >>> round(0.5, 0)
    0.0

看来靠round解决四舍五入是不行的,因为其设计目标不在于此,简单调整也满足不了四舍五入的要求。

逼近舍入有利于数据分析的精确性,是一个误差最小策略。

同时,从用户的角度来看,round也受二进制表示的影响。只考虑四舍五入问题的话,一定精度范围内

仅与舍入规则有关。但用户给出的值首先要转换到双精度近似值,round的规则用于这个近似值四舍五入,

就要考虑精度范围。这个范围对float 来说,就是52位二进制存储精度,即十进制的17位小数有效位之内。

这个有效位数包括整数部分位数。

三. 解决方法

字符串转换方法(不支持位数为负数,其他问题有待检验...):

def round45s(v, d=0):
    vl = str(v).split('.')
    sign = -1 if v < 0 else 1 
    if len(vl) == 2:
        if len(vl[1]) > d:
            if vl[1][d] >= '5':
                if d > 0:
                    vp = (eval(vl[1][:d]) + 1)/10**d * sign
                    return int(v)+vp
                else:
                    return int(v)+sign
            else:
                if d > 0:
                    return int(v) + eval(vl[1][:d])/10**d * sign
                else:
                    return int(v)
        else:
            return v
    return int(v)

改进的方法:

这个方法将原值转换为略大的十进制值,从而使输入值的有限小数位数字不会发生变化(后面是0值,直到15位),避免出现"999变异"。但受到双精度二进制近似存储影响,只能在十进制有效位数15位以内使用(digits<15)。需要注意,整数部分的位数也考虑在有效位数之内。

def round45r(number, digits=0):
    int_len = len(str(int(abs(number))))
    signal_ = 1 if number >= 0 else -1
    err_place = 16 - int_len - 1 
    if err_place > 0:
        err_ = 10**-err_place
        return round(number + err_ * signal_, digits)
    else:
        raise NotImplemented   # 受到float表示精度的限制!

round45r() 对负数和整数也有效,即支持v, d为负数的情况:

>>> round45r(-1.205, 2)
	-1.210000000000002	# 在16位补误差,保障前面的数字不会变化
>>> round45r(123.205, -2)
	100.00000000000001
>>> round45r(153.205, -2)
	200.0
>>> round45r(-153.205, -2)
	-200.0

如果运行时间可以承受,尽量考虑使用高精度十进制表示模块decimal(精度和舍入策略都可以控制):

from decimal import Decimal, Context, ROUND_HALF_UP
def roundl45d(v, d):
	if int(v) > 0:
	    d = d + str(v).find('.')	# 有效位包括整数部分 
    return float(Context(prec=d, rounding=ROUND_HALF_UP).create_decimal(str(v)))

>>> decimal45(1.205)
    1.21
>>> decimal45(1.255)
    1.26

效率测试结果:

>>> test_round45s(number=1000000)
	6.26826286315918e-06
>>> test_round45r(number=1000000)
    1.287583589553833e-06
>>> test_round45d(number=1000000)
	1.7323946952819824e-06

round45s比round45r有5倍的运算速度差异。

round45d与round45r基本在一个水平。

四. 进一步思考

  1. 实现更高效的方法,应该考虑使用c模块去写round45。 >>> x = round45(1.275, 2); print(x) 1.28 >>> format(x, '.30f') '1.280000000000000026645352591004'
  2. 如果计算结果仍然使用浮点类型float表示,其值还是Python中存储的二进制双精度近似数。
  3. 要精确进行浮点数运算,建议使用decimal模块,并通过字符串进行赋值,并根据计算需要设置精度和舍入策略。
	>>> from decimal import Decimal, gecontext, ROUND_HALF_UP
	>>> Decimal(‘1.675’)
		Decimal(‘1.675’)			# 使用字符串得到精确值
	>>> getcontext().prec = 52		# 设置精度为52位(这里指十进制)
	>>> Decimal(‘1’) / Decimal(str(2**52))	
		Decimal('2.220446049250313080847263336181640625E-16')	# 按照要求精确表示有效位数字(实际有效位数37位)
	>>> Decimal('0.1') + Decimal('0.2')
		Decimal('0.3')				# 精度范围内,准确运算
	>>> getcontext().prec = 3
	>>> getcontext().rounding = ROUND_HALF_UP
	>>> Decimal('1.275')
	    Decimal('1.28')

了解一下decimal的八种舍入策略

在decimal中,实现了八种rounding策略,计算时可以根据需要选取。

这些应该是科学计算和数据处理中经常使用的,也基本已经成为标准。

有些文章也谈到了Java BigDecimal的舍入策略,基本与Python decimal 类同,只是decimal多了一个ROUND_05UP。

(https://www.jianshu.com/p/87627d53f77b?utm_campaign=maleskine&utm_content=note&utm_medium=seo_notes&utm_source=recommendation)

通过decimal运算环境Context管理,设置取舍精度和策略

>>> tc = getcontext()	# 获取运算环境
>>> tc.prec
      28		# 缺省精度
>>> tc.rounding
      decimal.ROUND_HALF_EVEN	# 缺省策略为ROUND_HALF_EVEN
>>> tc.prec = 5	 # 设置5位有效数字精度
>>> tc.rounding = decimal.ROUND_HALF_UP		# 设置为新的舍入策略
decimal运算中的八种舍入策略:

1) ROUND_CEILING 向正无穷(Infinity)靠近

>>> tc.rounding = decimal.ROUND_CEILING
>>> tc.create_decimal(‘1.12345’)
	Decimal('1.1235')	# 正数时靠近较大方向
>>> tc.create_decimal(‘-1.12345’)
	Decimal('-1.1234')	# 负数时靠近绝对值较小方向

2) ROUND_DOWN 向0靠近

>>> tc.rounding = decimal.ROUND_DOWN
>>> tc.create_decimal(‘1.12345’)
	Decimal('1.1234')	# 正数时是向下取整
>>> tc.create_decimal(‘-1.12345’)
	Decimal('-1.1234')	# 负数时靠近绝对值较小方向

3) ROUND_FLOOR 向负无穷(-Infinity)靠近

>>> tc.rounding = decimal.ROUND_FLOOR
>>> tc.create_decimal(‘1.12345’)
	Decimal('1.1234')	# 正数时是向下取整
>>> tc.create_decimal(‘-1.12345’)
	Decimal('-1.1235')	# 负数时靠近绝对值较大方向

4) ROUND_HALF_DOWN 向最接近的近似值靠近,两边相等时靠近0方向

>>> tc.rounding = decimal.ROUND_HALF_DOWN
>>> tc.create_decimal(‘1.12346’)
	Decimal('1.1235')	# 两端不相等,为4舍6入
>>> tc.create_decimal(‘-1.12346’)
	Decimal('-1.1235')	# 两端不相等,为4舍6入
>>> tc.create_decimal(‘1.12345’)
	Decimal('1.1234')	# 两端相等,正数时是向下取整
>>> tc.create_decimal(‘-1.12345’)
	Decimal('-1.1234')	# 两端相等,负数时靠近绝对值较小方向

5) ROUND_HALF_EVEN 向最接近的近似值靠近;两边相等时,前面是奇数进位,偶数不进位

>>> tc.rounding = decimal.ROUND_EVEN
>>> tc.create_decimal(‘1.12346’)
	Decimal('1.1235')	# 两端不相等,为4舍6入
>>> tc.create_decimal(‘-1.12346’)
	Decimal('-1.1235')	# 两端不相等,为4舍6入
>>> tc.create_decimal(‘1.12345’)
	Decimal('1.1234')	# 两端相等,向偶数靠近
>>> tc.create_decimal(‘-1.12335’)
	Decimal('-1.1234')	# 两端相等,向偶数靠近

6) ROUND_HALF_UP 向最接近的近似值靠近,两边相等时靠近远离0的方向

>>> tc.rounding = decimal.ROUND_HALF_UP
>>> tc.create_decimal(‘1.12346’)
	Decimal('1.1235')	# 两端不相等,为4舍6入
>>> tc.create_decimal(‘-1.12346’)
	Decimal('-1.1235')	# 两端不相等,为4舍6入
>>> tc.create_decimal(‘1.12345’)
	Decimal('1.1235')	# 两端相等,正数向靠Infinity近
>>> tc.create_decimal(‘-1.12345’)
	Decimal('-1.1235')	# 两端相等,负数向-Infinity靠近

7) ROUND_UP 靠近远离0的方向

>>> tc.rounding = decimal.ROUND_UP
>>> tc.create_decimal(‘1.12345’)
	Decimal('1.1235')		# 正数时向上取整
>>> tc.create_decimal(‘-1.12345’)
	Decimal('-1.1235')		# 负数时向下取整

8) ROUND_05UP 如果向0靠近取舍后保留小数的最后一位是0或5,就向远离0靠近,否则就向0靠近。

>>> tc.rounding = decimal.ROUND_05UP
>>> tc.create_decimal(‘1.12343’)
	Decimal('1.1234')		# 向0靠近取舍后最后一位不是0或5
>>> tc.create_decimal(‘1.12351’)
	Decimal('1.1236')		# 向0靠近取舍后最后一位是5,选择远离0
>>> tc.create_decimal(‘-1.12355’)
	Decimal('-1.1236')		# 向0靠近取舍后最后一位是5,选择远离0
>>> tc.create_decimal(‘-1.12305’)
	Decimal('-1.1231')		# 向0靠近取舍后最后一位是0,选择远离0