# Monte Carlo 測試的容差怎麼定：標準誤、誤拒率與位元重現

> Monte Carlo 測試可用 z 乘標準誤加近似誤差額度設定容差，隨 N 調整。固定容差可能誤拒或漏掉偏差；20 項獨立檢查各有 5% 誤拒率，整批約為 64%。

原文：https://sheng.page/posts/monte-carlo-test-tolerance-and-false-rejection/ · 發布：2026-11-01 · 作者：Sheng

模擬測試的接受範圍要有依據，至少得交代信賴水準、標準誤和允許的近似誤差。若只在工具或測試框架裡放一個預設容差，例如 0.005 或 1%，呼叫端就很難判斷它是否適合目前的樣本數 N。N 小時，容差可能太緊，正確實作也會隨機失敗；N 大時，容差又可能太鬆，放過原本想抓的偏差。

同 seed 重跑要另外檢查。若工具承諾在同 seed、同版本、同平台下逐位元重現，就該直接比對輸出，不套容差；換 seed 後，才用統計判準比較估計值與理論值。混用兩種檢查，可能漏掉位元差異，也可能把統計誤拒誤認為 RNG 故障。下面用 Python 和 numpy 示範。

## 抽樣誤差與接受範圍

Monte Carlo 估出來的 RTP 或命中率是隨機變數，直接要求它等於理論值，很容易因抽樣差異而失敗。可以檢查理論值有沒有落在這次估出的區間內，或者寫成明確的不等式

```text
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。

```python
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 '拒絕'}")
```

```text
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 之後整批的誤拒率。

```python
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}）")
```

```text
理論值 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 通常會產生不同的輸出，但沒有保證，所以範例只比估計值。

```python
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}）")
```

```text
同 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 正式環境要猜不到、測試要能重播](/posts/rng-unpredictable-in-prod-replayable-in-tests/)〉已經寫過，這裡不重講；送審時認證在看什麼，見〈[洗牌只有 8,640 萬種：遊戲 RNG 認證在檢查什麼](/posts/game-rng-certification/)〉。

## 自我檢查要能失敗

最後一點是 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](https://itl.nist.gov/div898/handbook/prc/section1/prc15.htm)〉：以已知母體標準差的常態平均數範例說明檢定與信賴區間的對應，拒絕門檻和區間半寬共用同一個 z，適用前仍須確認假設。
- NIST/SEMATECH e-Handbook〈[Introduction to hypothesis testing](https://itl.nist.gov/div898/handbook/prc/section1/prc13.htm)〉：把 α 定義為虛無假設為真卻被拒絕的風險，並說明 α 降低時 β 會升高。
- NIST/SEMATECH e-Handbook〈[Introduction to confidence intervals](https://itl.nist.gov/div898/handbook/prc/section1/prc14.htm)〉：平均數的 100(1-α)% 信賴區間公式，說明估計 SD 帶來的額外不確定性；不能據此斷言任意一次樣本區間必然比已知母體 SD 時寬。
- Wikipedia〈[Standard deviation](https://en.wikipedia.org/wiki/Standard_deviation)〉：只引平均數標準誤等於母體 SD 除以 sqrt(N) 這一條，以及它假設樣本獨立。
- Wikipedia〈[Monte Carlo method](https://en.wikipedia.org/wiki/Monte_Carlo_method)〉：在適用假設下，樣本數變四倍，標準誤減半，這個縮放率不隨維度改變；用小規模試跑估變異數再決定樣本數，背景用。

> 想法與技術判斷出自 Sheng，和 Claude 一起起草 · 題目由 Sheng 選定，內文、案例整理與範例由 Claude 撰寫，範例在 Linux 上以 Python 3.13.18 與 numpy 2.5.3 實測。