SQL 異常検知 — ランキング・累積和・集約アラートの基礎

基礎異常検知RANK / ROW_NUMBER累積和PARTITION BYCOUNT FILTERPostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 6

外れ値ランキング — ROW_NUMBER / RANK / DENSE_RANK で偏差の大きい異常を順位付けする

ROW_NUMBERRANK / DENSE_RANK外れ値ランキング異常トリアージ
前提知識

異常が多数あるとき、まず「どれが最も異常か」を順位付けして上位だけを精査します。ランキングのウィンドウ関数3兄弟は同値(タイ)の扱いがそれぞれ異なります。

ROW_NUMBER() OVER (ORDER BY x DESC)  -- 1,2,3,4 … 常に一意(同値でも連番/順序は非決定的)
RANK()       OVER (ORDER BY x DESC)  -- 1,1,3,4 … 同値=同順位/欠番あり
DENSE_RANK() OVER (ORDER BY x DESC)  -- 1,1,2,3 … 同値=同順位/欠番なし
OVER () の空窓:PARTITION も ORDER もない OVER ()テーブル全体を1つの窓として扱い、全行に同じ集計値(例: 全体平均)を付与します。GROUP BY と違い行は畳まれません。これを基準線にして各行の偏差を測ります。
問題

daily_sales から、全期間平均からの偏差(deviation)とその絶対値(abs_dev)を計算し、abs_dev の降順で ROW_NUMBER・RANK・DENSE_RANK の3種の順位を付与してください。出力列は dt, amount, deviation, abs_dev, rn, rnk, dense_rnk、abs_dev 降順・同値は dt 昇順で返してください。deviation / abs_dev は小数第1位まで。

使用テーブル
► daily_sales(8行)
dtamount
2024-01-01120
2024-01-02130
2024-01-03125
2024-01-0450
2024-01-05200
2024-01-06122
2024-01-07128
2024-01-08125
期待出力
dtamountdeviationabs_devrnrnkdense_rnk
2024-01-0450-75.075.0111
2024-01-0520075.075.0211
2024-01-01120-5.05.0332
2024-01-021305.05.0432
2024-01-06122-3.03.0553
2024-01-071283.03.0653
2024-01-031250.00.0774
2024-01-081250.00.0874
模範解答コード
WITH dev AS (
  SELECT
    dt, amount,
    amount - AVG(amount) OVER ()       AS deviation,   -- 全期間平均からの偏差
    ABS(amount - AVG(amount) OVER ())  AS abs_dev     -- 偏差の絶対値(異常度)
  FROM daily_sales
)
SELECT
  dt, amount,
  ROUND(deviation, 1) AS deviation,
  ROUND(abs_dev, 1)   AS abs_dev,
  ROW_NUMBER() OVER (ORDER BY abs_dev DESC, dt) AS rn,        -- 一意連番(dtでtie-break)
  RANK()       OVER (ORDER BY abs_dev DESC)     AS rnk,       -- 同値=同順位/欠番あり
  DENSE_RANK() OVER (ORDER BY abs_dev DESC)     AS dense_rnk  -- 同値=同順位/欠番なし
FROM dev
ORDER BY abs_dev DESC, dt;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM daily_sales                   → 行を読み込む
  2. AVG(amount) OVER ()                → ウィンドウ関数を評価(行数は保持)
  3. deviation / ABS()                  → 値を整形
  4. ROW_NUMBER / RANK / DENSE_RANK     → ウィンドウ関数を評価(行数は保持)
  5. SELECT / ORDER BY abs_dev DESC, dt → 並び替えて出力
*/
解説(テーブル変化・ポイント)
WITH dev AS ( SELECT dt, amount, amount - AVG(amount) OVER () AS deviation, ABS(amount - AVG(amount) OVER ()) AS abs_dev FROM daily_sales ) SELECT dt, amount, ROUND(deviation, 1) AS deviation, ROUND(abs_dev, 1) AS abs_dev, ROW_NUMBER() OVER (ORDER BY abs_dev DESC, dt) AS rn, RANK() OVER (ORDER BY abs_dev DESC) AS rnk, DENSE_RANK() OVER (ORDER BY abs_dev DESC) AS dense_rnk FROM dev ORDER BY abs_dev DESC, dt;
LEGEND
データ取得・読込対象
① FROM daily_sales(8行)
FROM daily_sales8日分の売上を読み込みます。01-04(50)の急落と 01-05(200)の急騰が異常候補。どちらが『より異常か』を偏差の大きさで順位付けします。
1 / 5
dtamount
01-01120
01-02130
01-03125
01-0450
01-05200
01-06122
01-07128
01-08125
8行読込
学習ポイント
「上位N件」の抽出は関数選びで結果が変わる:外側で WHERE rn <= 3 なら必ず3件WHERE rnk <= 3 なら同値を含むため4件以上になり得ます(本問では rnk が 1,1,3,3 のため rnk<=3 は4件)。dense_rnk <= 3abs_dev の上位3グループ=6件。目的に応じて使い分けます。
OVER () は「全行を1つの窓」にする:サブクエリで全体平均を別途求めて JOIN しなくても、AVG(amount) OVER () 一発で全行に基準値を付与できます。GROUP BY と違い行数は保たれるため、各行を残したまま偏差を計算できます。
ROW_NUMBER は ORDER BY が一意でないと非決定的:同値があると並び順は実行ごとに変わり得ます。再現性が必要なら ORDER BY abs_dev DESC, dt のように一意になるタイブレークキー(日付やID)を必ず添えます。
アンチパターン
ROW_NUMBER で上位を取り、同点の異常を取りこぼす:同じ異常度の事象が複数あるのに rn <= N で機械的に切ると、片方だけ拾って片方を見逃します。同点もすべて拾いたいなら RANK を使ってください。
ORDER BY + LIMIT だけで上位抽出する:ORDER BY abs_dev DESC LIMIT 3 は同値の扱いが不定で、境界の事象を恣意的に切り捨てます。順位関数を使えば「同点をどう扱うか」を意図として明示できます。
実務コラム:異常トリアージとスコアリング
本番では毎日大量の異常候補が出ます。z-score・変化率・偏差などを正規化して合成異常スコアを作り、RANK() OVER (ORDER BY score DESC) で並べて上位だけを人間がレビューする運用が定番です。PARTITION BY date を足せば「日ごとのワースト異常」も同時に取れます(PARTITION BY date で日単位に区切る方法)。
QUESTION 7

累積偏差によるドリフト検知 — SUM() OVER で目標値からの累積ズレを追跡する

SUM OVERUNBOUNDED PRECEDING累積和 / CUSUMドリフト検知
前提知識

z-score や前日比は「単発の大きな異常(点異常)」には強い一方、毎日わずかに高い…が積み重なる『じわじわドリフト』を見逃します。各日の目標からのズレを累積(running total)すると、小さなバイアスの蓄積が顕在化します。

SUM(amount - 100) OVER (
  ORDER BY dt
  ROWS UNBOUNDED PRECEDING   -- = ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
)
-- 先頭行から現在行までの累積和(running total)
-- ORDER BY だけでフレーム省略時の既定は RANGE UNBOUNDED PRECEDING …
累積和の既定フレームに注意:ウィンドウに ORDER BY がありフレームを省略すると、既定は RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW です。同じ dt が複数あると RANGE は同値をまとめて加算してしまうため、行単位の累積には ROWS UNBOUNDED PRECEDING を明示するのが安全です(ROWS vs RANGE と同じ論点)。
問題

daily_sales(1日の目標値=100とする)から、各日の目標からのズレ(daily_dev = amount − 100)と、その累積(cum_dev)を計算し、cum_dev が 20 を超えたら 'drift_alert'、それ以外は 'normal' の drift_flag を付与してください。出力列は dt, amount, daily_dev, cum_dev, drift_flag、dt 昇順で返してください。

使用テーブル
► daily_sales(8行)
dtamount
2024-01-01100
2024-01-02102
2024-01-03101
2024-01-04103
2024-01-05105
2024-01-06104
2024-01-07106
2024-01-08108
期待出力
dtamountdaily_devcum_devdrift_flag
2024-01-0110000normal
2024-01-02102+22normal
2024-01-03101+13normal
2024-01-04103+36normal
2024-01-05105+511normal
2024-01-06104+415normal
2024-01-07106+621drift_alert
2024-01-08108+829drift_alert
模範解答コード
SELECT
  dt, amount,
  amount - 100 AS daily_dev,          -- 1日あたりの目標(100)からのズレ
  SUM(amount - 100) OVER (            -- 累積偏差(running total)
    ORDER BY dt
    ROWS UNBOUNDED PRECEDING            -- 先頭〜現在行まで
  ) AS cum_dev,
  CASE
    WHEN SUM(amount - 100) OVER (ORDER BY dt ROWS UNBOUNDED PRECEDING) > 20
      THEN 'drift_alert'
    ELSE 'normal'
  END AS drift_flag
FROM daily_sales
ORDER BY dt;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM daily_sales         → 行を読み込む
  2. amount - 100             → 値を整形
  3. SUM(...) OVER (...)      → ウィンドウ関数を評価(行数は保持)
  4. CASE WHEN cum_dev > 20   → 値を整形
  5. SELECT / ORDER BY dt     → 並び替えて出力
*/
解説(テーブル変化・ポイント)
SELECT dt, amount, amount - 100 AS daily_dev, SUM(amount - 100) OVER ( ORDER BY dt ROWS UNBOUNDED PRECEDING ) AS cum_dev, CASE WHEN SUM(amount - 100) OVER (...) > 20 THEN 'drift_alert' ELSE 'normal' END AS drift_flag FROM daily_sales ORDER BY dt;
LEGEND
データ取得・読込対象
① FROM daily_sales(8行)
FROM daily_sales目標値100に対し、毎日わずかに上回る売上です。前日比はどれも数%以内で、点異常検知(z-score・変化率)では何も引っかかりません。
1 / 5
dtamount
01-01100
01-02102
01-03101
01-04103
01-05105
01-06104
01-07106
01-08108
8行読込
学習ポイント
点異常 vs 持続ドリフト:z-score・変化率(Q3・Q5)は1点の大きな逸脱に強く、累積和は小さなズレの蓄積に強い。両者は補完関係で、実務では並走させます。本問の累積和は CUSUM(累積和管理図)の最小形です。
ORDER BY だけのときの既定フレーム:SUM(x) OVER (ORDER BY dt) はフレーム省略で UNBOUNDED PRECEDING 〜 CURRENT ROW の累積になります。一方フレームも ORDER BY も無い SUM(x) OVER ()全体合計。挙動が全く違うので意図を明示しましょう。
基準値を動的にする:固定の 100 ではなく amount - AVG(amount) OVER (ORDER BY dt ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) を累積すれば、移動平均からの累積ズレになり、トレンドが変化する系列にも適応します。
アンチパターン
ORDER BY を付け忘れる:SUM(x) OVER () と書くと累積ではなく全行合計が全行に入り、ドリフトが見えません。累積和には必ず ORDER BY を付けてください。
片側しきい値だけで監視する:cum_dev > 20 だけでは下振れの累積(急落ドリフト)を見逃します。下方向も監視するなら ABS(cum_dev) や両側の閾値を設定してください。
実務コラム:CUSUM と SPC(統計的工程管理)
累積和は製造業のSPC(管理図)や SRE のメトリクス監視で、わずかな性能劣化の蓄積を早期検知するために使われます。レイテンシが毎回ほんの少しずつ悪化する…という単独では見えない劣化を、累積で炙り出します。本格的な CUSUM は「目標+許容幅」を超えた分だけ累積し、下限0でリセットする変種を使います。
QUESTION 8

セグメント別 z-score — PARTITION BY で店舗ごとに基準を変えて異常を検知する

PARTITION BYWINDOW 句セグメント別 z-score文脈依存の異常
前提知識

「全社平均」で異常を測ると、規模の違う店舗が混ざったときに破綻します。小型店の高額売上が大型店の基準では正常に見え、逆もまた然り。異常は『同じ仲間(セグメント)の中』で測るのが鉄則です。PARTITION BY は窓をグループごとに分割し、各行に「その所属グループ内の集計値」を付与します。

AVG(amount)    OVER (PARTITION BY store_id)  -- 店舗ごとの平均
STDDEV(amount) OVER (PARTITION BY store_id)  -- 店舗ごとの標準偏差
-- GROUP BY と違い行は畳まれず、各行に所属グループの集計値が並走する
WINDOW 句で重複排除:同じ PARTITION BY store_id を何度も書くのは冗長です。WINDOW w AS (PARTITION BY store_id) と一度定義して AVG(amount) OVER w のように使い回せます(WINDOW 句と同じ仕組み)。
問題

store_sales から、店舗ごと(store_id)に平均・標準偏差を求め、各行の z-score =(amount − 店舗平均)/店舗標準偏差 を計算し、|z| が 1.5 以上なら 'anomaly'、それ以外は 'normal' の status を付与してください。出力列は store_id, dt, amount, store_avg, store_std, z_score, status、store_id 昇順・dt 昇順で。平均・標準偏差・z は小数第2位まで。

使用テーブル
► store_sales(10行)
store_iddtamount
A2024-01-01100
A2024-01-02105
A2024-01-0395
A2024-01-04100
A2024-01-05300
B2024-01-01500
B2024-01-02510
B2024-01-03490
B2024-01-04505
B2024-01-05495
期待出力
store_iddtamountstore_avgstore_stdz_scorestatus
A2024-01-01100140.0089.51-0.45normal
A2024-01-02105140.0089.51-0.39normal
A2024-01-0395140.0089.51-0.50normal
A2024-01-04100140.0089.51-0.45normal
A2024-01-05300140.0089.51+1.79anomaly
B2024-01-01500500.007.910.00normal
B2024-01-02510500.007.91+1.26normal
B2024-01-03490500.007.91-1.26normal
B2024-01-04505500.007.91+0.63normal
B2024-01-05495500.007.91-0.63normal
模範解答コード
SELECT
  store_id, dt, amount,
  ROUND(AVG(amount)    OVER w, 2) AS store_avg,  -- 店舗内の平均
  ROUND(STDDEV(amount) OVER w, 2) AS store_std,  -- 店舗内の標準偏差
  ROUND(
    (amount - AVG(amount) OVER w)
    / NULLIF(STDDEV(amount) OVER w, 0)      -- SD=0 でのゼロ除算を回避
  , 2) AS z_score,
  CASE
    WHEN ABS(
      (amount - AVG(amount) OVER w)
      / NULLIF(STDDEV(amount) OVER w, 0)
    ) >= 1.5 THEN 'anomaly'
    ELSE 'normal'
  END AS status
FROM store_sales
WINDOW w AS (PARTITION BY store_id)     -- 店舗ごとに窓を分割(使い回し)
ORDER BY store_id, dt;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM store_sales                  → 行を読み込む
  2. WINDOW w (PARTITION BY store_id)  → 窓を定義
  3. AVG/STDDEV OVER w                 → ウィンドウ関数を評価(行数は保持)
  4. (amount-avg)/NULLIF(std,0)        → z-score を計算
  5. CASE WHEN ABS(z) が 1.5 以上         → 異常を判定
  6. SELECT / ORDER BY store_id, dt    → 並び替えて出力
  */
解説(テーブル変化・ポイント)
SELECT store_id, dt, amount, ROUND(AVG(amount) OVER w, 2) AS store_avg, ROUND(STDDEV(amount) OVER w, 2) AS store_std, ROUND( (amount - AVG(amount) OVER w) / NULLIF(STDDEV(amount) OVER w, 0) , 2) AS z_score, CASE WHEN ABS( (amount - AVG(amount) OVER w) / NULLIF(STDDEV(amount) OVER w, 0) ) >= 1.5 THEN 'anomaly' ELSE 'normal' END AS status FROM store_sales WINDOW w AS (PARTITION BY store_id) ORDER BY store_id, dt;
LEGEND
データ取得・読込対象
① FROM store_sales(10行)
FROM store_sales規模の違う2店舗が混在。店舗Aは100前後の小型店、店舗Bは500前後の大型店。これを一律の基準で測ると規模差に飲まれて異常を見失います。
1 / 6
store_iddtamount
A01-01100
A01-02105
A01-0395
A01-04100
A01-05300
B01-01500
B01-02510
B01-03490
B01-04505
B01-05495
10行読込
学習ポイント
異常は「同じ文脈の中」で測る:全社一律の基準では、規模・季節・曜日などの違いが異常に化けます。PARTITION BY比較すべき仲間に窓を区切ると、各セグメント固有の逸脱だけが浮かびます。本問は z-score を店舗単位に拡張したものです。
PARTITION BY と GROUP BY の決定的な違い:どちらもグループ化しますが、GROUP BY行を畳んで集計値だけを返すのに対し、PARTITION BY全行を残したまま各行にグループ集計値を付与します。「元の明細を残して比較したい」異常検知では後者が必須です(Q9 で GROUP BY 側を学びます)。
WINDOW 句で定義を共有:同一の窓を複数の関数で使うなら WINDOW w AS (...) にまとめ、AVG(...) OVER w と参照します。定義が一箇所に集約され、PARTITION/ORDER/フレームの変更漏れも防げます。
アンチパターン
無闇に ORDER BY を追加して全体の集計値を壊す:本問のように ORDER BY を省略した場合、暗黙のフレームは RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING(パーティション内の全行) となり、店舗全体の平均・標準偏差が求まります。しかし、ここに ORDER BY dt を足すと、既定のフレームが RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW(先頭行から現在行まで) に変化してしまいます。結果として「店舗全体の平均」ではなく「その日までの累積平均」が計算されてしまい、正しい z-score になりません。全体集計を意図する場合は ORDER BY を省略するか、明示的に全行をフレーム指定してください。
全データの平均・SDで全セグメントを裁く:規模の異なる集団を混ぜた基準は、小集団の異常を埋もれさせ、正常な大集団を誤検知します。セグメントが分かれているなら必ず PARTITION BY で区切ってください。
標準偏差0の除算を放置:1店舗1行や全値同一だと SD=0 になり、z 計算でゼロ除算エラー(またはInf)が発生します。必ず NULLIF(stddev, 0) で割り、結果を NULL に逃がしてください。
実務コラム:PARTITION BY は「異常検知の文脈スイッチ」
同じ z-score ロジックでも、PARTITION BY store_id(店舗別)、PARTITION BY dow(曜日別)、PARTITION BY product_category(商品別)と窓を変えるだけで検知の観点が切り替わります。実務では複数の PARTITION 軸で並行検知し、どの文脈で外れているかを多面的に見るのが定石です。さらに PARTITION BY store_id ORDER BY dtROWS BETWEEN ... とフレーム指定を足せば、店舗内の移動平均(Q1)にも自然に拡張できます。
QUESTION 9

重複レコード検知 — GROUP BY ... HAVING COUNT(*) > 1 で集約後に異常な集団を絞る

GROUP BYHAVING重複 / 二重計上検知集約後フィルタ
前提知識

同じ取引が二重計上される…データ品質の異常で最も多いのが重複レコードです。これは1行ずつ見ても分からず、同じキーが何回現れたかを数えて初めて見えます。ここで主役になるのが GROUP BY(行を畳んで集計)と HAVING集約結果に対する絞り込み)です。

WHERE  -- 集約「前」の行に対するフィルタ(COUNT等は使えない)
GROUP BY txn_id   -- 同じ txn_id を1グループに畳む
HAVING COUNT(*) > 1  -- 集約「後」の各グループに対するフィルタ
WHERE と HAVING は段階が違う:WHERE はグループ化の個々の行を絞り、HAVING はグループ化の集計値(COUNT・SUM 等)を絞ります。「2回以上出現したキー」は集計してからでないと判定できないので HAVING の出番です。
問題

txn_log(取引ログ)から、同一の txn_id が2回以上記録されている=重複している取引だけを抽出し、その出現回数(occurrences)と金額合計(total_amount)を求めてください。出力列は txn_id, occurrences, total_amount、occurrences の降順・txn_id 昇順で返してください。

使用テーブル
► txn_log(7行)
txn_iddtamount
T-1012024-01-011200
T-1022024-01-01800
T-1012024-01-021200
T-1032024-01-02500
T-1042024-01-03950
T-1022024-01-03800
T-1012024-01-031200
期待出力
txn_idoccurrencestotal_amount
T-10133600
T-10221600
模範解答コード
SELECT
  txn_id,
  COUNT(*)      AS occurrences,   -- グループ内の行数=出現回数
  SUM(amount)  AS total_amount  -- グループ内の金額合計
FROM txn_log
GROUP BY txn_id              -- 同一 txn_id を1グループに畳む
HAVING COUNT(*) > 1          -- 集約後:2回以上出現したグループだけ残す
ORDER BY occurrences DESC, txn_id;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM txn_log           → 行を読み込む
  2. (WHERE なし)           → 行を絞り込む
  3. GROUP BY txn_id        → グループ化
  4. COUNT(*) / SUM(amount) → 集計関数を評価
  5. HAVING COUNT(*) > 1    → グループを絞り込む
  6. SELECT / ORDER BY      → 並び替えて出力
*/
解説(テーブル変化・ポイント)
SELECT txn_id, COUNT(*) AS occurrences, SUM(amount) AS total_amount FROM txn_log GROUP BY txn_id HAVING COUNT(*) > 1 ORDER BY occurrences DESC, txn_id;
LEGEND
データ取得・読込対象
① FROM txn_log(7行)
FROM txn_log取引ログ7行。1行ずつ眺めても重複は見えません。『同じ txn_id が何回あるか』を数えて初めて二重計上が浮かび上がります。
1 / 5
txn_iddtamount
T-10101-011200
T-10201-01800
T-10101-021200
T-10301-02500
T-10401-03950
T-10201-03800
T-10101-031200
7行読込
学習ポイント
WHERE は集約前・HAVING は集約後:これが本問の核心です。WHERE は GROUP BY より前に個々の行を絞り、HAVING は GROUP BY より後に集計値(COUNT/SUM等)を絞ります。「出現回数が2以上」は集計しないと分からないので、WHERE COUNT(*) > 1エラー、正しくは HAVING です。
GROUP BY は行を畳む(PARTITION BY との対比):PARTITION BY が全行を残したのに対し、GROUP BYグループごとに1行へ集約します。「重複の有無」という集団の性質だけが欲しいときは GROUP BY、明細を残して各行を比較したいときは PARTITION BY、と使い分けます。
重複の定義は GROUP BY のキーで決まる:本問は GROUP BY txn_id で「同一ID」を重複とみなしました。「同一ID かつ同一日」を重複とするなら GROUP BY txn_id, dt のようにキーを複合します。何をもって重複とするかは GROUP BY 句がそのまま定義になります。
アンチパターン
WHERE で COUNT を使おうとする:WHERE COUNT(*) > 1 は集約前評価のため構文エラーになります。集計値での絞り込みは必ず HAVING に書いてください。逆に「特定期間の行だけを対象に重複を見たい」なら、その日付絞りは WHERE に書きます(両者は併用可)。
SELECT に集約も GROUP BY もしていない列を混ぜる:SELECT txn_id, dt, COUNT(*) ... GROUP BY txn_id は dt が一意に定まらず、多くのDBでエラー(MySQL は黙って任意の値を返し危険)。SELECT には GROUP BY のキーか集約関数だけを置いてください。
実務コラム:重複検知のその先(ウィンドウ関数での重複「印付け」)
GROUP BY+HAVING は「どのキーが重複したか」の一覧には最適ですが、明細のうちどれを残しどれを消すかには向きません。実務では ROW_NUMBER() OVER (PARTITION BY txn_id ORDER BY dt)(ROW_NUMBER と PARTITION BY の合わせ技)で各重複行に連番を振り、rn = 1 以外を重複として削除…という「重複排除(dedup)」が定番です。検知は GROUP BY、除去はウィンドウ関数、と役割分担で覚えましょう。
QUESTION 10

異常アラート集約 — COUNT(*) FILTER + GROUP BY + HAVING で『要対応カテゴリ』を炙り出す【総まとめ】

COUNT(*) FILTERGROUP BY / HAVING条件付き集約アラート集約総まとめ
前提知識

異常検知の最後は集約してアラートに変える工程です。「カテゴリごとに、異常だった日が何日あり、全体の何%か。異常日が一定以上のカテゴリだけ通知したい」——この『条件付きで数える+集団で絞る』を一発で書くのが COUNT(*) FILTER (WHERE ...)GROUP BY ... HAVING の組み合わせです(各種の検知手法の集大成)。

COUNT(*) FILTER (WHERE amount > 200)  -- 条件に一致した行だけ数える
COUNT(*)                              -- 全行を数える(母数)
-- 同じ GROUP のなかで「全体」と「異常だけ」を同時に集計できる
FILTER は条件付き集約の標準構文:COUNT(*) FILTER (WHERE 条件) は SQL標準で、グループ内の特定条件の行だけを数えます。WHERE でテーブル全体を絞ると母数まで減ってしまいますが、FILTER なら母数(全件)と分子(異常件)を1クエリで併取できます。
問題

metrics(カテゴリ別の日次計測値)から、amount > 200 を「異常」と定義し、カテゴリごとに 総日数(total_days)・異常日数(anomaly_days)・異常率%(anomaly_pct) を集計してください。さらに異常日数が2日以上のカテゴリだけを通知対象として残します。出力列は category, total_days, anomaly_days, anomaly_pct、anomaly_days 降順・category 昇順で。pct は小数第1位まで。

使用テーブル
► metrics(12行)
categorydtamount
web2024-01-01100
web2024-01-02250
web2024-01-03300
auth2024-01-01260
auth2024-01-02240
auth2024-01-03220
api2024-01-01120
api2024-01-02280
api2024-01-03130
cdn2024-01-0190
cdn2024-01-02100
cdn2024-01-0395
期待出力
categorytotal_daysanomaly_daysanomaly_pct
auth33100.0
web3266.7
模範解答コード
SELECT
  category,
  COUNT(*)                                   AS total_days,  -- カテゴリの総日数(母数)
  COUNT(*) FILTER (WHERE amount > 200)     AS anomaly_days,  -- 異常日だけを計数
  ROUND(
    100.0 * COUNT(*) FILTER (WHERE amount > 200)
    / COUNT(*)                                -- 異常率=異常日/総日数
  , 1) AS anomaly_pct
FROM metrics
GROUP BY category                            -- カテゴリ単位に集約
HAVING COUNT(*) FILTER (WHERE amount > 200) >= 2  -- 異常2日以上のみ通知
ORDER BY anomaly_days DESC, category;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM metrics                       → 行を読み込む
  2. GROUP BY category                  → グループ化
  3. COUNT(*) と COUNT(*) FILTER(...)   → 集計関数を評価
  4. ROUND(100.0*異常/母数,1)           → 値を整形
  5. HAVING COUNT(*) FILTER(...) >= 2   → グループを絞り込む
  6. SELECT / ORDER BY anomaly_days DESC → 並び替えて出力
*/
解説(テーブル変化・ポイント)
SELECT category, COUNT(*) AS total_days, COUNT(*) FILTER (WHERE amount > 200) AS anomaly_days, ROUND( 100.0 * COUNT(*) FILTER (WHERE amount > 200) / COUNT(*) , 1) AS anomaly_pct FROM metrics GROUP BY category HAVING COUNT(*) FILTER (WHERE amount > 200) >= 2 ORDER BY anomaly_days DESC, category;
LEGEND
データ取得・読込対象
① FROM metrics(12行)
FROM metrics4カテゴリ×3日の計測値。amount>200 を異常と定義します。生の明細のままでは『どのカテゴリが要対応か』が見えないので、カテゴリ単位に集約していきます。
1 / 6
categorydtamount
web01-01100
web01-02250
web01-03300
auth01-01260
auth01-02240
auth01-03220
api01-01120
api01-02280
api01-03130
cdn01-0190
cdn01-02100
cdn01-0395
12行読込(緑=異常 amount>200)
学習ポイント
FILTER は「母数と分子を1クエリで」取る:WHERE amount > 200 でテーブルを絞ると母数まで異常行だけになり率が出せません。COUNT(*) FILTER (WHERE ...) なら全件の COUNT(*) と並べて、異常率を同時に算出できます。これが条件付き集約の真価です。
整数除算に注意・100.0 を掛ける:COUNT(*) FILTER(...) / COUNT(*) は整数同士だと小数が切り捨てられ 0 になります。先に 100.0(浮動小数)を掛けるCAST(... AS numeric) で実数化してから割ります(NULLIF と並ぶ「割り算の罠」対策)。
HAVING には別名でなく式を書く:多くのDBで HAVING anomaly_days >= 2(SELECT の出力別名)は参照不可です。論理評価順で HAVING は SELECT の別名付与よりのため、HAVING COUNT(*) FILTER (WHERE ...) >= 2 と式を繰り返します(ORDER BY は後段なので別名OK)。
アンチパターン
異常率を出すのに WHERE で全体を絞る:... WHERE amount > 200 GROUP BY category異常行だけを集計するため母数が失われ、率も「全カテゴリ100%」になってしまいます。母数を残すなら FILTER(または CASE 集約)を使ってください。
FILTER 非対応DBで構文エラー:FILTER は PostgreSQL・SQLite 等では使えますが MySQL では未対応です。その場合は SUM(CASE WHEN amount > 200 THEN 1 ELSE 0 END)COUNT(CASE WHEN amount > 200 THEN 1 END) に置き換えます(挙動は同じ)。移植性が要るなら CASE 集約で書きましょう。
実務コラム:検知から「アラート」へ — Q6〜総まとめ
本シリーズの締めくくりとして、異常検知の全体像を整理します。①点異常を z-score(Q3・Q8)や偏差ランキング(Q6)で捉え、②持続ドリフトを累積和(Q7)で追い、③データ品質異常を重複検知(Q9)で拾う。そして最後に本問の④条件付き集約(FILTER + GROUP BY + HAVING)で「どのセグメントが、どれだけ、どの優先度で要対応か」を行動可能なアラートに変換します。検知ロジックは多彩でも、最終的に人を動かすのは「集約された一枚のリスト」です。SQLの集約・ウィンドウ・条件分岐を組み合わせれば、専用ツールなしでも実務水準の異常監視が組めます。