Multi-MIMIC 0.0.1__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.
@@ -0,0 +1,7 @@
1
+ """Download the MIMIC-IV demo patients across modalities from PhysioNet."""
2
+ from importlib.metadata import PackageNotFoundError, version
3
+
4
+ try:
5
+ __version__ = version("Multi-MIMIC") # set from the git tag when the package is built
6
+ except PackageNotFoundError:
7
+ __version__ = "0.0.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
multi_mimic/check.py ADDED
@@ -0,0 +1,145 @@
1
+ """Final file check: is everything there, complete and uncorrupted?
2
+
3
+ Every file is compared with its PhysioNet checksum (SHA256SUMS.txt); every
4
+ filtered table with the checksum recorded when it was written. Results are
5
+ printed as counts only; the paths of problem files go to a local report."""
6
+ from __future__ import annotations
7
+
8
+ import gzip
9
+ import csv
10
+ from collections import Counter, defaultdict
11
+ from pathlib import Path
12
+
13
+ from .download import bar, human, local_path
14
+ from .listing import subject_of
15
+ from .manifest import Item, Manifest
16
+ from .sources import BY_KEY
17
+ from .tables import sha256_file
18
+
19
+ OK, MISSING, INCOMPLETE, CORRUPT, SKIPPED = "ok", "missing", "incomplete", "corrupt", "skipped"
20
+ REPORT = "multi-mimic.check-report.txt"
21
+ LEGACY_REPORT = "mimic-demo-mm.check-report.txt" # written by the pre-release mimic-demo-mm
22
+
23
+
24
+ def verify_item(root: Path, it: Item, quick: bool) -> str:
25
+ if it.status == "skipped":
26
+ return SKIPPED
27
+ p = local_path(root, it)
28
+ if not p.exists():
29
+ return INCOMPLETE if p.with_name(p.name + ".part").exists() else MISSING
30
+ size = p.stat().st_size
31
+ if it.kind == "table":
32
+ if it.out_sha256 is None:
33
+ return MISSING
34
+ if size != it.out_size:
35
+ return CORRUPT
36
+ return OK if quick or sha256_file(p) == it.out_sha256 else CORRUPT
37
+ if it.size is not None and size != it.size:
38
+ return INCOMPLETE if size < it.size else CORRUPT
39
+ if not quick and it.sha256 and sha256_file(p) != it.sha256:
40
+ return CORRUPT
41
+ return OK
42
+
43
+
44
+ def verify(root: Path, man: Manifest, keys: list[str] | None = None, quick: bool = False) -> dict[tuple, str]:
45
+ items = [i for i in man.items.values() if keys is None or i.source in keys]
46
+ total = sum((i.out_size if i.kind == "table" else i.size) or 0 for i in items if i.status != "skipped")
47
+ res = {}
48
+ with bar(total=total, unit="B", unit_scale=True, desc=" checking files" + (" (sizes only)" if quick else ""),
49
+ leave=False) as b:
50
+ for it in items:
51
+ res[it.key] = verify_item(root, it, quick)
52
+ b.update((it.out_size if it.kind == "table" else it.size) or 0)
53
+ return res
54
+
55
+
56
+ def _ed_subjects(root: Path) -> set[str]:
57
+ p = root / BY_KEY["mimic-iv-ed-demo"].rel / "ed/edstays.csv.gz"
58
+ if not p.exists():
59
+ return set()
60
+ with gzip.open(p, "rt", newline="") as f:
61
+ r = csv.DictReader(f)
62
+ return {row["subject_id"] for row in r}
63
+
64
+
65
+ def coverage(root: Path, man: Manifest, res: dict) -> list[tuple[str, set[str] | None, str]]:
66
+ """-> [(label, demo patients with it or None for all of them, modality group)]."""
67
+ ok = [man.items[k] for k, v in res.items() if v == OK]
68
+ present = {src for src, _ in res} # only the sources that were checked
69
+
70
+ def files_of(src, exts):
71
+ return {subject_of(i.path) for i in ok if i.source == src and BY_KEY[src].in_files(i.path)
72
+ and i.path.lower().endswith(exts)} - {None}
73
+
74
+ def tables_of(src, name):
75
+ s = set()
76
+ for i in ok:
77
+ if i.source == src and i.kind == "table" and name in Path(i.path).name:
78
+ s.update(i.subjects)
79
+ return s
80
+
81
+ rows = []
82
+ if "mimic-iv-demo" in present:
83
+ rows.append(("structured, categorical and numeric events (EHR)", None, "EHR"))
84
+ if "mimic-iv-ed-demo" in present:
85
+ rows.append(("emergency department stays", _ed_subjects(root), "ED stay"))
86
+ for key, label, kind, arg, group in (
87
+ ("mimic-iv-ecg-demo", "ECG waveforms", "files", (".hea",), "ECG"),
88
+ ("mimic-iv-ecg", "ECG machine reports (text)", "tables", "machine_measurements", "ECG"),
89
+ ("mimic4wdb", "ICU bedside-monitor waveforms", "files", (".hea",), "ICU waveforms"),
90
+ ("mimic-cxr-jpg", "chest X-ray images (JPG)", "files", (".jpg",), "Chest X-ray"),
91
+ ("mimic-cxr", "chest X-ray DICOMs", "files", (".dcm",), "Chest X-ray"),
92
+ ("mimic-iv-echo", "echo DICOMs", "files", (".dcm",), "Echo"),
93
+ ("mimic-iv-echo", "echo measurements", "tables", "measurement", "Echo"),
94
+ ("mimic-iv-note", "discharge summaries", "tables", "discharge.csv", "Notes"),
95
+ ("mimic-iv-note", "radiology reports", "tables", "radiology.csv", "Notes")):
96
+ if key in present:
97
+ rows.append((label, files_of(key, arg) if kind == "files" else tables_of(key, arg), group))
98
+ return rows
99
+
100
+
101
+ def report(root: Path, man: Manifest, res: dict, quick: bool) -> bool:
102
+ per = defaultdict(Counter)
103
+ sizes = Counter()
104
+ for (src, _), st in res.items():
105
+ per[src][st] += 1
106
+ it = man.items[(src, _)]
107
+ if st == OK:
108
+ sizes[src] += (it.out_size if it.kind == "table" else it.size) or 0
109
+ print(f"\n== File check{' (sizes only)' if quick else ' (checksums)'} ==")
110
+ print(f" {'source':<18}{'ok':>8}{'missing':>9}{'partial':>9}{'corrupt':>9}{'skipped':>9}{'size':>11} status")
111
+ complete = True
112
+ checked = {src for src, _ in res}
113
+ for key in [k for k in man.sources if k in checked]:
114
+ c = per.get(key, Counter())
115
+ bad = c[MISSING] + c[INCOMPLETE] + c[CORRUPT]
116
+ complete &= bad == 0 and c[OK] > 0
117
+ status = "complete" if bad == 0 and c[OK] else ("EMPTY" if not c[OK] and not bad else "INCOMPLETE")
118
+ print(f" {key:<18}{c[OK]:>8,}{c[MISSING]:>9,}{c[INCOMPLETE]:>9,}{c[CORRUPT]:>9,}{c[SKIPPED]:>9,}"
119
+ f"{human(sizes[key]):>11} {status}")
120
+ n_demo = man.demo_subjects or 0
121
+ print(f"\n== Demo patients per modality (of {n_demo}) ==")
122
+ rows = coverage(root, man, res)
123
+ for label, s, _ in rows:
124
+ print(f" {label:<48}{n_demo if s is None else len(s):>5}")
125
+ optional = ("emergency department stays", "ICU bedside-monitor waveforms") # few patients have these
126
+ sets = [s for label, s, _ in rows if s is not None and label not in optional]
127
+ if len(sets) > 1:
128
+ label = "with all of the above (ED, waveforms optional)"
129
+ print(f" {label:<48}{len(set.intersection(*sets)):>5}")
130
+
131
+ bad = [(k, v) for k, v in res.items() if v in (MISSING, INCOMPLETE, CORRUPT)]
132
+ rep = root / REPORT
133
+ (root / LEGACY_REPORT).unlink(missing_ok=True) # replaced by the report below, if any
134
+ if bad:
135
+ with open(rep, "w") as f:
136
+ for (src, path), st in sorted(bad):
137
+ note = man.items[(src, path)].note
138
+ f.write(f"{st}\t{BY_KEY[src].rel}/{path}\t{note}\n")
139
+ print(f"\nINCOMPLETE: {len(bad):,} problem file(s), listed in {rep} (local file).")
140
+ print("Run `multi-mimic check --fix` (or the same download command again) to fetch them.")
141
+ else:
142
+ rep.unlink(missing_ok=True)
143
+ print("\nCOMPLETE: every expected file is present and matches its checksum." if not quick else
144
+ "\nCOMPLETE (sizes only): run `multi-mimic check` for full checksum verification.")
145
+ return complete and not bad
multi_mimic/cli.py ADDED
@@ -0,0 +1,235 @@
1
+ """Multi-MIMIC: assemble a local multimodal MIMIC-IV demo.
2
+
3
+ multi-mimic sources list the datasets
4
+ multi-mimic download --user NAME download everything (asks for the password)
5
+ multi-mimic download --local mimic-iv-note=/data/mimic-iv-note/2.2 ...
6
+ take subsets from local copies instead
7
+ multi-mimic check [--fix] verify every file; re-fetch broken ones
8
+ multi-mimic figure [--readme README.md] draw the modality coverage / co-occurrence overview
9
+ multi-mimic migrate [--dry-run] rename folders from the mimic-demo-mm layout
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import getpass
15
+ import os
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from . import __version__
20
+ from .check import report, verify
21
+ from .download import (Runner, demo_ids, make_origins, migrate_layout, plan, require_current_layout,
22
+ summarize_plan)
23
+ from .http import DEFAULT_BASE_URL, Client, PhysioNetError, base_url
24
+ from .manifest import Manifest, manifest_file
25
+ from .sources import BY_KEY, SOURCES, select
26
+
27
+
28
+ def _local_map(pairs: list[str] | None, man: Manifest | None = None) -> dict[str, Path]:
29
+ out = {}
30
+ if man is not None: # remember local copies used by earlier runs (for check --fix)
31
+ out.update({k: Path(v["local_dir"]) for k, v in man.sources.items() if v.get("local_dir")})
32
+ for p in pairs or []:
33
+ if "=" not in p:
34
+ raise SystemExit(f"--local expects SOURCE=FOLDER, got '{p}'")
35
+ k, d = p.split("=", 1)
36
+ if k not in BY_KEY:
37
+ raise SystemExit(f"--local: unknown source '{k}'. See: multi-mimic sources")
38
+ out[k] = Path(d)
39
+ return out
40
+
41
+
42
+ def _client(sources, local, user) -> Client:
43
+ """Sign-in for credentialed sources, in this order: --user (or PHYSIONET_USER /
44
+ PHYSIONET_USERNAME) with PHYSIONET_PASSWORD or a prompt; PHYSIONET_TOKEN; the
45
+ sign-in saved by `physionet login`; else ask for a username and password."""
46
+ remote_cred = [s.key for s in sources if s.credentialed and s.key not in local]
47
+ if not remote_cred:
48
+ return Client()
49
+ user = user or os.environ.get("PHYSIONET_USER") or os.environ.get("PHYSIONET_USERNAME")
50
+ if not user:
51
+ if os.environ.get("PHYSIONET_TOKEN"):
52
+ return Client(token=os.environ["PHYSIONET_TOKEN"])
53
+ from physionet.auth import load_saved_login
54
+ if load_saved_login(base_url()) is not None:
55
+ return Client() # the physionet package uses the saved sign-in
56
+ print(" (tip: `uv run physionet login` signs in once, without a password here)")
57
+ user = input("PhysioNet username: ").strip()
58
+ pw = os.environ.get("PHYSIONET_PASSWORD") or getpass.getpass(f"PhysioNet password for {user}: ")
59
+ return Client(user, pw)
60
+
61
+
62
+ def cmd_sources(a) -> int:
63
+ print(f"{'source':<19}{'access':<14}{'what is fetched':<26}{'default':<9}description")
64
+ what = {"full": "everything", "demo": "demo patients' files+rows", "tables": "demo patients' rows"}
65
+ for s in SOURCES:
66
+ print(f"{s.key:<19}{s.access:<14}{what[s.mode]:<26}{'yes' if s.default else 'no':<9}{s.title}")
67
+ print("\nPhysioNet project: https://physionet.org/content/<source>/<version>/ "
68
+ "(versions: " + ", ".join(f"{s.key} {s.version}" for s in SOURCES) + ")")
69
+ return 0
70
+
71
+
72
+ def _download(root: Path, sources, local, link, user, jobs, yes, plan_only, quick,
73
+ source="auto", subjects=None) -> int:
74
+ root.mkdir(parents=True, exist_ok=True)
75
+ require_current_layout(root)
76
+ client = _client(sources, local, user)
77
+ origins = make_origins(sources, client, local, link, root)
78
+ man = Manifest.load(root)
79
+ ids = demo_ids(client, root, local)
80
+ if subjects:
81
+ unknown = set(subjects) - ids
82
+ if unknown:
83
+ raise SystemExit(f"--subjects: {len(unknown)} ID(s) are not MIMIC-IV demo patients")
84
+ ids = set(subjects)
85
+ man.demo_subjects = len(ids)
86
+ print(f"Multi-MIMIC {__version__}: {len(ids)} demo patients -> {root}\n\nListing files")
87
+ items = plan(origins, sources, ids, man, jobs)
88
+ files, tables, store, free = summarize_plan(root, sources, items, origins)
89
+ man.save()
90
+ if plan_only:
91
+ return 0
92
+ if store > free:
93
+ print(" x not enough free space: choose fewer sources (--only/--skip), use --link with --local, "
94
+ "or --out on a bigger disk")
95
+ return 2
96
+ if (files or tables) and not yes:
97
+ if input("\n Start? [Y/n] ").strip().lower() in ("n", "no"):
98
+ print(" Nothing downloaded. Re-run any time.")
99
+ return 0
100
+ if files or tables:
101
+ print()
102
+ if client.base_url != DEFAULT_BASE_URL:
103
+ source = "physionet" # a test server or mirror: never the AWS copy of the real projects
104
+ Runner(origins, root, man, ids, jobs, client=client, source=source).run(files, tables)
105
+ res = verify(root, man, [s.key for s in sources], quick)
106
+ return 0 if report(root, man, res, quick) else 1
107
+
108
+
109
+ def cmd_download(a) -> int:
110
+ sources = select(a.only, a.skip, a.include)
111
+ local = _local_map(a.local)
112
+ return _download(Path(a.out).resolve(), sources, local, a.link, a.user, a.jobs, a.yes, a.plan, a.quick_check,
113
+ a.source, a.subjects)
114
+
115
+
116
+ def cmd_check(a) -> int:
117
+ root = Path(a.out).resolve()
118
+ require_current_layout(root)
119
+ man = Manifest.load(root)
120
+ if not man.items:
121
+ print(f"No manifest in {root}. Run `multi-mimic download` first (or pass --out).")
122
+ return 2
123
+ res = verify(root, man, None, a.quick)
124
+ ok = report(root, man, res, a.quick)
125
+ if ok or not a.fix:
126
+ return 0 if ok else 1
127
+ bad = {k for k, v in res.items() if v in ("missing", "incomplete", "corrupt")}
128
+ for k in bad:
129
+ it = man.items[k]
130
+ it.status = "pending"
131
+ p = root / BY_KEY[it.source].rel / it.path
132
+ if res[k] == "corrupt" and p.exists():
133
+ p.unlink()
134
+ print(f"\nFixing {len(bad):,} file(s)...")
135
+ sources = [BY_KEY[k] for k in man.sources if k in BY_KEY]
136
+ local = _local_map(a.local, man)
137
+ return _download(root, sources, local, a.link, a.user, a.jobs, True, False, False, a.source)
138
+
139
+
140
+ def cmd_migrate(a) -> int:
141
+ root = Path(a.out).resolve()
142
+ if manifest_file(root) is None:
143
+ print(f"x {root} has no Multi-MIMIC manifest: not a folder this tool made, nothing changed")
144
+ return 2
145
+ migrate_layout(root, a.dry_run)
146
+ return 0
147
+
148
+
149
+ def cmd_figure(a) -> int:
150
+ try:
151
+ import matplotlib # noqa: F401
152
+ except ImportError:
153
+ print("x the figure needs matplotlib: run `uv run --extra figure multi-mimic figure ...`")
154
+ return 2
155
+ from .figure import run
156
+ if a.min_count < 1:
157
+ print("x --min-count must be at least 1")
158
+ return 2
159
+ return run(Path(a.out).resolve(), Path(a.to), Path(a.readme) if a.readme else None, a.min_count)
160
+
161
+
162
+ def main(argv=None) -> int:
163
+ ap = argparse.ArgumentParser(prog="multi-mimic", description=__doc__,
164
+ formatter_class=argparse.RawDescriptionHelpFormatter)
165
+ ap.add_argument("--version", action="version", version=__version__)
166
+ sub = ap.add_subparsers(dest="cmd", required=True)
167
+
168
+ sub.add_parser("sources", help="list the datasets").set_defaults(fn=cmd_sources)
169
+
170
+ def common(p):
171
+ p.add_argument("--out", default="mimic-demo-multimodal", help="output folder (default: %(default)s)")
172
+ p.add_argument("--user", help="PhysioNet username (or env PHYSIONET_USER; password: env PHYSIONET_PASSWORD "
173
+ "or prompt). Without it: PHYSIONET_TOKEN, or the sign-in saved by `physionet login`")
174
+ p.add_argument("--local", action="append", metavar="SOURCE=FOLDER",
175
+ help="take this source from a local copy instead of PhysioNet (repeatable), "
176
+ "e.g. mimic-iv-note=/data/mimic-iv-note/2.2")
177
+ p.add_argument("--link", choices=["copy", "hardlink", "symlink"], default="copy",
178
+ help="for --local sources: copy files (default), or hard-link / symlink to save space")
179
+ p.add_argument("--jobs", type=int, default=4,
180
+ help="parallel size checks, local copies and fallback downloads (default: %(default)s)")
181
+ p.add_argument("--source", choices=["auto", "physionet", "aws"], default="auto",
182
+ help="where the physionet package downloads files from: auto (AWS open-data mirror "
183
+ "when a project is on it, else PhysioNet), physionet or aws (default: %(default)s)")
184
+
185
+ d = sub.add_parser("download", help="download / extract everything, then check it")
186
+ common(d)
187
+ d.add_argument("--only", nargs="+", metavar="SOURCE", help="only these sources")
188
+ d.add_argument("--skip", nargs="+", metavar="SOURCE", help="leave these sources out")
189
+ d.add_argument("--include", nargs="+", metavar="SOURCE", help="add optional sources (e.g. mimic-cxr)")
190
+ d.add_argument("--yes", "-y", action="store_true", help="don't ask before starting")
191
+ d.add_argument("--plan", action="store_true", help="only list what would be fetched and how big it is")
192
+ d.add_argument("--quick-check", action="store_true", help="final check by size only (skip checksums)")
193
+ d.add_argument("--subjects", nargs="+", metavar="ID",
194
+ help="only these demo patients (for a quick or partial download); full sources stay whole")
195
+ d.set_defaults(fn=cmd_download)
196
+
197
+ c = sub.add_parser("check", help="verify every file against its checksum")
198
+ common(c)
199
+ c.add_argument("--quick", action="store_true", help="sizes only (skip checksums)")
200
+ c.add_argument("--fix", action="store_true", help="re-fetch missing, partial or corrupt files")
201
+ c.set_defaults(fn=cmd_check)
202
+
203
+ m = sub.add_parser("migrate", help="rename folders from the mimic-demo-mm layout (<project>/<version>/) "
204
+ "to <project>-<version>/")
205
+ m.add_argument("--out", default="mimic-demo-multimodal", help="data folder (default: %(default)s)")
206
+ m.add_argument("--dry-run", action="store_true", help="only show what would be renamed")
207
+ m.set_defaults(fn=cmd_migrate)
208
+
209
+ g = sub.add_parser("figure", help="draw the modality coverage and co-occurrence overview (counts only)")
210
+ g.add_argument("--out", default="mimic-demo-multimodal", help="data folder (default: %(default)s)")
211
+ g.add_argument("--to", default="docs", help="folder for the SVGs (default: %(default)s)")
212
+ g.add_argument("--readme", help="also update the overview section of this README")
213
+ g.add_argument("--min-count", type=int, default=5,
214
+ help="show smaller counts as '<N' and merge smaller combinations into 'other' "
215
+ "(default: %(default)s; 1 shows everything)")
216
+ g.set_defaults(fn=cmd_figure)
217
+
218
+ a = ap.parse_args(argv)
219
+ try:
220
+ return a.fn(a)
221
+ except PhysioNetError as e:
222
+ print(f"x {e}")
223
+ return 2
224
+ except SystemExit as e:
225
+ if isinstance(e.code, str): # a readable error message from deeper down
226
+ print(e.code)
227
+ return 2
228
+ raise
229
+ except KeyboardInterrupt:
230
+ print("\nStopped.")
231
+ return 130
232
+
233
+
234
+ if __name__ == "__main__":
235
+ sys.exit(main())