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/controls.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 hashtagRR
|
|
3
|
+
"""Controls computed after the configuration is frozen (DESIGN.md section 6).
|
|
4
|
+
|
|
5
|
+
Everything here takes the frozen selection as given; nothing feeds back into it.
|
|
6
|
+
All controls use the same fitted first-stage model as the cascade.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from dataclasses import replace
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from idslab.config import Config
|
|
18
|
+
from idslab.graph import Routing, StageState, recorded_types, route, with_off
|
|
19
|
+
from idslab.metrics import (
|
|
20
|
+
binary_metrics,
|
|
21
|
+
metric_names,
|
|
22
|
+
em_prior,
|
|
23
|
+
frontier_ci,
|
|
24
|
+
frontier_gap,
|
|
25
|
+
objective,
|
|
26
|
+
auc,
|
|
27
|
+
paired_bootstrap,
|
|
28
|
+
type_metrics,
|
|
29
|
+
unseen_metrics,
|
|
30
|
+
)
|
|
31
|
+
from idslab.selection import Scores, Selection, active_scores
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def budget_matched_threshold(p_val: np.ndarray, y_val: np.ndarray, target_fpr: float) -> tuple[float, float]:
|
|
35
|
+
"""Highest threshold whose VAL FPR is at least ``target_fpr``; returns (threshold, achieved FPR)."""
|
|
36
|
+
benign = np.sort(p_val[y_val == 0])[::-1]
|
|
37
|
+
if len(benign) == 0:
|
|
38
|
+
raise ValueError("no benign VAL rows to match a false-alarm budget")
|
|
39
|
+
k = math.ceil(target_fpr * len(benign) - 1e-12)
|
|
40
|
+
if k <= 0:
|
|
41
|
+
return math.inf, 0.0
|
|
42
|
+
t = float(benign[min(k, len(benign)) - 1])
|
|
43
|
+
return t, float((benign >= t).mean())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def best_cutoff(p_val: np.ndarray, y_val: np.ndarray, grid: tuple[float, ...], goal: str) -> float:
|
|
47
|
+
values = [objective(y_val, (p_val >= c).astype(int), goal) for c in grid]
|
|
48
|
+
return float(grid[int(np.argmax(values))])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def evaluate(
|
|
52
|
+
cfg: Config,
|
|
53
|
+
sel: Selection,
|
|
54
|
+
*,
|
|
55
|
+
val_scores: Scores,
|
|
56
|
+
test_scores: Scores,
|
|
57
|
+
y_val: np.ndarray,
|
|
58
|
+
y_test: np.ndarray,
|
|
59
|
+
shares: dict[str, float],
|
|
60
|
+
first_train_share: float,
|
|
61
|
+
seed: int,
|
|
62
|
+
types_test: np.ndarray | None = None,
|
|
63
|
+
unseen: np.ndarray | None = None,
|
|
64
|
+
extra_states: Mapping[str, Mapping[str, StageState]] | None = None,
|
|
65
|
+
) -> tuple[dict, dict[str, np.ndarray], Routing]:
|
|
66
|
+
"""TEST evaluation of the frozen cascade and every control.
|
|
67
|
+
|
|
68
|
+
Returns the summary block, the per-flow predictions of each system and the TEST routing.
|
|
69
|
+
"""
|
|
70
|
+
goal = cfg.protocol.objective
|
|
71
|
+
order, state = sel.order, sel.state
|
|
72
|
+
n_val, n_test = len(y_val), len(y_test)
|
|
73
|
+
first = cfg.controls.first_stage
|
|
74
|
+
fs = state[first]
|
|
75
|
+
if fs.off or fs.candidate is None or fs.threshold is None:
|
|
76
|
+
raise RuntimeError("the control first stage must be active in the frozen configuration")
|
|
77
|
+
p1_val, p1_test = val_scores[first][fs.candidate], test_scores[first][fs.candidate]
|
|
78
|
+
|
|
79
|
+
def run(st, scores, n):
|
|
80
|
+
return route(order, cfg.stages, active_scores(scores, st), st, n)
|
|
81
|
+
|
|
82
|
+
cascade_val = run(state, val_scores, n_val).verdict
|
|
83
|
+
routing = run(state, test_scores, n_test)
|
|
84
|
+
cascade = routing.verdict
|
|
85
|
+
|
|
86
|
+
preds = {"cascade": cascade, "B1_first_stage": (p1_test >= fs.threshold).astype(np.int8)}
|
|
87
|
+
b2_cut = best_cutoff(p1_val, y_val, cfg.controls.cutoff_grid, goal)
|
|
88
|
+
preds["B2_first_stage_val_cutoff"] = (p1_test >= b2_cut).astype(np.int8)
|
|
89
|
+
target = binary_metrics(y_val, cascade_val)["fpr"]
|
|
90
|
+
t_budget, achieved = budget_matched_threshold(p1_val, y_val, target)
|
|
91
|
+
preds["budget_matched_first_stage"] = (p1_test >= t_budget).astype(np.int8)
|
|
92
|
+
for name, st_map in (extra_states or {}).items():
|
|
93
|
+
preds[name] = run(dict(st_map), test_scores, n_test).verdict
|
|
94
|
+
|
|
95
|
+
systems = {name: binary_metrics(y_test, p, goal) for name, p in preds.items()}
|
|
96
|
+
frontier = frontier_gap(y_test, p1_test, cascade)
|
|
97
|
+
frontier["ci95_interp"] = (
|
|
98
|
+
frontier_ci(y_test, p1_test, cascade, cfg.controls.frontier_bootstrap, seed)
|
|
99
|
+
if frontier["defined"]
|
|
100
|
+
else None
|
|
101
|
+
)
|
|
102
|
+
boot = {
|
|
103
|
+
name: paired_bootstrap(
|
|
104
|
+
y_test, cascade, preds[name], n=cfg.controls.bootstrap, seed=seed + i, names=metric_names(goal)
|
|
105
|
+
)
|
|
106
|
+
for i, name in enumerate(preds)
|
|
107
|
+
if name != "cascade"
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
removal = {}
|
|
111
|
+
for s in order:
|
|
112
|
+
if state[s].off:
|
|
113
|
+
continue
|
|
114
|
+
p = run(with_off(state, [s]), test_scores, n_test).verdict
|
|
115
|
+
m = binary_metrics(y_test, p, goal)
|
|
116
|
+
removal[s] = {"metrics": m, f"cascade_minus_removed_{goal}": systems["cascade"][goal] - m[goal]}
|
|
117
|
+
|
|
118
|
+
prefix = []
|
|
119
|
+
for k in range(len(order) + 1):
|
|
120
|
+
st_k = {s: (state[s] if i < k else replace(state[s], off=True)) for i, s in enumerate(order)}
|
|
121
|
+
m = binary_metrics(y_test, run(st_k, test_scores, n_test).verdict, goal)
|
|
122
|
+
prefix.append({"stages_on": [s for s in order[:k] if not state[s].off], goal: m[goal], "metrics": m})
|
|
123
|
+
for k in range(1, len(prefix)):
|
|
124
|
+
prefix[k][f"step_{goal}"] = prefix[k][goal] - prefix[k - 1][goal]
|
|
125
|
+
|
|
126
|
+
calibrated = cfg.stages[first].calibrate != "none"
|
|
127
|
+
prevalence = {
|
|
128
|
+
"attack_share": {**shares, "TEST": float(np.mean(y_test))},
|
|
129
|
+
"em_estimate_TEST": em_prior(p1_test, first_train_share) if calibrated else None,
|
|
130
|
+
"note": "EM assumes label shift only"
|
|
131
|
+
if calibrated
|
|
132
|
+
else "EM estimate needs a calibrated first stage; not computed",
|
|
133
|
+
}
|
|
134
|
+
summary = {
|
|
135
|
+
"objective": goal,
|
|
136
|
+
"first_stage": first,
|
|
137
|
+
"systems_test": systems,
|
|
138
|
+
"cascade_val": binary_metrics(y_val, cascade_val, goal),
|
|
139
|
+
"b1_threshold": fs.threshold,
|
|
140
|
+
"b2_val_cutoff": b2_cut,
|
|
141
|
+
"budget_matched": {
|
|
142
|
+
"target_val_fpr": target,
|
|
143
|
+
"achieved_val_fpr": achieved,
|
|
144
|
+
"threshold": None if math.isinf(t_budget) else t_budget,
|
|
145
|
+
},
|
|
146
|
+
"frontier_gap_test": frontier,
|
|
147
|
+
"bootstrap_cascade_minus": boot,
|
|
148
|
+
"stage_removal_test": removal,
|
|
149
|
+
"prefix_test": prefix,
|
|
150
|
+
"prevalence": prevalence,
|
|
151
|
+
}
|
|
152
|
+
typed = [s for s in order if cfg.stages[s].kind == "multiclass" and not state[s].off]
|
|
153
|
+
if typed and types_test is not None:
|
|
154
|
+
recorded = recorded_types(routing, cfg.dataset.normal_type)
|
|
155
|
+
summary["types_test"] = {
|
|
156
|
+
"stages": typed,
|
|
157
|
+
"cascade": type_metrics(types_test, recorded, y_test == 1),
|
|
158
|
+
}
|
|
159
|
+
preds["type_recorded"] = recorded
|
|
160
|
+
if routing.pool_off:
|
|
161
|
+
summary["pool_stages_off_on_test"] = routing.pool_off
|
|
162
|
+
if unseen is not None:
|
|
163
|
+
summary["unseen_test"] = unseen_block(
|
|
164
|
+
y_test, unseen, p1_test, preds, n_boot=cfg.controls.frontier_bootstrap, seed=seed
|
|
165
|
+
)
|
|
166
|
+
return summary, preds, routing
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def unseen_block(
|
|
170
|
+
y: np.ndarray, unseen: np.ndarray, p1: np.ndarray, preds: dict[str, np.ndarray], *, n_boot: int, seed: int
|
|
171
|
+
) -> dict:
|
|
172
|
+
"""Held-out or novel attacks on TEST: per-system recall and FPR, plus the frontier gap and the
|
|
173
|
+
first-stage AUC on the {benign, unseen} subset (DESIGN.md section 7)."""
|
|
174
|
+
u = np.asarray(unseen).astype(bool) & (y == 1)
|
|
175
|
+
systems = ("cascade", "B1_first_stage", "B2_first_stage_val_cutoff", "budget_matched_first_stage")
|
|
176
|
+
out: dict = {"n_unseen": int(u.sum()), "systems": {k: unseen_metrics(y, u, preds[k]) for k in systems}}
|
|
177
|
+
sub = u | (y == 0)
|
|
178
|
+
if u.any() and (y == 0).any():
|
|
179
|
+
gap = frontier_gap(y[sub], p1[sub], preds["cascade"][sub])
|
|
180
|
+
gap["ci95_interp"] = (
|
|
181
|
+
frontier_ci(y[sub], p1[sub], preds["cascade"][sub], n_boot, seed) if gap["defined"] else None
|
|
182
|
+
)
|
|
183
|
+
out["frontier_gap_unseen"] = gap
|
|
184
|
+
out["first_stage_auc_unseen_vs_benign"] = auc(y[sub], p1[sub])
|
|
185
|
+
return out
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# IDSLab dashboard: source reference
|
|
2
|
+
|
|
3
|
+
A local web UI over one workspace folder. Standard library only (`http.server`), so it adds no
|
|
4
|
+
dependencies.
|
|
5
|
+
|
|
6
|
+
**Using it?** See [docs/dashboard.md](../../../docs/dashboard.md) for how to start it and what each
|
|
7
|
+
page does. This page is the source-level reference: file layout, the HTTP API and the security model,
|
|
8
|
+
for anyone reading or extending the implementation.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
idslab dashboard --workspace demo # http://127.0.0.1:8765/
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+

|
|
15
|
+
|
|
16
|
+
## Layout
|
|
17
|
+
|
|
18
|
+
| File | What it is |
|
|
19
|
+
|---|---|
|
|
20
|
+
| `server.py` | the HTTP server: `App` (workspace, job, validation, the experiment list with the running job marked), the request handler with its guards, and `make_server` |
|
|
21
|
+
| `workspace.py` | `Workspace`: path checks (relative, inside the root, no hidden parts), config and run discovery by layout (`<name>/<mode>/seed_<s>`), `experiment_index` (configs grouped with their outputs, see below), `history` and `datasets` read from files, file listing; `describe(cfg)` for the cascade preview |
|
|
22
|
+
| `catalog.py` | the models and pipeline steps the builder offers, with parameter forms read from each class's constructor, plus plugin entries |
|
|
23
|
+
| `jobs.py` | `build_command` turns a request into the argv of `python -m idslab.cli`, refusing what the CLI parser would; `Job` runs one subprocess at a time in its own process group, keeps the last 5,000 log lines and stops the whole group; `progress` reads phase and finished units from the log |
|
|
24
|
+
| `static/index.html` | the page shell: sidebar navigation, top bar with the running-job indicator, the `<main>` the pages render into, a live region for screen readers |
|
|
25
|
+
| `static/core.js` | helpers: `el()` (DOM from `textContent`, never `innerHTML`), `api()`, formatting, objective-aware numbers, plain-language errors, dialogs, a line diff, the shared job poller |
|
|
26
|
+
| `static/app.js` | the hash router, the redirects from earlier addresses, theme, job indicator |
|
|
27
|
+
| `static/draft.js` | `Draft`: one config being edited, shared by the setup steps, the builder and the YAML view; validation, rewrite consent, save, revert; `bound()` form fields |
|
|
28
|
+
| `static/experiments.js` | the home list with next actions, New experiment, and one experiment's shell (header, save controls, pages, leave guard) |
|
|
29
|
+
| `static/setup.js` | the Data, Cascade, Evaluation and Review & run steps and the YAML view |
|
|
30
|
+
| `static/builder.js` | the visual builder component: canvas, ports, click and keyboard routing, drawing problems, the stage inspector |
|
|
31
|
+
| `static/results.js` | an experiment's Runs, Results, Report and Files pages |
|
|
32
|
+
| `static/library.js` | Templates, Datasets, Reports and Help |
|
|
33
|
+
| `static/jobs.js` | the Jobs page: progress, stop, failures, past activity |
|
|
34
|
+
| `static/compare.js` | the Compare page (pick experiments, paired comparison) and the Sweeps pages (list, new sweep, editor with live check and run panel) |
|
|
35
|
+
| `static/views.js` | one unit in detail, and the file browser |
|
|
36
|
+
| `static/charts.js` | SVG charts: grouped bars with error bars, and the cascade map drawn from parsed routes |
|
|
37
|
+
| `static/styles.css` | all styling; colours are tokens on `:root` with a dark set |
|
|
38
|
+
|
|
39
|
+
Routes: `#/` experiments, `#/new[/blank|/template/<file>]`, `#/e/<key>/<page>[/<step>]` one experiment
|
|
40
|
+
(`key` is `c:<config path>`, `o:<output folder>` or `n:<unsaved draft>`; pages `setup`, `runs`,
|
|
41
|
+
`results`, `report`, `files`; steps `data`, `cascade`, `evaluation`, `review`, `yaml`), `#/templates`,
|
|
42
|
+
`#/datasets[/<file>]`, `#/jobs`, `#/reports`, `#/help`, `#/compare`, `#/sweeps`, `#/sweep/<file>`,
|
|
43
|
+
`#/unit/<folder>`, `#/files/<folder>`. Earlier addresses (`#/config`, `#/builder`, `#/run`, `#/job`,
|
|
44
|
+
`#/command/...`, `#/example`, `#/configs`, `#/runs`) redirect to their new pages.
|
|
45
|
+
|
|
46
|
+
## Experiments
|
|
47
|
+
|
|
48
|
+
An experiment is not stored anywhere: `experiment_index` builds it from the files each time. Each
|
|
49
|
+
config is one experiment. A run output folder belongs to the config whose `name` it carries. When
|
|
50
|
+
several configs share that name, the config fingerprints in the folder's `DONE` files must match
|
|
51
|
+
exactly one of them. Otherwise the folder is listed on its own (`o:` key) with the reason, and the
|
|
52
|
+
page offers no edit, run or rebuild for it.
|
|
53
|
+
|
|
54
|
+
The state (`draft`, `needs_data`, `invalid`, `running`, `partial`, `completed`) and
|
|
55
|
+
`changed_since_run` come from the config's validation, its data paths, the outputs and the job.
|
|
56
|
+
|
|
57
|
+
Static files are served as they are, so a change shows up on a browser reload; a change to the Python
|
|
58
|
+
files needs a restart.
|
|
59
|
+
|
|
60
|
+
## HTTP API
|
|
61
|
+
|
|
62
|
+
| Method | Path | Returns or does |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| GET | `/`, `/static/<file>` | the page and its assets (only the files listed in `server.STATIC`) |
|
|
65
|
+
| GET | `/api/meta` | version, workspace, commands, default output folders, modes, objectives, installed extras, CPU count |
|
|
66
|
+
| GET | `/api/configs` | YAML files in the workspace that look like configs: path, name, stage count, mode |
|
|
67
|
+
| GET | `/api/config?path=` | the text of one config |
|
|
68
|
+
| GET | `/api/templates` | the shipped reference configs |
|
|
69
|
+
| GET | `/api/experiments` | every experiment: key, state, config record, output folders, next-action inputs |
|
|
70
|
+
| GET | `/api/experiment?key=` (or `?output=<folder>`) | one experiment with its chosen output in full (`&out=` picks the folder) and the config's validation |
|
|
71
|
+
| GET | `/api/runs` | every run output found: units done, report headline and report state |
|
|
72
|
+
| GET | `/api/run?path=` | one run output: units, `report.json`, matching configs |
|
|
73
|
+
| GET | `/api/datasets` | data files with size, time and the configs that read them |
|
|
74
|
+
| GET | `/api/history` | finished units, reports and comparisons read from files, newest first |
|
|
75
|
+
| GET | `/api/unit?path=` | one unit's `summary.json` and `DONE` |
|
|
76
|
+
| GET | `/api/files?path=` | a folder listing |
|
|
77
|
+
| GET | `/api/catalog` | models per stage kind and pipeline steps, with parameter forms |
|
|
78
|
+
| GET | `/api/datafiles` | Parquet and CSV files in the workspace, outside run outputs |
|
|
79
|
+
| GET | `/api/dataset?path=` | columns of a data file from a sample, with suggested label, type, normal value and categorical columns |
|
|
80
|
+
| GET | `/api/sweeps` | sweep files in the workspace |
|
|
81
|
+
| GET | `/api/layout?path=` | saved node positions of a config |
|
|
82
|
+
| GET | `/api/job[?tail=N]` | the job: state, command and argv, target, timings, log, progress read from the log, units finished on disk |
|
|
83
|
+
| GET | `/files/<path>[?download=1]` | a workspace file: report pages as HTML under a no-script policy, text as plain text, anything else as a download |
|
|
84
|
+
| POST | `/api/validate` | `{yaml, path}`: the CLI's loader; returns the parsed design or the error |
|
|
85
|
+
| POST | `/api/config/save` | `{path, yaml, overwrite}`: writes a `.yaml` or `.yml` file, then validates it |
|
|
86
|
+
| POST | `/api/parse` | `{yaml}`: the config as data, for the builder |
|
|
87
|
+
| POST | `/api/dump` | `{config, path}`: the config written as YAML (the CLI's rules), then validated |
|
|
88
|
+
| POST | `/api/compare` | `{experiments, bootstrap}`: the comparison of finished experiments, computed in process |
|
|
89
|
+
| POST | `/api/sweep/check` | `{yaml, path}`: checks a sweep; returns its variants and the base config's dotted keys |
|
|
90
|
+
| POST | `/api/layout` | `{path, positions}`: writes `<config>.layout.json` |
|
|
91
|
+
| POST | `/api/job` | `{command, config, out, seeds, jobs, force, no_report, debug}` or `{command: example, directory, overwrite}`: starts a CLI job |
|
|
92
|
+
| POST | `/api/job/stop` | stops the running job |
|
|
93
|
+
|
|
94
|
+
## Security model
|
|
95
|
+
|
|
96
|
+
The dashboard runs commands and has no login, so it is built for one user on one machine:
|
|
97
|
+
|
|
98
|
+
- it binds to 127.0.0.1 unless told otherwise, and prints a warning for any other address;
|
|
99
|
+
- every request must carry the server's own `Host` (blocks DNS rebinding);
|
|
100
|
+
- POST takes JSON only, with a same-origin `Origin` when one is sent, so another site cannot send a
|
|
101
|
+
request without a CORS preflight, which the server never approves;
|
|
102
|
+
- paths are relative to the workspace, resolved with symlinks, and refused if they leave it or contain
|
|
103
|
+
a hidden part; configs are written only as `.yaml` or `.yml`;
|
|
104
|
+
- the page has a strict Content Security Policy (scripts and styles from the server only, no inline
|
|
105
|
+
code); report pages get a policy that allows no scripts at all, and other text is served as
|
|
106
|
+
`text/plain`;
|
|
107
|
+
- the browser receives data only; every string from a file reaches the DOM through `textContent`.
|
|
108
|
+
|
|
109
|
+
`tests/test_dashboard.py` covers the path rules, discovery, experiment grouping, progress, command
|
|
110
|
+
building and these guards through a live server. `tests/test_dashboard_browser.py` drives the pages in
|
|
111
|
+
Chromium (every page and redirect, narrow screens in both themes, the template to report journey, the
|
|
112
|
+
shared YAML and visual draft); it is skipped when Playwright is not installed
|
|
113
|
+
(`pip install -e .[browser-tests]` and `playwright install chromium`).
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 hashtagRR
|
|
3
|
+
"""Models and pipeline steps the builder offers, with parameter forms read from each class.
|
|
4
|
+
|
|
5
|
+
The lists are a curated starting point, not a limit: the builder also takes any class path, and
|
|
6
|
+
plugins (``idslab.plugins`` entry points) add their own entries. A class from a missing optional
|
|
7
|
+
library is listed as not installed, with the extra that provides it.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib.util
|
|
13
|
+
import inspect
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from idslab.config import EXTRAS, import_class
|
|
17
|
+
from idslab.plugins import load as load_plugins
|
|
18
|
+
|
|
19
|
+
# (class path, short label, note)
|
|
20
|
+
CLASSIFIERS = [
|
|
21
|
+
("sklearn.ensemble.RandomForestClassifier", "Random forest", "strong default for flow features"),
|
|
22
|
+
("sklearn.ensemble.ExtraTreesClassifier", "Extra trees", "faster, more random random forest"),
|
|
23
|
+
("sklearn.ensemble.HistGradientBoostingClassifier", "Gradient boosting", "handles large tables fast"),
|
|
24
|
+
("sklearn.linear_model.LogisticRegression", "Logistic regression", "linear; scale the features"),
|
|
25
|
+
("sklearn.neighbors.KNeighborsClassifier", "k nearest neighbours", "scale the features"),
|
|
26
|
+
("sklearn.tree.DecisionTreeClassifier", "Decision tree", "one readable tree"),
|
|
27
|
+
("sklearn.neural_network.MLPClassifier", "MLP (scikit-learn)", "small neural network"),
|
|
28
|
+
("sklearn.naive_bayes.GaussianNB", "Gaussian naive Bayes", "very fast baseline"),
|
|
29
|
+
("xgboost.XGBClassifier", "XGBoost", "extra: xgboost"),
|
|
30
|
+
("lightgbm.LGBMClassifier", "LightGBM", "extra: lightgbm"),
|
|
31
|
+
("idslab.torch_models.TorchMLPClassifier", "PyTorch MLP", "extra: torch; scale the features"),
|
|
32
|
+
]
|
|
33
|
+
DETECTORS = [
|
|
34
|
+
("sklearn.ensemble.IsolationForest", "Isolation forest", "fit once; no scaling needed"),
|
|
35
|
+
("sklearn.neighbors.LocalOutlierFactor", "Local outlier factor", "fit once or per batch; scale"),
|
|
36
|
+
("sklearn.svm.OneClassSVM", "One-class SVM", "scale the features; slow on large data"),
|
|
37
|
+
("sklearn.covariance.EllipticEnvelope", "Elliptic envelope", "assumes roughly Gaussian features"),
|
|
38
|
+
("pyod.models.ecod.ECOD", "ECOD (PyOD)", "extra: pyod; no parameters to tune"),
|
|
39
|
+
("pyod.models.copod.COPOD", "COPOD (PyOD)", "extra: pyod"),
|
|
40
|
+
("pyod.models.hbos.HBOS", "HBOS (PyOD)", "extra: pyod; very fast"),
|
|
41
|
+
("idslab.torch_models.TorchAutoencoder", "PyTorch autoencoder", "extra: torch; scale the features"),
|
|
42
|
+
]
|
|
43
|
+
STEPS = [
|
|
44
|
+
("idslab.prep.Encode", "Encode categories", "required for text columns; list them in categorical"),
|
|
45
|
+
("sklearn.preprocessing.StandardScaler", "Standard scaler", "zero mean, unit variance"),
|
|
46
|
+
("sklearn.preprocessing.MinMaxScaler", "Min-max scaler", "scale to [0, 1]"),
|
|
47
|
+
("sklearn.preprocessing.RobustScaler", "Median and quartile scaler", "less sensitive to outliers"),
|
|
48
|
+
("sklearn.preprocessing.QuantileTransformer", "Quantile transformer", "map to a uniform or normal shape"),
|
|
49
|
+
("sklearn.decomposition.PCA", "PCA", "n_components below 1 keeps that share of variance"),
|
|
50
|
+
("sklearn.feature_selection.VarianceThreshold", "Drop constant features", ""),
|
|
51
|
+
("sklearn.feature_selection.SelectKBest", "Select k best", "univariate feature selection"),
|
|
52
|
+
("imblearn.over_sampling.SMOTE", "SMOTE", "extra: imbalance; oversample during fit only"),
|
|
53
|
+
("imblearn.under_sampling.RandomUnderSampler", "Random undersampler", "extra: imbalance"),
|
|
54
|
+
]
|
|
55
|
+
SKIP_PARAMS = {"random_state", "n_jobs", "verbose", "warm_start", "copy", "novelty", "device"}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _installed(path: str) -> bool:
|
|
59
|
+
return importlib.util.find_spec(path.split(".", 1)[0]) is not None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def params_of(path: str) -> list[dict]:
|
|
63
|
+
"""Constructor parameters with simple defaults: name, default and a form type."""
|
|
64
|
+
try:
|
|
65
|
+
cls = import_class(path)
|
|
66
|
+
sig = inspect.signature(cls.__init__)
|
|
67
|
+
except (ImportError, TypeError, ValueError):
|
|
68
|
+
return []
|
|
69
|
+
out = []
|
|
70
|
+
for name, p in sig.parameters.items():
|
|
71
|
+
if name == "self" or name in SKIP_PARAMS or p.kind in (p.VAR_POSITIONAL, p.VAR_KEYWORD):
|
|
72
|
+
continue
|
|
73
|
+
d = None if p.default is inspect.Parameter.empty else p.default
|
|
74
|
+
if isinstance(d, bool):
|
|
75
|
+
kind = "bool"
|
|
76
|
+
elif isinstance(d, int):
|
|
77
|
+
kind = "int"
|
|
78
|
+
elif isinstance(d, float):
|
|
79
|
+
kind = "float"
|
|
80
|
+
elif isinstance(d, str) or d is None:
|
|
81
|
+
kind = "text"
|
|
82
|
+
elif isinstance(d, tuple) and all(isinstance(x, (int, float)) for x in d):
|
|
83
|
+
kind, d = "list", list(d)
|
|
84
|
+
else:
|
|
85
|
+
continue # objects and callables: set them in YAML
|
|
86
|
+
out.append({"name": name, "default": d, "type": kind})
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _entry(path: str, label: str, note: str) -> dict:
|
|
91
|
+
installed = _installed(path)
|
|
92
|
+
top = path.split(".", 1)[0]
|
|
93
|
+
return {
|
|
94
|
+
"class": path,
|
|
95
|
+
"label": label,
|
|
96
|
+
"note": note,
|
|
97
|
+
"installed": installed,
|
|
98
|
+
"extra": EXTRAS.get(top),
|
|
99
|
+
"params": params_of(path) if installed else [],
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def plugin_entries() -> dict[str, list[tuple[str, str, str]]]:
|
|
104
|
+
"""Catalog entries from installed plugins (``idslab.plugins`` entry points), by section."""
|
|
105
|
+
loaded = load_plugins()
|
|
106
|
+
return {
|
|
107
|
+
section: [
|
|
108
|
+
(i["class"], str(i.get("label", i["class"])), str(i.get("note", ""))) for i in loaded[section]
|
|
109
|
+
]
|
|
110
|
+
for section in ("classifier", "detector", "step")
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def catalog() -> dict[str, Any]:
|
|
115
|
+
extra = plugin_entries()
|
|
116
|
+
classifiers = [_entry(*c) for c in CLASSIFIERS + extra["classifier"]]
|
|
117
|
+
return {
|
|
118
|
+
"classifier": classifiers,
|
|
119
|
+
"multiclass": classifiers,
|
|
120
|
+
"detector": [_entry(*c) for c in DETECTORS + extra["detector"]],
|
|
121
|
+
"step": [_entry(*c) for c in STEPS + extra["step"]],
|
|
122
|
+
}
|