Monte Carlo 測試的容差怎麼定:標準誤、誤拒率與位元重現
模擬測試的接受範圍要有依據,至少得交代信賴水準、標準誤和允許的近似誤差。若只在工具或測試框架裡放一個預設容差,例如 0.005 或 1%,呼叫端就很難判斷它是否適合目前的樣本數 N。N 小時,容差可能太緊,正確實作也會隨機失敗;N 大時,容差又可能太鬆,放過原本想抓的偏差。
同 seed 重跑要另外檢查。若工具承諾在同 seed、同版本、同平台下逐位元重現,就該直接比對輸出,不套容差;換 seed 後,才用統計判準比較估計值與理論值。混用兩種檢查,可能漏掉位元差異,也可能把統計誤拒誤認為 RNG 故障。下面用 Python 和 numpy 示範。
抽樣誤差與接受範圍
Monte Carlo 估出來的 RTP 或命中率是隨機變數,直接要求它等於理論值,很容易因抽樣差異而失敗。可以檢查理論值有沒有落在這次估出的區間內,或者寫成明確的不等式
abs(估計值 - 理論值) <= z × SE + δ
SE 是估計量的標準誤,在雙尾常態近似下,z 由信賴水準決定,95% 對應 1.96。δ 是另外給的近似誤差額度,用來吸收理論值本身不精確的部分,例如賠付表用的理論值是四捨五入過的,或理論值本身是另一個近似算出來的。δ 的單位和來源要跟判準寫在一起,否則幾個月後沒人知道它是怎麼來的。
NIST e-Handbook 說明了相互對應的檢定與信賴區間:在該頁已知母體標準差的常態平均數範例中,假設的參數值落在區間外,就拒絕虛無假設,拒絕門檻與區間半寬用同一個 z。上面的兩種寫法也可以這樣對照,但加入 δ 後,接受範圍已經放寬,不能直接沿用原本的誤拒率。手冊把 α 定義為虛無假設為真卻遭拒絕的風險,本文用「誤拒率」指這件事,測試的實際拒絕比例要和所選判準一起看。
樣本平均數的 SE 是母體 SD 除以 sqrt(N),前提是樣本獨立同分布、變異數有限。命中率這種 Bernoulli 估計量,SD 是 sqrt(p(1-p)),N = 10,000、p = 0.25 時 SE 約 0.0043。幾個限制要先講清楚:
- 抽樣單位要是真的獨立。若一局裡的多次轉動互相有關,像 free spin 或連鎖消除,不能把每一轉都當成獨立樣本,拿單轉 SD 除以總轉數的平方根當作 SE,可考慮以互相獨立的一局為抽樣單位,但仍需依估計目標定義統計量。
- N 變 4 倍,SE 減半,這是標準誤的縮放關係,某一次實驗的誤差不保證跟著減半。
- 罕見事件的命中率不適合一律套常態近似,p 很小時 Np 要夠大才行;若 SD 由小樣本估計,還得確認 t 分布等方法的適用條件,不能直接套用。本文只處理 N 夠大、p 不極端的命中率估計。
固定容差在不同 N 下的誤拒率
把常見的寫法和 z × SE 判準放在一起,重複 2,000 次獨立實驗,數實際被拒絕的比例。理論命中率 0.25,固定容差 0.005,seed 固定為 20261101。
import numpy as np
P_TRUE = 0.25 # 理論命中率
FIXED_TOL = 0.005 # 常見寫法:一個與 N 無關的預設容差
Z = 1.96 # 95% 對應的 z
RUNS = 2_000 # 獨立實驗次數
rng = np.random.default_rng(20261101)
for n in (10_000, 1_000_000):
se = np.sqrt(P_TRUE * (1 - P_TRUE) / n)
fixed_rej = z_rej = 0
for _ in range(RUNS):
hits = rng.random(n) < P_TRUE
err = abs(hits.mean() - P_TRUE)
fixed_rej += err > FIXED_TOL
z_rej += err > Z * se
print(f"N={n:>9,} SE={se:.5f} 固定容差 {FIXED_TOL} = {FIXED_TOL/se:5.2f} SE")
print(f" 固定容差誤拒率 {fixed_rej/RUNS:.3f} z×SE 誤拒率 {z_rej/RUNS:.3f} 名目 α 0.050")
# 真的偏了 0.003,固定容差在大 N 下放過它,z×SE 抓得到
n = 1_000_000
se = np.sqrt(P_TRUE * (1 - P_TRUE) / n)
hits = rng.random(n) < P_TRUE + 0.003
err = abs(hits.mean() - P_TRUE)
print(f"\n實際 p=0.253, N={n:,}: 誤差 {err:.5f}, "
f"固定容差 {'通過' if err <= FIXED_TOL else '拒絕'}, "
f"z×SE {'通過' if err <= Z * se else '拒絕'}")
N= 10,000 SE=0.00433 固定容差 0.005 = 1.15 SE
固定容差誤拒率 0.255 z×SE 誤拒率 0.047 名目 α 0.050
N=1,000,000 SE=0.00043 固定容差 0.005 = 11.55 SE
固定容差誤拒率 0.000 z×SE 誤拒率 0.055 名目 α 0.050
實際 p=0.253, N=1,000,000: 誤差 0.00319, 固定容差 通過, z×SE 拒絕
同一個 0.005,在 N = 10,000 時只有 1.15 個 SE,範例中正確實作約每四次就有一次遭拒,接近常態近似的 P(|Z| > 1.15) ≈ 0.25。到了 N = 1,000,000,它變成 11.55 個 SE,這批模擬的誤拒率是零,但實際命中率偏了 0.003 也通過了,z × SE 判準則在這個 N 下抓到偏差。z × SE 的誤拒率在兩個 N 下都接近名目 5%。這些比例本身也是 2,000 次實驗的估計,同 seed 重跑會得到一樣的數字,換 seed 則會有些出入。
把容差改成必填,只保證有人寫了數字,不保證數字對。比較實際的做法是在每個檢查點旁邊記錄 z、SE 的推導和 δ 的來源,review 時才有東西可以對。另外要分開的是確定性的檢查點,例如解析解算出來的期望值對照另一條公式,這種數值容差來自浮點精度,跟 N 無關,不該跟統計容差共用一個常數。
多項檢查會累積誤拒
單項 95% 通過率,整批不會是 95%。假設一批有 20 項獨立檢查、每項誤拒率 5%,至少一項誤拒的機率是 1 - 0.95^20,約 64%。下面用相同的命中率估計驗算,每批 20 項各抽 10,000 個樣本,重複 2,000 批,再看把每項 α 改成 α/20 之後整批的誤拒率。
import numpy as np
K = 20 # 一批有 20 項獨立檢查
ALPHA = 0.05 # 每項的名目誤拒率
N = 10_000 # 每項檢查的樣本數
P_TRUE = 0.25
BATCHES = 2_000 # 重複跑 2000 批
rng = np.random.default_rng(20261101)
se = np.sqrt(P_TRUE * (1 - P_TRUE) / N)
z = 1.96 # 對應 ALPHA = 0.05
batch_fail = 0
for _ in range(BATCHES):
# 20 項檢查,每項各自獨立抽 N 個樣本
hits = rng.random((K, N)) < P_TRUE
err = np.abs(hits.mean(axis=1) - P_TRUE)
batch_fail += np.any(err > z * se)
print(f"理論值 1 - (1-α)^K = 1 - 0.95^20 = {1 - (1 - ALPHA) ** K:.4f}")
print(f"模擬值({BATCHES} 批中至少一項誤拒)= {batch_fail / BATCHES:.4f}")
# 把每項 α 改成 α/K(Bonferroni),再看整批誤拒率
z_adj = 3.023 # 對應 α/K = 0.0025 的雙尾 z
batch_fail = 0
for _ in range(BATCHES):
hits = rng.random((K, N)) < P_TRUE
err = np.abs(hits.mean(axis=1) - P_TRUE)
batch_fail += np.any(err > z_adj * se)
print(f"每項改用 α/K 後,整批誤拒率 = {batch_fail / BATCHES:.4f}(目標 ≤ {ALPHA})")
理論值 1 - (1-α)^K = 1 - 0.95^20 = 0.6415
模擬值(2000 批中至少一項誤拒)= 0.6390
每項改用 α/K 後,整批誤拒率 = 0.0520(目標 ≤ 0.05)
沿用這組假設,若 CI 每天換 seed 跑一次,長期平均約每三天就有兩天失敗。若習慣用 rerun 處理,真正的偏差也可能遭忽略。把每項 α 除以檢查數可以降低整批誤拒風險,前提是各項檢定的實際誤拒率不高於調降後的 α,常態近似與有限次模擬都不保證觀察到的比例低於目標。門檻會變寬,同樣的 N 下較難抓到小偏差;想維持偵測能力,就得增加 N。樣本數應由可接受的偏差與誤判風險決定,不能一路放寬容差直到測試通過。通過率上升,也可能只是區間變寬。
位元重現與統計檢查是兩件事
位元重現可以訂成工具的驗收契約:同 seed、同版本、同平台,要求完整輸出逐位元相同,不套容差。下面範例用 np.array_equal 比對完整輸出,截短的 hash 只印出來給人看,hash 相同不足以證明逐位元相同。這還需要固定輸入、亂數消耗順序與平行方式;這些條件變動時,輸出可能改變。若要查出亂數消耗順序是否遭改動,就不能只靠統計檢查。
統計檢查則換 seed,比較估計值與理論值的差距是否落在接受範圍內,不要求不同 seed 產生相同 hash,也不能反過來要求每個 hash 都不同。只比平均和區間,檢查的範圍就限於平均,還不足以驗證整個分布,賠付表各檔命中率仍得逐項比。不同 seed 通常會產生不同的輸出,但沒有保證,所以範例只比估計值。
import hashlib
import numpy as np
P_TRUE, N, Z = 0.25, 100_000, 1.96
SE = np.sqrt(P_TRUE * (1 - P_TRUE) / N)
def simulate(seed):
rng = np.random.default_rng(seed)
return rng.random(N) < P_TRUE
def digest(hits): # 只供顯示,比對用 np.array_equal
return hashlib.sha256(np.packbits(hits).tobytes()).hexdigest()[:16]
# 檢查一:同 seed 要位元相同,沒有容差
a, b = simulate(42), simulate(42)
print("同 seed 42 :", digest(a), digest(b), "位元相同" if np.array_equal(a, b) else "位元不同")
# 檢查二:換 seed,位元通常不同,比的是估計值落不落在接受範圍
for seed in (42, 43, 44):
hits = simulate(seed)
err = abs(hits.mean() - P_TRUE)
print(f"seed {seed}: hash {digest(hits)} 估計 {hits.mean():.5f} "
f"誤差 {err:.5f} {'通過' if err <= Z * SE else '拒絕'}(門檻 {Z * SE:.5f})")
同 seed 42 : 205382a815fa93c5 205382a815fa93c5 位元相同
seed 42: hash 205382a815fa93c5 估計 0.24948 誤差 0.00052 通過(門檻 0.00268)
seed 43: hash 4b56bb7791cc4cf4 估計 0.24974 誤差 0.00026 通過(門檻 0.00268)
seed 44: hash 72ba14e71e60cf15 估計 0.25030 誤差 0.00030 通過(門檻 0.00268)
兩種檢查混用的後果有兩個方向。一個是位元重現也套了容差,於是亂數消耗順序被改掉、hash 變了,估計值仍在區間內,測試綠燈,到送審時才發現重播對不上。另一個是統計檢查被當成位元重現,某個 seed 落在 5% 的尾巴,被判成 RNG 壞了,接著有人去改 RNG。seed 怎麼在測試裡固定、正式環境怎麼避免被推導、爭議怎麼重播,〈遊戲 RNG 正式環境要猜不到、測試要能重播〉已經寫過,這裡不重講;送審時認證在看什麼,見〈洗牌只有 8,640 萬種:遊戲 RNG 認證在檢查什麼〉。
自我檢查要能失敗
最後一點是 CI 層面的事。精度自我檢查裡期望 PASS 的項目失敗時,要標 FAIL 並以非零 exit code 結束,只印 warning,CI 就無法據此阻擋失敗。除了正常案例,也要放一個刻意越界的案例,例如上面 p = 0.253 那種,確認它真的會報 FAIL,不然沒人知道那段判斷式有沒有在跑。
補充筆記
- 接受範圍寫成 z × SE + δ,z 來自信賴水準,SE 隨 N 變,δ 是另外記錄來源的近似誤差額度。
- SE = SD/sqrt(N) 要求樣本獨立同分布,一局內有關聯的轉次可考慮以獨立的局為單位,統計量仍需依估計目標定義;本文只處理 N 夠大、p 不極端的命中率。
- 固定容差 0.005 在範例 N = 10,000 時誤拒率為 25.5%,在 N = 1,000,000 時放過 0.003 的真偏差;z × SE 在兩個 N 下都貼近名目 5%。
- 20 項獨立檢查各 α = 0.05,整批至少一項誤拒理論值 64.15%,模擬 63.9%;每項改用 α/20 後整批降到 5.2%。
- 在固定執行條件下,同 seed 應比完整輸出,不套容差,範例的截短 hash 比對不足以證明逐位元相同;換 seed 比 z × SE,不要求 hash 相同。
- 期望 PASS 的自我檢查失敗時要非零退出,並放刻意越界的案例確認它會 FAIL。
延伸閱讀
- NIST/SEMATECH e-Handbook〈Relationship between hypothesis testing and confidence intervals〉:以已知母體標準差的常態平均數範例說明檢定與信賴區間的對應,拒絕門檻和區間半寬共用同一個 z,適用前仍須確認假設。
- NIST/SEMATECH e-Handbook〈Introduction to hypothesis testing〉:把 α 定義為虛無假設為真卻被拒絕的風險,並說明 α 降低時 β 會升高。
- NIST/SEMATECH e-Handbook〈Introduction to confidence intervals〉:平均數的 100(1-α)% 信賴區間公式,說明估計 SD 帶來的額外不確定性;不能據此斷言任意一次樣本區間必然比已知母體 SD 時寬。
- Wikipedia〈Standard deviation〉:只引平均數標準誤等於母體 SD 除以 sqrt(N) 這一條,以及它假設樣本獨立。
- Wikipedia〈Monte Carlo method〉:在適用假設下,樣本數變四倍,標準誤減半,這個縮放率不隨維度改變;用小規模試跑估變異數再決定樣本數,背景用。
想法與技術判斷出自 Sheng,和 Claude 一起起草 · 題目由 Sheng 選定,內文、案例整理與範例由 Claude 撰寫,範例在 Linux 上以 Python 3.13.18 與 numpy 2.5.3 實測。