「施策前後の比較」だけでは因果関係を証明できない
キャンペーンを打ったら売上が上がった。しかし本当にキャンペーンの効果か?
- 年末の季節要因で元々売上が上がる時期だったかもしれない
- キャンペーン対象に「元々購買傾向が高い顧客」を選んでしまっていたかもしれない
- 競合他社が同時期にサービスを停止していたかもしれない
「施策前後の比較」(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_month | treatment_group | sales_per_customer | period |
|---|---|---|---|
| 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;出力イメージ
| grp | pre_period_avg | post_period_avg | within_group_diff | did_estimate |
|---|---|---|---|---|
| 処置群(ゴールド) | 14,950 | 20,500 | +5,550 | +4,600 |
| 対照群(シルバー) | 8,400 | 9,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,950 | 20,500 | +5,550 | +37.1% | +4,600 | +30.8% |
| 対照群 | 8,400 | 9,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_month | grp | sales_per_customer | mom_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」が精度を上げる。NTILE や ROW_NUMBER でスコアが近い顧客をペアリングしてから DiD を適用する。
② スピルオーバー効果への注意
対照群の顧客が処置群の顧客の「口コミや紹介」を通じて間接的に施策の影響を受けることがある(スピルオーバー)。これが起きると対照群の変化にも施策効果が混入し、DiD が過小推定になる。地域別の施策展開など、グループ間の相互作用が起きにくい設計にすることでスピルオーバーを防ぐ。
③ DiD は「差の差」なので解釈を間違えないこと
DiD の推定量は「処置群の変化から対照群の変化を引いたもの」だ。「施策後の処置群の売上」ではなく「施策がなかった場合と比較した増分」が推定値だ。会議で報告するときに「施策によって1顧客あたり月4,600円の売上が増えた」と正確に伝えることが重要だ。
まとめ
DiD 分析の SQL 実装をまとめる。
- 処置群と対照群を定義し、施策前・施策後の期間を設定する
- 各グループ×各期間の「顧客1人あたり月次売上」を集計する(ピボット形式に)
(処置群の差分) − (対照群の差分)で DiD 推定量を計算する- 施策前の月次トレンドを比較して「平行トレンド仮定」が成立しているか確認する
- サブグループ別に DiD を計算して「効果が大きい属性」を特定する
「売上が上がった」という観察から「施策のおかげで売上が上がった」という因果推論へ。この一歩は、データドリブンな意思決定の信頼性を根本的に高める。DiD はその一歩を踏み出すための実践的な道具だ。