ファネル分析 — CASE WHEN + MAX でステップ到達フラグを立て、NULLIF でゼロ除算を防ぐ
イベントモデリングの最重要分析のひとつがファネル分析です。「view → click → purchase」のようなステップ到達数とその転換率を計算します。縦持ちのイベントテーブルから横持ちのフラグに変換するのが出発点です。
-- CASE WHEN で boolean フラグを作る(1=達成, 0=未達成) MAX(CASE WHEN event_type = 'view' THEN 1 ELSE 0 END) AS did_view -- NULLIF で除数ゼロのときに NULL を返す(ゼロ除算エラー回避) ROUND(num * 100.0 / NULLIF(denom, 0), 1)
MAX(CASE WHEN...THEN 1 ELSE 0 END) は「1回でもそのイベントがあれば 1、なければ 0」を返します。PostgreSQL 以外(BigQuery・MySQL・Redshift)でも動作する汎用パターンのため、実務では FILTER より使われる場面が多いです。後続の SUM() で「達成ユーザー数」が算出できます。user_events テーブルから、ファネル各ステップ(view → click → purchase)の到達ユーザー数と各ステップ間のコンバージョン率を計算してください。出力列は total_users, view_users, click_users, purchase_users, view_to_click_pct, click_to_purch_pct の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 |
| 2 | view | 2024-01-10 11:00 |
| 2 | click | 2024-01-10 11:10 |
| 3 | view | 2024-01-11 09:00 |
| 4 | view | 2024-01-11 14:00 |
| 4 | click | 2024-01-11 14:20 |
| 4 | purchase | 2024-01-11 14:50 |
| total_users | view_users | click_users | purchase_users | view_to_click_pct | click_to_purch_pct |
|---|---|---|---|---|---|
| 4 | 4 | 3 | 2 | 75.0 | 66.7 |
WITH funnel AS ( SELECT user_id, MAX(CASE WHEN event_type = 'view' THEN 1 ELSE 0 END) AS did_view, -- 1度でも view したら 1 MAX(CASE WHEN event_type = 'click' THEN 1 ELSE 0 END) AS did_click, MAX(CASE WHEN event_type = 'purchase' THEN 1 ELSE 0 END) AS did_purchase FROM user_events GROUP BY user_id ) SELECT COUNT(*) AS total_users, SUM(did_view) AS view_users, SUM(did_click) AS click_users, SUM(did_purchase) AS purchase_users, ROUND(SUM(did_click) * 100.0 / NULLIF(SUM(did_view), 0), 1) AS view_to_click_pct, -- view→click 通過率(%) ROUND(SUM(did_purchase) * 100.0 / NULLIF(SUM(did_click), 0), 1) AS click_to_purch_pct -- click→purchase 通過率(%) FROM funnel; /* 実行順序(SQLの論理的な評価順): 1. CTE funnel 2. 外側クエリ */
LEGEND
① FROM user_events(9行)
FROM user_eventsuser_events テーブルの9行を読み込みます。4ユーザーが view / click / purchase の各ステップに参加しています。ユーザーごとにどのステップに到達したかを集計するのが今回の目的です。| user_id | event_type |
|---|---|
| 1 | view |
| 1 | click |
| 1 | purchase |
| 2 | view |
| 2 | click |
| 3 | view |
| 4 | view |
| 4 | click |
| 4 | purchase |
MAX(...THEN 1 ELSE 0...) は 1 を返します。SUM と組み合わせることで「そのステップに1回でも到達したユーザー数」が正確に算出できます。COUNT(DISTINCT user_id) FILTER より GROUP BY 前の1パスで完結するため高速です。NULLIF(x, 0) は x = 0 のとき NULL を返します。NULL ÷ 何かは NULL(エラーにならない)。view_users が 0 のとき、その下流のコンバージョン率は定義不能なので NULL を返すのが正しい挙動です。0 を返すとコンバージョン率 0% と誤読されるリスクがあります。GROUP BY user_id が必要です。CASE WHEN event_type = 'view' THEN 1 END と書くと、条件不成立時に 0 ではなく NULL が返ります。SUM(NULL, NULL) は NULL を無視しますが COUNT に誤差が出ることがあるため、ELSE 0 は必ず明記してください。CASE WHEN signup_month = '2024-01' THEN 'Jan cohort' などのコホート軸を加えれば、コホートごとのファネル比較が可能になり、施策の効果測定に直結します。ファネル分析(厳格順序)— MIN() と CASE WHEN で初回イベント時刻を取得し順序を検証する
QUESTION 6の到達ベースファネルでは「順序」が考慮されませんでしたが、実務では「view → click → purchase」と順序通りに進んだユーザーのみを評価する厳格ファネル(Strict Funnel)が求められることがあります。
-- 各ステップの初回時刻を取得 MIN(CASE WHEN event_type = 'view' THEN event_time END) AS view_time -- 次のステップの時刻が前ステップ以降か判定 CASE WHEN view_time <= click_time THEN 1 ELSE 0 END
view_time <= click_time は、どちらかが NULL の場合は UNKNOWN となるため、未到達のユーザーは自動的に除外されるという便利な性質があります。user_events テーブルから、順序通りに各ステップ(view → click → purchase)を通過したユーザー数を集計してください。出力列は total_users, view_users, click_users, purchase_users の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 |
| 2 | click | 2024-01-10 11:00 |
| 2 | view | 2024-01-10 11:10 |
| 3 | view | 2024-01-11 09:00 |
| 4 | view | 2024-01-11 14:00 |
| 4 | purchase | 2024-01-11 14:10 |
| 4 | click | 2024-01-11 14:20 |
| 5 | view | 2024-01-12 10:00 |
| total_users | view_users | click_users | purchase_users |
|---|---|---|---|
| 5 | 5 | 2 | 1 |
WITH user_times AS ( SELECT user_id, MIN(CASE WHEN event_type = 'view' THEN event_time END) AS view_time, -- 最初に view した時刻 MIN(CASE WHEN event_type = 'click' THEN event_time END) AS click_time, MIN(CASE WHEN event_type = 'purchase' THEN event_time END) AS purchase_time FROM user_events GROUP BY user_id ) SELECT COUNT(*) AS total_users, SUM(CASE WHEN view_time IS NOT NULL THEN 1 ELSE 0 END) AS view_users, SUM(CASE WHEN view_time <= click_time THEN 1 ELSE 0 END) AS click_users, -- view→click の順序を満たす SUM(CASE WHEN view_time <= click_time AND click_time <= purchase_time THEN 1 ELSE 0 END) AS purchase_users -- view→click→purchase の順序 FROM user_times; /* 実行順序(SQLの論理的な評価順): 1. CTE user_times 2. 外側クエリ */
LEGEND
① FROM user_events(10行)
FROM user_events5ユーザーのイベント履歴です。user2やuser4はイベント順序が逆転している履歴を持っています。| user_id | event_type | event_time |
|---|---|---|
| 1 | view | 10:00 |
| 1 | click | 10:05 |
| 1 | purchase | 10:30 |
| 2 | click | 11:00 |
| 2 | view | 11:10 |
| 3 | view | 09:00 |
| 4 | view | 14:00 |
| 4 | purchase | 14:10 |
| 4 | click | 14:20 |
| 5 | view | 10:00 |
event_time を抽出することで、単なる到達だけでなく「いつ到達したか」を保持でき、順序の検証が可能になります。view_time <= click_time は、どちらかが NULL の場合は UNKNOWN となり FALSE 扱い(0が返る)となります。そのため、未到達ユーザーや一部のイベントが欠落しているユーザーは自動的に除外されるという便利な性質があります。LAG() を使って「直前のイベントが view だったか」を直接判定する方法や、MATCH_RECOGNIZE(Snowflake等でサポート)を使う方法が強力です。分析要件(初回到達で良いのか、連続した遷移のみをカウントするのか)に応じてクエリを使い分けることがデータエンジニアの腕の見せ所です。コホート分析の初歩 — 登録月コホートごとに初月30日間のリテンション率を計算する
コホート分析とは、同じ時期に登録したユーザーグループ(コホート)の行動を追跡する手法です。登録月別に「登録後30日以内にアクティブだったか」を集計することで、施策改善の効果が特定コホートに出ているかを判別できます。
-- ON 句に INTERVAL 条件を追加して期間を限定する LEFT JOIN user_events e ON c.user_id = e.user_id AND e.event_time >= c.signup_date AND e.event_time < c.signup_date + INTERVAL '30 days'
users と user_events テーブルから、登録月コホートごとに、コホートサイズ・登録後30日以内にイベントを発生させたアクティブユーザー数・リテンション率を計算してください。出力列は cohort_month, cohort_size, active_users, retention_pct、cohort_month 昇順で返してください。
| user_id | signup_date |
|---|---|
| 1 | 2024-01-05 |
| 2 | 2024-01-15 |
| 3 | 2024-02-01 |
| 4 | 2024-02-10 |
| 5 | 2024-02-20 |
| user_id | event_time |
|---|---|
| 1 | 2024-01-07 |
| 1 | 2024-01-20 |
| 2 | 2024-01-16 |
| 3 | 2024-02-03 |
| 4 | 2024-03-15 |
| cohort_month | cohort_size | active_users | retention_pct |
|---|---|---|---|
| 2024-01-01 | 2 | 2 | 100.0 |
| 2024-02-01 | 3 | 1 | 33.3 |
WITH cohorts AS ( -- コホート月を定義 SELECT user_id, signup_date, DATE_TRUNC('month', signup_date)::date AS cohort_month FROM users ), activity AS ( -- 30日以内のアクティビティ SELECT c.user_id, c.cohort_month, COUNT(e.event_time) AS event_count -- 0 = 非アクティブ FROM cohorts c LEFT JOIN user_events e ON c.user_id = e.user_id AND e.event_time >= c.signup_date AND e.event_time < c.signup_date + INTERVAL '30 days' -- ON 句に期間条件 GROUP BY c.user_id, c.cohort_month ) SELECT cohort_month, COUNT(*) AS cohort_size, COUNT(*) FILTER (WHERE event_count > 0) AS active_users, ROUND( COUNT(*) FILTER (WHERE event_count > 0) * 100.0 / NULLIF(COUNT(*), 0), 1) AS retention_pct FROM activity GROUP BY cohort_month ORDER BY cohort_month; /* 実行順序(SQLの論理的な評価順): 1. CTE cohorts 2. CTE activity 3. 外側クエリ */
LEGEND
① FROM users(5行)— コホート月を定義
DATE_TRUNC('month', signup_date) → cohort_monthusers テーブルを読み込み、DATE_TRUNC で登録月(cohort_month)を付与します。1月登録は user1・user2、2月登録は user3・user4・user5 の2コホートになります。| user_id | signup_date | cohort_month |
|---|---|---|
| 1 | 2024-01-05 | 2024-01-01 |
| 2 | 2024-01-15 | 2024-01-01 |
| 3 | 2024-02-01 | 2024-02-01 |
| 4 | 2024-02-10 | 2024-02-01 |
| 5 | 2024-02-20 | 2024-02-01 |
e.event_time < c.signup_date + INTERVAL '30 days' と書くと、結合後に NULL 行が除外され INNER JOIN と同義になります。「イベントがないユーザーも含めて分母に計上したい」場合は必ず ON 句に書いてください。コホートリテンションの計算では全コホートメンバーが分母に必要なため、この点は特に重要です。COUNT(NULL) は 0 を返します(COUNT(*) と異なり NULL を無視する)。これにより event_count = 0 がアクティビティなしの意味になり、FILTER (WHERE event_count > 0) で正確なアクティブユーザー数を数えられます。DATE_TRUNC('week', signup_date) や DATE_TRUNC('quarter', signup_date) に変更するだけでコホートの粒度が変わります。分析したい施策の影響期間に合わせて粒度を選択するのが実務の鉄則です。COUNT(*) を使うと、LEFT JOIN で NULL になった行も 1 としてカウントされます。event_count が 0 ではなく 1 になり、全ユーザーがアクティブと誤判定されます。NULL 列を安全にカウントするには COUNT(non_null_column) の形式を使ってください。ユーザーセグメント分類 — CASE WHEN でエンゲージメントレベルを判定し、セグメントごとの平均を算出する
イベントモデリングでは、ユーザーを行動頻度で分類するエンゲージメントセグメントが基本分析のひとつです。CTE でユーザーごとのイベント数を集計し、外側の CASE WHEN でセグメントに分類する2ステップ構成が定石です。
CASE WHEN purchase_count >= 10 THEN 'frequent' WHEN purchase_count >= 4 THEN 'regular' ELSE 'occasional' END -- WHEN 句は上から順に評価され、最初に真になった THEN が採用される
user_events テーブルから、ユーザーごとのイベント発生数を集計し、power(5回以上)・casual(2〜4回)・light(1回)の3セグメントに分類してください。出力列は segment, user_count, avg_events(avg_events は小数第1位まで)で、user_count の多い順・segment 名昇順で返してください。
| user_id | event_type |
|---|---|
| 1 | view |
| 1 | click |
| 1 | purchase |
| 1 | view |
| 1 | click |
| 2 | view |
| 2 | click |
| 2 | purchase |
| 3 | view |
| 3 | click |
| 4 | view |
| segment | user_count | avg_events |
|---|---|---|
| casual | 2 | 2.5 |
| light | 1 | 1.0 |
| power | 1 | 5.0 |
WITH event_counts AS ( -- ユーザーごとのイベント数 SELECT user_id, COUNT(*) AS event_count FROM user_events GROUP BY user_id ), segmented AS ( -- CASE WHEN でセグメント列を追加 SELECT user_id, event_count, CASE WHEN event_count >= 5 THEN 'power' -- 最初に評価(5以上) WHEN event_count >= 2 THEN 'casual' -- 次に評価(2以上かつ5未満) ELSE 'light' -- それ以外(1回) END AS segment FROM event_counts ) SELECT segment, COUNT(*) AS user_count, ROUND(AVG(event_count), 1) AS avg_events -- セグメント内の平均イベント数 FROM segmented GROUP BY segment ORDER BY user_count DESC, segment; -- user_count 降順・同順は segment 昇順 /* 実行順序(SQLの論理的な評価順): 1. CTE event_counts 2. CTE segmented 3. 外側クエリ */
LEGEND
① FROM user_events(11行)
FROM user_eventsuser_events テーブルの11行を読み込みます。user1 が5件と最多で power 候補、user2 が3件・user3 が2件(casual 候補)、user4 が1件(light 候補)です。| user_id | event_type |
|---|---|
| 1 | view |
| 1 | click |
| 1 | purchase |
| 1 | view |
| 1 | click |
| 2 | view |
| 2 | click |
| 2 | purchase |
| 3 | view |
| 3 | click |
| 4 | view |
WHEN event_count >= 2 THEN 'casual' は「2以上かつ5未満」を明示しなくても正確に動作します。境界値は「幅が広い範囲」→「狭い範囲」の順に並べるのが読みやすく誤りも少ないパターンです。ROUND(AVG(event_count), 1) で小数第1位まで丸めます。PostgreSQL では AVG の返り値は NUMERIC(可変精度)なので ROUND が適用できます。整数カラムの AVG は自動的に小数点を含む NUMERIC になるため、整数で割り切れなくても正確な平均が得られます。NTILE(3) OVER (ORDER BY event_count DESC) を使えば上位33%・中位33%・下位33%のようにデータ分布を等分割するセグメントが作れます。閾値を事前に決められない場合やユーザー数を均等にしたい場合に有効です。WHEN event_count >= 2 THEN 'casual' を先に書くと、event_count = 5 のユーザーも 'casual' に分類されてしまいます。CASE WHEN の評価順序を意識せずに書くと、意図しないセグメントが発生します。最も限定的な条件(>=5)を先に書いてください。休眠ユーザー検出 — LEFT JOIN + HAVING で最終アクティビティ日を判定し、一定期間非アクティブなユーザーを特定する
イベントモデリングでの重要課題のひとつが休眠ユーザー(Dormant User)検出です。「最終イベントが N 日以上前、または一度もイベントがないユーザー」を抽出するには、LEFT JOIN + GROUP BY + HAVING のパターンが基本です。
FROM users u LEFT JOIN user_events e ON u.user_id = e.user_id GROUP BY u.user_id, u.signup_date HAVING MAX(e.event_time) < '2024-01-25' -- 基準日より一定日数以上古い OR MAX(e.event_time) IS NULL -- 一度もイベントなし
users と user_events テーブルから、最終イベントが 2024-01-25 より前(21日以上前の基準日: 2024-02-15)、またはイベントが一度もないユーザーを、user_id, signup_date, last_event_time で抽出してください。last_event_time の NULLS FIRST 昇順、同順は user_id 昇順で返してください。
| user_id | signup_date |
|---|---|
| 1 | 2024-01-01 |
| 2 | 2024-01-05 |
| 3 | 2024-01-10 |
| 4 | 2024-01-20 |
| 5 | 2024-02-01 |
| user_id | event_time |
|---|---|
| 1 | 2024-01-10 10:00 |
| 1 | 2024-01-20 14:00 |
| 2 | 2024-01-10 09:00 |
| 3 | 2024-02-01 11:00 |
| 3 | 2024-02-10 15:00 |
| 4 | 2024-01-22 08:00 |
| user_id | signup_date | last_event_time |
|---|---|---|
| 5 | 2024-02-01 | NULL |
| 2 | 2024-01-05 | 2024-01-10 09:00 |
| 1 | 2024-01-01 | 2024-01-20 14:00 |
| 4 | 2024-01-20 | 2024-01-22 08:00 |
SELECT u.user_id, u.signup_date, MAX(e.event_time) AS last_event_time -- 最終イベント日時(イベントなしの場合 NULL) FROM users u LEFT JOIN user_events e ON u.user_id = e.user_id -- イベントなしユーザーも保持 GROUP BY u.user_id, u.signup_date HAVING MAX(e.event_time) < '2024-01-25'::date -- 最終イベントが21日以上前 OR MAX(e.event_time) IS NULL -- 一度もイベントなし ORDER BY last_event_time ASC NULLS FIRST, -- NULL(イベントなし)を先頭に u.user_id; /* 実行順序(SQLの論理的な評価順): 1. FROM users LEFT JOIN user_events → 全ユーザーを保持 2. GROUP BY u.user_id, u.signup_date → 集約し MAX(event_time) を取得 3. HAVING ... → 最終イベントが基準日前 or NULL を抽出 4. ORDER BY last_event_time ASC NULLS FIRST, u.user_id → 並び替えて出力 */
LEGEND
① FROM users(5行)
users起点となる users テーブルです。この5人のユーザー全員を対象に休眠判定を行います。| user_id | signup_date |
|---|---|
| 1 | 2024-01-01 |
| 2 | 2024-01-05 |
| 3 | 2024-01-10 |
| 4 | 2024-01-20 |
| 5 | 2024-02-01 |
NULLS FIRST の指定は休眠ユーザー向けメール配信の優先度付けに直結します。MAX(e.event_time) < '2024-01-25' のみでは NULL 行(イベントなしユーザー)が HAVING を通過しません。OR MAX(e.event_time) IS NULL を併記することでイベントなしユーザーを正しく捕捉できます。WHERE MAX(e.event_time) < '2024-01-25' と書くと SQL 構文エラーになります。集計関数は WHERE 段階ではまだ計算されておらず、参照不可能です。集計結果に条件をかけるには必ず HAVING を使ってください。HAVING MAX(e.event_time) < '2024-01-25' だけだと、NULL(イベントなしユーザー)は UNKNOWN として判定され、HAVING を通過しません。履歴がない想定リスクの高いユーザーが漏れるため、OR IS NULL は必須です。