在电脑上打开浏览器,按 F12 打开控制台,输入 0.1 + 0.2,回车。答案是 0.30000000000000004。换成 Python 试试:一样。一台专门用来计算的机器,竟然算错了一道小学加法。
这一课的傻问题是:为什么电脑说 0.1 + 0.2 不等于 0.3? 它没坏。它存数字的方式决定了 0.1 存不准。这一课讲清楚这种方式是什么,为什么几乎察觉不到,以及什么时候会露馅。
选一张牌:零基础选 ♣,不怕简单公式选 ♦,想看记号选 ♠。随时可以换。
0.1 在二进制里放不下
新手
试着用小数写出三分之一:0.3333…… 不管写多少位,都到不了头,总差一点。这个数本身没什么奇怪的,只是十进制按十分之一、百分之一、千分之一来数,三分之一在这种数法里没有尽头。
电脑不按十分之一来数,它按一半来数:一半、四分之一、八分之一、十六分之一……每一块都是前一块的一半。用这样的小块去拼,十分之一同样拼不完:可以非常接近,但总是缺一小块。
电脑只有几十块的位置,所以拼到那里就停下,留下最接近的那个数。它的 0.1 不是 0.1,而是多了一丁点。它的 0.2 也多了一丁点,而它的 0.3 却少了一丁点。加在一起,这些“一丁点”对不上,结果就成了 0.30000000000000004。
进阶
电脑用二进制存数字,只有 0 和 1。在十进制里,小数点后每一位的值是前一位的十分之一;在二进制里,每一位的值是前一位的一半:½、¼、⅛、1/16……
要把 0.1 写成二进制,就看哪些小块放得进去:½ 不行,¼ 不行,⅛ 不行,1/16 可以……得到 0.0001100110011……,0011 这一组永远重复下去。写九位能到 0.099609375,位数越多越接近,但永远写不完。这和十进制里的 1/3 是一回事。
普通电脑保留 53 位二进制数字,并舍入到最接近的数。它的 0.1 精确地等于 0.1000000000000000055511151231257827021181583404541015625,比 0.1 稍大一点(Python 教程)。
0.2 和 0.3 也是这样,各有各的小误差。前两个数相加的结果,没有落在电脑用来表示 0.3 的那个数上,而是落在下一个数上:0.30000000000000004。
技术
| ♦ 里的说法 | ♠ 里的写法 |
|---|---|
| 二进制 | 以 2 为底;(·)₂ 表示用二进制写的数 |
| 0011 这一组永远重复下去 | 0.0(0011)₂ = Σₖ≥₁ (2⁻⁴ᵏ + 2⁻⁴ᵏ⁻¹),循环展开 |
| 这和十进制里的 1/3 是一回事 | 既约分数 p/q ∈ ℚ(有理数):在二进制中展开有限 ⇔ q = 2ᵏ |
| 53 位二进制数字 | 精度 p = 53 位 |
| 舍入到最接近的数 | fl(x):就近舍入,平局时取偶 |
| 相加 | x ⊕ y = fl(x + y),浮点加法 |
| 下一个数 | 相差一个 ulp(unit in the last place,末位单位)的邻居,这里是 2⁻⁵⁴ |
既约分数 p/q 在二进制中展开有限,当且仅当 q 是 2 的幂。1/10 的分母里有因子 5,所以是循环小数:0.1 = 0.0(0011)₂ = Σₖ≥₁ (2⁻⁴ᵏ + 2⁻⁴ᵏ⁻¹)。用几何级数验证:(3/32) / (15/16) = 1/10。
当 p = 53 时,fl(0.1) 是最接近的分数 J/2ᴺ,其中 J 有 53 位:J = 7205759403792794,N = 56,约分后是 3602879701896397 / 2⁵⁵(Python 教程)。因为 10 × 3602879701896397 = 36028797018963970,而 2⁵⁵ = 36028797018963968:
fl(0.1) − 0.1 = 2 / (10 · 2⁵⁵) = 1 / (5 · 2⁵⁵) ≈ 5.55×10⁻¹⁸
也就是向上舍入了。在 0.1 ⊕ 0.2 中,精确和 fl(0.1) + fl(0.2) 恰好落在两个 53 位数的正中间。按“平局取偶”的规则,它被送到上面那个数,比 fl(0.3) 大一个 ulp,即 2⁻⁵⁴ ≈ 5.55×10⁻¹⁷。所以 0.1 + 0.2 == 0.3 在 Python、JavaScript 以及任何使用这种格式的语言里都是假的(用精确算术自行计算;在 JavaScript 里,0.1 + 0.2 - 0.3 得到 2⁻⁵⁴)。
电脑做加法没算错。问题是它手里的数根本不是 0.1:放不下。
浮点数:二进制的科学记数法
新手
在“很大的数”那一课里,你见过科学记数法:7×10²⁷ 就是 7 后面跟 27 个零。用它可以只写几位数字,就表示很大或很小的数。
电脑也是这么做的,只不过用二进制,而且位数总是固定的:大约 16 位十进制有效数字。这叫浮点数,因为小数点是“浮动”的:它随数的大小移动,而数字的位数始终不变。
想象一把刻度数量固定的尺子。靠近 0 的地方刻度很密,可以把小东西量得很细;离 0 越远,刻度越稀。要量的东西落在两道刻度之间,就记下最近的那一道:这就是舍入。
有 16 位数字,这把尺子已经非常精细:0.1 的偏差不到 10¹⁶ 分之一。所以几乎察觉不到。
进阶
一个浮点数分三部分存储,就像科学记数法,只是底数是 2:
- 符号,正或负;
- 指数,表示乘以 2 的几次方;
- 尾数,也就是那些数字。
最常用的格式叫 binary64(也叫“双精度”):64 位,1 位符号,11 位指数,52 位尾数(MDN;NumPy)。尾数总是以 1 开头,这个 1 不用存,所以实际有 53 位二进制数字,约 16 位十进制数字。JavaScript 的数、Python 的 float 和 Excel 用的都是它。例如 0.1 = + 1.6 × 2⁻⁴。
因为位数总是一样多,相邻两个数之间的间隔会随数的大小变大。紧挨着 1 的上方,间隔是 2.2×10⁻¹⁶。 在 10¹⁶ 附近,间隔是 2,所以 10¹⁶ + 1 还是 10¹⁶。同样的原因,整数只在大约 9×10¹⁵ 以内是精确的(MDN)。
有些位组合留给了零、无穷大和没有意义的结果,比如 −1 的平方根(微软关于 Excel 的文章)。
运算结果落在两个能存储的数之间时,就舍入到最近的那个。每次舍入的误差最多是间隔的一半:不到 9×10¹⁵ 分之一。
技术
| ♦ 里的说法 | ♠ 里的写法 |
|---|---|
| 符号 | s ∈ {0, 1} |
| 指数 | E,11 位,偏移量 1023:实际指数是 E − 1023 |
| 尾数 | (1.f)₂,f 是存下来的 52 位,1 是隐含的 |
| binary64 | IEEE 754-2019 的格式,p = 53,emin = −1022,emax = 1023 |
| 相邻两个数之间的间隔 | ulp(x) = 2^(⌊log₂|x|⌋ − 52),⌊·⌋ 表示向下取整 |
| 就舍入到最近的那个 | fl(x) = x(1 + δ),|δ| ≤ u = 2⁻⁵³(u:单位舍入误差) |
| 紧挨着 1 的上方,间隔是 2.2×10⁻¹⁶ | ε = ulp(1) = 2⁻⁵²(Number.EPSILON、sys.float_info.epsilon) |
| 无穷大和没有意义的结果 | ±∞ 和 NaN(not a number,非数),E = 2047;E = 0 用于零和次正规数 |
一个正规的 binary64 等于 x = (−1)ˢ · (1.f)₂ · 2^(E − 1023),其中 1 ≤ E ≤ 2046。E = 0 和 E = 2047 留给零、次正规数(没有隐含的 1)、±∞ 和 NaN。参数 p = 53、emin = −1022、emax = 1023 来自 Goldberg(1991)的表 D-1;这项标准目前的版本是 IEEE 754-2019。
在 2ᵉ 和 2ᵉ⁺¹ 之间有 2⁵² 个等距的数,间隔为 ulp(x) = 2^(⌊log₂|x|⌋ − 52)。就近舍入时,fl(x) = x(1 + δ),|δ| ≤ u = 2⁻⁵³,每一次基本运算也满足同样的界:x ⊕ y = (x + y)(1 + δ)(Goldberg)。
注意名称。MDN、Python 和 PyTorch 把 ε = 2⁻⁵² = ulp(1),也就是 1 到下一个数的距离,叫作 epsilon。Goldberg 所说的 machine epsilon 是相对误差的上界,只有它的一半:u = 2⁻⁵³。整数在 2⁵³ = 9007199254740992 以内是精确的;超过它,ulp ≥ 2(MDN,Number.MAX_SAFE_INTEGER = 2⁵³ − 1)。
舍入误差会累积
新手
想象一台有点特别的地磅:它不会把所有东西重新称一遍,而是把新放上去的重量加到已经显示的数字上,再舍入到千克。上面停着一辆 10000 千克的卡车,另外有一千根羽毛,合起来正好 1 千克。放一根羽毛:地磅还是 10000。再放一根:10000。一千根一根一根地放:还是 10000,因为每次都舍入到千克,一根羽毛不够。现在把一千根羽毛装进袋子,一次放上去:10001。
电脑也是这样。每次加法都要舍入,小误差会按照相加的顺序累积起来,或者丢掉。把 0.1 连加十次,得到的不是 1,而是 0.9999999999999999。
大多数时候这无所谓,因为误差极小。但如果你拿结果和 1 比较来做决定,答案就是“不相等”。所以,对带小数的数,程序员不问“相等吗?”,而问“足够接近吗?”。
进阶
每次运算都要舍入,误差会一点点累积。在 JavaScript 里用循环累加 0.1:
| 次数 | 结果 |
|---|---|
| 3 | 0.30000000000000004 |
| 8 | 0.7999999999999999 |
| 10 | 0.9999999999999999 |
| 10000 | 1000.0000000001588 |
而且,顺序会改变结果。给一个很大的数加上一个小数,可能什么也没变(10¹⁶ + 1 = 10¹⁶),所以 (a + b) + c 不一定等于 a + (b + c)(Goldberg,1991)。
三个常用的办法:
- 带容差比较,而不是用
==。在 Python 里,math.isclose(0.1 + 0.1 + 0.1, 0.3)返回True(Python 教程)。 - 小心地求和。
math.fsum会记下丢掉的那些位,只舍入一次。从 Python 3.12 起,sum()也会在内部补偿误差:在几乎所有安装版本中,sum([0.1] * 10) == 1.0都返回True,而手写的循环得到 0.9999999999999999。 - 显示时再舍入,计算时不要舍入:价格保留两位小数。
技术
| ♦ 里的说法 | ♠ 里的写法 |
|---|---|
| 每次运算都要舍入 | x ⊕ y = fl(x + y),x ⊖ y = fl(x − y) |
| 误差会一点点累积 | 递归求和 s ← s ⊕ xⱼ:第 j 项的扰动最多为 (n − j)u |
| 顺序会改变结果 | ⊕ 满足交换律,但不满足结合律 |
| 带容差比较 | |a − b| ≤ max(rel_tol · max(|a|, |b|), abs_tol),rel_tol = 10⁻⁹(math.isclose) |
| 小心地求和 | Kahan 补偿求和,c 是每一步带着的误差 |
| 在内部补偿误差 | Neumaier 求和,Kahan 求和的一种变体(gh-100425) |
| 只舍入一次 | 最后只舍入一次(math.fsum);O(nu²):nu² 量级或更小的项 |
递归求和时,每一项 xⱼ 都带着一个因子 (1 + δⱼ),|δⱼ| < (n − j)u:误差随 n 增长(Goldberg,1991;他把 u = 2⁻⁵³ 叫作 epsilon,见上一部分)。Kahan 补偿求和把每次 ⊕ 丢掉的部分存在 c 里,在下一步还回去。它的误差保持在 (2u + O(nu²)) Σ|xⱼ|,几乎与 n 无关(Goldberg,定理 8;Kahan,CACM 8, 40, 1965)。
没有结合律,顺序就很重要:(10³⁰ ⊕ −10³⁰) ⊕ 1 = 1,但 10³⁰ ⊕ (−10³⁰ ⊕ 1) = 0(Goldberg)。由此推出:把一个很长的求和分给几个处理器去算,会改变顺序,结果的最后几位可能不同。
比较时,math.isclose(a, b) 检查 |a − b| ≤ max(rel_tol · max(|a|, |b|), abs_tol),默认 rel_tol = 10⁻⁹:大约 9 位一致(Python 文档)。从 Python 3.12 起,sum() 使用 Neumaier 求和(gh-100425),math.fsum 只在最后舍入一次。
永远别问一个带小数的数是否等于另一个数。要问它们是否足够接近。
AI 用更少的位,记账用十进制
新手
不是每个数都需要 16 位数字。神经网络,比如机器翻译背后的那种,要用几百万个数做极多的乘法。如果每个数只占一半的空间,内存就能多装一倍,计算也可能更快。代价是每个数更粗糙,就像像素更少的照片。
只有 16 位时,电脑只有大约 3 位有效数字。把 0.1 一遍遍加上去,到 256 时和就卡住了:0.1 还不到尺子上两道刻度间隔的一半,一舍入就没了。下面的小工具里能看到。
记账则正好相反:一分钱的误差都不行。记账时建议不要用这种二进制小数,而是用整数按“分”来记,整数能精确存储;或者像人一样用十进制来算。
进阶
除了 binary64,还有更小的格式:
- float32,32 位(1 + 8 + 23):约 7 位有效数字。
- float16,16 位(1 + 5 + 10):约 3 位,而且不能超过 65504。
- bfloat16,16 位(1 + 8 + 7):只有约 2 位,但范围和 float32 一样。它是谷歌为机器学习设计的(谷歌云博客,2019)。
神经网络大量使用这些小格式:内存只占一半,速度也可能更快。诀窍叫混合精度:乘法用 16 位做,同时用 32 位保存一份网络权重的副本。在 float16 里,如果被加的数比一个小的改变量大 2048 倍或更多,这个小的改变量就可能丢失(Micikevicius 等,2018)。又是那台地磅。
记账则相反:用十进制算术,像我们一样按十分之一、百分之一来数,或者用整数记分。在十进制里,0.1 + 0.1 + 0.1 − 0.3 正好等于 0;在二进制里是 5.55×10⁻¹⁷(Python decimal 模块的文档,这个模块就是为会计设计的)。
技术
| ♦ 里的说法 | ♠ 里的写法 |
|---|---|
| 约 7 位有效数字 | p · log₁₀2 位十进制数字:p = 24 时为 7.22 |
| 不能超过 65504 | 最大有限值 (2 − 2¹⁻ᵖ) · 2^emax,emax 为最大指数 |
| 范围和 float32 一样 | 同样是 8 位指数:emax = 127 |
| 混合精度 | mixed precision:乘法用 FP16 或 BF16(float16、bfloat16);主权重和累加用 FP32(float32) |
| 网络权重 | 权重 w;梯度:每个权重变动时,损失(网络的误差)变化多少 |
| 谷歌 | TPU(Tensor Processing Unit,张量处理器):谷歌的神经网络芯片 |
| 小的改变量就可能丢失 | 若 |Δw| < ulp(w)/2,则 w ⊕ Δw = w |
| 2048 倍 | 2¹¹ = 2ᵖ,p = 11 |
| 十进制算术 | 底数 β = 10 的浮点数(Python 的 decimal 模块) |
精度为 p 位、最大指数为 emax 时,ε = 2¹⁻ᵖ,最大有限值为 (2 − 2¹⁻ᵖ) · 2^emax。位的划分来自 NumPy、PyTorch 和谷歌;表中的数值是自行计算的。对 binary16,(2 − 2⁻¹⁰) · 2¹⁵ = 65504,与 Micikevicius 等(2018)给出的值相同。
只要 |Δw| < ulp(w)/2,就有 w ⊕ Δw = w;在 FP16 中,从 |w| ≥ 2¹¹|Δw| 起就可能发生(Micikevicius 等)。他们的混合精度方案用 FP32 保存主权重,用 FP16 做乘法,并放大损失,让小梯度不至于变成零:在 FP16 里,没有小于 2⁻²⁴ 的正数;在他们的目标检测网络(SSD)中,放大 8 倍就够了。在谷歌的 TPU 上,每次矩阵乘法都用 bfloat16 相乘、用 FP32 累加(谷歌云,2019)。小工具用 0.1 演示了这一点:在 float16 里,从 256 起 ulp 是 0.25,而 0.1 < 0.125。
Python 的 decimal 模块是以 10 为底的浮点数:0.1 能精确存储,0.1 + 0.1 + 0.1 − 0.3 等于 0。记账也可以用整数记分,在 binary64 中直到 2⁵³ 都是精确的。
动手试试:把 0.1 加了又加
选择电脑存数字的方式,以及把 0.1 加多少次。比较实际得到的和应该得到的,看看图上的误差怎么增长。试试 float16 加 5000 次。
最后的挑战
三句话总结
- 电脑用二进制计数,而 0.1 在二进制里没有尽头,就像十进制里的 1/3:它存下的是舍入后的值,稍微偏大一点。
- 浮点数就是位数固定的二进制科学记数法(binary64 约 16 位):每次运算都要舍入,误差会累积,求和的顺序也有影响。
- 所以带小数的数要带容差比较;AI 用 16 位格式是因为省内存,钱则用整数按分记,或者用十进制。
参考资料
- 0.1 为什么存不准、它存下的值、
math.isclose和math.fsum:Python 3.15,教程第 15 章 和math模块。sum()使用 Neumaier 求和:Python 3.12 新特性。十进制算术:decimal模块。 - 表示、舍入、不满足结合律和 Kahan 求和:D. Goldberg, “What every computer scientist should know about floating-point arithmetic”, ACM Computing Surveys 23(1), 5–48 (1991), doi:10.1145/103162.103163,Oracle 重印版;W. Kahan, Communications of the ACM 8(1), 40 (1965), doi:10.1145/363707.363723。标准:IEEE 754-2019。
- 格式和位:MDN,
Number;NumPy 2.5,数据类型;PyTorch 2.14,torch.dtype;Excel 与浮点数(微软)。 - bfloat16:谷歌云博客(2019) 和 Cloud TPU 文档。混合精度:P. Micikevicius et al., “Mixed Precision Training”, ICLR 2018, arXiv:1710.03740。
这一课是怎么做的: 在 Claude 的帮助下,根据上面的资料调研和撰写,并于 2026 年 10 月 15 日用第二个来源核对。各种格式下循环累加 0.1 的结果是在 JavaScript(Node 24)里实测的,并有测试检查;没有单独链接的数字是根据资料计算的。插图是原创的。