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,875 @@
|
|
|
1
|
+
"""Formalizes audit findings into a citable, versioned scorecard.
|
|
2
|
+
|
|
3
|
+
Not a new check. A rollup of the audit findings, dataset fingerprint, and
|
|
4
|
+
tool version already computed elsewhere, into one stable, versioned artifact
|
|
5
|
+
a paper can point to (e.g. "this result was obtained on a dataset that
|
|
6
|
+
passed the following IDS2Eval checks"). See guide/checks.md's "The
|
|
7
|
+
scorecard" section for the prior art this format draws on.
|
|
8
|
+
|
|
9
|
+
SCHEMA_VERSION is independent of ids2eval's own package version: it only
|
|
10
|
+
changes if this dict's shape changes, so a citation naming a schema version
|
|
11
|
+
stays parseable even after ids2eval itself moves on. 1.2 is additive over
|
|
12
|
+
1.1, which was additive over 1.0 (1.1 added "checks", "counts",
|
|
13
|
+
"dedup_effect", "previous_run_comparison"; 1.2 adds an "evidence" field
|
|
14
|
+
inside each side of a "checks" row; every earlier key is unchanged). 1.3
|
|
15
|
+
renames the "flag" verdict from "failed" to "review_required": a flag means
|
|
16
|
+
a named check crossed its threshold and the scorecard needs a human reading,
|
|
17
|
+
not that the dataset is unusable. upgrade() converts older scorecards.
|
|
18
|
+
1.4 adds the "not_applicable" status (the check could not produce a result on
|
|
19
|
+
this input; see ids2eval.audit._status), a "not_applicable" entry in "counts",
|
|
20
|
+
and "checks_not_run": the known checks this run's config left disabled. A
|
|
21
|
+
not_applicable finding never raises the verdict. Earlier scorecards reported
|
|
22
|
+
such findings as "ok"; their "summary" text still says why. When no check
|
|
23
|
+
produced a substantive result (every finding not_applicable, or none at all),
|
|
24
|
+
overall_status is "not_evaluated" instead of "passed": an audit that tested
|
|
25
|
+
nothing must not read as a pass.
|
|
26
|
+
"checks" has one entry per check: {check, category, before, after} - category is
|
|
27
|
+
"audit" or "known_issue" (see ids2eval.audit.KNOWN_ISSUE_CHECKS) - each side
|
|
28
|
+
{status, summary, evidence} or null - "after" is null throughout when dedup
|
|
29
|
+
didn't run. "evidence" is one of "documented", "direct-experiment",
|
|
30
|
+
"statistical", or "cross-corroborated" (see ids2eval.audit.EVIDENCE_LEVEL):
|
|
31
|
+
a fixed classification of how the check's status was determined, not a
|
|
32
|
+
confidence guess about this particular result.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import html
|
|
38
|
+
from datetime import datetime, timezone
|
|
39
|
+
|
|
40
|
+
from .. import version_info
|
|
41
|
+
from ..audit import DEFAULT_EVIDENCE_LEVEL, EVIDENCE_LEVEL, KNOWN_ISSUE_CHECKS, STRUCTURAL_CHECKS
|
|
42
|
+
|
|
43
|
+
SCHEMA_VERSION = "1.4"
|
|
44
|
+
|
|
45
|
+
EVIDENCE_LABEL = {
|
|
46
|
+
"documented": "documented",
|
|
47
|
+
"direct-experiment": "direct experiment",
|
|
48
|
+
"cross-corroborated": "cross-corroborated",
|
|
49
|
+
"statistical": "statistical",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
_STATUS_RANK = {"not_applicable": -1, "ok": 0, "warning": 1, "flag": 2}
|
|
53
|
+
_OVERALL_STATUS = {"ok": "passed", "warning": "passed_with_warnings", "flag": "review_required",
|
|
54
|
+
"not_applicable": "not_evaluated"}
|
|
55
|
+
_LEGACY_VERDICTS = {"failed": "review_required"}
|
|
56
|
+
_STATUS_EMOJI = {"ok": "✅", "warning": "⚠️", "flag": "🚩", "not_applicable": "⚪"}
|
|
57
|
+
_STATUS_BADGE = {"ok": "pass", "warning": "warn", "flag": "flag", "not_applicable": "n/a"}
|
|
58
|
+
_VERDICT_LABEL = {
|
|
59
|
+
"passed": "✅ Passed",
|
|
60
|
+
"passed_with_warnings": "⚠️ Passed with warnings",
|
|
61
|
+
"review_required": "🚩 Review Required",
|
|
62
|
+
"not_evaluated": "⚪ Not evaluated",
|
|
63
|
+
}
|
|
64
|
+
_VERDICT_PLAIN = {"passed": "Passed", "passed_with_warnings": "Passed with warnings", "review_required": "Review Required",
|
|
65
|
+
"not_evaluated": "Not evaluated"}
|
|
66
|
+
_VERDICT_STATUS = {"passed": "ok", "passed_with_warnings": "warning", "review_required": "flag",
|
|
67
|
+
"not_evaluated": "not_applicable"}
|
|
68
|
+
|
|
69
|
+
# Hover text for each check in SCORECARD.html: what it measures, and the exact
|
|
70
|
+
# rule that turns a measurement into ok/warning/flag. Keep the rules in sync
|
|
71
|
+
# with the thresholds in ids2eval/audit/ - they're restated here, not imported,
|
|
72
|
+
# because a sentence has to say "more than 1 point", not "MATERIAL_DROP_THRESHOLD".
|
|
73
|
+
CHECK_INFO = {
|
|
74
|
+
"dedup_check": (
|
|
75
|
+
"Exact duplicate feature rows: within train, within test, and test rows whose features exactly "
|
|
76
|
+
"match a train row (labels ignored). Test data sharing under 10% with train is a common industry "
|
|
77
|
+
"rule of thumb (Zhou et al. 2024) - this check is stricter, since any overlap at all measures "
|
|
78
|
+
"memorization rather than generalization.",
|
|
79
|
+
"Flag: any test row matches a train row. Warning: duplicates only within a split.",
|
|
80
|
+
),
|
|
81
|
+
"label_conflict_check": (
|
|
82
|
+
"Groups rows by identical features (same columns dedup_check compares on) and checks whether "
|
|
83
|
+
"every row in a group has the same label. Must run on raw data: preprocessing.dedup already "
|
|
84
|
+
"collapses these groups to one arbitrarily-kept label, so it always reports zero after cleaning.",
|
|
85
|
+
"Flag: any feature vector maps to more than one label - the ground truth contradicts itself.",
|
|
86
|
+
),
|
|
87
|
+
"near_duplicate_class_check": (
|
|
88
|
+
"Extends label_conflict_check from identical feature vectors to near-identical ones: a single "
|
|
89
|
+
"nearest-neighbor index over sampled rows from every class, checking whether a row's nearest "
|
|
90
|
+
"neighbor under a different label sits at effectively zero scaled distance.",
|
|
91
|
+
"Flag: any near-zero-distance neighbor pair spans two different labels.",
|
|
92
|
+
),
|
|
93
|
+
"leakage_screen": (
|
|
94
|
+
"Fits a random forest on all features and checks whether one or two features carry most of the "
|
|
95
|
+
"importance - a shortcut a model can learn instead of attack behavior.",
|
|
96
|
+
"Flag: the top feature holds more than 50% of importance, or the top two more than 70%.",
|
|
97
|
+
),
|
|
98
|
+
"one_rule_check": (
|
|
99
|
+
"Fits a single depth-1 decision tree (one feature, one threshold) and reports the winning rule "
|
|
100
|
+
"in plain language. Per Wu & Keogh 2021: most widely-used time-series anomaly benchmarks turned "
|
|
101
|
+
"out solvable by a single line of code, which meant published algorithm comparisons on them were "
|
|
102
|
+
"measuring nothing.",
|
|
103
|
+
"Flag: the one-rule classifier reaches above 95% test accuracy on its own and removes at least half "
|
|
104
|
+
"the errors of always predicting the majority class.",
|
|
105
|
+
),
|
|
106
|
+
"feature_auc_ranking_check": (
|
|
107
|
+
"Standalone one-vs-rest AUC of every numeric feature against every class, computed directly from "
|
|
108
|
+
"each feature's raw values (no model fit needed, unlike identity_column_flag). Reports the single "
|
|
109
|
+
"best feature-class pair found.",
|
|
110
|
+
"Flag: AUC above 0.95. Warning: above 0.90. A higher bar than identity_column_flag's 0.8, since "
|
|
111
|
+
"this ranks every feature, including ones expected to correlate with the label somewhat on their own.",
|
|
112
|
+
),
|
|
113
|
+
"identity_column_flag": (
|
|
114
|
+
"How well looking each id-like column's value (IP, port, MAC) up in the training data predicts the "
|
|
115
|
+
"test label, as a ROC AUC over all test rows; values never seen in training get the class prior. "
|
|
116
|
+
"Also reports the share of test rows with a seen value and the AUC on those rows alone.",
|
|
117
|
+
"Flag: all-test AUC above 0.8. Not applicable when no schema.id_like_columns are configured.",
|
|
118
|
+
),
|
|
119
|
+
"port_protocol_shortcut_check": (
|
|
120
|
+
"How well a declared port-like column combined with a proto-like column predicts the label, as a "
|
|
121
|
+
"standalone ROC AUC - the same method as identity_column_flag, applied to the pair rather than "
|
|
122
|
+
"either column alone.",
|
|
123
|
+
"Flag: AUC above 0.8. Not applicable when no port-like and proto-like column pair is found.",
|
|
124
|
+
),
|
|
125
|
+
"temporal_leakage_check": (
|
|
126
|
+
"How well schema.timestamp_column alone predicts the label, as a standalone ROC AUC - datasets "
|
|
127
|
+
"collected as scenario-specific time windows (attack X launched 2-3pm, attack Y 3-4pm, ...) let "
|
|
128
|
+
"a model win by learning 'when', not 'what' (Wu & Keogh 2021's 'run-to-failure bias', confirmed "
|
|
129
|
+
"on this project's own CIC-IDS2018 run: Timestamp alone reached AUC 0.93).",
|
|
130
|
+
"Flag: AUC above 0.8. Not applicable when no schema.timestamp_column is configured.",
|
|
131
|
+
),
|
|
132
|
+
"temporal_realism_check": (
|
|
133
|
+
"The narrowest attack class's own timestamp span, as a share of the full capture's time span - "
|
|
134
|
+
"a class confined to a short burst window even when the aggregate timestamp AUC isn't dramatically "
|
|
135
|
+
"high.",
|
|
136
|
+
"Flag: below 5% of the full span. Warning: below 15%. Not applicable when no "
|
|
137
|
+
"schema.timestamp_column is configured.",
|
|
138
|
+
),
|
|
139
|
+
"flow_group_leakage_check": (
|
|
140
|
+
"Hashes schema.flow_id_columns (typically a 5-tuple) for train and test and checks whether any "
|
|
141
|
+
"flow identity appears on both sides of the split - a model can partly recognize the connection "
|
|
142
|
+
"instead of the attack behavior on those rows.",
|
|
143
|
+
"Flag: any flow identity present in both train and test. Not applicable when no "
|
|
144
|
+
"schema.flow_id_columns is configured.",
|
|
145
|
+
),
|
|
146
|
+
"row_order_leakage_check": (
|
|
147
|
+
"Counts label transitions between adjacent rows and compares it to the rate a randomly shuffled "
|
|
148
|
+
"ordering of the same label distribution would produce, a closed-form Wald-Wolfowitz-style "
|
|
149
|
+
"runs statistic, not a model fit.",
|
|
150
|
+
"Flag: the transition rate is below 30% of the shuffled expectation. Warning: below 60%.",
|
|
151
|
+
),
|
|
152
|
+
"homogeneity_test": (
|
|
153
|
+
"Per class: do test rows have an identical training row (distance below 1e-6 after scaling) more "
|
|
154
|
+
"often than training rows have one among the rest of training? 95% CI on the difference of the two "
|
|
155
|
+
"match rates, up to 500 rows per class.",
|
|
156
|
+
"Flag: in any class the CI's lower bound exceeds +0.05. Warning: some class's CI straddles +0.05 "
|
|
157
|
+
"(inconclusive). Ok: every class's upper bound is below +0.05.",
|
|
158
|
+
),
|
|
159
|
+
"resplit_falsification": (
|
|
160
|
+
"Retrains with a grouped split (no session from dataset.group_columns on both sides) and compares "
|
|
161
|
+
"accuracy against a random split. Rebuilds both splits from the raw data itself, so its result is "
|
|
162
|
+
"the same whether or not preprocessing.dedup ran.",
|
|
163
|
+
"Flag: grouped-split accuracy is more than 1 percentage point lower even after allowing for the "
|
|
164
|
+
"test sets' sampling margin. Warning: more than 1 point lower, but within that margin.",
|
|
165
|
+
),
|
|
166
|
+
"scenario_holdout_falsification": (
|
|
167
|
+
"Holds out the smallest schema.scenario_column value entirely as test, training on every other "
|
|
168
|
+
"scenario, and compares accuracy against a random split. Rebuilds both splits from the raw data "
|
|
169
|
+
"itself, so its result is the same whether or not preprocessing.dedup ran.",
|
|
170
|
+
"Flag: accuracy drops more than 10 percentage points on the held-out scenario. Warning: more than "
|
|
171
|
+
"5. Not applicable when no schema.scenario_column is configured.",
|
|
172
|
+
),
|
|
173
|
+
"class_distribution_report": (
|
|
174
|
+
"Class counts in train and test, the majority:minority ratio, and rare classes.",
|
|
175
|
+
"Warning: ratio above 100:1, or any class below 1% of train.",
|
|
176
|
+
),
|
|
177
|
+
"low_cardinality_warning": (
|
|
178
|
+
"Unique values in each id-like column - few values let a model memorize specific hosts.",
|
|
179
|
+
"Warning: fewer than 50 unique values.",
|
|
180
|
+
),
|
|
181
|
+
"schema_fingerprint_check": (
|
|
182
|
+
"Whether the column names match a flow extractor with documented bugs (currently CICFlowMeter). "
|
|
183
|
+
"Looks only at column names, which dedup never changes, so its result is the same on raw and "
|
|
184
|
+
"cleaned data.",
|
|
185
|
+
"Warning: 60% or more of the extractor's signature columns are present - a caution about the "
|
|
186
|
+
"extractor, not evidence that this copy is affected.",
|
|
187
|
+
),
|
|
188
|
+
"data_integrity_check": (
|
|
189
|
+
"Missing labels, missing values, +-inf values, and constant features (a single unique value) in train.",
|
|
190
|
+
"Flag: any missing label. Warning: any of the others.",
|
|
191
|
+
),
|
|
192
|
+
"synthetic_realism_check": (
|
|
193
|
+
"Trains a classifier to tell this dataset apart from audit.reference_dataset, plus per-feature "
|
|
194
|
+
"KS divergence.",
|
|
195
|
+
"Flag: the classifier separates them with AUC above 0.9.",
|
|
196
|
+
),
|
|
197
|
+
"cross_dataset_drift_check": (
|
|
198
|
+
"Trains on this dataset, tests on audit.reference_dataset, and compares accuracy.",
|
|
199
|
+
"Flag: accuracy drops by more than 10 percentage points.",
|
|
200
|
+
),
|
|
201
|
+
"cross_capture_matrix_check": (
|
|
202
|
+
"Trains on each schema.scenario_column value and tests on every other one, an N x N accuracy "
|
|
203
|
+
"matrix rather than scenario_holdout_falsification's single held-out number. Capped at 6 "
|
|
204
|
+
"scenarios and skipped past that, since each pair is a real extra model fit.",
|
|
205
|
+
"Flag: best-to-worst scenario-pair accuracy spread exceeds 20 points. Warning: exceeds 10. "
|
|
206
|
+
"Not applicable when no schema.scenario_column is configured.",
|
|
207
|
+
),
|
|
208
|
+
"feature_category_ablation_check": (
|
|
209
|
+
"Fits once on every feature, then once per schema.feature_categories group with that group's "
|
|
210
|
+
"columns removed, and reports how much accuracy each group's removal costs.",
|
|
211
|
+
"Flag: one category's removal costs more than 30 points of accuracy. Warning: more than 15. "
|
|
212
|
+
"Not applicable when no schema.feature_categories is configured.",
|
|
213
|
+
),
|
|
214
|
+
"artifact_sensitivity_check": (
|
|
215
|
+
"Fits once on every feature, then reshuffles just one declared identity or timestamp column's "
|
|
216
|
+
"own values across test rows and re-predicts with the same model, measuring what share of "
|
|
217
|
+
"predictions flip.",
|
|
218
|
+
"Flag: reshuffling one column flips more than 30% of predictions. Warning: more than 15%. Not "
|
|
219
|
+
"applicable when none of schema.id_like_columns or schema.timestamp_column are present as a feature.",
|
|
220
|
+
),
|
|
221
|
+
"result_robustness_check": (
|
|
222
|
+
"Re-fits under each condition it can build from the config, random split, session-grouped "
|
|
223
|
+
"split, deduplicated, identity columns dropped, and reports the full accuracy-by-condition "
|
|
224
|
+
"vector rather than one collapsed score.",
|
|
225
|
+
"Flag: the spread between the best and worst condition exceeds 15 points of accuracy even after "
|
|
226
|
+
"allowing for the test sets' sampling margin. Warning: more than 8 points. Not applicable when fewer "
|
|
227
|
+
"than two conditions can be built.",
|
|
228
|
+
),
|
|
229
|
+
"known_issue_lookup": (
|
|
230
|
+
"Looks the dataset name up in a hand-curated list of published problems with specific datasets. "
|
|
231
|
+
"Looks only at the name, so its result is the same on raw and cleaned data.",
|
|
232
|
+
"Warning: a match. Ok only means the dataset isn't in the list, not that it has no known issues.",
|
|
233
|
+
),
|
|
234
|
+
"seed_sensitivity_check": (
|
|
235
|
+
"Re-fits leakage_screen and one_rule_check across 5 seeds and checks whether their flag/ok "
|
|
236
|
+
"conclusion holds up, not just the point estimate a single seed happened to produce "
|
|
237
|
+
"(D'Amour et al. 2022's underspecification finding).",
|
|
238
|
+
"Warning: either check's verdict changed across seeds.",
|
|
239
|
+
),
|
|
240
|
+
"repeated_seed_falsification_check": (
|
|
241
|
+
"Re-runs resplit_falsification's random-vs-grouped accuracy comparison across "
|
|
242
|
+
"audit.repeated_seed_count independent seeds (default 10) and reports a 95% "
|
|
243
|
+
"confidence interval on the mean accuracy drop, instead of a single seed's point "
|
|
244
|
+
"estimate.",
|
|
245
|
+
"Flag: even the CI's most conservative bound exceeds the materiality threshold. "
|
|
246
|
+
"Ok: even its most generous bound stays below it. Warning: the CI straddles the "
|
|
247
|
+
"threshold, inconclusive at this many seeds, not evidence of no effect.",
|
|
248
|
+
),
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
_TWO_RUNS_NOTE = (
|
|
252
|
+
"Every check ran twice: on the raw data as loaded, and on the cleaned data - the same data with exact "
|
|
253
|
+
"duplicate rows removed, nothing else changed."
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
_KNOWN_ISSUES_NOTE = (
|
|
257
|
+
"Documented facts about this dataset or the tool that produced it, from published research - not "
|
|
258
|
+
"measured from this run's data, and nothing in this run's config can fix what they report."
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
_NOT_RUN_NOTE = (
|
|
262
|
+
"Disabled in this run's config, so they produced no result and say nothing about the dataset. "
|
|
263
|
+
"Checks marked n/a above ran but had no input to test."
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
_VERDICT_RULE = ("any flag → review required · warnings only → passed with warnings · all ok → passed; "
|
|
267
|
+
"not applicable checks never change the verdict; if no check produced a result → not evaluated")
|
|
268
|
+
_PRIOR_ART = (
|
|
269
|
+
"Scorecard format inspired by structured dataset-documentation practices. Datasheets for "
|
|
270
|
+
"Datasets (arXiv:1803.09010) and scorecards for synthetic data evaluation (arXiv:2406.11143)."
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _check_rows(findings_before: list[dict] | None, findings_after: list[dict] | None) -> list[dict]:
|
|
275
|
+
"""One entry per check, with that check's result in each audit pass side
|
|
276
|
+
by side - so every row has the same shape, and a status that changed
|
|
277
|
+
across dedup is visible by comparing two columns rather than two rows.
|
|
278
|
+
"""
|
|
279
|
+
before_by = {f["check"]: f for f in findings_before or []}
|
|
280
|
+
after_by = {f["check"]: f for f in findings_after or []}
|
|
281
|
+
return [
|
|
282
|
+
{
|
|
283
|
+
"check": check,
|
|
284
|
+
"category": "known_issue" if check in KNOWN_ISSUE_CHECKS else "audit",
|
|
285
|
+
"before": _result(before_by.get(check), _evidence(check, before_by)),
|
|
286
|
+
"after": _result(after_by.get(check), _evidence(check, after_by)),
|
|
287
|
+
}
|
|
288
|
+
for check in dict.fromkeys([*before_by, *after_by])
|
|
289
|
+
]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _evidence(check: str, findings_by_check: dict) -> str:
|
|
293
|
+
base = EVIDENCE_LEVEL.get(check, DEFAULT_EVIDENCE_LEVEL)
|
|
294
|
+
if check != "homogeneity_test":
|
|
295
|
+
return base
|
|
296
|
+
homogeneity = findings_by_check.get("homogeneity_test")
|
|
297
|
+
resplit = findings_by_check.get("resplit_falsification")
|
|
298
|
+
if homogeneity and resplit and homogeneity["status"] == "flag" and resplit["status"] == "flag":
|
|
299
|
+
return "cross-corroborated"
|
|
300
|
+
return base
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _result(finding: dict | None, evidence: str | None = None) -> dict | None:
|
|
304
|
+
if finding is None:
|
|
305
|
+
return None
|
|
306
|
+
result = {"status": finding["status"], "summary": finding["summary"]}
|
|
307
|
+
if evidence is not None:
|
|
308
|
+
result["evidence"] = evidence
|
|
309
|
+
return result
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _dedup_effect(findings_before: list[dict] | None) -> dict | None:
|
|
313
|
+
dedup = next((f for f in findings_before or [] if f["check"] == "dedup_check"), None)
|
|
314
|
+
if dedup is None or "train_rows_raw" not in dedup.get("details", {}):
|
|
315
|
+
return None
|
|
316
|
+
d = dedup["details"]
|
|
317
|
+
return {k: int(d[k]) for k in (
|
|
318
|
+
"train_rows_raw", "train_rows_deduped", "train_duplicates_dropped",
|
|
319
|
+
"test_rows_raw", "test_rows_deduped", "test_leakage_dropped", "test_duplicates_dropped",
|
|
320
|
+
) if k in d}
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def build_scorecard(
|
|
324
|
+
findings: list[dict],
|
|
325
|
+
audit_stage: str,
|
|
326
|
+
cfg: dict,
|
|
327
|
+
fingerprint: dict,
|
|
328
|
+
findings_before: list[dict] | None = None,
|
|
329
|
+
) -> dict:
|
|
330
|
+
"""audit_stage is "before" or "after" - which of
|
|
331
|
+
audit_report_before.json/audit_report_after.json these findings came
|
|
332
|
+
from (whichever is the FINAL state of the data shipped in this run:
|
|
333
|
+
"after" if preprocessing.dedup ran, else "before"). The verdict is
|
|
334
|
+
always judged on these final findings.
|
|
335
|
+
|
|
336
|
+
findings_before is the pre-dedup pass, passed only when audit_stage is
|
|
337
|
+
"after": it adds the before/after comparison rows and the dedup effect,
|
|
338
|
+
never the verdict - a flag dedup has already fixed isn't a flag on the
|
|
339
|
+
data this run shipped.
|
|
340
|
+
"""
|
|
341
|
+
# No finding, or only not_applicable ones, leaves worst at not_applicable: not_evaluated.
|
|
342
|
+
worst = max((f["status"] for f in findings), key=lambda s: _STATUS_RANK[s], default="not_applicable")
|
|
343
|
+
version = version_info.get_version_info()
|
|
344
|
+
counts = {"ok": 0, "warning": 0, "flag": 0, "not_applicable": 0}
|
|
345
|
+
for f in findings:
|
|
346
|
+
counts[f["status"]] += 1
|
|
347
|
+
ran = {f["check"] for f in findings} | {f["check"] for f in findings_before or []}
|
|
348
|
+
|
|
349
|
+
if audit_stage == "after":
|
|
350
|
+
rows = _check_rows(findings_before, findings)
|
|
351
|
+
else:
|
|
352
|
+
rows = _check_rows(findings, None)
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
"scorecard_schema_version": SCHEMA_VERSION,
|
|
356
|
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
357
|
+
"dataset_name": cfg["dataset"]["name"],
|
|
358
|
+
"ids2eval_version": version["version"],
|
|
359
|
+
"ids2eval_git_commit": version["git_commit"],
|
|
360
|
+
"audit_stage": audit_stage,
|
|
361
|
+
"overall_status": _OVERALL_STATUS[worst],
|
|
362
|
+
"counts": counts,
|
|
363
|
+
"checks_not_run": [c for c in CHECK_INFO if c not in ran],
|
|
364
|
+
"findings": [{"check": f["check"], "status": f["status"], "summary": f["summary"]} for f in findings],
|
|
365
|
+
"checks": rows,
|
|
366
|
+
# Only once dedup actually ran - before it, these are hypothetical counts.
|
|
367
|
+
"dedup_effect": _dedup_effect(findings_before) if audit_stage == "after" else None,
|
|
368
|
+
"dataset_fingerprint": {
|
|
369
|
+
"train_rows": fingerprint["train_rows"],
|
|
370
|
+
"test_rows": fingerprint["test_rows"],
|
|
371
|
+
"feature_count": fingerprint.get("feature_count"),
|
|
372
|
+
"train_content_hash": fingerprint["train_content_hash"],
|
|
373
|
+
"test_content_hash": fingerprint["test_content_hash"],
|
|
374
|
+
},
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _counts(scorecard: dict) -> dict:
|
|
379
|
+
# Scorecards written under schema 1.0 have no "counts" key.
|
|
380
|
+
if "counts" in scorecard:
|
|
381
|
+
return {"not_applicable": 0, **scorecard["counts"]}
|
|
382
|
+
counts = {"ok": 0, "warning": 0, "flag": 0, "not_applicable": 0}
|
|
383
|
+
for f in scorecard["findings"]:
|
|
384
|
+
counts[f["status"]] += 1
|
|
385
|
+
return counts
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _tally(counts: dict) -> str:
|
|
389
|
+
text = f"{counts['ok']} ok, {counts['warning']} warning(s), {counts['flag']} flag(s)"
|
|
390
|
+
return text + (f", {counts['not_applicable']} not applicable" if counts["not_applicable"] else "")
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _rows(scorecard: dict) -> list[dict]:
|
|
394
|
+
"""Per-check rows plus what the renderers need: the final result, the
|
|
395
|
+
pre-dedup summary when it differs, and whether the status moved.
|
|
396
|
+
Scorecards written under schema 1.0 have no "checks" - rebuilt from
|
|
397
|
+
"findings", which is the final pass.
|
|
398
|
+
"""
|
|
399
|
+
if scorecard.get("checks"):
|
|
400
|
+
rows = scorecard["checks"]
|
|
401
|
+
else:
|
|
402
|
+
final = [{"check": f["check"], "status": f["status"], "summary": f["summary"]} for f in scorecard["findings"]]
|
|
403
|
+
rows = _check_rows(final, None) if scorecard["audit_stage"] == "before" else _check_rows(None, final)
|
|
404
|
+
out = []
|
|
405
|
+
for r in rows:
|
|
406
|
+
b, a = r["before"], r["after"]
|
|
407
|
+
final = a or b
|
|
408
|
+
out.append({
|
|
409
|
+
**r,
|
|
410
|
+
"summary": final["summary"],
|
|
411
|
+
"summary_before": b["summary"] if a and b and b["summary"] != a["summary"] else None,
|
|
412
|
+
"moved": bool(a and b and a["status"] != b["status"]),
|
|
413
|
+
# Provably the same check run once, not two independent measurements of the
|
|
414
|
+
# same result - see STRUCTURAL_CHECKS. Renderers show one status, not two.
|
|
415
|
+
"structural": r["check"] in STRUCTURAL_CHECKS,
|
|
416
|
+
# Absent on scorecards written under schema 1.0/1.1.
|
|
417
|
+
"evidence": final.get("evidence"),
|
|
418
|
+
})
|
|
419
|
+
return out
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _has_after(scorecard: dict) -> bool:
|
|
423
|
+
return scorecard["audit_stage"] == "after"
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _split_rows(rows: list[dict]) -> tuple[list[dict], list[dict]]:
|
|
427
|
+
"""(audit rows, known-issue rows) - see KNOWN_ISSUE_CHECKS. Known-issue rows always
|
|
428
|
+
render with a single Status column: their result can't differ between raw and
|
|
429
|
+
cleaned data (see CHECK_INFO), so showing two columns would only invite the
|
|
430
|
+
question this split exists to answer - see _rows()'s "category" field.
|
|
431
|
+
"""
|
|
432
|
+
audit_rows = [r for r in rows if r["category"] == "audit"]
|
|
433
|
+
known_rows = [r for r in rows if r["category"] == "known_issue"]
|
|
434
|
+
return audit_rows, known_rows
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _md_status(result: dict | None) -> str:
|
|
438
|
+
return "not run" if result is None else f"{_STATUS_EMOJI[result['status']]} {_STATUS_BADGE[result['status']]}"
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _md_evidence_note(r: dict) -> str:
|
|
442
|
+
evidence = r.get("evidence")
|
|
443
|
+
return f"*Evidence: {EVIDENCE_LABEL[evidence]}.* " if evidence else ""
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _drift_lines(scorecard: dict) -> list[str]:
|
|
447
|
+
"""Markdown bullet lines for the previous-run comparison, or [] if the
|
|
448
|
+
scorecard carries none (schema 1.0, or no previous run existed).
|
|
449
|
+
"""
|
|
450
|
+
drift = scorecard.get("previous_run_comparison")
|
|
451
|
+
if not drift:
|
|
452
|
+
return []
|
|
453
|
+
lines = [f"Compared to `{drift['previous_run']}` ({drift['previous_generated_at']})."]
|
|
454
|
+
if drift["fingerprint_changed"]:
|
|
455
|
+
lines.append(
|
|
456
|
+
"The underlying data changed since then (a different content hash) - the "
|
|
457
|
+
"comparison below may reflect a different dataset, not drift within the same one."
|
|
458
|
+
)
|
|
459
|
+
if drift["changed_checks"]:
|
|
460
|
+
for c in drift["changed_checks"]:
|
|
461
|
+
lines.append(f"- `{c['check']}`: {c['previous_status']} to {c['current_status']}")
|
|
462
|
+
else:
|
|
463
|
+
lines.append("No check's status changed since the previous run.")
|
|
464
|
+
return lines
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def normalize_verdict(verdict: str | None) -> str | None:
|
|
468
|
+
return _LEGACY_VERDICTS.get(verdict, verdict)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def upgrade(scorecard: dict) -> dict:
|
|
472
|
+
"""A copy of a scorecard read from disk, in the current verdict vocabulary.
|
|
473
|
+
|
|
474
|
+
Only the 1.3 rename needs converting: every other key is unchanged since 1.0.
|
|
475
|
+
"""
|
|
476
|
+
sc = dict(scorecard)
|
|
477
|
+
if "overall_status" in sc:
|
|
478
|
+
sc["overall_status"] = normalize_verdict(sc["overall_status"])
|
|
479
|
+
prev = sc.get("previous_run_comparison")
|
|
480
|
+
if isinstance(prev, dict) and "previous_verdict" in prev:
|
|
481
|
+
sc["previous_run_comparison"] = {**prev, "previous_verdict": normalize_verdict(prev["previous_verdict"])}
|
|
482
|
+
if sc.get("scorecard_schema_version") in ("1.0", "1.1", "1.2"):
|
|
483
|
+
sc["scorecard_schema_version"] = SCHEMA_VERSION
|
|
484
|
+
return sc
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def citation_text(scorecard: dict) -> str:
|
|
488
|
+
counts = _counts(scorecard)
|
|
489
|
+
return (
|
|
490
|
+
f"This result was obtained on a dataset audited with IDS2Eval "
|
|
491
|
+
f"v{scorecard['ids2eval_version']} (scorecard schema "
|
|
492
|
+
f"{scorecard['scorecard_schema_version']}), which reported "
|
|
493
|
+
f"{scorecard['overall_status'].replace('_', ' ')}, "
|
|
494
|
+
f"{_tally(counts)} "
|
|
495
|
+
f"across {len(scorecard['findings'])} checks. Full report: "
|
|
496
|
+
f"audit_report_{scorecard['audit_stage']}.json."
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def render_markdown(scorecard: dict, has_plot: bool = False) -> str:
|
|
501
|
+
counts = _counts(scorecard)
|
|
502
|
+
rows = _rows(scorecard)
|
|
503
|
+
fp = scorecard["dataset_fingerprint"]
|
|
504
|
+
|
|
505
|
+
lines = [
|
|
506
|
+
"# IDS2Eval Scorecard",
|
|
507
|
+
"",
|
|
508
|
+
f"**Dataset:** {scorecard['dataset_name']}",
|
|
509
|
+
f"**Overall status:** {_VERDICT_LABEL[scorecard['overall_status']]}"
|
|
510
|
+
f", {_tally(counts)}",
|
|
511
|
+
f"**Generated:** {scorecard['generated_at']}",
|
|
512
|
+
f"**IDS2Eval version:** {scorecard['ids2eval_version']}"
|
|
513
|
+
+ (f" (git {scorecard['ids2eval_git_commit'][:7]})" if scorecard["ids2eval_git_commit"] else ""),
|
|
514
|
+
f"**Scorecard schema version:** {scorecard['scorecard_schema_version']}",
|
|
515
|
+
f"**Verdict judged on:** {'cleaned data (exact duplicates removed)' if _has_after(scorecard) else 'raw data'}",
|
|
516
|
+
*([f"**Status rule:** {scorecard['status_rule']}"] if scorecard.get("status_rule") else []),
|
|
517
|
+
"",
|
|
518
|
+
"A full, styled version of this scorecard is in `SCORECARD.html`.",
|
|
519
|
+
]
|
|
520
|
+
if has_plot:
|
|
521
|
+
# An explicit width, not bare markdown ![]() syntax - GitHub renders
|
|
522
|
+
# an embedded image at full native size otherwise, which dwarfs the
|
|
523
|
+
# rest of the page for a figure this tall.
|
|
524
|
+
lines += ["", '<img src="scorecard.png" alt="IDS2Eval Scorecard" width="760">']
|
|
525
|
+
audit_rows, known_rows = _split_rows(rows)
|
|
526
|
+
has_after = _has_after(scorecard)
|
|
527
|
+
|
|
528
|
+
lines += [
|
|
529
|
+
"",
|
|
530
|
+
"## Checks",
|
|
531
|
+
"",
|
|
532
|
+
"Findings from this run's own data. Each one can, in principle, be reacted to - "
|
|
533
|
+
"by dropping a column, resampling, switching split modes, or just noting the caveat.",
|
|
534
|
+
"",
|
|
535
|
+
"| # | Check | Raw data | Cleaned data | Summary |" if has_after else "| # | Check | Status | Summary |",
|
|
536
|
+
"|---|---|---|---|---|" if has_after else "|---|---|---|---|",
|
|
537
|
+
]
|
|
538
|
+
for i, r in enumerate(audit_rows, 1):
|
|
539
|
+
if has_after and r["structural"]:
|
|
540
|
+
statuses = f"{_md_status(r['before'])} (same on raw and cleaned data) |"
|
|
541
|
+
elif has_after:
|
|
542
|
+
statuses = f"{_md_status(r['before'])} | {_md_status(r['after'])} |"
|
|
543
|
+
else:
|
|
544
|
+
statuses = f"{_md_status(r['before'])} |"
|
|
545
|
+
lines.append(
|
|
546
|
+
f"| {i} | `{r['check']}` | {statuses} {_md_evidence_note(r)}{r['summary']}"
|
|
547
|
+
+ (f"<br>*raw data:* {r['summary_before']}" if r["summary_before"] else "") + " |"
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
if known_rows:
|
|
551
|
+
lines += [
|
|
552
|
+
"",
|
|
553
|
+
"## Known issues",
|
|
554
|
+
"",
|
|
555
|
+
_KNOWN_ISSUES_NOTE,
|
|
556
|
+
"",
|
|
557
|
+
"| # | Check | Status | Summary |",
|
|
558
|
+
"|---|---|---|---|",
|
|
559
|
+
]
|
|
560
|
+
for i, r in enumerate(known_rows, 1):
|
|
561
|
+
lines.append(f"| {i} | `{r['check']}` | {_md_status(r['before'])} | {_md_evidence_note(r)}{r['summary']} |")
|
|
562
|
+
|
|
563
|
+
not_run = scorecard.get("checks_not_run")
|
|
564
|
+
if not_run:
|
|
565
|
+
lines += ["", "## Not run", "", f"{_NOT_RUN_NOTE} " + ", ".join(f"`{c}`" for c in not_run) + "."]
|
|
566
|
+
|
|
567
|
+
effect = scorecard.get("dedup_effect")
|
|
568
|
+
if effect:
|
|
569
|
+
lines += [
|
|
570
|
+
"",
|
|
571
|
+
"## What cleaning removed",
|
|
572
|
+
"",
|
|
573
|
+
f"- Train: {effect['train_rows_raw']:,} → {effect['train_rows_deduped']:,} rows "
|
|
574
|
+
f"({effect['train_duplicates_dropped']:,} duplicates dropped)",
|
|
575
|
+
f"- Test: {effect['test_rows_raw']:,} → {effect['test_rows_deduped']:,} rows "
|
|
576
|
+
f"({effect['test_leakage_dropped']:,} train-leaking rows and "
|
|
577
|
+
f"{effect['test_duplicates_dropped']:,} test-internal duplicates dropped)",
|
|
578
|
+
]
|
|
579
|
+
|
|
580
|
+
drift_lines = _drift_lines(scorecard)
|
|
581
|
+
if drift_lines:
|
|
582
|
+
lines += ["", "## Compared to the previous run", "", *drift_lines]
|
|
583
|
+
|
|
584
|
+
lines += [
|
|
585
|
+
"",
|
|
586
|
+
"## Dataset fingerprint",
|
|
587
|
+
"",
|
|
588
|
+
f"- Train rows: {fp['train_rows']:,} · test rows: {fp['test_rows']:,}"
|
|
589
|
+
+ (f" · features: {fp['feature_count']}" if fp.get("feature_count") is not None else ""),
|
|
590
|
+
f"- Train content hash: `{fp['train_content_hash']}`",
|
|
591
|
+
f"- Test content hash: `{fp['test_content_hash']}`",
|
|
592
|
+
"",
|
|
593
|
+
"## Citing this result",
|
|
594
|
+
"",
|
|
595
|
+
"> " + citation_text(scorecard)
|
|
596
|
+
+ (" A vector figure of this chart is at `scorecard.pdf`, ready to cite directly." if has_plot else ""),
|
|
597
|
+
"",
|
|
598
|
+
f"Verdict rule: {_VERDICT_RULE}.",
|
|
599
|
+
"",
|
|
600
|
+
"---",
|
|
601
|
+
"*Scorecard format inspired by structured dataset-documentation "
|
|
602
|
+
"practices. Datasheets for Datasets ([arXiv:1803.09010]"
|
|
603
|
+
"(https://arxiv.org/abs/1803.09010)) and scorecards for synthetic "
|
|
604
|
+
"data evaluation ([arXiv:2406.11143](https://arxiv.org/abs/2406.11143)).*",
|
|
605
|
+
]
|
|
606
|
+
return "\n".join(lines) + "\n"
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
_HTML_CSS = """
|
|
610
|
+
:root{--bg:#f6f7f9;--card:#fff;--ink:#16181c;--muted:#6b6f7a;--line:#e6e8ec;--bar:#16181c;--bar-ink:#f2f3f5;
|
|
611
|
+
--ok:#1a7f4e;--ok-bg:#e3f4ea;--warn:#a15c00;--warn-bg:#fdf0da;--flag:#b3261e;--flag-bg:#fbe3e1;--code:#f0f1f4}
|
|
612
|
+
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--bg:#101216;--card:#171a1f;--ink:#e8eaee;
|
|
613
|
+
--muted:#9aa0ab;--line:#2a2e36;--bar:#0b0c0f;--bar-ink:#e8eaee;--ok:#5cc98f;--ok-bg:#15301f;--warn:#f0b35a;
|
|
614
|
+
--warn-bg:#35270f;--flag:#f08a80;--flag-bg:#3a1916;--code:#20242b}}
|
|
615
|
+
*{box-sizing:border-box}
|
|
616
|
+
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",
|
|
617
|
+
Helvetica,Arial,sans-serif}
|
|
618
|
+
.mono,code,td.check{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
|
619
|
+
.page{max-width:1080px;margin:24px auto;padding:0 16px}
|
|
620
|
+
.sheet{background:var(--card);border:1px solid var(--line);border-left:8px solid var(--accent);border-radius:10px;
|
|
621
|
+
padding:36px 40px}
|
|
622
|
+
.eyebrow{color:var(--muted);font-size:1.05rem;margin:0}
|
|
623
|
+
h1{font-size:1.9rem;line-height:1.2;margin:.2rem 0 .5rem}
|
|
624
|
+
.lede{color:var(--muted);margin:0 0 28px}
|
|
625
|
+
.card{border:1px solid var(--line);border-radius:8px}
|
|
626
|
+
.bar{background:var(--bar);color:var(--bar-ink);display:flex;justify-content:space-between;gap:12px;
|
|
627
|
+
flex-wrap:wrap;padding:12px 24px;border-radius:7px 7px 0 0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
|
628
|
+
.bar b{font-weight:700}
|
|
629
|
+
.body{padding:20px 24px 8px}
|
|
630
|
+
.meta{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;padding-bottom:18px;
|
|
631
|
+
border-bottom:1px solid var(--line)}
|
|
632
|
+
.meta .k{color:var(--muted);font-size:.82rem}
|
|
633
|
+
.meta .v{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;overflow-wrap:anywhere}
|
|
634
|
+
table{width:100%;border-collapse:collapse;margin-top:10px}
|
|
635
|
+
th{text-align:left;color:var(--muted);font-size:.82rem;font-weight:600;padding:10px;border-bottom:1px solid var(--line)}
|
|
636
|
+
td{padding:11px 10px;border-bottom:1px solid var(--line);vertical-align:top}
|
|
637
|
+
td{overflow-wrap:break-word}
|
|
638
|
+
td.num{color:var(--muted);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.82rem;
|
|
639
|
+
width:1%;white-space:nowrap}
|
|
640
|
+
td.check{white-space:nowrap;position:relative}
|
|
641
|
+
.info{display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;margin-left:6px;
|
|
642
|
+
border:1px solid var(--muted);border-radius:50%;color:var(--muted);font:700 10px/1 -apple-system,BlinkMacSystemFont,
|
|
643
|
+
"Segoe UI",Helvetica,Arial,sans-serif;cursor:help;vertical-align:1px}
|
|
644
|
+
.info:hover,.info:focus-visible{color:var(--ink);border-color:var(--ink);outline:none}
|
|
645
|
+
.tip{display:none;position:absolute;left:4px;top:calc(100% - 4px);z-index:10;width:min(360px,calc(100vw - 48px));
|
|
646
|
+
padding:10px 12px;background:var(--bar);color:var(--bar-ink);border-radius:8px;box-shadow:0 8px 24px rgb(0 0 0/.28);
|
|
647
|
+
font:13px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;white-space:normal;
|
|
648
|
+
text-align:left;overflow-wrap:normal}
|
|
649
|
+
.tip .tk{display:block;font-size:.7rem;font-weight:700;letter-spacing:.05em;text-transform:uppercase;opacity:.65}
|
|
650
|
+
.tip .tv{display:block;margin-bottom:8px}.tip .tv:last-child{margin-bottom:0}
|
|
651
|
+
.info:hover+.tip,.info:focus+.tip{display:block}
|
|
652
|
+
td.status{white-space:nowrap}
|
|
653
|
+
.same-note{color:var(--muted);font-size:.78rem;margin-left:6px}
|
|
654
|
+
.badge{display:inline-block;font:700 .75rem/1 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
|
655
|
+
padding:5px 9px;border-radius:999px}
|
|
656
|
+
.b-ok{color:var(--ok);background:var(--ok-bg)}.b-warning{color:var(--warn);background:var(--warn-bg)}
|
|
657
|
+
.b-flag{color:var(--flag);background:var(--flag-bg)}.b-not_applicable{color:var(--muted);background:var(--code)}
|
|
658
|
+
.b-none{color:var(--muted);background:transparent}
|
|
659
|
+
.evidence{display:inline-block;color:var(--muted);font-size:.72rem;text-transform:uppercase;letter-spacing:.03em;
|
|
660
|
+
margin-right:6px}
|
|
661
|
+
tr.moved td{background:color-mix(in srgb,var(--accent) 4%,transparent)}
|
|
662
|
+
.was{color:var(--muted);font-size:.82rem;margin-top:3px}
|
|
663
|
+
h2{font-size:1.05rem;margin:28px 0 10px}
|
|
664
|
+
h2.first{margin-top:0}
|
|
665
|
+
.section-note{margin:0 0 4px;color:var(--muted);font-size:.9rem}
|
|
666
|
+
.effect{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
|
|
667
|
+
.tile{border:1px solid var(--line);border-radius:8px;padding:12px 14px}
|
|
668
|
+
.tile .k{color:var(--muted);font-size:.82rem}.tile .v{font-size:1.05rem;font-weight:600}
|
|
669
|
+
blockquote{margin:0;padding:14px 16px;border-left:3px solid var(--accent);background:var(--code);border-radius:4px}
|
|
670
|
+
.hash{font-size:.8rem;overflow-wrap:anywhere}
|
|
671
|
+
.foot{color:var(--muted);font-size:.8rem;margin-top:28px;border-top:1px solid var(--line);padding-top:12px}
|
|
672
|
+
@media (max-width:720px){.sheet{padding:24px 18px}.meta{grid-template-columns:repeat(2,minmax(0,1fr))}
|
|
673
|
+
.effect{grid-template-columns:1fr}
|
|
674
|
+
thead{display:none}table,tbody,tr,td{display:block;width:100%}
|
|
675
|
+
tr{border-bottom:1px solid var(--line);padding:10px 4px}td{border:0;padding:3px 0}
|
|
676
|
+
td.num,td.check,td.status{display:inline-block;width:auto;margin-right:10px;vertical-align:middle}
|
|
677
|
+
td.status[data-label]:not([data-label=""])::before{content:attr(data-label) " ";color:var(--muted);font-size:.75rem;
|
|
678
|
+
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
|
679
|
+
.same-note{display:block;margin:2px 0 0}
|
|
680
|
+
td.check{white-space:normal}.tip{left:0;width:calc(100vw - 72px)}
|
|
681
|
+
tr.moved{background:color-mix(in srgb,var(--accent) 4%,transparent)}tr.moved td{background:none}}
|
|
682
|
+
@media print{body{background:#fff}.page{margin:0;max-width:none}.sheet{border-radius:0}.info,.tip{display:none}}
|
|
683
|
+
"""
|
|
684
|
+
|
|
685
|
+
_ACCENT = {"ok": "#1a8a5a", "warning": "#c07a10", "flag": "#c0392b", "not_applicable": "#8a8f99"}
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _info_html(check: str, row: int) -> str:
|
|
689
|
+
"""The hover/focus explainer for one check - CSS only, no script, so the
|
|
690
|
+
page stays self-contained and still works under the dashboard's CSP.
|
|
691
|
+
"""
|
|
692
|
+
if check not in CHECK_INFO:
|
|
693
|
+
return ""
|
|
694
|
+
what, rule = CHECK_INFO[check]
|
|
695
|
+
tip_id = f"tip-{row}"
|
|
696
|
+
return (
|
|
697
|
+
f'<span class="info" tabindex="0" role="button" aria-label="About {html.escape(check)}" '
|
|
698
|
+
f'aria-describedby="{tip_id}">i</span>'
|
|
699
|
+
f'<span class="tip" role="tooltip" id="{tip_id}"><span class="tk">What it checks</span>'
|
|
700
|
+
f'<span class="tv">{html.escape(what)}</span><span class="tk">Rule</span>'
|
|
701
|
+
f'<span class="tv">{html.escape(rule)}</span></span>'
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def render_html(scorecard: dict) -> str:
|
|
706
|
+
"""A standalone, self-contained page (no external assets) - opens from
|
|
707
|
+
disk, prints to PDF from any browser, and is what the web UI shows.
|
|
708
|
+
"""
|
|
709
|
+
e = html.escape
|
|
710
|
+
counts = _counts(scorecard)
|
|
711
|
+
rows = _rows(scorecard)
|
|
712
|
+
fp = scorecard["dataset_fingerprint"]
|
|
713
|
+
verdict = scorecard["overall_status"]
|
|
714
|
+
commit = scorecard["ids2eval_git_commit"]
|
|
715
|
+
generated = scorecard["generated_at"][:16].replace("T", " ") + " UTC"
|
|
716
|
+
n_checks = len(scorecard["findings"])
|
|
717
|
+
has_after = _has_after(scorecard)
|
|
718
|
+
moved = any(r["moved"] for r in rows)
|
|
719
|
+
|
|
720
|
+
def badge(result: dict | None) -> str:
|
|
721
|
+
return '<span class="badge b-none">not run</span>' if result is None else \
|
|
722
|
+
f'<span class="badge b-{result["status"]}">{_STATUS_BADGE[result["status"]]}</span>'
|
|
723
|
+
|
|
724
|
+
def status_cells(r: dict) -> str:
|
|
725
|
+
if not has_after:
|
|
726
|
+
return f'<td class="status">{badge(r["before"])}</td>'
|
|
727
|
+
if r["structural"]:
|
|
728
|
+
# One measurement, not two: a single cell spanning both data columns, with a
|
|
729
|
+
# muted note in place of a second badge - showing the same colored badge twice
|
|
730
|
+
# would look like an independent second result that just happened to agree.
|
|
731
|
+
return (
|
|
732
|
+
f'<td class="status" colspan="2">{badge(r["before"])} '
|
|
733
|
+
f'<span class="same-note">same on raw and cleaned data</span></td>'
|
|
734
|
+
)
|
|
735
|
+
return (
|
|
736
|
+
f'<td class="status" data-label="raw">{badge(r["before"])}</td>'
|
|
737
|
+
f'<td class="status" data-label="cleaned">{badge(r["after"])}</td>'
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
def evidence_note(r: dict) -> str:
|
|
741
|
+
evidence = r.get("evidence")
|
|
742
|
+
return f'<span class="evidence">{e(EVIDENCE_LABEL[evidence])}</span>' if evidence else ""
|
|
743
|
+
|
|
744
|
+
def audit_row(i: int, r: dict) -> str:
|
|
745
|
+
return (
|
|
746
|
+
f'<tr class="{"moved" if r["moved"] else ""}">'
|
|
747
|
+
f'<td class="num">{i}</td>'
|
|
748
|
+
f'<td class="check">{e(r["check"])}{_info_html(r["check"], i)}</td>'
|
|
749
|
+
+ status_cells(r)
|
|
750
|
+
+ f"<td>{evidence_note(r)}{e(r['summary'])}"
|
|
751
|
+
+ (f'<div class="was">raw data: {e(r["summary_before"])}</div>' if r["summary_before"] else "")
|
|
752
|
+
+ "</td></tr>"
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
def known_issue_row(i: int, r: dict) -> str:
|
|
756
|
+
# Always one status - see _split_rows(): can't differ between raw and cleaned data.
|
|
757
|
+
return (
|
|
758
|
+
f'<tr><td class="num">{i}</td>'
|
|
759
|
+
f'<td class="check">{e(r["check"])}{_info_html(r["check"], i + 1000)}</td>'
|
|
760
|
+
f'<td class="status">{badge(r["before"])}</td>'
|
|
761
|
+
f"<td>{evidence_note(r)}{e(r['summary'])}</td></tr>"
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
audit_rows, known_rows = _split_rows(rows)
|
|
765
|
+
audit_body = "\n".join(audit_row(i, r) for i, r in enumerate(audit_rows, 1))
|
|
766
|
+
header = "<th>#</th><th>Check</th><th>Raw data</th><th>Cleaned data</th><th>Summary</th>" if has_after \
|
|
767
|
+
else "<th>#</th><th>Check</th><th>Status</th><th>Summary</th>"
|
|
768
|
+
|
|
769
|
+
known_issues_html = ""
|
|
770
|
+
if known_rows:
|
|
771
|
+
known_body = "\n".join(known_issue_row(i, r) for i, r in enumerate(known_rows, 1))
|
|
772
|
+
known_issues_html = f"""<h2>Known issues</h2>
|
|
773
|
+
<p class="section-note">{e(_KNOWN_ISSUES_NOTE)}</p>
|
|
774
|
+
<table>
|
|
775
|
+
<thead><tr><th>#</th><th>Check</th><th>Status</th><th>Summary</th></tr></thead>
|
|
776
|
+
<tbody>
|
|
777
|
+
{known_body}
|
|
778
|
+
</tbody></table>"""
|
|
779
|
+
|
|
780
|
+
tally = f"{_tally(counts)} across {n_checks} checks"
|
|
781
|
+
if has_after:
|
|
782
|
+
lede = f"{_TWO_RUNS_NOTE} Verdict ({tally}) is judged on the cleaned data."
|
|
783
|
+
if moved:
|
|
784
|
+
lede += " Highlighted rows are checks whose status differs between the two."
|
|
785
|
+
else:
|
|
786
|
+
lede = f"{tally}, run once on the raw data (duplicate removal was off for this run)."
|
|
787
|
+
|
|
788
|
+
drift = scorecard.get("previous_run_comparison")
|
|
789
|
+
drift_html = ""
|
|
790
|
+
if drift:
|
|
791
|
+
if drift["changed_checks"]:
|
|
792
|
+
rows = "".join(
|
|
793
|
+
f"<li><code>{e(c['check'])}</code>: {e(c['previous_status'])} to {e(c['current_status'])}</li>"
|
|
794
|
+
for c in drift["changed_checks"]
|
|
795
|
+
)
|
|
796
|
+
changes = f"<ul>{rows}</ul>"
|
|
797
|
+
else:
|
|
798
|
+
changes = "<p>No check's status changed since the previous run.</p>"
|
|
799
|
+
fp_note = (
|
|
800
|
+
'<p class="section-note">The underlying data changed since then (a different content hash) - '
|
|
801
|
+
"the comparison below may reflect a different dataset, not drift within the same one.</p>"
|
|
802
|
+
if drift["fingerprint_changed"] else ""
|
|
803
|
+
)
|
|
804
|
+
drift_html = f"""<h2>Compared to the previous run</h2>
|
|
805
|
+
<p class="section-note">Compared to <code>{e(drift['previous_run'])}</code>
|
|
806
|
+
({e(str(drift['previous_generated_at']))}).</p>
|
|
807
|
+
{fp_note}
|
|
808
|
+
{changes}"""
|
|
809
|
+
|
|
810
|
+
not_run = scorecard.get("checks_not_run")
|
|
811
|
+
not_run_html = ""
|
|
812
|
+
if not_run:
|
|
813
|
+
not_run_html = (
|
|
814
|
+
f'<h2>Not run</h2><p class="section-note">{e(_NOT_RUN_NOTE)} '
|
|
815
|
+
+ ", ".join(f"<code>{e(c)}</code>" for c in not_run) + ".</p>"
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
effect = scorecard.get("dedup_effect")
|
|
819
|
+
effect_html = ""
|
|
820
|
+
if effect:
|
|
821
|
+
effect_html = (
|
|
822
|
+
'<h2>What cleaning removed</h2><div class="effect">'
|
|
823
|
+
f'<div class="tile"><div class="k">Train rows</div><div class="v mono">{effect["train_rows_raw"]:,} → '
|
|
824
|
+
f'{effect["train_rows_deduped"]:,}</div><div class="k">{effect["train_duplicates_dropped"]:,} '
|
|
825
|
+
"duplicates dropped</div></div>"
|
|
826
|
+
f'<div class="tile"><div class="k">Test rows</div><div class="v mono">{effect["test_rows_raw"]:,} → '
|
|
827
|
+
f'{effect["test_rows_deduped"]:,}</div><div class="k">{effect["test_leakage_dropped"]:,} train-leaking '
|
|
828
|
+
f'+ {effect["test_duplicates_dropped"]:,} internal duplicates dropped</div></div></div>'
|
|
829
|
+
)
|
|
830
|
+
|
|
831
|
+
features = f" · {fp['feature_count']} features" if fp.get("feature_count") is not None else ""
|
|
832
|
+
return f"""<!doctype html>
|
|
833
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
834
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
835
|
+
<title>IDS2Eval Scorecard · {e(scorecard['dataset_name'])}</title>
|
|
836
|
+
<style>{_HTML_CSS}</style></head>
|
|
837
|
+
<body style="--accent:{_ACCENT[_VERDICT_STATUS[verdict]]}"><div class="page"><div class="sheet">
|
|
838
|
+
<p class="eyebrow">IDS2Eval: the scorecard, not a magic number</p>
|
|
839
|
+
<h1>{e(scorecard['dataset_name'])}: {_VERDICT_PLAIN[verdict]}</h1>
|
|
840
|
+
<p class="lede">{e(lede)}</p>
|
|
841
|
+
<div class="card">
|
|
842
|
+
<div class="bar"><b>SCORECARD</b><span>ids2eval v{e(scorecard['ids2eval_version'])} · generated {e(generated)}</span></div>
|
|
843
|
+
<div class="body">
|
|
844
|
+
<div class="meta">
|
|
845
|
+
<div><div class="k">Dataset fingerprint</div><div class="v">{e(scorecard['dataset_name'])} ·
|
|
846
|
+
{e(fp['train_content_hash'][:8])}</div></div>
|
|
847
|
+
<div><div class="k">Checks run</div><div class="v">{len(audit_rows)} checks + {len(known_rows)} known issue(s)
|
|
848
|
+
· {"raw + cleaned" if has_after else "raw only"}</div></div>
|
|
849
|
+
<div><div class="k">Tool / schema version</div><div class="v">v{e(scorecard['ids2eval_version'])}
|
|
850
|
+
{f"({e(commit[:7])})" if commit else ""} · schema {e(scorecard['scorecard_schema_version'])}</div></div>
|
|
851
|
+
<div><div class="k">Verdict</div><div class="v"><span class="badge b-{_VERDICT_STATUS[verdict]}">
|
|
852
|
+
{_VERDICT_PLAIN[verdict].lower()}</span></div></div>
|
|
853
|
+
</div>
|
|
854
|
+
{f'<p class="section-note">Status rule: {e(scorecard["status_rule"])}</p>' if scorecard.get("status_rule") else ""}
|
|
855
|
+
<h2 class="first">Checks</h2>
|
|
856
|
+
<p class="section-note">Findings from this run's own data. Each one can, in principle, be reacted to - by
|
|
857
|
+
dropping a column, resampling, switching split modes, or just noting the caveat.</p>
|
|
858
|
+
<table>
|
|
859
|
+
<thead><tr>{header}</tr></thead>
|
|
860
|
+
<tbody>
|
|
861
|
+
{audit_body}
|
|
862
|
+
</tbody></table>
|
|
863
|
+
</div></div>
|
|
864
|
+
{known_issues_html}
|
|
865
|
+
{drift_html}
|
|
866
|
+
{not_run_html}
|
|
867
|
+
{effect_html}
|
|
868
|
+
<h2>Dataset fingerprint</h2>
|
|
869
|
+
<p class="mono hash">train: {fp['train_rows']:,} rows · test: {fp['test_rows']:,} rows{features}<br>
|
|
870
|
+
train sha256 {e(fp['train_content_hash'])}<br>test sha256 {e(fp['test_content_hash'])}</p>
|
|
871
|
+
<h2>Citing this result</h2>
|
|
872
|
+
<blockquote>{e(citation_text(scorecard))}</blockquote>
|
|
873
|
+
<p class="foot">Verdict rule: {e(_VERDICT_RULE)}.<br>{e(_PRIOR_ART)}</p>
|
|
874
|
+
</div></div></body></html>
|
|
875
|
+
"""
|