SQL 行動分析 — Gaps and Islands・再帰CTEの基礎

基礎行動分析ウィンドウ関数Gap-and-Island再帰CTE / NTILEPostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 6

ファーストタッチ分析 — ROW_NUMBER() で各ユーザーの初回購入イベントを特定する

ROW_NUMBERPARTITION BYファーストタッチ分析初回購入特定
前提知識

ウィンドウ関数は 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 vs RANK vs DENSE_RANK:同順位(同一日時)が存在する場合、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 昇順で返してください。

使用テーブル
▸ purchase_events(6行)
user_idproduct_idpurchased_atamount
1A2024-01-053000
1B2024-01-205000
2C2024-01-082000
2A2024-02-034000
3B2024-01-151500
3A2024-01-223000
期待出力
user_idfirst_product_idfirst_purchased_atfirst_amount
1A2024-01-053000
2C2024-01-082000
3B2024-01-151500
模範解答コード
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          → 並び替えて出力
*/
解説(テーブル変化・ポイント)
WITH ranked AS ( SELECT user_id, product_id, purchased_at, amount, ROW_NUMBER() OVER ( PARTITION BY user_id ORDER BY purchased_at, 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;
LEGEND
データ取得・読込対象
① FROM purchase_events — 6行読込
FROM purchase_eventspurchase_events の全6行を読み込みます。3ユーザーがそれぞれ2件の購入履歴を持ちます。どれが初回購入かはこの時点では不明で、ウィンドウ関数が各行を保持しながら番号を付与します。
1 / 5
user_idproduct_idpurchased_atamount
1A2024-01-053000
1B2024-01-205000
2C2024-01-082000
2A2024-02-034000
3B2024-01-151500
3A2024-01-223000
purchase_events: 6行(3ユーザー × 2件)
学習ポイント
ORDER BY で同順位を排除する(決定性の確保):purchased_at が同一の行が複数ある場合、ORDER BY purchased_at だけでは rn=1 がどちらの行になるか不定(実行のたびに変わる可能性)です。ORDER BY purchased_at, product_id のように追加列を入れて結果を決定的(Deterministic)にするのが実務の必須習慣です。
PARTITION BY がウィンドウ関数の「グループ境界」:PARTITION BY を省略するとテーブル全体が1つのパーティションになり全行を通じて 1,2,3,… と通し番号が振られます。PARTITION BY user_id があるからユーザーごとに独立して 1 から始まる連番になります。
ウィンドウ関数の結果列は同じ SELECT の WHERE では参照不可:SELECT ..., ROW_NUMBER() AS rn FROM t WHERE rn = 1 はエラーになります。ウィンドウ関数は WHERE の評価後に実行されるためです。必ず CTE またはサブクエリでラップしてから WHERE で絞る必要があります。
アンチパターン
MIN(purchased_at) + GROUP BY で他の列を取ろうとする: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 を使うのが正解です。
RANK や DENSE_RANK を使うと複数行が rn=1 になる:同日に複数購入した場合 RANK は複数行に 1 を付与します。「初回の1行だけ」を取りたい場合は必ず ROW_NUMBER を使い、追加の ORDER BY 列で tie-break を明示してください。
実務コラム:ファーストタッチ分析の活用
初回購入した商品カテゴリを集計すると「どのカテゴリがユーザーの入口になっているか」が分かります。入口カテゴリ別に LTV(生涯購入額)を比較することでマーケティング投資の優先順位を決定できます。ROW_NUMBER のパターンは初回だけでなく、rn = 2 で2回目の購入(リピートのきっかけ商品)、WHERE rn = MAX(rn) OVER (PARTITION BY user_id) で最後の購入も同じ構造で取得できます。
QUESTION 7

再訪間隔分析 — LAG() でセッション間隔を算出しリエンゲージメントを検出する

LAGOVER PARTITION BY再訪間隔分析リエンゲージメント
前提知識

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(日数):PostgreSQL では 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_sessions(8行)
user_idsession_date
12024-03-01
12024-03-04
12024-03-10
22024-03-02
22024-03-05
32024-03-07
32024-03-08
32024-03-20
期待出力
user_idsession_dateprev_session_datedays_since_last
12024-03-01NULLNULL
12024-03-042024-03-013
12024-03-102024-03-046
22024-03-02NULLNULL
22024-03-052024-03-023
32024-03-07NULLNULL
32024-03-082024-03-071
32024-03-202024-03-0812
模範解答コード
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    → 並び替えて出力
*/
解説(テーブル変化・ポイント)
WITH sessions_with_prev AS ( SELECT user_id, session_date, LAG(session_date) OVER ( PARTITION BY user_id ORDER BY session_date ) AS prev_session_date FROM user_sessions ) SELECT user_id, session_date, prev_session_date, (session_date - prev_session_date) AS days_since_last FROM sessions_with_prev ORDER BY user_id, session_date;
LEGEND
データ取得・読込対象
① FROM user_sessions — 8行読込
FROM user_sessionsuser_sessions の全8行を読み込みます。3ユーザーのセッション履歴があります。PARTITION BY でユーザーごとの境界を設定し、ORDER BY でセッション順に並べてから LAG で前の行を参照します。
1 / 4
user_idsession_date
12024-03-01
12024-03-04
12024-03-10
22024-03-02
22024-03-05
32024-03-07
32024-03-08
32024-03-20
user_sessions: 8行(user1:3行 / user2:2行 / user3:3行)
学習ポイント
LAG vs LEAD — 前後どちらを参照するか:LAG(col) は前の行(過去)、LEAD(col) は次の行(未来)を参照します。再訪間隔の算出には LAG を使います。一方 LEAD を使うと「次のセッションまでの日数」が計算でき、次回来訪がない行(LEAD=NULL)がチャーン予測の候補になります。
PARTITION BY がなければユーザー境界を越えて参照してしまう:PARTITION BY user_id を省略すると、user2 の最初のセッションに対して user1 の最後のセッションが「前の行」として参照されます。異なるユーザーの日付差を算出するという致命的なバグになるため、PARTITION BY は必須です。
LAG の第2・第3引数でオフセットとデフォルト値を制御:LAG(session_date, 2) で2行前、LAG(session_date, 1, '2000-01-01') で前行がない場合のデフォルト値を設定できます。初回セッションの NULL を 0 等に変換したい場合は COALESCE か第3引数を使います。
アンチパターン
PARTITION BY なしで LAG を使う:ユーザーをまたいで前行を参照するため、user2 の初回セッションに user1 の最終セッション日が prev_session_date として入ります。days_since_last が意味不明な値になり、分析結果が完全に狂います。
TIMESTAMP 型の差分で INTEGER を期待する:session_date が TIMESTAMP 型の場合、session_date - prev_session_date は INTERVAL 型になります(例: '3 days')。日数を INTEGER で取りたい場合は EXTRACT(DAY FROM (session_date - prev_session_date))::int キャストが必要です。
実務コラム:リエンゲージメント施策への応用
days_since_last が大きいセッションを持つユーザーは「長期離脱後の自発的復帰者」です。このセグメントはプッシュ通知やメールによるリエンゲージメントキャンペーンの効果が出やすいユーザー層です。SQL では WHERE days_since_last >= 14 で14日以上空いたセッションを抽出し、その直後の行動(購入・継続率)を分析することで「復帰後の継続施策」の有効性を測定できます。
QUESTION 8

連続アクティブ日数分析 — GAP-AND-ISLAND でログインストリークを検出する

GAP-AND-ISLANDROW_NUMBERストリーク検出連続行動分析
前提知識

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日が欠落)
ROW_NUMBER() は bigint:PostgreSQL の date - integer は date を返しますが、ROW_NUMBER() の返却型は bigint です。したがって (rn - 1)::integer と明示的に cast してから日付から引きます。
問題

login_logs テーブルから、各ユーザーの連続ログイン期間(ストリーク)の開始日・終了日・連続日数を算出してください。取得列は user_id, streak_start, streak_end, streak_days、user_id・streak_start 昇順で返してください。

使用テーブル
▸ login_logs(8行)
user_idlogin_date
12024-01-01
12024-01-02
12024-01-03
12024-01-05
22024-01-03
22024-01-04
22024-01-05
22024-01-06
期待出力
user_idstreak_startstreak_endstreak_days
12024-01-012024-01-033
12024-01-052024-01-051
22024-01-032024-01-064
模範解答コード
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 → 並び替えて出力
*/
解説(テーブル変化・ポイント)
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 ( SELECT user_id, login_date, login_date - (rn - 1)::integer AS grp 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;
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 テクニックの第一段階です。
1 / 5
user_idlogin_date▸ rn
12024-01-011
12024-01-022
12024-01-033
12024-01-054
22024-01-031
22024-01-042
22024-01-053
22024-01-064
numbered CTE: 8行
学習ポイント
GAP-AND-ISLAND の核心:日付 - 連番 = 定数(連続なら同じ値):連続した日付列に対して ROW_NUMBER のオフセットを引くと、連続している行は同じ値になります。これが island のグループキーです。1日でも抜けると grp が変化し新しい island の始まりを示します。日付でなく任意の連続する整数値にも応用できます。
最大ストリーク(max_streak)への拡張:ユーザーごとの最長連続日数を取るには SELECT user_id, MAX(streak_days) FROM ... GROUP BY user_id とさらにラップします。ゲームのデイリーログインボーナスや健康アプリの継続日数ランキングなどgamification 機能の実装に直接応用できます。
「連続」の定義を変える応用:日次ログインでなく週次アクティブ(weekly streak)の場合は DATE_TRUNC('week', login_date) で週単位に変換してから同じパターンを適用します。「任意の単位での連続」に汎用的に使えるテクニックです。
アンチパターン
LAG + CASE で前日との差を見るアプローチの限界:CASE WHEN login_date - LAG(login_date) = 1 THEN 1 ELSE 0 END で連続フラグを立てるだけでは、島の開始・終了・長さを1クエリで集計できません。フラグ立て後にさらにグループ集計するクエリが必要になり冗長です。GAP-AND-ISLAND は1つの CTE で完結します。
login_logs に重複行がある場合の誤動作:同一ユーザーの同日ログインが複数行あると rn が 2 以上になり、grp 値がずれて別 island に分類されてしまいます。事前に SELECT DISTINCT user_id, login_date FROM login_logs で重複を排除してから GAP-AND-ISLAND を適用する必要があります。
実務コラム:ストリーク分析の活用場面
連続ログイン日数(ストリーク)はユーザーエンゲージメントの強さを示す最も直感的な指標の一つです。Duolingo や習慣管理アプリでは「7日連続ログイン」でバッジを付与するゲーミフィケーションが定番です。SQL では WHERE streak_days >= 7 でストリーク達成ユーザーを抽出し、その後の継続率(リテンション)を非達成ユーザーと比較することで、ストリーク機能の LTV への貢献を定量評価できます。
QUESTION 9

日付シーケンス生成 — WITH RECURSIVE でゼロ埋め集計と累積登録ユーザー数を算出する

WITH RECURSIVESUM OVER日付シーケンス生成累積集計 / ゼロ埋め
前提知識

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   -- 停止条件(必須!)
)
停止条件は必須:WHERE 句を省略すると無限ループになりDBがクラッシュします。PostgreSQL はデフォルトで max_recursion が 100 に設定されていますが、必ず明示的な停止条件を書く習慣をつけてください。
問題

users テーブルから、2024-01-01〜2024-01-05 の日別新規登録数と累積登録ユーザー数を算出してください。登録がない日も 0 で埋めて(ゼロ埋め)表示します。取得列は date, new_users, cumulative_users、date 昇順で返してください。

使用テーブル
▸ users(5行)
user_idregistered_at
12024-01-01
22024-01-01
32024-01-03
42024-01-05
52024-01-05
期待出力
datenew_userscumulative_users
2024-01-0122
2024-01-0202
2024-01-0313
2024-01-0403
2024-01-0525
模範解答コード
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                 → 並び替えて出力
*/
解説(テーブル変化・ポイント)
WITH RECURSIVE date_series AS ( SELECT '2024-01-01'::date AS dt UNION ALL 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, 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;
LEGEND
データ取得・読込対象
✓ 通過
① アンカーメンバー — SELECT '2024-01-01' で最初の1行を生成
SELECT '2024-01-01'::date AS dt (非再帰部)WITH RECURSIVE の非再帰部(アンカーメンバー)が最初の1行を生成します。この1行が再帰の起点になります。アンカーメンバーは1回だけ実行されます。
1 / 9
dt役割
2024-01-01✓ アンカー(起点・非再帰部)
date_series 初期状態: 1行
学習ポイント
WITH RECURSIVE の3要素:アンカー・UNION ALL・停止条件:①アンカーメンバー(初期値を生成)、② UNION ALL(再帰メンバーと接続、重複は除去しない)、③再帰メンバーの WHERE(停止条件)の3つが必須です。UNION を使うと重複除去の分遅くなるため、再帰CTE では常に UNION ALL を使います。
LEFT JOIN でゼロ埋め・COALESCE で NULL を変換:date_series(登録なし日含む全日付)を基準に daily_new を LEFT JOIN することで、登録のない日は dn.new_users = NULL になります。COALESCE(dn.new_users, 0) で NULL を 0 に変換するのがゼロ埋めの定石です。
SUM() OVER の ROWS BETWEEN 句:ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW は「最初の行から現在行まで」を窓の範囲として指定します。これが累積和(Running Total)の標準パターンです。PostgreSQL では ORDER BY だけでも同じ動作になりますが、明示的に書くことで意図が明確になります。
アンチパターン
再帰の停止条件を省略する:WHERE 句を書かないと再帰メンバーが永遠に実行され続けます。PostgreSQL の max_recursion_depth (デフォルト100) に達してエラーになりますが、それまでの計算でサーバーに負荷をかけます。停止条件は必ず明示的に書いてください。
GENERATE_SERIES を使える場合は使う:PostgreSQL では GENERATE_SERIES('2024-01-01'::date, '2024-01-05'::date, '1 day'::interval) で日付シーケンスを生成できます。WITH RECURSIVE より簡潔です。ただし BigQuery・Snowflake など GENERATE_SERIES がないDBでは WITH RECURSIVE が必要です。
実務コラム:累積指標とグロースの可視化
累積登録ユーザー数(Cumulative Users)のグラフはサービスのグロース曲線を示します。傾きが急なほど成長が速く、傾きが緩やかになるとグロースが鈍化しているサインです。SQL で日別・週別・月別の累積値を計算しダッシュボードに表示するのは BizOps・Data Analyst の定番業務です。WITH RECURSIVE でのゼロ埋めは、「売上のない日でも 0 を表示するグラフ」を正しく描くための基礎技術です。
QUESTION 10

ユーザーセグメント分析 — NTILE() で購入額を四分位に分類しRFM分析の基礎を学ぶ

NTILECASE WHENユーザーセグメント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施策)
端数の扱い:行数が n で割り切れない場合、余りの行は先頭のバケットから1行ずつ配分されます。例えば9行を NTILE(4) で分けると、バケットごとの行数は 3,2,2,2(1〜4の順に配分)になります。
問題

orders テーブルから、ユーザーごとの合計購入額を算出し NTILE(4) で四分位に分類してセグメントラベルを付与してください。取得列は user_id, total_amount, quartile, segment(ブロンズ〜プラチナ)、total_amount 昇順で返してください。

使用テーブル
▸ orders(10行)
user_idorder_dateamount
12024-01-05500
22024-01-081500
32024-01-102000
42024-01-121200
42024-02-011800
52024-01-184000
62024-01-226000
72024-02-054000
72024-02-154000
82024-01-2512000
期待出力
user_idtotal_amountquartilesegment
15001ブロンズ
215001ブロンズ
320002シルバー
430002シルバー
540003ゴールド
660003ゴールド
780004プラチナ
8120004プラチナ
模範解答コード
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      → 並び替えて出力
*/
解説(テーブル変化・ポイント)
WITH user_totals AS ( SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY user_id ), segmented AS ( SELECT user_id, total_amount, NTILE(4) OVER ( ORDER BY total_amount ASC ) 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;
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)は複数注文があり合算されます。
1 / 5
user_idorders合計行▸ total_amount
11件500
21件1500
31件2000
42件(1200+1800)3000
51件4000
61件6000
72件(4000+4000)8000
81件12000
user_totals CTE: 8行(ユーザーごとの合計購入額)
学習ポイント
NTILE(n) の動作:n 等分・端数は先頭バケットに配分:行数が n で割り切れない場合、余った行は quartile=1 から順に1行ずつ追加されます。9行を NTILE(4) するとバケットごとの行数は 3,2,2,2(先頭バケットが1行多い)になります。等分されるのは行数であって値の範囲ではありません(これは NTILE と PERCENTILE の最大の違いです)。
ASC vs DESC の方向に注意:ORDER BY total_amount ASC では低額ユーザーが quartile=1(ブロンズ)になります。DESC にすると高額ユーザーが quartile=1 になり意味が逆転します。「高い quartile ほど優良顧客」という設計なら ASC、「1が最良」という設計なら DESC を選択します。
RFM 分析の3軸への拡張:RFM は Recency(最終購入日)・Frequency(購入回数)・Monetary(購入額)の3軸で各ユーザーをスコアリングします。今回は Monetary のみ扱いましたが、3軸それぞれに NTILE(5) を適用して 1〜5 のスコアを付与し、スコアの組み合わせでセグメントを定義するのが本格的な RFM 分析です。
アンチパターン
ORDER BY なしで NTILE を使う:NTILE(4) OVER () のように ORDER BY を省略すると行の物理的な並び順でバケットが決まります。データの挿入順は保証されないため、実行のたびに結果が変わる可能性があります。NTILE には必ず意味のある ORDER BY を指定してください。
NTILE と PERCENT_RANK を混同する:PERCENT_RANK() は 0〜1 の相対順位(最小行が 0.0、最大行が 1.0)を返します。固定数のバケットに分類したい場合は NTILE、「上位 X% のユーザー」を抽出したい場合は PERCENT_RANK か CUME_DIST を使います。
実務コラム:RFM 分析でCRMの精度を上げる
RFM 分析はメールマーケティングや push 通知のターゲティング精度を大幅に改善します。プラチナ層(高額・高頻度・最近購入)には VIP 限定オファーを、ブロンズ層(低額・低頻度)には初回割引クーポンを送るなど、セグメントごとにコミュニケーションを変えることで CVR が向上します。SQL では NTILE(5) OVER (ORDER BY last_purchase_date DESC) で Recency スコア、NTILE(5) OVER (ORDER BY purchase_count) で Frequency スコアを付与し、3つの CTE を JOIN してユーザーごとの RFM スコアを統合します。