SQL 異常検知 — 移動平均・z-score・IQRの基礎

基礎異常検知移動平均・移動標準偏差z-score / IQRROWS BETWEENPostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 1

移動平均 — AVG() OVER (ROWS BETWEEN) で直近3日間の売上平均を計算する

AVG OVERROWS BETWEEN移動平均時系列分析
前提知識

異常検知の第一歩は「正常範囲の基準を作る」ことです。単純な全期間平均ではなく、直近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 vs RANGE BETWEEN:ROWS BETWEEN物理的な行数でフレームを定義します。RANGE BETWEEN は値の差でフレームを定義するため、同一 dt の行が複数ある場合に挙動が変わります。時系列の異常検知では行数ベースの ROWS BETWEEN が一般的です。
問題

daily_sales テーブルから、各日付の売上(amount)と直近3日間(当日含む)の移動平均(moving_avg)を計算してください。出力列は dt, amount, moving_avg、dt 昇順で返してください。moving_avg は小数第1位まで丸めてください。

使用テーブル
► daily_sales(7行)
dtamount
2024-01-01100
2024-01-02120
2024-01-03110
2024-01-04130
2024-01-05500
2024-01-06115
2024-01-07125
期待出力
dtamountmoving_avg
2024-01-01100100.0
2024-01-02120110.0
2024-01-03110110.0
2024-01-04130120.0
2024-01-05500246.7
2024-01-06115248.3
2024-01-07125246.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             → 並び替えて出力
*/
解説(テーブル変化・ポイント)
SELECT dt, amount, ROUND( AVG(amount) OVER ( ORDER BY dt ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ), 1 ) AS moving_avg FROM daily_sales ORDER BY dt;
LEGEND
データ取得・読込対象
① FROM daily_sales(7行)
FROM daily_salesdaily_sales テーブルの7行を読み込みます。2024-01-05 の amount=500 が明らかなスパイクです。移動平均でこの「外れ感」を数値で捉えるのが目標です。
1 / 5
dtamount
01-01100
01-02120
01-03110
01-04130
01-05500
01-06115
01-07125
7行読込
学習ポイント
ROWS BETWEEN のフレーム指定バリエーション:ROWS BETWEEN 6 PRECEDING AND CURRENT ROW で直近7日間(週次移動平均)、ROWS BETWEEN 29 PRECEDING AND CURRENT ROW で直近30日間の移動平均になります。N を変えるだけで平滑化の粒度を調整できます
移動平均は異常検知の「ベースライン」になる:移動平均は正常な傾向(トレンド)を表します。実測値 − 移動平均 = 残差 とすることでトレンドを除去した偏差が得られ、「今日の値は平均からいくら離れているか」が定量化できます。z-score はこの発展形です。
スパイクが後続の移動平均を汚染する:01-05 のスパイクにより 01-06・01-07 の moving_avg も高止まりしています。異常値の影響を受けにくくする手法として、中央値ベースの移動メジアン(PERCENTILE_CONT)や、外れ値を除いた AVG FILTER の組み合わせが実務では使われます。
アンチパターン
RANGE BETWEEN を使うと重複日付で想定外の挙動になる:RANGE BETWEEN 2 PRECEDING AND CURRENT ROW は「dt の値が現在行から2以下差」という意味になり、日付が重複する場合に意図しない行がフレームに含まれます。行数ベースには必ず ROWS BETWEEN を使ってください。
GROUP BY と OVER の混在に注意:同一クエリで GROUP BY dtOVER (ORDER BY dt) を組み合わせる場合、GROUP BY が先に実行されます。複数イベント/日が存在するテーブルでは、先に日次集計 CTE を作ってからウィンドウ関数を適用してください。
実務コラム:移動平均の窓サイズの選び方
移動平均の窓サイズはノイズ除去と反応速度のトレードオフです。窓が小さいと最新の変化に敏感(誤検知しやすい)、大きいと平滑化が強くなり本物の異常も遅れて検知されます。ECサイトの売上異常では7日(週次)が曜日効果を吸収できるためよく使われます。機械学習パイプラインでは、moving_avgmoving_stddev を特徴量として渡すことが標準的です。
QUESTION 2

移動標準偏差 — STDDEV() OVER (ROWS BETWEEN) でばらつきの基準を作る

STDDEV OVERROWS 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 は標本標準偏差(分母 N-1):PostgreSQL の 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位まで丸めてください。

使用テーブル
► daily_sales(7行)
dtamount
2024-01-01100
2024-01-02120
2024-01-03110
2024-01-04130
2024-01-05500
2024-01-06115
2024-01-07125
期待出力
dtamountmoving_avgmoving_stddev
2024-01-01100100.00NULL
2024-01-02120110.0014.14
2024-01-03110110.0010.00
2024-01-04130120.0010.00
2024-01-05500246.67219.62
2024-01-06115248.33218.08
2024-01-07125246.67219.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    → 列を評価し並び替えて出力
*/
解説(テーブル変化・ポイント)
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 2 PRECEDING AND CURRENT ROW ) ORDER BY dt;
LEGEND
データ取得・読込対象
① FROM daily_sales(7行)
FROM daily_sales同じテーブルを使います。今回は移動平均に加えて「ばらつき(標準偏差)」も計算することで、正常範囲の上下限を数値で表現します。
1 / 5
dtamount
01-01100
01-02120
01-03110
01-04130
01-05500
01-06115
01-07125
7行読込
学習ポイント
WINDOW 句による再利用でコードを DRY に保つ:同一フレーム定義を OVER (ORDER BY dt ROWS BETWEEN ...) と繰り返すのは冗長でミスの温床です。WINDOW w AS (...) で一度定義して OVER w と参照することで、フレームの変更も1か所で済みます
STDDEV vs STDDEV_POP の使い分け:STDDEV(= STDDEV_SAMP)は不偏標準偏差(分母 N-1)で、サンプルから母集団を推定するときに使います。STDDEV_POP は母標準偏差(分母 N)で、観測値全体が母集団そのもののとき(例:1日の全トランザクション)に使います。異常検知では慣習的に STDDEV が使われます。
stddev が膨張したら「正常範囲の喪失」のサイン:スパイク混入後に stddev が急増すると、正常帯 = avg ± 2σ が広がりすぎて次の異常値を見逃しやすくなります。この「汚染」を避けるため、実務ではスパイクを除外してから stddev を計算する 2-pass アプローチがよく使われます。
アンチパターン
STDDEV の NULL を除去せず後続計算でエラーになる:フレームが1行のとき STDDEV は NULL を返します。(amount - avg) / stddev の計算が NULL になり、異常フラグが正しく立ちません。NULLIF(STDDEV(...), 0)COALESCE でガードするか、先頭行を WHERE で除外してください。
STDDEV=0 での割り算(ゼロ除算)に注意:フレーム内の全値が同一のとき STDDEV=0 になります。後続の z-score 計算で amount / 0 になりエラーが発生します。NULLIF(STDDEV(...), 0) を常に使って、分母が 0 のときは NULL にしてください。
実務コラム:ボリンジャーバンドと異常検知の関係
移動平均 ± 2×移動標準偏差で定義した上下限帯は、金融のボリンジャーバンドと同一の概念です。価格がバンドを外れたとき「統計的に珍しい」と判断します。データ分析では、売上・エラー率・レイテンシなど任意の指標にこのパターンを適用できます。機械学習ベースの異常検知(Isolation Forest など)も、特徴量としてこの残差(偏差)を入力に使います。
QUESTION 3

z-score による異常スコアリング — (value - avg) / stddev で偏差を標準化する

z-scoreNULLIF異常スコアリング標準化
前提知識

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(expr, 0) でゼロ除算をガード: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位まで丸めてください。

使用テーブル
► daily_sales(8行)
dtamount
2024-01-01100
2024-01-02105
2024-01-0398
2024-01-04102
2024-01-05110
2024-01-06400
2024-01-07108
2024-01-08103
期待出力
dtamountmoving_avgmoving_stddevz_scoreanomaly_flag
2024-01-01100100.00NULLNULLnormal
2024-01-02105102.503.540.71normal
2024-01-0398101.003.61-0.83normal
2024-01-04102101.252.990.25normal
2024-01-05110103.004.691.49normal
2024-01-06400163.00132.561.79normal
2024-01-07108163.60132.24-0.42normal
2024-01-08103164.60131.64-0.47normal
模範解答コード
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          → 並び替えて出力
*/
解説(テーブル変化・ポイント)
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 ) ) SELECT dt, amount, moving_avg, moving_stddev, ROUND( (amount - moving_avg) / NULLIF(moving_stddev, 0), 2 ) AS z_score, CASE WHEN ABS(...)>= 2 THEN 'anomaly' ELSE 'normal' END AS anomaly_flag FROM stats ORDER BY dt;
LEGEND
データ取得・読込対象
① FROM daily_sales + WINDOW(8行)
CTE stats: FROM + AVG/STDDEV OVER wdaily_sales の8行を読み込みます。
1 / 6
dtamount
01-01100
01-02105
01-0398
01-04102
01-05110
01-06400
01-07108
01-08103
8行読込
学習ポイント
スパイク汚染の本質的な解決策 — 「過去窓」のみを使う:当日を含めて計算すると、スパイク自体が平均と標準偏差を押し上げ z-score が小さくなります。実務では ROWS BETWEEN 5 PRECEDING AND 1 PRECEDING(当日を除く過去5日) を使い、「当日より前の正常帯」で評価するのが定石です。
z-score の閾値と正規分布の対応:正規分布を仮定すると、|z| > 2 は全体の約4.6%、|z| > 3 は約0.3%の確率しか起こらない値です。ビジネスの許容誤検知率に応じて閾値を 2〜3 の間で調整します。EC の注文数スパイクなら 2、医療センサーなら 3 以上を使うことが多いです。
NULLIF が必要な2つのケース:① フレーム1行のとき STDDEV = NULL(分母 N-1=0)、② フレーム内の全値が同一のとき STDDEV = 0(完全な一定値)。どちらも NULLIF(STDDEV(...), 0) で安全にガードでき、結果は NULL になります
アンチパターン
全期間の固定 AVG/STDDEV を使う:テーブル全体の AVG と STDDEV で z-score を計算すると、将来のデータも計算に含まれてしまう「未来参照(データリーク)」になります。リアルタイム検知パイプラインでは必ず過去窓の移動統計を使ってください。
CTE を使わず SELECT 内で重複計算する:SELECT (amount - AVG(amount) OVER w) / NULLIF(STDDEV(amount) OVER w, 0) でも同じ結果が得られますが、CASE WHEN でも同式を繰り返す必要があり冗長です。CTE で先に stats を計算し、外側クエリで参照するパターンを習慣化してください。
実務コラム:z-score のリアルタイム適用とデータパイプライン
z-score 異常検知を本番パイプラインで動かすには、ROWS BETWEEN N PRECEDING AND 1 PRECEDING の「過去窓」を使って「今日の値を昨日までの正常帯で評価」する設計が重要です。dbt(data build tool)や Apache Airflow のバッチで毎日このクエリを実行し、anomaly_flag = 'anomaly' の行を Slack やメールに通知する構成がよく使われます。モデルが複雑になる前にまずこのシンプルな z-score ベースラインから始めることが推奨されます。
QUESTION 4

IQR による外れ値検出 — PERCENTILE_CONT で四分位範囲を計算する

PERCENTILE_CONTWITHIN GROUPIQR 外れ値検出ロバスト統計
前提知識

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
PERCENTILE_CONT は順序集合集約関数(Ordered-Set Aggregate):通常の集約関数(SUM, AVG)と異なり、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 昇順で返してください。

使用テーブル
► daily_sales(8行)
dtamount
2024-01-01100
2024-01-02105
2024-01-0398
2024-01-04102
2024-01-05110
2024-01-06400
2024-01-07108
2024-01-08103
期待出力
dtamountq1q3iqrlower_fenceupper_fenceoutlier_flag
2024-01-01100101.50108.507.0091.00119.00normal
2024-01-02105101.50108.507.0091.00119.00normal
2024-01-0398101.50108.507.0091.00119.00normal
2024-01-04102101.50108.507.0091.00119.00normal
2024-01-05110101.50108.507.0091.00119.00normal
2024-01-06400101.50108.507.0091.00119.00outlier
2024-01-07108101.50108.507.0091.00119.00normal
2024-01-08103101.50108.507.0091.00119.00normal
模範解答コード
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         → 並び替えて出力
*/
解説(テーブル変化・ポイント)
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, f.q3 + 1.5 * (f.q3 - f.q1) AS upper_fence, CASE WHEN s.amount < lower OR s.amount > upper THEN 'outlier' ELSE 'normal' END AS outlier_flag FROM daily_sales s CROSS JOIN fences f ORDER BY s.dt;
LEGEND
データ取得・読込対象
① CTE fences — 昇順ソートと集約準備
WITHIN GROUP (ORDER BY amount)daily_sales 全8行を昇順ソートします。25パーセンタイル(Q1)は2〜3番目の中間、75パーセンタイル(Q3)は6〜7番目の中間になります。
1 / 5
順位dtamount(昇順)
101-0398
201-01100
301-04102
401-08103
501-02105
601-07108
701-05110
801-06400
8行(昇順ソート済)
学習ポイント
IQR が z-score より外れ値に強い理由:z-score の平均・標準偏差は外れ値自身に引っ張られます(第1〜4四半位の値に左右される)。IQR は第1〜3四分位数(中央50%)だけを使うため、外れ値が全体の25%未満であれば Q1/Q3 への影響は軽微です。これを「ロバスト統計」と呼びます。
PERCENTILE_CONT vs PERCENTILE_DISC:PERCENTILE_CONT は隣接する2値を線形補間するため非整数値を返します(例: 101.25)。PERCENTILE_DISC は実在する値を返します(例: 102)。連続量(売上・価格)には PERCENTILE_CONT、順位・評価スコアなど離散量には PERCENTILE_DISC を使うのが原則です。
係数 1.5 の根拠(Tukey の Fence):1.5 は統計学者 John Tukey が箱ひげ図で提案した値で、正規分布なら約99.3%のデータがこの範囲に収まります。より厳しい検知には 3.0(extreme outlier)を使い、誤検知を減らします。
アンチパターン
PERCENTILE_CONT に OVER を付ける(ウィンドウ版)との混同:PERCENTILE_CONT(0.25) WITHIN GROUP (...) は集約関数でグループ全体を1行に畳みます。PERCENTILE_CONT(0.25) WITHIN GROUP (...) OVER (PARTITION BY ...) とウィンドウ版にすることも可能ですが、WITHIN GROUP の ORDER BY を省略するとエラーになります。
外れ値を「除去」して再集計するときの順序ミス:IQR で外れ値を検出した後に WHERE outlier_flag = 'normal' で除いた平均を計算することがあります。この場合、fences CTE を計算するテーブルと、平均を計算するテーブルを別々の CTE に分けて、外れ値除去後のデータで再集計する順序を守ってください。
実務コラム:z-score と IQR の使い分け指針
実務では両手法を組み合わせることが多いです。z-score が向く場面:データが正規分布に近い(例:体温・製造ラインの寸法誤差)、連続した時系列で過去窓ベースのリアルタイム検知を行う。IQR が向く場面:データが歪んでいる(売上・ページビューなど対数正規分布的)、外れ値が多く含まれる可能性がある、バッチ集計で全期間の安定した閾値を作りたい。両方の異常フラグがついた場合だけアラートにする「AND 結合」で誤検知を減らすことも有効です。
QUESTION 5

時系列の急騰・急落検出 — LAG() で前日比変化率を計算し閾値アラートを作る

LAGCASE WHEN変化率検出時系列アラート
前提知識

実務の異常検知では「絶対値の大きさ」だけでなく、「前日から何%変化したか」(変化率) を監視することが重要です。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(浮動小数点)で整数除算を回避
LEAD/LAG の使い分けと変化率パターン:前日比には 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位まで丸めてください。

使用テーブル
► daily_sales(8行)
dtamount
2024-01-01100
2024-01-02110
2024-01-03105
2024-01-04108
2024-01-05400
2024-01-0660
2024-01-07112
2024-01-08115
期待出力
dtamountprev_amountpct_changealert_type
2024-01-01100NULLNULLnormal
2024-01-0211010010.0normal
2024-01-03105110-4.5normal
2024-01-041081052.9normal
2024-01-05400108270.4spike
2024-01-0660400-85.0drop
2024-01-071126086.7spike
2024-01-081151122.7normal
模範解答コード
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           → 並び替えて出力
*/
解説(テーブル変化・ポイント)
WITH with_prev AS ( SELECT dt, amount, LAG(amount) OVER (ORDER BY dt) AS prev_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 ... >= 0.5 THEN 'spike' WHEN ... <= -0.3 THEN 'drop' ELSE 'normal' END AS alert_type FROM with_prev ORDER BY dt;
LEGEND
データ取得・読込対象
① FROM daily_sales(8行)
FROM daily_salesdaily_sales の8行を読み込みます。
1 / 6
dtamount
01-01100
01-02110
01-03105
01-04108
01-05400
01-0660
01-07112
01-08115
8行読込
学習ポイント
変化率と絶対値検知の組み合わせが最強:変化率だけでは「100 → 150」も「10,000 → 15,000」も同じ +50% として扱います。実務では 変化率(前日比 ≥ 50%)AND 絶対偏差(|amount - moving_avg| ≥ 閾値) の両条件を組み合わせることで誤検知が大幅に減ります。
複数セグメント(商品・店舗別)での変化率計算:テーブルに segment_id 列がある場合、LAG(amount) OVER (PARTITION BY segment_id ORDER BY dt) とすることでセグメントをまたいで前日値を参照しません。PARTITION BY はセグメントの「壁」を作り、独立した変化率計算を保証します。
* 100.0(浮動小数点リテラル)で整数除算を回避:108 / 108 は PostgreSQL では整数除算で 1 になります。* 100.0浮動小数点へのキャストを強制し、0.2963... のような小数値を得るために必須です。または ::float::numeric でキャストしても同じ効果があります。
アンチパターン
PARTITION BY なしで複数カテゴリが混在するテーブルを処理する:LAG(amount) OVER (ORDER BY dt) はカテゴリが混在していても全行を1列として並べます。カテゴリ A の最終行の「次」がカテゴリ B の最初の行として prev_amount に入り込み、意味のない変化率が計算されます。複数カテゴリには必ず PARTITION BY category を追加してください。
CASE WHEN の評価順序を間違える:CASE WHEN pct >= 0.5 THEN 'spike' WHEN pct <= -0.3 THEN 'drop' と書くと +270% は最初の WHEN で捕捉されます。もし spike の条件と drop の条件が重複するような定義(例: どちらも |pct| >= 0.5)にすると、先に書いた WHEN が優先されます。条件の順序を意図通りに設計してください。
実務コラム:変化率アラートをデータパイプラインに組み込む
このクエリを dbt のモデルとして定義し、毎朝 6:00 に実行してアラートテーブルを更新する設計が実務の定番です。WHERE alert_type != 'normal' で抽出した行を、Slack の Webhook・PagerDuty・Datadog モニターに流す構成にすれば、データチームが毎朝目視でダッシュボードを確認する「手動監視」から脱却できます。変化率・z-score・IQR の3手法を組み合わせた多層異常検知パイプラインは、本番データ基盤の標準的なアーキテクチャです。