為什麼一個數字本身不算證據
這一級前面的指南教你把計算「做對」。你學會倚靠經過測試的數值軟體堆疊而非自己重造輪子;學會用驗證 vs 確認把驗證(我把方程式解對了嗎?)和確認(我解的是對的方程式嗎?)分開;也學會用收斂研究與人造解法證明你程式碼的階數。本篇要加上讓上述一切真正算數的習慣:產生一個你「拿得回來」的結果。一個你六個月後無法重現的正確答案,對科學而言,根本不算答案。
可重現性,也就是計算可重現性這個標準,意思是:給定你的程式碼、你的輸入、以及你記錄下來的環境,別人能跑同一條流程、得到你所報告的同樣數字——理想上逐位元一致,至少在某個聲明的容差內一致。這聽來理所當然,卻出奇地容易弄丟。論文裡的一張圖依賴一支腳本,腳本依賴二十個特定版本的函式庫,函式庫依賴某個編譯器與某顆晶片,再加上一串「隨機」數字、以及幾條通往資料檔的路徑。把其中任何一個悄悄改掉,你的重現就會漂移——或乾脆崩潰。解方不靠英雄式的努力,而是三個樸素的習慣。
這三個習慣對應到標題。種子讓隨機的部分可重複。版本釘住軟體與硬體,讓確定性的部分以相同方式計算。來源軌跡記下從原始輸入到最終圖表的這條鏈,好讓整件事能被重播與稽核。把它們當成三人組記住:一個結果之所以可重現,正是因為這三者都被釘牢。任一鬆脫,你就是在賭運氣。
種子:釘住「隨機」
從讓初學者害怕的那部分開始:一次充滿隨機性的蒙地卡羅執行,怎麼可能可重現?因為那隨機性是假的。電腦的偽隨機數產生器是一道確定性的食譜:它持有一個內部狀態,每次呼叫就用固定的算術規則攪拌那個狀態,交給你一個「看起來」隨機的數字。整串序列在你選定起始狀態的那一瞬間就已決定,那個起始狀態就叫種子。把種子設成 42,你得到一串固定的數流;明天再設成 42,你得到一模一樣的數流、相同的順序,永遠如此。
# Same seed -> identical stream, every run, every machine (same generator)
seed = 42
rng = Generator(seed)
rng.random() -> 0.6394267984...
rng.random() -> 0.0250108004...
rng.random() -> 0.2750929132...
# No seed set -> library seeds from the clock / OS entropy:
# the stream changes every run, and the run is NOT reproducible.
# A linear congruential generator, the simplest PRNG, is literally:
# state_{n+1} = (a * state_n + c) mod m (deterministic arithmetic)所以規則很簡單:記下你用的種子,並在執行的最開頭明確設定它。經典的產生器讓這件事具體起來——一個線性同餘產生器就是 state_{n+1} = (a*state_n + c) mod m,而廣為使用的梅森旋轉器則保有更大的狀態、與長得多的週期。兩者都由其種子完全決定。一個微妙的陷阱:別只種一次、然後對許多本應彼此獨立的試驗重用「同一個」種子——那樣你會重播同一串相同的數流,用假的重複來騙自己。為整個實驗挑一個種子、記下它,然後讓產生器一路往前走。
版本:相同的程式碼,相同的答案?
你把一切都種好了種子,可是同事跑你的腳本,卻得到略為不同的數字。通常什麼都沒壞——底下的軟體變了。你的程式碼坐在一座高塔上:一個直譯器、數值函式庫、其下的 BLAS/LAPACK 核心、編譯出這些的編譯器、以及那顆晶片本身。撞動任何一層,計算就可能位移,因為浮點數運算不是實數運算。浮點加法不滿足結合律:(a + b) + c 可能在最後幾個位元上與 a + (b + c) 不同。一次重排某個總和的函式庫更新、或一顆使用更寬的融合指令的較快 CPU,都會改動那最後幾個位元——很小,卻足以打破逐位元的一致。
這正是為什麼版本是可重現性的一部分,而非事後才補的東西。對策是「釘住」:把你執行所依賴的每個套件的確切版本寫下來,連同重建那整組的方法——一份鎖定檔、一個容器映像、一份記錄下來的環境。如此「重現」就意味著「重建這個確切的堆疊,然後執行」。這裡誠實很重要:對浮點工作而言,跨越不同晶片與編譯器的逐位元重現往往不切實際,而這沒關係。可達成且有用的目標,是在某個聲明容差內的可重現性——精確到某個已知位數的同樣答案——再加上一份記錄下來的環境,讓任何重建同一堆疊的人都「能夠」做到精確重現。
你要怎麼在版本漂移悄悄汙染結果之前抓到它?用回歸測試。回歸測試的想法是:把一個已知良好的輸出鎖定下來,並自動檢查未來的執行仍在容差內與它相符。把你今天驗證過的程式碼產生的答案存起來——一個小小的參考值,最理想的是你之所以信任它,正是因為一次人造解檢驗確認了該方法的階數——然後讓一個測試在每次更動程式碼或堆疊後重新跑它。如果一次函式庫升級把答案推過了容差,測試就轉紅,你便會「刻意地」去調查,而不是在一張已發表的圖裡才發現那次漂移。
來源軌跡:從輸入到圖表的那條鏈
種子與版本釘住執行本身;來源軌跡則記下圍繞它的整個故事。來源軌跡是一個數字的保管鏈:究竟是哪份輸入資料進去、哪個版本的哪支腳本處理它、用了什麼參數與什麼種子、在哪一天、產出哪個輸出與哪張圖。黃金標準是:報告裡的每一張圖,都能一步步追溯回它所源自的原始位元組——並由一道指令從那些位元組重新生成。如果你說不出一條曲線從何而來,你就無法為它辯護,更別說在做出它的那台筆電消失之後重建它。
做到這件事大半的日常工具是版本控制,幾乎總是 git。把你的程式碼提交(commit),會給每個狀態一個獨一無二的指紋(commit 雜湊),於是「做出圖 3 的那支腳本」就成了精確、不可變的參照,而不是「final_v2_REALfinal.py」。在每個輸出上蓋上產生它的 commit 雜湊、參數、與種子,你就免費得到了來源軌跡。誠實地提兩個告誡:版本控制是給程式碼與小型設定用的,不是給數 GB 的原始資料用的(請用一個資料儲存庫加上記錄下來的校驗碼,好讓你能證明那些位元組從未變過);而且一個結果的可重現性,只取決於它最雜亂的那個手動步驟。每一次未記錄的點擊,都是這條鏈上的一個破洞。
- 把程式碼放進版本控制、提交一個乾淨的狀態;記下這次執行的 commit 雜湊。
- 釘住環境:把確切的函式庫版本記在一份鎖定檔或容器裡,好讓堆疊能被重建。
- 在執行的最開頭設定並記錄一個明確的隨機種子,讓偽隨機數流被固定下來。
- 把原始輸入連同一個校驗碼一起記錄下來,以及傳入的確切參數。
- 在每個輸出與圖表上蓋上產生它的雜湊、種子、與參數。
- 加上一個回歸測試,重新跑整條流程、檢查輸出仍在容差內。
可重現性為你買到什麼、又買不到什麼
對這個習慣的邊界要看得清楚,因為它很容易被過度吹捧。可重現性保證你能「再次」得到「同一個」數字;它對那個數字是否「正確」隻字未提。一支有 bug 的腳本,用固定種子在釘住的堆疊上跑,會把它的 bug 完美地重現,永遠如此。這正是為什麼前面的指南要先來:驗證(人造解、收斂研究)與確認(它符合現實嗎?)評斷的是正確性,而可重現性僅僅保證可重複性。它們是夥伴,不是替代品——可重現性正是讓一個驗證宣稱能被你以外的人核對的東西。
這一切之下還有一份更深的謙卑。即使是一個被完美重現、完美驗證的結果,仍然是近似的,因為每一個數值答案都是。浮點數的下限、你在收斂研究中量到的離散化誤差、被你的種子馴服的 O(1/sqrt(N)) 蒙地卡羅雜訊——這些不會只因為執行能重複就消失。可重現性讓你的近似誠實且可被檢視;它不會讓它變得精確。成熟的姿態是:把數字、產生它的環境、以及為它定界的誤差估計——一起報告出來。
最後那句話是通往這一級最後一篇的橋。我們已讓執行可重複;接下來要問的是,該多信任它所產生的數字。不確定性量化、敏感度分析、與誠實的誤差棒,會拿起一條可重現的流程、把它包進一句量化的「存疑」陳述裡——而對於反問題,也就是小小的資料抖動會讓答案爆掉的那種,正則化讓那份存疑不致失控。可重現性是讓那些誤差棒有意義的地基:一個信賴區間唯有在其背後的計算能被重跑與核對時,才可信。