ids2eval 0.2.2__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 (73) hide show
  1. ids2eval/__init__.py +0 -0
  2. ids2eval/__main__.py +4 -0
  3. ids2eval/_json_utils.py +24 -0
  4. ids2eval/audit/__init__.py +239 -0
  5. ids2eval/audit/_auc.py +48 -0
  6. ids2eval/audit/_by_class.py +29 -0
  7. ids2eval/audit/_checkpoint.py +74 -0
  8. ids2eval/audit/_fit_score.py +40 -0
  9. ids2eval/audit/_materiality.py +28 -0
  10. ids2eval/audit/_status.py +15 -0
  11. ids2eval/audit/_time.py +14 -0
  12. ids2eval/audit/artifact_sensitivity.py +88 -0
  13. ids2eval/audit/class_distribution.py +57 -0
  14. ids2eval/audit/cross_capture_matrix.py +115 -0
  15. ids2eval/audit/cross_dataset_drift.py +74 -0
  16. ids2eval/audit/data_integrity.py +67 -0
  17. ids2eval/audit/dedup.py +67 -0
  18. ids2eval/audit/feature_auc_ranking.py +125 -0
  19. ids2eval/audit/feature_category_ablation.py +98 -0
  20. ids2eval/audit/flow_group_leakage.py +66 -0
  21. ids2eval/audit/homogeneity.py +160 -0
  22. ids2eval/audit/identity_columns.py +171 -0
  23. ids2eval/audit/known_issues.py +144 -0
  24. ids2eval/audit/label_conflict.py +92 -0
  25. ids2eval/audit/leakage.py +99 -0
  26. ids2eval/audit/near_duplicate_class.py +87 -0
  27. ids2eval/audit/one_rule.py +82 -0
  28. ids2eval/audit/port_protocol_shortcut.py +75 -0
  29. ids2eval/audit/repeated_seed_falsification.py +182 -0
  30. ids2eval/audit/resplit.py +92 -0
  31. ids2eval/audit/result_robustness.py +154 -0
  32. ids2eval/audit/row_order_leakage.py +86 -0
  33. ids2eval/audit/scenario_holdout.py +115 -0
  34. ids2eval/audit/schema_fingerprint.py +52 -0
  35. ids2eval/audit/seed_sensitivity.py +69 -0
  36. ids2eval/audit/synthetic_realism.py +73 -0
  37. ids2eval/audit/temporal_leakage.py +58 -0
  38. ids2eval/audit/temporal_realism.py +81 -0
  39. ids2eval/cli.py +284 -0
  40. ids2eval/config.py +362 -0
  41. ids2eval/dashboard/__init__.py +1 -0
  42. ids2eval/dashboard/__main__.py +3 -0
  43. ids2eval/dashboard/server.py +527 -0
  44. ids2eval/dashboard/static/app.js +1064 -0
  45. ids2eval/dashboard/static/charts.js +366 -0
  46. ids2eval/dashboard/static/index.html +33 -0
  47. ids2eval/dashboard/static/style.css +293 -0
  48. ids2eval/data/__init__.py +3 -0
  49. ids2eval/data/cache.py +99 -0
  50. ids2eval/data/chunked_io.py +387 -0
  51. ids2eval/data/dataset.py +208 -0
  52. ids2eval/data/features.py +95 -0
  53. ids2eval/data/label_grouping.py +38 -0
  54. ids2eval/modeling/__init__.py +3 -0
  55. ids2eval/modeling/benchmark.py +360 -0
  56. ids2eval/modeling/classifiers.py +145 -0
  57. ids2eval/modeling/preprocessing.py +73 -0
  58. ids2eval/reporting/__init__.py +2 -0
  59. ids2eval/reporting/cite.py +57 -0
  60. ids2eval/reporting/compare_datasets.py +103 -0
  61. ids2eval/reporting/drift.py +80 -0
  62. ids2eval/reporting/intervention_manifest.py +83 -0
  63. ids2eval/reporting/recommend.py +402 -0
  64. ids2eval/reporting/run_manager.py +184 -0
  65. ids2eval/reporting/scorecard.py +875 -0
  66. ids2eval/reporting/scorecard_plot.py +162 -0
  67. ids2eval/version_info.py +36 -0
  68. ids2eval-0.2.2.dist-info/METADATA +119 -0
  69. ids2eval-0.2.2.dist-info/RECORD +73 -0
  70. ids2eval-0.2.2.dist-info/WHEEL +5 -0
  71. ids2eval-0.2.2.dist-info/entry_points.txt +4 -0
  72. ids2eval-0.2.2.dist-info/licenses/LICENSE +661 -0
  73. ids2eval-0.2.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,95 @@
1
+ """Shared feature-matrix prep for audit checks that need to fit a model.
2
+
3
+ Not a full preprocessing pipeline (no scaling/sampling), just enough
4
+ to turn schema-declared feature columns into a numeric matrix, with
5
+ categorical encoding fit on train and applied to test so unseen
6
+ categories don't leak information or crash.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import numpy as np
12
+ import pandas as pd
13
+
14
+
15
+ def _coerce_numeric(series: pd.Series) -> pd.Series:
16
+ """Numeric-coerce a column, replacing +-inf as well as NaN with 0.
17
+
18
+ Real CIC-IDS2018 quirk hit in practice: rate features like
19
+ 'Flow Byts/s' are computed as bytes/duration and are literally
20
+ Infinity when duration is 0 - a valid float, not NaN, so a plain
21
+ .fillna(0) leaves it untouched and it crashes RandomForest ("Input
22
+ X contains infinity"). Zero-filling both is a simple, defensible v1
23
+ choice - a more faithful treatment (matching the IDS project's own
24
+ approach) would cap to the column's observed finite range instead
25
+ of zeroing, preserving "this was an extreme rate" as a signal
26
+ rather than discarding it; not done here for simplicity.
27
+ """
28
+ return pd.to_numeric(series, errors="coerce").replace([np.inf, -np.inf], np.nan).fillna(0)
29
+
30
+
31
+ def feature_columns(df: pd.DataFrame, cfg: dict) -> list[str]:
32
+ schema = cfg["schema"]
33
+ ignore = set(schema["drop_columns"]) | {schema["label_column"]}
34
+ if schema["attack_category_column"]:
35
+ ignore.add(schema["attack_category_column"])
36
+ return [c for c in df.columns if c not in ignore]
37
+
38
+
39
+ def encode_multi(
40
+ base_df: pd.DataFrame, other_dfs: list[pd.DataFrame], columns: list[str], unseen: str = "code"
41
+ ) -> tuple[pd.DataFrame, list[pd.DataFrame]]:
42
+ """Numeric-encode `columns` in base_df and every frame in other_dfs.
43
+
44
+ Categories are fit on base_df only and applied identically to every
45
+ other frame, so a cross-dataset comparison (e.g. cross_dataset_drift)
46
+ uses the same encoding on all sides.
47
+
48
+ unseen decides what a category absent from base_df becomes. "code"
49
+ maps it to -1, which suits distance-based checks: it never equals a
50
+ seen code, so an unseen value is never an exact match. "missing" maps
51
+ it to NaN, which tree models need instead: a tree cannot split -1 from
52
+ code 0 (no training row lies between them), so -1 would send every
53
+ unseen value down code 0's branch and give it that one value's
54
+ prediction. scikit-learn trees route a NaN they never saw in training
55
+ to the branch most training rows took.
56
+ """
57
+ if unseen not in ("code", "missing"):
58
+ raise ValueError(f"unseen must be 'code' or 'missing', not {unseen!r}")
59
+ base_out = base_df[columns].copy()
60
+ category_maps: dict[str, pd.Index] = {}
61
+ for col in columns:
62
+ # dtype == object misses pandas' newer dedicated string dtype
63
+ # (pandas >= 2.x with future.infer_string, default in pandas 3.x), so
64
+ # checking is_numeric_dtype instead and treating everything else as
65
+ # categorical is robust to both, and to category dtype too.
66
+ if pd.api.types.is_numeric_dtype(base_out[col]):
67
+ base_out[col] = _coerce_numeric(base_out[col])
68
+ else:
69
+ categories = pd.Index(base_out[col].astype(str).unique())
70
+ category_maps[col] = categories
71
+ base_out[col] = categories.get_indexer(base_out[col].astype(str))
72
+
73
+ others_out = []
74
+ for other_df in other_dfs:
75
+ other_out = other_df[columns].copy()
76
+ for col in columns:
77
+ if col in category_maps:
78
+ codes = category_maps[col].get_indexer(other_out[col].astype(str))
79
+ other_out[col] = np.where(codes < 0, np.nan, codes) if unseen == "missing" else codes
80
+ else:
81
+ other_out[col] = _coerce_numeric(other_out[col])
82
+ others_out.append(other_out)
83
+ return base_out, others_out
84
+
85
+
86
+ def encode_aligned(
87
+ train_df: pd.DataFrame, test_df: pd.DataFrame, columns: list[str], unseen: str = "code"
88
+ ) -> tuple[pd.DataFrame, pd.DataFrame]:
89
+ """Numeric-encode `columns` in both frames, categories fit on train only.
90
+
91
+ See encode_multi for `unseen`: "code" (-1) for distance-based use,
92
+ "missing" (NaN) when the encoding feeds a tree model.
93
+ """
94
+ train_out, (test_out,) = encode_multi(train_df, [test_df], columns, unseen=unseen)
95
+ return train_out, test_out
@@ -0,0 +1,38 @@
1
+ """Collapse raw attack-type labels into coarser categories.
2
+
3
+ Driven by label_grouping.attack_type_mapping: a partial mapping, not an
4
+ exhaustive relabeling. {"DoS Hulk": "DoS", "DoS GoldenEye": "DoS"} merges
5
+ just those two into "DoS" - any category not named as a mapping key
6
+ (e.g. "PortScan") passes through unchanged, so a user can group a
7
+ handful of similar attacks into one class without having to enumerate
8
+ every other class that should stay as-is.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+
15
+ import pandas as pd
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def apply_attack_type_mapping(
21
+ train_df: pd.DataFrame, test_df: pd.DataFrame, cfg: dict
22
+ ) -> tuple[pd.DataFrame, pd.DataFrame]:
23
+ mapping = cfg["label_grouping"]["attack_type_mapping"]
24
+ col = cfg["schema"]["attack_category_column"]
25
+ if not mapping:
26
+ return train_df, test_df
27
+
28
+ affected = sorted(set(mapping) & set(train_df[col].unique()))
29
+ logger.info(
30
+ "Applying attack_type_mapping to '%s': merging %s into %s",
31
+ col, affected, sorted({mapping[k] for k in affected}),
32
+ )
33
+
34
+ train_df = train_df.copy()
35
+ test_df = test_df.copy()
36
+ train_df[col] = train_df[col].replace(mapping)
37
+ test_df[col] = test_df[col].replace(mapping)
38
+ return train_df, test_df
@@ -0,0 +1,3 @@
1
+ """Preprocessing and classifier benchmarking: preprocessing, classifiers,
2
+ benchmark.
3
+ """
@@ -0,0 +1,360 @@
1
+ """Classifier benchmarking loop, driven by classifiers.* config.
2
+
3
+ Runs a "binary" stage against schema.label_column always, and a "type"
4
+ stage against schema.attack_category_column if one is configured, two
5
+ independent flat benchmarks, not a chained/routed cascade (that
6
+ architecture is explicitly out of scope for this tool). Each stage can
7
+ run under one or more scaling methods and one or more sampling
8
+ strategies (preprocessing.scaling and preprocessing.sampling.<stage>
9
+ each accept a string or a list), producing one row per
10
+ (stage, scaling, sampling, classifier) combination.
11
+
12
+ Scaling and sampling run INSIDE an imblearn Pipeline together with the
13
+ classifier, not once up front - this is a fixed leakage bug, not a
14
+ style choice: fitting a scaler/sampler once on the whole training set
15
+ and only THEN handing the result to GridSearchCV/CalibratedClassifierCV
16
+ lets those tools' internal cross-validation folds see data that was
17
+ already transformed using information from the other folds (SMOTE's
18
+ synthetic points are the sharpest version of this: a fold's synthetic
19
+ training rows can be interpolated from real neighbors that landed in a
20
+ different, supposedly-held-out fold). Wrapping [scaler, sampler,
21
+ classifier] in one Pipeline and always fitting it on raw, unscaled,
22
+ unsampled data means every internal CV fold repeats scaling/sampling
23
+ independently, exactly as it should. The plain (non-tuned, non-
24
+ calibrated) path was never affected by this, a single fit with no
25
+ internal CV has nowhere for this kind of leakage to occur, but it now
26
+ shares the same code path rather than a separate, easy-to-forget one.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import logging
32
+ import time
33
+
34
+ import numpy as np
35
+ import pandas as pd
36
+ from imblearn.pipeline import Pipeline as ImbPipeline
37
+ from sklearn.calibration import CalibratedClassifierCV
38
+ from sklearn.metrics import accuracy_score, classification_report, confusion_matrix, f1_score, roc_auc_score
39
+ from sklearn.model_selection import GridSearchCV
40
+ from sklearn.preprocessing import LabelEncoder
41
+
42
+ from ..data import features
43
+ from . import classifiers as clf_registry
44
+ from .preprocessing import SAMPLERS, SCALERS
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+ MAX_FIT_ROWS = 200_000
49
+ TUNING_CV_FOLDS = 3
50
+
51
+
52
+ def run_benchmark(train_df: pd.DataFrame, test_df: pd.DataFrame, cfg: dict) -> tuple[pd.DataFrame, dict]:
53
+ """Returns (summary_df, extras).
54
+
55
+ summary_df has one row per (stage, scaling, sampling, classifier)
56
+ with the scalar metrics, including f1_macro alongside f1_weighted:
57
+ weighted averages every class by its support, so a strong majority
58
+ class can carry a weak result on rare classes, while macro treats
59
+ every class equally. extras holds the heavier per-row detail
60
+ (confusion matrix, per-class report, feature importance, best
61
+ hyperparameters) keyed by "{stage}|{scaling}|{sampling}|{classifier}",
62
+ a "class_distributions" entry recording each stage's original and
63
+ post-sampling class counts (scaling doesn't change class counts, so
64
+ it isn't part of that key), an "unseen_test_labels" entry mapping each
65
+ stage that had test labels absent from training to {label: test row
66
+ count} (see _encode_test_labels), and a flat "per_class_metrics" list, the
67
+ same per-class precision/recall/f1/support already nested inside
68
+ each combination's per_class_report, pulled out so a summary table
69
+ can scan it without opening the per-combination JSON.
70
+ """
71
+ schema = cfg["schema"]
72
+ stages = [("binary", schema["label_column"])]
73
+ if schema["attack_category_column"]:
74
+ stages.append(("type", schema["attack_category_column"]))
75
+
76
+ cols = features.feature_columns(train_df, cfg)
77
+ x_train_all, x_test = features.encode_aligned(train_df, test_df, cols)
78
+ x_train_all, x_test = x_train_all.to_numpy(), x_test.to_numpy()
79
+
80
+ rows = []
81
+ per_class_rows = []
82
+ extras: dict = {"class_distributions": {}, "unseen_test_labels": {}}
83
+ # Progress counter for the log; one fit per (stage, sampling, scaling, classifier).
84
+ step = 0
85
+ total = sum(len(_sampling_strategies(cfg, st)) for st, _ in stages) * len(_scaling_strategies(cfg)) \
86
+ * len(_resolve_classifier_list(cfg))
87
+ for stage, label_col in stages:
88
+ label_encoder = LabelEncoder()
89
+ y_train_all = label_encoder.fit_transform(train_df[label_col])
90
+ y_test, class_names, unseen = _encode_test_labels(label_encoder, test_df[label_col])
91
+ if unseen:
92
+ extras["unseen_test_labels"][stage] = unseen
93
+ logger.warning(
94
+ "Stage '%s': %d test row(s) carry %d label(s) never seen in training (%s). They stay in the "
95
+ "evaluation as their own classes, which no classifier can predict; AUC uses the other rows only.",
96
+ stage, sum(unseen.values()), len(unseen), ", ".join(f"{k}={v}" for k, v in unseen.items()),
97
+ )
98
+
99
+ if len(x_train_all) > MAX_FIT_ROWS:
100
+ idx = np.random.RandomState(cfg["random_seed"]).choice(len(x_train_all), size=MAX_FIT_ROWS, replace=False)
101
+ x_train, y_train = x_train_all[idx], y_train_all[idx]
102
+ else:
103
+ x_train, y_train = x_train_all, y_train_all
104
+
105
+ stage_distributions = {"original": _distribution(y_train, label_encoder)}
106
+
107
+ for sampling_algo in _sampling_strategies(cfg, stage):
108
+ stage_distributions[sampling_algo] = _resampled_distribution(
109
+ x_train, y_train, sampling_algo, label_encoder, cfg["random_seed"]
110
+ )
111
+
112
+ for scaling_algo in _scaling_strategies(cfg):
113
+ for name in _resolve_classifier_list(cfg):
114
+ step += 1
115
+ logger.info("Benchmark %d/%d: %s (stage=%s, scaling=%s, sampling=%s) ...",
116
+ step, total, name, stage, scaling_algo, sampling_algo)
117
+ try:
118
+ model, fit_time, best_params = _fit_classifier(
119
+ name, x_train, y_train, cfg, sampling_algo, scaling_algo
120
+ )
121
+ except Exception as e:
122
+ logger.warning(
123
+ "Classifier '%s' failed on stage '%s' (scaling=%s, sampling=%s): %s",
124
+ name, stage, scaling_algo, sampling_algo, e,
125
+ )
126
+ continue
127
+
128
+ metrics, infer_time, detail = _evaluate(
129
+ model, x_test, y_test, class_names, len(label_encoder.classes_), cols
130
+ )
131
+ logger.info("Benchmark %d/%d done: %s macro-F1=%.4f (fit %.1fs, predict %.1fs)",
132
+ step, total, name, metrics.get("f1_macro", float("nan")), fit_time, infer_time)
133
+ rows.append({
134
+ "stage": stage, "scaling": scaling_algo, "sampling": sampling_algo, "classifier": name,
135
+ "train_time_s": round(fit_time, 4), "infer_time_s": round(infer_time, 4),
136
+ **metrics,
137
+ })
138
+ extras[f"{stage}|{scaling_algo}|{sampling_algo}|{name}"] = {"best_params": best_params, **detail}
139
+ per_class_rows.extend(
140
+ _per_class_rows(stage, scaling_algo, sampling_algo, name, detail["per_class_report"])
141
+ )
142
+
143
+ extras["class_distributions"][stage] = stage_distributions
144
+
145
+ extras["per_class_metrics"] = per_class_rows
146
+ return pd.DataFrame(rows), extras
147
+
148
+
149
+ def _per_class_rows(
150
+ stage: str, scaling_algo: str, sampling_algo: str, classifier: str, per_class_report: dict
151
+ ) -> list[dict]:
152
+ """Flattens one (stage, scaling, sampling, classifier) combination's
153
+ per_class_report (already computed by _evaluate, just nested inside
154
+ benchmark_details.json) into rows a summary CSV can scan directly,
155
+ without opening the JSON. Skips classification_report's aggregate
156
+ keys ("accuracy", "macro avg", "weighted avg"): those are already
157
+ the f1_macro/f1_weighted columns in benchmark_results.csv.
158
+ """
159
+ return [
160
+ {
161
+ "stage": stage, "scaling": scaling_algo, "sampling": sampling_algo, "classifier": classifier,
162
+ "class": class_name, "precision": stats["precision"], "recall": stats["recall"],
163
+ "f1": stats["f1-score"], "support": stats["support"],
164
+ }
165
+ for class_name, stats in per_class_report.items()
166
+ if class_name not in ("accuracy", "macro avg", "weighted avg")
167
+ ]
168
+
169
+
170
+ def _sampling_strategies(cfg: dict, stage: str) -> list[str]:
171
+ algo = cfg["preprocessing"]["sampling"][stage]
172
+ return algo if isinstance(algo, list) else [algo]
173
+
174
+
175
+ def _scaling_strategies(cfg: dict) -> list[str]:
176
+ scaling = cfg["preprocessing"]["scaling"]
177
+ return scaling if isinstance(scaling, list) else [scaling]
178
+
179
+
180
+ def _distribution(y_encoded: np.ndarray, label_encoder: LabelEncoder) -> dict:
181
+ labels = label_encoder.inverse_transform(y_encoded)
182
+ counts = pd.Series(labels).value_counts()
183
+ return {str(k): int(v) for k, v in counts.items()}
184
+
185
+
186
+ def _resampled_distribution(x_train, y_train, sampling_algo: str, label_encoder: LabelEncoder, seed: int) -> dict:
187
+ """A standalone probe resample purely for reporting, decoupled from
188
+ what each classifier's own pipeline does internally, but deterministic
189
+ (same seed) so it matches what they'll actually see.
190
+ """
191
+ if sampling_algo == "none":
192
+ return _distribution(y_train, label_encoder)
193
+ try:
194
+ _, y_res = SAMPLERS[sampling_algo](seed).fit_resample(x_train, y_train)
195
+ except ValueError as e:
196
+ logger.warning("Could not compute resampled distribution for '%s': %s", sampling_algo, e)
197
+ return {}
198
+ return _distribution(y_res, label_encoder)
199
+
200
+
201
+ def _resolve_classifier_list(cfg: dict) -> list[str]:
202
+ clf_list = cfg["classifiers"]["list"]
203
+ return list(clf_registry.REGISTRY) if clf_list == "all" else clf_list
204
+
205
+
206
+ def _build_pipeline(name: str, cfg: dict, overrides: dict, sampling_algo: str, scaling_algo: str) -> ImbPipeline:
207
+ seed = cfg["random_seed"]
208
+ scaler = SCALERS[scaling_algo]() if scaling_algo != "none" else "passthrough"
209
+ sampler = SAMPLERS[sampling_algo](seed) if sampling_algo != "none" else "passthrough"
210
+ clf = clf_registry.build_estimator(name, overrides, seed=seed)
211
+ return ImbPipeline([("scaler", scaler), ("sampler", sampler), ("clf", clf)])
212
+
213
+
214
+ def _prefix_grid(grid: dict) -> dict:
215
+ return {f"clf__{k}": v for k, v in grid.items()}
216
+
217
+
218
+ def _fit_classifier(name: str, x_train, y_train, cfg: dict, sampling_algo: str, scaling_algo: str):
219
+ classifiers_cfg = cfg["classifiers"]
220
+ spec = clf_registry.REGISTRY[name]
221
+ overrides = classifiers_cfg["hyperparameters"].get(name, {})
222
+ pipe = _build_pipeline(name, cfg, overrides, sampling_algo, scaling_algo)
223
+
224
+ best_params = None
225
+ start = time.perf_counter()
226
+ if classifiers_cfg["tuning"] and not spec.no_tune:
227
+ grid = _prefix_grid(classifiers_cfg["search_space"].get(name, spec.default_grid))
228
+ if grid:
229
+ search = GridSearchCV(pipe, grid, cv=TUNING_CV_FOLDS, n_jobs=-1)
230
+ search.fit(x_train, y_train)
231
+ model = search.best_estimator_
232
+ best_params = {k.removeprefix("clf__"): v for k, v in search.best_params_.items()}
233
+ else:
234
+ model = pipe
235
+ model.fit(x_train, y_train)
236
+ else:
237
+ model = pipe
238
+ model.fit(x_train, y_train)
239
+
240
+ calibration = classifiers_cfg["calibration"]
241
+ if calibration != "none" and name != "SVM": # SVM is already CalibratedClassifierCV-wrapped
242
+ method = "sigmoid" if calibration == "platt" else "isotonic"
243
+ model = CalibratedClassifierCV(model, method=method, cv=5)
244
+ model.fit(x_train, y_train)
245
+ fit_time = time.perf_counter() - start
246
+
247
+ return model, fit_time, best_params
248
+
249
+
250
+ def _encode_test_labels(label_encoder: LabelEncoder, test_labels: pd.Series) -> tuple[np.ndarray, list[str], dict]:
251
+ """Encodes test labels against the classes seen in training. A test label
252
+ the training split never had (NSL-KDD's KDDTest+ has several attack types
253
+ KDDTrain+ lacks) gets a code after the training classes instead of
254
+ raising, so those rows are scored as the misses they are rather than
255
+ crashing the stage or being dropped, which would overstate every metric.
256
+
257
+ Returns (codes, class names in code order, {unseen label: test row count}).
258
+ """
259
+ known = {c: i for i, c in enumerate(label_encoder.classes_)}
260
+ unseen_labels = sorted(set(test_labels.unique()) - set(known), key=str)
261
+ codes = {**known, **{c: len(known) + j for j, c in enumerate(unseen_labels)}}
262
+ y_test = test_labels.map(codes).to_numpy(dtype=int)
263
+ counts = test_labels.value_counts()
264
+ unseen = {str(c): int(counts[c]) for c in unseen_labels}
265
+ class_names = [str(c) for c in label_encoder.classes_] + [str(c) for c in unseen_labels]
266
+ return y_test, class_names, unseen
267
+
268
+
269
+ def _evaluate(
270
+ model, x_test, y_test, class_names: list[str], n_train_classes: int, feature_names: list[str]
271
+ ) -> tuple[dict, float, dict]:
272
+ """Codes 0..n_train_classes-1 are the training classes; any code past that
273
+ is a test-only class (see _encode_test_labels). Every per-class output is
274
+ built over the full code range, so the confusion matrix and report keep
275
+ one row per class even when a class is absent from y_test and y_pred.
276
+ """
277
+ start = time.perf_counter()
278
+ y_pred = model.predict(x_test)
279
+ infer_time = time.perf_counter() - start
280
+
281
+ metrics = {
282
+ "accuracy": float(accuracy_score(y_test, y_pred)),
283
+ "f1_weighted": float(f1_score(y_test, y_pred, average="weighted", zero_division=0)),
284
+ # Weighted averages every class by its support, so a majority class can carry
285
+ # a poor result on rare classes. Macro treats every class equally, surfacing
286
+ # exactly that collapse on the imbalanced label distributions typical here.
287
+ "f1_macro": float(f1_score(y_test, y_pred, average="macro", zero_division=0)),
288
+ }
289
+ try:
290
+ # AUC needs a probability column per true class, and the model has none
291
+ # for a class it never trained on, so it covers the known-class rows.
292
+ known = y_test < n_train_classes
293
+ proba = _full_proba(model, x_test[known], n_train_classes)
294
+ y_known = y_test[known]
295
+ if n_train_classes == 2:
296
+ metrics["auc"] = float(roc_auc_score(y_known, proba[:, 1]))
297
+ else:
298
+ metrics["auc"] = _ovr_weighted_auc(y_known, proba)
299
+ except (AttributeError, ValueError) as e:
300
+ logger.warning("Could not compute AUC: %s", e)
301
+ metrics["auc"] = None
302
+
303
+ labels = np.arange(len(class_names))
304
+ detail = {
305
+ "confusion_matrix": confusion_matrix(y_test, y_pred, labels=labels).tolist(),
306
+ "confusion_matrix_labels": class_names,
307
+ "per_class_report": classification_report(
308
+ y_test, y_pred, labels=labels, target_names=class_names, output_dict=True, zero_division=0
309
+ ),
310
+ "feature_importance": _feature_importance(model, feature_names),
311
+ }
312
+ return metrics, infer_time, detail
313
+
314
+
315
+ def _ovr_weighted_auc(y: np.ndarray, proba: np.ndarray) -> float:
316
+ """One-vs-rest AUC per class present in y, on that class's own probability
317
+ column, averaged by support: what roc_auc_score(multi_class="ovr",
318
+ average="weighted") returns when every class is present, but still
319
+ defined when a training class has no test rows (sklearn raises then).
320
+ """
321
+ present, support = np.unique(y, return_counts=True)
322
+ if len(present) < 2:
323
+ raise ValueError("only one class present in the test labels")
324
+ aucs = [roc_auc_score(y == c, proba[:, c]) for c in present]
325
+ return float(np.average(aucs, weights=support))
326
+
327
+
328
+ def _full_proba(model, x, n_classes: int) -> np.ndarray:
329
+ """predict_proba widened to one column per training class: a class the
330
+ fit sample happened to miss (MAX_FIT_ROWS subsampling, a resampler)
331
+ has no column in model.classes_, which would misalign the others.
332
+ """
333
+ proba = model.predict_proba(x)
334
+ full = np.zeros((proba.shape[0], n_classes))
335
+ full[:, np.asarray(model.classes_, dtype=int)] = proba
336
+ return full
337
+
338
+
339
+ def _feature_importance(model, feature_names: list[str]) -> dict | None:
340
+ """Best-effort: pokes into whatever wrapping (Pipeline, CalibratedClassifierCV)
341
+ sits around the actual classifier. Returns None rather than raising if the
342
+ wrapping doesn't match what's expected, this is a nice-to-have, not
343
+ something that should ever crash a benchmark run.
344
+ """
345
+ try:
346
+ clf = model
347
+ if isinstance(clf, CalibratedClassifierCV):
348
+ clf = clf.calibrated_classifiers_[0].estimator
349
+ if hasattr(clf, "named_steps"):
350
+ clf = clf.named_steps.get("clf", clf)
351
+ if hasattr(clf, "feature_importances_"):
352
+ values = clf.feature_importances_
353
+ elif hasattr(clf, "coef_"):
354
+ coef = clf.coef_
355
+ values = coef[0] if coef.ndim > 1 else coef
356
+ else:
357
+ return None
358
+ return {name: float(v) for name, v in zip(feature_names, values, strict=True)}
359
+ except Exception:
360
+ return None
@@ -0,0 +1,145 @@
1
+ """Registry of the 14 supported classifiers, generalized from the IDS
2
+ project's models/classifiers/definitions.py.
3
+
4
+ Grids here are deliberately smaller than the source project's deep,
5
+ dataset-tuned search spaces (e.g. RandomForest's 10-parameter
6
+ RandomizedSearchCV grid), this is a general-purpose default for
7
+ arbitrary datasets, not a re-tuned space for one specific dataset's
8
+ scale. Override via classifiers.search_space in the config for
9
+ anything heavier.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass, field
16
+ from typing import Any
17
+
18
+ import xgboost as xgb
19
+ from sklearn.calibration import CalibratedClassifierCV
20
+ from sklearn.discriminant_analysis import LinearDiscriminantAnalysis
21
+ from sklearn.ensemble import (
22
+ AdaBoostClassifier,
23
+ ExtraTreesClassifier,
24
+ HistGradientBoostingClassifier,
25
+ RandomForestClassifier,
26
+ StackingClassifier,
27
+ VotingClassifier,
28
+ )
29
+ from sklearn.linear_model import LogisticRegression
30
+ from sklearn.naive_bayes import GaussianNB
31
+ from sklearn.neighbors import KNeighborsClassifier
32
+ from sklearn.neural_network import MLPClassifier
33
+ from sklearn.svm import LinearSVC
34
+ from sklearn.tree import DecisionTreeClassifier
35
+
36
+
37
+ @dataclass
38
+ class ClassifierSpec:
39
+ factory: Callable[[dict], Any] # params -> unfitted estimator
40
+ default_params: dict = field(default_factory=dict)
41
+ default_grid: dict = field(default_factory=dict)
42
+ no_tune: bool = False # tuning has no effect on this classifier
43
+
44
+
45
+ def _stacking_voting_base(random_state: int = 50):
46
+ return [
47
+ ("rf", RandomForestClassifier(n_estimators=100, random_state=random_state, n_jobs=-1)),
48
+ ("xgb", xgb.XGBClassifier(n_estimators=100, random_state=random_state, n_jobs=-1)),
49
+ ("dt", DecisionTreeClassifier(random_state=random_state)),
50
+ ]
51
+
52
+
53
+ REGISTRY: dict[str, ClassifierSpec] = {
54
+ "RandomForest": ClassifierSpec(
55
+ factory=lambda p: RandomForestClassifier(**p),
56
+ default_params={"n_estimators": 100, "random_state": 50, "n_jobs": -1},
57
+ default_grid={"n_estimators": [100, 200], "max_depth": [10, 20, 30, None],
58
+ "min_samples_leaf": [1, 2, 4]},
59
+ ),
60
+ "XGBoost": ClassifierSpec(
61
+ factory=lambda p: xgb.XGBClassifier(**p),
62
+ default_params={"n_estimators": 100, "random_state": 50, "n_jobs": -1,
63
+ "learning_rate": 0.1, "max_depth": 6},
64
+ default_grid={"learning_rate": [0.01, 0.05, 0.1], "max_depth": [6, 10, 15]},
65
+ ),
66
+ "DecisionTree": ClassifierSpec(
67
+ factory=lambda p: DecisionTreeClassifier(**p),
68
+ default_params={"random_state": 50},
69
+ default_grid={"max_depth": [5, 10, 20], "min_samples_split": [2, 5, 10]},
70
+ ),
71
+ "NaiveBayes": ClassifierSpec(
72
+ factory=lambda p: GaussianNB(**p), no_tune=True,
73
+ ),
74
+ "LogisticRegression": ClassifierSpec(
75
+ factory=lambda p: LogisticRegression(**p),
76
+ default_params={"max_iter": 2000, "random_state": 50},
77
+ default_grid={"C": [0.01, 0.1, 1.0, 10.0]},
78
+ ),
79
+ "KNN": ClassifierSpec(
80
+ factory=lambda p: KNeighborsClassifier(**p),
81
+ default_params={"n_neighbors": 5, "n_jobs": -1},
82
+ default_grid={"n_neighbors": [3, 5, 7, 11], "weights": ["uniform", "distance"]},
83
+ ),
84
+ "SVM": ClassifierSpec(
85
+ # LinearSVC scales to large datasets, unlike a full-kernel SVC
86
+ # (O(n^2)-O(n^3)); it has no predict_proba, so it's wrapped in
87
+ # CalibratedClassifierCV to get one.
88
+ factory=lambda p: CalibratedClassifierCV(LinearSVC(**p), cv=5),
89
+ default_params={"max_iter": 2000, "random_state": 50},
90
+ default_grid={"C": [0.1, 1.0, 10.0]},
91
+ ),
92
+ "LDA": ClassifierSpec(
93
+ factory=lambda p: LinearDiscriminantAnalysis(**p),
94
+ default_grid={"solver": ["svd", "lsqr"]},
95
+ ),
96
+ "Stacking": ClassifierSpec(
97
+ factory=lambda p: StackingClassifier(
98
+ estimators=_stacking_voting_base(), final_estimator=LogisticRegression(max_iter=2000), n_jobs=-1,
99
+ ),
100
+ no_tune=True,
101
+ ),
102
+ "Voting": ClassifierSpec(
103
+ factory=lambda p: VotingClassifier(estimators=_stacking_voting_base(), voting="soft", n_jobs=-1),
104
+ no_tune=True,
105
+ ),
106
+ "ExtraTrees": ClassifierSpec(
107
+ factory=lambda p: ExtraTreesClassifier(**p),
108
+ default_params={"n_estimators": 100, "random_state": 50, "n_jobs": -1},
109
+ default_grid={"n_estimators": [100, 200], "max_depth": [10, 20, 30]},
110
+ ),
111
+ "HistGradientBoosting": ClassifierSpec(
112
+ factory=lambda p: HistGradientBoostingClassifier(**p),
113
+ default_params={"max_iter": 100, "random_state": 50},
114
+ default_grid={"max_iter": [50, 100, 200], "learning_rate": [0.01, 0.1, 0.2]},
115
+ ),
116
+ "AdaBoost": ClassifierSpec(
117
+ factory=lambda p: AdaBoostClassifier(**p),
118
+ default_params={"estimator": DecisionTreeClassifier(max_depth=1, random_state=50),
119
+ "n_estimators": 50, "random_state": 50},
120
+ default_grid={"n_estimators": [50, 100, 200], "learning_rate": [0.5, 1.0, 1.5]},
121
+ ),
122
+ "MLP": ClassifierSpec(
123
+ factory=lambda p: MLPClassifier(**p),
124
+ default_params={"hidden_layer_sizes": (64,), "max_iter": 300,
125
+ "early_stopping": True, "random_state": 50},
126
+ default_grid={"hidden_layer_sizes": [(64,), (128,), (64, 32)], "alpha": [0.0001, 0.001]},
127
+ ),
128
+ }
129
+
130
+
131
+ def build_estimator(name: str, overrides: dict | None = None, seed: int | None = None):
132
+ """Build an unfitted estimator. seed, when given, overrides random_state
133
+ for any classifier that has one in its default_params - but never an
134
+ explicit classifiers.hyperparameters override, which wins on purpose.
135
+
136
+ Known simplification: doesn't reach into nested sub-estimators
137
+ (AdaBoost's fixed depth-1 stump, Stacking/Voting's base learners) -
138
+ those keep their own fixed defaults regardless of seed.
139
+ """
140
+ spec = REGISTRY[name]
141
+ overrides = overrides or {}
142
+ params = {**spec.default_params, **overrides}
143
+ if seed is not None and "random_state" in spec.default_params and "random_state" not in overrides:
144
+ params["random_state"] = seed
145
+ return spec.factory(params)