diyaolhaqq commited on
Commit
3fccb94
·
verified ·
1 Parent(s): 8bd7c2b

Create eval/audit_split_manifest.py

Browse files
Files changed (1) hide show
  1. eval/audit_split_manifest.py +115 -0
eval/audit_split_manifest.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Audit a MaizeGuard CSV manifest for group leakage and source concentration.
3
+
4
+ The manifest is expected to contain ``split``, ``label``, ``source_id``,
5
+ ``record_id`` and ``group_id`` columns. The command prints a deterministic JSON
6
+ report and exits non-zero when a biological/collection group or record appears
7
+ in more than one split.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import csv
14
+ import json
15
+ from collections import Counter, defaultdict
16
+ from pathlib import Path
17
+
18
+ REQUIRED_COLUMNS = {"split", "label", "source_id", "record_id", "group_id"}
19
+
20
+
21
+ def parse_args() -> argparse.Namespace:
22
+ parser = argparse.ArgumentParser(description=__doc__)
23
+ parser.add_argument("manifest", type=Path, help="CSV split manifest to audit")
24
+ parser.add_argument(
25
+ "--max-source-share",
26
+ type=float,
27
+ default=0.80,
28
+ help="Flag classes where one source supplies more than this share (default: 0.80)",
29
+ )
30
+ return parser.parse_args()
31
+
32
+
33
+ def audit(path: Path, max_source_share: float) -> dict[str, object]:
34
+ groups: dict[tuple[str, str], set[str]] = defaultdict(set)
35
+ records: dict[str, set[str]] = defaultdict(set)
36
+ split_counts: Counter[str] = Counter()
37
+ label_counts: Counter[str] = Counter()
38
+ label_sources: dict[str, Counter[str]] = defaultdict(Counter)
39
+
40
+ with path.open(newline="", encoding="utf-8") as handle:
41
+ reader = csv.DictReader(handle)
42
+ missing = REQUIRED_COLUMNS - set(reader.fieldnames or ())
43
+ if missing:
44
+ raise ValueError(f"manifest is missing required columns: {sorted(missing)}")
45
+ for row in reader:
46
+ split = row["split"].strip()
47
+ label = row["label"].strip()
48
+ source = row["source_id"].strip()
49
+ record = row["record_id"].strip()
50
+ group = row["group_id"].strip()
51
+ if not all((split, label, source, record, group)):
52
+ raise ValueError("manifest contains an empty required field")
53
+ groups[(source, group)].add(split)
54
+ records[record].add(split)
55
+ split_counts[split] += 1
56
+ label_counts[label] += 1
57
+ label_sources[label][source] += 1
58
+
59
+ cross_split_groups = sorted(
60
+ (source, group, sorted(splits))
61
+ for (source, group), splits in groups.items()
62
+ if len(splits) > 1
63
+ )
64
+ cross_split_records = sorted(
65
+ (record, sorted(splits)) for record, splits in records.items() if len(splits) > 1
66
+ )
67
+ concentration: dict[str, object] = {}
68
+ concentrated_labels: list[str] = []
69
+ for label in sorted(label_sources):
70
+ total = label_counts[label]
71
+ source, count = label_sources[label].most_common(1)[0]
72
+ share = count / total
73
+ concentration[label] = {
74
+ "record_count": total,
75
+ "largest_source": source,
76
+ "largest_source_count": count,
77
+ "largest_source_share": round(share, 6),
78
+ }
79
+ if share > max_source_share:
80
+ concentrated_labels.append(label)
81
+
82
+ return {
83
+ "schema_version": "1",
84
+ "manifest": path.name,
85
+ "record_count": sum(split_counts.values()),
86
+ "group_count": len(groups),
87
+ "split_counts": dict(sorted(split_counts.items())),
88
+ "cross_split_group_count": len(cross_split_groups),
89
+ "cross_split_group_examples": [
90
+ {"source_id": source, "group_id": group, "splits": splits}
91
+ for source, group, splits in cross_split_groups[:20]
92
+ ],
93
+ "cross_split_record_count": len(cross_split_records),
94
+ "cross_split_record_examples": [
95
+ {"record_id": record, "splits": splits}
96
+ for record, splits in cross_split_records[:20]
97
+ ],
98
+ "class_source_concentration": concentration,
99
+ "classes_above_source_share_threshold": concentrated_labels,
100
+ "max_source_share_threshold": max_source_share,
101
+ "leakage_gate_passed": not cross_split_groups and not cross_split_records,
102
+ }
103
+
104
+
105
+ def main() -> int:
106
+ args = parse_args()
107
+ if not 0 < args.max_source_share <= 1:
108
+ raise SystemExit("--max-source-share must be greater than 0 and at most 1")
109
+ report = audit(args.manifest, args.max_source_share)
110
+ print(json.dumps(report, indent=2, sort_keys=True))
111
+ return 0 if report["leakage_gate_passed"] else 2
112
+
113
+
114
+ if __name__ == "__main__":
115
+ raise SystemExit(main())