あるWebサービスのデザインチームが、ボタンの色を青から緑に変えたらコンバージョン率が上がるかを知りたいとします。2つのバージョンをランダムに表示して比較する — これがA/Bテストの基本的なアイデアです。しかし、「緑のほうが5%多くクリックされた」という結果は、本当に色の効果なのでしょうか。それとも単なる偶然の揺らぎなのでしょうか。
この問いに答えるために、A/Bテストは統計学の力を借ります。適切な統計手法を使わなければ、偶然の差を「効果あり」と誤って判断したり、逆に本当の改善を見逃したりしてしまいます。
A/Bテストの統計学を理解すると、以下のような実践的な判断ができるようになります。
- 実験設計: テスト開始前に必要なサンプルサイズを決定し、無駄のない実験を行う
- 正しい判定: 偶然の揺らぎと真の効果の違いを、確率的に区別する
- 早期終了の制御: テスト途中で何度もp値を見る「覗き見問題」を回避する
- ベイズ的意思決定: 「施策Bが施策Aより良い確率は何%か」という直接的な問いに答える
本記事の内容
- A/Bテストの統計的フレームワーク(帰無仮説・対立仮説)
- 比率の差の検定(Z検定)の理論と導出
- サンプルサイズ設計の方法
- 覗き見問題と逐次検定による解決策
- ベイズ的A/Bテストの理論と実装
- Pythonでの総合的な実装と可視化
前提知識
この記事を読む前に、以下の記事を読んでおくと理解が深まります。
- 仮説検定の全体像 — 帰無仮説・対立仮説・p値の基本
- 効果量と検出力 — サンプルサイズ設計の基礎
- ベイズ統計 vs 頻度主義 — 2つのアプローチの違い
- ベータ分布を完全理解 — ベイズ的A/Bテストの共役事前分布
A/Bテストの統計的フレームワーク
A/Bテストの構造
A/Bテストとは、2つの処理(treatment)を比較するランダム化比較試験(Randomized Controlled Trial, RCT)の一種です。日常の例えで言えば、料理の味付けを2パターン作り、何人かの人に「どちらが美味しいか」を聞くのに似ています。ただし、A/Bテストが料理の味見と違うのは、各ユーザーが1つのパターンしか見ないという点です。これにより、バイアスのない比較が可能になります。
A/Bテストの典型的な構造は次のとおりです。
- ランダム割り当て: ユーザーをランダムにグループAまたはグループBに振り分ける
- 処理の適用: グループAには現行の施策(コントロール)、グループBには新しい施策(トリートメント)を適用する
- 指標の計測: 各グループのコンバージョン率やクリック率などを記録する
- 統計的検定: 観測された差が偶然の揺らぎでは説明できないかどうかを判定する
この枠組みを数学的に定式化しましょう。グループAのコンバージョン率を $p_A$、グループBのコンバージョン率を $p_B$ とします。ここで知りたいのは、$p_A$ と $p_B$ に本質的な差があるかどうかです。
仮説の設定
頻度主義の枠組みでは、仮説検定として定式化します。
$$ \begin{equation} H_0: p_A = p_B \quad \text{(帰無仮説: 差はない)} \end{equation} $$
$$ \begin{equation} H_1: p_A \neq p_B \quad \text{(対立仮説: 差がある)} \end{equation} $$
片側検定を使うこともあります。新しい施策が「改善する」ことだけに興味がある場合は、$H_1: p_B > p_A$ とします。しかし、新しい施策が悪化する可能性も検出したい場合は、両側検定を使うのが安全です。
2種類の過誤
検定の結果は4つのパターンに分かれます。
| $H_0$ が真(差がない) | $H_0$ が偽(差がある) | |
|---|---|---|
| $H_0$ を棄却(差ありと判定) | 第一種の過誤(偽陽性) | 正しい判定(検出成功) |
| $H_0$ を棄却しない(差なしと判定) | 正しい判定 | 第二種の過誤(偽陰性) |
- 第一種の過誤の確率 $\alpha$: 差がないのに「差あり」と判定する確率。通常 $\alpha = 0.05$ に設定
- 第二種の過誤の確率 $\beta$: 差があるのに「差なし」と判定する確率。検出力 = $1 – \beta$ で、通常 $1 – \beta \geq 0.80$ を目標にする
A/Bテストの文脈では、第一種の過誤は「効果のない施策をリリースしてしまうこと」、第二種の過誤は「本当に効果のある施策を見逃してしまうこと」に対応します。どちらもビジネス上のコストがあるため、両方の過誤率を管理する必要があります。
仮説検定の枠組みが定まったところで、具体的な検定統計量を導出しましょう。
比率の差の検定(Z検定)
標本比率の分布
グループAから $n_A$ 人中 $x_A$ 人がコンバージョンしたとき、標本比率は $\hat{p}_A = x_A / n_A$ です。各ユーザーのコンバージョンはベルヌーイ試行と見なせるため、コンバージョン数 $x_A$ は二項分布 $\text{Bin}(n_A, p_A)$ に従います。
サンプルサイズ $n_A$ が十分大きい場合(目安: $n_A p_A \geq 5$ かつ $n_A(1-p_A) \geq 5$)、中心極限定理により標本比率は近似的に正規分布に従います。
$$ \begin{equation} \hat{p}_A \sim \mathcal{N}\left(p_A, \frac{p_A(1-p_A)}{n_A}\right) \end{equation} $$
同様に $\hat{p}_B \sim \mathcal{N}\left(p_B, \frac{p_B(1-p_B)}{n_B}\right)$ です。
検定統計量の導出
2つの独立な正規確率変数の差は、やはり正規分布に従います。$\hat{p}_A$ と $\hat{p}_B$ は独立(ランダム割り当てにより)なので、
$$ \hat{p}_B – \hat{p}_A \sim \mathcal{N}\left(p_B – p_A, \frac{p_A(1-p_A)}{n_A} + \frac{p_B(1-p_B)}{n_B}\right) $$
帰無仮説 $H_0: p_A = p_B = p$(共通の比率を $p$ とする)の下では、差の期待値は0になります。
標準化するために、共通比率 $p$ をプール推定量で推定します。
$$ \begin{equation} \hat{p} = \frac{x_A + x_B}{n_A + n_B} \end{equation} $$
このプール推定量を使うと、$H_0$ の下での分散の推定値は次のようになります。
$$ \hat{\sigma}^2 = \hat{p}(1-\hat{p})\left(\frac{1}{n_A} + \frac{1}{n_B}\right) $$
以上から、検定統計量(Z統計量)は次のように定義されます。
$$ \begin{equation} Z = \frac{\hat{p}_B – \hat{p}_A}{\sqrt{\hat{p}(1-\hat{p})\left(\frac{1}{n_A} + \frac{1}{n_B}\right)}} \end{equation} $$
$H_0$ の下で $Z \sim \mathcal{N}(0, 1)$ に近似的に従います。
p値の計算と判定
両側検定の場合、p値は次のように計算します。
$$ \begin{equation} \text{p-value} = 2 \cdot \Phi(-|Z|) = 2 \cdot P(\mathcal{Z} \geq |Z|) \end{equation} $$
ここで $\Phi$ は標準正規分布の累積分布関数です。$\text{p-value} < \alpha$ ならば帰無仮説を棄却し、2つの比率に統計的に有意な差があると結論します。
信頼区間
比率の差 $p_B – p_A$ の $100(1-\alpha)\%$ 信頼区間も重要です。ここでは帰無仮説を仮定しないため、プール推定量ではなく各群の分散を使います。
$$ \begin{equation} (\hat{p}_B – \hat{p}_A) \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}} \end{equation} $$
信頼区間が0を含まなければ、差は統計的に有意です。信頼区間の幅は効果の大きさの不確実性を示すため、p値よりも情報量の多い指標と言えます。
検定統計量の導出ができたところで、テスト開始前に決めるべき最も重要な設計要素、すなわちサンプルサイズの決定方法を見ていきましょう。
サンプルサイズ設計
なぜ事前にサンプルサイズを決めるのか
A/Bテストで最もよくある失敗の一つは、サンプルサイズを事前に決めずにテストを始めることです。「十分なデータが集まったら止めよう」という曖昧な基準では、次のような問題が起こります。
- 効果がたまたま大きく見えた時点で止めてしまう: 偽陽性率が名目の $\alpha$ より遥かに高くなる
- いつまでも止められない: 効果が小さいと、検出力不足でいつまでもp値が下がらない
適切なサンプルサイズ設計には、次の4つのパラメータが必要です。
- 有意水準 $\alpha$: 偽陽性率の許容上限(通常 0.05)
- 検出力 $1 – \beta$: 検出したい確率(通常 0.80 以上)
- 最小検出効果量(MDE): 検出したい最小の差 $\delta = |p_B – p_A|$
- ベースラインのコンバージョン率 $p_A$: コントロール群の予想コンバージョン率
サンプルサイズの公式
両側検定で等しいグループサイズ $n = n_A = n_B$ の場合、必要なサンプルサイズは次の式で近似できます。
$$ \begin{equation} n \geq \frac{(z_{\alpha/2} + z_\beta)^2 \left[p_A(1-p_A) + p_B(1-p_B)\right]}{(p_B – p_A)^2} \end{equation} $$
ここで $z_{\alpha/2}$ と $z_\beta$ はそれぞれ標準正規分布の上側分位点です。
この公式を導出しましょう。検出力の条件は、対立仮説 $H_1: p_B – p_A = \delta$ の下で帰無仮説を棄却する確率が $1-\beta$ 以上であることです。
$H_1$ の下での比率の差の標準偏差は次のとおりです。
$$ \sigma_1 = \sqrt{\frac{p_A(1-p_A)}{n} + \frac{p_B(1-p_B)}{n}} = \sqrt{\frac{p_A(1-p_A) + p_B(1-p_B)}{n}} $$
$H_0$ の下での棄却点は $\delta = 0$ を中心に $\pm z_{\alpha/2} \sigma_0$ です。ここで $\sigma_0 = \sqrt{2\bar{p}(1-\bar{p})/n}$、$\bar{p} = (p_A + p_B)/2$ です。
検出力の条件を簡略化すると、次の近似式が得られます。
$$ \frac{\delta}{\sigma_1} \geq z_{\alpha/2} + z_\beta $$
$\sigma_1$ の定義を代入して $n$ について解くと、上記のサンプルサイズの公式が得られます。
実用的な計算例
ベースラインのコンバージョン率が $p_A = 0.10$(10%)で、5%の相対的な改善($p_B = 0.105$、つまり10.5%)を検出したいとします。$\alpha = 0.05$、$\beta = 0.20$ のとき、
$z_{0.025} = 1.96$、$z_{0.20} = 0.842$ を代入します。
$$ n \geq \frac{(1.96 + 0.842)^2 \left[0.10 \times 0.90 + 0.105 \times 0.895\right]}{(0.105 – 0.10)^2} $$
分子を計算すると、$(2.802)^2 \times [0.090 + 0.0940] = 7.85 \times 0.184 = 1.444$ です。
分母は $(0.005)^2 = 0.000025$ です。
$$ n \geq \frac{1.444}{0.000025} \approx 57{,}776 $$
各群約57,800人、合計約115,600人が必要です。5%の相対的な改善を検出するには、かなりのサンプルサイズが必要であることがわかります。
一方、20%の相対的な改善($p_B = 0.12$)を検出する場合は
$$ n \geq \frac{7.85 \times [0.090 + 0.1056]}{(0.02)^2} = \frac{7.85 \times 0.1956}{0.0004} \approx 3{,}837 $$
と大幅に少なくなります。検出したい効果の大きさがサンプルサイズに劇的に影響することが確認できます。
サンプルサイズの設計方法がわかったところで、テスト実行中に起こりがちな深刻な問題、「覗き見問題」について見ていきましょう。
覗き見問題と逐次検定
覗き見問題(Peeking Problem)とは
A/Bテストでよくある失敗パターンは、テスト途中でp値を何度も確認し、$p < 0.05$ になった時点で「有意だ」と判定してテストを終了してしまうことです。これを覗き見問題(peeking problem)または多重検定問題の時間的側面と呼びます。
なぜこれが問題なのでしょうか。直感的に説明します。コイン投げを10回行って表が7回出たら「偏ったコインだ」と結論するかもしれませんが、実は100回中10回ごとに結果を確認していたら、どこかのタイミングで偏りがあるように見える区間が出る確率は高くなります。
数学的には、$n$ 回の独立な検定を行うと、少なくとも1回偽陽性が出る確率は次のようになります。
$$ P(\text{少なくとも1回偽陽性}) = 1 – (1 – \alpha)^n $$
例えば $\alpha = 0.05$ で10回覗き見すると、$1 – 0.95^{10} \approx 0.40$ となり、偽陽性率は40%にまで膨れ上がります。
実際の研究によると、データが集まるたびにp値を確認する場合、無限に続けると偽陽性率はほぼ100%に達することが示されています。これは固定サンプルサイズの検定を逐次的に適用することの本質的な限界です。
対策1: ボンフェローニ補正
最も単純な対策は、覗き見の回数 $K$ に応じて有意水準を厳しくすることです。
$$ \alpha’ = \frac{\alpha}{K} $$
例えば5回確認するなら、各回の有意水準を $0.05/5 = 0.01$ にします。しかし、この方法は保守的すぎて検出力が大幅に低下します。
対策2: オブライエン・フレミング法
より洗練された方法がオブライエン・フレミング法(O’Brien-Fleming method)です。この方法では、テスト初期の棄却限界を非常に厳しくし、テスト終盤に近づくにつれて緩和します。
中間分析を $k = 1, 2, \ldots, K$ 回行う場合、各回の棄却限界は次のようになります。
$$ \begin{equation} Z_k^* = \frac{c}{\sqrt{t_k}} \end{equation} $$
ここで $t_k = k/K$ は情報分率(information fraction)、$c$ は全体の第一種の過誤率を $\alpha$ に制御する定数です。
例えば $K = 5$ 回の中間分析を行う場合、各段階の棄却限界は次のようになります($\alpha = 0.05$ の場合)。
| 段階 $k$ | 情報分率 $t_k$ | Z限界値(近似) | 名目p値(近似) |
|---|---|---|---|
| 1 | 0.2 | 4.56 | 0.000005 |
| 2 | 0.4 | 3.23 | 0.0012 |
| 3 | 0.6 | 2.63 | 0.0085 |
| 4 | 0.8 | 2.28 | 0.023 |
| 5 | 1.0 | 2.04 | 0.041 |
初期段階では非常に強い証拠がない限り棄却しないが、最終段階ではほぼ通常の有意水準に近づきます。この性質により、検出力の低下が最小限に抑えられます。
対策3: 常時有効p値(Always-Valid p-value)
近年注目されているアプローチが常時有効p値です。これは、任意のタイミングで検定を行っても第一種の過誤率が $\alpha$ 以下に保たれるp値です。
混合尤度比に基づくアプローチでは、次のような検定統計量を使います。
$$ \begin{equation} \Lambda_n = \int_{\delta > 0} \frac{L(\delta; X_1, \ldots, X_n)}{L(0; X_1, \ldots, X_n)} \, dH(\delta) \end{equation} $$
ここで $L(\delta; \cdot)$ は効果量 $\delta$ の下での尤度、$H(\delta)$ は対立仮説に対する混合分布です。$\Lambda_n \geq 1/\alpha$ のときに帰無仮説を棄却します。
この方法の理論的根拠はヴィルの不等式(Ville’s inequality)に基づいています。超マルチンゲール $M_n$ に対して、
$$ P\left(\sup_{n \geq 1} M_n \geq 1/\alpha\right) \leq \alpha $$
が成り立つため、いつ停止しても第一種の過誤率が制御されます。
覗き見問題の対策を理解したところで、まったく異なるアプローチ — ベイズ的なA/Bテストを見ていきましょう。
ベイズ的A/Bテスト
頻度主義の限界とベイズの動機
頻度主義のA/Bテストには、実務上いくつかの不便な点があります。
- 結果の解釈が間接的: p値は「帰無仮説が正しい場合にこの結果以上に極端な結果が得られる確率」であり、「施策Bが施策Aより良い確率」ではない
- 固定サンプルサイズの前提: テスト前にサンプルサイズを決め、途中で止めてはいけない
- 効果の大きさの不確実性: 信頼区間は提供するが、効果の事後分布は提供しない
ベイズ的アプローチでは、これらの問題を自然に解決できます。ベイズ的A/Bテストの答えは「施策Bが施策Aより良い確率は87%です」のような直接的な確率文であり、意思決定者にとって遥かに直感的です。
ベータ-二項モデル
コンバージョン率のベイズ的推定には、ベータ-二項モデルを使います。これはベータ分布が二項分布の共役事前分布であるという性質を利用した、計算的に効率的なモデルです。
事前分布として、各群のコンバージョン率に独立なベータ分布を仮定します。
$$ p_A \sim \text{Beta}(\alpha_A, \beta_A), \quad p_B \sim \text{Beta}(\alpha_B, \beta_B) $$
情報のない事前分布(無情報事前分布)としては $\text{Beta}(1, 1)$(一様分布)がよく使われます。過去のデータがある場合は、それを反映した事前分布を設定することもできます。
$n_A$ 人中 $x_A$ 人がコンバージョンしたデータを観測すると、ベイズの定理により事後分布は次のようになります。
$$ \begin{equation} p_A \mid x_A \sim \text{Beta}(\alpha_A + x_A, \beta_A + n_A – x_A) \end{equation} $$
同様に
$$ \begin{equation} p_B \mid x_B \sim \text{Beta}(\alpha_B + x_B, \beta_B + n_B – x_B) \end{equation} $$
ベータ分布の共役性のおかげで、MCMCのような近似手法を使わずに事後分布が解析的に得られます。
勝率の計算
「施策Bが施策Aより良い確率」は次のように定義されます。
$$ \begin{equation} P(p_B > p_A \mid \text{data}) = \int_0^1 \int_0^{p_B} f(p_A \mid \text{data}) \, f(p_B \mid \text{data}) \, dp_A \, dp_B \end{equation} $$
ここで $f(p_A \mid \text{data})$ と $f(p_B \mid \text{data})$ はそれぞれの事後分布の密度関数です。
この二重積分は一般には解析的に計算が困難ですが、モンテカルロシミュレーションで簡単に近似できます。事後分布からそれぞれ $N$ 個のサンプルを生成し、$p_B^{(i)} > p_A^{(i)}$ となるサンプルの割合を計算するだけです。
$$ \hat{P}(p_B > p_A \mid \text{data}) = \frac{1}{N}\sum_{i=1}^{N} \mathbb{1}[p_B^{(i)} > p_A^{(i)}] $$
期待損失による意思決定
ベイズ的A/Bテストでは、確率だけでなく期待損失(expected loss)に基づく意思決定も可能です。これは「間違った選択をした場合に平均的にどれだけ損をするか」を定量化するものです。
施策Bを選んだ場合の期待損失は次のように定義されます。
$$ \begin{equation} L_B = E[\max(p_A – p_B, 0) \mid \text{data}] \end{equation} $$
同様に、施策Aを選んだ場合の期待損失は
$$ L_A = E[\max(p_B – p_A, 0) \mid \text{data}] $$
期待損失が十分小さくなったら(例えば $L_B < \epsilon$ で $\epsilon = 0.001$)、施策Bを採用する、という意思決定ルールを使います。
この方法の利点は、勝率が50%に近いときでも「どちらを選んでも損失は小さい」という状況を識別できることです。勝率95%でも期待損失が無視できるほど小さい場合があり、逆に勝率90%でも期待損失が大きい場合があります。
ベイズ的アプローチの理論を理解したところで、頻度主義とベイズの両方をPythonで実装して比較してみましょう。
Pythonでの実装
頻度主義的A/Bテストの実装
まず、比率の差のZ検定をスクラッチで実装します。このコードでは、検定統計量の計算、p値の算出、信頼区間の構成を一通り行います。
import numpy as np
from scipy import stats
def ab_test_z(x_a, n_a, x_b, n_b, alpha=0.05):
"""比率の差のZ検定(両側)"""
# 標本比率
p_a = x_a / n_a
p_b = x_b / n_b
# プール推定量
p_pool = (x_a + x_b) / (n_a + n_b)
# 検定統計量
se = np.sqrt(p_pool * (1 - p_pool) * (1/n_a + 1/n_b))
z = (p_b - p_a) / se
# p値
p_value = 2 * stats.norm.sf(abs(z))
# 信頼区間(非プール分散を使用)
se_ci = np.sqrt(p_a*(1-p_a)/n_a + p_b*(1-p_b)/n_b)
z_crit = stats.norm.ppf(1 - alpha/2)
ci_lower = (p_b - p_a) - z_crit * se_ci
ci_upper = (p_b - p_a) + z_crit * se_ci
return {
'p_a': p_a, 'p_b': p_b,
'diff': p_b - p_a,
'z_stat': z, 'p_value': p_value,
'ci': (ci_lower, ci_upper),
'significant': p_value < alpha
}
# 例: A群 1000人中100人コンバージョン、B群 1000人中130人コンバージョン
result = ab_test_z(100, 1000, 130, 1000)
print("=== 頻度主義的 A/Bテスト ===")
print(f"コンバージョン率: A = {result['p_a']:.3f}, B = {result['p_b']:.3f}")
print(f"差: {result['diff']:.3f}")
print(f"Z統計量: {result['z_stat']:.3f}")
print(f"p値: {result['p_value']:.4f}")
print(f"95%信頼区間: ({result['ci'][0]:.4f}, {result['ci'][1]:.4f})")
print(f"統計的に有意: {result['significant']}")
この実装では、A群のコンバージョン率10.0%に対してB群が13.0%であり、3ポイントの差が統計的に有意かどうかを検定しています。Z統計量は約2.23、p値は約0.026となり、5%水準で有意です。95%信頼区間は約(0.004, 0.056)で0を含まないため、検定結果と整合します。
サンプルサイズ計算の実装
次に、サンプルサイズの計算を実装します。
def sample_size_two_proportions(p_a, mde, alpha=0.05, power=0.80, sides=2):
"""2群の比率の差のA/Bテストに必要なサンプルサイズを計算"""
p_b = p_a + mde
if sides == 2:
z_alpha = stats.norm.ppf(1 - alpha/2)
else:
z_alpha = stats.norm.ppf(1 - alpha)
z_beta = stats.norm.ppf(power)
# 各群のサンプルサイズ
n = ((z_alpha + z_beta)**2 * (p_a*(1-p_a) + p_b*(1-p_b))) / mde**2
return int(np.ceil(n))
# 様々な条件でのサンプルサイズ
print("必要サンプルサイズ(各群、α=0.05, Power=0.80, 両側):")
print("-" * 50)
for p_a in [0.05, 0.10, 0.20]:
for rel_mde in [0.05, 0.10, 0.20]:
mde = p_a * rel_mde
n = sample_size_two_proportions(p_a, mde)
print(f" p_A={p_a:.2f}, 相対MDE={rel_mde:.0%} (絶対MDE={mde:.4f}): "
f"n={n:,} /群, 合計={2*n:,}")
この出力から、ベースラインのコンバージョン率と検出したい効果の大きさによって必要なサンプルサイズが大きく変わることがわかります。ベースラインが低いほど、また検出したい効果が小さいほど、より多くのサンプルが必要です。特に相対的な改善が5%の場合は数万から数十万のサンプルが各群に必要となり、大規模なサービスでないと実施が難しいことが読み取れます。
ベイズ的A/Bテストの実装
ベイズ的アプローチをモンテカルロシミュレーションで実装します。
def bayesian_ab_test(x_a, n_a, x_b, n_b,
prior_alpha=1, prior_beta=1,
n_samples=100000):
"""ベイズ的A/Bテスト"""
# 事後分布のパラメータ
post_a_alpha = prior_alpha + x_a
post_a_beta = prior_beta + n_a - x_a
post_b_alpha = prior_alpha + x_b
post_b_beta = prior_beta + n_b - x_b
# 事後分布からサンプリング
samples_a = np.random.beta(post_a_alpha, post_a_beta, n_samples)
samples_b = np.random.beta(post_b_alpha, post_b_beta, n_samples)
# Bが勝つ確率
prob_b_wins = np.mean(samples_b > samples_a)
# 差の分布
diff_samples = samples_b - samples_a
# 期待損失
loss_b = np.mean(np.maximum(samples_a - samples_b, 0)) # Bを選んだときの損失
loss_a = np.mean(np.maximum(samples_b - samples_a, 0)) # Aを選んだときの損失
# 相対的な改善率の分布
relative_uplift = (samples_b - samples_a) / samples_a
return {
'prob_b_wins': prob_b_wins,
'diff_mean': np.mean(diff_samples),
'diff_ci': (np.percentile(diff_samples, 2.5),
np.percentile(diff_samples, 97.5)),
'loss_a': loss_a,
'loss_b': loss_b,
'relative_uplift_mean': np.mean(relative_uplift),
'relative_uplift_ci': (np.percentile(relative_uplift, 2.5),
np.percentile(relative_uplift, 97.5)),
'samples_a': samples_a,
'samples_b': samples_b,
'diff_samples': diff_samples
}
np.random.seed(42)
bayes = bayesian_ab_test(100, 1000, 130, 1000)
print("\n=== ベイズ的 A/Bテスト ===")
print(f"P(B > A | data) = {bayes['prob_b_wins']:.4f}")
print(f"差の事後平均: {bayes['diff_mean']:.4f}")
print(f"差の95%信用区間: ({bayes['diff_ci'][0]:.4f}, {bayes['diff_ci'][1]:.4f})")
print(f"期待損失 (Bを選択): {bayes['loss_b']:.5f}")
print(f"期待損失 (Aを選択): {bayes['loss_a']:.5f}")
print(f"相対的改善率の事後平均: {bayes['relative_uplift_mean']:.2%}")
print(f"相対的改善率の95%信用区間: ({bayes['relative_uplift_ci'][0]:.2%}, "
f"{bayes['relative_uplift_ci'][1]:.2%})")
ベイズ的分析の結果は直接的に解釈できます。「施策Bが施策Aより良い確率は約98%」「施策Bを選んだ場合の期待損失はほぼ0」という結果が得られ、意思決定者にとって分かりやすい指標となっています。なお、95%信用区間はベイズの信用区間であり、頻度主義の信頼区間とは解釈が異なります。ベイズの信用区間は「真の値がこの区間に含まれる確率が95%」という直接的な確率文として解釈できます。
総合的な可視化
頻度主義とベイズのアプローチを比較する総合的な可視化を行います。
import numpy as np
import matplotlib.pyplot as plt
from scipy import stats
np.random.seed(42)
# テストデータ
x_a, n_a = 100, 1000
x_b, n_b = 130, 1000
fig, axes = plt.subplots(2, 3, figsize=(18, 11))
# (a) 事後分布の比較
ax = axes[0][0]
post_a = stats.beta(1 + x_a, 1 + n_a - x_a)
post_b = stats.beta(1 + x_b, 1 + n_b - x_b)
x_range = np.linspace(0.06, 0.18, 500)
ax.plot(x_range, post_a.pdf(x_range), 'b-', linewidth=2, label=f'A posterior (CVR={x_a/n_a:.1%})')
ax.fill_between(x_range, post_a.pdf(x_range), alpha=0.2, color='blue')
ax.plot(x_range, post_b.pdf(x_range), 'r-', linewidth=2, label=f'B posterior (CVR={x_b/n_b:.1%})')
ax.fill_between(x_range, post_b.pdf(x_range), alpha=0.2, color='red')
ax.set_xlabel('Conversion rate', fontsize=11)
ax.set_ylabel('Density', fontsize=11)
ax.set_title('(a) Posterior Distributions', fontsize=12)
ax.legend(fontsize=9)
ax.grid(True, alpha=0.3)
# (b) 差の事後分布
ax = axes[0][1]
samples_a = np.random.beta(1 + x_a, 1 + n_a - x_a, 100000)
samples_b = np.random.beta(1 + x_b, 1 + n_b - x_b, 100000)
diff = samples_b - samples_a
ax.hist(diff, bins=100, density=True, alpha=0.7, color='green', edgecolor='none')
ax.axvline(0, color='black', linewidth=2, linestyle='--', label='No difference')
ax.axvline(np.mean(diff), color='red', linewidth=2, label=f'Mean = {np.mean(diff):.4f}')
ci_low, ci_high = np.percentile(diff, [2.5, 97.5])
ax.axvline(ci_low, color='orange', linewidth=1.5, linestyle=':', label=f'95% CI: [{ci_low:.4f}, {ci_high:.4f}]')
ax.axvline(ci_high, color='orange', linewidth=1.5, linestyle=':')
prob_positive = np.mean(diff > 0)
ax.fill_between([0, max(diff)], 0, 0.1, alpha=0.1, color='green',
transform=ax.get_xaxis_transform())
ax.set_xlabel('$p_B - p_A$', fontsize=11)
ax.set_ylabel('Density', fontsize=11)
ax.set_title(f'(b) Posterior of Difference (P(B>A)={prob_positive:.1%})', fontsize=12)
ax.legend(fontsize=8)
ax.grid(True, alpha=0.3)
# (c) 覗き見問題のシミュレーション
ax = axes[0][2]
n_sims = 2000
max_n = 5000
check_points = np.arange(100, max_n + 1, 50)
true_p = 0.10 # 帰無仮説が真(両群とも同じ比率)
false_positive_fixed = []
false_positive_peeking = []
for _ in range(n_sims):
data_a = np.random.binomial(1, true_p, max_n)
data_b = np.random.binomial(1, true_p, max_n)
peeking_rejected = False
for cp in check_points:
xa = data_a[:cp].sum()
xb = data_b[:cp].sum()
p_pool = (xa + xb) / (2 * cp)
if p_pool == 0 or p_pool == 1:
continue
se = np.sqrt(p_pool * (1-p_pool) * 2/cp)
z = (xb/cp - xa/cp) / se
p_val = 2 * stats.norm.sf(abs(z))
if p_val < 0.05:
peeking_rejected = True
break
false_positive_peeking.append(peeking_rejected)
# 固定サンプルサイズでの検定
xa = data_a.sum()
xb = data_b.sum()
p_pool = (xa + xb) / (2 * max_n)
se = np.sqrt(p_pool * (1-p_pool) * 2/max_n)
z = (xb/max_n - xa/max_n) / se
p_val = 2 * stats.norm.sf(abs(z))
false_positive_fixed.append(p_val < 0.05)
fpr_peeking = np.mean(false_positive_peeking)
fpr_fixed = np.mean(false_positive_fixed)
# 確認回数による偽陽性率の変化
check_counts = [1, 2, 5, 10, 20, 50, 100]
fprs = []
for k in check_counts:
cp_subset = np.linspace(100, max_n, k, dtype=int)
fp_count = 0
for _ in range(n_sims):
da = np.random.binomial(1, true_p, max_n)
db = np.random.binomial(1, true_p, max_n)
for cp in cp_subset:
xa = da[:cp].sum()
xb = db[:cp].sum()
pp = (xa + xb) / (2 * cp)
if pp == 0 or pp == 1:
continue
se = np.sqrt(pp * (1-pp) * 2/cp)
z = (xb/cp - xa/cp) / se
if 2 * stats.norm.sf(abs(z)) < 0.05:
fp_count += 1
break
fprs.append(fp_count / n_sims)
ax.plot(check_counts, fprs, 'ro-', linewidth=2, markersize=6, label='Peeking FPR')
ax.axhline(0.05, color='blue', linewidth=2, linestyle='--', label='Nominal $\\alpha = 0.05$')
ax.set_xlabel('Number of peeks', fontsize=11)
ax.set_ylabel('False Positive Rate', fontsize=11)
ax.set_title('(c) Peeking Problem', fontsize=12)
ax.legend(fontsize=9)
ax.grid(True, alpha=0.3)
ax.set_xscale('log')
# (d) サンプルサイズと必要期間
ax = axes[1][0]
daily_traffic = [100, 500, 1000, 5000, 10000, 50000]
p_a_base = 0.10
rel_mdes = [0.05, 0.10, 0.20]
z_a = stats.norm.ppf(0.975)
z_b = stats.norm.ppf(0.80)
bar_width = 0.25
x_pos = np.arange(len(daily_traffic))
for i, rel_mde in enumerate(rel_mdes):
mde = p_a_base * rel_mde
p_b = p_a_base + mde
n_per_group = ((z_a + z_b)**2 * (p_a_base*(1-p_a_base) + p_b*(1-p_b))) / mde**2
n_per_group = int(np.ceil(n_per_group))
days_needed = [2 * n_per_group / dt for dt in daily_traffic]
ax.bar(x_pos + i*bar_width, days_needed, bar_width,
label=f'Relative MDE = {rel_mde:.0%}', alpha=0.8)
ax.set_xlabel('Daily traffic per group', fontsize=11)
ax.set_ylabel('Days needed', fontsize=11)
ax.set_title(f'(d) Test Duration ($p_A$={p_a_base:.0%})', fontsize=12)
ax.set_xticks(x_pos + bar_width)
ax.set_xticklabels([f'{d:,}' for d in daily_traffic], rotation=30)
ax.legend(fontsize=9)
ax.grid(True, alpha=0.3, axis='y')
ax.set_yscale('log')
# (e) ベイズ的A/Bテストの経時変化
ax = axes[1][1]
true_pa = 0.10
true_pb = 0.13
max_obs = 2000
np.random.seed(123)
data_a_seq = np.random.binomial(1, true_pa, max_obs)
data_b_seq = np.random.binomial(1, true_pb, max_obs)
obs_points = np.arange(20, max_obs + 1, 10)
prob_b_wins_seq = []
loss_b_seq = []
for t in obs_points:
xa = data_a_seq[:t].sum()
xb = data_b_seq[:t].sum()
sa = np.random.beta(1 + xa, 1 + t - xa, 50000)
sb = np.random.beta(1 + xb, 1 + t - xb, 50000)
prob_b_wins_seq.append(np.mean(sb > sa))
loss_b_seq.append(np.mean(np.maximum(sa - sb, 0)))
ax.plot(obs_points, prob_b_wins_seq, 'r-', linewidth=2, label='$P(p_B > p_A | \\mathrm{data})$')
ax.axhline(0.95, color='green', linewidth=1.5, linestyle='--', label='Threshold = 0.95')
ax.axhline(0.5, color='gray', linewidth=1, linestyle=':', alpha=0.5)
# 95%を初めて超えた時点
first_cross = None
for i, p in enumerate(prob_b_wins_seq):
if p >= 0.95:
first_cross = obs_points[i]
break
if first_cross:
ax.axvline(first_cross, color='green', linewidth=1, linestyle='-', alpha=0.3)
ax.annotate(f'First crosses 95%\nat n={first_cross}',
xy=(first_cross, 0.95), fontsize=9,
xytext=(first_cross + 200, 0.75),
arrowprops=dict(arrowstyle='->', color='green'))
ax.set_xlabel('Observations per group', fontsize=11)
ax.set_ylabel('Probability', fontsize=11)
ax.set_title(f'(e) Bayesian A/B Test Over Time\n($p_A$={true_pa}, $p_B$={true_pb})', fontsize=12)
ax.legend(fontsize=9, loc='center right')
ax.grid(True, alpha=0.3)
ax.set_ylim(0.3, 1.05)
# (f) 頻度主義 vs ベイズの判定結果比較
ax = axes[1][2]
np.random.seed(42)
n_experiments = 500
p_a_true = 0.10
effects = np.random.choice([0, 0.01, 0.02, 0.03, 0.05], n_experiments,
p=[0.4, 0.15, 0.15, 0.15, 0.15])
test_n = 500
freq_decisions = []
bayes_decisions = []
for eff in effects:
p_b_true = p_a_true + eff
xa = np.random.binomial(test_n, p_a_true)
xb = np.random.binomial(test_n, p_b_true)
# 頻度主義
p_pool = (xa + xb) / (2 * test_n)
if p_pool == 0 or p_pool == 1:
freq_decisions.append(False)
else:
se = np.sqrt(p_pool * (1-p_pool) * 2 / test_n)
z = (xb/test_n - xa/test_n) / se
freq_decisions.append(2 * stats.norm.sf(abs(z)) < 0.05)
# ベイズ(期待損失 < 0.001 かつ P(B>A) > 0.95)
sa = np.random.beta(1 + xa, 1 + test_n - xa, 50000)
sb = np.random.beta(1 + xb, 1 + test_n - xb, 50000)
p_b_better = np.mean(sb > sa)
exp_loss = np.mean(np.maximum(sa - sb, 0))
bayes_decisions.append(p_b_better > 0.95 and exp_loss < 0.001)
# 効果あり/なし別の判定率
true_null = effects == 0
true_alt = effects > 0
results = {
'Frequentist': {
'FPR': np.mean(np.array(freq_decisions)[true_null]),
'TPR': np.mean(np.array(freq_decisions)[true_alt]),
},
'Bayesian': {
'FPR': np.mean(np.array(bayes_decisions)[true_null]),
'TPR': np.mean(np.array(bayes_decisions)[true_alt]),
}
}
categories = ['FPR\n(False Positive)', 'TPR\n(True Positive)']
freq_vals = [results['Frequentist']['FPR'], results['Frequentist']['TPR']]
bayes_vals = [results['Bayesian']['FPR'], results['Bayesian']['TPR']]
x_cat = np.arange(len(categories))
ax.bar(x_cat - 0.15, freq_vals, 0.3, label='Frequentist', color='steelblue', alpha=0.8)
ax.bar(x_cat + 0.15, bayes_vals, 0.3, label='Bayesian', color='coral', alpha=0.8)
for i, (fv, bv) in enumerate(zip(freq_vals, bayes_vals)):
ax.text(i - 0.15, fv + 0.01, f'{fv:.2f}', ha='center', fontsize=10)
ax.text(i + 0.15, bv + 0.01, f'{bv:.2f}', ha='center', fontsize=10)
ax.set_xticks(x_cat)
ax.set_xticklabels(categories, fontsize=11)
ax.set_ylabel('Rate', fontsize=11)
ax.set_title(f'(f) Frequentist vs Bayesian (n={test_n}/group)', fontsize=12)
ax.legend(fontsize=10)
ax.grid(True, alpha=0.3, axis='y')
ax.set_ylim(0, max(max(freq_vals), max(bayes_vals)) + 0.15)
plt.tight_layout()
plt.savefig('ab_testing_statistics.png', dpi=150, bbox_inches='tight')
plt.show()
6つのグラフから、A/Bテストの統計的な性質が包括的に読み取れます。
-
左上(事後分布): グループAとBの事後分布が描かれています。B群の分布がA群より右に位置しており、重なりはあるものの明確な差が見えます。重なりの大きさが判定の不確実性を直感的に表しています
-
中上(差の事後分布): $p_B – p_A$ の事後分布です。分布のほとんどが0より右にあり、P(B > A) が高いことが視覚的に確認できます。赤い線で示された事後平均と、オレンジの点線で示された95%信用区間が差の大きさの推定を示しています
-
右上(覗き見問題): 確認回数が増えるにつれて偽陽性率が名目の5%から大幅に上昇する様子が明確に見えます。100回の覗き見では偽陽性率が40%近くに達しており、覗き見の危険性を如実に示しています
-
左下(テスト期間): 日次トラフィックと検出したい効果の大きさに応じたテスト必要日数です。トラフィックが少ないサービスでは、小さな効果の検出に数ヶ月から数年かかることもあり、実験設計の現実的な制約が明確です
-
中下(ベイズ的経時変化): ベイズ的A/Bテストでデータが蓄積されるにつれてP(B > A)が上昇していく様子です。95%の閾値を超えた時点で判定できますが、この閾値の設定はビジネス要件に応じて調整できます
-
右下(頻度主義 vs ベイズ): 500回のシミュレーションで両アプローチの判定を比較しています。ベイズ的アプローチは偽陽性率が頻度主義より低い傾向がありますが、その代わりに真陽性率もやや低くなる場合があります。これは判定基準の保守性の違いを反映しています
実務上の落とし穴と対策
落とし穴1: ノベルティ効果と時系列バイアス
新しいデザインを見たユーザーは、珍しさから一時的にクリック率が上がることがあります(ノベルティ効果)。逆に、変化への抵抗で一時的にパフォーマンスが下がることもあります(変化への嫌悪)。
対策として、テスト期間を十分長く取ること(少なくとも1-2週間のビジネスサイクルをカバー)が重要です。また、テスト開始直後のデータを除外するバーンイン期間を設けることも有効です。
落とし穴2: 複数指標の検定
コンバージョン率、クリック率、滞在時間、売上金額など、複数の指標を同時に検定すると、多重検定問題が発生します。5つの指標を同時に検定すると、偽陽性率は $1 – 0.95^5 \approx 0.23$ にまで上昇します。
この問題に対処するには、テスト前に主要評価指標(OEC: Overall Evaluation Criterion)を1つ決め、その他の指標は探索的な分析として扱います。統計的な補正を行う場合は、ボンフェローニ補正やBenjamini-Hochberg法(FDR制御)を適用します。
落とし穴3: サンプル比率の不一致
A群とB群のサンプルサイズが大きく異なる場合、ランダム割り当てに問題がある可能性があります。これはサンプル比率の不一致(Sample Ratio Mismatch, SRM)と呼ばれ、テスト結果の信頼性を根本から揺るがします。
SRMの検出にはカイ二乗検定を使います。
def check_srm(n_a, n_b, expected_ratio=0.5):
"""サンプル比率の不一致を検出"""
n_total = n_a + n_b
expected_a = n_total * expected_ratio
expected_b = n_total * (1 - expected_ratio)
chi2 = (n_a - expected_a)**2 / expected_a + (n_b - expected_b)**2 / expected_b
p_value = 1 - stats.chi2.cdf(chi2, df=1)
return {
'chi2': chi2,
'p_value': p_value,
'ratio': n_a / n_total,
'srm_detected': p_value < 0.001 # 厳しめの閾値
}
# 正常な場合
srm1 = check_srm(4980, 5020)
print(f"\n=== SRM検出 ===")
print(f"正常例 (4980 vs 5020): p={srm1['p_value']:.4f}, SRM={srm1['srm_detected']}")
# 異常な場合
srm2 = check_srm(4700, 5300)
print(f"異常例 (4700 vs 5300): p={srm2['p_value']:.6f}, SRM={srm2['srm_detected']}")
正常な場合(4980 vs 5020)ではp値が大きく、ランダム割り当ては正常に機能していると判断できます。一方、異常な場合(4700 vs 5300)ではp値が非常に小さく、SRMが検出されます。SRMが検出された場合は、テスト結果を信用せず、実装上のバグを調査する必要があります。
落とし穴4: 分散削減を怠る
テストに必要なサンプルサイズを削減するためのテクニックがいくつか存在します。
CUPED(Controlled-experiment Using Pre-Experiment Data) は、テスト前のデータを共変量として使い、指標の分散を削減する手法です。共変量調整後の指標は
$$ \hat{Y}_{\text{cv}} = \hat{Y} – \theta (\hat{X} – \bar{X}) $$
と定義されます。ここで $\hat{X}$ はテスト前の指標、$\theta = \text{Cov}(X, Y) / \text{Var}(X)$ は最適な調整係数です。
分散の削減率は
$$ \text{Var}(\hat{Y}_{\text{cv}}) = \text{Var}(\hat{Y})(1 – \rho^2_{XY}) $$
であり、テスト前後の指標の相関 $\rho_{XY}$ が高いほど大きな分散削減が得られます。$\rho = 0.5$ なら必要サンプルサイズを25%削減でき、$\rho = 0.8$ なら36%削減できます。
これらの落とし穴と対策を踏まえて、最後に頻度主義とベイズのアプローチの使い分けを整理しましょう。
頻度主義 vs ベイズ: 使い分けの指針
比較表
| 観点 | 頻度主義 | ベイズ |
|---|---|---|
| 主要な出力 | p値、信頼区間 | 事後確率、信用区間、期待損失 |
| 解釈の直感性 | 間接的(帰無仮説の下での確率) | 直接的(パラメータの確率分布) |
| 事前情報の活用 | 不可 | 可(事前分布として組込み) |
| 逐次的な意思決定 | 覗き見問題あり(要対策) | 自然に対応可 |
| 計算コスト | 低い | やや高い(MCMCの場合) |
| 理論的保証 | 第一種の過誤率の厳密な制御 | 事前分布に依存 |
| 業界標準 | 伝統的に主流 | テック企業で採用が増加中 |
どちらを使うべきか
頻度主義が適する場面:
- 規制が厳しい分野(医薬品の臨床試験など)で、第一種の過誤率の厳密な制御が求められる場合
- テストを事前に設計し、固定サンプルサイズで実行できる場合
- 分析者の主観を排除する必要がある場合
ベイズが適する場面:
- ビジネスの意思決定で「Bが良い確率は何%か」という直接的な答えが欲しい場合
- テストの早期終了を柔軟に行いたい場合
- 過去のテスト結果を事前分布として活用したい場合
- 複数のバリエーション(A/B/Cテスト)を同時に比較する場合
実務的には、両方を併用するのが理想的です。頻度主義で厳密な実験設計を行い、ベイズ的分析で直感的な意思決定を支援するというアプローチが、多くのテック企業で採用されています。
まとめ
本記事では、A/Bテストの統計学を頻度主義とベイズの両面から解説しました。
- A/Bテストはランダム化比較試験であり、統計的検定によって偶然の揺らぎと真の効果を区別する
- 頻度主義のアプローチでは、比率の差のZ検定を使い、p値と信頼区間で判定する
- サンプルサイズの事前設計が不可欠であり、検出したい効果の大きさに強く依存する
- 覗き見問題はA/Bテストの最も一般的な落とし穴であり、逐次検定で対策できる
- ベイズ的アプローチでは、ベータ-二項モデルにより「BがAより良い確率」を直接計算できる
- 期待損失に基づく意思決定は、勝率だけでなく損失の大きさも考慮した合理的な判断を可能にする
- 実務では、SRMチェック、ノベルティ効果、多重検定問題、分散削減(CUPED)など多くの注意点がある
次のステップとして、以下の記事も参考にしてください。
- 多重検定問題とBonferroni・FDR補正 — 複数指標の同時検定
- ベイズの定理とは? — ベイズ統計の基礎
- ベータ分布を完全理解 — 共役事前分布の詳細
- 効果量と検出力 — サンプルサイズ設計の理論