浮点数问题:0.3 不等于 0.1 + 0.2

一个有趣的问题

假如你尝试进行这个计算:

>>> 0.3 == 0.1 + 0.2
False

有趣的是,结果居然是False。

那么0.3 为什么不等于 0.1 + 0.2 ?

Python官方的解答

Python官网教程里特地提到了这个问题。有个例子解释得特别好,官方是这么说的,我简单概括一下:

你无法将 1/3 (三分之一)表示成一个精确的小数。因为它是除不尽的,只能用0.3,0.33, 0.333……来近似表示它。并且,你的笔墨和纸张有限,只能将它写到0.33333333333,通过四舍五入,近似的表示它。这样一来,可以清楚地明白,0.33333333333并不等于 1/3 (三分之一)。

浮点数也是同理,由于计算机内部是以二进制的方式存储数据的,所以一个小数会被转换为以2进制表示的值,这一转换过程经常“除不尽”造成了舍入的问题。

在MySQL官方文档中将浮点数备注为“大约值”(Approximate Value)。这个描述是比较形象的。0.3 是近似的,0.1 和 0.2 也是近似的表示,所以两者并不相等。

不过正如官方文档所说,不必过于担心浮点数的问题!

在大多数生产生活场景中,你只需将它舍入到所期望的小数位数即可。就像我们在衡量身高的时候,一般只需探讨多少厘米,没有人会在意谁比谁高0.00000001厘米!

但在需要精确精算的场合,例如科学研究、会计金融等领域,请使用decimal模块表示精确的小数,用fractions模块表示精确的分数。这两个模块表示的数字是完全精确的,符合数学上的期望。

Python浮点数到底是什么

Python的官方实现来自cpython,也就是用C语言编写的Python。cpython的源码中,对Python里面的float类型的实现,其实就是一个PyObject_HEAD头加一个double变量。

也即Python浮点数的底层就是double。

cpython源码中的floatobject.h :

typedef struct {
    PyObject_HEAD
    double ob_fval;
} PyFloatObject;

追根溯源,C语言的double特性又是哪里来的?

C语言的double遵循了IEEE754协议。

IEEE是(国际)电气和电子工程师协会的简称,其下的标准制定机构在信息、通信、电力等多个领域制定了900多个现行的工业标准。(数据来源于wikipedia)

IEEE754是在1985年制定的有关浮点数的标准。

何为“浮点”(Floating Point)

“浮点数”就是小数点会“动”的小数。这其实是采用科学计数法,将小数点的位置标记在指数位上,采用科学计数法下,例如:

  • 1000 = 1 * 10^3 (1乘10的3次方)
  • 0.01 = 1 * 10^-2 (1乘10的-2次方)

这里的3和-2其实就是在说明小数点的位置。

顺便一提,Python字面量直接支持科学计数法,类型是float。可以直接输入形如 2.3e-2 等价于 2.3 * 10^-2 (2.3乘10的-2次方)。这里的e代表以10为基础。

x = 1e2
y = 2.3e-2
# x: 100.0
# y: 0.023

但在计算机里就是以二进制为基础了,必须将10进制转换为2进制的形式,即,一个数乘以2的多少次方的形式,并且分正负数。在形如“正负A乘以2的B次方”中:

  • 正负数:用一个符号位(sign bit)来表示 正负1
  • A:被称为分数值(fraction),也就是有效数(significant),之与科学计数法的尾数部分有所区别,因为它隐含了高位1
  • B:指数偏移值(exponent bias),也就是指数部分

也就是:F = S * F * 2^E

也就是:浮点数 = “符号位” 乘以 “有效数” 乘以 2 的 “指数偏移” 次方

IEEE754主要制定了两个浮点数标准,单精度(float)和双精度(double)。Python里面的float类型是使用双精度double的。

单精度占4字节,也就是32位,其中包括:符号位1位,指数偏移8位,有效数23位。加上隐含的高位1,十进制的精度可以达到 log 2^24 大约 7.2,也就是7个十进制有效数字。

双精度占8字节,也就是64位,其中包括:符号位1位,指数偏移11位,有效数52位。加上隐含的高位1,十进制的精度可以达到 log 2^53 大约 15.9,也就是15个十进制有效数字。

也就是说,日常使用的话,在15个十进制有效数字范围内使用浮点数是安全的。(这里可以简单联想记忆一下,浮点数的小数点会动嘛,把它想象成小偷,躲得过初一,躲不过十五)

舍入 round

内置函数round用于返回“四舍五入”后的值,它有两个参数,第一个是要进行操作的数字number,第二个是要进行舍入的小数位数ndigits,默认为None,其实就是默认舍入到整数。

例子:

print(round(1.7))  
print(round(3.1415, 2))
# 结果:
# 2
# 3.14

round函数有两个需要注意的地方:

其一,对于浮点数的舍入是在二进制下进行的,它按照2进制的标准来舍入,所以在十进制下并不总是表现为“四舍五入”,尤其是处于5这个中间值的时候,可能有差异,例如:print(round(2.675, 2)) 会得到 2.67 而不是 2.68。

其二,如果不是舍入到整数,round对浮点数舍入的结果还是浮点数!这就可以回到最初的那个问题,我用round舍入可不可以?

回到 0.3 == 0.1 + 0.2

用round舍入可不可以?

>>> round(0.3, 1) == round(0.1, 1) + round(0.2, 1)
False

结果仍然是False!

因为round对浮点数舍入的结果还是浮点数,既然都是浮点数,那么又会存在浮点数近似的问题,所以二者并不相等。

如果说这是“事先舍入”的话,你可以尝试“事后舍入”:

>>> round(0.3, 1) == round(0.1 + 0.2, 1)
True

结果为True。

理论上讲,把它拉长到双精度浮点数的有效位数15位也是可以的

>>> round(0.3, 15) == round(0.1 + 0.2, 15)
True

可是这种做法会不会存在什么问题?

如果我面对的是等式两边有可能超过15位有效数字,尤其是整数部分特别长的时候,

123456678991.1296348 == 123456678991.12 + 0.0096348
>>> round(123456678991.1296348, 15) == round(123456678991.12 + 0.0096348, 15)
False

或者浮点数的特殊值(NaN,正负无穷等)的时候,怎么处理?

这就要拿出 epsilon 了。

epsilon

既然都通过“四舍五入”来忽略误差了,这里有更好的忽略误差的方式,就是将两个数做减法,让减法产生的差异小于一个很小的值,也就是可以接受误差的值,这个值就是 epsilon。

要判断 0.3 == 0.1 + 0.2 这个问题,可以改写成:

>>> abs(0.3 - (0.1 + 0.2)) < 0.09
True

相比于 round(0.3, 1) == round(0.1 + 0.2, 1) 这个表达式,这样分析:

如果0.3是“四舍五入”来的,那么它一定在0.25到0.34之间;如果0.1 + 0.2是“四舍五入”来的,那么它一定在0.25到0.34之间。它们之间的差距一定在0.09以下。

实际上,考虑到双精度是15个十进制有效数字,那么可以直接把epsilon的值改到很小:

>>> abs(0.3 - (0.1 + 0.2)) < 1e-15
True

注意到15个有效数字并非小数点后面的数字,所以1e-15并不总是有效,它还取决于前面整数部分的数字有几个,比如当数字特别大的时候,就像之前提到的 123456678991.12,一共14个有效数字,但小数位数只有2位。

所以epsilon的大小应当根据你的目标和实际情况来设置。

将这一判断改写成函数形式,并处理NaN,大致是这样:

def float_equal(a, b, epsilon=0.000001):  
    if a == b:  # 处理 NaN情况  
        return True   
    return abs(a - b) < epsilon

其中 epsilon 需要根据实际情况取一个相对合理的值。

总结一下

浮点数是大约值,往往没有准确的值。尤其是,一个特别大的数和一个特别小的数做四则运算时,产生非常大的精度损失。

如果需要判断两个浮点数相等(不是绝对相等,而是相对的,在我们的目标范围内相等),最好是设置一个目标最小值epsilon,将两者做差并取绝对值,让它小于epsilon。

需要精确计算的情形,请使用decimal模块表示小数和fraction模块表示分数。

注意:

虽然本文探讨了可以使用epsilon等方法判断近似相等的情形,但是,一旦有“比较两个数是否相等”这个需求,就尽量不要用float浮点数了,而是使用decimal和fraction来实现。

在这种情况下,强行使用float只会让代码的可读性变得很糟糕,甚至产生很多莫名其妙的问题。

所以,涉及到相等的比较,有精确计算的需求,请使用decimal模块或者fraction模块,而不要用float。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,658评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,482评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,213评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,395评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,487评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,523评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,525评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,300评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,753评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,048评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,223评论 1 343
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,905评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,541评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,168评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,417评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,094评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,088评论 2 352

推荐阅读更多精彩内容