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,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
+ }
@@ -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
+ }