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 實測。