idslab 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- idslab/__init__.py +5 -0
- idslab/adapters.py +214 -0
- idslab/cli.py +301 -0
- idslab/compare.py +375 -0
- idslab/config.py +1179 -0
- idslab/controls.py +185 -0
- idslab/dashboard/README.md +113 -0
- idslab/dashboard/__init__.py +3 -0
- idslab/dashboard/catalog.py +122 -0
- idslab/dashboard/jobs.py +333 -0
- idslab/dashboard/server.py +377 -0
- idslab/dashboard/static/app.js +188 -0
- idslab/dashboard/static/builder.js +884 -0
- idslab/dashboard/static/charts.js +151 -0
- idslab/dashboard/static/compare.js +231 -0
- idslab/dashboard/static/core.js +375 -0
- idslab/dashboard/static/draft.js +276 -0
- idslab/dashboard/static/experiments.js +413 -0
- idslab/dashboard/static/index.html +49 -0
- idslab/dashboard/static/jobs.js +122 -0
- idslab/dashboard/static/library.js +215 -0
- idslab/dashboard/static/results.js +313 -0
- idslab/dashboard/static/setup.js +567 -0
- idslab/dashboard/static/styles.css +421 -0
- idslab/dashboard/static/views.js +94 -0
- idslab/dashboard/workspace.py +778 -0
- idslab/data.py +268 -0
- idslab/examples/__init__.py +3 -0
- idslab/examples/r1_four_layer.yaml +66 -0
- idslab/examples/r1_router_gate.yaml +57 -0
- idslab/examples/r2_anomaly_first.yaml +45 -0
- idslab/external.py +196 -0
- idslab/features.py +96 -0
- idslab/fitting.py +429 -0
- idslab/graph.py +174 -0
- idslab/html_report.py +314 -0
- idslab/metrics.py +365 -0
- idslab/plugins.py +64 -0
- idslab/prep.py +74 -0
- idslab/report.py +453 -0
- idslab/runner.py +529 -0
- idslab/selection.py +308 -0
- idslab/sweep.py +261 -0
- idslab/synthetic.py +56 -0
- idslab/torch_models.py +169 -0
- idslab-0.3.0.dist-info/METADATA +254 -0
- idslab-0.3.0.dist-info/RECORD +51 -0
- idslab-0.3.0.dist-info/WHEEL +5 -0
- idslab-0.3.0.dist-info/entry_points.txt +2 -0
- idslab-0.3.0.dist-info/licenses/LICENSE +661 -0
- idslab-0.3.0.dist-info/top_level.txt +1 -0
idslab/compare.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 hashtagRR
|
|
3
|
+
"""Compare finished experiments side by side.
|
|
4
|
+
|
|
5
|
+
An experiment is a run folder ``<out>/<name>`` holding ``report.json`` and its run units. The
|
|
6
|
+
comparison lists each experiment's headline figures from its report, and, for experiments that ran
|
|
7
|
+
on the same data with the same splits, a paired comparison against the first experiment: per unit,
|
|
8
|
+
the TEST objective of one cascade minus the other on the very same flows, with a paired bootstrap
|
|
9
|
+
interval. Only units the report included (fresh fingerprints) take part.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import glob
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
from html import escape
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
import pandas as pd
|
|
22
|
+
|
|
23
|
+
from idslab.metrics import paired_bootstrap
|
|
24
|
+
from idslab.report import unit
|
|
25
|
+
|
|
26
|
+
STAGE_SHOWN = 6
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CompareError(ValueError):
|
|
30
|
+
"""An experiment folder that cannot be compared (missing report, no units)."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _read(path: str) -> Any:
|
|
34
|
+
with open(path, encoding="utf-8") as f:
|
|
35
|
+
return json.load(f)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_experiment(path: str) -> dict:
|
|
39
|
+
"""An experiment's report and its included units, each with its folder and fingerprints."""
|
|
40
|
+
path = os.path.normpath(path)
|
|
41
|
+
report_path = os.path.join(path, "report.json")
|
|
42
|
+
if not os.path.isfile(report_path):
|
|
43
|
+
raise CompareError(f"{path}: no report.json (run the experiment or `idslab report` first)")
|
|
44
|
+
rep = _read(report_path)
|
|
45
|
+
wanted = set(rep.get("units") or [])
|
|
46
|
+
units = {}
|
|
47
|
+
pattern = os.path.join(path, rep["mode"], "seed_*", "**", "summary.json")
|
|
48
|
+
for summary_path in sorted(glob.glob(pattern, recursive=True)):
|
|
49
|
+
d = os.path.dirname(summary_path)
|
|
50
|
+
done = os.path.join(d, "DONE")
|
|
51
|
+
if not os.path.isfile(done):
|
|
52
|
+
continue
|
|
53
|
+
s = _read(summary_path)
|
|
54
|
+
label = unit(s)
|
|
55
|
+
if label in wanted:
|
|
56
|
+
units[label] = {"dir": d, "fingerprints": _read(done).get("fingerprints", {})}
|
|
57
|
+
if not units:
|
|
58
|
+
raise CompareError(f"{path}: the report lists no units that are still on disk")
|
|
59
|
+
return {"path": path, "report": rep, "units": units}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _mean(d: dict | None) -> float | None:
|
|
63
|
+
return None if not d else d.get("mean")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _headline(e: dict) -> dict:
|
|
67
|
+
rep = e["report"]
|
|
68
|
+
goal = rep["objective"]
|
|
69
|
+
st = rep["systems_test"]
|
|
70
|
+
frozen = rep.get("frozen_per_seed") or {}
|
|
71
|
+
stages = {}
|
|
72
|
+
for st_name in rep.get("stage_removal", {}) | {s: None for f in frozen.values() for s in f}:
|
|
73
|
+
on = sum(1 for f in frozen.values() if st_name in f and not f[st_name].get("off", True))
|
|
74
|
+
stages[st_name] = {
|
|
75
|
+
"on": on,
|
|
76
|
+
"units": len(frozen),
|
|
77
|
+
"removal": rep.get("stage_removal", {}).get(st_name),
|
|
78
|
+
}
|
|
79
|
+
sc = rep.get("stage_cost")
|
|
80
|
+
return {
|
|
81
|
+
"path": e["path"],
|
|
82
|
+
"name": rep["config_name"],
|
|
83
|
+
"mode": rep["mode"],
|
|
84
|
+
"objective": goal,
|
|
85
|
+
"idslab_version": rep.get("idslab_version"),
|
|
86
|
+
"units": sorted(e["units"]),
|
|
87
|
+
"cascade": st["cascade"].get(goal),
|
|
88
|
+
"first_stage": st["B1_first_stage"].get(goal),
|
|
89
|
+
"minus_first": (rep.get("cascade_minus_control", {}).get("B1_first_stage") or {}).get("diff"),
|
|
90
|
+
"frontier_gap": (rep.get("frontier_gap") or {}).get("interp"),
|
|
91
|
+
"stages": stages,
|
|
92
|
+
"expected_cost": None if not sc else sc.get("cascade_cost"),
|
|
93
|
+
"first_stage_cost": None if not sc else sc.get("first_stage_cost"),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _pair(base: dict, other: dict, goal: str, *, bootstrap: int) -> dict:
|
|
98
|
+
"""Per unit, other's cascade minus base's cascade on the same TEST flows."""
|
|
99
|
+
common = sorted(set(base["units"]) & set(other["units"]))
|
|
100
|
+
rows, skipped = [], []
|
|
101
|
+
for label in common:
|
|
102
|
+
fa, fb = base["units"][label]["fingerprints"], other["units"][label]["fingerprints"]
|
|
103
|
+
if fa.get("split") != fb.get("split") or fa.get("data") != fb.get("data"):
|
|
104
|
+
skipped.append(f"{label}: different data or splits")
|
|
105
|
+
continue
|
|
106
|
+
pa = pd.read_parquet(os.path.join(base["units"][label]["dir"], "per_flow.parquet"))
|
|
107
|
+
pb = pd.read_parquet(os.path.join(other["units"][label]["dir"], "per_flow.parquet"))
|
|
108
|
+
merged = pa[["row_id", "y", "pred_cascade"]].merge(
|
|
109
|
+
pb[["row_id", "pred_cascade"]], on="row_id", suffixes=("_base", "_other")
|
|
110
|
+
)
|
|
111
|
+
if len(merged) != len(pa) or len(merged) != len(pb):
|
|
112
|
+
skipped.append(f"{label}: the TEST flows differ")
|
|
113
|
+
continue
|
|
114
|
+
seed = int(label.split("/")[0])
|
|
115
|
+
res = paired_bootstrap(
|
|
116
|
+
merged["y"].to_numpy(),
|
|
117
|
+
merged["pred_cascade_other"].to_numpy(),
|
|
118
|
+
merged["pred_cascade_base"].to_numpy(),
|
|
119
|
+
n=bootstrap,
|
|
120
|
+
seed=seed,
|
|
121
|
+
names=(goal,),
|
|
122
|
+
)[goal]
|
|
123
|
+
rows.append({"unit": label, "diff": res["diff"], "ci95": res["ci95"]})
|
|
124
|
+
diffs = [r["diff"] for r in rows]
|
|
125
|
+
return {
|
|
126
|
+
"base": base["report"]["config_name"],
|
|
127
|
+
"other": other["report"]["config_name"],
|
|
128
|
+
"objective": goal,
|
|
129
|
+
"per_unit": rows,
|
|
130
|
+
"mean_diff": float(np.mean(diffs)) if diffs else None,
|
|
131
|
+
"units_ci_above_zero": sum(1 for r in rows if r["ci95"] and r["ci95"][0] > 0),
|
|
132
|
+
"units_ci_below_zero": sum(1 for r in rows if r["ci95"] and r["ci95"][1] < 0),
|
|
133
|
+
"skipped": skipped
|
|
134
|
+
+ [f"{u}: only in one experiment" for u in sorted(set(base["units"]) ^ set(other["units"]))],
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def compare(paths: list[str], *, bootstrap: int = 1000, names: dict[str, str] | None = None) -> dict:
|
|
139
|
+
"""Compare experiments; the first one is the baseline of the paired comparisons."""
|
|
140
|
+
if len(paths) < 2:
|
|
141
|
+
raise CompareError("give at least two experiments to compare")
|
|
142
|
+
exps = [load_experiment(p) for p in paths]
|
|
143
|
+
heads = [_headline(e) for e in exps]
|
|
144
|
+
if names:
|
|
145
|
+
for h in heads:
|
|
146
|
+
h["label"] = names.get(h["path"], h["name"])
|
|
147
|
+
warnings = []
|
|
148
|
+
goals = {h["objective"] for h in heads}
|
|
149
|
+
if len(goals) > 1:
|
|
150
|
+
warnings.append(
|
|
151
|
+
f"the experiments use different objectives ({', '.join(sorted(goals))}); "
|
|
152
|
+
"paired differences are shown only for experiments with the baseline's objective"
|
|
153
|
+
)
|
|
154
|
+
modes = {h["mode"] for h in heads}
|
|
155
|
+
if len(modes) > 1:
|
|
156
|
+
warnings.append(f"the experiments use different modes ({', '.join(sorted(modes))})")
|
|
157
|
+
datas = {
|
|
158
|
+
json.dumps(next(iter(e["units"].values()))["fingerprints"].get("data"), sort_keys=True) for e in exps
|
|
159
|
+
}
|
|
160
|
+
if len(datas) > 1:
|
|
161
|
+
warnings.append("the experiments ran on different data files; their numbers are not comparable")
|
|
162
|
+
base = exps[0]
|
|
163
|
+
goal = heads[0]["objective"]
|
|
164
|
+
pairs = [
|
|
165
|
+
_pair(base, e, goal, bootstrap=bootstrap)
|
|
166
|
+
for e, h in zip(exps[1:], heads[1:], strict=True)
|
|
167
|
+
if h["objective"] == goal
|
|
168
|
+
]
|
|
169
|
+
return {
|
|
170
|
+
"baseline": heads[0]["name"],
|
|
171
|
+
"objective": goal,
|
|
172
|
+
"experiments": heads,
|
|
173
|
+
"paired": pairs,
|
|
174
|
+
"warnings": warnings,
|
|
175
|
+
"bootstrap": bootstrap,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ---------- output ----------
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _f(d: Any, digits: int = 4, signed: bool = False) -> str:
|
|
183
|
+
v = d.get("mean") if isinstance(d, dict) else d
|
|
184
|
+
if v is None:
|
|
185
|
+
return "n/a"
|
|
186
|
+
s = f"{v:+.{digits}f}" if signed else f"{v:.{digits}f}"
|
|
187
|
+
sd = d.get("sd") if isinstance(d, dict) else None
|
|
188
|
+
return s + (f" ± {sd:.{digits}f}" if sd is not None else "")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _label(h: dict) -> str:
|
|
192
|
+
return h.get("label") or h["name"]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def to_markdown(c: dict) -> str:
|
|
196
|
+
goal = c["objective"]
|
|
197
|
+
lines = [
|
|
198
|
+
"# IDSLab comparison",
|
|
199
|
+
"",
|
|
200
|
+
f"Baseline: `{c['baseline']}`. TEST {goal}, mean ± sd over units.",
|
|
201
|
+
"",
|
|
202
|
+
]
|
|
203
|
+
lines += [f"- warning: {w}" for w in c["warnings"]]
|
|
204
|
+
if c["warnings"]:
|
|
205
|
+
lines.append("")
|
|
206
|
+
lines += [
|
|
207
|
+
"## Experiments",
|
|
208
|
+
"",
|
|
209
|
+
"| experiment | units | cascade | first stage alone | cascade minus first stage | frontier gap | expected cost per flow |",
|
|
210
|
+
"|---|---|---|---|---|---|---|",
|
|
211
|
+
]
|
|
212
|
+
for h in c["experiments"]:
|
|
213
|
+
lines.append(
|
|
214
|
+
f"| {_label(h)} | {len(h['units'])} | {_f(h['cascade'])} | {_f(h['first_stage'])} | "
|
|
215
|
+
f"{_f(h['minus_first'], signed=True)} | {_f(h['frontier_gap'], signed=True)} | {_f(h['expected_cost'], 3)} |"
|
|
216
|
+
)
|
|
217
|
+
lines += ["", "## Stages", "", "| experiment | stage | kept on | lost if removed |", "|---|---|---|---|"]
|
|
218
|
+
for h in c["experiments"]:
|
|
219
|
+
for st, r in list(h["stages"].items())[:STAGE_SHOWN]:
|
|
220
|
+
lines.append(
|
|
221
|
+
f"| {_label(h)} | {st} | {r['on']} of {r['units']} | {_f(r['removal'], 3, signed=True)} |"
|
|
222
|
+
)
|
|
223
|
+
if c["paired"]:
|
|
224
|
+
lines += [
|
|
225
|
+
"",
|
|
226
|
+
f"## Paired against {c['baseline']}",
|
|
227
|
+
"",
|
|
228
|
+
(
|
|
229
|
+
"Same data and splits, so each unit compares the two cascades on the same TEST flows: "
|
|
230
|
+
"experiment minus baseline, with a paired bootstrap 95% interval."
|
|
231
|
+
),
|
|
232
|
+
"",
|
|
233
|
+
"| experiment | mean difference | units with CI above 0 | units with CI below 0 | per unit |",
|
|
234
|
+
"|---|---|---|---|---|",
|
|
235
|
+
]
|
|
236
|
+
for p in c["paired"]:
|
|
237
|
+
per = "; ".join(
|
|
238
|
+
f"{r['unit']}: {r['diff']:+.4f}"
|
|
239
|
+
+ (f" [{r['ci95'][0]:+.4f}, {r['ci95'][1]:+.4f}]" if r["ci95"] else "")
|
|
240
|
+
for r in p["per_unit"]
|
|
241
|
+
)
|
|
242
|
+
n = len(p["per_unit"])
|
|
243
|
+
lines.append(
|
|
244
|
+
f"| {p['other']} | {_f(p['mean_diff'], signed=True)} | {p['units_ci_above_zero']}/{n} | "
|
|
245
|
+
f"{p['units_ci_below_zero']}/{n} | {per or 'none'} |"
|
|
246
|
+
)
|
|
247
|
+
lines += [f"| | skipped: {s} | | | |" for s in p["skipped"]]
|
|
248
|
+
return "\n".join(lines) + "\n"
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
CSS = """
|
|
252
|
+
:root{--bg:#f6f8fa;--panel:#fff;--ink:#1f2328;--muted:#59636e;--line:#d0d7de;--accent:#0969da;--good:#1a7f37;--bad:#cf222e}
|
|
253
|
+
@media (prefers-color-scheme:dark){:root{--bg:#0d1117;--panel:#161b22;--ink:#e6edf3;--muted:#9198a1;--line:#30363d;--accent:#4493f8;--good:#3fb950;--bad:#f85149}}
|
|
254
|
+
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 -apple-system,"Segoe UI",Helvetica,Arial,sans-serif}
|
|
255
|
+
main{max-width:1180px;margin:0 auto;padding:32px 20px}
|
|
256
|
+
h1{margin:0 0 6px;font-size:28px}h2{margin:34px 0 10px;font-size:19px}
|
|
257
|
+
.muted{color:var(--muted)}
|
|
258
|
+
.warn{border:1px solid #d4a72c;background:rgba(212,167,44,.1);border-radius:8px;padding:8px 12px;margin:8px 0}
|
|
259
|
+
.scroll{overflow-x:auto}
|
|
260
|
+
table{border-collapse:collapse;width:100%;background:var(--panel);border:1px solid var(--line);border-radius:8px}
|
|
261
|
+
th,td{padding:8px 10px;border-bottom:1px solid var(--line);text-align:left;font-size:14px}
|
|
262
|
+
th{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
|
|
263
|
+
td.n{text-align:right;font-family:ui-monospace,Consolas,monospace;white-space:nowrap}
|
|
264
|
+
.pos{color:var(--good)}.neg{color:var(--bad)}
|
|
265
|
+
.bar{fill:var(--accent)}.bar.first{fill:var(--muted)}.axis{stroke:var(--line)}
|
|
266
|
+
svg text{fill:var(--muted);font-size:12px;font-family:inherit}
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _cls(v: float | None) -> str:
|
|
271
|
+
return "" if v is None else "pos" if v > 0 else "neg" if v < 0 else ""
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _chart(c: dict) -> str:
|
|
275
|
+
"""Cascade and first stage alone per experiment, as horizontal bars."""
|
|
276
|
+
heads = c["experiments"]
|
|
277
|
+
vals = [v for h in heads for v in (_mean(h["cascade"]), _mean(h["first_stage"])) if v is not None]
|
|
278
|
+
if not vals:
|
|
279
|
+
return ""
|
|
280
|
+
lo, hi = min(vals), max(vals)
|
|
281
|
+
pad = (hi - lo) * 0.25 or 0.01
|
|
282
|
+
lo, hi = lo - pad, hi + pad
|
|
283
|
+
W, row = 760, 46
|
|
284
|
+
H = 20 + row * len(heads)
|
|
285
|
+
x0, x1 = 230, W - 90
|
|
286
|
+
parts = [
|
|
287
|
+
f'<svg viewBox="0 0 {W} {H}" width="100%" role="img" aria-label="Cascade and first stage alone per experiment">'
|
|
288
|
+
]
|
|
289
|
+
for i, h in enumerate(heads):
|
|
290
|
+
y = 10 + i * row
|
|
291
|
+
parts.append(f'<text x="0" y="{y + 20}">{escape(_label(h)[:30])}</text>')
|
|
292
|
+
for j, (key, cls) in enumerate((("cascade", "bar"), ("first_stage", "bar first"))):
|
|
293
|
+
v = _mean(h[key])
|
|
294
|
+
if v is None:
|
|
295
|
+
continue
|
|
296
|
+
w = (v - lo) / (hi - lo) * (x1 - x0)
|
|
297
|
+
parts.append(
|
|
298
|
+
f'<rect class="{cls}" x="{x0}" y="{y + j * 16}" width="{max(w, 1):.1f}" height="13" rx="3"/>'
|
|
299
|
+
)
|
|
300
|
+
parts.append(
|
|
301
|
+
f'<text x="{x0 + w + 6:.1f}" y="{y + j * 16 + 11}">{v:.4f}{" (first stage)" if j else ""}</text>'
|
|
302
|
+
)
|
|
303
|
+
parts.append(f'<line class="axis" x1="{x0}" x2="{x0}" y1="4" y2="{H - 4}"/></svg>')
|
|
304
|
+
return "".join(parts)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def render_html(c: dict) -> str:
|
|
308
|
+
goal = escape(c["objective"])
|
|
309
|
+
warn = "".join(f'<div class="warn">{escape(w)}</div>' for w in c["warnings"])
|
|
310
|
+
rows = "".join(
|
|
311
|
+
f"<tr><td>{escape(_label(h))}</td><td class=n>{len(h['units'])}</td><td class=n>{escape(_f(h['cascade']))}</td>"
|
|
312
|
+
f"<td class=n>{escape(_f(h['first_stage']))}</td>"
|
|
313
|
+
f"<td class='n {_cls(_mean(h['minus_first']))}'>{escape(_f(h['minus_first'], signed=True))}</td>"
|
|
314
|
+
f"<td class='n {_cls(_mean(h['frontier_gap']))}'>{escape(_f(h['frontier_gap'], signed=True))}</td>"
|
|
315
|
+
f"<td class=n>{escape(_f(h['expected_cost'], 3))}</td></tr>"
|
|
316
|
+
for h in c["experiments"]
|
|
317
|
+
)
|
|
318
|
+
stage_rows = "".join(
|
|
319
|
+
f"<tr><td>{escape(_label(h))}</td><td>{escape(st)}</td><td class=n>{r['on']} of {r['units']}</td>"
|
|
320
|
+
f"<td class='n {_cls(_mean(r['removal']))}'>{escape(_f(r['removal'], 3, signed=True))}</td></tr>"
|
|
321
|
+
for h in c["experiments"]
|
|
322
|
+
for st, r in list(h["stages"].items())[:STAGE_SHOWN]
|
|
323
|
+
)
|
|
324
|
+
paired = ""
|
|
325
|
+
if c["paired"]:
|
|
326
|
+
prow = []
|
|
327
|
+
for p in c["paired"]:
|
|
328
|
+
n = len(p["per_unit"])
|
|
329
|
+
per = (
|
|
330
|
+
"<br>".join(
|
|
331
|
+
escape(
|
|
332
|
+
f"{r['unit']}: {r['diff']:+.4f}"
|
|
333
|
+
+ (f" [{r['ci95'][0]:+.4f}, {r['ci95'][1]:+.4f}]" if r["ci95"] else "")
|
|
334
|
+
)
|
|
335
|
+
for r in p["per_unit"]
|
|
336
|
+
)
|
|
337
|
+
or "none"
|
|
338
|
+
)
|
|
339
|
+
skipped = "".join(f'<div class="muted">skipped {escape(s)}</div>' for s in p["skipped"])
|
|
340
|
+
prow.append(
|
|
341
|
+
f"<tr><td>{escape(p['other'])}</td><td class='n {_cls(p['mean_diff'])}'>{escape(_f(p['mean_diff'], signed=True))}</td>"
|
|
342
|
+
f"<td class=n>{p['units_ci_above_zero']}/{n}</td><td class=n>{p['units_ci_below_zero']}/{n}</td>"
|
|
343
|
+
f"<td class=n>{per}{skipped}</td></tr>"
|
|
344
|
+
)
|
|
345
|
+
paired = (
|
|
346
|
+
f"<h2>Paired against {escape(c['baseline'])}</h2><p class=muted>Same data and splits, so each unit "
|
|
347
|
+
"compares the two cascades on the same TEST flows: experiment minus baseline, with a paired bootstrap "
|
|
348
|
+
"95% interval.</p><div class=scroll><table><tr><th>experiment</th><th>mean difference</th>"
|
|
349
|
+
f"<th>CI above 0</th><th>CI below 0</th><th>per unit</th></tr>{''.join(prow)}</table></div>"
|
|
350
|
+
)
|
|
351
|
+
return f"""<!doctype html>
|
|
352
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
353
|
+
<title>IDSLab comparison</title><style>{CSS}</style></head><body><main>
|
|
354
|
+
<h1>IDSLab comparison</h1><p class=muted>Baseline {escape(c["baseline"])}. TEST {goal}, mean ± sd over units.</p>
|
|
355
|
+
{warn}
|
|
356
|
+
<h2>Cascade and first stage alone</h2>{_chart(c)}
|
|
357
|
+
<div class=scroll><table><tr><th>experiment</th><th>units</th><th>cascade</th><th>first stage alone</th>
|
|
358
|
+
<th>cascade minus first stage</th><th>frontier gap</th><th>expected cost per flow</th></tr>{rows}</table></div>
|
|
359
|
+
{paired}
|
|
360
|
+
<h2>Stages</h2><div class=scroll><table><tr><th>experiment</th><th>stage</th><th>kept on</th><th>lost if removed</th></tr>{stage_rows}</table></div>
|
|
361
|
+
</main></body></html>
|
|
362
|
+
"""
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def write_comparison(c: dict, out_dir: str) -> tuple[str, str, str]:
|
|
366
|
+
"""Write compare.md, compare.json and compare.html into ``out_dir``."""
|
|
367
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
368
|
+
md, js, html = (os.path.join(out_dir, f"compare.{ext}") for ext in ("md", "json", "html"))
|
|
369
|
+
with open(md, "w", encoding="utf-8") as f:
|
|
370
|
+
f.write(to_markdown(c))
|
|
371
|
+
with open(js, "w", encoding="utf-8") as f:
|
|
372
|
+
json.dump(c, f, indent=2, default=str)
|
|
373
|
+
with open(html, "w", encoding="utf-8") as f:
|
|
374
|
+
f.write(render_html(c))
|
|
375
|
+
return md, js, html
|