一道數值閘門亮紅燈,答案在 repo 之外:一場關於 Python 執行環境與浮點精度的排查紀錄
在量化交易與回測系統中,數字的可重現性(Reproducibility)是系統信任度的基石。同一份程式碼、同一份資料,算出來的數字絕對不能自己漂移。
這是一篇記錄一次數值閘門亮紅燈的排查過程。原本以為是程式碼邏輯或容差設定的問題,最後卻發現問題完全隱藏在 repository 之外。
背景
在回測專案裡有一道「oracle 閘門」:把一組統計輸出凍結成數值基準,每次執行測試時就拿當前輸出與基準進行比對,只要差異超過容差(tolerance)就會判定為 FAIL。這道閘門存在的全部理由,就是確保回測具備嚴格的可重現性。
在 2026-07-29 凍結的那組基準中,驗收報告上記載的最大差異是 2.27e-13,遠在設定的容差範圍以內。
問題
某天執行閘門測試時,Stage D 突然亮起紅燈,回報數值差異達到了 4.10e-9。
當時進度看板將這起事件登記為「容差設定自相矛盾」,並提出了三個處置選項:
- 放寬容差
- 修改 docstring
- 重凍 oracle
這三個選項都有一個共同的隱含預設:這個數值差異來自 repository 內部。
其中「放寬容差」這個選項尤其危險。4.096e-9 恰好落在 1e-9 與 1e-8 之間,一旦放寬容差,閘門會立刻變回綠燈,但同時也會永久關閉系統偵測這類數值漂移的能力。
幸運的是,看板上留下了另一句提醒,成為了唯一指向正確方向的線索:
動手前先確認:舊記載是「統計層最大差 2.27e-13」,現在是 4.1e-9,差了四個數量級。
四個數量級的落差,絕非調整容差就能合理解釋的現象。
深度排查(Root Cause)
1. 三個 Stage 全數漂移
進一步逐欄拆解資料後發現,並非只有 Stage D 出現問題,而是三個 stage 全部發生了漂移:
| Stage | 報告記載 | 重現時 |
|---|---|---|
| C | 2.27e-13 | 7.32e-11 |
| D | 2.27e-13 | 4.10e-9(FAIL) |
| E | 5.68e-14 | 4.11e-11 |
令人注意到的是,發生漂移的欄位僅限於 ci_lower、ci_upper、ci_paired_* 以及 mde 這幾個信賴區間欄位——這恰好是統計庫裡唯二呼叫 stats.t.ppf 的地方。其餘欄位如 EV、std、N、win_rate、p_paired 的差異全部維持在 ≤1e-13。
這裡還有個關鍵細節:Stage C 的 std 欄位最大差正好是 2.273737e-13,完全對應報告上記載的數字。這意味著在當初凍結基準時,整個統計層的最大誤差來自 std 欄位,而 CI 欄位的誤差比它更小(也就是說當時 CI 是極為精準的)。漂移是後來才出現的。
2. 被否證的兩個假設
排查過程中,我首先驗證了兩個假設,但都被證偽:
假設一:SciPy 在這段期間被升級了? 檢查安裝時間:
scipy-1.16.2.dist-info 10月 21 20:28:14 2025
oracle 是七月底才凍結的,而 SciPy 早在九個月前就已安裝且從未變動。同一個套件版本不可能在同一台機器上對完全相同的輸入給出兩個不同答案。
假設二:程式碼呼叫了不同的 API 路徑?
懷疑是否某些腳本呼叫了精確度更高的 API,而統計庫走了較不精準的 stats.t.ppf。實測了八種不同寫法:
stats.t.ppf(0.975, df=111) = 1.9815667570310707 err=4.3830e-11
stats.t.ppf(0.975, 111) = 1.9815667570310707 err=4.3830e-11
stats.t.ppf(0.975, df=111.0) = 1.9815667570310707 err=4.3830e-11
stats.t.isf(0.025, 111) = 1.981566757031071 err=4.3830e-11
stats.t(111).ppf(0.975) = 1.9815667570310707 err=4.3830e-11
stats.t.interval(0.95, 111)[1] = 1.9815667570310707 err=4.3830e-11
special.stdtrit(111, 0.975) = 1.9815667570310707 err=4.3830e-11
special.stdtrit(111.0, 0.975) = 1.9815667570310707 err=4.3830e-11
八種寫法的結果完全相同。然而這個測試失敗直接指明了方向:既然在同一個 Python 直譯器內怎麼呼叫都一樣,那麼變因只可能存在於直譯器本身。此時,輸出的最後一行印出了 .venv。
3. 先確定真值,再問誰對誰錯
在釐清哪個環境才是正確的之前,我先做了一件決定性的事:確定真值。使用 mpmath 計算至高精度的 40 位小數:
mpmath high-precision t(0.975, df=111) = 1.9815667570749010056
vs oracle diff: 5.58116e-18
vs current diff: 4.38303e-11
比對結果顯示:凍結在 oracle 中的數值才是對的,當前環境算出來的數字反而存在較大誤差。
這個結論與一般的直覺相反(通常人們會假設「最新執行的環境比較新、比較準」)。這也證實了當初如果選擇「重凍 oracle」,實際上會把較不精準的數字凍結成新的基準真值。
4. 最終答案:環境不一致
最後釐清兩個 Python 環境的差異:
/Volumes/.../.venv/bin/python3 py 3.13.3 scipy 1.17.0 err=2.2204e-16
/Library/Frameworks/.../python3 py 3.13.x scipy 1.16.2 err=4.3830e-11
兩個 Python 環境安裝了不同版本的 SciPy,導致 stats.t.ppf 的計算精度相差了一個數量級。當初凍結 oracle 時使用的是 .venv,而後來執行閘門時使用的是系統的裸 python3。
切換回 .venv 重新執行閘門:exit 0,三個 stage 的最大差異與驗收報告上記載的數值逐位相同。
解決方案與實作
真正的根本原因並非「以後記得一律用 .venv」——那只是治標。
問題的本質在於:
requirements.txt沒有固定(pin)SciPy 與 NumPy 的版本。- README 中依然指引使用者執行裸
python3命令。
這導致系統實際使用哪個環境完全取決於當下 shell 的環境變數與狀態,而這個狀態沒有被記錄在任何設定檔中。這種精度差異雖然在量級上對最終回測結論影響為零,但會導致位元級的可重現性(bit-for-bit reproducibility)失敗——而這正是數值閘門存在的全部理由。
根本解決辦法包含:
- 在
requirements.txt中明確 pin 定 SciPy 及相關數值計算庫的具體版本。 - 更新 README 文件的執行指引,確保所有測試與閘門統一在明確指定的虛擬環境(
.venv)中執行,避免依賴未記錄的環境狀態。
結論與關鍵反思
這次排查過程帶來了幾個超越問題本身的深刻反思:
1. 平反一樁舊公案
在驗收報告 §8 記載著一段歷史:之前在實作階段曾提報過 4.096e-9 這個差異數字,當時兩次外部審查據此判為 FAIL。然而,當時的規劃階段將其判定為「無法重現」,推翻了那兩次 FAIL,並寫下了這段分析:
4.096e-09 相對於量級 588 約為 36,000 ULP,遠超過浮點噪音的可能範圍——這反而證明當時確實存在一個真實的演算法差異,而該差異已在自我修正中被消除。
這段分析讀起來嚴謹、自信且有數字支撐。
但事實上,兩邊都沒有算錯,程式碼層面也沒有演算法差異。實作階段與審查階段當時使用的是裸 python3,而規劃階段使用的是 .venv——他們看到的都是各自環境下的真實結果。當時被認定為「只讀報告文字、沒有真的重跑程式碼」的那兩次審查,很可能是真的認真重跑了。
一段看起來無懈可擊的 ULP 論證,推翻的其實是兩個做對了事的人。
2. 一樣的自信,不同的處理方式
上一輪調查遇到 4.096e-9 時,結論落在「我這邊重現不到」並用 ULP 分析包裝。那個推論致命的地方不在於粗心,而是從「我重現不到」推導至「對方錯了」,中間跨過了一個未經驗證的前提:我的執行環境與對方完全一致。
這次面對紅燈,同樣面臨先判定誰對誰錯的衝動。不同之處在於,這次我選擇先用 mpmath 確定高精度真值。先問「到底哪個值才是對的」,而不是先問「誰錯了」。同一種「我這邊是對的」的自信,上一輪是過,這次靠先定真值繞過了。
3. 預測值當裁判的陷阱
在專案協作中,規劃階段預先計算一組預測值附在計畫中,供實作與審查階段作為比對基準。當實作階段算錯時,預測值是很好的錨點。
但當算錯的是規劃階段自己時,這組預測值就變成了「錯的錨」。當實作階段算出正確答案卻與計畫對不上時,所有人會開始反覆自我懷疑與驗證,消耗大量時間與資源,卻無法確定結果是否正確,因為被當作真值的來源本身就是錯的。
這套機制在「規劃階段正確」時是加速器,在「規劃階段錯誤」時是盲區,且機制本身無法區分當下屬於哪一種情況。這個缺陷我還沒想到怎麼從結構上解。
4. 閘門無法自證其執行前提
閘門運作的核心前提是「同一份程式碼會給出同一個答案」。當這個前提被執行環境暗中替換時,閘門本身無法感知——它只能回報「數字不一致」。
這也是為什麼「放寬容差」是最危險的選項:它並未解決問題,而是把偵測問題的能力關閉。一旦關閉,當未來執行環境再次發生漂移時,系統將連紅燈都無法亮起。