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/__init__.py
ADDED
idslab/adapters.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 hashtagRR
|
|
3
|
+
"""Model adapters: build estimators and read their output as a score contract.
|
|
4
|
+
|
|
5
|
+
Contracts (DESIGN.md section 0.1):
|
|
6
|
+
|
|
7
|
+
* AttackScore: one value per row, P(attack), read from ``predict_proba`` at the
|
|
8
|
+
column of class 1.
|
|
9
|
+
* ClassProba: per row, probabilities over named types, read from ``predict_proba``
|
|
10
|
+
with the columns named by ``classes_``.
|
|
11
|
+
* AnomalyScore: one value per row, higher means more anomalous. scikit-learn
|
|
12
|
+
detectors use ``score_samples`` (higher means more normal, so it is negated);
|
|
13
|
+
PyOD detectors use ``decision_function`` (already higher means more anomalous);
|
|
14
|
+
a candidate may override both with ``score: {method, higher_is_anomalous}``.
|
|
15
|
+
* BatchAnomaly: the detector is fitted on one batch of flows and scores that same
|
|
16
|
+
batch, higher = more anomalous (LOF without novelty, PyOD ``decision_scores_``).
|
|
17
|
+
|
|
18
|
+
A class that is not a scikit-learn estimator can still be used: give it a class
|
|
19
|
+
attribute ``idslab_contract`` naming its contract, with ``fit(X, y)`` and
|
|
20
|
+
``attack_score(X)`` (AttackScore), ``fit(X, y)`` and ``class_proba(X)`` plus
|
|
21
|
+
``classes_`` (ClassProba), ``fit(X)`` and ``anomaly_score(X)`` (AnomalyScore), or
|
|
22
|
+
``fit_score(X)`` (BatchAnomaly).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
|
|
32
|
+
from idslab.config import Candidate, import_class
|
|
33
|
+
|
|
34
|
+
ATTACK = 1
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ContractError(RuntimeError):
|
|
38
|
+
"""A model's output does not meet its score contract."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_custom(model: Any) -> bool:
|
|
42
|
+
return getattr(model, "idslab_contract", None) is not None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class ClassScores:
|
|
47
|
+
"""Probabilities over named types (ClassProba contract); rows follow the data, columns ``classes``."""
|
|
48
|
+
|
|
49
|
+
proba: np.ndarray
|
|
50
|
+
classes: tuple[str, ...]
|
|
51
|
+
|
|
52
|
+
def __len__(self) -> int:
|
|
53
|
+
return len(self.proba)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_estimator(cand: Candidate, seed: int, n_jobs: int, *, novelty: bool = True) -> Any:
|
|
57
|
+
cls = import_class(cand.cls)
|
|
58
|
+
params = dict(cand.params)
|
|
59
|
+
if getattr(cls, "idslab_contract", None) is not None:
|
|
60
|
+
return cls(**params)
|
|
61
|
+
est = cls(**params)
|
|
62
|
+
known = est.get_params()
|
|
63
|
+
extra = {}
|
|
64
|
+
if "random_state" in known and "random_state" not in params:
|
|
65
|
+
extra["random_state"] = seed
|
|
66
|
+
if "n_jobs" in known and "n_jobs" not in params and n_jobs != 1:
|
|
67
|
+
extra["n_jobs"] = n_jobs
|
|
68
|
+
if "novelty" in known:
|
|
69
|
+
extra["novelty"] = novelty
|
|
70
|
+
if extra:
|
|
71
|
+
est.set_params(**extra)
|
|
72
|
+
return est
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def build_pipeline(steps: tuple[dict, ...], estimator: Any, seed: int) -> Any:
|
|
76
|
+
"""Pipeline of the stage's steps with the model last, named ``model``.
|
|
77
|
+
|
|
78
|
+
An imbalanced-learn sampler in the steps switches to its Pipeline, which
|
|
79
|
+
resamples during fit only.
|
|
80
|
+
"""
|
|
81
|
+
built = []
|
|
82
|
+
for i, step in enumerate(steps):
|
|
83
|
+
obj = import_class(step["class"])(**(step.get("params") or {}))
|
|
84
|
+
if "random_state" in obj.get_params() and "random_state" not in (step.get("params") or {}):
|
|
85
|
+
obj.set_params(random_state=seed)
|
|
86
|
+
built.append((f"step{i}", obj))
|
|
87
|
+
if is_custom(estimator):
|
|
88
|
+
if built:
|
|
89
|
+
raise ContractError("a custom adapter class cannot be combined with pipeline steps")
|
|
90
|
+
return estimator
|
|
91
|
+
if any(hasattr(obj, "fit_resample") for _, obj in built):
|
|
92
|
+
from imblearn.pipeline import Pipeline as ImbPipeline # noqa: PLC0415 (optional extra)
|
|
93
|
+
|
|
94
|
+
return ImbPipeline([*built, ("model", estimator)])
|
|
95
|
+
from sklearn.pipeline import Pipeline # noqa: PLC0415
|
|
96
|
+
|
|
97
|
+
return Pipeline([*built, ("model", estimator)])
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def attack_score(model: Any, X: Any) -> np.ndarray:
|
|
101
|
+
"""P(attack) per row (AttackScore contract)."""
|
|
102
|
+
if is_custom(model):
|
|
103
|
+
out = np.asarray(model.attack_score(X), dtype=np.float64)
|
|
104
|
+
else:
|
|
105
|
+
classes = list(np.asarray(model.classes_).tolist())
|
|
106
|
+
if ATTACK not in classes:
|
|
107
|
+
raise ContractError(f"classifier classes {classes} do not include the attack class 1")
|
|
108
|
+
out = np.asarray(model.predict_proba(X), dtype=np.float64)[:, classes.index(ATTACK)]
|
|
109
|
+
_check(out, X, "AttackScore")
|
|
110
|
+
if out.min() < 0 or out.max() > 1:
|
|
111
|
+
raise ContractError("AttackScore must be a probability in [0, 1]")
|
|
112
|
+
return out
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def class_proba(model: Any, X: Any) -> ClassScores:
|
|
116
|
+
"""Probabilities over the model's types (ClassProba contract)."""
|
|
117
|
+
raw = model.class_proba(X) if is_custom(model) else model.predict_proba(X)
|
|
118
|
+
proba = np.asarray(raw, dtype=np.float64)
|
|
119
|
+
classes = tuple(str(c) for c in np.asarray(model.classes_).tolist())
|
|
120
|
+
if proba.ndim != 2 or proba.shape != (len(X), len(classes)):
|
|
121
|
+
raise ContractError(f"ClassProba must be rows x {len(classes)} classes; got shape {proba.shape}")
|
|
122
|
+
if not np.isfinite(proba).all() or proba.min() < 0 or proba.max() > 1:
|
|
123
|
+
raise ContractError("ClassProba must hold finite probabilities in [0, 1]")
|
|
124
|
+
return ClassScores(proba=proba, classes=classes)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class PoolDetector:
|
|
128
|
+
"""A detector refitted on each batch of flows reaching its stage (BatchAnomaly contract).
|
|
129
|
+
|
|
130
|
+
The preprocessing steps are fitted once on FIT rows; the detector itself is built
|
|
131
|
+
afresh for every batch, fitted on that batch's features only and asked to score it.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
def __init__(self, cand: Candidate, steps: tuple[dict, ...], seed: int, n_jobs: int):
|
|
135
|
+
self.cand, self.steps, self.seed, self.n_jobs = cand, steps, seed, n_jobs
|
|
136
|
+
self.prep: Any = None
|
|
137
|
+
|
|
138
|
+
def fit(self, X: Any) -> PoolDetector:
|
|
139
|
+
if self.steps:
|
|
140
|
+
from sklearn.pipeline import Pipeline # noqa: PLC0415
|
|
141
|
+
|
|
142
|
+
built = []
|
|
143
|
+
for i, step in enumerate(self.steps):
|
|
144
|
+
obj = import_class(step["class"])(**(step.get("params") or {}))
|
|
145
|
+
if "random_state" in obj.get_params() and "random_state" not in (step.get("params") or {}):
|
|
146
|
+
obj.set_params(random_state=self.seed)
|
|
147
|
+
built.append((f"step{i}", obj))
|
|
148
|
+
self.prep = Pipeline(built).fit(X)
|
|
149
|
+
return self
|
|
150
|
+
|
|
151
|
+
def score_pool(self, X: Any) -> np.ndarray:
|
|
152
|
+
data = self.prep.transform(X) if self.prep is not None else X
|
|
153
|
+
det = build_estimator(self.cand, self.seed, self.n_jobs, novelty=False)
|
|
154
|
+
if is_custom(det):
|
|
155
|
+
out = np.asarray(det.fit_score(data), dtype=np.float64)
|
|
156
|
+
else:
|
|
157
|
+
det.fit(data)
|
|
158
|
+
if hasattr(det, "negative_outlier_factor_"):
|
|
159
|
+
out = -np.asarray(det.negative_outlier_factor_, dtype=np.float64)
|
|
160
|
+
elif hasattr(det, "decision_scores_"): # PyOD: training scores, higher = more anomalous
|
|
161
|
+
out = np.asarray(det.decision_scores_, dtype=np.float64)
|
|
162
|
+
elif hasattr(det, "score_samples"):
|
|
163
|
+
out = -np.asarray(det.score_samples(data), dtype=np.float64)
|
|
164
|
+
else:
|
|
165
|
+
raise ContractError(f"{self.cand.cls} gives no batch scores (BatchAnomaly contract)")
|
|
166
|
+
_check(out, X, "BatchAnomaly")
|
|
167
|
+
return out
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def anomaly_score(model: Any, X: Any, cls_path: str, override: dict | None) -> np.ndarray:
|
|
171
|
+
"""Anomaly score per row, higher = more anomalous (AnomalyScore contract)."""
|
|
172
|
+
if is_custom(model):
|
|
173
|
+
out = np.asarray(model.anomaly_score(X), dtype=np.float64)
|
|
174
|
+
else:
|
|
175
|
+
detector, data = final_step(model, X)
|
|
176
|
+
if override is not None:
|
|
177
|
+
method, higher_is_anomalous = override["method"], bool(override["higher_is_anomalous"])
|
|
178
|
+
elif cls_path.startswith("pyod."):
|
|
179
|
+
method, higher_is_anomalous = "decision_function", True
|
|
180
|
+
elif hasattr(detector, "score_samples"):
|
|
181
|
+
method, higher_is_anomalous = "score_samples", False
|
|
182
|
+
else:
|
|
183
|
+
method, higher_is_anomalous = "decision_function", False
|
|
184
|
+
if not hasattr(detector, method):
|
|
185
|
+
raise ContractError(f"{cls_path} has no method {method!r}")
|
|
186
|
+
raw = np.asarray(getattr(detector, method)(data), dtype=np.float64)
|
|
187
|
+
out = raw if higher_is_anomalous else -raw
|
|
188
|
+
_check(out, X, "AnomalyScore")
|
|
189
|
+
return out
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def final_step(model: Any, X: Any) -> tuple[Any, Any]:
|
|
193
|
+
"""The last estimator of a pipeline and ``X`` passed through the steps before it.
|
|
194
|
+
|
|
195
|
+
Scoring methods are called on the detector itself, so any method it has can be
|
|
196
|
+
named in a ``score`` override (a Pipeline passes only a fixed set through).
|
|
197
|
+
"""
|
|
198
|
+
steps = getattr(model, "steps", None)
|
|
199
|
+
if not steps:
|
|
200
|
+
return model, X
|
|
201
|
+
data = X
|
|
202
|
+
for _, step in steps[:-1]:
|
|
203
|
+
if hasattr(step, "transform"):
|
|
204
|
+
data = step.transform(data)
|
|
205
|
+
return steps[-1][1], data
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _check(out: np.ndarray, X: Any, contract: str) -> None:
|
|
209
|
+
if out.ndim != 1 or len(out) != len(X):
|
|
210
|
+
raise ContractError(
|
|
211
|
+
f"{contract} must give one value per row; got shape {out.shape} for {len(X)} rows"
|
|
212
|
+
)
|
|
213
|
+
if not np.isfinite(out).all():
|
|
214
|
+
raise ContractError(f"{contract} has {int((~np.isfinite(out)).sum())} non-finite values")
|
idslab/cli.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 hashtagRR
|
|
3
|
+
"""Command line: idslab validate | run | report | export-splits | example | compare | sweep | dashboard."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import sys
|
|
12
|
+
from importlib import resources
|
|
13
|
+
|
|
14
|
+
from idslab import __version__
|
|
15
|
+
from idslab.adapters import ContractError
|
|
16
|
+
from idslab.compare import CompareError
|
|
17
|
+
from idslab.config import Config, ConfigError, load
|
|
18
|
+
from idslab.data import DataError
|
|
19
|
+
from idslab.fitting import FitError
|
|
20
|
+
from idslab.report import write_report
|
|
21
|
+
from idslab.runner import data_fingerprints, export_splits, run
|
|
22
|
+
from idslab.sweep import SweepError
|
|
23
|
+
from idslab.synthetic import write_example
|
|
24
|
+
|
|
25
|
+
EXAMPLES = ("r1_router_gate.yaml", "r2_anomaly_first.yaml", "r1_four_layer.yaml")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def validation_text(path: str, cfg: Config) -> str:
|
|
29
|
+
"""What ``idslab validate`` prints for a valid config (the dashboard shows the same lines)."""
|
|
30
|
+
lines = [f"{path}: valid", f" stages in selection order: {' > '.join(cfg.order())}"]
|
|
31
|
+
for name in cfg.order():
|
|
32
|
+
st = cfg.stages[name]
|
|
33
|
+
tune = "chosen on VAL" if st.decision.tunable else "fixed"
|
|
34
|
+
cands = ", ".join(c.label for c in st.candidates)
|
|
35
|
+
lines.append(
|
|
36
|
+
f" {name}: {st.kind}, {st.decision.mode} {list(st.decision.values)} ({tune}), candidates {cands}"
|
|
37
|
+
)
|
|
38
|
+
lines.append(f" controls compare against: {cfg.controls.first_stage}; seeds {list(cfg.protocol.seeds)}")
|
|
39
|
+
return "\n".join(lines)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _validate(args: argparse.Namespace) -> int:
|
|
43
|
+
print(validation_text(args.config, load(args.config)))
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _run(args: argparse.Namespace) -> int:
|
|
48
|
+
cfg = load(args.config)
|
|
49
|
+
data_fps = data_fingerprints(cfg) # hash the data once for the run and the report
|
|
50
|
+
results = run(
|
|
51
|
+
cfg, out_root=args.out, seeds=args.seeds, jobs=args.jobs, force=args.force, data_fps=data_fps
|
|
52
|
+
)
|
|
53
|
+
goal = cfg.protocol.objective
|
|
54
|
+
for r in results:
|
|
55
|
+
s = r["summary"]
|
|
56
|
+
sys_ = s["controls"]["systems_test"]
|
|
57
|
+
tag = "skipped (already done)" if r["skipped"] else f"{s['runtime_seconds']}s"
|
|
58
|
+
print(
|
|
59
|
+
f"seed {r['seed']}{' family ' + r['family'] if r.get('family') else ''}: {tag}; "
|
|
60
|
+
f"TEST {goal} cascade {sys_['cascade'][goal]:.4f}, "
|
|
61
|
+
f"first stage {sys_['B1_first_stage'][goal]:.4f}"
|
|
62
|
+
)
|
|
63
|
+
if not args.no_report:
|
|
64
|
+
md, _ = write_report(args.out, cfg, args.seeds or (), data_fps)
|
|
65
|
+
print(f"report: {md} (and report.html next to it)")
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _report(args: argparse.Namespace) -> int:
|
|
70
|
+
cfg = load(args.config)
|
|
71
|
+
md, js = write_report(args.out, cfg, args.seeds or ())
|
|
72
|
+
print(f"report: {md}\nreport page: {os.path.splitext(md)[0]}.html\nreport data: {js}")
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _export(args: argparse.Namespace) -> int:
|
|
77
|
+
cfg = load(args.config)
|
|
78
|
+
paths = export_splits(cfg, args.out, args.seeds)
|
|
79
|
+
print(
|
|
80
|
+
f"wrote {len(paths)} split files (each with a .json sidecar) under {os.path.join(args.out, cfg.name)}"
|
|
81
|
+
)
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _example(args: argparse.Namespace) -> int:
|
|
86
|
+
target = args.directory
|
|
87
|
+
data_dir = os.path.join(target, "data")
|
|
88
|
+
targets = [os.path.join(target, n) for n in EXAMPLES]
|
|
89
|
+
targets += [os.path.join(data_dir, n) for n in ("train.parquet", "test.parquet")]
|
|
90
|
+
existing = [t for t in targets if os.path.exists(t)]
|
|
91
|
+
if existing and not args.overwrite:
|
|
92
|
+
print(f"already present: {', '.join(existing)}; use --overwrite to replace them", file=sys.stderr)
|
|
93
|
+
return 2
|
|
94
|
+
os.makedirs(target, exist_ok=True)
|
|
95
|
+
for name in EXAMPLES:
|
|
96
|
+
with resources.as_file(resources.files("idslab.examples") / name) as src:
|
|
97
|
+
shutil.copyfile(src, os.path.join(target, name))
|
|
98
|
+
train, test = write_example(data_dir)
|
|
99
|
+
print(f"wrote {train}, {test} and {', '.join(EXAMPLES)} in {target}")
|
|
100
|
+
print(f"try: idslab run {os.path.join(target, EXAMPLES[0])} --out {os.path.join(target, 'runs')}")
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _compare(args: argparse.Namespace) -> int:
|
|
105
|
+
from idslab.compare import compare, write_comparison # noqa: PLC0415
|
|
106
|
+
|
|
107
|
+
c = compare(args.experiments, bootstrap=args.bootstrap)
|
|
108
|
+
out = args.out or os.path.join(
|
|
109
|
+
"comparisons", "-vs-".join(os.path.basename(os.path.normpath(p)) for p in args.experiments)[:120]
|
|
110
|
+
)
|
|
111
|
+
md, _, html = write_comparison(c, out)
|
|
112
|
+
goal = c["objective"]
|
|
113
|
+
for h in c["experiments"]:
|
|
114
|
+
cas, first = (h["cascade"] or {}).get("mean"), (h["first_stage"] or {}).get("mean")
|
|
115
|
+
print(f"{h['name']}: TEST {goal} cascade {_num(cas)}, first stage alone {_num(first)}")
|
|
116
|
+
for p in c["paired"]:
|
|
117
|
+
n = len(p["per_unit"])
|
|
118
|
+
print(
|
|
119
|
+
f"{p['other']} minus {p['base']}: {_num(p['mean_diff'], signed=True)} over {n} paired units "
|
|
120
|
+
f"(CI above 0 in {p['units_ci_above_zero']}, below 0 in {p['units_ci_below_zero']})"
|
|
121
|
+
)
|
|
122
|
+
for w in c["warnings"]:
|
|
123
|
+
print(f"warning: {w}")
|
|
124
|
+
print(f"comparison: {md} (and {html})")
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _num(v: float | None, signed: bool = False) -> str:
|
|
129
|
+
return "n/a" if v is None else (f"{v:+.4f}" if signed else f"{v:.4f}")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _sweep(args: argparse.Namespace) -> int:
|
|
133
|
+
from idslab.sweep import load_sweep, run_sweep # noqa: PLC0415
|
|
134
|
+
|
|
135
|
+
if args.dry_run:
|
|
136
|
+
sw = load_sweep(args.sweep)
|
|
137
|
+
print(f"{args.sweep}: valid; {len(sw.variants)} variants of {sw.base_path}")
|
|
138
|
+
for v in sw.variants:
|
|
139
|
+
print(f" {v.name}: {v.label}")
|
|
140
|
+
return 0
|
|
141
|
+
md, _, html = run_sweep(
|
|
142
|
+
args.sweep,
|
|
143
|
+
out_root=args.out,
|
|
144
|
+
seeds=args.seeds,
|
|
145
|
+
jobs=args.jobs,
|
|
146
|
+
force=args.force,
|
|
147
|
+
bootstrap=args.bootstrap,
|
|
148
|
+
)
|
|
149
|
+
print(f"comparison: {md} (and {html})")
|
|
150
|
+
return 0
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _name(folder: str) -> str:
|
|
154
|
+
"""A folder as typed, when it is a plain name every shell passes unchanged; otherwise a
|
|
155
|
+
placeholder rather than a command that would paste wrongly."""
|
|
156
|
+
plain = folder and folder[0] != "-" and all(c.isalnum() or c in "_.-" for c in folder)
|
|
157
|
+
return folder if plain else "<this folder>"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _dashboard(args: argparse.Namespace) -> int:
|
|
161
|
+
from idslab.dashboard.server import App, make_server # noqa: PLC0415 (only this command needs it)
|
|
162
|
+
from idslab.dashboard.workspace import PathError, WorkspaceMissingError # noqa: PLC0415
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
app = App(args.workspace)
|
|
166
|
+
except PathError as e: # the workspace folder: told plainly, it is not an unexpected error
|
|
167
|
+
if args.debug:
|
|
168
|
+
raise
|
|
169
|
+
print(f"idslab: {e}", file=sys.stderr)
|
|
170
|
+
if isinstance(e, WorkspaceMissingError):
|
|
171
|
+
print(
|
|
172
|
+
"If it should hold your own data, check the path for a typo. To try the example data, "
|
|
173
|
+
"create the folder first with the example command, started the same way as this one: "
|
|
174
|
+
f"idslab example {_name(args.workspace)}",
|
|
175
|
+
file=sys.stderr,
|
|
176
|
+
)
|
|
177
|
+
return 2
|
|
178
|
+
server = make_server(args.host, args.port, app)
|
|
179
|
+
host, port = server.server_address[:2]
|
|
180
|
+
url = f"http://{'127.0.0.1' if host in ('0.0.0.0', '::') else host}:{port}/" # noqa: S104
|
|
181
|
+
print(f"IDSLab dashboard for {app.ws.root} at {url} (Ctrl+C to stop)", flush=True)
|
|
182
|
+
if args.host not in ("127.0.0.1", "localhost", "::1"):
|
|
183
|
+
print("warning: the dashboard runs commands and has no login; keep it on a trusted network")
|
|
184
|
+
if args.open:
|
|
185
|
+
import webbrowser # noqa: PLC0415
|
|
186
|
+
|
|
187
|
+
webbrowser.open(url)
|
|
188
|
+
try:
|
|
189
|
+
server.serve_forever()
|
|
190
|
+
except KeyboardInterrupt:
|
|
191
|
+
pass
|
|
192
|
+
finally:
|
|
193
|
+
app.job.stop()
|
|
194
|
+
server.server_close()
|
|
195
|
+
return 0
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _jobs(text: str) -> int:
|
|
199
|
+
v = int(text)
|
|
200
|
+
if v == 0 or v < -1:
|
|
201
|
+
raise argparse.ArgumentTypeError("--jobs must be a positive integer or -1")
|
|
202
|
+
return v
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
206
|
+
p = argparse.ArgumentParser(prog="idslab", description="Evaluate staged intrusion detection designs.")
|
|
207
|
+
p.add_argument("--version", action="version", version=f"idslab {__version__}")
|
|
208
|
+
p.add_argument("--debug", action="store_true", help="show the full traceback on errors")
|
|
209
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
210
|
+
|
|
211
|
+
v = sub.add_parser("validate", help="check a config without reading data")
|
|
212
|
+
v.add_argument("config")
|
|
213
|
+
v.set_defaults(func=_validate)
|
|
214
|
+
|
|
215
|
+
r = sub.add_parser("run", help="fit, select on VAL, evaluate on TEST, write results and the report")
|
|
216
|
+
r.add_argument("config")
|
|
217
|
+
r.add_argument("--out", default="runs", help="output root (default: runs)")
|
|
218
|
+
r.add_argument("--seeds", type=int, nargs="+", help="run only these seeds")
|
|
219
|
+
r.add_argument("--jobs", type=_jobs, default=1, help="seeds run in parallel (default 1, -1 = all cores)")
|
|
220
|
+
r.add_argument("--force", action="store_true", help="rerun seeds that are already done")
|
|
221
|
+
r.add_argument("--no-report", action="store_true", help="skip writing report.md")
|
|
222
|
+
r.set_defaults(func=_run)
|
|
223
|
+
|
|
224
|
+
rep = sub.add_parser("report", help="aggregate finished seeds into report.md and report.json")
|
|
225
|
+
rep.add_argument("config")
|
|
226
|
+
rep.add_argument("--out", default="runs")
|
|
227
|
+
rep.add_argument("--seeds", type=int, nargs="+", help="also include these seeds")
|
|
228
|
+
rep.set_defaults(func=_report)
|
|
229
|
+
|
|
230
|
+
x = sub.add_parser("export-splits", help="write row ids, folds and fingerprints for external score jobs")
|
|
231
|
+
x.add_argument("config")
|
|
232
|
+
x.add_argument("--out", default="splits", help="output root (default: splits)")
|
|
233
|
+
x.add_argument("--seeds", type=int, nargs="+", help="only these seeds")
|
|
234
|
+
x.set_defaults(func=_export)
|
|
235
|
+
|
|
236
|
+
e = sub.add_parser("example", help="write the synthetic dataset and the two reference configs")
|
|
237
|
+
e.add_argument("directory")
|
|
238
|
+
e.add_argument("--overwrite", action="store_true")
|
|
239
|
+
e.set_defaults(func=_example)
|
|
240
|
+
|
|
241
|
+
cp = sub.add_parser("compare", help="compare finished experiments side by side, paired where possible")
|
|
242
|
+
cp.add_argument(
|
|
243
|
+
"experiments", nargs="+", help="experiment folders (<out>/<name>); the first is the baseline"
|
|
244
|
+
)
|
|
245
|
+
cp.add_argument("--out", help="folder for compare.md/.json/.html (default: comparisons/<names>)")
|
|
246
|
+
cp.add_argument("--bootstrap", type=int, default=1000, help="paired bootstrap replicates (default 1000)")
|
|
247
|
+
cp.set_defaults(func=_compare)
|
|
248
|
+
|
|
249
|
+
sw = sub.add_parser("sweep", help="run every variant of a sweep file and compare them")
|
|
250
|
+
sw.add_argument("sweep", help="sweep file (YAML: name, base, and variants or grid)")
|
|
251
|
+
sw.add_argument("--out", default="runs", help="output root (default: runs)")
|
|
252
|
+
sw.add_argument("--seeds", type=int, nargs="+", help="run only these seeds")
|
|
253
|
+
sw.add_argument("--jobs", type=_jobs, default=1, help="seeds run in parallel (default 1, -1 = all cores)")
|
|
254
|
+
sw.add_argument("--force", action="store_true", help="rerun seeds that are already done")
|
|
255
|
+
sw.add_argument("--bootstrap", type=int, default=1000, help="paired bootstrap replicates (default 1000)")
|
|
256
|
+
sw.add_argument(
|
|
257
|
+
"--dry-run", action="store_true", help="check the sweep and list its variants, run nothing"
|
|
258
|
+
)
|
|
259
|
+
sw.set_defaults(func=_sweep)
|
|
260
|
+
|
|
261
|
+
d = sub.add_parser("dashboard", help="open the web dashboard over a workspace folder")
|
|
262
|
+
d.add_argument("--workspace", default=".", help="folder with configs, data and runs (default: .)")
|
|
263
|
+
d.add_argument("--host", default="127.0.0.1", help="address to bind (default: 127.0.0.1)")
|
|
264
|
+
d.add_argument("--port", type=int, default=8765, help="port (default 8765, 0 = any free port)")
|
|
265
|
+
d.add_argument("--open", action="store_true", help="open the page in the default browser")
|
|
266
|
+
d.set_defaults(func=_dashboard)
|
|
267
|
+
return p
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def main(argv: list[str] | None = None) -> int:
|
|
271
|
+
args = build_parser().parse_args(argv)
|
|
272
|
+
if args.command in ("run", "export-splits", "sweep"): # progress lines on stderr; results stay on stdout
|
|
273
|
+
handler = logging.StreamHandler()
|
|
274
|
+
handler.setFormatter(logging.Formatter("%(asctime)s %(message)s", "%H:%M:%S"))
|
|
275
|
+
log = logging.getLogger("idslab")
|
|
276
|
+
if not log.handlers: # main() can run more than once in a process (tests)
|
|
277
|
+
log.addHandler(handler)
|
|
278
|
+
log.setLevel(logging.INFO)
|
|
279
|
+
try:
|
|
280
|
+
return int(args.func(args))
|
|
281
|
+
except (
|
|
282
|
+
ConfigError,
|
|
283
|
+
DataError,
|
|
284
|
+
FitError,
|
|
285
|
+
ContractError,
|
|
286
|
+
CompareError,
|
|
287
|
+
SweepError,
|
|
288
|
+
ImportError,
|
|
289
|
+
OSError,
|
|
290
|
+
) as e:
|
|
291
|
+
print(f"idslab: {e}", file=sys.stderr)
|
|
292
|
+
return 2
|
|
293
|
+
except (ValueError, RuntimeError, IndexError) as e:
|
|
294
|
+
if args.debug:
|
|
295
|
+
raise
|
|
296
|
+
print(f"idslab: {type(e).__name__}: {e} (rerun with --debug for the traceback)", file=sys.stderr)
|
|
297
|
+
return 2
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
if __name__ == "__main__":
|
|
301
|
+
sys.exit(main())
|