SQL KPI分析 — 移動平均・構成比・チャーン率の基礎

基礎移動平均構成比・パレートランキングHAVING・FILTERチャーン率(アンチ結合)PostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 6

DAU 移動平均 — AVG() OVER + ROWS BETWEEN でトレンドのノイズを平滑化する

AVG OVERROWS BETWEEN移動平均ウィンドウフレーム
前提知識

DAU や売上などの日次 KPI は曜日・キャンペーン・偶発的なスパイクで激しく上下します。移動平均(Moving Average)はこのノイズを平滑化し、本質的なトレンドを浮き彫りにする定番手法です。ウィンドウ関数にフレーム句(ROWS BETWEEN ...)を付けることで、「現在行を含む直近N行」だけを集計範囲に指定できます。

AVG(dau) OVER (
  ORDER BY event_date
  ROWS BETWEEN 2 PRECEDING AND CURRENT ROW  -- 当日+直前2日=3日窓
)
-- フレームを書かないと既定は RANGE UNBOUNDED PRECEDING ... CURRENT ROW
-- =先頭からの「累計平均」になってしまい移動平均にならない(重要)
ROWS と RANGE の違い:ROWS物理的な行数で窓を区切ります(直近3行)。RANGEORDER BY 値が同じ行をまとめて扱います。移動平均では行数で区切りたいので必ず ROWS を使ってください。
問題

daily_active テーブルから、各日の DAU と「当日を含む直近3日の移動平均(dau_ma3)」を計算してください。出力列は event_date, dau, dau_ma3、event_date 昇順、移動平均は小数第2位まで丸めてください。

使用テーブル
► daily_active(7行)
event_datedau
2024-03-01100
2024-03-02120
2024-03-03110
2024-03-0490
2024-03-05150
2024-03-06140
2024-03-07160
期待出力
event_datedaudau_ma3
2024-03-01100100.00
2024-03-02120110.00
2024-03-03110110.00
2024-03-0490106.67
2024-03-05150116.67
2024-03-06140126.67
2024-03-07160150.00
模範解答コード
SELECT
  event_date,
  dau,
  ROUND(
    AVG(dau) OVER (
      ORDER BY event_date
      ROWS BETWEEN 2 PRECEDING AND CURRENT ROW  -- 当日含む直近3日の窓
    ), 2
  ) AS dau_ma3
FROM  daily_active
ORDER BY event_date;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM daily_active             → 行を読込
  2. ORDER BY event_date(ウィンドウ内)   → フレームの順序を確定
  3. AVG(dau) OVER (... ROWS ...)  → 当日+直前2日を平均
  4. ROUND(..., 2)                 → 小数第2位で丸め
  5. SELECT / ORDER BY event_date  → 日付昇順で出力
  */
解説(テーブル変化・ポイント)
SELECT event_date, dau, ROUND( AVG(dau) OVER ( ORDER BY event_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ), 2 ) AS dau_ma3 FROM daily_active ORDER BY event_date;
LEGEND
データ取得・読込対象
① FROM daily_active(7行)
FROM daily_active日次のDAUを全件読み込みます。03-04 が落ち込み、03-05〜07 が伸びるなど日々の変動が大きく、生の数値ではトレンドが読みにくい状態です。
1 / 4
event_datedau
03-01100
03-02120
03-03110
03-0490
03-05150
03-06140
03-07160
7行読込
学習ポイント
フレーム句が「移動平均」と「累計平均」を分ける:ORDER BY 付きウィンドウでフレームを省略すると、既定は RANGE UNBOUNDED PRECEDING=先頭からの累計平均です。直近N行に限定したいなら 必ず ROWS BETWEEN n PRECEDING AND CURRENT ROW を明示します。
先頭付近は「部分窓」になる:03-01 は前に行がないので1行平均、03-02 は2行平均です。これは仕様通りの挙動で、7日移動平均でも最初の6日は行数が不足します。厳密に7日揃った点だけ使いたい場合は COUNT(*) OVER (...) = 7 でフィルタします。
2 を 6 に変えるだけで7日移動平均:ROWS BETWEEN 6 PRECEDING AND CURRENT ROW で7日移動平均、3 PRECEDING AND 3 FOLLOWING で前後3日の中心化移動平均になります。フレームの両端を変えるだけで様々な平滑化が表現できます。
アンチパターン
フレームを書かずに移動平均のつもりで累計平均を出す:これは最頻出のバグです。AVG(dau) OVER (ORDER BY event_date) だけだと先頭からの累計平均になり、日が進むほど鈍化します。移動平均には ROWS フレームが必須です。
自己結合で N日分を手書き集計する:JOIN ... ON b.date BETWEEN a.date-2 AND a.date のような自己結合は冗長で、欠損日があると窓がずれます。ウィンドウフレームを使えば1行で正確かつ高速です。
実務コラム:曜日効果と移動平均の窓幅
B2C サービスの DAU は週末に伸びる「曜日効果」を持つことが多く、3日移動平均では曜日の波が残ります。7日移動平均は1週間を丸ごと平均するため曜日効果を相殺でき、週次トレンドの監視に最適です。窓が短いほど反応は速いがノイズが多く、長いほど滑らかだが変化の検知が遅れる——この感度のトレードオフを理解して窓幅を選ぶのが分析者の腕の見せどころです。
QUESTION 7

売上構成比とパレート分析 — SUM() OVER () と累計でABC分析を実装する

SUM OVER ()累計構成比パレート/ABC
前提知識

「どのカテゴリが売上の何%を占めるか(構成比)」「上位から積み上げて何%に達するか(累計構成比)」は、重点商材を見極めるパレート分析・ABC分析の核です。ウィンドウ関数を使うと、明細行を残したまま「全体合計」や「累計」を同じ行に並べられます。

SUM(sales) OVER ()                       -- 空のOVER=全行を1つの窓。各行に総合計を付与
SUM(sales) OVER (ORDER BY sales DESC)   -- 並び順に沿った累計(ランニング合計)
GROUP BY との決定的な違い:GROUP BY は行を畳み込んで消してしまいます。ウィンドウ関数は明細行を保持したまま集計値を横に添えられるため、「各行の値 ÷ 全体合計=構成比」のような明細と全体を同時に扱う計算が1クエリで完結します。
問題

category_sales テーブル(カテゴリ別の集計済み売上)から、構成比(sales_pct)と売上降順での累計構成比(cum_pct)を計算してください。出力列は category, sales, sales_pct, cum_pct、sales 降順、比率は小数第2位まで丸めてください。

使用テーブル
► category_sales(5行)
categorysales
Electronics5000
Apparel3000
Home1500
Books800
Toys700
期待出力
categorysalessales_pctcum_pct
Electronics500045.4545.45
Apparel300027.2772.73
Home150013.6486.36
Books8007.2793.64
Toys7006.36100.00
模範解答コード
SELECT
  category,
  sales,
  ROUND(sales * 100.0 / SUM(sales) OVER (), 2) AS sales_pct,  -- 構成比 = 各行 ÷ 全体合計
  ROUND(
    SUM(sales) OVER (ORDER BY sales DESC) * 100.0      -- 累計(ランニング合計)
    / SUM(sales) OVER (), 2
  ) AS cum_pct                                              -- 累計構成比
FROM category_sales
ORDER BY sales DESC;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM category_sales                    → 行を読込
  2. SUM(sales) OVER ()                     → 総合計を全行に付与
  3. SUM(sales) OVER (ORDER BY sales DESC)  → 売上降順での累計を計算
  4. SELECT で比率を計算                          → 構成比を算出
  5. ORDER BY sales DESC                    → 売上降順で出力
  */
解説(テーブル変化・ポイント)
SELECT category, sales, ROUND(sales * 100.0 / SUM(sales) OVER () , 2) AS sales_pct, ROUND( SUM(sales) OVER (ORDER BY sales DESC) * 100.0 / SUM(sales) OVER () , 2) AS cum_pct FROM category_sales ORDER BY sales DESC;
LEGEND
データ取得・読込対象
① FROM category_sales(5行)
FROM category_salesカテゴリ別の集計済み売上を読み込みます。すでに sales 降順に近い形ですが、ウィンドウ関数の ORDER BY が並びを保証します。
1 / 4
categorysales
Electronics5000
Apparel3000
Home1500
Books800
Toys700
5行読込
学習ポイント
空の OVER () は「全行が1つの窓」:SUM(sales) OVER () は GROUP BY と違い行を畳まず、各明細行に総合計を添えます。これにより 明細 ÷ 全体 の構成比が同じ行で計算できます。サブクエリで総合計を取り直すより簡潔です。
ORDER BY を付けた SUM は「累計」になる:SUM(sales) OVER (ORDER BY sales DESC) は既定フレーム(先頭〜現在行)で動くため累計合計を返します。累計 ÷ 全体 が累計構成比、すなわちパレート曲線の y 値です。
* 100.0 で整数除算を回避:sales が integer のままだと 3000/11000 が 0 に切り捨てられます。* 100.0(または ::numeric)で浮動小数点に昇格させてから ROUND します。
アンチパターン
同順位(タイ)で累計がずれる:既定の RANGE フレームは ORDER BY 値が同じ行をまとめて合算するため、売上が同額の行があると累計が一段飛びます。1行ずつ厳密に積みたいときは ORDER BY sales DESC ROWS UNBOUNDED PRECEDING のように ROWS を使い、タイブレーク列も足します。
構成比を出すために GROUP BY で畳んでしまう:GROUP BY すると明細行が消え、各行の構成比を横に並べられません。明細を保持したまま全体と比較するのはウィンドウ関数の役割です。
実務コラム:パレートの法則とABC分析
「上位2割の商品が売上の8割を生む」——パレートの法則(80/20)は在庫・棚割・営業リソース配分の指針です。累計構成比で 〜70%をA群、〜90%をB群、残りをC群 と区切るのがABC分析の定石。A群は欠品を絶対に避け、C群は思い切って整理する、といった意思決定が累計構成比一発で導けます。BI ツールでは sales 降順の棒グラフに cum_pct の折れ線を重ねた「パレート図」が定番ビジュアルです。
QUESTION 8

カテゴリ別ランキング — RANK / DENSE_RANK / ROW_NUMBER と PARTITION BY

RANKPARTITION BYランキングTop-NDENSE_RANK
前提知識

「カテゴリごとの売れ筋Top3」「部門内の順位」といったグループ内ランキングは PARTITION BY で実装します。順位付け関数は3種あり、同順位(タイ)の扱いが異なるのが最重要ポイントです。

RANK()       OVER (PARTITION BY category ORDER BY sales DESC)  -- 同順位あり・次を飛ばす  1,1,3
DENSE_RANK() OVER (PARTITION BY category ORDER BY sales DESC)  -- 同順位あり・飛ばさない  1,1,2
ROW_NUMBER() OVER (PARTITION BY category ORDER BY sales DESC)  -- 強制的に連番      1,2,3
PARTITION BY は「窓のGROUP BY」:GROUP BY が行を畳むのに対し、PARTITION BY行を残したままグループ(パーティション)ごとに順位をリセットします。各パーティションの先頭から 1 が振り直されます。
問題

product_sales テーブルから、カテゴリ内の売上順位を RANK / DENSE_RANK / ROW_NUMBER の3通りで付与してください。出力列は category, product, sales, rnk, dense_rnk, row_num、category 昇順・sales 降順・product 昇順で返してください。

使用テーブル
► product_sales(7行)
categoryproductsales
FoodApple500
FoodBread500
FoodCake300
FoodDonut200
BeverageCola400
BeverageTea250
BeverageWater150
期待出力
categoryproductsalesrnkdense_rnkrow_num
BeverageCola400111
BeverageTea250222
BeverageWater150333
FoodApple500111
FoodBread500112
FoodCake300323
FoodDonut200434
模範解答コード
SELECT
  category,
  product,
  sales,
  RANK()       OVER (PARTITION BY category ORDER BY sales DESC)          AS rnk,        -- 同順位あり・次を飛ばす
  DENSE_RANK() OVER (PARTITION BY category ORDER BY sales DESC)          AS dense_rnk,  -- 同順位あり・飛ばさない
  ROW_NUMBER() OVER (PARTITION BY category ORDER BY sales DESC, product) AS row_num     -- 強制連番(タイ崩しに第2キー)
FROM product_sales
ORDER BY category, sales DESC, product;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM product_sales          → 行を読込
  2. PARTITION BY category       → カテゴリで分割
  3. ORDER BY sales DESC(窓内)     → 各パーティション内で売上降順
  4. RANK/DENSE_RANK/ROW_NUMBER  → 順位を振る
  5. ORDER BY                    → 最終表示順を整える
  */
解説(テーブル変化・ポイント)
SELECT category, product, sales, RANK() OVER (PARTITION BY category ORDER BY sales DESC) AS rnk, DENSE_RANK() OVER (PARTITION BY category ORDER BY sales DESC) AS dense_rnk, ROW_NUMBER() OVER (PARTITION BY category ORDER BY sales DESC, product) AS row_num FROM product_sales ORDER BY category, sales DESC, product;
LEGEND
データ取得・読込対象
① FROM product_sales(7行)
FROM product_salesカテゴリと商品ごとの売上を読み込みます。Food の Apple・Bread が同額500(タイ)である点に注目してください。
1 / 4
categoryproductsales
FoodApple500
FoodBread500
FoodCake300
FoodDonut200
BeverageCola400
BeverageTea250
BeverageWater150
7行読込
学習ポイント
3関数の違いはタイの扱いに集約される:同順位が発生したとき、RANK=同順位を付け次を飛ばす(1,1,3)、DENSE_RANK=飛ばさない(1,1,2)、ROW_NUMBER=タイでも一意の連番(1,2)。「3位以内」の定義しだいで選ぶ関数が変わります
PARTITION BY は順位をグループ単位でリセット:PARTITION BY category により Beverage と Food が独立採番されます。PARTITION BY を省くとテーブル全体で1本の通し順位になります。
Top-N はサブクエリ/CTEで包んで絞る:ウィンドウ関数は WHERE 句より後に評価されるため WHERE row_num <= 3 は直接書けません。WITH ranked AS (SELECT ..., ROW_NUMBER() ...) SELECT * FROM ranked WHERE row_num <= 3 と一段包むのが定石です。
アンチパターン
ROW_NUMBER でタイブレーク列を指定し忘れる:Apple と Bread が同額のとき第2キーがないと、どちらが 1 になるか非決定的になります。ORDER BY sales DESC, product のように一意になる列を足して結果を安定させてください。
「Top3」を ROW_NUMBER で取りタイを取りこぼす:3位が同点で2件ある場合、ROW_NUMBER だと片方しか入りません。同点も含めたいなら RANK/DENSE_RANK を使います。要件(同点を含めるか)で関数を選び分けます。
実務コラム:ランキングKPIの実務シーン
「店舗別売上ランキング」「キーワード別流入Top10」「ユーザー内の最新注文だけ抽出」など、PARTITION BY + ROW_NUMBER は分析SQLで最も登場頻度の高いパターンの一つです。特に 各ユーザーの最新レコードだけ取り出す(ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) = 1) という「最新1件抽出」イディオムは、重複排除・最新状態の取得で頻出するので必ず手に馴染ませておきましょう。
QUESTION 9

ヘビーユーザー抽出 — GROUP BY + HAVING + FILTER でセグメントを切り出す

GROUP BYHAVINGセグメント抽出FILTER
前提知識

「完了注文が2件以上のリピーター」のように集計結果の条件で対象を絞るのが HAVING です。行単位で絞る WHERE との役割分担が要。さらに PostgreSQL の FILTER 句を使うと、集計関数ごとに「数える対象」をスマートに指定できます。

-- WHERE  : グループ化のに「行」を絞る
-- HAVING : グループ化のに「グループ」を絞る(集計値が条件に使える)

COUNT(*) FILTER (WHERE status = 'completed')   -- 条件に合う行だけ数える
-- = SUM(CASE WHEN status='completed' THEN 1 ELSE 0 END) と同義だが読みやすい
論理評価順を思い出す:FROM → WHERE → GROUP BY → 集計 → HAVING → SELECT → ORDER BY。HAVING は集計のに走るので COUNT(*) >= 2 のような集計値を条件にできます。逆に WHERE では集計値を条件にできません(まだ集計していないため)。
問題

orders テーブルから、completed(完了)注文が2件以上のヘビーユーザーを抽出し、完了注文数と完了金額合計を出してください。出力列は user_id, completed_orders, completed_amount、completed_amount 降順で返してください。

使用テーブル
► orders(8行)
user_idorder_idamountstatus
U1O12000completed
U1O21500completed
U1O3800cancelled
U2O4500completed
U3O53000completed
U3O62500completed
U3O71000completed
U4O8300cancelled
期待出力
user_idcompleted_orderscompleted_amount
U336500
U123500
模範解答コード
SELECT
  user_id,
  COUNT(*) FILTER (WHERE status = 'completed')   AS completed_orders,  -- 完了注文だけ数える
  COALESCE(SUM(amount) FILTER (WHERE status = 'completed'), 0) AS completed_amount  -- 完了金額だけ合計(該当なしは0)
FROM orders
GROUP BY user_id
HAVING COUNT(*) FILTER (WHERE status = 'completed') >= 2   -- 集計後にグループを絞る
ORDER BY completed_amount DESC;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM orders                                   → 8行読込
  2. GROUP BY user_id                              → U1,U2,U3,U4 の4グループ
  3. 集計(FILTER で completed のみ対象)          → U1:2/3500, U2:1/500, U3:3/6500, U4:0/0
  4. HAVING completed_orders >= 2                  → U2(1)・U4(0) を除外
  5. SELECT / ORDER BY completed_amount DESC       → 金額降順で出力
*/
解説(テーブル変化・ポイント)
SELECT user_id, COUNT(*) FILTER (WHERE status = 'completed') AS completed_orders, COALESCE(SUM(amount) FILTER (WHERE status = 'completed'), 0) AS completed_amount FROM orders GROUP BY user_id HAVING COUNT(*) FILTER (WHERE status = 'completed') >= 2 ORDER BY completed_amount DESC;
LEGEND
データ取得・読込対象
① FROM orders(8行)
FROM orders注文ログを全件読み込みます。status が completed と cancelled が混在しており、cancelled は売上に数えるべきではありません。
1 / 5
user_idorder_idamountstatus
U1O12000completed
U1O21500completed
U1O3800cancelled
U2O4500completed
U3O53000completed
U3O62500completed
U3O71000completed
U4O8300cancelled
8行読込
学習ポイント
WHERE は行を、HAVING はグループを絞る:WHERE はグループ化の行フィルタ、HAVING はグループ化の集計値フィルタです。HAVING COUNT(*) >= 2 のように集計結果を条件にできるのは HAVING だけです。
FILTER は CASE WHEN の読みやすい上位互換:Q3 で使った SUM(CASE WHEN ... THEN 1 ELSE 0 END) は、PostgreSQL では COUNT(*) FILTER (WHERE ...) と書けます。条件付き集計を1つのクエリに複数並べても意図が明快です。
WHERE と HAVING は併用できる:例えば WHERE order_date >= '2024-01-01'(期間で行を先に絞る)→ GROUP BY → HAVING SUM(amount) >= 10000(高額グループだけ残す)のように、両者を組み合わせるのが実務の定番です。
アンチパターン
WHERE に集計条件を書いてエラー:WHERE COUNT(*) >= 2 は「集計関数は WHERE で使えない」エラーになります。集計値の条件は必ず HAVING へ。逆に、行単位で絞れる条件(status の事前フィルタ等)は WHERE に置くほうが高速です。
cancelled を含めたまま「購入額」を集計する:FILTER を付けずに SUM(amount) すると、キャンセル分まで売上に混入します。「何を売上と数えるか」をFILTER(または WHERE)で明示し、定義を揃えてください。
実務コラム:HAVING でセグメント定義を一元化する
「優良顧客=完了注文3件以上かつ累計5万円以上」のようなセグメント定義は、HAVING の条件式そのものが定義書になります。RFM分析(Recency・Frequency・Monetary)でも、F(頻度)や M(金額)の閾値を HAVING で表現するのが定番。閾値をクエリ冒頭の変数や dbt の var() に切り出しておくと、定義変更が一箇所で済み、チーム全体で「優良顧客」の認識を統一できます。
QUESTION 10

月次チャーン率 — アンチ結合(LEFT JOIN + IS NULL)で離脱ユーザーを特定する

LEFT JOINアンチ結合チャーン率IS NULL自己結合
前提知識

チャーン(解約・離脱)はリテンションの裏返しで、SaaS・サブスクの生命線となるKPIです。「先月いたが今月いないユーザー」を求めるには、アンチ結合——LEFT JOIN して結合相手がいなかった行(IS NULL)だけを残す——が定石です。

LEFT JOIN ... ON 条件
WHERE 右テーブル.key IS NULL   -- 結合相手がいない=「集合の差」を抽出
-- prev(先月)にいて curr(今月)にいない = 離脱(churn)
外部結合の絞り込みは ON か WHERE か:右テーブル(curr)への絞り込み条件 curr.month = '2024-04' は必ず ON 句に書きます。WHERE に書くと NULL 行が NULL = '2024-04' で偽となり消え、LEFT JOIN が実質 INNER JOIN に退化してアンチ結合が壊れます。
問題

monthly_active(月次アクティブユーザー)から、2024-03 にアクティブだが 2024-04 に非アクティブな「離脱ユーザー」を特定してください。出力列は user_id、user_id 昇順。あわせて解説でチャーン率も確認します。

使用テーブル
► monthly_active(7行)
monthuser_id
2024-03U1
2024-03U2
2024-03U3
2024-03U4
2024-04U1
2024-04U3
2024-04U5
期待出力
user_id
U2
U4
模範解答コード
SELECT prev.user_id                -- 3月にいて4月にいない=離脱ユーザー
FROM      monthly_active AS prev
LEFT JOIN monthly_active AS curr
  ON  curr.user_id = prev.user_id
  AND curr.month   = '2024-04'     -- 右テーブルの絞り込みは ON 句に置く
WHERE prev.month = '2024-03'
  AND curr.user_id IS NULL         -- 結合相手なし=4月に非アクティブ
ORDER BY prev.user_id;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM prev LEFT JOIN curr      → prev に翌月を突き合わせ(相手なしはNULL保持)
  2. WHERE prev.month で基準月     → 基準月のユーザーに限定
  3. AND curr.user_id IS NULL      → 翌月に相手がない行=離脱だけ残す
  4. SELECT prev.user_id / ORDER BY → 昇順で出力

  -- チャーン率(おまけ):
  --   COUNT(*) FILTER (WHERE curr.user_id IS NULL) * 100.0 / COUNT(*)
  --   = 2 / 4 * 100 = 50.00 (分母は3月アクティブ数)
*/
解説(テーブル変化・ポイント)
SELECT prev.user_id FROM monthly_active AS prev LEFT JOIN monthly_active AS curr ON curr.user_id = prev.user_id AND curr.month = '2024-04' WHERE prev.month = '2024-03' AND curr.user_id IS NULL ORDER BY prev.user_id;
LEGEND
データ取得・読込対象
① FROM monthly_active(7行)
FROM monthly_active月次アクティブの全件です。3月に U1〜U4、4月に U1・U3・U5。同じテーブルを prev(先月)と curr(今月)の2役で自己結合します。
1 / 4
monthuser_id
2024-03U1
2024-03U2
2024-03U3
2024-03U4
2024-04U1
2024-04U3
2024-04U5
7行読込
学習ポイント
アンチ結合=「集合の差」を取る定石:LEFT JOIN で全行を残し、WHERE 右.key IS NULL結合相手がいなかった行だけを抽出すれば「prev にあって curr にない」差集合が得られます。チャーン・未購入・欠損検出など応用範囲が広いパターンです。
右テーブルの条件は ON、左の条件は WHERE:curr.month='2024-04' は ON に置きます。基準側 prev.month='2024-03' は保持対象の左テーブルなので WHERE で問題ありません。この置き場所の使い分けが外部結合の核心です。
NOT EXISTS / NOT IN でも書ける:WHERE NOT EXISTS (SELECT 1 FROM monthly_active c WHERE c.user_id=prev.user_id AND c.month='2024-04') も同義で、可読性が高く NULL の罠も少ないため実務で好まれます。NOT IN は対象側に NULL があると全件偽になる罠があるので NOT EXISTS が安全です。
アンチパターン
右テーブルの条件を WHERE に書いて INNER JOIN 化:WHERE curr.month='2024-04' と書くと、離脱者の NULL 行が NULL='2024-04' で偽になり消えます。結果アンチ結合が成立せず離脱者が1件も出ないという典型バグになります。右テーブルの絞り込みは ON へ。
新規ユーザーを離脱に混ぜる:4月の U5 は「新規」であり離脱ではありません。チャーンの分母は先月(3月)のアクティブです。prev を基準にしているので U5 は自然に対象外ですが、方向を逆にすると新規と離脱を取り違えます。
実務コラム:チャーン率とリテンション率は表裏一体
チャーン率 + リテンション率 = 100%(同一コホート・同一期間で見た場合)。本問は50%チャーン=50%リテンションです。SaaS では 月次チャーン5%でも年換算では約46%が離脱する計算になり、複利的に効いてきます。さらに「解約はしたが翌々月に戻った」復帰(reactivation)ユーザーを別途追うと、単純な月次チャーンでは見えない回復力まで把握できます。リテンション(残存)とセットで、ユーザーの出入りを両面から監視するのが定石です。