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.
- ids2eval/__init__.py +0 -0
- ids2eval/__main__.py +4 -0
- ids2eval/_json_utils.py +24 -0
- ids2eval/audit/__init__.py +239 -0
- ids2eval/audit/_auc.py +48 -0
- ids2eval/audit/_by_class.py +29 -0
- ids2eval/audit/_checkpoint.py +74 -0
- ids2eval/audit/_fit_score.py +40 -0
- ids2eval/audit/_materiality.py +28 -0
- ids2eval/audit/_status.py +15 -0
- ids2eval/audit/_time.py +14 -0
- ids2eval/audit/artifact_sensitivity.py +88 -0
- ids2eval/audit/class_distribution.py +57 -0
- ids2eval/audit/cross_capture_matrix.py +115 -0
- ids2eval/audit/cross_dataset_drift.py +74 -0
- ids2eval/audit/data_integrity.py +67 -0
- ids2eval/audit/dedup.py +67 -0
- ids2eval/audit/feature_auc_ranking.py +125 -0
- ids2eval/audit/feature_category_ablation.py +98 -0
- ids2eval/audit/flow_group_leakage.py +66 -0
- ids2eval/audit/homogeneity.py +160 -0
- ids2eval/audit/identity_columns.py +171 -0
- ids2eval/audit/known_issues.py +144 -0
- ids2eval/audit/label_conflict.py +92 -0
- ids2eval/audit/leakage.py +99 -0
- ids2eval/audit/near_duplicate_class.py +87 -0
- ids2eval/audit/one_rule.py +82 -0
- ids2eval/audit/port_protocol_shortcut.py +75 -0
- ids2eval/audit/repeated_seed_falsification.py +182 -0
- ids2eval/audit/resplit.py +92 -0
- ids2eval/audit/result_robustness.py +154 -0
- ids2eval/audit/row_order_leakage.py +86 -0
- ids2eval/audit/scenario_holdout.py +115 -0
- ids2eval/audit/schema_fingerprint.py +52 -0
- ids2eval/audit/seed_sensitivity.py +69 -0
- ids2eval/audit/synthetic_realism.py +73 -0
- ids2eval/audit/temporal_leakage.py +58 -0
- ids2eval/audit/temporal_realism.py +81 -0
- ids2eval/cli.py +284 -0
- ids2eval/config.py +362 -0
- ids2eval/dashboard/__init__.py +1 -0
- ids2eval/dashboard/__main__.py +3 -0
- ids2eval/dashboard/server.py +527 -0
- ids2eval/dashboard/static/app.js +1064 -0
- ids2eval/dashboard/static/charts.js +366 -0
- ids2eval/dashboard/static/index.html +33 -0
- ids2eval/dashboard/static/style.css +293 -0
- ids2eval/data/__init__.py +3 -0
- ids2eval/data/cache.py +99 -0
- ids2eval/data/chunked_io.py +387 -0
- ids2eval/data/dataset.py +208 -0
- ids2eval/data/features.py +95 -0
- ids2eval/data/label_grouping.py +38 -0
- ids2eval/modeling/__init__.py +3 -0
- ids2eval/modeling/benchmark.py +360 -0
- ids2eval/modeling/classifiers.py +145 -0
- ids2eval/modeling/preprocessing.py +73 -0
- ids2eval/reporting/__init__.py +2 -0
- ids2eval/reporting/cite.py +57 -0
- ids2eval/reporting/compare_datasets.py +103 -0
- ids2eval/reporting/drift.py +80 -0
- ids2eval/reporting/intervention_manifest.py +83 -0
- ids2eval/reporting/recommend.py +402 -0
- ids2eval/reporting/run_manager.py +184 -0
- ids2eval/reporting/scorecard.py +875 -0
- ids2eval/reporting/scorecard_plot.py +162 -0
- ids2eval/version_info.py +36 -0
- ids2eval-0.2.2.dist-info/METADATA +119 -0
- ids2eval-0.2.2.dist-info/RECORD +73 -0
- ids2eval-0.2.2.dist-info/WHEEL +5 -0
- ids2eval-0.2.2.dist-info/entry_points.txt +4 -0
- ids2eval-0.2.2.dist-info/licenses/LICENSE +661 -0
- 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,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)
|