15. 浮點算術:爭議和限制?

Floating-point numbers are represented in computer hardware as base 2 (binary) fractions. For example, the decimal fraction 0.125 has value 1/10 + 2/100 + 5/1000, and in the same way the binary fraction 0.001 has value 0/2 + 0/4 + 1/8. These two fractions have identical values, the only real difference being that the first is written in base 10 fractional notation, and the second in base 2.

不幸的是,大多數(shù)的十進制小數(shù)都不能精確地表示為二進制小數(shù)。這導致在大多數(shù)情況下,你輸入的十進制浮點數(shù)都只能近似地以二進制浮點數(shù)形式儲存在計算機中。

用十進制來理解這個問題顯得更加容易一些??紤]分數(shù) 1/3 。我們可以得到它在十進制下的一個近似值

0.3

或者,更近似的,:

0.33

或者,更近似的,:

0.333

以此類推。結果是無論你寫下多少的數(shù)字,它都永遠不會等于 1/3 ,只是更加更加地接近 1/3 。

同樣的道理,無論你使用多少位以 2 為基數(shù)的數(shù)碼,十進制的 0.1 都無法精確地表示為一個以 2 為基數(shù)的小數(shù)。 在以 2 為基數(shù)的情況下, 1/10 是一個無限循環(huán)小數(shù)

0.0001100110011001100110011001100110011001100110011...

在任何一個位置停下,你都只能得到一個近似值。因此,在今天的大部分架構上,浮點數(shù)都只能近似地使用二進制小數(shù)表示,對應分數(shù)的分子使用每 8 字節(jié)的前 53 位表示,分母則表示為 2 的冪次。在 1/10 這個例子中,相應的二進制分數(shù)是 3602879701896397 / 2 ** 55 ,它很接近 1/10 ,但并不是 1/10 。

大部分用戶都不會意識到這個差異的存在,因為 Python 只會打印計算機中存儲的二進制值的十進制近似值。在大部分計算機中,如果 Python 想把 0.1 的二進制對應的精確十進制打印出來,將會變成這樣

>>>
>>> 0.1
0.1000000000000000055511151231257827021181583404541015625

這比大多數(shù)人認為有用的數(shù)字更多,因此Python通過顯示舍入值來保持可管理的位數(shù)

>>>
>>> 1 / 10
0.1

牢記,即使輸出的結果看起來好像就是 1/10 的精確值,實際儲存的值只是最接近 1/10 的計算機可表示的二進制分數(shù)。

有趣的是,有許多不同的十進制數(shù)共享相同的最接近的近似二進制小數(shù)。例如, 0.1 、 0.100000000000000010.1000000000000000055511151231257827021181583404541015625 全都近似于 3602879701896397 / 2 ** 55 。由于所有這些十進制值都具有相同的近似值,因此可以顯示其中任何一個,同時仍然保留不變的 eval(repr(x)) == x

在歷史上,Python 提示符和內(nèi)置的 repr() 函數(shù)會選擇具有 17 位有效數(shù)字的來顯示,即 0.10000000000000001。 從 Python 3.1 開始,Python(在大多數(shù)系統(tǒng)上)現(xiàn)在能夠選擇這些表示中最短的并簡單地顯示 0.1 。

請注意這種情況是二進制浮點數(shù)的本質(zhì)特性:它不是 Python 的錯誤,也不是你代碼中的錯誤。 你會在所有支持你的硬件中的浮點運算的語言中發(fā)現(xiàn)同樣的情況(雖然某些語言在默認狀態(tài)或所有輸出模塊下都不會 顯示 這種差異)。

想要更美觀的輸出,你可能會希望使用字符串格式化來產(chǎn)生限定長度的有效位數(shù):

>>>
>>> format(math.pi, '.12g')  # give 12 significant digits
'3.14159265359'

>>> format(math.pi, '.2f')   # give 2 digits after the point
'3.14'

>>> repr(math.pi)
'3.141592653589793'

必須重點了解的是,這在實際上只是一個假象:你只是將真正的機器碼值進行了舍入操作再 顯示 而已。

一個假象還可能導致另一個假象。 例如,由于這個 0.1 并非真正的 1/10,將三個 0.1 的值相加也不一定能恰好得到 0.3:

>>>
>>> .1 + .1 + .1 == .3
False

而且,由于這個 0.1 無法精確表示 1/10 的值而這個 0.3 也無法精確表示 3/10 的值,使用 round() 函數(shù)進行預先舍入也是沒用的:

>>>
>>> round(.1, 1) + round(.1, 1) + round(.1, 1) == round(.3, 1)
False

雖然這些小數(shù)無法精確表示其所要代表的實際值,round() 函數(shù)還是可以用來“事后舍入”,使得實際的結果值可以做相互比較:

>>>
>>> round(.1 + .1 + .1, 10) == round(.3, 10)
True

Binary floating-point arithmetic holds many surprises like this. The problem with "0.1" is explained in precise detail below, in the "Representation Error" section. See The Perils of Floating Point for a more complete account of other common surprises.

正如那篇文章的結尾所言,“對此問題并無簡單的答案。” 但是也不必過于擔心浮點數(shù)的問題! Python 浮點運算中的錯誤是從浮點運算硬件繼承而來,而在大多數(shù)機器上每次浮點運算得到的 2**53 數(shù)碼位都會被作為 1 個整體來處理。 這對大多數(shù)任務來說都已足夠,但你確實需要記住它并非十進制算術,且每次浮點運算都可能會導致新的舍入錯誤。

雖然病態(tài)的情況確實存在,但對于大多數(shù)正常的浮點運算使用來說,你只需簡單地將最終顯示的結果舍入為你期望的十進制數(shù)值即可得到你期望的結果。 str() 通常已足夠,對于更精度的控制可參看 格式字符串語法str.format() 方法的格式描述符。

對于需要精確十進制表示的使用場景,請嘗試使用 decimal 模塊,該模塊實現(xiàn)了適合會計應用和高精度應用的十進制運算。

另一種形式的精確運算由 fractions 模塊提供支持,該模塊實現(xiàn)了基于有理數(shù)的算術運算(因此可以精確表示像 1/3 這樣的數(shù)值)。

如果你是浮點運算的重度用戶則你應當了解一下 NumPy 包以及由 SciPy 項目所提供的許多其他數(shù)字和統(tǒng)計運算包。 參見 <https://scipy.org>。

Python 也提供了一些工具,可以在你真的 想要 知道一個浮點數(shù)精確值的少數(shù)情況下提供幫助。 例如 float.as_integer_ratio() 方法會將浮點數(shù)表示為一個分數(shù):

>>>
>>> x = 3.14159
>>> x.as_integer_ratio()
(3537115888337719, 1125899906842624)

由于這是一個精確的比值,它可以被用來無損地重建原始值:

>>>
>>> x == 3537115888337719 / 1125899906842624
True

float.hex() 方法會以十六進制(以 16 為基數(shù))來表示浮點數(shù),同樣能給出保存在你的計算機中的精確值:

>>>
>>> x.hex()
'0x1.921f9f01b866ep+1'

這種精確的十六進制表示法可被用來精確地重建浮點值:

>>>
>>> x == float.fromhex('0x1.921f9f01b866ep+1')
True

由于這種表示法是精確的,它適用于跨越不同版本(平臺無關)的 Python 移植數(shù)值,以及與支持相同格式的其他語言(例如 Java 和 C99)交換數(shù)據(jù).

另一個有用的工具是 math.fsum() 函數(shù),它有助于減少求和過程中的精度損失。 它會在數(shù)值被添加到總計值的時候跟蹤“丟失的位”。 這可以很好地保持總計值的精確度, 使得錯誤不會積累到能影響結果總數(shù)的程度:

>>>
>>> sum([0.1] * 10) == 1.0
False
>>> math.fsum([0.1] * 10) == 1.0
True

15.1. 表示性錯誤?

本小節(jié)將詳細解釋 "0.1" 的例子,并說明你可以怎樣親自對此類情況進行精確分析。 假定前提是已基本熟悉二進制浮點表示法。

表示性錯誤 是指某些(其實是大多數(shù))十進制小數(shù)無法以二進制(以 2 為基數(shù)的計數(shù)制)精確表示這一事實造成的錯誤。 這就是為什么 Python(或者 Perl、C、C++、Java、Fortran 以及許多其他語言)經(jīng)常不會顯示你所期待的精確十進制數(shù)值的主要原因。

為什么會這樣? 1/10 是無法用二進制小數(shù)精確表示的。 目前(2000年11月)幾乎所有使用 IEEE-754 浮點運算標準的機器以及幾乎所有系統(tǒng)平臺都會將 Python 浮點數(shù)映射為 IEEE-754 “雙精度類型”。 754 雙精度類型包含 53 位精度,因此在輸入時,計算會盡量將 0.1 轉換為以 J/2**N 形式所能表示的最接近分數(shù),其中 J 為恰好包含 53 個二進制位的整數(shù)。 重新將

1 / 10 ~= J / (2**N)

寫為

J ~= 2**N / 10

并且由于 J 恰好有 53 位 (即 >= 2**52< 2**53),N 的最佳值為 56:

>>>
>>> 2**52 <=  2**56 // 10  < 2**53
True

也就是說,56 是唯一的 N 值能令 J 恰好有 53 位。 這樣 J 的最佳可能值就是經(jīng)過舍入的商:

>>>
>>> q, r = divmod(2**56, 10)
>>> r
6

由于余數(shù)超過 10 的一半,最佳近似值可通過四舍五入獲得:

>>>
>>> q+1
7205759403792794

這樣在 754 雙精度下 1/10 的最佳近似值為:

7205759403792794 / 2 ** 56

分子和分母都除以二則結果小數(shù)為:

3602879701896397 / 2 ** 55

請注意由于我們做了向上舍入,這個結果實際上略大于 1/10;如果我們沒有向上舍入,則商將會略小于 1/10。 但無論如何它都不會是 精確的 1/10!

因此計算永遠不會“看到”1/10:它實際看到的就是上面所給出的小數(shù),它所能達到的最佳 754 雙精度近似值:

>>>
>>> 0.1 * 2 ** 55
3602879701896397.0

如果我們將該小數(shù)乘以 10**55,我們可以看到該值輸出為 55 位的十進制數(shù):

>>>
>>> 3602879701896397 * 10 ** 55 // 2 ** 55
1000000000000000055511151231257827021181583404541015625

這意味著存儲在計算機中的確切數(shù)值等于十進制數(shù)值 0.1000000000000000055511151231257827021181583404541015625。 許多語言(包括較舊版本的 Python)都不會顯示這個完整的十進制數(shù)值,而是將結果舍入為 17 位有效數(shù)字:

>>>
>>> format(0.1, '.17f')
'0.10000000000000001'

fractionsdecimal 模塊可令進行此類計算更加容易:

>>>
>>> from decimal import Decimal
>>> from fractions import Fraction

>>> Fraction.from_float(0.1)
Fraction(3602879701896397, 36028797018963968)

>>> (0.1).as_integer_ratio()
(3602879701896397, 36028797018963968)

>>> Decimal.from_float(0.1)
Decimal('0.1000000000000000055511151231257827021181583404541015625')

>>> format(Decimal.from_float(0.1), '.17')
'0.10000000000000001'