セグメント別の移動統計 — PARTITION BY で店舗ごとに独立した正常基準を作る
基礎編では単一系列を扱いましたが、実務のデータは店舗・商品・ユーザーなど複数セグメントが1つのテーブルに混在します。セグメントをまたいで移動平均を計算すると、ある店舗の最終行の「次」に別店舗の先頭行が紛れ込み、無意味な基準になります。これを防ぐのが PARTITION BY です。
AVG(amount) OVER ( PARTITION BY store_id -- セグメントごとに「壁」を作る ORDER BY dt ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ) -- PARTITION BY ごとにフレームが独立してリセットされる -- store_id が変わる境界で移動平均・移動標準偏差は再計算される
store_sales テーブル(店舗ごとの日次売上)から、店舗別(store_id)に直近3日間(当日含む)の移動平均(moving_avg)と移動標準偏差(moving_stddev)を計算してください。出力列は store_id, dt, amount, moving_avg, moving_stddev、store_id, dt 昇順で返してください。小数第2位まで丸めてください。
| store_id | dt | amount |
|---|---|---|
| A | 2024-01-01 | 100 |
| A | 2024-01-02 | 110 |
| A | 2024-01-03 | 105 |
| A | 2024-01-04 | 500 |
| B | 2024-01-01 | 50 |
| B | 2024-01-02 | 55 |
| B | 2024-01-03 | 52 |
| B | 2024-01-04 | 200 |
| store_id | dt | amount | moving_avg | moving_stddev |
|---|---|---|---|---|
| A | 2024-01-01 | 100 | 100.00 | NULL |
| A | 2024-01-02 | 110 | 105.00 | 7.07 |
| A | 2024-01-03 | 105 | 105.00 | 5.00 |
| A | 2024-01-04 | 500 | 238.33 | 226.62 |
| B | 2024-01-01 | 50 | 50.00 | NULL |
| B | 2024-01-02 | 55 | 52.50 | 3.54 |
| B | 2024-01-03 | 52 | 52.33 | 2.52 |
| B | 2024-01-04 | 200 | 102.33 | 84.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 → 並び替えて出力 */
LEGEND
① FROM store_sales(8行 / 2店舗)
FROM store_sales2店舗(A・B)×4日の8行を読み込みます。店舗Aは売上規模が大きく、Bは小さい。この異なる規模の系列が同一テーブルに混在しているのがポイントです。| store_id | dt | amount |
|---|---|---|
| A | 01-01 | 100 |
| A | 01-02 | 110 |
| A | 01-03 | 105 |
| A | 01-04 | 500 |
| B | 01-01 | 50 |
| B | 01-02 | 55 |
| B | 01-03 | 52 |
| B | 01-04 | 200 |
PARTITION BY は行数を保ったまま各行に集計値を付与します。「店舗ごとの移動平均を、元の明細行すべてに残したい」という異常検知の要件にはウィンドウ関数の PARTITION BY が最適です。PARTITION BY store_id, product_id のように複数列を指定すれば「店舗×商品」の粒度で独立基準を作れます。実務では 店舗・商品・チャネル・曜日 などを組み合わせ、セグメントごとの正常範囲を細かく定義します。WINDOW w AS (...) で名前付き定義にして OVER w で共有しています。同じ OVER (...) を何度も書くミスを防ぎ、定義変更も一箇所で済むのが利点です。OVER (ORDER BY dt) だけだと、テーブル上で店舗Aの最終行の直後に店舗Bの先頭行が並び、Aの値がBの移動平均フレームに混入します。複数セグメントを含むテーブルでは PARTITION BY を必ず付けてください。ORDER BY dt, id のようにタイブレーク列を加えてください。自己汚染を防ぐ z-score — 当日を除外したトレーリング窓 (n PRECEDING AND 1 PRECEDING)
基礎編は「スパイク汚染」という弱点がありました。当日を含む窓で平均・標準偏差を計算すると、スパイク自身がその統計量を押し上げ、自分の z-score を小さくしてしまうのです。実務の定石は当日を除外した「過去だけ」のトレーリング窓で基準を作ること。これでスパイク当日は「汚れていない過去」と比較され、正しく検知されます。
AVG(amount) OVER ( ORDER BY dt ROWS BETWEEN 5 PRECEDING AND 1 PRECEDING -- 当日(CURRENT ROW)を含めない! ) -- フレーム = 1日前 〜 5日前 の最大5行(当日は除外) -- 当日の値は基準計算に一切影響しない = リークなし基準
... 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位まで丸めてください。
| dt | amount |
|---|---|
| 2024-01-01 | 100 |
| 2024-01-02 | 101 |
| 2024-01-03 | 100 |
| 2024-01-04 | 101 |
| 2024-01-05 | 100 |
| 2024-01-06 | 300 |
| 2024-01-07 | 101 |
| 2024-01-08 | 100 |
| dt | amount | base_avg | base_stddev | z_score | anomaly_flag |
|---|---|---|---|---|---|
| 2024-01-01 | 100 | NULL | NULL | NULL | normal |
| 2024-01-02 | 101 | 100.00 | NULL | NULL | normal |
| 2024-01-03 | 100 | 100.50 | 0.71 | -0.71 | normal |
| 2024-01-04 | 101 | 100.33 | 0.58 | 1.15 | normal |
| 2024-01-05 | 100 | 100.50 | 0.58 | -0.87 | normal |
| 2024-01-06 | 300 | 100.40 | 0.55 | 364.42 | anomaly |
| 2024-01-07 | 101 | 140.40 | 89.22 | -0.44 | normal |
| 2024-01-08 | 100 | 140.40 | 89.22 | -0.45 | normal |
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 → 並び替えて出力 */
LEGEND
① FROM daily_sales(8行)
FROM daily_sales8行を読み込みます。01-05 までは安定し、01-06 に 300 のスパイクがあります。基礎編Q3ではこのスパイクを見逃したことを思い出してください。| dt | amount |
|---|---|
| 01-01 | 100 |
| 01-02 | 101 |
| 01-03 | 100 |
| 01-04 | 101 |
| 01-05 | 100 |
| 01-06 | 300 |
| 01-07 | 101 |
| 01-08 | 100 |
... AND 1 PRECEDING で当日を除くだけで、この自己汚染が消えます。n PRECEDING(n行前)、1 PRECEDING(1行前)、CURRENT ROW(当日)、n FOLLOWING(n行後)、UNBOUNDED PRECEDING(先頭まで)を組み合わせて任意の窓を作れます。「過去だけ」なら終端を 1 PRECEDING、「未来だけ」なら始端を 1 FOLLOWING にします。COUNT(*) OVER w >= 3 を併用し、基準が十分なデータ点を持つ行だけ判定することで、初期の不安定な検知を抑制します。ROWS BETWEEN 5 PRECEDING AND CURRENT ROW のままだとスパイクが stddev を膨張させ z が小さくなります。検知漏れの典型原因です。当日を含めるべきは「平滑化(描画用の移動平均)」、含めないべきは「異常判定の基準」と用途で使い分けてください。ロバスト異常検知 — 中央値とMAD(中央絶対偏差)で外れ値に強いスコアを作る
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 を外れ値の目安とする
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位まで丸めてください。
| dt | amount |
|---|---|
| 2024-01-01 | 100 |
| 2024-01-02 | 102 |
| 2024-01-03 | 98 |
| 2024-01-04 | 101 |
| 2024-01-05 | 99 |
| 2024-01-06 | 500 |
| 2024-01-07 | 103 |
| 2024-01-08 | 100 |
| dt | amount | median_val | mad_val | mod_z | flag |
|---|---|---|---|---|---|
| 2024-01-01 | 100 | 100.50 | 1.50 | -0.22 | normal |
| 2024-01-02 | 102 | 100.50 | 1.50 | 0.67 | normal |
| 2024-01-03 | 98 | 100.50 | 1.50 | -1.12 | normal |
| 2024-01-04 | 101 | 100.50 | 1.50 | 0.22 | normal |
| 2024-01-05 | 99 | 100.50 | 1.50 | -0.67 | normal |
| 2024-01-06 | 500 | 100.50 | 1.50 | 179.64 | anomaly |
| 2024-01-07 | 103 | 100.50 | 1.50 | 1.12 | normal |
| 2024-01-08 | 100 | 100.50 | 1.50 | -0.22 | normal |
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 → 並び替えて出力 */
LEGEND
① 昇順ソート — 中央値の準備
WITHIN GROUP (ORDER BY amount)全8行を amount 昇順に並べます。中央値は4番目と5番目の中間。スパイクの 500 は最大値として端に押しやられ、中央付近の値(中央値)には影響しません。| 順位 | dt | amount(昇順) |
|---|---|---|
| 1 | 01-03 | 98 |
| 2 | 01-05 | 99 |
| 3 | 01-01 | 100 |
| 4 | 01-08 | 100 |
| 5 | 01-04 | 101 |
| 6 | 01-02 | 102 |
| 7 | 01-07 | 103 |
| 8 | 01-06 | 500 |
0.6745 × 偏差 / MAD とすることで通常の z-score と同じスケール(σ単位)に揃い、閾値 3.5 が比較可能になります。med → dev → mad と段階的にCTEを重ね、各段の CROSS JOIN で「1行の集約結果を全行に配る」パターンを使うのが定石です。NULLIF(mad_val, 0) でガードし、必要なら MAD の代わりに平均絶対偏差や微小値の下駄を履かせるなどのフォールバックを用意してください。AVG(abs_dev) にしてしまうと「平均絶対偏差(MeanAD)」になり、MAD のロバスト性が失われます(平均は外れ値に弱い)。MAD は必ず中央値(PERCENTILE_CONT(0.5))で取ってください。PERCENTILE_CONT(...) OVER (...) のウィンドウ版は計算コストが高いため、全期間バッチ集計(本問の形)や、セグメント別の GROUP BY 集計と併用されることが多いです。Q4 ではトレンドに追従する EWMA を、Q5 では連続性を見るアプローチを学びます。EWMA(指数加重移動平均)— 再帰CTEで前回値を更新しながら平滑化する
単純移動平均(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;
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 と同値とします。
| dt | amount |
|---|---|
| 2024-01-01 | 100 |
| 2024-01-02 | 100 |
| 2024-01-03 | 100 |
| 2024-01-04 | 200 |
| 2024-01-05 | 100 |
| 2024-01-06 | 100 |
| dt | amount | ewma |
|---|---|---|
| 2024-01-01 | 100 | 100.00 |
| 2024-01-02 | 100 | 100.00 |
| 2024-01-03 | 100 | 100.00 |
| 2024-01-04 | 200 | 150.00 |
| 2024-01-05 | 100 | 125.00 |
| 2024-01-06 | 100 | 112.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 で並び替えて出力 */
LEGEND
① CTE seq — ROW_NUMBER() で連番付与
ROW_NUMBER() OVER (ORDER BY dt) AS rn再帰の「進む順序」を決めるため、各行に rn=1..6 を付与します。再帰項は s.rn = e.rn + 1 でこの連番を1つずつ辿ります。| rn | dt | amount |
|---|---|---|
| 1 | 01-01 | 100 |
| 2 | 01-02 | 100 |
| 3 | 01-03 | 100 |
| 4 | 01-04 | 200 |
| 5 | 01-05 | 100 |
| 6 | 01-06 | 100 |
FROM ewma e JOIN seq s ON s.rn = e.rn + 1 が、「いま累積されている ewma の最後の行(e)」から「次の行(s)」を1つ生成します。1回の反復で1行追加され、次の rn が無くなると JOIN が空になり自然に停止します。α 1つで反応速度を調整できます。amount::numeric は、再帰項で 0.5 * ... の小数計算が型不一致を起こさないための明示キャストです。再帰CTEはアンカーと再帰項の各列の型が一致している必要があるため、初項側で型を揃えておくのが安全です。e.dt + interval '1 day' 方式は途切れたり多重化します。必ず ROW_NUMBER() の連番で「1つ次」を一意に辿ってください。s.rn = e.rn + 1 以外(例: 不等号や自己結合ミス)にすると行が増え続け、PostgreSQL は max_recursion 到達でエラーになります。「1反復でちょうど1行進む」設計を厳守してください。連続異常の検出 — gaps & 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)が分かれる
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 昇順で返してください。
| dt | amount |
|---|---|
| 2024-01-01 | 100 |
| 2024-01-02 | 300 |
| 2024-01-03 | 100 |
| 2024-01-04 | 100 |
| 2024-01-05 | 300 |
| 2024-01-06 | 320 |
| 2024-01-07 | 310 |
| 2024-01-08 | 100 |
| 2024-01-09 | 300 |
| 2024-01-10 | 100 |
| 2024-01-11 | 310 |
| 2024-01-12 | 300 |
| 2024-01-13 | 100 |
| dt | amount | is_high | run_length | alert_type |
|---|---|---|---|---|
| 2024-01-01 | 100 | 0 | 1 | normal |
| 2024-01-02 | 300 | 1 | 1 | noise |
| 2024-01-03 | 100 | 0 | 2 | normal |
| 2024-01-04 | 100 | 0 | 2 | normal |
| 2024-01-05 | 300 | 1 | 3 | sustained_anomaly |
| 2024-01-06 | 320 | 1 | 3 | sustained_anomaly |
| 2024-01-07 | 310 | 1 | 3 | sustained_anomaly |
| 2024-01-08 | 100 | 0 | 1 | normal |
| 2024-01-09 | 300 | 1 | 1 | noise |
| 2024-01-10 | 100 | 0 | 1 | normal |
| 2024-01-11 | 310 | 1 | 2 | noise |
| 2024-01-12 | 300 | 1 | 2 | noise |
| 2024-01-13 | 100 | 0 | 1 | normal |
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 → 並び替えて出力 */
LEGEND
① FROM daily_sales(13行)
FROM daily_sales13日分のデータを読み込みます。スパイクが単発(01-02, 01-09)、3日連続(01-05〜07)、2日連続(01-11〜12)と様々なパターンで発生しています。| dt | amount |
|---|---|
| 01-01 | 100 |
| 01-02 | 300 |
| 01-03 | 100 |
| 01-04 | 100 |
| 01-05 | 300 |
| 01-06 | 320 |
| 01-07 | 310 |
| 01-08 | 100 |
| 01-09 | 300 |
| 01-10 | 100 |
| 01-11 | 310 |
| 01-12 | 300 |
| 01-13 | 100 |
PARTITION BY is_high, grp と2列の組で島を一意に識別することで、高水準の島だけを正しく数えられます。LAG(is_high,1)・LAG(is_high,2)・… を並べて「3日連続か」を判定するのは、連続日数が変わるたびに書き換えが必要で破綻します。連続区間は gaps & islands で島を作り、COUNT で長さを測るのが正解です。応用編の総括として、異常検知は単一手法ではなく ① セグメント正規化(PARTITION BY) → ② リークなし基準(過去窓) → ③ ロバスト統計(MAD) → ④ トレンド追従(EWMA) → ⑤ 連続性による抑制(gaps & islands) を組み合わせる多層構造で精度が決まります。各手法の「何に強く、何に弱いか」を理解し、ドメインに合わせて重ねることが実務の腕の見せどころです。