🎓 レベル:標準 | 重要度:B(標準)
📎 前提:なぜRCTが黄金律か | 数理:第一種の過誤・第二種の過誤・検出力(2種類の誤りとトレードオフ・サンプルサイズ設計)・母比率・母分散の検定(統計)
要点(BLUF)
- A/Bテスト=Webの世界のRCT。ユーザーを A/B にランダム割り当てするから交換可能性が設計で保証され(なぜRCTが黄金律か)、群間差をそのまま効果と読める。
- 設計:有意水準 ・検出力 ・最小検出効果(MDE)を決めると、必要サンプルサイズが逆算できる。分析:2標本比率(または平均)の検定で有意性を判定する。
- 真の効果を仕込んでシミュレーションすると、設計した で第一種の過誤率は 、検出力は にきっちり乗る。設計は「気休め」ではなく数理的な保証だ。
1. A/Bテストは因果のどこに位置するか
施策(新しいボタン、レコメンド、価格)の因果効果を測りたい。観察データ(施策を見た人 vs 見なかった人)で比べると、見た人は元々アクティブ、などの交絡が入る。そこでユーザーを A(既存)/ B(新施策)にランダム割り当てする。これは なぜRCTが黄金律か のRCTそのもので、交絡を設計で断つ。
A/Bテスト固有の論点は2つ。(i) 何人集めれば効果を見逃さずに済むか(設計)、(ii) 集めたデータをどう判定するか(分析)。順に見る。
2. 設計:4つの量はつながっている
A/Bテストの設計は、次の4つの量の関係に尽きる(土台は 第一種の過誤・第二種の過誤・検出力(2種類の誤りとトレードオフ・サンプルサイズ設計))。
| 記号 | 意味 | 典型値 |
|---|---|---|
| 有意水準=第一種の過誤(効果ゼロを誤って「あり」と判定)の許容率 | ||
| 検出力=真に効果があるとき正しく検出する確率 | ||
| MDE | 最小検出効果(これだけの差は見逃したくない、という大きさ) | など |
| 片群あたりサンプルサイズ | 求めたい量 |
4つのうち3つを決めれば残り1つが決まる。実務では MDE を決めて を逆算する。2標本比率の場合、効果量にCohen の (分散安定化のための逆正弦変換)
を使うと、必要サンプルサイズ(片群あたり)は
で与えられる。分子の係数 は「2標本の差」の分散が になることから来る(片群だけなら )。
コード:必要サンプルサイズの逆算
ベースライン転換率 を に改善できるか(MDE )を、・検出力 で検出するのに何人要るかを 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 と一致)
出力の意味: という小さな改善を見逃さないために、片群 人・合計 人が必要だと分かる。statsmodels の solve_power と教科書公式 がぴったり一致している。MDE を小さくする(より微細な差を捉える)ほど が小さくなり、 は急増する()点に注意。
コード:検出力曲線
サンプルサイズを変えると検出力がどう動くかを図示する。 で検出力 に届くことを視覚的に確かめる。
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()
出力の意味:曲線は とともに単調に立ち上がり、赤い破線(目標 )と緑の点線()がちょうど交わる。設計とは「この曲線上のどの点で運用するか」を選ぶ作業に他ならない。
3. 分析:2標本比率の検定
データが集まったら、A群とB群の転換率に差があるかを判定する。プールした2標本比率の 検定(母比率・母分散の検定)を使う。
ここで は帰無仮説(両群同じ転換率)のもとでのプール推定値。両側 値が 未満なら「差あり」と判定する。
コード:設計どおりの過誤率・検出力になるか検証
設計した で、(1) 効果ゼロのデータを多数生成して棄却率が になるか(第一種の過誤の較正)、(2) 真に の効果があるデータで棄却率が になるか(検出力)を確かめる。
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
出力の意味:効果ゼロのとき棄却率は = 約 (設計した )。「効果が無いのに有意と誤判定する確率」が確かに に抑えられている。そして真に の効果があるとき棄却率は = ちょうど設計した検出力。設計は理論上の約束ではなく、シミュレーションでも再現する実装可能な保証だ。最後の1テスト例は statsmodels の proportions_ztest での実分析の作法(, で有意)。
4. 分散低減:CUPED(実験前データの活用)
共変量調整と層別とブロック化 で見た「予後因子で分散を下げる」をA/Bテストに持ち込んだのが CUPED(Controlled-experiment Using Pre-Experiment Data)。実験前の同じ指標 (ユーザーの過去の購買額など)で結果 を補正する。
は割り当てと独立なので効果推定は不偏のまま、分散だけが 倍に縮む( は と の相関)。これは本質的に「事前指標を共変量にした回帰調整」だ。
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 でも効果の中心は のまま(不偏)、標準偏差は へ半分以下。低減率 は理論値 に整合する。同じ精度を半分以下のサンプルで達成できる=テスト期間を短縮できるので、大規模A/Bテスト基盤では標準装備になっている。中身は 共変量調整と層別とブロック化 の回帰調整と同じだ。
⚠️ よくある誤解・落とし穴
(1)結果を覗き見て早期停止する(peeking) 有意になった瞬間にテストを止める運用は、第一種の過誤率を から大きく押し上げる(何度も検定すれば、いつかは偶然有意になる)。固定サンプルサイズで最後まで回すか、逐次検定・グループ逐次法・常時有効な信頼区間など覗き見を許す手法を使う。設計した に達する前の「有意」は信用しない。
(2)多数の指標・セグメントを一度に検定する(多重比較) 個の指標を で検定すれば、効果ゼロでも平均1個は偶然有意になる。主要指標(OEC)を1つ事前登録し、補助指標は第一種の過誤・第二種の過誤・検出力(2種類の誤りとトレードオフ・サンプルサイズ設計)の多重比較補正を併用する。
(3)SUTVAの干渉(ネットワーク・両面市場) SNS のいいね、マーケットプレイスの在庫、配車の需給などでは、B群の挙動がA群に波及して個体独立が崩れる。ユーザー単位でなくクラスター(地域・時間帯)単位で割り当てるなどの設計で対処する(なぜRCTが黄金律か のSUTVA)。
(4)有意 ≠ 重要、非有意 ≠ 効果ゼロ は「効果がMDE以上ある」ことを意味しない。効果量と信頼区間を必ず併記する。逆に非有意でも、検出力不足( が足りない)なら「効果なし」とは言えない。
関連ノート
- 因果:なぜRCTが黄金律か(A/Bテストの識別の根拠)/共変量調整と層別とブロック化(CUPEDの正体)/非遵守とITT(割り当てを守らないユーザー)
- 統計(数理的土台):仮説検定の枠組み(帰無仮説・対立仮説・p値・有意水準)/第一種の過誤・第二種の過誤・検出力(2種類の誤りとトレードオフ・サンプルサイズ設計)/母平均の検定(1標本・2標本t検定)/母比率・母分散の検定