round 不是四舍五入:2.5 舍成 2,负数区间也照旧
对账时冒出来的怪数字
给人查一段金额处理,对方说 2.5 舍完竟然是 2,怀疑程序写错了。我起初也以为是浮点尾数在捣乱,可把值打印出来,干干净净一个 2.5。顺着查下去才明白,Python 的 round 压根没走小学那套满五进一。这类问题藏得深,多数时候结果看着都正常,只有正好落在 .5 上才会露出来。
把几个 .5 挨个过一遍
在本机 Python 3.13.14 上,把 0.5 到 5.5 全部测一遍:
for x in [0.5, 1.5, 2.5, 3.5, 4.5, 5.5]:
print(x, "->", round(x))
输出:
0.5 -> 0
1.5 -> 2
2.5 -> 2
3.5 -> 4
4.5 -> 4
5.5 -> 6
规律一眼能看出来:结果全往偶数上靠。0.5、2.5、4.5 往下压,1.5、3.5、5.5 往上抬,共同点是结果一定是个偶数。
这套规则叫银行家舍入
它的正式名字是银行家舍入,也叫四舍六入五成双。碰到正好卡在两数中间的 .5,就看前一位是奇数还是偶数:是偶数就压下去,是奇数就抬上来,最终停在偶数。Python 从 3 开始就固定采用这套策略,并且把它写进了语言行为里,不是某个版本的临时实现。
负数半值也是同一套规则
负数这边常被当成例外,其实单参数形式下规则一模一样。下面只测负半值,同一套测法换成负数:
for x in [-0.5, -1.5, -2.5, -3.5, -4.5, -5.5]:
print(x, "->", round(x))
输出:
-0.5 -> 0
-1.5 -> -2
-2.5 -> -2
-3.5 -> -4
-4.5 -> -4
-5.5 -> -6
镜像关系很整齐:正数那边 0.5 舍成 0,负数这边 -0.5 也舍成 0;正数 1.5 升到 2,负数 -1.5 降到 -2。在单参数形式下,这几个值都满足 round(-x) == -round(x),可以用下面这一行验证:
print(all(round(-x) == -round(x) for x in [0.5, 1.5, 2.5, 3.5, 4.5, 5.5]))
输出:
True
这里要留意网上流传较广的一种说法:负数一律往远离零的方向舍,round(-0.5) 得 -1。平局时远离零确实是 Python 2 的行为,那时 round(-0.5) 等于 -1.0;Python 3 改成平局取偶之后,同一个式子变成 0。但「负数一律得 -1」这个概括跟 Python 2 也对不上,因为 round(-1.5) 在两个版本里都是 -2,更像是把负数进位想当然地套了过来。在刚才测的这六个负半值里,两个版本之间变了的是 -0.5、-2.5、-4.5 这三处,正好是前一位为偶数的那些,Python 2 分别给 -1.0、-3.0、-5.0,Python 3 给 0、-2、-4;前一位为奇数的 -1.5、-3.5、-5.5 两边一致,因为远离零和取偶在奇数位上撞到同一个答案。
真正让人困惑的是浮点表示
再看两个数,容易觉得规则又变了:
print(round(2.500000000000001))
print(round(0.05, 1))
输出:
3
0.1
第一个得 3 好理解,2.500000000000001 比 2.5 大了一丁点,不算正中间,按更大处理直接进位。第二个 0.05 保留一位得到 0.1,看着又像满五进一了。其实 0.05 在二进制里存的值比十进制的 0.05 略大那么一点,它并没有真的落在中间,于是按略微更大往上走。用 Decimal 把它打出来,差值看得见:
from decimal import Decimal
print(Decimal(0.05) - Decimal("0.05"))
输出:
2.775557561562891351059079170E-18
差是正的,说明实际存的比字面写的略大。Decimal 默认精度 28 位,改了 getcontext().prec 输出会跟着变,但正负号不会变。两处都不是规则改了,是二进制浮点的表示误差在底下起作用。
想确认某个 float 是不是精确等于某个十进制字面量,直接判等更直观:
from decimal import Decimal
print(Decimal(0.5) == Decimal("0.5"))
print(Decimal(0.05) == Decimal("0.05"))
输出:
True
False
0.5 是二进制能精确表示的,所以它跟字面量相等;0.05 不行,存下来的值比字面写的略大,连跟字面量都不相等。判等回答的是「存下来的值是否精确等于这个十进制字面」,不是通用的判平局方法。
对称分布下偏差能互相抵消
这套规则常被说成偏差能抵消,拿十个 .5 就能验证:
vals = [i + 0.5 for i in range(10)]
print(sum(vals))
print(sum(round(v) for v in vals))
输出:
50.0
50
原始总和是 50.0,逐个舍入之后再加还是 50,一点没偏。要是换成满五进一,这十个值会全部往上抬,总和变成 55,凭空多出 5 个。这就是偏差抵消的实感:五次压下去,五次抬上来,账是平的。
不过抵消依赖样本分布对称。十个数里 .5 落在偶位和奇位各一半,账才刚好平;换成五个递增的半值,和是 12.5,舍入后 12,差 -0.5;换成 101 个递增的半值,和是 5100.5,舍入后 5100,仍只差 -0.5。可要是 101 个值全是同一个 0.5,原始和 50.5,逐个舍入后全成 0,账直接塌了。所以偏差抵消的前提是分布对称,一旦所有半值压在同一侧,偏差会原样累积。反复舍入的统计、财务场景,这个差别会被放大。
要严格满五进一就换 decimal
发票、报价这类按业务规矩必须满五进一的场合,别指望 round,把口径显式写出来才稳:
from decimal import Decimal, ROUND_HALF_UP
one = Decimal("1")
print(Decimal("2.5").quantize(one, rounding=ROUND_HALF_UP))
print(Decimal("3.5").quantize(one, rounding=ROUND_HALF_UP))
输出:
3
4
给 Decimal 传字符串这一步很要紧,先转成 float 就又会把表示误差带进来。上面两个恰好是二进制能精确表示的值,所以看不出差别;换成 0.05 就不行了,Decimal(0.05) 存的是那个偏大的近似值,只有 Decimal("0.05") 才是业务想要的十进制意图。
有个坑要单独提:ROUND_HALF_UP 的官方定义是平局时远离零,负数上也不例外,Decimal("-2.5") 在这个模式下得 -3,不是 -2。业务要负数也往大的方向进,可以换成 ROUND_CEILING,但它对所有值都朝正无穷取整,2.1 这样不是平局的数也会变成 3,并不是「满五进一的负数版」,用之前先确认这符合业务口径。
遇到 .5 先别急着改代码
我的排查顺序是两步:先把原始值转成 Decimal、跟十进制字面量比一下是否精确相等,确认它是不是真的恰好在一半,多数怪现象到这一步就解释清楚;再回头问清楚业务到底要哪种舍入口径,是银行家舍入还是满五进一。口径定错了,比写错一行代码藏得深得多。这次最大的收获不是记住 round 怎么算,而是意识到舍入底下叠着两套逻辑,一套是规则,一套是浮点表示,分开看才不会互相背锅。
- 点赞
- 收藏
- 关注作者
评论(0)