「メルマガを送ったら売上が上がった」は本当か
メルマガを配信した翌日に注文が増えると、担当者は「効果があった」と判断しがちだ。しかし少し立ち止まって考えてほしい。
- その注文は、メルマガを受け取ったから起きたのか
- それとも、メルマガとは無関係にそもそも買うつもりだった顧客が注文したのか
- あるいは、配信日がたまたま週末でもともと注文が多い曜日だったのか
この問いに答えるためには、「誰にメルマガを送ったか」と「誰が購買したか」を突き合わせるクエリが必要だ。
今回は、メルマガの配信ログと購買ログをJOINして転換率を計測するクエリを作る。「メルマガを受け取った人」と「受け取っていない人」で購買率を比較することで、施策の真の効果に近づくことができる。
アトリビューションの考え方
SQLに入る前に、「どこまでをメルマガの効果と見なすか」を決めておく必要がある。これをアトリビューション(貢献度の帰属)と呼ぶ。
今回は最もシンプルな定義を使う。
メルマガ配信から72時間以内に購買した顧客を「転換した」と見なす
72時間(3日間)という窓は業界でよく使われる目安だ。短すぎると「開封して検討した顧客」を取りこぼし、長すぎると「メルマガと無関係な購買」を巻き込んでしまう。自社の顧客行動データを見ながら調整してほしい。
| 指標 | 定義 |
|---|---|
| 配信数 | メルマガを送った顧客の数 |
| 転換数 | 配信から72時間以内に購買した顧客の数 |
| 転換率 | 転換数 ÷ 配信数 |
| 非受信者購買率 | 配信しなかった顧客が同期間に購買した割合(比較対象) |
使用するテーブル
今回は新たに2つのテーブルを使う。
-- メルマガ配信ログテーブル(mail_send_log)
-- send_id : 配信ID(主キー)
-- campaign_id : キャンペーンID
-- campaign_name: キャンペーン名
-- customer_id : 配信対象の顧客ID
-- sent_at : 配信日時(TIMESTAMP型)| send_id | campaign_id | campaign_name | customer_id | sent_at |
|---|---|---|---|---|
| S0001 | C001 | 春の新作案内 | C001 | 2024-03-01 10:00:00 |
| S0002 | C001 | 春の新作案内 | C002 | 2024-03-01 10:00:00 |
| S0003 | C001 | 春の新作案内 | C003 | 2024-03-01 10:00:00 |
| S0004 | C002 | 会員限定セール | C001 | 2024-04-15 11:00:00 |
| … | … | … | … | … |
-- 注文テーブル(orders)
-- order_id : 注文ID
-- customer_id : 顧客ID
-- order_date : 注文日(DATE型)
-- ordered_at : 注文日時(TIMESTAMP型)
-- total_amount : 注文金額
-- status : 'completed' / 'cancelled' など日付型とタイムスタンプ型の使い分け
sent_atは時刻まで持つ TIMESTAMP 型だ。72時間ウィンドウのような時刻精度の計算には TIMESTAMP 型が必要なので、ordersテーブルにもordered_at(TIMESTAMP型)カラムがあることを前提にしている。order_date(DATE型)しか持っていない場合は、CAST(order_date AS TIMESTAMP)で代用できるが、時刻が00:00:00になるため72時間の計算が日をまたぐ場合に注意が必要だ。
STEP 1 ― 配信リストと購買ログを突き合わせる
まず特定キャンペーンの配信リストと、その後の購買を結びつける。
-- STEP1: 配信顧客と購買の突き合わせ
WITH campaign_sends AS (
-- 対象キャンペーンの配信リスト
SELECT
customer_id,
campaign_id,
campaign_name,
sent_at
FROM mail_send_log
WHERE campaign_id = 'C001' -- 分析対象のキャンペーンIDを指定
),
post_send_orders AS (
-- 配信後に購買した記録を引っ張る
SELECT
s.customer_id,
s.campaign_id,
s.campaign_name,
s.sent_at,
o.order_id,
o.ordered_at,
o.total_amount,
-- 配信から購買までの時間(時間単位)
DATE_DIFF('hour', s.sent_at, o.ordered_at) AS hours_after_send
FROM campaign_sends s
LEFT JOIN orders o
ON s.customer_id = o.customer_id
AND o.status = 'completed'
-- 配信後から72時間以内の購買のみ
AND o.ordered_at >= s.sent_at
AND o.ordered_at < s.sent_at + INTERVAL '72' HOUR
)
SELECT *
FROM post_send_orders
ORDER BY customer_id, ordered_at;出力イメージ
| customer_id | campaign_name | sent_at | order_id | ordered_at | total_amount | hours_after_send |
|---|---|---|---|---|---|---|
| C001 | 春の新作案内 | 2024-03-01 10:00 | 10241 | 2024-03-02 14:32 | 8,400 | 28 |
| C002 | 春の新作案内 | 2024-03-01 10:00 | NULL | NULL | NULL | NULL |
| C003 | 春の新作案内 | 2024-03-01 10:00 | 10298 | 2024-03-01 21:15 | 4,200 | 11 |
| C003 | 春の新作案内 | 2024-03-01 10:00 | 10412 | 2024-03-03 09:40 | 12,600 | 47 |
| … | … | … | … | … | … | … |
C001は配信から28時間後に購買した(転換)。C002は72時間以内に購買なし(未転換)。C003は配信後に2回購買している。LEFT JOINを使っているので、購買しなかった顧客もNULLとして残り、転換率の分母に含まれる。
STEP 2 ― 顧客単位に集約して転換フラグを立てる
STEP1では1顧客が複数行になる場合がある(72時間以内に複数回購買した場合)。転換率の計算では「転換した/しなかった」の2択なので、顧客単位に集約する。
-- STEP2: 顧客単位に集約して転換フラグを付与
WITH campaign_sends AS (
SELECT
customer_id,
campaign_id,
campaign_name,
sent_at
FROM mail_send_log
WHERE campaign_id = 'C001'
),
post_send_orders AS (
SELECT
s.customer_id,
s.campaign_id,
s.campaign_name,
s.sent_at,
o.order_id,
o.ordered_at,
o.total_amount,
DATE_DIFF('hour', s.sent_at, o.ordered_at) AS hours_after_send
FROM campaign_sends s
LEFT JOIN orders o
ON s.customer_id = o.customer_id
AND o.status = 'completed'
AND o.ordered_at >= s.sent_at
AND o.ordered_at < s.sent_at + INTERVAL '72' HOUR
),
customer_conversion AS (
SELECT
customer_id,
campaign_id,
campaign_name,
sent_at,
-- 72時間以内に1件でも購買があれば転換フラグ=1
CASE WHEN COUNT(order_id) > 0 THEN 1 ELSE 0 END AS is_converted,
-- 転換した場合の購買件数・購買金額
COUNT(order_id) AS order_count,
COALESCE(SUM(total_amount), 0) AS total_amount,
-- 初回転換までの時間
MIN(hours_after_send) AS hours_to_first_purchase
FROM post_send_orders
GROUP BY
customer_id,
campaign_id,
campaign_name,
sent_at
)
SELECT *
FROM customer_conversion
ORDER BY is_converted DESC, hours_to_first_purchase;出力イメージ
| customer_id | campaign_name | is_converted | order_count | total_amount | hours_to_first_purchase |
|---|---|---|---|---|---|
| C001 | 春の新作案内 | 1 | 1 | 8,400 | 28 |
| C003 | 春の新作案内 | 1 | 2 | 16,800 | 11 |
| C012 | 春の新作案内 | 1 | 1 | 5,600 | 4 |
| C002 | 春の新作案内 | 0 | 0 | 0 | NULL |
| C008 | 春の新作案内 | 0 | 0 | 0 | NULL |
| … | … | … | … | … | … |
STEP 3 ― キャンペーン単位の転換率サマリを出す(完成版)
顧客単位のフラグが揃ったので、キャンペーン全体のサマリを出す。
-- STEP3: キャンペーン転換率サマリ(完成版)
WITH campaign_sends AS (
SELECT
customer_id,
campaign_id,
campaign_name,
sent_at
FROM mail_send_log
WHERE campaign_id = 'C001'
),
post_send_orders AS (
SELECT
s.customer_id,
s.campaign_id,
s.campaign_name,
s.sent_at,
o.order_id,
o.ordered_at,
o.total_amount,
DATE_DIFF('hour', s.sent_at, o.ordered_at) AS hours_after_send
FROM campaign_sends s
LEFT JOIN orders o
ON s.customer_id = o.customer_id
AND o.status = 'completed'
AND o.ordered_at >= s.sent_at
AND o.ordered_at < s.sent_at + INTERVAL '72' HOUR
),
customer_conversion AS (
SELECT
customer_id,
campaign_id,
campaign_name,
CASE WHEN COUNT(order_id) > 0 THEN 1 ELSE 0 END AS is_converted,
COUNT(order_id) AS order_count,
COALESCE(SUM(total_amount), 0) AS total_amount,
MIN(hours_after_send) AS hours_to_first_purchase
FROM post_send_orders
GROUP BY customer_id, campaign_id, campaign_name, sent_at
)
SELECT
campaign_id,
campaign_name,
-- 配信数
COUNT(customer_id) AS send_count,
-- 転換数・転換率
SUM(is_converted) AS converted_count,
ROUND(SUM(is_converted) * 100.0 / COUNT(customer_id), 2)
AS conversion_rate_pct,
-- 転換顧客の購買金額
SUM(total_amount) AS attributed_sales,
ROUND(AVG(CASE WHEN is_converted = 1 THEN total_amount END), 0)
AS avg_sales_per_converter,
-- 配信1件あたりの売上貢献(ROAS的な指標)
ROUND(SUM(total_amount) * 1.0 / COUNT(customer_id), 0)
AS sales_per_send,
-- 転換までの中央値時間(Prestoの場合)
APPROX_PERCENTILE(hours_to_first_purchase, 0.5) AS median_hours_to_purchase
FROM customer_conversion
GROUP BY campaign_id, campaign_name;
出力イメージ
| campaign_id | campaign_name | send_count | converted_count | conversion_rate_pct | attributed_sales | sales_per_send |
|---|---|---|---|---|---|---|
| C001 | 春の新作案内 | 4,820 | 724 | 15.02 | 8,412,000 | 1,745 |
配信4,820件のうち724件(15%)が72時間以内に購買した。1配信あたりの売上貢献は1,745円だ。
STEP 4 ― 複数キャンペーンを横並びで比較する
実務では「どのキャンペーンが効いたか」を比較したい。campaign_id = 'C001' の絞り込みを外すだけで全キャンペーンが比較できる。
-- STEP4: 全キャンペーンの転換率比較
WITH all_sends AS (
SELECT
customer_id,
campaign_id,
campaign_name,
sent_at
FROM mail_send_log
),
post_send_orders AS (
SELECT
s.customer_id,
s.campaign_id,
s.campaign_name,
o.order_id,
o.total_amount
FROM all_sends s
LEFT JOIN orders o
ON s.customer_id = o.customer_id
AND o.status = 'completed'
AND o.ordered_at >= s.sent_at
AND o.ordered_at < s.sent_at + INTERVAL '72' HOUR
),
customer_conversion AS (
SELECT
customer_id,
campaign_id,
campaign_name,
CASE WHEN COUNT(order_id) > 0 THEN 1 ELSE 0 END AS is_converted,
COALESCE(SUM(total_amount), 0) AS total_amount
FROM post_send_orders
GROUP BY customer_id, campaign_id, campaign_name
)
SELECT
campaign_id,
campaign_name,
COUNT(customer_id) AS send_count,
SUM(is_converted) AS converted_count,
ROUND(SUM(is_converted) * 100.0 / COUNT(customer_id), 2) AS conversion_rate_pct,
SUM(total_amount) AS attributed_sales,
ROUND(SUM(total_amount) * 1.0 / COUNT(customer_id), 0) AS sales_per_send
FROM customer_conversion
GROUP BY campaign_id, campaign_name
ORDER BY conversion_rate_pct DESC;
出力イメージ
| campaign_name | send_count | converted_count | conversion_rate_pct | attributed_sales | sales_per_send |
|---|---|---|---|---|---|
| 会員限定セール | 2,140 | 427 | 19.95 | 6,840,000 | 3,196 |
| 春の新作案内 | 4,820 | 724 | 15.02 | 8,412,000 | 1,745 |
| 誕生日クーポン | 1,280 | 179 | 13.98 | 2,512,000 | 1,963 |
| 再入荷通知 | 890 | 98 | 11.01 | 1,176,000 | 1,321 |
会員限定セールは転換率・1配信あたり売上ともに最も高い。春の新作案内は配信数が多く総売上は大きいが、効率は劣る。このような比較があれば、次の配信計画や予算配分の議論が具体的になる。
実務での注意点
① 同一顧客への重複配信に気をつける
同じ顧客に同じキャンペーンを複数回配信していると、mail_send_log に同一顧客の行が複数存在する。その場合、campaign_sends のCTEで SELECT DISTINCT customer_id にするか、ROW_NUMBER() で最初の配信だけを取るなどの重複排除が必要だ。
② 「メルマガなしでも買っていたか」という問題
今回計測した転換率は「送ったら何%が買ったか」だ。しかし「送らなくても買っていた人」が含まれている可能性は排除できない。厳密な効果測定には、配信した顧客群(処置群)と配信しなかった顧客群(対照群)を比較するABテスト設計が必要だ。今回のクエリはその第一歩として「まず基準値を把握する」目的で使ってほしい。
③ Treasure DataでのINTERVAL構文
Treasure DataのPrestoでは INTERVAL '72' HOUR の代わりに sent_at + (72 * 3600) のようにUNIXタイムスタンプ演算で書くほうが安定するケースがある。sent_at がTD_TIME形式(UNIX秒)で格納されている場合は ordered_at_unix <= sent_at_unix + 72 * 3600 と書き換えよう。
まとめ
今回のクエリの骨格を振り返る。
mail_send_logから対象キャンペーンの配信リストを作るordersを LEFT JOIN(配信後72時間以内という時間条件付き)で結合する- 顧客単位に集約して
is_convertedフラグを立てる SUM(is_converted) / COUNT(customer_id)で転換率を計算する
LEFT JOINと時間条件の組み合わせがこのクエリの核心だ。「配信した全員を分母に保ちながら、転換した人だけを数える」という構造を体に馴染ませておくと、さまざまな施策効果測定に応用できる。