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.
Files changed (51) hide show
  1. idslab/__init__.py +5 -0
  2. idslab/adapters.py +214 -0
  3. idslab/cli.py +301 -0
  4. idslab/compare.py +375 -0
  5. idslab/config.py +1179 -0
  6. idslab/controls.py +185 -0
  7. idslab/dashboard/README.md +113 -0
  8. idslab/dashboard/__init__.py +3 -0
  9. idslab/dashboard/catalog.py +122 -0
  10. idslab/dashboard/jobs.py +333 -0
  11. idslab/dashboard/server.py +377 -0
  12. idslab/dashboard/static/app.js +188 -0
  13. idslab/dashboard/static/builder.js +884 -0
  14. idslab/dashboard/static/charts.js +151 -0
  15. idslab/dashboard/static/compare.js +231 -0
  16. idslab/dashboard/static/core.js +375 -0
  17. idslab/dashboard/static/draft.js +276 -0
  18. idslab/dashboard/static/experiments.js +413 -0
  19. idslab/dashboard/static/index.html +49 -0
  20. idslab/dashboard/static/jobs.js +122 -0
  21. idslab/dashboard/static/library.js +215 -0
  22. idslab/dashboard/static/results.js +313 -0
  23. idslab/dashboard/static/setup.js +567 -0
  24. idslab/dashboard/static/styles.css +421 -0
  25. idslab/dashboard/static/views.js +94 -0
  26. idslab/dashboard/workspace.py +778 -0
  27. idslab/data.py +268 -0
  28. idslab/examples/__init__.py +3 -0
  29. idslab/examples/r1_four_layer.yaml +66 -0
  30. idslab/examples/r1_router_gate.yaml +57 -0
  31. idslab/examples/r2_anomaly_first.yaml +45 -0
  32. idslab/external.py +196 -0
  33. idslab/features.py +96 -0
  34. idslab/fitting.py +429 -0
  35. idslab/graph.py +174 -0
  36. idslab/html_report.py +314 -0
  37. idslab/metrics.py +365 -0
  38. idslab/plugins.py +64 -0
  39. idslab/prep.py +74 -0
  40. idslab/report.py +453 -0
  41. idslab/runner.py +529 -0
  42. idslab/selection.py +308 -0
  43. idslab/sweep.py +261 -0
  44. idslab/synthetic.py +56 -0
  45. idslab/torch_models.py +169 -0
  46. idslab-0.3.0.dist-info/METADATA +254 -0
  47. idslab-0.3.0.dist-info/RECORD +51 -0
  48. idslab-0.3.0.dist-info/WHEEL +5 -0
  49. idslab-0.3.0.dist-info/entry_points.txt +2 -0
  50. idslab-0.3.0.dist-info/licenses/LICENSE +661 -0
  51. 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
+ ![The YAML view of an experiment](../../../docs/img/dashboard-editor.png)
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,3 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 hashtagRR
3
+ """Local web dashboard: every CLI command from the browser, plus run and report browsing."""
@@ -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
+ }