convitom commited on
Commit ·
3248df2
1
Parent(s): 6c43000
- .gitignore +4 -1
- data/export_split_by_pathology.py +321 -0
- data/export_test_by_pathology.ipynb +263 -0
- docs/convert_md.py +1 -1
- docs/export_eda_figs.py +51 -0
- docs/make_figures.py +6 -5
- docs/methodology_rewrite.md +104 -68
- docs/report_front.md +51 -0
- docs/report_part1.md +11 -9
- docs/report_part4_5.md +16 -10
- scripts/cxrvlm_colab_inference.ipynb +245 -0
- scripts/vertex_eval_job.yaml +4 -4
.gitignore
CHANGED
|
@@ -1,4 +1,7 @@
|
|
| 1 |
*.docx
|
| 2 |
*.png
|
| 3 |
__pycache__/
|
| 4 |
-
.claude/
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
*.docx
|
| 2 |
*.png
|
| 3 |
__pycache__/
|
| 4 |
+
.claude/
|
| 5 |
+
*.pdf
|
| 6 |
+
|
| 7 |
+
|
data/export_split_by_pathology.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
export_split_by_pathology.py
|
| 3 |
+
----------------------------
|
| 4 |
+
Tải 1 split (mặc định: test) của MIMIC-CXR_resized từ HF, giải nén tar shards,
|
| 5 |
+
rồi đổ ảnh vào thư mục theo 14 nhãn bệnh lý CheXpert.
|
| 6 |
+
|
| 7 |
+
Vì sao phải làm thế này:
|
| 8 |
+
- Trên HF, MIMIC-CXR_resized lưu ảnh dưới dạng tar shards (cxr-0000.tar, ...),
|
| 9 |
+
KHÔNG tách theo split. Ảnh của 1 split nằm rải khắp các shard.
|
| 10 |
+
- manifest_{train,val,test}.csv mới là nơi biết ảnh nào thuộc split nào, và
|
| 11 |
+
chứa 14 cột `chex_<Pathology>` (giá trị U-MultiClass: 1/0/-1/blank).
|
| 12 |
+
- Mỗi study là MULTI-LABEL: 1 ảnh có thể dương tính nhiều bệnh -> được copy
|
| 13 |
+
vào NHIỀU thư mục (mỗi nhãn positive 1 thư mục).
|
| 14 |
+
|
| 15 |
+
Quy ước nhãn (giống mimic_cxr_resized_builder._row_to_pnu):
|
| 16 |
+
"1" / "1.0" -> positive -> bỏ vào thư mục <Pathology>/
|
| 17 |
+
"-1" / "-1.0" -> uncertain -> tuỳ --uncertain
|
| 18 |
+
"0"/"0.0"/blank -> negative -> bỏ qua
|
| 19 |
+
|
| 20 |
+
Cấu trúc output (mặc định --uncertain separate):
|
| 21 |
+
OUT/
|
| 22 |
+
Cardiomegaly/<dicom>.jpg
|
| 23 |
+
Pleural_Effusion/<dicom>.jpg
|
| 24 |
+
No_Finding/<dicom>.jpg
|
| 25 |
+
_uncertain/Atelectasis/<dicom>.jpg (nếu --uncertain separate)
|
| 26 |
+
_summary.csv (đếm ảnh mỗi nhãn)
|
| 27 |
+
|
| 28 |
+
Chạy LOCAL (đã có data) hoặc trên Colab/Kaggle (tự tải từ HF):
|
| 29 |
+
# tải từ HF rồi export test
|
| 30 |
+
python data/export_split_by_pathology.py --out ./test_by_pathology
|
| 31 |
+
|
| 32 |
+
# đã giải nén sẵn cây files/ ở đâu đó -> bỏ qua tải/giải nén
|
| 33 |
+
python data/export_split_by_pathology.py --out ./test_by_pathology \
|
| 34 |
+
--extracted_root /content/data/MIMIC-CXR_resized \
|
| 35 |
+
--manifest /content/data/MIMIC-CXR_resized/manifest_test.csv
|
| 36 |
+
|
| 37 |
+
# export cả split val, dùng hardlink cho đỡ tốn ổ
|
| 38 |
+
python data/export_split_by_pathology.py --split val --link hardlink
|
| 39 |
+
"""
|
| 40 |
+
from __future__ import annotations
|
| 41 |
+
|
| 42 |
+
import argparse
|
| 43 |
+
import csv
|
| 44 |
+
import os
|
| 45 |
+
import sys
|
| 46 |
+
import tarfile
|
| 47 |
+
from collections import defaultdict
|
| 48 |
+
from pathlib import Path
|
| 49 |
+
|
| 50 |
+
# 14 nhãn CheXpert — single source of truth.
|
| 51 |
+
try:
|
| 52 |
+
from model.chexpert_classifier import PATHOLOGIES
|
| 53 |
+
except Exception:
|
| 54 |
+
# fallback nếu chạy ngoài project (giữ đúng thứ tự).
|
| 55 |
+
PATHOLOGIES = [
|
| 56 |
+
"No Finding", "Enlarged Cardiomediastinum", "Cardiomegaly", "Lung Opacity",
|
| 57 |
+
"Lung Lesion", "Edema", "Consolidation", "Pneumonia", "Atelectasis",
|
| 58 |
+
"Pneumothorax", "Pleural Effusion", "Pleural Other", "Fracture",
|
| 59 |
+
"Support Devices",
|
| 60 |
+
]
|
| 61 |
+
|
| 62 |
+
_POS = {"1", "1.0"}
|
| 63 |
+
_UNC = {"-1", "-1.0"}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _safe(name: str) -> str:
|
| 67 |
+
"""Tên thư mục an toàn: 'Pleural Effusion' -> 'Pleural_Effusion'."""
|
| 68 |
+
return name.replace(" ", "_")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _norm(p: str) -> str:
|
| 72 |
+
"""Chuẩn hoá path để so khớp tar member name <-> manifest image_relpath."""
|
| 73 |
+
return p.replace("\\", "/").lstrip("/")
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# ── Phase 1: tải từ HF (manifest + shards) ──────────────────────────────────
|
| 77 |
+
|
| 78 |
+
def download_from_hf(repo_id: str, split: str, work: Path) -> tuple[Path, list[Path]]:
|
| 79 |
+
"""Tải manifest_<split>.csv + toàn bộ tar shards về `work`.
|
| 80 |
+
Trả về (manifest_path, [shard_paths])."""
|
| 81 |
+
from huggingface_hub import snapshot_download
|
| 82 |
+
|
| 83 |
+
manifest_name = {"train": "manifest_train.csv",
|
| 84 |
+
"val": "manifest_val.csv",
|
| 85 |
+
"validate": "manifest_val.csv",
|
| 86 |
+
"test": "manifest_test.csv"}[split]
|
| 87 |
+
|
| 88 |
+
print(f"[download] snapshot_download {repo_id}:MIMIC-CXR_resized "
|
| 89 |
+
f"(manifest + shards) -> {work}")
|
| 90 |
+
snapshot_download(
|
| 91 |
+
repo_id=repo_id,
|
| 92 |
+
repo_type="dataset",
|
| 93 |
+
local_dir=str(work),
|
| 94 |
+
allow_patterns=[
|
| 95 |
+
f"MIMIC-CXR_resized/{manifest_name}",
|
| 96 |
+
"MIMIC-CXR_resized/shards/*.tar",
|
| 97 |
+
],
|
| 98 |
+
)
|
| 99 |
+
mr = work / "MIMIC-CXR_resized"
|
| 100 |
+
manifest = mr / manifest_name
|
| 101 |
+
shards = sorted((mr / "shards").glob("*.tar"))
|
| 102 |
+
if not manifest.is_file():
|
| 103 |
+
sys.exit(f"ERROR: không thấy manifest sau khi tải: {manifest}")
|
| 104 |
+
if not shards:
|
| 105 |
+
sys.exit(f"ERROR: không thấy tar shard nào dưới {mr/'shards'}")
|
| 106 |
+
print(f"[download] manifest={manifest.name} shards={len(shards)}")
|
| 107 |
+
return manifest, shards
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# ── Phase 2: đọc manifest -> map ảnh test -> nhãn ───────────────────────────
|
| 111 |
+
|
| 112 |
+
def load_label_map(manifest: Path):
|
| 113 |
+
"""Trả về dict: image_relpath(norm) -> {'pos': set, 'unc': set, 'report': str|None}."""
|
| 114 |
+
label_map: dict[str, dict] = {}
|
| 115 |
+
missing_cols = None
|
| 116 |
+
with open(manifest, encoding="utf-8", newline="") as f:
|
| 117 |
+
reader = csv.DictReader(f)
|
| 118 |
+
cols = reader.fieldnames or []
|
| 119 |
+
chex_cols = {p: f"chex_{p}" for p in PATHOLOGIES if f"chex_{p}" in cols}
|
| 120 |
+
missing_cols = [p for p in PATHOLOGIES if f"chex_{p}" not in cols]
|
| 121 |
+
rel_col = "image_relpath" if "image_relpath" in cols else None
|
| 122 |
+
if rel_col is None:
|
| 123 |
+
sys.exit(f"ERROR: manifest thiếu cột 'image_relpath'. Có: {cols}")
|
| 124 |
+
has_report = "report_relpath" in cols
|
| 125 |
+
for row in reader:
|
| 126 |
+
rel = _norm(str(row[rel_col]).strip())
|
| 127 |
+
pos, unc = set(), set()
|
| 128 |
+
for path, col in chex_cols.items():
|
| 129 |
+
v = str(row.get(col, "")).strip()
|
| 130 |
+
if v in _POS:
|
| 131 |
+
pos.add(path)
|
| 132 |
+
elif v in _UNC:
|
| 133 |
+
unc.add(path)
|
| 134 |
+
rep = _norm(str(row["report_relpath"]).strip()) if has_report else None
|
| 135 |
+
label_map[rel] = {"pos": pos, "unc": unc, "report": rep or None}
|
| 136 |
+
if missing_cols:
|
| 137 |
+
print(f"[labels] CẢNH BÁO: manifest thiếu cột cho: {missing_cols}")
|
| 138 |
+
if not has_report:
|
| 139 |
+
print("[labels] CẢNH BÁO: manifest không có cột 'report_relpath' → bỏ qua report")
|
| 140 |
+
print(f"[labels] {len(label_map):,} ảnh trong manifest")
|
| 141 |
+
return label_map
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def gather_reports(shards: list[Path], report_set: set) -> dict:
|
| 145 |
+
"""Pass phụ: rút text của các report cần dùng từ tar (report nằm rải, gom 1 lượt).
|
| 146 |
+
Report là file .txt nhỏ nên giữ trong RAM thoải mái."""
|
| 147 |
+
reports: dict[str, bytes] = {}
|
| 148 |
+
if not report_set:
|
| 149 |
+
return reports
|
| 150 |
+
for shard in shards:
|
| 151 |
+
with tarfile.open(shard, "r") as tf:
|
| 152 |
+
for m in tf:
|
| 153 |
+
if not m.isfile():
|
| 154 |
+
continue
|
| 155 |
+
name = _norm(m.name)
|
| 156 |
+
if name in report_set and name not in reports:
|
| 157 |
+
reports[name] = tf.extractfile(m).read()
|
| 158 |
+
print(f"[reports] rút được {len(reports):,} / {len(report_set):,} report")
|
| 159 |
+
return reports
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
# ── Phase 3: rút ảnh từ tar -> thư mục theo nhãn ────────────────────────────
|
| 163 |
+
|
| 164 |
+
def _place(data: bytes, dicom_name: str, paths: set, base: Path,
|
| 165 |
+
counts: defaultdict, link_mode: str, report: bytes | None = None):
|
| 166 |
+
"""Ghi 1 ảnh (và report cùng tên .txt nếu có) vào nhiều thư mục nhãn."""
|
| 167 |
+
txt_name = Path(dicom_name).stem + ".txt"
|
| 168 |
+
first_written: Path | None = None
|
| 169 |
+
for lab in paths:
|
| 170 |
+
d = base / _safe(lab)
|
| 171 |
+
d.mkdir(parents=True, exist_ok=True)
|
| 172 |
+
dst = d / dicom_name
|
| 173 |
+
counts[lab] += 1
|
| 174 |
+
# report .txt đặt cạnh ảnh, cùng tên
|
| 175 |
+
if report is not None:
|
| 176 |
+
(d / txt_name).write_bytes(report)
|
| 177 |
+
if dst.exists():
|
| 178 |
+
continue
|
| 179 |
+
if link_mode == "copy" or first_written is None:
|
| 180 |
+
dst.write_bytes(data)
|
| 181 |
+
first_written = dst
|
| 182 |
+
else:
|
| 183 |
+
try:
|
| 184 |
+
if link_mode == "hardlink":
|
| 185 |
+
os.link(first_written, dst)
|
| 186 |
+
else: # symlink
|
| 187 |
+
os.symlink(os.path.abspath(first_written), dst)
|
| 188 |
+
except OSError:
|
| 189 |
+
dst.write_bytes(data) # fallback nếu FS không hỗ trợ link
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def export(shards: list[Path], label_map: dict, out: Path,
|
| 193 |
+
uncertain: str, link_mode: str, with_report: bool = True):
|
| 194 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 195 |
+
unc_base = out / "_uncertain"
|
| 196 |
+
|
| 197 |
+
test_set = set(label_map.keys())
|
| 198 |
+
|
| 199 |
+
# Gom report cần dùng (1 pass phụ qua tar) trước khi rút ảnh.
|
| 200 |
+
reports: dict = {}
|
| 201 |
+
if with_report:
|
| 202 |
+
report_set = {label_map[k]["report"] for k in test_set
|
| 203 |
+
if label_map[k].get("report")}
|
| 204 |
+
reports = gather_reports(shards, report_set)
|
| 205 |
+
|
| 206 |
+
counts_pos: defaultdict = defaultdict(int)
|
| 207 |
+
counts_unc: defaultdict = defaultdict(int)
|
| 208 |
+
n_imgs = 0
|
| 209 |
+
n_no_report = 0
|
| 210 |
+
seen: set[str] = set()
|
| 211 |
+
|
| 212 |
+
for si, shard in enumerate(shards, 1):
|
| 213 |
+
print(f"[extract] [{si}/{len(shards)}] {shard.name}")
|
| 214 |
+
with tarfile.open(shard, "r") as tf:
|
| 215 |
+
for m in tf:
|
| 216 |
+
if not m.isfile():
|
| 217 |
+
continue
|
| 218 |
+
name = _norm(m.name)
|
| 219 |
+
if name not in test_set:
|
| 220 |
+
continue
|
| 221 |
+
seen.add(name)
|
| 222 |
+
ent = label_map[name]
|
| 223 |
+
pos, unc = ent["pos"], ent["unc"]
|
| 224 |
+
if not pos and not (uncertain != "skip" and unc):
|
| 225 |
+
# không có nhãn positive (toàn negative) -> bỏ qua
|
| 226 |
+
if not pos:
|
| 227 |
+
continue
|
| 228 |
+
data = tf.extractfile(m).read()
|
| 229 |
+
dicom_name = Path(name).name
|
| 230 |
+
rep = reports.get(ent.get("report")) if with_report else None
|
| 231 |
+
if with_report and rep is None:
|
| 232 |
+
n_no_report += 1
|
| 233 |
+
n_imgs += 1
|
| 234 |
+
if pos:
|
| 235 |
+
_place(data, dicom_name, pos, out, counts_pos, link_mode, rep)
|
| 236 |
+
if unc and uncertain != "skip":
|
| 237 |
+
if uncertain == "merge":
|
| 238 |
+
_place(data, dicom_name, unc, out, counts_unc, link_mode, rep)
|
| 239 |
+
else: # separate
|
| 240 |
+
_place(data, dicom_name, unc, unc_base, counts_unc, link_mode, rep)
|
| 241 |
+
|
| 242 |
+
if with_report and n_no_report:
|
| 243 |
+
print(f"[reports] CẢNH BÁO: {n_no_report:,} ảnh không tìm thấy report → chỉ có .jpg")
|
| 244 |
+
|
| 245 |
+
missing = test_set - seen
|
| 246 |
+
print(f"\n[done] ảnh test rút được: {n_imgs:,} / {len(test_set):,} trong manifest")
|
| 247 |
+
if missing:
|
| 248 |
+
print(f"[done] CẢNH BÁO: {len(missing):,} ảnh trong manifest không thấy trong shard "
|
| 249 |
+
f"(ví dụ: {list(missing)[:3]})")
|
| 250 |
+
print(f"[done] ảnh toàn-negative (không có positive): bỏ qua")
|
| 251 |
+
|
| 252 |
+
# _summary.csv
|
| 253 |
+
summ = out / "_summary.csv"
|
| 254 |
+
with open(summ, "w", encoding="utf-8", newline="") as f:
|
| 255 |
+
w = csv.writer(f)
|
| 256 |
+
w.writerow(["pathology", "positive_images", "uncertain_images"])
|
| 257 |
+
for p in PATHOLOGIES:
|
| 258 |
+
w.writerow([p, counts_pos.get(p, 0), counts_unc.get(p, 0)])
|
| 259 |
+
print(f"[done] thống kê -> {summ}")
|
| 260 |
+
print("\n Nhãn positive uncertain")
|
| 261 |
+
for p in PATHOLOGIES:
|
| 262 |
+
print(f" {p:28s} {counts_pos.get(p,0):8d} {counts_unc.get(p,0):8d}")
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
# ── CLI ─────────────────────────────────────────────────────────────────────
|
| 266 |
+
|
| 267 |
+
def main():
|
| 268 |
+
ap = argparse.ArgumentParser(
|
| 269 |
+
description="Export 1 split của MIMIC-CXR_resized thành thư mục theo 14 nhãn bệnh lý.")
|
| 270 |
+
ap.add_argument("--out", required=True, help="Thư mục output.")
|
| 271 |
+
ap.add_argument("--split", default="test", choices=["train", "val", "validate", "test"])
|
| 272 |
+
ap.add_argument("--repo_id", default="hieu3636/cxr-vlm-data")
|
| 273 |
+
ap.add_argument("--work", default="./_hf_resized_dl",
|
| 274 |
+
help="Thư mục cache tải từ HF (khi không dùng --extracted_root).")
|
| 275 |
+
ap.add_argument("--extracted_root", default=None,
|
| 276 |
+
help="Nếu đã có shards giải nén/tar sẵn ở local: trỏ tới thư mục "
|
| 277 |
+
"MIMIC-CXR_resized (chứa shards/*.tar). Bỏ qua bước tải HF.")
|
| 278 |
+
ap.add_argument("--manifest", default=None,
|
| 279 |
+
help="Đường dẫn manifest_<split>.csv (mặc định lấy trong dữ liệu đã tải).")
|
| 280 |
+
ap.add_argument("--uncertain", default="separate",
|
| 281 |
+
choices=["separate", "merge", "skip"],
|
| 282 |
+
help="Xử lý nhãn uncertain: separate=thư mục _uncertain/<P>; "
|
| 283 |
+
"merge=gộp chung <P>; skip=bỏ. Mặc định separate.")
|
| 284 |
+
ap.add_argument("--link", default="copy", choices=["copy", "hardlink", "symlink"],
|
| 285 |
+
help="copy (an toàn nhất) | hardlink/symlink (tiết kiệm ổ khi 1 ảnh nhiều nhãn).")
|
| 286 |
+
ap.add_argument("--no_report", action="store_true",
|
| 287 |
+
help="Không ghi report .txt cạnh ảnh (mặc định CÓ ghi).")
|
| 288 |
+
a = ap.parse_args()
|
| 289 |
+
|
| 290 |
+
out = Path(a.out)
|
| 291 |
+
|
| 292 |
+
# 1) Lấy manifest + shards
|
| 293 |
+
if a.extracted_root:
|
| 294 |
+
mr = Path(a.extracted_root)
|
| 295 |
+
shards = sorted((mr / "shards").glob("*.tar")) or sorted(mr.glob("*.tar"))
|
| 296 |
+
if not shards:
|
| 297 |
+
sys.exit(f"ERROR: không thấy *.tar dưới {mr} hoặc {mr/'shards'}")
|
| 298 |
+
if a.manifest:
|
| 299 |
+
manifest = Path(a.manifest)
|
| 300 |
+
else:
|
| 301 |
+
mname = {"train": "manifest_train.csv", "val": "manifest_val.csv",
|
| 302 |
+
"validate": "manifest_val.csv", "test": "manifest_test.csv"}[a.split]
|
| 303 |
+
manifest = mr / mname
|
| 304 |
+
if not manifest.is_file():
|
| 305 |
+
sys.exit(f"ERROR: không thấy manifest: {manifest}")
|
| 306 |
+
print(f"[local] manifest={manifest} shards={len(shards)}")
|
| 307 |
+
else:
|
| 308 |
+
manifest, shards = download_from_hf(a.repo_id, a.split, Path(a.work))
|
| 309 |
+
if a.manifest:
|
| 310 |
+
manifest = Path(a.manifest)
|
| 311 |
+
|
| 312 |
+
# 2) Đọc nhãn
|
| 313 |
+
label_map = load_label_map(manifest)
|
| 314 |
+
|
| 315 |
+
# 3) Rút ảnh (+ report) -> thư mục nhãn
|
| 316 |
+
export(shards, label_map, out, a.uncertain, a.link, with_report=not a.no_report)
|
| 317 |
+
print(f"\nXong. Output: {out.resolve()}")
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
if __name__ == "__main__":
|
| 321 |
+
main()
|
data/export_test_by_pathology.ipynb
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "markdown",
|
| 5 |
+
"id": "26283604",
|
| 6 |
+
"metadata": {},
|
| 7 |
+
"source": [
|
| 8 |
+
"# Export tập test MIMIC-CXR_resized → thư mục theo 14 nhãn bệnh lý\n",
|
| 9 |
+
"\n",
|
| 10 |
+
"Chỉ cần điền **CONFIG** bên dưới rồi **Run All**.\n",
|
| 11 |
+
"\n",
|
| 12 |
+
"- Tải `manifest_test.csv` + tar shards từ HF (`hieu3636/cxr-vlm-data/MIMIC-CXR_resized/`).\n",
|
| 13 |
+
"- Rút ảnh test, đổ vào `OUT/<Tên_bệnh>/`. Ảnh multi-label → copy vào nhiều thư mục.\n",
|
| 14 |
+
"- **Kèm report**: mỗi ảnh `<dicom>.jpg` có file `<dicom>.txt` (nội dung report của study đó) đặt ngay cạnh.\n",
|
| 15 |
+
"- Repo **private** → cần token HF (điền vào `HF_TOKEN`, hoặc đã `huggingface-cli login` thì để trống)."
|
| 16 |
+
]
|
| 17 |
+
},
|
| 18 |
+
{
|
| 19 |
+
"cell_type": "markdown",
|
| 20 |
+
"id": "c41dd18e",
|
| 21 |
+
"metadata": {},
|
| 22 |
+
"source": [
|
| 23 |
+
"## 1. CONFIG — chỉnh ở đây"
|
| 24 |
+
]
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"cell_type": "code",
|
| 28 |
+
"execution_count": null,
|
| 29 |
+
"id": "8fd81f44",
|
| 30 |
+
"metadata": {},
|
| 31 |
+
"outputs": [],
|
| 32 |
+
"source": [
|
| 33 |
+
"# ==== CHỈNH CÁC BIẾN NÀY ====\n",
|
| 34 |
+
"HF_TOKEN = \"\" # token HF (Read là đủ). Để \"\" nếu đã huggingface-cli login.\n",
|
| 35 |
+
"REPO_ID = \"hieu3636/cxr-vlm-data\"\n",
|
| 36 |
+
"SPLIT = \"test\" # \"train\" | \"val\" | \"test\"\n",
|
| 37 |
+
"\n",
|
| 38 |
+
"OUT = r\"D:\\USTH\\KLTN\\test_by_pathology\" # thư mục output\n",
|
| 39 |
+
"WORK = r\"D:\\USTH\\KLTN\\_hf_resized_dl\" # nơi cache tải từ HF\n",
|
| 40 |
+
"\n",
|
| 41 |
+
"# Nếu ĐÃ có shards giải nén/tar sẵn ở máy thì trỏ vào đây để KHỎI tải lại,\n",
|
| 42 |
+
"# ví dụ r\"D:\\USTH\\KLTN\\_hf_resized_dl\\MIMIC-CXR_resized\". Để None = tải từ HF.\n",
|
| 43 |
+
"EXTRACTED_ROOT = None\n",
|
| 44 |
+
"\n",
|
| 45 |
+
"WITH_REPORT = True # True = ghi kèm <dicom>.txt (report) cạnh mỗi ảnh\n",
|
| 46 |
+
"UNCERTAIN = \"separate\" # \"separate\" (_uncertain/<P>) | \"merge\" | \"skip\"\n",
|
| 47 |
+
"LINK = \"copy\" # \"copy\" | \"hardlink\" | \"symlink\" (hardlink đỡ tốn ổ)\n",
|
| 48 |
+
"# ============================\n",
|
| 49 |
+
"print(\"CONFIG ok | split =\", SPLIT, \"| out =\", OUT, \"| with_report =\", WITH_REPORT)"
|
| 50 |
+
]
|
| 51 |
+
},
|
| 52 |
+
{
|
| 53 |
+
"cell_type": "markdown",
|
| 54 |
+
"id": "8085defe",
|
| 55 |
+
"metadata": {},
|
| 56 |
+
"source": [
|
| 57 |
+
"## 2. Cài thư viện (chạy 1 lần)"
|
| 58 |
+
]
|
| 59 |
+
},
|
| 60 |
+
{
|
| 61 |
+
"cell_type": "code",
|
| 62 |
+
"execution_count": null,
|
| 63 |
+
"id": "11afbe51",
|
| 64 |
+
"metadata": {},
|
| 65 |
+
"outputs": [],
|
| 66 |
+
"source": [
|
| 67 |
+
"# Chỉ cần huggingface_hub; tarfile/csv là built-in.\n",
|
| 68 |
+
"try:\n",
|
| 69 |
+
" import huggingface_hub # noqa\n",
|
| 70 |
+
" print(\"huggingface_hub đã có:\", huggingface_hub.__version__)\n",
|
| 71 |
+
"except ImportError:\n",
|
| 72 |
+
" import sys, subprocess\n",
|
| 73 |
+
" subprocess.check_call([sys.executable, \"-m\", \"pip\", \"install\", \"-q\", \"huggingface_hub\"])\n",
|
| 74 |
+
" print(\"đã cài huggingface_hub\")"
|
| 75 |
+
]
|
| 76 |
+
},
|
| 77 |
+
{
|
| 78 |
+
"cell_type": "markdown",
|
| 79 |
+
"id": "ac53032e",
|
| 80 |
+
"metadata": {},
|
| 81 |
+
"source": [
|
| 82 |
+
"## 3. Logic (không cần sửa)"
|
| 83 |
+
]
|
| 84 |
+
},
|
| 85 |
+
{
|
| 86 |
+
"cell_type": "code",
|
| 87 |
+
"execution_count": null,
|
| 88 |
+
"id": "9ac60a59",
|
| 89 |
+
"metadata": {},
|
| 90 |
+
"outputs": [],
|
| 91 |
+
"source": [
|
| 92 |
+
"import os, csv, tarfile\n",
|
| 93 |
+
"from collections import defaultdict\n",
|
| 94 |
+
"from pathlib import Path\n",
|
| 95 |
+
"\n",
|
| 96 |
+
"# 14 nhãn CheXpert — đúng thứ tự dùng trong project.\n",
|
| 97 |
+
"PATHOLOGIES = [\n",
|
| 98 |
+
" \"No Finding\", \"Enlarged Cardiomediastinum\", \"Cardiomegaly\", \"Lung Opacity\",\n",
|
| 99 |
+
" \"Lung Lesion\", \"Edema\", \"Consolidation\", \"Pneumonia\", \"Atelectasis\",\n",
|
| 100 |
+
" \"Pneumothorax\", \"Pleural Effusion\", \"Pleural Other\", \"Fracture\",\n",
|
| 101 |
+
" \"Support Devices\",\n",
|
| 102 |
+
"]\n",
|
| 103 |
+
"_POS = {\"1\", \"1.0\"}\n",
|
| 104 |
+
"_UNC = {\"-1\", \"-1.0\"}\n",
|
| 105 |
+
"_MANIFEST = {\"train\": \"manifest_train.csv\", \"val\": \"manifest_val.csv\",\n",
|
| 106 |
+
" \"validate\": \"manifest_val.csv\", \"test\": \"manifest_test.csv\"}\n",
|
| 107 |
+
"\n",
|
| 108 |
+
"def _safe(n): return n.replace(\" \", \"_\")\n",
|
| 109 |
+
"def _norm(p): return p.replace(\"\\\\\", \"/\").lstrip(\"/\")\n",
|
| 110 |
+
"\n",
|
| 111 |
+
"def download_from_hf(repo_id, split, work):\n",
|
| 112 |
+
" from huggingface_hub import snapshot_download\n",
|
| 113 |
+
" mname = _MANIFEST[split]\n",
|
| 114 |
+
" print(f\"[download] {repo_id}:MIMIC-CXR_resized (manifest + shards) -> {work}\")\n",
|
| 115 |
+
" snapshot_download(\n",
|
| 116 |
+
" repo_id=repo_id, repo_type=\"dataset\", local_dir=str(work),\n",
|
| 117 |
+
" allow_patterns=[f\"MIMIC-CXR_resized/{mname}\", \"MIMIC-CXR_resized/shards/*.tar\"],\n",
|
| 118 |
+
" )\n",
|
| 119 |
+
" mr = Path(work) / \"MIMIC-CXR_resized\"\n",
|
| 120 |
+
" manifest = mr / mname\n",
|
| 121 |
+
" shards = sorted((mr / \"shards\").glob(\"*.tar\"))\n",
|
| 122 |
+
" assert manifest.is_file(), f\"không thấy manifest: {manifest}\"\n",
|
| 123 |
+
" assert shards, f\"không thấy tar shard dưới {mr/'shards'}\"\n",
|
| 124 |
+
" print(f\"[download] manifest={manifest.name} shards={len(shards)}\")\n",
|
| 125 |
+
" return manifest, shards\n",
|
| 126 |
+
"\n",
|
| 127 |
+
"def load_label_map(manifest):\n",
|
| 128 |
+
" label_map = {}\n",
|
| 129 |
+
" with open(manifest, encoding=\"utf-8\", newline=\"\") as f:\n",
|
| 130 |
+
" reader = csv.DictReader(f); cols = reader.fieldnames or []\n",
|
| 131 |
+
" chex_cols = {p: f\"chex_{p}\" for p in PATHOLOGIES if f\"chex_{p}\" in cols}\n",
|
| 132 |
+
" miss = [p for p in PATHOLOGIES if f\"chex_{p}\" not in cols]\n",
|
| 133 |
+
" assert \"image_relpath\" in cols, f\"manifest thiếu image_relpath. Có: {cols}\"\n",
|
| 134 |
+
" has_report = \"report_relpath\" in cols\n",
|
| 135 |
+
" for row in reader:\n",
|
| 136 |
+
" rel = _norm(str(row[\"image_relpath\"]).strip())\n",
|
| 137 |
+
" pos, unc = set(), set()\n",
|
| 138 |
+
" for p, c in chex_cols.items():\n",
|
| 139 |
+
" v = str(row.get(c, \"\")).strip()\n",
|
| 140 |
+
" if v in _POS: pos.add(p)\n",
|
| 141 |
+
" elif v in _UNC: unc.add(p)\n",
|
| 142 |
+
" rep = _norm(str(row[\"report_relpath\"]).strip()) if has_report else None\n",
|
| 143 |
+
" label_map[rel] = {\"pos\": pos, \"unc\": unc, \"report\": rep or None}\n",
|
| 144 |
+
" if miss: print(f\"[labels] CẢNH BÁO thiếu cột: {miss}\")\n",
|
| 145 |
+
" if not has_report: print(\"[labels] CẢNH BÁO: manifest không có report_relpath → bỏ qua report\")\n",
|
| 146 |
+
" print(f\"[labels] {len(label_map):,} ảnh trong manifest\")\n",
|
| 147 |
+
" return label_map\n",
|
| 148 |
+
"\n",
|
| 149 |
+
"def gather_reports(shards, report_set):\n",
|
| 150 |
+
" \"\"\"Gom text các report cần dùng (1 pass qua tar). Report nhỏ → giữ RAM.\"\"\"\n",
|
| 151 |
+
" reports = {}\n",
|
| 152 |
+
" if not report_set: return reports\n",
|
| 153 |
+
" for shard in shards:\n",
|
| 154 |
+
" with tarfile.open(shard, \"r\") as tf:\n",
|
| 155 |
+
" for m in tf:\n",
|
| 156 |
+
" if not m.isfile(): continue\n",
|
| 157 |
+
" name = _norm(m.name)\n",
|
| 158 |
+
" if name in report_set and name not in reports:\n",
|
| 159 |
+
" reports[name] = tf.extractfile(m).read()\n",
|
| 160 |
+
" print(f\"[reports] rút được {len(reports):,} / {len(report_set):,} report\")\n",
|
| 161 |
+
" return reports\n",
|
| 162 |
+
"\n",
|
| 163 |
+
"def _place(data, dicom, paths, base, counts, link, report=None):\n",
|
| 164 |
+
" txt = Path(dicom).stem + \".txt\"\n",
|
| 165 |
+
" first = None\n",
|
| 166 |
+
" for lab in paths:\n",
|
| 167 |
+
" d = base / _safe(lab); d.mkdir(parents=True, exist_ok=True)\n",
|
| 168 |
+
" dst = d / dicom; counts[lab] += 1\n",
|
| 169 |
+
" if report is not None: (d / txt).write_bytes(report)\n",
|
| 170 |
+
" if dst.exists(): continue\n",
|
| 171 |
+
" if link == \"copy\" or first is None:\n",
|
| 172 |
+
" dst.write_bytes(data); first = dst\n",
|
| 173 |
+
" else:\n",
|
| 174 |
+
" try:\n",
|
| 175 |
+
" os.link(first, dst) if link == \"hardlink\" else os.symlink(os.path.abspath(first), dst)\n",
|
| 176 |
+
" except OSError:\n",
|
| 177 |
+
" dst.write_bytes(data)\n",
|
| 178 |
+
"\n",
|
| 179 |
+
"def export(shards, label_map, out, uncertain, link, with_report=True):\n",
|
| 180 |
+
" out = Path(out); out.mkdir(parents=True, exist_ok=True)\n",
|
| 181 |
+
" unc_base = out / \"_uncertain\"\n",
|
| 182 |
+
" test_set = set(label_map)\n",
|
| 183 |
+
" reports = {}\n",
|
| 184 |
+
" if with_report:\n",
|
| 185 |
+
" rset = {label_map[k][\"report\"] for k in test_set if label_map[k].get(\"report\")}\n",
|
| 186 |
+
" reports = gather_reports(shards, rset)\n",
|
| 187 |
+
" cpos, cunc = defaultdict(int), defaultdict(int)\n",
|
| 188 |
+
" n_imgs = 0; n_no_rep = 0; seen = set()\n",
|
| 189 |
+
" for si, shard in enumerate(shards, 1):\n",
|
| 190 |
+
" print(f\"[extract] [{si}/{len(shards)}] {shard.name}\")\n",
|
| 191 |
+
" with tarfile.open(shard, \"r\") as tf:\n",
|
| 192 |
+
" for m in tf:\n",
|
| 193 |
+
" if not m.isfile(): continue\n",
|
| 194 |
+
" name = _norm(m.name)\n",
|
| 195 |
+
" if name not in test_set: continue\n",
|
| 196 |
+
" seen.add(name)\n",
|
| 197 |
+
" ent = label_map[name]; pos, unc = ent[\"pos\"], ent[\"unc\"]\n",
|
| 198 |
+
" if not pos and not (uncertain != \"skip\" and unc): continue\n",
|
| 199 |
+
" data = tf.extractfile(m).read(); dicom = Path(name).name; n_imgs += 1\n",
|
| 200 |
+
" rep = reports.get(ent.get(\"report\")) if with_report else None\n",
|
| 201 |
+
" if with_report and rep is None: n_no_rep += 1\n",
|
| 202 |
+
" if pos: _place(data, dicom, pos, out, cpos, link, rep)\n",
|
| 203 |
+
" if unc and uncertain != \"skip\":\n",
|
| 204 |
+
" _place(data, dicom, unc, (out if uncertain == \"merge\" else unc_base), cunc, link, rep)\n",
|
| 205 |
+
" if with_report and n_no_rep:\n",
|
| 206 |
+
" print(f\"[reports] CẢNH BÁO: {n_no_rep:,} ảnh không thấy report → chỉ có .jpg\")\n",
|
| 207 |
+
" missing = test_set - seen\n",
|
| 208 |
+
" print(f\"\\n[done] ảnh rút được: {n_imgs:,} / {len(test_set):,} trong manifest\")\n",
|
| 209 |
+
" if missing:\n",
|
| 210 |
+
" print(f\"[done] CẢNH BÁO: {len(missing):,} ảnh manifest không có trong shard (vd: {list(missing)[:2]})\")\n",
|
| 211 |
+
" with open(out / \"_summary.csv\", \"w\", encoding=\"utf-8\", newline=\"\") as f:\n",
|
| 212 |
+
" w = csv.writer(f); w.writerow([\"pathology\", \"positive_images\", \"uncertain_images\"])\n",
|
| 213 |
+
" for p in PATHOLOGIES: w.writerow([p, cpos.get(p, 0), cunc.get(p, 0)])\n",
|
| 214 |
+
" print(\"\\n Nhãn positive uncertain\")\n",
|
| 215 |
+
" for p in PATHOLOGIES:\n",
|
| 216 |
+
" print(f\" {p:28s} {cpos.get(p,0):8d} {cunc.get(p,0):8d}\")\n",
|
| 217 |
+
" return cpos, cunc\n",
|
| 218 |
+
"\n",
|
| 219 |
+
"print(\"logic loaded\")"
|
| 220 |
+
]
|
| 221 |
+
},
|
| 222 |
+
{
|
| 223 |
+
"cell_type": "markdown",
|
| 224 |
+
"id": "f23b1e30",
|
| 225 |
+
"metadata": {},
|
| 226 |
+
"source": [
|
| 227 |
+
"## 4. Run"
|
| 228 |
+
]
|
| 229 |
+
},
|
| 230 |
+
{
|
| 231 |
+
"cell_type": "code",
|
| 232 |
+
"execution_count": null,
|
| 233 |
+
"id": "df4a1338",
|
| 234 |
+
"metadata": {},
|
| 235 |
+
"outputs": [],
|
| 236 |
+
"source": [
|
| 237 |
+
"# token\n",
|
| 238 |
+
"if HF_TOKEN.strip():\n",
|
| 239 |
+
" os.environ[\"HF_TOKEN\"] = HF_TOKEN.strip()\n",
|
| 240 |
+
" os.environ[\"HUGGING_FACE_HUB_TOKEN\"] = HF_TOKEN.strip()\n",
|
| 241 |
+
"\n",
|
| 242 |
+
"# 1) manifest + shards\n",
|
| 243 |
+
"if EXTRACTED_ROOT:\n",
|
| 244 |
+
" mr = Path(EXTRACTED_ROOT)\n",
|
| 245 |
+
" shards = sorted((mr / \"shards\").glob(\"*.tar\")) or sorted(mr.glob(\"*.tar\"))\n",
|
| 246 |
+
" manifest = mr / _MANIFEST[SPLIT]\n",
|
| 247 |
+
" assert shards, f\"không thấy *.tar dưới {mr}\"\n",
|
| 248 |
+
" assert manifest.is_file(), f\"không thấy manifest: {manifest}\"\n",
|
| 249 |
+
" print(f\"[local] manifest={manifest} shards={len(shards)}\")\n",
|
| 250 |
+
"else:\n",
|
| 251 |
+
" manifest, shards = download_from_hf(REPO_ID, SPLIT, WORK)\n",
|
| 252 |
+
"\n",
|
| 253 |
+
"# 2) đọc nhãn 3) rút ảnh (+ report)\n",
|
| 254 |
+
"label_map = load_label_map(manifest)\n",
|
| 255 |
+
"cpos, cunc = export(shards, label_map, OUT, UNCERTAIN, LINK, with_report=WITH_REPORT)\n",
|
| 256 |
+
"print(f\"\\nXong! Output: {Path(OUT).resolve()}\")"
|
| 257 |
+
]
|
| 258 |
+
}
|
| 259 |
+
],
|
| 260 |
+
"metadata": {},
|
| 261 |
+
"nbformat": 4,
|
| 262 |
+
"nbformat_minor": 5
|
| 263 |
+
}
|
docs/convert_md.py
CHANGED
|
@@ -26,7 +26,7 @@ OUTPUT_NAME = None
|
|
| 26 |
# GOP NHIEU FILE THANH 1 BAO CAO:
|
| 27 |
# - De COMBINE = [] (rong) -> dung che do INPUT o tren (convert tung file rieng).
|
| 28 |
# - Liet ke file theo dung thu tu -> gop thanh 1 file COMBINE_OUTPUT duy nhat.
|
| 29 |
-
COMBINE = ["report_part1.md", "methodology_rewrite.md", "report_part4_5.md"]
|
| 30 |
COMBINE_OUTPUT = "report.docx"
|
| 31 |
|
| 32 |
# =====================================================================
|
|
|
|
| 26 |
# GOP NHIEU FILE THANH 1 BAO CAO:
|
| 27 |
# - De COMBINE = [] (rong) -> dung che do INPUT o tren (convert tung file rieng).
|
| 28 |
# - Liet ke file theo dung thu tu -> gop thanh 1 file COMBINE_OUTPUT duy nhat.
|
| 29 |
+
COMBINE = ["report_front.md", "report_part1.md", "methodology_rewrite.md", "report_part4_5.md"]
|
| 30 |
COMBINE_OUTPUT = "report.docx"
|
| 31 |
|
| 32 |
# =====================================================================
|
docs/export_eda_figs.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Trích các biểu đồ EDA đã render sẵn trong output cell của notebook -> file PNG.
|
| 3 |
+
Chay: python export_eda_figs.py (anh ra docs/figures/)
|
| 4 |
+
|
| 5 |
+
Neu ban chay lai notebook va cell index doi, sua dict PICKS ben duoi.
|
| 6 |
+
"""
|
| 7 |
+
import base64, json
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
HERE = Path(__file__).resolve().parent
|
| 11 |
+
NB_DIR = HERE.parent / "data"
|
| 12 |
+
OUT = HERE / "figures"
|
| 13 |
+
OUT.mkdir(exist_ok=True)
|
| 14 |
+
|
| 15 |
+
# (notebook, cell_index) -> ten file png
|
| 16 |
+
PICKS = {
|
| 17 |
+
("eda_full.ipynb", 25): "eda_chexpert_labels.png", # P/U/N 14 nhan
|
| 18 |
+
("eda_full.ipynb", 17): "eda_views.png", # view position bar+pie
|
| 19 |
+
("eda_full.ipynb", 14): "eda_imgs_per_study.png", # so anh / study
|
| 20 |
+
("eda_full.ipynb", 31): "eda_report_length.png", # do dai findings/impression
|
| 21 |
+
("eda_full.ipynb", 40): "eda_vqa_types.png", # semantic + content type
|
| 22 |
+
("build_subset_local.ipynb", 16): "eda_prevalence_compare.png", # full vs elig vs subset
|
| 23 |
+
("build_subset_local.ipynb", 17): "eda_vqa_compare.png", # VQA full vs subset
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def first_png(cell):
|
| 28 |
+
for o in cell.get("outputs", []):
|
| 29 |
+
img = (o.get("data", {}) or {}).get("image/png")
|
| 30 |
+
if img:
|
| 31 |
+
return img if isinstance(img, str) else "".join(img)
|
| 32 |
+
return None
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def main():
|
| 36 |
+
for (nb_name, idx), out_name in PICKS.items():
|
| 37 |
+
nb = json.load(open(NB_DIR / nb_name, encoding="utf-8"))
|
| 38 |
+
if idx >= len(nb["cells"]):
|
| 39 |
+
print(f"[x] {nb_name}: khong co cell {idx}")
|
| 40 |
+
continue
|
| 41 |
+
png_b64 = first_png(nb["cells"][idx])
|
| 42 |
+
if not png_b64:
|
| 43 |
+
print(f"[x] {nb_name} cell {idx}: khong co anh output")
|
| 44 |
+
continue
|
| 45 |
+
(OUT / out_name).write_bytes(base64.b64decode(png_b64))
|
| 46 |
+
src = "".join(nb["cells"][idx].get("source", []))
|
| 47 |
+
print(f"[+] {out_name:<28s} <- {nb_name} cell {idx} ({src[:55].strip()}...)")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
if __name__ == "__main__":
|
| 51 |
+
main()
|
docs/make_figures.py
CHANGED
|
@@ -296,12 +296,13 @@ def fig01_pipeline():
|
|
| 296 |
# ---- cac hinh con lai (don gian, den trang) --------------------------------
|
| 297 |
|
| 298 |
def fig02_data_pipeline():
|
| 299 |
-
fig, ax = canvas(
|
| 300 |
hrow(ax, 12, [
|
| 301 |
-
(16, 22, "
|
| 302 |
-
(49,
|
| 303 |
-
(
|
| 304 |
-
(
|
|
|
|
| 305 |
], h=14)
|
| 306 |
ax.text(49, 2, "(download from PhysioNet -> push to Hugging Face)",
|
| 307 |
ha="center", fontsize=6.6)
|
|
|
|
| 296 |
# ---- cac hinh con lai (don gian, den trang) --------------------------------
|
| 297 |
|
| 298 |
def fig02_data_pipeline():
|
| 299 |
+
fig, ax = canvas(136, 24)
|
| 300 |
hrow(ax, 12, [
|
| 301 |
+
(16, 22, "Data Sources", False),
|
| 302 |
+
(49, 22, "[Local]\nSelection", False),
|
| 303 |
+
(82, 26, "[Cloud VM]\nImage download", False),
|
| 304 |
+
(117, 22, "Resize + shard", False),
|
| 305 |
+
(151, 18, "Unified\nJSON", False),
|
| 306 |
], h=14)
|
| 307 |
ax.text(49, 2, "(download from PhysioNet -> push to Hugging Face)",
|
| 308 |
ha="center", fontsize=6.6)
|
docs/methodology_rewrite.md
CHANGED
|
@@ -1,14 +1,14 @@
|
|
| 1 |
# 3. Materials and Methods
|
| 2 |
|
| 3 |
-
This chapter describes how the proposed Vision–Language Model (
|
| 4 |
|
| 5 |
---
|
| 6 |
|
| 7 |
## 3.1 Overview
|
| 8 |
|
| 9 |
-
CXR-VLM is a single vision–language network that solves three downstream tasks —
|
| 10 |
|
| 11 |
-
- a frozen 14-pathology CheXpert-style classifier whose predictions are serialised into the prompt as a
|
| 12 |
- an optional contrastive Stage 1 that pre-aligns the projection in a joint image–text space without ever loading the language model.
|
| 13 |
|
| 14 |
The full forward path can be summarised as:
|
|
@@ -17,7 +17,7 @@ $$
|
|
| 17 |
\mathbf{x}_{518\times518} \xrightarrow{\text{RAD-DINO}} \mathbf{P} \in \mathbb{R}^{B\times 1369\times 768} \xrightarrow{\text{MLP-Proj}} \mathbf{V} \in \mathbb{R}^{B\times 32\times 4096} \xrightarrow{\text{Vicuna-7B + LoRA}} \hat{\mathbf{y}}
|
| 18 |
$$
|
| 19 |
|
| 20 |
-
Only the MLP projection, the LoRA adapters on Vicuna, and (when enabled) the ITC head are trained. The image encoder, the CheXpert classifier, and the Vicuna base weights are kept frozen. In total, fewer than
|
| 21 |
|
| 22 |

|
| 23 |
|
|
@@ -29,31 +29,31 @@ This section defines the core concepts that the rest of the chapter relies on.
|
|
| 29 |
|
| 30 |
- **Vision–Language Model (VLM).** A model that takes an image together with a text instruction and produces a text response, by mapping visual features into the embedding space of a language model.
|
| 31 |
|
| 32 |
-
- **Chest X-ray report.** A radiology report has two main free-text sections.
|
| 33 |
|
| 34 |
- **Visual Question Answering (VQA).** Given an image and a natural-language question, the model returns a short answer (often a single word or phrase).
|
| 35 |
|
| 36 |
-
- **Large Language Model (LLM).** A transformer trained on large text corpora to generate human-like text. We use
|
| 37 |
|
| 38 |
-
- **Vision Transformer (ViT) and self-supervision.** A ViT splits an image into fixed-size patches and processes them as a token sequence.
|
| 39 |
|
| 40 |
-
- **Parameter-efficient fine-tuning (LoRA / QLoRA).** Instead of updating all weights of the LLM,
|
| 41 |
|
| 42 |
- **Image–Text Contrastive learning (ITC / InfoNCE).** A training objective that pulls the embedding of an image and its matching text together while pushing non-matching pairs apart, using the symmetric InfoNCE loss. It is the mechanism behind Stage 1 alignment.
|
| 43 |
|
| 44 |
-
- **CheXpert labels and U-MultiClass.** CheXpert defines 14 pathology categories. Rather than a binary present/absent label,
|
| 45 |
|
| 46 |
---
|
| 47 |
|
| 48 |
## 3.3 Data Preparation
|
| 49 |
|
| 50 |
-
The data pipeline runs in three phases, each placed where it is most efficient.
|
| 51 |
|
| 52 |

|
| 53 |
|
| 54 |
-
### 3.3.1 Sources
|
| 55 |
|
| 56 |
-
All
|
| 57 |
|
| 58 |
| Source | Version | Used for |
|
| 59 |
|---|---|---|
|
|
@@ -61,50 +61,88 @@ All sources come from the public PhysioNet distribution under credentialed acces
|
|
| 61 |
| MIMIC-CXR-JPG | 2.1.0 | Pre-converted JPEG images |
|
| 62 |
| MIMIC-Ext-CXR-VQA | 1.0.0 | (image, question, answer) triples |
|
| 63 |
| `mimic-cxr-2.0.0-split` | — | Official patient-disjoint train/validate/test split |
|
| 64 |
-
| `mimic-cxr-2.0.0-metadata` | — | `ViewPosition` for frontal selection |
|
| 65 |
| `mimic-cxr-2.0.0-chexpert` | — | 14 pathology labels per study |
|
| 66 |
|
| 67 |
-
|
| 68 |
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
-
|
| 72 |
-
- **(b) Report parsing.** A strict regex accepts a section only if its header is exactly `FINDINGS` or `IMPRESSION`. Synonyms (`CONCLUSION`, `WET READ`, composite headers) are deliberately not merged. A study survives only if **both** sections are present and non-empty — required for `split_cascade`, where the impression prompt uses the ground-truth findings.
|
| 73 |
-
- **(c) Length-based outlier removal.** Per-section word counts are computed; studies above `Q3 + 1.5·IQR` (multi-paragraph teaching reports) or below a small floor are dropped. This trims the long tail without shifting the median.
|
| 74 |
-
- **(d) Stratified patient-disjoint sampling.** Each study is assigned a stratum equal to its rarest positive CheXpert label; the target counts are allocated per stratum proportionally to prevalence. Validation/test pools are filled first from the official split; any overflow is drawn from train and the affected subjects removed from train. The three sets are therefore **patient-disjoint**.
|
| 75 |
|
| 76 |
-
|
| 77 |
|
| 78 |
-
![Hình 3.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
### 3.3.3 Storage and Consumption Layout
|
| 81 |
|
| 82 |
For each split a manifest is emitted as JSON and CSV. Every row captures one image plus the standard PhysioNet identifiers, the 14 `chex_*` U-MultiClass label columns, and a `has_vqa` flag. The PhysioNet directory layout (`files/pXX/pSUBJ/sSTUDY/<dicom>.jpg`) is preserved so a partial download can be checked against the manifest by path lookup.
|
| 83 |
|
| 84 |
-
Because all training hosts consume the same images, we resize
|
| 85 |
|
| 86 |
-
### 3.3.4
|
| 87 |
|
| 88 |
-
On the training host, a single
|
| 89 |
|
| 90 |
```
|
| 91 |
{ image_path, task, target, question, structured_findings, split, study_id, subject_id }
|
| 92 |
```
|
| 93 |
|
| 94 |
-
|
| 95 |
|
| 96 |
-
-
|
| 97 |
-
-
|
|
|
|
| 98 |
|
| 99 |
-
|
| 100 |
-
|---|---|
|
| 101 |
-
| `split` | Two independent tasks; each sees only image + PNU string. |
|
| 102 |
-
| `split_cascade` | As above, but impression conditions on the GT findings string (findings → impression summarisation). |
|
| 103 |
-
| `merged` | One `report` task emitting both sections in a single pass. |
|
| 104 |
|
| 105 |
-
|
| 106 |
|
| 107 |
-
![Hình 3.
|
| 108 |
|
| 109 |
---
|
| 110 |
|
|
@@ -112,16 +150,16 @@ The 14 CheXpert labels are formatted into the PNU string and written into `struc
|
|
| 112 |
|
| 113 |
The model has four modules — image encoder, MLP projection, CheXpert classifier, and language model with LoRA — plus the prompt format that connects them. Each module is described below by its objective, inputs, outputs, and core logic.
|
| 114 |
|
| 115 |
-
![Hình 3.
|
| 116 |
|
| 117 |
### 3.4.1 Image Encoder
|
| 118 |
|
| 119 |
- **Objective.** Turn a chest X-ray into a dense grid of patch features for the projection.
|
| 120 |
- **Input.** A 518×518 image (RAD-DINO's native resolution, matching the offline resize target).
|
| 121 |
- **Output.** Patch features `P ∈ R^{1369×768}`; the `[CLS]` token is discarded.
|
| 122 |
-
- **Core logic.** The backbone is Microsoft's
|
| 123 |
|
| 124 |
-
![Hình 3.
|
| 125 |
|
| 126 |
### 3.4.2 MLP Projection
|
| 127 |
|
|
@@ -140,16 +178,16 @@ $$
|
|
| 140 |
\mathbf{V} = \mathbf{W}_2 \mathbf{H}^{(1)} \in \mathbb{R}^{32\times 4096}
|
| 141 |
$$
|
| 142 |
|
| 143 |
-
with `Q₀` a learnable parameter and `CrossAttn` an 8-head attention block.
|
| 144 |
|
| 145 |
-
![Hình 3.
|
| 146 |
|
| 147 |
### 3.4.3 CheXpert Abnormality Classifier
|
| 148 |
|
| 149 |
- **Objective.** Provide explicit abnormality cues to the prompt as a readable 3-class string instead of a logit vector.
|
| 150 |
- **Input.** The global `[CLS]` embedding of RAD-DINO.
|
| 151 |
- **Output.** A 14×3 logit tensor → one of {Positive, Negative, Uncertain} per pathology → the PNU string.
|
| 152 |
-
- **Core logic.** A small MLP head on the frozen `[CLS]` embedding,
|
| 153 |
|
| 154 |
```
|
| 155 |
Positive Abnormalities: Cardiomegaly, Pleural Effusion
|
|
@@ -157,18 +195,18 @@ Negative Abnormalities: No Finding, Edema, Pneumothorax, ...
|
|
| 157 |
Uncertain Abnormalities: Atelectasis
|
| 158 |
```
|
| 159 |
|
| 160 |
-
Three properties motivate this design. (1)
|
| 161 |
|
| 162 |
-
![Hình 3.
|
| 163 |
|
| 164 |
### 3.4.4 Language Model and Parameter-Efficient Adaptation
|
| 165 |
|
| 166 |
- **Objective.** Generate the findings / impression / answer text conditioned on visual tokens and the prompt.
|
| 167 |
- **Input.** The assembled token sequence with the `<image>` placeholder replaced by 32 visual tokens.
|
| 168 |
- **Output.** The autoregressive text response.
|
| 169 |
-
- **Core logic.** The decoder is
|
| 170 |
|
| 171 |
-
![
|
| 172 |
|
| 173 |
### 3.4.5 Prompt Assembly
|
| 174 |
|
|
@@ -181,25 +219,25 @@ All three tasks share one prompt skeleton, following Vicuna's v1.1 chat template
|
|
| 181 |
{instruction} ASSISTANT: {target}
|
| 182 |
```
|
| 183 |
|
| 184 |
-
The `<image>` placeholder is a special token (id 32000). At forward time the model finds this single token, replaces its embedding with the 32 visual tokens, and
|
| 185 |
|
| 186 |
-
The task-specific context block
|
| 187 |
|
| 188 |
-
- **Findings** — block empty;
|
| 189 |
-
- **Impression** — block is the literal `Findings: <
|
| 190 |
-
- **VQA** — block empty; the question becomes the instruction.
|
| 191 |
|
| 192 |
-
|
| 193 |
|
| 194 |
-
![Hình 3.
|
| 195 |
|
| 196 |
---
|
| 197 |
|
| 198 |
## 3.5 Training Strategy
|
| 199 |
|
| 200 |
-
Training
|
| 201 |
|
| 202 |
-
![Hình 3.
|
| 203 |
|
| 204 |
### 3.5.1 Stage 0 — CheXpert Classifier Head
|
| 205 |
|
|
@@ -210,7 +248,7 @@ The PNU classifier is fitted before Stages 1 and 2. It is a small MLP on the fro
|
|
| 210 |
The goal is to specialise the projection (and only the projection) so its visual tokens are linearly aligned with the text representation of the matching report, before any language modelling.
|
| 211 |
|
| 212 |
- **Image side.** The 32 intermediate 1024-d tokens are mean-pooled, projected to 128-d, and L2-normalised by the ITC head.
|
| 213 |
-
- **Text side.** The canonical reference sentence per study (findings, falling back to impression) is encoded
|
| 214 |
|
| 215 |
Stage 1 minimises the symmetric InfoNCE loss:
|
| 216 |
|
|
@@ -218,11 +256,11 @@ $$
|
|
| 218 |
\mathcal{L}_{\text{ITC}} = -\tfrac{1}{2}\Big[ \sum_{i}\log\frac{\exp(\mathbf{v}_i^\top\mathbf{t}_i/\tau)}{\sum_j \exp(\mathbf{v}_i^\top\mathbf{t}_j/\tau)} + \sum_{i}\log\frac{\exp(\mathbf{t}_i^\top\mathbf{v}_i/\tau)}{\sum_j \exp(\mathbf{t}_i^\top\mathbf{v}_j/\tau)} \Big]
|
| 219 |
$$
|
| 220 |
|
| 221 |
-
**Where:** `vᵢ` is the image embedding from projection + ITC head, `tᵢ` the cached text embedding for the same study, and `τ = 0.07` the temperature
|
| 222 |
|
| 223 |
-
The dataset is de-duplicated to one image per `study_id` (the text embedding is study-level). Crucially, Stage 1 loads the model with
|
| 224 |
|
| 225 |
-
![Hình 3.
|
| 226 |
|
| 227 |
### 3.5.3 Stage 2 — Instruction Tuning
|
| 228 |
|
|
@@ -238,15 +276,15 @@ Trainable parameters are the projection's MLP and the LoRA adapters; the encoder
|
|
| 238 |
|
| 239 |
### 3.5.4 Loss Masking and Image-Token Accounting
|
| 240 |
|
| 241 |
-
The bookkeeping around the `<image>` placeholder deserves explicit mention. The tokenised prompt contains exactly
|
| 242 |
|
| 243 |
-
![Hình 3.
|
| 244 |
|
| 245 |
---
|
| 246 |
|
| 247 |
## 3.6 Evaluation Protocol
|
| 248 |
|
| 249 |
-
Evaluation reflects the three downstream tasks. For findings and impression we report
|
| 250 |
|
| 251 |
| Family | Metric | Tasks |
|
| 252 |
|---|---|---|
|
|
@@ -256,26 +294,25 @@ Evaluation reflects the three downstream tasks. For findings and impression we r
|
|
| 256 |
| Semantic embedding | BERTScore F1 | findings, impression, VQA |
|
| 257 |
| Clinical accuracy | CheXbert macro-F1, P, R | findings, impression |
|
| 258 |
| Exact answer | Exact match, token F1 | VQA |
|
| 259 |
-
| Judge model | GPT-4o-mini score (0–5) | VQA (optional) |
|
| 260 |
|
| 261 |
### 3.6.1 Tasks and Held-Out Data
|
| 262 |
|
| 263 |
-
All metrics are computed on the patient-disjoint
|
| 264 |
|
| 265 |
-
![Hình 3.
|
| 266 |
|
| 267 |
### 3.6.2 Natural Language Generation Metrics
|
| 268 |
|
| 269 |
- **BLEU.** Corpus-level BLEU-1 and BLEU-4 with NLTK smoothing method 1. Reported for comparability with the RRG literature, but treated as a fluency floor — it correlates weakly with clinical correctness.
|
| 270 |
-
- **ROUGE.** ROUGE-1/2/L F-measures with Porter stemming. ROUGE-L is the most commonly reported single number
|
| 271 |
- **METEOR.** A weighted token-level F-measure that credits stems and WordNet synonyms (e.g. *cardiomegaly* ↔ *enlarged heart*), with a fragmentation penalty. Of the n-gram-style metrics it correlates best with human judgement on radiology.
|
| 272 |
- **BERTScore.** Greedy-aligned cosine similarity between contextual embeddings, aggregated to F1. Captures semantic equivalence the n-gram metrics miss, but has no notion of clinical correctness — it can reward a paraphrase that flips a finding's polarity.
|
| 273 |
|
| 274 |
### 3.6.3 Clinical Correctness: CheXbert F1
|
| 275 |
|
| 276 |
-
CheXbert is a BERT-based labeler mapping a free-text report onto the 14 CheXpert categories. Running it on both the generated and reference reports and comparing the label vectors gives a
|
| 277 |
|
| 278 |
-
![Hình 3.
|
| 279 |
|
| 280 |
### 3.6.4 Visual Question Answering Metrics
|
| 281 |
|
|
@@ -284,8 +321,7 @@ VQA targets are short, so the suite differs from generation.
|
|
| 284 |
- **Exact match.** Lower-cased, punctuation-stripped, whitespace-collapsed string equality — the lower bound on correctness; harsh on phrasing but rewards the closed-form yes/no and quantitative questions that dominate the dataset.
|
| 285 |
- **Token F1.** F1 between the bags of normalised tokens — the most diagnostic single number for short-answer correctness.
|
| 286 |
- **BLEU-1, METEOR, BERTScore.** Reported for symmetry with the generation tasks. BLEU-4 and ROUGE-L are omitted (rarely meaningful / subsumed by token F1).
|
| 287 |
-
- **LLM-as-judge (optional).** For free-form answers where the above misalign with clinical equivalence (e.g. *cardiomegaly* vs *enlarged cardiac silhouette*), GPT-4o-mini is prompted with the question, reference, and prediction and returns an integer 0–5 plus a rationale (G-Eval rubric). We report the mean and its [0, 1] normalisation. The judge runs at temperature 0 with `response_format=json_object`; at most 500 VQA items are sampled to bound cost.
|
| 288 |
|
| 289 |
### 3.6.5 Model Selection and Reporting
|
| 290 |
|
| 291 |
-
Model selection uses `eval_loss` on the validation split (the same causal cross-entropy minimised in training); the best checkpoint by this criterion is evaluated on the test split. We deliberately do
|
|
|
|
| 1 |
# 3. Materials and Methods
|
| 2 |
|
| 3 |
+
This chapter describes how the proposed Vision–Language Model (CXR-VLM) is built and trained. It is organised in six parts. Section 3.1 gives an end-to-end overview. Section 3.2 defines the key concepts used throughout the chapter. Section 3.3 documents how the MIMIC-CXR corpus is analysed, filtered, and serialised into the training format. Section 3.4 describes the four modules of the model and the prompt that ties them together. Section 3.5 specifies the two-stage training schedule. Section 3.6 defines the evaluation protocol.
|
| 4 |
|
| 5 |
---
|
| 6 |
|
| 7 |
## 3.1 Overview
|
| 8 |
|
| 9 |
+
CXR-VLM is a single vision–language network that solves three downstream tasks — findings generation, impression generation, and visual question answering (VQA) — through one shared backbone. It couples a frozen image encoder, a trainable projection, and a LoRA-adapted language model, with two design choices aimed at clinical reliability and at fitting a limited compute budget:
|
| 10 |
|
| 11 |
+
- a frozen 14-pathology CheXpert-style classifier whose predictions are serialised into the prompt as a Positive / Negative / Uncertain (PNU) string, and
|
| 12 |
- an optional contrastive Stage 1 that pre-aligns the projection in a joint image–text space without ever loading the language model.
|
| 13 |
|
| 14 |
The full forward path can be summarised as:
|
|
|
|
| 17 |
\mathbf{x}_{518\times518} \xrightarrow{\text{RAD-DINO}} \mathbf{P} \in \mathbb{R}^{B\times 1369\times 768} \xrightarrow{\text{MLP-Proj}} \mathbf{V} \in \mathbb{R}^{B\times 32\times 4096} \xrightarrow{\text{Vicuna-7B + LoRA}} \hat{\mathbf{y}}
|
| 18 |
$$
|
| 19 |
|
| 20 |
+
Only the MLP projection, the LoRA adapters on Vicuna, and (when enabled) the ITC head are trained. The image encoder, the CheXpert classifier, and the Vicuna base weights are kept frozen. In total, fewer than 0.3% of the parameters are trainable (≈ 21.5 M of ≈ 7.1 B).
|
| 21 |
|
| 22 |

|
| 23 |
|
|
|
|
| 29 |
|
| 30 |
- **Vision–Language Model (VLM).** A model that takes an image together with a text instruction and produces a text response, by mapping visual features into the embedding space of a language model.
|
| 31 |
|
| 32 |
+
- **Chest X-ray report.** A radiology report has two main free-text sections. Findings is the detailed, observation-by-observation description of the image; Impression is the short clinical summary written *after* the findings. We treat their generation as two separate tasks.
|
| 33 |
|
| 34 |
- **Visual Question Answering (VQA).** Given an image and a natural-language question, the model returns a short answer (often a single word or phrase).
|
| 35 |
|
| 36 |
+
- **Large Language Model (LLM).** A transformer trained on large text corpora to generate human-like text. We use Vicuna-7B, a LLaMA-derived instruction-tuned chat model, as the decoder.
|
| 37 |
|
| 38 |
+
- **Vision Transformer (ViT) and self-supervision.** A ViT splits an image into fixed-size patches and processes them as a token sequence. RAD-DINO is a ViT-B/14 trained with DINOv2 self-supervision on chest X-rays; it requires no text labels.
|
| 39 |
|
| 40 |
+
- **Parameter-efficient fine-tuning (LoRA / QLoRA).** Instead of updating all weights of the LLM, LoRA inserts small trainable low-rank matrices into selected layers and freezes the rest. QLoRA additionally keeps the base weights in 4-bit quantisation, which drastically reduces memory.
|
| 41 |
|
| 42 |
- **Image–Text Contrastive learning (ITC / InfoNCE).** A training objective that pulls the embedding of an image and its matching text together while pushing non-matching pairs apart, using the symmetric InfoNCE loss. It is the mechanism behind Stage 1 alignment.
|
| 43 |
|
| 44 |
+
- **CheXpert labels and U-MultiClass.** CheXpert defines 14 pathology categories. Rather than a binary present/absent label, U-MultiClass keeps three states per pathology — Positive, Negative, Uncertain — preserving the clinically important difference between a confident negative and a hedged one.
|
| 45 |
|
| 46 |
---
|
| 47 |
|
| 48 |
## 3.3 Data Preparation
|
| 49 |
|
| 50 |
+
The data pipeline runs in three phases, each placed where it is most efficient. (i) Selection runs locally on the MIMIC-CXR CSV metadata to decide which studies to keep. (ii) Image download runs on a cloud virtual machine (Google Colab): the images chosen in the manifest are downloaded from PhysioNet onto the VM's disk and then pushed to a Hugging Face repository for reuse. This step is done in the cloud rather than on a local machine because the selected image set is too large for the local disk, and the cloud VM also has a faster, more stable connection to PhysioNet. (iii) Resize and re-shard runs once on a GPU host so that every training run consumes minimal-size JPEGs. The same subset is reused across all experiments, so the pipeline is run once — changing prompt templates or task weights only rebuilds the JSON, not the images.
|
| 51 |
|
| 52 |

|
| 53 |
|
| 54 |
+
### 3.3.1 Sources and dataset overview
|
| 55 |
|
| 56 |
+
All data come from the public PhysioNet distribution under credentialed access; no manual annotation is performed.
|
| 57 |
|
| 58 |
| Source | Version | Used for |
|
| 59 |
|---|---|---|
|
|
|
|
| 61 |
| MIMIC-CXR-JPG | 2.1.0 | Pre-converted JPEG images |
|
| 62 |
| MIMIC-Ext-CXR-VQA | 1.0.0 | (image, question, answer) triples |
|
| 63 |
| `mimic-cxr-2.0.0-split` | — | Official patient-disjoint train/validate/test split |
|
| 64 |
+
| `mimic-cxr-2.0.0-metadata` | — | `ViewPosition` (used for frontal selection) |
|
| 65 |
| `mimic-cxr-2.0.0-chexpert` | — | 14 pathology labels per study |
|
| 66 |
|
| 67 |
+
An exploratory analysis of the full corpus motivates the design choices below. MIMIC-CXR contains 377,110 images from 227,835 studies of 65,379 patients (on average 1.66 images per study and 3.48 studies per patient).
|
| 68 |
|
| 69 |
+
- **Label imbalance (Hình 3.3).** The 14 CheXpert labels are highly imbalanced: *No Finding* (33% positive), *Support Devices* (29%), *Pleural Effusion* (24%), and *Lung Opacity* (23%) dominate, while *Fracture* (1.9%) and *Pleural Other* (0.9%) are rare — which directly affects the abnormality classifier in Section 4.2.2.
|
| 70 |
+
- **Views (Hình 3.4).** Each study can contain several views. Frontal projections — AP (147,173) and PA (96,161) — together make up the majority; the rest are lateral (LATERAL 82,853, LL 35,133) or have no recorded `ViewPosition` (15,769).
|
| 71 |
+
- **Images per study (Hình 3.5).** Many studies contain two or more images (mean 1.66, up to 11), even though they share a single report.
|
| 72 |
+
- **Report sections (Hình 3.6).** Parsing all reports, a clean Findings section is present in 149,060 studies (65%) and an Impression in 186,865 (82%); many reports therefore lack one section. Findings are longer (median ≈ 45 words) than Impression (median ≈ 16 words).
|
| 73 |
+
- **VQA (Hình 3.7).** MIMIC-Ext-CXR-VQA provides 377,391 (image, question, answer) triples over these images, organised by semantic type (verify / choose / query) and content type (presence, anatomy, attribute, size, …).
|
| 74 |
|
| 75 |
+

|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
+

|
| 78 |
|
| 79 |
+

|
| 80 |
+
|
| 81 |
+

|
| 82 |
+
|
| 83 |
+

|
| 84 |
+
|
| 85 |
+
### 3.3.2 Selecting the working subset
|
| 86 |
+
|
| 87 |
+
We do not train on the full corpus. With no dedicated GPU available, training is done in the cloud, where compute is billed by the hour; repeatedly processing all ≈ 227k studies (≈ 377k images) would be both slow and expensive. A subset of 50,000 studies (40,000 train / 5,000 validation / 5,000 test) is large enough to train and evaluate the model while keeping cloud time and cost manageable, and — as Table 3.1 shows — it preserves the pathology distribution of the full dataset, so results on it remain representative.
|
| 88 |
+
|
| 89 |
+
A four-stage filter chain produces this subset:
|
| 90 |
+
|
| 91 |
+
- **(a) One frontal image per study.** Each DICOM is joined with the metadata CSV and only frontal views (`ViewPosition ∈ {PA, AP}`) are kept; if a study has several, it is collapsed to one image, preferring PA over AP. We use a single frontal image per study for two reasons. First, the frontal projection carries the most diagnostic information, and PA is the standard reference view (AP is reserved for bedside/portable exams), so PA is preferred when both exist. Second, we deliberately avoid multi-view training: besides its higher per-study compute, it is noisy here — a study has a single report that does not state which finding belongs to which view, so pairing several images with one report can mislead the model. Multi-view training is left as future work (Section 5).
|
| 92 |
+
- **(b) Both report sections present.** A strict regex accepts a section only if its header is exactly `FINDINGS` or `IMPRESSION`; synonyms (`CONCLUSION`, `WET READ`, composite headers) are not merged. A study survives only if both sections are present and non-empty, because the impression task later conditions on the ground-truth findings (Section 3.4.5).
|
| 93 |
+
- **(c) Length-based outlier removal.** Per-section word counts are computed; studies above `Q3 + 1.5·IQR` (multi-paragraph teaching reports) or below a small floor are dropped, trimming the long tail without shifting the median.
|
| 94 |
+
- **(d) Stratified patient-disjoint sampling.** Each study is assigned a stratum equal to its rarest positive CheXpert label; target counts are allocated per stratum proportionally to prevalence. The validation/test pools are filled first from the official split, any overflow drawn from train with the affected subjects removed from train, so the three sets are patient-disjoint.
|
| 95 |
+
|
| 96 |
+
After steps (a)–(c) the eligible pool is 108,783 studies (from the 227,835 total), and step (d) samples the final 50,000.
|
| 97 |
+
|
| 98 |
+

|
| 99 |
+
|
| 100 |
+
**Distribution preserved after selection.** To confirm the subset is representative, Table 3.1 and Hình 3.9 compare the per-pathology positive rate of the full dataset, the eligible pool, and the final subset. The subset tracks the eligible pool almost exactly (largest gap 1.25 pp, on *No Finding*). The eligible pool itself differs from the raw corpus — mainly a higher *No Finding* rate and lower *Support Devices* / *Pleural Effusion* — which is expected, since requiring a clean Findings + Impression section and a frontal view removes many device-heavy ICU portables.
|
| 101 |
+
|
| 102 |
+
**Table 3.1.** CheXpert positive rate (%) at three stages of selection (largest 8 labels shown; |Δ| = subset − eligible).
|
| 103 |
+
|
| 104 |
+
| Pathology | Full (%) | Eligible (%) | Subset (%) | \|Δ\| |
|
| 105 |
+
|---|---|---|---|---|
|
| 106 |
+
| No Finding | 33.1 | 51.6 | 52.8 | 1.25 |
|
| 107 |
+
| Support Devices | 29.2 | 8.7 | 8.5 | 0.24 |
|
| 108 |
+
| Pleural Effusion | 23.8 | 12.3 | 11.6 | 0.65 |
|
| 109 |
+
| Lung Opacity | 22.6 | 14.9 | 14.5 | 0.34 |
|
| 110 |
+
| Atelectasis | 20.1 | 11.0 | 10.7 | 0.29 |
|
| 111 |
+
| Cardiomegaly | 19.7 | 6.6 | 6.3 | 0.30 |
|
| 112 |
+
| Edema | 11.9 | 8.0 | 7.6 | 0.46 |
|
| 113 |
+
| Pneumonia | 7.3 | 5.6 | 5.6 | 0.01 |
|
| 114 |
+
|
| 115 |
+

|
| 116 |
+
|
| 117 |
+
The VQA distribution is likewise preserved: Hình 3.10 shows that the shares of question semantic types, content types, and answer types in the subset closely match the full dataset, so the VQA evaluation on the subset is not biased towards any question category.
|
| 118 |
+
|
| 119 |
+

|
| 120 |
|
| 121 |
### 3.3.3 Storage and Consumption Layout
|
| 122 |
|
| 123 |
For each split a manifest is emitted as JSON and CSV. Every row captures one image plus the standard PhysioNet identifiers, the 14 `chex_*` U-MultiClass label columns, and a `has_vqa` flag. The PhysioNet directory layout (`files/pXX/pSUBJ/sSTUDY/<dicom>.jpg`) is preserved so a partial download can be checked against the manifest by path lookup.
|
| 124 |
|
| 125 |
+
Because all training hosts consume the same images, we resize once offline. RAD-DINO centre-crops to 518×518 regardless of input, so the full-resolution JPEGs (~2–3 MP each, ~100 GB) are wasteful. Each image is resized so the shortest edge is 518, saved as JPEG quality 90, and packed into ~2 GB tar shards; the 50k subset compresses to ~5–8 GB.
|
| 126 |
|
| 127 |
+
### 3.3.4 Training data format
|
| 128 |
|
| 129 |
+
On the training host, a single builder turns the resized images, the manifests, and the VQA files into one JSON file — one entry per training sample — with a fixed schema:
|
| 130 |
|
| 131 |
```
|
| 132 |
{ image_path, task, target, question, structured_findings, split, study_id, subject_id }
|
| 133 |
```
|
| 134 |
|
| 135 |
+
Each selected study (one frontal image) yields up to three kinds of sample:
|
| 136 |
|
| 137 |
+
- a findings sample — target is the Findings paragraph, generated from the image and the abnormality (PNU) string;
|
| 138 |
+
- an impression sample — target is the Impression, generated from the image plus the study's ground-truth Findings as context. The impression is thus produced as a short summary of the findings (a findings → impression cascade), which mirrors clinical practice, where the impression is written after the findings; this is also why step (b) requires a clean Findings section;
|
| 139 |
+
- **VQA** samples (when the study has associated questions) — target is the answer, with the question itself acting as the instruction.
|
| 140 |
|
| 141 |
+
The 14 CheXpert labels are formatted into the PNU string (Section 3.4.3) and written into the `structured_findings` field at build time, so the trainer loads no labels at runtime. For the impression sample, this same field instead carries the ground-truth Findings text.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
|
| 143 |
+
Because there is exactly one frontal image per study, the findings and impression tasks each contribute one sample per study (about 50,000 samples each). VQA, however, is larger — 127,010 question–answer samples in total (102,941 train / 12,189 validation / 11,880 test) — because a single image can be paired with several different questions in MIMIC-Ext-CXR-VQA.
|
| 144 |
|
| 145 |
+

|
| 146 |
|
| 147 |
---
|
| 148 |
|
|
|
|
| 150 |
|
| 151 |
The model has four modules — image encoder, MLP projection, CheXpert classifier, and language model with LoRA — plus the prompt format that connects them. Each module is described below by its objective, inputs, outputs, and core logic.
|
| 152 |
|
| 153 |
+

|
| 154 |
|
| 155 |
### 3.4.1 Image Encoder
|
| 156 |
|
| 157 |
- **Objective.** Turn a chest X-ray into a dense grid of patch features for the projection.
|
| 158 |
- **Input.** A 518×518 image (RAD-DINO's native resolution, matching the offline resize target).
|
| 159 |
- **Output.** Patch features `P ∈ R^{1369×768}`; the `[CLS]` token is discarded.
|
| 160 |
+
- **Core logic.** The backbone is Microsoft's RAD-DINO (ViT-B/14), self-supervised on ≈ 840k chest X-rays. It is loaded from the HuggingFace hub and kept entirely frozen. RAD-DINO is chosen because it ships as a standard HuggingFace model, is trained on a large amount of chest-X-ray data, and produces a patch grid dense enough to capture both global pathology and small focal abnormalities. Freezing is chosen for stability and memory: with the LLM held in 4-bit, gradient flow through an 86 M-parameter ViT would exhaust the activation budget of the GPUs available for this work.
|
| 161 |
|
| 162 |
+
![Hình 3.13. Kiến trúc RAD-DINO (ViT-B/14): ảnh 518×518 → 1369 patch → patch + position embedding → 12 khối Transformer Encoder (frozen) → [CLS] + patch token (768-d).](figures/fig05_patchify.png)
|
| 163 |
|
| 164 |
### 3.4.2 MLP Projection
|
| 165 |
|
|
|
|
| 178 |
\mathbf{V} = \mathbf{W}_2 \mathbf{H}^{(1)} \in \mathbb{R}^{32\times 4096}
|
| 179 |
$$
|
| 180 |
|
| 181 |
+
with `Q₀` a learnable parameter and `CrossAttn` an 8-head attention block. We use 32 visual tokens: enough to retain spatial detail on small pathologies without inflating the LLM's sequence length. The 1024-d intermediate `H⁽¹⁾` is the grounding signal of the ITC head; it sits *after* the GELU so the contrastive objective sees an already-nonlinear representation.
|
| 182 |
|
| 183 |
+

|
| 184 |
|
| 185 |
### 3.4.3 CheXpert Abnormality Classifier
|
| 186 |
|
| 187 |
- **Objective.** Provide explicit abnormality cues to the prompt as a readable 3-class string instead of a logit vector.
|
| 188 |
- **Input.** The global `[CLS]` embedding of RAD-DINO.
|
| 189 |
- **Output.** A 14×3 logit tensor → one of {Positive, Negative, Uncertain} per pathology → the PNU string.
|
| 190 |
+
- **Core logic.** A small MLP head on the frozen `[CLS]` embedding, predicting three states (positive / negative / uncertain) per pathology. The string injected into the prompt looks like:
|
| 191 |
|
| 192 |
```
|
| 193 |
Positive Abnormalities: Cardiomegaly, Pleural Effusion
|
|
|
|
| 195 |
Uncertain Abnormalities: Atelectasis
|
| 196 |
```
|
| 197 |
|
| 198 |
+
Three properties motivate this design. (1) The three-state form preserves the negative-vs-uncertain distinction, which binary CheXpert mappings destroy. (2) Expressing labels as text needs no architectural change when labels are missing — the field simply becomes empty and the prompt degrades gracefully. (3) Placing the PNU string between the visual tokens and the instruction lets self-attention route freely between text and image. The classifier is trained separately in Stage 0 and then frozen. During VLM training the ground-truth CSV labels populate the PNU string (oracle setting); at evaluation the classifier predicts its own PNU.
|
| 199 |
|
| 200 |
+
![Hình 3.15. Từ embedding [CLS] của RAD-DINO tới PNU string đưa vào prompt.](figures/fig07_chexpert_pnu.png)
|
| 201 |
|
| 202 |
### 3.4.4 Language Model and Parameter-Efficient Adaptation
|
| 203 |
|
| 204 |
- **Objective.** Generate the findings / impression / answer text conditioned on visual tokens and the prompt.
|
| 205 |
- **Input.** The assembled token sequence with the `<image>` placeholder replaced by 32 visual tokens.
|
| 206 |
- **Output.** The autoregressive text response.
|
| 207 |
+
- **Core logic.** The decoder is Vicuna-7B v1.3, chosen for its clean `USER: … ASSISTANT: …` chat template (which simplifies label masking) and for offering strong instruction-following at a 7B size that fits the available hardware budget. It is loaded in 4-bit NF4 quantisation (double-quant, compute dtype BF16 on Ampere+ / FP16 on Turing), bringing the resident footprint from ≈ 14 GB down to ≈ 4 GB. Adaptation uses LoRA: rank-16 adapters on the four attention projections (`q_proj`, `k_proj`, `v_proj`, `o_proj`) of every block; the feed-forward sublayers are left untouched. With `lora_alpha = 32` and `lora_dropout = 0.05`, the effective scaling is `α/r = 2`.
|
| 208 |
|
| 209 |
+

|
| 210 |
|
| 211 |
### 3.4.5 Prompt Assembly
|
| 212 |
|
|
|
|
| 219 |
{instruction} ASSISTANT: {target}
|
| 220 |
```
|
| 221 |
|
| 222 |
+
The `<image>` placeholder is a special token (id 32000). At forward time the model finds this single token, replaces its embedding with the 32 visual tokens, and expands the attention mask, position ids, and label tensor by 31 positions so the causal mask stays consistent. Visual-token positions in the label tensor are set to −100 so they are excluded from the loss.
|
| 223 |
|
| 224 |
+
The task-specific context block differs per task:
|
| 225 |
|
| 226 |
+
- **Findings** — block empty; the findings are produced from the image plus the PNU labels.
|
| 227 |
+
- **Impression** — block is the literal `Findings: <ground-truth findings>`; the model conditions on the findings and summarises them.
|
| 228 |
+
- **VQA** — block empty; the question itself becomes the instruction.
|
| 229 |
|
| 230 |
+
Findings and impression each have ten hand-written instruction paraphrases, sampled at training time; at evaluation the first variant is used deterministically. A sample is tokenised with `cutoff_len = 512` and right-truncation (the response sits at the right end; left-truncation would destroy the system prompt and PNU block). The label tensor is masked with −100 on every prompt, padding, and visual token, so loss is computed strictly on the assistant response.
|
| 231 |
|
| 232 |
+

|
| 233 |
|
| 234 |
---
|
| 235 |
|
| 236 |
## 3.5 Training Strategy
|
| 237 |
|
| 238 |
+
Training uses a two-stage curriculum. Stage 1 first aligns the projection with the report text through explicit image–text contrastive learning; Stage 2 then instruction-tunes the projection together with the LLM's LoRA adapters. The split follows a representation-then-instruction logic: it is wasteful to drive the LoRA adapters while the projection still emits ill-conditioned visual tokens, and the projection cannot be trained efficiently against the language-modelling loss without paying for a full Vicuna forward at every step. A Stage 0 classifier training precedes both.
|
| 239 |
|
| 240 |
+

|
| 241 |
|
| 242 |
### 3.5.1 Stage 0 — CheXpert Classifier Head
|
| 243 |
|
|
|
|
| 248 |
The goal is to specialise the projection (and only the projection) so its visual tokens are linearly aligned with the text representation of the matching report, before any language modelling.
|
| 249 |
|
| 250 |
- **Image side.** The 32 intermediate 1024-d tokens are mean-pooled, projected to 128-d, and L2-normalised by the ITC head.
|
| 251 |
+
- **Text side.** The canonical reference sentence per study (findings, falling back to impression) is encoded once, offline with `microsoft/BiomedVLP-CXR-BERT-specialized` into a 128-d L2-normalised vector. These are cached as `{study_id → tensor[128]}` and published to the data repo so any host can pull them in seconds.
|
| 252 |
|
| 253 |
Stage 1 minimises the symmetric InfoNCE loss:
|
| 254 |
|
|
|
|
| 256 |
\mathcal{L}_{\text{ITC}} = -\tfrac{1}{2}\Big[ \sum_{i}\log\frac{\exp(\mathbf{v}_i^\top\mathbf{t}_i/\tau)}{\sum_j \exp(\mathbf{v}_i^\top\mathbf{t}_j/\tau)} + \sum_{i}\log\frac{\exp(\mathbf{t}_i^\top\mathbf{v}_i/\tau)}{\sum_j \exp(\mathbf{t}_i^\top\mathbf{v}_j/\tau)} \Big]
|
| 257 |
$$
|
| 258 |
|
| 259 |
+
**Where:** `vᵢ` is the image embedding from projection + ITC head, `tᵢ` the cached text embedding for the same study, and `τ = 0.07` the temperature.
|
| 260 |
|
| 261 |
+
The dataset is de-duplicated to one image per `study_id` (the text embedding is study-level). Crucially, Stage 1 loads the model with `load_llm = False` — Vicuna is simply not instantiated. Freeing the ≈ 13 GB of Vicuna weights lifts the per-device batch from 8 (Stage 2 budget) to 64–96, which directly enlarges the InfoNCE negative pool. Stage 1 runs for 2 epochs at peak LR `1e-3` with a 5% cosine warm-up; the saved checkpoint is the projection-only state dict (the ITC head is discarded, as it has no role at generation time).
|
| 262 |
|
| 263 |
+

|
| 264 |
|
| 265 |
### 3.5.3 Stage 2 — Instruction Tuning
|
| 266 |
|
|
|
|
| 276 |
|
| 277 |
### 3.5.4 Loss Masking and Image-Token Accounting
|
| 278 |
|
| 279 |
+
The bookkeeping around the `<image>` placeholder deserves explicit mention. The tokenised prompt contains exactly one `<image>` token, replaced by 32 visual tokens at forward time. To keep the attention mask, position ids, and labels consistent, the forward pass expands all three by 31 entries at the placeholder: each visual-token mask entry is set to 1, position ids are made contiguous, and the visual span in the labels is filled with −100. The same expansion is applied at inference. This is the most error-prone part of the pipeline — an off-by-one silently shifts the labels and produces a degenerate loss curve — so an integration test asserts that the count of non-−100 label entries is preserved before and after expansion.
|
| 280 |
|
| 281 |
+

|
| 282 |
|
| 283 |
---
|
| 284 |
|
| 285 |
## 3.6 Evaluation Protocol
|
| 286 |
|
| 287 |
+
Evaluation reflects the three downstream tasks. For findings and impression we report NLG metrics (lexical, fluency, semantic) plus the clinical-accuracy metric standard in chest-X-ray report generation. For VQA we report a short-answer suite.
|
| 288 |
|
| 289 |
| Family | Metric | Tasks |
|
| 290 |
|---|---|---|
|
|
|
|
| 294 |
| Semantic embedding | BERTScore F1 | findings, impression, VQA |
|
| 295 |
| Clinical accuracy | CheXbert macro-F1, P, R | findings, impression |
|
| 296 |
| Exact answer | Exact match, token F1 | VQA |
|
|
|
|
| 297 |
|
| 298 |
### 3.6.1 Tasks and Held-Out Data
|
| 299 |
|
| 300 |
+
All metrics are computed on the patient-disjoint test split (5,000 studies). Inference uses greedy decoding (`do_sample=False`, `num_beams=1`) and the canonical instruction variant (index 0). For findings and impression the model receives the test image plus the PNU string predicted by the frozen classifier; the impression prompt additionally receives the ground-truth findings, matching the training setup. For VQA the question replaces the instruction slot. Maximum new tokens: 300 (findings), 200 (impression), 64 (VQA), matched to the 99th-percentile reference length.
|
| 301 |
|
| 302 |
+

|
| 303 |
|
| 304 |
### 3.6.2 Natural Language Generation Metrics
|
| 305 |
|
| 306 |
- **BLEU.** Corpus-level BLEU-1 and BLEU-4 with NLTK smoothing method 1. Reported for comparability with the RRG literature, but treated as a fluency floor — it correlates weakly with clinical correctness.
|
| 307 |
+
- **ROUGE.** ROUGE-1/2/L F-measures with Porter stemming. ROUGE-L is the most commonly reported single number for this task.
|
| 308 |
- **METEOR.** A weighted token-level F-measure that credits stems and WordNet synonyms (e.g. *cardiomegaly* ↔ *enlarged heart*), with a fragmentation penalty. Of the n-gram-style metrics it correlates best with human judgement on radiology.
|
| 309 |
- **BERTScore.** Greedy-aligned cosine similarity between contextual embeddings, aggregated to F1. Captures semantic equivalence the n-gram metrics miss, but has no notion of clinical correctness — it can reward a paraphrase that flips a finding's polarity.
|
| 310 |
|
| 311 |
### 3.6.3 Clinical Correctness: CheXbert F1
|
| 312 |
|
| 313 |
+
CheXbert is a BERT-based labeler mapping a free-text report onto the 14 CheXpert categories. Running it on both the generated and reference reports and comparing the label vectors gives a factual correctness measure invariant to paraphrasing. This Clinical F1 is the de-facto standard clinical-accuracy metric for chest-X-ray report generation. We report macro-averaged F1, precision, and recall over the 14 pathologies, binarising the labeler's {−1, 0, 1} output by collapsing −1 and 0. When the CheXbert weights are unavailable on a host, the metric degrades gracefully to 0.0 with a warning rather than failing the run.
|
| 314 |
|
| 315 |
+

|
| 316 |
|
| 317 |
### 3.6.4 Visual Question Answering Metrics
|
| 318 |
|
|
|
|
| 321 |
- **Exact match.** Lower-cased, punctuation-stripped, whitespace-collapsed string equality — the lower bound on correctness; harsh on phrasing but rewards the closed-form yes/no and quantitative questions that dominate the dataset.
|
| 322 |
- **Token F1.** F1 between the bags of normalised tokens — the most diagnostic single number for short-answer correctness.
|
| 323 |
- **BLEU-1, METEOR, BERTScore.** Reported for symmetry with the generation tasks. BLEU-4 and ROUGE-L are omitted (rarely meaningful / subsumed by token F1).
|
|
|
|
| 324 |
|
| 325 |
### 3.6.5 Model Selection and Reporting
|
| 326 |
|
| 327 |
+
Model selection uses `eval_loss` on the validation split (the same causal cross-entropy minimised in training); the best checkpoint by this criterion is evaluated on the test split. We deliberately do not select on downstream metrics, to avoid the optimistic bias of optimising the reported signal — and CheXbert F1 in particular is expensive and unstable on small validation samples. Each run writes per-task predictions to `results/{run_id}/predictions_{task}.json` and an aggregated summary to `results/{run_id}/metrics_summary.json`.
|
docs/report_front.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Acknowledgements
|
| 2 |
+
|
| 3 |
+
This thesis would not have been possible without the help of many people. My deepest thanks go to my supervisor, [Supervisor name], whose questions, patience, and detailed feedback shaped both the direction of this project and the way I approached it. I am grateful to the lecturers of the Department of Information and Communication Technology at the University of Science and Technology of Hanoi for the foundation they gave me over the past years. Finally, I thank my family and friends, who kept me motivated through the long nights of training runs and debugging that this work demanded.
|
| 4 |
+
|
| 5 |
+
> *(Điền tên giảng viên hướng dẫn vào `[Supervisor name]`; thêm internal/external supervisor nếu cần.)*
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
# Abstract
|
| 10 |
+
|
| 11 |
+
This thesis presents CXR-VLM, a vision–language model for chest X-ray interpretation that performs findings generation, impression generation, and visual question answering with a single shared backbone. The model couples a frozen RAD-DINO image encoder with a trainable projection and a Vicuna-7B language model adapted through low-rank (LoRA) adapters, and is explicitly guided by a 14-pathology Positive / Negative / Uncertain (PNU) abnormality signal produced by a CheXpert-style classifier. It is trained with a parameter-efficient two-stage schedule — an image–text contrastive alignment followed by instruction tuning — on a 50,000-study subset of MIMIC-CXR, chosen so that training remains feasible on a limited cloud-compute budget while preserving the pathology distribution of the full dataset. On the held-out test split the model is competitive with established systems on semantic report-generation metrics (ROUGE-L 0.292 and a high METEOR and BERTScore for findings), reaches a visual-question-answering exact match of 0.306 (stronger on closed-ended questions), and its abnormality classifier attains a macro F1 of 0.328. These results show that a frozen-encoder, frozen-LLM design adapted only through a small projection and LoRA adapters can drive three chest-X-ray tasks at once on modest hardware, and point to abnormality classification and clinical-efficacy evaluation as the main directions for further improvement.
|
| 12 |
+
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
# List of Abbreviations
|
| 16 |
+
|
| 17 |
+
| Abbreviation | Meaning |
|
| 18 |
+
|---|---|
|
| 19 |
+
| AI | Artificial Intelligence |
|
| 20 |
+
| AP | Anteroposterior (X-ray view) |
|
| 21 |
+
| BERTScore | BERT-based similarity Score |
|
| 22 |
+
| BLEU | Bilingual Evaluation Understudy |
|
| 23 |
+
| CE | Clinical Efficacy (F1) |
|
| 24 |
+
| CheXbert | BERT-based CheXpert report labeler |
|
| 25 |
+
| CheXpert | Chest eXpert — 14-label chest X-ray labeler |
|
| 26 |
+
| CXR | Chest X-Ray |
|
| 27 |
+
| DINO | self-Distillation with NO labels (DINOv2 self-supervision) |
|
| 28 |
+
| EHR | Electronic Health Record |
|
| 29 |
+
| F1 | F1-score (harmonic mean of precision and recall) |
|
| 30 |
+
| GPU | Graphics Processing Unit |
|
| 31 |
+
| InfoNCE | Info Noise-Contrastive Estimation (loss) |
|
| 32 |
+
| IQR | Interquartile Range |
|
| 33 |
+
| ITC | Image–Text Contrastive (learning) |
|
| 34 |
+
| LLM | Large Language Model |
|
| 35 |
+
| LoRA | Low-Rank Adaptation |
|
| 36 |
+
| METEOR | Metric for Evaluation of Translation with Explicit ORdering |
|
| 37 |
+
| MIMIC-CXR | Medical Information Mart for Intensive Care — Chest X-Ray |
|
| 38 |
+
| MLP | Multi-Layer Perceptron |
|
| 39 |
+
| NF4 | 4-bit NormalFloat quantisation |
|
| 40 |
+
| NLG | Natural Language Generation |
|
| 41 |
+
| PA | Posteroanterior (X-ray view) |
|
| 42 |
+
| PNU | Positive / Negative / Uncertain |
|
| 43 |
+
| QLoRA | Quantised Low-Rank Adaptation |
|
| 44 |
+
| RAD-DINO | Chest-X-ray ViT image encoder (DINOv2-based) |
|
| 45 |
+
| ROUGE | Recall-Oriented Understudy for Gisting Evaluation |
|
| 46 |
+
| RRG | Radiology Report Generation |
|
| 47 |
+
| ViT | Vision Transformer |
|
| 48 |
+
| VLM | Vision–Language Model |
|
| 49 |
+
| VQA | Visual Question Answering |
|
| 50 |
+
|
| 51 |
+
---
|
docs/report_part1.md
CHANGED
|
@@ -2,15 +2,15 @@
|
|
| 2 |
|
| 3 |
## 1.1 Context and Motivation
|
| 4 |
|
| 5 |
-
The chest X-ray (CXR) is the most frequently performed medical imaging examination in the world, with hundreds of millions of studies acquired every year for the screening and diagnosis of cardiothoracic conditions. Each examination must be read by a radiologist, who then writes a structured free-text report — typically a detailed
|
| 6 |
|
| 7 |
-
Automatic
|
| 8 |
|
| 9 |
-
A recurring difficulty is that fluent text is not necessarily *clinically correct*: a model can produce a well-formed report that misses or flips a finding.
|
| 10 |
|
| 11 |
## 1.2 Internship objectives
|
| 12 |
|
| 13 |
-
The objective of this internship is to design, implement, and evaluate
|
| 14 |
|
| 15 |
- **Data pipeline.** Build a reproducible pipeline that filters the MIMIC-CXR corpus and serialises it into a unified instruction format covering findings, impression, and VQA samples.
|
| 16 |
- **Abnormality-guided prompting.** Train a CheXpert-style classifier that emits a PNU abnormality string, and inject it into the language-model prompt as clinical guidance.
|
|
@@ -32,14 +32,16 @@ The remainder of this report is organised as follows:
|
|
| 32 |
|
| 33 |
## 2.1 Medical report generation
|
| 34 |
|
| 35 |
-
Early radiology report generation systems adapted natural-image captioning architectures, pairing a CNN image encoder with an RNN/LSTM decoder. These models produced fluent sentences but struggled with the long, multi-sentence structure of radiology reports and with rare but clinically important findings. The introduction of
|
| 36 |
|
| 37 |
-
More recently, the field has shifted to
|
| 38 |
|
| 39 |
-
|
| 40 |
|
| 41 |
## 2.2 Visual question answering
|
| 42 |
|
| 43 |
-
Visual question answering combines image understanding with natural-language reasoning to answer a question about an image. In the medical domain,
|
| 44 |
|
| 45 |
-
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
## 1.1 Context and Motivation
|
| 4 |
|
| 5 |
+
The chest X-ray (CXR) is the most frequently performed medical imaging examination in the world, with hundreds of millions of studies acquired every year for the screening and diagnosis of cardiothoracic conditions. Each examination must be read by a radiologist, who then writes a structured free-text report — typically a detailed Findings section followed by a concise Impression. As imaging volume keeps growing while the number of trained radiologists remains limited, report turnaround time, reader fatigue, and inter-reader variability have become real bottlenecks in clinical workflows.
|
| 6 |
|
| 7 |
+
Automatic radiology report generation (RRG) aims to ease this burden by drafting a report directly from the image, which the radiologist can then verify and edit. The field has evolved quickly: from early CNN–RNN captioning models, to transformer-based architectures, and most recently to large vision–language models (VLMs) such as LLaVA and BLIP-2, whose medical adaptations (RaDialog, LLaVA-Med, CheXagent, MAIRA-2) couple a frozen image encoder with a large language model (LLM) to produce fluent, instruction-following text. In parallel, clinicians often need to ask focused questions about an image — "is there a pleural effusion?", "what is the size of the cardiac silhouette?" — which motivates visual question answering (VQA) on chest X-rays.
|
| 8 |
|
| 9 |
+
A recurring difficulty is that fluent text is not necessarily *clinically correct*: a model can produce a well-formed report that misses or flips a finding. A natural way to mitigate this is to feed the language model explicit pathology labels, so that generation stays anchored to detected abnormalities rather than to language priors alone. Motivated by this idea, this thesis builds a single VLM that performs findings generation, impression generation, and VQA, guided by an explicit 14-pathology Positive / Negative / Uncertain (PNU) signal.
|
| 10 |
|
| 11 |
## 1.2 Internship objectives
|
| 12 |
|
| 13 |
+
The objective of this internship is to design, implement, and evaluate CXR-VLM, a unified vision–language model for chest X-ray interpretation built on a shared RAD-DINO + projection + Vicuna-7B (LoRA) backbone. Concretely, the work targets the following goals:
|
| 14 |
|
| 15 |
- **Data pipeline.** Build a reproducible pipeline that filters the MIMIC-CXR corpus and serialises it into a unified instruction format covering findings, impression, and VQA samples.
|
| 16 |
- **Abnormality-guided prompting.** Train a CheXpert-style classifier that emits a PNU abnormality string, and inject it into the language-model prompt as clinical guidance.
|
|
|
|
| 32 |
|
| 33 |
## 2.1 Medical report generation
|
| 34 |
|
| 35 |
+
Early radiology report generation systems adapted natural-image captioning architectures, pairing a CNN image encoder with an RNN/LSTM decoder. These models produced fluent sentences but struggled with the long, multi-sentence structure of radiology reports and with rare but clinically important findings. The introduction of R2Gen (Chen et al., 2020) replaced the recurrent decoder with a memory-driven transformer that records report patterns across studies, and R2GenCMN (Chen et al., 2021) added a shared cross-modal memory to better align visual and textual features; both became standard baselines on the MIMIC-CXR benchmark. A parallel line of work injected medical prior knowledge through knowledge graphs (e.g. PPKED, KGAE) or retrieval, and emphasised clinical-efficacy metrics — comparing the pathology labels extracted from generated and reference reports with the CheXbert labeler — rather than n-gram overlap alone.
|
| 36 |
|
| 37 |
+
More recently, the field has shifted to LLM-based generators. RaDialog (Pellegrini et al., 2023) instruction-tunes a Vicuna LLM on chest X-rays, conditions it on a structured list of CheXpert findings, and adapts it with low-rank (LoRA) adapters, enabling both report generation and conversational interaction. Related foundation-style efforts include CheXagent, MAIRA-2, LLaVA-Med, and XrayGPT. To improve clinical grounding, META-CXR introduces a *U-MultiClass* formulation that preserves three states per pathology — positive, negative, and uncertain — instead of collapsing them to a binary present/absent label.
|
| 38 |
|
| 39 |
+
Taken together, these advances — abnormality-aware generation, LLM-based decoding, and parameter-efficient adaptation — motivate the approach taken in this thesis: a single vision–language model that generates findings and impression and answers questions about a chest X-ray, explicitly guided by detected abnormalities. The components and training of this model are described in Section 3.
|
| 40 |
|
| 41 |
## 2.2 Visual question answering
|
| 42 |
|
| 43 |
+
Visual question answering combines image understanding with natural-language reasoning to answer a question about an image. In the medical domain, general benchmarks span radiology and pathology: VQA-RAD (Lau et al., *Scientific Data*, 2018) and SLAKE (Liu et al., *IEEE ISBI*, 2021) cover radiology images of several modalities and body regions — including, but not limited to, the chest — while PathVQA targets pathology microscopy rather than radiographs. Early methods on these benchmarks fuse a CNN image embedding with a question embedding and classify over a fixed answer vocabulary, often with co-attention or meta-learning of the visual features (e.g. MEVF, *MICCAI* 2019; MMQ, *MICCAI* 2021).
|
| 44 |
|
| 45 |
+
Dedicated chest-X-ray VQA resources are more recent. Medical-Diff-VQA / MIMIC-Diff-VQA (Hu et al., *ACM KDD*, 2023) poses difference questions over pairs of MIMIC-CXR images; MIMIC-CXR-VQA, introduced together with EHRXQA (Bae et al., *NeurIPS Datasets & Benchmarks*, 2023), pairs chest X-rays with structured electronic-health-record question answering; and the large-scale MIMIC-Ext-CXR-VQA used in this work organises questions by semantic type (verify, choose, query) and content type (presence, anatomy, attribute, size, and others).
|
| 46 |
+
|
| 47 |
+
Compared with chest-X-ray report generation, dedicated VQA for chest X-rays is a younger and less standardised area: most existing work contributes datasets or specialised settings (difference questions, EHR-linked QA) and still treats VQA as a standalone closed-set classification problem. This motivates the approach taken here: rather than building a separate VQA classifier, VQA is handled by the same encoder, projection, and language model used for report generation, with the question taking the place of the instruction and the same PNU abnormality guidance supplied in the prompt. A single set of weights therefore serves findings, impression, and VQA, and the abnormality signal benefits short-answer questions as well as long-form generation.
|
docs/report_part4_5.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
| 6 |
|
| 7 |
### 4.1.1 Dataset and configuration
|
| 8 |
|
| 9 |
-
All experiments use the
|
| 10 |
|
| 11 |
### 4.1.2 Implementation and hyperparameters
|
| 12 |
|
|
@@ -34,9 +34,9 @@ The model is implemented in PyTorch with HuggingFace `transformers`, `peft` (LoR
|
|
| 34 |
|
| 35 |
### 4.1.3 Evaluation protocol
|
| 36 |
|
| 37 |
-
Metrics follow Section 3.6. Generation uses greedy decoding with the canonical instruction variant; the maximum number of new tokens is 300 (findings), 200 (impression), and 64 (VQA). For report generation we report BLEU-1/4, ROUGE-1/2/L, METEOR, and BERTScore-F1; for VQA we report exact match, token-F1, micro-F1, BLEU-1, METEOR, and BERTScore-F1.
|
| 38 |
|
| 39 |
-
> **Note on cross-paper comparison.** The baseline numbers in Tables 4.2 and 4.5 are quoted as compiled in the META-CXR paper (Edirisinghe et al., 2025, Tables 2–3), which is the closest prior work to ours. NLG scores (BLEU, METEOR, ROUGE, BERTScore) depend on the tokeniser, smoothing, and the specific BERTScore backbone/baseline, so absolute values are only
|
| 40 |
|
| 41 |
## 4.2 Results
|
| 42 |
|
|
@@ -68,7 +68,7 @@ The full breakdown for both report tasks is given in Table 4.3. Impression score
|
|
| 68 |
|
| 69 |
### 4.2.2 Clinical correctness: abnormality classification
|
| 70 |
|
| 71 |
-
Clinical correctness is measured by the CheXpert abnormality classifier (Stage 0), which predicts the 14 pathologies — with an explicit *uncertain* state — directly from the image. This is the same role that META-CXR's MHCAC classifier plays. Table 4.4 gives our per-pathology results. Performance is strongest on common, visually salient categories (No Finding F1 0.785, Pleural Effusion 0.622, Support Devices 0.552, Edema 0.508) and collapses to zero on rare categories with few positives (Enlarged Cardiomediastinum, Pleural Other, Fracture), reflecting the strong class imbalance of MIMIC-CXR. The macro averages over the 14 labels are
|
| 72 |
|
| 73 |
**Table 4.4.** CheXpert classifier per-pathology results (test split).
|
| 74 |
|
|
@@ -90,7 +90,7 @@ Clinical correctness is measured by the CheXpert abnormality classifier (Stage 0
|
|
| 90 |
| Support Devices | 0.445 | 0.727 | 0.552 |
|
| 91 |
| **Macro average (14)** | **0.319** | **0.377** | **0.328** |
|
| 92 |
|
| 93 |
-
Table 4.5 places this in context. Two families of "clinical" F1 appear in the literature: (i) a
|
| 94 |
|
| 95 |
**Table 4.5.** Clinical / abnormality F1 in context (MIMIC-CXR test; values as compiled in Edirisinghe et al., 2025, Tables 2–3). †image-level classifier; ‡report-derived CheXbert CE F1.
|
| 96 |
|
|
@@ -109,7 +109,7 @@ For cross-domain reference, META-CXR reports a mean F1 of 0.699 on the CheXpert
|
|
| 109 |
|
| 110 |
### 4.2.3 Visual question answering
|
| 111 |
|
| 112 |
-
Table 4.6 gives the overall VQA results on the 11,880-item test set. Tables 4.7 and 4.8 break the score down by answer type and by question semantic/content type. The model is clearly stronger on
|
| 113 |
|
| 114 |
**Table 4.6.** Overall VQA results (test, 11,880 items).
|
| 115 |
|
|
@@ -148,24 +148,26 @@ Table 4.6 gives the overall VQA results on the 11,880-item test set. Tables 4.7
|
|
| 148 |
|
| 149 |
**Report generation.** On findings, CXR-VLM is competitive with established systems on the recall/semantics metrics (ROUGE-L 0.292, strong METEOR) while trailing on BLEU-4 (0.083), where it sits close to RaDialog (0.095). This is the expected profile of an instruction-tuned LLM that rephrases findings rather than reproducing reference n-grams, so BLEU acts as a fluency floor rather than a quality measure. We deliberately do not claim a BLEU/BERTScore state-of-the-art, because those metrics are sensitive to tokenisation and to the BERTScore backbone and are only approximately comparable across papers.
|
| 150 |
|
| 151 |
-
**Impression weakness.** Impression scores are much lower than findings on every metric, even though
|
| 152 |
|
| 153 |
**Clinical correctness.** The Stage-0 CheXpert classifier — our clinical metric — reaches macro-F1 0.328 over 14 labels (≈ 0.42 over the 11 labels it can predict). It is well below META-CXR's dedicated multi-encoder MHCAC classifier (weighted F1 0.73), but comparable to the report-derived clinical-efficacy F1 of recent generation systems (0.31–0.43). The gap to META-CXR is consistent with their heavier design (three fused encoders CNN+ViT+Swin, expert-token cross-attention, class-balanced and contrastive losses) versus our single-encoder MLP head. Because the test-time PNU string is produced by this classifier (`pnu_source = predicted`), its errors propagate into the prompt, so improving it is the highest-leverage next step. Its weakness on rare classes (zero F1 on Enlarged Cardiomediastinum, Pleural Other, Fracture) is a direct symptom of class imbalance.
|
| 154 |
|
| 155 |
**VQA.** The model handles closed-ended verification and presence questions far better than open-ended query and attribute questions, mirroring the general difficulty ordering in medical VQA. The strong results on size and plane questions suggest the visual tokens retain coarse geometric information well.
|
| 156 |
|
| 157 |
-
**
|
|
|
|
|
|
|
| 158 |
|
| 159 |
---
|
| 160 |
|
| 161 |
# 5. Conclusion and future work
|
| 162 |
|
| 163 |
-
This thesis presented
|
| 164 |
|
| 165 |
Several directions would strengthen the work:
|
| 166 |
|
| 167 |
- **Stronger abnormality classifier.** The clearest lever: adopt a META-CXR-style design (multi-encoder fusion, class-balancing or focal loss, contrastive/uncertainty objectives) so the predicted PNU string is more reliable and propagates fewer errors into generation and VQA.
|
| 168 |
-
- **Report-level clinical efficacy.** Run the CheXbert labeler on generated reports to report the standard CE F1 and enable a like-for-like comparison with the literature,
|
| 169 |
- **Impression generation.** Investigate and close the gap on impression (dedicated decoding budget, task-specific tuning, or a true end-to-end findings→impression cascade).
|
| 170 |
- **Scale and backbones.** Train on more data and views (multi-image studies) and evaluate stronger LLMs (e.g. Llama-3).
|
| 171 |
- **Cross-dataset validation.** Evaluate on IU X-ray and CheXpert to measure generalisation beyond MIMIC-CXR.
|
|
@@ -191,3 +193,7 @@ Several directions would strengthen the work:
|
|
| 191 |
15. A. E. W. Johnson et al. *MIMIC-CXR-JPG, a Large Publicly Available Database of Labeled Chest Radiographs.* arXiv:1901.07042, 2019.
|
| 192 |
16. T. Zhang, V. Kishore, F. Wu, K. Q. Weinberger, Y. Artzi. *BERTScore: Evaluating Text Generation with BERT.* ICLR, 2020.
|
| 193 |
17. S. Banerjee, A. Lavie. *METEOR: An Automatic Metric for MT Evaluation with Improved Correlation with Human Judgments.* ACL Workshop, 2005.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
### 4.1.1 Dataset and configuration
|
| 8 |
|
| 9 |
+
All experiments use the 50,000-study subset described in Section 3.3: 40,000 train / 5,000 validation / 5,000 test, patient-disjoint, one frontal image per study, with the impression task conditioned on the ground-truth findings. Findings and impression targets come from the MIMIC-CXR reports; VQA samples come from MIMIC-Ext-CXR-VQA, attached by study. The reported run is evaluated on the held-out test split with predicted PNU guidance — the abnormality string comes from the frozen CheXpert classifier rather than from ground-truth labels, which is the realistic inference setting. The VQA data contain 127,010 question–answer items in total (102,941 train / 12,189 validation / 11,880 test); there are far more VQA samples than studies because a single image can be paired with several questions, whereas the findings and impression tasks contribute one sample per study. All metrics below are on the 11,880-item test set.
|
| 10 |
|
| 11 |
### 4.1.2 Implementation and hyperparameters
|
| 12 |
|
|
|
|
| 34 |
|
| 35 |
### 4.1.3 Evaluation protocol
|
| 36 |
|
| 37 |
+
Metrics follow Section 3.6. Generation uses greedy decoding with the canonical instruction variant; the maximum number of new tokens is 300 (findings), 200 (impression), and 64 (VQA). For report generation we report BLEU-1/4, ROUGE-1/2/L, METEOR, and BERTScore-F1; for VQA we report exact match, token-F1, micro-F1, BLEU-1, METEOR, and BERTScore-F1. Clinical correctness is assessed through the dedicated CheXpert abnormality classifier (Section 4.2.2), which predicts the 14 pathologies directly from the image; the alternative report-level CheXbert-F1 (which re-labels the *generated text*) was not available on the evaluation host and is therefore not reported.
|
| 38 |
|
| 39 |
+
> **Note on cross-paper comparison.** The baseline numbers in Tables 4.2 and 4.5 are quoted as compiled in the META-CXR paper (Edirisinghe et al., 2025, Tables 2–3), which is the closest prior work to ours. NLG scores (BLEU, METEOR, ROUGE, BERTScore) depend on the tokeniser, smoothing, and the specific BERTScore backbone/baseline, so absolute values are only approximately comparable across papers; the ordering matters more than the exact digits. In particular our BERTScore-F1 (DistilBERT, no baseline rescaling) is not comparable to META-CXR's rescaled BERTScore of 0.426 and is therefore omitted from the comparison table.
|
| 40 |
|
| 41 |
## 4.2 Results
|
| 42 |
|
|
|
|
| 68 |
|
| 69 |
### 4.2.2 Clinical correctness: abnormality classification
|
| 70 |
|
| 71 |
+
Clinical correctness is measured by the CheXpert abnormality classifier (Stage 0), which predicts the 14 pathologies — with an explicit *uncertain* state — directly from the image. This is the same role that META-CXR's MHCAC classifier plays. Table 4.4 gives our per-pathology results. Performance is strongest on common, visually salient categories (No Finding F1 0.785, Pleural Effusion 0.622, Support Devices 0.552, Edema 0.508) and collapses to zero on rare categories with few positives (Enlarged Cardiomediastinum, Pleural Other, Fracture), reflecting the strong class imbalance of MIMIC-CXR. The macro averages over the 14 labels are precision 0.319, recall 0.377, F1 0.328; over the 11 labels the model actually predicts (excluding the three degenerate classes) the macro-F1 rises to about 0.42.
|
| 72 |
|
| 73 |
**Table 4.4.** CheXpert classifier per-pathology results (test split).
|
| 74 |
|
|
|
|
| 90 |
| Support Devices | 0.445 | 0.727 | 0.552 |
|
| 91 |
| **Macro average (14)** | **0.319** | **0.377** | **0.328** |
|
| 92 |
|
| 93 |
+
Table 4.5 places this in context. Two families of "clinical" F1 appear in the literature: (i) a dedicated image classifier F1, as in META-CXR's MHCAC, and (ii) a report-derived Clinical-Efficacy (CE) F1, obtained by running the CheXbert labeler on the generated report and comparing label vectors. Our classifier (macro-F1 0.328, ≈ 0.42 on predictable classes) is well below META-CXR's multi-encoder MHCAC classifier (weighted F1 0.73), but lands in the same range as the report-derived CE F1 of recent generation systems (0.31–0.43). These quantities are not strictly comparable (image-level vs report-derived; macro vs weighted averaging), so Table 4.5 is a positioning reference, not a like-for-like ranking.
|
| 94 |
|
| 95 |
**Table 4.5.** Clinical / abnormality F1 in context (MIMIC-CXR test; values as compiled in Edirisinghe et al., 2025, Tables 2–3). †image-level classifier; ‡report-derived CheXbert CE F1.
|
| 96 |
|
|
|
|
| 109 |
|
| 110 |
### 4.2.3 Visual question answering
|
| 111 |
|
| 112 |
+
Table 4.6 gives the overall VQA results on the 11,880-item test set. Tables 4.7 and 4.8 break the score down by answer type and by question semantic/content type. The model is clearly stronger on closed-ended questions (exact match 0.385) than on open-ended ones (0.204), and within content types it does best on size and plane questions and worst on anatomy and attribute questions.
|
| 113 |
|
| 114 |
**Table 4.6.** Overall VQA results (test, 11,880 items).
|
| 115 |
|
|
|
|
| 148 |
|
| 149 |
**Report generation.** On findings, CXR-VLM is competitive with established systems on the recall/semantics metrics (ROUGE-L 0.292, strong METEOR) while trailing on BLEU-4 (0.083), where it sits close to RaDialog (0.095). This is the expected profile of an instruction-tuned LLM that rephrases findings rather than reproducing reference n-grams, so BLEU acts as a fluency floor rather than a quality measure. We deliberately do not claim a BLEU/BERTScore state-of-the-art, because those metrics are sensitive to tokenisation and to the BERTScore backbone and are only approximately comparable across papers.
|
| 150 |
|
| 151 |
+
**Impression weakness.** Impression scores are much lower than findings on every metric, even though the impression prompt is given the ground-truth findings and should make the task easier. Likely factors: impressions are short and abstractive (harsh for exact-overlap metrics), the impression task gets a smaller training share, and the model may carry findings-style phrasing into the impression. This is the clearest area for improvement (Section 5).
|
| 152 |
|
| 153 |
**Clinical correctness.** The Stage-0 CheXpert classifier — our clinical metric — reaches macro-F1 0.328 over 14 labels (≈ 0.42 over the 11 labels it can predict). It is well below META-CXR's dedicated multi-encoder MHCAC classifier (weighted F1 0.73), but comparable to the report-derived clinical-efficacy F1 of recent generation systems (0.31–0.43). The gap to META-CXR is consistent with their heavier design (three fused encoders CNN+ViT+Swin, expert-token cross-attention, class-balanced and contrastive losses) versus our single-encoder MLP head. Because the test-time PNU string is produced by this classifier (`pnu_source = predicted`), its errors propagate into the prompt, so improving it is the highest-leverage next step. Its weakness on rare classes (zero F1 on Enlarged Cardiomediastinum, Pleural Other, Fracture) is a direct symptom of class imbalance.
|
| 154 |
|
| 155 |
**VQA.** The model handles closed-ended verification and presence questions far better than open-ended query and attribute questions, mirroring the general difficulty ordering in medical VQA. The strong results on size and plane questions suggest the visual tokens retain coarse geometric information well.
|
| 156 |
|
| 157 |
+
**Comparison with prior work.** The same profile appears in recent LLM-based chest-X-ray systems: META-CXR, for instance, also reports a modest BLEU-4 (0.102) while leading on METEOR and BERTScore, which matches our pattern of low n-gram overlap but competitive semantic scores. Our abnormality classifier (macro F1 0.328) is clearly weaker than META-CXR's (weighted F1 0.73), but that system reaches its score with a much heavier design — three fused encoders (CNN, ViT, Swin) plus several auxiliary losses — and its authors themselves note the resulting inference-time complexity as a drawback. Our single-encoder MLP head trades accuracy for a far smaller and simpler model, consistent with the limited-compute goal of this thesis.
|
| 158 |
+
|
| 159 |
+
**Limitations.** Several limitations qualify these results. (i) Evaluation uses a single corpus (MIMIC-CXR) from one institution, so generalisation to other scanners and patient populations is untested. (ii) The report-level CheXbert clinical-efficacy F1 — the metric that best reflects diagnostic usefulness — was not computed for this run. (iii) The PNU guidance comes from an imbalanced, only moderate classifier, so its errors propagate into generation and VQA. (iv) Like any LLM-based generator, the model can hallucinate fluent but unsupported statements, which lexical metrics do not penalise. (v) Impression generation clearly underperforms findings. (vi) Compute constraints fix a small effective batch, a 7B LLM, and a single-view, single-image setup, with no multi-view or longitudinal (prior-study) context. Cross-paper metric comparison is also only approximate, given differing tokenisers and BERTScore backbones.
|
| 160 |
|
| 161 |
---
|
| 162 |
|
| 163 |
# 5. Conclusion and future work
|
| 164 |
|
| 165 |
+
This thesis presented CXR-VLM, a unified vision–language model that performs chest-X-ray findings generation, impression generation, and visual question answering with a single RAD-DINO + projection + Vicuna-7B (LoRA) backbone, guided by an explicit Positive/Negative/Uncertain abnormality signal and trained with a parameter-efficient two-stage schedule. On the MIMIC-CXR test split the model is competitive with established systems on semantic report-generation metrics (ROUGE-L 0.292, strong METEOR), its abnormality classifier reaches macro-F1 0.328 (≈ 0.42 on predictable classes) — in the range of report-derived clinical-efficacy F1 of recent work — and it achieves a VQA exact match of 0.306, markedly stronger on closed-ended questions. These results show that a frozen-encoder, frozen-LLM design adapted only through a small projection and LoRA adapters can drive three chest-X-ray tasks at once on modest hardware.
|
| 166 |
|
| 167 |
Several directions would strengthen the work:
|
| 168 |
|
| 169 |
- **Stronger abnormality classifier.** The clearest lever: adopt a META-CXR-style design (multi-encoder fusion, class-balancing or focal loss, contrastive/uncertainty objectives) so the predicted PNU string is more reliable and propagates fewer errors into generation and VQA.
|
| 170 |
+
- **Report-level clinical efficacy.** Run the CheXbert labeler on generated reports to report the standard CE F1 and enable a like-for-like comparison with the literature, complemented by expert radiologist assessment.
|
| 171 |
- **Impression generation.** Investigate and close the gap on impression (dedicated decoding budget, task-specific tuning, or a true end-to-end findings→impression cascade).
|
| 172 |
- **Scale and backbones.** Train on more data and views (multi-image studies) and evaluate stronger LLMs (e.g. Llama-3).
|
| 173 |
- **Cross-dataset validation.** Evaluate on IU X-ray and CheXpert to measure generalisation beyond MIMIC-CXR.
|
|
|
|
| 193 |
15. A. E. W. Johnson et al. *MIMIC-CXR-JPG, a Large Publicly Available Database of Labeled Chest Radiographs.* arXiv:1901.07042, 2019.
|
| 194 |
16. T. Zhang, V. Kishore, F. Wu, K. Q. Weinberger, Y. Artzi. *BERTScore: Evaluating Text Generation with BERT.* ICLR, 2020.
|
| 195 |
17. S. Banerjee, A. Lavie. *METEOR: An Automatic Metric for MT Evaluation with Improved Correlation with Human Judgments.* ACL Workshop, 2005.
|
| 196 |
+
18. J. J. Lau, S. Gayen, A. Ben Abacha, D. Demner-Fushman. *A Dataset of Clinically Generated Visual Questions and Answers about Radiology Images (VQA-RAD).* Scientific Data, 2018.
|
| 197 |
+
19. B. Liu, L.-M. Zhan, L. Xu, L. Ma, Y. Yang, X.-M. Wu. *SLAKE: A Semantically-Labeled Knowledge-Enhanced Dataset for Medical Visual Question Answering.* IEEE ISBI, 2021.
|
| 198 |
+
20. X. Hu et al. *Expert Knowledge-Aware Image Difference Graph Representation Learning for Difference-Aware Medical Visual Question Answering (Medical-Diff-VQA / MIMIC-Diff-VQA).* ACM SIGKDD (KDD), 2023.
|
| 199 |
+
21. S. Bae et al. *EHRXQA: A Multi-Modal Question Answering Dataset for Electronic Health Records with Chest X-ray Images (incl. MIMIC-CXR-VQA).* NeurIPS Datasets & Benchmarks, 2023.
|
scripts/cxrvlm_colab_inference.ipynb
CHANGED
|
@@ -745,6 +745,251 @@
|
|
| 745 |
"- For deterministic outputs keep `DO_SAMPLE=False`. For diversity, set `DO_SAMPLE=True, TEMPERATURE=0.7`.\n",
|
| 746 |
"- `predict_report()` is the realistic end-to-end pipeline (no GT leakage). `predict(task='report')` only works on runs trained with `report_mode='merged'`.\n"
|
| 747 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 748 |
}
|
| 749 |
],
|
| 750 |
"metadata": {
|
|
|
|
| 745 |
"- For deterministic outputs keep `DO_SAMPLE=False`. For diversity, set `DO_SAMPLE=True, TEMPERATURE=0.7`.\n",
|
| 746 |
"- `predict_report()` is the realistic end-to-end pipeline (no GT leakage). `predict(task='report')` only works on runs trained with `report_mode='merged'`.\n"
|
| 747 |
]
|
| 748 |
+
},
|
| 749 |
+
{
|
| 750 |
+
"cell_type": "markdown",
|
| 751 |
+
"id": "qual-md",
|
| 752 |
+
"metadata": {},
|
| 753 |
+
"source": [
|
| 754 |
+
"## 10. Qualitative eval theo nhãn bệnh (GT cạnh prediction)\n",
|
| 755 |
+
"\n",
|
| 756 |
+
"Với **mỗi nhãn bệnh** lấy random `QUAL_N_PER_CLASS` mẫu test (study dương tính nhãn đó), rồi:\n",
|
| 757 |
+
"- Tạo folder `QUAL_OUT/<Tên_bệnh>/<study>/` chứa: ảnh, **GT report** (`report_gt.txt`),\n",
|
| 758 |
+
" **GT VQA** (`vqa_gt.json`), và **so sánh** GT↔prediction (`comparison.md`).\n",
|
| 759 |
+
"- Chạy inference (cascade findings→impression + trả lời các câu VQA của ảnh đó).\n",
|
| 760 |
+
"- Hiển thị inline: ảnh + GT report vs PRED, GT VQA vs PRED, và **GT PNU** (nhãn CheXpert).\n",
|
| 761 |
+
"\n",
|
| 762 |
+
"> Cần tải `manifest`, `vqa`, và **tar shards** của MIMIC-CXR_resized từ HF (shards ~vài GB —\n",
|
| 763 |
+
"> phần lâu nhất; chỉ tải 1 lần, lần sau cache lại). Nếu chỉ muốn xem nhanh, giảm\n",
|
| 764 |
+
"> `QUAL_N_PER_CLASS` hoặc rút gọn `QUAL_PATHOLOGIES`."
|
| 765 |
+
]
|
| 766 |
+
},
|
| 767 |
+
{
|
| 768 |
+
"cell_type": "code",
|
| 769 |
+
"execution_count": null,
|
| 770 |
+
"id": "qual-cfg",
|
| 771 |
+
"metadata": {},
|
| 772 |
+
"outputs": [],
|
| 773 |
+
"source": [
|
| 774 |
+
"# ===== CONFIG qualitative eval =====\n",
|
| 775 |
+
"QUAL_SPLIT = 'test' # 'test' | 'val' | 'train'\n",
|
| 776 |
+
"QUAL_N_PER_CLASS = 3 # số mẫu random / nhãn\n",
|
| 777 |
+
"QUAL_VQA_PER_IMG = 3 # số câu VQA tối đa hỏi mỗi ảnh (None = tất cả)\n",
|
| 778 |
+
"QUAL_USE_ORACLE_PNU = False # True: ĐƯA GT PNU vào model làm structured_findings (oracle)\n",
|
| 779 |
+
"QUAL_SEED = 42\n",
|
| 780 |
+
"QUAL_PATHOLOGIES = None # None = đủ 14 nhãn; hoặc list con, vd ['Cardiomegaly','Edema']\n",
|
| 781 |
+
"\n",
|
| 782 |
+
"QUAL_DATA_REPO = f'{HF_USER}/cxr-vlm-data'\n",
|
| 783 |
+
"QUAL_WORK = WORK / 'qual_data' # cache tải/giải nén\n",
|
| 784 |
+
"QUAL_OUT = WORK / 'qual_by_pathology' # folder output để duyệt\n",
|
| 785 |
+
"print('QUAL config ok |', QUAL_SPLIT, '| n/class =', QUAL_N_PER_CLASS,\n",
|
| 786 |
+
" '| oracle PNU =', QUAL_USE_ORACLE_PNU)"
|
| 787 |
+
]
|
| 788 |
+
},
|
| 789 |
+
{
|
| 790 |
+
"cell_type": "markdown",
|
| 791 |
+
"id": "qual-pick-md",
|
| 792 |
+
"metadata": {},
|
| 793 |
+
"source": [
|
| 794 |
+
"### 10a. Tải manifest + VQA, chọn mẫu random theo nhãn"
|
| 795 |
+
]
|
| 796 |
+
},
|
| 797 |
+
{
|
| 798 |
+
"cell_type": "code",
|
| 799 |
+
"execution_count": null,
|
| 800 |
+
"id": "qual-pick",
|
| 801 |
+
"metadata": {},
|
| 802 |
+
"outputs": [],
|
| 803 |
+
"source": [
|
| 804 |
+
"import csv, json, random, tarfile, shutil\n",
|
| 805 |
+
"from pathlib import Path\n",
|
| 806 |
+
"from huggingface_hub import snapshot_download\n",
|
| 807 |
+
"from data.mimic_cxr_resized_builder import _row_to_pnu # GT CheXpert -> PNU string\n",
|
| 808 |
+
"from data.mimic_cxr_builder import _parse_report # report .txt -> (findings, impression)\n",
|
| 809 |
+
"from model.chexpert_classifier import PATHOLOGIES\n",
|
| 810 |
+
"\n",
|
| 811 |
+
"def _norm(s): return str(s).replace('\\\\', '/').lstrip('/')\n",
|
| 812 |
+
"def _safe(s): return s.replace(' ', '_')\n",
|
| 813 |
+
"\n",
|
| 814 |
+
"_MANI = {'test':'manifest_test.csv','val':'manifest_val.csv',\n",
|
| 815 |
+
" 'validate':'manifest_val.csv','train':'manifest_train.csv'}[QUAL_SPLIT]\n",
|
| 816 |
+
"_VQA = {'test':'vqa_test.json','val':'vqa_val.json',\n",
|
| 817 |
+
" 'validate':'vqa_val.json','train':'vqa.json'}[QUAL_SPLIT]\n",
|
| 818 |
+
"\n",
|
| 819 |
+
"# 1) manifest + vqa (nhẹ)\n",
|
| 820 |
+
"snapshot_download(repo_id=QUAL_DATA_REPO, repo_type='dataset', token=os.environ['HF_TOKEN'],\n",
|
| 821 |
+
" local_dir=str(QUAL_WORK),\n",
|
| 822 |
+
" allow_patterns=[f'MIMIC-CXR_resized/{_MANI}', f'MIMIC-CXR_resized/vqa/{_VQA}'])\n",
|
| 823 |
+
"_mr = QUAL_WORK / 'MIMIC-CXR_resized'\n",
|
| 824 |
+
"manifest = _mr / _MANI\n",
|
| 825 |
+
"vqa_path = _mr / 'vqa' / _VQA\n",
|
| 826 |
+
"\n",
|
| 827 |
+
"# 1 row / study (giữ row đầu)\n",
|
| 828 |
+
"rows_by_study = {}\n",
|
| 829 |
+
"with open(manifest, encoding='utf-8', newline='') as f:\n",
|
| 830 |
+
" for row in csv.DictReader(f):\n",
|
| 831 |
+
" rows_by_study.setdefault(str(row['study_id']).strip(), row)\n",
|
| 832 |
+
"print(f'{len(rows_by_study):,} studies trong manifest')\n",
|
| 833 |
+
"\n",
|
| 834 |
+
"# VQA gom theo dicom (image_id)\n",
|
| 835 |
+
"vqa_by_dicom = {}\n",
|
| 836 |
+
"if vqa_path.is_file():\n",
|
| 837 |
+
" for v in json.load(open(vqa_path, encoding='utf-8')):\n",
|
| 838 |
+
" vqa_by_dicom.setdefault(str(v.get('image_id')).strip(), []).append(v)\n",
|
| 839 |
+
"print(f'VQA: {sum(len(v) for v in vqa_by_dicom.values()):,} câu / {len(vqa_by_dicom):,} ảnh')\n",
|
| 840 |
+
"\n",
|
| 841 |
+
"# 2) chọn random N study dương tính / nhãn\n",
|
| 842 |
+
"random.seed(QUAL_SEED)\n",
|
| 843 |
+
"target_labels = QUAL_PATHOLOGIES or PATHOLOGIES\n",
|
| 844 |
+
"picks = {}\n",
|
| 845 |
+
"print('\\nNhãn #positive picked')\n",
|
| 846 |
+
"for lab in target_labels:\n",
|
| 847 |
+
" col = f'chex_{lab}'\n",
|
| 848 |
+
" pos = [r for r in rows_by_study.values()\n",
|
| 849 |
+
" if str(r.get(col, '')).strip() in ('1', '1.0')]\n",
|
| 850 |
+
" random.shuffle(pos)\n",
|
| 851 |
+
" picks[lab] = pos[:QUAL_N_PER_CLASS]\n",
|
| 852 |
+
" print(f' {lab:28s} {len(pos):6d} {len(picks[lab])}')"
|
| 853 |
+
]
|
| 854 |
+
},
|
| 855 |
+
{
|
| 856 |
+
"cell_type": "markdown",
|
| 857 |
+
"id": "qual-extract-md",
|
| 858 |
+
"metadata": {},
|
| 859 |
+
"source": [
|
| 860 |
+
"### 10b. Tải shards + rút ảnh/report của các mẫu đã chọn"
|
| 861 |
+
]
|
| 862 |
+
},
|
| 863 |
+
{
|
| 864 |
+
"cell_type": "code",
|
| 865 |
+
"execution_count": null,
|
| 866 |
+
"id": "qual-extract",
|
| 867 |
+
"metadata": {},
|
| 868 |
+
"outputs": [],
|
| 869 |
+
"source": [
|
| 870 |
+
"# files cần rút (ảnh + report của các study đã chọn)\n",
|
| 871 |
+
"need = set()\n",
|
| 872 |
+
"for rs in picks.values():\n",
|
| 873 |
+
" for r in rs:\n",
|
| 874 |
+
" need.add(_norm(r['image_relpath']))\n",
|
| 875 |
+
" if r.get('report_relpath'):\n",
|
| 876 |
+
" need.add(_norm(r['report_relpath']))\n",
|
| 877 |
+
"print(f'cần rút {len(need)} file (ảnh + report)')\n",
|
| 878 |
+
"\n",
|
| 879 |
+
"# tải toàn bộ shards (ảnh nằm rải) — lâu nhất, cache lại lần sau\n",
|
| 880 |
+
"print('Tải tar shards (vài GB)…')\n",
|
| 881 |
+
"snapshot_download(repo_id=QUAL_DATA_REPO, repo_type='dataset', token=os.environ['HF_TOKEN'],\n",
|
| 882 |
+
" local_dir=str(QUAL_WORK), allow_patterns=['MIMIC-CXR_resized/shards/*.tar'])\n",
|
| 883 |
+
"shards = sorted((_mr / 'shards').glob('*.tar'))\n",
|
| 884 |
+
"print(f'{len(shards)} shards')\n",
|
| 885 |
+
"\n",
|
| 886 |
+
"# rút đúng các file cần (1 pass)\n",
|
| 887 |
+
"extract_root = QUAL_WORK / 'extracted'\n",
|
| 888 |
+
"extracted = {}\n",
|
| 889 |
+
"for shard in shards:\n",
|
| 890 |
+
" with tarfile.open(shard, 'r') as tf:\n",
|
| 891 |
+
" for m in tf:\n",
|
| 892 |
+
" if not m.isfile(): continue\n",
|
| 893 |
+
" name = _norm(m.name)\n",
|
| 894 |
+
" if name in need and name not in extracted:\n",
|
| 895 |
+
" dst = extract_root / name\n",
|
| 896 |
+
" dst.parent.mkdir(parents=True, exist_ok=True)\n",
|
| 897 |
+
" dst.write_bytes(tf.extractfile(m).read())\n",
|
| 898 |
+
" extracted[name] = dst\n",
|
| 899 |
+
"print(f'rút được {len(extracted)} / {len(need)} file')"
|
| 900 |
+
]
|
| 901 |
+
},
|
| 902 |
+
{
|
| 903 |
+
"cell_type": "markdown",
|
| 904 |
+
"id": "qual-run-md",
|
| 905 |
+
"metadata": {},
|
| 906 |
+
"source": [
|
| 907 |
+
"### 10c. Inference + hiển thị GT↔PRED + lưu folder"
|
| 908 |
+
]
|
| 909 |
+
},
|
| 910 |
+
{
|
| 911 |
+
"cell_type": "code",
|
| 912 |
+
"execution_count": null,
|
| 913 |
+
"id": "qual-run",
|
| 914 |
+
"metadata": {},
|
| 915 |
+
"outputs": [],
|
| 916 |
+
"source": [
|
| 917 |
+
"def _fmt_answer(a):\n",
|
| 918 |
+
" return ', '.join(map(str, a)) if isinstance(a, list) else str(a)\n",
|
| 919 |
+
"\n",
|
| 920 |
+
"def _cmp_block(lab, study, gt_pnu, gt_find, gt_impr, pred, vqa_rows, oracle_used):\n",
|
| 921 |
+
" L = [f'PATHOLOGY (picked for): {lab}', f'STUDY: {study}',\n",
|
| 922 |
+
" f'oracle PNU fed to model: {oracle_used}', '',\n",
|
| 923 |
+
" '### GT CheXpert labels (PNU)', gt_pnu or '(none)', '',\n",
|
| 924 |
+
" '### FINDINGS',\n",
|
| 925 |
+
" f'[GT] {gt_find or \"(none)\"}',\n",
|
| 926 |
+
" f'[PRED] {pred[\"findings\"]}', '',\n",
|
| 927 |
+
" '### IMPRESSION',\n",
|
| 928 |
+
" f'[GT] {gt_impr or \"(none)\"}',\n",
|
| 929 |
+
" f'[PRED] {pred[\"impression\"]}']\n",
|
| 930 |
+
" if vqa_rows:\n",
|
| 931 |
+
" L += ['', '### VQA']\n",
|
| 932 |
+
" for i, (q, a_gt, a_pred) in enumerate(vqa_rows, 1):\n",
|
| 933 |
+
" L += [f'Q{i}: {q}', f' [GT] {a_gt}', f' [PRED] {a_pred}']\n",
|
| 934 |
+
" return '\\n'.join(L)\n",
|
| 935 |
+
"\n",
|
| 936 |
+
"QUAL_OUT = Path(QUAL_OUT)\n",
|
| 937 |
+
"index, n_done, n_skip = [], 0, 0\n",
|
| 938 |
+
"for lab, rs in picks.items():\n",
|
| 939 |
+
" for r in rs:\n",
|
| 940 |
+
" img_rel = _norm(r['image_relpath'])\n",
|
| 941 |
+
" img_p = extracted.get(img_rel)\n",
|
| 942 |
+
" if img_p is None:\n",
|
| 943 |
+
" n_skip += 1; continue\n",
|
| 944 |
+
" study = str(r.get('study_id'))\n",
|
| 945 |
+
" dicom = Path(img_rel).stem\n",
|
| 946 |
+
"\n",
|
| 947 |
+
" gt_pnu = _row_to_pnu(r)\n",
|
| 948 |
+
" rep_p = extracted.get(_norm(r.get('report_relpath', '')))\n",
|
| 949 |
+
" gt_find, gt_impr = _parse_report(rep_p) if rep_p else (None, None)\n",
|
| 950 |
+
"\n",
|
| 951 |
+
" oracle = gt_pnu if QUAL_USE_ORACLE_PNU else None\n",
|
| 952 |
+
" pred = predict_report(img_p, structured_findings=oracle)\n",
|
| 953 |
+
"\n",
|
| 954 |
+
" vqas = vqa_by_dicom.get(dicom, [])\n",
|
| 955 |
+
" if QUAL_VQA_PER_IMG is not None:\n",
|
| 956 |
+
" vqas = vqas[:QUAL_VQA_PER_IMG]\n",
|
| 957 |
+
" vqa_rows = []\n",
|
| 958 |
+
" for v in vqas:\n",
|
| 959 |
+
" a_pred = predict(img_p, task='vqa', question=v['question'],\n",
|
| 960 |
+
" structured_findings=oracle)\n",
|
| 961 |
+
" vqa_rows.append((v['question'], _fmt_answer(v.get('answer')), a_pred))\n",
|
| 962 |
+
"\n",
|
| 963 |
+
" block = _cmp_block(lab, study, gt_pnu, gt_find, gt_impr, pred,\n",
|
| 964 |
+
" vqa_rows, QUAL_USE_ORACLE_PNU)\n",
|
| 965 |
+
"\n",
|
| 966 |
+
" # hiển thị inline\n",
|
| 967 |
+
" show(img_p, title=f'{lab} | {study} | {r.get(\"view\",\"\")}')\n",
|
| 968 |
+
" print('=' * 80); print(block); print('=' * 80, '\\n')\n",
|
| 969 |
+
"\n",
|
| 970 |
+
" # lưu folder\n",
|
| 971 |
+
" d = QUAL_OUT / _safe(lab) / study\n",
|
| 972 |
+
" d.mkdir(parents=True, exist_ok=True)\n",
|
| 973 |
+
" shutil.copy(img_p, d / f'{dicom}.jpg')\n",
|
| 974 |
+
" if rep_p: shutil.copy(rep_p, d / 'report_gt.txt')\n",
|
| 975 |
+
" (d / 'comparison.md').write_text(block, encoding='utf-8')\n",
|
| 976 |
+
" json.dump([{'question': q, 'answer_gt': a, 'answer_pred': p_}\n",
|
| 977 |
+
" for q, a, p_ in vqa_rows],\n",
|
| 978 |
+
" open(d / 'vqa_gt.json', 'w', encoding='utf-8'),\n",
|
| 979 |
+
" ensure_ascii=False, indent=2)\n",
|
| 980 |
+
" json.dump({'pathology': lab, 'study_id': study, 'dicom': dicom,\n",
|
| 981 |
+
" 'gt_pnu': gt_pnu, 'gt_findings': gt_find, 'gt_impression': gt_impr,\n",
|
| 982 |
+
" 'pred_findings': pred['findings'], 'pred_impression': pred['impression'],\n",
|
| 983 |
+
" 'oracle_pnu': QUAL_USE_ORACLE_PNU},\n",
|
| 984 |
+
" open(d / 'prediction.json', 'w', encoding='utf-8'),\n",
|
| 985 |
+
" ensure_ascii=False, indent=2)\n",
|
| 986 |
+
" index.append({'pathology': lab, 'study_id': study, 'dir': str(d)})\n",
|
| 987 |
+
" n_done += 1\n",
|
| 988 |
+
"\n",
|
| 989 |
+
"json.dump(index, open(QUAL_OUT / '_index.json', 'w', encoding='utf-8'),\n",
|
| 990 |
+
" ensure_ascii=False, indent=2)\n",
|
| 991 |
+
"print(f'\\nXong: {n_done} mẫu (skip {n_skip}). Folder: {QUAL_OUT.resolve()}')"
|
| 992 |
+
]
|
| 993 |
}
|
| 994 |
],
|
| 995 |
"metadata": {
|
scripts/vertex_eval_job.yaml
CHANGED
|
@@ -61,7 +61,7 @@ workerPoolSpecs:
|
|
| 61 |
- name: DATASET_NAME
|
| 62 |
value: MIMIC-CXR_resized # 'IU-Xray' | 'MIMIC-CXR' | 'MIMIC-CXR_resized'
|
| 63 |
- name: RUN_ID
|
| 64 |
-
value: MIMIC-
|
| 65 |
# ── Optional ────────────────────────────────────────────────────────────
|
| 66 |
- name: HF_RUNS_REPO
|
| 67 |
value: hieu3636/cxr-vlm-runs
|
|
@@ -87,10 +87,10 @@ workerPoolSpecs:
|
|
| 87 |
# PNU prompt-condition source. 'oracle' = GT chex_* labels from JSON;
|
| 88 |
# 'predicted' = run the Stage-0 CheXpert classifier per image (realistic).
|
| 89 |
- name: PNU_SOURCE
|
| 90 |
-
value:
|
| 91 |
# Classifier checkpoint on HF_RUNS_REPO (only used when PNU_SOURCE=predicted).
|
| 92 |
-
|
| 93 |
-
|
| 94 |
- name: MAX_NEW_TOKENS
|
| 95 |
value: "300"
|
| 96 |
- name: UPLOAD_RESULTS_TO_HF
|
|
|
|
| 61 |
- name: DATASET_NAME
|
| 62 |
value: MIMIC-CXR_resized # 'IU-Xray' | 'MIMIC-CXR' | 'MIMIC-CXR_resized'
|
| 63 |
- name: RUN_ID
|
| 64 |
+
value: MIMIC-CXR_resized_run_3 # which run on HF_RUNS_REPO to evaluate
|
| 65 |
# ── Optional ────────────────────────────────────────────────────────────
|
| 66 |
- name: HF_RUNS_REPO
|
| 67 |
value: hieu3636/cxr-vlm-runs
|
|
|
|
| 87 |
# PNU prompt-condition source. 'oracle' = GT chex_* labels from JSON;
|
| 88 |
# 'predicted' = run the Stage-0 CheXpert classifier per image (realistic).
|
| 89 |
- name: PNU_SOURCE
|
| 90 |
+
value: predicted # 'oracle' | 'predicted'
|
| 91 |
# Classifier checkpoint on HF_RUNS_REPO (only used when PNU_SOURCE=predicted).
|
| 92 |
+
- name: CHEXPERT_CKPT_PATH
|
| 93 |
+
value: chexpert_classifier/chexpert_mimic_resized.pt
|
| 94 |
- name: MAX_NEW_TOKENS
|
| 95 |
value: "300"
|
| 96 |
- name: UPLOAD_RESULTS_TO_HF
|