SQL 異常検知 — MAD・EWMA・連続異常の応用

応用異常検知PARTITION BYMAD / ロバスト統計EWMA / 再帰CTEgaps and islandsPostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 1

セグメント別の移動統計 — PARTITION BY で店舗ごとに独立した正常基準を作る

PARTITION BYROWS BETWEENセグメント別集計移動統計
前提知識

基礎編では単一系列を扱いましたが、実務のデータは店舗・商品・ユーザーなど複数セグメントが1つのテーブルに混在します。セグメントをまたいで移動平均を計算すると、ある店舗の最終行の「次」に別店舗の先頭行が紛れ込み、無意味な基準になります。これを防ぐのが PARTITION BY です。

AVG(amount) OVER (
  PARTITION BY store_id   -- セグメントごとに「壁」を作る
  ORDER BY dt
  ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
)
-- PARTITION BY ごとにフレームが独立してリセットされる
-- store_id が変わる境界で移動平均・移動標準偏差は再計算される
PARTITION BY は「窓を分割する壁」:ウィンドウ関数の処理順は PARTITION BY でグループ分割 → 各パーティション内で ORDER BY → ROWS BETWEEN でフレーム決定 です。パーティションをまたいでフレームが広がることは絶対にありません。GROUP BY と違い行数は減らず、各行に集計値が付与されます。
問題

store_sales テーブル(店舗ごとの日次売上)から、店舗別(store_id)に直近3日間(当日含む)の移動平均(moving_avg)と移動標準偏差(moving_stddev)を計算してください。出力列は store_id, dt, amount, moving_avg, moving_stddevstore_id, dt 昇順で返してください。小数第2位まで丸めてください。

使用テーブル
► store_sales(8行 / 2店舗 × 4日)
store_iddtamount
A2024-01-01100
A2024-01-02110
A2024-01-03105
A2024-01-04500
B2024-01-0150
B2024-01-0255
B2024-01-0352
B2024-01-04200
期待出力
store_iddtamountmoving_avgmoving_stddev
A2024-01-01100100.00NULL
A2024-01-02110105.007.07
A2024-01-03105105.005.00
A2024-01-04500238.33226.62
B2024-01-015050.00NULL
B2024-01-025552.503.54
B2024-01-035252.332.52
B2024-01-04200102.3384.60
模範解答コード
SELECT
  store_id,
  dt,
  amount,
  ROUND(AVG(amount)    OVER w, 2) AS moving_avg,
  ROUND(STDDEV(amount) OVER w, 2) AS moving_stddev
FROM  store_sales
WINDOW w AS (
  PARTITION BY store_id          -- 店舗ごとにフレームを独立させる
  ORDER BY dt
  ROWS BETWEEN 2 PRECEDING AND CURRENT ROW  -- 直近3日
)
ORDER BY store_id, dt;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM store_sales            → 行を読み込む
  2. WINDOW w (PARTITION/ORDER)  → ウィンドウを定義
  3. AVG(amount) OVER w          → ウィンドウ関数を評価(行数は保持)
  4. STDDEV(amount) OVER w       → ウィンドウ関数を評価(行数は保持)
  5. ROUND(..., 2)               → 値を整形
  6. SELECT / ORDER BY           → 並び替えて出力
*/
解説(テーブル変化・ポイント)
SELECT store_id, dt, amount, ROUND(AVG(amount) OVER w, 2) AS moving_avg, ROUND(STDDEV(amount) OVER w, 2) AS moving_stddev FROM store_sales WINDOW w AS ( PARTITION BY store_id ORDER BY dt ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ) ORDER BY store_id, dt;
LEGEND
データ取得・読込対象
① FROM store_sales(8行 / 2店舗)
FROM store_sales2店舗(A・B)×4日の8行を読み込みます。店舗Aは売上規模が大きく、Bは小さい。この異なる規模の系列が同一テーブルに混在しているのがポイントです。
1 / 5
store_iddtamount
A01-01100
A01-02110
A01-03105
A01-04500
B01-0150
B01-0255
B01-0352
B01-04200
8行読込
学習ポイント
PARTITION BY は GROUP BY とは別物:GROUP BY は行を畳んで集計しますが、PARTITION BY行数を保ったまま各行に集計値を付与します。「店舗ごとの移動平均を、元の明細行すべてに残したい」という異常検知の要件にはウィンドウ関数の PARTITION BY が最適です。
複合パーティションも可能:PARTITION BY store_id, product_id のように複数列を指定すれば「店舗×商品」の粒度で独立基準を作れます。実務では 店舗・商品・チャネル・曜日 などを組み合わせ、セグメントごとの正常範囲を細かく定義します。
WINDOW 句で定義を再利用:移動平均と移動標準偏差で同じフレーム定義を使うため、WINDOW w AS (...) で名前付き定義にして OVER w で共有しています。同じ OVER (...) を何度も書くミスを防ぎ、定義変更も一箇所で済むのが利点です。
アンチパターン
PARTITION BY を忘れて全店舗を1系列として処理する:これが最頻出のバグです。OVER (ORDER BY dt) だけだと、テーブル上で店舗Aの最終行の直後に店舗Bの先頭行が並び、Aの値がBの移動平均フレームに混入します。複数セグメントを含むテーブルでは PARTITION BY を必ず付けてください。
ORDER BY を ORDER BY dt だけにして同点を放置する:同一店舗で同じ dt の行が複数あると、ROWS BETWEEN の並び順が不定になり結果が再現しません。一意になるよう ORDER BY dt, id のようにタイブレーク列を加えてください。
実務コラム:セグメント別異常検知のスケール
EC・SaaS では数千〜数万のセグメント(店舗・商品・テナント)を同時に監視します。セグメントごとに正常範囲が大きく異なる(人気商品と不人気商品では「正常な売上」が桁違い)ため、全体で1つの閾値を使うと小規模セグメントの異常を見逃します。PARTITION BY による「セグメント正規化」は、この見逃しを防ぐ最も基本かつ重要なテクニックです。後続の z-score・EWMA もすべて PARTITION BY と組み合わせて使います。
QUESTION 2

自己汚染を防ぐ z-score — 当日を除外したトレーリング窓 (n PRECEDING AND 1 PRECEDING)

ROWS BETWEEN1 PRECEDINGリーク防止z-score
前提知識

基礎編は「スパイク汚染」という弱点がありました。当日を含む窓で平均・標準偏差を計算すると、スパイク自身がその統計量を押し上げ、自分の z-score を小さくしてしまうのです。実務の定石は当日を除外した「過去だけ」のトレーリング窓で基準を作ること。これでスパイク当日は「汚れていない過去」と比較され、正しく検知されます。

AVG(amount) OVER (
  ORDER BY dt
  ROWS BETWEEN 5 PRECEDING
       AND 1 PRECEDING   -- 当日(CURRENT ROW)を含めない!
)
-- フレーム = 1日前 〜 5日前 の最大5行(当日は除外)
-- 当日の値は基準計算に一切影響しない = リークなし基準
フレーム終端を CURRENT ROW から 1 PRECEDING へ:基礎編は ... AND CURRENT ROW で当日を含めていました。終端を 1 PRECEDING にするだけで「当日を除いた純粋な過去のベースライン」になります。機械学習でいうデータリーク(未来・自己情報の混入)の防止と同じ発想で、検知対象の値で検知基準を作らないのが鉄則です。
問題

daily_sales テーブルから、当日を除いた直近5日間(1〜5日前)の移動平均(base_avg)・移動標準偏差(base_stddev)を基準に z-score を計算し、|z| ≥ 3 なら 'anomaly'、それ以外は 'normal' の anomaly_flag を付与してください。出力列は dt, amount, base_avg, base_stddev, z_score, anomaly_flag、dt 昇順で返してください。base_avg・base_stddev は小数第2位、z_score は小数第2位まで丸めてください。

使用テーブル
► daily_sales(8行)
dtamount
2024-01-01100
2024-01-02101
2024-01-03100
2024-01-04101
2024-01-05100
2024-01-06300
2024-01-07101
2024-01-08100
期待出力
dtamountbase_avgbase_stddevz_scoreanomaly_flag
2024-01-01100NULLNULLNULLnormal
2024-01-02101100.00NULLNULLnormal
2024-01-03100100.500.71-0.71normal
2024-01-04101100.330.581.15normal
2024-01-05100100.500.58-0.87normal
2024-01-06300100.400.55364.42anomaly
2024-01-07101140.4089.22-0.44normal
2024-01-08100140.4089.22-0.45normal
模範解答コード
WITH base AS (
  SELECT
    dt, amount,
    AVG(amount)    OVER w AS base_avg,
    STDDEV(amount) OVER w AS base_stddev
  FROM  daily_sales
  WINDOW w AS (
    ORDER BY dt
    ROWS BETWEEN 5 PRECEDING AND 1 PRECEDING  -- 当日除外: 1〜5日前
  )
)
SELECT
  dt, amount,
  ROUND(base_avg, 2) AS base_avg,
  ROUND(base_stddev, 2) AS base_stddev,
  ROUND(
    (amount - base_avg) / NULLIF(base_stddev, 0),  -- 過去基準に対する標準化
    2
  ) AS z_score,
  CASE
    WHEN ABS((amount - base_avg) / NULLIF(base_stddev, 0)) >= 3
    THEN 'anomaly'
    ELSE 'normal'
  END AS anomaly_flag
FROM  base
ORDER BY dt;

/*
  実行順序(SQLの論理的な評価順):
  1. CTE base          → CTE を定義(過去窓でウィンドウ関数を評価)
  2. 外側クエリ        → z-score を評価し anomaly/normal を判定
  3. ORDER BY dt       → 並び替えて出力
*/
解説(テーブル変化・ポイント)
WITH base AS ( SELECT dt, amount, AVG(amount) OVER w AS base_avg, STDDEV(amount) OVER w AS base_stddev FROM daily_sales WINDOW w AS ( ORDER BY dt ROWS BETWEEN 5 PRECEDING AND 1 PRECEDING ) ) SELECT dt, amount, ROUND(base_avg, 2) AS base_avg, ROUND(base_stddev, 2) AS base_stddev, ROUND( (amount - base_avg) / NULLIF(base_stddev, 0), 2 ) AS z_score, CASE WHEN ABS((amount - base_avg) / NULLIF(base_stddev, 0)) >= 3 THEN 'anomaly' ELSE 'normal' END AS anomaly_flag FROM base ORDER BY dt;
LEGEND
データ取得・読込対象
① FROM daily_sales(8行)
FROM daily_sales8行を読み込みます。01-05 までは安定し、01-06 に 300 のスパイクがあります。基礎編Q3ではこのスパイクを見逃したことを思い出してください。
1 / 6
dtamount
01-01100
01-02101
01-03100
01-04101
01-05100
01-06300
01-07101
01-08100
8行読込
学習ポイント
「検知対象の値で基準を作らない」=リーク防止:異常検知は機械学習の予測と同じく、当日の観測値を当日の基準計算に使ってはいけません。当日を含めるとスパイクが自分の閾値を引き上げ「自己隠蔽」が起きます。... AND 1 PRECEDING で当日を除くだけで、この自己汚染が消えます。
フレーム境界の語彙:n PRECEDING(n行前)、1 PRECEDING(1行前)、CURRENT ROW(当日)、n FOLLOWING(n行後)、UNBOUNDED PRECEDING(先頭まで)を組み合わせて任意の窓を作れます。「過去だけ」なら終端を 1 PRECEDING、「未来だけ」なら始端を 1 FOLLOWING にします。
min要素数のガード:先頭付近は窓の要素が足りず stddev が NULL になります。実務では COUNT(*) OVER w >= 3 を併用し、基準が十分なデータ点を持つ行だけ判定することで、初期の不安定な検知を抑制します。
アンチパターン
当日込みの窓で「なぜ検知できない」と悩む:ROWS BETWEEN 5 PRECEDING AND CURRENT ROW のままだとスパイクが stddev を膨張させ z が小さくなります。検知漏れの典型原因です。当日を含めるべきは「平滑化(描画用の移動平均)」、含めないべきは「異常判定の基準」と用途で使い分けてください。
スパイク翌日以降の感度低下を見落とす:当日除外でも、スパイクが過去窓に入る翌日以降(01-07・01-08)は stddev が膨張し検知感度が落ちます。連続スパイクや段差状の変化はこの窓だと取りこぼすため、ロバスト統計(MAD)や 連続異常検知と組み合わせてください。
実務コラム:トレーニング窓とリアルタイム検知
本番の時系列異常検知(例: Prophet・Twitter AnomalyDetection・各種APMツール)は、すべて「過去の観測値だけ」で当日を予測・評価します。SQLのトレーリング窓はこの考え方の最小実装です。当日を含むか除くかという1語の違いが、検知できるかできないかを分けるため、本番クエリのレビューでは必ず確認される論点です。さらに、スパイク混入による感度低下を避けるため、平均・標準偏差の代わりに中央値・MAD(次問)を使うのが上級者の定石です。
QUESTION 3

ロバスト異常検知 — 中央値とMAD(中央絶対偏差)で外れ値に強いスコアを作る

PERCENTILE_CONTMADロバスト統計modified z-score
前提知識

z-score は 平均と標準偏差が外れ値そのものに引っ張られる弱点があります。大きなスパイクが1つあると平均・標準偏差が膨張し、そのスパイク自身の z-score が小さくなって見逃される現象をマスキング(masking)と呼びます。対策は、外れ値に強い中央値(median)と MAD(中央絶対偏差)を使うことです。

-- MAD = median( |x_i - median(x)| )
-- 修正 z-score (Iglewicz-Hoaglin):
modified_z = 0.6745 * (x - median) / MAD
-- 0.6745 = 標準正規分布の0.75分位(MADをσの一致推定量にする係数)
-- |modified_z| > 3.5 を外れ値の目安とする
なぜ中央値・MADは外れ値に強いのか:中央値は順位50%の値なので、データの半分未満が外れ値であれば影響を受けません(ブレークダウンポイント50%)。平均(同0%=1点で破綻)とは対照的です。MAD も同じ理由で頑健。これをロバスト統計と呼び、汚染されたデータでも安定した基準を与えます。
問題

daily_sales テーブルから、全期間の中央値(median_val)と MAD(mad_val)を求め、各日の修正 z-score(mod_z = 0.6745×(amount−median)/MAD)を計算し、|mod_z| > 3.5 なら 'anomaly'、それ以外は 'normal' の flag を付与してください。出力列は dt, amount, median_val, mad_val, mod_z, flag、dt 昇順で返してください。mod_z は小数第2位まで丸めてください。

使用テーブル
► daily_sales(8行)
dtamount
2024-01-01100
2024-01-02102
2024-01-0398
2024-01-04101
2024-01-0599
2024-01-06500
2024-01-07103
2024-01-08100
期待出力
dtamountmedian_valmad_valmod_zflag
2024-01-01100100.501.50-0.22normal
2024-01-02102100.501.500.67normal
2024-01-0398100.501.50-1.12normal
2024-01-04101100.501.500.22normal
2024-01-0599100.501.50-0.67normal
2024-01-06500100.501.50179.64anomaly
2024-01-07103100.501.501.12normal
2024-01-08100100.501.50-0.22normal
模範解答コード
WITH med AS (                       -- ① 全期間の中央値
  SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount) AS median_val
  FROM  daily_sales
),
dev AS (                              -- ② 各行の中央値からの絶対偏差
  SELECT
    s.dt, s.amount, m.median_val,
    ABS(s.amount - m.median_val) AS abs_dev
  FROM  daily_sales s
  CROSS JOIN med m                    -- 1行の median を全行へ展開
),
mad AS (                              -- ③ 絶対偏差の中央値 = MAD
  SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY abs_dev) AS mad_val
  FROM  dev
)
SELECT
  d.dt, d.amount, d.median_val, m.mad_val,
  ROUND(
    (0.6745 * (d.amount - d.median_val) / NULLIF(m.mad_val, 0))::numeric,  -- 修正z-score
    2
  ) AS mod_z,
  CASE
    WHEN ABS(0.6745 * (d.amount - d.median_val) / NULLIF(m.mad_val, 0)) > 3.5
    THEN 'anomaly'
    ELSE 'normal'
  END AS flag
FROM  dev d
CROSS JOIN mad m
ORDER BY d.dt;

/*
  実行順序(SQLの論理的な評価順):
  1. CTE med    → CTE を定義(中央値を集計)
  2. CTE dev    → CTE を定義(絶対偏差を評価)
  3. CTE mad    → CTE を定義(絶対偏差の中央値を集計)
  4. 外側クエリ → 修正z-scoreを評価し anomaly/normal を判定
  5. ORDER BY   → 並び替えて出力
*/
解説(テーブル変化・ポイント)
WITH med AS ( SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount) AS median_val FROM daily_sales ), dev AS ( SELECT s.dt, s.amount, m.median_val, ABS(s.amount - m.median_val) AS abs_dev FROM daily_sales s CROSS JOIN med m ), mad AS ( SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY abs_dev) AS mad_val FROM dev ) SELECT d.dt, d.amount, d.median_val, m.mad_val, ROUND( (0.6745 * (d.amount - d.median_val) / NULLIF(m.mad_val, 0))::numeric, 2 ) AS mod_z, CASE WHEN ABS(...) > 3.5 THEN 'anomaly' ELSE 'normal' END AS flag FROM dev d CROSS JOIN mad m ORDER BY d.dt;
LEGEND
データ取得・読込対象
① 昇順ソート — 中央値の準備
WITHIN GROUP (ORDER BY amount)全8行を amount 昇順に並べます。中央値は4番目と5番目の中間。スパイクの 500 は最大値として端に押しやられ、中央付近の値(中央値)には影響しません。
1 / 6
順位dtamount(昇順)
101-0398
201-0599
301-01100
401-08100
501-04101
601-02102
701-07103
801-06500
8行(昇順ソート済)
学習ポイント
マスキング効果が z-score の致命的弱点:本問のデータで通常の z-score を計算すると、平均が 150.4・標準偏差が約141に膨張し、スパイク 500 自身の z はわずか ≈2.47。|z|≥3 の閾値に届かず見逃します。中央値・MADは順位ベースで外れ値の大きさに鈍感なため、この罠を回避できます。
0.6745 という係数の意味:MAD はそのままでは標準偏差σより小さい値です。正規分布のとき MAD ≈ 0.6745σ の関係があるため、0.6745 × 偏差 / MAD とすることで通常の z-score と同じスケール(σ単位)に揃い、閾値 3.5 が比較可能になります。
3つのCTEで「2段階の中央値」を表現:MAD は中央値を2回取る計算(①値の中央値 → ②絶対偏差の中央値)です。SQLでは med → dev → mad と段階的にCTEを重ね、各段の CROSS JOIN で「1行の集約結果を全行に配る」パターンを使うのが定石です。
アンチパターン
MAD=0 のときのゼロ除算:データの過半数が同一値だと MAD=0 になり mod_z が発散します。NULLIF(mad_val, 0) でガードし、必要なら MAD の代わりに平均絶対偏差や微小値の下駄を履かせるなどのフォールバックを用意してください。
abs_dev の中央値を取り忘れ平均にする:AVG(abs_dev) にしてしまうと「平均絶対偏差(MeanAD)」になり、MAD のロバスト性が失われます(平均は外れ値に弱い)。MAD は必ず中央値(PERCENTILE_CONT(0.5))で取ってください。
実務コラム:いつ MAD を使うか
MAD ベースの修正 z-score は、外れ値が複数混入しうる・分布が歪んでいる・データが少ない場面で z-score より信頼できます。決済不正検知、IoTセンサーの故障値、ログの異常スパイクなど「汚染前提」の現場で標準的に使われます。一方、窓ごとに median/MAD を計算する PERCENTILE_CONT(...) OVER (...) のウィンドウ版は計算コストが高いため、全期間バッチ集計(本問の形)や、セグメント別の GROUP BY 集計と併用されることが多いです。Q4 ではトレンドに追従する EWMA を、Q5 では連続性を見るアプローチを学びます。
QUESTION 4

EWMA(指数加重移動平均)— 再帰CTEで前回値を更新しながら平滑化する

WITH RECURSIVEUNION ALLEWMA再帰的平滑化
前提知識

単純移動平均(SMA)は窓を外れた古い値を急に「忘れる」ため、平滑曲線がギザつきます。EWMA(指数加重移動平均)は 新しい値ほど重く、古い値ほど指数的に軽く重み付けし、滑らかかつ素早くトレンドに追従します。各時点の EWMA は「前回の EWMA」を使って逐次更新されるため、再帰CTE(WITH RECURSIVE)が自然な実装になります。

-- EWMA の漸化式(α=平滑化係数, 0<α≤1)
ema1 = amount1                       -- 初項(アンカー)
emat = α·amountt + (1−α)·emat−1   -- 漸化(再帰)
-- α が大きい → 直近重視(反応速い)
-- α が小さい → 過去重視(滑らか)

-- ▼ WITH RECURSIVE の基本構文 ▼
WITH RECURSIVE cte_name AS (
  -- ① 非再帰項(アンカー:最初の1行目)
  SELECT 1 AS rn, initial_value AS val
  
  UNION ALL
  
  -- ② 再帰項(前回結果 cte_name を参照して次の行を作る)
  SELECT c.rn + 1, calculate_next_val(c.val)
  FROM cte_name c
  WHERE c.rn < limit_condition
)
SELECT * FROM cte_name;
WITH RECURSIVE の2部構成:再帰CTEは 非再帰項(アンカー:最初の1行)再帰項(自分自身を参照して次の行を作る)UNION ALL で繋ぎます。JOIN seq ON s.rn = e.rn + 1 で「1つ前の行の ema」を引き継ぎ、行が尽きるまで(次の rn が存在しなくなるまで)反復します。
問題

daily_sales テーブルから、平滑化係数 α=0.5 の EWMA(ewma)を再帰CTEで計算してください。出力列は dt, amount, ewma、dt 昇順で返してください。ewma は小数第2位まで丸めてください。先頭行の ewma は amount と同値とします。

使用テーブル
► daily_sales(6行)
dtamount
2024-01-01100
2024-01-02100
2024-01-03100
2024-01-04200
2024-01-05100
2024-01-06100
期待出力
dtamountewma
2024-01-01100100.00
2024-01-02100100.00
2024-01-03100100.00
2024-01-04200150.00
2024-01-05100125.00
2024-01-06100112.50
模範解答コード
WITH RECURSIVE seq AS (        -- 行に連番(rn)を付与
  SELECT
    dt, amount,
    ROW_NUMBER() OVER (ORDER BY dt) AS rn
  FROM  daily_sales
),
ewma AS (
  -- ① 非再帰項(アンカー): 最初の行は ema = amount
  SELECT rn, dt, amount, amount::numeric AS ema
  FROM  seq
  WHERE rn = 1

  UNION ALL

  -- ② 再帰項: 前回 ema を引き継いで今回 ema を計算
  SELECT
    s.rn, s.dt, s.amount,
    0.5 * s.amount + 0.5 * e.ema  -- α=0.5
  FROM  ewma e
  JOIN  seq s ON s.rn = e.rn + 1  -- 1つ後ろの行へ進む
)
SELECT
  dt, amount,
  ROUND(ema, 2) AS ewma
FROM  ewma
ORDER BY rn;

/*
  実行順序(SQLの論理的な評価順):
  1. CTE seq            → CTE を定義(ROW_NUMBER で連番付与)
  2. CTE ewma(再帰CTE):
     アンカー部を評価(起点)
     再帰(子を順に展開)
     追加行なしで停止
  3. 外側クエリを評価   → ROUND / ORDER BY で並び替えて出力
*/
解説(テーブル変化・ポイント)
WITH RECURSIVE seq AS ( SELECT dt, amount, ROW_NUMBER() OVER (ORDER BY dt) AS rn FROM daily_sales ), ewma AS ( SELECT rn, dt, amount, amount::numeric AS ema FROM seq WHERE rn = 1 UNION ALL SELECT s.rn, s.dt, s.amount, 0.5 * s.amount + 0.5 * e.ema FROM ewma e JOIN seq s ON s.rn = e.rn + 1 ) SELECT dt, amount, ROUND(ema, 2) AS ewma FROM ewma ORDER BY rn;
LEGEND
データ取得・読込対象
① CTE seq — ROW_NUMBER() で連番付与
ROW_NUMBER() OVER (ORDER BY dt) AS rn再帰の「進む順序」を決めるため、各行に rn=1..6 を付与します。再帰項は s.rn = e.rn + 1 でこの連番を1つずつ辿ります。
1 / 7
rndtamount
101-01100
201-02100
301-03100
401-04200
501-05100
601-06100
6行(rn付与)
学習ポイント
再帰CTEの心臓部は「前回結果の引き継ぎ」:再帰項の FROM ewma e JOIN seq s ON s.rn = e.rn + 1 が、「いま累積されている ewma の最後の行(e)」から「次の行(s)」を1つ生成します。1回の反復で1行追加され、次の rn が無くなると JOIN が空になり自然に停止します。
EWMA と SMA の違い:SMA(単純移動平均)は窓内を均等平均し、窓を外れた値を急に忘れます。EWMA は すべての過去を指数的に減衰させながら記憶するため、スパイクへの反応が速く(150へ即応)、その後の減衰も滑らか(125→112.5)です。α 1つで反応速度を調整できます。
::numeric で型を固定:アンカーの amount::numeric は、再帰項で 0.5 * ... の小数計算が型不一致を起こさないための明示キャストです。再帰CTEはアンカーと再帰項の各列の型が一致している必要があるため、初項側で型を揃えておくのが安全です。
アンチパターン
連番(rn)を使わず dt で直接 JOIN する:日付に欠損や重複があると e.dt + interval '1 day' 方式は途切れたり多重化します。必ず ROW_NUMBER() の連番で「1つ次」を一意に辿ってください。
停止条件の崩壊(無限再帰):再帰項の JOIN 条件を s.rn = e.rn + 1 以外(例: 不等号や自己結合ミス)にすると行が増え続け、PostgreSQL は max_recursion 到達でエラーになります。「1反復でちょうど1行進む」設計を厳守してください。
実務コラム:EWMA と本番監視ツール
EWMA は Datadog・Prometheus・CloudWatch などの監視ツールの異常検知で広く使われる基礎手法です。メモリ効率がよく(直近1点と前回値だけで更新できる)、ストリーミング処理に向くためです。SQLの再帰CTEは「考え方の可視化」には最適ですが、大量データではウィンドウ関数で近似するか、アプリ側・dbt の incremental モデルで逐次計算するのが実務的です。実測値と EWMA の残差 (amount − ewma) を z-score 化すれば、トレンド追従型の異常検知(EWMA control chart / EWMAチャート)になります。品質管理で標準的に使われる手法です。
QUESTION 5

連続異常の検出 — gaps & islands で単発ノイズを除き持続的な異常だけアラートする

ROW_NUMBERgaps & islands連続グルーピングノイズ抑制
前提知識

1日だけ閾値を超える「単発スパイク」は計測ノイズや一時的イベントのことが多く、毎回アラートを出すとアラート疲れ(alert fatigue)を招きます。実務では 「N日連続で異常が続いたときだけ通知する」のが定石。これを実現するのが gaps & islands(連続区間のグルーピング)という古典テクニックです。

-- 連番の差分で「連続するグループ」を識別する
grp = ROW_NUMBER() OVER (ORDER BY dt)
    - ROW_NUMBER() OVER (PARTITION BY is_high ORDER BY dt)
-- 連続する同じ is_high の行は grp が一定になる
-- 連続が途切れると grp の値が変わる → 島(island)が分かれる
なぜ「2つの連番の差」で連続が分かるのか:全体の連番は1ずつ増えます。is_high 内の連番も連続する間は1ずつ増えます。両者の差は連続している間は一定で、連続が途切れて再開すると差が変わります。この差を島のID(grp)として使い、COUNT(*) OVER (PARTITION BY is_high, grp) で各島の長さ(連続日数)を測ります。
問題

daily_sales テーブルで、amount ≥ 200 を「高水準(is_high=1)」とし、高水準が3日以上連続した区間だけ 'sustained_anomaly'、単発・2日以下の高水準は 'noise'、それ以外は 'normal' の alert_type を付与してください。出力列は dt, amount, is_high, run_length, alert_type、dt 昇順で返してください。

使用テーブル
► daily_sales(13行)
dtamount
2024-01-01100
2024-01-02300
2024-01-03100
2024-01-04100
2024-01-05300
2024-01-06320
2024-01-07310
2024-01-08100
2024-01-09300
2024-01-10100
2024-01-11310
2024-01-12300
2024-01-13100
期待出力
dtamountis_highrun_lengthalert_type
2024-01-0110001normal
2024-01-0230011noise
2024-01-0310002normal
2024-01-0410002normal
2024-01-0530013sustained_anomaly
2024-01-0632013sustained_anomaly
2024-01-0731013sustained_anomaly
2024-01-0810001normal
2024-01-0930011noise
2024-01-1010001normal
2024-01-1131012noise
2024-01-1230012noise
2024-01-1310001normal
模範解答コード
WITH flagged AS (
  -- ① 高水準フラグを付与
  SELECT dt, amount,
    CASE WHEN amount >= 200 THEN 1 ELSE 0 END AS is_high
  FROM  daily_sales
),
rownums AS (
  -- ② 2種類の連番を計算(全体連番とis_high別連番)
  SELECT dt, amount, is_high,
    ROW_NUMBER() OVER (ORDER BY dt) AS rn_all,
    ROW_NUMBER() OVER (PARTITION BY is_high ORDER BY dt) AS rn_flag
  FROM  flagged
),
grouped AS (
  -- ③ 連番の差分で連続区間ID(grp)を算出
  SELECT dt, amount, is_high,
    (rn_all - rn_flag) AS grp
  FROM  rownums
),
runs AS (
  -- ④ 各島の長さ(連続日数)を集計
  SELECT dt, amount, is_high, grp,
    COUNT(*) OVER (PARTITION BY is_high, grp) AS run_length
  FROM  grouped
)
SELECT
  dt, amount, is_high, run_length,
  CASE
    WHEN is_high = 1 AND run_length >= 3 THEN 'sustained_anomaly'
    WHEN is_high = 1                     THEN 'noise'
    ELSE 'normal'
  END AS alert_type
FROM  runs
ORDER BY dt;

/*
  実行順序(SQLの論理的な評価順):
  1. CTE flagged  → CTE を定義(高額フラグを付与)
  2. CTE rownums  → CTE を定義(連番を付与)
  3. CTE grouped  → CTE を定義(連番差で島を識別)
  4. CTE runs     → CTE を定義(島ごとの連続日数を集計)
  5. 外側 CASE    → 列を評価(区分を判定)
  6. ORDER BY dt  → 並び替えて出力
*/
解説(テーブル変化・ポイント)
WITH flagged AS ( SELECT dt, amount, CASE WHEN amount >= 200 THEN 1 ELSE 0 END AS is_high FROM daily_sales ), rownums AS ( SELECT dt, amount, is_high, ROW_NUMBER() OVER (ORDER BY dt) AS rn_all, ROW_NUMBER() OVER (PARTITION BY is_high ORDER BY dt) AS rn_flag FROM flagged ), grouped AS ( SELECT dt, amount, is_high, (rn_all - rn_flag) AS grp FROM rownums ), runs AS ( SELECT dt, amount, is_high, grp, COUNT(*) OVER (PARTITION BY is_high, grp) AS run_length FROM grouped ) SELECT dt, amount, is_high, run_length, CASE WHEN is_high = 1 AND run_length >= 3 THEN 'sustained_anomaly' WHEN is_high = 1 THEN 'noise' ELSE 'normal' END AS alert_type FROM runs ORDER BY dt;
LEGEND
データ取得・読込対象
① FROM daily_sales(13行)
FROM daily_sales13日分のデータを読み込みます。スパイクが単発(01-02, 01-09)、3日連続(01-05〜07)、2日連続(01-11〜12)と様々なパターンで発生しています。
1 / 7
dtamount
01-01100
01-02300
01-03100
01-04100
01-05300
01-06320
01-07310
01-08100
01-09300
01-10100
01-11310
01-12300
01-13100
13行読込
学習ポイント
gaps & islands は「連番の差」が鍵:全体連番と条件別連番の差は、連続している間だけ一定になります。これは「等差数列から等差数列を引くと連続区間で定数になる」という性質を利用したもので、SQLで連続グループを作る最も汎用的なイディオムです。ログイン連続日数・在庫切れ連続期間・連続エラーなど応用範囲が広いです。
島のキーは (is_high, grp) の組:grp 単独だと is_high=0 の島と is_high=1 の島で値が偶然一致することがあります。PARTITION BY is_high, grp2列の組で島を一意に識別することで、高水準の島だけを正しく数えられます。
「回数」ではなく「連続性」で判定する発想:異常を単純にカウントすると、散発的なノイズも積み上がってアラートになります。連続日数(持続性)を条件にすることで、一過性のノイズと本物のインシデント(障害が継続している状態)を切り分けられます。run_length の閾値(3日)はドメインに応じて調整します。
アンチパターン
LAG を何段も重ねて連続判定する:LAG(is_high,1)・LAG(is_high,2)・… を並べて「3日連続か」を判定するのは、連続日数が変わるたびに書き換えが必要で破綻します。連続区間は gaps & islands で島を作り、COUNT で長さを測るのが正解です。
日付に欠損があるのに ROW_NUMBER の連番をそのまま使う:本問は毎日データが揃う前提です。日付が飛ぶ(欠測日がある)場合、連番の差は「カレンダー上の連続」ではなく「行の連続」を見る点に注意。暦日の連続を厳密に見るなら、日付グリッドを生成して LEFT JOIN で埋めてから処理してください。
実務コラム:アラート設計と異常検知の総まとめ
本問の「N日連続で発火」は、監視の世界で "for" 句(Prometheus Alertmanager)や Datadog の "trigger after N occurrences" として標準装備される考え方です。SQLで同じロジックを書けると、データ基盤側でアラート前の集計・抑制(dedup / debounce)を設計できます。

応用編の総括として、異常検知は単一手法ではなく ① セグメント正規化(PARTITION BY) → ② リークなし基準(過去窓) → ③ ロバスト統計(MAD) → ④ トレンド追従(EWMA) → ⑤ 連続性による抑制(gaps & islands) を組み合わせる多層構造で精度が決まります。各手法の「何に強く、何に弱いか」を理解し、ドメインに合わせて重ねることが実務の腕の見せどころです。