本文へ移動
効果検証SQL 更新日: 2026年5月2日 約31分で読めます

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


「施策前後の比較」だけでは因果関係を証明できない

キャンペーンを打ったら売上が上がった。しかし本当にキャンペーンの効果か?

  • 年末の季節要因で元々売上が上がる時期だったかもしれない
  • キャンペーン対象に「元々購買傾向が高い顧客」を選んでしまっていたかもしれない
  • 競合他社が同時期にサービスを停止していたかもしれない

「施策前後の比較」(Before-After比較)はこれらのバイアスを排除できない。そのとき使うのが差分の差分法(Difference-in-Differences、DiD)だ。

DiD の核心的なアイデアはシンプルだ。

真の施策効果 = (処置群の変化) − (対照群の変化)

「施策を受けたグループ(処置群)の変化」から「施策を受けなかったグループ(対照群)の同期間の変化」を引くことで、「季節変動など施策以外の要因」をキャンセルアウトする。これが「差分の差分」の名前の由来だ。


DiD の前提条件:平行トレンド仮定

DiD が正しく機能するためには「平行トレンド仮定」が成立している必要がある。

「施策がなかったとしたら、処置群と対照群は施策期間中も同じように変化したはずだ」

この仮定が成立しているかを施策前のデータで確認することが、DiD 分析の品質チェックとして重要だ。施策前の2グループの売上トレンドが平行かどうかを SQL でプロットし、目視で確認する。


使用するシナリオ

  • 施策:ゴールド会員向けの「送料無料クーポン」を2024年11月に配布
  • 処置群:ゴールド会員(クーポンを受け取った)
  • 対照群:シルバー会員(クーポンを受け取っていない、属性が近い層)
  • 施策前期間:2024年9月・10月(2ヶ月間)
  • 施策後期間:2024年11月・12月(2ヶ月間)

使用するテーブル

-- orders テーブル
-- order_id      : 注文ID
-- customer_id   : 顧客ID
-- order_date    : 注文日
-- total_amount  : 注文金額
-- status        : 'completed' / 'cancelled'

-- customers テーブル
-- customer_id   : 顧客ID
-- rank          : 会員ランク('gold' / 'silver' / 'general')
-- registered_at : 登録日

STEP 1 ― 処置群・対照群の定義と施策前データの確認

まず2グループを定義し、施策前のトレンドが平行かどうかを確認する。

-- STEP1: 処置群・対照群の月次売上(施策前後)

WITH group_assignment AS (
    SELECT
        customer_id,
        CASE
            WHEN rank = 'gold'   THEN '処置群(ゴールド)'
            WHEN rank = 'silver' THEN '対照群(シルバー)'
            ELSE NULL
        END  AS treatment_group
    FROM customers
    WHERE rank IN ('gold', 'silver')
),
monthly_sales AS (
    SELECT
        DATE_TRUNC('month', o.order_date)  AS sales_month,
        g.treatment_group,
        COUNT(DISTINCT o.customer_id)       AS active_customers,
        COUNT(o.order_id)                   AS order_count,
        SUM(o.total_amount)                 AS total_sales,
        ROUND(AVG(o.total_amount), 0)       AS avg_order_value,
        -- 1顧客あたりの月次売上(DIDで最も重要なKPI)
        ROUND(SUM(o.total_amount) * 1.0
            / COUNT(DISTINCT o.customer_id), 0)  AS sales_per_customer
    FROM orders             o
    JOIN group_assignment   g  ON o.customer_id = g.customer_id
    WHERE
        o.status = 'completed'
        AND o.order_date >= DATE '2024-09-01'
        AND o.order_date <  DATE '2025-01-01'
        AND g.treatment_group IS NOT NULL
    GROUP BY
        DATE_TRUNC('month', o.order_date),
        g.treatment_group
)
SELECT
    sales_month,
    treatment_group,
    active_customers,
    order_count,
    total_sales,
    sales_per_customer,

    -- 期間ラベル
    CASE
        WHEN sales_month < DATE '2024-11-01' THEN '施策前'
        ELSE                                      '施策後'
    END  AS period
FROM monthly_sales
ORDER BY sales_month, treatment_group;

出力イメージ

sales_monthtreatment_groupsales_per_customerperiod
2024-09-01対照群(シルバー)8,200施策前
2024-09-01処置群(ゴールド)14,800施策前
2024-10-01対照群(シルバー)8,600施策前
2024-10-01処置群(ゴールド)15,100施策前
2024-11-01対照群(シルバー)9,100施策後
2024-11-01処置群(ゴールド)19,800施策後
2024-12-01対照群(シルバー)9,800施策後
2024-12-01処置群(ゴールド)21,200施策後

施策前(9〜10月)は処置群と対照群のトレンドが平行(どちらも緩やかに増加)しており、平行トレンド仮定が成立していそうだ。施策後の11〜12月に処置群が急増している。


STEP 2 ― DiD の推定量を計算する(核心)

DiD の計算式は次の通りだ。

DiD = (処置群の施策後平均 − 処置群の施策前平均)
− (対照群の施策後平均 − 対照群の施策前平均)
= 処置効果の純粋な推定値
-- STEP2: DiD 推定量の計算

WITH group_assignment AS (
    SELECT customer_id,
           CASE WHEN rank = 'gold'   THEN '処置群' WHEN rank = 'silver' THEN '対照群' END AS grp
    FROM customers WHERE rank IN ('gold', 'silver')
),
period_sales AS (
    SELECT
        g.grp,
        CASE WHEN o.order_date >= DATE '2024-11-01' THEN '施策後' ELSE '施策前' END AS period,
        -- 顧客1人あたりの月平均売上(2ヶ月の平均)
        ROUND(
            SUM(o.total_amount) * 1.0
            / COUNT(DISTINCT o.customer_id)
            / 2.0  -- 2ヶ月で割って月平均に
        , 0)  AS avg_monthly_sales_per_customer
    FROM orders           o
    JOIN group_assignment g  ON o.customer_id = g.customer_id
    WHERE o.status = 'completed'
    AND o.order_date >= DATE '2024-09-01'
    AND o.order_date <  DATE '2025-01-01'
    GROUP BY g.grp, CASE WHEN o.order_date >= DATE '2024-11-01' THEN '施策後' ELSE '施策前' END
),
pivoted AS (
    SELECT
        grp,
        MAX(CASE WHEN period = '施策前' THEN avg_monthly_sales_per_customer END)  AS pre,
        MAX(CASE WHEN period = '施策後' THEN avg_monthly_sales_per_customer END)  AS post
    FROM period_sales
    GROUP BY grp
)
SELECT
    grp,
    pre   AS pre_period_avg,
    post  AS post_period_avg,
    post - pre  AS within_group_diff,

    -- DiD: 処置群の差分 − 対照群の差分
    (MAX(CASE WHEN grp = '処置群' THEN post - pre END) OVER ()
    - MAX(CASE WHEN grp = '対照群' THEN post - pre END) OVER ())  AS did_estimate
FROM pivoted
ORDER BY grp DESC;

出力イメージ

grppre_period_avgpost_period_avgwithin_group_diffdid_estimate
処置群(ゴールド)14,95020,500+5,550+4,600
対照群(シルバー)8,4009,350+950+4,600
  • 処置群(ゴールド)の変化:+5,550円(14,950→20,500)
  • 対照群(シルバー)の変化:+950円(8,400→9,350)
  • DiD 推定量:5,550 − 950 = +4,600円

「施策後に売上が増えた5,550円のうち、950円は季節変動など施策以外の要因で、4,600円が送料無料クーポンによる純粋な効果」と解釈できる。


STEP 3 ― 2×2 の DiD テーブルを整形して分かりやすく表示する(完成版)

-- STEP3: 2×2 DiD テーブル(完成版・解釈付き)

WITH group_assignment AS (
    SELECT customer_id,
           CASE WHEN rank = 'gold'   THEN '処置群' WHEN rank = 'silver' THEN '対照群' END AS grp
    FROM customers WHERE rank IN ('gold', 'silver')
),
customer_counts AS (
    -- 各グループの顧客数
    SELECT grp, COUNT(*) AS customer_count
    FROM group_assignment
    GROUP BY grp
),
period_sales AS (
    SELECT
        g.grp,
        CASE WHEN o.order_date >= DATE '2024-11-01' THEN 'post' ELSE 'pre' END AS period,
        SUM(o.total_amount)                              AS total_sales,
        COUNT(DISTINCT o.customer_id)                    AS active_customers,
        COUNT(o.order_id)                                AS order_count,
        ROUND(
            SUM(o.total_amount) * 1.0
            / COUNT(DISTINCT o.customer_id)
            / 2.0
        , 0)  AS avg_monthly_sales_per_customer
    FROM orders o
    JOIN group_assignment g ON o.customer_id = g.customer_id
    WHERE o.status = 'completed'
    AND o.order_date >= DATE '2024-09-01'
    AND o.order_date <  DATE '2025-01-01'
    GROUP BY g.grp,
             CASE WHEN o.order_date >= DATE '2024-11-01' THEN 'post' ELSE 'pre' END
),
pivoted AS (
    SELECT
        grp,
        MAX(CASE WHEN period = 'pre'  THEN avg_monthly_sales_per_customer END)  AS pre_sales,
        MAX(CASE WHEN period = 'post' THEN avg_monthly_sales_per_customer END)  AS post_sales,
        MAX(CASE WHEN period = 'pre'  THEN active_customers END)                AS pre_active,
        MAX(CASE WHEN period = 'post' THEN active_customers END)                AS post_active,
        MAX(CASE WHEN period = 'pre'  THEN order_count END)                     AS pre_orders,
        MAX(CASE WHEN period = 'post' THEN order_count END)                     AS post_orders
    FROM period_sales
    GROUP BY grp
),
with_did AS (
    SELECT
        grp,
        pre_sales,
        post_sales,
        post_sales - pre_sales  AS sales_diff,
        pre_active,
        post_active,
        pre_orders,
        post_orders,
        -- DiD(ウィンドウ関数で両グループの差分を計算)
        (post_sales - pre_sales)
        - MIN(post_sales - pre_sales) OVER ()  AS did_sales,
        -- 相対的な施策効果(%)
        ROUND(
            ((post_sales - pre_sales)
            - MIN(post_sales - pre_sales) OVER ())
            * 100.0
            / NULLIF(MAX(CASE WHEN grp = '処置群' THEN pre_sales END) OVER (), 0)
        , 1)  AS did_relative_pct
    FROM pivoted
)
SELECT
    grp,
    pre_sales                    AS "施策前(月平均)",
    post_sales                   AS "施策後(月平均)",
    sales_diff                   AS "変化額",
    ROUND(sales_diff * 100.0 / NULLIF(pre_sales, 0), 1)  AS "変化率%",
    did_sales                    AS "DiD推定値(施策純効果)",
    did_relative_pct             AS "施策前比の純効果%",
    pre_active                   AS "施策前アクティブ顧客",
    post_active                  AS "施策後アクティブ顧客",
    pre_orders                   AS "施策前注文数",
    post_orders                  AS "施策後注文数"
FROM with_did
ORDER BY grp DESC;

完成した出力イメージ

grp施策前施策後変化額変化率%DiD純効果施策前比の純効果%
処置群14,95020,500+5,550+37.1%+4,600+30.8%
対照群8,4009,350+950+11.3%0(基準)

送料無料クーポンは「37.1%の売上増」のうち「30.8%ポイント分が純粋な施策効果」で、残り「6.3%ポイントは季節変動」と分解できる。


STEP 4 ― 平行トレンド仮定の検証

施策前の月次データで処置群と対照群のトレンドが平行かどうかを確認する。

-- STEP4: 施策前の月次トレンド比較(平行トレンド仮定の確認)

WITH group_assignment AS (
    SELECT customer_id,
           CASE WHEN rank = 'gold'   THEN '処置群' WHEN rank = 'silver' THEN '対照群' END AS grp
    FROM customers WHERE rank IN ('gold', 'silver')
),
monthly_pre AS (
    SELECT
        DATE_TRUNC('month', o.order_date)  AS sales_month,
        g.grp,
        ROUND(
            SUM(o.total_amount) * 1.0
            / COUNT(DISTINCT o.customer_id)
        , 0)  AS sales_per_customer
    FROM orders o
    JOIN group_assignment g ON o.customer_id = g.customer_id
    WHERE o.status = 'completed'
    -- 施策前の複数ヶ月を確認(ここでは6ヶ月前まで)
    AND o.order_date >= DATE '2024-05-01'
    AND o.order_date <  DATE '2024-11-01'
    GROUP BY DATE_TRUNC('month', o.order_date), g.grp
),
with_growth AS (
    SELECT
        sales_month,
        grp,
        sales_per_customer,
        -- 前月比成長率
        LAG(sales_per_customer) OVER (
            PARTITION BY grp
            ORDER BY sales_month
        )  AS prev_month_sales,
        ROUND(
            (sales_per_customer
            - LAG(sales_per_customer) OVER (PARTITION BY grp ORDER BY sales_month))
            * 100.0
            / NULLIF(LAG(sales_per_customer) OVER (PARTITION BY grp ORDER BY sales_month), 0)
        , 1)  AS mom_growth_pct
    FROM monthly_pre
)
SELECT
    sales_month,
    grp,
    sales_per_customer,
    mom_growth_pct,
    -- 処置群と対照群の成長率の差
    mom_growth_pct
    - AVG(mom_growth_pct) OVER (
        PARTITION BY sales_month
    )  AS growth_diff_from_avg
FROM with_growth
ORDER BY sales_month, grp;

出力イメージ(施策前の月次成長率)

sales_monthgrpsales_per_customermom_growth_pct
2024-06処置群14,200+2.1%
2024-06対照群7,800+2.4%
2024-07処置群13,800-2.8%
2024-07対照群7,600-2.6%
2024-08処置群14,100+2.2%
2024-08対照群7,900+3.9%
2024-09処置群14,800+5.0%
2024-09対照群8,200+3.8%
2024-10処置群15,100+2.0%
2024-10対照群8,600+4.9%

施策前の5ヶ月間、処置群と対照群の月次成長率が類似した動きをしており、平行トレンド仮定が概ね成立している。ただし10月の対照群の成長率(+4.9%)が処置群(+2.0%)を上回るなど、完全な平行とは言えない。この場合はさらに長い施策前期間を確認するか、DiD の解釈に「不完全な平行トレンド」という留意点を付記する。


STEP 5 ― 施策効果を顧客属性別に分解する(異質な処置効果)

DiD 全体の平均効果だけでなく、「どのサブグループで効果が大きかったか」を分解する。

-- STEP5: 属性別の DiD 推定(異質な処置効果の検出)

WITH group_assignment AS (
    SELECT
        c.customer_id,
        CASE WHEN c.rank = 'gold'   THEN '処置群' WHEN c.rank = 'silver' THEN '対照群' END AS grp,
        -- 属性の分割(ここでは過去の購買頻度で高頻度・低頻度に分類)
        CASE
            WHEN order_hist.annual_orders >= 6 THEN '高頻度購買者'
            ELSE                                    '低頻度購買者'
        END  AS frequency_segment
    FROM customers c
    LEFT JOIN (
        SELECT customer_id, COUNT(*) AS annual_orders
        FROM orders
        WHERE status = 'completed'
        AND order_date >= DATE '2023-09-01'
        AND order_date <  DATE '2024-09-01'
        GROUP BY customer_id
    ) order_hist  ON c.customer_id = order_hist.customer_id
    WHERE c.rank IN ('gold', 'silver')
),
period_sales AS (
    SELECT
        g.grp,
        g.frequency_segment,
        CASE WHEN o.order_date >= DATE '2024-11-01' THEN 'post' ELSE 'pre' END AS period,
        ROUND(
            SUM(o.total_amount) * 1.0
            / COUNT(DISTINCT o.customer_id)
            / 2.0
        , 0)  AS avg_monthly_sales_per_customer
    FROM orders           o
    JOIN group_assignment g  ON o.customer_id = g.customer_id
    WHERE o.status = 'completed'
    AND o.order_date >= DATE '2024-09-01'
    AND o.order_date <  DATE '2025-01-01'
    GROUP BY g.grp, g.frequency_segment,
             CASE WHEN o.order_date >= DATE '2024-11-01' THEN 'post' ELSE 'pre' END
),
pivoted AS (
    SELECT
        grp,
        frequency_segment,
        MAX(CASE WHEN period = 'pre'  THEN avg_monthly_sales_per_customer END)  AS pre,
        MAX(CASE WHEN period = 'post' THEN avg_monthly_sales_per_customer END)  AS post
    FROM period_sales
    GROUP BY grp, frequency_segment
),
with_diff AS (
    SELECT
        grp,
        frequency_segment,
        pre,
        post,
        post - pre  AS diff
    FROM pivoted
),
did_by_segment AS (
    SELECT
        frequency_segment,
        MAX(CASE WHEN grp = '処置群' THEN diff END)  AS treatment_diff,
        MAX(CASE WHEN grp = '対照群' THEN diff END)  AS control_diff,
        MAX(CASE WHEN grp = '処置群' THEN diff END)
        - MAX(CASE WHEN grp = '対照群' THEN diff END)  AS did_estimate,
        MAX(CASE WHEN grp = '処置群' THEN pre END)    AS treatment_pre
    FROM with_diff
    GROUP BY frequency_segment
)
SELECT
    frequency_segment,
    treatment_diff  AS "処置群の変化",
    control_diff    AS "対照群の変化",
    did_estimate    AS "DiD純効果",
    ROUND(did_estimate * 100.0 / NULLIF(treatment_pre, 0), 1)  AS "施策前比純効果%"
FROM did_by_segment
ORDER BY did_estimate DESC;

出力イメージ

frequency_segment処置群の変化対照群の変化DiD純効果施策前比純効果%
高頻度購買者+7,200+1,100+6,100+34.2%
低頻度購買者+3,800+800+3,000+28.6%

高頻度購買者のほうが施策効果が大きい(+6,100円 vs +3,000円)。送料無料クーポンは「元々よく買う顧客」の購買金額をさらに押し上げる効果がある。低頻度購買者への効果も確認できるが、相対的に小さい。今後の施策設計では高頻度購買者に絞ってより手厚い施策を打つことが ROI を最大化する。


実務での運用ヒント

① 処置群・対照群の選び方が DiD の品質を決める

今回はゴールド vs シルバーという会員ランクで分割したが、2グループが最初から売上・属性・購買傾向が大きく異なると平行トレンド仮定が崩れやすい。理想的には「施策直前の購買金額が近い顧客同士」を処置群・対照群にマッチングする「マッチング DiD」が精度を上げる。NTILEROW_NUMBER でスコアが近い顧客をペアリングしてから DiD を適用する。

② スピルオーバー効果への注意

対照群の顧客が処置群の顧客の「口コミや紹介」を通じて間接的に施策の影響を受けることがある(スピルオーバー)。これが起きると対照群の変化にも施策効果が混入し、DiD が過小推定になる。地域別の施策展開など、グループ間の相互作用が起きにくい設計にすることでスピルオーバーを防ぐ。

③ DiD は「差の差」なので解釈を間違えないこと

DiD の推定量は「処置群の変化から対照群の変化を引いたもの」だ。「施策後の処置群の売上」ではなく「施策がなかった場合と比較した増分」が推定値だ。会議で報告するときに「施策によって1顧客あたり月4,600円の売上が増えた」と正確に伝えることが重要だ。


まとめ

DiD 分析の SQL 実装をまとめる。

  1. 処置群と対照群を定義し、施策前・施策後の期間を設定する
  2. 各グループ×各期間の「顧客1人あたり月次売上」を集計する(ピボット形式に)
  3. (処置群の差分) − (対照群の差分) で DiD 推定量を計算する
  4. 施策前の月次トレンドを比較して「平行トレンド仮定」が成立しているか確認する
  5. サブグループ別に DiD を計算して「効果が大きい属性」を特定する

「売上が上がった」という観察から「施策のおかげで売上が上がった」という因果推論へ。この一歩は、データドリブンな意思決定の信頼性を根本的に高める。DiD はその一歩を踏み出すための実践的な道具だ。


次のアクション

SQLやデータ活用を、手元で試しながら理解する

記事で読んだ考え方を、SQL練習場や関連カテゴリの記事でさらに深掘りできます。相談やご依頼もお問い合わせページから受け付けています。

SQL練習場で試す お問い合わせ

この記事を書いた人:martechfarm

Treasure Data Top Lapidarist Award受賞。

SQL / Digdag / Python / CDP設計 / CRM設計を横断し、企業のデータ活用を支援。

実績・支援内容を見る →

MarTech Farmをもっと見る

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

続きを読む