イベントピボット集計 — FILTER(WHERE ...) + GROUP BY でイベント種別を横展開する
イベントモデリングでは、アクション(クリック・購入・閲覧など)をすべて 1テーブルの縦持ち(tall format)で記録します。分析時はこれを横持ち(wide format)に変換することが頻出です。PostgreSQL の FILTER (WHERE ...) 句を使うと、各イベント種別を1行の中で集計できます。
-- 縦持ち(イベントテーブルの標準形) user_id | event_type | event_time 1 | view | 2024-01-10 10:00 1 | click | 2024-01-10 10:05 1 | purchase | 2024-01-10 10:30 -- 横持ちに変換したい結果イメージ user_id | view_cnt | click_cnt | purchase_cnt 1 | 1 | 1 | 1
COUNT(*) FILTER (WHERE event_type = 'view') は、その GROUP のうち条件を満たす行だけを COUNT します。イベントが存在しないユーザーのカウントは 0 になり(NULLではない)、これが後続の割り算でゼロ除算を防ぐ利点になります。user_events テーブルから、ユーザーごとに view・ click・ purchase の各イベント発生回数を横持ちで集計してください。出力列は user_id, view_cnt, click_cnt, purchase_cnt、user_id 昇順で返してください。
| user_id | event_type | event_time |
|---|---|---|
| 1 | view | 2024-01-10 10:00 |
| 1 | click | 2024-01-10 10:05 |
| 1 | purchase | 2024-01-10 10:30 |
| 2 | view | 2024-01-10 11:00 |
| 2 | click | 2024-01-10 11:10 |
| 3 | view | 2024-01-11 09:00 |
| 3 | view | 2024-01-11 09:15 |
| 4 | view | 2024-01-11 14:00 |
| 4 | click | 2024-01-11 14:20 |
| 4 | purchase | 2024-01-11 14:50 |
| user_id | view_cnt | click_cnt | purchase_cnt |
|---|---|---|---|
| 1 | 1 | 1 | 1 |
| 2 | 1 | 1 | 0 |
| 3 | 2 | 0 | 0 |
| 4 | 1 | 1 | 1 |
SELECT user_id, COUNT(*) FILTER (WHERE event_type = 'view') AS view_cnt, -- 種別ごとに条件カウント COUNT(*) FILTER (WHERE event_type = 'click') AS click_cnt, COUNT(*) FILTER (WHERE event_type = 'purchase') AS purchase_cnt FROM user_events GROUP BY user_id ORDER BY user_id; /* 実行順序(SQLの論理的な評価順): 1. FROM user_events → 行を読込 2. GROUP BY user_id → グループ化 3. COUNT(*) FILTER (...) → イベント種別ごとに条件カウント 4. ORDER BY user_id → 並び替えて出力 */
LEGEND
① FROM user_events(10行)
FROM user_eventsuser_events テーブルの10行を読み込みます。event_type が view / click / purchase の3種類あり、これを横展開するのが今回の目的です。| user_id | event_type | event_time |
|---|---|---|
| 1 | view | 2024-01-10 10:00 |
| 1 | click | 2024-01-10 10:05 |
| 1 | purchase | 2024-01-10 10:30 |
| 2 | view | 2024-01-10 11:00 |
| 2 | click | 2024-01-10 11:10 |
| 3 | view | 2024-01-11 09:00 |
| 3 | view | 2024-01-11 09:15 |
| 4 | view | 2024-01-11 14:00 |
| 4 | click | 2024-01-11 14:20 |
| 4 | purchase | 2024-01-11 14:50 |
COUNT(*) FILTER (WHERE event_type = 'view') はマッチしない行を 0 としてカウントし NULL を返さないため、後続の割り算で NULLIF ガードが不要です。SUM(CASE WHEN ... THEN 1 ELSE 0 END) も同等ですが FILTER の方が可読性が高く、PostgreSQL のオプティマイザがより最適化しやすい形式です。COUNT(*) を使います。「そのイベントを1回でも発生させたユーザー数」を集計したい場合は COUNT(DISTINCT user_id) FILTER (...) になります。分析の目的(回数 vs ユニーク数)を明確に意識することが重要です。WHERE view_cnt > 0 AND purchase_cnt = 0 と簡潔に書けます。GROUP BY user_id が必要です。jsonb_object_agg(event_type, cnt) や crosstab() を検討してください。初回イベント抜出 — ROW_NUMBER() OVER (PARTITION BY ORDER BY) で最初のアクションを特定する
ROW_NUMBER() はウィンドウ関数の基本で、指定した PARTITION BY と ORDER BY に従って各行に連番を振ります。これにより「ユーザーごとの最初のイベント」を CTE + WHERE rn = 1 のパターンで抜出できます。
ROW_NUMBER() OVER ( PARTITION BY user_id -- ユーザーごとにリセット ORDER BY event_time ASC -- 最も古い順 → rn=1 が最初のイベント ) -- ASC → rn=1 が最初のイベント(first touch) -- DESC → rn=1 が最後のイベント(last touch)
ROW_NUMBER() は元の行数を保ったまま各行に計算結果(連番)を付与します。その後 CTE に包んで WHERE rn = 1 でフィルタするのが定番パターンです。WHERE 句でウィンドウ関数の結果を直接参照することはできないため、必ず CTE かサブクエリを挟みます。user_events テーブルから、ユーザーごとに最初に発生したイベント(event_type と event_time)を1行だけ抜出してください。出力列は user_id, first_event_type, first_event_time、user_id 昇順で返してください。
| user_id | event_type | event_time |
|---|---|---|
| 1 | view | 2024-01-10 10:00 |
| 1 | signup | 2024-01-10 10:30 |
| 1 | purchase | 2024-01-10 11:00 |
| 2 | signup | 2024-01-11 09:00 |
| 2 | purchase | 2024-01-11 09:45 |
| 3 | view | 2024-01-12 14:00 |
| 3 | purchase | 2024-01-12 15:00 |
| 4 | view | 2024-01-13 10:00 |
| user_id | first_event_type | first_event_time |
|---|---|---|
| 1 | view | 2024-01-10 10:00 |
| 2 | signup | 2024-01-11 09:00 |
| 3 | view | 2024-01-12 14:00 |
| 4 | view | 2024-01-13 10:00 |
WITH ranked AS ( SELECT user_id, event_type, event_time, ROW_NUMBER() OVER ( -- ウィンドウ関数:GROUP BY しない PARTITION BY user_id -- ユーザーごとに番号をリセット ORDER BY event_time ASC -- 古い順に番号付け → rn=1 が最初 ) AS rn FROM user_events ) SELECT user_id, event_type AS first_event_type, event_time AS first_event_time FROM ranked WHERE rn = 1 -- 各ユーザーの最初の1行だけを取り出す ORDER BY user_id; /* 実行順序(SQLの論理的な評価順): 1. CTE ranked 2. 外側クエリ 3. SELECT event_type AS first_event_type → 列名をリネーム 4. ORDER BY user_id → user_id 昇順 */
LEGEND
① FROM user_events(8行)
FROM user_eventsuser_events テーブルの8行を読み込みます。user_id ごとに複数のイベントがあり、最初の1行だけを抜出するのが今回の目的です。| user_id | event_type | event_time |
|---|---|---|
| 1 | view | 2024-01-10 10:00 |
| 1 | signup | 2024-01-10 10:30 |
| 1 | purchase | 2024-01-10 11:00 |
| 2 | signup | 2024-01-11 09:00 |
| 2 | purchase | 2024-01-11 09:45 |
| 3 | view | 2024-01-12 14:00 |
| 3 | purchase | 2024-01-12 15:00 |
| 4 | view | 2024-01-13 10:00 |
ROW_NUMBER は任意の1行に rn=1 を割り当て(決定論的でない)、RANK と DENSE_RANK は同順位に同じ番号を付けるため複数行が rn=1 になります。「1行だけ欲しい」なら ROW_NUMBER、「同着を全部欲しい」なら RANK を使います。WHERE ROW_NUMBER() OVER (...) = 1 と書くとエラーになります。必ず CTE かサブクエリで一度包んでから WHERE rn = 1 と参照してください。ORDER BY event_time DESC にするだけで rn=1 が最後のイベントになります。「ユーザーが最後に行ったアクション(ラストタッチ)」の取得もまったく同じパターンです。ROW_NUMBER() OVER (ORDER BY event_time) と書くと、全ユーザーを通じた1本の連番になります。rn=1 はテーブル全体の最古イベント行1件だけになり、「各ユーザーの最初のイベント」にはなりません。ORDER BY event_time, event_id のようにユニークなキーを追加してください。first_event_type = 'signup' のユーザー(view をスキップして直接登録)は、紹介リンクや SNS 広告経由の流入が多い傾向があります。ROW_NUMBER() で取り出した最初のイベントにキャンペーンIDやリファラー情報をJOINすることで、どの獲得チャネルがLTVの高いユーザーを連れてくるかを分析できます。セッション分析 — LAG() でイベント間隔を計算してセッション境界を検出する
LAG() はウィンドウ関数で、現在行の1つ前の行の値を参照します。イベントモデリングでは「直前イベントとの時間差」を計算し、一定時間(例: 30分)以上空いた場合を新しいセッション開始と定義するために使います。
LAG(event_time) OVER ( PARTITION BY user_id -- ユーザーをまたいで前の行を参照しない ORDER BY event_time -- 時系列順に並べた上で前の行を取る ) -- タイムスタンプ差を分に変換する計算 EXTRACT(EPOCH FROM (event_time - prev_event_time)) / 60 -- EPOCH = 秒数。÷60 で分に変換
CASE WHEN prev_event_time IS NULL THEN true とすることで、最初のイベントを常に「新セッション開始」として扱うことができます。user_events テーブルから、各イベントの直前イベントとの時間差(分)を計算し、間隔が 30 分以上(または最初のイベント)なら true のセッション開始フラグを付与してください。出力列は user_id, event_type, event_time, prev_event_time, gap_min, is_new_session、user_id / event_time 昇順で返してください。
| user_id | event_type | event_time |
|---|---|---|
| 1 | view | 2024-01-10 10:00 |
| 1 | click | 2024-01-10 10:03 |
| 1 | view | 2024-01-10 10:35 |
| 1 | purchase | 2024-01-10 10:40 |
| 2 | view | 2024-01-10 11:00 |
| 2 | click | 2024-01-10 11:05 |
| 2 | view | 2024-01-10 12:10 |
| user_id | event_type | event_time | prev_event_time | gap_min | is_new_session |
|---|---|---|---|---|---|
| 1 | view | 10:00 | NULL | NULL | true |
| 1 | click | 10:03 | 10:00 | 3 | false |
| 1 | view | 10:35 | 10:03 | 32 | true |
| 1 | purchase | 10:40 | 10:35 | 5 | false |
| 2 | view | 11:00 | NULL | NULL | true |
| 2 | click | 11:05 | 11:00 | 5 | false |
| 2 | view | 12:10 | 11:05 | 65 | true |
WITH with_lag AS ( SELECT user_id, event_type, event_time, LAG(event_time) OVER ( -- 直前の event_time を参照 PARTITION BY user_id -- ユーザーをまたがらない ORDER BY event_time -- 時系列順 ) AS prev_event_time FROM user_events ) SELECT user_id, event_type, TO_CHAR(event_time, 'HH24:MI') AS event_time, TO_CHAR(prev_event_time, 'HH24:MI') AS prev_event_time, ROUND( EXTRACT(EPOCH FROM (event_time - prev_event_time)) / 60 -- 秒→分に変換 )::int AS gap_min, CASE WHEN prev_event_time IS NULL -- 最初のイベント OR EXTRACT(EPOCH FROM (event_time - prev_event_time)) / 60 >= 30 -- 30分以上の空白 THEN true ELSE false END AS is_new_session FROM with_lag ORDER BY user_id, event_time; /* 実行順序(SQLの論理的な評価順): 1. CTE with_lag 2. 外側クエリ 3. ORDER BY user_id, event_time → 時系列順 */
LEGEND
① FROM user_events(7行)
FROM user_eventsuser_events テーブルの7行を読み込みます。user1 が4行、user2 が3行のイベントを持ちます。これをユーザーごとの時系列として処理します。| user_id | event_type | event_time |
|---|---|---|
| 1 | view | 10:00 |
| 1 | click | 10:03 |
| 1 | view | 10:35 |
| 1 | purchase | 10:40 |
| 2 | view | 11:00 |
| 2 | click | 11:05 |
| 2 | view | 12:10 |
LAG(event_time) OVER (ORDER BY event_time) と PARTITION BY を省略すると、user1 の最初の行がuser2の最後のイベントを「前の行」として参照するバグが発生します。イベントログの分析では必ず PARTITION BY user_id を付けるのが鉄則です。event_time - prev_event_time の結果は PostgreSQL の interval 型です。interval 型は >= 30 などの数値比較ができないため、EXTRACT(EPOCH FROM interval) で「秒数(float)」に変換してから比較します。÷60 で分、÷3600 で時間、÷86400 で日数に変換できます。SUM(is_new_session::int) OVER (PARTITION BY user_id ORDER BY event_time) で「ユーザー内の累積セッション数」が得られ、これがそのままセッションIDとして機能します。event_time AT TIME ZONE 'Asia/Tokyo' で変換してから LAG を計算する習慣をつけてください。SUM(is_new_session::int) OVER (PARTITION BY user_id ORDER BY event_time ROWS UNBOUNDED PRECEDING) の累積和でセッション番号が生成できます。セッションIDが揃えば「セッションあたりの平均イベント数」「セッション長の分布」「セッション内でのコンバージョン率」など、ページビュー単位ではなくセッション単位の指標が算出できるようになります。イベント遷移分析 — LEAD() で次のイベントを参照してユーザー行動パスを集計する
LEAD() は LAG() と逆方向のウィンドウ関数で、現在行の1つ後ろの行の値を参照します。「このイベントの次にどのイベントが発生したか」を取得することで、ユーザー行動パス(遷移パターン)を集計できます。
LEAD(event_type) OVER ( PARTITION BY user_id -- ユーザーをまたいで次の行を参照しない ORDER BY event_time -- 時系列順に並べた上で次の行を取る ) -- 最後のイベントは「次の行」が存在しないため NULL を返す
user_events テーブルから、各イベントの直後に発生したイベントの遷移パターン(from_event → to_event)を集計してください。末尾イベント(to_event = NULL)は除外し、出力列は from_event, to_event, transition_count、transition_count 降順 / from_event 昇順で返してください。
| user_id | event_type | event_time |
|---|---|---|
| 1 | view | 2024-01-10 10:00 |
| 1 | click | 2024-01-10 10:05 |
| 1 | view | 2024-01-10 10:10 |
| 1 | purchase | 2024-01-10 10:30 |
| 2 | view | 2024-01-11 09:00 |
| 2 | click | 2024-01-11 09:10 |
| 2 | view | 2024-01-11 09:20 |
| 2 | click | 2024-01-11 09:35 |
| 3 | view | 2024-01-12 14:00 |
| 3 | purchase | 2024-01-12 14:30 |
| from_event | to_event | transition_count |
|---|---|---|
| view | click | 3 |
| click | view | 2 |
| view | purchase | 2 |
WITH transitions AS ( SELECT user_id, event_type AS from_event, LEAD(event_type) OVER ( -- 直後のイベント種別を参照 PARTITION BY user_id -- ユーザーをまたがらない ORDER BY event_time -- 時系列順 ) AS to_event FROM user_events ) SELECT from_event, to_event, COUNT(*) AS transition_count FROM transitions WHERE to_event IS NOT NULL -- 末尾イベント(次のイベントなし)を除外 GROUP BY from_event, to_event ORDER BY transition_count DESC, from_event; /* 実行順序(SQLの論理的な評価順): 1. CTE transitions 2. 外側クエリ 3. ORDER BY transition_count DESC, from_event → 多い順・アルファベット順 */
LEGEND
① FROM user_events(10行)
FROM user_eventsuser_events テーブルの10行を読み込みます。user1が4行、user2が4行、user3が2行のイベント系列を持ちます。各ユーザー内の時系列を LEAD() で処理します。| user_id | event_type | event_time |
|---|---|---|
| 1 | view | 10:00 |
| 1 | click | 10:05 |
| 1 | view | 10:10 |
| 1 | purchase | 10:30 |
| 2 | view | 09:00 |
| 2 | click | 09:10 |
| 2 | view | 09:20 |
| 2 | click | 09:35 |
| 3 | view | 14:00 |
| 3 | purchase | 14:30 |
LEAD(event_type, 2) とすれば「2つ後のイベント」が取得できます。第3引数はデフォルト値で LEAD(event_type, 1, 'end') とすれば末尾行の NULL を 'end' に置換できます。遷移分析では通常 IS NOT NULL で除外する方が意味が明確ですが、末尾状態を明示したいケースでは便利です。transition_count * 1.0 / SUM(transition_count) OVER (PARTITION BY from_event) とすることで、「view の後に click が来る確率」などの条件付き確率が算出できます。LEAD(event_type) OVER (ORDER BY event_time) と書くと、user1 の最後のイベントの「次」が user2 の最初のイベントとして参照されます。存在しない遷移がデータに混入し、分析が歪みます。DAU / MAU の算出 — DATE_TRUNC で日・月単位のアクティブユーザー指標を集計する
イベントモデリングの最重要指標が DAU(Daily Active Users)と MAU(Monthly Active Users)です。DATE_TRUNC で event_time の粒度を「日」や「月」に丸め、COUNT(DISTINCT user_id) でユニークユーザー数を算出します。
| 指標 | DATE_TRUNC の粒度 | 意味 |
|---|---|---|
| DAU | 'day' | その日に1回以上イベントを発生させたユニークユーザー数 |
| WAU | 'week' | その週に1回以上イベントを発生させたユニークユーザー数 |
| MAU | 'month' | その月に1回以上イベントを発生させたユニークユーザー数 |
SELECT DATE_TRUNC('day', ts_col) AS bucket, -- 粒度は 'day' / 'week' / 'month' COUNT(DISTINCT id_col) AS uniq_cnt -- 重複を除いたユニーク数 FROM table_name GROUP BY DATE_TRUNC('day', ts_col) ORDER BY bucket;
user_events テーブルから、① 日付ごとの DAU(日次アクティブユーザー数) と ② 月ごとの MAU(月次アクティブユーザー数) をそれぞれ算出してください。①は activity_date, dau、②は activity_month, mau を activity_date / activity_month 昇順で返してください。
| user_id | event_time |
|---|---|
| 1 | 2024-01-10 10:00 |
| 1 | 2024-01-10 14:00 |
| 2 | 2024-01-10 11:00 |
| 3 | 2024-01-11 09:00 |
| 1 | 2024-01-11 15:00 |
| 2 | 2024-01-12 10:00 |
| 4 | 2024-01-12 16:00 |
| 5 | 2024-02-01 09:00 |
| 1 | 2024-02-01 11:00 |
| 3 | 2024-02-02 10:00 |
期待する出力①:
| activity_date | dau |
|---|---|
| 2024-01-10 | 2 |
| 2024-01-11 | 2 |
| 2024-01-12 | 2 |
| 2024-02-01 | 2 |
| 2024-02-02 | 1 |
期待する出力②:
| activity_month | mau |
|---|---|
| 2024-01-01 | 4 |
| 2024-02-01 | 3 |
-- ① DAU: event_time を日単位に丸めてユニークユーザーを集計 SELECT DATE_TRUNC('day', event_time)::date AS activity_date, COUNT(DISTINCT user_id) AS dau FROM user_events GROUP BY activity_date ORDER BY activity_date; -- ② MAU: event_time を月単位に丸めてユニークユーザーを集計 SELECT DATE_TRUNC('month', event_time)::date AS activity_month, COUNT(DISTINCT user_id) AS mau FROM user_events GROUP BY activity_month ORDER BY activity_month; /* 実行順序(DAU クエリ): 1. FROM user_events → 行を読込 2. DATE_TRUNC('day', ...) → event_time を日に丸める 3. GROUP BY activity_date → 日付でグループ化 4. COUNT(DISTINCT user_id) → 日内のユニークユーザー数 5. ORDER BY activity_date → 日付昇順 実行順序(MAU クエリ): 1. FROM user_events → 行を読込 2. DATE_TRUNC('month', ...) → event_time を月に丸める 3. GROUP BY activity_month → 月でグループ化 4. COUNT(DISTINCT user_id) → 月内のユニークユーザー数 5. ORDER BY activity_month → 月昇順 */
LEGEND
① FROM user_events(10行)
FROM user_eventsuser_events テーブルの10行を読み込みます。user1 が同日(2024-01-10)に2回アクセスしていることに注目。COUNT DISTINCT がこの重複を除去します。| user_id | event_time |
|---|---|
| 1 | 2024-01-10 10:00 |
| 1 | 2024-01-10 14:00 |
| 2 | 2024-01-10 11:00 |
| 3 | 2024-01-11 09:00 |
| 1 | 2024-01-11 15:00 |
| 2 | 2024-01-12 10:00 |
| 4 | 2024-01-12 16:00 |
| 5 | 2024-02-01 09:00 |
| 1 | 2024-02-01 11:00 |
| 3 | 2024-02-02 10:00 |
LEGEND
① FROM user_events(10行)
FROM user_events同じ user_events テーブルを使いますが、今度は月単位に丸めて月間アクティブユーザー数(MAU)を計算します。1月に複数日アクティブなユーザーも月内では1人として数えます。| user_id | event_time |
|---|---|
| 1 | 2024-01-10 10:00 |
| 1 | 2024-01-10 14:00 |
| 2 | 2024-01-10 11:00 |
| 3 | 2024-01-11 09:00 |
| 1 | 2024-01-11 15:00 |
| 2 | 2024-01-12 10:00 |
| 4 | 2024-01-12 16:00 |
| 5 | 2024-02-01 09:00 |
| 1 | 2024-02-01 11:00 |
| 3 | 2024-02-02 10:00 |
::date で日付型にキャストすることで、出力が '2024-01-01 00:00:00' ではなく '2024-01-01' と表示されるなど、後続広告ツールや BI ツールとの相性が良くなります。COUNT(*) はイベントの「発生件数」です。1人のユーザーが10回イベントを発火した場合、COUNT(*)=10 になります。DAU/MAU は必ず COUNT(DISTINCT user_id) を使ってください。