浮点数学运算是否被破坏?

考虑以下代码:

0.1 + 0.2 == 0.3  ->  false
0.1 + 0.2         ->  0.30000000000000004

为什么会出现这些错误?

答案

二进制浮点数学就是这样。在大多数编程语言中,它基于IEEE 754 标准 。问题的症结在于数字以这种格式表示为整数乘以 2 的幂。分母不是 2 的幂的有理数(例如0.1 ,即1/10 )无法精确表示。

对于标准binary64格式的0.1 ,表示形式可以完全按照

  • 0.1000000000000000055511151231257827021181583404541015625 (十进制)
  • 0x1.999999999999ap-4 十六进制表示法中的 0x1.999999999999ap-4

相反,有理数0.1 ,即1/10 ,可以完全写为

  • 十进制的0.1 ,或者
  • 0x1.99999999999999...p-4 ,类似于 C99 十六进制表示法,其中...表示 9 的无休止序列。

程序中的常数0.20.3也将接近其真实值。碰巧的是,最接近0.2 double 0.2数大于有理数0.2但最接近0.3 double 0.3却小于有理数0.30.10.2之和最终大于有理数0.3 ,因此与代码中的常数不一致。

每个计算机科学家都应该对浮点算术了解什么是对浮点算术问题的相当全面的处理。有关更容易理解的说明,请参见floating-point-gui.de

旁注:所有位置(以 N 为底的)数字系统均会精确地共享此问题

普通的旧十进制数(以 10 为底)有相同的问题,这就是为什么像 1/3 这样的数字最终会变成 0.333333333 ...

您偶然发现了一个数字(3/10),该数字很容易用十进制表示,但不适合二进制。它也是双向的(在某种程度上也是如此):1/16 是一个丑陋的数字,十进制(0.0625),但是在二进制中,它看起来像 10,000 十进制(0.0001)** 一样整洁 - 如果我们在习惯于在我们的日常生活中使用基数 2 的数字系统,您甚至会查看该数字,并且本能地理解,可以通过将某物减半,一次又一次减半而到达那里。

** 当然,这并不完全是将浮点数存储在内存中的方式(它们使用科学计数形式)。但是,它的确说明了二进制浮点精度误差趋于增加的观点,因为我们通常感兴趣的 “真实世界” 数通常是 10 的幂 - 但这仅仅是因为我们使用了十进制数天 - 今天。这也是为什么我们要说 71%而不是 “每 7 个中有 5 个”(71%是一个近似值,因为 5/7 不能用任何十进制数字精确表示)的原因。

否:二进制浮点数没有被破坏,它们恰好与其他所有基数 N 的系统一样不完美:)

侧面说明:在编程中使用浮点数

实际上,这种精度问题意味着您需要使用舍入函数将浮点数四舍五入为您感兴趣的任意小数位,然后再显示它们。

您还需要用允许一定程度的公差的比较替换相等性测试,这意味着:

不要if (x == y) { ... }

取而代之的是if (abs(x - y) < myToleranceValue) { ... }

其中abs是绝对值。需要为您的特定应用选择myToleranceValue这与您准备允许多少 “摆动空间” 以及要比较的最大数字有很大关系(由于精度下降)问题)。注意您选择的语言中的 “epsilon” 样式常量。这些不得用作公差值。

硬件设计师的观点

我相信我应该为此添加硬件设计师的观点,因为我设计并构建了浮点硬件。知道错误的来源可能有助于理解软件中发生的事情,并且最终,我希望这可以帮助解释为什么会出现浮点错误并随着时间的推移而累积的原因。

1. 概述

从工程的角度来看,大多数浮点运算都将具有一定的错误元素,因为进行浮点计算的硬件仅要求最后的误差小于一个单元的一半。因此,许多硬件将停止在一个精度上,该精度仅对于单次操作在最后一次产生的误差小于一个单元的一半所必需,而这在浮点除法中尤其成问题。构成单个操作的要素取决于该单元采用的操作数。对于大多数情况,它是两个,但是某些单位使用 3 个或更多操作数。因此,不能保证重复操作会导致理想的错误,因为随着时间的推移这些错误加起来。

2. 标准

大多数处理器遵循IEEE-754标准,但有些使用非规范化或不同的标准。例如,IEEE-754 中存在一种非规范化模式,该模式允许以精度为代价表示非常小的浮点数。但是,以下内容将涵盖 IEEE-754 的标准化模式,这是典型的操作模式。

在 IEEE-754 标准中,只要最后一位小于一个单位的一半,那么硬件设计者就可以允许任何误差 /ε值,并且最后的结果必须小于一个单位的一半。一个手术的地方。这解释了为什么当重复操作时,错误加起来。对于 IEEE-754 双精度,这是第 54 位,因为 53 位用于表示浮点数的数字部分(规格化),也称为尾数(例如 5.3e5 中的 5.3)。下一节将更详细地介绍各种浮点操作上的硬件错误原因。

3. 除法中舍入错误的原因

浮点除法中错误的主要原因是用于计算商的除法算法。大多数计算机系统使用乘以逆来计算除法,主要是在Z=X/YZ = X * (1/Y) 。迭代计算除法,即每个周期计算商的某些位,直到达到所需的精度为止,对于 IEEE-754 而言,这是任何最后一次误差小于一个单位的东西。 Y(1 / Y)的倒数表在慢除法中称为商选择表(QST),商选择表的位大小通常为基数的宽度,或者为每次迭代中计算出的商,加上一些保护位。对于 IEEE-754 标准(双精度(64 位)),它将是除法器基数的大小,加上几个保护位 k,其中k>=2 。因此,例如,用于一次计算商 2 位(基数 4)的除法器的典型商选择表将是2+2= 4位(加上一些可选位)。

3.1 除法舍入误差:倒数的近似

商选择表中的倒数取决于除法 :慢除法(例如 SRT 除法)或快速除法(例如 Goldschmidt 除法);根据划分算法修改每个条目,以尝试产生尽可能低的错误。但是,无论如何,所有倒数都是实际倒数的近似值 ,并且会引入一些误差元素。慢速除法和快速除法方法都是迭代计算商,即,每步计算商的位数,然后从被除数中减去结果,然后除法器重复执行这些步骤,直到误差小于二分之一为止。单位放在最后。慢除法在每个步骤中计算商的位数固定,并且通常构建成本较低,而快速除法在每步中计算可变数位数,并且通常构建成本较高。除法中最重要的部分是,大多数方法都依赖于近似的倒数重复进行乘法运算,因此容易出错。

4. 其他操作中的舍入错误:截断

所有操作中舍入错误的另一个原因是 IEEE-754 允许的最终答案截断的不同模式。有截断,向零舍入,最近舍入(默认),向下舍入和向上舍入。对于单个操作,所有方法最后都会引入误差小于一单位的元素。随着时间的流逝和重复的操作,截断还会累积地增加所产生的错误。截断误差在取幂时尤其成问题,涉及某种形式的重复乘法。

5. 重复操作

由于执行浮点计算的硬件仅需要产生一个结果,该结果的单个操作的最后一个位置的误差小于一个单元的一半,因此如果不注意,该误差将随着重复的操作而扩大。这就是为什么在需要有限误差的计算中,数学家使用诸如 IEEE-754 的最后一位使用四舍五入到最接近的偶数之类的方法的原因,因为随着时间的流逝,误差更可能相互抵消。 间隔算法IEEE 754 舍入模式的变体相结合,以预测舍入误差并进行校正。由于与其他舍入模式相比其相对误差较低,因此舍入到最接近的偶数位(最后一位)是 IEEE-754 的默认舍入模式。

请注意,默认的舍入模式( 最后一位舍入到最接近的偶数位)保证一次操作的最后一位的误差小于一个单位的一半。仅使用截断,向上舍入和向下舍入可能会导致错误,最后一个位置的误差大于一个单元的一半,但最后一个位置的误差小于一个单元,因此不建议使用这些模式,除非它们是在间隔算术中使用。

6. 总结

简而言之,浮点运算错误的根本原因是硬件的截断和除法时的倒数截断的组合。由于 IEEE-754 标准在一次操作中只要求最后一个位置的误差小于一个单元的一半,因此,除非纠正,否则重复操作中的浮点错误将加起来。

当您将. 1 或 1/10 转换为以 2 为基数(二进制)时,会在小数点后得到一个重复模式,就像试图以 10 为基数表示 1/3。该值不精确,因此您不能执行使用普通的浮点方法进行精确数学运算。

这里的大多数答案都是用非常干燥的技术术语来解决这个问题。我想用普通人能理解的术语来解决这个问题。

想象一下,您正在尝试切比萨饼。你有一个机器人比萨刀,可以削减比萨正好一半。它可以将整个披萨减半,也可以将现有的薄片减半,但是无论如何,减半总是精确的。

比萨机的动作非常精细,如果您从整个比萨饼开始,然后将其减半,然后每次将最小的薄片减半,那么您可以将薄片减半53 次 ,直到薄片即使对于其高精度功能而言仍然太小。此时,您不能再将这一薄片减半,而必须按原样包含或排除它。

现在,您如何将所有的切片切成这样的厚度,使它们的总和等于比萨饼的十分之一(0.1)或五分之一(0.2)?真正考虑一下,然后尝试解决。如果您手边有神话般的精密披萨切割器,您甚至可以尝试使用真正的披萨。 :-)


大多数有经验的程序员,当然知道真正的答案,这是没有办法拼凑出一个确切的十分之一或五分之一的比萨使用这些片,不管你如何精细切片他们。您可以做一个非常好的近似值,如果您将 0.1 的近似值与 0.2 的近似值相加,那么您会得到一个非常好的 0.3 的近似值,但是仍然只是一个近似值。

对于双精度数字(可以使您的比萨饼减半 53 的精度),立即小于或大于 0.1 的数字是 0.09999999999999999167332731531132594682276248931884765625 和 0.1000000000000000055511151231257827021181583404541015625。后者比前者更接近 0.1,因此,在输入为 0.1 的情况下,数值解析器将偏爱后者。

(这两个数字之间的差是我们必须决定包括的 “最小切片”,它会引入向上的偏差,而排除的结果是引入向下的偏差。最小切片的技术术语是ulp 。)

在 0.2 的情况下,数字都是相同的,只是放大了 2 倍。同样,我们支持略高于 0.2 的值。

请注意,在两种情况下,0.1 和 0.2 的近似值都有轻微的向上偏差。如果我们添加足够的这些偏差,它们将使数字离我们想要的距离越来越远,实际上,在 0.1 + 0.2 的情况下,偏差足够大,以致所得的数字不再是最接近的数字到 0.3。

特别地,0.1 + 0.2 实际上是 0.1000000000000000055511151231257827021181583404541015625 + 0.200000000000000011102230246251565404236316680908203125 = 0.3000000000000000444089209850062616169452667236328125,而最接近 0.3 的数字实际上是 0.2999999999999999888977697484484957636833191791575。


PS 一些编程语言还提供了比萨饼切割器,可以将切成精确的十分之一 。尽管这种比萨饼切割器并不常见,但是如果您确实有机会使用它,那么当重要的是要精确获得十分之一或五分之一的切片时,您应该使用它。

(最初发布在 Quora 上。)

浮点舍入错误。由于缺少素数 5,所以 0.1 在 base-2 中不能像在 base-10 中一样准确地表示。正如 1/3 可以用无数个数字来表示十进制一样,而在 base-3 中则是 “0.1”, 0.1 在 base-2 中采用无数位数,而在 base-10 中则采用无数位数。而且计算机没有无限的内存量。

除了其他正确答案外,您可能还需要考虑缩放值,以避免浮点运算出现问题。

例如:

var result = 1.0 + 2.0;     // result === 3.0 returns true

... 代替:

var result = 0.1 + 0.2;     // result === 0.3 returns false

表达式0.1 + 0.2 === 0.3在 JavaScript 中返回false ,但是幸运的是,浮点数中的整数运算是精确的,因此可以通过缩放避免十进制表示错误。

作为一个实际的例子,为避免浮点问题,其中精度是最重要的,建议1以整数形式表示货币,该货币代表美分的数量: 2550美分而不是25.50美元。


1 Douglas Crockford: JavaScript:优秀部分 :附录 A - 糟糕的部分(第 105 页)

我的回答很长,因此我将其分为三个部分。由于问题是关于浮点数学的,因此我将重点放在机器的实际功能上。我还专门针对双精度(64 位)精度,但是该参数同样适用于任何浮点运算。

前言

IEEE 754 双精度二进制浮点格式(binary64)数字表示以下形式的数字

值 =(-1)^ s *(1.m 51 m 50 ... m 2 m 1 m 02 * 2 e-1023

64 位:

  • 第一比特是符号位1如果数是负的, 0否则为1。
  • 接下来的 11 位是指数 ,它偏移 1023。换句话说,从双精度数读取指数位后,必须减去 1023 以获得 2 的幂。
  • 剩余的 52 位为有效数字 (或尾数)。在尾数中,“隐含” 1.总是2,因为任何二进制值的最高有效位是1

1 -IEEE 754 支持带符号零的概念 - +0-0的区别对待: 1 / (+0)是正无穷大; 1 / (-0)为负无穷大。对于零值,尾数和指数位均为零。注意:零值(+0 和 - 0)没有明确地归类为非正规2

2- 非正规数不是这种情况, 非正规数的偏移指数为零(隐含0. )。反规范双精度数的范围是 d 分钟 ≤| X | ≤d MAX,其中 d 分钟 (最小可表示非零数)为 2 -1023 - 51(≈4.94 * 10 -324)和 d MAX(最大反规范数,其尾数完全由1 s)为 2 - 1023 + 1-2 -1023-51 (≈2.225 * 10 -308 )。


将双精度数转换为二进制

存在许多在线转换器,用于将双精度浮点数转换为二进制(例如,在binaryconvert.com 上 ),但是这里有一些示例 C#代码,用于获取双精度浮点数的 IEEE 754 表示形式(我用冒号将这三个部分分开( : ):

public static string BinaryRepresentation(double value)
{
    long valueInLongType = BitConverter.DoubleToInt64Bits(value);
    string bits = Convert.ToString(valueInLongType, 2);
    string leadingZeros = new string('0', 64 - bits.Length);
    string binaryRepresentation = leadingZeros + bits;

    string sign = binaryRepresentation[0].ToString();
    string exponent = binaryRepresentation.Substring(1, 11);
    string mantissa = binaryRepresentation.Substring(12);

    return string.Format("{0}:{1}:{2}", sign, exponent, mantissa);
}

切入点:原始问题

(跳到底部的 TL; DR 版本)

Cato Johnston (提问者)问为什么 0.1 + 0.2!= 0.3。

IEEE 754 以二进制形式(用冒号分隔三个部分)表示,其值表示为:

0.1 => 0:01111111011:1001100110011001100110011001100110011001100110011010
0.2 => 0:01111111100:1001100110011001100110011001100110011001100110011010

请注意,尾数由0011的重复数字组成。这是为什么计算会出错的关键 -0.1、0.2 和 0.3 不能以有限数量的二进制位精确地以二进制表示,超过 1 / 9、1 / 3 或 1/7 可以精确地表示为十进制数字

还要注意,我们可以将指数的幂减小 52,并将二进制表示形式的点向右移动 52 个位置(非常类似于 10 -3 * 1.23 == 10 -5 * 123)。然后,这使我们能够将二进制表示形式表示为它以 a * 2 p形式表示的精确值。其中 “a” 是整数。

将指数转换为十进制,除去偏移,然后重新添加隐含的1 (在方括号中),0.1 和 0.2 是:

0.1 => 2^-4 * [1].1001100110011001100110011001100110011001100110011010
0.2 => 2^-3 * [1].1001100110011001100110011001100110011001100110011010
or
0.1 => 2^-56 * 7205759403792794 = 0.1000000000000000055511151231257827021181583404541015625
0.2 => 2^-55 * 7205759403792794 = 0.200000000000000011102230246251565404236316680908203125

要相加两个数字,指数必须相同,即:

0.1 => 2^-3 *  0.1100110011001100110011001100110011001100110011001101(0)
0.2 => 2^-3 *  1.1001100110011001100110011001100110011001100110011010
sum =  2^-3 * 10.0110011001100110011001100110011001100110011001100111
or
0.1 => 2^-55 * 3602879701896397  = 0.1000000000000000055511151231257827021181583404541015625
0.2 => 2^-55 * 7205759403792794  = 0.200000000000000011102230246251565404236316680908203125
sum =  2^-55 * 10808639105689191 = 0.3000000000000000166533453693773481063544750213623046875

由于总和不是 2 n * 1. {bbb} 的形式,因此我们将指数增加 1,然后将小数点( 二进制 )移至:

sum = 2^-2  * 1.0011001100110011001100110011001100110011001100110011(1)
    = 2^-54 * 5404319552844595.5 = 0.3000000000000000166533453693773481063544750213623046875

现在尾数中有 53 位(第 53 位在上一行的方括号中)。 IEEE 754 的默认舍入模式为 “ 最接近 舍入 ”- 即,如果数字x介于两个值ab 之间 ,则选择最低有效位为零的值。

a = 2^-54 * 5404319552844595 = 0.299999999999999988897769753748434595763683319091796875
  = 2^-2  * 1.0011001100110011001100110011001100110011001100110011

x = 2^-2  * 1.0011001100110011001100110011001100110011001100110011(1)

b = 2^-2  * 1.0011001100110011001100110011001100110011001100110100
  = 2^-54 * 5404319552844596 = 0.3000000000000000444089209850062616169452667236328125

请注意, ab仅在最后一位不同。 ...0011 + 1 = ...0100在这种情况下,最低有效位为零的值为b ,因此总和为:

sum = 2^-2  * 1.0011001100110011001100110011001100110011001100110100
    = 2^-54 * 5404319552844596 = 0.3000000000000000444089209850062616169452667236328125

而 0.3 的二进制表示形式是:

0.3 => 2^-2  * 1.0011001100110011001100110011001100110011001100110011
    =  2^-54 * 5404319552844595 = 0.299999999999999988897769753748434595763683319091796875

这仅与 0.1 和 0.2 之和的二进制表示形式相差 2 -54

0.1 和 0.2 的二进制表示形式是 IEEE 754 允许的最准确的数字表示形式。由于默认的舍入模式,将这些表示形式相加会导致仅在最低有效位上有所不同的值。

TL; DR

用 IEEE 754 二进制表示形式(用冒号分隔三个部分)编写0.1 + 0.2 ,并将其与0.3进行比较,这就是(我将不同的位放在方括号中):

0.1 + 0.2 => 0:01111111101:0011001100110011001100110011001100110011001100110[100]
0.3       => 0:01111111101:0011001100110011001100110011001100110011001100110[011]

转换回十进制,这些值为:

0.1 + 0.2 => 0.300000000000000044408920985006...
0.3       => 0.299999999999999988897769753748...

差异恰好是 2 -54 ,约为 5.5511151231258×10 -17-与原始值相比不重要(对于许多应用程序)。

比较浮点数的最后几位本质上是危险的,因为任何读过著名的 “ 每个计算机科学家应该了解的浮点算术 ”(涵盖了该答案的所有主要部分)的人都将知道。

大多数计算器使用附加的保护位来解决此问题,这就是0.1 + 0.2给出0.3 :最后几位是四舍五入的。

存储在计算机中的浮点数由两部分组成:一个整数和一个以整数为底并乘以该整数的指数。

如果计算机是在基座 10 的工作, 0.1 。将1 x 10⁻¹0.2将是2 x 10⁻¹ ,和0.3将是3 x 10⁻¹ 。整数数学既简单又精确,因此添加0.1 + 0.2显然会得出0.3

计算机通常不以 10 为基数工作,而是以 2 为基数。您仍然可以获得某些值的精确结果,例如0.51 x 2⁻¹0.251 x 2⁻² ,并将它们的结果加到3 x 2⁻²0.75 。究竟。

问题在于数字可以精确地以 10 为底,而不能以 2 为底。这些数字需要四舍五入到最接近的等值。假定非常常见的 IEEE 64 位浮点格式,最接近0.1数字是3602879701896397 x 2⁻⁵⁵ ,最接近0.2数字是7205759403792794 x 2⁻⁵⁵ ;将它们加在一起将得到10808639105689191 x 2⁻⁵⁵或精确的十进制值0.3000000000000000444089209850062616169452667236328125 。浮点数通常会四舍五入以显示。

浮点舍入错误。从每位计算机科学家应该了解的浮点算法中

将无限多个实数压缩为有限数量的位需要近似表示。尽管有无限多个整数,但是在大多数程序中,整数计算的结果可以存储在 32 位中。相反,在给定固定位数的情况下,大多数使用实数的计算将产生无法使用那么多位数精确表示的数量。因此,浮点计算的结果通常必须四舍五入,以重新适合其有限表示形式。舍入误差是浮点计算的特征。

我的解决方法:

function add(a, b, precision) {
    var x = Math.pow(10, precision || 2);
    return (Math.round(a * x) + Math.round(b * x)) / x;
}

精度是指在加法过程中要保留的小数点后要保留的位数。