コホート分析 — DATE_TRUNC('month') で登録月コホートを定義し初月アクティビティ率を算出する
コホート分析は、同じ時期に獲得したユーザー群(コホート)の行動を追跡する手法です。まず DATE_TRUNC('month', registered_at) で登録日を月初日に丸め、同じ登録月のユーザーを1つのコホートとして扱います。
DATE_TRUNC('month', '2023-07-15'::date) -- → 2023-07-01 DATE_TRUNC('month', '2023-08-22'::date) -- → 2023-08-01
LEFT JOIN の ON 句で、行動月と登録月を同じ粒度に丸めて比較すると、「登録月と同じ月の行動のみ」を結合できます。マッチしない行は右側の識別子が NULL になるため、右側の識別子を DISTINCT COUNT して初月の活動人数を取得します。users と login_events テーブルから、登録月コホートごとの初月アクティビティ率を算出してください。取得列は cohort_month, cohort_size, active_in_first_month, first_month_active_pct(小数第1位)、cohort_month 昇順で返してください。
| user_id | registered_at |
|---|---|
| 1 | 2024-01-10 |
| 2 | 2024-01-15 |
| 3 | 2024-01-22 |
| 4 | 2024-02-05 |
| 5 | 2024-02-14 |
| 6 | 2024-02-20 |
| user_id | event_date |
|---|---|
| 1 | 2024-01-12 |
| 2 | 2024-01-18 |
| 3 | 2024-02-03 |
| 4 | 2024-02-07 |
| 5 | 2024-03-05 |
| 6 | 2024-02-22 |
| cohort_month | cohort_size | active_in_first_month | first_month_active_pct |
|---|---|---|---|
| 2024-01-01 | 3 | 2 | 66.7 |
| 2024-02-01 | 3 | 2 | 66.7 |
SELECT DATE_TRUNC('month', u.registered_at)::date AS cohort_month, COUNT(DISTINCT u.user_id) AS cohort_size, COUNT(DISTINCT e.user_id) AS active_in_first_month, -- NULL は COUNT されない ROUND( COUNT(DISTINCT e.user_id) * 100.0 / COUNT(DISTINCT u.user_id), 1 ) AS first_month_active_pct FROM users u LEFT JOIN login_events e ON e.user_id = u.user_id AND DATE_TRUNC('month', e.event_date) -- イベントが登録月と同月か判定 = DATE_TRUNC('month', u.registered_at) GROUP BY cohort_month ORDER BY cohort_month; /* 実行順序(SQLの論理的な評価順): 1. FROM users u → 行を読み込む 2. LEFT JOIN login_events → 結合(左表を全行保持) 3. GROUP BY registered_at → グループ化 4. 集計関数を評価 → COUNT(DISTINCT ...) 5. SELECT → 値を整形 6. ORDER BY cohort_month → 並び替えて出力 */
LEGEND
① FROM users + login_events(2テーブル)
FROM users u / login_events eusers(6行)と login_events(6行)の2テーブルを読み込みます。LEFT JOIN のため users が基準となり、マッチしない login_events 行は除外されます。| user_id | registered_at |
|---|---|
| 1 | 2024-01-10 |
| 2 | 2024-01-15 |
| 3 | 2024-01-22 |
| 4 | 2024-02-05 |
| 5 | 2024-02-14 |
| 6 | 2024-02-20 |
| user_id | event_date |
|---|---|
| 1 | 2024-01-12 |
| 2 | 2024-01-18 |
| 3 | 2024-02-03 |
| 4 | 2024-02-07 |
| 5 | 2024-03-05 |
| 6 | 2024-02-22 |
COUNT(DISTINCT e.user_id) は e.user_id が NULL の行を自動でスキップします。これにより「初月に一度もログインしなかったユーザー」を分母に含め、分子には含めないという正しい計算が実現します。DATE_TRUNC('month', col) は月初日(timestamp 型)を返します。MySQL の DATE_FORMAT(col, '%Y-%m-01') や BigQuery の DATE_TRUNC(col, MONTH) と概念は同じですが、構文が異なります。コホート分析ではどのDBでも「月単位への丸め」が必須です。LEFT JOIN login_events e ON e.user_id = u.user_id WHERE DATE_TRUNC(...) と書くと、NULL 行が WHERE で除去されます。初月ログインなしユーザーがカウントから消え、コホートサイズが意図せず縮小します。リテンション分析 — CTE + INTERVAL '1 month' で翌月リテンション率を算出する
リテンション分析は、登録月のユーザーが翌月以降も継続して利用しているかを測定します。コホート月の翌月にアクティブだったユーザー数 ÷ コホートサイズ が Month 1 リテンション率です。
| 指標 | 計算式 | 実務での目安(SaaS) |
|---|---|---|
| Month 1 Retention | 翌月アクティブ数 / コホートサイズ | 40〜60% が優良 |
| Month 3 Retention | 3ヶ月後アクティブ数 / コホートサイズ | 20〜40% が優良 |
SELECT DATE_TRUNC('month', ts_col) AS month_start, -- 月初へ丸める ts_col + INTERVAL '1 month' AS next_month, -- 月を加算する ts_col - INTERVAL '7 days' AS week_ago -- 日を減算する FROM table_name;
cohort_month + INTERVAL '1 month' は PostgreSQL で月を加算する標準記法です。DATE_TRUNC('month', ...) が返す date 型に加算できます。BigQuery では DATE_ADD(cohort_month, INTERVAL 1 MONTH) と書きます。users・login_events テーブルから、登録月コホートごとの翌月リテンション率を算出してください。取得列は cohort_month, cohort_size, retained_month1, retention_rate_pct(小数第1位)、cohort_month 昇順で返してください。
| user_id | registered_at |
|---|---|
| 1 | 2024-01-10 |
| 2 | 2024-01-15 |
| 3 | 2024-01-22 |
| 4 | 2024-02-05 |
| 5 | 2024-02-14 |
| 6 | 2024-02-20 |
| user_id | event_date |
|---|---|
| 1 | 2024-01-12 |
| 2 | 2024-01-18 |
| 3 | 2024-01-25 |
| 1 | 2024-02-05 |
| 3 | 2024-02-10 |
| 4 | 2024-02-07 |
| 4 | 2024-03-02 |
| 6 | 2024-02-22 |
| cohort_month | cohort_size | retained_month1 | retention_rate_pct |
|---|---|---|---|
| 2024-01-01 | 3 | 2 | 66.7 |
| 2024-02-01 | 3 | 1 | 33.3 |
WITH cohorts AS ( -- 各ユーザーの登録月を月初日に丸めてコホートを定義 SELECT user_id, DATE_TRUNC('month', registered_at)::date AS cohort_month FROM users ), activity AS ( -- ユーザーごとにアクティブだった月の一覧を重複排除して取得 SELECT DISTINCT user_id, DATE_TRUNC('month', event_date)::date AS active_month FROM login_events ) SELECT c.cohort_month, COUNT(DISTINCT c.user_id) AS cohort_size, COUNT(DISTINCT a.user_id) AS retained_month1, ROUND( COUNT(DISTINCT a.user_id) * 100.0 / COUNT(DISTINCT c.user_id), 1 ) AS retention_rate_pct FROM cohorts c LEFT JOIN activity a ON a.user_id = c.user_id AND a.active_month = c.cohort_month + INTERVAL '1 month' -- 翌月のみ結合 GROUP BY c.cohort_month ORDER BY c.cohort_month; /* 実行順序(SQLの論理的な評価順): 1. CTE cohorts → CTE を定義 2. CTE activity → CTE を定義 3. FROM cohorts c LEFT JOIN activity a → 結合(左表を全行保持) 4. GROUP BY cohort_month → グループ化 5. 集計関数を評価 → COUNT(DISTINCT ...) 6. SELECT → 値を整形 */
LEGEND
① CTE cohorts — 登録月を月初日に丸める
DATE_TRUNC('month', registered_at)::date AS cohort_monthusers テーブルの registered_at を DATE_TRUNC('month', ...) で月初日に丸めます。同月登録者が同一コホートとしてグループ化される準備です。| user_id | registered_at | ▸ cohort_month |
|---|---|---|
| 1 | 2024-01-10 | 2024-01-01 |
| 2 | 2024-01-15 | 2024-01-01 |
| 3 | 2024-01-22 | 2024-01-01 |
| 4 | 2024-02-05 | 2024-02-01 |
| 5 | 2024-02-14 | 2024-02-01 |
| 6 | 2024-02-20 | 2024-02-01 |
cohort_month + INTERVAL '1 month' でリテンション期間を柔軟に変えられます。INTERVAL '2 month' で Month 2 リテンション、INTERVAL '3 month' で Month 3 リテンションと、数値を変えるだけで任意の期間のリテンションを算出できます。UNNEST(ARRAY[1,2,3]) AS offset と組み合わせ、a.active_month = cohort_month + (offset || ' month')::INTERVAL とすることで全期間分のリテンションを一クエリで生成できます。WHERE a.active_month = cohort_month + INTERVAL '1 month' と書くと LEFT JOIN が INNER JOIN 化し、翌月に未ログインのユーザーが消えます。これにより cohort_size が翌月アクティブ数と等しくなり、リテンション率が常に100%になるバグが発生します。a.active_month >= cohort_month + INTERVAL '1 month' にすると、翌々月以降のアクティビティも結合されユーザーが重複集計されます。Month 1 リテンションには = ではなく = で厳密に翌月のみを指定してください。ファネル分析 — FILTER(WHERE ...) でステップ別コンバージョン率を算出する
ファネル分析は、ユーザーが「閲覧→登録→購入」などの複数ステップを経てゴールに到達する過程を定量化します。PostgreSQL の FILTER (WHERE ...) 句を使うと、CASE WHEN より簡潔にステップ別の件数を集計できます。
-- FILTER 句(推奨) COUNT(DISTINCT user_id) FILTER (WHERE event_type = 'signup') -- CASE WHEN(同等だが冗長) COUNT(DISTINCT CASE WHEN event_type = 'signup' THEN user_id END)
funnel_events テーブルから、4ステップのファネル(ページ閲覧→会員登録→カート追加→購入完了)の各ステップ到達ユーザー数と転換率を算出してください。CTE で各ステップのカウントを集約し、外側クエリで CVR を計算してください。出力列は page_view, signup, add_cart, purchase, view_to_signup_pct, signup_to_cart_pct, cart_to_purchase_pct(各%は小数第1位)。
| user_id | event_type | event_date |
|---|---|---|
| 1 | page_view | 2024-01-10 |
| 2 | page_view | 2024-01-10 |
| 3 | page_view | 2024-01-10 |
| 4 | page_view | 2024-01-11 |
| 5 | page_view | 2024-01-11 |
| 1 | signup | 2024-01-10 |
| 2 | signup | 2024-01-11 |
| 3 | signup | 2024-01-12 |
| 4 | signup | 2024-01-13 |
| 2 | add_cart | 2024-01-12 |
| 3 | add_cart | 2024-01-13 |
| 4 | add_cart | 2024-01-13 |
| 3 | purchase | 2024-01-15 |
| 4 | purchase | 2024-01-16 |
| page_view | signup | add_cart | purchase | view_to_signup_pct | signup_to_cart_pct | cart_to_purchase_pct |
|---|---|---|---|---|---|---|
| 5 | 4 | 3 | 2 | 80.0 | 75.0 | 66.7 |
WITH counts AS ( -- 各ステップの到達ユーザーを1行に集約 SELECT COUNT(DISTINCT user_id) FILTER (WHERE event_type = 'page_view') AS s1, COUNT(DISTINCT user_id) FILTER (WHERE event_type = 'signup') AS s2, COUNT(DISTINCT user_id) FILTER (WHERE event_type = 'add_cart') AS s3, COUNT(DISTINCT user_id) FILTER (WHERE event_type = 'purchase') AS s4 FROM funnel_events ) SELECT s1 AS page_view, s2 AS signup, s3 AS add_cart, s4 AS purchase, ROUND(s2 * 100.0 / s1, 1) AS view_to_signup_pct, -- step1→2 の転換率 ROUND(s3 * 100.0 / s2, 1) AS signup_to_cart_pct, -- step2→3 の転換率 ROUND(s4 * 100.0 / s3, 1) AS cart_to_purchase_pct -- step3→4 の転換率 FROM counts; /* 実行順序(SQLの論理的な評価順): 1. CTE counts → CTE を定義 2. 集計関数を評価 → COUNT(DISTINCT ...) FILTER で集約 3. 外側クエリを評価 → 値を整形して列を選択 */
LEGEND
① FROM funnel_events(14行)
FROM funnel_eventsfunnel_events テーブルの14行を読み込みます。4種類の event_type がファネルの各ステップに対応します。この時点では全行が対象です。| user_id | event_type | event_date |
|---|---|---|
| 1 | page_view | 2024-01-10 |
| 2 | page_view | 2024-01-10 |
| 3 | page_view | 2024-01-10 |
| 4 | page_view | 2024-01-11 |
| 5 | page_view | 2024-01-11 |
| 1 | signup | 2024-01-10 |
| 2 | signup | 2024-01-11 |
| 3 | signup | 2024-01-12 |
| 4 | signup | 2024-01-13 |
| 2 | add_cart | 2024-01-12 |
| 3 | add_cart | 2024-01-13 |
| 4 | add_cart | 2024-01-13 |
| 3 | purchase | 2024-01-15 |
| 4 | purchase | 2024-01-16 |
CASE WHEN ... END より可読性が高く、クエリオプティマイザが個別に最適化しやすい形です。BigQuery や DuckDB でも同じ構文が使えます。MySQL(8.0未満)では使えず CASE WHEN が必要です。ROUND(s4 * 100.0 / s1, 1) です。step CVR は「どのステップで脱落が多いか」の特定に、overall CVR は「広告→購入の総合効率」の評価に使います。COUNT(*) FILTER (WHERE event_type = 'signup') は同一ユーザーが複数回 signup イベントを持つ場合に重複カウントします。COUNT(DISTINCT user_id) を使うことで、ユニークユーザー単位のファネルになります。NULLIF(s1, 0) でガードするか、CASE WHEN s1 = 0 THEN NULL ELSE ROUND(...) END と書く習慣をつけてください。アクティブユーザー分析 — DAU/MAU でスティッキネス(粘着性)を算出する
スティッキネス(Stickiness)は DAU(日次アクティブユーザー)の平均 ÷ MAU(月次アクティブユーザー)で算出され、ユーザーがどれだけ頻繁にサービスを使っているかを示す指標です。
| 指標 | 定義 | 算出方法 |
|---|---|---|
| DAU | 特定日にセッションがあったユニークユーザー数 | COUNT(DISTINCT user_id) per date |
| MAU | その月にセッションがあったユニークユーザー数 | COUNT(DISTINCT user_id) per month |
| Stickiness | DAU平均 ÷ MAU(高いほど習慣的に使われている) | AVG(DAU) / MAU × 100 |
WITH per_day AS ( -- ① 細かい粒度で集計する SELECT date_col, COUNT(DISTINCT id_col) AS daily_cnt FROM table_name GROUP BY date_col ) SELECT AVG(daily_cnt) AS avg_daily -- ② 集計結果をさらに集計する FROM per_day;
user_sessions テーブルから、月別の avg_dau・MAU・スティッキネスを算出してください。取得列は month, avg_dau, mau, stickiness_pct(各値は小数第1位)、month 昇順で返してください。
| user_id | session_date |
|---|---|
| 1 | 2024-01-01 |
| 2 | 2024-01-01 |
| 3 | 2024-01-01 |
| 1 | 2024-01-15 |
| 4 | 2024-01-15 |
| 2 | 2024-01-20 |
| 1 | 2024-02-01 |
| 2 | 2024-02-01 |
| 5 | 2024-02-10 |
| 1 | 2024-02-20 |
| month | avg_dau | mau | stickiness_pct |
|---|---|---|---|
| 2024-01-01 | 2.0 | 4 | 50.0 |
| 2024-02-01 | 1.3 | 3 | 44.4 |
WITH daily AS ( -- 日ごとの DAU(ユニークユーザー数)を集計 SELECT DATE_TRUNC('month', session_date)::date AS month, session_date, COUNT(DISTINCT user_id) AS dau FROM user_sessions GROUP BY month, session_date ), monthly AS ( -- 月ごとの MAU(月内でユニークなユーザー数)を集計 SELECT DATE_TRUNC('month', session_date)::date AS month, COUNT(DISTINCT user_id) AS mau FROM user_sessions GROUP BY month ) SELECT m.month, ROUND(AVG(d.dau), 1) AS avg_dau, m.mau, ROUND(AVG(d.dau) * 100.0 / m.mau, 1) AS stickiness_pct -- DAU平均/MAU FROM monthly m JOIN daily d USING (month) GROUP BY m.month, m.mau ORDER BY m.month; /* 実行順序(SQLの論理的な評価順): 1. CTE daily → CTE を定義 2. CTE monthly → CTE を定義 3. FROM monthly m JOIN daily d → 結合(一致行のみ) 4. GROUP BY m.month → グループ化 5. 集計関数を評価 → AVG(d.dau) 6. SELECT → 値を整形 7. ORDER BY m.month → 並び替えて出力 */
LEGEND
① CTE daily — 日ごとのDAUを集計
GROUP BY month, session_date → COUNT(DISTINCT user_id) AS dauuser_sessions を月・日付でグループ化し、各日のユニークユーザー数(DAU)を集計します。1月01日はuser1・2・3の3人、1月15日はuser1・4の2人、1月20日はuser2の1人です。| month | session_date | ▸ dau |
|---|---|---|
| 2024-01-01 | 2024-01-01 | 3 |
| 2024-01-01 | 2024-01-15 | 2 |
| 2024-01-01 | 2024-01-20 | 1 |
| 2024-02-01 | 2024-02-01 | 2 |
| 2024-02-01 | 2024-02-10 | 1 |
| 2024-02-01 | 2024-02-20 | 1 |
SELECT AVG(COUNT(DISTINCT user_id)) のような入れ子集計は PostgreSQL で直接は書けません。必ず CTE や サブクエリで先に DAU を行に展開してから AVG を適用する2段階構成が必要です。COUNT(*) はセッション数(回数)になり DAU(人数)ではありません。アクティブユーザー分析では必ず COUNT(DISTINCT user_id) を使ってください。チャーン分析 — LEFT JOIN IS NULL でひと月以上離脱したユーザーを特定する
チャーン(Churn)分析は「前月はアクティブだったが今月はアクティブでないユーザー」を特定します。SQLでは LEFT JOIN IS NULL パターン(アンチジョイン)が最も読みやすく効率的です。
| 手法 | 書き方 | 特徴 |
|---|---|---|
| LEFT JOIN IS NULL | LEFT JOIN ... WHERE b.key IS NULL | 最も可読性が高く最適化されやすい ★推奨 |
| NOT EXISTS | WHERE NOT EXISTS (SELECT 1 FROM ...) | 相関サブクエリ。大規模テーブルで有効 |
| NOT IN | WHERE user_id NOT IN (SELECT ...) | NULL を含む場合に全行除外バグあり ✗非推奨 |
SELECT a.id_col FROM table_a a LEFT JOIN table_b b ON b.id_col = a.id_col -- 結合条件 WHERE b.id_col IS NULL; -- マッチしなかった行だけが残る
WHERE 右テーブル.key IS NULL でこの「マッチしなかった行だけ」を抽出します。これは「前月アクティブ かつ 翌月未アクティブ」=チャーンユーザーを特定する定番パターンです。login_events テーブルから、2024年1月にアクティブだったユーザーのうち、2024年2月にログインしなかった(チャーンした)ユーザーを特定してください。取得列は user_id, active_month, is_churned、user_id 昇順で返してください。
| user_id | event_date |
|---|---|
| 1 | 2024-01-12 |
| 2 | 2024-01-18 |
| 3 | 2024-01-25 |
| 1 | 2024-02-05 |
| 3 | 2024-02-10 |
| 4 | 2024-02-08 |
| user_id | active_month | is_churned |
|---|---|---|
| 1 | 2024-01 | false |
| 2 | 2024-01 | true |
| 3 | 2024-01 | false |
WITH jan_active AS ( -- 2024年1月にアクティブだったユーザーを抽出(重複排除) SELECT DISTINCT user_id FROM login_events WHERE DATE_TRUNC('month', event_date) = '2024-01-01' ), feb_active AS ( -- 2024年2月にアクティブだったユーザーを抽出(重複排除) SELECT DISTINCT user_id FROM login_events WHERE DATE_TRUNC('month', event_date) = '2024-02-01' ) SELECT j.user_id, '2024-01' AS active_month, CASE WHEN f.user_id IS NULL THEN true ELSE false END AS is_churned -- NULL=翌月未ログイン=チャーン FROM jan_active j LEFT JOIN feb_active f USING (user_id) -- 2月にいないユーザーは f.user_id = NULL ORDER BY j.user_id; /* 実行順序(SQLの論理的な評価順): 1. CTE jan_active → CTE を定義 2. CTE feb_active → CTE を定義 3. FROM jan_active j LEFT JOIN feb_active → 結合(左表を全行保持) 4. SELECT → 列を評価(CASE で判定) 5. ORDER BY j.user_id → 並び替えて出力 */
LEGEND
① CTE jan_active — 1月アクティブユーザーを抽出
WHERE DATE_TRUNC('month', event_date) = '2024-01-01'login_events から 2024年1月のイベント行を WHERE で絞り込み、DISTINCT でユーザーを重複排除します。1月にログインした user1・2・3 の3人が抽出されます。| user_id | event_date | 1月フィルタ? |
|---|---|---|
| 1 | 2024-01-12 | ✓ → jan_active |
| 2 | 2024-01-18 | ✓ → jan_active |
| 3 | 2024-01-25 | ✓ → jan_active |
| 1 | 2024-02-05 | ✗ 対象外 |
| 3 | 2024-02-10 | ✗ 対象外 |
| 4 | 2024-02-08 | ✗ 対象外 |
WHERE user_id NOT IN (SELECT user_id FROM feb_active) と書くと、feb_active の user_id に NULL が1行でも含まれると WHERE 全体が false になり0行が返るバグが発生します。NOT IN より LEFT JOIN IS NULL か NOT EXISTS を使うのが安全です。ROUND(COUNT(*) FILTER (WHERE is_churned) * 100.0 / COUNT(*), 1) とすれば「1月→2月のチャーン率」を1つの数値で算出できます。今回のケースでは 1/3 ≈ 33.3% がチャーン率です。WHERE user_id NOT IN (SELECT user_id FROM feb_active WHERE ...) のサブクエリが NULL を返すと、NOT IN は user_id != NULL を評価し全行が UNKNOWN になります。結果として0行が返るという致命的なバグです。必ず LEFT JOIN IS NULL か NOT EXISTS を使ってください。LEAD(active_month) OVER (PARTITION BY user_id ORDER BY active_month) で「次のアクティブ月が翌月ではない行」を一般化すると、任意の期間・任意のコホートのチャーンを動的に検出できます。