usmar commited on
Commit
ef95487
·
verified ·
1 Parent(s): 11dc2e1

Deploy outliar

Browse files
Files changed (5) hide show
  1. README.md +27 -5
  2. findings.json +158 -0
  3. index.html +386 -19
  4. outliar.js +471 -0
  5. shim.js +208 -0
README.md CHANGED
@@ -1,10 +1,32 @@
1
  ---
2
- title: Outliar
3
- emoji: 📊
4
- colorFrom: green
5
- colorTo: blue
6
  sdk: static
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
19
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 &amp; 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
+ })();