記事の一覧

異常検知とは?主な手法と時系列データでの使い分けをSQL付きで解説

異常検知の主な手法 (平均と標準偏差、パーセンタイル、移動平均、季節性の扱い、機械学習、LLM による判断) を、注文数や決済の失敗率といった業務データの例と、そのまま使える SQL で解説します。誤検知と見逃しの調整も扱います。

「注文数がいつもより少ない気がする」「決済の失敗が増えていないか」。こうした違和感を、人が気づく前に機械で見つけるのが異常検知です。この記事では異常検知の主な手法を、簡単な統計から機械学習、LLM による判断まで順に説明します。データベースに入っている業務データを題材に、手元で試せる SQL を添えました。

異常検知とは

異常検知 (anomaly detection) は、データの中から「ふだんの振る舞いと違うもの」を見つける技術です。外れ値検出 (outlier detection) とほぼ同じ意味で使われます。

どの手法も、やっていることは同じ 3 段階です。

  1. 過去のデータから「ふだん」を表す基準を作る
  2. 新しい値が基準からどれだけ離れているかを数値にする (異常度、スコア)
  3. 離れ具合が一定を超えたら異常とみなす

手法の違いは 1 の「ふだん」の作り方にあります。平均で表すのか、曜日と時刻ごとの値で表すのか、機械学習のモデルで表すのかで、見つけられる異常と見逃す異常が変わります。

異常の 3 つの形

見つけたい異常は形によって 3 つに分けられます。

形 説明 業務データの例
点の異常 1 つの値だけが飛び抜けている 1 件だけ桁違いの金額の注文
文脈の異常 値そのものは珍しくないが、その時間帯や条件では珍しい 平日の昼 12 時に注文が深夜並みの件数しかない
変化・停止 ある時点から水準が変わる、または止まる 決済の失敗率が昨日のリリース以降ずっと高い、夜間バッチが止まって未処理が溜まり続ける

業務データで見落とされやすいのは 2 番目と 3 番目です。「深夜の注文 30 件」はふつうでも、「昼の注文 30 件」は大きな異常です。全体の平均だけを見ていると、この違いが分かりません。

業務データで起きる異常の例

異常検知の解説は、工場のセンサーやサーバーの CPU 使用率を例にすることが多いのですが、Web サービスや社内システムでまず見るべきなのは、データベースにある業務の数字です。

  • 1 時間あたりの注文数や会員登録数の急な減少
  • 決済の失敗率の上昇
  • 未処理のジョブ、送信待ちのメールの件数の増加
  • 特定の店舗や商品、取引先だけ数字が 0 になる

これらは、サーバーが正常に動いていても起こります。外部の決済サービスの仕様変更、フロントエンドの不具合でボタンが押せない、バッチのスケジュール設定のミス、といった原因では、CPU やエラーログにはほとんど何も出ません。

以降の SQL は PostgreSQL で書いています。テーブルは orders (id, created_at) と payments (id, status, created_at) を仮定しています。MySQL との違いは後ろでまとめます。

準備: 時間ごとの件数を、0 件の時間も含めて数える

異常検知の SQL で最初につまずくのが、0 件の時間帯です。GROUP BY で 1 時間ごとに数えると、注文が 1 件もなかった時間は行として出てきません。「注文が止まった」というもっとも重要な異常が、集計から消えてしまいます。

そこで、generate_series で時間の枠を先に作り、注文を外部結合します。

-- 直近 29 日分の 1 時間ごとの注文数 (0 件の時間も 0 として出す)
WITH hours AS (
  SELECT generate_series(
           date_trunc('hour', now()) - interval '29 days',
           date_trunc('hour', now()) - interval '1 hour',
           interval '1 hour'
         ) AS hour
)
SELECT h.hour, count(o.id) AS orders
FROM hours h
LEFT JOIN orders o
  ON o.created_at >= h.hour
 AND o.created_at <  h.hour + interval '1 hour'
GROUP BY h.hour
ORDER BY h.hour;

終わりを「現在の 1 時間前」にしているのは、集計中の今の時間を含めないためです。12 時 10 分に 12 時台を数えると、10 分ぶんの件数しかなく、必ず少なく見えます。この誤検知はとてもよく起こるので、確定した時間帯だけを判定の対象にしてください。

このクエリを hourly という名前の CTE として、以降のクエリで使います。

手法 1: 平均と標準偏差 (z スコア)

もっとも基本的な方法です。過去の値の平均と標準偏差 (ばらつきの大きさ) を求め、新しい値が平均から標準偏差の何倍離れているかを計算します。この倍数を z スコアと呼びます。

データが正規分布に従うなら、平均 ±3 標準偏差の範囲に約 99.7% の値が収まります。そのため「z スコアの絶対値が 3 を超えたら異常」がよく使われる目安です。

-- 直近 7 日 (168 時間) の平均・標準偏差と比べた z スコア
WITH hourly AS ( /* 上の準備のクエリ */ )
SELECT hour,
       orders,
       round(avg(orders) OVER w, 1)                  AS mean_7d,
       round(stddev_samp(orders) OVER w, 1)          AS sd_7d,
       round((orders - avg(orders) OVER w)
             / nullif(stddev_samp(orders) OVER w, 0), 2) AS z
FROM hourly
WINDOW w AS (ORDER BY hour ROWS BETWEEN 168 PRECEDING AND 1 PRECEDING)
ORDER BY hour DESC
LIMIT 24;

ウィンドウの範囲を 1 PRECEDING までにして、判定する時間自身を基準から外しているのがポイントです。自分を含めると、異常な値が平均と標準偏差を引っ張り、z スコアが小さく出ます。nullif は、標準偏差が 0 のとき (ずっと同じ値だったとき) にゼロ除算を避けるためです。

この方法には弱点が 2 つあります。

1 つ目は季節性を無視することです。上のクエリは 1 週間のすべての時間を一つの集団として扱うため、深夜の 0 件は「平均より少ない」、昼のピークは「平均より多い」と毎日判定されます。注文数のように時間帯で大きく動く指標には、このままでは使えません。

2 つ目は業務の数字の多くが正規分布に従わないことです。件数は 0 未満にならず、まれに大きく跳ねるため、分布が右に長く伸びます。「3 標準偏差で 99.7%」という目安は、そのまま当てはまりません。

z スコアが向いているのは、季節性が小さく、値がある程度大きい指標です。たとえば、1 日あたりの決済の失敗率や、1 件あたりの処理時間の平均などです。

手法 2: 中央値とパーセンタイル

平均と標準偏差は、外れ値そのものに引きずられます。過去に一度だけ大きな障害があると、その値で標準偏差が膨らみ、以後の異常が見つけにくくなります。

この影響を受けにくいのが中央値とパーセンタイルです。パーセンタイルは値を小さい順に並べたときの位置を表します。99 パーセンタイル (p99) は 100 個のうち小さい方から 99 番目にあたる値です。

-- 直近 28 日の 1 時間ごとの決済失敗率の分布
WITH hourly AS (
  SELECT date_trunc('hour', created_at) AS hour,
         count(*) AS total,
         (count(*) FILTER (WHERE status = 'failed'))::float8 / count(*) AS fail_rate
  FROM payments
  WHERE created_at >= date_trunc('hour', now()) - interval '28 days'
    AND created_at <  date_trunc('hour', now())
  GROUP BY 1
)
SELECT percentile_cont(0.5)  WITHIN GROUP (ORDER BY fail_rate) AS p50,
       percentile_cont(0.95) WITHIN GROUP (ORDER BY fail_rate) AS p95,
       percentile_cont(0.99) WITHIN GROUP (ORDER BY fail_rate) AS p99
FROM hourly
WHERE total >= 20;

ここでは、決済が 20 件未満の時間を除いています。深夜に 2 件中 1 件が失敗すると失敗率は 50% になりますが、これを異常と呼ぶのは無理があります。率を見るときは、分母が小さい時間を除くか、件数の条件を併せて置いてください。

得られた p99 を閾値にすれば、「過去 4 週間で 1% の時間しか超えなかった水準」を超えたら知らせる、という分かりやすい基準になります。

標準偏差の代わりに、MAD (中央絶対偏差) を使う方法もあります。各値と中央値の差の絶対値をとり、その中央値を求めたものです。MAD に 1.4826 を掛けると、正規分布のときの標準偏差と同じ尺度になり、外れ値に強い z スコアとして使えます。

-- 中央値と MAD (上と同じ hourly を使う)
WITH hourly AS ( /* 上と同じ */ ),
     med AS (
       SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY fail_rate) AS m
       FROM hourly WHERE total >= 20
     )
SELECT med.m AS median,
       percentile_cont(0.5) WITHIN GROUP (ORDER BY abs(h.fail_rate - med.m)) * 1.4826 AS robust_sd
FROM hourly h, med
WHERE h.total >= 20
GROUP BY med.m;

ただし、失敗がめったに起きない指標では、ほとんどの時間の失敗率が 0 なので、中央値も MAD も 0 になります。こうした指標には MAD は使えないので、パーセンタイルや件数の条件で判定します。

なお、PostgreSQL の percentile_cont のような順序集合集約関数はウィンドウ関数としては使えません (OVER を付けるとエラーになります)。時間ごとに過去の中央値を出したい場合は、相関サブクエリや LATERAL で区間ごとに集計します。

手法 3: 移動平均

移動平均は直近の一定期間の平均です。直近 24 時間の平均を 1 時間ずつずらして計算すると、細かい上下がならされ、水準の変化が見やすくなります。

-- 1 日あたりの注文数と、直前 7 日の移動平均との比
WITH daily AS (
  SELECT d::date AS day, count(o.id) AS orders
  FROM generate_series(current_date - 35, current_date - 1, interval '1 day') AS d
  LEFT JOIN orders o
    ON o.created_at >= d AND o.created_at < d + interval '1 day'
  GROUP BY d
)
SELECT day,
       orders,
       round(avg(orders) OVER w, 1) AS ma_7d,
       round(orders / nullif(avg(orders) OVER w, 0), 2) AS ratio
FROM daily
WINDOW w AS (ORDER BY day ROWS BETWEEN 7 PRECEDING AND 1 PRECEDING)
ORDER BY day DESC;

ratio が 0.5 を下回れば「直前 1 週間の平均の半分以下」です。日単位で見るなら、7 日の窓には各曜日が 1 回ずつ入るので、曜日の偏りもある程度ならされます。

移動平均の窓の幅には、トレードオフがあります。窓が短いと、異常な値自身がすぐ平均に入り込み、基準が異常に引っ張られます。窓が長いと、セールや新機能で水準が本当に変わったときに、基準が追いつくまで誤検知が続きます。直前の値を窓から外すのは z スコアのときと同じ理由です。

手法 4: 季節性を考える

注文数、アクセス数、問い合わせ件数のような業務の数字には、1 日の中の周期 (昼に多く夜に少ない)、1 週間の周期 (平日と週末で違う)、1 年の周期 (月末、年末、季節) があります。これを季節性と呼びます。

季節性のある指標でいちばん手軽で、説明もしやすいのが「同じ曜日の同じ時刻」と比べる方法です。

-- 直前の 1 時間を、過去 4 週の同じ曜日・同じ時刻と比べる
WITH hourly AS ( /* 準備のクエリ (29 日分) */ ),
     compared AS (
       SELECT hour,
              orders,
              lag(orders, 168) OVER (ORDER BY hour) AS w1,
              lag(orders, 336) OVER (ORDER BY hour) AS w2,
              lag(orders, 504) OVER (ORDER BY hour) AS w3,
              lag(orders, 672) OVER (ORDER BY hour) AS w4
       FROM hourly
     )
SELECT hour, orders, w1, w2, w3, w4,
       least(w1, w2, w3, w4)    AS low,
       greatest(w1, w2, w3, w4) AS high
FROM compared
WHERE hour = date_trunc('hour', now()) - interval '1 hour';

準備のクエリで 0 件の時間も行にしているので、168 行前がちょうど 1 週間前の同じ時刻になります (日本のように夏時間がない地域の場合)。orders が low を大きく下回っていれば、過去 4 週のどの週よりも少ないということです。1 週分だけと比べると、その週に障害やセールがあったときに判断が狂うので、複数週と比べるのが無難です。

より本格的には、時系列を「傾向 (トレンド)」「季節性」「残り (残差)」に分解し、残差に z スコアや MAD を当てる方法 (STL 分解など) や、ARIMA のような予測モデルで予測の幅を作り、そこから外れたものを異常とする方法があります。

自分で実装しなくても、こうした機能を持つサービスがあります。たとえば Amazon CloudWatch の異常検知は、最大 2 週間分のデータから傾向と時間・日・週の周期を学んで「予想される値の帯」を作ります。BigQuery ML では、ARIMA_PLUS で作った時系列モデルに ML.DETECT_ANOMALIES を使って異常を判定できます。

季節性の手法でも、学習期間に含まれない周期は扱えません。年末年始、決算期、大型連休、セールの日は、過去数週間のデータからは予測できないので、予定として分かっているものは判定から外すか、別の基準を用意します。

手法 5: 機械学習

統計の手法は基本的に 1 つの指標を 1 つずつ見ます。機械学習が力を発揮するのは、複数の指標の組み合わせで異常を判定したいときです。たとえば「注文数はふつう、決済の件数もふつう、しかし 1 件あたりの金額と特定の支払い方法の割合の組み合わせが珍しい」といった異常は、指標を 1 つずつ見ても分かりません。

業務の異常には「これは異常だった」というラベルがほとんどないので、使われるのは主に教師なしの手法です。

  • Isolation Forest: データをランダムに分割していき、少ない分割で孤立するものを異常とみなす
  • k-means などのクラスタリング: どのまとまり (クラスタ) の中心からも遠い点を異常とみなす
  • オートエンコーダ、主成分分析 (PCA): 正常なデータを圧縮して復元するモデルを作り、うまく復元できない点を異常とみなす

BigQuery ML の ML.DETECT_ANOMALIES も k-means、オートエンコーダ、PCA のモデルに対応しています。

機械学習の手法は、なぜ異常と判定したのかが説明しにくいという弱点があります。通知を受けた人が「どの値が、何と比べて、どれだけ違うのか」を確かめられないと、対応の判断ができません。1 つの指標で十分に判定できるなら、統計の手法のほうが運用は楽です。

手法 6: LLM による判断

最近は大規模言語モデル (LLM) に値の推移を読ませて、異常かどうかを判断させる方法も使われ始めています。

統計や機械学習の手法は「数値がどれだけ離れているか」しか見ません。LLM には数値に加えて文脈を渡せます。「今日はセールの初日」「この取引先は毎月 25 日にまとめて発注する」「先週同じ通知が出たが、決済サービス側のメンテナンスが原因だった」といった情報を踏まえ、数値の外れ方が問題なのかどうかを判断させられます。判断の理由を文章で残せるのも利点です。

一方で、注意点もあります。

  • 同じ入力でも判断がぶれることがある
  • 計算を誤ることがあるので、平均や比率は SQL などで計算してから渡したほうが確実
  • 判定の回数だけ API の費用と時間がかかる
  • 渡したデータが外部に送られる

実務では統計の手法で「いつもと違う候補」を絞り込み、その候補について LLM に文脈を踏まえた判断をさせる、という組み合わせが現実的です。データを外部に送るときの注意は「生成 AI に個人情報を渡すときの注意」で解説しています。

手法の比較

手法 季節性 外れ値の影響 説明しやすさ 手間 向いている指標
固定の閾値 扱えない なし 高い 小 ディスク残量、キューの上限など限界が明確なもの
平均と標準偏差 扱えない 受けやすい 高い 小 季節性の小さい比率や平均値
中央値・パーセンタイル・MAD 扱えない 受けにくい 高い 小 失敗率、処理時間
移動平均 窓の取り方次第 受けやすい 高い 小 日次の件数、ゆるやかな水準の変化
同じ曜日・時刻との比較 扱える 比較する週の数による 高い 小〜中 時間ごとの注文数、アクセス数
分解・予測モデル 扱える 手法による 中 中〜大 周期のはっきりした時系列全般
機械学習 (教師なし) 特徴量次第 手法による 低い 大 複数の指標の組み合わせ
LLM による判断 文脈として扱える - 文章で説明できる 中 事情を踏まえた判断が要るもの

誤検知と見逃しのトレードオフ

どの手法を選んでも、最後は「どこから異常とみなすか」を決めることになります。閾値を厳しく (z スコアなら 2 など) すれば見逃しは減りますが、誤検知が増えます。緩く (z スコア 4 など) すれば誤検知は減りますが、小さな異常を見逃します。両方を同時に減らすことはできません。

この調整は技術ではなく業務の判断です。次の 2 つを比べて決めます。

  • 見逃したときの損失 (決済が 3 時間止まっていたら、いくら失うか)
  • 誤検知 1 件あたりの対応の手間 (確認に 10 分かかる通知が 1 日に何件まで許されるか)

閾値のほかにも、誤検知を減らす工夫があります。

継続条件をつける。 1 回の外れでは通知せず、2 回続けて外れたら通知します。誤検知は減りますが、気づくのが 1 周期遅れます。

件数の下限を置く。 前述のとおり、分母の小さい率は大きく揺れます。「失敗率が p99 を超え、かつ失敗が 5 件以上」のように件数の条件を併せます。

上下で基準を分ける。 注文数は減るほうが問題で、増えるのはたいてい喜ばしいことです。減る側だけを判定すれば、増えたときの誤検知はなくなります。

0 件は別に扱う。 「ふだん 1 時間に 100 件ある注文が 0 件」は、統計の手法を通さなくても異常です。停止の検知は単純な条件で別に持っておくと確実です。

誤検知が続くと、通知そのものが読まれなくなります。アラートの量を保つ考え方は「アラート疲れの原因と対策」、閾値の決め方は「アラートの閾値の決め方」で詳しく解説しています。

よくある落とし穴

基準に異常な期間が混ざっている。 過去に障害があった期間を基準の計算に含めると、平均や標準偏差がゆがみます。障害の期間が分かっていれば、基準の計算から除外します。

タイムゾーンがずれている。 created_at が UTC で保存されているのに日本時間のつもりで「9 時台」を集計すると、曜日や時刻の比較がずれます。PostgreSQL の timestamptz は、セッションの TimeZone 設定に従って時刻を切り捨てます。どのタイムゾーンで集計しているかを確かめてください。

データの遅れを異常と取り違える。 外部からの連携や非同期の書き込みで、レコードが数分遅れて入ることがあります。その場合、直前の時間帯は少なく見えます。判定する時間帯を、遅れの分だけ前にずらします。

ゆっくりした劣化を見逃す。 移動平均や予測モデルは、徐々に悪くなる値を「新しいふだん」として受け入れてしまいます。数か月前の同じ時期と比べる、あるいは固定の閾値を併用するなどして補います。

MySQL で書く場合の違い

MySQL 8.0 以降でも、AVG() や STDDEV_SAMP() をウィンドウ関数として使えるので、z スコアと移動平均は同じ考え方で書けます。違いは次の 2 点です。

1 つ目に、generate_series がありません。時間の枠は再帰 CTE で作ります。再帰の深さの上限 cte_max_recursion_depth は既定で 1000 なので、29 日分の 1 時間 (696 行) なら収まりますが、それより長い期間では設定を変える必要があります。

-- MySQL: 直近 29 日分の 1 時間の枠を作る
WITH RECURSIVE hours (hour) AS (
  SELECT CAST(DATE_FORMAT(NOW() - INTERVAL 29 DAY, '%Y-%m-%d %H:00:00') AS DATETIME)
  UNION ALL
  SELECT hour + INTERVAL 1 HOUR FROM hours
  WHERE hour + INTERVAL 1 HOUR < CAST(DATE_FORMAT(NOW(), '%Y-%m-%d %H:00:00') AS DATETIME)
)
SELECT h.hour, COUNT(o.id) AS orders
FROM hours h
LEFT JOIN orders o
  ON o.created_at >= h.hour
 AND o.created_at <  h.hour + INTERVAL 1 HOUR
GROUP BY h.hour
ORDER BY h.hour;

2 つ目に、MySQL の集約関数には PERCENTILE_CONT や MEDIAN がありません。パーセンタイルが必要な場合は、ROW_NUMBER() と件数から位置を計算するか、値をアプリケーション側に取り出して計算します。

異常検知を続けるには

ここまでの SQL は cron で 1 時間ごとに実行し、条件に当たったら Slack やメールに送る、という形で運用を始められます。監視ツールが SQL の結果を取り込めるなら、ツールの異常検知の機能にかけることもできます。ただ、指標が増えるほど、どの手法と閾値を使うか、季節性やセールの日をどう扱うかを指標ごとに決め、見直し続ける手間が増えていきます。

その判断を AI に任せる方法もあります。Scandock は、データベースの接続文字列を登録すると、AI がテーブルの構成と実データを読んで、注文数や決済の成否、ジョブの滞留のような数字の監視項目と、取得・判定の間隔を決めます。判定は固定の閾値ではなく、値の推移や過去の発火とその結末、プロジェクトに蓄積した知識を踏まえて AI が行い、異常なしと判断した理由も残ります。異常と判断した場合は、読み取り専用のクエリで関連データを調べ、原因・影響・根拠・対処案をつけて通知します。

参考