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,402 @@
1
+ """Structural, config-level recommendations for a run's flagged/warned checks.
2
+
3
+ Deliberately narrow: only checks with an unambiguous, mechanical fix get a
4
+ patch (a partial config to merge onto the run's own resolved_config.json).
5
+ Everything else - shortcut-feature checks (leakage_screen, one_rule_check,
6
+ feature_auc_ranking_check) chief among them - names a suspiciously
7
+ predictive feature, not a defect, and this project's own examples show why
8
+ that distinction matters: UNSW-NB15's sttl is flagged by exactly these
9
+ checks every time, but feature_category_ablation_check/artifact_sensitivity_
10
+ check show the model barely depends on it. Recommending "drop it" would be
11
+ bad advice dressed up as a fix. So the shortcut-feature family gets a
12
+ different kind of recommendation, enabling result_robustness_check, itself
13
+ just more evidence, not a change to the data. Checks that report a property
14
+ of the data or a documented fact rather than something fixable at all
15
+ (known_issue_lookup, homogeneity_test, resplit_falsification, ...) get an
16
+ explanatory note and no patch, never an invented "fix."
17
+
18
+ Every finding with status "flag" or "warning" ends up in the output, one
19
+ way or another: build_recommendations() tracks which checks a patch
20
+ already covers, and falls back to a note (a specific one from
21
+ _NOT_FIXABLE_NOTES, or a generic one if a check isn't in that table at
22
+ all) for everything else. A check silently missing from the list would
23
+ misreport "nothing to recommend" for a run that still has an unaddressed
24
+ flag - the exact bug this guarantees against.
25
+
26
+ A recommendation's "patch" is a partial config dict, deep-merged onto the
27
+ run's resolved config with config.apply_patch(); the caller (CLI or
28
+ dashboard) decides which recommendations to apply, and can edit the
29
+ resulting YAML by hand before re-running - this module only ever proposes.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ from pathlib import Path
36
+
37
+ import yaml
38
+
39
+ from ..config import apply_patch
40
+
41
+ # Checks that name a feature which is merely suspiciously predictive, not a
42
+ # defect - the "recommendation" there is more evidence, not feature removal.
43
+ _SHORTCUT_FEATURE_CHECKS = ("leakage_screen", "one_rule_check", "feature_auc_ranking_check")
44
+
45
+ # Checks that report a property of the data or a documented fact rather
46
+ # than something a config change fixes. Each gets a short explanation of
47
+ # why there's no patch, so a flagged/warned check is never silently
48
+ # omitted from the recommendation list.
49
+ _NOT_FIXABLE_NOTES = {
50
+ "dedup_check": (
51
+ "Reports duplication already present in the data. preprocessing.dedup "
52
+ "(on by default) already removes it before training; if this is still "
53
+ "flagged after dedup, see near_duplicate_class_check below instead."
54
+ ),
55
+ "label_conflict_check": (
56
+ "Contradictory ground truth for identical feature vectors. Usually "
57
+ "resolves once preprocessing.dedup runs (the conflicting rows were "
58
+ "exact duplicates); if it persists after dedup, the contradiction is "
59
+ "in the labels themselves, not fixable by a config change."
60
+ ),
61
+ "near_duplicate_class_check": (
62
+ "Near-identical (not exact) feature vectors mapped to different "
63
+ "labels. preprocessing.dedup only removes exact duplicates, so this "
64
+ "can't be config-fixed; it's evidence two classes may not be "
65
+ "reliably distinguishable in this feature set."
66
+ ),
67
+ "row_order_leakage_check": (
68
+ "Rows are still in collection order. For dataset.raw_files, IDS2Eval's "
69
+ "own split already shuffles regardless of source order, so this "
70
+ "shouldn't happen there; for a pre-split train_file/test_file pair, "
71
+ "the file itself is ordered and there's no config change that "
72
+ "reshuffles it, only obtaining the combined unsplit data instead."
73
+ ),
74
+ "homogeneity_test": (
75
+ "Tests whether test rows sit closer to training rows than training rows "
76
+ "sit to each other. A property of the data, not a config problem."
77
+ ),
78
+ "resplit_falsification": (
79
+ "Tests whether a session-grouped split costs accuracy. A flag here "
80
+ "means session-correlated leakage may be doing real work; report "
81
+ "results under the grouped split, or investigate dataset.group_columns, "
82
+ "rather than treating it as fixable."
83
+ ),
84
+ "scenario_holdout_falsification": (
85
+ "Tests whether accuracy survives never having seen an entire "
86
+ "collection scenario. A flag means generalization to genuinely new "
87
+ "conditions is worse than the random split suggests; not fixable by "
88
+ "a config change, only reportable as a caveat."
89
+ ),
90
+ "cross_capture_matrix_check": (
91
+ "N x N cross-scenario accuracy matrix. Asymmetric results are a "
92
+ "property of the scenarios themselves, not something to fix."
93
+ ),
94
+ "class_distribution_report": (
95
+ "Reports imbalance, it doesn't diagnose a defect. preprocessing."
96
+ "sampling (SMOTE etc.) is a real option but a judgment call this "
97
+ "module won't make automatically - see configs/README.md."
98
+ ),
99
+ "schema_fingerprint_check": (
100
+ "Matches a documented extractor bug by column-name fingerprint. "
101
+ "A fact about the tool that produced this data, not fixable here."
102
+ ),
103
+ "known_issue_lookup": (
104
+ "A curated, published problem with this specific dataset. Disclose "
105
+ "it when reporting results; there's nothing in your config to change."
106
+ ),
107
+ "result_robustness_check": (
108
+ "Measures whether the headline accuracy is sensitive to split/dedup/"
109
+ "feature-set methodology, it doesn't diagnose a fixable defect. A "
110
+ "flag means the reported number moves depending on how the "
111
+ "experiment is set up - disclose it as a caveat on the result, "
112
+ "there's no config change that makes a model methodology-invariant."
113
+ ),
114
+ "feature_category_ablation_check": (
115
+ "Reports how much accuracy a declared schema.feature_categories "
116
+ "group costs when removed. Evidence to report, not a defect: which "
117
+ "category the result depends on is exactly the point, dropping it "
118
+ "automatically would defeat the purpose of measuring this."
119
+ ),
120
+ "artifact_sensitivity_check": (
121
+ "Measures how much a fitted model's predictions actually depend on "
122
+ "one column when its values are reshuffled, inside the full feature "
123
+ "set. Like the shortcut-feature checks above, a flag here is "
124
+ "evidence to weigh, not necessarily a defect - a model can be "
125
+ "accurate while barely depending on the column in question."
126
+ ),
127
+ "synthetic_realism_check": (
128
+ "Needs audit.reference_dataset; a flag means this dataset's rows "
129
+ "are easily distinguished from real traffic by a domain classifier. "
130
+ "Evidence about how representative this data is, not something a "
131
+ "config change to this dataset fixes."
132
+ ),
133
+ "cross_dataset_drift_check": (
134
+ "Needs audit.reference_dataset; a flag means accuracy drops "
135
+ "materially on that independent dataset. Evidence about "
136
+ "generalization, to disclose alongside the benchmark result, not "
137
+ "fixable by a config change to this dataset."
138
+ ),
139
+ "seed_sensitivity_check": (
140
+ "Re-fits leakage_screen/one_rule_check across a few seeds and flags "
141
+ "if their flag/ok conclusion isn't stable. Means a single-seed "
142
+ "result from those checks shouldn't be over-trusted; report the "
143
+ "instability rather than picking a favorable seed."
144
+ ),
145
+ }
146
+
147
+ _GENERIC_NOTE = (
148
+ "No structural, mechanical fix for this one - see the check's own "
149
+ "summary and details above, and guide/checks.md for what it tests."
150
+ )
151
+
152
+
153
+ def _drop_columns_recommendation(by_check: dict, cfg: dict) -> dict | None:
154
+ candidates: dict[str, set[str]] = {}
155
+
156
+ f = by_check.get("identity_column_flag")
157
+ if f and f["status"] == "flag":
158
+ for col in f["details"].get("suggested_drop", []):
159
+ candidates.setdefault(col, set()).add("identity_column_flag")
160
+
161
+ f = by_check.get("low_cardinality_warning")
162
+ if f and f["status"] == "warning":
163
+ for col in f["details"].get("flagged", []):
164
+ candidates.setdefault(col, set()).add("low_cardinality_warning")
165
+
166
+ f = by_check.get("port_protocol_shortcut_check")
167
+ if f and f["status"] == "flag":
168
+ for col in (f["details"].get("port_column"), f["details"].get("protocol_column")):
169
+ if col:
170
+ candidates.setdefault(col, set()).add("port_protocol_shortcut_check")
171
+
172
+ f = by_check.get("temporal_leakage_check")
173
+ if f and f["status"] == "flag":
174
+ col = f["details"].get("column")
175
+ if col:
176
+ candidates.setdefault(col, set()).add("temporal_leakage_check")
177
+
178
+ f = by_check.get("data_integrity_check")
179
+ if f and f["status"] in ("flag", "warning"):
180
+ d = f["details"]
181
+ for col in d.get("constant_features", []):
182
+ candidates.setdefault(col, set()).add("data_integrity_check (constant)")
183
+ for col in d.get("missing_by_feature", {}):
184
+ candidates.setdefault(col, set()).add("data_integrity_check (missing values)")
185
+ for col in d.get("inf_by_feature", {}):
186
+ candidates.setdefault(col, set()).add("data_integrity_check (+-inf values)")
187
+
188
+ if not candidates:
189
+ return None
190
+ existing = set(cfg["schema"].get("drop_columns") or [])
191
+ new_cols = sorted(c for c in candidates if c not in existing)
192
+ if not new_cols:
193
+ return None
194
+
195
+ # identity_column_flag/low_cardinality_warning/port_protocol_shortcut_check/
196
+ # flow_group_leakage_check/temporal_leakage_check all test the column
197
+ # named in id_like_columns/flow_id_columns/timestamp_column directly
198
+ # against the raw loaded data - none of them look at schema.drop_columns
199
+ # at all. Adding a column to drop_columns alone would not stop the check
200
+ # that flagged it from flagging it again on the very next run; clearing
201
+ # the declaration here is what actually does.
202
+ schema_patch: dict = {"drop_columns": sorted(existing | set(new_cols))}
203
+ new_set = set(new_cols)
204
+ remaining_id_like = [c for c in cfg["schema"].get("id_like_columns") or [] if c not in new_set]
205
+ if remaining_id_like != (cfg["schema"].get("id_like_columns") or []):
206
+ schema_patch["id_like_columns"] = remaining_id_like
207
+ remaining_flow_id = [c for c in cfg["schema"].get("flow_id_columns") or [] if c not in new_set]
208
+ if remaining_flow_id != (cfg["schema"].get("flow_id_columns") or []):
209
+ schema_patch["flow_id_columns"] = remaining_flow_id
210
+ if cfg["schema"].get("timestamp_column") in new_set:
211
+ schema_patch["timestamp_column"] = None
212
+
213
+ reasons = sorted({r for rs in candidates.values() for r in rs})
214
+ return {
215
+ "id": "drop_flagged_columns",
216
+ "title": f"Add {len(new_cols)} flagged column(s) to schema.drop_columns",
217
+ "checks": reasons,
218
+ "explanation": (
219
+ f"{', '.join(new_cols)} were named by: {', '.join(reasons)}. Each is "
220
+ "either an identity-style shortcut, a timestamp tested standalone, "
221
+ "or a column data_integrity_check found broken (constant/missing/"
222
+ "±inf) - dropping it removes the column from training entirely, "
223
+ "the same fix in every case. Also clears it from id_like_columns/"
224
+ "flow_id_columns/timestamp_column where it was declared: those "
225
+ "checks test the raw column directly and ignore drop_columns, so "
226
+ "leaving the declaration in place would flag it again next run."
227
+ ),
228
+ "patch": {"schema": schema_patch},
229
+ }
230
+
231
+
232
+ def _grouped_split_recommendation(by_check: dict, cfg: dict) -> dict | None:
233
+ f = by_check.get("flow_group_leakage_check")
234
+ if not f or f["status"] != "flag":
235
+ return None
236
+ flow_cols = f["details"].get("flow_id_columns", [])
237
+ if not flow_cols:
238
+ return None
239
+ if not cfg["dataset"].get("raw_files"):
240
+ return {
241
+ "id": "grouped_split_unavailable", "title": "Session-grouped split isn't available",
242
+ "checks": ["flow_group_leakage_check"],
243
+ "explanation": (
244
+ "The same flow identity appears on both sides of the split, but "
245
+ "dataset.split_mode: grouped needs dataset.raw_files (it builds the "
246
+ "split itself); this dataset uses a pre-split train_file/test_file "
247
+ "pair, so the split can't be rebuilt without the combined raw data."
248
+ ),
249
+ "patch": None,
250
+ }
251
+ return {
252
+ "id": "grouped_split", "title": "Switch to a session-grouped split",
253
+ "checks": ["flow_group_leakage_check"],
254
+ "explanation": (
255
+ f"The same flow identity ({', '.join(flow_cols)}) appears on both sides "
256
+ "of the split; a grouped split keeps every row sharing that identity on "
257
+ "one side, making this specific leakage structurally impossible."
258
+ ),
259
+ "patch": {"dataset": {"split_mode": "grouped", "group_columns": flow_cols}},
260
+ }
261
+
262
+
263
+ def _result_robustness_recommendation(by_check: dict, cfg: dict) -> dict | None:
264
+ flagged = [c for c in _SHORTCUT_FEATURE_CHECKS if by_check.get(c, {}).get("status") == "flag"]
265
+ if not flagged:
266
+ return None
267
+ if cfg["audit"].get("result_robustness_check"):
268
+ return None
269
+ if not cfg["dataset"].get("raw_files"):
270
+ return {
271
+ "id": "result_robustness_unavailable", "title": "result_robustness_check isn't available",
272
+ "checks": flagged,
273
+ "explanation": (
274
+ f"{', '.join(flagged)} named a suspiciously predictive feature - not "
275
+ "necessarily a defect, since a model can be accurate while barely "
276
+ "depending on it (see guide/checks.md). result_robustness_check would "
277
+ "measure that directly, but needs dataset.raw_files; this dataset uses "
278
+ "a pre-split train_file/test_file pair."
279
+ ),
280
+ "patch": None,
281
+ }
282
+ return {
283
+ "id": "enable_result_robustness", "title": "Enable result_robustness_check",
284
+ "checks": flagged,
285
+ "explanation": (
286
+ f"{', '.join(flagged)} named a suspiciously predictive feature - not "
287
+ "necessarily a defect, since a model can be accurate while barely "
288
+ "depending on it (this project's own UNSW-NB15 example is exactly this "
289
+ "case). Dropping the feature isn't recommended automatically; "
290
+ "result_robustness_check measures whether the headline accuracy actually "
291
+ "moves across split/dedup/feature-set conditions before you decide."
292
+ ),
293
+ "patch": {"audit": {"result_robustness_check": True}},
294
+ }
295
+
296
+
297
+ def build_recommendations(findings: list[dict], cfg: dict) -> list[dict]:
298
+ """findings: the run's final audit findings (the after-dedup pass if
299
+ preprocessing.dedup ran, otherwise the only pass - the same findings the
300
+ scorecard's verdict is judged on), each a full Finding dict including
301
+ "details". cfg: that run's resolved config.
302
+
303
+ Every finding with status "flag" or "warning" ends up in exactly one
304
+ recommendation (a patch, or a note explaining why there isn't one) -
305
+ never silently absent. A check this module doesn't specifically know
306
+ about yet (a future check, or a v2 check not otherwise covered) still
307
+ gets a generic fallback note rather than vanishing from the list, which
308
+ would otherwise misleadingly report "nothing to recommend" for a run
309
+ that still has an unaddressed flag.
310
+ """
311
+ by_check = {f["check"]: f for f in findings}
312
+ recs: list[dict] = []
313
+ covered: set[str] = set()
314
+
315
+ for builder in (_drop_columns_recommendation, _grouped_split_recommendation,
316
+ _result_robustness_recommendation):
317
+ rec = builder(by_check, cfg)
318
+ if rec:
319
+ recs.append(rec)
320
+ covered.update(rec["checks"])
321
+
322
+ # data_integrity_check's missing-label symptom is never addressed by
323
+ # dropping columns (there's no column to drop for a missing label
324
+ # value), so it gets its own note even when the same check's other
325
+ # symptoms (constant/missing-feature/±inf columns) already got a patch
326
+ # and left the check name in `covered` above.
327
+ di = by_check.get("data_integrity_check")
328
+ if di and di["details"].get("missing_label_count"):
329
+ recs.append({
330
+ "id": "note_data_integrity_check_missing_labels",
331
+ "title": "data_integrity_check: missing label values have no automatic fix",
332
+ "checks": ["data_integrity_check"],
333
+ "explanation": (
334
+ f"{di['details']['missing_label_count']} row(s) have no label value at "
335
+ "all - not something a config change can supply. Re-collect or "
336
+ "re-label those rows, or drop them before this dataset is loaded."
337
+ ),
338
+ "patch": None,
339
+ })
340
+ covered.add("data_integrity_check")
341
+
342
+ for f in findings:
343
+ if f["status"] not in ("flag", "warning") or f["check"] in covered:
344
+ continue
345
+ note = _NOT_FIXABLE_NOTES.get(f["check"], _GENERIC_NOTE)
346
+ recs.append({
347
+ "id": f"note_{f['check']}", "title": f"{f['check']}: no automatic fix",
348
+ "checks": [f["check"]], "explanation": note, "patch": None,
349
+ })
350
+ covered.add(f["check"])
351
+
352
+ return recs
353
+
354
+
355
+ def recommendations_for_run(run_dir: str | Path) -> tuple[list[dict], dict]:
356
+ """Reads a completed run directory's own artifacts and returns
357
+ (recommendations, resolved_config) - the same resolved_config a patch
358
+ would be applied to.
359
+ """
360
+ run_dir = Path(run_dir)
361
+ cfg = json.loads((run_dir / "resolved_config.json").read_text())
362
+ after_path = run_dir / "audit_report_after.json"
363
+ before_path = run_dir / "audit_report_before.json"
364
+ if after_path.exists():
365
+ findings = json.loads(after_path.read_text())
366
+ elif before_path.exists():
367
+ findings = json.loads(before_path.read_text())
368
+ else:
369
+ findings = []
370
+ return build_recommendations(findings, cfg), cfg
371
+
372
+
373
+ def render_report(recommendations: list[dict]) -> str:
374
+ if not recommendations:
375
+ return "No flagged or warned check has a recommendation - nothing to report."
376
+ lines = []
377
+ for rec in recommendations:
378
+ lines.append(f"[{rec['id']}] {rec['title']}")
379
+ lines.append(f" checks: {', '.join(rec['checks'])}")
380
+ lines.append(f" {rec['explanation']}")
381
+ if rec["patch"]:
382
+ patch_text = yaml.safe_dump(rec["patch"], sort_keys=False).strip()
383
+ lines.append(" patch:")
384
+ lines.extend(f" {line}" for line in patch_text.splitlines())
385
+ else:
386
+ lines.append(" patch: none (see explanation above)")
387
+ lines.append("")
388
+ return "\n".join(lines).rstrip("\n")
389
+
390
+
391
+ def apply_recommendations(cfg: dict, recommendations: list[dict], ids: list[str]) -> dict:
392
+ """Merges the patches of the recommendations whose id is in `ids` onto
393
+ cfg, in the order given. Recommendations with patch=None (informational
394
+ only) are silently skipped even if their id is passed.
395
+ """
396
+ by_id = {r["id"]: r for r in recommendations}
397
+ patched = cfg
398
+ for rec_id in ids:
399
+ rec = by_id.get(rec_id)
400
+ if rec and rec.get("patch"):
401
+ patched = apply_patch(patched, rec["patch"])
402
+ return patched
@@ -0,0 +1,184 @@
1
+ """Per-run experiment directories, retention cleanup, and run metadata.
2
+
3
+ Deliberately separate from the load-cache (ids2eval/cache.py): that
4
+ cache lives at a FIXED output.dir/.cache/ specifically so a second run
5
+ with the same config reuses it. Per-run artifacts (results, reports,
6
+ metadata) go in output.dir/runs/<timestamp>/ instead, one new directory
7
+ per invocation, so results are never silently overwritten - retention
8
+ (output.keep_runs) prunes old ones instead of letting them accumulate
9
+ forever, but .cache/ is never touched by that cleanup.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import platform
18
+ import sys
19
+ from datetime import datetime, timezone
20
+ from pathlib import Path
21
+
22
+ import pandas as pd
23
+
24
+ from .. import version_info
25
+ from ..data import features
26
+
27
+ RUNS_DIRNAME = "runs"
28
+
29
+
30
+ def create_run_dir(output_dir: Path) -> Path:
31
+ timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d_%H%M%S_%f")
32
+ run_dir = output_dir / RUNS_DIRNAME / timestamp
33
+ run_dir.mkdir(parents=True, exist_ok=True)
34
+ return run_dir
35
+
36
+
37
+ def cleanup_old_runs(output_dir: Path, keep_runs: int | None) -> None:
38
+ if keep_runs is None:
39
+ return
40
+ runs_dir = output_dir / RUNS_DIRNAME
41
+ if not runs_dir.exists():
42
+ return
43
+ run_dirs = sorted((p for p in runs_dir.iterdir() if p.is_dir()), key=lambda p: p.name)
44
+ for stale in run_dirs[:-keep_runs] if keep_runs > 0 else run_dirs:
45
+ for f in stale.rglob("*"):
46
+ if f.is_file():
47
+ f.unlink()
48
+ for d in sorted(stale.rglob("*"), key=lambda p: len(p.parts), reverse=True):
49
+ if d.is_dir():
50
+ d.rmdir()
51
+ stale.rmdir()
52
+
53
+
54
+ def _package_versions() -> dict:
55
+ versions = {}
56
+ for pkg in ("pandas", "numpy", "scikit-learn", "scipy", "imbalanced-learn", "xgboost", "pyyaml", "pyarrow"):
57
+ module_name = {"scikit-learn": "sklearn", "imbalanced-learn": "imblearn", "pyyaml": "yaml"}.get(pkg, pkg)
58
+ try:
59
+ mod = __import__(module_name)
60
+ versions[pkg] = getattr(mod, "__version__", "unknown")
61
+ except ImportError:
62
+ versions[pkg] = "not installed"
63
+ return versions
64
+
65
+
66
+ def write_environment_info(run_dir: Path) -> None:
67
+ info = {
68
+ "python_version": sys.version,
69
+ "platform": platform.platform(),
70
+ "ids2eval": version_info.get_version_info(),
71
+ "packages": _package_versions(),
72
+ }
73
+ (run_dir / "environment.json").write_text(json.dumps(info, indent=2))
74
+
75
+
76
+ def write_resolved_config(run_dir: Path, cfg: dict) -> None:
77
+ (run_dir / "resolved_config.json").write_text(json.dumps(cfg, indent=2, default=str))
78
+
79
+
80
+ def content_hash(df: pd.DataFrame) -> str:
81
+ return hashlib.sha256(pd.util.hash_pandas_object(df, index=False).values.tobytes()).hexdigest()
82
+
83
+
84
+ def content_hash_unordered(df: pd.DataFrame) -> str:
85
+ """Order-independent variant of content_hash: sums per-row hashes rather than
86
+ hashing the row sequence, so identical row content in a different order (or
87
+ split differently across train/test) hashes the same. Used by compare_datasets'
88
+ combined-hash check, which needs exactly that property to detect the same
89
+ underlying data loaded with a different split_ratio/split_mode/seed; content_hash's
90
+ order-sensitive behavior stays the default everywhere else (dataset_fingerprint.json,
91
+ citation keys), which track one specific run's specific split, not row-set equality.
92
+ Sum (not XOR) preserves row-count multiplicity, so duplicate rows still change the
93
+ result - real IDS datasets often contain genuine duplicates, and this check
94
+ shouldn't treat "n copies of a row" the same as "one copy."
95
+ """
96
+ row_hashes = pd.util.hash_pandas_object(df, index=False).to_numpy(dtype="uint64")
97
+ combined = int(row_hashes.sum(dtype="uint64"))
98
+ return hashlib.sha256(combined.to_bytes(8, "little", signed=False)).hexdigest()
99
+
100
+
101
+ def _file_sha256(path: str, chunk_size: int = 1 << 20) -> str | None:
102
+ try:
103
+ h = hashlib.sha256()
104
+ with open(path, "rb") as f:
105
+ for chunk in iter(lambda: f.read(chunk_size), b""):
106
+ h.update(chunk)
107
+ return h.hexdigest()
108
+ except OSError:
109
+ return None
110
+
111
+
112
+ def _source_file_manifest(dataset_cfg: dict) -> list[dict]:
113
+ """Per-source-file provenance: the exact bytes this run's data came
114
+ from, independent of how ids2eval parsed them. The same fact this
115
+ project's own example READMEs have recorded by hand ("every file's
116
+ SHA-256 matched the mirror's published checksum"), computed and
117
+ written automatically instead. Hashes the file as stored on disk
118
+ (typically gzipped), matching what a published checksum is normally
119
+ computed against, not the decompressed content.
120
+ """
121
+ paths = dataset_cfg["raw_files"] or [p for p in (dataset_cfg["train_file"], dataset_cfg["test_file"]) if p]
122
+ manifest = []
123
+ for path in paths:
124
+ try:
125
+ size_bytes = os.path.getsize(path)
126
+ except OSError:
127
+ size_bytes = None
128
+ manifest.append({"path": path, "size_bytes": size_bytes, "sha256": _file_sha256(path)})
129
+ return manifest
130
+
131
+
132
+ def compute_dataset_fingerprint(train_df: pd.DataFrame, test_df: pd.DataFrame, cfg: dict) -> dict:
133
+ """The dict write_dataset_fingerprint writes to disk, exposed on its own
134
+ so a caller that doesn't have (or want) a run directory, like
135
+ ids2eval.reporting.compare_datasets, can compute the same fingerprint
136
+ without a file-writing side effect.
137
+ """
138
+ label_col = cfg["schema"]["label_column"]
139
+ return {
140
+ "train_rows": len(train_df),
141
+ "test_rows": len(test_df),
142
+ "feature_count": len(features.feature_columns(train_df, cfg)),
143
+ "train_class_distribution": {str(k): int(v) for k, v in train_df[label_col].value_counts().items()},
144
+ "test_class_distribution": {str(k): int(v) for k, v in test_df[label_col].value_counts().items()},
145
+ "train_content_hash": content_hash(train_df),
146
+ "test_content_hash": content_hash(test_df),
147
+ "columns": list(train_df.columns),
148
+ "source_files": _source_file_manifest(cfg["dataset"]),
149
+ }
150
+
151
+
152
+ def write_dataset_fingerprint(run_dir: Path, train_df: pd.DataFrame, test_df: pd.DataFrame, cfg: dict) -> dict:
153
+ """Real, content-based proof of what data this run used - distinct from
154
+ cache.py's fingerprint (which exists to answer "can I skip reloading",
155
+ keyed on cheap file stats) and dataset_cfg source-file stats. This one
156
+ hashes the actual loaded DataFrame contents, so it also catches a
157
+ resplit or a cache-invalidation edge case the file-stats check missed,
158
+ not just "did the source CSV change". Also records the source files'
159
+ own checksums (source_files) and the loaded column schema (columns),
160
+ a provenance manifest a second run, or a second person, can compare
161
+ a claimed-identical dataset against.
162
+
163
+ Returns the fingerprint dict too, so callers (e.g. scorecard.py) can
164
+ reuse it without recomputing the content hashes.
165
+ """
166
+ fingerprint = compute_dataset_fingerprint(train_df, test_df, cfg)
167
+ (run_dir / "dataset_fingerprint.json").write_text(json.dumps(fingerprint, indent=2))
168
+ return fingerprint
169
+
170
+
171
+ def write_scorecard(run_dir: Path, scorecard: dict, markdown: str, html: str) -> None:
172
+ (run_dir / "scorecard.json").write_text(json.dumps(scorecard, indent=2))
173
+ (run_dir / "SCORECARD.md").write_text(markdown)
174
+ (run_dir / "SCORECARD.html").write_text(html)
175
+
176
+
177
+ def write_run_status(run_dir: Path, status: str, failed_stage: str | None = None, error: str | None = None) -> None:
178
+ payload = {
179
+ "status": status,
180
+ "finished_at": datetime.now(timezone.utc).isoformat(),
181
+ "failed_stage": failed_stage,
182
+ "error": error,
183
+ }
184
+ (run_dir / "run_status.json").write_text(json.dumps(payload, indent=2))