DAU 移動平均 — AVG() OVER + ROWS 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 は物理的な行数で窓を区切ります(直近3行)。RANGE はORDER BY 値が同じ行をまとめて扱います。移動平均では行数で区切りたいので必ず ROWS を使ってください。daily_active テーブルから、各日の DAU と「当日を含む直近3日の移動平均(dau_ma3)」を計算してください。出力列は event_date, dau, dau_ma3、event_date 昇順、移動平均は小数第2位まで丸めてください。
| event_date | dau |
|---|---|
| 2024-03-01 | 100 |
| 2024-03-02 | 120 |
| 2024-03-03 | 110 |
| 2024-03-04 | 90 |
| 2024-03-05 | 150 |
| 2024-03-06 | 140 |
| 2024-03-07 | 160 |
| event_date | dau | dau_ma3 |
|---|---|---|
| 2024-03-01 | 100 | 100.00 |
| 2024-03-02 | 120 | 110.00 |
| 2024-03-03 | 110 | 110.00 |
| 2024-03-04 | 90 | 106.67 |
| 2024-03-05 | 150 | 116.67 |
| 2024-03-06 | 140 | 126.67 |
| 2024-03-07 | 160 | 150.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 → 日付昇順で出力 */
LEGEND
① FROM daily_active(7行)
FROM daily_active日次のDAUを全件読み込みます。03-04 が落ち込み、03-05〜07 が伸びるなど日々の変動が大きく、生の数値ではトレンドが読みにくい状態です。| event_date | dau |
|---|---|
| 03-01 | 100 |
| 03-02 | 120 |
| 03-03 | 110 |
| 03-04 | 90 |
| 03-05 | 150 |
| 03-06 | 140 |
| 03-07 | 160 |
RANGE UNBOUNDED PRECEDING=先頭からの累計平均です。直近N行に限定したいなら 必ず ROWS BETWEEN n PRECEDING AND CURRENT ROW を明示します。COUNT(*) OVER (...) = 7 でフィルタします。ROWS BETWEEN 6 PRECEDING AND CURRENT ROW で7日移動平均、3 PRECEDING AND 3 FOLLOWING で前後3日の中心化移動平均になります。フレームの両端を変えるだけで様々な平滑化が表現できます。AVG(dau) OVER (ORDER BY event_date) だけだと先頭からの累計平均になり、日が進むほど鈍化します。移動平均には ROWS フレームが必須です。JOIN ... ON b.date BETWEEN a.date-2 AND a.date のような自己結合は冗長で、欠損日があると窓がずれます。ウィンドウフレームを使えば1行で正確かつ高速です。売上構成比とパレート分析 — SUM() OVER () と累計でABC分析を実装する
「どのカテゴリが売上の何%を占めるか(構成比)」「上位から積み上げて何%に達するか(累計構成比)」は、重点商材を見極めるパレート分析・ABC分析の核です。ウィンドウ関数を使うと、明細行を残したまま「全体合計」や「累計」を同じ行に並べられます。
SUM(sales) OVER () -- 空のOVER=全行を1つの窓。各行に総合計を付与 SUM(sales) OVER (ORDER BY sales DESC) -- 並び順に沿った累計(ランニング合計)
category_sales テーブル(カテゴリ別の集計済み売上)から、構成比(sales_pct)と売上降順での累計構成比(cum_pct)を計算してください。出力列は category, sales, sales_pct, cum_pct、sales 降順、比率は小数第2位まで丸めてください。
| category | sales |
|---|---|
| Electronics | 5000 |
| Apparel | 3000 |
| Home | 1500 |
| Books | 800 |
| Toys | 700 |
| category | sales | sales_pct | cum_pct |
|---|---|---|---|
| Electronics | 5000 | 45.45 | 45.45 |
| Apparel | 3000 | 27.27 | 72.73 |
| Home | 1500 | 13.64 | 86.36 |
| Books | 800 | 7.27 | 93.64 |
| Toys | 700 | 6.36 | 100.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 → 売上降順で出力 */
LEGEND
① FROM category_sales(5行)
FROM category_salesカテゴリ別の集計済み売上を読み込みます。すでに sales 降順に近い形ですが、ウィンドウ関数の ORDER BY が並びを保証します。| category | sales |
|---|---|
| Electronics | 5000 |
| Apparel | 3000 |
| Home | 1500 |
| Books | 800 |
| Toys | 700 |
SUM(sales) OVER () は GROUP BY と違い行を畳まず、各明細行に総合計を添えます。これにより 明細 ÷ 全体 の構成比が同じ行で計算できます。サブクエリで総合計を取り直すより簡潔です。SUM(sales) OVER (ORDER BY sales DESC) は既定フレーム(先頭〜現在行)で動くため累計合計を返します。累計 ÷ 全体 が累計構成比、すなわちパレート曲線の y 値です。3000/11000 が 0 に切り捨てられます。* 100.0(または ::numeric)で浮動小数点に昇格させてから ROUND します。ORDER BY sales DESC ROWS UNBOUNDED PRECEDING のように ROWS を使い、タイブレーク列も足します。カテゴリ別ランキング — RANK / DENSE_RANK / ROW_NUMBER と PARTITION BY
「カテゴリごとの売れ筋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 は行を残したままグループ(パーティション)ごとに順位をリセットします。各パーティションの先頭から 1 が振り直されます。product_sales テーブルから、カテゴリ内の売上順位を RANK / DENSE_RANK / ROW_NUMBER の3通りで付与してください。出力列は category, product, sales, rnk, dense_rnk, row_num、category 昇順・sales 降順・product 昇順で返してください。
| category | product | sales |
|---|---|---|
| Food | Apple | 500 |
| Food | Bread | 500 |
| Food | Cake | 300 |
| Food | Donut | 200 |
| Beverage | Cola | 400 |
| Beverage | Tea | 250 |
| Beverage | Water | 150 |
| category | product | sales | rnk | dense_rnk | row_num |
|---|---|---|---|---|---|
| Beverage | Cola | 400 | 1 | 1 | 1 |
| Beverage | Tea | 250 | 2 | 2 | 2 |
| Beverage | Water | 150 | 3 | 3 | 3 |
| Food | Apple | 500 | 1 | 1 | 1 |
| Food | Bread | 500 | 1 | 1 | 2 |
| Food | Cake | 300 | 3 | 2 | 3 |
| Food | Donut | 200 | 4 | 3 | 4 |
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 → 最終表示順を整える */
LEGEND
① FROM product_sales(7行)
FROM product_salesカテゴリと商品ごとの売上を読み込みます。Food の Apple・Bread が同額500(タイ)である点に注目してください。| category | product | sales |
|---|---|---|
| Food | Apple | 500 |
| Food | Bread | 500 |
| Food | Cake | 300 |
| Food | Donut | 200 |
| Beverage | Cola | 400 |
| Beverage | Tea | 250 |
| Beverage | Water | 150 |
RANK=同順位を付け次を飛ばす(1,1,3)、DENSE_RANK=飛ばさない(1,1,2)、ROW_NUMBER=タイでも一意の連番(1,2)。「3位以内」の定義しだいで選ぶ関数が変わります。PARTITION BY category により Beverage と Food が独立採番されます。PARTITION BY を省くとテーブル全体で1本の通し順位になります。WHERE row_num <= 3 は直接書けません。WITH ranked AS (SELECT ..., ROW_NUMBER() ...) SELECT * FROM ranked WHERE row_num <= 3 と一段包むのが定石です。ORDER BY sales DESC, product のように一意になる列を足して結果を安定させてください。ヘビーユーザー抽出 — GROUP BY + HAVING + FILTER でセグメントを切り出す
「完了注文が2件以上のリピーター」のように集計結果の条件で対象を絞るのが HAVING です。行単位で絞る WHERE との役割分担が要。さらに PostgreSQL の FILTER 句を使うと、集計関数ごとに「数える対象」をスマートに指定できます。
-- WHERE : グループ化の前に「行」を絞る -- HAVING : グループ化の後に「グループ」を絞る(集計値が条件に使える) COUNT(*) FILTER (WHERE status = 'completed') -- 条件に合う行だけ数える -- = SUM(CASE WHEN status='completed' THEN 1 ELSE 0 END) と同義だが読みやすい
COUNT(*) >= 2 のような集計値を条件にできます。逆に WHERE では集計値を条件にできません(まだ集計していないため)。orders テーブルから、completed(完了)注文が2件以上のヘビーユーザーを抽出し、完了注文数と完了金額合計を出してください。出力列は user_id, completed_orders, completed_amount、completed_amount 降順で返してください。
| user_id | order_id | amount | status |
|---|---|---|---|
| U1 | O1 | 2000 | completed |
| U1 | O2 | 1500 | completed |
| U1 | O3 | 800 | cancelled |
| U2 | O4 | 500 | completed |
| U3 | O5 | 3000 | completed |
| U3 | O6 | 2500 | completed |
| U3 | O7 | 1000 | completed |
| U4 | O8 | 300 | cancelled |
| user_id | completed_orders | completed_amount |
|---|---|---|
| U3 | 3 | 6500 |
| U1 | 2 | 3500 |
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 → 金額降順で出力 */
LEGEND
① FROM orders(8行)
FROM orders注文ログを全件読み込みます。status が completed と cancelled が混在しており、cancelled は売上に数えるべきではありません。| user_id | order_id | amount | status |
|---|---|---|---|
| U1 | O1 | 2000 | completed |
| U1 | O2 | 1500 | completed |
| U1 | O3 | 800 | cancelled |
| U2 | O4 | 500 | completed |
| U3 | O5 | 3000 | completed |
| U3 | O6 | 2500 | completed |
| U3 | O7 | 1000 | completed |
| U4 | O8 | 300 | cancelled |
HAVING COUNT(*) >= 2 のように集計結果を条件にできるのは HAVING だけです。SUM(CASE WHEN ... THEN 1 ELSE 0 END) は、PostgreSQL では COUNT(*) FILTER (WHERE ...) と書けます。条件付き集計を1つのクエリに複数並べても意図が明快です。WHERE order_date >= '2024-01-01'(期間で行を先に絞る)→ GROUP BY → HAVING SUM(amount) >= 10000(高額グループだけ残す)のように、両者を組み合わせるのが実務の定番です。WHERE COUNT(*) >= 2 は「集計関数は WHERE で使えない」エラーになります。集計値の条件は必ず HAVING へ。逆に、行単位で絞れる条件(status の事前フィルタ等)は WHERE に置くほうが高速です。SUM(amount) すると、キャンセル分まで売上に混入します。「何を売上と数えるか」をFILTER(または WHERE)で明示し、定義を揃えてください。月次チャーン率 — アンチ結合(LEFT JOIN + IS NULL)で離脱ユーザーを特定する
チャーン(解約・離脱)はリテンションの裏返しで、SaaS・サブスクの生命線となるKPIです。「先月いたが今月いないユーザー」を求めるには、アンチ結合——LEFT JOIN して結合相手がいなかった行(IS NULL)だけを残す——が定石です。
LEFT JOIN ... ON 条件 WHERE 右テーブル.key IS NULL -- 結合相手がいない=「集合の差」を抽出 -- prev(先月)にいて curr(今月)にいない = 離脱(churn)
curr.month = '2024-04' は必ず ON 句に書きます。WHERE に書くと NULL 行が NULL = '2024-04' で偽となり消え、LEFT JOIN が実質 INNER JOIN に退化してアンチ結合が壊れます。monthly_active(月次アクティブユーザー)から、2024-03 にアクティブだが 2024-04 に非アクティブな「離脱ユーザー」を特定してください。出力列は user_id、user_id 昇順。あわせて解説でチャーン率も確認します。
| month | user_id |
|---|---|
| 2024-03 | U1 |
| 2024-03 | U2 |
| 2024-03 | U3 |
| 2024-03 | U4 |
| 2024-04 | U1 |
| 2024-04 | U3 |
| 2024-04 | U5 |
| 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月アクティブ数) */
LEGEND
① FROM monthly_active(7行)
FROM monthly_active月次アクティブの全件です。3月に U1〜U4、4月に U1・U3・U5。同じテーブルを prev(先月)と curr(今月)の2役で自己結合します。| month | user_id |
|---|---|
| 2024-03 | U1 |
| 2024-03 | U2 |
| 2024-03 | U3 |
| 2024-03 | U4 |
| 2024-04 | U1 |
| 2024-04 | U3 |
| 2024-04 | U5 |
WHERE 右.key IS NULL で結合相手がいなかった行だけを抽出すれば「prev にあって curr にない」差集合が得られます。チャーン・未購入・欠損検出など応用範囲が広いパターンです。curr.month='2024-04' は ON に置きます。基準側 prev.month='2024-03' は保持対象の左テーブルなので WHERE で問題ありません。この置き場所の使い分けが外部結合の核心です。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 curr.month='2024-04' と書くと、離脱者の NULL 行が NULL='2024-04' で偽になり消えます。結果アンチ結合が成立せず離脱者が1件も出ないという典型バグになります。右テーブルの絞り込みは ON へ。