移動平均 — AVG() OVER (ROWS BETWEEN) で直近3日間の売上平均を計算する
異常検知の第一歩は「正常範囲の基準を作る」ことです。単純な全期間平均ではなく、直近N日間の平均(移動平均) を基準にすることで、トレンドを考慮した動的な異常検知が可能になります。
AVG(amount) OVER ( ORDER BY dt ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ) -- ROWS BETWEEN 2 PRECEDING AND CURRENT ROW -- = 現在行 + 2行前 → 直近3行(3日間)が対象 -- 先頭2行は対象行数が不足するため平均の分母が変わる
ROWS BETWEEN は物理的な行数でフレームを定義します。RANGE BETWEEN は値の差でフレームを定義するため、同一 dt の行が複数ある場合に挙動が変わります。時系列の異常検知では行数ベースの ROWS BETWEEN が一般的です。daily_sales テーブルから、各日付の売上(amount)と直近3日間(当日含む)の移動平均(moving_avg)を計算してください。出力列は dt, amount, moving_avg、dt 昇順で返してください。moving_avg は小数第1位まで丸めてください。
| dt | amount |
|---|---|
| 2024-01-01 | 100 |
| 2024-01-02 | 120 |
| 2024-01-03 | 110 |
| 2024-01-04 | 130 |
| 2024-01-05 | 500 |
| 2024-01-06 | 115 |
| 2024-01-07 | 125 |
| dt | amount | moving_avg |
|---|---|---|
| 2024-01-01 | 100 | 100.0 |
| 2024-01-02 | 120 | 110.0 |
| 2024-01-03 | 110 | 110.0 |
| 2024-01-04 | 130 | 120.0 |
| 2024-01-05 | 500 | 246.7 |
| 2024-01-06 | 115 | 248.3 |
| 2024-01-07 | 125 | 246.7 |
SELECT dt, amount, ROUND( AVG(amount) OVER ( -- ウィンドウ集計関数 ORDER BY dt -- 日付昇順に並べてフレームを決定 ROWS BETWEEN 2 PRECEDING -- 2行前から AND CURRENT ROW -- 現在行まで = 直近3行 ), 1 -- 小数第1位まで丸め ) AS moving_avg FROM daily_sales ORDER BY dt; /* 実行順序(SQLの論理的な評価順): 1. FROM daily_sales → 行を読み込む 2. AVG(amount) OVER (...) → ウィンドウ関数を評価(行数は保持) 3. ROUND(..., 1) → 値を整形 4. SELECT → 列を評価(moving_avg) 5. ORDER BY dt → 並び替えて出力 */
LEGEND
① FROM daily_sales(7行)
FROM daily_salesdaily_sales テーブルの7行を読み込みます。2024-01-05 の amount=500 が明らかなスパイクです。移動平均でこの「外れ感」を数値で捉えるのが目標です。| dt | amount |
|---|---|
| 01-01 | 100 |
| 01-02 | 120 |
| 01-03 | 110 |
| 01-04 | 130 |
| 01-05 | 500 |
| 01-06 | 115 |
| 01-07 | 125 |
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW で直近7日間(週次移動平均)、ROWS BETWEEN 29 PRECEDING AND CURRENT ROW で直近30日間の移動平均になります。N を変えるだけで平滑化の粒度を調整できます。AVG FILTER の組み合わせが実務では使われます。RANGE BETWEEN 2 PRECEDING AND CURRENT ROW は「dt の値が現在行から2以下差」という意味になり、日付が重複する場合に意図しない行がフレームに含まれます。行数ベースには必ず ROWS BETWEEN を使ってください。GROUP BY dt と OVER (ORDER BY dt) を組み合わせる場合、GROUP BY が先に実行されます。複数イベント/日が存在するテーブルでは、先に日次集計 CTE を作ってからウィンドウ関数を適用してください。moving_avg と moving_stddev を特徴量として渡すことが標準的です。移動標準偏差 — STDDEV() OVER (ROWS BETWEEN) でばらつきの基準を作る
移動平均だけでは「どれくらいのばらつきが正常か」がわかりません。移動標準偏差(moving_stddev)を組み合わせることで、正常範囲 = 移動平均 ± k×移動標準偏差 という動的な閾値帯を構築できます。
STDDEV(amount) OVER ( ORDER BY dt ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ) -- STDDEV = 標本標準偏差(分母 N-1) -- フレームが1行のとき → NULL(標準偏差は2点以上必要) -- STDDEV_POP = 母標準偏差(分母 N)
STDDEV() は 不偏標準偏差(Bessel 補正あり) です。フレームが1行のときは分母が0になり NULL を返します。COALESCE(STDDEV(...), 0) で NULL を 0 に置換するか、先頭行を判定ロジックで除外することが重要です。daily_sales テーブルから、各日付の売上(amount)・直近3日間の移動平均(moving_avg)・移動標準偏差(moving_stddev)を計算してください。出力列は dt, amount, moving_avg, moving_stddev、dt 昇順で返してください。小数第2位まで丸めてください。
| dt | amount |
|---|---|
| 2024-01-01 | 100 |
| 2024-01-02 | 120 |
| 2024-01-03 | 110 |
| 2024-01-04 | 130 |
| 2024-01-05 | 500 |
| 2024-01-06 | 115 |
| 2024-01-07 | 125 |
| dt | amount | moving_avg | moving_stddev |
|---|---|---|---|
| 2024-01-01 | 100 | 100.00 | NULL |
| 2024-01-02 | 120 | 110.00 | 14.14 |
| 2024-01-03 | 110 | 110.00 | 10.00 |
| 2024-01-04 | 130 | 120.00 | 10.00 |
| 2024-01-05 | 500 | 246.67 | 219.62 |
| 2024-01-06 | 115 | 248.33 | 218.08 |
| 2024-01-07 | 125 | 246.67 | 219.45 |
SELECT dt, amount, ROUND(AVG(amount) OVER w, 2) AS moving_avg, ROUND(STDDEV(amount) OVER w, 2) AS moving_stddev -- 標本標準偏差(N-1) FROM daily_sales WINDOW w AS ( -- ウィンドウ定義を名前付きで共有 ORDER BY dt ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ) ORDER BY dt; /* 実行順序(SQLの論理的な評価順): 1. FROM daily_sales → 行を読み込む 2. WINDOW w AS (...) → 名前付きウィンドウを定義 3. AVG(amount) OVER w → ウィンドウ関数を評価(行数は保持) 4. STDDEV(amount) OVER w → ウィンドウ関数を評価(行数は保持) 5. ROUND(..., 2) → 値を整形 6. SELECT / ORDER BY dt → 列を評価し並び替えて出力 */
LEGEND
① FROM daily_sales(7行)
FROM daily_sales同じテーブルを使います。今回は移動平均に加えて「ばらつき(標準偏差)」も計算することで、正常範囲の上下限を数値で表現します。| dt | amount |
|---|---|
| 01-01 | 100 |
| 01-02 | 120 |
| 01-03 | 110 |
| 01-04 | 130 |
| 01-05 | 500 |
| 01-06 | 115 |
| 01-07 | 125 |
OVER (ORDER BY dt ROWS BETWEEN ...) と繰り返すのは冗長でミスの温床です。WINDOW w AS (...) で一度定義して OVER w と参照することで、フレームの変更も1か所で済みます。STDDEV(= STDDEV_SAMP)は不偏標準偏差(分母 N-1)で、サンプルから母集団を推定するときに使います。STDDEV_POP は母標準偏差(分母 N)で、観測値全体が母集団そのもののとき(例:1日の全トランザクション)に使います。異常検知では慣習的に STDDEV が使われます。(amount - avg) / stddev の計算が NULL になり、異常フラグが正しく立ちません。NULLIF(STDDEV(...), 0) や COALESCE でガードするか、先頭行を WHERE で除外してください。amount / 0 になりエラーが発生します。NULLIF(STDDEV(...), 0) を常に使って、分母が 0 のときは NULL にしてください。z-score による異常スコアリング — (value - avg) / stddev で偏差を標準化する
z-score(標準化スコア)は「現在値が平均からどれだけ標準偏差分離れているか」を表す無次元の指標です。異常検知では |z-score| > 2〜3 を異常の閾値 として使うのが一般的です。
-- z-score の定義 z = (現在値 - 平均) / 標準偏差 -- SQL での実装(移動ウィンドウベース) (amount - AVG(amount) OVER w) / NULLIF(STDDEV(amount) OVER w, 0) -- ゼロ除算をNULLにガード -- |z| > 2 → 正規分布の外側 4.6% → 要注意 -- |z| > 3 → 正規分布の外側 0.3% → 異常とみなす目安
NULLIF(x, 0) は x が 0 のとき NULL を返します。0 で割ろうとする式の分母に NULLIF を挟むだけでゼロ除算エラーを防げます。NULL ÷ anything = NULL となるため、後続の CASE WHEN でフラグを立てることができます。daily_sales テーブルから、直近5日間の移動平均・移動標準偏差を使って各日の z-score を計算し、|z| ≥ 2 なら 'anomaly'、それ以外は 'normal' の anomaly_flag を付与してください。出力列は dt, amount, moving_avg, moving_stddev, z_score, anomaly_flag、dt 昇順で返してください。z_score は小数第2位まで丸めてください。
| dt | amount |
|---|---|
| 2024-01-01 | 100 |
| 2024-01-02 | 105 |
| 2024-01-03 | 98 |
| 2024-01-04 | 102 |
| 2024-01-05 | 110 |
| 2024-01-06 | 400 |
| 2024-01-07 | 108 |
| 2024-01-08 | 103 |
| dt | amount | moving_avg | moving_stddev | z_score | anomaly_flag |
|---|---|---|---|---|---|
| 2024-01-01 | 100 | 100.00 | NULL | NULL | normal |
| 2024-01-02 | 105 | 102.50 | 3.54 | 0.71 | normal |
| 2024-01-03 | 98 | 101.00 | 3.61 | -0.83 | normal |
| 2024-01-04 | 102 | 101.25 | 2.99 | 0.25 | normal |
| 2024-01-05 | 110 | 103.00 | 4.69 | 1.49 | normal |
| 2024-01-06 | 400 | 163.00 | 132.56 | 1.79 | normal |
| 2024-01-07 | 108 | 163.60 | 132.24 | -0.42 | normal |
| 2024-01-08 | 103 | 164.60 | 131.64 | -0.47 | normal |
WITH stats AS ( SELECT dt, amount, ROUND(AVG(amount) OVER w, 2) AS moving_avg, ROUND(STDDEV(amount) OVER w, 2) AS moving_stddev FROM daily_sales WINDOW w AS ( ORDER BY dt ROWS BETWEEN 4 PRECEDING AND CURRENT ROW -- 直近5日間 ) ) SELECT dt, amount, moving_avg, moving_stddev, ROUND( (amount - moving_avg) / NULLIF(moving_stddev, 0), -- ゼロ除算をNULLに変換 2 ) AS z_score, CASE WHEN ABS( (amount - moving_avg) / NULLIF(moving_stddev, 0) ) >= 2 THEN 'anomaly' ELSE 'normal' END AS anomaly_flag FROM stats ORDER BY dt; /* 実行順序(SQLの論理的な評価順): 1. CTE stats を定義 → ウィンドウ関数を評価(行数は保持) 2. 外側クエリを評価 → 列を評価(z_score, anomaly_flag) 3. ORDER BY dt → 並び替えて出力 */
LEGEND
① FROM daily_sales + WINDOW(8行)
CTE stats: FROM + AVG/STDDEV OVER wdaily_sales の8行を読み込みます。| dt | amount |
|---|---|
| 01-01 | 100 |
| 01-02 | 105 |
| 01-03 | 98 |
| 01-04 | 102 |
| 01-05 | 110 |
| 01-06 | 400 |
| 01-07 | 108 |
| 01-08 | 103 |
ROWS BETWEEN 5 PRECEDING AND 1 PRECEDING(当日を除く過去5日) を使い、「当日より前の正常帯」で評価するのが定石です。STDDEV = NULL(分母 N-1=0)、② フレーム内の全値が同一のとき STDDEV = 0(完全な一定値)。どちらも NULLIF(STDDEV(...), 0) で安全にガードでき、結果は NULL になります。SELECT (amount - AVG(amount) OVER w) / NULLIF(STDDEV(amount) OVER w, 0) でも同じ結果が得られますが、CASE WHEN でも同式を繰り返す必要があり冗長です。CTE で先に stats を計算し、外側クエリで参照するパターンを習慣化してください。ROWS BETWEEN N PRECEDING AND 1 PRECEDING の「過去窓」を使って「今日の値を昨日までの正常帯で評価」する設計が重要です。dbt(data build tool)や Apache Airflow のバッチで毎日このクエリを実行し、anomaly_flag = 'anomaly' の行を Slack やメールに通知する構成がよく使われます。モデルが複雑になる前にまずこのシンプルな z-score ベースラインから始めることが推奨されます。IQR による外れ値検出 — PERCENTILE_CONT で四分位範囲を計算する
IQR(四分位範囲)は外れ値に頑健(ロバスト)な異常検知手法です。z-score が外れ値自身によって汚染されるのに対し、IQR は中央50%のデータだけを使うため、スパイクが多い実データでも安定した閾値を計算できます。
-- 第1四分位数 (Q1) と第3四分位数 (Q3) の計算 PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY amount) AS q1 PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY amount) AS q3 -- IQR = Q3 - Q1 -- 外れ値の判定範囲(Tukey の Fence) -- 下限: Q1 - 1.5 * IQR -- 上限: Q3 + 1.5 * IQR
WITHIN GROUP (ORDER BY col) が必須です。PERCENTILE_CONT(0.5) は中央値(MEDIAN)と同義です。PERCENTILE_DISC(離散版)は実在する値を返し、PERCENTILE_CONT(連続版)は線形補間した値を返します。daily_sales テーブルから、全期間の Q1・Q3・IQR を計算し、各日の amount が [Q1 − 1.5×IQR, Q3 + 1.5×IQR] の範囲を外れる場合は 'outlier'、範囲内なら 'normal' の outlier_flag を付与してください。出力列は dt, amount, q1, q3, iqr, lower_fence, upper_fence, outlier_flag、dt 昇順で返してください。
| dt | amount |
|---|---|
| 2024-01-01 | 100 |
| 2024-01-02 | 105 |
| 2024-01-03 | 98 |
| 2024-01-04 | 102 |
| 2024-01-05 | 110 |
| 2024-01-06 | 400 |
| 2024-01-07 | 108 |
| 2024-01-08 | 103 |
| dt | amount | q1 | q3 | iqr | lower_fence | upper_fence | outlier_flag |
|---|---|---|---|---|---|---|---|
| 2024-01-01 | 100 | 101.50 | 108.50 | 7.00 | 91.00 | 119.00 | normal |
| 2024-01-02 | 105 | 101.50 | 108.50 | 7.00 | 91.00 | 119.00 | normal |
| 2024-01-03 | 98 | 101.50 | 108.50 | 7.00 | 91.00 | 119.00 | normal |
| 2024-01-04 | 102 | 101.50 | 108.50 | 7.00 | 91.00 | 119.00 | normal |
| 2024-01-05 | 110 | 101.50 | 108.50 | 7.00 | 91.00 | 119.00 | normal |
| 2024-01-06 | 400 | 101.50 | 108.50 | 7.00 | 91.00 | 119.00 | outlier |
| 2024-01-07 | 108 | 101.50 | 108.50 | 7.00 | 91.00 | 119.00 | normal |
| 2024-01-08 | 103 | 101.50 | 108.50 | 7.00 | 91.00 | 119.00 | normal |
WITH fences AS ( SELECT PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY amount) AS q1, PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY amount) AS q3 FROM daily_sales ) SELECT s.dt, s.amount, f.q1, f.q3, f.q3 - f.q1 AS iqr, f.q1 - 1.5 * (f.q3 - f.q1) AS lower_fence, -- Tukey の下限 f.q3 + 1.5 * (f.q3 - f.q1) AS upper_fence, -- Tukey の上限 CASE WHEN s.amount < f.q1 - 1.5 * (f.q3 - f.q1) OR s.amount > f.q3 + 1.5 * (f.q3 - f.q1) THEN 'outlier' ELSE 'normal' END AS outlier_flag FROM daily_sales s CROSS JOIN fences f -- 全行に同じ q1/q3 を結合(1行CTE) ORDER BY s.dt; /* 実行順序(SQLの論理的な評価順): 1. CTE fences を定義 → 集計関数を評価(Q1, Q3) 2. FROM daily_sales s → 行を読み込む 3. CROSS JOIN fences f → 結合(直積) 4. SELECT → 列を評価(lower_fence, upper_fence, flag) 5. ORDER BY s.dt → 並び替えて出力 */
LEGEND
① CTE fences — 昇順ソートと集約準備
WITHIN GROUP (ORDER BY amount)daily_sales 全8行を昇順ソートします。25パーセンタイル(Q1)は2〜3番目の中間、75パーセンタイル(Q3)は6〜7番目の中間になります。| 順位 | dt | amount(昇順) |
|---|---|---|
| 1 | 01-03 | 98 |
| 2 | 01-01 | 100 |
| 3 | 01-04 | 102 |
| 4 | 01-08 | 103 |
| 5 | 01-02 | 105 |
| 6 | 01-07 | 108 |
| 7 | 01-05 | 110 |
| 8 | 01-06 | 400 |
PERCENTILE_CONT は隣接する2値を線形補間するため非整数値を返します(例: 101.25)。PERCENTILE_DISC は実在する値を返します(例: 102)。連続量(売上・価格)には PERCENTILE_CONT、順位・評価スコアなど離散量には PERCENTILE_DISC を使うのが原則です。PERCENTILE_CONT(0.25) WITHIN GROUP (...) は集約関数でグループ全体を1行に畳みます。PERCENTILE_CONT(0.25) WITHIN GROUP (...) OVER (PARTITION BY ...) とウィンドウ版にすることも可能ですが、WITHIN GROUP の ORDER BY を省略するとエラーになります。時系列の急騰・急落検出 — LAG() で前日比変化率を計算し閾値アラートを作る
実務の異常検知では「絶対値の大きさ」だけでなく、「前日から何%変化したか」(変化率) を監視することが重要です。100 → 500 は +400%の急騰であり、絶対値が低くても変化率は大きい。逆に絶対値が大きくても安定した推移は正常です。
-- 前日比変化率の計算 (amount - LAG(amount) OVER (ORDER BY dt)) / NULLIF(LAG(amount) OVER (ORDER BY dt), 0) * 100.0 -- % に変換 -- 前日値が 0 のとき NULLIF で NULL に変換 → ゼロ除算防止 -- * 100.0(浮動小数点)で整数除算を回避
LAG(過去参照)、翌日比には LEAD(未来参照)を使います。変化率 = (現在 − 前) / |前| × 100 が基本式です。急落の検知には ABS() で絶対値をとるか、負の閾値(例: −50%以下)を別途定義します。daily_sales テーブルから、各日の前日比変化率(pct_change)を計算し、変化率が +50% 以上なら 'spike'、−30% 以下なら 'drop'、それ以外は 'normal' の alert_type を付与してください。出力列は dt, amount, prev_amount, pct_change, alert_type、dt 昇順で返してください。pct_change は小数第1位まで丸めてください。
| dt | amount |
|---|---|
| 2024-01-01 | 100 |
| 2024-01-02 | 110 |
| 2024-01-03 | 105 |
| 2024-01-04 | 108 |
| 2024-01-05 | 400 |
| 2024-01-06 | 60 |
| 2024-01-07 | 112 |
| 2024-01-08 | 115 |
| dt | amount | prev_amount | pct_change | alert_type |
|---|---|---|---|---|
| 2024-01-01 | 100 | NULL | NULL | normal |
| 2024-01-02 | 110 | 100 | 10.0 | normal |
| 2024-01-03 | 105 | 110 | -4.5 | normal |
| 2024-01-04 | 108 | 105 | 2.9 | normal |
| 2024-01-05 | 400 | 108 | 270.4 | spike |
| 2024-01-06 | 60 | 400 | -85.0 | drop |
| 2024-01-07 | 112 | 60 | 86.7 | spike |
| 2024-01-08 | 115 | 112 | 2.7 | normal |
WITH with_prev AS ( SELECT dt, amount, LAG(amount) OVER (ORDER BY dt) AS prev_amount -- 前日の amount を取得 FROM daily_sales ) SELECT dt, amount, prev_amount, ROUND( (amount - prev_amount) * 100.0 / NULLIF(prev_amount, 0), -- % 変化率(ゼロ除算ガード) 1 ) AS pct_change, CASE WHEN (amount - prev_amount) * 1.0 / NULLIF(prev_amount, 0) >= 0.5 -- +50% 以上 THEN 'spike' WHEN (amount - prev_amount) * 1.0 / NULLIF(prev_amount, 0) <= -0.3 -- -30% 以下 THEN 'drop' ELSE 'normal' END AS alert_type FROM with_prev ORDER BY dt; /* 実行順序(SQLの論理的な評価順): 1. CTE with_prev を定義 → ウィンドウ関数を評価(行数は保持) 2. 外側クエリを評価 → 列を評価(pct_change, flag) 3. ORDER BY dt → 並び替えて出力 */
LEGEND
① FROM daily_sales(8行)
FROM daily_salesdaily_sales の8行を読み込みます。| dt | amount |
|---|---|
| 01-01 | 100 |
| 01-02 | 110 |
| 01-03 | 105 |
| 01-04 | 108 |
| 01-05 | 400 |
| 01-06 | 60 |
| 01-07 | 112 |
| 01-08 | 115 |
segment_id 列がある場合、LAG(amount) OVER (PARTITION BY segment_id ORDER BY dt) とすることでセグメントをまたいで前日値を参照しません。PARTITION BY はセグメントの「壁」を作り、独立した変化率計算を保証します。108 / 108 は PostgreSQL では整数除算で 1 になります。* 100.0 は 浮動小数点へのキャストを強制し、0.2963... のような小数値を得るために必須です。または ::float や ::numeric でキャストしても同じ効果があります。LAG(amount) OVER (ORDER BY dt) はカテゴリが混在していても全行を1列として並べます。カテゴリ A の最終行の「次」がカテゴリ B の最初の行として prev_amount に入り込み、意味のない変化率が計算されます。複数カテゴリには必ず PARTITION BY category を追加してください。CASE WHEN pct >= 0.5 THEN 'spike' WHEN pct <= -0.3 THEN 'drop' と書くと +270% は最初の WHEN で捕捉されます。もし spike の条件と drop の条件が重複するような定義(例: どちらも |pct| >= 0.5)にすると、先に書いた WHEN が優先されます。条件の順序を意図通りに設計してください。WHERE alert_type != 'normal' で抽出した行を、Slack の Webhook・PagerDuty・Datadog モニターに流す構成にすれば、データチームが毎朝目視でダッシュボードを確認する「手動監視」から脱却できます。変化率・z-score・IQR の3手法を組み合わせた多層異常検知パイプラインは、本番データ基盤の標準的なアーキテクチャです。