在 Python 中使用浮点数时,一定要小心。有一天,我发现浮点数出现了意外的行为。
我运行了一个简单的计算来查看问题所在,结果让我大吃一惊——计算结果竟然是错误的。
我运行了以下代码:
print(0.1 + 0.2)
我本以为结果会是 0.3,但结果却让我震惊,我得到了一个错误的答案。
我开始思考这个多余的 4 是怎么来的。我以为这是一个 Bug,但第二天我又运行了相同的程序,发现它并不是。
为什么会这样?
问题的根源在于计算机存储浮点数的方式。
我们知道,计算机通常使用二进制(基数为 2),但有些十进制分数无法在二进制中完美表示。
例如,0.1 在二进制中会变成一个无限循环的小数,就像这样:
0.1 在二进制中 ≈ 0.00011001100110011001100110011...
因此,Python 只能存储有限的位数。有时,这会导致微小的舍入误差,而这些误差会累积起来,导致错误的结果。
我知道,这只是一个精度问题,但请记住,它可能会破坏我们的代码。
如果我们像这样检查数字的等式:
我们的程序会给出错误的输出。
从理论上来看,Python使用的是IEEE 754 标准,同样采用该标准的还是JavaScript、C、C++、Java,所以这其实是比较普遍的一个问题。
IEEE 754 标准 是一种广泛采用的浮点数算术标准,它定义了浮点数的表示方式、舍入规则、异常处理等内容。这个标准最早由 IEEE(Institute of Electrical and Electronics Engineers,电气与电子工程师学会)于 1985 年发布,至今在计算机科学和工程领域仍然是浮点数运算的基准。
这个标准定义了两个主要的浮点数表示格式:
- 单精度浮点数(32 位)
- 双精度浮点数(64 位)
此外,还有扩展精度(如 80 位)和更高精度的支持。
单精度浮点数使用 32 位来表示一个浮点数,其中包括三部分:
- 符号位(Sign bit):1 位,用于表示数值的正负。
- 指数(Exponent):8 位,表示浮点数的指数部分。它使用偏移量表示法(bias),即将指数值加上一个常数值(在单精度中,偏移量是 127)。
- 尾数(Mantissa 或 Fraction):23 位,表示浮点数的有效数字部分(小数部分),它以二进制形式存储。
单精度浮点数的表示公式为:
(-1)^S * (1 + Fraction) * 2^(Exponent - Bias)
其中:
- S 是符号位。
- Fraction 是尾数部分(加上隐含的 1)。
- Exponent 是指数部分。
- Bias 是偏移量,对于单精度来说是 127。
而双精度的话则拓展了指数和尾数的位数,分别是11位和52位。
IEEE 754 标准定义了几种舍入模式,通常用于浮点数计算中。当计算结果的精度超出了表示范围时,必须进行舍入。常见的舍入模式包括:
-
- 向零舍入(Round to Zero):舍去超出表示范围的部分,接近零的方向舍入。
-
- 向最近偶数舍入(Round to Nearest, ties to Even):将结果舍入到最接近的可表示值。如果有两个同样接近的值,则舍入到偶数。
-
- 向正无穷舍入(Round to Positive Infinity):舍去超出部分,并且始终舍入到正方向。
-
- 向负无穷舍入(Round to Negative Infinity):舍去超出部分,并且始终舍入到负方向
IEEE 754 标准定义了几种舍入模式,通常用于浮点数计算中。当计算结果的精度超出了表示范围时,必须进行舍入。常见的舍入模式包括:
-
- 向零舍入(Round to Zero):舍去超出表示范围的部分,接近零的方向舍入。
-
- 向最近偶数舍入(Round to Nearest, ties to Even):将结果舍入到最接近的可表示值。如果有两个同样接近的值,则舍入到偶数。
-
- 向正无穷舍入(Round to Positive Infinity):舍去超出部分,并且始终舍入到正方向。
-
- 向负无穷舍入(Round to Negative Infinity):舍去超出部分,并且始终舍入到负方向
除了 IEEE 754 标准,浮点数的表示和计算还有其他一些标准和方案。不过,IEEE 754 是目前最广泛使用的标准。如:
- IBM Floating Point Format (IBM FP):IBM 的浮点数使用的是类似于 IEEE 754 的格式,但有一些不同的细节。IBM 的浮点数格式使用了不同的尾数(mantissa)和指数(exponent)的编码方式。
- VAX Floating Point Format:VAX 浮点格式与 IEEE 754 不同,特别是在精度和指数的表示上有很多变化。VAX 标准有 4 种浮点格式。
- Floating Point Standard in CUDA:NVIDIA 的 CUDA 架构对浮点数的支持有特定的要求。虽然 CUDA 中的浮点数标准遵循 IEEE 754,但它们在 GPU 上的实现和优化上有所不同。
如何解决这个问题?
让我们来了解一下解决方法。
1. 使用 decimal 模块进行高精度计算
Python 提供了 decimal 模块,它提供了比标准的浮点数更高精度的浮点数运算。decimal 使用 十进制表示,避免了浮点数表示的二进制精度问题,特别适合处理金融计算或需要高精度的小数运算。
from decimal import Decimal, getcontext
# 设置全局精度
getcontext().prec = 28
# 使用 Decimal 进行高精度计算
a = Decimal('0.1')
b = Decimal('0.2')
result = a + b
print(result) # 输出 0.3
Decimal 对象支持更精确的算术运算,舍入方式也可以灵活配置。另外通过 getcontext().prec 设置精度,可以避免浮点数的精度丢失。
2. 使用 fractions 模块进行分数表示
当需要精确表示某些特定的数值(比如1/3)时,可以使用 fractions 模块,它通过分数的方式来表示数字,避免了浮点数的精度问题。
from fractions import Fraction
# 使用 Fraction 表示分数
a = Fraction(1, 3)
b = Fraction(2, 3)
result = a + b
print(result) # 输出 1
Fraction 可以精确地表示分数,避免了浮点数计算中的舍入误差。它更适合处理小数无法精确表示的情况,特别是在进行数学计算时。
3. 使用 math.isclose 进行浮点数比较
由于浮点数精度问题,直接比较两个浮点数是否相等可能会导致错误。math.isclose 方法提供了一个内置的方式来比较两个浮点数是否相等,它使用一个容差值来判断两个数是否足够接近。
import math
a = 0.1 + 0.2
b = 0.3
# 使用 math.isclose 来判断两个浮点数是否相等
print(math.isclose(a, b, rel_tol=1e-9)) # 输出 True
math.isclose 支持相对误差和绝对误差控制,可以根据需要调整容差值(rel_tol 和 abs_tol 参数)。
4. 避免浮点数直接加法操作
在浮点数计算中,特别是加法和减法操作,顺序和计算方式会影响精度。通过适当调整运算顺序或对中间结果进行汇总来减少误差。例如,对于很多浮点数的加法运算,可以先对较大的数值进行加法,再加上较小的数值,这样可以避免较小数值的精度丢失。
a = 1.0e16
b = 1.0
c = a + b # 这里 b 的影响可能被忽略
d = b + a # 通过调整加法顺序,b 的影响就不会被忽略
5. 使用 numpy 提供的高精度数组运算
在科学计算中,numpy 提供了高效的数组操作,它也支持使用高精度浮点数(如 float128)。如果你的计算涉及大量的浮点数,使用 numpy 可以加速运算并减少精度误差。
import numpy as np
# 使用 numpy 提供的高精度浮点数类型
a = np.float128(0.1)
b = np.float128(0.2)
result = a + b
print(result) # 输出 0.3
numpy 提供了 float128 类型,它可以提供更高的精度,适合于需要高精度的数值计算。不过需要注意,numpy 对于较高精度的支持可能依赖于平台和硬件,某些平台可能无法完全支持 float128。
根据具体的应用场景,选择适当的策略可以有效减少浮点数计算中的精度问题。