ids2eval 0.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ids2eval/__init__.py +0 -0
- ids2eval/__main__.py +4 -0
- ids2eval/_json_utils.py +24 -0
- ids2eval/audit/__init__.py +239 -0
- ids2eval/audit/_auc.py +48 -0
- ids2eval/audit/_by_class.py +29 -0
- ids2eval/audit/_checkpoint.py +74 -0
- ids2eval/audit/_fit_score.py +40 -0
- ids2eval/audit/_materiality.py +28 -0
- ids2eval/audit/_status.py +15 -0
- ids2eval/audit/_time.py +14 -0
- ids2eval/audit/artifact_sensitivity.py +88 -0
- ids2eval/audit/class_distribution.py +57 -0
- ids2eval/audit/cross_capture_matrix.py +115 -0
- ids2eval/audit/cross_dataset_drift.py +74 -0
- ids2eval/audit/data_integrity.py +67 -0
- ids2eval/audit/dedup.py +67 -0
- ids2eval/audit/feature_auc_ranking.py +125 -0
- ids2eval/audit/feature_category_ablation.py +98 -0
- ids2eval/audit/flow_group_leakage.py +66 -0
- ids2eval/audit/homogeneity.py +160 -0
- ids2eval/audit/identity_columns.py +171 -0
- ids2eval/audit/known_issues.py +144 -0
- ids2eval/audit/label_conflict.py +92 -0
- ids2eval/audit/leakage.py +99 -0
- ids2eval/audit/near_duplicate_class.py +87 -0
- ids2eval/audit/one_rule.py +82 -0
- ids2eval/audit/port_protocol_shortcut.py +75 -0
- ids2eval/audit/repeated_seed_falsification.py +182 -0
- ids2eval/audit/resplit.py +92 -0
- ids2eval/audit/result_robustness.py +154 -0
- ids2eval/audit/row_order_leakage.py +86 -0
- ids2eval/audit/scenario_holdout.py +115 -0
- ids2eval/audit/schema_fingerprint.py +52 -0
- ids2eval/audit/seed_sensitivity.py +69 -0
- ids2eval/audit/synthetic_realism.py +73 -0
- ids2eval/audit/temporal_leakage.py +58 -0
- ids2eval/audit/temporal_realism.py +81 -0
- ids2eval/cli.py +284 -0
- ids2eval/config.py +362 -0
- ids2eval/dashboard/__init__.py +1 -0
- ids2eval/dashboard/__main__.py +3 -0
- ids2eval/dashboard/server.py +527 -0
- ids2eval/dashboard/static/app.js +1064 -0
- ids2eval/dashboard/static/charts.js +366 -0
- ids2eval/dashboard/static/index.html +33 -0
- ids2eval/dashboard/static/style.css +293 -0
- ids2eval/data/__init__.py +3 -0
- ids2eval/data/cache.py +99 -0
- ids2eval/data/chunked_io.py +387 -0
- ids2eval/data/dataset.py +208 -0
- ids2eval/data/features.py +95 -0
- ids2eval/data/label_grouping.py +38 -0
- ids2eval/modeling/__init__.py +3 -0
- ids2eval/modeling/benchmark.py +360 -0
- ids2eval/modeling/classifiers.py +145 -0
- ids2eval/modeling/preprocessing.py +73 -0
- ids2eval/reporting/__init__.py +2 -0
- ids2eval/reporting/cite.py +57 -0
- ids2eval/reporting/compare_datasets.py +103 -0
- ids2eval/reporting/drift.py +80 -0
- ids2eval/reporting/intervention_manifest.py +83 -0
- ids2eval/reporting/recommend.py +402 -0
- ids2eval/reporting/run_manager.py +184 -0
- ids2eval/reporting/scorecard.py +875 -0
- ids2eval/reporting/scorecard_plot.py +162 -0
- ids2eval/version_info.py +36 -0
- ids2eval-0.2.2.dist-info/METADATA +119 -0
- ids2eval-0.2.2.dist-info/RECORD +73 -0
- ids2eval-0.2.2.dist-info/WHEEL +5 -0
- ids2eval-0.2.2.dist-info/entry_points.txt +4 -0
- ids2eval-0.2.2.dist-info/licenses/LICENSE +661 -0
- ids2eval-0.2.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|
+
}
|