外れ値ランキング — ROW_NUMBER / RANK / 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 () はテーブル全体を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位まで。
| dt | amount |
|---|---|
| 2024-01-01 | 120 |
| 2024-01-02 | 130 |
| 2024-01-03 | 125 |
| 2024-01-04 | 50 |
| 2024-01-05 | 200 |
| 2024-01-06 | 122 |
| 2024-01-07 | 128 |
| 2024-01-08 | 125 |
| dt | amount | deviation | abs_dev | rn | rnk | dense_rnk |
|---|---|---|---|---|---|---|
| 2024-01-04 | 50 | -75.0 | 75.0 | 1 | 1 | 1 |
| 2024-01-05 | 200 | 75.0 | 75.0 | 2 | 1 | 1 |
| 2024-01-01 | 120 | -5.0 | 5.0 | 3 | 3 | 2 |
| 2024-01-02 | 130 | 5.0 | 5.0 | 4 | 3 | 2 |
| 2024-01-06 | 122 | -3.0 | 3.0 | 5 | 5 | 3 |
| 2024-01-07 | 128 | 3.0 | 3.0 | 6 | 5 | 3 |
| 2024-01-03 | 125 | 0.0 | 0.0 | 7 | 7 | 4 |
| 2024-01-08 | 125 | 0.0 | 0.0 | 8 | 7 | 4 |
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 → 並び替えて出力 */
LEGEND
① FROM daily_sales(8行)
FROM daily_sales8日分の売上を読み込みます。01-04(50)の急落と 01-05(200)の急騰が異常候補。どちらが『より異常か』を偏差の大きさで順位付けします。| dt | amount |
|---|---|
| 01-01 | 120 |
| 01-02 | 130 |
| 01-03 | 125 |
| 01-04 | 50 |
| 01-05 | 200 |
| 01-06 | 122 |
| 01-07 | 128 |
| 01-08 | 125 |
WHERE rn <= 3 なら必ず3件、WHERE rnk <= 3 なら同値を含むため4件以上になり得ます(本問では rnk が 1,1,3,3 のため rnk<=3 は4件)。dense_rnk <= 3 はabs_dev の上位3グループ=6件。目的に応じて使い分けます。AVG(amount) OVER () 一発で全行に基準値を付与できます。GROUP BY と違い行数は保たれるため、各行を残したまま偏差を計算できます。ORDER BY abs_dev DESC, dt のように一意になるタイブレークキー(日付やID)を必ず添えます。rn <= N で機械的に切ると、片方だけ拾って片方を見逃します。同点もすべて拾いたいなら RANK を使ってください。ORDER BY abs_dev DESC LIMIT 3 は同値の扱いが不定で、境界の事象を恣意的に切り捨てます。順位関数を使えば「同点をどう扱うか」を意図として明示できます。RANK() OVER (ORDER BY score DESC) で並べて上位だけを人間がレビューする運用が定番です。PARTITION BY date を足せば「日ごとのワースト異常」も同時に取れます(PARTITION BY date で日単位に区切る方法)。累積偏差によるドリフト検知 — SUM() OVER で目標値からの累積ズレを追跡する
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 昇順で返してください。
| dt | amount |
|---|---|
| 2024-01-01 | 100 |
| 2024-01-02 | 102 |
| 2024-01-03 | 101 |
| 2024-01-04 | 103 |
| 2024-01-05 | 105 |
| 2024-01-06 | 104 |
| 2024-01-07 | 106 |
| 2024-01-08 | 108 |
| dt | amount | daily_dev | cum_dev | drift_flag |
|---|---|---|---|---|
| 2024-01-01 | 100 | 0 | 0 | normal |
| 2024-01-02 | 102 | +2 | 2 | normal |
| 2024-01-03 | 101 | +1 | 3 | normal |
| 2024-01-04 | 103 | +3 | 6 | normal |
| 2024-01-05 | 105 | +5 | 11 | normal |
| 2024-01-06 | 104 | +4 | 15 | normal |
| 2024-01-07 | 106 | +6 | 21 | drift_alert |
| 2024-01-08 | 108 | +8 | 29 | drift_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 → 並び替えて出力 */
LEGEND
① FROM daily_sales(8行)
FROM daily_sales目標値100に対し、毎日わずかに上回る売上です。前日比はどれも数%以内で、点異常検知(z-score・変化率)では何も引っかかりません。| dt | amount |
|---|---|
| 01-01 | 100 |
| 01-02 | 102 |
| 01-03 | 101 |
| 01-04 | 103 |
| 01-05 | 105 |
| 01-06 | 104 |
| 01-07 | 106 |
| 01-08 | 108 |
SUM(x) OVER (ORDER BY dt) はフレーム省略で UNBOUNDED PRECEDING 〜 CURRENT ROW の累積になります。一方フレームも ORDER BY も無い SUM(x) OVER () は全体合計。挙動が全く違うので意図を明示しましょう。amount - AVG(amount) OVER (ORDER BY dt ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) を累積すれば、移動平均からの累積ズレになり、トレンドが変化する系列にも適応します。SUM(x) OVER () と書くと累積ではなく全行合計が全行に入り、ドリフトが見えません。累積和には必ず ORDER BY を付けてください。cum_dev > 20 だけでは下振れの累積(急落ドリフト)を見逃します。下方向も監視するなら ABS(cum_dev) や両側の閾値を設定してください。セグメント別 z-score — PARTITION BY で店舗ごとに基準を変えて異常を検知する
「全社平均」で異常を測ると、規模の違う店舗が混ざったときに破綻します。小型店の高額売上が大型店の基準では正常に見え、逆もまた然り。異常は『同じ仲間(セグメント)の中』で測るのが鉄則です。PARTITION BY は窓をグループごとに分割し、各行に「その所属グループ内の集計値」を付与します。
AVG(amount) OVER (PARTITION BY store_id) -- 店舗ごとの平均 STDDEV(amount) OVER (PARTITION BY store_id) -- 店舗ごとの標準偏差 -- GROUP BY と違い行は畳まれず、各行に所属グループの集計値が並走する
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_id | dt | amount |
|---|---|---|
| A | 2024-01-01 | 100 |
| A | 2024-01-02 | 105 |
| A | 2024-01-03 | 95 |
| A | 2024-01-04 | 100 |
| A | 2024-01-05 | 300 |
| B | 2024-01-01 | 500 |
| B | 2024-01-02 | 510 |
| B | 2024-01-03 | 490 |
| B | 2024-01-04 | 505 |
| B | 2024-01-05 | 495 |
| store_id | dt | amount | store_avg | store_std | z_score | status |
|---|---|---|---|---|---|---|
| A | 2024-01-01 | 100 | 140.00 | 89.51 | -0.45 | normal |
| A | 2024-01-02 | 105 | 140.00 | 89.51 | -0.39 | normal |
| A | 2024-01-03 | 95 | 140.00 | 89.51 | -0.50 | normal |
| A | 2024-01-04 | 100 | 140.00 | 89.51 | -0.45 | normal |
| A | 2024-01-05 | 300 | 140.00 | 89.51 | +1.79 | anomaly |
| B | 2024-01-01 | 500 | 500.00 | 7.91 | 0.00 | normal |
| B | 2024-01-02 | 510 | 500.00 | 7.91 | +1.26 | normal |
| B | 2024-01-03 | 490 | 500.00 | 7.91 | -1.26 | normal |
| B | 2024-01-04 | 505 | 500.00 | 7.91 | +0.63 | normal |
| B | 2024-01-05 | 495 | 500.00 | 7.91 | -0.63 | normal |
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 → 並び替えて出力 */
LEGEND
① FROM store_sales(10行)
FROM store_sales規模の違う2店舗が混在。店舗Aは100前後の小型店、店舗Bは500前後の大型店。これを一律の基準で測ると規模差に飲まれて異常を見失います。| store_id | dt | amount |
|---|---|---|
| A | 01-01 | 100 |
| A | 01-02 | 105 |
| A | 01-03 | 95 |
| A | 01-04 | 100 |
| A | 01-05 | 300 |
| B | 01-01 | 500 |
| B | 01-02 | 510 |
| B | 01-03 | 490 |
| B | 01-04 | 505 |
| B | 01-05 | 495 |
PARTITION BY で比較すべき仲間に窓を区切ると、各セグメント固有の逸脱だけが浮かびます。本問は z-score を店舗単位に拡張したものです。GROUP BY は行を畳んで集計値だけを返すのに対し、PARTITION BY は全行を残したまま各行にグループ集計値を付与します。「元の明細を残して比較したい」異常検知では後者が必須です(Q9 で GROUP BY 側を学びます)。WINDOW w AS (...) にまとめ、AVG(...) OVER w と参照します。定義が一箇所に集約され、PARTITION/ORDER/フレームの変更漏れも防げます。ORDER BY を省略した場合、暗黙のフレームは RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING(パーティション内の全行) となり、店舗全体の平均・標準偏差が求まります。しかし、ここに ORDER BY dt を足すと、既定のフレームが RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW(先頭行から現在行まで) に変化してしまいます。結果として「店舗全体の平均」ではなく「その日までの累積平均」が計算されてしまい、正しい z-score になりません。全体集計を意図する場合は ORDER BY を省略するか、明示的に全行をフレーム指定してください。NULLIF(stddev, 0) で割り、結果を NULL に逃がしてください。PARTITION BY store_id(店舗別)、PARTITION BY dow(曜日別)、PARTITION BY product_category(商品別)と窓を変えるだけで検知の観点が切り替わります。実務では複数の PARTITION 軸で並行検知し、どの文脈で外れているかを多面的に見るのが定石です。さらに PARTITION BY store_id ORDER BY dt に ROWS BETWEEN ... とフレーム指定を足せば、店舗内の移動平均(Q1)にも自然に拡張できます。重複レコード検知 — GROUP BY ... HAVING COUNT(*) > 1 で集約後に異常な集団を絞る
同じ取引が二重計上される…データ品質の異常で最も多いのが重複レコードです。これは1行ずつ見ても分からず、同じキーが何回現れたかを数えて初めて見えます。ここで主役になるのが GROUP BY(行を畳んで集計)と HAVING(集約結果に対する絞り込み)です。
WHERE -- 集約「前」の行に対するフィルタ(COUNT等は使えない) GROUP BY txn_id -- 同じ txn_id を1グループに畳む HAVING COUNT(*) > 1 -- 集約「後」の各グループに対するフィルタ
WHERE はグループ化前の個々の行を絞り、HAVING はグループ化後の集計値(COUNT・SUM 等)を絞ります。「2回以上出現したキー」は集計してからでないと判定できないので HAVING の出番です。txn_log(取引ログ)から、同一の txn_id が2回以上記録されている=重複している取引だけを抽出し、その出現回数(occurrences)と金額合計(total_amount)を求めてください。出力列は txn_id, occurrences, total_amount、occurrences の降順・txn_id 昇順で返してください。
| txn_id | dt | amount |
|---|---|---|
| T-101 | 2024-01-01 | 1200 |
| T-102 | 2024-01-01 | 800 |
| T-101 | 2024-01-02 | 1200 |
| T-103 | 2024-01-02 | 500 |
| T-104 | 2024-01-03 | 950 |
| T-102 | 2024-01-03 | 800 |
| T-101 | 2024-01-03 | 1200 |
| txn_id | occurrences | total_amount |
|---|---|---|
| T-101 | 3 | 3600 |
| T-102 | 2 | 1600 |
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 → 並び替えて出力 */
LEGEND
① FROM txn_log(7行)
FROM txn_log取引ログ7行。1行ずつ眺めても重複は見えません。『同じ txn_id が何回あるか』を数えて初めて二重計上が浮かび上がります。| txn_id | dt | amount |
|---|---|---|
| T-101 | 01-01 | 1200 |
| T-102 | 01-01 | 800 |
| T-101 | 01-02 | 1200 |
| T-103 | 01-02 | 500 |
| T-104 | 01-03 | 950 |
| T-102 | 01-03 | 800 |
| T-101 | 01-03 | 1200 |
WHERE は GROUP BY より前に個々の行を絞り、HAVING は GROUP BY より後に集計値(COUNT/SUM等)を絞ります。「出現回数が2以上」は集計しないと分からないので、WHERE COUNT(*) > 1 はエラー、正しくは HAVING です。PARTITION BY が全行を残したのに対し、GROUP BY はグループごとに1行へ集約します。「重複の有無」という集団の性質だけが欲しいときは GROUP BY、明細を残して各行を比較したいときは PARTITION BY、と使い分けます。GROUP BY txn_id で「同一ID」を重複とみなしました。「同一ID かつ同一日」を重複とするなら GROUP BY txn_id, dt のようにキーを複合します。何をもって重複とするかは GROUP BY 句がそのまま定義になります。WHERE COUNT(*) > 1 は集約前評価のため構文エラーになります。集計値での絞り込みは必ず HAVING に書いてください。逆に「特定期間の行だけを対象に重複を見たい」なら、その日付絞りは WHERE に書きます(両者は併用可)。SELECT txn_id, dt, COUNT(*) ... GROUP BY txn_id は dt が一意に定まらず、多くのDBでエラー(MySQL は黙って任意の値を返し危険)。SELECT には GROUP BY のキーか集約関数だけを置いてください。ROW_NUMBER() OVER (PARTITION BY txn_id ORDER BY dt)(ROW_NUMBER と PARTITION BY の合わせ技)で各重複行に連番を振り、rn = 1 以外を重複として削除…という「重複排除(dedup)」が定番です。検知は GROUP BY、除去はウィンドウ関数、と役割分担で覚えましょう。異常アラート集約 — COUNT(*) FILTER + GROUP BY + HAVING で『要対応カテゴリ』を炙り出す【総まとめ】
異常検知の最後は集約してアラートに変える工程です。「カテゴリごとに、異常だった日が何日あり、全体の何%か。異常日が一定以上のカテゴリだけ通知したい」——この『条件付きで数える+集団で絞る』を一発で書くのが COUNT(*) FILTER (WHERE ...) と GROUP BY ... HAVING の組み合わせです(各種の検知手法の集大成)。
COUNT(*) FILTER (WHERE amount > 200) -- 条件に一致した行だけ数える COUNT(*) -- 全行を数える(母数) -- 同じ GROUP のなかで「全体」と「異常だけ」を同時に集計できる
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位まで。
| category | dt | amount |
|---|---|---|
| web | 2024-01-01 | 100 |
| web | 2024-01-02 | 250 |
| web | 2024-01-03 | 300 |
| auth | 2024-01-01 | 260 |
| auth | 2024-01-02 | 240 |
| auth | 2024-01-03 | 220 |
| api | 2024-01-01 | 120 |
| api | 2024-01-02 | 280 |
| api | 2024-01-03 | 130 |
| cdn | 2024-01-01 | 90 |
| cdn | 2024-01-02 | 100 |
| cdn | 2024-01-03 | 95 |
| category | total_days | anomaly_days | anomaly_pct |
|---|---|---|---|
| auth | 3 | 3 | 100.0 |
| web | 3 | 2 | 66.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 → 並び替えて出力 */
LEGEND
① FROM metrics(12行)
FROM metrics4カテゴリ×3日の計測値。amount>200 を異常と定義します。生の明細のままでは『どのカテゴリが要対応か』が見えないので、カテゴリ単位に集約していきます。| category | dt | amount |
|---|---|---|
| web | 01-01 | 100 |
| web | 01-02 | 250 |
| web | 01-03 | 300 |
| auth | 01-01 | 260 |
| auth | 01-02 | 240 |
| auth | 01-03 | 220 |
| api | 01-01 | 120 |
| api | 01-02 | 280 |
| api | 01-03 | 130 |
| cdn | 01-01 | 90 |
| cdn | 01-02 | 100 |
| cdn | 01-03 | 95 |
WHERE amount > 200 でテーブルを絞ると母数まで異常行だけになり率が出せません。COUNT(*) FILTER (WHERE ...) なら全件の COUNT(*) と並べて、異常率を同時に算出できます。これが条件付き集約の真価です。100.0 を掛ける:COUNT(*) FILTER(...) / COUNT(*) は整数同士だと小数が切り捨てられ 0 になります。先に 100.0(浮動小数)を掛けるか CAST(... AS numeric) で実数化してから割ります(NULLIF と並ぶ「割り算の罠」対策)。HAVING anomaly_days >= 2(SELECT の出力別名)は参照不可です。論理評価順で HAVING は SELECT の別名付与より前のため、HAVING COUNT(*) FILTER (WHERE ...) >= 2 と式を繰り返します(ORDER BY は後段なので別名OK)。... WHERE amount > 200 GROUP BY category は異常行だけを集計するため母数が失われ、率も「全カテゴリ100%」になってしまいます。母数を残すなら FILTER(または CASE 集約)を使ってください。FILTER は PostgreSQL・SQLite 等では使えますが MySQL では未対応です。その場合は SUM(CASE WHEN amount > 200 THEN 1 ELSE 0 END) や COUNT(CASE WHEN amount > 200 THEN 1 END) に置き換えます(挙動は同じ)。移植性が要るなら CASE 集約で書きましょう。