【问题标题】:When using doubles, why isn't (x / (y * z)) the same as (x / y / z)? [duplicate]使用双打时,为什么 (x / (y * z)) 与 (x / y / z) 不一样? [复制]
【发布时间】:2015-07-02 17:26:51
【问题描述】:

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

这是我为将其缩小到最简单的实现而编写的测试:

@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.000000000000000X 的值不同的原因?

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

【问题讨论】:

  • @UwePlonus 我不这么认为。这个问题及其答案如何消除这种影响,而不是解释引擎盖下到底发生了什么。
  • 因为它实际上会进行不同的计算。第一行给出450.00d / (420d)(在计算7d * 60 的第一步中没有精度损失)。第二行首先计算450.00d / 7d 并存储结果,由于计算机存储浮点数的方式,此步骤中会丢失少量精度,然后将此结果除以 60。您可以阅读如何浮动点数在这里工作:floating-point-gui.de/formats/fp
  • @UwePlonus 不,我认为这不符合 Ben 在这里要求的详细解释。
  • 如果您对 Java 处理浮点运算的方式感兴趣,您可能想阅读我的文章存档:blogs.msdn.com/b/ericlippert/archive/tags/…ericlippert.com/tag/floating-point-arithmetic。虽然这些都是关于 C# 和 JavaScript 的,但几乎所有这些都同样适用于 Java。
  • 这应该在每个关于 FP 的问题中链接:What every computer scientist should know about floating-point arithmetic

标签: java double rounding double-precision operator-precedence


【解决方案1】:

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

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

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

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^0(记住,它存储为503142),这样x == y == z 将成立。

关键在于,有一整套实数都映射到相同的底层数字序列(或位,在真实计算机中)。具体来说,任何满足3.1415 <= x <= 3.1425(假设为半偶数舍入)的x 都会转换为表示503142 以存储在内存中。

这种舍入发生每次您的程序在内存中存储一​​个浮点值。第一次发生这种情况是当您在源代码中编写常量时,就像我在上面对xyz 所做的那样。每当您执行的算术运算增加了超出数据类型可以表示的精度位数时,它就会再次发生。这些效果中的任何一种都称为roundoff error。发生这种情况有几种不同的方式:

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

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

    四舍五入为1.237 x 10^2,或123.7,精度下降两位数。

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

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

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

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

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

    没有舍入问题。

  • Division:这就是事情变得混乱的地方。除法几乎总是会导致一定量的舍入误差,除非您要除以的数字恰好是基数的幂(在这种情况下,除法只是数字移位或位移二进制)。例如,取两个非常简单的数字,3 和 7,将它们相除,得到

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

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

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

舍入误差分析

一般来说,如果您不能假设您的数字“不错”,则舍入误差可以是正数也可以是负数,并且仅根据运算很难预测它会朝哪个方向发展。这取决于所涉及的具体值。查看 2.718 z 作为 z 函数的舍入误差图(仍然使用 float6d 数据类型):

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

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

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

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

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

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

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

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

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

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

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

血腥细节

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

1.1100000000000000000000000000000000000000000000000000 x 2^00000000010
                       52 bits                             11 bits

前导1 未显式存储,构成第53 位。此外,您应该注意存储的 11 位表示指数实际上是实际指数加上 1023。例如,这个特定值是 7,即 1.75 x 22。尾数为二进制1.75,即1.11,指数为二进制1023 + 2 = 1025,即10000000001,所以内存中存储的内容为

01000000000111100000000000000000000000000000000000000000000000000
 ^          ^
 exponent   mantissa

但这并不重要。

你的例子也涉及到450,

1.1100001000000000000000000000000000000000000000000000 x 2^00000001000

和 60,

1.1110000000000000000000000000000000000000000000000000 x 2^00000000101

您可以使用this converter 或互联网上的许多其他值来玩弄这些值。

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

1.1010010000000000000000000000000000000000000000000000 x 2^00000001000

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

1.0001001001001001001001001001001001001001001001001001001001001001001001...

二进制。现在,the Java language specification 这么说

不精确的结果必须四舍五入到最接近无限精确结果的可表示值;如果两个最接近的可表示值同样接近,则选择其最低有效位为零的值。这是 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...

看最后:图案不一样!重复的是0011,而不是其他情况下的001。最接近的可表示值是

1.0001001001001001001001001001001001001001001001001010 x 2^00000000000

这与最后两位的其他操作顺序不同:它们是10 而不是01。十进制等效值为 1.0714285714285716。

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

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

在这种情况下,前一个结果(数字 15/14)恰好是精确值的最准确表示。这是一个例子,说明离开分裂直到结束如何使您受益。但同样,该规则仅在您使用的值不使用数据类型的完整精度时才成立。一旦开始使用不精确(四舍五入)的值,您将不再通过先进行乘法来保护自己免受进一步的四舍五入错误。

【讨论】:

    【解决方案2】:

    这与double 类型的实现方式以及浮点类型不能提供与其他更简单的数值类型相同的精度保证有关。尽管以下答案更具体地与总和有关,但它也通过解释浮点数学运算如何无法保证无限精度来回答您的问题:Why does changing the sum order returns a different result?。本质上,您不应该在没有指定可接受的误差范围的情况下尝试确定浮点值的相等性。 Google 的 Guava 库包含 DoubleMath.fuzzyEquals(double, double, double) 以确定两个 double 值在一定精度内的相等性。如果你想了解浮点相等的细节this site is quite useful;同一站点also explains floating-point rounding errors。总而言之:您的计算的预期值和实际值不同,因为由于运算顺序不同,计算之间的舍入不同。

    【讨论】:

      【解决方案3】:

      让我们稍微简化一下。你想知道为什么450d / 420450d / 7 / 60(特别是)给出不同的结果。

      让我们看看如何在 IEE 双精度浮点格式中执行除法。不深入实现细节,基本上就是XOR-ing符号位,从被除数的指数中减去除数的指数,除以尾数,归一化结果。

      首先,我们应该以double 的正确格式表示我们的数字:

      450    is  0 10000000111 1100001000000000000000000000000000000000000000000000
      
      420    is  0 10000000111 1010010000000000000000000000000000000000000000000000
      
      7      is  0 10000000001 1100000000000000000000000000000000000000000000000000
      
      60     is  0 10000000100 1110000000000000000000000000000000000000000000000000
      

      我们先450除以420

      首先是符号位,它是0 (0 xor 0 == 0)。

      然后是指数。 10000000111b - 10000000111b + 1023 == 10000000111b - 10000000111b + 01111111111b == 01111111111b

      看起来不错,现在是尾数:

      1.1100001000000000000000000000000000000000000000000000 / 1.1010010000000000000000000000000000000000000000000000 == 1.1100001 / 1.101001。有几种不同的方法可以做到这一点,我稍后会讨论它们。结果是1.0(001)(可以验证here)。

      现在我们应该标准化结果。让我们看看guard、round和sticky位值:

      0001001001001001001001001001001001001001001001001001 0 0 1

      保护位为 0,我们不进行任何舍入。结果是二进制:

      0 01111111111 0001001001001001001001001001001001001001001001001001

      以十进制表示为1.0714285714285714

      现在让我们450除以7以此类推。

      符号位 = 0

      指数 = 10000000111b - 10000000001b + 01111111111b == -01111111001b + 01111111111b + 01111111111b == 10000000101b

      尾数 = 1.1100001 / 1.11 == 1.00000(001)

      四舍五入:

      0000000100100100100100100100100100100100100100100100 1 0 0

      保护位已设置,圆形和粘性位未设置。我们正在四舍五入到最近(IEEE 的默认模式),并且我们被困在我们可以四舍五入的两个可能值之间。由于 lsb 是0,我们添加1。这给了我们圆尾尾数:

      0000000100100100100100100100100100100100100100100101

      结果是

      0 10000000101 0000000100100100100100100100100100100100100100100101

      以十进制表示为64.28571428571429

      现在我们必须将它除以60...但是您已经知道我们失去了一些精度。将450 除以420 根本不需要四舍五入,但在这里,我们已经必须将结果至少四舍五入一次。但是,为了完整起见,让我们完成这项工作:

      64.28571428571429 除以60

      符号位 = 0

      指数 = 10000000101b - 10000000100b + 01111111111b == 01111111110b

      尾数 = 1.0000000100100100100100100100100100100100100100100101 / 1.111 == 0.10001001001001001001001001001001001001001001001001001100110011

      四舍五入:

      0.1000100100100100100100100100100100100100100100100100 1 1 0 0
      
      1.0001001001001001001001001001001001001001001001001001 1 0 0
      

      与前一种情况一样四舍五入,我们得到尾数:0001001001001001001001001001001001001001001001001010

      当我们移动 1 时,我们将它添加到指数中,得到

      指数 = 01111111111b

      所以,结果是:

      0 01111111111 0001001001001001001001001001001001001001001001001010

      以十进制表示为1.0714285714285716

      Tl;dr

      第一师给了我们:

      0 01111111111 0001001001001001001001001001001001001001001001001001

      最后一个部门给了我们:

      0 01111111111 0001001001001001001001001001001001001001001001001010

      区别仅在于最后 2 位,但我们可能会丢失更多 - 毕竟,要获得第二个结果,我们必须舍入 两次而不是一次!

      现在,关于尾数除法。浮点除法主要有两种实现方式。

      IEEE 长除法规定的方式(here 是一些很好的例子;它基本上是常规的长除法,但使用二进制而不是十进制),而且速度很慢。这就是您的计算机所做的。

      还有一个更快但不太准确的选项,乘以逆。首先求除数的倒数,然后进行乘法运算。

      【讨论】:

        【解决方案4】:

        这是因为双除法通常会导致精度损失。所述损失可以根据划分的顺序而有所不同。

        当你除以7d 时,你已经失去了实际结果的一些精度。那么只有你将错误结果除以60

        当你除以7d * 60时,你只需要使用一次除法,因此只损失一次精度。

        请注意,双倍乘法有时也会失败,但这种情况不太常见。

        【讨论】:

        • "请注意,双倍乘法有时也会失败,但这种情况不太常见" - 只是整数参数不太常见。这对于非整数来说很常见。例如,0.1*0.1 != 0.01
        【解决方案5】:

        当然,操作的顺序与 doubles 不精确的事实混合在一起:

        450.00d / (7d * 60) --> a = 7d * 60 --> result = 450.00d / a
        

        450.00d / 7d / 60 --> a = 450.00d /7d --> result = a / 60
        

        【讨论】:

          猜你喜欢
          • 1970-01-01
          • 1970-01-01
          • 2013-02-26
          • 1970-01-01
          • 2019-05-13
          • 1970-01-01
          • 2022-01-12
          • 2012-01-09
          相关资源
          最近更新 更多