本文へスキップ
martechfarmDATA & TECHNOLOGY DOCS

効果検証SQL 内を検索 · 全記事に戻す

効果検証SQL

7 件

タイトルを選ぶと本文が開きます

アップリフトモデリングをSQLで近似する方法|施策が効く顧客だけを抽出する

マーケティングチャネル貢献度をSQLで計算する方法|遷移確率でアトリビューション分析

差分の差分法をSQLで実装する方法|キャンペーン効果を因果推論で検証する

ファネル分析をSQLで行う方法|30分以内のカゴ投入から購買を正確に測る

SQLでランダムサンプリングとA/Bテスト割当を行う方法

クーポン施策の効果をSQLで検証する方法|割引の売上貢献を正しく測る

メルマガの購買転換率をSQLで計測する方法|配信効果を可視化するクエリ

本文
記事一覧へ ↑

SQLでランダムサンプリングとA/Bテスト割当を行う方法


「施策の効果」を正しく測るためのグループ設計

クーポン分析で「インクリメンタルリフト」という考え方を紹介した。施策の効果を本当に測るには、施策を受けたグループと受けなかったグループを比較する必要がある。

しかし「比較グループ」の作り方が適切でなければ、どんな精緻な集計をしても意味がない。

よくある失敗例がある。「新しいメールの件名を試したので効果を見ましょう」と言いながら、月曜に旧件名・火曜に新件名を送って比較する。しかし月曜と火曜では購買傾向が異なるため、件名の違いか曜日の違いかが分からない。

正しくやるには「同じタイミングに、ランダムに分けた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_groupcustomer_countpctavg_days_since_last_order
A_control8,41249.842.3
B_treatment8,48950.242.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_grouptotal_customerspurchasersconversion_rate_pcttotal_salesrevenue_per_customer
A_control8,4121,17814.0016,492,0001,960
B_treatment8,4891,44317.0020,203,2002,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_ratetreatment_rateabsolute_lift_pctrelative_lift_pctpooled_sez_score
0.14000.17003.0021.430.0053875.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 実装のポイントを振り返る。

  1. RAND() は再現性なし ― 配信リストの再現が必要な実務では使わない
  2. HASH(customer_id) で再現性のある分割 ― 何度実行しても同じ顧客が同じグループに入る
  3. ソルトを加えてテストごとに独立 ― HASH(customer_id || '_' || test_id) で複数テストの独立性を保つ
  4. 母集団の条件は実施前に固定 ― テスト開始後に条件を変えると結果が歪む
  5. 結果集計は LEFT JOIN ― 購買しなかった顧客も分母に含める
  6. z スコアで有意性を近似確認 ― 詳細な統計検定は Python / R へ

MarTech Farmをもっと見る

今すぐ購読し、続きを読んで、すべてのアーカイブにアクセスしましょう。

続きを読む