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