在 Python 中如何正确地四舍五入?

用 Python 进行四舍五入是一个很常见的需求。但是经常有人用习惯性的思维去理解,导致出现与期望不同的现象,进而引发各种问题。

以下均以保留 2 位小数为例

round 函数的问题

这是网上的普遍的用法:

1
2
3
4
5
In [3]: round(1.115, 2)
Out[3]: 1.11 # 错误,应该是 1.12

In [8]: round(0.375, 2)
Out[8]: 0.38 # 正确

可见,round 并不总是能返回我们想要的结果的。

一个容易发现的原因就是十进制小数转二进制时精度丢失的问题,即 Python 存储 1.115 的时候实际上是 1.1149...:

1
2
3
4
In [11]: from decimal import Decimal # decimal 是 Python 专门处理高精度的库

In [12]: Decimal(1.115)
Out[12]: Decimal('1.1149999999999999911182158029987476766109466552734375')

而对 1.1149 保留2位小数,当然是 1.11 而不是 1.12 了。

但是,十进制小数转二进制的时候也会出现能完全转换的,例如:1.125

1
2
In [15]: Decimal(1.125)
Out[15]: Decimal('1.125')

那么这时 round 的结果会符合我们的预期吗?

1
2
In [16]: round(1.125, 2)
Out[16]: 1.12

依然不行。这又是什么原因呢?

实际上,计算机语言都是这样处理的,这个方式叫:奇进偶舍
> 例如数 a.bcd,我们需要保留 2 位小数的话,要看小数点后第三位:

  1. 如果 d<5,直接舍去
  2. 如果 d<5,直接进位
  3. 如果 d == 5:
    1. d 后面还有非 0 数字,例如 a.bcdef,f 非 0,那么要进位
    2. d 后面没有数据,且 c 为偶数,那么不进位
    3. d 后面没有数据,且 c 为奇数,那么要进位

所以,我们可以总结一下,假如你要使用 round 来保留位数,那么你需要考虑 2 个问题:

  1. 这个数可能无法被计算机精确地表示,这个误差是否能接受?
  2. 奇进偶舍这个方法的误差是否能接受?

如果有一个答案是:不能,你就需要 decimal(其他的方法不考虑,见文章最后)。

decimal: 高精度解决方案

由我们自己去关注上面提到的问题显然是反人类的。于是 Python 提供了用于处理高精度数字的库:decimal

这个库直接解决了上面的第一个问题。

但是使用的时候需要注意给 Decimal 传字符串与浮点数的区别:

1
2
3
4
5
In [24]: Decimal(1.115)
Out[24]: Decimal('1.1149999999999999911182158029987476766109466552734375')

In [25]: Decimal('1.115')
Out[25]: Decimal('1.115')

为什么两种不同呢?原因在于直接传浮点的时候,Python 没法存下 1.115,将 1.115 转为 1.11499...,所以在给 Decimal 的时候就已经丢失精度了。所以使用 decimal 的时候最好用字符串传参。并且,一定要全程使用 Decimal 类型,不要混用 float 与 Decimal 类型

接下来我们对 1.115 试试四舍五入,看看是不是我们需要的:

1
2
In [27]: Decimal('1.115').quantize(Decimal('0.00'))
Out[27]: Decimal('1.12')

不错,是我们需要的。再试试 0.125
1
2
In [33]: Decimal('0.125').quantize(Decimal('0.00'))
Out[33]: Decimal('0.12')

居然还是不行。问题又出在哪呢?其实和之前的 round 一样,也是 奇进偶舍。为了证明这一点,我们可以去 Python 的官方文档查阅 quantize 的用法:
quantize

可以看到:

简单的翻译一下就是:quantize 有个可选参数 rounding。如果不指定它的话,使用的是 context 的值。如果 context 也没指定值,那就用此线程的上下文中的 rounding

那么问题来了,rounding 到底有哪些值可选呢?文档也有:

那么我们显然没指定 rounding,上下文用的是哪个呢?我们打印一下看看:

1
2
3
4
In [42]: from decimal import getcontext

In [43]: getcontext().rounding
Out[43]: 'ROUND_HALF_EVEN'

ROUND_HALF_EVENRound to nearest with ties going to nearest even integer.,就是我们上面提到的 奇进偶舍

ROUND_HALF_UP 才是我们想要的:

1
2
3
4
5
6
7
In [65]: import decimal

In [66]: Decimal('1.115').quantize(Decimal('0.00'), rounding=decimal.ROUND_HALF_UP)
Out[66]: Decimal('1.12')

In [67]: Decimal('0.125').quantize(Decimal('0.00'), rounding=decimal.ROUND_HALF_UP)
Out[67]: Decimal('0.13')

这下第二个问题也解决了。

后记

感慨于这么简单的需求实际上有很多东西蕴含在里面。Python 尚且如此,其他语言呢?我特意问了一下擅长 C++ 的 Barriery,他说 C++里是这样四舍五入的:

1
int(0.115*100+0.5)/100

2 位就是 100,3 位就是 1000,以此类推。我拿到 Python 里试了一下,还真的可以:

1
2
3
4
5
In [68]: int(0.115*100+0.5)/100
Out[68]: 0.12

In [69]: int(0.125*100+0.5)/100
Out[69]: 0.13

原理嘛,就是利用了 int 直接截断的特性。不得不说思路真是无奇不有啊。不过我还是推荐 decimal 这个库,专门处理高精度的数字,相当好用。


来呀快活呀


在 Python 中如何正确地四舍五入?
https://www.tr0y.wang/2019/04/08/Python四舍五入/
作者
Tr0y
发布于
2019年4月8日
更新于
2024年6月3日
许可协议