結論

ABテストとは、利用者をランダムにAとBに分け、同じ期間に2つの案の結果を比べる方法です。差が偶然かどうかは統計の検定で判定します。

この記事でわかること

  • やり方の7手順と、開始前の確認6項目
  • サンプルサイズ早見表20通りと、判定の計算8種
  • 途中で見ると偽陽性が5%→24%になる実測

2026年10月22日更新|SciPy 1.17.1・statsmodels 0.15.0で計算

ABテストは「仮説を決める → 人数を決める → ランダムに分ける → 決めた人数まで待つ → 検定する」の順に進めます。途中の結果を見て止めないことが、正しく判定するための条件です。

flowchart TD
    A[仮説と指標を決める] --> B[必要な人数と<br>期間を計算する]
    B --> C[利用者をランダムに<br>A と B に分ける]
    C --> D[決めた人数まで<br>途中で止めずに待つ]
    D --> E[検定と信頼区間で<br>差を判定する]
    E --> F[B を採用か見送り<br>結果を記録する]

急いでいる人向けに、よく調べられることの答えを先にまとめます。

知りたいこと 答え 詳しい節
ABテストとは 2案をランダムに分けて同時に比べる ABテストとは
何人必要か CVR 5%→6%なら1グループ8,158人 サンプルサイズ
何日かかるか 必要人数×2÷1日の訪問者。週単位で区切る サンプルサイズ
有意差の判定 p値が有意水準(多くは0.05)未満か、差の信頼区間 有意差の判定
途中で止めてよいか 止めない。20回見ると偽陽性が24% 途中で見る問題
何を変えるか 1回のテストで変えるのは1か所 仮説の立て方
ツール Google オプティマイズは2023年9月30日に終了 ツール
SEO への影響 canonical・302・終わったら消す SEO

確認環境:Ubuntu 24.04.4 LTS・Python 3.11.15・NumPy 2.4.4・SciPy 1.17.1・statsmodels 0.15.0。サンプルサイズ・カイ二乗検定・z検定・フィッシャーの正確検定・信頼区間・SRM の検定・多重比較の補正・ベイズの事後分布・Welch の t 検定は、この記事のコードをすべて実行して出力を載せています。「途中で見ると偽陽性が増える」「検出力が足りないと効果を大きく見積もる」は、乱数の種を固定したシミュレーション(A/Aテスト2万回など)の実測値です。Google オプティマイズの終了と GA4 との連携は Google アナリティクスのヘルプ、SEO の注意は Google 検索セントラル、各ツールの統計の方式は Firebase・Optimizely・Wingify(VWO)の公式ページ、SRM の閾値は Microsoft Research の Experimentation Platform の記事、途中で見る問題は Evan Miller の解説で確かめました(確認日 2026年10月22日)。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「ABテストとは|やり方・サンプルサイズ・有意差の判定をPythonで実演」

ABテストとは(意味・英語・何がわかるか)

この節では、ABテストが何を比べる方法で、なぜ「同時に」「ランダムに」分けるのかが分かります。

ABテスト(A/Bテスト)は、今の案(A)と新しい案(B)を、利用者をランダムに分けて同じ期間に見せ、どちらの結果がよいかを数字で比べる方法です。 英語では A/B testing、split testing(スプリットテスト)とも呼ばれ、統計の言葉では「ランダム化比較実験(randomized controlled experiment)」の一種です。今の案 A を「コントロール(対照群)」、新しい案 B を「バリアント(トリートメント)」と呼びます。

比べる対象の例です。

分野 比べるもの 見る指標の例
Web サイト・LP ボタンの文言・色、見出し、画像、フォームの項目数 申込み率(CVR)・クリック率
EC サイト 送料の表示、おすすめの並び、購入の手順 購入率・1人あたり売上
アプリ オンボーディングの画面、通知の文面 継続率・起動回数
広告 広告文・画像・入札の設定 クリック率・獲得単価
メール 件名・送信時刻 開封率・クリック率
機能・アルゴリズム 検索の順位付け、レコメンドのモデル クリック率・売上・表示速度

「前後で比べる」ではなく「同時にランダムに分ける」理由

ボタンを変えて効果を確かめたいとき

前後比較先月は A、今月は B にして CVR を比べる。季節・広告・曜日・ニュースの影響と、ボタンの効果が混ざって区別できない
→
ABテスト同じ期間に、来た人をランダムに A と B に分ける。外の影響は両方に同じように入るので、差はボタンの違いと偶然のどちらかになる

残った「偶然」の大きさを測るのが、後の節のサンプルサイズと検定

ランダムに分けることで、A と B の違いは「変えた1か所」と「偶然のばらつき」だけになります。 偶然でも差は必ず出るので、その差が偶然で説明できる大きさかどうかを、統計の検定で判定します。

ABテストの種類(A/Aテスト・多変量テスト・スプリットURL・バンディット)

この節では、ABテストに似た方法の違いと、どれを選ぶかが分かります。

1A/Bテスト(A/B/n)

1か所を変えた案を比べる基本の形。3案以上を同時に比べると A/B/n テスト。案が増えるほど必要な人数が増え、後の多重比較の補正が要る。

2多変量テスト(MVT)

複数の要素の組み合わせを一度に比べる。見出し3×画像3×ボタン2なら18通りで、1通りあたりの人数が大きく減る。

3スプリットURLテスト

A と B を別の URL のページとして作り、振り分ける。デザインを大きく変えるときに使う。SEO の注意は後の節。

4多腕バンディット

結果のよい案に、テスト中から配分を寄せていく方法。テスト中の損を減らせる一方で、差の大きさを正確に測る目的には向かない。

方法 向いている場面 注意
A/Bテスト 1か所の変更の効果を測る 1回で変えるのは1か所
A/B/n テスト 3〜4案から選ぶ 多重比較の補正、人数は案の数に比例
多変量テスト 要素どうしの組み合わせの効果を見る 訪問者が多いページでないと人数が足りない
スプリットURLテスト ページ全体を作り直したとき 302 リダイレクトと canonical(SEO の節)
多腕バンディット 短い期間のキャンペーンで損を減らしたい 差の大きさを測る目的には向かない
A/Aテスト 振り分けと計測の仕組みを確かめる 同じ案どうしなので、差は出ないはず

A/Aテストは、A と A(同じもの)を比べるテストです。 有意水準5%なら、偶然で「有意」になるのは約5%のはずです。後のシミュレーションでは、最後に1回だけ判定した2万回の A/Aテストで 4.83% でした。これより明らかに多いなら、振り分けか計測の仕組みに問題があります。

新しい機能を一部の利用者から段階的に広げる「カナリアリリース」も一部の人にだけ見せる点は似ていますが、目的は障害を早く見つけることで、効果を測ることではありません。リリースの方式は、関連記事「デプロイ方式の種類と違い|ブルーグリーン・カナリア・ローリング比較」で扱っています。

ABテストのやり方(7つの手順)

この節では、ABテストを始めてから終えるまでの順番と、それぞれで決めることが分かります。

1

目的と仮説を書く

「何を、なぜ変えると、どの指標が、どれだけ変わるか」を1文で書きます。仮説が無いテストは、結果が出ても次に生かせません。

次の節の型

2

指標を決める

判定に使う主要指標を1つ、悪くなっていないか見張るガードレール指標を2〜3個決めます。

主要は1つだけ

3

人数と期間を計算する

今の CVR・見つけたい差・有意水準・検出力から、1グループの人数を出し、1日の訪問者数から日数を出します。

5%→6%なら8,158人

4

振り分けと計測を作る

ユーザーIDのハッシュなどで、同じ人がいつも同じ側に入るように分けます。可能なら A/Aテストで仕組みを確かめます。

SRM の確認も

5

決めた人数まで待つ

途中で有意になっても止めません。毎日見るのは、計測が壊れていないか・大きな不具合が無いかの確認だけにします。

途中で見る問題の節

6

検定と信頼区間で判定する

p 値と差の95%信頼区間を出し、ガードレール指標と振り分けの比率も確かめてから結論を出します。

判定スクリプトあり

7

反映して記録する

勝った案を全員に出し、テスト用のコード・URL を消します。差が出なかった結果も、仮説・人数・区間と一緒に残します。

次の仮説につなぐ

開始前に、次の6つを確かめておくと、終わってから「判定できない」を防げます。

  • 1仮説を1文で書いたか変える場所・理由・指標・期待する差が入っているか
  • 2主要指標を1つに決めたかあとから都合のよい指標を選ぶと、多重比較と同じ問題になる
  • 31グループの人数と終了日を決めたか途中で止めないために、先に書き残す
  • 4同じ人がいつも同じ側に入るかページを開くたびに入れ替わると、比較にならない
  • 5A と B で計測のタグが同じに動くか片方だけ計測が漏れると、振り分けの比率がずれる(SRM)
  • 6他のテストや施策と重なっていないか同じページで同時に別のテストやセールがあると、結果が混ざる

ABテストの仮説の立て方(何を変えるか)

この節では、テストする価値のある仮説の書き方と、変える場所の選び方が分かります。

仮説は「(観察した事実)なので、(変更)をすると、(指標)が(期待する差)だけ上がる。なぜなら(理由)」の形で書きます。 例です。

部品 例
観察した事実 申込みフォームの入力開始は多いが、完了まで進む人が少ない。電話番号の欄で離脱が多い
変更 電話番号を任意の項目にする
指標 フォームの完了率(主要指標)
期待する差 5.0% → 6.0%(相対 +20%)以上
理由 電話がかかってくることを嫌う人が、そこで止まっているため

期待する差は、サンプルサイズの計算にそのまま使います。 「少しでも上がれば」と決めると、必要な人数が際限なく増えます(次の次の節の早見表)。「この差より小さければ、作り直す手間に見合わない」という最小の差を決めます。これを MDE(最小検出効果)と呼びます。

変える場所の選び方の目安です。

選び方 理由
訪問者が多いページから 人数が早く集まり、短い期間で判定できる
離脱やつまずきが数字で見えている所から 仮説に根拠があり、差が大きく出やすい
1回で変えるのは1か所 2か所を同時に変えると、どちらが効いたか分からない
小さすぎる変更は避ける ボタンの色の違いのような小さな差は、何万人いても見つけにくい

ABテストの指標の選び方(主要指標とガードレール指標)

この節では、判定に使う指標と、悪化していないか見張る指標の分け方が分かります。

判定に使う「主要指標」は1つにし、副作用を見張る「ガードレール指標」を別に決めます。 主要指標を複数にして「どれか1つでも上がれば勝ち」にすると、後の多重比較の節で示すとおり、偶然の勝ちが増えます。

主要指標(判定に使う)

仮説が直接動かすもの:フォームの完了率、購入率

1つだけ。テストの前に決める

サンプルサイズはこの指標で計算する

ガードレール指標(見張る)

悪くなっては困るもの:返品率、問い合わせ数、表示速度

振り分けの比率(SRM)もここで見る

悪化したら、主要指標が勝っても採用を見直す

指標の種類 例 使う検定
比率(する・しない) CVR・クリック率・継続率 カイ二乗検定・z検定(人数が少なければフィッシャーの正確検定)
平均(金額・回数・時間) 1人あたり売上・ページビュー数・滞在時間 Welch の t 検定
比の比率 1セッションあたりのクリック数(1人が何回も来る) デルタ法・ブートストラップ(分析の単位に注意)

分析の単位は、振り分けの単位にそろえます。 ユーザー単位で振り分けたのに、ページビュー単位で CVR を計算すると、同じ人の行動が何回も数えられ、ばらつきを小さく見積もります。

会社やサービス全体で追う指標の決め方は、関連記事「ノーススターメトリックとは|KGI・KPIとの違い」で扱っています。ABテストの主要指標は、その指標につながる、テスト期間で動く指標から選びます。

ABテストに必要なサンプルサイズ(何人・何日)の計算

この節では、1グループに何人必要かを計算する式と Python のコード、早見表、日数の出し方が分かります。

サンプルサイズは、今の CVR(p1)・見つけたい CVR(p2)・有意水準(α)・検出力(1−β)の4つで決まります。

  • 有意水準 α:差が無いのに「差がある」と判定してしまう確率(偽陽性)の上限。多くは5%。
  • 検出力 1−β:本当に差があるときに、それを見つけられる確率。多くは80%。
  • MDE:見つけたい最小の差(p2 − p1)。

2つの比率を両側検定で比べるときの1グループの人数は、次の式で求められます(正規近似)。

n = ( zα/2 × √(2 × p̄ × (1 − p̄)) + zβ × √(p1 × (1 − p1) + p2 × (1 − p2)) )² ÷ (p2 − p1)²

  p̄    = (p1 + p2) ÷ 2
  zα/2 = 1.96(有意水準5%・両側)
  zβ   = 0.8416(検出力80%)

CVR 5% を 6% にしたいとき(相対 +20%)を手で計算すると、次のようになります。

計算 値
p̄ = (0.05 + 0.06) ÷ 2 0.055
1.96 × √(2 × 0.055 × 0.945) = 1.96 × 0.32241 0.63192
0.8416 × √(0.05 × 0.95 + 0.06 × 0.94) = 0.8416 × 0.32234 0.27128
(0.63192 + 0.27128)² 0.81577
0.81577 ÷ (0.06 − 0.05)² 8,157.7 → 8,158人

同じ計算を Python で行い、statsmodels の検出力の計算と、Evan Miller の経験則(n = 16 × σ² ÷ δ²、σ² = p × (1 − p))とも比べました。

"""A/Bテストのサンプルサイズを3通りで計算する"""
import math
from scipy.stats import norm
from statsmodels.stats.power import NormalIndPower
from statsmodels.stats.proportion import proportion_effectsize

def n_per_group(p1, p2, alpha=0.05, power=0.8):
    """2つの比率の差を両側検定で見つけるのに要る、1グループあたりの人数(正規近似の公式)"""
    za = norm.ppf(1 - alpha / 2)          # 有意水準5%の両側 → 1.96
    zb = norm.ppf(power)                  # 検出力80% → 0.84
    pbar = (p1 + p2) / 2
    num = (za * math.sqrt(2 * pbar * (1 - pbar)) + zb * math.sqrt(p1 * (1 - p1) + p2 * (1 - p2))) ** 2
    return math.ceil(num / (p2 - p1) ** 2)

p1, p2 = 0.05, 0.06
print("z(0.975) =", round(norm.ppf(0.975), 4), " z(0.80) =", round(norm.ppf(0.80), 4))
print("公式      :", n_per_group(p1, p2))
h = proportion_effectsize(p2, p1)       # Cohen の h(逆正弦変換した差)
n_sm = NormalIndPower().solve_power(effect_size=h, alpha=0.05, power=0.8, ratio=1, alternative="two-sided")
print("statsmodels: h =", round(h, 4), " n =", math.ceil(n_sm))
print("16σ²/δ²   :", math.ceil(16 * p1 * (1 - p1) / (p2 - p1) ** 2))
print()
print("基準CVR  相対+5%   相対+10%  相対+20%  相対+50%")
for base in [0.01, 0.02, 0.05, 0.10, 0.20]:
    row = [n_per_group(base, base * (1 + r)) for r in (0.05, 0.10, 0.20, 0.50)]
    print(f"{base:>6.0%}  " + "  ".join(f"{n:>8,d}" for n in row))
print()
print("有意水準・検出力を変えたとき(5%→6%)")
for a, pw in [(0.05, 0.8), (0.05, 0.9), (0.01, 0.8), (0.10, 0.8)]:
    print(f"alpha={a:.2f} power={pw:.1f} n={n_per_group(p1, p2, a, pw):,d}")
print()
# 何日かかるか:1日に両方あわせて 2,000 人が来るページ
n = n_per_group(p1, p2)
for daily in (500, 2000, 10000):
    print(f"1日 {daily:>6,d} 人 → {math.ceil(2 * n / daily)} 日")
z(0.975) = 1.96  z(0.80) = 0.8416
公式      : 8158
statsmodels: h = 0.0439  n = 8143
16σ²/δ²   : 7601

基準CVR  相対+5%   相対+10%  相対+20%  相対+50%
    1%   637,010   163,095    42,693     7,750
    2%   315,206    80,682    21,109     3,826
    5%   122,124    31,234     8,158     1,471
   10%    57,763    14,751     3,841       686
   20%    25,583     6,510     1,683       294

有意水準・検出力を変えたとき(5%→6%)
alpha=0.05 power=0.8 n=8,158
alpha=0.05 power=0.9 n=10,921
alpha=0.01 power=0.8 n=12,139
alpha=0.10 power=0.8 n=6,426

1日    500 人 → 33 日
1日  2,000 人 → 9 日
1日 10,000 人 → 2 日

3つの方法で 7,601〜8,158人と、ほぼ同じ大きさになりました。 statsmodels の NormalIndPower は比率を逆正弦変換した「Cohen の h」で計算するので、公式と少しずれます。同じ式で CVR 3% → 3.6% を計算すると 13,914人で、R の power.prop.test の結果として紹介されている値と一致しました。

8,158人CVR 5%→6% の1グループ有意水準5%・検出力80%
約4倍見つけたい差を半分にしたとき相対+20%→+10%で8,158→31,234人
163,095人CVR 1% で相対+10%CVR が低いほど人数が増える

人数は「見つけたい差の2乗」に反比例します。 差を半分にすると、必要な人数は約4倍です。CVR 1% のページで1割の改善を見つけるには、1グループ16万人を超えます。訪問者が少ないページでは、大きな変更をテストするか、もっと上の段階(クリック率など人数の多い指標)を主要指標にします。

何日続けるか

日数は「1グループの人数 × グループの数 ÷ 1日の訪問者数(テストの対象になる人)」です。上の計算で、1日2,000人のページなら9日でした。計算した日数が1週間より短くても、曜日で行動が違うサイトでは、週の単位(7日・14日)で区切ると曜日の偏りが入りません。 終了日は始める前に決めて記録します。

A と B を 50:50 以外に分けるとき

新しい案が心配なときに B を少なくすると、合計の人数は増えます。statsmodels の ratio で計算しました(5%→6%、Cohen の h を使う計算)。

import math
from statsmodels.stats.power import NormalIndPower
from statsmodels.stats.proportion import proportion_effectsize
h = proportion_effectsize(0.06, 0.05)
for r in (1, 4, 9):
    n1 = NormalIndPower().solve_power(effect_size=h, alpha=0.05, power=0.8, ratio=r)
    print(f"B:A=1:{r}  B={math.ceil(n1):,d}  A={math.ceil(n1 * r):,d}  合計={math.ceil(n1) + math.ceil(n1 * r):,d}")
B:A=1:1  B=8,143  A=8,143  合計=16,286
B:A=1:4  B=5,090  A=20,357  合計=25,447
B:A=1:9  B=4,524  A=40,713  合計=45,237

90:10 に分けると、合計は 50:50 の約2.8倍(45,237人)になりました。 期間を延ばしてもよいなら、少ない側を減らしてリスクを抑える方法として使えます。

ユーザーの振り分け方とSRM(振り分け比率のずれ)の確認

この節では、同じ人がいつも同じ側に入る振り分けのコードと、振り分けが壊れていないかを確かめる検定が分かります。

振り分けは、ユーザーIDとテストの名前をつないだ文字列のハッシュ値で決めると、同じ人は何度来ても同じ側に入ります。 乱数で毎回決めると、ページを開くたびに A と B が入れ替わります。

"""ユーザーIDのハッシュで A/B を振り分ける(同じ人は何度来ても同じ側)"""
import hashlib
from collections import Counter
from scipy.stats import chisquare

def assign(user_id: str, experiment: str, ratio_b: float = 0.5) -> str:
    h = hashlib.sha256(f"{experiment}:{user_id}".encode()).hexdigest()
    bucket = int(h[:8], 16) / 0xFFFFFFFF      # 0〜1 の数にする
    return "B" if bucket < ratio_b else "A"

print(assign("user-00042", "cta-button-2026-10"), assign("user-00042", "cta-button-2026-10"),
      assign("user-00042", "hero-image-2026-10"))
cnt = Counter(assign(f"user-{i:05d}", "cta-button-2026-10") for i in range(20000))
print(dict(sorted(cnt.items())), "SRM p =", round(chisquare([cnt["A"], cnt["B"]]).pvalue, 4))
cnt2 = Counter(assign(f"user-{i:05d}", "cta-button-2026-10", 0.1) for i in range(20000))
print("B に 10%:", dict(sorted(cnt2.items())), "SRM p =", round(chisquare([cnt2["A"], cnt2["B"]], f_exp=[18000, 2000]).pvalue, 4))
A A B
{'A': 10069, 'B': 9931} SRM p = 0.3292
B に 10%: {'A': 18044, 'B': 1956} SRM p = 0.2997

同じ user-00042 は同じテストで2回とも A、別のテストでは B になりました。テストの名前をハッシュに混ぜると、テストごとに振り分けが独立します。 混ぜないと、ある人はどのテストでもいつも B になり、テストどうしの結果が絡みます。2万人を分けた比率は 10,069:9,931 で、50:50 からのずれは偶然の範囲(p = 0.33)でした。

フラグで機能を出し分ける仕組みと、その管理の方法は、関連記事「フィーチャーフラグとは:4種類の使い分けと実装コード・管理台帳テンプレート」で扱っています。

SRM(サンプル比率の不一致)とは

SRM(Sample Ratio Mismatch)は、50:50 で分けたはずの人数が、偶然では説明できないほどずれている状態です。 Microsoft Research の Experimentation Platform の記事は、カイ二乗検定などで確かめること、偽陽性を減らすため p 値 0.0005 未満を SRM とする厳しめの基準を使っていること、SRM が LinkedIn のある種のテストで約10%、Microsoft の ABテストで約6% 起きていたことを紹介しています。原因には、振り分けの誤り・片方だけのリダイレクト・ログの結合の誤り・ボットの除外の偏りなどが挙げられています。

from scipy.stats import chisquare
for a, b in ((8200, 8200), (8200, 8390), (8200, 8500)):
    s, ps = chisquare([a, b])
    print(f"SRM 振り分け {a}:{b}  chi2={s:.3f} p={ps:.5f}")
SRM 振り分け 8200:8200  chi2=0.000 p=1.00000
SRM 振り分け 8200:8390  chi2=2.176 p=0.14018
SRM 振り分け 8200:8500  chi2=5.389 p=0.02026

後の判定スクリプトで 8,200:8,800 を入れると p = 0.000004 で、SRM と判定されました。SRM があるテストの結果は、検定の p 値がどうであっても使わずに、原因を先に調べます。 片方の人数だけが多い・少ないのは、その側で計測が漏れているか、特定の人が抜けているしるしで、比べている集団がもう同じではないからです。

有意差の判定(カイ二乗検定・z検定・信頼区間)

この節では、ABテストの結果に有意差があるかを、4つの方法で計算した結果と、その読み方が分かります。

例として、次の結果を判定します(A・B とも予定の 8,158人を超えた時点)。

案 訪問者 コンバージョン CVR
A(今のボタン) 8,200 410 5.00%
B(新しいボタン) 8,200 492 6.00%

p 値は「本当は差が無いとしたら、今回以上の差が偶然で出る確率」です。 p 値が有意水準(0.05)より小さければ「差がある(有意)」と判定します。p 値は「B が A よりよい確率」ではありません。

どの検定を使うかは、指標の種類と人数で決まります。

flowchart TD
    A{指標の種類は?} -->|比率| B{期待度数が<br>すべて5以上?}
    A -->|平均| E[Welch の<br>t 検定]
    B -->|はい| C[カイ二乗検定<br>または z 検定]
    B -->|いいえ| D[フィッシャーの<br>正確検定]
"""A/Bテストの結果を、カイ二乗検定・z検定・信頼区間で判定する"""
import math
import numpy as np
from scipy.stats import chi2_contingency, fisher_exact, norm
from statsmodels.stats.proportion import proportions_ztest, confint_proportions_2indep, proportion_confint

nA, cA = 8200, 410    # 訪問者数, コンバージョン数
nB, cB = 8200, 492
pA, pB = cA / nA, cB / nB

# 1) カイ二乗検定(2×2 の分割表)
table = np.array([[cA, nA - cA], [cB, nB - cB]])
chi2, p, dof, exp = chi2_contingency(table, correction=False)
chi2y, py, _, _ = chi2_contingency(table)            # 既定は Yates の補正あり
print(f"カイ二乗(補正なし): chi2={chi2:.4f} p={p:.5f} 自由度={dof}")
print(f"カイ二乗(Yates補正): chi2={chi2y:.4f} p={py:.5f}")
print("期待度数:", exp.round(1).tolist())

# 2) z 検定(2つの比率)
z, pz = proportions_ztest([cB, cA], [nB, nA])         # 既定は両側・プールした分散
print(f"z検定(statsmodels): z={z:.4f} p={pz:.5f}  z^2={z * z:.4f}")
pp = (cA + cB) / (nA + nB)                            # 手計算
se0 = math.sqrt(pp * (1 - pp) * (1 / nA + 1 / nB))
zm = (pB - pA) / se0
print(f"z検定(手計算)     : 合算CVR={pp:.5f} SE={se0:.6f} z={zm:.4f} p={2 * norm.sf(abs(zm)):.5f}")
z1, p1 = proportions_ztest([cB, cA], [nB, nA], alternative="larger")
print(f"片側(B>A)          : p={p1:.5f}")

# 3) フィッシャーの正確検定
odds, pf = fisher_exact(table)
print(f"フィッシャーの正確検定: p={pf:.5f}")

# 4) 信頼区間
se = math.sqrt(pA * (1 - pA) / nA + pB * (1 - pB) / nB)
lo, hi = (pB - pA) - 1.96 * se, (pB - pA) + 1.96 * se
print(f"差の95%信頼区間(手計算 Wald): {lo:+.4%} 〜 {hi:+.4%}  SE={se:.6f}")
for m in ("wald", "newcomb", "agresti-caffo"):
    l, h = confint_proportions_2indep(cB, nB, cA, nA, method=m, compare="diff")
    print(f"  statsmodels {m:<14}: {l:+.4%} 〜 {h:+.4%}")
l, h = confint_proportions_2indep(cB, nB, cA, nA, compare="ratio")
print(f"  比(B/A)の95%区間        : {l:.4f} 〜 {h:.4f}  → 相対 {l - 1:+.1%} 〜 {h - 1:+.1%}")
for name, c, n in (("A", cA, nA), ("B", cB, nB)):
    l, h = proportion_confint(c, n, method="wilson")
    print(f"  {name} の CVR(Wilson)       : {l:.3%} 〜 {h:.3%}")
カイ二乗(補正なし): chi2=7.8884 p=0.00498 自由度=1
カイ二乗(Yates補正): chi2=7.6972 p=0.00553
期待度数: [[451.0, 7749.0], [451.0, 7749.0]]
z検定(statsmodels): z=2.8086 p=0.00498  z^2=7.8884
z検定(手計算)     : 合算CVR=0.05500 SE=0.003560 z=2.8086 p=0.00498
片側(B>A)          : p=0.00249
フィッシャーの正確検定: p=0.00550
差の95%信頼区間(手計算 Wald): +0.3023% 〜 +1.6977%  SE=0.003560
  statsmodels wald          : +0.3023% 〜 +1.6977%
  statsmodels newcomb       : +0.3022% 〜 +1.6997%
  statsmodels agresti-caffo : +0.3015% 〜 +1.6980%
  比(B/A)の95%区間        : 1.0563 〜 1.3627  → 相対 +5.6% 〜 +36.3%
  A の CVR(Wilson)       : 4.549% 〜 5.493%
  B の CVR(Wilson)       : 5.506% 〜 6.535%

p = 0.00498 で 0.05 より小さく、B の CVR は A より有意に高いと判定できます。 読み取れることを表にしました。

結果 読み方
カイ二乗(補正なし)と z 検定の p が同じ 2×2 のカイ二乗検定と、2つの比率の両側 z 検定は同じ検定。z² = 7.8884 = カイ二乗の値
Yates の補正ありは p = 0.00553 SciPy の chi2_contingency は自由度1のとき既定で補正する。補正すると p が少し大きくなる
フィッシャーの正確検定は p = 0.00550 近似を使わない計算。人数が多いと他の検定とほぼ同じ
片側は p = 0.00249 両側の半分。「B が上か」だけを見る。上か下か分からない段階では両側を使う
差の95%信頼区間は +0.30%〜+1.70% 0 を含まないので有意。改善の幅は小さければ +0.3 ポイント、大きければ +1.7 ポイント
相対では +5.6%〜+36.3% 「20%改善」と1つの数字で言わず、幅で伝える

判定には、p 値だけでなく差の信頼区間を一緒に出します。 p 値は「差があるか」しか言いませんが、信頼区間は「どれくらいの差か」を示します。下の端(+0.30%)でも作り直す価値があるかで、採用を決められます。

z 検定を手で計算する

上の出力の手計算の部分を、式で書くと次のとおりです。

合算した CVR  p = (410 + 492) ÷ (8,200 + 8,200) = 902 ÷ 16,400 = 0.055
標準誤差     SE = √( p × (1 − p) × (1 ÷ 8,200 + 1 ÷ 8,200) ) = 0.003560
z 値          z = (0.0600 − 0.0500) ÷ 0.003560 = 2.809
p 値(両側)  p = 2 × (標準正規分布で 2.809 より外側の確率) = 0.00498

信頼区間は、合算しない標準誤差 √(pA × (1 − pA) ÷ nA + pB × (1 − pB) ÷ nB) を使い、「差 ± 1.96 × 標準誤差」で出します。

差がもっと小さかったら

B のコンバージョンが 430・455・470 件だった場合も計算しました。

for cb in (430, 455, 470):
    z2, p2 = proportions_ztest([cb, cA], [nB, nA])
    l2, h2 = confint_proportions_2indep(cb, nB, cA, nA, method="wald")
    print(f"B={cb}: CVR {cb / nB:.2%}  p={p2:.4f}  差の95%区間 {l2:+.3%} 〜 {h2:+.3%}")
B=430: CVR 5.24%  p=0.4787  差の95%区間 -0.431% 〜 +0.919%
B=455: CVR 5.55%  p=0.1159  差の95%区間 -0.135% 〜 +1.233%
B=470: CVR 5.73%  p=0.0376  差の95%区間 +0.042% 〜 +1.421%

B = 455 件は相対 +11% でも p = 0.12 で、有意ではありませんでした。 区間は −0.14%〜+1.23% で、「悪くなっている可能性も、1ポイント以上よくなっている可能性も残っている」という意味です。「差が無いことが分かった」ではありません。B = 470 件は p = 0.038 で有意ですが、区間の下の端は +0.04% で、ほとんど改善していない可能性も含みます。

途中で結果を見て止めると偽陽性が増える(シミュレーション)

この節では、テストの途中で p 値を見て「有意になったら止める」と、偽陽性がどれだけ増えるかが数値で分かります。

本当は差が無い A/Aテストを2万回行い、途中で何回か p 値を見て、1回でも p < 0.05 になったら「有意」として止めるルールで数えました。 1グループの最終の人数は1万人、両方とも本当の CVR は 5% です。

"""A/Aテスト(差が本当に無い)を何千回も行い、途中で見て止めると偽陽性がどれだけ増えるかを数える"""
import numpy as np
from scipy.stats import norm

rng = np.random.default_rng(325)
SIMS, N, P = 20000, 10000, 0.05        # 試行回数, 1グループの最終人数, 両方とも本当の CVR は 5%
step = 100                              # 100人ごとに累計を記録
xa = rng.binomial(step, P, size=(SIMS, N // step)).cumsum(axis=1)
xb = rng.binomial(step, P, size=(SIMS, N // step)).cumsum(axis=1)
n = np.arange(1, N // step + 1) * step  # 各時点の1グループの人数

def zstat(ca, cb, nn):
    pp = (ca + cb) / (2 * nn)
    se = np.sqrt(pp * (1 - pp) * 2 / nn)
    with np.errstate(divide="ignore", invalid="ignore"):
        return np.where(se > 0, (cb - ca) / nn / se, 0.0)

Z = zstat(xa, xb, n)                    # 形は (SIMS, 100)
crit = norm.ppf(0.975)                  # 1.96

print(f"A/Aテスト {SIMS:,d} 回、1グループ最終 {N:,d} 人、有意水準 5%(両側)")
print("見る回数  偽陽性率(どこかで p<0.05 になって止めた割合)")
for looks in (1, 2, 5, 10, 20, 50, 100):
    idx = np.linspace(N // step / looks, N // step, looks).round().astype(int) - 1
    hit = (np.abs(Z[:, idx]) > crit).any(axis=1).mean()
    print(f"{looks:>6d}回  {hit:.2%}")

p_end = 2 * norm.sf(np.abs(Z[:, -1]))
print(f"\n最後だけ見た場合の p<0.05 の割合: {(p_end < 0.05).mean():.2%}")

first = np.argmax(np.abs(Z) > crit, axis=1)
stopped = (np.abs(Z) > crit).any(axis=1)
print("100回見て止めた試行の、止めた時点の人数(中央値):", int(np.median((first[stopped] + 1) * step)))

# 対策:見る回数が決まっているなら、境界を厳しくする(毎回同じ境界)
for looks in (2, 5, 10):
    idx = np.linspace(N // step / looks, N // step, looks).round().astype(int) - 1
    zmax = np.abs(Z[:, idx]).max(axis=1)
    c = np.quantile(zmax, 0.95)          # 偽陽性率がちょうど 5% になる境界をシミュレーションで求める
    print(f"{looks}回見るときの境界 |z|>{c:.3f}(1回あたりの p<{2 * norm.sf(c):.4f})")
A/Aテスト 20,000 回、1グループ最終 10,000 人、有意水準 5%(両側)
見る回数  偽陽性率(どこかで p<0.05 になって止めた割合)
     1回  4.83%
     2回  8.13%
     5回  14.12%
    10回  18.94%
    20回  24.23%
    50回  31.44%
   100回  36.58%

最後だけ見た場合の p<0.05 の割合: 4.83%
100回見て止めた試行の、止めた時点の人数(中央値): 1000
2回見るときの境界 |z|>2.168(1回あたりの p<0.0302)
5回見るときの境界 |z|>2.403(1回あたりの p<0.0163)
10回見るときの境界 |z|>2.542(1回あたりの p<0.0110)

差が無いのに「有意」で止めた割合(A/Aテスト2万回)有意水準5%。見る回数ごと

最後に1回4.83%
2回見る8.13%
5回見る14.12%
10回見る18.94%
20回見る24.23%
100回見る36.58%

最後に1回だけ判定すれば偽陽性は 4.83%(約5%)なのに、20回見ると 24.23%、100回見ると 36.58% になりました。 毎日1回、20日間結果を見て「有意になった日に止める」と、差が無い変更の約4回に1回を「効果あり」と判定する計算です。100回見て止めた試行の半分は、1グループ1,000人(予定の1割)までに止まっていました。

Evan Miller の解説も、同じ理由で「繰り返し有意性を検定すると、偽陽性の率は必ず上がる」とし、サンプルサイズを先に決めて、それまで結果を判断に使わないことを第一の対策に挙げています。Optimizely は、訪問者ごとに結果を見た A/Aテストのシミュレーションで、5,000人規模のテストの57%超で少なくとも一度誤った判定が出たと紹介し、2015年に逐次検定の仕組み(Stats Engine)を入れました。

途中で見たいときの方法

方法 やること この記事の結果
固定の人数まで見ない 予定の人数に届くまで判定しない。途中は計測と不具合の確認だけ 偽陽性 4.83%
見る回数を決めて境界を厳しくする(群逐次検定) 例えば5回見るなら、毎回 |z| > 2.403(p < 0.0163)で判定 5回見ても偽陽性 5%
常に有効な方法を使う(逐次検定) いつ見ても有効になるよう作られた統計(ツールの機能) ―
ベイズで判定する 事後分布で判断する。ただし止め方のルールは別に要る 後の節

上の境界の値は、見る回数ごとに偽陽性がちょうど5%になるよう、シミュレーションから求めたものです。見る回数を増やすほど、1回ごとの境界は厳しくなります。

多重比較(パターン・指標・セグメントを増やすとき)

この節では、パターンや指標を増やして検定すると偶然の「有意」が増えることと、その補正の方法が分かります。

差の無い指標を k 個同時に検定すると、どれか1つが p < 0.05 になる確率は 1 − 0.95^k です。 シミュレーションでも同じになりました。

"""多重比較:差の無い指標・パターンを k 個同時に検定すると"""
import numpy as np
from scipy.stats import norm
from statsmodels.stats.multitest import multipletests

rng = np.random.default_rng(325)
SIMS, n, p = 20000, 5000, 0.05
for k in (1, 3, 5, 10, 20):
    a = rng.binomial(n, p, size=(SIMS, k)); b = rng.binomial(n, p, size=(SIMS, k))
    pp = (a + b) / (2 * n); z = (b - a) / n / np.sqrt(pp * (1 - pp) * 2 / n)
    pv = 2 * norm.sf(np.abs(z))
    print(f"k={k:>2d}  どれかが p<0.05: {(pv < 0.05).any(axis=1).mean():.2%}  理論 1-0.95^k={1 - 0.95 ** k:.2%}"
          f"  Bonferroni(0.05/k): {(pv < 0.05 / k).any(axis=1).mean():.2%}")

pvals = [0.004, 0.019, 0.030, 0.240, 0.610]   # 5つの指標の p 値
for m in ("bonferroni", "holm", "fdr_bh"):
    rej, adj, _, _ = multipletests(pvals, alpha=0.05, method=m)
    print(f"{m:<10}: 補正後 {np.round(adj, 3).tolist()}  有意 {rej.tolist()}")
k= 1  どれかが p<0.05: 4.83%  理論 1-0.95^k=5.00%  Bonferroni(0.05/k): 4.83%
k= 3  どれかが p<0.05: 14.27%  理論 1-0.95^k=14.26%  Bonferroni(0.05/k): 4.73%
k= 5  どれかが p<0.05: 22.57%  理論 1-0.95^k=22.62%  Bonferroni(0.05/k): 4.58%
k=10  どれかが p<0.05: 39.96%  理論 1-0.95^k=40.13%  Bonferroni(0.05/k): 4.69%
k=20  どれかが p<0.05: 64.14%  理論 1-0.95^k=64.15%  Bonferroni(0.05/k): 5.05%
bonferroni: 補正後 [0.02, 0.095, 0.15, 1.0, 1.0]  有意 [True, False, False, False, False]
holm      : 補正後 [0.02, 0.076, 0.09, 0.48, 0.61]  有意 [True, False, False, False, False]
fdr_bh    : 補正後 [0.02, 0.047, 0.05, 0.3, 0.61]  有意 [True, True, True, False, False]
22.6%5つの指標で偶然どれかが有意シミュレーションは 22.57%
64.1%20個の比較で偶然どれかが有意セグメントを20に分けたときなど
4.6〜5.1%Bonferroni の補正後0.05÷k で判定

「スマホだけ」「新規の人だけ」とセグメントを20に分けて探すと、差の無い変更でも64%の確率でどこかに「有意な」セグメントが見つかります。 結果を見てからセグメントを探すのは、多重比較そのものです。

補正の方法 考え方 上の例の結果
Bonferroni 有意水準を k で割る(p × k で判定) 1つだけ有意
Holm Bonferroni を段階的にゆるめる。Bonferroni より有意になりやすい 1つだけ有意
Benjamini-Hochberg(fdr_bh) 「有意とした中の誤りの割合(偽発見率)」を抑える 3つ有意

主要指標を1つに決めておけば、その判定には補正が要りません。A/B/n テストで B・C・D を A と比べるときや、探索として多くの指標・セグメントを見るときに補正を使います。

ベイズのABテスト(BがAを上回る確率)

この節では、ベイズの考え方で「B が A を上回る確率」を計算する方法と、p 値との違いが分かります。

ベイズの方法では、CVR を「分からないので確率分布で表す量」として扱い、データを見て分布を更新します(事後分布)。 CVR には Beta 分布がよく使われ、何も知らない状態を Beta(1, 1)(0〜1 で一様)とすると、データを見た後の分布は次のとおりです。

事後分布 = Beta(1 + コンバージョン数, 1 + 訪問者数 − コンバージョン数)
A の例   = Beta(1 + 410, 1 + 8,200 − 410) = Beta(411, 7791)

事後分布から乱数を40万個ずつ取り、B が A を上回る割合などを数えました。

"""ベイズ:Beta(1,1) の事前分布で B が A を上回る確率(more.py の一部)"""
nA, cA, nB = 8200, 410, 8200
for label, cb in (("B=492", 492), ("B=455", 455)):
    sa = rng.beta(1 + cA, 1 + nA - cA, 400000); sb = rng.beta(1 + cb, 1 + nB - cb, 400000)
    d = sb - sa
    lo, hi = np.quantile(d, [0.025, 0.975])
    loss_b = np.maximum(sa - sb, 0).mean()     # B を選んで外れたときの平均の損
    print(f"{label}: P(B>A)={(sb > sa).mean():.4f}  差の95%信用区間 {lo:+.3%} 〜 {hi:+.3%}  "
          f"B を選んだときの期待損失 {loss_b:.5%}  P(相対+10%以上)={(sb > sa * 1.1).mean():.3f}")
print("事後分布 A:", f"Beta({1 + cA}, {1 + nA - cA})", " 平均", f"{beta.mean(1 + cA, 1 + nA - cA):.4%}")
B=492: P(B>A)=0.9974  差の95%信用区間 +0.301% 〜 +1.698%  B を選んだときの期待損失 0.00028%  P(相対+10%以上)=0.909
B=455: P(B>A)=0.9419  差の95%信用区間 -0.136% 〜 +1.232%  B を選んだときの期待損失 0.00871%  P(相対+10%以上)=0.551
事後分布 A: Beta(411, 7791)  平均 5.0110%

B = 455 件のとき、頻度論の検定では p = 0.12 で「有意ではない」でしたが、ベイズでは「B が A を上回る確率 94.2%」と表せます。 一方で「相対で10%以上よくなっている確率」は 55% で、大きな改善かどうかは五分五分です。差の95%信用区間(−0.14%〜+1.23%)は、頻度論の信頼区間とほぼ同じ幅でした。

同じデータ(A 410/8,200・B 455/8,200)の伝え方

頻度論p = 0.12。有意水準5%では「差があるとは言えない」。p 値は「差が無いとしたら、この差以上が出る確率」
→
ベイズB が A を上回る確率 94.2%。B を選んで外れたときの平均の損は 0.0087 ポイント。意思決定の言葉で言える

どちらも「どれだけ確かか」の表し方の違い。判定の基準(何%で採用するか・いつ止めるか)は、どちらも事前に決める

ベイズにしても、何度も見て「95%を超えたら止める」を繰り返すと、誤った採用は増えます。 事前分布の選び方や止め方のルールで結果が変わるので、採用の基準(例:上回る確率 95% 以上かつ期待損失が一定以下)と終了の条件を、始める前に決めておきます。

売上など平均の指標の検定(Welch の t 検定)

この節では、1人あたり売上のように「0 が多く、ごく一部が大きい」指標の検定と、その難しさが分かります。

1人あたり売上は、買わなかった人は 0 円、買った人だけが金額を持つ指標です。B の購入者の平均金額を1割高くした(購入率は同じ 5%)データを作り、Welch の t 検定(A と B の分散が違ってよい t 検定)で比べました。

"""売上(1人あたり)は t 検定:0 円が多く、ごく一部が高額(more.py の一部)"""
from scipy.stats import ttest_ind, mannwhitneyu
def revenue(n, cvr, mean):
    buy = rng.random(n) < cvr
    return np.where(buy, rng.lognormal(np.log(mean) - 0.5, 1.0, n), 0.0)
ra, rb = revenue(8200, 0.05, 6000), revenue(8200, 0.05, 6600)
t = ttest_ind(rb, ra, equal_var=False)
u = mannwhitneyu(rb, ra)
print(f"A 平均 {ra.mean():.1f}円  B 平均 {rb.mean():.1f}円  差 {rb.mean() - ra.mean():+.1f}円")
print(f"Welch の t 検定: t={t.statistic:.3f} p={t.pvalue:.4f}  df={t.df:.1f}")
ci = t.confidence_interval(0.95)
print(f"差の95%信頼区間: {ci.low:+.1f}円 〜 {ci.high:+.1f}円")
print(f"Mann-Whitney U: p={u.pvalue:.4f}")
print(f"購入者の割合 A {np.mean(ra > 0):.2%} B {np.mean(rb > 0):.2%}、購入者の平均 A {ra[ra > 0].mean():.0f}円 B {rb[rb > 0].mean():.0f}円")
A 平均 265.8円  B 平均 322.2円  差 +56.3円
Welch の t 検定: t=1.590 p=0.1119  df=16351.2
差の95%信頼区間: -13.1円 〜 +125.8円
Mann-Whitney U: p=0.8510
購入者の割合 A 4.71% B 4.73%、購入者の平均 A 5647円 B 6809円

1人あたり売上は B が 21% 高かったのに、p = 0.11 で有意になりませんでした。 金額のばらつきが大きく、8,200人ずつでは差の信頼区間が −13円〜+126円と広いためです。売上を主要指標にするなら、比率の指標より多くの人数が要ります。Mann-Whitney の U 検定は p = 0.85 で、ほとんど差を示しませんでした。この検定は「分布の位置のずれ」を見るもので、平均の差を見る検定ではないため、0 円が95%を占める指標では平均の差を捉えにくくなります。平均の差を判定するなら Welch の t 検定かブートストラップを使います。

有意差が出なかったとき・結果の読み方の注意

この節では、有意にならなかった結果の扱い方と、人数が足りないテストで起きる「効果の過大評価」が分かります。

人数が足りないまま有意になった結果は、効果を大きく見積もっています。 本当は CVR 5% → 5.5%(相対 +10%)の変更を、1グループ3,000人と 31,234人(検出力80%の人数)でそれぞれ2万回テストしました。

"""検出力不足:本当に 5%→5.5%(相対+10%)なのに人数が少ないと(more.py の一部)"""
for n in (3000, 31234):
    a = rng.binomial(n, 0.05, SIMS); b = rng.binomial(n, 0.055, SIMS)
    pp = (a + b) / (2 * n); z = (b - a) / n / np.sqrt(pp * (1 - pp) * 2 / n)
    sig = (2 * norm.sf(np.abs(z)) < 0.05) & (z > 0)
    lift = (b / a - 1)[sig]
    print(f"n={n:>6,d}: 有意になった割合 {sig.mean():.1%}  有意だった試行の相対リフト平均 {lift.mean():+.1%}(本当は +10.0%)")
n= 3,000: 有意になった割合 13.8%  有意だった試行の相対リフト平均 +31.4%(本当は +10.0%)
n=31,234: 有意になった割合 80.1%  有意だった試行の相対リフト平均 +11.3%(本当は +10.0%)

3,000人では、本当に効果があっても有意になるのは13.8%だけで、有意になったときの改善率は平均 +31.4%(本当の約3倍)でした。 偶然大きく出たときだけ有意の線を越えるためです。「テストでは +30% だったのに、全員に出したら伸びなかった」は、この形でよく起きます。計算どおりの人数(31,234人)では、有意になる割合は 80.1%(検出力80%のとおり)で、改善率の見積もりも +11.3% と本当の値に近づきました。

有意差が出なかったときの判断の目安です。

状況 読み方 次にすること
区間が狭く、0 の近くにある 効果があっても小さい 見送り。別の仮説を試す
区間が広く、大きな改善も含む 人数が足りず、判定できていない 次は MDE と人数を計算し直してから行う
区間は 0 を含むが、ほとんどが正の側 改善の可能性は高いが確定しない 費用やリスクが小さければ採用し、指標を見続ける判断もある
ガードレール指標が悪化 主要指標が同じでも副作用がある 採用しない。原因を調べる
SRM がある 振り分けか計測が壊れている 結果を使わず、直してからやり直す

ABテストの判定スクリプト(コピペで使える完成版)

この節では、ここまでの確認(SRM・予定人数・検定・信頼区間・ベイズの参考値)を1回で行うスクリプトと、4つの場合の出力が分かります。

"""A/Bテストの結果を1回で判定する(比率の指標)
使い方: python ab_report.py 訪問者A CV数A 訪問者B CV数B [--alpha 0.05] [--planned 1グループの予定人数]
"""
import argparse
import numpy as np
from scipy.stats import chisquare
from statsmodels.stats.proportion import proportions_ztest, confint_proportions_2indep

ap = argparse.ArgumentParser()
ap.add_argument("nA", type=int); ap.add_argument("cA", type=int)
ap.add_argument("nB", type=int); ap.add_argument("cB", type=int)
ap.add_argument("--alpha", type=float, default=0.05)
ap.add_argument("--planned", type=int, default=0, help="事前に決めた1グループの人数")
a = ap.parse_args()

pA, pB = a.cA / a.nA, a.cB / a.nB
print(f"A: {a.cA:,d}/{a.nA:,d} = {pA:.3%}")
print(f"B: {a.cB:,d}/{a.nB:,d} = {pB:.3%}   差 {pB - pA:+.3%}(相対 {pB / pA - 1:+.1%})")

# 1. 振り分けの比率(50:50 のはず)
srm_p = chisquare([a.nA, a.nB]).pvalue
srm = srm_p < 0.0005                     # Microsoft の ExP と同じ厳しめの閾値
print(f"[1] 振り分け SRM 検定 p={srm_p:.6f}", "→ 比率がずれている" if srm else "→ 問題なし")
# 2. 予定の人数に届いたか
ok = True
if a.planned:
    ok = min(a.nA, a.nB) >= a.planned
    print(f"[2] 予定人数 {a.planned:,d} 人/グループ:", "到達" if ok else "未到達")
# 3. 検定と信頼区間
z, p = proportions_ztest([a.cB, a.cA], [a.nB, a.nA])
lo, hi = confint_proportions_2indep(a.cB, a.nB, a.cA, a.nA, method="newcomb", compare="diff", alpha=a.alpha)
print(f"[3] z={z:.3f} p={p:.4f}  差の{1 - a.alpha:.0%}信頼区間 {lo:+.3%} 〜 {hi:+.3%}")
# 4. ベイズの参考値
rng = np.random.default_rng(0)
sa = rng.beta(1 + a.cA, 1 + a.nA - a.cA, 200000); sb = rng.beta(1 + a.cB, 1 + a.nB - a.cB, 200000)
print(f"[4] 参考:B が A を上回る確率 {(sb > sa).mean():.1%}")
verdict = ("保留:振り分けの不具合を先に調べる" if srm else
           "保留:予定の人数まで続ける(途中の p 値で止めない)" if not ok else
           "B の勝ち" if p < a.alpha and pB > pA else
           "B の負け" if p < a.alpha else "差があるとは言えない(区間を見て次を決める)")
print("判定:", verdict)
python ab_report.py 8200 410 8200 492 --planned 8158
python ab_report.py 8200 410 8200 455 --planned 8158
python ab_report.py 8200 410 8800 480 --planned 8158
python ab_report.py 3000 150 3000 185 --planned 8158
A: 410/8,200 = 5.000%
B: 492/8,200 = 6.000%   差 +1.000%(相対 +20.0%)
[1] 振り分け SRM 検定 p=1.000000 → 問題なし
[2] 予定人数 8,158 人/グループ: 到達
[3] z=2.809 p=0.0050  差の95%信頼区間 +0.302% 〜 +1.700%
[4] 参考:B が A を上回る確率 99.8%
判定: B の勝ち

A: 410/8,200 = 5.000%
B: 455/8,200 = 5.549%   差 +0.549%(相対 +11.0%)
[1] 振り分け SRM 検定 p=1.000000 → 問題なし
[2] 予定人数 8,158 人/グループ: 到達
[3] z=1.572 p=0.1159  差の95%信頼区間 -0.136% 〜 +1.235%
[4] 参考:B が A を上回る確率 94.2%
判定: 差があるとは言えない(区間を見て次を決める)

A: 410/8,200 = 5.000%
B: 480/8,800 = 5.455%   差 +0.455%(相対 +9.1%)
[1] 振り分け SRM 検定 p=0.000004 → 比率がずれている
[2] 予定人数 8,158 人/グループ: 到達
[3] z=1.330 p=0.1837  差の95%信頼区間 -0.217% 〜 +1.124%
[4] 参考:B が A を上回る確率 90.7%
判定: 保留:振り分けの不具合を先に調べる

A: 150/3,000 = 5.000%
B: 185/3,000 = 6.167%   差 +1.167%(相対 +23.3%)
[1] 振り分け SRM 検定 p=1.000000 → 問題なし
[2] 予定人数 8,158 人/グループ: 未到達
[3] z=1.968 p=0.0491  差の95%信頼区間 +0.003% 〜 +2.336%
[4] 参考:B が A を上回る確率 97.5%
判定: 保留:予定の人数まで続ける(途中の p 値で止めない)

4つ目は p = 0.0491 で 0.05 を下回っていますが、予定の人数の37%の時点なので判定を保留にしています。 これが「途中で見て止める」の典型の場面です。--alpha 0.01 を付けると、信頼区間も99%で出します(1つ目の例で +0.082%〜+1.921%)。集計したデータを CSV から読んでこのスクリプトに渡すときの集計の書き方は、関連記事「pandasの使い方 逆引き81選|pandas 3.0の実行結果つき」で扱っています。

ABテストのツール(Googleオプティマイズ終了後の現状)

この節では、Google オプティマイズが終わった後の Google の案内と、主なツールの統計の方式が分かります。

Google オプティマイズとオプティマイズ 360 は、2023年9月30日で提供を終了しました(Google アナリティクス ヘルプ)。Google は終了の理由を、利用者がテストに必要とする機能とサービスを十分に備えていなかったためと説明し、代わりに GA4(Google アナリティクス 4)とサードパーティの ABテストツールとの連携に投資していること、連携先として AB Tasty・Optimizely・VWO と取り組んでいること、誰でも GA4 と連携できるよう API を公開していることを案内しています。

各ツールの公式ページにある統計の方式です(2026年10月22日時点)。

ツール 主な対象 統計の方式(公式の説明)
Google オプティマイズ Web 2023年9月30日に提供終了
Firebase A/B Testing アプリ 頻度論。有意水準 0.05、比率は z 検定、数値は不等分散の t 検定、p 値は片側
Optimizely Web・アプリ Stats Engine。逐次検定と偽発見率の制御(2015年1月から)
VWO(Wingify) Web SmartStats。ベイズ統計で「A が B に勝つ確率」を示す
Google 広告のカスタムテスト 広告 元のキャンペーンとテストの間でトラフィックと予算の割合を決める

Firebase のドキュメントは、2023年10月23日以降に始めたテストでは、Google オプティマイズに依存しない頻度論の方法で計算すると説明しています。Google 広告のヘルプは、50:50 に分けても、参加するオークションの割合が管理されるだけで、表示回数や費用が均等になるとは限らないとしています。

ツールによって、片側か両側か、頻度論かベイズか、途中で見てよい設計かが違います。 同じデータでも表示される数字が変わるので、ツールの「有意」「勝つ確率」が何を計算した値かを、ヘルプで確かめてから判定に使います。自社で振り分けと集計をする場合は、この記事のコードで同じ計算ができます。

ABテストとSEO(Googleの推奨)

この節では、ABテストが検索の評価に悪い影響を与えないための、Google の推奨事項が分かります。

Google 検索セントラルの「ウェブサイトのテスト」のページは、次の4つを挙げています。

推奨 内容
クローキングしない Googlebot と人に違う内容を見せない。ユーザーエージェントで出し分けない
rel="canonical" を使う テスト用の別 URL には、元の URL を正規として示す。noindex より適している
302 リダイレクトを使う 元の URL からテストの URL へは、一時的な転送(302)か JavaScript の転送にする。301 は使わない
必要な期間だけ行う 判定できたら終え、テスト用の URL・スクリプト・マークアップを消す

テストを必要以上に長く続け、多くの人に一方の案だけを出し続けると、検索エンジンを欺く意図と見なされることがある、とも書かれています。手順の7番目(反映して記録する)で、テスト用のコードを消すまでを終わりにします。

ABテストでよくある失敗と直し方

この節では、ABテストでよく起きる失敗と、Python で計算するときに出たエラー文の直し方を引けます。

失敗・症状 原因 直し方
途中で有意になったので止めた 何度も見ると偽陽性が増える(20回で24%) 予定の人数まで待つ。見るなら境界を厳しくする
テストでは +30% だったのに伸びない 人数不足で、偶然大きく出たときだけ有意になった 検出力80%の人数を計算してから始める
どの指標かで有意になった 指標・セグメントを増やした多重比較 主要指標を1つに決める。探索は補正する
A と B の人数がずれている 振り分け・計測・リダイレクトの不具合(SRM) p < 0.0005 なら結果を使わず原因を調べる
2か所を同時に変えた どちらが効いたか分からない 1か所ずつ。組み合わせを見るなら多変量テスト
先月と今月で比べた 季節や広告の影響が混ざる 同じ期間にランダムに分ける
ページビュー単位で CVR を出した 同じ人が何度も数えられ、ばらつきを小さく見積もる 振り分けの単位(ユーザー)で集計する
公開直後だけ B がよかった 目新しさで反応が増える(ノベルティ効果) 週の単位で期間を取り、日ごとの差の推移も見る
有意でなかったので「差が無い」と結論した 有意でないことは、差が無いことの証明ではない 信頼区間の幅を見て、判定できたかを確かめる

Python で計算したときに、この記事の環境で出たエラー文です。

エラー文・症状 原因 直し方
ValueError: The internally computed table of expected frequencies has a zero element at (np.int64(0), np.int64(0)). chi2_contingency の表で、ある列が全部 0(どちらもコンバージョンが0件など) 人数が集まるまで待つ。少ない件数はフィッシャーの正確検定
ValueError: All values in `observed` must be nonnegative. 表に負の数がある(訪問者数 − CV 数を逆に引いた等) 表を [[CV数, 訪問者数 − CV数], …] の形にする
ValueError: Cannot detect an effect-size of 0. Try changing your effect-size. solve_power に効果量 0(p1 と p2 が同じ)を渡した 見つけたい差(MDE)を 0 より大きくする
ValueError: For each axis slice, the sum of the observed frequencies must agree with the sum of the expected frequencies … chisquare の f_exp の合計が観測の合計と違う 期待度数を「合計 × 比率」で作る
エラーが出ずに z 値がおかしい proportions_ztest([10, 20], [100]) のように件数と人数の長さが違っても、そのまま計算される count と nobs を同じ長さのリストで渡す
z の符号が逆 proportions_ztest は1番目 − 2番目で計算する B を先に書く([cB, cA], [nB, nA])

配列の扱いの基本は、関連記事「NumPyの使い方 基本一覧|配列の作成・演算・形状変更」で扱っています。シミュレーションの結果をグラフにする方法は、関連記事「matplotlibの使い方と日本語文字化け対策|OS別・Colab・3.12対応」で扱っています。

よくある質問

Q. ABテストとは簡単に言うと何ですか? A. 利用者をランダムに2つに分け、今の案(A)と新しい案(B)を同じ期間に見せて、どちらの結果がよいかを数字で比べる方法です。差が偶然かどうかは統計の検定で判定します。

Q. ABテストは英語で何と言いますか? A. A/B testing です。split testing(スプリットテスト)とも呼ばれ、統計の言葉では「ランダム化比較実験」の一種です。

Q. ABテストは何人必要ですか? A. 今の CVR と見つけたい差で決まります。CVR 5% を 6% にする差を、有意水準5%・検出力80%で見つけるには、1グループ8,158人です。見つけたい差を半分にすると約4倍に増えます(「必要なサンプルサイズ」の節の早見表)。

Q. ABテストの期間はどれくらいですか? A. 「1グループの人数 × グループの数 ÷ 1日の訪問者数」で日数を出し、曜日の偏りが入らないよう週の単位で区切ります。上の例で1日2,000人なら9日なので、14日にする、という決め方です。

Q. 有意差とは何ですか?p値0.05の意味は? A. 偶然では説明しにくい差のことです。p 値は「本当は差が無いとしたら、今回以上の差が偶然で出る確率」で、0.05 未満なら有意と判定するのが一般的です。「B がよい確率が95%」という意味ではありません。

Q. カイ二乗検定とz検定はどちらを使えばいいですか? A. 2つの CVR を比べる2×2 の場合、補正なしのカイ二乗検定と両側の z 検定は同じ結果になります(z² = カイ二乗の値)。この記事の例ではどちらも p = 0.00498 でした。人数が少なく期待度数が5未満のセルがあるときは、フィッシャーの正確検定を使います。

Q. 途中で結果を見てもいいですか? A. 見て「有意になったら止める」と偽陽性が増えます。シミュレーションでは、5%のはずが20回見ると24%になりました。予定の人数まで判定に使わないか、見る回数を決めて境界を厳しくします。

Q. 有意差が出ないときはどうすればいいですか? A. 差の信頼区間を見ます。区間が狭く 0 の近くなら効果は小さいので見送り、区間が広いなら人数が足りず判定できていないので、人数を計算し直して次に生かします。

Q. 多変量テストとABテストの違いは? A. ABテストは1か所を変えた案を比べ、多変量テストは複数の要素の組み合わせ(見出し3×画像3×ボタン2なら18通り)を一度に比べます。多変量テストは1通りあたりの人数が少なくなるので、訪問者の多いページ向きです。

Q. Googleオプティマイズの代わりは? A. Google オプティマイズは2023年9月30日に終了しました。Google は GA4 とサードパーティの ABテストツールとの連携を案内し、連携先として AB Tasty・Optimizely・VWO を挙げています。アプリは Firebase A/B Testing があります。

Q. ベイズのABテストと普通のABテストの違いは? A. 普通の(頻度論の)方法は p 値で「差があるか」を判定し、ベイズは「B が A を上回る確率」を直接出します。どちらも、採用の基準と止め方を先に決めないと誤りが増えます。

Q. ABテストはSEOに悪影響がありますか? A. Google の推奨(クローキングしない・別 URL には rel="canonical"・302 リダイレクト・必要な期間だけ)を守れば、影響を小さくできます。終わったらテスト用の URL やスクリプトを消します。

まとめ

  • ABテストは、利用者をランダムに A と B に分け、同じ期間に比べる方法。前後比較と違い、外の影響を取り除ける。
  • 始める前に、仮説・主要指標1つ・ガードレール指標・1グループの人数・終了日を決める。CVR 5%→6% なら1グループ8,158人。差を半分にすると人数は約4倍。
  • 判定は p 値と差の95%信頼区間。2×2 のカイ二乗検定と z 検定は同じ結果(z² = カイ二乗)。
  • 途中で見て止めると偽陽性が増える(20回で 4.8%→24.2%)。人数が足りないと、有意になった効果は約3倍に見積もられる。
  • 指標・パターン・セグメントを増やすと偶然の「有意」が増える(20個で64%)。主要指標を決め、探索は補正する。
  • ベイズは「B が A を上回る確率」で表せるが、止め方のルールは同じく要る。SRM(p < 0.0005)があれば結果を使わない。
  • Google オプティマイズは2023年9月30日に終了。SEO は canonical・302・必要な期間だけ。

参考・出典

確認日はいずれも 2026年10月22日です。

  • Google アナリティクス ヘルプ「Google オプティマイズとオプティマイズ 360 のサポート終了」:https://support.google.com/optimize/answer/12979939?hl=ja
  • Google 検索セントラル「ウェブサイトのテストと Google 検索」:https://developers.google.com/search/docs/crawling-indexing/website-testing?hl=ja
  • Firebase「About Firebase A/B Testing」(A/B Testing の統計):https://firebase.google.com/docs/ab-testing/ab-concepts
  • Google 広告 ヘルプ「カスタムテストについて」:https://support.google.com/google-ads/answer/10683687?hl=ja
  • Optimizely「The story behind our Stats Engine」:https://www.optimizely.com/insights/blog/statistics-for-the-internet-age-the-story-behind-optimizelys-new-stats-engine/
  • Optimizely ドキュメント「Statistical significance」:https://docs.optimizely.com/experimentation-strategy/docs/statistical-significance
  • Wingify ヘルプ「What is SmartStats」:https://help.wingify.com/hc/en-us/articles/59066040145049-What-is-SmartStats
  • Microsoft Research「Diagnosing Sample Ratio Mismatch in A/B Testing」:https://www.microsoft.com/en-us/research/articles/diagnosing-sample-ratio-mismatch-in-a-b-testing/
  • Evan Miller「How Not To Run an A/B Test」:https://www.evanmiller.org/how-not-to-run-an-ab-test.html
  • renue「ABテストのやり方と統計的有意差の判定方法」(CVR 3%→3.6% のサンプルサイズの例):https://renue.co.jp/posts/ab-test-how-to-statistical-significance-complete-guide
  • SciPy「scipy.stats.chi2_contingency」:https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.chi2_contingency.html
  • SciPy「scipy.stats.ttest_ind」:https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.ttest_ind.html
  • statsmodels「proportions_ztest」:https://www.statsmodels.org/stable/generated/statsmodels.stats.proportion.proportions_ztest.html
  • statsmodels「confint_proportions_2indep」:https://www.statsmodels.org/stable/generated/statsmodels.stats.proportion.confint_proportions_2indep.html
  • statsmodels「NormalIndPower」:https://www.statsmodels.org/stable/generated/statsmodels.stats.power.NormalIndPower.html
  • statsmodels「multipletests」:https://www.statsmodels.org/stable/generated/statsmodels.stats.multitest.multipletests.html

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「ABテストとは|やり方・サンプルサイズ・有意差の判定をPythonで実演」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?