「連続」を数えることの難しさ
「3ヶ月連続購買キャンペーン」「6ヶ月継続でゴールド会員昇格」
通販では顧客の継続購買を促すロイヤリティ施策がよく使われる。
このとき必ず必要になるのが「今この顧客は何ヶ月連続で買っているか」を計算するクエリだ。
一見シンプルに見えるが、これは SQL の中でも難問の部類に入る。なぜか。
「連続」とは「途切れていないこと」だ。しかしデータには「買った月」しか記録されていない。「買わなかった月」はデータに存在しない。存在しないものを使って「途切れた」かどうかを判定するのが難しさの正体だ。
この問題は GAP and ISLAND 問題と呼ばれる。データが連続している区間(Island)と、途切れている区間(Gap)を特定するテクニックだ。SQL の応用問題としてデータエンジニアの採用面接でも頻出する。
GAP and ISLAND 問題とは
まず概念をイメージしよう。
ある顧客の購買月が次のようだったとする。
2024-01, 2024-02, 2024-03, (空白), 2024-05, 2024-06, (空白), 2024-08これを「連続している区間(Island)」で区切ると
Island 1: 2024-01 〜 2024-03(3ヶ月連続)
Island 2: 2024-05 〜 2024-06(2ヶ月連続)
Island 3: 2024-08 〜 2024-08(1ヶ月のみ)「Gap(空白)」は 2024-04 と 2024-07 だ。
この Island を SQL で検出することが今回のゴールだ。
使用するテーブル
-- orders テーブル
-- order_id : 注文ID
-- customer_id : 顧客ID
-- order_date : 注文日(DATE型)
-- total_amount : 注文金額
-- status : 'completed' / 'cancelled'STEP 1 ― 顧客×月の購買有無を整理する
まず「この顧客はこの月に買ったか」を月単位で1行にまとめる。
同じ月に複数回買っていても「その月は購買した」という1行にする。
-- STEP1: 顧客×購買月の一覧(月次ユニーク化)
WITH monthly_purchases AS (
SELECT DISTINCT
customer_id,
DATE_TRUNC('month', order_date) AS purchase_month
FROM orders
WHERE status = 'completed'
)
SELECT *
FROM monthly_purchases
ORDER BY customer_id, purchase_month;出力イメージ(顧客 C001 の例)
| customer_id | purchase_month |
|---|---|
| C001 | 2024-01-01 |
| C001 | 2024-02-01 |
| C001 | 2024-03-01 |
| C001 | 2024-05-01 |
| C001 | 2024-06-01 |
| C001 | 2024-08-01 |
2024-04 と 2024-07 が存在しないことが Gap だ。
STEP 2 ― Island を特定する核心ロジック
ここが GAP and ISLAND 問題の核心だ。
考え方:「連続する月」は ROW_NUMBER との差が一定になる
購買月を昇順に並べて ROW_NUMBER を振ると、連続している月では「購買月 – ROW_NUMBER(月単位)」が常に同じ値になる。途切れた瞬間にこの差がずれる。
具体的に見てみよう。
| purchase_month | ROW_NUMBER | 月番号 | 月番号 – ROW_NUMBER |
|---|---|---|---|
| 2024-01 | 1 | 1 | 0 |
| 2024-02 | 2 | 2 | 0 |
| 2024-03 | 3 | 3 | 0 |
| 2024-05 | 4 | 5 | 1 ← ここで変わる |
| 2024-06 | 5 | 6 | 1 |
| 2024-08 | 6 | 8 | 2 ← またずれる |
「月番号 – ROW_NUMBER」が同じ値のグループが、そのまま Island(連続購買区間)になる。
この値をグループキーとして使うことで、Island を特定できる。
-- STEP2: Island グループキーを計算する
WITH monthly_purchases AS (
SELECT DISTINCT
customer_id,
DATE_TRUNC('month', order_date) AS purchase_month
FROM orders
WHERE status = 'completed'
),
with_group_key AS (
SELECT
customer_id,
purchase_month,
-- 購買月を「月番号」に変換(基準日からの月数)
DATE_DIFF('month', DATE '2020-01-01', purchase_month) AS month_num,
-- 顧客ごとに購買月の連番
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY purchase_month
) AS rn,
-- グループキー = 月番号 - 連番(連続している間は一定)
DATE_DIFF('month', DATE '2020-01-01', purchase_month)
- ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY purchase_month
) AS group_key
FROM monthly_purchases
)
SELECT *
FROM with_group_key
ORDER BY customer_id, purchase_month;出力イメージ(顧客 C001)
| customer_id | purchase_month | month_num | rn | group_key |
|---|---|---|---|---|
| C001 | 2024-01-01 | 48 | 1 | 47 |
| C001 | 2024-02-01 | 49 | 2 | 47 |
| C001 | 2024-03-01 | 50 | 3 | 47 |
| C001 | 2024-05-01 | 52 | 4 | 48 |
| C001 | 2024-06-01 | 53 | 5 | 48 |
| C001 | 2024-08-01 | 55 | 6 | 49 |
group_key が同じ行が Island(連続購買区間)だ。
STEP 3 ― Island ごとに開始・終了・連続月数を集計する
group_key でグループ化して、各 Island の開始月・終了月・連続月数を出す。
-- STEP3: 連続購買区間(Island)ごとのサマリ
WITH monthly_purchases AS (
SELECT DISTINCT
customer_id,
DATE_TRUNC('month', order_date) AS purchase_month
FROM orders
WHERE status = 'completed'
),
with_group_key AS (
SELECT
customer_id,
purchase_month,
DATE_DIFF('month', DATE '2020-01-01', purchase_month)
- ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY purchase_month
) AS group_key
FROM monthly_purchases
)
SELECT
customer_id,
MIN(purchase_month) AS streak_start, -- 連続購買の開始月
MAX(purchase_month) AS streak_end, -- 連続購買の終了月
COUNT(*) AS streak_months -- 連続購買の月数
FROM with_group_key
GROUP BY customer_id, group_key
ORDER BY customer_id, streak_start;出力イメージ
| customer_id | streak_start | streak_end | streak_months |
|---|---|---|---|
| C001 | 2024-01-01 | 2024-03-01 | 3 |
| C001 | 2024-05-01 | 2024-06-01 | 2 |
| C001 | 2024-08-01 | 2024-08-01 | 1 |
| C002 | 2024-02-01 | 2024-07-01 | 6 |
| C003 | 2024-01-01 | 2024-01-01 | 1 |
C001 の最長ストリークは3ヶ月、C002 は6ヶ月連続購買していることが分かる。
STEP 4 ― 現在進行中のストリークと最長ストリークを出す(完成版)
施策で使いたいのは「今この瞬間、何ヶ月連続購買中か」という現在のストリークと、「過去の最長記録は何ヶ月か」の2つだ。
-- STEP4: 現在ストリーク・最長ストリーク(完成版)
WITH monthly_purchases AS (
SELECT DISTINCT
customer_id,
DATE_TRUNC('month', order_date) AS purchase_month
FROM orders
WHERE status = 'completed'
),
with_group_key AS (
SELECT
customer_id,
purchase_month,
DATE_DIFF('month', DATE '2020-01-01', purchase_month)
- ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY purchase_month
) AS group_key
FROM monthly_purchases
),
islands AS (
SELECT
customer_id,
MIN(purchase_month) AS streak_start,
MAX(purchase_month) AS streak_end,
COUNT(*) AS streak_months
FROM with_group_key
GROUP BY customer_id, group_key
),
streak_summary AS (
SELECT
customer_id,
-- 最長ストリーク
MAX(streak_months) AS longest_streak,
-- 現在のストリーク:直前の購買月が「先月」であるIslandのみ
-- 基準日の前月と streak_end が一致しているものが「現在進行中」
MAX(
CASE
WHEN streak_end = DATE_TRUNC('month', DATE_ADD('month', -1, CURRENT_DATE))
THEN streak_months
ELSE 0
END
) AS current_streak,
-- 現在ストリークの開始月
MIN(
CASE
WHEN streak_end = DATE_TRUNC('month', DATE_ADD('month', -1, CURRENT_DATE))
THEN streak_start
ELSE NULL
END
) AS current_streak_start
FROM islands
GROUP BY customer_id
)
SELECT
customer_id,
current_streak,
current_streak_start,
longest_streak,
-- キャンペーン対象フラグ(例:3ヶ月連続達成)
CASE WHEN current_streak >= 3 THEN 1 ELSE 0 END AS campaign_eligible
FROM streak_summary
ORDER BY current_streak DESC, longest_streak DESC;
完成した出力イメージ
| customer_id | current_streak | current_streak_start | longest_streak | campaign_eligible |
|---|---|---|---|---|
| C002 | 6 | 2024-02-01 | 6 | 1 |
| C004 | 4 | 2024-04-01 | 4 | 1 |
| C001 | 0 | NULL | 3 | 0 |
| C003 | 1 | 2024-08-01 | 1 | 0 |
C002 は現在6ヶ月連続継続中でキャンペーン対象。C001 は過去最長3ヶ月の記録があるが今月は購買していないため現在ストリークは0だ。
「先月」を基準にする理由
「今月購買したか」は月が終わるまで確定しない。そのため基準を「先月末時点でのストリーク」とするのが現実的だ。月次バッチで毎月初旬に実行する設計に合わせてある。当月を含めたい場合はDATE_ADD('month', -1, CURRENT_DATE)をCURRENT_DATEに変えればよい。
応用:ストリーク別の顧客分布を出す
どの長さのストリークの顧客が何人いるかを把握すると、キャンペーン閾値の設計に使える。
-- ストリーク長の分布
WITH monthly_purchases AS (
SELECT DISTINCT
customer_id,
DATE_TRUNC('month', order_date) AS purchase_month
FROM orders
WHERE status = 'completed'
),
with_group_key AS (
SELECT
customer_id,
purchase_month,
DATE_DIFF('month', DATE '2020-01-01', purchase_month)
- ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY purchase_month
) AS group_key
FROM monthly_purchases
),
islands AS (
SELECT
customer_id,
MAX(purchase_month) AS streak_end,
COUNT(*) AS streak_months
FROM with_group_key
GROUP BY customer_id, group_key
),
current_streaks AS (
SELECT
customer_id,
MAX(CASE
WHEN streak_end = DATE_TRUNC('month', DATE_ADD('month', -1, CURRENT_DATE))
THEN streak_months ELSE 0
END) AS current_streak
FROM islands
GROUP BY customer_id
)
SELECT
current_streak AS streak_length,
COUNT(customer_id) AS customer_count,
ROUND(
COUNT(customer_id) * 100.0
/ SUM(COUNT(customer_id)) OVER ()
, 1) AS pct
FROM current_streaks
GROUP BY current_streak
ORDER BY current_streak DESC;出力イメージ
| streak_length | customer_count | pct |
|---|---|---|
| 0(今月未購買) | 8,412 | 62.3 |
| 1 | 2,841 | 21.0 |
| 2 | 1,024 | 7.6 |
| 3 | 584 | 4.3 |
| 4 | 312 | 2.3 |
| 5 | 148 | 1.1 |
| 6以上 | 189 | 1.4 |
「3ヶ月連続達成」のキャンペーン対象は全体の約9%(4+5+6以上)だと分かる。「2ヶ月連続の顧客(7.6%)」はあと1ヶ月でキャンペーン達成できる層なので、背中を押す施策の対象として最適だ。
実務での運用ヒント
① ストリーク計算の基準を「月」から「週」や「日」に変える
今回は月次購買のストリークを扱ったが、週次や日次に変えたい場合は DATE_TRUNC('month', ...) を DATE_TRUNC('week', ...) や order_date(日付そのまま)に変え、DATE_DIFF の単位も 'month' から 'week' / 'day' に変えるだけで同じロジックが動く。
② 「○ヶ月連続でキャンペーン達成後にまた連続中」の顧客を区別する
同じ顧客が複数の Island を持つ場合、過去に一度キャンペーン達成した人が再び連続購買しているケースも拾える。islands テーブルを使えばすべての連続区間が揃っているので、「最長が○ヶ月以上かつ現在も継続中」のような複合条件も CASE WHEN で表現できる。
③ Treasure Data での注意点
DATE_DIFF('month', DATE '2020-01-01', purchase_month) の基準日(DATE '2020-01-01')は、データの中で最も古い購買月より前であれば何でもよい。Presto では月番号の計算が整数として返るため、ROW_NUMBER との差引きがそのまま使える。
まとめ
GAP and ISLAND 問題を解くカギは、「月番号 − ROW_NUMBER = グループキー」 という1つの式だ。
DATE_TRUNC('month', ...)とDISTINCTで月次購買フラグを作るDATE_DIFF('month', ...)で月番号に変換し、ROW_NUMBERとの差をgroup_keyにするgroup_keyでグループ化してMIN/MAX/COUNTで Island の開始・終了・長さを出す- 現在進行中のストリークは「
streak_endが先月と一致するか」で判定する
この発想は購買だけでなく、「ログイン連続日数」「メルマガ開封連続回数」「レビュー投稿連続週数」など、あらゆる連続行動の分析に応用できる。
一度パターンを掴んでしまえば、幅広い場面で使い回せる強力な技法だ。