在电脑上打开浏览器,按 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 是隐含的
binary64IEEE 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:

次数结果
30.30000000000000004
80.7999999999999999
100.9999999999999999
100001000.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 加了又加相对误差随 0.1 累加次数变化的图,两条轴都是对数刻度,每种格式一条曲线:binary64、float32、float16 和 bfloat16。初始状态:binary64,加 10 次,应该得到 1,实际得到 0.9999999999999999。10⁰10⁻⁴10⁻⁸10⁻¹²10⁻¹⁶110100100010⁴0.1 累加的次数相对误差

每种格式一条曲线;选中的那条加粗。

数字的存储方式
10 次
应该得到
1
实际得到
0.9999999999999999
误差
1.1×10⁻¹⁶
0.1 实际存成
0.1000000000000000055511151231257827021181583404541015625

偏差 1.1×10⁻¹⁶:相当于 9×10¹⁵ 分之一。

最后的挑战

为什么电脑无法精确存储 0.1?
你在循环里把 0.1 加十次,然后检查结果是否等于 1。会怎样?
要存一个银行账户的余额,用什么最好?

三句话总结

  1. 电脑用二进制计数,而 0.1 在二进制里没有尽头,就像十进制里的 1/3:它存下的是舍入后的值,稍微偏大一点。
  2. 浮点数就是位数固定的二进制科学记数法(binary64 约 16 位):每次运算都要舍入,误差会累积,求和的顺序也有影响。
  3. 所以带小数的数要带容差比较;AI 用 16 位格式是因为省内存,钱则用整数按分记,或者用十进制。

参考资料

这一课是怎么做的: 在 Claude 的帮助下,根据上面的资料调研和撰写,并于 2026 年 10 月 15 日用第二个来源核对。各种格式下循环累加 0.1 的结果是在 JavaScript(Node 24)里实测的,并有测试检查;没有单独链接的数字是根据资料计算的。插图是原创的。