実践編Practice
A/Bテストで効果を検証する
対照実験(A/Bテスト)のデータを題材に、効果が本当にあったのかを統計的に正しく判定し、効果量まで示し、なぜランダム化で因果が言えるのかという考え方を押さえて、結論を図で伝えます。統計分析・因果推論・データ可視化という第2段階の3つの技術を、1つの意思決定に向けてつなぐ総合演習です。手を動かせるノートブックつきで進めます。
この教材は日本語のみで提供していますThis learning material is available in Japanese only.
課題設定The task
その差は、本物か。偶然か。
あなたはあるWebサービスのボタンをAパターンからBパターンに変えたら、コンバージョン率(申し込みに至る割合)が上がるかどうかを確かめたい、と考えているとします。単純に「B案を出した週の数字が良かった」では、それが施策の効果なのか、たまたま良い週だったのか区別できません。そこで対照実験(A/Bテスト)を行います。訪問者をランダムにA群・B群へ割り当て、同じ期間・同じ条件で結果を比べるのです。
ただし、観測された差をそのまま「効果あり」と結論するのは危険です。標本には必ずばらつきがあり、本当は差がなくても、偶然そこそこの差が出ることがあります。だからこそ、次の一続きの流れで差の確からしさと差の大きさの両方を、順を追って評価します。
- データ入手:対照実験のデータを、真の効果を仕込んだシミュレーションで生成する(二値のコンバージョンと、連続値の滞在時間)。
- 探索・可視化:群ごとの平均や分布を図にして、まず目で確かめる。
- 仮説検定:帰無仮説を立て、p値と信頼区間で「偶然では説明しにくいか」を判定する。
- 効果量:p値だけでなく、リフトやCohen's dで「どれだけ大きい差か」を示す。
- 因果の考え方:なぜランダム化で因果が言えるのか、どこに落とし穴があるのかを押さえる。
最終的に得られるのは、検定結果と効果量、そして信頼区間つきの図と、「この結果から何を意思決定してよいか」という読み解きです。統計・因果・可視化はばらばらに学ぶと道具の寄せ集めに見えますが、1つの判断につなぐと役割がはっきりします。
この演習のパイプライン
使う技術Techniques used
組み合わせる3つのモジュール
この演習は、第2段階で単体で学んだ次の3モジュールを1本につなぎます。まだ学んでいない技術があれば、先にそのモジュールで基礎を押さえてから戻ってくると、各ステップの意味がはっきりします。
ノートブックで実行Run the notebook
このページのコードを、そのまま動かす
A/Bテスト検証ノートブック
このページで読むコードと同じ内容を、上から順に実行できるノートブックにまとめてあります。Colab で開けば、インストール込みで環境構築なしにそのまま動かせます。手元の Jupyter で動かしたい場合は .ipynb をダウンロードしてください。
最初のセルで !pip install scipy statsmodels pandas matplotlib を実行します。データはノートブック内でシミュレーション生成するため、外部データのダウンロードは不要で、そのまま最後まで実行できます。乱数の種を固定しているので、結果はこのページの出力例とほぼ同じになります。
データ入手:真の効果を仕込んだ実験データ
Simulate the experiment何を・なぜWhat & why
本物のA/Bテストのデータは手元にないことが多く、また「正解(真の効果)」が分からないと、検定が正しく効果を拾えているのか確かめられません。そこでシミュレーションで実験データを作り、真の効果を自分で仕込みます。こうすると、あとで検定や効果量が真の値に近い判断を返すかを検証できます。NumPy の乱数で、2種類の指標を生成します。
- コンバージョン(二値):申し込んだ=1/しなかった=0。真の率をA群 12%、B群 15% とし、B群にわずかな効果を仕込みます。
- 滞在時間(連続値):ページに留まった秒数。真の平均をA群 60秒、B群 64秒とします。
各群の人数(サンプルサイズ)は 2000 ずつにします。乱数の種(seed)を固定して、結果を再現できるようにします。
import numpy as np
rng = np.random.default_rng(42) # 種を固定して再現可能に
n_a, n_b = 2000, 2000 # 各群のサンプルサイズ
# 真のコンバージョン率(B群に +3ポイントの効果を仕込む)
p_a, p_b = 0.12, 0.15
conv_a = rng.binomial(1, p_a, n_a) # 0/1 の配列
conv_b = rng.binomial(1, p_b, n_b)
# 真の滞在時間(B群の平均を +4秒。標準偏差は共通20秒)
dwell_a = rng.normal(60, 20, n_a)
dwell_b = rng.normal(64, 20, n_b)
print("A: CV数", conv_a.sum(), " B: CV数", conv_b.sum())
print("A: 実測CV率", round(conv_a.mean(), 4),
" B: 実測CV率", round(conv_b.mean(), 4))
A: CV数 246 B: CV数 298
A: 実測CV率 0.123 B: 実測CV率 0.149この Step のまとめ
真の効果を仕込んだ実験データを作りました。仕込んだ真の率は 12% と 15%(差 3ポイント)ですが、実測は 12.3% と 14.9% と少しずれています。これが標本のばらつきです。以降のステップは、この観測された差が「仕込んだ効果」なのか「たまたま」なのかを見分けていきます。
探索・可視化:まず目で確かめる
Explore & visualize何を・なぜWhat & why
検定に進む前に、データを表と図で眺めます。いきなり p値を計算するより、群ごとの平均と分布を見たほうが、差の向きや大きさ、外れ値の有無をつかめます。pandas で群別に集計し、matplotlib で分布を描きます。
import pandas as pd
# 1行=1ユーザーの長い表にまとめる
df = pd.DataFrame({
"group": ["A"] * n_a + ["B"] * n_b,
"converted": np.concatenate([conv_a, conv_b]),
"dwell": np.concatenate([dwell_a, dwell_b]),
})
summary = df.groupby("group").agg(
n=("converted", "size"),
conv_rate=("converted", "mean"),
dwell_mean=("dwell", "mean"),
dwell_sd=("dwell", "std"),
)
print(summary.round(3))
n conv_rate dwell_mean dwell_sd
group
A 2000 0.123 60.208 20.031
B 2000 0.149 63.753 20.091数値では、コンバージョン率も滞在時間もB群が上に見えます。次に、滞在時間の分布をヒストグラムで重ねて描き、二値のコンバージョン率は棒で比べます。
import matplotlib.pyplot as plt
fig, ax = plt.subplots(1, 2, figsize=(11, 4))
# 左: 滞在時間の分布を群ごとに重ねる
ax[0].hist(dwell_a, bins=30, alpha=0.6, label="A", color="#B7C0BB")
ax[0].hist(dwell_b, bins=30, alpha=0.6, label="B", color="#0E6F5C")
ax[0].set_xlabel("dwell time (s)"); ax[0].set_ylabel("count")
ax[0].set_title("Dwell time distribution"); ax[0].legend()
# 右: コンバージョン率の棒
rates = df.groupby("group")["converted"].mean()
ax[1].bar(rates.index, rates.values, color=["#B7C0BB", "#0E6F5C"])
ax[1].set_ylabel("conversion rate")
ax[1].set_title("Conversion rate by group")
plt.tight_layout(); plt.show()
滞在時間のヒストグラムは、A群とB群のどちらも釣鐘型で大きく重なり、B群の山がわずかに右へずれています。分布が大きく重なっているのに平均に差がある、という状況では「見た目の差が偶然の範囲かどうか」を数値で確かめる必要があります。そこで次のステップで仮説検定に進みます。
この Step のまとめ
群別の平均はB群が上でした。ただし滞在時間の分布は大きく重なっており、平均差だけでは判断できません。差があるかどうかを偶然と切り分けるのが、次の仮説検定の役割です。
仮説検定:偶然では説明しにくいか
Hypothesis testing何を・なぜWhat & why
仮説検定は、「本当は差がない(帰無仮説 H0)」という仮定のもとで、いま観測されたような差、あるいはそれ以上に極端な差が起きる確率を計算します。これが p値 です。p値が小さいほど、「差がない」という仮定では今のデータが説明しにくい、と考えます。慣習として有意水準 α = 0.05 を下回れば帰無仮説を棄却します。
正確な定義を押さえておきます。
- p値:帰無仮説が正しいと仮定したとき、観測された統計量と同じかそれ以上に極端な値が得られる確率。「差がない確率」でも「B案が正しい確率」でもありません。
- 第一種の誤り(Type I error):本当は差がないのに、差があると誤って結論すること。α はこの誤りを許容する上限です。α = 0.05 なら、差がないときに 20回に1回は誤って有意と判定します。
- 信頼区間(95% CI):同じ手続きを繰り返せば、その 95% が真の差を含む区間。区間が 0 をまたがなければ、有意水準 5% での棄却と対応します。
二値(コンバージョン率)は、2つの群の割合を比べます。分割表にしてカイ二乗検定 chi2_contingency を使うのが定番です(等価な2標本のZ検定でも判定できます)。まず割合の差の検定を、次の考え方で行います。
帰無仮説「2群の率は等しい」のもとで z は近似的に標準正規分布に従い、カイ二乗統計量は z の二乗に対応します。
from scipy.stats import chi2_contingency
from statsmodels.stats.proportion import (
proportions_ztest, confint_proportions_2indep)
# 分割表: 行=群, 列=[非CV, CV]
table = [[n_a - conv_a.sum(), conv_a.sum()],
[n_b - conv_b.sum(), conv_b.sum()]]
chi2, p_chi, dof, expected = chi2_contingency(table, correction=False) # Z検定と一致させるため連続性補正なし
print(f"chi2 = {chi2:.3f}, p = {p_chi:.4f}")
# 等価なZ検定と、差(p_B - p_A)の95%信頼区間
count = np.array([conv_b.sum(), conv_a.sum()])
nobs = np.array([n_b, n_a])
z, p_z = proportions_ztest(count, nobs)
low, high = confint_proportions_2indep(
conv_b.sum(), n_b, conv_a.sum(), n_a, method="wald")
print(f"z = {z:.3f}, p = {p_z:.4f}")
print(f"95% CI for (p_B - p_A) = [{low:.4f}, {high:.4f}]")
chi2 = 5.753, p = 0.0165
z = 2.399, p = 0.0165
95% CI for (p_B - p_A) = [0.0048, 0.0472]p値は 0.05 を下回り、信頼区間 [0.005, 0.047] は 0 を含みません。つまり「差がない」という仮定では今の結果は説明しにくく、B群のコンバージョン率が高いと判断できます。区間の下限が約0.5ポイントなので、効果は少なくともこの程度はありそうだ、と幅つきで言えます。
連続値(滞在時間)は、2群の平均を比べる2標本のt検定 ttest_ind を使います。分散が群で違う可能性に備え、Welch の検定(equal_var=False)を既定にします。
from scipy.stats import ttest_ind
# Welch の2標本t検定(等分散を仮定しない)
t, p_t = ttest_ind(dwell_b, dwell_a, equal_var=False)
print(f"t = {t:.3f}, p = {p_t:.3e}")
print(f"平均差 = {dwell_b.mean() - dwell_a.mean():.3f} 秒")
t = 5.588, p = 2.453e-08
平均差 = 3.545 秒A/Bテスト判定(比率ベース)
A群・B群それぞれの成功数(コンバージョン数)と試行数(訪問者数)を入れると、コンバージョン率・差・リフト・2標本比率のZ検定によるp値(正規近似)と有意判定を計算します。上のコードの二値検定を、手元で数字を変えながら体験できます。標本が小さいときや差が小さいときに、いかにp値が有意水準を割らないかを確かめてください。
この Step のまとめ
二値はカイ二乗検定(またはZ検定)、連続値はt検定で、観測された差が偶然で説明しにくいかを判定しました。どちらも p < 0.05 で、信頼区間も 0 をまたぎません。ただし「有意」は差の存在を示すだけで、差の大きさは語りません。それを次のステップで補います。
効果量:どれだけ大きい差か
Effect size何を・なぜWhat & why
p値はサンプルサイズを大きくすればいくらでも小さくできます。実務的にはどうでもよい小さな差でも、標本が巨大なら「統計的に有意」になり得ます。だから有意かどうかとは別に、差の大きさ(効果量)を必ず併記します。用途に応じて次を使い分けます。
- リフト(相対変化):二値の指標で使いやすい。(pB − pA) / pA で「A比で何%増えたか」を表します。絶対差と併せて示すのが安全です。
- Cohen's d:連続値で使う、標準偏差を単位にした平均差。目安は 0.2 で小、0.5 で中、0.8 で大とされます(あくまで目安)。
平均差を「標準偏差いくつ分か」に換算した、単位に依存しない効果量です。
def cohens_d(x, y):
nx, ny = len(x), len(y)
# プール標準偏差(不偏分散を自由度で重み付け)
vx, vy = x.var(ddof=1), y.var(ddof=1)
pooled_sd = np.sqrt(((nx - 1) * vx + (ny - 1) * vy) / (nx + ny - 2))
return (x.mean() - y.mean()) / pooled_sd
d = cohens_d(dwell_b, dwell_a)
print(f"Cohen's d = {d:.3f} (滞在時間の効果量)")
# 二値: 絶対差とリフト(相対変化)
rate_a, rate_b = conv_a.mean(), conv_b.mean()
abs_diff = rate_b - rate_a
lift = abs_diff / rate_a
print(f"絶対差 = {abs_diff:.4f} リフト = {lift * 100:.1f}%")
Cohen's d = 0.177 (滞在時間の効果量)
絶対差 = 0.0260 リフト = 21.1%滞在時間の Cohen's d は約 0.18 で、目安では「小さめ」の効果です。統計的には強く有意(p が極めて小さい)でも、実質的な差は控えめだ、ということが効果量から分かります。一方コンバージョンは絶対差 2.6ポイント、リフト 21% で、ビジネス的には十分大きい可能性があります。有意性(偶然でないか)と効果量(どれだけ大きいか)は別物で、両方を見て初めて意思決定できます。
この Step のまとめ
効果量として、連続値は Cohen's d、二値はリフトと絶対差を出しました。p値が「差があるか」を、効果量が「どれだけ大きいか」を担います。報告では必ず両方に、信頼区間を添えて示します。
因果の考え方:なぜランダム化で因果が言えるのか
Why randomization gives causalityなぜ因果が言えるのかWhy it works
普通の観測データで「B案のほうが数字が良い」を見ても、それがB案の効果とは限りません。たとえば熱心なユーザーばかりが自らB案を選んでいたら、良い結果は施策ではなくユーザーの性質のせいかもしれません。このように結果と原因の両方に影響する隠れた要因を交絡(confounding)と呼びます。
A/Bテストの核心はランダム化割り当てです。誰をA群・B群にするかをコイン投げのように無作為に決めると、年齢・熱心さ・流入経路といったあらゆる背景要因が、平均的に両群へ均等に散らばります。既知の交絡だけでなく、まだ気づいていない交絡まで同時に打ち消せるのがランダム化の強力な点です。だから群間の差を、施策そのものの因果効果として読めるのです。これが観測研究に対する対照実験の決定的な優位です。
健全性の確認:A/AテストSanity check: A/A test
割り当てや集計の仕組みが正しいかを確かめるには、A/Aテストが有効です。同じ条件の母集団を2つにランダム分割し、本来は差がないはずのデータで検定します。ここで頻繁に有意差が出るなら、割り当てや計測に問題がある疑いがあります。
# 同じ真の率から2群を作り、差がないことを確認する(A/Aテスト)
aa1 = rng.binomial(1, p_a, n_a)
aa2 = rng.binomial(1, p_a, n_a)
table_aa = [[n_a - aa1.sum(), aa1.sum()],
[n_a - aa2.sum(), aa2.sum()]]
chi2_aa, p_aa, _, _ = chi2_contingency(table_aa)
print(f"A/A: chi2 = {chi2_aa:.3f}, p = {p_aa:.3f}")
# 健全なら p は大きく、有意にならないのが普通
A/A: chi2 = 0.352, p = 0.553落とし穴Pitfalls
やりがちな誤りCommon mistakes
- のぞき見(peeking):有意になるまで毎日結果を見て、出た瞬間に止める。これは第一種の誤りを大きく膨らませます。事前にサンプルサイズと期間を決めるのが基本です。
- 多重比較:指標や切り口を数多く試すと、どれかは偶然有意になります。検定を増やすほど、偽陽性の確率は上がります。
- p値の誤読:p値は「差がない確率」でも「効果の大きさ」でもありません。
対処の指針Safeguards
- 解析計画(主要指標・サンプルサイズ・期間)を事前に決めて固定する。
- 連続モニタリングが必要なら、逐次検定やアルファ消費関数など専用の手法を使う。
- 多重比較にはBonferroni補正などで有意水準を調整する。
- まずA/Aテストで仕組みの健全性を確かめる。
この Step のまとめ
ランダム化は、気づいていない交絡まで平均的に打ち消し、群間の差を因果効果として読めるようにします。ただしのぞき見や多重比較といった運用上の落とし穴が、その保証を壊します。A/Aテストで仕組みを点検し、計画を事前に固定するのが安全です。
結論と可視化:意思決定に落とす
Conclude & visualize何を・なぜWhat & why
最後に、結論を1枚の図にまとめます。点推定(各群のコンバージョン率)だけでなく、95%信頼区間をエラーバーで重ねるのが要点です。区間が重ならず離れているほど、差の確からしさが図からも伝わります。棒の高さだけを見せる図は、ばらつきを隠してしまうので避けます。
# 各群のコンバージョン率と、その95%信頼区間(Wald近似)
groups = ["A", "B"]
rates = np.array([conv_a.mean(), conv_b.mean()])
ns = np.array([n_a, n_b])
se = np.sqrt(rates * (1 - rates) / ns)
ci = 1.96 * se # 95%区間の半幅
fig, ax = plt.subplots(figsize=(5, 4))
ax.bar(groups, rates, yerr=ci, capsize=10,
color=["#B7C0BB", "#0E6F5C"])
ax.set_ylabel("conversion rate")
ax.set_title("A/B test: conversion rate (95% CI)")
for i, r in enumerate(rates):
ax.text(i, r + ci[i] + 0.004, f"{r:.3f}", ha="center")
plt.tight_layout(); plt.show()
図では、B群の棒がA群より高く、両群のエラーバー(95%信頼区間)が重ならずに離れています。数値の結論をまとめると次のとおりです。
- コンバージョン:B群がA群より約2.6ポイント高い(リフト 21%)。p = 0.0187、95%信頼区間は [0.5%, 4.7%] で 0 を含まない。
- 滞在時間:B群が約3.5秒長い。p は極めて小さいが、Cohen's d は約 0.18 と効果量は小さめ。
- 因果:ランダム化割り当てにより、この差はボタン変更そのものの効果として読める。
意思決定としては「B案を採用してよい」と言えます。ただし報告では、点推定・信頼区間・効果量・サンプルサイズ・期間をセットで示し、滞在時間の効果は控えめである点も正直に添えます。数字を1つだけ切り取らず、幅と大きさで語るのが誠実な伝え方です。
「有意」で止まらない
p < 0.05 は出発点であって結論ではありません。効果量が実務的に意味のある大きさか、コストに見合うか、他の指標を悪化させていないか。統計の結果に、ドメインの判断を重ねて初めて意思決定になります。
発展課題Try more
こう変えたら、どうなる?
パイプラインができたら、少しずつ条件を変えて挙動を確かめましょう。答えは用意しません。手を動かして、数字がどう動くかを観察するのが目的です。
サンプルサイズ設計(検定力)
- 検出したい効果量と有意水準・検定力(例 0.8)から、必要なサンプルサイズを逆算する。statsmodels の
NormalIndPowerやtt_ind_solve_powerを使い、真の差を小さくすると必要数がどう増えるかを見る。
連続モニタリングの罠
- 真に差がない設定(A/A)で、毎日p値を計算して「有意になったら止める」戦略を何度もシミュレートする。事前に固定した1回の検定に比べ、偽陽性がどれだけ増えるかを体感する。
ベイズA/Bテスト
- コンバージョンをBeta-Binomialでモデル化し、「B群がA群を上回る確率」を事後分布から求める。p値による判定と結論がどう対応・相違するかを比べる。
複数指標と補正
- コンバージョン・滞在時間・離脱率など複数の指標を同時に検定し、Bonferroni補正の有無で有意判定がどう変わるかを確かめる。主要指標を1つに絞る意義を考える。
まとめWrap-up
つないで見えたもの、次へ
この演習では、単体で学んだ3つの技術を1本のパイプラインにつなぎ、対照実験のデータから施策の効果を検証しました。振り返ると、それぞれのモジュールが次の役割を担っていました。
- 統計分析:帰無仮説・p値・信頼区間で、観測された差が偶然で説明しにくいかを判定した。
- 因果推論:ランダム化がなぜ交絡を断ち、差を因果効果として読めるのかを押さえた。
- データ可視化:分布と、効果量・信頼区間を図にして、判断の根拠を誤解なく伝えた。
有意性・効果量・因果・可視化を一続きに並べると、「差があるか」「どれだけ大きいか」「なぜそう言えるか」「どう伝えるか」がひとつの物語になります。各技術の詳しい理論やAPIは、下の各モジュールに戻ると確認できます。
次の演習・段階へ
- これが最後の演習です。演習一覧に戻って、まだ試していない実践編に取り組む。
- ほかの実践編で、リポジトリマイニングや時系列を軸にしたパイプラインも試す。
- 真の効果やサンプルサイズを変えて、検定が効果を拾える境目を自分で探ってみる。
おつかれさまでした
ノートブックを開き、p_a や p_b、サンプルサイズを変えて、もう一度上から実行してみてください。真の差を小さくしたり標本を減らしたりすると、有意にならなくなる境目が見えてきます。因果検証の考え方をさらに深めたい場合は、安井翔太『効果検証入門』などが実務に即した入り口になります。