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,87 @@
1
+ """Near-duplicate feature vectors across different classes.
2
+
3
+ label_conflict_check catches identical features mapped to different
4
+ labels. This extends that to features close enough in scaled distance
5
+ to be effectively identical without matching exactly, the common case
6
+ when the same traffic-generation script produced two nominally
7
+ different attack labels, or two attack tools share almost all of their
8
+ flow statistics. Runs on train only: this is about label confusability
9
+ within the data a model actually learns from, not a train/test split
10
+ property (that's homogeneity_test's job).
11
+
12
+ Same nearest-neighbor machinery and near-zero distance threshold as
13
+ homogeneity_test, applied across classes rather than across the split.
14
+ A single NearestNeighbors index over all sampled rows, queried once,
15
+ avoids an O(classes^2) pairwise comparison.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import numpy as np
21
+ import pandas as pd
22
+ from sklearn.neighbors import NearestNeighbors
23
+ from sklearn.preprocessing import StandardScaler
24
+
25
+ from ..data import features
26
+ from ._status import NOT_APPLICABLE
27
+
28
+ SAMPLE_SIZE = 500
29
+ MIN_CLASS_SIZE = 20
30
+ NEAR_ZERO_DISTANCE = 1e-6
31
+
32
+
33
+ def check(train_df: pd.DataFrame, cfg: dict) -> dict:
34
+ schema = cfg["schema"]
35
+ group_col = schema["attack_category_column"] or schema["label_column"]
36
+ cols = features.feature_columns(train_df, cfg)
37
+
38
+ class_counts = train_df[group_col].value_counts()
39
+ eligible = class_counts[class_counts >= MIN_CLASS_SIZE].index
40
+ if len(eligible) < 2:
41
+ return {
42
+ "check": "near_duplicate_class_check", "status": NOT_APPLICABLE,
43
+ "summary": "fewer than two classes have enough rows to compare", "details": {},
44
+ }
45
+
46
+ rng = np.random.RandomState(0)
47
+ sample_parts = []
48
+ for cls in eligible:
49
+ idx = np.flatnonzero((train_df[group_col] == cls).to_numpy())
50
+ chosen = rng.choice(idx, size=min(SAMPLE_SIZE, len(idx)), replace=False)
51
+ sample_parts.append(train_df.iloc[chosen])
52
+ sample_df = pd.concat(sample_parts, ignore_index=True)
53
+
54
+ x_raw = features.encode_aligned(sample_df, sample_df, cols)[0]
55
+ x = StandardScaler().fit_transform(x_raw)
56
+ labels = sample_df[group_col].to_numpy()
57
+
58
+ nn = NearestNeighbors(n_neighbors=2, algorithm="auto", n_jobs=-1).fit(x)
59
+ dist, idx = nn.kneighbors(x)
60
+ nearest_dist, nearest_idx = dist[:, 1], idx[:, 1] # excluding self
61
+
62
+ cross_label = labels != labels[nearest_idx]
63
+ near_zero = nearest_dist < NEAR_ZERO_DISTANCE
64
+ confused = cross_label & near_zero
65
+
66
+ if not confused.any():
67
+ return {
68
+ "check": "near_duplicate_class_check", "status": "ok",
69
+ "summary": f"no near-zero-distance feature vectors found across {len(eligible)} classes tested",
70
+ "details": {"classes_tested": len(eligible)},
71
+ }
72
+
73
+ pairs = pd.Series(
74
+ [tuple(sorted((a, b))) for a, b in zip(labels[confused], labels[nearest_idx[confused]], strict=True)]
75
+ ).value_counts()
76
+ top_pairs = {f"{a} / {b}": int(n) for (a, b), n in pairs.head(5).items()}
77
+ affected_rows = int(confused.sum())
78
+ pct = affected_rows / len(sample_df)
79
+
80
+ summary = (
81
+ f"{affected_rows:,} of {len(sample_df):,} sampled rows ({pct:.2%}) have a near-zero-distance "
82
+ f"neighbor under a different label. Most affected pairs: {top_pairs}"
83
+ )
84
+ return {
85
+ "check": "near_duplicate_class_check", "status": "flag", "summary": summary,
86
+ "details": {"classes_tested": len(eligible), "affected_rows": affected_rows, "top_pairs": top_pairs},
87
+ }
@@ -0,0 +1,82 @@
1
+ """One-rule ("one-liner") solvability check.
2
+
3
+ Per Wu & Keogh 2021 (IEEE TKDE): most widely-used time-series anomaly
4
+ benchmarks (Yahoo, NASA, Numenta) are largely solvable by a single line of
5
+ code - one feature compared to one threshold - which means published
6
+ comparisons of sophisticated algorithms on them are measuring nothing more
7
+ than who found the same threshold. leakage_screen already reports that a
8
+ feature carries most of a random forest's importance, but a share-of-
9
+ importance number is abstract; this check names the winning rule in plain
10
+ language ("Dst Port <= 1024.5"), which is far harder to argue with. A
11
+ depth-1 decision tree (a "stump") is exactly a brute-force search over
12
+ every feature and threshold for the single best split, done in one cheap
13
+ fit rather than a manual loop.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import pandas as pd
19
+ from sklearn.tree import DecisionTreeClassifier
20
+
21
+ from ..data import features
22
+ from ._status import NOT_APPLICABLE
23
+
24
+ # Wu & Keogh don't give a numeric cutoff (their test is "can a human write this in
25
+ # one line"); a one-rule accuracy this high is the tabular-data equivalent - a
26
+ # threshold on a single feature is about as simple a rule as exists.
27
+ ACCURACY_FLAG_THRESHOLD = 0.95
28
+ # A rule must also remove at least this share of the errors made by always
29
+ # predicting the training majority class: on a 99:1 dataset with no signal, any
30
+ # stump scores ~99% by predicting the majority, which says nothing about shortcuts.
31
+ MIN_ERROR_REDUCTION = 0.5
32
+ MAX_FIT_ROWS = 200_000
33
+
34
+
35
+ def check(train_df: pd.DataFrame, test_df: pd.DataFrame, cfg: dict, seed: int = 0) -> dict:
36
+ """seed varies the sample draw and the stump's own randomness. The
37
+ default single run is called with cfg["random_seed"] (run_audit's job);
38
+ seed_sensitivity_check separately sweeps its own fixed range of seeds to
39
+ test whether the flag/ok conclusion holds up regardless of that one value.
40
+ """
41
+ label_col = cfg["schema"]["label_column"]
42
+ cols = features.feature_columns(train_df, cfg)
43
+ train_fit = train_df.sample(n=min(len(train_df), MAX_FIT_ROWS), random_state=seed)
44
+ x_train, x_test = features.encode_aligned(train_fit, test_df, cols, unseen="missing")
45
+ y_train, y_test = train_fit[label_col], test_df[label_col]
46
+
47
+ stump = DecisionTreeClassifier(max_depth=1, random_state=seed)
48
+ stump.fit(x_train, y_train)
49
+ tree = stump.tree_
50
+ root_feature = tree.feature[0]
51
+
52
+ if root_feature < 0: # -2: the root is already a leaf - e.g. a single-class split
53
+ return {
54
+ "check": "one_rule_check", "status": NOT_APPLICABLE,
55
+ "summary": "no single-feature rule found (fewer than two classes in the fitted sample)",
56
+ "details": {},
57
+ }
58
+
59
+ rule = f"{x_train.columns[root_feature]} <= {tree.threshold[0]:.4g}"
60
+ train_acc = float(stump.score(x_train, y_train))
61
+ test_acc = float(stump.score(x_test, y_test))
62
+
63
+ majority = y_train.value_counts().idxmax()
64
+ baseline_acc = float((y_test == majority).mean())
65
+ error_reduction = (test_acc - baseline_acc) / (1 - baseline_acc) if baseline_acc < 1 else 0.0
66
+
67
+ flagged = test_acc > ACCURACY_FLAG_THRESHOLD and error_reduction >= MIN_ERROR_REDUCTION
68
+ status = "flag" if flagged else "ok"
69
+ summary = (
70
+ f"single rule '{rule}' reaches {test_acc:.1%} test accuracy ({train_acc:.1%} train), "
71
+ f"vs. {baseline_acc:.1%} from always predicting the majority class"
72
+ )
73
+ if flagged:
74
+ summary += ". This problem may be solvable without learning attack behavior"
75
+
76
+ return {
77
+ "check": "one_rule_check", "status": status, "summary": summary,
78
+ "details": {
79
+ "rule": rule, "train_accuracy": train_acc, "test_accuracy": test_acc,
80
+ "majority_baseline_accuracy": baseline_acc, "error_reduction_vs_baseline": error_reduction,
81
+ },
82
+ }
@@ -0,0 +1,75 @@
1
+ """Combined port+protocol shortcut check.
2
+
3
+ identity_column_flag already tests each id_like_columns entry alone, but
4
+ destination port and protocol together are a specific, well-known NIDS
5
+ shortcut: a handful of (port, protocol) pairs (80/TCP, 443/TCP, 53/UDP,
6
+ ...) route almost all benign traffic, and attack tools often target one
7
+ fixed port, so the pair can separate classes a single column understates,
8
+ generalizing across CIC, UNSW, and ToN-IoT-style datasets alike (the same
9
+ families where identity_column_flag already flags port alone). This
10
+ check quantifies that combined signal specifically, rather than leaving
11
+ it as an unmeasured gap between "one column alone" and "the full feature
12
+ set."
13
+
14
+ Needs no new config field: a port-like column is whichever entry in
15
+ schema.id_like_columns has "port" in its name (case-insensitive), and a
16
+ protocol-like column is whichever loaded column has "proto" in its name
17
+ (case-insensitive) - protocol is ordinarily a plain feature, not
18
+ id_like, so it is looked for across all columns rather than restricted
19
+ to id_like_columns. No-ops if either is missing.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import pandas as pd
25
+ from sklearn.ensemble import RandomForestClassifier
26
+
27
+ from ._auc import robust_auc
28
+ from ._status import NOT_APPLICABLE
29
+
30
+ AUC_FLAG_THRESHOLD = 0.8
31
+
32
+
33
+ def _find_column(candidates: list[str], needle: str) -> str | None:
34
+ for c in candidates:
35
+ if needle in c.lower():
36
+ return c
37
+ return None
38
+
39
+
40
+ def check(train_df: pd.DataFrame, test_df: pd.DataFrame, cfg: dict) -> dict:
41
+ id_cols = [c for c in cfg["schema"]["id_like_columns"] if c in train_df.columns]
42
+ port_col = _find_column(id_cols, "port")
43
+ proto_col = _find_column(list(train_df.columns), "proto")
44
+
45
+ if not port_col or not proto_col:
46
+ return {
47
+ "check": "port_protocol_shortcut_check", "status": NOT_APPLICABLE,
48
+ "summary": (
49
+ "no port-like entry in schema.id_like_columns and/or no proto-like "
50
+ "column found, this check needs both"
51
+ ),
52
+ "details": {"port_column": port_col, "protocol_column": proto_col},
53
+ }
54
+
55
+ label_col = cfg["schema"]["label_column"]
56
+ cols = [port_col, proto_col]
57
+ categories = {c: pd.Index(train_df[c].astype(str).unique()) for c in cols}
58
+ x_train = pd.DataFrame({c: categories[c].get_indexer(train_df[c].astype(str)) for c in cols})
59
+ x_test = pd.DataFrame({c: categories[c].get_indexer(test_df[c].astype(str)) for c in cols})
60
+
61
+ clf = RandomForestClassifier(n_estimators=50, random_state=cfg["random_seed"], n_jobs=-1)
62
+ clf.fit(x_train, train_df[label_col])
63
+ auc = robust_auc(test_df[label_col], clf.predict_proba(x_test), clf.classes_)
64
+
65
+ flagged = auc is not None and auc > AUC_FLAG_THRESHOLD
66
+ status = "flag" if flagged else "ok"
67
+ auc_text = "n/a" if auc is None else f"{auc:.3f}"
68
+ summary = f"standalone AUC of '{port_col}' + '{proto_col}' combined: {auc_text}"
69
+ if flagged:
70
+ summary += ". Port and protocol together may predict the label without attack behavior"
71
+
72
+ return {
73
+ "check": "port_protocol_shortcut_check", "status": status, "summary": summary,
74
+ "details": {"port_column": port_col, "protocol_column": proto_col, "standalone_auc": auc},
75
+ }
@@ -0,0 +1,182 @@
1
+ """Repeated-seed resplit falsification (v2, opt-in).
2
+
3
+ resplit_falsification (Section 3.5) reports one random-split accuracy and
4
+ one grouped-split accuracy, each from a single seed. That single comparison
5
+ cannot itself distinguish "this gap is genuinely small" from "a different
6
+ seed for either split would show a materially different gap purely from
7
+ sampling variance" - exactly the limitation this project's own audit paper
8
+ disclosed and left as its most important piece of undone follow-up work.
9
+
10
+ This check closes that gap by repeating the same random-vs-grouped
11
+ comparison across several independent seeds (each seed reshuffles both the
12
+ random split and the StratifiedGroupKFold shuffle within the grouped split,
13
+ and refits a freshly-seeded RandomForest for each), then reports the mean
14
+ drop and a 95% confidence interval, using Student's t-distribution rather
15
+ than a normal approximation since n_seeds is deliberately small (5-10, not
16
+ hundreds - each additional seed is a full pair of RandomForest refits on
17
+ the whole raw dataset).
18
+
19
+ Materiality classification compares the CI against the SAME frozen
20
+ threshold resplit_falsification itself uses (audit.materiality_thresholds.
21
+ repeated_seed_falsification_check, defaulting to resplit_falsification's own
22
+ default), via a three-way rule rather than a single point-estimate cutoff:
23
+
24
+ material if the CI's lower bound is still above the threshold
25
+ (even the most conservative estimate says it matters)
26
+ not material if the CI's upper bound is below the threshold
27
+ (even the most generous estimate says it doesn't)
28
+ inconclusive if the CI straddles the threshold (n_seeds isn't enough
29
+ to tell, one way or the other - reported as its own
30
+ outcome, not silently folded into "ok", per this
31
+ project's own statistical-analysis-plan rule: never
32
+ infer "no effect" merely because a test isn't
33
+ significant)
34
+
35
+ Same preconditions as resplit_falsification: needs dataset.raw_files and
36
+ dataset.group_columns, and reloads the raw data itself, so its result
37
+ cannot depend on preprocessing.dedup either (see STRUCTURAL_CHECKS).
38
+
39
+ If audit.parallel_checks is set, the remaining (not yet checkpointed) seeds
40
+ run concurrently via a thread pool instead of one at a time - this check is
41
+ n_seeds independent full refits, so it's the single biggest reason a config
42
+ enabling it alongside only 1-2 other checks can still leave most of a
43
+ multi-vCPU VM idle if only the OUTER per-check dispatch were parallelized.
44
+ Never combined with checkpoint_path: per-seed checkpointing and concurrent
45
+ seeds are mutually exclusive at the config level (audit.checkpoint and
46
+ audit.parallel_checks can't both be true) for the same reason run_audit's
47
+ outer dispatch keeps them apart - concurrent writers to one checkpoint file
48
+ would race.
49
+
50
+ Accepts an already-loaded `combined` DataFrame so run_audit can share one
51
+ load across this, resplit_falsification, and result_robustness_check when
52
+ several run together, instead of each reloading the full raw dataset from
53
+ disk - a real cost at full scale (a real failure this was built to avoid:
54
+ a 70M-row config running all three of these checks was reloading the same
55
+ data three times sequentially, contributing to repeated VM-level
56
+ instability on a long run). Still loads it itself when called standalone,
57
+ so this is purely an optimization, not a behavior change.
58
+ """
59
+
60
+ from __future__ import annotations
61
+
62
+ import concurrent.futures
63
+ from statistics import mean, stdev
64
+
65
+ import pandas as pd
66
+ from scipy import stats
67
+
68
+ from ..data import dataset
69
+ from . import _checkpoint, _materiality
70
+ from ._fit_score import fit_and_score
71
+ from ._status import NOT_APPLICABLE
72
+ from .resplit import MATERIAL_DROP_THRESHOLD
73
+
74
+ DEFAULT_N_SEEDS = 10
75
+ MIN_N_SEEDS = 3 # below this, a t-based CI is too unstable to report meaningfully
76
+ _CHECK_NAME = "repeated_seed_falsification_check"
77
+
78
+
79
+ def check(
80
+ cfg: dict, n_seeds: int | None = None, checkpoint_path: str | None = None,
81
+ combined: pd.DataFrame | None = None,
82
+ ) -> dict:
83
+ dataset_cfg = cfg["dataset"]
84
+ if n_seeds is None:
85
+ n_seeds = cfg["audit"]["repeated_seed_count"]
86
+ if n_seeds < MIN_N_SEEDS:
87
+ return {
88
+ "check": _CHECK_NAME, "status": NOT_APPLICABLE,
89
+ "summary": f"audit.repeated_seed_count ({n_seeds}) is below the minimum of "
90
+ f"{MIN_N_SEEDS} needed for a meaningful confidence interval",
91
+ "details": {},
92
+ }
93
+
94
+ label_col = cfg["schema"]["label_column"]
95
+
96
+ # Resume from any seeds already checkpointed (e.g. a prior run of this same
97
+ # checkpoint_path that crashed partway through) rather than redoing them -
98
+ # this check alone can dominate a full-scale run's cost, so losing all
99
+ # progress to a mid-sweep crash is exactly the failure mode worth avoiding.
100
+ completed: dict[int, float] = (
101
+ _checkpoint.get_seed_progress(checkpoint_path, _CHECK_NAME) if checkpoint_path else {}
102
+ )
103
+ drops = [completed[s] for s in sorted(completed) if s < n_seeds]
104
+ start_seed = len(drops)
105
+
106
+ remaining_seeds = list(range(start_seed, n_seeds))
107
+ if combined is None and remaining_seeds:
108
+ combined = dataset.load_raw_combined(dataset_cfg, seed=cfg["random_seed"])
109
+
110
+ def run_seed(seed: int) -> float:
111
+ # Scored one split at a time, not both built up front - see resplit.check's
112
+ # own comment: holding combined plus both split pairs at once is the
113
+ # dominant memory cost at full scale, and this runs n_seeds times.
114
+ random_train, random_test = dataset._random_split(combined, label_col, dataset_cfg, seed=seed)
115
+ random_acc = fit_and_score(random_train, random_test, label_col, cfg, seed=seed)
116
+ del random_train, random_test
117
+
118
+ grouped_train, grouped_test = dataset._grouped_split(combined, label_col, dataset_cfg, seed=seed)
119
+ grouped_acc = fit_and_score(grouped_train, grouped_test, label_col, cfg, seed=seed)
120
+ del grouped_train, grouped_test
121
+ return random_acc - grouped_acc
122
+
123
+ if cfg["audit"]["parallel_checks"] and not checkpoint_path and len(remaining_seeds) > 1:
124
+ max_workers = min(cfg["audit"]["max_parallel_checks"], len(remaining_seeds))
125
+ with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as pool:
126
+ # map preserves input order in its results regardless of completion order,
127
+ # so drops_by_seed stays seed-ordered the same as the sequential path.
128
+ drops.extend(pool.map(run_seed, remaining_seeds))
129
+ else:
130
+ for seed in remaining_seeds:
131
+ drop = run_seed(seed)
132
+ drops.append(drop)
133
+ if checkpoint_path:
134
+ _checkpoint.save_seed_progress(checkpoint_path, _CHECK_NAME, seed, drop)
135
+
136
+ mean_drop = mean(drops)
137
+ sample_std = stdev(drops) # n_seeds >= MIN_N_SEEDS >= 2, safe for stdev's ddof=1
138
+ # Two-sided 95% CI for the mean via the t-distribution (n_seeds - 1 degrees of
139
+ # freedom), appropriate for this few independent repeats rather than a normal
140
+ # approximation that would understate the uncertainty at this sample size.
141
+ margin = stats.t.ppf(0.975, df=n_seeds - 1) * sample_std / (n_seeds ** 0.5)
142
+ ci_low, ci_high = mean_drop - margin, mean_drop + margin
143
+
144
+ threshold = _materiality.threshold(
145
+ cfg, "repeated_seed_falsification_check", MATERIAL_DROP_THRESHOLD,
146
+ )
147
+ if ci_low > threshold:
148
+ material, status = True, "flag"
149
+ verdict = (
150
+ f"material: even the most conservative estimate in the 95% CI ({ci_low:+.4f}) "
151
+ f"exceeds the {threshold:+.4f} materiality threshold"
152
+ )
153
+ elif ci_high < threshold:
154
+ material, status = False, "ok"
155
+ verdict = (
156
+ f"not material: even the most generous estimate in the 95% CI ({ci_high:+.4f}) "
157
+ f"stays below the {threshold:+.4f} materiality threshold"
158
+ )
159
+ else:
160
+ material, status = None, "warning"
161
+ verdict = (
162
+ f"inconclusive: the 95% CI [{ci_low:+.4f}, {ci_high:+.4f}] straddles the "
163
+ f"{threshold:+.4f} materiality threshold; {n_seeds} seeds aren't enough to "
164
+ "tell whether this gap is real or sampling noise"
165
+ )
166
+
167
+ return {
168
+ "check": _CHECK_NAME,
169
+ "status": status,
170
+ "summary": (
171
+ f"grouped-split accuracy drop across {n_seeds} seeds: mean={mean_drop:+.4f}, "
172
+ f"95% CI=[{ci_low:+.4f}, {ci_high:+.4f}]; {verdict}"
173
+ ),
174
+ "details": {
175
+ "n_seeds": n_seeds,
176
+ "drops_by_seed": drops,
177
+ "mean_drop": mean_drop,
178
+ "ci_95": [ci_low, ci_high],
179
+ "materiality_threshold": threshold,
180
+ "material": material,
181
+ },
182
+ }
@@ -0,0 +1,92 @@
1
+ """Resplit falsification test (paper Section 3.5).
2
+
3
+ The nearest-neighbor test alone can't distinguish "this class is
4
+ inherently homogeneous" from "a different split would reveal real
5
+ difficulty that random sampling happens to hide", both hypotheses look
6
+ identical under random sampling. Constructing a non-random split along a
7
+ dimension the random split ignores (session/group identity) and
8
+ observing whether accuracy changes breaks that symmetry: if
9
+ session-correlated leakage across the random split's boundary had been
10
+ doing real work, the grouped split should cost measurable accuracy.
11
+
12
+ Independent of whichever split_mode is configured for the main run:
13
+ always builds both splits from the raw data to compare them directly.
14
+
15
+ Accepts an already-loaded `combined` DataFrame so run_audit can share one
16
+ load across this, scenario_holdout_falsification, result_robustness_check,
17
+ and repeated_seed_falsification_check when several run together - each
18
+ independently reloading the full raw dataset from disk is a real cost at
19
+ full scale (a real failure this was built to avoid: a 70M-row config
20
+ running all three of these checks was reloading the same data three times
21
+ sequentially, contributing to repeated VM-level instability on a long run).
22
+ Still loads it itself when called standalone (e.g. every existing direct
23
+ unit test), so this is purely an optimization, not a behavior change.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import pandas as pd
29
+
30
+ from ..data import dataset
31
+ from . import _materiality
32
+ from ._fit_score import fit_and_score
33
+
34
+ # A grouped-split accuracy drop below this is read as "structurally
35
+ # ruling out session-correlated leakage as the explanation," not proof
36
+ # of zero leakage of any kind. Overridable via audit.materiality_thresholds.
37
+ # resplit_falsification.
38
+ MATERIAL_DROP_THRESHOLD = 0.01
39
+
40
+
41
+ def check(cfg: dict, seed: int = 0, combined: pd.DataFrame | None = None) -> dict:
42
+ dataset_cfg = cfg["dataset"]
43
+ label_col = cfg["schema"]["label_column"]
44
+ if combined is None:
45
+ combined = dataset.load_raw_combined(dataset_cfg, seed=cfg["random_seed"])
46
+
47
+ # Scored one split at a time, not both built up front: at full scale (hundreds
48
+ # of millions of rows) holding combined plus both split pairs simultaneously -
49
+ # five full-size DataFrames at once - is itself the dominant memory cost, a
50
+ # real failure this was built to avoid, not a theoretical one. fit_and_score
51
+ # only needs the float back, so each split's train/test can be freed before
52
+ # the next is built.
53
+ random_train, random_test = dataset._random_split(combined, label_col, dataset_cfg, seed=seed)
54
+ random_acc = fit_and_score(random_train, random_test, label_col, cfg, seed=seed)
55
+ n_random = len(random_test)
56
+ del random_train, random_test
57
+
58
+ grouped_train, grouped_test = dataset._grouped_split(combined, label_col, dataset_cfg, seed=seed)
59
+ grouped_acc = fit_and_score(grouped_train, grouped_test, label_col, cfg, seed=seed)
60
+ n_grouped = len(grouped_test)
61
+ del grouped_train, grouped_test
62
+ drop = random_acc - grouped_acc
63
+
64
+ threshold = _materiality.threshold(cfg, "resplit_falsification", MATERIAL_DROP_THRESHOLD)
65
+ # One comparison on finite test sets: a drop only counts as material once it
66
+ # clears the threshold by more than its own sampling margin. A drop above the
67
+ # threshold but within that margin is reported as inconclusive (warning).
68
+ margin = _materiality.accuracy_gap_margin(random_acc, n_random, grouped_acc, n_grouped)
69
+ if drop - margin > threshold:
70
+ status, verdict = "flag", (
71
+ "grouped split costs measurable accuracy beyond test-set sampling noise, "
72
+ "session-correlated leakage in the random split may be doing real work"
73
+ )
74
+ elif drop > threshold:
75
+ status, verdict = "warning", (
76
+ f"the drop exceeds the {threshold:.2f} threshold but lies within its sampling margin "
77
+ f"(+/-{margin:.4f}) at this test size; inconclusive, see repeated_seed_falsification_check"
78
+ )
79
+ else:
80
+ status, verdict = "ok", (
81
+ "grouped split reproduces random-split accuracy: no evidence that information tied "
82
+ "to the grouping key drives the result (other grouping keys and shortcuts are not tested)"
83
+ )
84
+ summary = (
85
+ f"random-split accuracy={random_acc:.4f}, grouped-split accuracy={grouped_acc:.4f} "
86
+ f"(drop={drop:+.4f}); {verdict}"
87
+ )
88
+ return {
89
+ "check": "resplit_falsification", "status": status, "summary": summary,
90
+ "details": {"random_accuracy": random_acc, "grouped_accuracy": grouped_acc, "drop": drop,
91
+ "sampling_margin": margin, "random_test_rows": n_random, "grouped_test_rows": n_grouped},
92
+ }
@@ -0,0 +1,154 @@
1
+ """Result robustness matrix (v2, opt-in).
2
+
3
+ Every other check here measures a property of the data. This measures
4
+ something different: how much a benchmark's headline accuracy moves
5
+ across a handful of methodology choices that are each individually
6
+ reasonable, random split vs. a session-grouped split, with vs. without
7
+ exact duplicates, with vs. without declared identity columns, rather
8
+ than assuming a single reported number is the whole story. A result
9
+ that barely moves across these conditions is robust to how it was
10
+ produced; one that swings widely was measuring the methodology as much
11
+ as the attack behavior.
12
+
13
+ Deliberately reports the full accuracy-by-condition vector rather than
14
+ collapsing it into one fragility score: which specific condition moved
15
+ the result, and by how much, is the useful information, and a single
16
+ number would hide exactly that (the same reasoning class_distribution_
17
+ report and every per-class breakdown in this project already follow).
18
+
19
+ Builds on infrastructure already in place rather than duplicating it:
20
+ dataset._random_split/_grouped_split (resplit_falsification), dataset.
21
+ dedup (dedup_check), and fit_and_score's column-subset override
22
+ (feature_category_ablation_check). Needs dataset.raw_files, like every
23
+ other check that builds its own comparison splits from the raw data;
24
+ the grouped-split and identity-column conditions are each included only
25
+ when the config actually supports them (dataset.group_columns,
26
+ schema.id_like_columns), so a minimal config still gets at least the
27
+ random-split-vs-dedup comparison.
28
+
29
+ Every condition's (train, test) pair is built first - relatively cheap next
30
+ to the fit itself - so the actual fit_and_score calls can be dispatched
31
+ together, concurrently via a thread pool when audit.parallel_checks is set.
32
+
33
+ Accepts an already-loaded `combined` DataFrame so run_audit can share one
34
+ load across this, resplit_falsification, and repeated_seed_falsification_
35
+ check when several run together, instead of each reloading the full raw
36
+ dataset from disk - a real cost at full scale. Still loads it itself when
37
+ called standalone, so this is purely an optimization.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import concurrent.futures
43
+
44
+ import pandas as pd
45
+
46
+ from ..data import dataset, features
47
+ from . import _materiality
48
+ from ._fit_score import fit_and_score
49
+ from ._status import NOT_APPLICABLE
50
+
51
+ # Overridable via audit.materiality_thresholds.result_robustness_check ({"warning": .., "flag": ..}).
52
+ SPREAD_FLAG_THRESHOLD = 0.15
53
+ SPREAD_WARNING_THRESHOLD = 0.08
54
+
55
+
56
+ def check(cfg: dict, combined: pd.DataFrame | None = None) -> dict:
57
+ dataset_cfg = cfg["dataset"]
58
+ if not dataset_cfg["raw_files"]:
59
+ return {
60
+ "check": "result_robustness_check", "status": NOT_APPLICABLE,
61
+ "summary": "requires dataset.raw_files, not applicable to a pre-split train_file/test_file pair",
62
+ "details": {},
63
+ }
64
+
65
+ seed = cfg["random_seed"]
66
+ label_col = cfg["schema"]["label_column"]
67
+ if combined is None:
68
+ combined = dataset.load_raw_combined(dataset_cfg, seed=seed)
69
+
70
+ # name -> (train_df, test_df, cols_override); built up front so the actual fits
71
+ # (the expensive part) can all be dispatched together below.
72
+ pending: dict[str, tuple] = {}
73
+ random_train, random_test = dataset._random_split(combined, label_col, dataset_cfg, seed=seed)
74
+ pending["random_split"] = (random_train, random_test, None)
75
+
76
+ if dataset_cfg["group_columns"]:
77
+ try:
78
+ grouped_train, grouped_test = dataset._grouped_split(combined, label_col, dataset_cfg, seed=seed)
79
+ pending["grouped_split"] = (grouped_train, grouped_test, None)
80
+ except (ValueError, RuntimeError):
81
+ pass # same graceful-skip as resplit_falsification when a grouped split can't be built
82
+
83
+ deduped_train, deduped_test, dedup_stats = dataset.dedup(random_train.copy(), random_test.copy(), cfg)
84
+ # Only a distinct condition if dedup actually removed something; otherwise it's
85
+ # an identical refit of the random_split condition, not a second data point.
86
+ if (
87
+ dedup_stats["train_rows_raw"] != dedup_stats["train_rows_deduped"]
88
+ or dedup_stats["test_rows_raw"] != dedup_stats["test_rows_deduped"]
89
+ ):
90
+ pending["deduplicated"] = (deduped_train, deduped_test, None)
91
+
92
+ id_cols = [c for c in cfg["schema"]["id_like_columns"] if c in random_train.columns]
93
+ if id_cols:
94
+ all_cols = features.feature_columns(random_train, cfg)
95
+ without_ids = [c for c in all_cols if c not in id_cols]
96
+ if without_ids:
97
+ pending["identity_columns_dropped"] = (random_train, random_test, without_ids)
98
+
99
+ if len(pending) < 2:
100
+ return {
101
+ "check": "result_robustness_check", "status": NOT_APPLICABLE,
102
+ "summary": "fewer than two conditions could be built to compare", "details": {},
103
+ }
104
+
105
+ test_rows = {name: len(test_df) for name, (_, test_df, _) in pending.items()}
106
+
107
+ def run(name: str) -> tuple[str, float]:
108
+ train_df, test_df, cols = pending[name]
109
+ return name, fit_and_score(train_df, test_df, label_col, cfg, cols=cols, seed=seed)
110
+
111
+ if cfg["audit"]["parallel_checks"]:
112
+ max_workers = min(cfg["audit"]["max_parallel_checks"], len(pending))
113
+ with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as pool:
114
+ conditions = dict(pool.map(run, pending))
115
+ else:
116
+ conditions = dict(run(name) for name in pending)
117
+
118
+ worst_condition, worst_acc = min(conditions.items(), key=lambda kv: kv[1])
119
+ best_condition, best_acc = max(conditions.items(), key=lambda kv: kv[1])
120
+ spread = best_acc - worst_acc
121
+
122
+ tiers = _materiality.threshold(
123
+ cfg, "result_robustness_check",
124
+ {"warning": SPREAD_WARNING_THRESHOLD, "flag": SPREAD_FLAG_THRESHOLD},
125
+ )
126
+ # Tiers are judged on the spread minus its sampling margin at these test sizes,
127
+ # so test-set noise alone can't flag; a spread that crosses a tier only within
128
+ # that margin is reported as a warning (inconclusive) instead.
129
+ margin = _materiality.accuracy_gap_margin(
130
+ best_acc, test_rows[best_condition], worst_acc, test_rows[worst_condition],
131
+ )
132
+ if spread - margin > tiers["flag"]:
133
+ status = "flag"
134
+ elif spread > tiers["warning"]:
135
+ status = "warning"
136
+ else:
137
+ status = "ok"
138
+
139
+ summary = (
140
+ f"accuracy ranges from {worst_acc:.4f} ({worst_condition}) to {best_acc:.4f} "
141
+ f"({best_condition}) across {len(conditions)} conditions, a spread of {spread:.4f} "
142
+ f"(sampling margin +/-{margin:.4f})"
143
+ )
144
+ if status != "ok":
145
+ summary += "; the headline result depends on methodology choices as much as the classifier"
146
+
147
+ return {
148
+ "check": "result_robustness_check", "status": status, "summary": summary,
149
+ "details": {
150
+ "accuracy_by_condition": conditions, "spread": spread,
151
+ "worst_condition": worst_condition, "best_condition": best_condition,
152
+ "sampling_margin": margin,
153
+ },
154
+ }