Fisher’s Exact Test

統計
Author

Ryo Nakagami

Published

2025-12-19

Modified

2025-12-19

Fisher’s exact test(Fisherの正確検定)

体外式膜型人工肺(ECMO)は,重度の呼吸不全を抱える新生児を治療するための処置です.

ある実験1にて

  • 29人の新生児がECMOで治療
  • 10人の新生児が従来の内科的治療(CMT)で治療

が実施された結果,以下のような主要評価項目が得られました.

Treatment Die Live total
ECMO 1 28 29
CMT 4 6 10
total 5 34 39

このデータに対して,ECMOとCMTで死亡率に差があるかどうかを検定したいという問題を考えます.

NHSTフレームワーク

Note検証仮説

\[ H_0: \text{治療法(ECMO or CMT)と結果(Die or Live)は独立である} \]

つまり,ECMOで治療しようがCMTで治療しようが,死亡率は同じであるという仮説.

このときの対立仮説は,両側検定の場合

\[ H_1: \text{治療法と結果は独立ではない(関連がある)} \]

片側検定の場合は

\[ H_1: \text{CMTと比べECMOは死亡率の低い治療法である} \]

となります.

\(H_0\) が真のときの分布

Fisher’s exact testでは (ECMOの人数, CMTの人数) = (29, 10) はstudy designによって固定されていると考えます.\(H_0\)が真であるとき,39人の患者のうち5人が死亡し34人が生存するという周辺度数は固定されていると考えます.

このとき,ECMO群29人の中で死亡する人数 \(X\) は超幾何分布に従うと考え

\[ X \sim \text{Hypergeometric}(N=39, M=5, K=29) \]

ここで,

  • \(N = 39\): 全患者数
  • \(M = 5\): 全死亡者数
  • \(K = 29\): ECMO群の患者数

\(H_0\)の下で,ECMO群の死亡者数 \(X = x\) となる確率は

\[ P(X = x \mid H_0) = \frac{\binom{5}{x}\binom{34}{29-x}}{\binom{39}{29}} \]

検定統計量とp値

観測されたECMO群の死亡者数は \(x = 1\) です.

片側検定(ECMOの方が死亡率が低い)のp値は

\[ p_{\text{one-sided}} = P(X \leq 1 \mid H_0) = \sum_{k=0}^{1} \frac{\binom{5}{k}\binom{34}{29-k}}{\binom{39}{29}} \]

両側検定のp値の計算方法はいくつかありますが,scipy.stats.fisher_exactでは観測値の確率以下となるすべての\(x\)の確率を合計する方法を採用しています:

\[ p_{\text{two-sided}} = \sum_{x: P(X=x \mid H_0) \leq P(X=1 \mid H_0)} P(X = x \mid H_0) \]

つまり,観測された \(x=1\) の確率 \(P(X=1 \mid H_0)\) を計算し,それ以下の確率を持つすべての \(x\) の値について確率を合計します.

Code
from scipy.stats import hypergeom, fisher_exact
from scipy.stats.contingency import odds_ratio as odds_ratio_ci
import numpy as np

# パラメータ
N = 39  # 全患者数
R = 5   # 全死亡者数
K = 29  # ECMO群の患者数

# P(X <= 1) を計算
p_value_left = hypergeom.cdf(1, N, R, K)
print(f"P(X <= 1 | H_0) = {p_value_left:.6f}")

# scipy.stats.fisher_exact で確認
table = [[1, 28], [4, 6]]
odds_ratio, p_two_sided = fisher_exact(table, alternative='two-sided')
_, p_less = fisher_exact(table, alternative='less')

# オッズ比の信頼区間(exact method)
res = odds_ratio_ci(table)
ci_two_sided = res.confidence_interval(confidence_level=0.95)
ci_less = res.confidence_interval(confidence_level=0.95, alternative='less')

print(f"\nFisher's exact test (scipy):")
print(f"  Odds ratio: {odds_ratio:.4f}")
print(f"  95% CI (two-sided): ({ci_two_sided.low:.4f}, {ci_two_sided.high:.4f})")
print(f"  95% CI (less): (0, {ci_less.high:.4f})")
print(f"  p-value (two-sided): {p_two_sided:.6f}")
print(f"  p-value (less): {p_less:.6f}")
P(X <= 1 | H_0) = 0.011015

Fisher's exact test (scipy):
  Odds ratio: 0.0536
  95% CI (two-sided): (0.0011, 0.7319)
  95% CI (less): (0, 0.5453)
  p-value (two-sided): 0.011015
  p-value (less): 0.011015
Note
  • 上記のscipyによる計算結果は R における fisher.test() とほぼ同じ計算結果となっています
  • one-sided testにおけるOdds ratioのCIは OR < 1 をベースに計算しています

Example 1 (両側検定で計算される領域)

\(N=45, M=12, K=30\), 観測値 \(x=5\) における両側検定のp値計算領域を可視化します.

outcome 1 outcome 2 total
group 1 5 25 30
group 2 7 8 15
total 12 33 45
Code
import matplotlib.pyplot as plt
from scipy.stats import hypergeom
import numpy as np

# パラメータ
N, M, K = 45, 12, 30
obs = 5

# x の範囲
x_min = max(0, K - (N - M))
x_max = min(M, K)
x = np.arange(x_min, x_max + 1)

# PMF の計算
pmf = hypergeom.pmf(x, N, M, K)

# 観測値の確率
p_obs = hypergeom.pmf(obs, N, M, K)

# 両側検定: 観測値の確率以下となる x を特定
two_sided_mask = pmf <= p_obs + 1e-10  # 数値誤差を考慮

# 色分け
colors = ['red' if two_sided_mask[i] else 'steelblue' for i in range(len(x))]

fig, ax = plt.subplots(figsize=(8, 5))
bars = ax.bar(x, pmf, color=colors, alpha=0.7, edgecolor='black')

# 観測値にマーカー
ax.axvline(x=obs, color='darkred', linestyle='--', linewidth=2, label=f'Observed x={obs}')

# P(X=obs) の水平線
ax.axhline(y=p_obs, color='gray', linestyle=':', linewidth=1.5, label=f'P(X={obs}) = {p_obs:.4f}')

ax.set_xlabel('x', fontsize=12)
ax.set_ylabel('P(X = x)', fontsize=12)
ax.set_title(f'Hypergeometric Distribution under $H_0$ (N={N}, M={M}, K={K})\nRed: two-sided p-value region', fontsize=13)
ax.set_xticks(x)
ax.legend(loc='upper right')
ax.grid(axis='y', alpha=0.3)

plt.tight_layout()
plt.show()

# p値の計算
p_one_sided = hypergeom.cdf(obs, N, M, K)
p_two_sided = pmf[two_sided_mask].sum()

print(f"P(X={obs}) = {p_obs:.6f}")
print(f"Two-sided region: x ∈ {set(int(xi) for xi in x[two_sided_mask])}")
print(f"p-value (one-sided, less): {p_one_sided:.6f}")
print(f"p-value (two-sided): {p_two_sided:.6f}")

P(X=5) = 0.031885
Two-sided region: x ∈ {0, 1, 2, 3, 4, 5, 11, 12}
p-value (one-sided, less): 0.038771
p-value (two-sided): 0.070269

赤色の領域が両側検定のp値に寄与する部分です.観測値 \(x=1\) の確率 \(P(X=1 \mid H_0)\) 以下の確率を持つすべての \(x\) の値(この場合 \(x=0, 1, 2, 3, 4, 5, 11, 12\))の確率を合計します.

検定統計量とp値

観測されたECMO群の死亡者数は \(x = 1\) である.片側検定(ECMOの方が死亡率が低い)のp値は

\[ p = P(X \leq 1 \mid H_0) = \sum_{k=0}^{1} \frac{\binom{5}{k}\binom{34}{29-k}}{\binom{39}{29}} \]

Code
from scipy.stats import hypergeom, fisher_exact
from scipy.stats.contingency import odds_ratio as odds_ratio_ci
import numpy as np

# パラメータ
N = 39  # 全患者数
R = 5   # 全死亡者数
K = 29  # ECMO群の患者数

# P(X <= 1) を計算
p_value_left = hypergeom.cdf(1, N, R, K)
print(f"P(X <= 1 | H_0) = {p_value_left:.6f}")

# scipy.stats.fisher_exact で確認
table = [[1, 28], [4, 6]]
odds_ratio, p_two_sided = fisher_exact(table, alternative='two-sided')
_, p_less = fisher_exact(table, alternative='less')

# オッズ比の信頼区間(exact method)
res = odds_ratio_ci(table)
ci_two_sided = res.confidence_interval(confidence_level=0.95)
ci_less = res.confidence_interval(confidence_level=0.95, alternative='less')

print(f"\nFisher's exact test (scipy):")
print(f"  Odds ratio: {odds_ratio:.4f}")
print(f"  95% CI (two-sided): ({ci_two_sided.low:.4f}, {ci_two_sided.high:.4f})")
print(f"  95% CI (less): (0, {ci_less.high:.4f})")
print(f"  p-value (two-sided): {p_two_sided:.6f}")
print(f"  p-value (less): {p_less:.6f}")
P(X <= 1 | H_0) = 0.011015

Fisher's exact test (scipy):
  Odds ratio: 0.0536
  95% CI (two-sided): (0.0011, 0.7319)
  95% CI (less): (0, 0.5453)
  p-value (two-sided): 0.011015
  p-value (less): 0.011015

サンプルサイズと周辺度数のバランス

Fisher’s exact testでは,サンプルサイズが大きくても周辺度数のバランスが悪いと検出力が下がることがあります.

以下の2つのテーブルを比較してみます(どちらもオッズ比は同じ0.5):

Code
from scipy.stats import fisher_exact
import pandas as pd

# Case 1: バランスの取れた周辺度数 (N=40)
table1 = [[5, 15], [8, 12]]
or1, p1 = fisher_exact(table1, alternative='less')

# Case 2: アンバランスな周辺度数 (N=50)
table2 = [[10, 30], [4, 6]]
or2, p2 = fisher_exact(table2, alternative='less')

print("Case 1: バランスの取れた周辺度数")
print(f"  Table: {table1}")
print(f"  N = {sum(sum(row) for row in table1)}, 行比 = 20:20 (50%:50%)")
print(f"  Odds ratio: {or1:.4f}")
print(f"  p-value (less): {p1:.6f}")

print("\nCase 2: アンバランスな周辺度数")
print(f"  Table: {table2}")
print(f"  N = {sum(sum(row) for row in table2)}, 行比 = 40:10 (80%:20%)")
print(f"  Odds ratio: {or2:.4f}")
print(f"  p-value (less): {p2:.6f}")
Case 1: バランスの取れた周辺度数
  Table: [[5, 15], [8, 12]]
  N = 40, 行比 = 20:20 (50%:50%)
  Odds ratio: 0.5000
  p-value (less): 0.250302

Case 2: アンバランスな周辺度数
  Table: [[10, 30], [4, 6]]
  N = 50, 行比 = 40:10 (80%:20%)
  Odds ratio: 0.5000
  p-value (less): 0.283076

なぜCase 2の方がp値が大きい(検出力が低い)のか?

超幾何分布の観点から説明すると:

  • Case 1: 行2(対照群)が20人いるため,死亡者13人の配分パターンが多様
  • Case 2: 行2(対照群)がわずか10人のため,死亡者14人のうち最大でも10人しか行2に配分できない
Code
import matplotlib.pyplot as plt
from scipy.stats import hypergeom
import numpy as np

# Case 1: N=40, M=13(死亡), K=20(行1)
N1, M1, K1 = 40, 13, 20
x1_min, x1_max = max(0, K1-(N1-M1)), min(M1, K1)
x1 = np.arange(x1_min, x1_max + 1)
pmf1 = hypergeom.pmf(x1, N1, M1, K1)

# Case 2: N=50, M=14(死亡), K=40(行1)
N2, M2, K2 = 50, 14, 40
x2_min, x2_max = max(0, K2-(N2-M2)), min(M2, K2)
x2 = np.arange(x2_min, x2_max + 1)
pmf2 = hypergeom.pmf(x2, N2, M2, K2)

# 観測値
obs1, obs2 = 5, 10

fig, axes = plt.subplots(1, 2, figsize=(10, 4.5))

# Case 1
colors1 = ['red' if xi <= obs1 else 'steelblue' for xi in x1]
axes[0].bar(x1, pmf1, color=colors1, alpha=0.7, edgecolor='black')
axes[0].set_title('Case 1: N=40, group ratio 50:50', fontsize=12)
axes[0].set_xlabel('Die in treated', fontsize=11)
axes[0].set_ylabel('P(X=x)', fontsize=11)
axes[0].set_xticks(x1)
axes[0].grid(axis='y', alpha=0.3)

# Case 2
colors2 = ['red' if xi <= obs2 else 'steelblue' for xi in x2]
axes[1].bar(x2, pmf2, color=colors2, alpha=0.7, edgecolor='black')
axes[1].set_title('Case 2: N=50, group ratio 80:20', fontsize=12)
axes[1].set_xlabel('Die in treated', fontsize=11)
axes[1].set_xticks(x2)
axes[1].grid(axis='y', alpha=0.3)

fig.suptitle('hypergeometric distribution under $H_0$', fontsize=14)
plt.tight_layout()
plt.show()

print(f"Case 1: P(X ≤ {obs1}) = {hypergeom.cdf(obs1, N1, M1, K1):.6f}")
print(f"Case 2: P(X ≤ {obs2}) = {hypergeom.cdf(obs2, N2, M2, K2):.6f}")

Case 1: P(X ≤ 5) = 0.250302
Case 2: P(X ≤ 10) = 0.283076

Case 2では,分布の取りうる範囲が狭く(\(x \in [4, 14]\)),観測値10が分布の中央付近に位置するため,p値が大きくなります.

Tip実験計画への示唆

Fisher’s exact testの検出力を高めるには:

  1. サンプルサイズを増やすだけでなく
  2. 群間のバランスを取る(できれば1:1に近づける)ことが重要

特に対照群のサンプルサイズが小さいと,効果量が同じでも検出力が大幅に低下します.

Table Matrixの転置に対する頑健性

Fisher’s exact testは行と列を対称に扱うため,テーブルを転置しても検定結果は変わりません.

\(2 \times 2\) テーブルを以下のように表記します:

\(Y=1\) \(Y=2\) sum
\(X=1\) \(n_{11}\) \(n_{12}\) \(n_{1+}\)
\(X=2\) \(n_{21}\) \(n_{22}\) \(n_{2+}\)
sum \(n_{+1}\) \(n_{+2}\) \(n\)

超幾何分布の確率は

\[ P(n_{11}) = \frac{\binom{n_{+1}}{n_{11}}\binom{n_{+2}}{n_{12}}}{\binom{n}{n_{1+}}} = \frac{\binom{n_{1+}}{n_{11}}\binom{n_{2+}}{n_{21}}}{\binom{n}{n_{+1}}} = \frac{n_{1+}! \, n_{2+}! \, n_{+1}! \, n_{+2}!}{n! \, n_{11}! \, n_{12}! \, n_{21}! \, n_{22}!} \]

と表され,\(n_{11}\) セルの値で与えられます.この式は行と列について完全に対称であるため:

  • 行を入れ替えても\(X=1 \leftrightarrow X=2\)
  • 列を入れ替えても\(Y=1 \leftrightarrow Y=2\)
  • 行と列を転置しても\(X \leftrightarrow Y\)

超幾何分布に基づくPMFの値は変わりません.

Code
from scipy.stats import fisher_exact

# 元のテーブル
table_original = [[1, 28], [4, 6]]

# 転置したテーブル
table_transposed = [[1, 4], [28, 6]]

# 行を入れ替えたテーブル
table_row_swap = [[4, 6], [1, 28]]

# 列を入れ替えたテーブル
table_col_swap = [[28, 1], [6, 4]]

tables = [
    ("Original", table_original),
    ("Transposed", table_transposed),
    ("Row swapped", table_row_swap),
    ("Column swapped", table_col_swap),
]

for name, table in tables:
    odds_ratio, p_two_sided = fisher_exact(table, alternative='two-sided')
    print(f"{name:15s}: table={table}, OR={odds_ratio:.4f}, p={p_two_sided:.6f}")
Original       : table=[[1, 28], [4, 6]], OR=0.0536, p=0.011015
Transposed     : table=[[1, 4], [28, 6]], OR=0.0536, p=0.011015
Row swapped    : table=[[4, 6], [1, 28]], OR=18.6667, p=0.011015
Column swapped : table=[[28, 1], [6, 4]], OR=18.6667, p=0.011015

この性質により,Fisher’s exact testは説明変数と応答変数を区別しないため,前向き研究(prospective study)でも後ろ向き研究(retrospective study)でも適用可能です.

Note前向き研究と後ろ向き研究
研究デザイン 説明
前向き研究
(Prospective)
曝露(治療法など)を先に決め,その後の結果(アウトカム)を追跡観察する ECMO vs CMT の患者を登録し,死亡/生存を追跡
後ろ向き研究
(Retrospective)
結果(アウトカム)が既に判明している集団から,過去の曝露を調べる 死亡/生存の患者記録から,過去にどの治療を受けたか調査

Fisher’s exact testは超幾何確率の対称性により,どちらのデザインでも同じ検定が適用できます:

  • 前向き研究:行(治療群)を固定 → 列(結果)の分布を見る
  • 後ろ向き研究:列(結果)を固定 → 行(治療群)の分布を見る

どちらの場合も \(n_{11}\) の超幾何確率は同じ値になるため,p値も同一です.

Power Analuysis for Fishers extact test

Fisher’s exact testのpower analysisは Mainland and Sutcliffe (1953) に基づくと, \(N = 2n\), \(M = r\), \(K = n\) と対応する形で

\[ \beta(n, p_1, p_2) = \sum_{r=0}^{2n} \sum_{x \in C_r} \binom{n}{x}\binom{n}{r-x} p_1^x(1-p_1)^{n-x} p_2^{r-x}(1-p_2)^{n-r+x} \]

で計算することが出来ます.これは2つの独立な二項分布の積,及び同時分布のうち棄却域を満たす事象の総和を取っていると解釈することが出来ます.

記号 意味
\(r\) 両群の成功数の合計(\(0〜n1+n2\)
\(x\) 第1群の成功数
\(C_r\) 臨界領域(p値 < αとなるxの集合)
\(p_1, p_2\) 各群の真の成功確率

pseudocode

上記のPower analysis計算関数を踏まえると以下のように整理できます

\begin{algorithm} \caption{Power of Fisher's Exact Test} \begin{algorithmic} \Procedure{PowerFisherExact}{$n_1, n_2, p_1, p_2, \alpha$} \State $\beta \gets 0$ \Comment{検出力を初期化} \State $n \gets n_1 + n_2$ \Comment{総サンプルサイズ} \For{$r = 0$ \To $n$} \Comment{両群の成功数の合計} \State $x_{min} \gets \max(0, r - n_2)$ \State $x_{max} \gets \min(n_1, r)$ \For{$x = x_{min}$ \To $x_{max}$} \Comment{群1の成功数} \State $T \gets \begin{pmatrix} x & n_1 - x \\ r - x & n_2 - r + x \end{pmatrix}$ \Comment{2×2分割表} \State $p_{value} \gets$ \Call{FisherExactTest}{$T$} \If{$p_{value} < \alpha$} \Comment{ 臨界領域の判定} \State $P_1 \gets \binom{n_1}{x} p_1^x (1-p_1)^{n_1-x}$ \Comment{群1の二項確率} \State $P_2 \gets \binom{n_2}{r-x} p_2^{r-x} (1-p_2)^{n_2-r+x}$ \Comment{群2の二項確率} \State $\beta \gets \beta + P_1 \cdot P_2$ \Comment{同時確率を加算} \EndIf \EndFor \EndFor \State \Return $\beta$ \EndProcedure \end{algorithmic} \end{algorithm}

上記を見てみると,各 \(r\) の計算は完全に独立のため,最後に任意の順番で各チャンクの \(\beta_{chunk}\) を足すことで最終的な検出力が計算できるので,並列化できることがわかります.

実装

Code
from scipy import stats
from typing import Literal, Iterable
from multiprocessing import Pool, cpu_count

def _power_contribution(
    r_values: Iterable[int],
    n1: int,
    n2: int,
    p1: float,
    p2: float,
    alpha: float,
    alternative: str,
) -> float:
    """r の集合に対する検出力寄与を計算"""
    power = 0.0

    # Binomial PMF を事前計算
    binom1 = stats.binom(n1, p1)
    binom2 = stats.binom(n2, p2)

    for r in r_values:
        x_min = max(0, r - n2)
        x_max = min(n1, r)

        for x in range(x_min, x_max + 1):
            table = [[x, n1 - x], [r - x, n2 - (r - x)]]
            _, p_value = stats.fisher_exact(table, alternative=alternative)

            if p_value < alpha:
                power += binom1.pmf(x) * binom2.pmf(r - x)

    return power

def power_fisher_exact(
    p1: float,
    p2: float,
    n1: int,
    n2: int,
    alpha: float = 0.05,
    alternative: Literal["two-sided", "greater", "less"] = "two-sided",
    multi: bool = False,
    n_workers: int | None = None,
) -> float:
    """
    Fisher正確検定の検出力を解析的に計算する。

    Mainland and Sutcliffe (1953) の検出力関数に基づき、
    Thomas and Conlon (1991) のアルゴリズムを使用して効率的に計算します。

    検出力は以下の式で計算されます:
        β(n1, p1, p2) = Σ_{r=0}^{2n} Σ_{x∈C_r} C(n,x) * C(n,r-x) *
                        p1^x * (1-p1)^{n-x} * p2^{r-x} * (1-p2)^{n-r+x}

    ここで、rは両群の成功数の合計、xは第1群の成功数、
    C_rは超幾何分布による臨界領域です。

    Parameters
    ----------
    p1 : float
        第1群の真の比率 (0 < p1 < 1)
    p2 : float
        第2群の真の比率 (0 < p2 < 1)
    n1 : int
        第1群のサンプルサイズ
    n2 : int
        第2群のサンプルサイズ
    alpha : float, optional
        有意水準 (デフォルト: 0.05)
    alternative : {"two-sided", "greater", "less"}, optional
        対立仮説の種類 (デフォルト: "two-sided")
    multi : bool, optional
        マルチプロセス並列化を使用するかどうか (デフォルト: False)
    n_workers : int or None, optional
        ワーカー数 (デフォルト: CPU数)。multi=Trueの場合のみ有効

    Returns
    -------
    float
        検出力 (0から1の間の値)

    Examples
    --------
    >>> power_fisher_exact(0.5, 0.9, 20, 20)
    0.7123  # 解析的に計算された検出力

    >>> power_fisher_exact(0.5, 0.9, 100, 100, multi=True)
    0.9999  # マルチプロセスで高速化

    Notes
    -----
    - シミュレーションと異なり、正確な値を返します
    - 計算量は O(n1 * n2) であり、大きなサンプルサイズでも高速です
    - multi=Trueにすると複数CPUコアを使用して高速化できます

    References
    ----------
    - Mainland, D. and Sutcliffe, M.I. (1953). Statistical methods in
      medical research. Canadian Journal of Medical Sciences, 31, 406-416.
    - Thomas, R.G. and Conlon, M. (1991). Algorithm AS 259: Statistical
      Algorithms. Applied Statistics, 40(1), 258-261.
    """
    # 入力値の検証
    if not (0 < p1 < 1):
        raise ValueError(f"p1は0より大きく1より小さい値である必要があります: {p1}")
    if not (0 < p2 < 1):
        raise ValueError(f"p2は0より大きく1より小さい値である必要があります: {p2}")
    if n1 <= 0:
        raise ValueError(f"n1は正の整数である必要があります: {n1}")
    if n2 <= 0:
        raise ValueError(f"n2は正の整数である必要があります: {n2}")
    if not (0 < alpha < 1):
        raise ValueError(f"alphaは0より大きく1より小さい値である必要があります: {alpha}")
    if alternative not in ["two-sided", "greater", "less"]:
        raise ValueError(
            f"alternativeは 'two-sided', 'greater', 'less' のいずれかである必要があります: {alternative}"
        )

    n = n1 + n2  # 総サンプルサイズ
    M_values = list(range(n + 1))

    # --- single process ---
    if not multi:
        return _power_contribution(
            M_values, n1, n2, p1, p2, alpha, alternative
        )

    # --- multi process ---
    if n_workers is None:
        n_workers = cpu_count()

    chunks = [M_values[i::n_workers] for i in range(n_workers)]
    args = [
        (chunk, n1, n2, p1, p2, alpha, alternative)
        for chunk in chunks
        if chunk
    ]

    with Pool(n_workers) as pool:
        results = pool.starmap(_power_contribution, args)

    return sum(results)

Example 2 (Power Analysis 動作の確認)

冒頭のECMO vs CMTの例を用いて,power_fisher_exact 関数の動作を確認します.

観測データは以下の通りです:

Treatment Die Live total
ECMO 1 28 29
CMT 4 6 10
total 5 34 39

観測された死亡率は ECMO群で \(\hat{p}_1 = 1/29 \approx 0.034\),CMT群で \(\hat{p}_2 = 4/10 = 0.4\)

この効果量(\(p_1 = 0.034, p_2 = 0.4\))を真の値と仮定した場合,サンプルサイズ \((n_1, n_2) = (29, 10)\) でどの程度の検出力が得られるかを計算します.

# 観測された効果量
p1_observed = 1 / 29  # ECMO群の死亡率
p2_observed = 4 / 10  # CMT群の死亡率

# 実際のサンプルサイズ
n1, n2 = 29, 10

# 検出力を計算(片側検定:ECMOの方が死亡率が低い)
power_less = power_fisher_exact(
    p1=p1_observed,
    p2=p2_observed,
    n1=n1,
    n2=n2,
    alpha=0.05,
    alternative="less"
)

# 両側検定の検出力
power_two_sided = power_fisher_exact(
    p1=p1_observed,
    p2=p2_observed,
    n1=n1,
    n2=n2,
    alpha=0.05,
    alternative="two-sided"
)

print(f"観測された効果量: p1={p1_observed:.4f}, p2={p2_observed:.4f}")
print(f"サンプルサイズ: n1={n1}, n2={n2}")
print(f"\n検出力 (α=0.05):")
print(f"  片側検定 (less): {power_less:.4f}")
print(f"  両側検定: {power_two_sided:.4f}")
観測された効果量: p1=0.0345, p2=0.4000
サンプルサイズ: n1=29, n2=10

検出力 (α=0.05):
  片側検定 (less): 0.7560
  両側検定: 0.7560

Appendix: 超幾何分布

Definition 1 Hypergeometric Distribution

  • \(M\): ツボの中の赤いボールの個数
  • \(B(= N - M)\): ツボの中の青いボールの個数
  • \(N\): ツボの中のボールの個数

上記のセットアップにおいて,ツボから \(K (\leq N)\) 個のボールを無作為に非復元抽出(sampling without replacement)したところ, 抽出できた赤いボールの個数を表す確率変数を \(X\) とする.このとき,PMFは

\[ \displaylines P(X= x | N, M, K) = \frac{\left(\begin{array}{c}M\\ x\end{array}\right)\left(\begin{array}{c}N - M\\ K-x\end{array}\right)}{\left(\begin{array}{c}N\\ K\end{array}\right)} \]

で与えられる。ただし,

\[ \max(0, K - (N - M)) \le x \le \min(M, K) \]

であり,それ以外の場合は

\[ P(X = x \mid N, M, K) = 0 \]

と定義する.

確率の公理

上記のPMFが確率の公理 \(\sum P(X= x | N, M, K) = 1\) を満たすことを確認します.定義より示すべきは

\[ \left(\begin{array}{c}N\\ K\end{array}\right) = \sum \left(\begin{array}{c}M\\ x\end{array}\right)\left(\begin{array}{c}N - M\\ K-x\end{array}\right) \]

です.ヴァンデルモンドの畳み込みで明らかですが,ここでは組み合わせ的に考えてみます. 上記の式のLHSは赤と青を合わせた \(N\) 個のボールから \(K\) 個のボールを選ぶ組み合わせとなります. これは赤のボールの組み合わせをまず選び,その後青のボールの組み合わせを計算するという場合の数に相当します.つまり,

  1. まず赤いボール \(M\) 個の中から \(x\) 個を選ぶ
  2. 次に残りの \(K - x\) 個を,青いボール \(N−R\) 個の中から選ぶ
  3. 赤いボールを \(x\) 個選ぶすべての場合について和を取る

つまり,

\[ \sum \left(\begin{array}{c}M\\ x\end{array}\right)\left(\begin{array}{c}N - M\\ K-x\end{array}\right) \]

これはRHSに相当するので, \(\sum P(X= x | N, M, K) = 1\) を満たすことがわかる.

Example 3 (赤ボールを \(x\) drawする確率)

赤玉を \(X\) 引く状況を \(2 \times 2\) tableで整理したものが以下となります

Red Blue total
Drawn \(X\) \(K - X\) \(K\)
Not Drawn \(M-X\) \(B-(K-X)\) \(M+B-K\)
total \(M\) \(B\) \(N = R+B\)

このとき,\(X = x\) となる確率は

\[ \displaylines P(X= x | N, M, K) = \frac{\left(\begin{array}{c}M\\ x\end{array}\right)\left(\begin{array}{c}N - M\\ K-x\end{array}\right)}{\left(\begin{array}{c}N\\ K\end{array}\right)} \]

Code
import numpy as np
from plotly.subplots import make_subplots
import plotly.graph_objects as go
from scipy.stats import hypergeom

# パラメータ設定
N = 50  # 全ボール数
R = 20  # 赤ボール数
K_values = [15, 31]  # 抽出数

fig = make_subplots(rows=1, cols=2, subplot_titles=[f'K={K}' for K in K_values], vertical_spacing=0.15)

for i, K in enumerate(K_values):
    # x の範囲
    x_min = max(0, K - (N - R))
    x_max = min(R, K)
    x = np.arange(x_min, x_max + 1)

    # PMF の計算
    pmf = hypergeom.pmf(x, N, R, K)

    fig.add_trace(
        go.Bar(x=x, y=pmf, name=f'K={K}', opacity=0.7),
        row=1, col=i+1
    )

fig.update_layout(
    title_text=f'Hypergeometric Distribution PMF (N={N}, R={R})',
    title_font_size=18,
    title_y=0.98,
    margin=dict(t=60),
    showlegend=False,
    font=dict(size=14)
)

# サブプロットタイトルのフォントサイズを大きく
for annotation in fig['layout']['annotations']:
    annotation['font'] = dict(size=16)
fig.update_xaxes(title_text='x (Drawn red ball)', title_font_size=14)
fig.update_yaxes(title_text='P(X = x)', col=1, title_font_size=14)

fig.show()

References

Footnotes

  1. Example 10.4.1 on p.412 in Statistics for the Life Sciences (5ed) by Samuels, Witmer, and Schaffner. Original study by O’Rourke et al. Pediatrics. 1989 Dec;84(6):957-63. PMID: 2685740. See also Ware, J. H. (1989). “Investigating Therapies of Potentially Great Benefit: ECMO”. Statistical Science, 4(4), 298–306. http://www.jstor.org/stable/2245829↩︎