Mímisbrunnr知恵の泉

← マーケティングサイエンス 一覧

🎓 レベル:標準 | 重要度:A(必須)

📎 関連:第7章 実験と因果推論 目次 | 前提:広告・販促の効果測定

要点(BLUF)

1. ランダム化が反実仮想を作る

広告・販促の効果測定 で、効果測定とは「観測売上 == ベースライン(反実仮想)++ 増分 ++ ノイズ」のうち見えないベースラインを当てて増分を取り出すことだと見ました。前後比較は、施策期と直前期でベースライン(トレンド・季節性)が動くと、その差を増分に混ぜて誤ります。回帰はベースラインを関数でモデル化して切り離しますが、関数形を誤れば回帰にもバイアスが残ります。

A/B テストは、この厄介な「見えないベースラインの推定」を実験デザインそのもので回避します。ユーザーをコイン投げ(ランダム化)で2群に分けます——対照群 A(介入なし)と介入群 B(施策あり)。割り付けが完全にランダムなら、年齢・過去の購買・来訪経路といった観測できる要因も、できない要因も、両群で確率的に同じ分布になります(バランス)。つまり介入以外はそっくりなので、

p^A対照群の反応率    介入群が介入を受けなかったときの反応率観測できない反実仮想\underbrace{\hat p_A}_{\text{対照群の反応率}} \;\approx\; \underbrace{\text{介入群が介入を受けなかったときの反応率}}_{\text{観測できない反実仮想}}

が成り立ち、両群の差 δ^=p^Bp^A\hat\delta=\hat p_B-\hat p_Aまるごと介入の因果効果(増分)になります。ベースラインを関数で当てる必要すらありません——対照群が反実仮想を実物で与えてくれるからです。

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円  ← 介入前からそっくり(バランス)

出力の意味:施策とは無関係な過去支出が、両群でほぼ同じ(5,9995{,}999 円 対 6,1076{,}107 円、差 108108 円=水準の約 2%2\%)になりました。割り付けをランダムにしただけで、群が介入前から揃うのです。重要なのは、ここで使った過去支出に限らず、年齢でも好みでも“まだ思いついていない交絡要因”でも同じように揃うこと——だから介入後の反応率の差は、群の素性の違いではなく介入そのものの効果だと言えます。これが「ベースラインをモデル化して引き算する」回帰(広告・販促の効果測定)との決定的な違いです。なお群サイズは 5972597260286028 と完全な半々ではありませんが、これは正常な揺らぎの範囲(後述の SRM チェック)です。

2. 処置効果と2標本比率の検定(数式)

観測した増分 δ^=p^Bp^A\hat\delta=\hat p_B-\hat p_A は、真に差がなくても偶然ある程度ばらつきます。「観測した差が偶然で説明できないほど大きいか」を判定するのが 2標本比率の z 検定です。

帰無仮説 H0: pA=pBH_0:\ p_A=p_B(差はない)のもとでは両群が共通の反応率 pp を持つと考え、両群をプールして推定します。

p^=xA+xBnA+nB,SE=p^(1p^)(1nA+1nB),z=δ^SE\hat p=\frac{x_A+x_B}{n_A+n_B},\qquad SE=\sqrt{\hat p\,(1-\hat p)\left(\frac{1}{n_A}+\frac{1}{n_B}\right)},\qquad z=\frac{\hat\delta}{SE}

ここで xA,xBx_A,x_B は各群のコンバージョン数です。H0H_0 が正しければ zz は近似的に標準正規分布に従うので、両側 pp

p=2(1Φ(z))p\text{値}=2\bigl(1-\Phi(|z|)\bigr)

で得られます(Φ\Phi は標準正規の累積分布)。一方、効果の大きさを区間で示すには、プールしない各群の分散から差の 95% 信頼区間を作ります。

δ^  ±  zα/2p^A(1p^A)nA+p^B(1p^B)nB\hat\delta \;\pm\; z_{\alpha/2}\,\sqrt{\frac{\hat p_A(1-\hat p_A)}{n_A}+\frac{\hat p_B(1-\hat p_B)}{n_B}}

この区間が 00 を含まなければ「差は 00 ではない」と 95%95\% の信頼度で言えます(検定と整合)。なお、なぜ zz で近似してよいか・tt 検定や多重比較・検定の一般論は統計テキストの領域です。ここでの主役は、§1 のランダム化のおかげで、この差が(相関ではなく)因果効果として読めるという点にあります。

3. A/Bテストを分析する(コード)

真の反応率を**対照 pA=0.10p_A=0.10・介入 pB=0.12p_B=0.12(真の増分 +0.02+0.02)**と決め打ちし、各群 n=6000n=6000 のコンバージョン(0/10/1)を二項で生成します。観測 CVR・処置効果・z 検定・両側 pp 値・差の 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 は対照 0.09480.0948・介入 0.12070.1207 と、それぞれ真値 0.10/0.120.10/0.12 をよく捉えています。処置効果 δ^=+0.0258\hat\delta=+0.0258 は真の増分 +0.02+0.02 より少し大きめですが、これはこの1回のサンプリングのノイズ(真値は信頼区間 [+0.0147,+0.0369][+0.0147,+0.0369] の中)。z=4.56z=4.56 は標準正規ではめったに出ない大きさで、両側 pp 値は表示桁で 0.00000.0000(実際は約 5×1065\times10^{-6}。差の 95% 信頼区間が 00 を跨がないことからも、「B の反応率は A より高い」を偶然では片づけられない——介入には実効果がある、と結論できます。§1 のランダム化があるからこそ、この差を因果効果として読める点が、ただの相関の比較との違いです。

増分の点推定と区間を1枚の図にして、「両群の CVR」と「処置効果 δ\delta00 から離れていること」を同時に確かめます。

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 より明確に上にあります。右パネルは処置効果 δ\delta そのもので、点推定 +0.026+0.026 の区間が破線(δ=0\delta=0)を完全に上回っています。「区間が 00 を跨がない=有意」が目で見える図です。

4. 何人必要か:検出力とサンプルサイズ(数式+コード)

A/B テストは事前に必要人数を決めて始めます。鍵は2つ。MDE(Minimum Detectable Effect)=「見逃したくない最小の差」と、検出力(power)1β1-\beta=「真にその差があるとき、ちゃんと有意と判定できる確率」。慣例は検出力 80%80\%・有意水準 α=0.05\alpha=0.05 です。2標本比率では、1群あたり必要人数 nn は近似的に

n(zα/2+zβ)2[pA(1pA)+pB(1pB)](pBpA)2n \approx \frac{\bigl(z_{\alpha/2}+z_{\beta}\bigr)^2\,\bigl[p_A(1-p_A)+p_B(1-p_B)\bigr]}{(p_B-p_A)^2}

で求まります。分母の (pBpA)2(p_B-p_A)^2 = 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 は必要人数を上回るので検出力は十分

出力の意味pA=0.10p_A=0.10 から +0.02+0.02 の差を検出力 80%80\% で検出するには 1群あたり 3,8393{,}839(両群 7,6787{,}678 人)必要です。§3 で各群 60006000 を使ったのは、これを上回り検出力を約 94%94\% に上げるため——だから z=4.56z=4.56 という余裕のある有意が出ました。逆に言えば、人数が足りないと、本当に差があっても検出できず「効果なし」と誤る(第二種の過誤)。なお統計的有意と実務的有意は別物で、nn をさらに増やせば +0.001+0.001 のような微差すら有意にできますが、それが投資に見合うかは別の話。MDE は「いくらの差なら動く価値があるか」という事業判断で決めるべきで、検定はその閾値を統計的に裏づける道具にすぎません。

⚠️ よくある誤解

関連ノート