スマートフォン・タブレットからインターネットサーバーオペレーション

APPW.jp
 
anomaly detection / heavy tail

zスコアは、ログでは効かなかった
2倍の増加も、完全消滅も見逃した

ログ集計の基盤ができたので、次は「いつもと違う」の自動検知を作りました。 教科書どおり平均と標準偏差でzスコアを出す実装です。動かしてみたら—— 403が2倍に増えても、5xxが完全に消えても、判定はすべて「変化なし」。 なぜ効かなかったのか、何に替えたのかの記録です。

zスコア MAD 百分位 MongoDB 監視

[ § 0 ] 導入

作ったのに、何も検知しなかった

Apacheログを追記分だけ集計する仕組みを作り、 氷山ビューで 403 と 404 が見えるようになりました。 次に欲しくなるのは自動検知です。毎回ダッシュボードを開かなくても、変化があったら分かるようにしたい。

統計の定番はzスコアです。平均からの隔たりを標準偏差で割る。実装は10行程度で済みました。

そして画面を開いたら、すべての項目が「変化なし」でした。 その裏で、403は倍増し、5xxは完全に消えていたにもかかわらず。

[ § 1 ] 実装

何を作ったか

考え方は単純です。ベースライン(それ以前)を「いつもの状態」とみなし、直近と比べる。 新しいコレクションは要りません。すでに持っている時間帯バケットの集計を、期間で分けるだけです。

z = ( 直近の平均 − ベースラインの平均 ) ÷ σ |z| ≧ 2.5 を「変化あり」と判定した
判定部分(簡略)
const mean = a => a.reduce((x,y)=>x+y,0)/a.length;
const sd   = a => { const m=mean(a);
  return Math.sqrt(a.reduce((x,y)=>x+(y-m)**2,0)/a.length); };

const z = (mean(recent) - mean(base)) / sd(base);
const verdict = z >= 2.5 ? '増加' : z <= -2.5 ? '減少' : '変化なし';

直近6時間を、その前のベースライン168時間(7日)と比較する設定にしました。

[ § 2 ] 結果

2倍も、消滅も、「変化なし」

実際に出た数字です。

指標毎時の推移倍率z値判定
403 遮断81.29 → 160.831.98倍0.65変化なし
404 探索106.52 → 191.831.80倍0.31変化なし
5xx エラー24.43 → 0完全消滅-0.15変化なし

403は倍近く増えている。5xxは完全にゼロになっている。 それでも判定は全部「変化なし」でした。

5xx のゼロは、自分で直した結果でした。 rewrite の自己ループを修正して、 15%あったサーバエラーが消えた直後だったのです。 効果が出ているのに、検知器はそれを見ていない。 これでは何のために作ったのか分かりません。
[ § 3 ] 原因①

σが、大きすぎた

ダッシュボードが表示していたのは z の値だけでした。そこで、 式を σ について解いて逆算してみます。

σ = ( 直近の平均 − ベースラインの平均 ) ÷ z 表示されていた z から逆に求めた推定値
指標ベース平均逆算した σσ ÷ 平均
403 遮断81.29約 1221.5倍
404 探索106.52約 2752.6倍
5xx エラー24.43約 1636.7倍
この σ は逆算値です。 ダッシュボードが記録していたのは z だけで、σ を直接測っていたわけではありません。 上の式から求めた推定にすぎない点は、断っておきます。 それでも桁として何が起きているかは十分に分かります。

σが平均の6.7倍になる、ということ

正規分布を想定するなら、σ が平均の数倍というのは異常です。 平均24なのにσが163では、「±2.5σ」の幅は −383 から +432。 件数がマイナスになる範囲まで「正常」に含めていることになります。 この幅の中では、何が起きても検知されません

犯人は、たった一つのスパイク

ベースラインの168時間には、rewrite の不具合で 1時間に約1,968件 の 5xx が出た山が含まれていました。 平均は24でも、この1点があるだけでσは跳ね上がります。

zスコアは、正規分布を前提にしています。 ところがログの計数は、下限がゼロで、上には青天井。 スキャナが来れば1時間で千件跳ねることもあります。 裾が重く、左右非対称。前提が最初から成り立っていませんでした。
[ § 4 ] 原因②

日周期も、σに乗る

もうひとつ見落としていました。アクセスには日周期があります。 深夜はスキャナが多く、昼は人間のアクセスが増える。時間帯によって水準がまるで違います。

168時間ぶんをひとつの母集団にすると、この昼夜差もばらつきとしてσに含まれます。 本来「時間帯が違うだけ」の変動が、「いつもの揺れ」として計上されてしまう。

結果として、σ は二重に膨らんでいました。スパイクによる膨張と、日周期による膨張です。

[ § 5 ] 対策①

中央値+MADでも、足りなかった

外れ値に強い統計といえば、中央値と MAD(中央絶対偏差)です。定石どおり替えてみました。

z = ( 直近の平均 − ベースラインの中央値 ) ÷ ( 1.4826 × MAD ) スパイク1つでは中央値もMADもほとんど動かない

結果は、こうでした。

再現データでの検証平均+σ中央値+MAD正解
5xx が完全消滅z=-0.29 変化なしz=-0.67 変化なし減少
403 が昼に2倍z=2.21 変化なしz=1.94 変化なし増加

どちらも検知できません。 σ の膨張は緩和されましたが、 日周期のばらつきは残ったままです。 そして根本的に、時間ごとの生カウントに正規分布を当てはめるという構造自体が合っていませんでした。

ロバスト統計に替えれば済む、という話ではありませんでした。 中央値+MADは「外れ値に引っ張られない」ための道具です。 今回の問題は外れ値だけではなく、そもそも比較の枠組みが間違っていたことにありました。
[ § 6 ] 対策②

百分位に変え、時刻帯を揃える

分布の形を、仮定しない

発想を変えました。平均も σ も使いません。 「直近の値は、ベースライン分布のどの位置にあるか」だけを見ます。

百分位なら、裾が重かろうが歪んでいようが関係ありません。 上位95%以上なら急増、下位5%以下なら急減。順位の話なので、分布の形に依存しない

同じ時刻どうしで比べる

日周期への対策はこうです。直近の各時間を、ベースラインの「同じ時刻」の値とだけ比較する。 深夜3時の値は、過去7日間の深夜3時とだけ比べる。

時刻帯ごとに母集団を分ける
// ベースラインを hour-of-day で束ねる
const byHod = {};
base.forEach(r => { (byHod[r.hod] = byHod[r.hod] || []).push(r.v); });

// 直近の各点を、同じ時刻の母集団の中で順位づけする
recent.forEach(r => {
  const pool = byHod[r.hod] || [];
  let below=0, eq=0;
  pool.forEach(x => { if(x < r.v) below++; else if(x === r.v) eq++; });
  pcts.push((below + eq/2) / pool.length * 100);
});

これで「深夜だから多い」を誤検知しなくなります。 検証では、深夜の通常値が31%(変化なし)と正しく判定されました。 補正が無ければ、全時間帯の中では高い位置に出ていたはずの値です。

[ § 7 ] 対策③

百分位でも、捉えられないもの

これで解決かと思いましたが、まだ穴がありました。5xx の完全消滅が検知できません

理由は単純です。ベースライン168時間のうち、修正後の期間は既にゼロが並んでいます。 全体の4割がゼロなら、新たにゼロになっても下位21%にしかならない。 5%の閾値には届きません。

倍率で補う

順位で捉えられないなら、で捉えます。 ベースラインの中央値に対して、直近が何倍か。

判定条件
急増百分位 ≧ 95% / または 倍率 ≧ 3 かつ件数が下限以上
増加百分位 ≧ 85% / または 倍率 ≧ 2
急減百分位 ≦ 5% / または 倍率 ≦ 0.2
減少百分位 ≦ 15% / または 倍率 ≦ 0.5

さらに下限(5件)を設けました。1件が2件になっただけで「2倍!」と騒がないためです。 小さな数の揺れは、比で見ると必ず大きく出ます。

ひとつの指標で全部を捉えようとしない。 百分位は「いつもと比べて高いか」に強く、倍率は「桁が変わったか」に強い。 得意分野が違うものを組み合わせる——これが最終的な形になりました。
[ § 8 ] 体感デモ

3つの方式を、同じデータに当てる

7つのシナリオを用意し、3方式で判定させます。 正解と一致したセルが緑、外したセルが赤です。

Interactive · method comparison
7シナリオ × 3方式
ベースライン168時間(日周期あり)と直近6時間。実運用のログを模した再現データで、ブラウザ内で毎回計算しています。
正解と一致 外した

A と B が外すのは、同じ2つです。5xx の完全消滅と、403 の2倍増—— まさに実運用で起きていた変化そのもの。 「変化なし」のケースは正しく判定するので、一見まともに動いているように見えます。 実際には、本当の変化だけを見逃していました

[ § 9 ] まとめ

手法の選択も、実装のうち

今回のコードにバグはありませんでした。zスコアの計算式は正しく、平均もσも正確です。 それでも何も検知しませんでした。 間違っていたのは実装ではなく、分布の形を確かめずに手法を選んだことです。

そして怖いのは、この失敗が静かなことでした。 例外も出ず、エラーログにも残らない。画面には「変化なし」と表示され続けます。 7つのシナリオを用意して初めて、欠陥が見えました。

この記事のまとめ

  • ログの計数は下限ゼロ・上は青天井で裾が重い。zスコアの前提が成り立たない。
  • スパイク1つでσが膨張する。逆算するとσは平均の1.5〜6.7倍だった。
  • 日周期もσに乗る。昼夜差が「いつもの揺れ」として計上される。
  • 中央値+MADに替えても足りない。比較の枠組み自体が合っていなかった。
  • 百分位なら分布の形を仮定しない。同じ時刻どうしで比べて日周期を打ち消す。
  • 百分位で捉えられない変化(既にゼロが多い場合)は倍率で補う。小さな数には下限を設ける。
  • 検証は「動くか」ではなく「正しく判定するか」で。ケースを並べて初めて欠陥が見える。

関連記事

『zスコアは、ログでは効かなかった — 2倍の増加も、完全消滅も見逃した』を公開しました。