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,57 @@
|
|
|
1
|
+
"""Class-distribution / imbalance report.
|
|
2
|
+
|
|
3
|
+
Universal IDS-benchmark pattern (KDD99 U2R/R2L <1-2%, BoT-IoT 0.01%
|
|
4
|
+
benign, TON_IoT 3.56% benign). Reported unconditionally, not only when
|
|
5
|
+
it crosses some threshold, since even a "known" imbalance shifts which
|
|
6
|
+
metrics (accuracy vs. F1/AUC) are meaningful for a given dataset.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import pandas as pd
|
|
12
|
+
|
|
13
|
+
# A minority class below this share of a split is called out explicitly,
|
|
14
|
+
# rather than just left to be read off the full per-class table.
|
|
15
|
+
RARE_CLASS_THRESHOLD = 0.01
|
|
16
|
+
|
|
17
|
+
# 100:1 sits at the boundary of what general ML data-quality literature calls
|
|
18
|
+
# "moderate" imbalance (roughly 1:4-1:100), with "extreme" starting around
|
|
19
|
+
# 1:1000-1:5000 (Zhou, Tu, Sha, Ding & Chen 2024, arXiv:2406.19614, Table II) -
|
|
20
|
+
# not an arbitrary cutoff, though NIDS-specific imbalance can be far worse than
|
|
21
|
+
# either range in practice (KDD99's U2R/R2L classes, BoT-IoT's 0.01% benign).
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _distribution(series: pd.Series) -> dict:
|
|
25
|
+
counts = series.value_counts()
|
|
26
|
+
shares = (counts / counts.sum()).to_dict()
|
|
27
|
+
return {"counts": counts.to_dict(), "shares": shares}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def check(train_df: pd.DataFrame, test_df: pd.DataFrame, cfg: dict) -> dict:
|
|
31
|
+
label_col = cfg["schema"]["label_column"]
|
|
32
|
+
train_dist = _distribution(train_df[label_col])
|
|
33
|
+
test_dist = _distribution(test_df[label_col])
|
|
34
|
+
|
|
35
|
+
train_counts = train_dist["counts"]
|
|
36
|
+
imbalance_ratio = max(train_counts.values()) / max(min(train_counts.values()), 1)
|
|
37
|
+
rare_classes = [c for c, s in train_dist["shares"].items() if s < RARE_CLASS_THRESHOLD]
|
|
38
|
+
|
|
39
|
+
status = "warning" if (imbalance_ratio > 100 or rare_classes) else "ok"
|
|
40
|
+
summary = (
|
|
41
|
+
f"{len(train_counts)} classes, train imbalance ratio (majority:minority) "
|
|
42
|
+
f"= {imbalance_ratio:.0f}:1"
|
|
43
|
+
)
|
|
44
|
+
if rare_classes:
|
|
45
|
+
summary += f"; classes below {RARE_CLASS_THRESHOLD:.0%} of train: {rare_classes}"
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
"check": "class_distribution_report",
|
|
49
|
+
"status": status,
|
|
50
|
+
"summary": summary,
|
|
51
|
+
"details": {
|
|
52
|
+
"train": train_dist,
|
|
53
|
+
"test": test_dist,
|
|
54
|
+
"imbalance_ratio": imbalance_ratio,
|
|
55
|
+
"rare_classes": rare_classes,
|
|
56
|
+
},
|
|
57
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Cross-capture generalization matrix (v2, opt-in).
|
|
2
|
+
|
|
3
|
+
scenario_holdout_falsification reports one number: accuracy when the
|
|
4
|
+
single smallest scenario is held out entirely. That can't distinguish
|
|
5
|
+
"generalization to any unseen scenario is genuinely hard here" from
|
|
6
|
+
"this one specific pair of scenarios is asymmetric": training on
|
|
7
|
+
scenario A and testing on B might collapse while the reverse does not,
|
|
8
|
+
invisible in a single holdout figure. This trains on each declared
|
|
9
|
+
scenario (schema.scenario_column) and tests on every other one,
|
|
10
|
+
producing the full N x N accuracy matrix instead.
|
|
11
|
+
|
|
12
|
+
Real, not negligible, compute: N*(N-1) RandomForest fits rather than
|
|
13
|
+
scenario_holdout_falsification's one, so this is opt-in and capped at
|
|
14
|
+
MAX_SCENARIOS scenarios, skipped rather than run past that cap. Same
|
|
15
|
+
raw-data reload as resplit_falsification/scenario_holdout_falsification,
|
|
16
|
+
so its result cannot depend on preprocessing.dedup either.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from ..data import dataset
|
|
22
|
+
from . import _materiality
|
|
23
|
+
from ._fit_score import fit_and_score
|
|
24
|
+
from ._status import NOT_APPLICABLE
|
|
25
|
+
|
|
26
|
+
MAX_SCENARIOS = 6
|
|
27
|
+
MIN_SCENARIO_ROWS = 20
|
|
28
|
+
# Overridable via audit.materiality_thresholds.cross_capture_matrix_check ({"warning": .., "flag": ..}).
|
|
29
|
+
SPREAD_FLAG_THRESHOLD = 0.20
|
|
30
|
+
SPREAD_WARNING_THRESHOLD = 0.10
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def check(cfg: dict) -> dict:
|
|
34
|
+
dataset_cfg = cfg["dataset"]
|
|
35
|
+
scenario_col = cfg["schema"]["scenario_column"]
|
|
36
|
+
if not scenario_col:
|
|
37
|
+
return {
|
|
38
|
+
"check": "cross_capture_matrix_check", "status": NOT_APPLICABLE,
|
|
39
|
+
"summary": "no schema.scenario_column configured", "details": {},
|
|
40
|
+
}
|
|
41
|
+
if not dataset_cfg["raw_files"]:
|
|
42
|
+
return {
|
|
43
|
+
"check": "cross_capture_matrix_check", "status": NOT_APPLICABLE,
|
|
44
|
+
"summary": "requires dataset.raw_files, not applicable to a pre-split train_file/test_file pair",
|
|
45
|
+
"details": {},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
seed = cfg["random_seed"]
|
|
49
|
+
label_col = cfg["schema"]["label_column"]
|
|
50
|
+
combined = dataset.load_raw_combined(dataset_cfg, seed=seed)
|
|
51
|
+
if scenario_col not in combined.columns:
|
|
52
|
+
return {
|
|
53
|
+
"check": "cross_capture_matrix_check", "status": NOT_APPLICABLE,
|
|
54
|
+
"summary": f"'{scenario_col}' not found in the loaded data", "details": {},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
counts = combined[scenario_col].value_counts()
|
|
58
|
+
eligible = counts[counts >= MIN_SCENARIO_ROWS].index.tolist()
|
|
59
|
+
if len(eligible) < 2:
|
|
60
|
+
return {
|
|
61
|
+
"check": "cross_capture_matrix_check", "status": NOT_APPLICABLE,
|
|
62
|
+
"summary": "fewer than two scenarios with enough rows to build a matrix", "details": {},
|
|
63
|
+
}
|
|
64
|
+
if len(eligible) > MAX_SCENARIOS:
|
|
65
|
+
return {
|
|
66
|
+
"check": "cross_capture_matrix_check", "status": NOT_APPLICABLE,
|
|
67
|
+
"summary": f"skipped, {len(eligible)} eligible scenarios exceeds the {MAX_SCENARIOS}-scenario cap",
|
|
68
|
+
"details": {},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
matrix = {}
|
|
72
|
+
for train_scenario in eligible:
|
|
73
|
+
train_part = combined[combined[scenario_col] == train_scenario]
|
|
74
|
+
if train_part[label_col].nunique() < 2:
|
|
75
|
+
continue
|
|
76
|
+
for test_scenario in eligible:
|
|
77
|
+
if test_scenario == train_scenario:
|
|
78
|
+
continue
|
|
79
|
+
test_part = combined[combined[scenario_col] == test_scenario]
|
|
80
|
+
matrix[f"{train_scenario}->{test_scenario}"] = fit_and_score(
|
|
81
|
+
train_part, test_part, label_col, cfg, seed=seed
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if not matrix:
|
|
85
|
+
return {
|
|
86
|
+
"check": "cross_capture_matrix_check", "status": NOT_APPLICABLE,
|
|
87
|
+
"summary": "no scenario pair had enough distinct labels to test", "details": {},
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
worst_pair, worst_acc = min(matrix.items(), key=lambda kv: kv[1])
|
|
91
|
+
best_pair, best_acc = max(matrix.items(), key=lambda kv: kv[1])
|
|
92
|
+
spread = best_acc - worst_acc
|
|
93
|
+
|
|
94
|
+
tiers = _materiality.threshold(
|
|
95
|
+
cfg, "cross_capture_matrix_check",
|
|
96
|
+
{"warning": SPREAD_WARNING_THRESHOLD, "flag": SPREAD_FLAG_THRESHOLD},
|
|
97
|
+
)
|
|
98
|
+
if spread > tiers["flag"]:
|
|
99
|
+
status = "flag"
|
|
100
|
+
elif spread > tiers["warning"]:
|
|
101
|
+
status = "warning"
|
|
102
|
+
else:
|
|
103
|
+
status = "ok"
|
|
104
|
+
|
|
105
|
+
summary = (
|
|
106
|
+
f"cross-scenario accuracy ranges from {worst_acc:.4f} ({worst_pair}) to "
|
|
107
|
+
f"{best_acc:.4f} ({best_pair}), a spread of {spread:.4f} across {len(matrix)} scenario pairs"
|
|
108
|
+
)
|
|
109
|
+
if status != "ok":
|
|
110
|
+
summary += "; generalization across scenarios is uneven rather than uniformly hard or easy"
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
"check": "cross_capture_matrix_check", "status": status, "summary": summary,
|
|
114
|
+
"details": {"matrix": matrix, "worst_pair": worst_pair, "best_pair": best_pair, "spread": spread},
|
|
115
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Cross-dataset drift check (v2, opt-in).
|
|
2
|
+
|
|
3
|
+
Trains on this dataset and evaluates on a reference dataset
|
|
4
|
+
(audit.reference_dataset) in a unified feature space, comparing accuracy
|
|
5
|
+
against the same model's in-dataset test accuracy. A large drop is a
|
|
6
|
+
specific, checkable signature that the model learned dataset-specific
|
|
7
|
+
artifacts rather than generalizable attack behavior, the pattern
|
|
8
|
+
documented across BoT-IoT/TON_IoT/UNSW-NB15 cross-dataset evaluations.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import pandas as pd
|
|
14
|
+
from sklearn.ensemble import RandomForestClassifier
|
|
15
|
+
from sklearn.metrics import accuracy_score
|
|
16
|
+
|
|
17
|
+
from ..data import chunked_io, features
|
|
18
|
+
from . import _materiality
|
|
19
|
+
|
|
20
|
+
MAX_FIT_ROWS = 200_000
|
|
21
|
+
# An accuracy drop beyond this crossing datasets is read as evidence of
|
|
22
|
+
# dataset-specific overfitting rather than ordinary generalization noise.
|
|
23
|
+
# Overridable via audit.materiality_thresholds.cross_dataset_drift_check.
|
|
24
|
+
MATERIAL_DROP_THRESHOLD = 0.10
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def check(train_df: pd.DataFrame, test_df: pd.DataFrame, cfg: dict) -> dict:
|
|
28
|
+
dataset_cfg = cfg["dataset"]
|
|
29
|
+
ref_df = chunked_io.load_file(
|
|
30
|
+
cfg["audit"]["reference_dataset"], dataset_cfg["chunk_size"], dataset_cfg["max_rows"]
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
label_col = cfg["schema"]["label_column"]
|
|
34
|
+
if label_col not in ref_df.columns:
|
|
35
|
+
return {
|
|
36
|
+
"check": "cross_dataset_drift_check", "status": "warning",
|
|
37
|
+
"summary": f"reference_dataset has no '{label_col}' column, cannot compute cross-dataset accuracy",
|
|
38
|
+
"details": {},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
cols = [c for c in features.feature_columns(train_df, cfg) if c in ref_df.columns]
|
|
42
|
+
if not cols:
|
|
43
|
+
return {
|
|
44
|
+
"check": "cross_dataset_drift_check", "status": "warning",
|
|
45
|
+
"summary": "no feature columns in common with reference_dataset", "details": {},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
seed = cfg["random_seed"]
|
|
49
|
+
train_fit = train_df.sample(n=min(len(train_df), MAX_FIT_ROWS), random_state=seed)
|
|
50
|
+
x_train, (x_test, x_ref) = features.encode_multi(train_fit, [test_df, ref_df], cols, unseen="missing")
|
|
51
|
+
|
|
52
|
+
clf = RandomForestClassifier(n_estimators=100, random_state=seed, n_jobs=-1)
|
|
53
|
+
clf.fit(x_train, train_fit[label_col])
|
|
54
|
+
|
|
55
|
+
in_dataset_acc = float(accuracy_score(test_df[label_col], clf.predict(x_test)))
|
|
56
|
+
cross_dataset_acc = float(accuracy_score(ref_df[label_col], clf.predict(x_ref)))
|
|
57
|
+
drop = in_dataset_acc - cross_dataset_acc
|
|
58
|
+
|
|
59
|
+
flagged = drop > _materiality.threshold(cfg, "cross_dataset_drift_check", MATERIAL_DROP_THRESHOLD)
|
|
60
|
+
status = "flag" if flagged else "ok"
|
|
61
|
+
summary = (
|
|
62
|
+
f"in-dataset accuracy={in_dataset_acc:.4f}, cross-dataset (reference_dataset) "
|
|
63
|
+
f"accuracy={cross_dataset_acc:.4f} (drop={drop:+.4f}); "
|
|
64
|
+
+ (
|
|
65
|
+
"material drop crossing datasets, model likely learned dataset-specific "
|
|
66
|
+
"artifacts rather than generalizable attack behavior"
|
|
67
|
+
if flagged else
|
|
68
|
+
"no material drop crossing datasets"
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
return {
|
|
72
|
+
"check": "cross_dataset_drift_check", "status": status, "summary": summary,
|
|
73
|
+
"details": {"in_dataset_accuracy": in_dataset_acc, "cross_dataset_accuracy": cross_dataset_acc, "drop": drop},
|
|
74
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Basic dataset sanity checks: missing labels, constant features, ±inf values.
|
|
2
|
+
|
|
3
|
+
Complements leakage_screen/identity_column_flag (which catch features
|
|
4
|
+
that are *too* informative) by catching the opposite class of problem:
|
|
5
|
+
features carrying no information at all, or values that will silently
|
|
6
|
+
corrupt downstream numeric coercion if left unreported. Structural
|
|
7
|
+
problems severe enough to make the dataset unusable (duplicate column
|
|
8
|
+
names, a train/test schema that shares no feature columns) are handled
|
|
9
|
+
separately in dataset.validate_loaded() as hard failures, not here:
|
|
10
|
+
this check only reports things worth a human's attention, not things
|
|
11
|
+
that make the rest of the pipeline meaningless.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pandas as pd
|
|
18
|
+
|
|
19
|
+
from ..data import features
|
|
20
|
+
|
|
21
|
+
# A feature with fewer unique values than this (or zero variance for a
|
|
22
|
+
# numeric column) is flagged as carrying little-to-no signal.
|
|
23
|
+
CONSTANT_NUNIQUE_THRESHOLD = 1
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def check(train_df: pd.DataFrame, cfg: dict) -> dict:
|
|
27
|
+
schema = cfg["schema"]
|
|
28
|
+
label_col = schema["label_column"]
|
|
29
|
+
cols = features.feature_columns(train_df, cfg)
|
|
30
|
+
|
|
31
|
+
missing_label = int(train_df[label_col].isna().sum())
|
|
32
|
+
missing_by_feature = {
|
|
33
|
+
c: int(n) for c in cols if (n := train_df[c].isna().sum()) > 0
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
inf_by_feature = {}
|
|
37
|
+
constant_features = []
|
|
38
|
+
for c in cols:
|
|
39
|
+
if pd.api.types.is_numeric_dtype(train_df[c]):
|
|
40
|
+
n_inf = int(np.isinf(train_df[c].to_numpy(dtype="float64", na_value=0.0)).sum())
|
|
41
|
+
if n_inf:
|
|
42
|
+
inf_by_feature[c] = n_inf
|
|
43
|
+
if train_df[c].nunique(dropna=True) <= CONSTANT_NUNIQUE_THRESHOLD:
|
|
44
|
+
constant_features.append(c)
|
|
45
|
+
|
|
46
|
+
issues = []
|
|
47
|
+
if missing_label:
|
|
48
|
+
issues.append(f"{missing_label} missing label value(s)")
|
|
49
|
+
if missing_by_feature:
|
|
50
|
+
issues.append(f"missing values in {len(missing_by_feature)} feature column(s)")
|
|
51
|
+
if inf_by_feature:
|
|
52
|
+
issues.append(f"+-inf values in {len(inf_by_feature)} feature column(s)")
|
|
53
|
+
if constant_features:
|
|
54
|
+
issues.append(f"{len(constant_features)} constant/near-constant feature(s)")
|
|
55
|
+
|
|
56
|
+
status = "flag" if missing_label else ("warning" if issues else "ok")
|
|
57
|
+
summary = "; ".join(issues) if issues else "no missing labels, constant features, or +-inf values found"
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
"check": "data_integrity_check", "status": status, "summary": summary,
|
|
61
|
+
"details": {
|
|
62
|
+
"missing_label_count": missing_label,
|
|
63
|
+
"missing_by_feature": missing_by_feature,
|
|
64
|
+
"inf_by_feature": inf_by_feature,
|
|
65
|
+
"constant_features": constant_features,
|
|
66
|
+
},
|
|
67
|
+
}
|
ids2eval/audit/dedup.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Report exact + feature-space duplicate counts, without mutating the input.
|
|
2
|
+
|
|
3
|
+
Runs even when preprocessing.dedup is false, so users see how much
|
|
4
|
+
duplication is present before deciding whether to remove it.
|
|
5
|
+
|
|
6
|
+
Also attaches a per-class breakdown (details["by_class"]): the global
|
|
7
|
+
rate can look mild while one attack family carries almost all of it.
|
|
8
|
+
Recomputes the same duplicate/leakage masks dataset.dedup() computes
|
|
9
|
+
rather than calling it a second time, since dataset.dedup() drops rows
|
|
10
|
+
and returns aggregate counts only, not a per-row mask to group by class.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import pandas as pd
|
|
16
|
+
|
|
17
|
+
from ..data import dataset
|
|
18
|
+
from . import _by_class
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def check(train_df: pd.DataFrame, test_df: pd.DataFrame, cfg: dict) -> dict:
|
|
22
|
+
_, _, stats = dataset.dedup(train_df.copy(), test_df.copy(), cfg)
|
|
23
|
+
|
|
24
|
+
total_dropped = (
|
|
25
|
+
stats["train_duplicates_dropped"]
|
|
26
|
+
+ stats["test_leakage_dropped"]
|
|
27
|
+
+ stats["test_duplicates_dropped"]
|
|
28
|
+
)
|
|
29
|
+
status = "flag" if stats["test_leakage_dropped"] > 0 else (
|
|
30
|
+
"warning" if total_dropped > 0 else "ok"
|
|
31
|
+
)
|
|
32
|
+
summary = (
|
|
33
|
+
f"train duplicates: {stats['train_duplicates_dropped']:,} "
|
|
34
|
+
f"({stats['train_duplicates_dropped'] / max(stats['train_rows_raw'], 1):.2%}); "
|
|
35
|
+
f"test rows leaking a train feature-match: {stats['test_leakage_dropped']:,} "
|
|
36
|
+
f"({stats['test_leakage_dropped'] / max(stats['test_rows_raw'], 1):.2%}); "
|
|
37
|
+
f"test-internal duplicates: {stats['test_duplicates_dropped']:,}"
|
|
38
|
+
)
|
|
39
|
+
details = {**stats, "by_class": _by_class_rates(train_df, test_df, cfg)}
|
|
40
|
+
return {"check": "dedup_check", "status": status, "summary": summary, "details": details}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _by_class_rates(train_df: pd.DataFrame, test_df: pd.DataFrame, cfg: dict) -> dict:
|
|
44
|
+
schema = cfg["schema"]
|
|
45
|
+
ignore = set(schema["drop_columns"]) | {schema["label_column"]}
|
|
46
|
+
if schema["attack_category_column"]:
|
|
47
|
+
ignore.add(schema["attack_category_column"])
|
|
48
|
+
compare_cols = [c for c in train_df.columns if c not in ignore]
|
|
49
|
+
|
|
50
|
+
train_dup_mask = train_df.duplicated(subset=compare_cols).to_numpy()
|
|
51
|
+
merged = test_df.merge(
|
|
52
|
+
train_df[compare_cols].drop_duplicates(), on=compare_cols, how="left", indicator=True
|
|
53
|
+
)
|
|
54
|
+
test_leak_mask = (merged["_merge"] == "both").to_numpy()
|
|
55
|
+
|
|
56
|
+
group_col = _by_class.group_column(cfg)
|
|
57
|
+
by_class = {}
|
|
58
|
+
for cls in _by_class.eligible_classes(train_df, group_col):
|
|
59
|
+
cls_train_mask = (train_df[group_col] == cls).to_numpy()
|
|
60
|
+
cls_test_mask = (test_df[group_col] == cls).to_numpy()
|
|
61
|
+
by_class[str(cls)] = {
|
|
62
|
+
"train_duplicate_rate": float(train_dup_mask[cls_train_mask].mean()),
|
|
63
|
+
"test_leak_rate": (
|
|
64
|
+
float(test_leak_mask[cls_test_mask].mean()) if cls_test_mask.any() else None
|
|
65
|
+
),
|
|
66
|
+
}
|
|
67
|
+
return by_class
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""All-feature standalone AUC ranking.
|
|
2
|
+
|
|
3
|
+
identity_column_flag, port_protocol_shortcut_check, and
|
|
4
|
+
temporal_leakage_check each test one declared column (or pair) for
|
|
5
|
+
standalone predictive power, via a model fit, since a small classifier
|
|
6
|
+
is needed to turn an arbitrary categorical column into a score. This
|
|
7
|
+
check ranks every NUMERIC feature the same way, but without fitting
|
|
8
|
+
anything: a numeric column's own raw values already are a score, so
|
|
9
|
+
its one-vs-rest AUC against each class is a direct roc_auc_score call.
|
|
10
|
+
Categorical columns are not covered here (their category codes carry
|
|
11
|
+
no meaningful order, unlike a numeric value), the model-based checks
|
|
12
|
+
already cover those.
|
|
13
|
+
|
|
14
|
+
Deliberately a higher flag threshold than identity_column_flag's 0.8:
|
|
15
|
+
this ranks every behavioral feature too, ones expected to correlate
|
|
16
|
+
with the label to some degree on their own, so only a near-perfect
|
|
17
|
+
single feature is a specific, checkable oddity worth a flag.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
import pandas as pd
|
|
27
|
+
from scipy.stats import rankdata
|
|
28
|
+
|
|
29
|
+
from ..data import features
|
|
30
|
+
from ._status import NOT_APPLICABLE
|
|
31
|
+
|
|
32
|
+
AUC_FLAG_THRESHOLD = 0.95
|
|
33
|
+
AUC_WARNING_THRESHOLD = 0.90
|
|
34
|
+
TOP_N = 5
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _best_auc(values, labels: pd.Series):
|
|
38
|
+
"""The best one-vs-rest AUC this column reaches against any single
|
|
39
|
+
class, and which class, or None if no class has both members present.
|
|
40
|
+
|
|
41
|
+
Each class's AUC is the Mann-Whitney statistic from one ranking of the
|
|
42
|
+
column (average ranks for ties, which is how ROC AUC scores ties), so
|
|
43
|
+
the column is sorted once rather than once per class: equal to
|
|
44
|
+
roc_auc_score's value, at a fraction of the cost on tens of millions of
|
|
45
|
+
rows. A column holding +-inf is skipped, as roc_auc_score rejects it.
|
|
46
|
+
"""
|
|
47
|
+
values = np.asarray(values, dtype=float)
|
|
48
|
+
if not np.isfinite(values).all():
|
|
49
|
+
return None, None
|
|
50
|
+
classes, codes = np.unique(labels.to_numpy(), return_inverse=True)
|
|
51
|
+
n = len(values)
|
|
52
|
+
n_pos = np.bincount(codes, minlength=len(classes)).astype(float)
|
|
53
|
+
rank_sum = np.bincount(codes, weights=rankdata(values), minlength=len(classes))
|
|
54
|
+
auc_by_class = {}
|
|
55
|
+
for i, cls in enumerate(classes):
|
|
56
|
+
if n_pos[i] == 0 or n_pos[i] == n:
|
|
57
|
+
continue
|
|
58
|
+
auc = (rank_sum[i] - n_pos[i] * (n_pos[i] + 1) / 2) / (n_pos[i] * (n - n_pos[i]))
|
|
59
|
+
# A feature can separate a class by high values or low values;
|
|
60
|
+
# max() with the inverse reports separability regardless of direction.
|
|
61
|
+
auc_by_class[cls] = max(auc, 1 - auc)
|
|
62
|
+
best_auc, best_class = None, None
|
|
63
|
+
for cls in labels.unique(): # same tie-break order as before: first class to reach the maximum
|
|
64
|
+
auc = auc_by_class.get(cls)
|
|
65
|
+
if auc is not None and (best_auc is None or auc > best_auc):
|
|
66
|
+
best_auc, best_class = float(auc), cls
|
|
67
|
+
return best_auc, best_class
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def check(train_df: pd.DataFrame, cfg: dict) -> dict:
|
|
71
|
+
label_col = cfg["schema"]["label_column"]
|
|
72
|
+
cols = features.feature_columns(train_df, cfg)
|
|
73
|
+
numeric_cols = [c for c in cols if pd.api.types.is_numeric_dtype(train_df[c])]
|
|
74
|
+
if not numeric_cols:
|
|
75
|
+
return {
|
|
76
|
+
"check": "feature_auc_ranking_check", "status": NOT_APPLICABLE,
|
|
77
|
+
"summary": "no numeric feature columns to rank", "details": {},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
labels = train_df[label_col]
|
|
81
|
+
|
|
82
|
+
def score(col):
|
|
83
|
+
values = train_df[col].to_numpy()
|
|
84
|
+
valid = ~pd.isna(values)
|
|
85
|
+
if valid.sum() < 2:
|
|
86
|
+
return None, None
|
|
87
|
+
return _best_auc(values[valid], labels[valid])
|
|
88
|
+
|
|
89
|
+
# Columns are ranked in threads: numpy's sort releases the GIL, and at
|
|
90
|
+
# full scale ranking dozens of columns of tens of millions of rows one by
|
|
91
|
+
# one dominates the audit's run time.
|
|
92
|
+
with ThreadPoolExecutor(max_workers=min(len(numeric_cols), os.cpu_count() or 1, 16)) as pool:
|
|
93
|
+
scored = list(pool.map(score, numeric_cols))
|
|
94
|
+
|
|
95
|
+
best_per_feature = {}
|
|
96
|
+
top_feature, top_class, top_auc = None, None, 0.0
|
|
97
|
+
for col, (auc, cls) in zip(numeric_cols, scored, strict=True):
|
|
98
|
+
if auc is None:
|
|
99
|
+
continue
|
|
100
|
+
best_per_feature[col] = auc
|
|
101
|
+
if auc > top_auc:
|
|
102
|
+
top_feature, top_class, top_auc = col, cls, auc
|
|
103
|
+
|
|
104
|
+
if not best_per_feature:
|
|
105
|
+
return {
|
|
106
|
+
"check": "feature_auc_ranking_check", "status": NOT_APPLICABLE,
|
|
107
|
+
"summary": "no numeric feature had both classes present to rank", "details": {},
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if top_auc > AUC_FLAG_THRESHOLD:
|
|
111
|
+
status = "flag"
|
|
112
|
+
elif top_auc > AUC_WARNING_THRESHOLD:
|
|
113
|
+
status = "warning"
|
|
114
|
+
else:
|
|
115
|
+
status = "ok"
|
|
116
|
+
|
|
117
|
+
top_ranked = dict(sorted(best_per_feature.items(), key=lambda kv: kv[1], reverse=True)[:TOP_N])
|
|
118
|
+
summary = f"'{top_feature}' alone reaches AUC {top_auc:.3f} identifying '{top_class}'"
|
|
119
|
+
if status != "ok":
|
|
120
|
+
summary += "; a single feature this separable is worth checking for a leakage artifact"
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
"check": "feature_auc_ranking_check", "status": status, "summary": summary,
|
|
124
|
+
"details": {"top_features": top_ranked, "best_feature": str(top_feature), "best_class": str(top_class)},
|
|
125
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Feature-category ablation (v2, opt-in).
|
|
2
|
+
|
|
3
|
+
Needs schema.feature_categories, a user-declared mapping from column
|
|
4
|
+
name to a category label (e.g. "behavioural", "topology", "identity",
|
|
5
|
+
"timing", any names the user chooses). Fits once on every feature
|
|
6
|
+
(baseline), then once per declared category with that category's
|
|
7
|
+
columns removed, and reports how much accuracy each category's removal
|
|
8
|
+
costs. A category responsible for a large share of the model's
|
|
9
|
+
accuracy on its own is worth a second look, especially if it is a
|
|
10
|
+
category like identity or topology rather than behavioural, though
|
|
11
|
+
this check reports the fact and leaves that judgment to the reader
|
|
12
|
+
rather than hardcoding which category names should be considered
|
|
13
|
+
suspicious.
|
|
14
|
+
|
|
15
|
+
Deliberately opt-in, not a default check: a category label is exactly
|
|
16
|
+
as good as the person who assigned it, unlike every other check here,
|
|
17
|
+
which measures something rather than trusting a declared fact. Wrong
|
|
18
|
+
labels produce a plausible-looking result that says nothing real.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import pandas as pd
|
|
24
|
+
|
|
25
|
+
from ..data import features
|
|
26
|
+
from . import _materiality
|
|
27
|
+
from ._fit_score import fit_and_score
|
|
28
|
+
from ._status import NOT_APPLICABLE
|
|
29
|
+
|
|
30
|
+
# Overridable via audit.materiality_thresholds.feature_category_ablation_check
|
|
31
|
+
# ({"warning": .., "flag": ..}).
|
|
32
|
+
MATERIAL_DROP_THRESHOLD = 0.30
|
|
33
|
+
WARNING_DROP_THRESHOLD = 0.15
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def check(train_df: pd.DataFrame, test_df: pd.DataFrame, cfg: dict) -> dict:
|
|
37
|
+
categories_map = cfg["schema"]["feature_categories"]
|
|
38
|
+
if not categories_map:
|
|
39
|
+
return {
|
|
40
|
+
"check": "feature_category_ablation_check", "status": NOT_APPLICABLE,
|
|
41
|
+
"summary": "no schema.feature_categories configured", "details": {},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
label_col = cfg["schema"]["label_column"]
|
|
45
|
+
all_cols = features.feature_columns(train_df, cfg)
|
|
46
|
+
|
|
47
|
+
by_category: dict[str, list[str]] = {}
|
|
48
|
+
for col, category in categories_map.items():
|
|
49
|
+
if col in all_cols:
|
|
50
|
+
by_category.setdefault(category, []).append(col)
|
|
51
|
+
if not by_category:
|
|
52
|
+
return {
|
|
53
|
+
"check": "feature_category_ablation_check", "status": NOT_APPLICABLE,
|
|
54
|
+
"summary": "none of schema.feature_categories's columns are present in the loaded data",
|
|
55
|
+
"details": {},
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
seed = cfg["random_seed"]
|
|
59
|
+
baseline_acc = fit_and_score(train_df, test_df, label_col, cfg, cols=all_cols, seed=seed)
|
|
60
|
+
|
|
61
|
+
drop_by_category = {}
|
|
62
|
+
for category, cols_in_category in by_category.items():
|
|
63
|
+
remaining = [c for c in all_cols if c not in cols_in_category]
|
|
64
|
+
if not remaining:
|
|
65
|
+
continue # every feature falls under this one category, nothing to ablate against
|
|
66
|
+
ablated_acc = fit_and_score(train_df, test_df, label_col, cfg, cols=remaining, seed=seed)
|
|
67
|
+
drop_by_category[category] = baseline_acc - ablated_acc
|
|
68
|
+
|
|
69
|
+
if not drop_by_category:
|
|
70
|
+
return {
|
|
71
|
+
"check": "feature_category_ablation_check", "status": NOT_APPLICABLE,
|
|
72
|
+
"summary": "every declared category covers all features, nothing left to ablate against",
|
|
73
|
+
"details": {"baseline_accuracy": baseline_acc},
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
worst_category, worst_drop = max(drop_by_category.items(), key=lambda kv: kv[1])
|
|
77
|
+
tiers = _materiality.threshold(
|
|
78
|
+
cfg, "feature_category_ablation_check",
|
|
79
|
+
{"warning": WARNING_DROP_THRESHOLD, "flag": MATERIAL_DROP_THRESHOLD},
|
|
80
|
+
)
|
|
81
|
+
if worst_drop > tiers["flag"]:
|
|
82
|
+
status = "flag"
|
|
83
|
+
elif worst_drop > tiers["warning"]:
|
|
84
|
+
status = "warning"
|
|
85
|
+
else:
|
|
86
|
+
status = "ok"
|
|
87
|
+
|
|
88
|
+
summary = (
|
|
89
|
+
f"baseline accuracy={baseline_acc:.4f}; removing '{worst_category}' features costs "
|
|
90
|
+
f"{worst_drop:+.4f} accuracy, the most of any declared category"
|
|
91
|
+
)
|
|
92
|
+
if status != "ok":
|
|
93
|
+
summary += "; a category this load-bearing is worth checking, especially if it isn't behavioural"
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
"check": "feature_category_ablation_check", "status": status, "summary": summary,
|
|
97
|
+
"details": {"baseline_accuracy": baseline_acc, "drop_by_category": drop_by_category},
|
|
98
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Flow-identity leakage check: same flow tuple on both sides of the split.
|
|
2
|
+
|
|
3
|
+
Direct counterpart to resplit_falsification, which asks "would a
|
|
4
|
+
session-grouped split cost accuracy" without ever naming which specific
|
|
5
|
+
rows would move. This check names them: given schema.flow_id_columns
|
|
6
|
+
(typically a 5-tuple, e.g. source/destination IP, source/destination
|
|
7
|
+
port, protocol, or whatever subset survives in a given release), it
|
|
8
|
+
finds identity values that appear in both train and test. Many NIDS
|
|
9
|
+
papers still split flow-level data at random rather than by session,
|
|
10
|
+
letting the same connection contribute a row to both sides, so a model
|
|
11
|
+
can partly recognize the connection rather than the attack behavior.
|
|
12
|
+
|
|
13
|
+
Unlike label_conflict_check (which compares on every feature column),
|
|
14
|
+
this compares on only the columns named in schema.flow_id_columns, so
|
|
15
|
+
two different flows that happen to share a source IP and port (against
|
|
16
|
+
different destinations, say) are correctly treated as different
|
|
17
|
+
identities, not merged into one.
|
|
18
|
+
|
|
19
|
+
No-ops if schema.flow_id_columns is empty or none of its columns survive
|
|
20
|
+
in the loaded data (many public releases already strip IP columns before
|
|
21
|
+
distribution, e.g. the NetFlow-V2 mirrors this project audits).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import pandas as pd
|
|
27
|
+
|
|
28
|
+
from ._status import NOT_APPLICABLE
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def check(train_df: pd.DataFrame, test_df: pd.DataFrame, cfg: dict) -> dict:
|
|
32
|
+
flow_cols = [c for c in cfg["schema"]["flow_id_columns"] if c in train_df.columns]
|
|
33
|
+
if not flow_cols:
|
|
34
|
+
return {
|
|
35
|
+
"check": "flow_group_leakage_check", "status": NOT_APPLICABLE,
|
|
36
|
+
"summary": "no schema.flow_id_columns configured", "details": {},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
train_keys = pd.util.hash_pandas_object(train_df[flow_cols], index=False)
|
|
40
|
+
test_keys = pd.util.hash_pandas_object(test_df[flow_cols], index=False)
|
|
41
|
+
overlap = set(train_keys.to_numpy()) & set(test_keys.to_numpy())
|
|
42
|
+
|
|
43
|
+
if not overlap:
|
|
44
|
+
return {
|
|
45
|
+
"check": "flow_group_leakage_check", "status": "ok",
|
|
46
|
+
"summary": f"no flow identity in {flow_cols} appears on both sides of the split",
|
|
47
|
+
"details": {"flow_id_columns": flow_cols},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
train_rows = int(train_keys.isin(overlap).sum())
|
|
51
|
+
test_rows = int(test_keys.isin(overlap).sum())
|
|
52
|
+
summary = (
|
|
53
|
+
f"{len(overlap):,} flow identit{'y' if len(overlap) == 1 else 'ies'} in {flow_cols} "
|
|
54
|
+
f"appear on both sides of the split, touching {train_rows:,} train row(s) and "
|
|
55
|
+
f"{test_rows:,} test row(s); a model can partly recognize the connection instead "
|
|
56
|
+
f"of the attack behavior on these rows"
|
|
57
|
+
)
|
|
58
|
+
return {
|
|
59
|
+
"check": "flow_group_leakage_check", "status": "flag", "summary": summary,
|
|
60
|
+
"details": {
|
|
61
|
+
"flow_id_columns": flow_cols,
|
|
62
|
+
"overlapping_identities": len(overlap),
|
|
63
|
+
"train_rows_affected": train_rows,
|
|
64
|
+
"test_rows_affected": test_rows,
|
|
65
|
+
},
|
|
66
|
+
}
|