「施策の効果」を正しく測るためのグループ設計
クーポン分析で「インクリメンタルリフト」という考え方を紹介した。施策の効果を本当に測るには、施策を受けたグループと受けなかったグループを比較する必要がある。
しかし「比較グループ」の作り方が適切でなければ、どんな精緻な集計をしても意味がない。
よくある失敗例がある。「新しいメールの件名を試したので効果を見ましょう」と言いながら、月曜に旧件名・火曜に新件名を送って比較する。しかし月曜と火曜では購買傾向が異なるため、件名の違いか曜日の違いかが分からない。
正しくやるには「同じタイミングに、ランダムに分けた2グループに別々の施策を当てる」という A/B テスト設計が必要だ。
今回はこの「ランダムなグループ分割」を SQL だけで実装する。さらに重要なのは 再現性だ。同じクエリを2週間後に実行しても同じ顧客が同じグループに入る設計にしないと、「先週はAグループだった顧客が今週はBグループになった」という混乱が起きる。
サンプリングとグループ分割の2つのフェーズ
A/B テストの SQL 実装は2段階に分かれる。
フェーズ1:母集団から対象者をサンプリングする
(全顧客の中から「今回のテスト対象」を絞り込む)
フェーズ2:サンプリングした対象者をA/Bグループに割り振る
(ランダムかつ再現性のある方法で)使用するテーブル
-- customers テーブル
-- customer_id : 顧客ID
-- registered_at : 会員登録日
-- channel : 初回流入チャネル
-- opt_out_flag : メルマガ配信停止フラグ(1=停止)
-- orders テーブル
-- order_id : 注文ID
-- customer_id : 顧客ID
-- order_date : 注文日
-- total_amount : 注文金額
-- status : 'completed' / 'cancelled'STEP 1 ― RAND() によるランダムサンプリング(再現性なし)
最もシンプルなサンプリングは RAND() を使う方法だ。0〜1の乱数を各行に付与し、閾値以下の行だけを残す。
-- STEP1: RAND() で全顧客の約10%をサンプリング
SELECT
customer_id,
RAND() AS random_value
FROM customers
WHERE opt_out_flag = 0
HAVING random_value < 0.10 -- 10%をサンプリング
ORDER BY random_value;これは簡単だが再現性がない。実行するたびに異なる顧客が選ばれる。
一度配信リストを作った後に「あのリストを再現して」と言われても対応できない。
STEP 2 ― HASH による再現性のあるサンプリング
再現性を持たせるには RAND() の代わりに決定論的なハッシュ関数を使う。同じ入力値に対して常に同じハッシュ値が返るため、何度実行しても同じ顧客が選ばれる。
-- STEP2: ハッシュによる再現性のあるサンプリング
SELECT
customer_id,
ABS(HASH(customer_id)) % 100 AS hash_bucket -- 0〜99の整数
FROM customers
WHERE opt_out_flag = 0;HASH(customer_id) % 100 は顧客IDを0〜99のバケットに割り振る。
バケットの値は顧客IDが変わらない限り永遠に同じ値になる。
hash_bucket < 10→ 約10%のサンプリングhash_bucket < 50→ 約50%のサンプリング
-- 約10%をサンプリング(再現性あり)
SELECT customer_id
FROM customers
WHERE opt_out_flag = 0
AND ABS(HASH(customer_id)) % 100 < 10
ORDER BY customer_id;STEP 3 ― A/B グループへの割り振り(完成版)
サンプリングした母集団をA・Bの2グループに分ける。同じハッシュを使って、偶数バケットをA、奇数バケットをBとすることで均等に分割できる。
-- STEP3: A/Bグループへの割り振り(再現性あり)
WITH target_population AS (
-- テスト対象の母集団を定義
-- 条件:直近6ヶ月に購買あり・配信停止でない・登録から30日以上経過
SELECT
c.customer_id,
c.registered_at,
c.channel,
MAX(o.order_date) AS last_order_date
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
WHERE
c.opt_out_flag = 0
AND o.status = 'completed'
AND o.order_date >= DATE_ADD('month', -6, CURRENT_DATE)
AND c.registered_at <= DATE_ADD('day', -30, CURRENT_DATE)
GROUP BY c.customer_id, c.registered_at, c.channel
),
ab_assigned AS (
SELECT
customer_id,
registered_at,
channel,
last_order_date,
-- ハッシュバケット(0〜99)
ABS(HASH(customer_id)) % 100 AS hash_bucket,
-- A/Bグループ割り振り
-- 0〜49 → グループA(コントロール:施策なし)
-- 50〜99 → グループB(トリートメント:施策あり)
CASE
WHEN ABS(HASH(customer_id)) % 100 < 50 THEN 'A_control'
ELSE 'B_treatment'
END AS ab_group
FROM target_population
)
SELECT
ab_group,
COUNT(customer_id) AS customer_count,
ROUND(COUNT(customer_id) * 100.0 / SUM(COUNT(customer_id)) OVER (), 1) AS pct,
ROUND(AVG(DATEDIFF(CURRENT_DATE, last_order_date)), 1) AS avg_days_since_last_order
FROM ab_assigned
GROUP BY ab_group
ORDER BY ab_group;出力イメージ(グループの均衡確認)
| ab_group | customer_count | pct | avg_days_since_last_order |
|---|---|---|---|
| A_control | 8,412 | 49.8 | 42.3 |
| B_treatment | 8,489 | 50.2 | 42.1 |
2グループの顧客数(50%:50%)と「最終購買からの日数の平均」がほぼ等しいことが確認できた。グループ間にバイアスがなければ、施策を当てた後の差分が純粋な施策効果として解釈できる。
STEP 4 ― ソルトを使って複数テストで同じグループ分けを避ける
1つの HASH(customer_id) を複数のテストに使いまわすと、毎回同じ顧客がAグループになる。異なるテストでは異なるグループ分けが必要だ。
「ソルト(salt)」を加えることでテストごとに異なるハッシュ値を生成できる。
-- STEP4: テストIDをソルトとして使い、テストごとに独立したグループ分け
WITH test_config AS (
SELECT 'TEST_2024_DEC_EMAIL_SUBJECT' AS test_id -- テストの識別子
),
ab_assigned AS (
SELECT
c.customer_id,
-- customer_id とテストIDを連結してハッシュ化
ABS(HASH(c.customer_id || '_' || t.test_id)) % 100 AS hash_bucket,
CASE
WHEN ABS(HASH(c.customer_id || '_' || t.test_id)) % 100 < 50
THEN 'A_control'
ELSE 'B_treatment'
END AS ab_group
FROM customers c
CROSS JOIN test_config t
WHERE c.opt_out_flag = 0
)
SELECT
customer_id,
hash_bucket,
ab_group
FROM ab_assigned
ORDER BY customer_id;'TEST_2024_DEC_EMAIL_SUBJECT' という文字列を変えるだけで、異なるテストごとに独立したグループ分けができる。同じ顧客でも、テストAではBグループになり、テストBではAグループになることがある。これにより複数テスト間の相互影響を最小化できる。
STEP 5 ― テスト結果を集計して効果を測定する
施策実施後、A/Bグループ別の購買転換率・売上を比較する。
-- STEP5: A/Bテスト結果の集計
WITH test_config AS (
SELECT
'TEST_2024_DEC_EMAIL_SUBJECT' AS test_id,
DATE '2024-12-01' AS test_start,
DATE '2024-12-08' AS test_end -- 計測期間(7日間)
),
target_population AS (
SELECT
c.customer_id,
CASE
WHEN ABS(HASH(c.customer_id || '_' || t.test_id)) % 100 < 50
THEN 'A_control'
ELSE 'B_treatment'
END AS ab_group
FROM customers c
CROSS JOIN test_config t
WHERE c.opt_out_flag = 0
-- テスト前の条件で対象者を絞る(直近6ヶ月購買あり など)
),
post_test_orders AS (
-- テスト期間中の購買データ
SELECT
o.customer_id,
COUNT(DISTINCT o.order_id) AS order_count,
SUM(o.total_amount) AS total_sales
FROM orders o
CROSS JOIN test_config t
WHERE
o.status = 'completed'
AND o.order_date >= t.test_start
AND o.order_date < t.test_end
GROUP BY o.customer_id
)
SELECT
tp.ab_group,
-- 母数
COUNT(tp.customer_id) AS total_customers,
-- 購買者数と転換率
COUNT(po.customer_id) AS purchasers,
ROUND(COUNT(po.customer_id) * 100.0
/ NULLIF(COUNT(tp.customer_id), 0), 2) AS conversion_rate_pct,
-- 売上
COALESCE(SUM(po.total_sales), 0) AS total_sales,
COALESCE(ROUND(AVG(po.total_sales), 0), 0) AS avg_sales_per_converter,
-- 1顧客あたりの売上(転換しなかった顧客も含む)
ROUND(COALESCE(SUM(po.total_sales), 0) * 1.0
/ NULLIF(COUNT(tp.customer_id), 0), 0) AS revenue_per_customer
FROM target_population tp
LEFT JOIN post_test_orders po ON tp.customer_id = po.customer_id
GROUP BY tp.ab_group
ORDER BY tp.ab_group;出力イメージ
| ab_group | total_customers | purchasers | conversion_rate_pct | total_sales | revenue_per_customer |
|---|---|---|---|---|---|
| A_control | 8,412 | 1,178 | 14.00 | 16,492,000 | 1,960 |
| B_treatment | 8,489 | 1,443 | 17.00 | 20,203,200 | 2,380 |
Bグループ(新件名のメール)の転換率は17.0%で、Aグループ(旧件名)の14.0%より3ポイント高い。1顧客あたりの売上も2,380円 vs 1,960円で、Bグループが420円高い。これが「件名変更の効果」の定量値だ。
STEP 6 ― 差分の統計的有意性を近似評価する
「3ポイントの差が偶然でないか」を確認するために、簡易的な検定指標を SQL で計算する。
-- STEP6: 転換率の差分と信頼区間の近似計算
WITH ab_results AS (
-- STEP5の結果を再利用(実際はCTEとして連結する)
SELECT 'A_control' AS group_name, 8412 AS n, 1178 AS conversions
UNION ALL
SELECT 'B_treatment' , 8489 , 1443
),
with_rates AS (
SELECT
group_name,
n,
conversions,
ROUND(conversions * 1.0 / n, 4) AS rate
FROM ab_results
),
-- 制御群の転換率
control_rate AS (
SELECT rate AS cr FROM with_rates WHERE group_name = 'A_control'
),
-- 処置群の転換率
treatment_rate AS (
SELECT rate AS tr FROM with_rates WHERE group_name = 'B_treatment'
)
SELECT
cr.cr AS control_rate,
tr.tr AS treatment_rate,
ROUND((tr.tr - cr.cr) * 100, 2) AS absolute_lift_pct,
ROUND((tr.tr - cr.cr) / cr.cr * 100, 2) AS relative_lift_pct,
-- プールした標準誤差(正規近似によるz検定の近似)
ROUND(
SQRT(
((cr.cr * (1 - cr.cr)) / 8412)
+ ((tr.tr * (1 - tr.tr)) / 8489)
)
, 6) AS pooled_se,
-- z値(1.96以上で95%水準で有意と近似)
ROUND(
(tr.tr - cr.cr)
/ NULLIF(
SQRT(
((cr.cr * (1 - cr.cr)) / 8412)
+ ((tr.tr * (1 - tr.tr)) / 8489)
)
, 0)
, 3) AS z_score
FROM control_rate cr, treatment_rate tr;出力イメージ
| control_rate | treatment_rate | absolute_lift_pct | relative_lift_pct | pooled_se | z_score |
|---|---|---|---|---|---|
| 0.1400 | 0.1700 | 3.00 | 21.43 | 0.005387 | 5.568 |
z スコアが 5.57 と 1.96(95%有意水準の閾値)を大きく超えている。この差は偶然ではなく、新しい件名が有意に効果的だと言える。
注意:SQLの統計検定は「近似」
上記の z 検定は正規近似を使った簡易計算だ。より正確な検定(カイ二乗検定・フィッシャーの正確検定など)は Python や R で行うことを推奨する。ただし「有意かどうかの概要を SQL だけで素早く確認する」用途には十分。
実務での運用ヒント
① テストの記録を必ずテーブルに残す
どのテストIDで、どの条件で、どの期間に、何人を対象にしたかを ab_test_registry のようなテーブルに記録しておく。後から「あのテストどうだったっけ」を再現できるように。
-- ab_test_registry テーブルの設計例
-- test_id : テストID
-- test_name : テスト名(「12月メール件名A/Bテスト」など)
-- test_start : 開始日
-- test_end : 終了日
-- salt : ハッシュに使ったソルト文字列
-- split_ratio : 分割比率(例:0.5 = 50:50)
-- target_criteria: 対象者の条件(JSONまたはテキスト)
-- created_by : 実施者
-- created_at : 登録日時② サンプルサイズの事前計算
「どれくらいの期間テストを走らせれば有意差を検出できるか」のサンプルサイズ計算は、テスト開始前に行う。以下の簡易式で最低サンプルサイズを見積もれる。
最低サンプルサイズ(各グループ)=
(z_α/2 + z_β)² × p(1-p) × 2
/ (最小検出差異)²
where:
z_α/2 = 1.96(有意水準5%)
z_β = 0.84(検出力80%)
p = 現在の転換率(基準値)
最小検出差異 = 検出したい転換率の差(例:0.02 = 2ポイント改善)-- サンプルサイズ計算(SQLで見積もる)
WITH params AS (
SELECT
0.14 AS base_rate, -- 現在の転換率
0.03 AS minimum_detectable_effect, -- 最低検出差異(3ポイント改善)
1.96 AS z_alpha, -- 有意水準5%
0.84 AS z_beta -- 検出力80%
)
SELECT
CEIL(
POWER(z_alpha + z_beta, 2)
* 2 * base_rate * (1 - base_rate)
/ POWER(minimum_detectable_effect, 2)
) AS required_sample_size_per_group
FROM params;出力イメージ
| required_sample_size_per_group |
|---|
| 5,765 |
各グループに5,765人以上いれば、3ポイントの改善を80%の確率で検出できる。
③ ノベルティ効果に注意
新しい施策を始めた直後は、新しさへの反応で一時的に効果が高く出ることがある(ノベルティ効果)。最低7〜14日テストを走らせ、最初の数日を除いた期間でも効果が持続しているかを確認する。
まとめ
A/B テスト設計の SQL 実装のポイントを振り返る。
RAND()は再現性なし ― 配信リストの再現が必要な実務では使わないHASH(customer_id)で再現性のある分割 ― 何度実行しても同じ顧客が同じグループに入る- ソルトを加えてテストごとに独立 ―
HASH(customer_id || '_' || test_id)で複数テストの独立性を保つ - 母集団の条件は実施前に固定 ― テスト開始後に条件を変えると結果が歪む
- 結果集計は LEFT JOIN ― 購買しなかった顧客も分母に含める
- z スコアで有意性を近似確認 ― 詳細な統計検定は Python / R へ