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
ids2eval/__init__.py ADDED
File without changes
ids2eval/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,24 @@
1
+ """Shared json.dumps(default=...) handler for numpy scalar/array types.
2
+
3
+ Findings can carry numpy bool/int/float scalars or arrays in their `details`
4
+ (most checks compute over pandas/numpy data), which the stdlib json module
5
+ doesn't know how to serialize on its own. Shared rather than duplicated
6
+ since both cli.py's output-writing and audit/_checkpoint.py's checkpoint
7
+ writing need the exact same conversion.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import numpy as np
13
+
14
+
15
+ def json_default(obj):
16
+ if isinstance(obj, np.bool_):
17
+ return bool(obj)
18
+ if isinstance(obj, np.integer):
19
+ return int(obj)
20
+ if isinstance(obj, np.floating):
21
+ return float(obj)
22
+ if isinstance(obj, np.ndarray):
23
+ return obj.tolist()
24
+ raise TypeError(f"Object of type {type(obj)} is not JSON serializable")
@@ -0,0 +1,239 @@
1
+ """Data-quality audit checks: Paper 1's methodology as reusable code.
2
+
3
+ v1 checks need no external reference data; v2 checks (synthetic_realism_check,
4
+ cross_dataset_drift_check) need audit.reference_dataset, and known_issue_lookup
5
+ is a curated per-dataset lookup table needing neither.
6
+
7
+ Each check function returns a Finding dict: {check, status, summary, details}.
8
+ status is one of "ok" (nothing notable), "warning" (worth a human look),
9
+ "flag" (a specific, checkable signature of a real data-quality problem), or
10
+ "not_applicable" (the check had no input to test; see _status.py).
11
+
12
+ STRUCTURAL_CHECKS are checks whose result cannot depend on
13
+ preprocessing.dedup: known_issue_lookup looks only at dataset.name,
14
+ schema_fingerprint_check only at column names (dedup removes rows, never
15
+ columns), and resplit_falsification/scenario_holdout_falsification/
16
+ cross_capture_matrix_check/result_robustness_check all reload the raw
17
+ data themselves and never look at the train_df/test_df they're passed.
18
+ Run once, on the raw data; run_audit's caller (cli.py)
19
+ reuses that result instead of recomputing an answer that is guaranteed
20
+ identical the second time.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import concurrent.futures
26
+ import logging
27
+ import time
28
+
29
+ import pandas as pd
30
+
31
+ from ..data import dataset
32
+ from . import (
33
+ _checkpoint,
34
+ artifact_sensitivity,
35
+ class_distribution,
36
+ cross_capture_matrix,
37
+ cross_dataset_drift,
38
+ data_integrity,
39
+ dedup,
40
+ feature_auc_ranking,
41
+ feature_category_ablation,
42
+ flow_group_leakage,
43
+ homogeneity,
44
+ identity_columns,
45
+ known_issues,
46
+ label_conflict,
47
+ leakage,
48
+ near_duplicate_class,
49
+ one_rule,
50
+ port_protocol_shortcut,
51
+ repeated_seed_falsification,
52
+ resplit,
53
+ result_robustness,
54
+ row_order_leakage,
55
+ scenario_holdout,
56
+ schema_fingerprint,
57
+ seed_sensitivity,
58
+ synthetic_realism,
59
+ temporal_leakage,
60
+ temporal_realism,
61
+ )
62
+
63
+ STRUCTURAL_CHECKS = frozenset({
64
+ "known_issue_lookup", "schema_fingerprint_check", "resplit_falsification",
65
+ "scenario_holdout_falsification", "cross_capture_matrix_check", "result_robustness_check",
66
+ "repeated_seed_falsification_check",
67
+ })
68
+
69
+ # A strict subset of STRUCTURAL_CHECKS: checks that report a documented, curated fact
70
+ # about the dataset or the tool that extracted it (a published labelling error, a
71
+ # known-buggy extractor), rather than measuring something about this run's actual
72
+ # data. resplit_falsification is structural too, but it measures this run's own split
73
+ # methodology and IS actionable (switch dataset.split_mode to "grouped"), so it stays
74
+ # out of this set and is reported alongside the other audit checks, not here.
75
+ KNOWN_ISSUE_CHECKS = frozenset({"known_issue_lookup", "schema_fingerprint_check"})
76
+
77
+ # Evidence basis for a check's result, fixed per check name, not computed per
78
+ # run: a citation needs a consistent answer to "how sure is this" for the
79
+ # same check every time, not a judgment that could vary run to run.
80
+ # "documented": a citation lookup against dataset.name or column names, not
81
+ # computed from this run's data at all.
82
+ # "direct-experiment": an actual counterfactual refit and comparison.
83
+ # "statistical": a threshold or hypothesis test against this run's data,
84
+ # with no independent corroboration. The default for anything not listed.
85
+ # A fourth level, "cross-corroborated", is not a per-check label: it applies
86
+ # only when homogeneity_test and resplit_falsification both flag on the
87
+ # same run, and is computed once at scorecard-build time from those two
88
+ # already-computed statuses (see ids2eval.reporting.scorecard).
89
+ EVIDENCE_LEVEL = {
90
+ "known_issue_lookup": "documented",
91
+ "schema_fingerprint_check": "documented",
92
+ "resplit_falsification": "direct-experiment",
93
+ "scenario_holdout_falsification": "direct-experiment",
94
+ "cross_capture_matrix_check": "direct-experiment",
95
+ "feature_category_ablation_check": "direct-experiment",
96
+ "artifact_sensitivity_check": "direct-experiment",
97
+ "result_robustness_check": "direct-experiment",
98
+ "repeated_seed_falsification_check": "direct-experiment",
99
+ }
100
+ DEFAULT_EVIDENCE_LEVEL = "statistical"
101
+
102
+ logger = logging.getLogger(__name__)
103
+
104
+
105
+ def run_audit(
106
+ train_df: pd.DataFrame, test_df: pd.DataFrame, cfg: dict, skip: frozenset[str] = frozenset(),
107
+ checkpoint_path: str | None = None,
108
+ ) -> list[dict]:
109
+ """Run every audit check enabled in cfg['audit'] and not in `skip`, in schema order.
110
+
111
+ Call this on the split BEFORE preprocessing.dedup is applied.
112
+ dedup_check reports duplication already present in train_df/test_df,
113
+ if the caller already deduplicated, it will always report zero.
114
+
115
+ `skip` exists for the caller to omit STRUCTURAL_CHECKS on a second call
116
+ with the same cfg (e.g. cli.py's after-dedup pass) - their answer can't
117
+ change, so recomputing them (including resplit_falsification's two
118
+ RandomForest fits) would be pure wasted work.
119
+
120
+ Runs sequentially by default. If audit.parallel_checks is set, enabled
121
+ checks instead run concurrently (capped at audit.max_parallel_checks) via
122
+ a thread pool - safe because no check mutates train_df/test_df, only
123
+ reads them. The returned list is always in the same schema order either
124
+ way; only execution order, not the caller-visible result, changes.
125
+ Off by default because several checks running at once means several may
126
+ hold their own encoded feature matrix/RandomForest fit in memory
127
+ simultaneously rather than one at a time, raising peak memory versus the
128
+ sequential default.
129
+
130
+ resplit_falsification, result_robustness_check, and repeated_seed_
131
+ falsification_check each need the full raw dataset (not just train_df/
132
+ test_df) and would otherwise each reload it independently - loaded once
133
+ here and shared across whichever of the three are enabled, since that
134
+ reload is a real, serious cost at full scale (see each check's own
135
+ docstring for the real failure this was built to avoid).
136
+
137
+ checkpoint_path, if given, persists each check's finding to a local JSON
138
+ file as soon as it's computed and skips recomputing a check whose result
139
+ is already there - so a crash partway through a long run only redoes the
140
+ remaining checks on the next call with the same path. Mutually exclusive
141
+ with parallel_checks (validated in config.py): concurrent checks writing
142
+ to one checkpoint file would race. repeated_seed_falsification_check
143
+ additionally checkpoints its own per-seed progress via this same path,
144
+ since it alone can dominate a full-scale run's cost.
145
+ """
146
+ audit_cfg = cfg["audit"]
147
+ seed = cfg["random_seed"]
148
+ dataset_cfg = cfg["dataset"]
149
+
150
+ # resplit_falsification, result_robustness_check, and repeated_seed_falsification_check
151
+ # each independently reload the full raw dataset when called standalone - fine at small
152
+ # scale, but a real, serious cost at full scale: a real failure hit running all three
153
+ # together against a 70M-row dataset, where each ~45-70 minute reload compounded into
154
+ # repeated VM-level instability on a run that redundantly reloaded the same data three
155
+ # times sequentially. Loaded once here and passed to whichever of the three are enabled.
156
+ _raw_reload_checks = ("resplit_falsification", "result_robustness_check", "repeated_seed_falsification_check")
157
+ shared_combined = None
158
+ if dataset_cfg["raw_files"] and any(
159
+ audit_cfg[name] and name not in skip for name in _raw_reload_checks
160
+ ):
161
+ shared_combined = dataset.load_raw_combined(dataset_cfg, seed=seed)
162
+
163
+ # Each entry is (check_name, thunk). Building the full ordered list up front - rather
164
+ # than appending each finding immediately - lets the dispatch loop below run these
165
+ # independently (they only read train_df/test_df, never mutate them) either in
166
+ # schema order (default) or concurrently, while the OUTPUT list always comes back in
167
+ # this same schema order regardless of which mode ran or which thunk finished first.
168
+ pending: list[tuple[str, object]] = []
169
+
170
+ def add(name: str, thunk) -> None:
171
+ if audit_cfg[name] and name not in skip:
172
+ pending.append((name, thunk))
173
+
174
+ add("dedup_check", lambda: dedup.check(train_df, test_df, cfg))
175
+ add("label_conflict_check", lambda: label_conflict.check(train_df, test_df, cfg))
176
+ add("near_duplicate_class_check", lambda: near_duplicate_class.check(train_df, cfg))
177
+ add("leakage_screen", lambda: leakage.check(train_df, test_df, cfg, seed=seed))
178
+ add("one_rule_check", lambda: one_rule.check(train_df, test_df, cfg, seed=seed))
179
+ add("feature_auc_ranking_check", lambda: feature_auc_ranking.check(train_df, cfg))
180
+ add("identity_column_flag", lambda: identity_columns.check_predictive_power(train_df, test_df, cfg))
181
+ add("port_protocol_shortcut_check", lambda: port_protocol_shortcut.check(train_df, test_df, cfg))
182
+ add("temporal_leakage_check", lambda: temporal_leakage.check(train_df, test_df, cfg))
183
+ add("temporal_realism_check", lambda: temporal_realism.check(train_df, cfg))
184
+ add("flow_group_leakage_check", lambda: flow_group_leakage.check(train_df, test_df, cfg))
185
+ add("row_order_leakage_check", lambda: row_order_leakage.check(train_df, test_df, cfg))
186
+ add("homogeneity_test", lambda: homogeneity.check(train_df, test_df, cfg))
187
+ add("resplit_falsification", lambda: resplit.check(cfg, seed=seed, combined=shared_combined))
188
+ add("scenario_holdout_falsification", lambda: scenario_holdout.check(cfg))
189
+ add("class_distribution_report", lambda: class_distribution.check(train_df, test_df, cfg))
190
+ add("low_cardinality_warning", lambda: identity_columns.check_cardinality(train_df, cfg))
191
+ add("schema_fingerprint_check", lambda: schema_fingerprint.check(train_df, cfg))
192
+ add("data_integrity_check", lambda: data_integrity.check(train_df, cfg))
193
+ add("synthetic_realism_check", lambda: synthetic_realism.check(train_df, cfg))
194
+ add("cross_dataset_drift_check", lambda: cross_dataset_drift.check(train_df, test_df, cfg))
195
+ add("cross_capture_matrix_check", lambda: cross_capture_matrix.check(cfg))
196
+ add("feature_category_ablation_check", lambda: feature_category_ablation.check(train_df, test_df, cfg))
197
+ add("artifact_sensitivity_check", lambda: artifact_sensitivity.check(train_df, test_df, cfg))
198
+ add("result_robustness_check", lambda: result_robustness.check(cfg, combined=shared_combined))
199
+ add("known_issue_lookup", lambda: known_issues.check(train_df, cfg))
200
+ add("seed_sensitivity_check", lambda: seed_sensitivity.check(train_df, test_df, cfg))
201
+ add(
202
+ "repeated_seed_falsification_check",
203
+ lambda: repeated_seed_falsification.check(
204
+ cfg, checkpoint_path=checkpoint_path, combined=shared_combined,
205
+ ),
206
+ )
207
+
208
+ # One log line per check: some take minutes on a large dataset, and
209
+ # without these a run (and the dashboard's live log) looks frozen.
210
+ total = len(pending)
211
+
212
+ def done(i: int, name: str, result: dict, started: float, note: str = "") -> None:
213
+ logger.info("Audit check %d/%d done: %s -> %s (%.1fs%s)", i, total, name,
214
+ (result or {}).get("status", "?"), time.monotonic() - started, note)
215
+
216
+ if not audit_cfg["parallel_checks"]:
217
+ results = []
218
+ for i, (name, thunk) in enumerate(pending, 1):
219
+ started = time.monotonic()
220
+ cached = _checkpoint.get_check(checkpoint_path, name) if checkpoint_path else None
221
+ if cached is None:
222
+ logger.info("Audit check %d/%d: %s ...", i, total, name)
223
+ result = cached if cached is not None else thunk()
224
+ if checkpoint_path and cached is None:
225
+ _checkpoint.save_check(checkpoint_path, name, result)
226
+ done(i, name, result, started, ", from checkpoint" if cached is not None else "")
227
+ results.append(result)
228
+ return results
229
+
230
+ max_workers = min(audit_cfg["max_parallel_checks"], len(pending)) or 1
231
+ logger.info("Audit: running %d checks, up to %d at a time", total, max_workers)
232
+ with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as pool:
233
+ started = time.monotonic()
234
+ futures = [pool.submit(thunk) for _, thunk in pending]
235
+ names = {f: name for f, (name, _) in zip(futures, pending, strict=True)}
236
+ for i, f in enumerate(concurrent.futures.as_completed(futures), 1):
237
+ if f.exception() is None:
238
+ done(i, names[f], f.result(), started, " since the batch started")
239
+ return [f.result() for f in futures]
ids2eval/audit/_auc.py ADDED
@@ -0,0 +1,48 @@
1
+ """ROC AUC that survives a class-set mismatch between train and test.
2
+
3
+ Shared by the checks that score a single-column classifier (leakage_screen's
4
+ standalone AUC, identity_column_flag). A plain roc_auc_score call assumes the
5
+ model's classes and the test labels are the same set, which real splits
6
+ break both ways: a rare class can land entirely in train (CIC-IDS2018's
7
+ SQL Injection, ~3 rows in a 500K reservoir sample - a real crash), and a
8
+ dataset can hold test-only classes by design (NSL-KDD's novel attacks).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+
15
+ import numpy as np
16
+ import pandas as pd
17
+ from sklearn.metrics import roc_auc_score
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def robust_auc(y_true, proba: np.ndarray, classes) -> float | None:
23
+ """Weighted one-vs-rest AUC over the classes present on both sides.
24
+
25
+ Test rows whose class the model never saw are skipped (there's no
26
+ score column to rank them by); probability columns for classes absent
27
+ from the test labels are dropped and each row renormalized. Returns
28
+ None when fewer than two classes remain - AUC isn't defined there.
29
+ With identical class sets this equals plain roc_auc_score.
30
+ """
31
+ y_true = pd.Series(y_true).to_numpy()
32
+ classes = np.asarray(classes)
33
+
34
+ seen = np.isin(y_true, classes)
35
+ if not seen.all():
36
+ logger.warning("AUC: skipped %d test row(s) whose class never appeared in train", int((~seen).sum()))
37
+ y_true, proba = y_true[seen], proba[seen]
38
+
39
+ present = np.isin(classes, np.unique(y_true))
40
+ if present.sum() < 2:
41
+ return None
42
+ classes, proba = classes[present], proba[:, present]
43
+
44
+ row_sums = proba.sum(axis=1, keepdims=True)
45
+ proba = np.divide(proba, row_sums, out=np.full_like(proba, 1.0 / len(classes)), where=row_sums > 0)
46
+ if len(classes) == 2:
47
+ return float(roc_auc_score(y_true == classes[1], proba[:, 1]))
48
+ return float(roc_auc_score(y_true, proba, multi_class="ovr", average="weighted", labels=classes))
@@ -0,0 +1,29 @@
1
+ """Shared per-class breakdown machinery.
2
+
3
+ A dataset can look fine in aggregate (8% duplication, say) while one
4
+ attack family is 61% duplicated and another is untouched, invisible in
5
+ a single global number. The checks that support a breakdown attach a
6
+ "by_class" entry to their details, keyed by whichever column groups
7
+ attack types: schema.attack_category_column if set, else the label
8
+ column itself.
9
+
10
+ Same MIN_CLASS_SIZE guard homogeneity_test and near_duplicate_class_check
11
+ already use: a class with too few rows to say anything meaningful about
12
+ is left out rather than reported on a handful of rows.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import pandas as pd
18
+
19
+ MIN_CLASS_SIZE = 20
20
+
21
+
22
+ def group_column(cfg: dict) -> str:
23
+ schema = cfg["schema"]
24
+ return schema["attack_category_column"] or schema["label_column"]
25
+
26
+
27
+ def eligible_classes(df: pd.DataFrame, group_col: str, min_size: int = MIN_CLASS_SIZE) -> list:
28
+ counts = df[group_col].value_counts()
29
+ return counts[counts >= min_size].index.tolist()
@@ -0,0 +1,74 @@
1
+ """JSON-file checkpoint for resuming a long audit run after a crash.
2
+
3
+ Exists for full-scale runs on Cloud Batch, where a preemption/retry starts a
4
+ FRESH VM instance - anything checkpointed only to that VM's local disk is
5
+ lost regardless of in-process logic. This module only handles the local
6
+ read/write side; surviving an actual VM-level retry additionally requires
7
+ the caller (the batch runner script) to sync the checkpoint directory
8
+ to/from a durable location (GCS) around the run - this module doesn't know
9
+ about that, it just needs a stable local path across separate process runs.
10
+
11
+ Two granularities:
12
+ - Whole-check: run_audit() skips recomputing a check entirely if its finding
13
+ is already checkpointed.
14
+ - Per-seed, repeated_seed_falsification_check only: that check alone can
15
+ dominate a full-scale run's cost (it's N independent full refits), so a
16
+ crash partway through its seed loop should only cost the remaining seeds,
17
+ not the whole check.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ from pathlib import Path
25
+
26
+ from .._json_utils import json_default
27
+
28
+
29
+ def load(path: str) -> dict:
30
+ p = Path(path)
31
+ if not p.exists():
32
+ return {}
33
+ try:
34
+ return json.loads(p.read_text())
35
+ except (json.JSONDecodeError, OSError):
36
+ return {}
37
+
38
+
39
+ def _atomic_write(path: str, data: dict) -> None:
40
+ p = Path(path)
41
+ p.parent.mkdir(parents=True, exist_ok=True)
42
+ tmp = p.with_suffix(p.suffix + ".tmp")
43
+ tmp.write_text(json.dumps(data, default=json_default))
44
+ os.replace(tmp, p)
45
+
46
+
47
+ def get_check(path: str, check_name: str) -> dict | None:
48
+ return load(path).get("checks", {}).get(check_name)
49
+
50
+
51
+ def save_check(path: str, check_name: str, finding: dict) -> None:
52
+ data = load(path)
53
+ data.setdefault("checks", {})[check_name] = finding
54
+ _atomic_write(path, data)
55
+
56
+
57
+ def get_seed_progress(path: str, check_name: str) -> dict[int, float]:
58
+ raw = load(path).get("partial", {}).get(check_name, {}).get("drops_by_seed", {})
59
+ return {int(k): v for k, v in raw.items()}
60
+
61
+
62
+ def save_seed_progress(path: str, check_name: str, seed: int, drop: float) -> None:
63
+ data = load(path)
64
+ partial = data.setdefault("partial", {}).setdefault(check_name, {"drops_by_seed": {}})
65
+ partial["drops_by_seed"][str(seed)] = drop
66
+ _atomic_write(path, data)
67
+
68
+
69
+ def clear(path: str) -> None:
70
+ """Remove the checkpoint after a run completes successfully, so a later
71
+ fresh run of the same config doesn't silently resume stale progress."""
72
+ p = Path(path)
73
+ if p.exists():
74
+ p.unlink()
@@ -0,0 +1,40 @@
1
+ """Shared fit-a-RandomForest-and-score-accuracy helper.
2
+
3
+ Used by any check that compares accuracy across two different ways of
4
+ splitting the same raw data (resplit_falsification: random vs. grouped;
5
+ scenario_holdout_falsification: random vs. one scenario held out
6
+ entirely), rather than measuring a property of a single given split.
7
+ Also used by feature_category_ablation_check, which instead compares
8
+ two different FEATURE subsets of the same split, via the optional
9
+ `cols` override.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import pandas as pd
15
+ from sklearn.ensemble import RandomForestClassifier
16
+ from sklearn.metrics import accuracy_score
17
+
18
+ from ..data import features
19
+
20
+ MAX_FIT_ROWS = 200_000
21
+
22
+
23
+ def fit_and_score(
24
+ train_df: pd.DataFrame, test_df: pd.DataFrame, label_col: str, cfg: dict,
25
+ cols: list[str] | None = None, seed: int = 0,
26
+ ) -> float:
27
+ """seed controls both which MAX_FIT_ROWS rows are subsampled and the
28
+ RandomForest's own random_state. Callers that want the run's single
29
+ baseline result pass cfg["random_seed"] (the default 0 only applies if a
30
+ caller omits it entirely); repeated_seed_falsification_check is the one
31
+ caller that instead sweeps its own independent range of seeds, to get a
32
+ distribution rather than one baseline point.
33
+ """
34
+ if cols is None:
35
+ cols = features.feature_columns(train_df, cfg)
36
+ train_fit = train_df.sample(n=min(len(train_df), MAX_FIT_ROWS), random_state=seed)
37
+ x_train, x_test = features.encode_aligned(train_fit, test_df, cols, unseen="missing")
38
+ clf = RandomForestClassifier(n_estimators=100, random_state=seed, n_jobs=-1)
39
+ clf.fit(x_train, train_fit[label_col])
40
+ return float(accuracy_score(test_df[label_col], clf.predict(x_test)))
@@ -0,0 +1,28 @@
1
+ """Shared lookup for audit.materiality_thresholds overrides.
2
+
3
+ Each check that compares a metric against a materiality threshold keeps its
4
+ own default as a module-level constant (see that check's own module for why
5
+ its specific number was chosen); this only looks up whether the config
6
+ overrides it. Validated in config.py's validate_config, not here - by the
7
+ time a check calls this, the config has already passed validation, so this
8
+ stays a trusting, unconditional lookup rather than repeating those checks.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+
14
+ def threshold(cfg: dict, check: str, default):
15
+ """Return cfg['audit']['materiality_thresholds'][check] if set, else default."""
16
+ return cfg["audit"]["materiality_thresholds"].get(check, default)
17
+
18
+
19
+ def accuracy_gap_margin(acc_a: float, n_a: int, acc_b: float, n_b: int) -> float:
20
+ """Half-width of a 95% normal-approximation interval on the difference
21
+ between two accuracies measured on test sets of n_a and n_b rows.
22
+
23
+ Treats the two test sets as independent, which is conservative when they
24
+ share rows (a paired difference has smaller variance), so a gap that clears
25
+ a threshold by more than this margin is not just test-set sampling noise.
26
+ """
27
+ var = acc_a * (1 - acc_a) / max(n_a, 1) + acc_b * (1 - acc_b) / max(n_b, 1)
28
+ return 1.959964 * var ** 0.5
@@ -0,0 +1,15 @@
1
+ """Finding statuses.
2
+
3
+ "ok", "warning" and "flag" are results: the check ran on its input and measured
4
+ something. NOT_APPLICABLE means the check could not produce a result on this
5
+ input (no configured column, a pre-split dataset where raw files are needed, too
6
+ few classes or rows to test), so it says nothing about the dataset either way.
7
+ It never raises the overall verdict and is counted separately in the scorecard.
8
+ """
9
+
10
+ OK = "ok"
11
+ WARNING = "warning"
12
+ FLAG = "flag"
13
+ NOT_APPLICABLE = "not_applicable"
14
+
15
+ STATUSES = (OK, WARNING, FLAG, NOT_APPLICABLE)
@@ -0,0 +1,14 @@
1
+ """Shared timestamp-to-numeric parsing, used by any check scoped to
2
+ schema.timestamp_column.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import pandas as pd
8
+
9
+
10
+ def to_numeric_time(series: pd.Series) -> pd.Series:
11
+ parsed = pd.to_datetime(series, errors="coerce")
12
+ if parsed.notna().mean() > 0.5:
13
+ return parsed.astype("int64") // 10**9
14
+ return pd.to_numeric(series, errors="coerce")
@@ -0,0 +1,88 @@
1
+ """Counterfactual artifact-sensitivity test (v2, opt-in).
2
+
3
+ Fits one RandomForest on every feature and predicts on test, then, for
4
+ each column already declared as a potential shortcut elsewhere
5
+ (schema.id_like_columns, schema.timestamp_column), permutes just that
6
+ column's own values across the test rows (a real, in-distribution
7
+ reshuffle, not a synthetic replacement) and predicts again with the
8
+ same already-fitted model. The share of predictions that flip is a
9
+ direct measurement of how much the model's decision for a given row
10
+ depends on that one column rather than the rest of the row's features:
11
+
12
+ AS(column) = P(model's prediction changes | only this column reshuffled)
13
+
14
+ High sensitivity to an identity or timestamp column specifically is a
15
+ concrete, checkable sign the model may be keying off which host or
16
+ which moment produced a flow rather than what the flow's behavior was,
17
+ complementing identity_column_flag/temporal_leakage_check's standalone
18
+ predictive-power tests with the model's actual dependence on the
19
+ column inside the full feature set, not the column in isolation.
20
+
21
+ Deliberately opt-in: which classifier is fit and how a specific column
22
+ is perturbed both shape the result, unlike a check that measures a
23
+ fixed statistic of the data itself. No-ops unless at least one
24
+ declared identity or timestamp column is actually present as a feature.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import numpy as np
30
+ import pandas as pd
31
+ from sklearn.ensemble import RandomForestClassifier
32
+
33
+ from ..data import features
34
+ from ._status import NOT_APPLICABLE
35
+
36
+ MAX_FIT_ROWS = 200_000
37
+ FLIP_RATE_FLAG_THRESHOLD = 0.30
38
+ FLIP_RATE_WARNING_THRESHOLD = 0.15
39
+
40
+
41
+ def check(train_df: pd.DataFrame, test_df: pd.DataFrame, cfg: dict) -> dict:
42
+ schema = cfg["schema"]
43
+ candidate_cols = list(schema["id_like_columns"])
44
+ if schema["timestamp_column"]:
45
+ candidate_cols.append(schema["timestamp_column"])
46
+
47
+ cols = features.feature_columns(train_df, cfg)
48
+ suspicious_cols = [c for c in dict.fromkeys(candidate_cols) if c in cols and c in test_df.columns]
49
+ if not suspicious_cols:
50
+ return {
51
+ "check": "artifact_sensitivity_check", "status": NOT_APPLICABLE,
52
+ "summary": "no schema.id_like_columns or schema.timestamp_column present as a feature",
53
+ "details": {},
54
+ }
55
+
56
+ seed = cfg["random_seed"]
57
+ label_col = schema["label_column"]
58
+ train_fit = train_df.sample(n=min(len(train_df), MAX_FIT_ROWS), random_state=seed)
59
+ x_train, x_test = features.encode_aligned(train_fit, test_df, cols, unseen="missing")
60
+ clf = RandomForestClassifier(n_estimators=100, random_state=seed, n_jobs=-1)
61
+ clf.fit(x_train, train_fit[label_col])
62
+ baseline_pred = clf.predict(x_test)
63
+
64
+ rng = np.random.RandomState(seed)
65
+ flip_rate_by_column = {}
66
+ for col in suspicious_cols:
67
+ perturbed_test = test_df.copy()
68
+ perturbed_test[col] = rng.permutation(perturbed_test[col].to_numpy())
69
+ _, x_test_perturbed = features.encode_aligned(train_fit, perturbed_test, cols, unseen="missing")
70
+ perturbed_pred = clf.predict(x_test_perturbed)
71
+ flip_rate_by_column[col] = float((perturbed_pred != baseline_pred).mean())
72
+
73
+ worst_col, worst_rate = max(flip_rate_by_column.items(), key=lambda kv: kv[1])
74
+ if worst_rate > FLIP_RATE_FLAG_THRESHOLD:
75
+ status = "flag"
76
+ elif worst_rate > FLIP_RATE_WARNING_THRESHOLD:
77
+ status = "warning"
78
+ else:
79
+ status = "ok"
80
+
81
+ summary = f"reshuffling '{worst_col}' alone flips {worst_rate:.1%} of the model's predictions"
82
+ if status != "ok":
83
+ summary += "; the model's decisions depend on this column more than its own behavior may warrant"
84
+
85
+ return {
86
+ "check": "artifact_sensitivity_check", "status": status, "summary": summary,
87
+ "details": {"flip_rate_by_column": flip_rate_by_column},
88
+ }