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,160 @@
1
+ """Nearest-neighbor class-homogeneity test (paper Section 3.4).
2
+
3
+ For each class, compares test-to-train nearest-neighbor distance against
4
+ a *control*, train-internal nearest-neighbor distance, excluding self, via
5
+ the near-zero-distance rate each side shows (the share of rows whose
6
+ nearest neighbor is an effectively-exact feature-space duplicate). If test
7
+ rows are no more prone to this than train rows are to each other,
8
+ near-duplication is a property of the class population itself (present
9
+ within each split independently), not train/test boundary leakage. If
10
+ test rows are notably more prone to it, that's a real leakage signature
11
+ the resplit-falsification check can then corroborate.
12
+
13
+ Reports a 95% CI on the rate difference (test minus control) against a
14
+ pre-specified margin rather than a single p-value, since failing to detect
15
+ a difference is not the same claim as having shown there is none. Leakage
16
+ is one-sided, so a class is "leakage" when the CI's lower bound exceeds
17
+ +margin, cleared when its upper bound is below +margin ("equivalent" if
18
+ the CI also sits inside +/-margin, "below_control" if test rows are
19
+ materially less near-duplicated than train is with itself), and
20
+ "inconclusive" when the CI straddles +margin.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import math
26
+
27
+ import numpy as np
28
+ import pandas as pd
29
+ from sklearn.neighbors import NearestNeighbors
30
+ from sklearn.preprocessing import StandardScaler
31
+
32
+ from ..data import features
33
+ from . import _materiality
34
+ from ._status import NOT_APPLICABLE
35
+
36
+ SAMPLE_SIZE = 500
37
+ MIN_CLASS_SIZE = 20
38
+ MAX_INDEX_ROWS = 200_000
39
+ # Post-StandardScaler distance below which two rows are treated as an
40
+ # effectively-exact feature-space duplicate.
41
+ NEAR_ZERO_DISTANCE = 1e-6
42
+ # Equivalence margin on the near-zero-distance *rate difference* (test minus
43
+ # control), on the same 0-1 scale as the rates themselves: if test rows are
44
+ # near-zero-distance to train no more than 5 percentage points more often
45
+ # than train rows are to each other, that gap is read as noise around zero,
46
+ # not a leakage signature - the same order of magnitude as this codebase's
47
+ # other diagnostic materiality thresholds (e.g. resplit_falsification's 0.01
48
+ # accuracy-drop, result_robustness_check's 0.08/0.15 spread tiers), chosen
49
+ # for consistency rather than independently derived. Overridable via
50
+ # audit.materiality_thresholds.homogeneity_test.
51
+ EQUIVALENCE_MARGIN = 0.05
52
+
53
+
54
+ _Z = 1.959964 # z_{0.975}
55
+
56
+
57
+ def _wilson(p: float, n: int) -> tuple[float, float]:
58
+ centre = (p + _Z**2 / (2 * n)) / (1 + _Z**2 / n)
59
+ half = _Z * math.sqrt(p * (1 - p) / n + _Z**2 / (4 * n**2)) / (1 + _Z**2 / n)
60
+ return centre - half, centre + half
61
+
62
+
63
+ def _rate_diff_ci(p_test: float, n_test: int, p_control: float, n_control: int) -> tuple[float, float]:
64
+ """95% Newcombe hybrid-score CI for the difference of two independent
65
+ proportions (test rate minus control rate). Unlike the Wald interval it
66
+ stays a proper interval when a rate is at 0 or 1, which match rates
67
+ often are here."""
68
+ diff = p_test - p_control
69
+ lo_t, hi_t = _wilson(p_test, n_test)
70
+ lo_c, hi_c = _wilson(p_control, n_control)
71
+ lower = diff - math.sqrt((p_test - lo_t) ** 2 + (hi_c - p_control) ** 2)
72
+ upper = diff + math.sqrt((hi_t - p_test) ** 2 + (p_control - lo_c) ** 2)
73
+ return lower, upper
74
+
75
+
76
+ def check(train_df: pd.DataFrame, test_df: pd.DataFrame, cfg: dict) -> dict:
77
+ schema = cfg["schema"]
78
+ group_col = schema["attack_category_column"] or schema["label_column"]
79
+ cols = features.feature_columns(train_df, cfg)
80
+
81
+ index_df = train_df.sample(n=min(len(train_df), MAX_INDEX_ROWS), random_state=cfg["random_seed"])
82
+ x_index_raw, x_test_raw = features.encode_aligned(index_df, test_df, cols)
83
+ scaler = StandardScaler().fit(x_index_raw)
84
+ x_index = scaler.transform(x_index_raw)
85
+ x_test = scaler.transform(x_test_raw)
86
+
87
+ nn = NearestNeighbors(n_neighbors=2, algorithm="auto", n_jobs=-1).fit(x_index)
88
+ margin = _materiality.threshold(cfg, "homogeneity_test", EQUIVALENCE_MARGIN)
89
+
90
+ per_class = {}
91
+ flagged_classes = []
92
+ inconclusive_classes = []
93
+ for cls, count in train_df[group_col].value_counts().items():
94
+ test_mask = test_df[group_col] == cls
95
+ index_mask = (index_df[group_col] == cls).values
96
+ if count < MIN_CLASS_SIZE or test_mask.sum() < MIN_CLASS_SIZE or index_mask.sum() < MIN_CLASS_SIZE:
97
+ continue
98
+
99
+ test_sample_idx = np.random.RandomState(0).choice(
100
+ np.flatnonzero(test_mask.values), size=min(SAMPLE_SIZE, test_mask.sum()), replace=False
101
+ )
102
+ control_sample_idx = np.random.RandomState(0).choice(
103
+ np.flatnonzero(index_mask), size=min(SAMPLE_SIZE, index_mask.sum()), replace=False
104
+ )
105
+
106
+ test_dist, _ = nn.kneighbors(x_test[test_sample_idx], n_neighbors=1)
107
+ control_dist, _ = nn.kneighbors(x_index[control_sample_idx], n_neighbors=2)
108
+
109
+ test_dist = test_dist[:, 0]
110
+ control_dist = control_dist[:, 1] # nearest neighbor excluding self
111
+
112
+ test_near_zero = float((test_dist < NEAR_ZERO_DISTANCE).mean())
113
+ control_near_zero = float((control_dist < NEAR_ZERO_DISTANCE).mean())
114
+ n_test, n_control = len(test_dist), len(control_dist)
115
+ ci_low, ci_high = _rate_diff_ci(test_near_zero, n_test, control_near_zero, n_control)
116
+
117
+ # Leakage is one-sided (test closer to train than the control), so the
118
+ # decision bound is +margin only. A CI entirely below -margin means the
119
+ # test rows are materially *less* near-duplicated than train is with
120
+ # itself: decisively not leakage, so it must not land in "inconclusive".
121
+ if ci_low > margin:
122
+ classification = "leakage"
123
+ flagged_classes.append(cls)
124
+ elif ci_high < margin:
125
+ classification = "equivalent" if ci_low > -margin else "below_control"
126
+ else:
127
+ classification = "inconclusive"
128
+ inconclusive_classes.append(cls)
129
+
130
+ per_class[cls] = {
131
+ "test_near_zero_rate": test_near_zero,
132
+ "control_near_zero_rate": control_near_zero,
133
+ "rate_diff_ci_95": [ci_low, ci_high],
134
+ "equivalence_margin": margin,
135
+ "classification": classification,
136
+ }
137
+
138
+ status = "flag" if flagged_classes else ("warning" if inconclusive_classes else "ok")
139
+ if not per_class:
140
+ status = NOT_APPLICABLE
141
+ detail = f"no class has {MIN_CLASS_SIZE} rows in train, test and the control sample, nothing to compare"
142
+ elif flagged_classes:
143
+ detail = f"leakage signature (95% CI lower bound exceeds the {margin:+.2f} margin) in: {flagged_classes}"
144
+ elif inconclusive_classes:
145
+ detail = (
146
+ f"inconclusive (95% CI straddles the +/-{margin:.2f} equivalence margin) for: "
147
+ f"{inconclusive_classes}; sample size isn't enough to tell for these classes"
148
+ )
149
+ else:
150
+ detail = (
151
+ f"for every class the 95% CI upper bound on (test-to-train minus train-internal) "
152
+ f"near-duplicate rate is below the +{margin:.2f} margin, so no class shows a "
153
+ f"train/test leakage signature"
154
+ )
155
+ summary = f"{len(per_class)} classes tested; {detail}"
156
+
157
+ return {
158
+ "check": "homogeneity_test", "status": status, "summary": summary,
159
+ "details": {"per_class": per_class, "equivalence_margin": margin},
160
+ }
@@ -0,0 +1,171 @@
1
+ """Identity-column checks: standalone predictive power + low-cardinality risk.
2
+
3
+ Per Sarhan et al. 2022 (NetFlow-standard feature set drops IP/port
4
+ columns) and Kostas et al. 2024/2025 (packet-level IP/port models hit
5
+ near-100% in-dataset, lose >90% cross-dataset): id_like_columns (IP,
6
+ port, MAC) are checked both for how well they predict the label alone
7
+ (identity_column_flag) and for how few unique values they take relative
8
+ to dataset size (low_cardinality_warning generalizes N-BaIoT's
9
+ per-device overfitting finding to CIC/UNSW's limited attacker/victim IP
10
+ pool).
11
+
12
+ identity_column_flag asks one specific question: on this split, how well
13
+ does looking an identifier value up in the training data predict the
14
+ test label? It builds, from the training partition only, a table of how
15
+ often each identifier value occurs with each class, and predicts a test
16
+ row's class distribution from its value's row of that table (additively
17
+ smoothed toward the training class prior). A test value never seen in
18
+ training gets the training class prior itself, i.e. no information. The
19
+ method uses only equality of values, so it does not depend on any
20
+ ordering or integer coding of the identifiers. It is a lookup, not a
21
+ model fit, so it uses the whole training partition, not a row sample.
22
+
23
+ Reported per column: seen_coverage (share of test rows whose value occurs
24
+ in training), standalone_auc (weighted one-vs-rest AUC over all test
25
+ rows, unseen ones scored with the prior) and seen_only_auc (the same AUC
26
+ on the seen rows only). The flag is on standalone_auc, which is the
27
+ shortcut's effect on this split as a whole; seen_only_auc shows how
28
+ strong the lookup is where it applies. Whether a column is associated
29
+ with the label for values never seen in training is a different question,
30
+ answered by a split grouped by that column, not by this check.
31
+
32
+ For a flagged column, details["by_class"] gives the one-vs-rest AUC of the
33
+ same lookup for each class of the attack-category (or label) column, so a
34
+ global AUC of 0.9 that is actually 0.99 for one attack family and 0.5 for
35
+ the rest is visible rather than averaged away.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import numpy as np
41
+ import pandas as pd
42
+
43
+ from . import _by_class
44
+ from ._auc import robust_auc
45
+ from ._status import NOT_APPLICABLE
46
+
47
+ # Standalone AUC above this on an identity column alone is a specific,
48
+ # checkable topology-shortcut signature, not just "IPs are informative."
49
+ AUC_FLAG_THRESHOLD = 0.8
50
+ # Fewer unique values than this (absolute) suggests the model could be
51
+ # memorizing a handful of attacker/victim hosts rather than a behavior.
52
+ LOW_CARDINALITY_THRESHOLD = 50
53
+ # Past this many classes the per-class breakdown is left out of the report.
54
+ MAX_CLASSES_FOR_BREAKDOWN = 25
55
+ # Pseudo-count of the additive smoothing toward the training class prior: a
56
+ # value seen once in training predicts half its own class, half the prior.
57
+ SMOOTHING = 1.0
58
+
59
+
60
+ def _keys(train_col: pd.Series, test_col: pd.Series) -> tuple[np.ndarray, np.ndarray]:
61
+ # Numeric on both sides: compare as float64, exact for integers such as
62
+ # ports. Otherwise compare as strings, so train and test still match on
63
+ # value when one side loaded a column as int and the other as object.
64
+ if pd.api.types.is_numeric_dtype(train_col) and pd.api.types.is_numeric_dtype(test_col):
65
+ return train_col.to_numpy(dtype=float), test_col.to_numpy(dtype=float)
66
+ return train_col.astype(str).to_numpy(), test_col.astype(str).to_numpy()
67
+
68
+
69
+ def lookup_proba(train_keys: np.ndarray, train_y, test_keys: np.ndarray):
70
+ """Class probabilities for each test row from a training-only value table.
71
+
72
+ Returns (classes, proba, seen): proba has one column per training class,
73
+ in sorted class order; seen marks test rows whose value occurs in
74
+ training. Unseen rows get the training class prior.
75
+ """
76
+ key_codes, values = pd.factorize(train_keys, use_na_sentinel=False)
77
+ classes = np.asarray(sorted(pd.unique(np.asarray(train_y))))
78
+ y_codes = pd.Index(classes).get_indexer(np.asarray(train_y))
79
+ k = len(classes)
80
+ table = np.bincount(key_codes * k + y_codes, minlength=len(values) * k).reshape(len(values), k).astype(float)
81
+ prior = table.sum(axis=0) / table.sum()
82
+ smoothed = (table + SMOOTHING * prior) / (table.sum(axis=1, keepdims=True) + SMOOTHING)
83
+ idx = pd.Index(values).get_indexer(test_keys)
84
+ seen = idx >= 0
85
+ proba = np.empty((len(idx), k))
86
+ proba[seen] = smoothed[idx[seen]]
87
+ proba[~seen] = prior
88
+ return classes, proba, seen
89
+
90
+
91
+ def check_predictive_power(train_df: pd.DataFrame, test_df: pd.DataFrame, cfg: dict) -> dict:
92
+ id_cols = [c for c in cfg["schema"]["id_like_columns"] if c in train_df.columns]
93
+ label_col = cfg["schema"]["label_column"]
94
+ if not id_cols:
95
+ return {
96
+ "check": "identity_column_flag", "status": NOT_APPLICABLE,
97
+ "summary": "no id_like_columns configured", "details": {},
98
+ }
99
+
100
+ auc_all, auc_seen, coverage, flagged, keys = {}, {}, {}, [], {}
101
+ y_test = test_df[label_col].to_numpy()
102
+ for col in id_cols:
103
+ train_keys, test_keys = _keys(train_df[col], test_df[col])
104
+ keys[col] = (train_keys, test_keys)
105
+ classes, proba, seen = lookup_proba(train_keys, train_df[label_col], test_keys)
106
+ coverage[col] = float(seen.mean()) if len(seen) else 0.0
107
+ auc_all[col] = robust_auc(y_test, proba, classes)
108
+ auc_seen[col] = robust_auc(y_test[seen], proba[seen], classes) if seen.any() else None
109
+ if auc_all[col] is not None and auc_all[col] > AUC_FLAG_THRESHOLD:
110
+ flagged.append(col)
111
+
112
+ def fmt(a):
113
+ return None if a is None else round(a, 3)
114
+
115
+ status = "flag" if flagged else "ok"
116
+ summary = "standalone AUC by column (seen-value coverage, AUC on seen values): " + ", ".join(
117
+ f"{c}: {fmt(auc_all[c])} ({coverage[c]:.1%}, {fmt(auc_seen[c])})" for c in id_cols
118
+ )
119
+ if flagged:
120
+ summary += f". Suggest dropping: {flagged}"
121
+
122
+ details = {"standalone_auc": auc_all, "seen_only_auc": auc_seen, "seen_coverage": coverage,
123
+ "suggested_drop": flagged, "method": "training-only value lookup, unseen values scored with the prior"}
124
+ if flagged:
125
+ details["by_class"] = {col: _by_class_auc(train_df, test_df, *keys[col], cfg) for col in flagged}
126
+
127
+ return {"check": "identity_column_flag", "status": status, "summary": summary, "details": details}
128
+
129
+
130
+ def _by_class_auc(train_df: pd.DataFrame, test_df: pd.DataFrame, train_keys, test_keys, cfg: dict) -> dict | str:
131
+ group_col = _by_class.group_column(cfg)
132
+ classes = _by_class.eligible_classes(train_df, group_col)
133
+ if len(classes) > MAX_CLASSES_FOR_BREAKDOWN:
134
+ return f"skipped, {len(classes)} eligible classes exceeds the {MAX_CLASSES_FOR_BREAKDOWN}-class cap"
135
+
136
+ table_classes, proba, _ = lookup_proba(train_keys, train_df[group_col], test_keys)
137
+ position = {c: i for i, c in enumerate(table_classes)}
138
+ y_test = test_df[group_col].to_numpy()
139
+ by_class = {}
140
+ for cls in classes:
141
+ y_binary = y_test == cls
142
+ if not y_binary.any() or y_binary.all():
143
+ continue
144
+ score = proba[:, position[cls]]
145
+ by_class[str(cls)] = robust_auc(y_binary, np.column_stack([1 - score, score]), np.array([False, True]))
146
+ return by_class
147
+
148
+
149
+ def check_cardinality(train_df: pd.DataFrame, cfg: dict) -> dict:
150
+ id_cols = [c for c in cfg["schema"]["id_like_columns"] if c in train_df.columns]
151
+ if not id_cols:
152
+ return {
153
+ "check": "low_cardinality_warning", "status": NOT_APPLICABLE,
154
+ "summary": "no id_like_columns configured", "details": {},
155
+ }
156
+
157
+ cardinalities = {c: int(train_df[c].nunique()) for c in id_cols}
158
+ flagged = [c for c, n in cardinalities.items() if n < LOW_CARDINALITY_THRESHOLD]
159
+
160
+ status = "warning" if flagged else "ok"
161
+ summary = f"unique values by column: {cardinalities}"
162
+ if flagged:
163
+ summary += (
164
+ f". {flagged} have fewer than {LOW_CARDINALITY_THRESHOLD} unique values, "
165
+ "risk of memorizing specific hosts rather than learning attack behavior"
166
+ )
167
+
168
+ return {
169
+ "check": "low_cardinality_warning", "status": status, "summary": summary,
170
+ "details": {"cardinalities": cardinalities, "flagged": flagged},
171
+ }
@@ -0,0 +1,144 @@
1
+ """Known-issue lookup (v2, opt-in): curated per-dataset documented problems.
2
+
3
+ Unlike schema_fingerprint_check (which matches the *extractor tool*'s
4
+ column-naming signature regardless of dataset), this matches on
5
+ dataset.name directly and surfaces problems specific to that dataset,
6
+ curation work, not a new algorithm. Needs no reference_dataset.
7
+
8
+ Extend KNOWN_ISSUES as more curated, citable findings are gathered.
9
+ Matching is substring-based against a lowercased dataset.name so
10
+ "cic-ids2018", "CIC_IDS2018", and "cic2018" all resolve the same way.
11
+ Matching by name cannot tell one release of a dataset from another (an original
12
+ CSV release from a corrected re-extraction of the same traffic), so an entry that
13
+ documents one release says which in its "release" field, and the summary prints
14
+ it; whether the issue applies to the audited files is for the reader to check.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import pandas as pd
20
+
21
+ KNOWN_ISSUES = {
22
+ "cic-ids2018": [
23
+ {
24
+ "issue": "In the original release, 172 of the 362 flows labeled Brute Force-Web "
25
+ "on 2018-02-23 are not part of the attack: 21 have malformed addresses "
26
+ "and 151 (41.7%) are background traffic, 128 of them UDP (126 to port 500) "
27
+ "(a ground-truth labeling error, not an extraction or pipeline bug).",
28
+ "citation": "Liu et al. 2022, IEEE CNS, companion documentation",
29
+ "release": "the original CSE-CIC-IDS2018 CSVs (UNB/AWS)",
30
+ },
31
+ {
32
+ "issue": "A full re-labeling of the original release found 7.53% of labels wrong, "
33
+ "with the error rate above 75% for some attack classes.",
34
+ "citation": "Liu et al. 2022, IEEE CNS (rates as summarized by Cantone et al. 2024)",
35
+ "release": "the original CSE-CIC-IDS2018 CSVs (UNB/AWS)",
36
+ },
37
+ ],
38
+ "cic-ids2017": [
39
+ {
40
+ "issue": "The traffic capture has packet misorder and duplication, and some "
41
+ "attacks that were actually launched are not correctly labeled as "
42
+ "attack traffic in the released CSVs.",
43
+ "citation": "Engelen et al. 2021, IEEE S&P Workshops (WTMC)",
44
+ "release": "the original CIC-IDS2017 CSVs (UNB)",
45
+ },
46
+ {
47
+ "issue": "A full re-labeling of the original release found 6.67% of labels wrong, "
48
+ "with the error rate above 75% for some attack classes.",
49
+ "citation": "Liu et al. 2022, IEEE CNS (rates as summarized by Cantone et al. 2024)",
50
+ "release": "the original CIC-IDS2017 CSVs (UNB)",
51
+ },
52
+ {
53
+ "issue": "Heartbleed has only 11 rows (about 0.0004% of the dataset), too few to "
54
+ "evaluate reliably regardless of labeling accuracy.",
55
+ "citation": "Cantone et al. 2024, IEEE Access, Table 1",
56
+ "release": "the original CIC-IDS2017 CSVs and the corrected re-extraction alike",
57
+ },
58
+ ],
59
+ "nsl-kdd": [
60
+ {
61
+ "issue": "The test set intentionally includes attack types absent from "
62
+ "the training set, by design, to test generalization to unknown "
63
+ "attacks rather than memorization; this project's own audit of "
64
+ "the official partition finds 17 such test-only attack types.",
65
+ "citation": "Tavallaee et al. 2009, IEEE CISDA",
66
+ },
67
+ ],
68
+ "bot-iot": [
69
+ {
70
+ "issue": "The NF-BoT-IoT-V2 NetFlow conversion is over 99.9% attack "
71
+ "traffic (benign flows are 129,437 of 30,420,086 rows, 0.43%), "
72
+ "an order of magnitude more skewed than most NIDS datasets; a "
73
+ "uniform row-level sample may carry very few or zero benign "
74
+ "rows, and a classifier's accuracy on this data mostly reflects "
75
+ "its attack-vs-attack discrimination, not its ability to "
76
+ "recognize normal traffic.",
77
+ "citation": "Koroniotis et al. 2019, Future Generation Computer Systems",
78
+ },
79
+ ],
80
+ "bot-iot-official": [
81
+ {
82
+ "issue": "The full original (non-NetFlow) release is over 99.9% attack "
83
+ "traffic (benign flows are 477 of 3,668,522 rows, 0.013%), even "
84
+ "more skewed than the NetFlow-V2 conversion of this dataset; a "
85
+ "classifier's accuracy on this data mostly reflects its "
86
+ "attack-vs-attack discrimination, not its ability to recognize "
87
+ "normal traffic.",
88
+ "citation": "Koroniotis et al. 2019, Future Generation Computer Systems",
89
+ },
90
+ ],
91
+ "cic-ddos2019": [
92
+ {
93
+ "issue": "By design, the two collection days cover different attack sets: "
94
+ "the testing day includes several DDoS types absent from the "
95
+ "training day (e.g. DNS, NTP, SNMP, TFTP, WebDDoS reflection "
96
+ "attacks), so a classifier trained only on the training day has "
97
+ "never seen those attack types.",
98
+ "citation": "Sharafaldin et al. 2019, IEEE ICCST",
99
+ },
100
+ {
101
+ "issue": "The same attack type is labeled inconsistently between the two "
102
+ "days (e.g. training's 'UDPLag'/'MSSQL' vs. testing's 'UDP-lag'/"
103
+ "'DrDoS_MSSQL'), so label strings must be remapped before any "
104
+ "train/test comparison, not matched literally.",
105
+ "citation": "Independently verified in this project's own audit methodology",
106
+ },
107
+ ],
108
+ "unsw-nb15": [
109
+ {
110
+ "issue": "Some redistributions invert the UNSW_NB15_training-set.csv/"
111
+ "testing-set.csv file-name-to-content mapping relative to the "
112
+ "dataset's documented convention (82,332 vs. 175,341 rows), "
113
+ "verify against the published per-attack-category counts before "
114
+ "trusting file names.",
115
+ "citation": "Independently verified in this project's own audit methodology",
116
+ },
117
+ ],
118
+ }
119
+
120
+
121
+ def check(train_df: pd.DataFrame, cfg: dict) -> dict:
122
+ name = (cfg["dataset"]["name"] or "").strip().lower()
123
+ matched_keys = [key for key in KNOWN_ISSUES if key in name]
124
+ # A key that is itself a substring of another matched key (e.g. "bot-iot" inside
125
+ # "bot-iot-official") is a less specific match for the same dataset name and is
126
+ # dropped in favor of the more specific one, so variant-specific curated numbers
127
+ # don't get mixed with a different variant's numbers under the same lookup.
128
+ matched_keys = [
129
+ key for key in matched_keys
130
+ if not any(key != other and key in other for other in matched_keys)
131
+ ]
132
+ matches = [issue for key in matched_keys for issue in KNOWN_ISSUES[key]]
133
+
134
+ status = "warning" if matches else "ok"
135
+ # The issue text itself, not just a count - a scorecard reader shouldn't have to
136
+ # go open audit_report_*.json to find out what the curated problem actually is.
137
+ summary = (
138
+ "; ".join(
139
+ f"{m['issue']} ({'applies to ' + m['release'] + '; ' if m.get('release') else ''}{m['citation']})"
140
+ for m in matches
141
+ ) if matches
142
+ else f"no curated known issues for dataset '{name}'"
143
+ )
144
+ return {"check": "known_issue_lookup", "status": status, "summary": summary, "details": {"matches": matches}}
@@ -0,0 +1,92 @@
1
+ """Label-conflict check: identical features, contradictory labels.
2
+
3
+ Independently motivated from three directions: Deepchecks' "Conflicting
4
+ Labels" check (general ML), Northcutt et al. 2021's finding that label
5
+ errors average 3.3% across major ML benchmarks, and Wu & Keogh 2021's
6
+ "mislabeled ground truth" flaw in time-series anomaly benchmarks. A feature
7
+ vector mapped to more than one label is a stronger, more specific claim
8
+ than a duplicate. The ground truth contradicts itself, and no model
9
+ can get both instances right regardless of how well it memorizes.
10
+
11
+ Deliberately scoped like dedup_check: it must run on the RAW data, before
12
+ preprocessing.dedup. dataset.dedup() already compares on features only
13
+ (ignoring the label column, see dataset.dedup's own docstring), so a
14
+ conflicting-label group is exactly the kind of "duplicate" it collapses to
15
+ one arbitrarily-kept row - after dedup, every feature vector maps to
16
+ exactly one label by construction, and this check will correctly report
17
+ zero. That's not the conflict being fixed, just made unobservable; the
18
+ "before" pass is what actually shows it existed.
19
+
20
+ Also attaches a per-class breakdown (details["by_class"]) when flagged:
21
+ the global conflict rate can be driven almost entirely by one attack
22
+ category, invisible in the aggregate count alone.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import pandas as pd
28
+
29
+ from . import _by_class
30
+
31
+
32
+ def check(train_df: pd.DataFrame, test_df: pd.DataFrame, cfg: dict) -> dict:
33
+ schema = cfg["schema"]
34
+ label_col = schema["label_column"]
35
+ group_col = _by_class.group_column(cfg)
36
+ ignore = set(schema["drop_columns"]) | {label_col}
37
+ if schema["attack_category_column"]:
38
+ ignore.add(schema["attack_category_column"])
39
+ compare_cols = [c for c in train_df.columns if c not in ignore]
40
+ carry_cols = [label_col] if group_col == label_col else [label_col, group_col]
41
+
42
+ combined = pd.concat(
43
+ [
44
+ train_df[[*compare_cols, *carry_cols]].assign(_split="train"),
45
+ test_df[[*compare_cols, *carry_cols]].assign(_split="test"),
46
+ ],
47
+ ignore_index=True,
48
+ )
49
+ # Group on a single hashed key rather than all compare_cols directly: pandas'
50
+ # groupby builds an internal combined index proportional to the number of key
51
+ # columns, which turned out to actually exhaust memory on a real ~2.9M-row,
52
+ # 78-feature run (CIC-IDS2017) - a real OOM kill, not a hypothetical concern.
53
+ # Same hash function dataset.py's own content fingerprint already relies on for
54
+ # an equally big-stakes purpose (proving two runs used identical data), so a
55
+ # collision here is the same astronomically unlikely event, not a new risk.
56
+ combined["_key"] = pd.util.hash_pandas_object(combined[compare_cols], index=False).to_numpy()
57
+ combined["_n_labels"] = combined.groupby("_key", sort=False)[label_col].transform("nunique")
58
+ conflicting = combined[combined["_n_labels"] > 1]
59
+
60
+ conflicting_rows = len(conflicting)
61
+ if conflicting_rows == 0:
62
+ return {
63
+ "check": "label_conflict_check", "status": "ok",
64
+ "summary": "no feature vector maps to more than one label", "details": {},
65
+ }
66
+
67
+ conflicting_groups = int(conflicting["_key"].nunique())
68
+ cross_split = int((conflicting.groupby("_key", sort=False)["_split"].nunique() > 1).sum())
69
+ pct = conflicting_rows / len(combined)
70
+
71
+ summary = (
72
+ f"{conflicting_groups:,} feature vector(s) ({conflicting_rows:,} rows, {pct:.2%}) map to more than "
73
+ f"one label. The ground truth contradicts itself for these rows"
74
+ )
75
+ if cross_split:
76
+ summary += f"; {cross_split:,} of these span train and test"
77
+
78
+ conflicting_mask = (combined["_n_labels"] > 1).to_numpy()
79
+ by_class = {}
80
+ for cls in _by_class.eligible_classes(combined, group_col):
81
+ cls_mask = (combined[group_col] == cls).to_numpy()
82
+ by_class[str(cls)] = {"conflicting_rate": float(conflicting_mask[cls_mask].mean())}
83
+
84
+ return {
85
+ "check": "label_conflict_check", "status": "flag", "summary": summary,
86
+ "details": {
87
+ "conflicting_groups": conflicting_groups,
88
+ "conflicting_rows": conflicting_rows,
89
+ "cross_split_conflicting_groups": cross_split,
90
+ "by_class": by_class,
91
+ },
92
+ }
@@ -0,0 +1,99 @@
1
+ """Feature-importance-driven leakage screen (paper Section 3.2).
2
+
3
+ Fits a fast RandomForest, inspects its own feature_importances_ rather
4
+ than guessing candidate leakage columns from domain knowledge, and flags
5
+ a single feature or feature pair carrying a large majority of total
6
+ split importance. Same method that found real leakage features in
7
+ CIC-IDS2018 (Dst Port, Init Win Bytes, FIN Flag Count).
8
+
9
+ When flagged, also attaches a per-class breakdown (details["by_class"]):
10
+ one-vs-rest AUC of the flagged feature's raw values against each class,
11
+ via roc_auc_score directly rather than fitting one classifier per class -
12
+ a numeric feature's own values already are the score, no model needed.
13
+ A class where the feature separates as well as the global fit and one
14
+ where it doesn't tells a different story than the aggregate hides.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import pandas as pd
20
+ from sklearn.ensemble import RandomForestClassifier
21
+ from sklearn.metrics import roc_auc_score
22
+
23
+ from ..data import features
24
+ from . import _by_class
25
+ from ._auc import robust_auc
26
+
27
+ # A single feature carrying more than this share of total split
28
+ # importance is flagged as a specific, checkable shortcut-learning signature.
29
+ TOP1_SHARE_THRESHOLD = 0.5
30
+ TOP2_SHARE_THRESHOLD = 0.7
31
+ MAX_FIT_ROWS = 200_000
32
+
33
+
34
+ def check(train_df: pd.DataFrame, test_df: pd.DataFrame, cfg: dict, seed: int = 0) -> dict:
35
+ """seed varies the sample draw and the classifier's own randomness. The
36
+ default single run is called with cfg["random_seed"] (run_audit's job);
37
+ seed_sensitivity_check separately sweeps its own fixed range of seeds to
38
+ test whether the flag/ok conclusion holds up regardless of that one value.
39
+ """
40
+ label_col = cfg["schema"]["label_column"]
41
+ cols = features.feature_columns(train_df, cfg)
42
+ train_fit = train_df.sample(n=min(len(train_df), MAX_FIT_ROWS), random_state=seed)
43
+ test_fit = test_df
44
+
45
+ x_train, x_test = features.encode_aligned(train_fit, test_fit, cols, unseen="missing")
46
+ y_train, y_test = train_fit[label_col], test_fit[label_col]
47
+
48
+ clf = RandomForestClassifier(n_estimators=100, random_state=seed, n_jobs=-1)
49
+ clf.fit(x_train, y_train)
50
+
51
+ importances = pd.Series(clf.feature_importances_, index=cols).sort_values(ascending=False)
52
+ top1_share = float(importances.iloc[0])
53
+ top2_share = float(importances.iloc[:2].sum())
54
+
55
+ flagged = top1_share > TOP1_SHARE_THRESHOLD or top2_share > TOP2_SHARE_THRESHOLD
56
+ details = {
57
+ "top_features": importances.head(5).to_dict(),
58
+ "top1_share": top1_share,
59
+ "top2_share": top2_share,
60
+ }
61
+
62
+ if flagged:
63
+ top_feature = importances.index[0]
64
+ standalone_auc = _standalone_auc(x_train[[top_feature]], y_train, x_test[[top_feature]], y_test, seed)
65
+ details["standalone_auc"] = {top_feature: standalone_auc}
66
+ details["by_class"] = _by_class_auc(train_fit, x_train[top_feature].to_numpy(), cfg)
67
+ auc_text = "n/a" if standalone_auc is None else f"{standalone_auc:.3f}"
68
+ summary = (
69
+ f"'{top_feature}' carries {top1_share:.1%} of total split importance "
70
+ f"(standalone AUC={auc_text}). Check whether it's a leakage "
71
+ f"artifact rather than attack-behavior signal"
72
+ )
73
+ status = "flag"
74
+ else:
75
+ summary = f"no single feature or pair dominates importance (top1={top1_share:.1%}, top2={top2_share:.1%})"
76
+ status = "ok"
77
+
78
+ return {"check": "leakage_screen", "status": status, "summary": summary, "details": details}
79
+
80
+
81
+ def _standalone_auc(x_train_col, y_train, x_test_col, y_test, seed: int = 0) -> float | None:
82
+ clf = RandomForestClassifier(n_estimators=50, random_state=seed, n_jobs=-1)
83
+ clf.fit(x_train_col, y_train)
84
+ return robust_auc(y_test, clf.predict_proba(x_test_col), clf.classes_)
85
+
86
+
87
+ def _by_class_auc(train_fit: pd.DataFrame, feature_values, cfg: dict) -> dict:
88
+ group_col = _by_class.group_column(cfg)
89
+ by_class = {}
90
+ for cls in _by_class.eligible_classes(train_fit, group_col):
91
+ y_binary = (train_fit[group_col] == cls).to_numpy()
92
+ if not y_binary.any() or y_binary.all():
93
+ continue
94
+ auc = roc_auc_score(y_binary, feature_values)
95
+ # A feature can separate a class either by high values or low values -
96
+ # max() with the inverse reports separability regardless of direction,
97
+ # same convention as a rule-based threshold could use either side.
98
+ by_class[str(cls)] = float(max(auc, 1 - auc))
99
+ return by_class