SQL イベントモデリング — セッションID・再帰CTEの応用

応用イベントモデリングファネル分析セッションID付与コホート別リテンション再帰CTEPostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 1

ファネル分析 — シーケンシャル変換率を MIN(...) FILTER + 順序比較で算出する

MIN FILTERNULLIFファネル分析変換率
前提知識

イベント分析の中核となるファネル分析は「view → cart → purchase のような複数ステップを順番に通過したユーザー数」を測定します。各ユーザーの各イベント初回時刻MIN(event_time) FILTER (WHERE event_type='...') で抜き出し、時刻の前後関係でステップ通過を判定するのが定番パターンです。

-- 各ユーザーごとに、各イベントタイプの最初の発生時刻を取得
MIN(event_time) FILTER (WHERE event_type = 'view')     AS t_view
MIN(event_time) FILTER (WHERE event_type = 'cart')     AS t_cart
MIN(event_time) FILTER (WHERE event_type = 'purchase') AS t_purchase

-- 順序比較: t_cart > t_view なら「view してから cart した」を確認
WHERE t_cart > t_view AND t_purchase > t_cart
NULL を含む比較は自動的に「false 扱い」になる:NULL > 任意の値 は NULL を返し、COUNT(*) FILTER は NULL の行を除外します。これにより、cart イベントを発火していないユーザー(t_cart が NULL)は自動的にステップ2の集計から除外されます。ユーザー側で IS NOT NULL チェックを書く必要がない点が SQL の三値論理の利点です。
問題

EC サイトの user_events テーブルから、シーケンシャル(順序保証つき)ファネルを集計してください。①各ユーザーが view → cart → purchase の順序で各ステップを通過したかを判定、②全ステップ合計人数と、③ view→cart 変換率cart→purchase 変換率をパーセント(小数点1位)で算出してください。出力は1行5列です。

使用テーブル
► user_events(11行 / 5ユーザー)
user_idevent_typeevent_time
1view2024-01-10 10:00
1cart2024-01-10 10:05
1purchase2024-01-10 10:30
2view2024-01-10 11:00
2cart2024-01-10 11:15
2purchase2024-01-10 11:45
3view2024-01-10 12:00
3cart2024-01-10 12:30
4view2024-01-10 14:00
5view2024-01-10 15:00
5purchase2024-01-10 15:30
期待出力
step1_viewstep2_cartstep3_purchaseview_to_cart_pctcart_to_purchase_pct
53260.066.7
模範解答コード
WITH user_first_steps AS (
  -- FROM: ユーザーイベントテーブルからデータを取得
  -- GROUP BY: ユーザー単位に集約する
  SELECT
    user_id,
    MIN(event_time) FILTER (WHERE event_type = 'view')     AS t_view,                    -- MIN() FILTER: 各ユーザーの指定した条件(event_type)を満たす一番古い時間(MIN)を取得
    MIN(event_time) FILTER (WHERE event_type = 'cart')     AS t_cart,
    MIN(event_time) FILTER (WHERE event_type = 'purchase') AS t_purchase
  FROM   user_events
  GROUP BY user_id
)
SELECT
  COUNT(*) FILTER (WHERE t_view IS NOT NULL)                       AS step1_view,  -- 条件に合う行数(IS NOT NULL=値あり)
  COUNT(*) FILTER (WHERE t_cart > t_view)                              AS step2_cart,    -- 順序比較: カート時間がビュー時間より後か判定
  COUNT(*) FILTER (WHERE t_purchase > t_cart AND t_cart > t_view)    AS step3_purchase,  -- 順序比較: さらに購入時間がカート時間より後か判定
  ROUND(100.0 * COUNT(*) FILTER (WHERE t_cart > t_view)                                          -- ROUND(): 指定した桁数で四捨五入する(ここでは小数点1位)/100.0を掛けることで、整数除算(小数点以下切り捨て)を回避し、小数計算(FLOAT)にする/NULLIF(値1, 値2): 値1が値2と等しい場合NULLを返す。ゼロ除算(0で割るエラー)を防ぐための定石
              / NULLIF(COUNT(*) FILTER (WHERE t_view IS NOT NULL), 0), 1)  AS view_to_cart_pct,
  ROUND(100.0 * COUNT(*) FILTER (WHERE t_purchase > t_cart AND t_cart > t_view)
              / NULLIF(COUNT(*) FILTER (WHERE t_cart > t_view), 0), 1)        AS cart_to_purchase_pct
FROM   user_first_steps;

/*
  実行順序(SQLの論理的な評価順):
  1. CTE user_first_steps  → 各ユーザーの初回イベント時刻を集計
  2. 外側クエリ                 → ステップ通過人数を条件カウントし変換率を算出
  */
解説(テーブル変化・ポイント)
WITH user_first_steps AS ( SELECT user_id, MIN(event_time) FILTER (WHERE event_type = 'view') AS t_view, MIN(event_time) FILTER (WHERE event_type = 'cart') AS t_cart, MIN(event_time) FILTER (WHERE event_type = 'purchase') AS t_purchase FROM user_events GROUP BY user_id ) SELECT COUNT(*) FILTER (WHERE t_view IS NOT NULL) AS step1_view, COUNT(*) FILTER (WHERE t_cart > t_view) AS step2_cart, COUNT(*) FILTER (WHERE t_purchase > t_cart AND t_cart > t_view) AS step3_purchase, ROUND(100.0 * COUNT(*) FILTER (WHERE t_cart > t_view) / NULLIF(COUNT(*) FILTER (WHERE t_view IS NOT NULL), 0), 1) AS view_to_cart_pct, ROUND(100.0 * COUNT(*) FILTER (WHERE t_purchase > t_cart AND t_cart > t_view) / NULLIF(COUNT(*) FILTER (WHERE t_cart > t_view), 0), 1) AS cart_to_purchase_pct FROM user_first_steps;
LEGEND
データ取得・読込対象
① FROM
FROM user_eventsユーザーごとに複数のイベント行があります。user5 は view → purchase で cart をスキップしている点に注意。
1 / 7
user_idevent_typeevent_time
1view10:00
1cart10:05
1purchase10:30
2view11:00
2cart11:15
2purchase11:45
3view12:00
3cart12:30
4view14:00
5view15:00
5purchase15:30
11行読込(5ユーザー)
学習ポイント
MIN(event_time) FILTER で「条件付き集約」を1パスで実現:各ユーザーの各イベント初回時刻を1回のテーブルスキャンで取得できます。自己結合より高速で、可読性も高いのが利点。t_view, t_cart, t_purchase を取り出せれば、ステップごとの順序判定は単純な不等号比較で済みます。
三値論理(NULL の比較は NULL)が自動でステップ除外を実現:user5 の t_cart は NULL なので、t_cart > t_view は NULL となり、COUNT(*) FILTER は NULL の行を除外します。明示的に IS NOT NULL チェックを書かなくても自動的に正しく動作するのが SQL の美しさです。
NULLIF(分母, 0) でゼロ除算を防ぐ定石:母数が 0 のときに x / NULLIF(0, 0) = x / NULL = NULL となるため、0で割るエラーを回避できます。「データがまだない期間の変換率」も NULL として綺麗に表示され、後続 BI ツールでも誤った 0% 表示にならない実務的利点があります。
アンチパターン
非シーケンシャルファネル(ステップ独立カウント)の罠:単に COUNT(DISTINCT user_id) FILTER (WHERE event_type='cart') としてしまうと、user5 のように「view せず cart したユーザー(外部リンク経由など)」もカウントされ、シーケンシャルファネルではなく「累計達成者数」になります。実務では用途を明示して区別してください。
整数除算による変換率の取りこぼし:3 / 5 = 0(PostgreSQL の整数除算)になります。必ず 100.0 * など片方を float にして算術を切り替えてください。::numeric* 1.0 でも同様の効果が得られます。
実務コラム:時間ウィンドウ付きファネル(24時間以内に変換)への拡張
実務のファネルでは「view から 24時間以内に cart したユーザー」など時間ウィンドウ制約を加えることが頻出です。本問の判定条件を t_cart > t_view AND t_cart <= t_view + INTERVAL '24 hours' に置き換えるだけで実装できます。リテンション分析・キャンペーン効果測定・広告 ROI 計算で同じパターンが繰り返し使えるため、この MIN(...) FILTER + 順序比較パターンはイベント分析の中核技術です。
QUESTION 2

セッションID付与 — LAG + SUM() OVER で累積セッション番号を生成(ギャップ&アイランド)

SUM OVERROWS UNBOUNDEDセッションIDギャップ&アイランド
前提知識

セッション境界の検出(基礎編で学習)の次の実務ステップはセッションIDの付与です。「セッション開始フラグ」を SUM() OVER (... ROWS UNBOUNDED PRECEDING) で累積和すると、各セッション内のすべての行に同じ番号が振られます。これが SQL の Gaps & Islands パターンの代表的応用です。

-- 累積セッション番号の生成
SUM(is_new_session) OVER (
  PARTITION BY user_id
  ORDER BY     event_time
  ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS session_num

-- ↑ is_new_session が [1,0,1,0] なら 累積和 → [1,1,2,2]
--   同じセッション内の行は同じ番号になる
ROWS UNBOUNDED PRECEDING の意味:「枠の先頭から現在行まで」を計算範囲に指定します。SUM(is_new_session) OVER (... ORDER BY ...) だけでも デフォルトで UNBOUNDED PRECEDING ~ CURRENT ROW(実は RANGE 単位)になりますが、ROWS と明示する方が安全で意図が明確です。ウィンドウ集計でのこの「枠指定」を理解することがウィンドウ関数熟達への鍵です。
問題

user_events テーブルに対して、30分以上のイベント間隔を新セッションの境界とみなし、各イベント行に user_id-session_num 形式のセッションIDを付与してください。出力列は user_id, session_id, event_type, event_time、user_id / event_time 昇順で返してください。

使用テーブル
► user_events(8行 / 3ユーザー)
user_idevent_typeevent_time
1view2024-01-10 10:00
1click2024-01-10 10:05
1view2024-01-10 10:50
1purchase2024-01-10 10:55
2view2024-01-10 11:00
2click2024-01-10 11:10
2view2024-01-10 12:00
3view2024-01-10 09:00
期待出力
user_idsession_idevent_typeevent_time
11-1view10:00
11-1click10:05
11-2view10:50
11-2purchase10:55
22-1view11:00
22-1click11:10
22-2view12:00
33-1view09:00
模範解答コード
WITH with_lag AS (
  -- LAG() OVER: 指定した並び順(ORDER BY)での前の行の値を取得するウィンドウ関数
  -- PARTITION BY: ユーザーごとに計算を独立させる(ユーザー境界をまたがない)
  SELECT
    user_id, event_type, event_time,
    LAG(event_time) OVER (
      PARTITION BY user_id ORDER BY event_time
    ) AS prev_event_time
  FROM   user_events
),
with_flag AS (
  -- CASE WHEN ... THEN ... ELSE ... END: 条件分岐の基礎文法
  -- EXTRACT(EPOCH FROM ...): 時間差を秒単位の数値(エポック秒)に変換する
  SELECT
    user_id, event_type, event_time,
    CASE
      WHEN prev_event_time IS NULL
        OR EXTRACT(EPOCH FROM (event_time - prev_event_time)) / 60 >= 30
      THEN 1 ELSE 0
    END AS is_new_session
  FROM   with_lag
),
with_session_num AS (
  -- SUM() OVER: 条件に合致する値(ここではセッション開始フラグ1)の累積和を計算する
  -- ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW: 「最初の行から現在の行まで」という計算枠の指定
  SELECT
    user_id, event_type, event_time,
    SUM(is_new_session) OVER (                                            -- フラグの累積和 = セッション番号
      PARTITION BY user_id
      ORDER BY     event_time
      ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS session_num
  FROM   with_flag
)
SELECT
  user_id,
  user_id || '-' || session_num  AS session_id,                           -- || 演算子: 文字列を連結する(例: '1' || '-' || '1' -> '1-1')
  event_type,
  TO_CHAR(event_time, 'HH24:MI') AS event_time
FROM   with_session_num
ORDER BY user_id, event_time;

/*
  実行順序(SQLの論理的な評価順):
  1. CTE with_lag
  2. CTE with_flag
  3. CTE with_session_num
  4. 外側クエリ
  */
解説(テーブル変化・ポイント)
WITH with_lag AS ( SELECT user_id, event_type, event_time, LAG(event_time) OVER (PARTITION BY user_id ORDER BY event_time) AS prev_event_time FROM user_events ), with_flag AS ( SELECT user_id, event_type, event_time, CASE WHEN prev_event_time IS NULL OR EXTRACT(EPOCH FROM (event_time - prev_event_time))/60 >= 30 THEN 1 ELSE 0 END AS is_new_session FROM with_lag ), with_session_num AS ( SELECT user_id, event_type, event_time, SUM(is_new_session) OVER ( PARTITION BY user_id ORDER BY event_time ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS session_num FROM with_flag ) SELECT user_id, user_id || '-' || session_num AS session_id, event_type, event_time FROM with_session_num ORDER BY user_id, event_time;
LEGEND
データ取得・読込対象
① FROM
FROM user_events3ユーザーの計8件のイベントログ。これに対し「30分以上の空白=新セッション」のルールで連続イベントをグルーピングし、セッションIDを付与します。
1 / 6
user_idevent_typeevent_time
1view10:00
1click10:05
1view10:50
1purchase10:55
2view11:00
2click11:10
2view12:00
3view09:00
8行読込(3ユーザー)
学習ポイント
Gaps & Islands パターンの一般化:「境界フラグを 0/1 で立てて累積和を取ると、同じグループ内の行に同じ番号が振られる」というのは、セッション分割以外にも幅広く応用できる強力なパターンです。連続する欠勤日のグループ化・在庫レコードの状態変化区間検出・株価の連続上昇期間の分割など、SQL の Gaps & Islands は実務で繰り返し使われる中核技術です。
ROWS と RANGE の違い:ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW物理的な行数で枠を決めますが、RANGEORDER BY のキー値で枠を決めます。同じ event_time の行が複数あると ROWS は1行ずつ、RANGE は同じ時刻の全行をまとめて処理するため挙動が変わります。累積セッション番号には ROWS を明示するのが安全です。
3段 CTE 設計の利点:with_lag → with_flag → with_session_num と段階分けすることで、各CTEで何が計算されているかが明確になり、デバッグが容易になります。中間状態を確認したいときは末尾のクエリを SELECT * FROM with_flag に差し替えるだけで検証可能。実務では1つの巨大なクエリより CTE 分割が保守性で勝ります。
アンチパターン
PARTITION BY を省略すると user 境界を越えて累積される:SUM(is_new_session) OVER (ORDER BY event_time) と書いてしまうと、user1 の累積和 が user2 に引き継がれて間違ったセッション番号になります。ユーザー単位で累積をリセットするため、必ず PARTITION BY user_id を明示してください。
セッション番号だけだとグローバル一意性がない:user1 の session_num=1 と user2 の session_num=1 は別物です。これらを区別せず GROUP BY session_num だけしてしまうと、複数ユーザーが混合されます。必ず user_id と session_num を組み合わせて一意キーを生成するか、(user_id, session_num) の複合キーで GROUP BY してください。
実務コラム:セッションIDからセッション指標へ
セッションIDが付与できれば、「セッションあたり平均イベント数」「セッション継続時間」「セッション内コンバージョン率」など、セッション単位の指標が一気に算出可能になります。SELECT session_id, COUNT(*) AS events, MAX(event_time) - MIN(event_time) AS duration FROM with_session_num GROUP BY session_id のように。Google Analytics や Mixpanel が提供する標準指標の多くは、内部的にはこの「セッション境界の検出 → セッションID付与 → 集計」の流れで実装されています。1つのウィンドウ関数パターンの理解が、プロダクト分析の世界を一気に広げます
QUESTION 3

コホート別リテンション — 登録月別の Nヶ月後生存率を JOIN + DATE_TRUNC で算出する

DATE_TRUNCFIRST_VALUE OVERコホート分析リテンション率
前提知識

コホート分析は「初回利用月(登録月)が同じユーザー群」をコホートとして、各月でどれだけ残存しているかを時系列で追う分析です。SaaS・モバイルアプリ・EC など、サブスクリプション型プロダクトの最重要指標である継続率(リテンション)はすべてこのパターンで算出されます。

-- 各ユーザーのコホート(初回月)を確定
SELECT user_id, DATE_TRUNC('month', MIN(event_time))::date AS cohort_month
FROM   user_events GROUP BY user_id

-- コホートに対する各月の生存率
SELECT
  cohort_month,
  months_since_signup,
  active_users,
  FIRST_VALUE(active_users) OVER (
    PARTITION BY cohort_month ORDER BY months_since_signup
  ) AS cohort_size              -- 各コホートの初期人数
2軸(コホート×時系列)の集計が分析の本質:1月入会コホート→1月/2月/3月の生存数、2月入会コホート→2月/3月の生存数、というように、2次元の表(コホートテーブル)が出来上がります。これを heatmap 可視化すると、特定の月にユーザー獲得施策が成功したかなどが一目でわかります。FIRST_VALUE() でコホート初期人数を取り出すことで、相対比(%)でも比較できます。
問題

user_events テーブルから、各ユーザーの初回利用月(コホート)を確定し、コホートごとの月次生存ユーザー数とリテンション率(%)を算出してください。出力列は cohort_month, months_since_signup, active_users, cohort_size, retention_pct、cohort_month / months_since_signup 昇順で返してください。

使用テーブル
► user_events(10行 / 6ユーザー / 3ヶ月)
user_idevent_time
12024-01-15
12024-02-10
12024-03-05
22024-01-20
22024-02-15
32024-01-25
42024-02-05
42024-03-12
52024-02-20
62024-03-15
期待出力
cohort_monthmonths_since_signupactive_userscohort_sizeretention_pct
2024-01-01033100.0
2024-01-0112366.7
2024-01-0121333.3
2024-02-01022100.0
2024-02-0111250.0
2024-03-01011100.0
模範解答コード
WITH user_cohort AS (
  SELECT                                                                                     -- 各ユーザーの初回月 = コホート
    user_id,
    DATE_TRUNC('month', MIN(event_time))::date  AS cohort_month  -- 月に切り捨て→DATE 型
  FROM   user_events
  GROUP BY user_id
),
user_activity AS (
  SELECT DISTINCT                                                                            -- 各ユーザーがアクティブだった月(重複排除)
    user_id,
    DATE_TRUNC('month', event_time)::date  AS activity_month
  FROM   user_events
),
cohort_join AS (
  SELECT                                                                                     -- 各活動行に経過月を付与
    uc.cohort_month,
    ua.activity_month,
    ua.user_id,
    ((EXTRACT(YEAR FROM ua.activity_month) - EXTRACT(YEAR FROM uc.cohort_month)) * 12  -- 登録からの経過月数(年差×12+月差)
      + (EXTRACT(MONTH FROM ua.activity_month) - EXTRACT(MONTH FROM uc.cohort_month)))::int
        AS months_since_signup
  FROM        user_cohort   uc                                                               -- USING (user_id): 両テーブルで共通のカラム名(user_id)を使って JOIN する省略記法
  JOIN        user_activity ua USING (user_id)
)
SELECT
  cohort_month,
  months_since_signup,
  COUNT(DISTINCT user_id)            AS active_users,                                        -- 各月のアクティブユーザー数
  FIRST_VALUE(COUNT(DISTINCT user_id)) OVER (                      -- FIRST_VALUE() OVER: 指定した並び順で「最初の行」の値を取得する/月の経過順に並べた最初の値=初期登録人数を取得
    PARTITION BY cohort_month ORDER BY months_since_signup
  )                                       AS cohort_size,
  ROUND(100.0 * COUNT(DISTINCT user_id)                            -- アクティブ数 / 初期登録人数 で残存率を算出
       / FIRST_VALUE(COUNT(DISTINCT user_id)) OVER (
           PARTITION BY cohort_month ORDER BY months_since_signup
         ), 1)                        AS retention_pct
FROM     cohort_join
GROUP BY cohort_month, months_since_signup
ORDER BY cohort_month, months_since_signup;

/*
  実行順序(SQLの論理的な評価順):
  1. CTE user_cohort
  2. CTE user_activity
  3. CTE cohort_join
  4. 外側クエリ
  5. 100.0 * active / cohort_size  → リテンション率(%)
  */
解説(テーブル変化・ポイント)
WITH user_cohort AS ( SELECT user_id, DATE_TRUNC('month', MIN(event_time))::date AS cohort_month FROM user_events GROUP BY user_id ), user_activity AS ( SELECT DISTINCT user_id, DATE_TRUNC('month', event_time)::date AS activity_month FROM user_events ), cohort_join AS ( SELECT uc.cohort_month, ua.activity_month, ua.user_id, ((EXTRACT(YEAR FROM ua.activity_month) - EXTRACT(YEAR FROM uc.cohort_month)) * 12 + (EXTRACT(MONTH FROM ua.activity_month) - EXTRACT(MONTH FROM uc.cohort_month)))::int AS months_since_signup FROM user_cohort uc JOIN user_activity ua USING (user_id) ) SELECT cohort_month, months_since_signup, COUNT(DISTINCT user_id) AS active_users, FIRST_VALUE(COUNT(DISTINCT user_id)) OVER ( PARTITION BY cohort_month ORDER BY months_since_signup ) AS cohort_size, ROUND(100.0 * COUNT(DISTINCT user_id) / FIRST_VALUE(COUNT(DISTINCT user_id)) OVER ( PARTITION BY cohort_month ORDER BY months_since_signup ), 1) AS retention_pct FROM cohort_join GROUP BY cohort_month, months_since_signup ORDER BY cohort_month, months_since_signup;
LEGEND
データ取得・読込対象
① FROM
FROM user_events3ヶ月(1〜3月)にわたる6ユーザー10件のイベントログ。これを「初回月コホート」「月次活動」の2つに分解してから結合し、コホート分析のための2次元テーブルを構築します。
1 / 7
user_idevent_time
12024-01-15
12024-02-10
12024-03-05
22024-01-20
22024-02-15
32024-01-25
42024-02-05
42024-03-12
52024-02-20
62024-03-15
10行読込
学習ポイント
「コホート確定 → 活動展開 → JOIN」の3段アーキテクチャ:コホート分析の SQL は3段の CTE で美しく構造化できます。①各ユーザーのコホート(初回月)を1行ずつに、②各ユーザーがアクティブだった月を DISTINCT で1行ずつに、③この2つを JOIN して経過月を計算。この明確な役割分担が、複雑な集計クエリでも保守性を保つコツです。
FIRST_VALUE() OVER で「枠の最初の値」を全行に展開:FIRST_VALUE(active_users) OVER (PARTITION BY cohort_month ORDER BY months_since_signup) は、各コホートの月0の値(=コホート初期人数)を、同じコホートの全行にコピーします。割合計算のために「分母」を各行に持たせる定石パターンで、リテンション・シェア率・寄与度などあらゆる比率指標で使えます。
EXTRACT で月差分を整数化する公式:(YYYY差 × 12) + 月差 という計算は、月単位の経過時間を整数で取得する標準的な手法です。AGE() 関数もありますが interval 型を返すため整数比較や ORDER BY で扱いにくい場合があり、EXTRACT による直接計算が実務で好まれます。
アンチパターン
SELECT DISTINCT を忘れて重複行を JOIN する:user_activity で DISTINCT を忘れると、同月に複数回イベントを発火したユーザーが複数行になり、JOIN 後の COUNT(DISTINCT user_id) は正しく動作してもクエリが無駄に重くなります。JOIN 前に DISTINCT で正規化するのが鉄則です。
FIRST_VALUE の代わりに副問い合わせを書く:(SELECT active_users FROM ... WHERE months_since_signup = 0) のような副問い合わせは1コホートずつ計算するため遅くなります。ウィンドウ関数なら 1パスで全コホートを処理でき、データが100万行を超える本番環境ではこの差が顕著です。
実務コラム:リテンション分析が示す「プロダクトの真の価値」
リテンション曲線(横軸:月、縦軸:%)の形状は、プロダクトの本質的価値を雄弁に物語ります。「最初の数ヶ月で急落しその後は平坦」はコア定着ユーザーが残っている健全な状態。「最初から急落し続けて0に向かう」はプロダクトマーケットフィットが未達。Andrew Chen のブログによれば、SaaS の 12ヶ月リテンションが30%以上であれば極めて優良と言われます。コホート別に切り出すことで、機能追加やオンボーディング改善の効果を時系列で計測できる点も実務上の大きな価値です。
QUESTION 4

行動パス集約 — STRING_AGG で頻出ユーザー行動パスを抽出

STRING_AGGARRAY_AGG集約2段階パス分析
前提知識

マーケティングや UX 改善において、「ユーザーがどの順序でイベントを経由したか」というパス情報は極めて重要です。例えば「view → cart → purchase」と「view → purchase」では同じ purchase でも背景が全く異なります。

本問題では STRING_AGG(または ARRAY_AGG)を用いた2段階集約を学びます。1段目でユーザー単位でイベントを時系列順に文字列化し、2段目で同一パスのユーザー数をカウントします。これにより「最も多いユーザー行動パターン Top N」を抽出できます。

-- ユーザーごとにイベントを時系列順に連結
STRING_AGG(event_type, ' → ' ORDER BY event_time)
STRING_AGG に ORDER BY を指定する理由:集約関数の中で ORDER BY を指定することで、連結される文字列の順序を保証します。これを忘れると実行のたびに順序が変わる可能性があります。
問題

各ユーザーがイベントを発火した順序を「 → 」で連結し、同一パスごとにユーザー数とパスの長さ(イベント数)を集計するクエリを書いてください。
集計の際は、ユーザー単位でイベントを時系列順に連結してパス文字列を生成したうえで、同一パスごとのユーザー数をカウントしてください。出力はユーザー数の多い順(同数の場合はパスが長い順)に並べます。

使用テーブル
► events(12行 / 5ユーザー)
user_idevent_typeevent_time
1view2024-01-15 10:00:00
1cart2024-01-15 10:05:00
1purchase2024-01-15 10:10:00
2view2024-01-15 11:00:00
2cart2024-01-15 11:03:00
2purchase2024-01-15 11:08:00
3view2024-01-15 12:00:00
3cart2024-01-15 12:02:00
4view2024-01-15 13:00:00
4purchase2024-01-15 13:01:00
5view2024-01-15 14:00:00
5purchase2024-01-15 14:05:00
期待出力
pathuser_countpath_length
view → cart → purchase23
view → purchase22
view → cart12
模範解答コード
WITH user_paths AS (
  -- 1段目: ユーザー単位でグループ化し、イベントを時系列に連結
  SELECT
    user_id,
    STRING_AGG(event_type, ' → ' ORDER BY event_time) AS path,      -- STRING_AGG(列, 区切り文字 ORDER BY 並び順): 順序を保証して文字列結合する関数
    COUNT(*)                                        AS path_length  -- COUNT(*): そのユーザーが発生させたイベント総数をカウント
  FROM   events
  GROUP BY user_id
)
-- 2段目: 同一のパス(行動パターン)ごとにユーザー数を集計
SELECT
  path,
  COUNT(*)         AS user_count,                                   -- この COUNT(*) は GROUP BY path によって束ねられた行(=同一パスのユーザー)の数
  MAX(path_length) AS path_length                                   -- MAX(): グループ内の最大値。同一パスなら文字の長さは同じため MAX() や MIN() で取得可能
FROM   user_paths
GROUP BY path
ORDER BY user_count DESC, path_length DESC;                         -- ORDER BY 列 DESC: 降順(大きいものから順)に並べ替え

/*
  実行順序(SQLの論理的な評価順):
  1. CTE user_paths
  2. 外側クエリ
  */
解説(テーブル変化・ポイント)
WITH user_paths AS ( SELECT user_id, STRING_AGG(event_type, ' → ' ORDER BY event_time) AS path, COUNT(*) AS path_length FROM events GROUP BY user_id ) SELECT path, COUNT(*) AS user_count, MAX(path_length) AS path_length FROM user_paths GROUP BY path ORDER BY user_count DESC, path_length DESC;
LEGEND
データ取得・読込対象
STEP 1
events テーブル5ユーザーの 12 行のイベントログ。これを user_id ごとにグループ化し、event_time の順にイベント名を連結する。
1 / 6
user_idevent_typeevent_time
1view10:00:00
1cart10:05:00
1purchase10:10:00
2view11:00:00
2cart11:03:00
2purchase11:08:00
3view12:00:00
3cart12:02:00
4view13:00:00
4purchase13:01:00
5view14:00:00
5purchase14:05:00
12 行 / 5 ユーザー
学習ポイント
STRING_AGG の ORDER BY 引数内指定で「順序を保証した連結」:STRING_AGG(event_type, ' → ' ORDER BY event_time) のように関数内に ORDER BY を書くことで、グループ内の集約順序を明示的に指定できます。これを忘れると「view → purchase → cart」など順序が崩れた結果が返り、パス分析として無意味になります。ARRAY_AGG でも同じ構文で配列を生成可能で、文字列より構造化された後続処理ができます。
「個別パス → パス集計」の2段階集約パターン:本問題のような「行動軌跡 → 軌跡の人気度」を求める分析は、必ず2段階の GROUP BY が必要です。1段目は user_id でユーザーごとに行動を1行に圧縮、2段目は path でパターンごとに集計。この階層構造は、A/B テストの変化パターン分類、購入経路分析、画面遷移分析など、多くの実務シーンで応用できます。
ARRAY_AGG への置き換えで分析可能性が広がる:PostgreSQL では ARRAY_AGG(event_type ORDER BY event_time) で配列を取得し、array_lengtharr[1](先頭要素)、arr && ARRAY['cart'](要素含有判定)など豊富な配列操作が可能です。文字列化は表示用、配列化は分析用と覚えておくと使い分けがスムーズです。
アンチパターン
STRING_AGG に ORDER BY を付け忘れる:SQL の集約関数は本来「順序を持たない集合」を処理するため、STRING_AGG(event_type, ' → ') だけだと実行ごとに結果順序が変わる可能性があります。データベースによっては挿入順や物理順で偶然動くこともありますが、本番では必ず ORDER BY を引数内に明記してください。
GROUP_CONCAT / LISTAGG の方言差を意識しない:同じ機能でも MySQL は GROUP_CONCAT(event_type ORDER BY event_time SEPARATOR ' → ')、Oracle は LISTAGG(event_type, ' → ') WITHIN GROUP (ORDER BY event_time) と構文が異なります。移植時には方言マトリクスを必ず確認すべきポイントです。
実務コラム:パス分析と「Sankey ダイアグラム」の関係
行動パスの集計結果は、Sankey ダイアグラム(流量を太さで表現する遷移図)として可視化されることが多いです。Google Analytics の「行動フロー」、Mixpanel の「Funnel/Flow」、Amplitude の「Pathfinder」など、主要プロダクト分析ツールはすべて内部で本質的にこの SQL ロジックを実行しています。パスの数が爆発的に増えるのがこの分析の難点で、実務では「最初の N イベントのみ」「主要イベントタイプのみフィルタ」などの絞り込みを併用するのが定石です。
QUESTION 5

再帰CTE で日付の穴を埋める — イベントが無い日も 0 として可視化

WITH RECURSIVELEFT JOINCOALESCE日付シリーズ
前提知識

日次の DAU(Daily Active Users)をグラフ化する際、「イベント発火がゼロだった日」がデータに存在しないと折れ線が不自然に途切れることがあります。これを防ぐには、分析対象期間の全日付を持つ「日付シリーズテーブル」を生成し、実データと LEFT JOIN するのが定石です。

本問題では WITH RECURSIVE(再帰共通テーブル式)を使って日付シリーズを動的に生成します。再帰CTE は SQL の中でも特に概念が掴みづらいため、本解説では「現在の累積結果」「今回追加された行」「次に生成される行」の3点を毎ステップで明示しながら、段階的に動作を可視化します。

-- WITH RECURSIVE の基本構造
WITH RECURSIVE date_series AS (
  SELECT DATE '2023-09-01' AS day          -- 基底(1行目)
  UNION ALL
  SELECT day + 1            -- 再帰部(前回の行に+1日)
  FROM date_series
  WHERE day < DATE '2023-09-10'            -- 終了条件
)
再帰処理の挙動:1段目(基底)がまず実行され、その結果を入力として2段目(再帰部)が評価されます。評価結果が新たに返される限り、再帰部は「直前に追加された行」に対して反復実行され続けます。WHERE 句で終了条件を設けないと無限ループになるので注意が必要です。
問題

分析期間 2024-01-10 から 2024-01-15 まで(両端含む6日間)について、日毎の DAU(重複排除したユーザー数)を集計し、イベント無しの日も 0 として含めて出力するクエリを書いてください。
events テーブル単体からの集計ではイベントの無い日が欠落するため、再帰CTEなどを活用して対象期間の連続した日付シリーズを生成し、集計結果と結合して欠損日を補完してください。

使用テーブル
► events(6行 / 4日分)
user_idevent_time
12024-01-10 09:00
22024-01-10 14:00
12024-01-12 10:00
32024-01-12 16:00
22024-01-14 11:00
12024-01-15 09:30
期待出力
dayactive_users
2024-01-102
2024-01-110
2024-01-122
2024-01-130
2024-01-141
2024-01-151
模範解答コード
-- WITH RECURSIVE: 再帰的な処理を行う CTE の宣言(自己参照が可能になる)
WITH RECURSIVE date_series AS (
  -- 1段目: 基底(ベースケース) — 開始日を1行返す
  SELECT DATE '2024-01-10' AS day

  -- UNION ALL: 複数の SELECT 結果を重複を保持したまま縦に結合する
  UNION ALL

  -- 2段目: 再帰部 — 前回の結果(直近行)の day に対して1日を加算した行を返す
  SELECT day + 1
  FROM   date_series
  WHERE  day < DATE '2024-01-15'                         -- 終了条件: day が 2024-01-15 より小さい間だけループを続ける
),
daily_dau AS (
  -- events テーブルの時間を日に丸めて日ごとの DAU(重複排除したユーザー数)を集計
  SELECT
    DATE_TRUNC('day', event_time)::DATE AS day,
    COUNT(DISTINCT user_id)             AS active_users  -- DISTINCT user_id: 重複するユーザーIDを1回だけカウントする
  FROM   events
  GROUP BY DATE_TRUNC('day', event_time)::DATE
)
-- 日付シリーズを主軸に LEFT JOIN し、一致しない(データがない)日の NULL を 0 に変換
SELECT
  ds.day,
  COALESCE(dd.active_users, 0) AS active_users           -- COALESCE(値1, 値2): 値1が NULL なら値2を返す関数(欠損値の穴埋めの定石)
FROM      date_series ds                                 -- LEFT JOIN: 左側のテーブル(ds)の行をすべて残し、右側(dd)は条件(ON)に合致する行だけを結合
LEFT JOIN daily_dau   dd ON ds.day = dd.day
ORDER BY  ds.day;

/*
  実行順序(SQLの論理的な評価順):
  1. CTE date_series (WITH RECURSIVE)
  2. CTE daily_dau
  3. 外側クエリ
  */
解説(テーブル変化・ポイント)
WITH RECURSIVE date_series AS ( SELECT DATE '2024-01-10' AS day UNION ALL SELECT day + 1 FROM date_series WHERE day < DATE '2024-01-15' ), daily_dau AS ( SELECT DATE_TRUNC('day', event_time)::DATE AS day, COUNT(DISTINCT user_id) AS active_users FROM events GROUP BY DATE_TRUNC('day', event_time)::DATE ) SELECT ds.day, COALESCE(dd.active_users, 0) AS active_users FROM date_series ds LEFT JOIN daily_dau dd ON ds.day = dd.day ORDER BY ds.day;
LEGEND
データ取得・読込対象
STEP 1
events テーブル(スパース)6 件のイベントログ。01-11 と 01-13 にはイベントが無いため、このテーブルだけでは「ゼロ DAU の日」が表現できない。
1 / 11
user_idevent_time
12024-01-10 09:00
22024-01-10 14:00
12024-01-12 10:00
32024-01-12 16:00
22024-01-14 11:00
12024-01-15 09:30
6 行 / 4 日分(01-11, 01-13 欠損)
学習ポイント
再帰CTE は「累積結果 + 直近行 → 1行追加」の繰り返し:再帰CTE は UNION ALL左側(ベースケース)が1度だけ評価され右側(再帰部)が「直前に追加された行」を入力に繰り返し評価されます。各反復で WHERE 句が偽になった時点で終了します。本問題のように「日付シリーズを生成する」「階層構造を辿る」「N以下のフィボナッチ数列を生成する」など、1行ずつ漸進的に積み上げる処理に最適です。
「日付シリーズ × LEFT JOIN × COALESCE」は穴埋めの定石3点セット:分析クエリで「全期間を埋める」要件は頻出します。基準軸となる連続テーブル(date_series)を左に置き、データテーブルを LEFT JOIN し、欠損値を COALESCE で 0 や既定値に変換。この3点セットは時系列ダッシュボード、SLA 計算、欠勤管理など、あらゆる時間軸分析の基本構造です。generate_series('2024-01-10'::date, '2024-01-15'::date, INTERVAL '1 day') という PostgreSQL の専用関数もありますが、概念理解には WITH RECURSIVE が最も学習価値が高いです。
終了条件は必須 — 無限ループを避ける:再帰部に WHERE day < '2024-01-15' がなければ無限に行を生成し、メモリ枯渇や DB クラッシュを引き起こします。必ず「有限回で偽になる条件」を WHERE に含めるのが鉄則。本問題では「直近追加された行」を入力にしているため、日付がインクリメントされ自然に終了します。多くの DB には安全装置として max_recursion のような上限設定もありますが、依存しない設計が望ましいです。
アンチパターン
INNER JOIN で書いて 0 の日が消える:うっかり JOIN(=INNER JOIN)と書くと、daily_dau に存在しない 01-11, 01-13 が結果から消え、穴埋め目的が果たせません。「全期間を保証したい側」を必ず LEFT JOIN の左に置くのが原則です。
WHERE 句に LEFT JOIN 後の右側条件を書いて INNER JOIN 化させる:WHERE dd.active_users IS NOT NULL など、JOIN 後の右側カラムを WHERE で絞ると、せっかくの LEFT JOIN が事実上 INNER JOIN に退化し、穴が再び消えます。右側条件は ON 句に書くか、または FROM 句の中で先に絞り込んでおきます。
終了条件を忘れた再帰CTE:WHERE 句を書き忘れたり、終了しない条件を書くと無限ループになります。初回の累積行が必ず WHERE 句を偽にできることを設計時点で確認するのが安全です。本番投入前に小さな範囲でテスト実行する習慣を。
実務コラム:dbt と「日付次元テーブル」のベストプラクティス
本番のデータウェアハウス運用では、毎クエリで再帰CTE を書くより、「日付次元テーブル(date_dimension)」を1つマテリアライズしておくのが定石です。day, year, month, day_of_week, is_weekend, fiscal_quarter, ... など豊富なカラムを持つこのテーブルを、ほぼ全ての時系列クエリで JOIN 軸として使います。dbt(data build tool)のパッケージ dbt_datedbt_utils.date_spine は内部でこの考え方を実装しています。再帰CTE の理解は「なぜ日付次元テーブルが必要か」を腹落ちさせるためにも極めて重要です。