A/Bの実務と罠 ― 割付・SRM・多重比較・後付け分析
「ランダム割付の作法」「標本比率ミスマッチ(SRM)の検知」「複数指標・複数群での多重比較」「セグメントを後から掘る分析の危険」を整理します。正しく回しているつもりのA/Bがなぜ誤るのか、その実務的な落とし穴をPythonの最小コードで一通り押さえます。
by Shin
正しく回しているはずのA/Bは、どこで誤るのか。典型は三つ——「割付の崩れ(SRM)」「指標と群を増やしたことによる多重比較」そして「結果を見てからセグメントを掘る後付け分析」です。検出力もメトリクスも前回までで設計したのに、最後の最後で実行段階のバグや解析の作法で結論がひっくり返る。この記事では、その実務的な落とし穴を一つずつ、検知と補正のコードまで含めて押さえます。
本連載は「介入を自分で設計して因果を測る」側です。観測データから因果に迫る手法(DiD・CausalImpactなど)は因果編が担当で、本記事はその対になる「実験を正しく回す」側。ここでは観測手法には立ち入らず、割付と解析の衛生だけを扱います。素材は前回までと同じく、取引件数の多い架空のD2C企業SOLNA(数値は説明用に丸めています)です。何を成功と判定するかは第2回・メトリクス設計で先に固定済みとして進めます。
ランダム割付の作法
最初の罠は実行段階にあります。「ランダムに振る」を素朴に random() で書くと壊れます。リクエストのたびに乱数を引くと、同じユーザーが訪問ごとに別の群へ飛び、両方のUIを見てしまう。割付は**決定的(deterministic)**でなければなりません。ユーザーIDと実験キーを結合してハッシュし、その剰余でバケットを決める。こうすれば状態を持たずに、同じユーザーは何度来ても同じ群に落ちます。
import hashlib
def assign(user_id: str, experiment_key: str, n_buckets: int = 1000) -> str:
# user_id と実験キーを結合してハッシュ → 剰余でバケット決定(決定的・冪等)
raw = f"{user_id}:{experiment_key}".encode("utf-8")
bucket = int(hashlib.sha256(raw).hexdigest(), 16) % n_buckets
return "treatment" if bucket < n_buckets // 2 else "control"
# 10万人を割り付けてバランス確認
from collections import Counter
balance = Counter(assign(f"u{i}", "checkout_btn_2026q2") for i in range(100_000))
print(balance) # Counter({'control': 50063, 'treatment': 49937})実験キー(ソルト)を必ず混ぜるのが要点です。これを省くと、同時に走る複数の実験で「同じユーザーが毎回同じ側」に固定され、実験間で割付が相関します。実験Aで処置だった人が実験Bでも処置に偏れば、効果の切り分けができません。実験ごとに別のソルトを与えれば、各実験の割付は互いに独立になります。
もう一つ、見落とされがちなのが割付単位=分析単位の一致です。ユーザー単位で割り付けたなら、分析もユーザー単位で集計する。ユーザーで割り付けておいてセッション単位やイベント単位で検定すると、同一ユーザー内の観測が相関しているのに独立とみなすことになり、分散を過小評価して偽陽性が増えます。SOLNAは購入が複数セッションにまたがるためユーザー単位で割り、指標もユーザー単位(CVRなら「購入した人数 / 群の人数」)で測ります。配信面や供給面のように個人で分けられない施策は、また別の設計が要る——これは第6回・geo実験/switchbackで扱います。
SRM(標本比率ミスマッチ)を検知する
割付を50:50で設計したのに、蓋を開けたら実測が大きくズレている。これがSRM(Sample Ratio Mismatch、標本比率ミスマッチ)です。SRMが出たら、その実験の結果は読んではいけません。 割付や記録のどこかが壊れているサインで、効果の推定値そのものが信用できなくなるからです。検知は単純で、観測度数を設計比率に対してカイ二乗適合度検定にかけるだけです。
from scipy.stats import chisquare
# SOLNA: 2週間・ユーザー単位50/50割付(説明用の丸め値)
observed = [120_400, 119_600] # [control, treatment]
expected = [sum(observed) * 0.5] * 2 # 設計比率 50/50
stat, p = chisquare(f_obs=observed, f_exp=expected)
print(f"chi2={stat:.2f}, p={p:.4f}") # chi2=2.67, p=0.1025 → SRMなし
# 壊れた割付の例(処置側が欠落)
observed_bad = [124_000, 116_000]
stat_b, p_b = chisquare(f_obs=observed_bad, f_exp=[sum(observed_bad)*0.5]*2)
print(f"chi2={stat_b:.2f}, p={p_b:.2e}") # chi2=266.67, p=6.05e-60SOLNAの実測は対照 約12.04万 / 処置 約11.96万で、、p = 0.1025。設計比率からのズレは偶然の範囲で、SRMなしと判定できます。一方、記録の欠落で処置側が約11.6万まで減り、対照側が約12.4万に振れていれば(壊れた例)、p ≈ 6e-60。これは偶然ではまず起きない乖離です。
SRMの原因の多くは実装側にあります。よくあるのは、「ボット・スパム除去を片群だけに強くかけてしまう」「リダイレクトの遅延で処置群の離脱だけ増える」「ログの重複排除や集計タイミングが群でズレる」といったもの。いずれも「効果の前」で数が崩れているので、効果の議論に進む前にまずSRMを通すのが鉄則です。実験ダッシュボードの一番上に常設し、SRMが出た実験は自動で結論を伏せるのが安全です。
多重比較(指標・群を増やすほど偽陽性)
指標を一つに絞れず「ついでに全部見る」と、罠が静かに開きます。各指標を有意水準 で独立に検定すると、少なくとも一つが偶然有意になる確率(ファミリーワイズ誤り率)は で増えます。、指標が5本なら 。本当は何も効いていなくても、5本に1本以上の確率で「どれかが有意」になる。群をA/B/C/Dと増やすのも同じ構造で、比較ペアが増えるほど偽陽性が積み上がります。
SOLNAのボタン変更で副次指標を5本まとめて検定すると、こうなります。
from statsmodels.stats.proportion import proportions_ztest
from statsmodels.stats.multitest import multipletests
# 各指標: (control_success, control_n, treatment_success, treatment_n) ※匿名化・丸め
metrics = {
"add_to_cart": (18_100, 120_400, 18_600, 119_600),
"email_signup": ( 6_050, 120_400, 6_280, 119_600),
"checkout": ( 3_853, 120_400, 4_066, 119_600),
"return_7d": ( 9_700, 120_400, 9_540, 119_600),
"review_post": ( 612, 120_400, 640, 119_600),
}
pvals, names = [], []
for name, (cs, cn, ts, tn) in metrics.items():
_, p = proportions_ztest([ts, cs], [tn, cn]) # [処置成功, 対照成功], [処置n, 対照n]
pvals.append(p); names.append(name)
# Benjamini-Hochberg(FDR制御)で補正
reject, p_adj, _, _ = multipletests(pvals, alpha=0.05, method="fdr_bh")
for name, p, pa, r in zip(names, pvals, p_adj, reject):
print(f"{name:12s} p={p:.4f} p_adj={pa:.4f} sig={r}")出力は次のとおりです。
| 指標 | 生のp値 | BH補正後 | 判定 |
|---|---|---|---|
| add_to_cart | 0.0004 | 0.0021 | 有意 |
| email_signup | 0.0122 | 0.0203 | 有意 |
| checkout | 0.0062 | 0.0156 | 有意 |
| return_7d | 0.4711 | 0.4711 | 非有意 |
| review_post | 0.3619 | 0.4524 | 非有意 |
補正の選択で結論が変わる点に注意します。ここではBenjamini-Hochberg(偽発見率=FDRを制御、検出力を残しやすい)を使い、email_signup は補正後も有意です。しかしより保守的なBonferroni(、ここでは )を使うと、email_signup の生p値 0.0122 は閾値 0.01 を超えて非有意になります。副次指標が多い探索ではBH、絶対に偽陽性を出したくない意思決定直結の比較ではBonferroni、と使い分けます。
検定の基礎(第一種・第二種の誤り、二項比率の検定)は前提知識として統計学入門(赤本)に置き、ここでは「増やすほど偽陽性が増える」という構造とその補正だけに絞りました。
セグメント後付け分析の危険
最後の罠が一番くせものです。実験全体では差が出なかったとき、「新規ユーザーだけ」「iOSだけ」「東京だけ」とセグメントを切り直し、どこかで有意差を見つけて「このセグメントには効いた」と報告する。これは多重比較を無自覚にやっているのと同じで、十分に細かく切れば、偶然の有意差はどこかに出てしまいます。データを見てから仮説を立てる(いわゆるHARKing、garden of forking paths)と、偽陽性率は計算上の 0.05 をはるかに超えます。
対策は二つです。第一に、見たいセグメントは実験を走らせる前に登録しておくこと。事前登録したセグメントなら、本数に応じて多重比較の補正をかけて正当に読めます。第二に、走らせた後で見つけた「効いたセグメント」は、結論ではなく次の実験の仮説として扱うこと。発見そのものは歓迎してよい。ただしそれは「面白い仮説が一つできた」段階であって、「効いた」と言うには、そのセグメントを主対象にした確認実験で再現する必要があります。
章末チェックリスト
- 割付は決定的(ハッシュ+実験ソルト)か。
random()を毎リクエスト引いていないか - 割付単位と分析単位が一致しているか(ユーザーで割り付けてセッションで検定していないか)
- SRM検定(・
p < 0.001)を結論の前段に常設したか - SRMが出た実験の結果を読まずに止められる運用になっているか
- 主要指標を1本に事前固定し、副次指標には多重比較補正をかけたか
- 補正方式(Bonferroni / BH)を意思決定の重みで選んでいるか
- セグメントを事前登録し、後付けの発見は「次の実験の仮説」に留めているか
ここまでで「正しく割り付け、正しく読む」までを揃えました。残る大きな罠が、いつ実験を止めてよいかです。有意になった瞬間に止めると偽陽性が跳ね上がる、これが覗き見(peeking)です。次回・第4回は、回数固定の検定と逐次検定、ベイズA/Bで「停止の作法」を設計します。
なお、ここで扱った割付・SRM・多重比較は「自分で介入を設計できる」前提の話でした。ユーザー単位で分けられない・そもそも実験が打てない構造で因果に迫る判断は、対になる因果編を参照してください。