资讯专栏INFORMATION COLUMN

数据精度问题自查手册

liangzai_cool / 1648人阅读

摘要:前言在数据敏感的业务场景中,常常会碰到数据精度问题,尤其在金额显示占比统计等地方,该问题尤为显著。计算机原理真香数值的精度问题,其实是非常基础的计算机原理知识。

前言

在数据敏感的业务场景中,常常会碰到数据精度问题,尤其在金额显示、占比统计等地方,该问题尤为显著。由于数据的每一位有效数字都包含真实的业务语义,一点点偏差甚至可能影响业务决策,这让问题的严重性上升了几个阶梯。

那,什么是精度丢失?

一言以概之,凡是在运行过程中,导致数值存在不可逆转换时,就是精度丢失。

诸如:

人均交易额、占比这类计算得出的除法获得的指标(分子/分母)时,如果盲目的直接从该结果去推算分子数值时,很可能就存在精度丢失

浮点数计算结果,会出现很长尾的小数

这两种广义上来说都是精度丢失,但第一种情况可以通过更改技术方案等方式进行规避。更多时候,所谓的精度问题,单指第二类问题。而面对这类问题时,如果没有掌握原理,往往会一知半解,对结论印象不深,再次碰到问题只能一查再查。

计算机原理真香

数值的精度问题,其实是非常基础的计算机原理知识。通常,js的系统知识书籍(基础类型章节)一般也会提到,但像我这样的非科班前端开发,往往在这方面的知识储备非常薄弱;而且,即使学习过了,也会因为第一次学习时没体感,没有实际场景去强化认知,掌握的也不深刻。

所以,在后续的业务开发中,有必要重新整理下遇到的问题,从遇到的问题出发,追根溯源,才能更深刻地掌握知识点。

真实的Number

(本章节为基础的规范介绍,有助于加深认知,非必要知识,尤其是存储形式,大部分问题的解答只需有概念即可。)

有别于其他语言会出现各类int、uint、float,JS语言只有一种数值类型——Number,它的背后是标准的双精度浮点数实现(其他语言一般称该类型为double或float64),这也就意味着,前端所有出现的数值,其实背后都是小数。

看一下双精度浮点数的内存模型(这幅维基百科的示意图真是每篇精度文章都会引用~):


总共64位,分成了三部分:符号(sign)、指数(exponent)、尾数(fraction)。即,最终每一个数值都可以表示成:(-1)^S * 2^E * M

存储形式

这篇文章介绍了一个非常简单的转换方式,拿一个数值实际体验一下过程,例如34.1

第一步,取整数部分——34,通过除2取余数:

计算过程 结果 余数
34/2 17 0
17/2 8 1
8/2 4 0
4/2 2 0
2/2 1 0
1/2 0 1

第二步,取小数部分——0.1,通过乘2取整数。如果结果大于1,则取1,否则取0:

计算过程 结果 整数
0.1*2 0.2 0
0.2*2 0.4 0
0.4*2 0.8 0
0.8*2 1.6 1
0.6*2 1.2 1
0.2*2 0.4 0
... ... ...

第三步,拼接结果,整数部分结果是从下往上取,小数部分则是从上往下取。结果为:(34.1)10 = (100010.0_0011_0011_0011...)2

ps:为了阅读清晰,使用下划线分隔符~该特性将在Chrome75到来,诸如Rust已经具备

第四步,转换为科学计数法(二进制版),(34.1)10 = 1.00010_0_0011_0011... * 2(5)10 。到此,已经可以获取到公式中各个值所对应的结果了:

S = 0

E = (5 + 1023)10 = (100_0000_0100)2

M = (00010_0_0011_0011...)2

最终的34.1的内存存储为:0  100_0000_0100  00010_0_0011_0011_0011_0011_0011_0011_0011_0011_0011_00 11_0011_01。(我反正是瞎了)

对于这个结果,还需要几点补充说明:

为什么指数E的结果需要+1023?

指数部分有11位bit。使用无符号表示,可以表示范围0~2047,其中0和2047为非规约形式,有特殊意义(详见wiki,不做展开了),那剩余的范围是1~2046;如果使用带符号表示,可以表示范围-1024~1023。因为实际指数是可以存在负值的,为了避免使用符号表示法,就加入了这个偏移量。

至于,为什么不使用符号?我没什么太深刻的体感。不过可以肯定的是,目的一定是为了后续的计算处理方便。比如:如果无符号,可以直接比较大小?

为什么尾数M的结果省略了整数部分?

这是因为,既然数值一定可以表示成科学计数法,那尾数M的整数部分必然是1。

为什么?如果实在想不明白,可以参考十进制的科学计数法,整数部分一定是1~9,因为一旦超过9,就会归入指数,即,整数部分为1~【进制-1】。那在二进制的科学计数法中,整数部分为1~1,则必然是1。

此外,这里还有另一点好处,通过省略整数部分,这个“1”就不需要占用存储了,相对的,小数部分可以多一位有效数字。

如何表示无限循环的尾数部分?

正如上例中的34.1,它的尾数部分就是无限循环,如果超出了存储位数,则势必要进行舍入。

实际上,存在多种舍入规则:

舍入到最接近

朝+∞方向舍入

朝-∞方向舍入

朝0方向舍入

也不做展开了,具体可以继续查阅wiki。默认理解下,“0舍1入”的规则够用了。

举一反三

Number.MAX_SAFE_INTEGER

Number类上的一个静态属性,值为9007199254740991。这个数是怎么来的呢?

因为Number的尾数有53位,理论上能表示的、精确的最大整数即为2-1,这也正是MAX_SAFE_INTEGER。超过这个值的数值,因为有效数字有限,Number已经无法精确表示了。

然而指数部分最大值是1023,所以理论上Number能表示的最大值应该至少达到2才对,那这个区间(2~2)的如何存储呢?我没有太深入思考,原理上应该也是通过舍入规则去理解,不过还是不展开了,留个坑位~

题外话:
很多面试题里都包含了大整数的考点。考的是两处,第一点是,是否意识到了面试题中存在大整数问题;第二点是,如何用程序模拟手算过程。

不过我比较好奇的是,假如面试者使用了BigInt来完成大整数的四则运算(跳过第二个考点)是不是也算合格?【笑

Number.EPSILON

同样是Number类上的一个静态属性,值为2.220446049250313e-16。这个数又是怎么来的?

同样和尾数相关,理论上能表示的最小尾数是1.00000000_00000000_00000000_00000000_00000000_00000000_0001,也就是EPSILON。

能精确表示的十进制有效位数

一般来说,double类型的有效位数,结论是16位。不过,目前我还没看到非常严谨的说明过程,现有的解释方式略作搬运:

    MAX_SAFE_INTEGER是9007199254740991,它的位数就是16

    EPSILON它能精确到小数点后15位,再加上整数位,所以,有效位数是16

为什么不推荐使用位运算

lint规则中一般是不建议在JS代码中使用位运算的。

第一点是,不便于维护,考虑到前端开发普遍对位运算不感冒;
第二点是,如两次取反(~~3.11)、或0(3.11 | 0)这种取整操作,其背后,实际上是将64位的双精度浮点数转成了32位整数。如果对此没有明确的认知,能确保程序运行时的入参必定是32位整数范围内的话,就很容易埋坑,不如老老实实的使用Math.floorMath.round

const n = 2**32 + 0.1 // 4294967296.1

~~n // 期望是2^32,但其实结果是0

Math.floor(n) // 符合预期
Number的计算

明白了真实的Number,很容易就理解了——由于一个小数无法用二进制精准表示,势必存在精度丢失,也就很自然地会出现诸如经典的“0.1+0.2 ≠ 0.3”问题。但与此同时,我产生了一个疑问,两个精度丢失的纯小数是否能得出一个精准表示的数值?

(由于双精度浮点数实在位数太多了。。。写得累,下面都使用单精度浮点数表意,双精度的情况可以同理类推。)

严格来说,浮点数计算需要经过:对阶、尾数求和、规约化、舍入、溢出判断(详细内容,可以参阅此文)。如果严格按照步骤进行,有些过于死板,而且其中有更多的概念需要消化,这里仅仅是为了加深体感,所以使用更“小学”的方式来解决这个问题。

在进行具体计算前,需要先掌握:

如何将十进制转为二进制,上一章介绍过了

有效数字位数,单精度浮点数尾数部分为23位,相应的,能表示的有效位数为24位(为什么?),上一章也介绍过了

手算加法

0.1 + 0.4

将0.1和0.4转为二进制(不需要转为科学计数法,即可跳过对阶步骤),结果是:

0.1 = 0.0_0011_0011_0011_0011_0011_0011_01,保留24位有效数字,根据“0舍1入”进位

0.4 = 0.0_1100_1100_1100_1100_1100_1101,保留24位有效数字,根据“0舍1入”进位

可以看到,0.1和0.4都是存在进位的,它的存储值比真实值都要大,那两个比真实值大的数的是如何恰好相加得出0.5的呢?

核心关键点,其实在于这个**“有效位数”**,我们手算一下,把这两个值直接相加,现在位数已经对齐了:

       0.0_0011_0011_0011_0011_0011_0011_01
+      0.0_1100_1100_1100_1100_1100_1101
-----------------------------------------------
       0.1_0000_0000_0000_0000_0000_0000_(01)

0.1就是0.5,实在是太巧了!误差正好被排除在有效位数之外!也就是,两个丢失精度的数值计算后恰好精度复原了。

好奇心如我,觉得这里应该是可以用数学方式去证明,无整数部分的小数计算,误差一定会控制在相对小的范围之内的。否则,如果按照常规理解,随着计算进行,误差会无休止的膨胀下去。

当然,这种证明过程肯定很专业,估计真展示在我面前,我也看不懂。我等普通吃瓜开发,还是只管喊666就成了~

0.1 * 10

掌握了加减法,就自然会对乘法产生新的疑惑(主要是解决精度问题中很常见的办法是转为整数)。既然,0.1是无法精确表示的,而1和10作为整数又是可以精确表示的,那这里的结果“1”是精确的“1”,还是一个非常近似的小数?如果是精确的,丢失精度的小数是如何转为精确的整数的呢?

浮点数的乘法有特别算法(Booth算法)可以细讲的,不过在此也不做具体展开。

基本原理上来说,就是将乘法简化为“移位 + 加减法”。在本例中,10可以拆为2 + 2,继续手算:

       0.1 * 10 = 0.1 * 2^3 + 0.1 * 2

       0.1100_1100_1100_1100_1100_1101
+      0.0011_0011_0011_0011_0011_0011_01
---------------------------------------------------
       1.0000_0000_0000_0000_0000_0000_(01)

是不是又一次感慨世界的奇妙?和上一例结果一样,误差再一次被命运排除在有效位数之外,amazing~~

不过,需要注意的是,这两个示例都限定在了无整数部分的小数计算(也可能是整数部分需要满足什么条件才可以)。如果整数部分存在有效数字,会不同程度的挤压小数部分可用的尾数有效位数,就有可能导致无法出现这些神奇结果了。

/10 和 *0.1 的区别

这个区别可以简单的进行求证。只需提高结果的精度表示,就可以看到差异:

(6 / 10).toPrecision(17)  // "0.59999999999999998"
(6 * 0.1).toPrecision(17) // "0.60000000000000009"

究其原因,0.1是无法精确表示的,而10是可以精确表示的,所以和一个可以准确表示的数进行计算,势必精度会高于和无法准确表示的数进行计算。

这就是典型的误差累计,当结果是无法精确表示的时候,之前那神奇的误差清除似乎就没那么灵验了。所以,如果有必要,计算过程中,可以有意识的尽量使用整数。

解决方式 toFixed

这是最基础的解法。不过需要注意的是,当尾数是5的时候,它的结果往往不符合预期。

这篇文章里,举了个例子:

(1.005).toFixed(2) // 结果是1.00,而不是1.01
// 文中给出的解释是将该数值进行更高精度展示,确实该数值的四舍五入确实是1.00
(1.005).toPrecision(17) // "1.0049999999999999"

然而,评论中,被人锤了:

(1.105).toPrecision(17) // "1.1050000000000000"
(1.105).toFixed(2) // 结果是1.10

这是为什么?

思路上没有问题,只是,精度还不够。如果我们按照规范理解toFixed,那核心在于这一步骤:

Let n be an integer for which the exact mathematical value of n ÷ 10 – x is as close to zero as possible. If there are two such n, pick the larger n.

套用在这个例子中就是:

n / 100 - 1.105 // n为整数,尽可能让结果趋于0,最终计算误差取17位精度
n = 110, // -0.0049999999999998934
n = 105, // 0.0050000000000001155

确实n = 110时,结果更接近0,也就是toFixed的结果是1.10。

当然,使用取高精度方式去求解也未尝不可,只是,实际规范过程中,可以注意到,这一步计算会把整数部分以及小数点后的n(toFixed参数)位全部归0,所以如果需要正确的观测当前值,需要toPrecision(17 + n),也就是:

(1.105).toPrecision(19) // 1.104999999999999982
// 也就可以正确推出toFixed(2)的结果是1.10了
Math.round

这里补充一点,一般场景中,如果想获取四舍五入的整数,往往会使用Math.round。但需要注意,这里依然有不符合预期的结果:

Math.round(1.005 * 100) / 100 // 结果是1,而不是期望的1.1
Math.round(-0.5) // 结果是0,而不是期望的-1

第一例的问题其实是1.005无法转为精确的整数导致的:1.005 * 1000 = 1004.9999999999999。所以只需要额外的多进行一次转换即可。

第二例的问题其实是符合规范的,Math.round的结果是取更靠近+∞方向,而不是常规理解的远离0,所以碰到负数,更保险的做法应该是使用绝对值再加符号位。

toPrecision

上文提过双精度浮点数能精确表示的位数是16位。如果toFixed使用时没有注意整数部分,也会导致预期之外的错误:

(1234123412341234.3).toFixed(2) // 1234123412341234.25

既然toFixed有种种问题,而Number本身能达到的精度是16位,那其实,数值运算后的最终结果只要进行Number.parseFloat(num.toPrecision(16))处理即可。

转整数计算

toPrecision可以避免绝大部分的小数点位数过长的问题。但,这可能导致结果和业务输入的位数不一致,例如:

add(0.11, 0.19) => "0.30"
add(0.11, 0.100) => "0.210"

要解决这类问题,一般需要转整数计算,不仅可以保证精度,也能输出符合业务预期的位数。这也是绝大部分轻量库的方案,基本原理是:

    求出入参的最大位数

    转为整数计算

    最后输出结果时再除去最大位数

当然,这种方案的缺陷是,过程中一般无法顾及超出范围的大数。

类库

一步步了解了各种场景下出现的问题,这时候再去选择类库,就有底气的多,毕竟对于各种问题的解决已初步具备思路,不会只停留在知其然而不知其所以然的境界。而使用成熟类库的好处是,它考虑的边界条件更多、逻辑更完备,运行时的稳定性更高。

我列举几个类库,不过使用不深,就请自行查阅啦~

Mathjs

BigNumber.js

Decimal.js(同一位大师)

Big.js(我不知道这位大师的三个库具体区别是什么。。。)

number-precision,轻量级方案






参考

Binary numbers – floating point conversion

JavaScript 浮点数陷阱及解法

如何避开JavaScript浮点数计算精度问题

IEEE 754

从0.1+0.2=0.30000000000000004再看JS中的Number类型

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/7260.html

相关文章

  • 系统的讲解 - PHP 浮点数高精度运算

    摘要:浮点数类型包括单精度浮点数和双精度浮点数。小结通过浮点数精度的问题,了解到浮点数的小数用二进制的表示。以后,在使用浮点数运算的时候,一定要慎之又慎,细节决定成败。 概述 记录下,工作中遇到的坑 ... 关于 PHP 浮点数运算,特别是金融行业、电子商务订单管理、数据报表等相关业务,利用浮点数进行加减乘除时,稍不留神运算结果就会出现偏差,轻则损失几十万,重则会有信誉损失,甚至吃上官司,我...

    makeFoxPlay 评论0 收藏0
  • 关于ADC芯片的选型

    摘要:关于芯片的选型,还是其他芯片的选型,那都不是随随便便就说了算得。芯片成本参差不齐,选的好直接起飞,选的不好,直接破产。 关于ADC芯片的选型,还是其他芯片的选型,那...

    mgckid 评论0 收藏0
  • 从拿到班车手册.xls到搜索附近班车地点

    摘要:辗转流传出班车手册后发现搜索实在是太不方便了,于是有了一个主义,想做一个可以搜索房子地址,找出附近班车点类似大众点评的定位搜索附近餐馆的功能。 起因 七月份要去某厂报道了,异地租房的时候发现想租一个有公司班车的地方,却不知道哪里有班车。辗转流传出班车手册后发现搜索实在是太不方便了,于是有了一个主义,想做一个可以搜索房子地址,找出附近班车点(类似大众点评的定位搜索附近餐馆的功能)。现在做...

    jhhfft 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<