Deploy outliar
Browse files- README.md +27 -5
- findings.json +158 -0
- index.html +386 -19
- outliar.js +471 -0
- shim.js +208 -0
README.md
CHANGED
|
@@ -1,10 +1,32 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: outliar
|
| 3 |
+
emoji: 🎲
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: gray
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# outliar
|
| 12 |
+
|
| 13 |
+
An anomaly-detection benchmark on the Numenta Anomaly Benchmark, and an audit of
|
| 14 |
+
the metric the field reports.
|
| 15 |
+
|
| 16 |
+
Pick a detector — including `random`, which is uniform noise and never looks at
|
| 17 |
+
the data — and drag the threshold. Point-adjusted F1, the number reported almost
|
| 18 |
+
universally in time-series anomaly detection, will tell you the noise is
|
| 19 |
+
excellent. The composite score will not.
|
| 20 |
+
|
| 21 |
+
Everything runs in your browser: the detectors and the four scoring protocols are
|
| 22 |
+
a JavaScript port of the NumPy implementation, pinned to it to 1e-9 by a
|
| 23 |
+
Python↔Node parity test. NAB itself is fetched from the upstream GitHub raw
|
| 24 |
+
endpoint, not redistributed here.
|
| 25 |
+
|
| 26 |
+
`iforest` is missing from this demo because it needs scikit-learn's fitted trees,
|
| 27 |
+
and `random` uses a JavaScript PRNG rather than NumPy's PCG64 — it is still
|
| 28 |
+
uniform noise, so the argument is unaffected, but the digits will differ slightly
|
| 29 |
+
from the repo's benchmark.
|
| 30 |
+
|
| 31 |
+
Code, the full 47-series benchmark and the write-up:
|
| 32 |
+
**https://github.com/UsmarHaider/outliar**
|
findings.json
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"noise_rank": {
|
| 3 |
+
"point": {
|
| 4 |
+
"noise_f1": 0.18629884319662968,
|
| 5 |
+
"noise_rank": 7,
|
| 6 |
+
"n_detectors": 8,
|
| 7 |
+
"real_detectors_beaten": 0,
|
| 8 |
+
"n_real_detectors": 5,
|
| 9 |
+
"best_real": "window_pca",
|
| 10 |
+
"best_real_f1": 0.31077404415860577,
|
| 11 |
+
"headroom": 0.12447520096197609,
|
| 12 |
+
"spread": 0.12737293454850926
|
| 13 |
+
},
|
| 14 |
+
"pa": {
|
| 15 |
+
"noise_f1": 0.9246043832991432,
|
| 16 |
+
"noise_rank": 4,
|
| 17 |
+
"n_detectors": 8,
|
| 18 |
+
"real_detectors_beaten": 3,
|
| 19 |
+
"n_real_detectors": 5,
|
| 20 |
+
"best_real": "ewma",
|
| 21 |
+
"best_real_f1": 0.9384605994911948,
|
| 22 |
+
"headroom": 0.013856216192051574,
|
| 23 |
+
"spread": 0.7558962756709838
|
| 24 |
+
},
|
| 25 |
+
"pa20": {
|
| 26 |
+
"noise_f1": 0.49753952257809236,
|
| 27 |
+
"noise_rank": 6,
|
| 28 |
+
"n_detectors": 8,
|
| 29 |
+
"real_detectors_beaten": 0,
|
| 30 |
+
"n_real_detectors": 5,
|
| 31 |
+
"best_real": "window_pca",
|
| 32 |
+
"best_real_f1": 0.6693155263887254,
|
| 33 |
+
"headroom": 0.17177600381063302,
|
| 34 |
+
"spread": 0.4859144167786289
|
| 35 |
+
},
|
| 36 |
+
"pa50": {
|
| 37 |
+
"noise_f1": 0.302802062489317,
|
| 38 |
+
"noise_rank": 5,
|
| 39 |
+
"n_detectors": 8,
|
| 40 |
+
"real_detectors_beaten": 1,
|
| 41 |
+
"n_real_detectors": 5,
|
| 42 |
+
"best_real": "window_pca",
|
| 43 |
+
"best_real_f1": 0.417929135071724,
|
| 44 |
+
"headroom": 0.11512707258240701,
|
| 45 |
+
"spread": 0.23452802546162752
|
| 46 |
+
},
|
| 47 |
+
"composite": {
|
| 48 |
+
"noise_f1": 0.26385262146624205,
|
| 49 |
+
"noise_rank": 7,
|
| 50 |
+
"n_detectors": 8,
|
| 51 |
+
"real_detectors_beaten": 0,
|
| 52 |
+
"n_real_detectors": 5,
|
| 53 |
+
"best_real": "window_pca",
|
| 54 |
+
"best_real_f1": 0.7125169793475976,
|
| 55 |
+
"headroom": 0.44866435788135556,
|
| 56 |
+
"spread": 0.5291158697375011
|
| 57 |
+
}
|
| 58 |
+
},
|
| 59 |
+
"win_rate_vs_noise": {
|
| 60 |
+
"point": {
|
| 61 |
+
"ewma": 0.14893617021276595,
|
| 62 |
+
"rolling_mad": 0.2765957446808511,
|
| 63 |
+
"seasonal": 0.3829787234042553,
|
| 64 |
+
"window_pca": 0.10638297872340426,
|
| 65 |
+
"iforest": 0.19148936170212766
|
| 66 |
+
},
|
| 67 |
+
"pa": {
|
| 68 |
+
"ewma": 0.23404255319148937,
|
| 69 |
+
"rolling_mad": 0.3404255319148936,
|
| 70 |
+
"seasonal": 0.2765957446808511,
|
| 71 |
+
"window_pca": 0.5531914893617021,
|
| 72 |
+
"iforest": 0.7021276595744681
|
| 73 |
+
},
|
| 74 |
+
"pa20": {
|
| 75 |
+
"ewma": 0.19148936170212766,
|
| 76 |
+
"rolling_mad": 0.3617021276595745,
|
| 77 |
+
"seasonal": 0.46808510638297873,
|
| 78 |
+
"window_pca": 0.19148936170212766,
|
| 79 |
+
"iforest": 0.3617021276595745
|
| 80 |
+
},
|
| 81 |
+
"pa50": {
|
| 82 |
+
"ewma": 0.2978723404255319,
|
| 83 |
+
"rolling_mad": 0.574468085106383,
|
| 84 |
+
"seasonal": 0.48936170212765956,
|
| 85 |
+
"window_pca": 0.3404255319148936,
|
| 86 |
+
"iforest": 0.425531914893617
|
| 87 |
+
},
|
| 88 |
+
"composite": {
|
| 89 |
+
"ewma": 0.06382978723404255,
|
| 90 |
+
"rolling_mad": 0.0851063829787234,
|
| 91 |
+
"seasonal": 0.06382978723404255,
|
| 92 |
+
"window_pca": 0.1276595744680851,
|
| 93 |
+
"iforest": 0.19148936170212766
|
| 94 |
+
}
|
| 95 |
+
},
|
| 96 |
+
"ranking_agreement_with_composite": {
|
| 97 |
+
"point": 0.7857142857142857,
|
| 98 |
+
"pa": 0.21428571428571427,
|
| 99 |
+
"pa20": 0.6666666666666666,
|
| 100 |
+
"pa50": 0.6904761904761905,
|
| 101 |
+
"composite": 1.0
|
| 102 |
+
},
|
| 103 |
+
"pa_inflation": {
|
| 104 |
+
"random": 138.5303219727133,
|
| 105 |
+
"constant": 1.0,
|
| 106 |
+
"lastvalue": 120.8234167872443,
|
| 107 |
+
"ewma": 79.51259413689223,
|
| 108 |
+
"rolling_mad": 110.6269531250534,
|
| 109 |
+
"seasonal": 72.16495727072648,
|
| 110 |
+
"window_pca": 68.14590138566369,
|
| 111 |
+
"iforest": 56.96334661834212
|
| 112 |
+
},
|
| 113 |
+
"oracle_cost": {
|
| 114 |
+
"point": {
|
| 115 |
+
"oracle_mean": 0.2591419755042485,
|
| 116 |
+
"calibrated_mean": 0.13502704664722287,
|
| 117 |
+
"retained": 0.5210543231542555
|
| 118 |
+
},
|
| 119 |
+
"pa": {
|
| 120 |
+
"oracle_mean": 0.8907523304190674,
|
| 121 |
+
"calibrated_mean": 0.5613190141132536,
|
| 122 |
+
"retained": 0.6301628353295162
|
| 123 |
+
},
|
| 124 |
+
"pa20": {
|
| 125 |
+
"oracle_mean": 0.5909787003477435,
|
| 126 |
+
"calibrated_mean": 0.21711963779403853,
|
| 127 |
+
"retained": 0.36738995443707373
|
| 128 |
+
},
|
| 129 |
+
"pa50": {
|
| 130 |
+
"oracle_mean": 0.35877155580561493,
|
| 131 |
+
"calibrated_mean": 0.14993999080354997,
|
| 132 |
+
"retained": 0.4179260824255214
|
| 133 |
+
},
|
| 134 |
+
"composite": {
|
| 135 |
+
"oracle_mean": 0.6290775850355814,
|
| 136 |
+
"calibrated_mean": 0.35201329852216534,
|
| 137 |
+
"retained": 0.5595705631480337
|
| 138 |
+
}
|
| 139 |
+
},
|
| 140 |
+
"protocol_descriptions": {
|
| 141 |
+
"point": "Per-point F1, unadjusted",
|
| 142 |
+
"pa": "Point-adjusted F1 (the literature standard)",
|
| 143 |
+
"pa20": "PA applied only when >20% of the window was flagged",
|
| 144 |
+
"pa50": "PA applied only when >50% of the window was flagged",
|
| 145 |
+
"composite": "Point-wise precision + event-wise recall (Garg et al.)"
|
| 146 |
+
},
|
| 147 |
+
"detector_order": [
|
| 148 |
+
"random",
|
| 149 |
+
"constant",
|
| 150 |
+
"lastvalue",
|
| 151 |
+
"ewma",
|
| 152 |
+
"rolling_mad",
|
| 153 |
+
"seasonal",
|
| 154 |
+
"window_pca",
|
| 155 |
+
"iforest"
|
| 156 |
+
],
|
| 157 |
+
"n_series": 47
|
| 158 |
+
}
|
index.html
CHANGED
|
@@ -1,19 +1,386 @@
|
|
| 1 |
-
<
|
| 2 |
-
<
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<meta charset="utf-8">
|
| 2 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 3 |
+
<title>outliar — the metric is the bug</title>
|
| 4 |
+
<style>
|
| 5 |
+
:root {
|
| 6 |
+
--blue: #2a78d6;
|
| 7 |
+
--orange: #eb6834;
|
| 8 |
+
--ink: #14202c;
|
| 9 |
+
--muted: #66748a;
|
| 10 |
+
--bg: #f6f8fb;
|
| 11 |
+
--card: #ffffff;
|
| 12 |
+
--line: #e2e8f1;
|
| 13 |
+
--shadow: 0 1px 2px rgba(20, 32, 44, .06), 0 8px 24px rgba(20, 32, 44, .06);
|
| 14 |
+
}
|
| 15 |
+
@media (prefers-color-scheme: dark) {
|
| 16 |
+
:root {
|
| 17 |
+
--ink: #e8edf4;
|
| 18 |
+
--muted: #93a1b5;
|
| 19 |
+
--bg: #0f151d;
|
| 20 |
+
--card: #171f2a;
|
| 21 |
+
--line: #26313f;
|
| 22 |
+
--shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 8px 24px rgba(0, 0, 0, .3);
|
| 23 |
+
}
|
| 24 |
+
}
|
| 25 |
+
* { box-sizing: border-box; }
|
| 26 |
+
body {
|
| 27 |
+
margin: 0; padding: 28px 20px 48px; background: var(--bg); color: var(--ink);
|
| 28 |
+
font: 15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
| 29 |
+
-webkit-font-smoothing: antialiased;
|
| 30 |
+
}
|
| 31 |
+
.wrap { max-width: 1080px; margin: 0 auto; }
|
| 32 |
+
header { margin-bottom: 20px; }
|
| 33 |
+
h1 { font-size: 25px; margin: 0 0 4px; letter-spacing: -.02em; }
|
| 34 |
+
h1 span { color: var(--orange); }
|
| 35 |
+
.tag { color: var(--muted); font-size: 14.5px; margin: 0; max-width: 66ch; }
|
| 36 |
+
.card {
|
| 37 |
+
background: var(--card); border: 1px solid var(--line); border-radius: 12px;
|
| 38 |
+
box-shadow: var(--shadow); padding: 18px; margin-bottom: 16px;
|
| 39 |
+
}
|
| 40 |
+
.controls { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; }
|
| 41 |
+
.field { display: flex; flex-direction: column; gap: 5px; min-width: 190px; flex: 1 1 200px; }
|
| 42 |
+
label { font-size: 11.5px; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); font-weight: 600; }
|
| 43 |
+
select, button {
|
| 44 |
+
font: inherit; color: var(--ink); background: var(--bg);
|
| 45 |
+
border: 1px solid var(--line); border-radius: 8px; padding: 9px 11px;
|
| 46 |
+
}
|
| 47 |
+
select { width: 100%; }
|
| 48 |
+
button {
|
| 49 |
+
background: var(--blue); color: #fff; border-color: transparent; cursor: pointer;
|
| 50 |
+
font-weight: 600; padding: 9px 16px; white-space: nowrap; transition: filter .15s;
|
| 51 |
+
}
|
| 52 |
+
button:hover { filter: brightness(1.08); }
|
| 53 |
+
button:disabled { opacity: .55; cursor: default; }
|
| 54 |
+
|
| 55 |
+
.verdict {
|
| 56 |
+
display: flex; gap: 12px; align-items: flex-start;
|
| 57 |
+
border-left: 3px solid var(--blue); padding: 10px 0 10px 14px; margin-top: 16px;
|
| 58 |
+
font-size: 14px;
|
| 59 |
+
}
|
| 60 |
+
.verdict.control { border-left-color: var(--orange); }
|
| 61 |
+
.verdict b { display: block; margin-bottom: 2px; }
|
| 62 |
+
.verdict .why { color: var(--muted); }
|
| 63 |
+
|
| 64 |
+
canvas { width: 100%; display: block; }
|
| 65 |
+
.slider-row { display: flex; align-items: center; gap: 14px; margin-top: 14px; }
|
| 66 |
+
.slider-row input[type=range] { flex: 1; accent-color: var(--orange); }
|
| 67 |
+
.thr { font-variant-numeric: tabular-nums; font-size: 13px; color: var(--muted); min-width: 128px; }
|
| 68 |
+
.presets { display: flex; gap: 8px; }
|
| 69 |
+
.presets button { background: transparent; color: var(--muted); border: 1px solid var(--line); font-weight: 500; font-size: 12.5px; padding: 6px 10px; }
|
| 70 |
+
.presets button:hover { color: var(--ink); filter: none; border-color: var(--muted); }
|
| 71 |
+
|
| 72 |
+
.metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; }
|
| 73 |
+
.metric { border: 1px solid var(--line); border-radius: 10px; padding: 12px 13px; background: var(--bg); }
|
| 74 |
+
.metric.hero { border-color: var(--orange); background: color-mix(in srgb, var(--orange) 8%, var(--card)); }
|
| 75 |
+
.metric.good { border-color: var(--blue); background: color-mix(in srgb, var(--blue) 8%, var(--card)); }
|
| 76 |
+
.metric .k { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); font-weight: 600; }
|
| 77 |
+
.metric .v { font-size: 27px; font-weight: 650; font-variant-numeric: tabular-nums; letter-spacing: -.02em; margin: 3px 0 1px; }
|
| 78 |
+
.metric .s { font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
| 79 |
+
|
| 80 |
+
.ledger { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--line); font-size: 13.5px; color: var(--muted); }
|
| 81 |
+
.ledger b { color: var(--ink); font-variant-numeric: tabular-nums; }
|
| 82 |
+
.legend { display: flex; gap: 16px; flex-wrap: wrap; font-size: 12.5px; color: var(--muted); margin-bottom: 10px; }
|
| 83 |
+
.swatch { display: inline-block; width: 11px; height: 11px; border-radius: 3px; vertical-align: -1px; margin-right: 5px; }
|
| 84 |
+
footer { color: var(--muted); font-size: 12.5px; text-align: center; margin-top: 22px; }
|
| 85 |
+
footer a { color: var(--blue); }
|
| 86 |
+
.err { color: var(--orange); font-size: 13.5px; }
|
| 87 |
+
</style>
|
| 88 |
+
|
| 89 |
+
<div class="wrap">
|
| 90 |
+
<header>
|
| 91 |
+
<h1>outl<span>iar</span></h1>
|
| 92 |
+
<p class="tag">
|
| 93 |
+
Anomaly detection on the Numenta benchmark, scored four ways. Pick a detector — including
|
| 94 |
+
one that is literally uniform noise — drag the threshold, and watch the metrics disagree
|
| 95 |
+
about whether it works.
|
| 96 |
+
</p>
|
| 97 |
+
</header>
|
| 98 |
+
|
| 99 |
+
<div class="card">
|
| 100 |
+
<div class="controls">
|
| 101 |
+
<div class="field">
|
| 102 |
+
<label for="series">Series</label>
|
| 103 |
+
<select id="series"></select>
|
| 104 |
+
</div>
|
| 105 |
+
<div class="field">
|
| 106 |
+
<label for="detector">Detector</label>
|
| 107 |
+
<select id="detector"></select>
|
| 108 |
+
</div>
|
| 109 |
+
<button id="sample">Try a sample</button>
|
| 110 |
+
</div>
|
| 111 |
+
<div class="verdict" id="verdict">
|
| 112 |
+
<div><b id="verdict-title">Loading…</b><span class="why" id="verdict-why"></span></div>
|
| 113 |
+
</div>
|
| 114 |
+
</div>
|
| 115 |
+
|
| 116 |
+
<div class="card">
|
| 117 |
+
<div class="legend">
|
| 118 |
+
<span><i class="swatch" style="background:#2a78d6;opacity:.35"></i>ground-truth anomaly window</span>
|
| 119 |
+
<span><i class="swatch" style="background:#eb6834"></i>alarm raised</span>
|
| 120 |
+
<span><i class="swatch" style="background:#66748a"></i>signal & anomaly score</span>
|
| 121 |
+
</div>
|
| 122 |
+
<canvas id="chart" height="360"></canvas>
|
| 123 |
+
<div class="slider-row">
|
| 124 |
+
<input type="range" id="threshold" min="0" max="1000" value="500">
|
| 125 |
+
<span class="thr" id="thr-label">threshold —</span>
|
| 126 |
+
<div class="presets">
|
| 127 |
+
<button id="opt-pa" title="The threshold that maximises point-adjusted F1">PA optimum</button>
|
| 128 |
+
<button id="opt-comp" title="The threshold that maximises composite F1">Composite optimum</button>
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
</div>
|
| 132 |
+
|
| 133 |
+
<div class="card">
|
| 134 |
+
<div class="metrics" id="metrics"></div>
|
| 135 |
+
<div class="ledger" id="ledger"></div>
|
| 136 |
+
</div>
|
| 137 |
+
|
| 138 |
+
<footer id="footer"></footer>
|
| 139 |
+
</div>
|
| 140 |
+
|
| 141 |
+
<script src="outliar.js"></script>
|
| 142 |
+
<script src="shim.js"></script>
|
| 143 |
+
<script>
|
| 144 |
+
const BLUE = "#2a78d6", ORANGE = "#eb6834";
|
| 145 |
+
const $ = (id) => document.getElementById(id);
|
| 146 |
+
const params = new URLSearchParams(location.search);
|
| 147 |
+
|
| 148 |
+
let state = { data: null, catalog: null, sampleIndex: 0, busy: false };
|
| 149 |
+
|
| 150 |
+
function ink() {
|
| 151 |
+
return getComputedStyle(document.body).getPropertyValue("--ink").trim();
|
| 152 |
+
}
|
| 153 |
+
function muted() {
|
| 154 |
+
return getComputedStyle(document.body).getPropertyValue("--muted").trim();
|
| 155 |
+
}
|
| 156 |
+
function lineColor() {
|
| 157 |
+
return getComputedStyle(document.body).getPropertyValue("--line").trim();
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
async function getJSON(url) {
|
| 161 |
+
const response = await fetch(url);
|
| 162 |
+
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
|
| 163 |
+
return response.json();
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
// ---------------------------------------------------------------- chart
|
| 167 |
+
function draw() {
|
| 168 |
+
const d = state.data;
|
| 169 |
+
const canvas = $("chart");
|
| 170 |
+
const ratio = window.devicePixelRatio || 1;
|
| 171 |
+
const width = canvas.clientWidth, height = 360;
|
| 172 |
+
canvas.width = width * ratio;
|
| 173 |
+
canvas.height = height * ratio;
|
| 174 |
+
canvas.style.height = height + "px";
|
| 175 |
+
const ctx = canvas.getContext("2d");
|
| 176 |
+
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
|
| 177 |
+
ctx.clearRect(0, 0, width, height);
|
| 178 |
+
if (!d) return;
|
| 179 |
+
|
| 180 |
+
const padL = 8, padR = 8;
|
| 181 |
+
const topH = 210, gap = 26, botH = 96;
|
| 182 |
+
const plotW = width - padL - padR;
|
| 183 |
+
const n = d.values.length;
|
| 184 |
+
const x = (i) => padL + (i / Math.max(1, n - 1)) * plotW;
|
| 185 |
+
|
| 186 |
+
// Ground-truth windows, drawn first so everything else sits on top. Window
|
| 187 |
+
// bounds are full-resolution indices; the drawn series is decimated.
|
| 188 |
+
const scale = n / d.n;
|
| 189 |
+
ctx.fillStyle = BLUE;
|
| 190 |
+
ctx.globalAlpha = 0.3;
|
| 191 |
+
for (const [s, e] of d.windows) {
|
| 192 |
+
const x0 = x(s * scale), x1 = x(e * scale);
|
| 193 |
+
ctx.fillRect(x0, 0, Math.max(1.5, x1 - x0), topH + gap + botH);
|
| 194 |
+
}
|
| 195 |
+
ctx.globalAlpha = 1;
|
| 196 |
+
|
| 197 |
+
// Probation shading — nothing there is scored.
|
| 198 |
+
const probX = x(d.probation * scale);
|
| 199 |
+
ctx.fillStyle = muted();
|
| 200 |
+
ctx.globalAlpha = 0.09;
|
| 201 |
+
ctx.fillRect(padL, 0, probX - padL, topH + gap + botH);
|
| 202 |
+
ctx.globalAlpha = 1;
|
| 203 |
+
|
| 204 |
+
const drawSeries = (arr, y0, h, color, lw) => {
|
| 205 |
+
let lo = Infinity, hi = -Infinity;
|
| 206 |
+
for (const v of arr) { if (v < lo) lo = v; if (v > hi) hi = v; }
|
| 207 |
+
if (!(hi > lo)) { hi = lo + 1; }
|
| 208 |
+
const y = (v) => y0 + h - ((v - lo) / (hi - lo)) * h;
|
| 209 |
+
ctx.beginPath();
|
| 210 |
+
for (let i = 0; i < arr.length; i++) {
|
| 211 |
+
const px = x(i), py = y(arr[i]);
|
| 212 |
+
i ? ctx.lineTo(px, py) : ctx.moveTo(px, py);
|
| 213 |
+
}
|
| 214 |
+
ctx.strokeStyle = color; ctx.lineWidth = lw; ctx.stroke();
|
| 215 |
+
return { y, lo, hi };
|
| 216 |
+
};
|
| 217 |
+
|
| 218 |
+
// Panel 1 — the signal, with an alarm tick under every flagged point.
|
| 219 |
+
const sig = drawSeries(d.values, 0, topH, muted(), 0.8);
|
| 220 |
+
ctx.fillStyle = ORANGE;
|
| 221 |
+
for (let i = 0; i < n; i++) {
|
| 222 |
+
if (d.scores[i] >= d.threshold && i * scale >= d.probation) {
|
| 223 |
+
ctx.fillRect(x(i) - 0.6, topH - 8, 1.6, 8);
|
| 224 |
+
}
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
// Panel 2 — the anomaly score and where the threshold cuts it.
|
| 228 |
+
const y0 = topH + gap;
|
| 229 |
+
const sc = drawSeries(d.scores, y0, botH, muted(), 0.8);
|
| 230 |
+
const ty = Math.min(y0 + botH, Math.max(y0, sc.y(d.threshold)));
|
| 231 |
+
ctx.beginPath();
|
| 232 |
+
ctx.setLineDash([5, 4]);
|
| 233 |
+
ctx.moveTo(padL, ty); ctx.lineTo(width - padR, ty);
|
| 234 |
+
ctx.strokeStyle = ORANGE; ctx.lineWidth = 1.4; ctx.stroke();
|
| 235 |
+
ctx.setLineDash([]);
|
| 236 |
+
|
| 237 |
+
ctx.fillStyle = muted();
|
| 238 |
+
ctx.font = "11px -apple-system, system-ui, sans-serif";
|
| 239 |
+
ctx.textAlign = "left";
|
| 240 |
+
ctx.fillText("signal", padL + 2, 12);
|
| 241 |
+
// Drop the caption to the floor of the panel when the threshold line is
|
| 242 |
+
// sitting where the caption would go — which is exactly what happens at the
|
| 243 |
+
// point-adjusted optimum, i.e. in every interesting screenshot.
|
| 244 |
+
const capY = ty < y0 + 22 ? y0 + botH - 4 : y0 + 12;
|
| 245 |
+
ctx.fillText("anomaly score", padL + 2, capY);
|
| 246 |
+
ctx.textAlign = "right";
|
| 247 |
+
const labY = Math.min(y0 + botH - 4, Math.max(y0 + 10, ty - 5));
|
| 248 |
+
ctx.fillText("threshold", width - padR - 4, labY);
|
| 249 |
+
ctx.textAlign = "left";
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
// ---------------------------------------------------------------- render
|
| 253 |
+
function metricCard(key, label, value, sub, cls) {
|
| 254 |
+
return `<div class="metric ${cls}">
|
| 255 |
+
<div class="k">${label}</div>
|
| 256 |
+
<div class="v">${value.toFixed(3)}</div>
|
| 257 |
+
<div class="s">${sub}</div>
|
| 258 |
+
</div>`;
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
function render() {
|
| 262 |
+
const d = state.data;
|
| 263 |
+
if (!d) return;
|
| 264 |
+
|
| 265 |
+
$("verdict").className = "verdict" + (d.is_control ? " control" : "");
|
| 266 |
+
$("verdict-title").textContent =
|
| 267 |
+
`${d.detector} — ${d.description}` + (d.is_control ? " · not a detector" : "");
|
| 268 |
+
$("verdict-why").textContent = d.note ||
|
| 269 |
+
(d.is_control
|
| 270 |
+
? "This is a control. Any score it earns is a property of the metric, not of the method."
|
| 271 |
+
: "A genuine detector: it fits on the warm-up period only and never sees a label.");
|
| 272 |
+
|
| 273 |
+
const m = d.metrics;
|
| 274 |
+
$("metrics").innerHTML = [
|
| 275 |
+
metricCard("point", "Point-wise F1", m.point.f1,
|
| 276 |
+
`P ${m.point.precision.toFixed(2)} · R ${m.point.recall.toFixed(2)}`, ""),
|
| 277 |
+
metricCard("pa", "Point-adjusted F1", m.pa.f1,
|
| 278 |
+
"the number papers report", "hero"),
|
| 279 |
+
metricCard("pa20", "PA%K, K=20", m.pa20.f1,
|
| 280 |
+
"needs >20% of the window", ""),
|
| 281 |
+
metricCard("composite", "Composite F1", m.composite.f1,
|
| 282 |
+
`precision ${m.composite.precision.toFixed(2)} · events ${d.windows_caught}/${d.windows_total}`, "good"),
|
| 283 |
+
].join("");
|
| 284 |
+
|
| 285 |
+
const ratio = d.alarms_inside > 0 ? (d.pa_credited / d.alarms_inside) : 0;
|
| 286 |
+
$("ledger").innerHTML =
|
| 287 |
+
`<b>${d.alarms.toLocaleString()}</b> alarms raised · ` +
|
| 288 |
+
`<b>${d.alarms_inside.toLocaleString()}</b> landed inside a real anomaly window · ` +
|
| 289 |
+
`point adjustment credits <b>${d.pa_credited.toLocaleString()}</b> of them as true positives` +
|
| 290 |
+
(ratio > 1.5 ? ` — a <b>${ratio.toFixed(0)}×</b> markup` : "") +
|
| 291 |
+
` · <b>${d.false_alarms_per_day.toFixed(1)}</b> false alarms per day.`;
|
| 292 |
+
|
| 293 |
+
const range = d.score_range;
|
| 294 |
+
const pos = Math.round(1000 * (d.threshold - range[0]) / Math.max(1e-9, range[1] - range[0]));
|
| 295 |
+
$("threshold").value = Math.max(0, Math.min(1000, pos));
|
| 296 |
+
$("thr-label").textContent = `threshold ${d.threshold.toFixed(3)}`;
|
| 297 |
+
draw();
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
// ---------------------------------------------------------------- data
|
| 301 |
+
async function load(threshold) {
|
| 302 |
+
if (state.busy) return;
|
| 303 |
+
state.busy = true;
|
| 304 |
+
$("sample").disabled = true;
|
| 305 |
+
try {
|
| 306 |
+
const series = $("series").value, detector = $("detector").value;
|
| 307 |
+
let url = `/api/evaluate?series=${encodeURIComponent(series)}&detector=${detector}`;
|
| 308 |
+
if (threshold !== undefined && threshold !== null) url += `&threshold=${threshold}`;
|
| 309 |
+
state.data = await getJSON(url);
|
| 310 |
+
render();
|
| 311 |
+
} catch (err) {
|
| 312 |
+
$("verdict-title").textContent = "Could not evaluate";
|
| 313 |
+
$("verdict-why").innerHTML = `<span class="err">${err.message}</span>`;
|
| 314 |
+
} finally {
|
| 315 |
+
state.busy = false;
|
| 316 |
+
$("sample").disabled = false;
|
| 317 |
+
}
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
async function loadSample() {
|
| 321 |
+
$("sample").disabled = true;
|
| 322 |
+
try {
|
| 323 |
+
state.data = await getJSON(`/sample?index=${state.sampleIndex++}`);
|
| 324 |
+
$("series").value = state.data.series;
|
| 325 |
+
$("detector").value = state.data.detector;
|
| 326 |
+
render();
|
| 327 |
+
} catch (err) {
|
| 328 |
+
$("verdict-title").textContent = "No sample available";
|
| 329 |
+
$("verdict-why").innerHTML = `<span class="err">${err.message}</span>`;
|
| 330 |
+
} finally {
|
| 331 |
+
$("sample").disabled = false;
|
| 332 |
+
}
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
async function boot() {
|
| 336 |
+
state.catalog = await getJSON("/api/catalog");
|
| 337 |
+
$("series").innerHTML = state.catalog.series
|
| 338 |
+
.map((s) => `<option value="${s.key}">${s.name} — ${s.corpus} (${s.windows} windows)</option>`)
|
| 339 |
+
.join("");
|
| 340 |
+
$("detector").innerHTML = state.catalog.detectors
|
| 341 |
+
.map((d) => `<option value="${d.name}">${d.name}${d.is_control ? " (control)" : ""}</option>`)
|
| 342 |
+
.join("");
|
| 343 |
+
|
| 344 |
+
$("series").onchange = () => load();
|
| 345 |
+
$("detector").onchange = () => load();
|
| 346 |
+
$("sample").onclick = loadSample;
|
| 347 |
+
$("opt-pa").onclick = () => load(state.data && state.data.pa_optimal_threshold);
|
| 348 |
+
$("opt-comp").onclick = () => load(state.data && state.data.composite_optimal_threshold);
|
| 349 |
+
|
| 350 |
+
$("threshold").oninput = (e) => {
|
| 351 |
+
if (!state.data) return;
|
| 352 |
+
const [lo, hi] = state.data.score_range;
|
| 353 |
+
state.data.threshold = lo + (e.target.value / 1000) * (hi - lo);
|
| 354 |
+
$("thr-label").textContent = `threshold ${state.data.threshold.toFixed(3)}`;
|
| 355 |
+
draw();
|
| 356 |
+
};
|
| 357 |
+
$("threshold").onchange = (e) => {
|
| 358 |
+
if (!state.data) return;
|
| 359 |
+
const [lo, hi] = state.data.score_range;
|
| 360 |
+
load(lo + (e.target.value / 1000) * (hi - lo));
|
| 361 |
+
};
|
| 362 |
+
window.addEventListener("resize", draw);
|
| 363 |
+
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", draw);
|
| 364 |
+
|
| 365 |
+
try {
|
| 366 |
+
const f = await getJSON("/api/findings");
|
| 367 |
+
const pa = f.noise_rank.pa, comp = f.noise_rank.composite;
|
| 368 |
+
$("footer").innerHTML =
|
| 369 |
+
`Across all ${f.n_series} real NAB series: uniform noise scores ` +
|
| 370 |
+
`<b>${pa.noise_f1.toFixed(3)}</b> point-adjusted F1 (rank ${pa.noise_rank}/${pa.n_detectors}) ` +
|
| 371 |
+
`and <b>${comp.noise_f1.toFixed(3)}</b> composite F1 (rank ${comp.noise_rank}/${comp.n_detectors}).`;
|
| 372 |
+
} catch {
|
| 373 |
+
$("footer").textContent = "Run `make benchmark` to populate the benchmark-wide summary.";
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
await loadSample();
|
| 377 |
+
if (params.get("demo") === "1") {
|
| 378 |
+
// Headless screenshots land on a populated, argument-making state.
|
| 379 |
+
const n = parseInt(params.get("sample") || "0", 10);
|
| 380 |
+
state.sampleIndex = n;
|
| 381 |
+
await loadSample();
|
| 382 |
+
}
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
boot();
|
| 386 |
+
</script>
|
outliar.js
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* A JavaScript port of outliar's detectors and metrics.
|
| 2 |
+
*
|
| 3 |
+
* The deployed demo is a *static* page: there is no server, so the same
|
| 4 |
+
* benchmark has to run in the browser. Every function here mirrors the NumPy
|
| 5 |
+
* implementation in src/outliar/{detectors,metrics}.py, and tests/test_parity.py
|
| 6 |
+
* pins them together to 1e-9 by running this file under Node against the Python
|
| 7 |
+
* results on a shared fixture.
|
| 8 |
+
*
|
| 9 |
+
* Isolation Forest is the one detector not ported — it needs scikit-learn's
|
| 10 |
+
* fitted trees. The static demo omits it and says so.
|
| 11 |
+
*/
|
| 12 |
+
(function (root) {
|
| 13 |
+
"use strict";
|
| 14 |
+
|
| 15 |
+
const EPS = 1e-9;
|
| 16 |
+
|
| 17 |
+
// ---------------------------------------------------------------- helpers
|
| 18 |
+
function median(values) {
|
| 19 |
+
if (!values.length) return 0;
|
| 20 |
+
const sorted = Float64Array.from(values).sort();
|
| 21 |
+
const mid = sorted.length >> 1;
|
| 22 |
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
function quantile(values, q) {
|
| 26 |
+
if (!values.length) return 0;
|
| 27 |
+
const sorted = Float64Array.from(values).sort();
|
| 28 |
+
// Linear interpolation between order statistics — numpy's default.
|
| 29 |
+
const pos = (sorted.length - 1) * q;
|
| 30 |
+
const lo = Math.floor(pos), hi = Math.ceil(pos);
|
| 31 |
+
return lo === hi ? sorted[lo] : sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo);
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function std(values) {
|
| 35 |
+
if (!values.length) return 0;
|
| 36 |
+
const mean = values.reduce((a, b) => a + b, 0) / values.length;
|
| 37 |
+
let acc = 0;
|
| 38 |
+
for (const v of values) acc += (v - mean) * (v - mean);
|
| 39 |
+
return Math.sqrt(acc / values.length);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
function robustScale(values) {
|
| 43 |
+
const med = median(values);
|
| 44 |
+
const scale = 1.4826 * median(Array.from(values, (v) => Math.abs(v - med)));
|
| 45 |
+
if (scale >= EPS) return scale;
|
| 46 |
+
const sd = std(values);
|
| 47 |
+
return sd > EPS ? sd : 1.0;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/** Right-aligned rolling windows, front-padded by repeating the first value. */
|
| 51 |
+
function rollingWindows(values, window) {
|
| 52 |
+
const w = Math.max(2, Math.min(window, values.length));
|
| 53 |
+
const padded = new Float64Array(values.length + w - 1);
|
| 54 |
+
padded.fill(values[0], 0, w - 1);
|
| 55 |
+
padded.set(values, w - 1);
|
| 56 |
+
const out = [];
|
| 57 |
+
for (let i = 0; i < values.length; i++) out.push(padded.subarray(i, i + w));
|
| 58 |
+
return out;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
function normalise(scores, probation) {
|
| 62 |
+
const warm = Array.from(scores.slice(0, probation)).filter(Number.isFinite);
|
| 63 |
+
const scale = warm.length ? robustScale(warm) : 1.0;
|
| 64 |
+
return Float64Array.from(scores, (s) => {
|
| 65 |
+
const v = s / (scale + EPS);
|
| 66 |
+
return Number.isFinite(v) ? v : 0;
|
| 67 |
+
});
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
const PROBATION_FRACTION = 0.15, PROBATION_MAX = 150;
|
| 71 |
+
function probationLength(n) {
|
| 72 |
+
return Math.min(PROBATION_MAX, Math.floor(PROBATION_FRACTION * n));
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
/** mulberry32 — a small seeded PRNG so the noise control is reproducible. */
|
| 76 |
+
function seededRandom(seed) {
|
| 77 |
+
let a = seed >>> 0;
|
| 78 |
+
return function () {
|
| 79 |
+
a = (a + 0x6d2b79f5) >>> 0;
|
| 80 |
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
| 81 |
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
| 82 |
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
| 83 |
+
};
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
// ---------------------------------------------------------------- detectors
|
| 87 |
+
function randomScore(series, seed) {
|
| 88 |
+
const rand = seededRandom((seed || 0) + series.values.length);
|
| 89 |
+
return Float64Array.from({ length: series.values.length }, rand);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
function constantScore(series) {
|
| 93 |
+
return new Float64Array(series.values.length).fill(0.5);
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
function lastvalueScore(series) {
|
| 97 |
+
const x = series.values, n = x.length;
|
| 98 |
+
const diff = new Float64Array(n);
|
| 99 |
+
for (let i = 1; i < n; i++) diff[i] = Math.abs(x[i] - x[i - 1]);
|
| 100 |
+
return normalise(diff, probationLength(n));
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
function ewmaScore(series, seed, alpha) {
|
| 104 |
+
alpha = alpha === undefined ? 0.05 : alpha;
|
| 105 |
+
const x = series.values, n = x.length;
|
| 106 |
+
const level = new Float64Array(n);
|
| 107 |
+
level[0] = x[0];
|
| 108 |
+
for (let t = 1; t < n; t++) level[t] = alpha * x[t - 1] + (1 - alpha) * level[t - 1];
|
| 109 |
+
const residual = Float64Array.from(x, (v, i) => Math.abs(v - level[i]));
|
| 110 |
+
return normalise(residual, probationLength(n));
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
function rollingMadScore(series, seed, window) {
|
| 114 |
+
window = window || 96;
|
| 115 |
+
const x = series.values, n = x.length;
|
| 116 |
+
const probation = probationLength(n);
|
| 117 |
+
const views = rollingWindows(x, window);
|
| 118 |
+
const fallback = robustScale(Array.from(x.slice(0, probation)));
|
| 119 |
+
const out = new Float64Array(n);
|
| 120 |
+
for (let i = 0; i < n; i++) {
|
| 121 |
+
const med = median(views[i]);
|
| 122 |
+
let scale = 1.4826 * median(Array.from(views[i], (v) => Math.abs(v - med)));
|
| 123 |
+
if (scale < EPS) scale = fallback;
|
| 124 |
+
out[i] = Math.abs(x[i] - med) / (scale + EPS);
|
| 125 |
+
}
|
| 126 |
+
return normalise(out, probation);
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
function seasonalScore(series) {
|
| 130 |
+
const x = series.values, n = x.length;
|
| 131 |
+
const probation = probationLength(n);
|
| 132 |
+
const buckets = 7 * 24;
|
| 133 |
+
const key = new Int32Array(n);
|
| 134 |
+
for (let i = 0; i < n; i++) {
|
| 135 |
+
const d = new Date(series.timestamps[i]);
|
| 136 |
+
key[i] = d.getUTCDay() === 0 ? 6 * 24 + d.getUTCHours()
|
| 137 |
+
: (d.getUTCDay() - 1) * 24 + d.getUTCHours();
|
| 138 |
+
}
|
| 139 |
+
const fallback = median(Array.from(x.slice(0, probation || n)));
|
| 140 |
+
const profile = new Float64Array(buckets).fill(fallback);
|
| 141 |
+
const groups = new Map();
|
| 142 |
+
for (let i = 0; i < probation; i++) {
|
| 143 |
+
if (!groups.has(key[i])) groups.set(key[i], []);
|
| 144 |
+
groups.get(key[i]).push(x[i]);
|
| 145 |
+
}
|
| 146 |
+
for (const [b, vals] of groups) profile[b] = median(vals);
|
| 147 |
+
const residual = Float64Array.from(x, (v, i) => Math.abs(v - profile[key[i]]));
|
| 148 |
+
return normalise(residual, probation);
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
/* Cyclic Jacobi eigendecomposition of a symmetric matrix.
|
| 152 |
+
*
|
| 153 |
+
* Power iteration was tried first and could not separate eigenvalues that
|
| 154 |
+
* sit within a percent of each other — which is exactly what the tail of a
|
| 155 |
+
* sliding-window covariance looks like once the signal has been explained.
|
| 156 |
+
* Jacobi is exact to machine precision and needs no starting guess, and at
|
| 157 |
+
* width 32 the O(width^3) cost is irrelevant.
|
| 158 |
+
*
|
| 159 |
+
* Returns eigenvectors as rows, sorted by descending eigenvalue.
|
| 160 |
+
*/
|
| 161 |
+
function symmetricEigen(matrix, width, sweeps) {
|
| 162 |
+
sweeps = sweeps || 60;
|
| 163 |
+
const a = matrix.map((row) => Float64Array.from(row));
|
| 164 |
+
const v = [];
|
| 165 |
+
for (let i = 0; i < width; i++) {
|
| 166 |
+
v.push(new Float64Array(width));
|
| 167 |
+
v[i][i] = 1;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
for (let sweep = 0; sweep < sweeps; sweep++) {
|
| 171 |
+
let off = 0;
|
| 172 |
+
for (let p = 0; p < width; p++)
|
| 173 |
+
for (let q = p + 1; q < width; q++) off += a[p][q] * a[p][q];
|
| 174 |
+
if (off < 1e-24) break;
|
| 175 |
+
|
| 176 |
+
for (let p = 0; p < width - 1; p++) {
|
| 177 |
+
for (let q = p + 1; q < width; q++) {
|
| 178 |
+
if (Math.abs(a[p][q]) < 1e-300) continue;
|
| 179 |
+
const theta = (a[q][q] - a[p][p]) / (2 * a[p][q]);
|
| 180 |
+
const t = Math.sign(theta || 1) / (Math.abs(theta) + Math.sqrt(theta * theta + 1));
|
| 181 |
+
const c = 1 / Math.sqrt(t * t + 1);
|
| 182 |
+
const s = t * c;
|
| 183 |
+
for (let i = 0; i < width; i++) {
|
| 184 |
+
const aip = a[i][p], aiq = a[i][q];
|
| 185 |
+
a[i][p] = c * aip - s * aiq;
|
| 186 |
+
a[i][q] = s * aip + c * aiq;
|
| 187 |
+
}
|
| 188 |
+
for (let i = 0; i < width; i++) {
|
| 189 |
+
const api = a[p][i], aqi = a[q][i];
|
| 190 |
+
a[p][i] = c * api - s * aqi;
|
| 191 |
+
a[q][i] = s * api + c * aqi;
|
| 192 |
+
}
|
| 193 |
+
for (let i = 0; i < width; i++) {
|
| 194 |
+
const vip = v[i][p], viq = v[i][q];
|
| 195 |
+
v[i][p] = c * vip - s * viq;
|
| 196 |
+
v[i][q] = s * vip + c * viq;
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
const order = Array.from({ length: width }, (_, i) => i).sort((x, y) => a[y][y] - a[x][x]);
|
| 203 |
+
return order.map((idx) => {
|
| 204 |
+
const vec = new Float64Array(width);
|
| 205 |
+
for (let i = 0; i < width; i++) vec[i] = v[i][idx];
|
| 206 |
+
// Fix the sign convention so the basis is reproducible run to run.
|
| 207 |
+
let lead = 0;
|
| 208 |
+
for (let i = 0; i < width; i++) {
|
| 209 |
+
if (Math.abs(vec[i]) > Math.abs(vec[lead])) lead = i;
|
| 210 |
+
}
|
| 211 |
+
if (vec[lead] < 0) for (let i = 0; i < width; i++) vec[i] = -vec[i];
|
| 212 |
+
return vec;
|
| 213 |
+
});
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
/** Top-k right singular vectors of `rows`, via the eigenvectors of X'X. */
|
| 217 |
+
function topSubspace(rows, width, k) {
|
| 218 |
+
const cov = [];
|
| 219 |
+
for (let i = 0; i < width; i++) cov.push(new Float64Array(width));
|
| 220 |
+
for (const row of rows) {
|
| 221 |
+
for (let a = 0; a < width; a++) {
|
| 222 |
+
const va = row[a];
|
| 223 |
+
if (!va) continue;
|
| 224 |
+
for (let b = a; b < width; b++) cov[a][b] += va * row[b];
|
| 225 |
+
}
|
| 226 |
+
}
|
| 227 |
+
for (let a = 0; a < width; a++) for (let b = 0; b < a; b++) cov[a][b] = cov[b][a];
|
| 228 |
+
return symmetricEigen(cov, width).slice(0, k);
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
function windowPcaScore(series, seed, window, rank) {
|
| 232 |
+
window = window || 32;
|
| 233 |
+
rank = rank || 3;
|
| 234 |
+
const x = series.values, n = x.length;
|
| 235 |
+
const probation = Math.max(window + 1, probationLength(n));
|
| 236 |
+
const views = rollingWindows(x, window);
|
| 237 |
+
const width = views[0].length;
|
| 238 |
+
|
| 239 |
+
const centre = new Float64Array(width);
|
| 240 |
+
for (let i = 0; i < probation; i++)
|
| 241 |
+
for (let j = 0; j < width; j++) centre[j] += views[i][j] / probation;
|
| 242 |
+
|
| 243 |
+
const warmFlat = [];
|
| 244 |
+
for (let i = 0; i < probation; i++)
|
| 245 |
+
for (let j = 0; j < width; j++) warmFlat.push(views[i][j] - centre[j]);
|
| 246 |
+
const scale = robustScale(warmFlat);
|
| 247 |
+
|
| 248 |
+
const warmRows = [];
|
| 249 |
+
for (let i = 0; i < probation; i++) {
|
| 250 |
+
const row = new Float64Array(width);
|
| 251 |
+
for (let j = 0; j < width; j++) row[j] = (views[i][j] - centre[j]) / scale;
|
| 252 |
+
warmRows.push(row);
|
| 253 |
+
}
|
| 254 |
+
const k = Math.min(rank, Math.min(warmRows.length, width) - 1, width);
|
| 255 |
+
if (k < 1) return new Float64Array(n);
|
| 256 |
+
const basis = topSubspace(warmRows, width, k);
|
| 257 |
+
|
| 258 |
+
const out = new Float64Array(n);
|
| 259 |
+
const centred = new Float64Array(width);
|
| 260 |
+
for (let i = 0; i < n; i++) {
|
| 261 |
+
for (let j = 0; j < width; j++) centred[j] = (views[i][j] - centre[j]) / scale;
|
| 262 |
+
const recon = new Float64Array(width);
|
| 263 |
+
for (const vec of basis) {
|
| 264 |
+
let dot = 0;
|
| 265 |
+
for (let j = 0; j < width; j++) dot += centred[j] * vec[j];
|
| 266 |
+
for (let j = 0; j < width; j++) recon[j] += dot * vec[j];
|
| 267 |
+
}
|
| 268 |
+
let acc = 0;
|
| 269 |
+
for (let j = 0; j < width; j++) {
|
| 270 |
+
const d = centred[j] - recon[j];
|
| 271 |
+
acc += d * d;
|
| 272 |
+
}
|
| 273 |
+
out[i] = Math.sqrt(acc);
|
| 274 |
+
}
|
| 275 |
+
return normalise(out, probationLength(n));
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
const DETECTORS = {
|
| 279 |
+
random: randomScore,
|
| 280 |
+
constant: constantScore,
|
| 281 |
+
lastvalue: lastvalueScore,
|
| 282 |
+
ewma: ewmaScore,
|
| 283 |
+
rolling_mad: rollingMadScore,
|
| 284 |
+
seasonal: seasonalScore,
|
| 285 |
+
window_pca: windowPcaScore,
|
| 286 |
+
};
|
| 287 |
+
|
| 288 |
+
const CONTROLS = new Set(["random", "constant", "lastvalue"]);
|
| 289 |
+
|
| 290 |
+
const DESCRIPTIONS = {
|
| 291 |
+
random: "Uniform noise — never looks at the data",
|
| 292 |
+
constant: "Untrained: one flat score everywhere",
|
| 293 |
+
lastvalue: "|x_t − x_{t−1}|, no fitting",
|
| 294 |
+
ewma: "EWMA one-step residual",
|
| 295 |
+
rolling_mad: "Trailing median/MAD robust z-score",
|
| 296 |
+
seasonal: "Time-of-day × day-of-week profile residual",
|
| 297 |
+
window_pca: "Low-rank subspace reconstruction error",
|
| 298 |
+
};
|
| 299 |
+
|
| 300 |
+
// ---------------------------------------------------------------- metrics
|
| 301 |
+
function contiguousRuns(mask) {
|
| 302 |
+
const runs = [];
|
| 303 |
+
let start = -1;
|
| 304 |
+
for (let i = 0; i < mask.length; i++) {
|
| 305 |
+
if (mask[i] && start < 0) start = i;
|
| 306 |
+
else if (!mask[i] && start >= 0) { runs.push([start, i - 1]); start = -1; }
|
| 307 |
+
}
|
| 308 |
+
if (start >= 0) runs.push([start, mask.length - 1]);
|
| 309 |
+
return runs;
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
function prf(tp, fp, fn) {
|
| 313 |
+
const precision = tp + fp > 0 ? tp / (tp + fp) : 0;
|
| 314 |
+
const recall = tp + fn > 0 ? tp / (tp + fn) : 0;
|
| 315 |
+
const f1 = precision + recall > 1e-12
|
| 316 |
+
? (2 * precision * recall) / (precision + recall) : 0;
|
| 317 |
+
return { precision, recall, f1, tp, fp, fn };
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
function pointF1(yTrue, yPred) {
|
| 321 |
+
let tp = 0, fp = 0, fn = 0;
|
| 322 |
+
for (let i = 0; i < yTrue.length; i++) {
|
| 323 |
+
if (yTrue[i] && yPred[i]) tp++;
|
| 324 |
+
else if (!yTrue[i] && yPred[i]) fp++;
|
| 325 |
+
else if (yTrue[i] && !yPred[i]) fn++;
|
| 326 |
+
}
|
| 327 |
+
return prf(tp, fp, fn);
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
function adjustPredictions(yTrue, yPred, k) {
|
| 331 |
+
k = k || 0;
|
| 332 |
+
const adjusted = Array.from(yPred, Boolean);
|
| 333 |
+
for (const [s, e] of contiguousRuns(yTrue)) {
|
| 334 |
+
let hits = 0;
|
| 335 |
+
for (let i = s; i <= e; i++) if (adjusted[i]) hits++;
|
| 336 |
+
const length = e - s + 1;
|
| 337 |
+
if (hits > 0 && hits / length > k) for (let i = s; i <= e; i++) adjusted[i] = true;
|
| 338 |
+
}
|
| 339 |
+
return adjusted;
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
function paF1(yTrue, yPred, k) {
|
| 343 |
+
return pointF1(yTrue, adjustPredictions(yTrue, yPred, k));
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
function eventRecall(yTrue, yPred) {
|
| 347 |
+
const windows = contiguousRuns(yTrue);
|
| 348 |
+
let caught = 0;
|
| 349 |
+
for (const [s, e] of windows) {
|
| 350 |
+
for (let i = s; i <= e; i++) if (yPred[i]) { caught++; break; }
|
| 351 |
+
}
|
| 352 |
+
return [caught, windows.length - caught];
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
function compositeF1(yTrue, yPred) {
|
| 356 |
+
let tpT = 0, fpT = 0;
|
| 357 |
+
for (let i = 0; i < yTrue.length; i++) {
|
| 358 |
+
if (yPred[i]) (yTrue[i] ? tpT++ : fpT++);
|
| 359 |
+
}
|
| 360 |
+
const precision = tpT + fpT > 0 ? tpT / (tpT + fpT) : 0;
|
| 361 |
+
const [tpE, fnE] = eventRecall(yTrue, yPred);
|
| 362 |
+
const recall = tpE + fnE > 0 ? tpE / (tpE + fnE) : 0;
|
| 363 |
+
const f1 = precision + recall > 1e-12
|
| 364 |
+
? (2 * precision * recall) / (precision + recall) : 0;
|
| 365 |
+
return { precision, recall, f1, tp: tpE, fp: fpT, fn: fnE };
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
function allProtocols(yTrue, yPred) {
|
| 369 |
+
return {
|
| 370 |
+
point: pointF1(yTrue, yPred),
|
| 371 |
+
pa: paF1(yTrue, yPred, 0),
|
| 372 |
+
pa20: paF1(yTrue, yPred, 0.2),
|
| 373 |
+
pa50: paF1(yTrue, yPred, 0.5),
|
| 374 |
+
composite: compositeF1(yTrue, yPred),
|
| 375 |
+
};
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
function candidateThresholds(scores, n) {
|
| 379 |
+
n = n || 200;
|
| 380 |
+
const finite = Array.from(scores).filter(Number.isFinite);
|
| 381 |
+
if (!finite.length) return [0];
|
| 382 |
+
const grid = [];
|
| 383 |
+
for (let i = 0; i < n; i++) grid.push(quantile(finite, i / (n - 1)));
|
| 384 |
+
const unique = Array.from(new Set(grid)).sort((a, b) => a - b);
|
| 385 |
+
unique.push(Math.max(...finite) + 1e-9);
|
| 386 |
+
return unique;
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
function sweep(yTrue, scores, nGrid) {
|
| 390 |
+
const best = {};
|
| 391 |
+
for (const name of ["point", "pa", "pa20", "pa50", "composite"]) {
|
| 392 |
+
best[name] = [Infinity, { f1: 0 }];
|
| 393 |
+
}
|
| 394 |
+
for (const threshold of candidateThresholds(scores, nGrid)) {
|
| 395 |
+
const yPred = Array.from(scores, (s) => s >= threshold);
|
| 396 |
+
const results = allProtocols(yTrue, yPred);
|
| 397 |
+
for (const name in results) {
|
| 398 |
+
if (results[name].f1 > best[name][1].f1) best[name] = [threshold, results[name]];
|
| 399 |
+
}
|
| 400 |
+
}
|
| 401 |
+
return best;
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
function falseAlarmsPerDay(yTrue, yPred, samplingMinutes) {
|
| 405 |
+
let runs = 0;
|
| 406 |
+
for (const [s, e] of contiguousRuns(yPred)) {
|
| 407 |
+
let overlaps = false;
|
| 408 |
+
for (let i = s; i <= e; i++) if (yTrue[i]) { overlaps = true; break; }
|
| 409 |
+
if (!overlaps) runs++;
|
| 410 |
+
}
|
| 411 |
+
const days = (yTrue.length * samplingMinutes) / (60 * 24);
|
| 412 |
+
return days > 0 ? runs / days : 0;
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
// ---------------------------------------------------------------- NAB
|
| 416 |
+
function parseCsv(text) {
|
| 417 |
+
const lines = text.trim().split("\n");
|
| 418 |
+
const timestamps = [], values = [];
|
| 419 |
+
for (let i = 1; i < lines.length; i++) {
|
| 420 |
+
const comma = lines[i].indexOf(",");
|
| 421 |
+
if (comma < 0) continue;
|
| 422 |
+
timestamps.push(lines[i].slice(0, comma).trim());
|
| 423 |
+
values.push(parseFloat(lines[i].slice(comma + 1)));
|
| 424 |
+
}
|
| 425 |
+
return { timestamps, values: Float64Array.from(values) };
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
/** NAB window bounds are timestamps; map them onto index positions. */
|
| 429 |
+
function windowsToIndices(timestamps, windows) {
|
| 430 |
+
const ms = timestamps.map((t) => Date.parse(t.replace(" ", "T") + "Z"));
|
| 431 |
+
const out = [];
|
| 432 |
+
for (const [startS, endS] of windows) {
|
| 433 |
+
const start = Date.parse(startS.replace(" ", "T").split(".")[0] + "Z");
|
| 434 |
+
const end = Date.parse(endS.replace(" ", "T").split(".")[0] + "Z");
|
| 435 |
+
let first = -1, last = -1;
|
| 436 |
+
for (let i = 0; i < ms.length; i++) {
|
| 437 |
+
if (ms[i] >= start && ms[i] <= end) { if (first < 0) first = i; last = i; }
|
| 438 |
+
}
|
| 439 |
+
if (first >= 0) out.push([first, last]);
|
| 440 |
+
}
|
| 441 |
+
return out;
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
function pointLabels(n, windows) {
|
| 445 |
+
const y = new Array(n).fill(false);
|
| 446 |
+
for (const [s, e] of windows) for (let i = s; i <= e; i++) y[i] = true;
|
| 447 |
+
return y;
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
function samplingMinutes(timestamps) {
|
| 451 |
+
if (timestamps.length < 2) return 5;
|
| 452 |
+
const deltas = [];
|
| 453 |
+
for (let i = 1; i < Math.min(timestamps.length, 200); i++) {
|
| 454 |
+
deltas.push(
|
| 455 |
+
(Date.parse(timestamps[i].replace(" ", "T") + "Z") -
|
| 456 |
+
Date.parse(timestamps[i - 1].replace(" ", "T") + "Z")) / 60000
|
| 457 |
+
);
|
| 458 |
+
}
|
| 459 |
+
return median(deltas);
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
root.outliar = {
|
| 463 |
+
median, quantile, robustScale, rollingWindows, normalise, probationLength,
|
| 464 |
+
DETECTORS, CONTROLS, DESCRIPTIONS,
|
| 465 |
+
contiguousRuns, pointF1, adjustPredictions, paF1, compositeF1, eventRecall,
|
| 466 |
+
allProtocols, candidateThresholds, sweep, falseAlarmsPerDay,
|
| 467 |
+
parseCsv, windowsToIndices, pointLabels, samplingMinutes,
|
| 468 |
+
};
|
| 469 |
+
})(typeof globalThis !== "undefined" ? globalThis : this);
|
| 470 |
+
|
| 471 |
+
if (typeof module !== "undefined" && module.exports) module.exports = globalThis.outliar;
|
shim.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Static-deployment shim.
|
| 2 |
+
*
|
| 3 |
+
* Hugging Face static Spaces serve files, not Python, so the page has no
|
| 4 |
+
* backend. This intercepts the app's own API routes and answers them in the
|
| 5 |
+
* browser: NAB is fetched straight from the upstream GitHub raw endpoint
|
| 6 |
+
* (which sends `access-control-allow-origin: *`) and every detector and metric
|
| 7 |
+
* is recomputed locally by web/outliar.js — the same code the Python↔Node
|
| 8 |
+
* parity test pins to the NumPy implementation.
|
| 9 |
+
*
|
| 10 |
+
* Two detectors do not survive the trip and the page says so rather than
|
| 11 |
+
* quietly serving different numbers: `iforest` needs scikit-learn's fitted
|
| 12 |
+
* trees, and `random` uses NumPy's PCG64, which a JS PRNG cannot reproduce
|
| 13 |
+
* bit-for-bit (it is still uniform noise, which is the entire point of it).
|
| 14 |
+
*/
|
| 15 |
+
(function () {
|
| 16 |
+
"use strict";
|
| 17 |
+
|
| 18 |
+
const NAB = "https://raw.githubusercontent.com/numenta/NAB/master";
|
| 19 |
+
const REAL_CORPORA = new Set([
|
| 20 |
+
"realAWSCloudwatch", "realAdExchange", "realKnownCause", "realTraffic", "realTweets",
|
| 21 |
+
]);
|
| 22 |
+
const N_GRID = 120;
|
| 23 |
+
|
| 24 |
+
const cache = { labels: null, series: new Map(), scores: new Map(), sweeps: new Map() };
|
| 25 |
+
|
| 26 |
+
const SAMPLES = [
|
| 27 |
+
{
|
| 28 |
+
series: "realKnownCause/nyc_taxi.csv",
|
| 29 |
+
detector: "random",
|
| 30 |
+
note: "Uniform noise on NYC taxi demand, at its own point-adjusted optimum.",
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
series: "realKnownCause/machine_temperature_system_failure.csv",
|
| 34 |
+
detector: "window_pca",
|
| 35 |
+
note: "A real detector on a real machine failure — the honest case.",
|
| 36 |
+
},
|
| 37 |
+
{
|
| 38 |
+
series: "realAWSCloudwatch/ec2_cpu_utilization_5f5533.csv",
|
| 39 |
+
detector: "random",
|
| 40 |
+
note: "Noise again, on EC2 CPU utilisation.",
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
series: "realTraffic/speed_7578.csv",
|
| 44 |
+
detector: "seasonal",
|
| 45 |
+
note: "A seasonal-profile detector on highway speeds.",
|
| 46 |
+
},
|
| 47 |
+
];
|
| 48 |
+
|
| 49 |
+
async function labels() {
|
| 50 |
+
if (!cache.labels) {
|
| 51 |
+
const response = await fetch(`${NAB}/labels/combined_windows.json`);
|
| 52 |
+
if (!response.ok) throw new Error(`could not reach NAB (${response.status})`);
|
| 53 |
+
cache.labels = await response.json();
|
| 54 |
+
}
|
| 55 |
+
return cache.labels;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
async function series(key) {
|
| 59 |
+
if (cache.series.has(key)) return cache.series.get(key);
|
| 60 |
+
const [all, response] = await Promise.all([
|
| 61 |
+
labels(), fetch(`${NAB}/data/${key}`),
|
| 62 |
+
]);
|
| 63 |
+
if (!response.ok) throw new Error(`could not load ${key} (${response.status})`);
|
| 64 |
+
const parsed = outliar.parseCsv(await response.text());
|
| 65 |
+
const windows = outliar.windowsToIndices(parsed.timestamps, all[key] || []);
|
| 66 |
+
const value = {
|
| 67 |
+
key,
|
| 68 |
+
name: key.split("/")[1].replace(/\.csv$/, ""),
|
| 69 |
+
corpus: key.split("/")[0],
|
| 70 |
+
timestamps: parsed.timestamps,
|
| 71 |
+
values: parsed.values,
|
| 72 |
+
windows,
|
| 73 |
+
n: parsed.values.length,
|
| 74 |
+
samplingMinutes: outliar.samplingMinutes(parsed.timestamps),
|
| 75 |
+
};
|
| 76 |
+
cache.series.set(key, value);
|
| 77 |
+
return value;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
function scores(s, detector) {
|
| 81 |
+
const id = `${s.key}::${detector}`;
|
| 82 |
+
if (!cache.scores.has(id)) {
|
| 83 |
+
cache.scores.set(id, outliar.DETECTORS[detector](s, 7));
|
| 84 |
+
}
|
| 85 |
+
return cache.scores.get(id);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
async function evaluate(key, detector, threshold) {
|
| 89 |
+
const s = await series(key);
|
| 90 |
+
const raw = scores(s, detector);
|
| 91 |
+
const probation = outliar.probationLength(s.n);
|
| 92 |
+
|
| 93 |
+
const scored = Array.from(raw.slice(probation));
|
| 94 |
+
const yTrue = outliar.pointLabels(s.n, s.windows).slice(probation);
|
| 95 |
+
|
| 96 |
+
const sweepId = `${key}::${detector}`;
|
| 97 |
+
if (!cache.sweeps.has(sweepId)) {
|
| 98 |
+
cache.sweeps.set(sweepId, outliar.sweep(yTrue, scored, N_GRID));
|
| 99 |
+
}
|
| 100 |
+
const best = cache.sweeps.get(sweepId);
|
| 101 |
+
const paOptimal = best.pa[0];
|
| 102 |
+
if (threshold === null || threshold === undefined) threshold = paOptimal;
|
| 103 |
+
|
| 104 |
+
const yPred = scored.map((v) => v >= threshold);
|
| 105 |
+
const protocols = outliar.allProtocols(yTrue, yPred);
|
| 106 |
+
const [caught, missed] = outliar.eventRecall(yTrue, yPred);
|
| 107 |
+
|
| 108 |
+
let alarms = 0, inside = 0;
|
| 109 |
+
for (let i = 0; i < yPred.length; i++) {
|
| 110 |
+
if (yPred[i]) { alarms++; if (yTrue[i]) inside++; }
|
| 111 |
+
}
|
| 112 |
+
const credited = outliar.adjustPredictions(yTrue, yPred).reduce((a, b) => a + (b ? 1 : 0), 0);
|
| 113 |
+
|
| 114 |
+
const step = Math.max(1, Math.floor(s.n / 2400));
|
| 115 |
+
const timestamps = [], values = [], drawn = [];
|
| 116 |
+
for (let i = 0; i < s.n; i += step) {
|
| 117 |
+
timestamps.push(s.timestamps[i]);
|
| 118 |
+
values.push(s.values[i]);
|
| 119 |
+
drawn.push(raw[i]);
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
const finite = scored.filter(Number.isFinite);
|
| 123 |
+
return {
|
| 124 |
+
series: key,
|
| 125 |
+
name: s.name,
|
| 126 |
+
detector,
|
| 127 |
+
is_control: outliar.CONTROLS.has(detector),
|
| 128 |
+
description: outliar.DESCRIPTIONS[detector],
|
| 129 |
+
threshold,
|
| 130 |
+
pa_optimal_threshold: paOptimal,
|
| 131 |
+
composite_optimal_threshold: best.composite[0],
|
| 132 |
+
score_range: [Math.min(...finite), outliar.quantile(finite, 0.999)],
|
| 133 |
+
probation,
|
| 134 |
+
n: s.n,
|
| 135 |
+
timestamps,
|
| 136 |
+
values,
|
| 137 |
+
scores: drawn,
|
| 138 |
+
windows: s.windows,
|
| 139 |
+
metrics: protocols,
|
| 140 |
+
alarms,
|
| 141 |
+
alarms_inside: inside,
|
| 142 |
+
pa_credited: credited,
|
| 143 |
+
windows_caught: caught,
|
| 144 |
+
windows_total: caught + missed,
|
| 145 |
+
false_alarms_per_day: outliar.falseAlarmsPerDay(yTrue, yPred, s.samplingMinutes),
|
| 146 |
+
};
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
async function catalog() {
|
| 150 |
+
const all = await labels();
|
| 151 |
+
const keys = Object.keys(all).filter((k) => REAL_CORPORA.has(k.split("/")[0])).sort();
|
| 152 |
+
return {
|
| 153 |
+
series: keys.map((k) => ({
|
| 154 |
+
key: k,
|
| 155 |
+
name: k.split("/")[1].replace(/\.csv$/, ""),
|
| 156 |
+
corpus: k.split("/")[0],
|
| 157 |
+
windows: all[k].length,
|
| 158 |
+
})),
|
| 159 |
+
detectors: Object.keys(outliar.DETECTORS).map((name) => ({
|
| 160 |
+
name,
|
| 161 |
+
description: outliar.DESCRIPTIONS[name],
|
| 162 |
+
is_control: outliar.CONTROLS.has(name),
|
| 163 |
+
})),
|
| 164 |
+
n_samples: SAMPLES.length,
|
| 165 |
+
};
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
function json(payload) {
|
| 169 |
+
return new Response(JSON.stringify(payload), {
|
| 170 |
+
status: 200, headers: { "content-type": "application/json" },
|
| 171 |
+
});
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
const original = window.fetch.bind(window);
|
| 175 |
+
|
| 176 |
+
window.fetch = async function (input, init) {
|
| 177 |
+
const url = typeof input === "string" ? input : input.url;
|
| 178 |
+
// Only the app's own routes are intercepted; NAB requests pass through.
|
| 179 |
+
if (!/^\/(api\/|sample|healthz)/.test(url)) return original(input, init);
|
| 180 |
+
|
| 181 |
+
const parsed = new URL(url, location.origin);
|
| 182 |
+
const path = parsed.pathname;
|
| 183 |
+
const query = parsed.searchParams;
|
| 184 |
+
|
| 185 |
+
try {
|
| 186 |
+
if (path === "/api/catalog") return json(await catalog());
|
| 187 |
+
if (path === "/healthz") return json({ status: "ok", static: true });
|
| 188 |
+
if (path === "/api/findings") {
|
| 189 |
+
const response = await original("findings.json");
|
| 190 |
+
if (!response.ok) throw new Error("findings.json missing");
|
| 191 |
+
return json(await response.json());
|
| 192 |
+
}
|
| 193 |
+
if (path === "/api/evaluate") {
|
| 194 |
+
const threshold = query.has("threshold") ? parseFloat(query.get("threshold")) : null;
|
| 195 |
+
return json(await evaluate(query.get("series"), query.get("detector"), threshold));
|
| 196 |
+
}
|
| 197 |
+
if (path === "/sample") {
|
| 198 |
+
const choice = SAMPLES[(parseInt(query.get("index") || "0", 10) || 0) % SAMPLES.length];
|
| 199 |
+
const payload = await evaluate(choice.series, choice.detector, null);
|
| 200 |
+
payload.note = choice.note;
|
| 201 |
+
return json(payload);
|
| 202 |
+
}
|
| 203 |
+
} catch (err) {
|
| 204 |
+
return new Response(String(err && err.message ? err.message : err), { status: 502 });
|
| 205 |
+
}
|
| 206 |
+
return new Response("not found", { status: 404 });
|
| 207 |
+
};
|
| 208 |
+
})();
|