dicleak 0.1.0__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.
- dicleak/__init__.py +3 -0
- dicleak/checks.py +208 -0
- dicleak/cli.py +44 -0
- dicleak/readers.py +93 -0
- dicleak/report.py +89 -0
- dicleak-0.1.0.dist-info/METADATA +80 -0
- dicleak-0.1.0.dist-info/RECORD +11 -0
- dicleak-0.1.0.dist-info/WHEEL +5 -0
- dicleak-0.1.0.dist-info/entry_points.txt +2 -0
- dicleak-0.1.0.dist-info/licenses/LICENSE +21 -0
- dicleak-0.1.0.dist-info/top_level.txt +1 -0
dicleak/__init__.py
ADDED
dicleak/checks.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""The seven integrity checks. Each returns a list of Finding."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections import Counter, defaultdict
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class Finding:
|
|
10
|
+
check_id: str
|
|
11
|
+
severity: str # CRITICAL / HIGH / MEDIUM / LOW
|
|
12
|
+
title: str
|
|
13
|
+
detail: str
|
|
14
|
+
deduction: int
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
SEVERITY_ORDER = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
|
|
18
|
+
SEVERITY_ICON = {"CRITICAL": "[!!]", "HIGH": "[!]", "MEDIUM": "[*]", "LOW": "[.]"}
|
|
19
|
+
|
|
20
|
+
_NAME_LIKE = re.compile(r"[A-Za-z]+[ _\-][A-Za-z]+")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _splits_of(rows):
|
|
24
|
+
return sorted({r["split"] for r in rows})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def check_leakage(rows):
|
|
28
|
+
"""CRITICAL: the same patient appears in more than one split."""
|
|
29
|
+
by_patient = defaultdict(set)
|
|
30
|
+
for r in rows:
|
|
31
|
+
by_patient[r["patient_id"]].add(r["split"])
|
|
32
|
+
leaked = {p: sorted(s) for p, s in by_patient.items() if len(s) > 1}
|
|
33
|
+
findings = []
|
|
34
|
+
for i, (patient, splits) in enumerate(sorted(leaked.items())):
|
|
35
|
+
if i >= 10:
|
|
36
|
+
findings.append(Finding(
|
|
37
|
+
"leakage", "CRITICAL",
|
|
38
|
+
"Patient-level data leakage (+%d more patients)" % (len(leaked) - 10),
|
|
39
|
+
"Additional leaked patients not listed individually.",
|
|
40
|
+
0,
|
|
41
|
+
))
|
|
42
|
+
break
|
|
43
|
+
findings.append(Finding(
|
|
44
|
+
"leakage", "CRITICAL",
|
|
45
|
+
"Patient '%s' appears in multiple splits: %s" % (patient, ", ".join(splits)),
|
|
46
|
+
"The same patient in train and test lets the model memorize "
|
|
47
|
+
"patient-specific features instead of learning the disease. "
|
|
48
|
+
"This inflates reported accuracy and is a known cause of "
|
|
49
|
+
"non-reproducible results and retractions in medical AI.",
|
|
50
|
+
10 if i == 0 else 5,
|
|
51
|
+
))
|
|
52
|
+
return findings
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def check_single_class_split(rows):
|
|
56
|
+
"""CRITICAL: a split that contains only one label value."""
|
|
57
|
+
findings = []
|
|
58
|
+
by_split = defaultdict(set)
|
|
59
|
+
for r in rows:
|
|
60
|
+
by_split[r["split"]].add(r["label"])
|
|
61
|
+
for split in sorted(by_split):
|
|
62
|
+
labels = by_split[split]
|
|
63
|
+
if len(labels) == 1:
|
|
64
|
+
findings.append(Finding(
|
|
65
|
+
"single_class_split", "CRITICAL",
|
|
66
|
+
"Split '%s' contains only one class: '%s'" % (split, next(iter(labels))),
|
|
67
|
+
"A split with a single class cannot train or fairly evaluate "
|
|
68
|
+
"a classifier. Check your stratification or labeling pipeline.",
|
|
69
|
+
15,
|
|
70
|
+
))
|
|
71
|
+
return findings
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def check_stratification(rows, threshold=0.20):
|
|
75
|
+
"""HIGH: label distribution differs strongly between splits."""
|
|
76
|
+
findings = []
|
|
77
|
+
by_split = defaultdict(Counter)
|
|
78
|
+
for r in rows:
|
|
79
|
+
by_split[r["split"]][r["label"]] += 1
|
|
80
|
+
labels = {lbl for c in by_split.values() for lbl in c}
|
|
81
|
+
for lbl in sorted(labels):
|
|
82
|
+
shares = {}
|
|
83
|
+
for split, counter in by_split.items():
|
|
84
|
+
total = sum(counter.values())
|
|
85
|
+
shares[split] = counter[lbl] / total if total else 0.0
|
|
86
|
+
spread = max(shares.values()) - min(shares.values())
|
|
87
|
+
if spread > threshold:
|
|
88
|
+
detail = ", ".join(
|
|
89
|
+
"%s=%.0f%%" % (s, shares[s] * 100) for s in sorted(shares))
|
|
90
|
+
findings.append(Finding(
|
|
91
|
+
"stratification", "HIGH",
|
|
92
|
+
"Class '%s' is unevenly distributed across splits (%s)" % (lbl, detail),
|
|
93
|
+
"A spread of %.0fpp between splits means the model sees a "
|
|
94
|
+
"different class balance at train vs. test time, which skews "
|
|
95
|
+
"metrics. Stratify by label when splitting." % (spread * 100),
|
|
96
|
+
8,
|
|
97
|
+
))
|
|
98
|
+
return findings
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def check_split_ratios(rows):
|
|
102
|
+
"""MEDIUM: test split too small, or a split nearly empty."""
|
|
103
|
+
findings = []
|
|
104
|
+
counter = Counter(r["split"] for r in rows)
|
|
105
|
+
total = sum(counter.values())
|
|
106
|
+
for split in sorted(counter):
|
|
107
|
+
share = counter[split] / total
|
|
108
|
+
if split == "test" and share < 0.10:
|
|
109
|
+
findings.append(Finding(
|
|
110
|
+
"split_ratios", "MEDIUM",
|
|
111
|
+
"Test split is only %.1f%% of the data (%d rows)" % (share * 100, counter[split]),
|
|
112
|
+
"A tiny test set gives noisy performance estimates with wide "
|
|
113
|
+
"confidence intervals. Aim for at least 10-20%.",
|
|
114
|
+
6,
|
|
115
|
+
))
|
|
116
|
+
elif share < 0.05:
|
|
117
|
+
findings.append(Finding(
|
|
118
|
+
"split_ratios", "MEDIUM",
|
|
119
|
+
"Split '%s' is only %.1f%% of the data (%d rows)" % (split, share * 100, counter[split]),
|
|
120
|
+
"A near-empty split contributes little and may indicate a "
|
|
121
|
+
"splitting bug.",
|
|
122
|
+
6,
|
|
123
|
+
))
|
|
124
|
+
return findings
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def check_patient_count(rows):
|
|
128
|
+
"""LOW/MEDIUM: too few unique patients to generalize."""
|
|
129
|
+
n = len({r["patient_id"] for r in rows})
|
|
130
|
+
if n < 20:
|
|
131
|
+
return [Finding(
|
|
132
|
+
"patient_count", "MEDIUM",
|
|
133
|
+
"Only %d unique patients in the whole dataset" % n,
|
|
134
|
+
"With so few patients, results are unlikely to generalize and "
|
|
135
|
+
"reviewers will question external validity.",
|
|
136
|
+
8,
|
|
137
|
+
)]
|
|
138
|
+
if n < 50:
|
|
139
|
+
return [Finding(
|
|
140
|
+
"patient_count", "LOW",
|
|
141
|
+
"Only %d unique patients in the whole dataset" % n,
|
|
142
|
+
"Small patient counts limit generalizability; consider "
|
|
143
|
+
"multi-center data or external validation.",
|
|
144
|
+
4,
|
|
145
|
+
)]
|
|
146
|
+
return []
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def check_duplicates(rows):
|
|
150
|
+
"""MEDIUM: the same file listed twice (only when filepaths are given).
|
|
151
|
+
|
|
152
|
+
Without filepaths, repeated (patient, split, label) rows are expected --
|
|
153
|
+
one patient normally contributes multiple images -- so the check is skipped.
|
|
154
|
+
"""
|
|
155
|
+
keyed = [r for r in rows if r.get("filepath")]
|
|
156
|
+
if not keyed:
|
|
157
|
+
return []
|
|
158
|
+
seen = Counter(r["filepath"] for r in keyed)
|
|
159
|
+
dup_files = sum(1 for c in seen.values() if c > 1)
|
|
160
|
+
if dup_files:
|
|
161
|
+
return [Finding(
|
|
162
|
+
"duplicates", "MEDIUM",
|
|
163
|
+
"%d file(s) listed more than once in the manifest" % dup_files,
|
|
164
|
+
"Duplicate files overweight those samples and can hint at a "
|
|
165
|
+
"broken export or merge step upstream.",
|
|
166
|
+
5,
|
|
167
|
+
)]
|
|
168
|
+
return []
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def check_phi_risk(rows):
|
|
172
|
+
"""HIGH: patient ids that look like real names (de-identification failure)."""
|
|
173
|
+
flagged = sorted({r["patient_id"] for r in rows if _NAME_LIKE.search(r["patient_id"])})
|
|
174
|
+
if not flagged:
|
|
175
|
+
return []
|
|
176
|
+
shown = ", ".join("'%s'" % p for p in flagged[:5])
|
|
177
|
+
more = " (+%d more)" % (len(flagged) - 5) if len(flagged) > 5 else ""
|
|
178
|
+
return [Finding(
|
|
179
|
+
"phi_risk", "HIGH",
|
|
180
|
+
"%d patient id(s) look like personal names: %s%s" % (len(flagged), shown, more),
|
|
181
|
+
"IDs resembling names suggest the data was not de-identified. "
|
|
182
|
+
"Sharing or publishing this dataset may violate privacy rules "
|
|
183
|
+
"(HIPAA/GDPR) and journal data policies.",
|
|
184
|
+
8,
|
|
185
|
+
)]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
ALL_CHECKS = (
|
|
189
|
+
check_leakage,
|
|
190
|
+
check_single_class_split,
|
|
191
|
+
check_stratification,
|
|
192
|
+
check_split_ratios,
|
|
193
|
+
check_patient_count,
|
|
194
|
+
check_duplicates,
|
|
195
|
+
check_phi_risk,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def run_all(rows):
|
|
200
|
+
findings = []
|
|
201
|
+
for check in ALL_CHECKS:
|
|
202
|
+
findings.extend(check(rows))
|
|
203
|
+
findings.sort(key=lambda f: (SEVERITY_ORDER[f.severity], f.check_id))
|
|
204
|
+
return findings
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def score(findings):
|
|
208
|
+
return max(0, 100 - sum(f.deduction for f in findings))
|
dicleak/cli.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Command-line interface."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from . import __version__
|
|
7
|
+
from .checks import run_all, score
|
|
8
|
+
from .readers import load_manifest
|
|
9
|
+
from .report import render_markdown, render_terminal
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_parser():
|
|
13
|
+
p = argparse.ArgumentParser(
|
|
14
|
+
prog="dicleak",
|
|
15
|
+
description="Audit a medical-imaging AI dataset manifest for "
|
|
16
|
+
"patient-level leakage and split flaws.",
|
|
17
|
+
)
|
|
18
|
+
p.add_argument("manifest", help="CSV with patient_id, split and label columns")
|
|
19
|
+
p.add_argument("--report", metavar="OUT.md",
|
|
20
|
+
help="also write a Markdown report to OUT.md")
|
|
21
|
+
p.add_argument("--cta", metavar="URL",
|
|
22
|
+
help="append a service link footer to the Markdown report")
|
|
23
|
+
p.add_argument("--version", action="version", version="dicleak " + __version__)
|
|
24
|
+
return p
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main(argv=None):
|
|
28
|
+
args = build_parser().parse_args(argv)
|
|
29
|
+
rows, warnings = load_manifest(args.manifest)
|
|
30
|
+
for w in warnings:
|
|
31
|
+
print("warning: %s" % w, file=sys.stderr)
|
|
32
|
+
findings = run_all(rows)
|
|
33
|
+
value = score(findings)
|
|
34
|
+
print(render_terminal(findings, value, rows))
|
|
35
|
+
if args.report:
|
|
36
|
+
md = render_markdown(findings, value, rows, cta_url=args.cta)
|
|
37
|
+
with open(args.report, "w", encoding="utf-8") as fh:
|
|
38
|
+
fh.write(md)
|
|
39
|
+
print("Markdown report written to %s" % args.report)
|
|
40
|
+
return 0 if value >= 60 else 1
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
sys.exit(main())
|
dicleak/readers.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""CSV manifest loading and column normalization."""
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
# accepted header spellings -> canonical field name
|
|
7
|
+
COLUMN_ALIASES = {
|
|
8
|
+
"patient_id": "patient_id",
|
|
9
|
+
"patientid": "patient_id",
|
|
10
|
+
"patient": "patient_id",
|
|
11
|
+
"subject_id": "patient_id",
|
|
12
|
+
"subjectid": "patient_id",
|
|
13
|
+
"subject": "patient_id",
|
|
14
|
+
"pid": "patient_id",
|
|
15
|
+
"split": "split",
|
|
16
|
+
"subset": "split",
|
|
17
|
+
"fold": "split",
|
|
18
|
+
"partition": "split",
|
|
19
|
+
"set": "split",
|
|
20
|
+
"phase": "split",
|
|
21
|
+
"label": "label",
|
|
22
|
+
"class": "label",
|
|
23
|
+
"target": "label",
|
|
24
|
+
"diagnosis": "label",
|
|
25
|
+
"annotation": "label",
|
|
26
|
+
"filepath": "filepath",
|
|
27
|
+
"file_path": "filepath",
|
|
28
|
+
"path": "filepath",
|
|
29
|
+
"filename": "filepath",
|
|
30
|
+
"file": "filepath",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
SPLIT_ALIASES = {
|
|
34
|
+
"train": "train",
|
|
35
|
+
"training": "train",
|
|
36
|
+
"trn": "train",
|
|
37
|
+
"val": "val",
|
|
38
|
+
"valid": "val",
|
|
39
|
+
"validation": "val",
|
|
40
|
+
"dev": "val",
|
|
41
|
+
"test": "test",
|
|
42
|
+
"testing": "test",
|
|
43
|
+
"tst": "test",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
REQUIRED = ("patient_id", "split", "label")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _normalize_header(name):
|
|
50
|
+
return name.strip().lower().replace("-", "_").replace(" ", "_")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_manifest(path):
|
|
54
|
+
"""Load a CSV manifest, return (rows, warnings).
|
|
55
|
+
|
|
56
|
+
rows: list of dicts with keys patient_id / split / label / filepath.
|
|
57
|
+
Raises SystemExit with a message on fatal problems.
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
fh = open(path, newline="", encoding="utf-8-sig")
|
|
61
|
+
except OSError as exc:
|
|
62
|
+
sys.exit("dicleak: cannot open %s: %s" % (path, exc))
|
|
63
|
+
with fh:
|
|
64
|
+
reader = csv.DictReader(fh)
|
|
65
|
+
if not reader.fieldnames:
|
|
66
|
+
sys.exit("dicleak: %s looks empty (no header row)" % path)
|
|
67
|
+
mapping = {}
|
|
68
|
+
for raw in reader.fieldnames:
|
|
69
|
+
key = _normalize_header(raw)
|
|
70
|
+
if key in COLUMN_ALIASES:
|
|
71
|
+
mapping[raw] = COLUMN_ALIASES[key]
|
|
72
|
+
missing = [c for c in REQUIRED if c not in mapping.values()]
|
|
73
|
+
if missing:
|
|
74
|
+
sys.exit(
|
|
75
|
+
"dicleak: %s is missing required column(s): %s "
|
|
76
|
+
"(need patient id, split and label columns)" % (path, ", ".join(missing))
|
|
77
|
+
)
|
|
78
|
+
rows = []
|
|
79
|
+
warnings = []
|
|
80
|
+
for lineno, raw_row in enumerate(reader, start=2):
|
|
81
|
+
row = {}
|
|
82
|
+
for raw_key, value in raw_row.items():
|
|
83
|
+
if raw_key in mapping:
|
|
84
|
+
row[mapping[raw_key]] = (value or "").strip()
|
|
85
|
+
if not row.get("patient_id"):
|
|
86
|
+
warnings.append("line %d: empty patient id, skipped" % lineno)
|
|
87
|
+
continue
|
|
88
|
+
split_raw = row.get("split", "").lower()
|
|
89
|
+
row["split"] = SPLIT_ALIASES.get(split_raw, split_raw)
|
|
90
|
+
rows.append(row)
|
|
91
|
+
if not rows:
|
|
92
|
+
sys.exit("dicleak: no usable data rows in %s" % path)
|
|
93
|
+
return rows, warnings
|
dicleak/report.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Terminal and Markdown report rendering."""
|
|
2
|
+
|
|
3
|
+
from collections import Counter
|
|
4
|
+
|
|
5
|
+
from .checks import SEVERITY_ICON
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _stats(rows):
|
|
9
|
+
patients = {r["patient_id"] for r in rows}
|
|
10
|
+
splits = Counter(r["split"] for r in rows)
|
|
11
|
+
labels = Counter(r["label"] for r in rows)
|
|
12
|
+
return {
|
|
13
|
+
"rows": len(rows),
|
|
14
|
+
"patients": len(patients),
|
|
15
|
+
"splits": dict(sorted(splits.items())),
|
|
16
|
+
"labels": dict(sorted(labels.items())),
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _grade(score):
|
|
21
|
+
if score >= 90:
|
|
22
|
+
return "A - publishable shape"
|
|
23
|
+
if score >= 75:
|
|
24
|
+
return "B - minor issues"
|
|
25
|
+
if score >= 60:
|
|
26
|
+
return "C - fix before training"
|
|
27
|
+
return "D - do not train on this"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def render_terminal(findings, score_value, rows):
|
|
31
|
+
st = _stats(rows)
|
|
32
|
+
lines = []
|
|
33
|
+
lines.append("=" * 60)
|
|
34
|
+
lines.append("dicleak dataset audit")
|
|
35
|
+
lines.append("=" * 60)
|
|
36
|
+
lines.append("rows: %d patients: %d" % (st["rows"], st["patients"]))
|
|
37
|
+
lines.append("splits: %s" % ", ".join(
|
|
38
|
+
"%s=%d" % kv for kv in st["splits"].items()))
|
|
39
|
+
lines.append("labels: %s" % ", ".join(
|
|
40
|
+
"%s=%d" % kv for kv in st["labels"].items()))
|
|
41
|
+
lines.append("-" * 60)
|
|
42
|
+
lines.append("score: %d/100 [%s]" % (score_value, _grade(score_value)))
|
|
43
|
+
lines.append("-" * 60)
|
|
44
|
+
if not findings:
|
|
45
|
+
lines.append("No issues found. Clean split.")
|
|
46
|
+
for f in findings:
|
|
47
|
+
icon = SEVERITY_ICON.get(f.severity, "[?]")
|
|
48
|
+
lines.append("%s %s (-%d)" % (icon, f.title, f.deduction))
|
|
49
|
+
lines.append(" %s" % f.detail)
|
|
50
|
+
lines.append("=" * 60)
|
|
51
|
+
return "\n".join(lines)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def render_markdown(findings, score_value, rows, cta_url=None):
|
|
55
|
+
st = _stats(rows)
|
|
56
|
+
lines = []
|
|
57
|
+
lines.append("# dicleak dataset audit report")
|
|
58
|
+
lines.append("")
|
|
59
|
+
lines.append("**Score: %d/100** - %s" % (score_value, _grade(score_value)))
|
|
60
|
+
lines.append("")
|
|
61
|
+
lines.append("## Dataset overview")
|
|
62
|
+
lines.append("")
|
|
63
|
+
lines.append("| metric | value |")
|
|
64
|
+
lines.append("|---|---|")
|
|
65
|
+
lines.append("| rows | %d |" % st["rows"])
|
|
66
|
+
lines.append("| unique patients | %d |" % st["patients"])
|
|
67
|
+
for split, n in st["splits"].items():
|
|
68
|
+
lines.append("| split `%s` | %d |" % (split, n))
|
|
69
|
+
for label, n in st["labels"].items():
|
|
70
|
+
lines.append("| label `%s` | %d |" % (label, n))
|
|
71
|
+
lines.append("")
|
|
72
|
+
lines.append("## Findings (%d)" % len(findings))
|
|
73
|
+
lines.append("")
|
|
74
|
+
if not findings:
|
|
75
|
+
lines.append("No issues found. Clean split.")
|
|
76
|
+
for f in findings:
|
|
77
|
+
lines.append("### %s %s (-%d)" % (
|
|
78
|
+
SEVERITY_ICON.get(f.severity, "[?]"), f.title, f.deduction))
|
|
79
|
+
lines.append("")
|
|
80
|
+
lines.append("Severity: **%s** | Check: `%s`" % (f.severity, f.check_id))
|
|
81
|
+
lines.append("")
|
|
82
|
+
lines.append(f.detail)
|
|
83
|
+
lines.append("")
|
|
84
|
+
if cta_url:
|
|
85
|
+
lines.append("---")
|
|
86
|
+
lines.append(
|
|
87
|
+
"Need expert eyes on your medical-imaging dataset? "
|
|
88
|
+
"[Clinical data audit service](%s)" % cta_url)
|
|
89
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dicleak
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Dataset integrity auditor for medical-imaging AI: catch patient-level data leakage and split flaws before they sink your model or paper
|
|
5
|
+
Author: dicleak contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: medical-imaging,dicom,dataset,data-leakage,machine-learning,validation
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# dicleak
|
|
16
|
+
|
|
17
|
+
Dataset integrity auditor for medical-imaging AI. Point it at a dataset manifest
|
|
18
|
+
CSV and it catches the flaws that sink models and papers **before** you train:
|
|
19
|
+
patient-level data leakage across splits, single-class splits, broken
|
|
20
|
+
stratification, tiny test sets, duplicate files, un-deidentified IDs.
|
|
21
|
+
|
|
22
|
+
医学影像 AI 数据集完整性审计工具。对 manifest CSV 跑一遍,在训练**之前**
|
|
23
|
+
揪出致命缺陷:病人级数据泄露、单类别数据划分、分布失衡、测试集过小、
|
|
24
|
+
文件重复、未脱敏的 ID。
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install dicleak
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
dicleak splits.csv
|
|
36
|
+
dicleak splits.csv --report audit.md
|
|
37
|
+
dicleak splits.csv --report audit.md --cta "https://your-service.example/audit"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The manifest CSV needs three columns (headers are flexible):
|
|
41
|
+
|
|
42
|
+
| concept | accepted headers |
|
|
43
|
+
|---|---|
|
|
44
|
+
| patient id | `patient_id`, `patientid`, `patient`, `subject_id`, `subject`, `pid` |
|
|
45
|
+
| split | `split`, `subset`, `fold`, `partition`, `set` (`train`/`val`/`test` and common aliases) |
|
|
46
|
+
| label | `label`, `class`, `target`, `diagnosis`, `annotation` |
|
|
47
|
+
| file path (optional) | `filepath`, `file_path`, `path`, `filename` |
|
|
48
|
+
|
|
49
|
+
Exit code is `0` when the score is 60+, `1` otherwise — handy for CI gates.
|
|
50
|
+
|
|
51
|
+
## The 7 checks
|
|
52
|
+
|
|
53
|
+
| # | check | severity |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| 1 | patient-level leakage across splits | CRITICAL |
|
|
56
|
+
| 2 | split containing a single class | CRITICAL |
|
|
57
|
+
| 3 | label distribution drift between splits | HIGH |
|
|
58
|
+
| 4 | test split < 10% (or any split < 5%) | MEDIUM |
|
|
59
|
+
| 5 | duplicate files in the manifest | MEDIUM |
|
|
60
|
+
| 6 | patient IDs that look like real names | HIGH |
|
|
61
|
+
| 7 | too few unique patients | LOW/MEDIUM |
|
|
62
|
+
|
|
63
|
+
## Why this exists
|
|
64
|
+
|
|
65
|
+
A disturbing share of medical-AI papers report inflated accuracy because the
|
|
66
|
+
same patient ended up in both the training and test sets. Reviewers now check
|
|
67
|
+
for this. `dicleak` makes the check a one-liner.
|
|
68
|
+
|
|
69
|
+
## Sample
|
|
70
|
+
|
|
71
|
+
`samples/flawed_splits.csv` is a deliberately broken manifest (leaked patients,
|
|
72
|
+
single-class val split, tiny test set, name-like IDs...). It scores 26/100:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
dicleak samples/flawed_splits.csv
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
dicleak/__init__.py,sha256=Z58sFiJBCSs5i2Hlkf3Kmk1OLDnkwM6Kjr6NNdCwzps,88
|
|
2
|
+
dicleak/checks.py,sha256=jzBPbCZVXgFCq6NjU-CdkccOuHC1pSH-ncoQKb1UZew,7398
|
|
3
|
+
dicleak/cli.py,sha256=maRKzU9krDaJgH7VOFza-THLEaMFeK1UHFJIEEM6FZU,1462
|
|
4
|
+
dicleak/readers.py,sha256=iyM6jYJZGHPXGolOjjc6WQUrbKEpDBypQPBuU9S6Oiw,2794
|
|
5
|
+
dicleak/report.py,sha256=twRJgZMgJfdCrnZLXqOqiUd4inrdqHXgeZasqw4M58A,3012
|
|
6
|
+
dicleak-0.1.0.dist-info/licenses/LICENSE,sha256=0c9Ek3JRHnBVjyvmVPPLUif7Et7T8OqimjItkx0nDxE,1078
|
|
7
|
+
dicleak-0.1.0.dist-info/METADATA,sha256=zgGuPG0kLb-0ZI-QyFCBGhCgyO2QBYSzITIgrheU1pk,2623
|
|
8
|
+
dicleak-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
+
dicleak-0.1.0.dist-info/entry_points.txt,sha256=ZkDhmsZ3nAYyyOIvPAo8TlP04XK4QAEJBM4VodYNd3I,45
|
|
10
|
+
dicleak-0.1.0.dist-info/top_level.txt,sha256=ptyYTc2g-qU83RaxQgnNvsHceE9fkhINTY1kMiOb0jM,8
|
|
11
|
+
dicleak-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 manulint contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dicleak
|