Python中的四舍五入误差在地板除法中。

44
我知道浮点运算中会发生舍入误差,但有人能解释一下这个问题的原因吗?
>>> 8.0 / 0.4  # as expected
20.0
>>> floor(8.0 / 0.4)  # int works too
20
>>> 8.0 // 0.4  # expecting 20.0
19.0

这在Python 2和3的x64上都发生。
就我所看到的,这要么是一个bug,要么是一个非常愚蠢的规范,因为我没有看到任何理由,最后的表达式应该评估为19.0。
为什么不把a // b简单地定义为floor(a / b)?
编辑:8.0 % 0.4也评估为0.3999999999999996。至少这是一致的,因为然后8.0 // 0.4 * 0.4 + 8.0 % 0.4评估为8.0
编辑:这不是Is floating point math broken?的重复,因为我想知道为什么这个特定的操作会受到(可能可避免的)舍入误差的影响,以及为什么a // b没有被定义为等于floor(a / b)。

备注:我猜这不起作用的更深层次原因是地板除法是不连续的,因此具有无限的条件数,使其成为一个病态问题。地板除法和浮点数根本不兼容,你永远不应该在浮点数上使用//。只需使用整数或分数即可。


4
有趣的是,'%.20f'%0.4 的结果为 '0.40000000000000002220',所以 0.4 显然略微大于 0.4 - khelwood
2
@khelwood,floor(8.0/0.4)如何产生正确的结果? - Aswin Murugesh
2
首先,使用 float 类型的浮点数通常是错误的。其次,对于负数和 float 数字,//% 是相当不可靠的(意思是,会出现意外行为)。Decimal 对象的文档简要讨论了负整数的 // 以及 Decimal 库如何以不同的方式处理它。 - TigerhawkT3
3
可能是与浮点数计算是否存在问题?相同的内容。 - Alexander Vogt
4
真的吗?问题不在于浮点数结果为什么不精确,而更多地是关于为什么Python对于floor(8.0/0.4)和"floor-division"的8.0//0.4会做出两种不同的处理。 - jotasi
显示剩余5条评论
5个回答

33

正如你和 khelwood 已经注意到的那样,0.4 无法被浮点数精确表示。为什么?因为它是二分之一(4/10 == 2/5),而这个数在二进制下没有有限的位数。

试试这个:

from fractions import Fraction
Fraction('8.0') // Fraction('0.4')
    # or equivalently
    #     Fraction(8, 1) // Fraction(2, 5)
    # or
    #     Fraction('8/1') // Fraction('2/5')
# 20

然而

Fraction('8') // Fraction(0.4)
# 19

这里,0.4 被解释为浮点数字面量(因此是浮点二进制数),需要进行(二进制)舍入,然后才转换为有理数 Fraction(3602879701896397, 9007199254740992),它几乎但不完全等于 4 / 10。然后执行向下取整除法,并且因为

19 * Fraction(3602879701896397, 9007199254740992) < 8.0

20 * Fraction(3602879701896397, 9007199254740992) > 8.0

结果是19,而不是20。

同样的情况可能也会发生在

8.0 // 0.4

即,整除似乎是以原子方式确定的(但仅限于解释浮点文字的近似浮点值)。

那么为什么会这样呢?

floor(8.0 / 0.4)

为什么会得到“正确”的结果呢?因为此时两个舍入误差互相抵消了。 首先1)进行除法运算,得到的值略小于20.0,但无法表示成浮点数。它被舍入到最接近的浮点数,正好是 20.0。然后才执行floor操作,现在作用于恰好是20.0,所以不再改变这个数字。


1)正如Kyle Strand所指出的那样,确定精确结果然后舍入实际上并不是 在底层(CPython 的 C 代码或甚至 CPU 指令中)发生的。然而,它可能是一个有用的模型来确定预期的3)结果。

2) 然而,在最低4)级别上,这可能并不太远。一些芯片组通过首先计算更精确的(但仍不准确,只是有一些更多的二进制位)内部浮点结果,然后舍入到 IEEE 双精度。

3) 由 Python 规范确定的“预期”结果,并不一定符合我们的直觉。

4) 好吧,在逻辑门之上最低级别。我们不必考虑制造半导体的量子力学来理解这个。


2
“看起来,被除数的取整操作是由原子操作完成的。”——非常棒的猜测,我认为语义上是正确的,但在实现必须执行的操作方面,它有些反向:由于没有硬件支持使用“原子”//语义,余数被预先计算并从分子中减去,以确保浮点除法(最终发生时)立即计算出正确的值,而不需要进一步调整。 - Kyle Strand
1
是的,我在这里使用术语“原子”的用户(即Python程序员)视角。类似于例如某些数据库操作可能被描述为“原子”,这也不会映射到单个硬件指令。因此,我谈论的是效果,而不是实现。 - das-g
关于实现,硬件是否支持与Python的//运算符等效的本地指令当然取决于硬件和操作数类型。早期的CPU显然支持整数除法运算。可能没有任何芯片组本地支持浮点数向下取整除法,但这也不是不可想象的,因为它只是不切实际而不是不可能的。 - das-g
1
8.0//0.4” 可能不会发生相同的情况。至少对于 cpython 而言是如此。实际上,它们更倾向于执行 round((8.0 - fmod(8.0, 0.4)) / 0.4),这将得到 19,因为(至少对于我的机器/编译器版本而言)fmod(8.0/0.4) 的结果为 0.4(在纯 C 中也是如此)。有关详细信息,请参见我的答案。 - jotasi

15

我检查了在Github上的cpython中半官方的float对象来源(https://github.com/python/cpython/blob/966b24071af1b320a1c7646d33474eeae057c20f/Objects/floatobject.c),这样就可以理解这里发生了什么。

对于普通除法,会调用float_div(第560行),它内部将Python float转换为C语言的double,执行除法运算,然后再将结果的double转换回Python float。如果在C语言中使用8.0/0.4,你会得到:

#include "stdio.h"
#include "math.h"

int main(){
    double vx = 8.0;
    double wx = 0.4;
    printf("%lf\n", floor(vx/wx));
    printf("%d\n", (int)(floor(vx/wx)));
}

// gives:
// 20.000000
// 20

对于地板除法,会发生另外的事情。内部调用float_floor_div(第654行),然后调用float_divmod,这是一个应该返回包含向下取整除法以及模数/余数的python float元组的函数,尽管后者被PyTuple_GET_ITEM(t, 0)抛弃了。这些值是按照以下方式计算的(转换为c-double后):
  1. 通过使用double mod = fmod(numerator, denominator)计算余数。
  2. 分子减去mod得到一个整数值,然后进行除法。
  3. 向下取整的结果通过有效地计算floor((numerator - mod) / denominator)来计算。
  4. 之后,执行了@Kasramvd答案中已经提到的检查。但这只将(numerator - mod) / denominator的结果捕捉到最近的整数值。
这个结果不同的原因是由于浮点运算导致fmod(8.0, 0.4)得到了0.4而不是0.0。因此,实际计算的结果是floor((8.0 - 0.4) / 0.4) = 19,将(8.0 - 0.4) / 0.4) = 19向最近的整数值进行调整并不能修复由"fmod"产生的错误。你也可以轻松地在C中检查这一点:
#include "stdio.h"
#include "math.h"

int main(){
    double vx = 8.0;
    double wx = 0.4;
    double mod = fmod(vx, wx);
    printf("%lf\n", mod);
    double div = (vx-mod)/wx;
    printf("%lf\n", div);
}

// gives:
// 0.4
// 19.000000

我猜他们选择这种方式进行向下取整的计算是为了保持 (numerator//divisor)*divisor + fmod(numerator, divisor) = numerator 的有效性(正如@0x539答案中提到的链接所述),即使现在这会导致 floor(8.0/0.4) != 8.0//0.4 这种稍有意外的行为。

2
你似乎是唯一一个给出正确答案的人。赞!不过,既然你不得不深入源代码才能找到答案,我想知道这是否是所有Python实现的强制要求? - Kyle Strand
2
根据PEP 238,似乎预期floor(a/b) == a // b是正确的,因为这明确规定为“floor-division”的语义。 - Kyle Strand
1
在已经被@0x539引用的问题报告(https://bugs.python.org/issue27463)中,并没有被认为是错误。而且这是Python的漏洞跟踪器。因此,我猜“floor-division”更像是一个名称,而不是定义实现的意思。 - jotasi
2
"向下取整除法的结果是通过有效地计算“floor((numerator - mod) / denominator)”来计算的,但实际上更像是“round((numerator - mod) / denominator)”。源代码确实使用了“floor”,但如果“floor”四舍五入错误,则立即向上调整结果。它依靠“- mod”部分来“有效地对numerator/denominator向下取整”。" - user2357112
1
@user2357112 你是对的。实际上,结果比起仅仅向下取整更接近于四舍五入。尽管如此,-mod导致了奇怪的结果。 - jotasi
显示剩余7条评论

10

@jotasi解释了背后的真正原因。

然而,如果你想要防止这种情况发生,你可以使用decimal模块,该模块基本上是为了精确表示十进制浮点数而设计的,与二进制浮点表示形式相反。

所以在你的情况下,你可以尝试这样做:

>>> from decimal import *
>>> Decimal('8.0')//Decimal('0.4')
Decimal('20')

参考:https://docs.python.org/2/library/decimal.html


虽然这不是对问题的回答,但使用decimal也不是一个合适的选择,因为我们可以简单地使用真除法来得到这个结果。 - Mazdak
fractions模块似乎也能胜任这项工作。 - GingerPlusPlus
@0x539的解释实际上是不正确的。请参考jotasi的回答以及我在0x539回答下面的评论。 - Kyle Strand
1
@KyleStrand 预订当然也适用于我的答案,因此我对它进行了一些修正。 - das-g
1
@shiva 抱歉,之前的评论是针对das-g编辑他们自己的答案的;你的仍然不正确... - Kyle Strand
显示剩余2条评论

9

经过一点研究,我发现了这个问题。 看起来发生的事情是,正如@khelwood所建议的那样,0.4在内部评估为0.40000000000000002220,当除以8.0时得到略小于20.0的结果。然后/运算符会四舍五入到最接近的浮点数,即20.0,但//运算符会立即截断结果,得到19.0

这应该更快,而且我认为它“接近处理器”,但这仍然不是用户想要/期望的。


7
不错的发现。但是用户在这里想要什么呢?对于本来就不正确的数字进行正确的数学运算?(对于这些数字,通常“典型用户”并不知情。) - Jongware
1
@RadLexus 用户希望得到此操作的最佳近似值。在这种情况下,它是 20.0 - 0x539
5
那些依赖于“//”操作符将数字略微截断至19.0而不是20.0的用户怎么办呢?问题在于用户想要精确计算,但使用了错误的工具。 - user1084944
1
实际上,如果我正确理解cpython的来源,截断并不是发生的事情。他们经历了一个相当大的折磨,通过实际计算floor((8.0 - fmod(8.0, 0.4)) / 0.4)来保持您链接中提到的身份,并且误差是由fmod(8.0, 0.4)=0.4引入的。(请参见我的答案以获取链接和更多解释)。 - jotasi
3
从数学上讲,你是正确的,即8.0 / 0.40000000000000002220“产生的结果略小于20.0”。然而,认为浮点运算发生在一系列步骤中,其中实际的数学值被计算,然后再四舍五入(当你说“/运算符然后四舍五入…”时,你暗示了这一点)是不正确的。当然,这是不可能的,因为计算机必须有一种方式来内部表示计算的所有中间步骤!请参见@jotasi的答案。 - Kyle Strand

7

这是因为在 Python 中没有0.4(浮点有限表示),实际上是一个类似于0.4000000000000001的浮点数,这使得除法的下取整结果为19。

>>> floor(8//0.4000000000000001)
19.0

但是真正的除法(/如果参数是浮点数或者复数,返回一个合理的除法近似值。这就是为什么8.0/0.4的结果是20的原因。它实际上取决于参数的大小(在C双精度参数中)。(不舍入到最接近的浮点数)。
阅读更多关于Python整数除法向下取整的信息,由Guido亲自撰写。
此外,有关浮点数的完整信息,您可以阅读本文https://docs.oracle.com/cd/E19957-01/806-3568/ncg_goldberg.html 对于那些感兴趣的人,以下函数是执行浮点数真除法任务的float_div,在Cpython的源代码中:
float_div(PyObject *v, PyObject *w)
{
    double a,b;
    CONVERT_TO_DOUBLE(v, a);
    CONVERT_TO_DOUBLE(w, b);
    if (b == 0.0) {
        PyErr_SetString(PyExc_ZeroDivisionError,
                        "float division by zero");
        return NULL;
    }
    PyFPE_START_PROTECT("divide", return 0)
    a = a / b;
    PyFPE_END_PROTECT(a)
    return PyFloat_FromDouble(a);
}

最终结果将由函数PyFloat_FromDouble计算得出:
PyFloat_FromDouble(double fval)
{
    PyFloatObject *op = free_list;
    if (op != NULL) {
        free_list = (PyFloatObject *) Py_TYPE(op);
        numfree--;
    } else {
        op = (PyFloatObject*) PyObject_MALLOC(sizeof(PyFloatObject));
        if (!op)
            return PyErr_NoMemory();
    }
    /* Inline PyObject_New */
    (void)PyObject_INIT(op, &PyFloat_Type);
    op->ob_fval = fval;
    return (PyObject *) op;
}

1
实际上,在我自己检查源代码后,我猜测浮点除法是在函数float_div中完成的,而float_divmod仅由float_floor_div调用,后者执行地板除法,从而得出“错误”的结果19而不是20。 - jotasi
@jotasi 是的,完全正确。这比简单的捕捉更加复杂。是的,它是 float_div 函数,它执行真正的除法任务。似乎它根据参数大小计算最终结果。我更新了答案。感谢您的关注。 - Mazdak
我仔细检查了C语言中的重要代码行,显然重要部分是通过简单的double除法计算8.0/0.4 = 20,而地板除法实际上计算floor((8.0 - fmod(8.0, 0.4)) / 0.4) = 19,因为由于浮点运算,fmod(8.0, 0.4) = 0.4。有关更多信息,请参见我的下面的答案。 - jotasi
在Python中的float类型相当于C语言中的double类型,而Python在这种情况下所做的正是C语言中所发生的事情。 - Mazdak
3
事实是它取决于可用的PyFloatObjects的大小 - 什么?不,它并不是这样。 PyFloatObject的大小都是相同的,并且PyFloatObject的存储细节与任何此行为几乎没有关系。 - user2357112
显示剩余6条评论

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