使用双精度时,为什么 (x / (y * z)) 与 (x / y / z) 不同?十进制数据类型中的舍入错误舍入误差分析血腥细节

这部分是学术性的,因为出于我的目的,我只需要将其四舍五入到小数点后两位;但我很想知道发生了什么,产生了两个略有不同的结果。

这是我编写的测试,用于将其缩小到最简单的实现:

@Test
public void shouldEqual() {
  double expected = 450.00d / (7d * 60);  // 1.0714285714285714
  double actual = 450.00d / 7d / 60;      // 1.0714285714285716

  assertThat(actual).isEqualTo(expected);
}

但它失败,这个输出:

org.junit.ComparisonFailure: 
Expected :1.0714285714285714
Actual   :1.0714285714285716

谁能详细解释一下引擎盖下发生了什么导致1.000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000X

我在答案中寻找的一些要点是:精度在哪里丢失?哪种方法是首选,为什么?哪个实际上是正确的?(在纯数学中,两者都不可能是正确的。也许两者都错了?对于这些算术运算,有没有更好的解决方案或方法?


答案 1

我看到一堆问题告诉你如何解决这个问题,但没有一个真正解释发生了什么的问题,除了“浮点四舍五入误差不好,m'kay?因此,让我来试一试。让我首先指出,这个答案中没有任何内容是Java特有的。舍入误差是任何固定精度数字表示所固有的问题,因此在C中也会遇到相同的问题。

十进制数据类型中的舍入错误

作为一个简化的例子,假设我们有某种本机使用无符号十进制数据类型的计算机,让我们称之为 。数据类型的长度为 6 位:4 位专用于尾数,2 位专用于指数。例如,数字 3.142 可以表示为float6d

3.142 x 10^0

它将以6位数字存储为

503142

前两位数字是指数加 50,最后四位是尾数。此数据类型可以表示 从 到 的任何数字。0.001 x 10^-509.999 x 10^+49

实际上,事实并非如此。它不能存储任何数字。如果要表示 3.141592,该怎么办?还是3.1412034?还是3.141488906?运气不好,数据类型不能存储超过四位数的精度,因此编译器必须舍入具有更多数字的任何内容以适应数据类型的约束。如果你写

float6d x = 3.141592;
float6d y = 3.1412034;
float6d z = 3.141488906;

然后编译器将这三个值中的每一个转换为相同的内部表示形式(请记住,它存储为 ),因此该值将成立。3.142 x 10^0503142x == y == z

关键是有一整系列的实数,它们都映射到相同的数字(或位,在真实计算机中)的底层序列。具体来说,任何令人满意的(假设半偶数舍入)都会转换为表示形式以存储在内存中。x3.1415 <= x <= 3.1425503142

每次程序在内存中存储浮点值时,都会发生此舍入。第一次发生这种情况是在源代码中编写常量时,就像我上面对 、 和 .每当执行算术运算时,如果算术运算的精度位数超过数据类型可以表示的位数,就会再次发生这种情况。这两种影响中的任何一种都称为舍入误差。有几种不同的方式可以发生这种情况:xyz

  • 加法和减法:如果要添加的某个值与另一个值的指数不同,则最终将获得额外的精度数字,并且如果有足够的数字,则需要删除最不重要的数字。例如,2.718 和 121.0 都是可以在数据类型中精确表示的值。但是,如果您尝试将它们加在一起:float6d

       1.210     x 10^2
    +  0.02718   x 10^2
    -------------------
       1.23718   x 10^2
    

    它被舍入为 ,或 123.7,降低两位数的精度。1.237 x 10^2

  • 乘法:结果中的位数大约是两个操作数中位数的总和。这将产生一定量的舍入误差,如果您的操作数已经有许多有效数字。例如,121 x 2.718 为您提供

       1.210     x 10^2
    x  0.02718   x 10^2
    -------------------
       3.28878   x 10^2
    

    它被舍入为 ,或 328.9,再次降低两位数的精度。3.289 x 10^2

    但是,请记住,如果您的操作数是“好”数字,并且没有许多有效数字,则浮点格式可能可以准确地表示结果,因此您不必处理舍入误差。例如,2.3 x 140 给出

       1.40      x 10^2
    x  0.23      x 10^2
    -------------------
       3.22      x 10^2
    

    这没有舍入问题。

  • 分工:这是事情变得混乱的地方。除法几乎总是会导致一定程度的舍入误差,除非你除以的数字恰好是基数的幂(在这种情况下,除法只是一个数字位移位,或二进制中的位移位)。举个例子,拿两个非常简单的数字,3和7,把它们除以,你得到

       3.                x 10^0
    /  7.                x 10^0
    ----------------------------
       0.428571428571... x 10^0
    

    可以表示为 a 的与此数字最接近的值是 ,或 0.4286,它与确切的结果明显不同。float6d4.286 x 10^-1

正如我们将在下一节中看到的那样,舍入引入的错误会随着您执行的每个操作而增长。因此,如果您正在使用“好”数字,例如您的示例,通常最好尽可能晚地执行除法操作,因为这些操作最有可能将舍入错误引入到以前不存在的程序中。

舍入误差分析

一般来说,如果你不能假设你的数字是“好的”,舍入误差可以是正的,也可以是负的,并且很难仅仅根据操作来预测它会朝哪个方向发展。这取决于所涉及的特定值。将此舍入误差图视为的函数(仍使用数据类型):2.718 zzfloat6d

roundoff error for multiplication by 2.718

实际上,当您使用使用数据类型的完全精度的值时,通常更容易将舍入误差视为随机误差。查看该图,您可能能够猜到误差的大小取决于运算结果的数量级。在这种特殊情况下,当 is 为 10-1 阶时,也在 10-1 的量级上,因此它将是一个数字的形式。最大舍入误差是最后一位精度的一半;在这种情况下,“精度的最后一位数字”是指0.0001,因此舍入误差在-0.00005和+0.00005之间变化。在跳到下一个数量级(即 1/2.718 = 0.3679)的点上,您可以看到舍入误差也跃升了一个数量级。z2.718 z0.XXXX2.718 z

您可以使用众所周知的误差分析技术来分析一定大小的随机(或不可预测的)误差如何影响结果。具体来说,对于乘法或除法,可以通过在正交中的每个操作数中添加相对误差来近似结果中的“平均”相对误差 - 即,将它们平方,将它们相加,然后取平方根。对于我们的数据类型,相对误差在 0.0005(对于类似 0.101 的值)和 0.00005(对于类似 0.995 的值)之间变化。float6d

relative error in values between 0.1 and 1

让我们将 0.0001 作为值和 中的相对误差的粗略平均值。或 中的相对误差由下式给出xyx * yx / y

sqrt(0.0001^2 + 0.0001^2) = 0.0001414

这是一个大于每个单独值的相对误差的因子。sqrt(2)

在组合运算时,可以多次应用此公式,每个浮点运算应用一次。例如,对于 ,中的相对误差平均为 0.0001414(在此十进制示例中),则 中的相对误差为z / (x * y)x * yz / (x * y)

sqrt(0.0001^2 + 0.0001414^2) = 0.0001732

请注意,平均相对误差随着每次运算而增长,特别是作为您执行的乘法和除法数的平方根。

同样,对于 ,中的平均相对误差为 0.0001414,而 中的相对误差为z / x * yz / xz / x * y

sqrt(0.0001414^2 + 0.0001^2) = 0.0001732

所以,在这种情况下,也是一样的。这意味着,对于任意值,平均而言,两个表达式会引入大致相同的误差。(从理论上讲,就是这样。我见过这些操作在实践中表现得非常不同,但那是另一回事了。

血腥细节

您可能对您在问题中提出的特定计算感到好奇,而不仅仅是平均值。对于该分析,让我们切换到二进制算术的真实世界。大多数系统和语言中的浮点数都使用 IEEE 标准 754 表示。对于 64 位数字,格式指定 52 位专用于尾数,11 位专用于指数,一位专用于符号。换句话说,当以 2 为基数时,浮点数是以下形式的值

1.1100000000000000000000000000000000000000000000000000 x 2^00000000010
                       52 bits                             11 bits

前导符未显式存储,而是构成第 53 位。另外,您应该注意,存储用于表示指数的11位实际上是实指数加1023。例如,此特定值为 7,即 1.75 x 22。二进制的尾数为 1.75,或者 ,二进制的指数为 1023 + 2 = 1025,或者 ,因此存储在内存中的内容为11.1110000000001

01000000000111100000000000000000000000000000000000000000000000000
 ^          ^
 exponent   mantissa

但这并不重要。

您的示例还涉及 450,

1.1100001000000000000000000000000000000000000000000000 x 2^00000001000

和 60,

1.1110000000000000000000000000000000000000000000000000 x 2^00000000101

您可以使用此转换器或互联网上许多其他值中的任何一个来玩这些值。

计算第一个表达式时,处理器首先执行乘法,得到 420,或者450/(7*60)

1.1010010000000000000000000000000000000000000000000000 x 2^00000001000

然后它除以450和420。这会产生15/14,即

1.0001001001001001001001001001001001001001001001001001001001001001001001...

在二进制中。现在,Java语言规范

不精确的结果必须四舍五入到最接近无限精确结果的可表示值;如果两个最接近的可表示值相等接近,则选择其最低有效位为零的值。这是 IEEE 754 标准的默认舍入模式,称为舍入到最接近。

在 64 位 IEEE 754 格式中最接近 15/14 的可表示值为

1.0001001001001001001001001001001001001001001001001001 x 2^00000000000

大约是十进制。(更准确地说,这是唯一指定此特定二进制表示形式的最不精确的十进制值。1.0714285714285714

另一方面,如果您先计算450 / 7,则结果为64.2857142857...,或二进制,

1000000.01001001001001001001001001001001001001001001001001001001001001001...

其最接近的可表示值为

1.0000000100100100100100100100100100100100100100100101 x 2^00000000110

这是64.28571428571429180465...请注意,由于舍入误差,二进制尾数的最后一位数字(与确切值相比)发生了变化。将其除以60可以得到

1.000100100100100100100100100100100100100100100100100110011001100110011...

看看最后:模式不同!这是重复的,而不是像另一种情况那样。最接近的可表示值为0011001

1.0001001001001001001001001001001001001001001001001010 x 2^00000000000

这与最后两位中的其他操作顺序不同:它们代替了 。十进制等效值为 1.0714285714285716。1001

如果您查看确切的二进制值,则导致此差异的特定舍入应该很清楚:

1.0001001001001001001001001001001001001001001001001001001001001001001001...
1.0001001001001001001001001001001001001001001001001001100110011001100110...
                                                     ^ last bit of mantissa

在这种情况下,它得出前一个结果,数字上是15/14,恰好是精确值的最准确表示。这是一个例子,说明将分裂留到最后如何使您受益。但同样,只要您正在使用的值不使用数据类型的完整精度,此规则才有效。一旦开始使用不精确(舍入)的值,您就不再通过先执行乘法来保护自己免受进一步的舍入误差的影响。


答案 2

它与类型的实现方式以及浮点类型不与其他更简单的数字类型具有相同的精度保证这一事实有关。虽然以下答案更具体地是关于求和的,但它也通过解释在浮点数学运算中如何不能保证无限精度来回答您的问题:为什么更改求和顺序会返回不同的结果?。从本质上讲,您永远不应该尝试在不指定可接受的误差幅度的情况下确定浮点值的相等性。谷歌的番石榴库包括DoubleMath.fuzzyEquals(double,double,double),以确定两个值在一定精度内的相等性。如果你想阅读浮点相等的细节,这个网站是相当有用的;同一站点还解释了浮点舍入错误。求和:由于运算顺序的不同,计算的预期值和实际值会有所不同,因为计算之间的舍入不同。doubledouble