Mímisbrunnr知恵の泉

← 因果推論 一覧

🎓 レベル:標準 | 重要度:B(標準)

📎 前提:なぜRCTが黄金律か | 数理:第一種の過誤・第二種の過誤・検出力(2種類の誤りとトレードオフ・サンプルサイズ設計)母比率・母分散の検定(統計)

要点(BLUF)


1. A/Bテストは因果のどこに位置するか

施策(新しいボタン、レコメンド、価格)の因果効果を測りたい。観察データ(施策を見た人 vs 見なかった人)で比べると、見た人は元々アクティブ、などの交絡が入る。そこでユーザーを A(既存)/ B(新施策)にランダム割り当てする。これは なぜRCTが黄金律か のRCTそのもので、交絡を設計で断つ。

A/Bテスト固有の論点は2つ。(i) 何人集めれば効果を見逃さずに済むか(設計)(ii) 集めたデータをどう判定するか(分析)。順に見る。


2. 設計:4つの量はつながっている

A/Bテストの設計は、次の4つの量の関係に尽きる(土台は 第一種の過誤・第二種の過誤・検出力(2種類の誤りとトレードオフ・サンプルサイズ設計))。

記号意味典型値
α\alpha有意水準=第一種の過誤(効果ゼロを誤って「あり」と判定)の許容率0.050.05
1β1-\beta検出力=真に効果があるとき正しく検出する確率0.800.80
MDE最小検出効果(これだけの差は見逃したくない、という大きさ)+0.02+0.02 など
nn片群あたりサンプルサイズ求めたい量

4つのうち3つを決めれば残り1つが決まる。実務では α,1β,\alpha, 1-\beta, MDE を決めて nn を逆算する。2標本比率の場合、効果量にCohen の hh(分散安定化のための逆正弦変換)

h=2arcsin ⁣p12arcsin ⁣p0h = 2\arcsin\!\sqrt{p_1} - 2\arcsin\!\sqrt{p_0}

を使うと、必要サンプルサイズ(片群あたり)は

n=2(z1α/2+z1β)2h2n = \frac{2\,(z_{1-\alpha/2} + z_{1-\beta})^2}{h^2}

で与えられる。分子の係数 22 は「2標本の差」の分散が 2/n2/n になることから来る(片群だけなら 1/n1/n)。

コード:必要サンプルサイズの逆算

ベースライン転換率 p0=0.10p_0=0.10p1=0.12p_1=0.12 に改善できるか(MDE =+0.02=+0.02)を、α=0.05\alpha=0.05・検出力 0.800.80 で検出するのに何人要るかを statsmodels で求める。

import numpy as np
from scipy.stats import norm
from statsmodels.stats.power import NormalIndPower
from statsmodels.stats.proportion import proportion_effectsize

# === A/Bテストの設計:必要サンプルサイズを検出力から逆算する ===
p0 = 0.10            # 既存(A群)の転換率
p1 = 0.12            # 検出したい改善後(B群)の転換率 → MDE = +0.02
alpha = 0.05         # 有意水準(第一種の過誤の許容率, 両側)
power_target = 0.80  # 望む検出力(1 - 第二種の過誤)

effect_h = proportion_effectsize(p1, p0)      # 2標本比率の効果量(Cohen の h)
analysis = NormalIndPower()
n_per_arm = analysis.solve_power(effect_size=effect_h, alpha=alpha,
                                 power=power_target, alternative="two-sided")

print(f"ベースライン転換率 p0 = {p0:.3f}")
print(f"目標転換率        p1 = {p1:.3f}  (MDE = {p1-p0:+.3f})")
print(f"効果量 Cohen's h     = {effect_h:.4f}")
print(f"必要サンプルサイズ   = 片群あたり {np.ceil(n_per_arm):.0f} 人  (合計 {2*np.ceil(n_per_arm):.0f} 人)")

# 閉形式の確認:2標本では差の分散が 2/n なので n/群 = 2(z_a+z_b)^2 / h^2
z_a = norm.ppf(1 - alpha / 2)
z_b = norm.ppf(power_target)
n_formula = 2 * (z_a + z_b) ** 2 / effect_h ** 2
print(f"閉形式 2(z_a+z_b)^2 / h^2 = {n_formula:.0f} 人/群  (solve_power と一致)")

出力:

ベースライン転換率 p0 = 0.100
目標転換率        p1 = 0.120  (MDE = +0.020)
効果量 Cohen's h     = 0.0640
必要サンプルサイズ   = 片群あたり 3835 人  (合計 7670 人)
閉形式 2(z_a+z_b)^2 / h^2 = 3835 人/群  (solve_power と一致)

出力の意味+0.02+0.02 という小さな改善を見逃さないために、片群 38353835 人・合計 76707670が必要だと分かる。statsmodelssolve_power と教科書公式 2(zα+zβ)2/h22(z_\alpha+z_\beta)^2/h^2 がぴったり一致している。MDE を小さくする(より微細な差を捉える)ほど hh が小さくなり、nn は急増する(n1/h2n \propto 1/h^2)点に注意。

コード:検出力曲線

サンプルサイズを変えると検出力がどう動くかを図示する。n=3835n=3835 で検出力 0.800.80 に届くことを視覚的に確かめる。

import numpy as np
import matplotlib.pyplot as plt
import japanize_matplotlib
from statsmodels.stats.power import NormalIndPower
from statsmodels.stats.proportion import proportion_effectsize

# === 検出力曲線:サンプルサイズを増やすと検出力がどう上がるか ===
p0, p1, alpha = 0.10, 0.12, 0.05
effect_h = proportion_effectsize(p1, p0)
analysis = NormalIndPower()

n_grid = np.arange(200, 8001, 100)
power_curve = analysis.power(effect_size=effect_h, nobs1=n_grid,
                             alpha=alpha, alternative="two-sided")

plt.figure(figsize=(8, 4.5))
plt.plot(n_grid, power_curve, linewidth=2)
plt.axhline(0.80, color="red", linestyle="--", label="目標検出力 0.80")
plt.axvline(3835, color="green", linestyle=":", label="必要n = 3835 / 群")
plt.xlabel("片群あたりサンプルサイズ n")
plt.ylabel("検出力 (1 - β)")
plt.title("検出力曲線(MDE=+0.02, α=0.05):nが増えるほど検出力が上がる")
plt.legend()
plt.tight_layout()
plt.show()

出力の意味:曲線は nn とともに単調に立ち上がり、赤い破線(目標 0.800.80)と緑の点線(n=3835n=3835)がちょうど交わる。設計とは「この曲線上のどの点で運用するか」を選ぶ作業に他ならない。


3. 分析:2標本比率の検定

データが集まったら、A群とB群の転換率に差があるかを判定する。プールした2標本比率の zz 検定(母比率・母分散の検定)を使う。

z=p^Bp^Ap^(1p^)(1nA+1nB),p^=xA+xBnA+nBz = \frac{\hat p_B - \hat p_A}{\sqrt{\hat p\,(1-\hat p)\left(\dfrac{1}{n_A} + \dfrac{1}{n_B}\right)}}, \qquad \hat p = \frac{x_A + x_B}{n_A + n_B}

ここで p^\hat p は帰無仮説(両群同じ転換率)のもとでのプール推定値。両側 pp 値が α\alpha 未満なら「差あり」と判定する。

コード:設計どおりの過誤率・検出力になるか検証

設計した n=3835n=3835 で、(1) 効果ゼロのデータを多数生成して棄却率が α=0.05\alpha=0.05 になるか(第一種の過誤の較正)、(2) 真に +0.02+0.02 の効果があるデータで棄却率が 0.800.80 になるか(検出力)を確かめる。

import numpy as np
from scipy.stats import norm
from statsmodels.stats.proportion import proportions_ztest

# === シミュレーションで設計を検証:第一種の過誤≈α、検出力≈目標 になるか ===
rng = np.random.default_rng(123)
p0 = 0.10
p1 = 0.12
alpha = 0.05
n_per_arm = 3835     # 設計で求めた片群あたり人数
n_rep = 20000        # A/Bテストを何回も繰り返す

def two_prop_test_vectorized(cA, cB, n):
    # プールした2標本比率のz検定(両側)をベクトル化
    pA = cA / n
    pB = cB / n
    p_pool = (cA + cB) / (2 * n)
    se = np.sqrt(p_pool * (1 - p_pool) * (2.0 / n))
    z = (pB - pA) / se
    pval = 2 * (1 - norm.cdf(np.abs(z)))
    return pval

# (1) 帰無仮説が真(効果ゼロ:両群とも p0)→ 棄却率は α になるべき
cA_null = rng.binomial(n_per_arm, p0, size=n_rep)
cB_null = rng.binomial(n_per_arm, p0, size=n_rep)
pval_null = two_prop_test_vectorized(cA_null, cB_null, n_per_arm)
type1 = np.mean(pval_null < alpha)

# (2) 対立仮説が真(効果あり:A=p0, B=p1)→ 棄却率は検出力になるべき
cA_alt = rng.binomial(n_per_arm, p0, size=n_rep)
cB_alt = rng.binomial(n_per_arm, p1, size=n_rep)
pval_alt = two_prop_test_vectorized(cA_alt, cB_alt, n_per_arm)
power_emp = np.mean(pval_alt < alpha)

print(f"設計:n=3835/群, alpha=0.05, 目標検出力=0.80")
print(f"第一種の過誤(実測棄却率, 効果ゼロ時)= {type1:.3f}   ← 約 0.05 になる")
print(f"検出力     (実測棄却率, 効果あり時)= {power_emp:.3f}   ← 約 0.80 になる")

# 参考:1回ぶんの分析を statsmodels の proportions_ztest で実演
cA, cB = cA_alt[0], cB_alt[0]
stat, pval = proportions_ztest([cB, cA], [n_per_arm, n_per_arm])
print(f"\n[1テスト例] A群転換={cA}/{n_per_arm}, B群転換={cB}/{n_per_arm}")
print(f"           z={stat:.3f}, p値={pval:.4f}, 有意(p<0.05)={pval < alpha}")

出力:

設計:n=3835/群, alpha=0.05, 目標検出力=0.80
第一種の過誤(実測棄却率, 効果ゼロ時)= 0.048   ← 約 0.05 になる
検出力     (実測棄却率, 効果あり時)= 0.800   ← 約 0.80 になる

[1テスト例] A群転換=384/3835, B群転換=465/3835
           z=2.948, p値=0.0032, 有意(p<0.05)=True

出力の意味:効果ゼロのとき棄却率は 0.0480.048 = 約 5%5\%(設計した α\alpha)。「効果が無いのに有意と誤判定する確率」が確かに 5%5\% に抑えられている。そして真に +0.02+0.02 の効果があるとき棄却率は 0.8000.800 = ちょうど設計した検出力。設計は理論上の約束ではなく、シミュレーションでも再現する実装可能な保証だ。最後の1テスト例は statsmodelsproportions_ztest での実分析の作法(z=2.95z=2.95, p=0.003p=0.003 で有意)。


4. 分散低減:CUPED(実験前データの活用)

共変量調整と層別とブロック化 で見た「予後因子で分散を下げる」をA/Bテストに持ち込んだのが CUPED(Controlled-experiment Using Pre-Experiment Data)。実験前の同じ指標 XX(ユーザーの過去の購買額など)で結果 YY を補正する。

Ycuped=Yθ(XXˉ),θ=Cov(Y,X)Var(X)Y_{\text{cuped}} = Y - \theta\,(X - \bar X), \qquad \theta = \frac{\operatorname{Cov}(Y, X)}{\operatorname{Var}(X)}

XX は割り当てと独立なので効果推定は不偏のまま、分散だけが 1ρ2\sqrt{1-\rho^2} 倍に縮む(ρ\rhoXXYY の相関)。これは本質的に「事前指標を共変量にした回帰調整」だ。

import numpy as np

# === CUPED:実験前の指標で分散を下げる(共変量調整の一種)===
# 連続指標(例:1人あたり売上)。実験前の同じ指標 X が結果 Y を強く予測する状況
rng = np.random.default_rng(2024)
ATE_true = 1.0       # 処置で売上が +1 上がる
n = 2000             # 片群あたり
n_rep = 3000

raw_est = []         # 生のYで推定
cuped_est = []       # CUPED調整後で推定

for _ in range(n_rep):
    # 実験前指標 X(ユーザーの過去の購買傾向)と、それに連動する実験中の結果 Y
    X = rng.normal(5.0, 2.0, size=2 * n)
    base = X + rng.normal(0.0, 1.0, size=2 * n)   # X と強く相関する潜在的な結果
    T = np.r_[np.zeros(n), np.ones(n)].astype(int)   # ちょうど半々に割り当て
    rng.shuffle(T)
    Y = base + ATE_true * T

    # 生の差分
    raw_est.append(Y[T == 1].mean() - Y[T == 0].mean())

    # CUPED:theta = Cov(Y,X)/Var(X) で Y を補正(X は割り当てと独立なので不偏のまま)
    theta = np.cov(Y, X, ddof=1)[0, 1] / np.var(X, ddof=1)
    Y_cuped = Y - theta * (X - X.mean())
    cuped_est.append(Y_cuped[T == 1].mean() - Y_cuped[T == 0].mean())

raw_arr = np.array(raw_est)
cuped_arr = np.array(cuped_est)

print(f"真の効果 ATE_true        = {ATE_true:+.3f}")
print(f"生のY     : 平均={raw_arr.mean():+.3f}  SD={raw_arr.std():.4f}")
print(f"CUPED調整 : 平均={cuped_arr.mean():+.3f}  SD={cuped_arr.std():.4f}")
print(f"分散低減率(CUPED SD / 生SD)= {cuped_arr.std()/raw_arr.std():.3f}")
# 理論:Var(base)=Var(X)+1=5, rho^2 = Var(X)/Var(base)=4/5 → sqrt(1-0.8)=0.447
print(f"理論 sqrt(1-rho^2) の目安     = {np.sqrt(1 - 4/5):.3f}")

出力:

真の効果 ATE_true        = +1.000
生のY     : 平均=+1.000  SD=0.0687
CUPED調整 : 平均=+1.000  SD=0.0318
分散低減率(CUPED SD / 生SD)= 0.463
理論 sqrt(1-rho^2) の目安     = 0.447

出力の意味:CUPED でも効果の中心は 1.01.0 のまま(不偏)、標準偏差は 0.0690.0320.069 \to 0.032 へ半分以下。低減率 0.4630.463 は理論値 1ρ2=0.447\sqrt{1-\rho^2}=0.447 に整合する。同じ精度を半分以下のサンプルで達成できる=テスト期間を短縮できるので、大規模A/Bテスト基盤では標準装備になっている。中身は 共変量調整と層別とブロック化 の回帰調整と同じだ。


⚠️ よくある誤解・落とし穴

(1)結果を覗き見て早期停止する(peeking) 有意になった瞬間にテストを止める運用は、第一種の過誤率を 5%5\% から大きく押し上げる(何度も検定すれば、いつかは偶然有意になる)。固定サンプルサイズで最後まで回すか、逐次検定・グループ逐次法・常時有効な信頼区間など覗き見を許す手法を使う。設計した nn に達する前の「有意」は信用しない。

(2)多数の指標・セグメントを一度に検定する(多重比較) 2020 個の指標を α=0.05\alpha=0.05 で検定すれば、効果ゼロでも平均1個は偶然有意になる。主要指標(OEC)を1つ事前登録し、補助指標は第一種の過誤・第二種の過誤・検出力(2種類の誤りとトレードオフ・サンプルサイズ設計)の多重比較補正を併用する。

(3)SUTVAの干渉(ネットワーク・両面市場) SNS のいいね、マーケットプレイスの在庫、配車の需給などでは、B群の挙動がA群に波及して個体独立が崩れる。ユーザー単位でなくクラスター(地域・時間帯)単位で割り当てるなどの設計で対処する(なぜRCTが黄金律か のSUTVA)。

(4)有意 ≠ 重要、非有意 ≠ 効果ゼロ p<0.05p<0.05 は「効果がMDE以上ある」ことを意味しない。効果量と信頼区間を必ず併記する。逆に非有意でも、検出力不足(nn が足りない)なら「効果なし」とは言えない。


関連ノート