🎓 レベル:標準 | 重要度:A(必須)
📎 関連:第7章 実験と因果推論 目次 | 前提:広告・販促の効果測定
要点(BLUF)
- ランダム化(コイン投げで介入群と対照群に分ける)と、両群は介入以外そっくりになります。すると対照群の結果が「介入群が介入を受けなかったらどうだったか」=**反実仮想(counterfactual)**として使え、両群の差がそのまま因果効果(増分)になります。広告・販促の効果測定 の前後比較が抱えるトレンド・季節性のバイアスを、ランダム化はベースラインをモデル化せずに原理的に消します——A/B テストが効果測定の決定版である理由はここにあります。
- 処置効果は 。それが「偶然の差」かを 2標本比率の z 検定で判定します。本ノートの例(、各群 )では観測 CVR が 対照 ・介入 、、、両側 値はほぼ 、差の 95% 信頼区間 が を跨がず、B が有意に優れていると結論できます。
- 「何人集めればよいか」は 検出力(power)と MDE(検出したい最小の差)から逆算します。・検出力 ・ なら 1群あたり 人。各群 はこれを上回り検出力は約 。ただし統計的有意 実務的有意——大標本ではごく小さな差も有意になるので、MDE は事業価値で決めます。
1. ランダム化が反実仮想を作る
広告・販促の効果測定 で、効果測定とは「観測売上 ベースライン(反実仮想) 増分 ノイズ」のうち見えないベースラインを当てて増分を取り出すことだと見ました。前後比較は、施策期と直前期でベースライン(トレンド・季節性)が動くと、その差を増分に混ぜて誤ります。回帰はベースラインを関数でモデル化して切り離しますが、関数形を誤れば回帰にもバイアスが残ります。
A/B テストは、この厄介な「見えないベースラインの推定」を実験デザインそのもので回避します。ユーザーをコイン投げ(ランダム化)で2群に分けます——対照群 A(介入なし)と介入群 B(施策あり)。割り付けが完全にランダムなら、年齢・過去の購買・来訪経路といった観測できる要因も、できない要因も、両群で確率的に同じ分布になります(バランス)。つまり介入以外はそっくりなので、
が成り立ち、両群の差 がまるごと介入の因果効果(増分)になります。ベースラインを関数で当てる必要すらありません——対照群が反実仮想を実物で与えてくれるからです。
flowchart LR
U["全ユーザー"] --> R{"ランダム化(コイン投げ)"}
R --> A["対照群 A(介入なし)"]
R --> B["介入群 B(介入あり)"]
A --> PA["反応率 p_A = 反実仮想"]
B --> PB["反応率 p_B"]
PA --> D["差 δ = p_B − p_A = 因果効果(増分)"]
PB --> D
R -.-> bal["両群は介入以外そっくり(バランス)<br/>→ ベースラインをモデル化せず増分が出る"]
ランダム化が本当に群を揃えるのかを、介入前から決まっている共変量で確かめます。各ユーザーの「過去30日の支出」を作り、コイン投げで割り付けたとき、両群の平均支出が(介入と無関係に)ほぼ一致することを見ます。
import numpy as np
rng = np.random.default_rng(0)
N = 12000
# 各ユーザーの「過去30日の支出」。介入前から決まっている共変量。
past_spend = rng.gamma(shape=2.0, scale=3000.0, size=N) # 平均 6000円・右に裾
# コイン投げで半々に割り付け(ランダム化)
assign = rng.integers(0, 2, N) # 0=対照A, 1=介入B
spend_A = past_spend[assign == 0]
spend_B = past_spend[assign == 1]
print(f"対照A: n={spend_A.size}, 過去支出の平均 {spend_A.mean():,.0f}円")
print(f"介入B: n={spend_B.size}, 過去支出の平均 {spend_B.mean():,.0f}円")
print(f"平均差 {spend_B.mean() - spend_A.mean():+,.0f}円 ← 介入前からそっくり(バランス)")
出力:
対照A: n=5972, 過去支出の平均 5,999円
介入B: n=6028, 過去支出の平均 6,107円
平均差 +108円 ← 介入前からそっくり(バランス)
出力の意味:施策とは無関係な過去支出が、両群でほぼ同じ( 円 対 円、差 円=水準の約 )になりました。割り付けをランダムにしただけで、群が介入前から揃うのです。重要なのは、ここで使った過去支出に限らず、年齢でも好みでも“まだ思いついていない交絡要因”でも同じように揃うこと——だから介入後の反応率の差は、群の素性の違いではなく介入そのものの効果だと言えます。これが「ベースラインをモデル化して引き算する」回帰(広告・販促の効果測定)との決定的な違いです。なお群サイズは 対 と完全な半々ではありませんが、これは正常な揺らぎの範囲(後述の SRM チェック)です。
2. 処置効果と2標本比率の検定(数式)
観測した増分 は、真に差がなくても偶然ある程度ばらつきます。「観測した差が偶然で説明できないほど大きいか」を判定するのが 2標本比率の z 検定です。
帰無仮説 (差はない)のもとでは両群が共通の反応率 を持つと考え、両群をプールして推定します。
ここで は各群のコンバージョン数です。 が正しければ は近似的に標準正規分布に従うので、両側 値は
で得られます( は標準正規の累積分布)。一方、効果の大きさを区間で示すには、プールしない各群の分散から差の 95% 信頼区間を作ります。
この区間が を含まなければ「差は ではない」と の信頼度で言えます(検定と整合)。なお、なぜ で近似してよいか・ 検定や多重比較・検定の一般論は統計テキストの領域です。ここでの主役は、§1 のランダム化のおかげで、この差が(相関ではなく)因果効果として読めるという点にあります。
3. A/Bテストを分析する(コード)
真の反応率を**対照 ・介入 (真の増分 )**と決め打ちし、各群 のコンバージョン()を二項で生成します。観測 CVR・処置効果・z 検定・両側 値・差の 95% 信頼区間を計算します。
import numpy as np
from scipy import stats
rng = np.random.default_rng(0)
p_A_true, p_B_true = 0.10, 0.12 # 真のCVR(真の増分 +0.02)
n_A = n_B = 6000
conv_A = rng.binomial(1, p_A_true, n_A) # 各ユーザーのCV(0/1)
conv_B = rng.binomial(1, p_B_true, n_B)
x_A, x_B = conv_A.sum(), conv_B.sum()
cvr_A, cvr_B = conv_A.mean(), conv_B.mean()
delta = cvr_B - cvr_A # 処置効果の推定(増分)
# 2標本比率のz検定:H0で共通比率をプールして標準誤差を作る
p_pool = (x_A + x_B) / (n_A + n_B)
se_pool = np.sqrt(p_pool * (1 - p_pool) * (1 / n_A + 1 / n_B))
z = delta / se_pool
pval = 2 * stats.norm.sf(abs(z)) # 両側p値
# 差の95%信頼区間(各群の分散から、プールしない標準誤差)
se = np.sqrt(cvr_A * (1 - cvr_A) / n_A + cvr_B * (1 - cvr_B) / n_B)
zc = stats.norm.ppf(0.975)
ci_lo, ci_hi = delta - zc * se, delta + zc * se
print(f"対照A: CV {x_A}/{n_A} CVR={cvr_A:.4f}")
print(f"介入B: CV {x_B}/{n_B} CVR={cvr_B:.4f}")
print(f"観測増分 delta = {delta:+.4f}(真値 +0.0200)")
print(f"z値 = {z:.3f}, 両側p値 = {pval:.4f}")
print(f"差の95%信頼区間 = [{ci_lo:+.4f}, {ci_hi:+.4f}]")
出力:
対照A: CV 569/6000 CVR=0.0948
介入B: CV 724/6000 CVR=0.1207
観測増分 delta = +0.0258(真値 +0.0200)
z値 = 4.563, 両側p値 = 0.0000
差の95%信頼区間 = [+0.0147, +0.0369]
出力の意味:観測 CVR は対照 ・介入 と、それぞれ真値 をよく捉えています。処置効果 は真の増分 より少し大きめですが、これはこの1回のサンプリングのノイズ(真値は信頼区間 の中)。 は標準正規ではめったに出ない大きさで、両側 値は表示桁で (実際は約 )。差の 95% 信頼区間が を跨がないことからも、「B の反応率は A より高い」を偶然では片づけられない——介入には実効果がある、と結論できます。§1 のランダム化があるからこそ、この差を因果効果として読める点が、ただの相関の比較との違いです。
増分の点推定と区間を1枚の図にして、「両群の CVR」と「処置効果 が から離れていること」を同時に確かめます。
import numpy as np
from scipy import stats
import matplotlib.pyplot as plt
import japanize_matplotlib
rng = np.random.default_rng(0)
n_A = n_B = 6000
conv_A = rng.binomial(1, 0.10, n_A)
conv_B = rng.binomial(1, 0.12, n_B)
cvr_A, cvr_B = conv_A.mean(), conv_B.mean()
zc = stats.norm.ppf(0.975)
se_A = np.sqrt(cvr_A * (1 - cvr_A) / n_A)
se_B = np.sqrt(cvr_B * (1 - cvr_B) / n_B)
delta = cvr_B - cvr_A
se_d = np.sqrt(cvr_A * (1 - cvr_A) / n_A + cvr_B * (1 - cvr_B) / n_B)
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(9.2, 4.2))
ax1.errorbar([0, 1], [cvr_A, cvr_B], yerr=[zc * se_A, zc * se_B],
fmt="o", ms=9, capsize=7, color="C0", lw=1.5)
ax1.set_xticks([0, 1]); ax1.set_xticklabels(["対照A", "介入B"])
ax1.set_xlim(-0.5, 1.5); ax1.set_ylabel("コンバージョン率")
ax1.set_title("両群のCVR ± 95%CI")
ax2.errorbar([0], [delta], yerr=[[zc * se_d], [zc * se_d]],
fmt="s", ms=10, capsize=9, color="C3", lw=1.5)
ax2.axhline(0, ls="--", color="black", label="効果なし δ=0")
ax2.set_xticks([0]); ax2.set_xticklabels(["B − A"])
ax2.set_xlim(-0.6, 0.6); ax2.set_ylabel("増分 δ")
ax2.set_title("処置効果 δ の点推定と95%CI"); ax2.legend()
fig.suptitle("A/Bテスト:観測CVRと処置効果(CIが0を跨がない=有意)")
fig.tight_layout()
plt.show()
左パネルは両群の CVR を 95% 信頼区間つきで並べたもので、介入 B の点と区間が対照 A より明確に上にあります。右パネルは処置効果 そのもので、点推定 の区間が破線()を完全に上回っています。「区間が を跨がない=有意」が目で見える図です。
4. 何人必要か:検出力とサンプルサイズ(数式+コード)
A/B テストは事前に必要人数を決めて始めます。鍵は2つ。MDE(Minimum Detectable Effect)=「見逃したくない最小の差」と、検出力(power)=「真にその差があるとき、ちゃんと有意と判定できる確率」。慣例は検出力 ・有意水準 です。2標本比率では、1群あたり必要人数 は近似的に
で求まります。分母の = MDE の2乗が効くのがポイントで、検出したい差を半分にすると必要人数は4倍になります。
import numpy as np
from scipy import stats
p_A, p_B = 0.10, 0.12
alpha, power = 0.05, 0.80
z_a = stats.norm.ppf(1 - alpha / 2) # 両側 z_(α/2)
z_b = stats.norm.ppf(power) # z_β
mde = p_B - p_A # 検出したい最小の差
n_per = (z_a + z_b) ** 2 * (p_A * (1 - p_A) + p_B * (1 - p_B)) / mde ** 2
print(f"z_(α/2) = {z_a:.4f}, z_β = {z_b:.4f}")
print(f"MDE = {mde:.3f}, 検出力 = {power:.0%}, α = {alpha}")
print(f"1群あたり必要人数 n = {np.ceil(n_per):.0f}, 両群合計 = {np.ceil(n_per) * 2:.0f}")
print(f"→ 実験の各群 n=6000 は必要人数を上回るので検出力は十分")
出力:
z_(α/2) = 1.9600, z_β = 0.8416
MDE = 0.020, 検出力 = 80%, α = 0.05
1群あたり必要人数 n = 3839, 両群合計 = 7678
→ 実験の各群 n=6000 は必要人数を上回るので検出力は十分
出力の意味: から の差を検出力 で検出するには 1群あたり 人(両群 人)必要です。§3 で各群 を使ったのは、これを上回り検出力を約 に上げるため——だから という余裕のある有意が出ました。逆に言えば、人数が足りないと、本当に差があっても検出できず「効果なし」と誤る(第二種の過誤)。なお統計的有意と実務的有意は別物で、 をさらに増やせば のような微差すら有意にできますが、それが投資に見合うかは別の話。MDE は「いくらの差なら動く価値があるか」という事業判断で決めるべきで、検定はその閾値を統計的に裏づける道具にすぎません。
⚠️ よくある誤解
- 相関と因果を取り違える:「介入群のほうが反応率が高かった」が因果になるのは、割り付けがランダムで両群が介入以外そっくりなときだけです。たとえば「キャンペーンに反応した人だけ B」のように結果に関係する選び方をすると、群が元から偏り、差が介入のせいか素性の違いか分かりません(広告・販促の効果測定 の前後比較バイアスと同根)。ランダム化が反実仮想を作るからこそ、差を因果効果と読めます。
- p 値は「効果がある確率」ではない: 値は「もし本当は差がない()としたら、今回くらい以上に大きな差が出る確率」です。 は「効果がない確率」でも「効果がある確率 」でもありません。 のように読みたいなら、事後確率を出す ベイズA/Bテスト が素直です。
- 統計的有意 実務的有意:大標本では、事業的に無意味なほど小さな差でも になります。有意かどうか(偶然か)と、効果が大きいか(価値があるか)は別の軸。信頼区間で効果の大きさを見て、MDE と突き合わせて判断します。
- のぞき見(早期停止・反復検定)は を膨らませる:「有意になった瞬間に止める」を許すと、本当は差がなくてもどこかで偶然 を踏み、偽陽性が宣言の何倍にも増えます。事前に人数(テスト期間)を固定する、または逐次検定・ 消費関数といった専用設計を使います。
- A/A テストと SRM を確認する:同じ施策どうしを比べる A/A テストで有意差が頻発するなら、計装か乱数割り付けが壊れています。また割り付け比率が のはずなのに大きく偏る SRM(Sample Ratio Mismatch)——たとえば のような統計的にありえない偏り——は、配信バグの赤信号で、結果を信じてはいけません(§1 の は正常範囲)。
- 検定の一般論( 近似の妥当性・ 検定・多重比較・検出力計算の理論)は統計テキスト、ランダム化できない観察データから因果を取り出す手法(傾向スコア・差分の差分・操作変数)は因果推論テキストの領域です。本ノートは「ランダム化が因果効果を素直にくれる」一点に絞っています。
関連ノート
- ベイズA/Bテスト(次のトピック・同じデータを頻度論でなく事後分布で判断する)
- 第7章 実験と因果推論 目次
- 広告・販促の効果測定(増分=因果効果。回帰でベースラインをモデル化したのに対し、A/B はランダム化で反実仮想を実物で作る決定版)
- ターゲティングとペルソナ設計(反応率の高さではなく、施策の効果そのものを測る——ターゲティングの検証は A/B で)
- 検定・検出力の理論は統計テキスト、ランダム化できない観察データの因果推定(傾向スコア・DiD・操作変数)は因果推論テキストへ
- マーケティング・サイエンス 全体目次