データ集計ジョブの結果に対して「直近 7 日平均と比べて 2 倍を超えたら警告」というシンプルな外れ値アラートを置いていました。
しかし、データの性質上 単発で大きく振れる日 が普通にあり、毎週のように誤検知の通知が飛んできて、本物の異常を見逃しかける状態に。これを 「連続 N 回」エスカレーション に切り替えた記録です。
単発閾値の限界
元のロジック
1def check_anomaly(today_value: float, baseline: float) -> bool:2 if today_value > baseline * 2:3 send_alert(f"値が異常: {today_value}")4 return True5 return Falseこれだけだと、1 日でも閾値を超えたら通知が来ます。
何が起きるか
1月曜 値=5.2 ← baseline=2.5、 2倍超え → 通知2火曜 値=2.1 ← 通常範囲、何も来ない3水曜 値=2.4 ← 通常範囲4...月曜の通知を見て対応しても、原因がそれっきり消えていて何もできない。「連休明けの一時的な集中」「キャンペーン日」「センサーの一時的なノイズ」 のような、そもそも異常じゃない振れが大量に通知されます。
通知の 8 割が誤検知になり、人間が「またか」となって本物を見逃しはじめました。
設計:N 回連続で発生したら通知
考え方
1 回の閾値超過は「ノイズかも」、3 回連続なら「持続的に異常」です。
1月曜 値=5.2 → トリガー(カウント開始)2火曜 値=4.8 → 連続 2 回目3水曜 値=5.0 → 連続 3 回目 → 通知!火曜の値が 2.1 のような通常範囲だったらカウントをリセットします。
実装
連続発生のカウントを永続化する必要があります。プロセスが落ちても引き継げるよう、ファイルか DB に持たせます。
1import json2from pathlib import Path3from datetime import date4
5STATE_FILE = Path("/var/state/anomaly_state.json")6CONSECUTIVE_THRESHOLD = 37
8def load_state() -> dict:9 if not STATE_FILE.exists():10 return {"consecutive_days": 0, "last_anomaly_date": None}11 return json.loads(STATE_FILE.read_text())12
13def save_state(state: dict):14 STATE_FILE.write_text(json.dumps(state, default=str))15
20 collapsed lines
16def check_anomaly_with_consecutive(today_value: float, baseline: float, target_date: date):17 state = load_state()18 is_anomaly = today_value > baseline * 219
20 if is_anomaly:21 state["consecutive_days"] += 122 state["last_anomaly_date"] = str(target_date)23
24 if state["consecutive_days"] >= CONSECUTIVE_THRESHOLD:25 send_alert(26 f"値が {state['consecutive_days']} 日連続で異常: "27 f"今日={today_value}, baseline={baseline}"28 )29 else:30 # 連続ストリークが切れた31 if state["consecutive_days"] >= CONSECUTIVE_THRESHOLD:32 send_alert(f"異常状態が解消: {state['consecutive_days']} 日連続→正常へ")33 state["consecutive_days"] = 034
35 save_state(state)N の決め方
CONSECUTIVE_THRESHOLD を何にするかが設計の肝です。
| N | 検知速度 | 誤検知率 | 適用ケース |
|---|---|---|---|
| 1 | 即時 | 高 | クリティカルな指標(停止検知) |
| 2 | 翌日 | 中 | 即対応したいが誤検知も避けたい |
| 3 | 2 日後 | 低 | 持続的なトレンド異常 |
| 5 | 4 日後 | 極低 | 長期トレンド分析 |
私は最初 N=3 から始めて、誤検知が減ってちゃんと持続的な異常だけが上がってくるか見ました。3 日連続で異常が続く事象は、ほぼ確実に何かが起きています。
罠と対処
罠 1: 状態ファイルの永続化
/tmp 配下に置いていたら、再起動で消えていつもカウント 0 から始まる、という罠を踏みました。
1# Bad2STATE_FILE = Path("/tmp/anomaly_state.json") # 再起動で消える3
4# Good5STATE_FILE = Path("/var/state/anomaly_state.json") # 永続パス連続カウントが消えると、N=3 の意味がなくなります。再起動を超えて状態が残るパスに置くか、DB に入れます。
罠 2: ジョブが落ちると保存されない
1state["consecutive_days"] += 12send_alert(...) # ← ここで例外3save_state(state) # ← 保存されない通知関数が外部 API なので失敗する可能性があります。保存を先にします。
1state["consecutive_days"] += 12save_state(state) # 先に保存3try:4 send_alert(...)5except Exception:6 logger.exception("alert failed but state saved")通知に失敗しても、カウント自体は記録されているので、翌日の判定が正しく動きます。
罠 3: 「連続」の定義
「連続 3 日」とは何を指すのか?
- 営業日のみ? → 土日を含めて連続でなくてもよい
- 暦日 3 日? → 中 1 日空いていたらリセット?
- データが取れた日 3 日連続? → 欠損日は無視?
ジョブが土日に走らない場合、最後の異常日と今日の日付の差を見る必要があります。
1def is_consecutive(last_date: date, today: date, max_gap_days: int = 1) -> bool:2 """営業日的な連続を判定(土日を許容)"""3 if last_date is None:4 return False5 gap = (today - last_date).days6 return gap <= max_gap_days私のジョブは平日のみなので、max_gap_days=3(月曜から見た金曜まで許容)にしました。
罠 4: 解消通知も忘れずに
「連続 3 日で異常通知」しただけで、解消したかどうかが追えないと、対応した人が「直ったの?」と確認に行く手間が発生します。
1if state["consecutive_days"] >= CONSECUTIVE_THRESHOLD:2 send_alert("異常状態が解消") # 必須3state["consecutive_days"] = 0「異常検知」と「解消検知」をセットで実装すると、運用の手間が減ります。
拡張: ヒステリシス
「異常閾値で発動して、即座に正常範囲に戻ったら解消」だと、境界線で行ったり来たりする振動が起きます。発動と解消で別の閾値を使うと安定します。
1TRIP_THRESHOLD = 2.0 # baseline の 2.0 倍を超えたら異常2RESET_THRESHOLD = 1.5 # baseline の 1.5 倍を下回ったら解消3
4def check(value, baseline, currently_tripped: bool) -> bool:5 if not currently_tripped and value > baseline * TRIP_THRESHOLD:6 return True7 if currently_tripped and value < baseline * RESET_THRESHOLD:8 return False9 return currently_tripped # 状態維持サーモスタットと同じ発想です。境界線で頻繁にトグルしないので、通知のノイズが減ります。
効果
| 指標 | Before(単発閾値) | After(連続 3 回) |
|---|---|---|
| 通知件数/月 | 12〜18 件 | 1〜3 件 |
| 誤検知率 | 約 80% | 約 10% |
| 対応所要時間 | 通知ごとに 30 分 | 通知ごとに 60 分(が、件数激減) |
| 本物の見逃し | 月 0〜1 件 | 月 0 件 |
通知 1 件あたりの所要時間は増えました(持続的異常なので調査が深い)が、月の合計時間は大幅に減少しました。
学び
1. 「単発で通知」は誤検知の温床
データに自然な振れがある以上、単発の閾値超過は通知すべきではないケースが多いです。持続性を判定軸に入れると、本物の異常だけが残ります。
2. 状態を持つ監視は永続化が必須
メモリだけで連続カウントを持つと、再起動で全部消えます。状態の置き場所を最初に決めます(ファイル or DB)。
3. 解消通知をセットで設計する
「発動」だけで「解消」がない監視は、対応者が状態を追えません。発動・解消をペアで実装します。
4. 「連続」の定義を明文化する
営業日 / 暦日 / データ取得日のどれか、コードコメントに必ず書きます。後で見ると思い出せません。
まとめ
| 観点 | 設計方針 |
|---|---|
| トリガー条件 | 単発ではなく連続 N 回 |
| N の値 | 3 から始めて誤検知率で調整 |
| 状態永続化 | /var/state/ 等の永続パス、または DB |
| 通知設計 | 発動と解消のセット |
| 振動対策 | 発動と解消で別閾値(ヒステリシス) |
「アラートが多い」と感じたとき、閾値を厳しくする方向ではなく 検知ロジックの構造を変えるアプローチが効くケースが多いです。
「連続 N 回」「ヒステリシス」「集約ウィンドウ」などの状態を持つ仕組みは実装の手間がかかりますが、長期的にはアラート疲労の根治につながります。