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,86 @@
1
+ """Row-order (sequence) leakage check.
2
+
3
+ Datasets assembled by concatenating scenario-specific collection blocks
4
+ (benign traffic captured first, then one attack type, then the next)
5
+ leave a detectable signature in raw row order: adjacent rows share a
6
+ label far more often than a randomly shuffled arrangement of the same
7
+ label distribution would. Measured via the number of label transitions
8
+ between adjacent rows (a Wald-Wolfowitz runs statistic), compared
9
+ against the closed-form expectation for a shuffled multiset (the
10
+ Simpson-diversity complement, 1 - sum(p_i^2), the exact probability two
11
+ independently drawn labels differ), rather than fitting and evaluating
12
+ a model.
13
+
14
+ A standalone-AUC check, the method identity_column_flag and
15
+ temporal_leakage_check use, does not fit this signal: that method needs
16
+ the same feature to be directly comparable between train and test (a
17
+ shared timestamp, a shared IP space). Row position within one dataframe
18
+ has no such correspondence to row position within another, so train
19
+ and test are each tested against their own row order independently,
20
+ not fit on one and evaluated on the other.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import pandas as pd
26
+
27
+ from ._status import NOT_APPLICABLE
28
+
29
+ # Below this share of the shuffled-baseline transition rate, adjacent rows
30
+ # share a label often enough to be a specific, checkable block-ordering
31
+ # signature, not incidental clustering.
32
+ CONTIGUITY_FLAG_THRESHOLD = 0.3
33
+ CONTIGUITY_WARNING_THRESHOLD = 0.6
34
+
35
+
36
+ def _contiguity_ratio(labels: pd.Series) -> float | None:
37
+ n = len(labels)
38
+ if n < 2:
39
+ return None
40
+ values = labels.to_numpy()
41
+ observed_transitions = int((values[1:] != values[:-1]).sum())
42
+ shares = labels.value_counts(normalize=True).to_numpy()
43
+ expected_rate = 1.0 - float((shares**2).sum())
44
+ if expected_rate <= 0:
45
+ return None # a single class: no transition rate to compare against
46
+ observed_rate = observed_transitions / (n - 1)
47
+ return observed_rate / expected_rate
48
+
49
+
50
+ def check(train_df: pd.DataFrame, test_df: pd.DataFrame, cfg: dict) -> dict:
51
+ label_col = cfg["schema"]["label_column"]
52
+ ratios = {
53
+ "train": _contiguity_ratio(train_df[label_col]),
54
+ "test": _contiguity_ratio(test_df[label_col]),
55
+ }
56
+ valid = {split: ratio for split, ratio in ratios.items() if ratio is not None}
57
+ if not valid:
58
+ return {
59
+ "check": "row_order_leakage_check", "status": NOT_APPLICABLE,
60
+ "summary": "not enough rows or classes in either split to test row order",
61
+ "details": {},
62
+ }
63
+
64
+ worst_split, worst_ratio = min(valid.items(), key=lambda kv: kv[1])
65
+ if worst_ratio < CONTIGUITY_FLAG_THRESHOLD:
66
+ status = "flag"
67
+ elif worst_ratio < CONTIGUITY_WARNING_THRESHOLD:
68
+ status = "warning"
69
+ else:
70
+ status = "ok"
71
+
72
+ ratio_text = ", ".join(f"{split}={ratio:.2f}" for split, ratio in valid.items())
73
+ summary = (
74
+ f"adjacent-row label-transition rate ({ratio_text}) as a share of what a "
75
+ f"randomly shuffled ordering would produce"
76
+ )
77
+ if status != "ok":
78
+ summary += (
79
+ f"; {worst_split} is the most contiguous, consistent with rows still in "
80
+ f"collection order rather than shuffled before this split"
81
+ )
82
+
83
+ return {
84
+ "check": "row_order_leakage_check", "status": status, "summary": summary,
85
+ "details": {"contiguity_ratio": ratios},
86
+ }
@@ -0,0 +1,115 @@
1
+ """Scenario-holdout falsification.
2
+
3
+ resplit_falsification asks whether session-correlated leakage crosses
4
+ a random split's boundary; this asks a different, complementary
5
+ question: does high random-split accuracy survive never having seen an
6
+ entire collection scenario (a capture day, an attacker machine, a
7
+ scenario label, whatever schema.scenario_column names) during training
8
+ at all? Compares random-split accuracy against holding out the single
9
+ scenario value with the fewest rows entirely as test, training on
10
+ every other scenario. A model that only memorized capture-environment
11
+ artifacts (Monday's specific traffic mix, one attacker host's quirks)
12
+ rather than attack behavior in general should collapse on a scenario
13
+ it never trained on; a model that learned the actual behavior should
14
+ not.
15
+
16
+ Some accuracy drop under a genuinely novel scenario is expected even
17
+ from a clean dataset, generalizing to conditions never seen in training
18
+ is a harder problem than a random split of the same conditions, so the
19
+ material-drop threshold here is deliberately looser than
20
+ resplit_falsification's: this test is falsifying "the model learned
21
+ attack behavior," not "there is zero session-correlated leakage."
22
+
23
+ Needs dataset.raw_files (like resplit_falsification, it builds its own
24
+ comparison split from the raw data) and schema.scenario_column; no-ops
25
+ otherwise, since scenario_column has no universal name to detect
26
+ automatically.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from ..data import dataset
32
+ from . import _materiality
33
+ from ._fit_score import fit_and_score
34
+ from ._status import NOT_APPLICABLE
35
+
36
+ # Overridable via audit.materiality_thresholds.scenario_holdout_falsification
37
+ # ({"warning": .., "flag": ..}).
38
+ MATERIAL_DROP_THRESHOLD = 0.10
39
+ WARNING_DROP_THRESHOLD = 0.05
40
+ MIN_HOLDOUT_ROWS = 20
41
+
42
+
43
+ def check(cfg: dict) -> dict:
44
+ dataset_cfg = cfg["dataset"]
45
+ scenario_col = cfg["schema"]["scenario_column"]
46
+ if not scenario_col:
47
+ return {
48
+ "check": "scenario_holdout_falsification", "status": NOT_APPLICABLE,
49
+ "summary": "no schema.scenario_column configured", "details": {},
50
+ }
51
+ if not dataset_cfg["raw_files"]:
52
+ return {
53
+ "check": "scenario_holdout_falsification", "status": NOT_APPLICABLE,
54
+ "summary": "requires dataset.raw_files, not applicable to a pre-split train_file/test_file pair",
55
+ "details": {},
56
+ }
57
+
58
+ seed = cfg["random_seed"]
59
+ label_col = cfg["schema"]["label_column"]
60
+ combined = dataset.load_raw_combined(dataset_cfg, seed=seed)
61
+ if scenario_col not in combined.columns:
62
+ return {
63
+ "check": "scenario_holdout_falsification", "status": NOT_APPLICABLE,
64
+ "summary": f"'{scenario_col}' not found in the loaded data", "details": {},
65
+ }
66
+
67
+ scenario_counts = combined[scenario_col].value_counts()
68
+ if len(scenario_counts) < 2:
69
+ return {
70
+ "check": "scenario_holdout_falsification", "status": NOT_APPLICABLE,
71
+ "summary": "fewer than two distinct scenarios to hold one out against", "details": {},
72
+ }
73
+
74
+ held_out = scenario_counts.index[-1] # the smallest scenario, cheapest to hold out
75
+ held_out_mask = combined[scenario_col] == held_out
76
+ scenario_test = combined[held_out_mask]
77
+ scenario_train = combined[~held_out_mask]
78
+ if len(scenario_test) < MIN_HOLDOUT_ROWS or scenario_train[label_col].nunique() < 2:
79
+ return {
80
+ "check": "scenario_holdout_falsification", "status": NOT_APPLICABLE,
81
+ "summary": f"scenario '{held_out}' has too few rows or labels to test", "details": {},
82
+ }
83
+
84
+ random_train, random_test = dataset._random_split(combined, label_col, dataset_cfg, seed=seed)
85
+ random_acc = fit_and_score(random_train, random_test, label_col, cfg, seed=seed)
86
+ scenario_acc = fit_and_score(scenario_train, scenario_test, label_col, cfg, seed=seed)
87
+ drop = random_acc - scenario_acc
88
+
89
+ tiers = _materiality.threshold(
90
+ cfg, "scenario_holdout_falsification",
91
+ {"warning": WARNING_DROP_THRESHOLD, "flag": MATERIAL_DROP_THRESHOLD},
92
+ )
93
+ if drop > tiers["flag"]:
94
+ status = "flag"
95
+ elif drop > tiers["warning"]:
96
+ status = "warning"
97
+ else:
98
+ status = "ok"
99
+
100
+ summary = (
101
+ f"random-split accuracy={random_acc:.4f}, holding out scenario '{held_out}' entirely: "
102
+ f"accuracy={scenario_acc:.4f} (drop={drop:+.4f})"
103
+ )
104
+ if status == "flag":
105
+ summary += "; accuracy collapses on a scenario never seen in training"
106
+ elif status == "warning":
107
+ summary += "; a real but moderate drop under a novel scenario"
108
+
109
+ return {
110
+ "check": "scenario_holdout_falsification", "status": status, "summary": summary,
111
+ "details": {
112
+ "held_out_scenario": str(held_out), "random_accuracy": random_acc,
113
+ "scenario_accuracy": scenario_acc, "drop": drop,
114
+ },
115
+ }
@@ -0,0 +1,52 @@
1
+ """Known-extractor-bug warning via column-name schema fingerprinting.
2
+
3
+ Not a per-dataset curated issue list (that's the v2 known_issue_lookup
4
+ check), this matches the *extractor tool*'s column-naming signature
5
+ (e.g. CICFlowMeter) and surfaces that tool's documented bugs generally,
6
+ regardless of which specific dataset the columns came from.
7
+
8
+ Extend KNOWN_EXTRACTORS as more extractor signatures get curated.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import pandas as pd
14
+
15
+ KNOWN_EXTRACTORS = {
16
+ "CICFlowMeter": {
17
+ # A handful of column names distinctive enough to fingerprint the
18
+ # tool without requiring every one of its ~80 columns present.
19
+ "signature_columns": {
20
+ "Flow Duration", "Fwd Packet Length Mean", "Bwd Packet Length Mean",
21
+ "Flow Bytes/s", "Flow Packets/s", "Fwd IAT Mean", "Bwd IAT Mean",
22
+ "Init_Win_bytes_forward", "Init_Win_bytes_backward",
23
+ },
24
+ "match_threshold": 0.6,
25
+ "warning": (
26
+ "CICFlowMeter is known to have miscalculated ~34 features in early "
27
+ "releases (Rosay et al. 2021), motivating the corrected LYCOS-IDS2017 "
28
+ "release. Verify which extractor version produced this data before "
29
+ "trusting derived-rate/duration features."
30
+ ),
31
+ },
32
+ }
33
+
34
+
35
+ def check(train_df: pd.DataFrame, cfg: dict) -> dict:
36
+ columns = set(train_df.columns)
37
+ matches = {}
38
+ for name, spec in KNOWN_EXTRACTORS.items():
39
+ overlap = spec["signature_columns"] & columns
40
+ match_share = len(overlap) / len(spec["signature_columns"])
41
+ if match_share >= spec["match_threshold"]:
42
+ matches[name] = {"match_share": match_share, "warning": spec["warning"]}
43
+
44
+ status = "warning" if matches else "ok"
45
+ summary = (
46
+ f"matched extractor signature(s): {list(matches)}" if matches
47
+ else "no known extractor signature matched"
48
+ )
49
+ return {
50
+ "check": "schema_fingerprint_check", "status": status, "summary": summary,
51
+ "details": matches,
52
+ }
@@ -0,0 +1,69 @@
1
+ """Seed-sensitivity check (v2, opt-in).
2
+
3
+ Per D'Amour et al. 2022 (JMLR, "Underspecification Presents Challenges
4
+ for Credibility in Modern Machine Learning"): predictors with identical
5
+ training-domain performance can behave very differently under stress
6
+ tests, purely from iid-performance-preserving perturbations like random
7
+ seed choice. leakage_screen and one_rule_check each fit exactly one
8
+ seeded classifier and report its result as fact; a borderline result
9
+ (say, leakage_screen's top1 share at 0.51 against a 0.5 threshold) could
10
+ flip with a different seed and nobody would know. This check re-fits
11
+ both across a handful of seeds and reports whether their flag/ok
12
+ conclusion is stable, not just their point estimate.
13
+
14
+ Opt-in rather than v1: it refits two classifiers per seed on top of the
15
+ single-seed runs those checks already do, meaningful extra compute on a
16
+ large dataset.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import pandas as pd
22
+
23
+ from . import leakage, one_rule
24
+
25
+ SEEDS = (0, 1, 2, 3, 4)
26
+
27
+
28
+ def check(train_df: pd.DataFrame, test_df: pd.DataFrame, cfg: dict) -> dict:
29
+ leakage_results = [leakage.check(train_df, test_df, cfg, seed=s) for s in SEEDS]
30
+ one_rule_results = [one_rule.check(train_df, test_df, cfg, seed=s) for s in SEEDS]
31
+
32
+ leakage_statuses = sorted({r["status"] for r in leakage_results})
33
+ one_rule_statuses = sorted({r["status"] for r in one_rule_results})
34
+ leakage_top1 = [r["details"]["top1_share"] for r in leakage_results if "top1_share" in r["details"]]
35
+ one_rule_acc = [r["details"]["test_accuracy"] for r in one_rule_results if "test_accuracy" in r["details"]]
36
+
37
+ unstable = []
38
+ if len(leakage_statuses) > 1:
39
+ unstable.append("leakage_screen")
40
+ if len(one_rule_statuses) > 1:
41
+ unstable.append("one_rule_check")
42
+
43
+ parts = []
44
+ if leakage_top1:
45
+ parts.append(
46
+ f"leakage_screen top1 share ranged {min(leakage_top1):.1%}-{max(leakage_top1):.1%} "
47
+ f"across {len(SEEDS)} seeds"
48
+ )
49
+ if one_rule_acc:
50
+ parts.append(
51
+ f"one_rule_check test accuracy ranged {min(one_rule_acc):.1%}-{max(one_rule_acc):.1%} "
52
+ f"across {len(SEEDS)} seeds"
53
+ )
54
+ summary = "; ".join(parts) if parts else "no comparable metric produced by either check across seeds"
55
+ if unstable:
56
+ summary += f". Verdict changed across seeds for: {', '.join(unstable)}"
57
+
58
+ return {
59
+ "check": "seed_sensitivity_check",
60
+ "status": "warning" if unstable else "ok",
61
+ "summary": summary,
62
+ "details": {
63
+ "seeds": list(SEEDS),
64
+ "leakage_screen_statuses": leakage_statuses,
65
+ "one_rule_check_statuses": one_rule_statuses,
66
+ "leakage_screen_top1_share_range": [min(leakage_top1), max(leakage_top1)] if leakage_top1 else None,
67
+ "one_rule_check_accuracy_range": [min(one_rule_acc), max(one_rule_acc)] if one_rule_acc else None,
68
+ },
69
+ }
@@ -0,0 +1,73 @@
1
+ """Synthetic-vs-real distributional realism check (v2, opt-in).
2
+
3
+ Per Layeghy et al. 2021 and Catillo et al. 2021: lab-generated/synthetic
4
+ IDS traffic can diverge from real network traffic in ways that make a
5
+ dataset's near-perfect classifier accuracy an artifact of unrepresentative
6
+ data rather than a real capability. Needs a reference real-traffic sample
7
+ (audit.reference_dataset) to compare against: there's no way to detect
8
+ "unrealistic compared to what" without something to compare to.
9
+
10
+ Method: a domain classifier trained to distinguish this dataset's rows
11
+ from the reference sample's rows. High separability (AUC) means the two
12
+ are easily distinguishable, a specific, checkable signature of
13
+ distributional divergence, not merely "different datasets differ."
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import pandas as pd
19
+ from scipy.stats import ks_2samp
20
+ from sklearn.ensemble import RandomForestClassifier
21
+ from sklearn.metrics import roc_auc_score
22
+ from sklearn.model_selection import train_test_split
23
+
24
+ from ..data import chunked_io, features
25
+
26
+ DOMAIN_AUC_FLAG_THRESHOLD = 0.9
27
+ MAX_ROWS_PER_SIDE = 50_000
28
+
29
+
30
+ def check(train_df: pd.DataFrame, cfg: dict) -> dict:
31
+ dataset_cfg = cfg["dataset"]
32
+ ref_df = chunked_io.load_file(
33
+ cfg["audit"]["reference_dataset"], dataset_cfg["chunk_size"], dataset_cfg["max_rows"]
34
+ )
35
+
36
+ cols = [c for c in features.feature_columns(train_df, cfg) if c in ref_df.columns]
37
+ if not cols:
38
+ return {
39
+ "check": "synthetic_realism_check", "status": "warning",
40
+ "summary": "no feature columns in common with reference_dataset", "details": {},
41
+ }
42
+
43
+ seed = cfg["random_seed"]
44
+ ours = train_df.sample(n=min(len(train_df), MAX_ROWS_PER_SIDE), random_state=seed)
45
+ ref = ref_df.sample(n=min(len(ref_df), MAX_ROWS_PER_SIDE), random_state=seed)
46
+
47
+ ours_enc, (ref_enc,) = features.encode_multi(ours, [ref], cols)
48
+ x = pd.concat([ours_enc, ref_enc], ignore_index=True)
49
+ y = pd.Series([0] * len(ours_enc) + [1] * len(ref_enc))
50
+
51
+ x_train, x_test, y_train, y_test = train_test_split(x, y, test_size=0.3, random_state=seed, stratify=y)
52
+ clf = RandomForestClassifier(n_estimators=100, random_state=seed, n_jobs=-1)
53
+ clf.fit(x_train, y_train)
54
+ domain_auc = float(roc_auc_score(y_test, clf.predict_proba(x_test)[:, 1]))
55
+
56
+ ks_scores = {}
57
+ for col in cols:
58
+ if pd.api.types.is_numeric_dtype(ours[col]):
59
+ stat, _ = ks_2samp(ours[col].dropna(), ref[col].dropna())
60
+ ks_scores[col] = float(stat)
61
+ top_divergent = dict(sorted(ks_scores.items(), key=lambda kv: -kv[1])[:5])
62
+
63
+ flagged = domain_auc > DOMAIN_AUC_FLAG_THRESHOLD
64
+ status = "flag" if flagged else "ok"
65
+ summary = (
66
+ f"domain classifier AUC={domain_auc:.3f} distinguishing this dataset from "
67
+ f"reference_dataset (>{DOMAIN_AUC_FLAG_THRESHOLD} flagged as easily separable); "
68
+ f"most divergent features (KS statistic): {top_divergent}"
69
+ )
70
+ return {
71
+ "check": "synthetic_realism_check", "status": status, "summary": summary,
72
+ "details": {"domain_auc": domain_auc, "ks_by_feature": ks_scores},
73
+ }
@@ -0,0 +1,58 @@
1
+ """Timestamp-only leakage check.
2
+
3
+ Per Wu & Keogh 2021's "run-to-failure bias" (IEEE TKDE, time-series
4
+ anomaly detection): several widely-used benchmarks were collected such
5
+ that anomalies cluster near the end of the recording, so a model can win
6
+ by learning "predict positive late" rather than the anomaly itself. NIDS
7
+ datasets built as a sequence of scenario-specific time windows (attack X
8
+ launched 2-3pm, attack Y launched 3-4pm, ...) have the same structure -
9
+ independently confirmed against this project's own real CIC-IDS2018 run,
10
+ where a RandomForest given nothing but the Timestamp column reached
11
+ AUC 0.93 predicting benign vs. attack. Same method as identity_column_flag
12
+ (a standalone classifier on one column), scoped to whichever column
13
+ schema.timestamp_column names, since there's no universal column name to
14
+ detect automatically the way id_like_columns are user-declared too.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import pandas as pd
20
+ from sklearn.ensemble import RandomForestClassifier
21
+
22
+ from ._auc import robust_auc
23
+ from ._status import NOT_APPLICABLE
24
+ from ._time import to_numeric_time
25
+
26
+ AUC_FLAG_THRESHOLD = 0.8
27
+ MAX_FIT_ROWS = 200_000
28
+
29
+
30
+ def check(train_df: pd.DataFrame, test_df: pd.DataFrame, cfg: dict) -> dict:
31
+ col = cfg["schema"]["timestamp_column"]
32
+ label_col = cfg["schema"]["label_column"]
33
+ if not col or col not in train_df.columns:
34
+ return {
35
+ "check": "temporal_leakage_check", "status": NOT_APPLICABLE,
36
+ "summary": "no schema.timestamp_column configured", "details": {},
37
+ }
38
+
39
+ train_fit = train_df.sample(n=min(len(train_df), MAX_FIT_ROWS), random_state=cfg["random_seed"])
40
+ x_train = to_numeric_time(train_fit[col]).fillna(0).to_numpy().reshape(-1, 1)
41
+ x_test = to_numeric_time(test_df[col]).fillna(0).to_numpy().reshape(-1, 1)
42
+ y_train, y_test = train_fit[label_col], test_df[label_col]
43
+
44
+ clf = RandomForestClassifier(n_estimators=50, random_state=cfg["random_seed"], n_jobs=-1)
45
+ clf.fit(x_train, y_train)
46
+ auc = robust_auc(y_test, clf.predict_proba(x_test), clf.classes_)
47
+
48
+ flagged = auc is not None and auc > AUC_FLAG_THRESHOLD
49
+ status = "flag" if flagged else "ok"
50
+ auc_text = "n/a" if auc is None else f"{auc:.3f}"
51
+ summary = f"standalone AUC of '{col}' alone: {auc_text}"
52
+ if flagged:
53
+ summary += ". Timestamp alone may predict the label without learning attack behavior"
54
+
55
+ return {
56
+ "check": "temporal_leakage_check", "status": status, "summary": summary,
57
+ "details": {"column": col, "standalone_auc": auc},
58
+ }
@@ -0,0 +1,81 @@
1
+ """Temporal-window realism check.
2
+
3
+ Complements temporal_leakage_check's single aggregate AUC number with a
4
+ per-class breakdown: how much of the full capture's time span does
5
+ each attack type's own traffic actually cover? A class confined to a
6
+ narrow burst window relative to the whole capture (attack X launched
7
+ for ten minutes out of a five-day capture) is a specific, checkable
8
+ scenario-window artifact. Visible here even when the aggregate
9
+ standalone-timestamp AUC isn't dramatically high, because most other
10
+ classes are well spread across the full period and only one narrow
11
+ class is actually driving the risk.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import pandas as pd
17
+
18
+ from . import _by_class
19
+ from ._status import NOT_APPLICABLE
20
+ from ._time import to_numeric_time
21
+
22
+ # Below this share of the full capture's time span, a class's own traffic is
23
+ # confined to a narrow enough window that timestamp alone could stand in for
24
+ # it, a specific, checkable scenario-window artifact.
25
+ BURST_FLAG_THRESHOLD = 0.05
26
+ BURST_WARNING_THRESHOLD = 0.15
27
+
28
+
29
+ def check(train_df: pd.DataFrame, cfg: dict) -> dict:
30
+ col = cfg["schema"]["timestamp_column"]
31
+ if not col or col not in train_df.columns:
32
+ return {
33
+ "check": "temporal_realism_check", "status": NOT_APPLICABLE,
34
+ "summary": "no schema.timestamp_column configured", "details": {},
35
+ }
36
+
37
+ times = to_numeric_time(train_df[col])
38
+ valid = times.notna()
39
+ if not valid.any():
40
+ return {
41
+ "check": "temporal_realism_check", "status": NOT_APPLICABLE,
42
+ "summary": "no usable timestamp values to test", "details": {},
43
+ }
44
+ overall_span = times[valid].max() - times[valid].min()
45
+ if not overall_span or overall_span <= 0:
46
+ return {
47
+ "check": "temporal_realism_check", "status": NOT_APPLICABLE,
48
+ "summary": "timestamp column has no usable time span to test", "details": {},
49
+ }
50
+
51
+ group_col = _by_class.group_column(cfg)
52
+ span_by_class = {}
53
+ for cls in _by_class.eligible_classes(train_df, group_col):
54
+ mask = (train_df[group_col] == cls) & valid
55
+ if mask.sum() < _by_class.MIN_CLASS_SIZE:
56
+ continue
57
+ cls_span = times[mask].max() - times[mask].min()
58
+ span_by_class[str(cls)] = float(cls_span / overall_span)
59
+
60
+ if not span_by_class:
61
+ return {
62
+ "check": "temporal_realism_check", "status": NOT_APPLICABLE,
63
+ "summary": "not enough valid timestamps per class to test", "details": {},
64
+ }
65
+
66
+ worst_cls, worst_ratio = min(span_by_class.items(), key=lambda kv: kv[1])
67
+ if worst_ratio < BURST_FLAG_THRESHOLD:
68
+ status = "flag"
69
+ elif worst_ratio < BURST_WARNING_THRESHOLD:
70
+ status = "warning"
71
+ else:
72
+ status = "ok"
73
+
74
+ summary = f"narrowest class's own time span: '{worst_cls}' covers {worst_ratio:.1%} of the full capture window"
75
+ if status != "ok":
76
+ summary += "; timestamp alone may act as a proxy for this class rather than its actual traffic behavior"
77
+
78
+ return {
79
+ "check": "temporal_realism_check", "status": status, "summary": summary,
80
+ "details": {"span_ratio_by_class": span_by_class},
81
+ }