ファーストタッチ分析 — ROW_NUMBER() で各ユーザーの初回購入イベントを特定する
ウィンドウ関数は OVER (PARTITION BY ... ORDER BY ...) 句を伴い、グループ集計しながら元の行を保持します。ROW_NUMBER() は各パーティション内で 1 から始まる連番を付与します。
ROW_NUMBER() OVER ( PARTITION BY user_id -- ユーザーごとに独立した番号空間 ORDER BY purchased_at -- 古い順に 1, 2, 3... を付与 ) AS rn
ROW_NUMBER は 重複なく 1,2,3 と振る(ORDER BY 追加列で制御)。RANK は同順位に同番号・次が飛ぶ(1,1,3)。DENSE_RANK は飛ばさない(1,1,2)。初回行を厳密に1行に絞るなら ROW_NUMBER が最適です。purchase_events テーブルから、各ユーザーの初回購入情報を取得してください。取得列は user_id, first_product_id, first_purchased_at, first_amount、user_id 昇順で返してください。
| user_id | product_id | purchased_at | amount |
|---|---|---|---|
| 1 | A | 2024-01-05 | 3000 |
| 1 | B | 2024-01-20 | 5000 |
| 2 | C | 2024-01-08 | 2000 |
| 2 | A | 2024-02-03 | 4000 |
| 3 | B | 2024-01-15 | 1500 |
| 3 | A | 2024-01-22 | 3000 |
| user_id | first_product_id | first_purchased_at | first_amount |
|---|---|---|---|
| 1 | A | 2024-01-05 | 3000 |
| 2 | C | 2024-01-08 | 2000 |
| 3 | B | 2024-01-15 | 1500 |
WITH ranked AS ( -- ユーザーごとに購入日時昇順で連番を付与 SELECT user_id, product_id, purchased_at, amount, ROW_NUMBER() OVER ( PARTITION BY user_id ORDER BY purchased_at, product_id -- 同日時は product_id で決定的ソート ) AS rn FROM purchase_events ) SELECT user_id, product_id AS first_product_id, purchased_at AS first_purchased_at, amount AS first_amount FROM ranked WHERE rn = 1 -- 各ユーザーの最初の購入行のみ抽出 ORDER BY user_id; /* 実行順序(SQLの論理的な評価順): 1. WITH ranked / CTE定義 → CTE を定義 2. FROM purchase_events → 行を読み込む 3. ROW_NUMBER() OVER (...) → ウィンドウ関数を評価(行数は保持) 4. FROM ranked / WHERE rn=1 → 行を絞り込む 5. SELECT → 列を評価 6. ORDER BY user_id → 並び替えて出力 */
LEGEND
① FROM purchase_events — 6行読込
FROM purchase_eventspurchase_events の全6行を読み込みます。3ユーザーがそれぞれ2件の購入履歴を持ちます。どれが初回購入かはこの時点では不明で、ウィンドウ関数が各行を保持しながら番号を付与します。| user_id | product_id | purchased_at | amount |
|---|---|---|---|
| 1 | A | 2024-01-05 | 3000 |
| 1 | B | 2024-01-20 | 5000 |
| 2 | C | 2024-01-08 | 2000 |
| 2 | A | 2024-02-03 | 4000 |
| 3 | B | 2024-01-15 | 1500 |
| 3 | A | 2024-01-22 | 3000 |
ORDER BY purchased_at だけでは rn=1 がどちらの行になるか不定(実行のたびに変わる可能性)です。ORDER BY purchased_at, product_id のように追加列を入れて結果を決定的(Deterministic)にするのが実務の必須習慣です。PARTITION BY を省略するとテーブル全体が1つのパーティションになり全行を通じて 1,2,3,… と通し番号が振られます。PARTITION BY user_id があるからユーザーごとに独立して 1 から始まる連番になります。SELECT ..., ROW_NUMBER() AS rn FROM t WHERE rn = 1 はエラーになります。ウィンドウ関数は WHERE の評価後に実行されるためです。必ず CTE またはサブクエリでラップしてから WHERE で絞る必要があります。SELECT user_id, product_id, MIN(purchased_at) FROM purchase_events GROUP BY user_id は PostgreSQL でエラー(product_id が集計されていない)。GROUP BY user_id, product_id にすると最小日時ユーザーが複数行出てしまい初回を1行に絞れません。ROW_NUMBER を使うのが正解です。ROW_NUMBER を使い、追加の ORDER BY 列で tie-break を明示してください。rn = 2 で2回目の購入(リピートのきっかけ商品)、WHERE rn = MAX(rn) OVER (PARTITION BY user_id) で最後の購入も同じ構造で取得できます。再訪間隔分析 — LAG() でセッション間隔を算出しリエンゲージメントを検出する
LAG(col) は前の行の値を参照するウィンドウ関数です。PARTITION BY でユーザーを分離し ORDER BY でセッション日付を昇順に並べると「前回セッション日」が取得できます。
LAG(session_date) OVER ( PARTITION BY user_id -- ユーザーをまたいで参照しない ORDER BY session_date -- 古いセッションが前の行になる ) AS prev_session_date -- 最初の行は前行なし → NULL
date型 - date型 は INTEGER(日数)を返します。session_date - prev_session_date だけで再訪間隔が日数として得られます。TIMESTAMP 型同士の差は INTERVAL になるため型に注意してください。user_sessions テーブルから、各ユーザーの全セッションについて前回セッション日と再訪間隔(日数)を算出してください。取得列は user_id, session_date, prev_session_date, days_since_last、user_id・session_date の昇順で返してください。
| user_id | session_date |
|---|---|
| 1 | 2024-03-01 |
| 1 | 2024-03-04 |
| 1 | 2024-03-10 |
| 2 | 2024-03-02 |
| 2 | 2024-03-05 |
| 3 | 2024-03-07 |
| 3 | 2024-03-08 |
| 3 | 2024-03-20 |
| user_id | session_date | prev_session_date | days_since_last |
|---|---|---|---|
| 1 | 2024-03-01 | NULL | NULL |
| 1 | 2024-03-04 | 2024-03-01 | 3 |
| 1 | 2024-03-10 | 2024-03-04 | 6 |
| 2 | 2024-03-02 | NULL | NULL |
| 2 | 2024-03-05 | 2024-03-02 | 3 |
| 3 | 2024-03-07 | NULL | NULL |
| 3 | 2024-03-08 | 2024-03-07 | 1 |
| 3 | 2024-03-20 | 2024-03-08 | 12 |
WITH sessions_with_prev AS ( -- ユーザーごとに前回セッション日を LAG で取得 SELECT user_id, session_date, LAG(session_date) OVER ( PARTITION BY user_id ORDER BY session_date ) AS prev_session_date -- 初回セッションは前行なし → NULL FROM user_sessions ) SELECT user_id, session_date, prev_session_date, (session_date - prev_session_date) AS days_since_last -- date - date = INTEGER(日数) FROM sessions_with_prev ORDER BY user_id, session_date; /* 実行順序(SQLの論理的な評価順): 1. WITH sessions_with_prev / CTE定義 → CTE を定義 2. FROM user_sessions → 行を読み込む 3. LAG(session_date) OVER (...) → ウィンドウ関数を評価(行数は保持) 4. FROM sessions_with_prev → 行を読み込む 5. SELECT → 列を評価(days_since_last) 6. ORDER BY user_id, session_date → 並び替えて出力 */
LEGEND
① FROM user_sessions — 8行読込
FROM user_sessionsuser_sessions の全8行を読み込みます。3ユーザーのセッション履歴があります。PARTITION BY でユーザーごとの境界を設定し、ORDER BY でセッション順に並べてから LAG で前の行を参照します。| user_id | session_date |
|---|---|
| 1 | 2024-03-01 |
| 1 | 2024-03-04 |
| 1 | 2024-03-10 |
| 2 | 2024-03-02 |
| 2 | 2024-03-05 |
| 3 | 2024-03-07 |
| 3 | 2024-03-08 |
| 3 | 2024-03-20 |
LAG(col) は前の行(過去)、LEAD(col) は次の行(未来)を参照します。再訪間隔の算出には LAG を使います。一方 LEAD を使うと「次のセッションまでの日数」が計算でき、次回来訪がない行(LEAD=NULL)がチャーン予測の候補になります。PARTITION BY user_id を省略すると、user2 の最初のセッションに対して user1 の最後のセッションが「前の行」として参照されます。異なるユーザーの日付差を算出するという致命的なバグになるため、PARTITION BY は必須です。LAG(session_date, 2) で2行前、LAG(session_date, 1, '2000-01-01') で前行がない場合のデフォルト値を設定できます。初回セッションの NULL を 0 等に変換したい場合は COALESCE か第3引数を使います。session_date - prev_session_date は INTERVAL 型になります(例: '3 days')。日数を INTEGER で取りたい場合は EXTRACT(DAY FROM (session_date - prev_session_date)) や ::int キャストが必要です。WHERE days_since_last >= 14 で14日以上空いたセッションを抽出し、その直後の行動(購入・継続率)を分析することで「復帰後の継続施策」の有効性を測定できます。連続アクティブ日数分析 — GAP-AND-ISLAND でログインストリークを検出する
GAP-AND-ISLAND は連続する行(Island)とギャップ(Gap)を検出するテクニックです。「各行の日付」から「パーティション内の連番オフセット」を引くと、連続した日付は同じ値になりグループキーとして機能します。
-- 連続していると grp が同じ値になる仕組み login_date rn login_date - (rn-1) grp 2024-01-01 1 01-01 - 0日 = 2024-01-01 ← 同じ island 2024-01-02 2 01-02 - 1日 = 2024-01-01 ← 同じ island 2024-01-03 3 01-03 - 2日 = 2024-01-01 ← 同じ island 2024-01-05 4 01-05 - 3日 = 2024-01-02 ← 新しい island(1月4日が欠落)
date - integer は date を返しますが、ROW_NUMBER() の返却型は bigint です。したがって (rn - 1)::integer と明示的に cast してから日付から引きます。login_logs テーブルから、各ユーザーの連続ログイン期間(ストリーク)の開始日・終了日・連続日数を算出してください。取得列は user_id, streak_start, streak_end, streak_days、user_id・streak_start 昇順で返してください。
| user_id | login_date |
|---|---|
| 1 | 2024-01-01 |
| 1 | 2024-01-02 |
| 1 | 2024-01-03 |
| 1 | 2024-01-05 |
| 2 | 2024-01-03 |
| 2 | 2024-01-04 |
| 2 | 2024-01-05 |
| 2 | 2024-01-06 |
| user_id | streak_start | streak_end | streak_days |
|---|---|---|---|
| 1 | 2024-01-01 | 2024-01-03 | 3 |
| 1 | 2024-01-05 | 2024-01-05 | 1 |
| 2 | 2024-01-03 | 2024-01-06 | 4 |
WITH numbered AS ( -- ユーザーごとにログイン日昇順で連番を付与 SELECT user_id, login_date, ROW_NUMBER() OVER ( PARTITION BY user_id ORDER BY login_date ) AS rn FROM login_logs ), grouped AS ( -- 日付から連番オフセットを引く → 連続日は同じ grp になる SELECT user_id, login_date, login_date - (rn - 1)::integer AS grp -- ROW_NUMBER() の bigint を integer へ cast FROM numbered ) SELECT user_id, MIN(login_date) AS streak_start, MAX(login_date) AS streak_end, COUNT(*) AS streak_days FROM grouped GROUP BY user_id, grp ORDER BY user_id, streak_start; /* 実行順序(SQLの論理的な評価順): 1. WITH numbered / CTE定義 → CTE を定義 2. FROM login_logs → 行を読み込む 3. ROW_NUMBER() OVER (...) → ウィンドウ関数を評価(行数は保持) 4. WITH grouped / CTE定義 → CTE を定義(grp キーを算出) 5. FROM grouped → 行を読み込む 6. GROUP BY user_id, grp → グループ化 7. SELECT → 集計関数を評価(MIN, MAX, COUNT) 8. ORDER BY user_id, streak_start → 並び替えて出力 */
LEGEND
① FROM login_logs — 8行読込 + ROW_NUMBER で連番付与
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date) AS rnlogin_logs の8行を読み込み、ユーザーごとにログイン日昇順で連番(rn)を付与します。これが GAP-AND-ISLAND テクニックの第一段階です。| user_id | login_date | ▸ rn |
|---|---|---|
| 1 | 2024-01-01 | 1 |
| 1 | 2024-01-02 | 2 |
| 1 | 2024-01-03 | 3 |
| 1 | 2024-01-05 | 4 |
| 2 | 2024-01-03 | 1 |
| 2 | 2024-01-04 | 2 |
| 2 | 2024-01-05 | 3 |
| 2 | 2024-01-06 | 4 |
SELECT user_id, MAX(streak_days) FROM ... GROUP BY user_id とさらにラップします。ゲームのデイリーログインボーナスや健康アプリの継続日数ランキングなどgamification 機能の実装に直接応用できます。DATE_TRUNC('week', login_date) で週単位に変換してから同じパターンを適用します。「任意の単位での連続」に汎用的に使えるテクニックです。CASE WHEN login_date - LAG(login_date) = 1 THEN 1 ELSE 0 END で連続フラグを立てるだけでは、島の開始・終了・長さを1クエリで集計できません。フラグ立て後にさらにグループ集計するクエリが必要になり冗長です。GAP-AND-ISLAND は1つの CTE で完結します。SELECT DISTINCT user_id, login_date FROM login_logs で重複を排除してから GAP-AND-ISLAND を適用する必要があります。WHERE streak_days >= 7 でストリーク達成ユーザーを抽出し、その後の継続率(リテンション)を非達成ユーザーと比較することで、ストリーク機能の LTV への貢献を定量評価できます。日付シーケンス生成 — WITH RECURSIVE でゼロ埋め集計と累積登録ユーザー数を算出する
WITH RECURSIVE は自己参照するCTEです。アンカーメンバー(非再帰部)が最初の行を返し、再帰メンバーが前の結果を参照して繰り返し行を追加します。UNION ALL で停止条件(WHERE 句)まで繰り返します。
WITH RECURSIVE date_series AS ( -- アンカーメンバー:最初の1行 SELECT '2023-07-01'::date AS dt UNION ALL -- 再帰メンバー:前の結果を参照して1日ずつ進める SELECT (dt + INTERVAL '1 day')::date FROM date_series WHERE dt < '2023-07-07'::date -- 停止条件(必須!) )
max_recursion が 100 に設定されていますが、必ず明示的な停止条件を書く習慣をつけてください。users テーブルから、2024-01-01〜2024-01-05 の日別新規登録数と累積登録ユーザー数を算出してください。登録がない日も 0 で埋めて(ゼロ埋め)表示します。取得列は date, new_users, cumulative_users、date 昇順で返してください。
| user_id | registered_at |
|---|---|
| 1 | 2024-01-01 |
| 2 | 2024-01-01 |
| 3 | 2024-01-03 |
| 4 | 2024-01-05 |
| 5 | 2024-01-05 |
| date | new_users | cumulative_users |
|---|---|---|
| 2024-01-01 | 2 | 2 |
| 2024-01-02 | 0 | 2 |
| 2024-01-03 | 1 | 3 |
| 2024-01-04 | 0 | 3 |
| 2024-01-05 | 2 | 5 |
WITH RECURSIVE date_series AS ( -- アンカーメンバー:集計開始日の1行を生成 SELECT '2024-01-01'::date AS dt UNION ALL -- 再帰メンバー:1日ずつ進めて停止条件まで繰り返す SELECT (dt + INTERVAL '1 day')::date FROM date_series WHERE dt < '2024-01-05'::date -- 停止条件(必須) ), daily_new AS ( -- 日別の新規登録数を集計(登録のない日は行なし) SELECT registered_at::date AS dt, COUNT(*) AS new_users FROM users GROUP BY registered_at::date ) SELECT ds.dt AS date, COALESCE(dn.new_users, 0) AS new_users, -- NULL → 0 でゼロ埋め SUM(COALESCE(dn.new_users, 0)) OVER ( ORDER BY ds.dt ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS cumulative_users -- 現在行までの累積和 FROM date_series ds LEFT JOIN daily_new dn ON dn.dt = ds.dt ORDER BY ds.dt; /* 実行順序(SQLの論理的な評価順): 1. CTE date_series(再帰): アンカー部を評価(起点) 再帰で日付を1日ずつ展開 追加行なしで停止 2. CTE daily_new: FROM users → GROUP BY registered_at::date → グループ化・集計 3. FROM date_series ds → 行を読み込む LEFT JOIN daily_new dn → 結合(左表を全行保持) 4. SUM(...) OVER (...) → ウィンドウ関数を評価(行数は保持) 5. ORDER BY ds.dt → 並び替えて出力 */
LEGEND
① アンカーメンバー — SELECT '2024-01-01' で最初の1行を生成
SELECT '2024-01-01'::date AS dt (非再帰部)WITH RECURSIVE の非再帰部(アンカーメンバー)が最初の1行を生成します。この1行が再帰の起点になります。アンカーメンバーは1回だけ実行されます。| dt | 役割 |
|---|---|
| 2024-01-01 | ✓ アンカー(起点・非再帰部) |
COALESCE(dn.new_users, 0) で NULL を 0 に変換するのがゼロ埋めの定石です。ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW は「最初の行から現在行まで」を窓の範囲として指定します。これが累積和(Running Total)の標準パターンです。PostgreSQL では ORDER BY だけでも同じ動作になりますが、明示的に書くことで意図が明確になります。max_recursion_depth (デフォルト100) に達してエラーになりますが、それまでの計算でサーバーに負荷をかけます。停止条件は必ず明示的に書いてください。GENERATE_SERIES('2024-01-01'::date, '2024-01-05'::date, '1 day'::interval) で日付シーケンスを生成できます。WITH RECURSIVE より簡潔です。ただし BigQuery・Snowflake など GENERATE_SERIES がないDBでは WITH RECURSIVE が必要です。ユーザーセグメント分析 — NTILE() で購入額を四分位に分類しRFM分析の基礎を学ぶ
NTILE(n) はウィンドウ関数の一つで、行を n 等分し各行に 1〜n のバケット番号を振ります。ユーザーを購入額で四分位(Quartile)に分類する際に使われます。
NTILE(4) OVER ( ORDER BY score DESC -- 高得点から4群へ分割 ) AS bucket_no
| quartile | 説明 | RFM での位置づけ |
|---|---|---|
| 1 | 最低 25% の購入額ユーザー | 低価値層(育成対象) |
| 2 | 下位 25〜50% | 中価値層(維持) |
| 3 | 上位 25〜50% | 高価値層(維持・優遇) |
| 4 | 最高 25% の購入額ユーザー | 最優良層(VIP施策) |
orders テーブルから、ユーザーごとの合計購入額を算出し NTILE(4) で四分位に分類してセグメントラベルを付与してください。取得列は user_id, total_amount, quartile, segment(ブロンズ〜プラチナ)、total_amount 昇順で返してください。
| user_id | order_date | amount |
|---|---|---|
| 1 | 2024-01-05 | 500 |
| 2 | 2024-01-08 | 1500 |
| 3 | 2024-01-10 | 2000 |
| 4 | 2024-01-12 | 1200 |
| 4 | 2024-02-01 | 1800 |
| 5 | 2024-01-18 | 4000 |
| 6 | 2024-01-22 | 6000 |
| 7 | 2024-02-05 | 4000 |
| 7 | 2024-02-15 | 4000 |
| 8 | 2024-01-25 | 12000 |
| user_id | total_amount | quartile | segment |
|---|---|---|---|
| 1 | 500 | 1 | ブロンズ |
| 2 | 1500 | 1 | ブロンズ |
| 3 | 2000 | 2 | シルバー |
| 4 | 3000 | 2 | シルバー |
| 5 | 4000 | 3 | ゴールド |
| 6 | 6000 | 3 | ゴールド |
| 7 | 8000 | 4 | プラチナ |
| 8 | 12000 | 4 | プラチナ |
WITH user_totals AS ( -- ユーザーごとの合計購入額を集計 SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY user_id ), segmented AS ( -- 合計購入額で昇順ソートし4等分して quartile を付与 SELECT user_id, total_amount, NTILE(4) OVER ( ORDER BY total_amount ASC -- 低額ユーザーから quartile=1 を割当 ) AS quartile FROM user_totals ) SELECT user_id, total_amount, quartile, CASE quartile WHEN 1 THEN 'ブロンズ' WHEN 2 THEN 'シルバー' WHEN 3 THEN 'ゴールド' WHEN 4 THEN 'プラチナ' END AS segment FROM segmented ORDER BY total_amount; /* 実行順序(SQLの論理的な評価順): 1. WITH user_totals / CTE定義 → CTE を定義 2. FROM orders → 行を読み込む 3. GROUP BY user_id → グループ化 4. SELECT → 集計関数を評価(SUM) 5. WITH segmented / CTE定義 → CTE を定義 6. NTILE(4) OVER (...) → ウィンドウ関数を評価(行数は保持) 7. SELECT → 列を評価(CASE でラベル付与) 8. ORDER BY total_amount → 並び替えて出力 */
LEGEND
① FROM orders + GROUP BY user_id — 合計購入額を集計
GROUP BY user_id → SUM(amount) AS total_amountorders の10行を読み込み、ユーザーごとに SUM(amount) で合計購入額を集計します。user4(1200+1800=3000)と user7(4000+4000=8000)は複数注文があり合算されます。| user_id | orders合計行 | ▸ total_amount |
|---|---|---|
| 1 | 1件 | 500 |
| 2 | 1件 | 1500 |
| 3 | 1件 | 2000 |
| 4 | 2件(1200+1800) | 3000 |
| 5 | 1件 | 4000 |
| 6 | 1件 | 6000 |
| 7 | 2件(4000+4000) | 8000 |
| 8 | 1件 | 12000 |
ORDER BY total_amount ASC では低額ユーザーが quartile=1(ブロンズ)になります。DESC にすると高額ユーザーが quartile=1 になり意味が逆転します。「高い quartile ほど優良顧客」という設計なら ASC、「1が最良」という設計なら DESC を選択します。NTILE(4) OVER () のように ORDER BY を省略すると行の物理的な並び順でバケットが決まります。データの挿入順は保証されないため、実行のたびに結果が変わる可能性があります。NTILE には必ず意味のある ORDER BY を指定してください。PERCENT_RANK() は 0〜1 の相対順位(最小行が 0.0、最大行が 1.0)を返します。固定数のバケットに分類したい場合は NTILE、「上位 X% のユーザー」を抽出したい場合は PERCENT_RANK か CUME_DIST を使います。NTILE(5) OVER (ORDER BY last_purchase_date DESC) で Recency スコア、NTILE(5) OVER (ORDER BY purchase_count) で Frequency スコアを付与し、3つの CTE を JOIN してユーザーごとの RFM スコアを統合します。