为什么Ruby的Float#round行为与Python不同?

13
"Behavior of “round” function in Python"观察到Python对浮点数的四舍五入方式为:
>>> round(0.45, 1)
0.5
>>> round(1.45, 1)
1.4
>>> round(2.45, 1)
2.5
>>> round(3.45, 1)
3.5
>>> round(4.45, 1)
4.5
>>> round(5.45, 1)
5.5
>>> round(6.45, 1)
6.5
>>> round(7.45, 1)
7.5
>>> round(8.45, 1)
8.4
>>> round(9.45, 1)
9.4

被接受的答案确认这是由于浮点数的二进制表示不准确造成的,这一切都是合乎逻辑的。

假设 Ruby 的浮点数与 Python 一样不准确,那么为什么 Ruby 浮点数会像人类一样四舍五入呢?Ruby 是作弊吗?

1.9.3p194 :009 > 0.upto(9) do |n|
1.9.3p194 :010 >     puts (n+0.45).round(1)
1.9.3p194 :011?>   end
0.5
1.5
2.5
3.5
4.5
5.5
6.5
7.5
8.5
9.5

确实很奇怪。printf "%.20f", 1.45 # => 1.44999999999999995559 1.45.round 1 # => 1.5 - Karoly Horvath
你的意思是为什么在Python中round(1.45,1)返回1.4,但在Ruby中1.45.round(1)返回1.5 - Colonel Panic
3个回答

10

摘要

这两种实现都面临着关于二进制浮点数的问题

Ruby直接对浮点数进行简单操作(乘以10的幂,调整和截断)。

Python使用David Gay的复杂算法将二进制浮点数转换为最短的十进制表示形式,该形式与二进制浮点数完全相等。这不会进行任何额外的舍入,它是一种精确的字符串转换。

有了最短字符串表示形式,Python使用精确的字符串操作将其舍入到适当的小数位数。float-to-string转换的目标是尝试“撤消”一些二进制浮点表示误差(即如果输入6.6,则Python在6.6上进行舍入而不是6.5999999999999996)。

此外,Ruby在舍入模式方面与某些版本的Python不同:向远离零的方向舍入与向最接近偶数的方向舍入。

细节

Ruby 不会作弊。它使用与 Python 相同的普通二进制浮点数开始。因此,它面临一些相同的挑战(例如,3.35 被表示为略微 大于 3.35,4.35 被表示为略微 小于 4.35):
>>> Decimal.from_float(3.35)
Decimal('3.350000000000000088817841970012523233890533447265625')
>>> Decimal.from_float(4.35)
Decimal('4.3499999999999996447286321199499070644378662109375')

了解实现差异的最佳方法是查看底层源代码:

这里是Ruby源代码的链接:https://github.com/ruby/ruby/blob/trunk/numeric.c#L1587

Python源代码从这里开始:http://hg.python.org/cpython/file/37352a3ccd54/Python/bltinmodule.c 并且在这里结束:http://hg.python.org/cpython/file/37352a3ccd54/Objects/floatobject.c#l1080

后者有一个详细的注释,揭示了两种实现之间的差异:

基本思想非常简单:使用_Py_dg_dtoa将双精度浮点数转换并四舍五入为十进制字符串,然后使用_Py_dg_strtod将该十进制字符串转换回双精度浮点数。有一个小困难:Python 2.x希望round执行四舍五入到最近的偶数,而_Py_dg_dtoa执行的是四舍五入到最近的一半。因此,我们需要一些方法来检测和纠正中间值的情况。
检测:一个中间值具有k * 0.5 * 10 ** -ndigits的形式,其中k是奇整数。或者换句话说,如果一个有理数x恰好处于两个10 ** -ndigits的倍数之间,则其2-估值恰好为-ndigits-1,其5-估值至少为-ndigits。对于ndigits> = 0,后一个条件对于二进制浮点数x自动满足,因为这样的任何浮点数都具有非负的5-估值。对于0> ndigits> = -22,x需要是5 ** -ndigits的整数倍;我们可以使用fmod来检查这一点。对于-22> ndigits,没有中间值的情况:5 ** 23需要54位才能准确表示,因此任何n> = 23的奇数倍的0.5 * 10 ** n至少需要54位的精度才能准确表示。
纠正:处理中间值的简单策略是(仅对中间值),将_Py_dg_dtoa的参数从ndigits更改为ndigits + 1(因此进行精确的十进制转换),手动四舍五入生成的字符串,然后使用_Py_dg_strtod进行转换。
简而言之,Python 2.7努力遵循向远离零的方向取整规则。
在Python 3.3中,它同样努力遵循向偶数取整规则。
下面是关于_Py_dg_dtoa函数的一些额外细节。Python调用浮点数转字符串函数,因为它实现了一种算法,可以在相等情况下给出最短的字符串表示。例如,在Python 2.6中,数字1.1显示为1.1000000000000001,但在Python 2.7及更高版本中,它只是1.1。David Gay的复杂dtoa.c算法提供了“人们期望的结果”,同时保证准确性。
那个字符串转换算法倾向于弥补困扰二进制浮点数round()实现的一些问题(即它能使4.35四舍五入后变成4.35而不是4.3499999999999996447286321199499070644378662109375)。
这也是Python和Ruby round()函数之间的本质区别,即舍入模式(一半向偶数舍入还是向零舍入)。

2
我不知道这怎么回答问题。 - Karoly Horvath
虽然您引用了相关的源材料,但我必须说您应该提取概念上的差异并加以解释。我怀疑这是否真正有助于OP,并且甚至不清楚您是否理解它。我相信您确实理解,但是,为什么不直接解释呢? - DigitalRoss
5
@DigitalRoss -1的用于完全错误的答案。不应将负评用于您出于某些原因不喜欢的答案。对于这样的答案,只需不给予赞同即可。 - ovgolovin
3
向下箭头的工具提示显示“此答案无用”,并未涉及正确或错误。如果你问我“现在几点了”,而我回答“是”,这个答案虽然毫无用处,但完全是100%正确的。 - Jörg W Mittag
+1。我已经反转了dv,因为Raymond添加了一个很好的解释。然而,我的临时dv似乎与网站设计完全一致。 - DigitalRoss
1
很抱歉,这个答案中关于Python的round的描述是不准确的。Python的round并没有使用Gay的“最短字符串”代码,在任何时候都不会进行精确的字符串转换,并且也不会尝试消除浮点表示误差。在round源代码中,使用mode=3调用了_Py_dg_dtoa,它只是计算正确舍入的小数点后(如果ndigits为负,则为小数点前)的ndigits位数字。相比之下,例如float.__repr__使用的最短字符串算法是以mode=0调用的。 - Mark Dickinson

8

基本区别在于:

Python: 先转换为十进制,然后四舍五入

Ruby:    先四舍五入,然后转换为十进制

Ruby是从原始浮点位串中进行四舍五入,但在使用10n操作后。如果不仔细查看,您将看不到原始二进制值。这些值是不精确的,因为它们是二进制的,而我们习惯于使用十进制,很多十进制小数字符串几乎没有一个精确的相当于二进制分数字符串。

特别地,0.45看起来像这样:

01111111101 1100110011001100110011001100110011001100110011001101 

在十六进制中,这是3fdccccccccccccd。

在二进制中重复,第一个未表示的数字为0xc,聪明的小数输入转换已将此最后一个小数位精确四舍五入为0xd。

这意味着在机器内部,该值比0.45大约1/2 50 。这显然是一个非常非常小的数字,但足以导致默认的四舍五入算法向上取整,而不是使用偶数来作为决胜者。

Python和Ruby都可能会多次进行四舍五入,因为每个操作实际上都会舍入为最低有效位。

我不确定我同意Ruby做了人类所做的。我认为Python正在逼近十进制算术会怎么做。 Python(根据版本)正在将四舍五入应用于十进制字符串,而Ruby正在将四舍五入算法应用于计算得出的二进制值。

请注意,在这里我们可以很清楚地看到人们说FP不精确的原因。这是一个相当真实的陈述,但更准确地说,我们只是不能在二进制和大多数十进制小数之间精确地进行转换。 (一些人做到了:0.25、0.5、0.75、...)大多数简单的十进制数字在二进制中是重复的数字,因此我们永远无法存储精确的等价值。 但是,我们可以精确地知道我们可以存储的每个值,并且对其执行的所有算术都是精确执行的。如果我们一开始就用二进制书写分数,我们的FP算术将被认为是精确的。


换句话说,Python 实现 round(f, n) 几乎就像是 s = '%.*f' % (n, f); return float(s[:s.index('.') + n + 1],并特别处理了中间值的情况。这应该被描述为“人类会做的事情”——非常有趣。 - user4815162342
但是,如果这就是实现方式,为什么 round(1.45, 1) 的结果不是 1.5 呢?看代码,它应该将 1.45 转换为 "1.45"(两个小数位:一个用于四舍五入,另一个用于处理中间值的情况),通过手动更改 "1.45" 以处理中间值的情况,并将 "1.5" 转换为 1.5。但是,作为字符串输入的 "1.5" 并转换为浮点数打印为 1.5,而不是 1.4 - user4815162342
1
还有更多的复杂性。早期版本的 Python 使用了一个不是 五种 IEEE-754 模式之一的四舍五入模式。后来的版本使用了一种变体的 四舍五入至最近偶数,在 IEEE-754 中它会将带有 .5 的数字舍入到最接近的偶数。如果没有小数转换的低阶残留,这将会将 1.45 舍入为 1.4。 - DigitalRoss
我可以在Python 2.7中重复round(1.45) -> 1.4,它实现了远离零的四舍五入。从代码来看,我怀疑对于这个数字,halfway_case检测评估为false,因此半数检测没有被触发。也就是说,除了半数检测之外,_Py_dg_dtoa处理一切都是正确的。 - user4815162342

3

Ruby并没有欺骗,它只是选择了另一种实现round的方式。

在Ruby中,9.45.round(1)几乎等同于(9.45*10.0).round / 10.0

irb(main):001:0> printf "%.20f", 9.45
9.44999999999999928946=> nil
irb(main):002:0> printf "%.20f", 9.45*10.0
94.50000000000000000000=> nil

所以

irb(main):003:0> puts 9.45.round(1)
9.5

如果我们在Python中使用这种方法,我们也会得到9.5。
>>> round(9.45, 1)
9.4
>>> round(9.45*10)/10
9.5

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接