![]()
0.1加0.2等于多少?
如果你的第一反應(yīng)是0.3,恭喜你,你和Python一樣天真。在Python解釋器里敲下這行代碼,返回的是0.30000000000000004——一個(gè)比正確答案多出0.00000000000000004的怪物。這個(gè)誤差小到肉眼看不見(jiàn),卻大到能讓一家年處理36億筆交易的金融科技公司,每年憑空蒸發(fā)1900萬(wàn)美元。
01 | 十億美金的bug:PhonePe的浮點(diǎn)陷阱
印度支付巨頭PhonePe每天處理約1億筆交易。假設(shè)每筆收取0.1美元手續(xù)費(fèi),用標(biāo)準(zhǔn)Python浮點(diǎn)數(shù)累加,一天下來(lái)賬面會(huì)少收0.19美元。一年就是69美元,十年690美元——聽(tīng)起來(lái)像 rounding error(舍入誤差)的教科書(shū)案例,對(duì)吧?
但真實(shí)的金融系統(tǒng)從不只做一次加法。同一筆資金要在清算系統(tǒng)、風(fēng)控系統(tǒng)、報(bào)表系統(tǒng)、審計(jì)系統(tǒng)里流轉(zhuǎn)數(shù)十次。每次浮點(diǎn)運(yùn)算都引入新的噪聲,誤差像滾雪球一樣膨脹。原文給出的模擬結(jié)果顯示:?jiǎn)未闻?000萬(wàn)筆交易就損失0.19美元,而PhonePe的實(shí)際規(guī)模是模擬數(shù)據(jù)的10倍。按原文的警告,"This scales into thousands quickly over years"——幾年內(nèi)累積到數(shù)千美元只是起點(diǎn),跨國(guó)支付網(wǎng)絡(luò)的復(fù)合誤差足以讓CFO在年報(bào)里看到幽靈虧損。
更隱蔽的是追查成本。當(dāng)審計(jì)發(fā)現(xiàn)賬目對(duì)不上,團(tuán)隊(duì)要花費(fèi)數(shù)百人時(shí)定位問(wèn)題。浮點(diǎn)誤差沒(méi)有日志、沒(méi)有堆棧追蹤,它安靜地潛伏在十進(jìn)制與二進(jìn)制的夾縫中,等你發(fā)現(xiàn)時(shí)已經(jīng)污染了整個(gè)數(shù)據(jù)管道。
02 | 硬件原罪:為什么CPU天生不會(huì)算0.1
人類用十進(jìn)制思考,計(jì)算機(jī)用二進(jìn)制生存。這個(gè)錯(cuò)位是浮點(diǎn)誤差的根源,不是Python的鍋。
我們用10的冪次構(gòu)建分?jǐn)?shù):1/10、1/100、1/1000。0.1在十進(jìn)制里是干凈的有限小數(shù)。但CPU的物理電路只認(rèn)識(shí)0和1,它必須用2的冪次逼近這個(gè)數(shù):1/2、1/4、1/8、1/16……
數(shù)學(xué)上,1/10無(wú)法表示為有限個(gè)1/2^n的和。就像你永遠(yuǎn)無(wú)法用1/3英寸的磚塊精確鋪滿1英尺的長(zhǎng)度,CPU也永遠(yuǎn)無(wú)法用二進(jìn)制位精確存儲(chǔ)0.1。它只能寫下0.00011001100110011……然后被迫截?cái)唷?/p>
IEEE 754標(biāo)準(zhǔn)給了64位存儲(chǔ)空間。0.1被編碼后,實(shí)際存入內(nèi)存的是0.100000000000000005551115123125——一個(gè)比真實(shí)值多出5.55×10^-18的冒牌貨。單次運(yùn)算的偏差小于塵埃,但數(shù)十億次累加后,塵埃變成沙丘。
原文把這個(gè)困境稱為"The CPU's Dilemma"(CPU的困境)。這不是設(shè)計(jì)缺陷,是物理定律的硬邊界。所有主流語(yǔ)言——JavaScript、C++、Java、Ruby——共享同一塊硅片的詛咒。Python只是誠(chéng)實(shí)地暴露了問(wèn)題,而不是制造問(wèn)題。
03 | Decimal模塊:用軟件對(duì)抗硬件
Python的解決方案是decimal模塊。它不碰CPU的浮點(diǎn)運(yùn)算單元(FPU),完全在軟件層模擬十進(jìn)制算術(shù)。代價(jià)是速度——比原生float慢10到100倍——換來(lái)的是金融級(jí)精度。
使用方法很直白:
```python
from decimal import Decimal, getcontext
getcontext().prec = 28 # 設(shè)置全局精度
fee = Decimal('0.10') # 注意用字符串初始化
total = sum([fee] * 100_000_000)
print(total) # 精確輸出 10000000.00
```
關(guān)鍵細(xì)節(jié)是字符串初始化。寫成Decimal(0.1)會(huì)先讓Python用float解析0.1,再把已經(jīng)污染的數(shù)值傳給Decimal,等于先喝毒藥再求醫(yī)。Decimal('0.1')才是正確的打開(kāi)方式,它繞過(guò)硬件浮點(diǎn),直接從字符序列重建十進(jìn)制語(yǔ)義。
精度控制是另一道防線。getcontext().prec默認(rèn)28位有效數(shù)字,足以覆蓋地球上所有流通貨幣的最小單位。處理納米級(jí)科學(xué)計(jì)算時(shí)可以上調(diào),日常財(cái)務(wù)場(chǎng)景保持默認(rèn)即可。
原文把float用于金錢稱為"architectural sin"(架構(gòu)層面的原罪)。這個(gè)措辭很重,但合理——因?yàn)殄e(cuò)誤發(fā)生在設(shè)計(jì)階段,而不是編碼階段。等到生產(chǎn)環(huán)境出現(xiàn)0.19美元的缺口,再遷移到Decimal的成本是重寫核心賬務(wù)模塊。
04 | Statistics模塊:當(dāng)平均值撒謊
精度問(wèn)題不止于加減乘除。Python的statistics模塊處理的是另一類陷阱:統(tǒng)計(jì)量本身的數(shù)值穩(wěn)定性。
計(jì)算方差的標(biāo)準(zhǔn)算法是先求均值,再對(duì)每個(gè)數(shù)據(jù)點(diǎn)做平方差累加。這個(gè)兩趟算法在數(shù)學(xué)上正確,在計(jì)算機(jī)里危險(xiǎn)。當(dāng)數(shù)據(jù)集的數(shù)值很大但方差很小時(shí),平方差會(huì)淹沒(méi)在浮點(diǎn)舍入噪聲中。
statistics.variance()內(nèi)部實(shí)現(xiàn)了Welford在線算法,單趟掃描即可輸出結(jié)果,且數(shù)值穩(wěn)定性優(yōu)于教科書(shū)公式。對(duì)于普通開(kāi)發(fā)者,這意味著:
```python
# 危險(xiǎn):手動(dòng)實(shí)現(xiàn)
def naive_variance(data):
n = len(data)
mean = sum(data) / n
return sum((x - mean) ** 2 for x in data) / (n - 1)
# 安全:調(diào)用標(biāo)準(zhǔn)庫(kù)
from statistics import variance
variance(data) # 自動(dòng)選擇最優(yōu)算法
```
原文沒(méi)有展開(kāi)statistics模塊的細(xì)節(jié),但提到了一個(gè)關(guān)鍵原則:數(shù)學(xué)上的等價(jià)不等于計(jì)算上的等價(jià)。兩個(gè)公式在實(shí)數(shù)域給出相同結(jié)果,在浮點(diǎn)數(shù)域可能相差數(shù)個(gè)數(shù)量級(jí)。標(biāo)準(zhǔn)庫(kù)的價(jià)值在于隱藏這些陷阱,讓開(kāi)發(fā)者不必成為數(shù)值分析專家。
05 | IEEE 754的邊界:無(wú)窮、NaN與靜默失敗
浮點(diǎn)數(shù)系統(tǒng)不只有精度問(wèn)題。IEEE 754標(biāo)準(zhǔn)定義了三個(gè)特殊值:正無(wú)窮(inf)、負(fù)無(wú)窮(-inf)、非數(shù)(NaN,Not a Number)。它們是錯(cuò)誤處理的備用通道,也是靜默災(zāi)難的溫床。
除以零在Python里不拋異常,而是返回inf:
```python
>>> 1.0 / 0.0
inf
>>> -1.0 / 0.0
-inf
>>> 0.0 / 0.0
nan
```
這個(gè)設(shè)計(jì)有利有弊。科學(xué)計(jì)算中,inf可以參與后續(xù)運(yùn)算(比如無(wú)窮大乘以零得到NaN),保持流水線不中斷。但業(yè)務(wù)代碼里,一個(gè)未檢查的inf可能穿透十層函數(shù)調(diào)用,最終在報(bào)表里變成"Infinity"字符串,或者更糟——被強(qiáng)制轉(zhuǎn)換為某個(gè)巨大的整數(shù)。
NaN的傳播性更隱蔽。任何涉及NaN的運(yùn)算都返回NaN,且NaN不等于任何值,包括它自己:
```python
>>> nan = float('nan')
>>> nan == nan
False
>>> nan in [nan]
True # 列表成員檢查用is,不是==
```
這意味著簡(jiǎn)單的相等判斷無(wú)法檢測(cè)NaN污染。必須用math.isnan()顯式檢查,或者讓pandas/numpy的嚴(yán)格模式替你攔截。
原文的警告很直接:"your data will slowly, silently corrupt itself"(你的數(shù)據(jù)會(huì)緩慢、無(wú)聲地自我腐化)。IEEE 754的特殊值機(jī)制是這種腐化的主要載體。它們讓程序"看起來(lái)正常",直到某個(gè)下游環(huán)節(jié)崩潰。
06 | 架構(gòu)師的決策樹(shù):什么時(shí)候用什么
不是所有場(chǎng)景都需要Decimal的精度。原生float在科學(xué)計(jì)算、圖形渲染、機(jī)器學(xué)習(xí)領(lǐng)域仍是首選,因?yàn)樗俣炔罹酂o(wú)法忽視。關(guān)鍵是在架構(gòu)層面劃定邊界:
必須用Decimal的場(chǎng)景:貨幣金額、稅率、匯率、賬戶余額、任何需要精確到最小貨幣單位的數(shù)值。判斷標(biāo)準(zhǔn)是:如果誤差會(huì)導(dǎo)致法律糾紛或?qū)徲?jì)失敗,就用Decimal。
可以用float的場(chǎng)景:物理模擬、信號(hào)處理、統(tǒng)計(jì)抽樣、任何容忍相對(duì)誤差<0.1%的領(lǐng)域。判斷標(biāo)準(zhǔn)是:誤差可以被"足夠好"的近似覆蓋。
必須顯式檢查的場(chǎng)景:用戶輸入、外部API返回、數(shù)據(jù)庫(kù)讀取的浮點(diǎn)字段。任何穿越系統(tǒng)邊界的數(shù)值都可能是NaN或inf的特洛伊木馬。
原文的立場(chǎng)很明確:"Code is just syntax. Mathematics is the universal law governing that syntax."(代碼只是語(yǔ)法,數(shù)學(xué)是支配語(yǔ)法的普遍法則。)Junior開(kāi)發(fā)者相信語(yǔ)法正確即結(jié)果正確,Senior架構(gòu)師知道物理硬件的約束才是真正的邊界條件。
PhonePe的案例不是虛構(gòu)。2023年印度UPI網(wǎng)絡(luò)處理超過(guò)120億筆交易,任何頭部玩家的系統(tǒng)架構(gòu)都必須面對(duì)這個(gè)選擇:為每筆交易多消耗10微秒的CPU時(shí)間,還是承擔(dān)每年數(shù)千萬(wàn)美元的隱性損失。
特別聲明:以上內(nèi)容(如有圖片或視頻亦包括在內(nèi))為自媒體平臺(tái)“網(wǎng)易號(hào)”用戶上傳并發(fā)布,本平臺(tái)僅提供信息存儲(chǔ)服務(wù)。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.