convitom commited on
Commit ·
aade474
1
Parent(s): 2c84a70
update vertex jobs and remove hardcoded secrets
Browse files- .gitignore +0 -0
- evaluation/evaluate.py +38 -3
- gcp_cli.docx +0 -0
- scripts/_build_eval_notebook.py +875 -0
- scripts/_build_inference_notebook.py +676 -0
- scripts/cxrvlm_colab_eval.ipynb +917 -0
- scripts/cxrvlm_colab_inference.ipynb +755 -0
- scripts/gcp_eval_entrypoint.py +390 -0
- scripts/vertex_eval_job.yaml +102 -0
- scripts/vertex_job.yaml +7 -7
.gitignore
CHANGED
|
Binary files a/.gitignore and b/.gitignore differ
|
|
|
evaluation/evaluate.py
CHANGED
|
@@ -125,8 +125,14 @@ def run_inference(
|
|
| 125 |
|
| 126 |
hypotheses, references, questions = [], [], []
|
| 127 |
|
|
|
|
|
|
|
|
|
|
| 128 |
for i in tqdm(range(0, len(task_samples), batch_size),
|
| 129 |
-
desc=f"Evaluating {task}"
|
|
|
|
|
|
|
|
|
|
| 130 |
batch_samples = task_samples[i:i + batch_size]
|
| 131 |
|
| 132 |
images, prompts = [], []
|
|
@@ -229,14 +235,32 @@ def main():
|
|
| 229 |
explicit_run_id = run_id,
|
| 230 |
)
|
| 231 |
|
| 232 |
-
# Build and load model
|
| 233 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
model = CXRVisionLanguageModel(model_cfg)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
load_checkpoint(model, args.checkpoint)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
model = model.to(args.device)
|
| 237 |
model.eval()
|
|
|
|
| 238 |
|
| 239 |
# Load test dataset (for the chosen dataset)
|
|
|
|
|
|
|
| 240 |
dataset = CXRInstructDataset(
|
| 241 |
data_path = spec.instruct_json,
|
| 242 |
image_root = spec.image_root,
|
|
@@ -249,6 +273,8 @@ def main():
|
|
| 249 |
max_images = spec.max_images,
|
| 250 |
feature_cache_dir = getattr(train_cfg.data, "feature_cache_dir", None) or None,
|
| 251 |
)
|
|
|
|
|
|
|
| 252 |
|
| 253 |
# Build task list, intersected with what's available for this dataset.
|
| 254 |
if args.task == "all":
|
|
@@ -263,10 +289,19 @@ def main():
|
|
| 263 |
else:
|
| 264 |
tasks_to_eval = [args.task]
|
| 265 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
all_results = {}
|
| 267 |
|
| 268 |
for task in tasks_to_eval:
|
| 269 |
logger.info(f"\nEvaluating task: {task.upper()}")
|
|
|
|
| 270 |
|
| 271 |
predictions = run_inference(
|
| 272 |
model = model,
|
|
|
|
| 125 |
|
| 126 |
hypotheses, references, questions = [], [], []
|
| 127 |
|
| 128 |
+
# mininterval=1.0 keeps the bar visibly updating in Colab subprocess
|
| 129 |
+
# output (default 10s feels like the kernel froze on T4). dynamic_ncols
|
| 130 |
+
# lets it adapt to whatever width the notebook renders.
|
| 131 |
for i in tqdm(range(0, len(task_samples), batch_size),
|
| 132 |
+
desc=f"Evaluating {task}",
|
| 133 |
+
mininterval=1.0,
|
| 134 |
+
dynamic_ncols=True,
|
| 135 |
+
file=sys.stdout):
|
| 136 |
batch_samples = task_samples[i:i + batch_size]
|
| 137 |
|
| 138 |
images, prompts = [], []
|
|
|
|
| 235 |
explicit_run_id = run_id,
|
| 236 |
)
|
| 237 |
|
| 238 |
+
# Build and load model — these phases are SILENT for several minutes on
|
| 239 |
+
# T4 (4-bit Vicuna shard download + quantization). Print breadcrumbs with
|
| 240 |
+
# flush=True so notebook users can see where the kernel is sitting.
|
| 241 |
+
import time as _time
|
| 242 |
+
print(f"[evaluate] === phase: build model ===", flush=True)
|
| 243 |
+
print(f"[evaluate] (T4 cold cache: ~5-8 min — encoder load + Vicuna-7B "
|
| 244 |
+
f"shard download + 4-bit NF4 quantization)", flush=True)
|
| 245 |
+
_t0 = _time.time()
|
| 246 |
model = CXRVisionLanguageModel(model_cfg)
|
| 247 |
+
print(f"[evaluate] model built in {_time.time()-_t0:.1f}s", flush=True)
|
| 248 |
+
|
| 249 |
+
print(f"[evaluate] === phase: load checkpoint ===", flush=True)
|
| 250 |
+
print(f"[evaluate] checkpoint: {args.checkpoint}", flush=True)
|
| 251 |
+
_t0 = _time.time()
|
| 252 |
load_checkpoint(model, args.checkpoint)
|
| 253 |
+
print(f"[evaluate] checkpoint loaded in {_time.time()-_t0:.1f}s", flush=True)
|
| 254 |
+
|
| 255 |
+
print(f"[evaluate] === phase: move model to {args.device} + eval() ===", flush=True)
|
| 256 |
+
_t0 = _time.time()
|
| 257 |
model = model.to(args.device)
|
| 258 |
model.eval()
|
| 259 |
+
print(f"[evaluate] model ready in {_time.time()-_t0:.1f}s", flush=True)
|
| 260 |
|
| 261 |
# Load test dataset (for the chosen dataset)
|
| 262 |
+
print(f"[evaluate] === phase: build dataset (split={args.split}) ===", flush=True)
|
| 263 |
+
_t0 = _time.time()
|
| 264 |
dataset = CXRInstructDataset(
|
| 265 |
data_path = spec.instruct_json,
|
| 266 |
image_root = spec.image_root,
|
|
|
|
| 273 |
max_images = spec.max_images,
|
| 274 |
feature_cache_dir = getattr(train_cfg.data, "feature_cache_dir", None) or None,
|
| 275 |
)
|
| 276 |
+
print(f"[evaluate] dataset built in {_time.time()-_t0:.1f}s "
|
| 277 |
+
f"({len(dataset.samples):,} total samples in split={args.split!r})", flush=True)
|
| 278 |
|
| 279 |
# Build task list, intersected with what's available for this dataset.
|
| 280 |
if args.task == "all":
|
|
|
|
| 289 |
else:
|
| 290 |
tasks_to_eval = [args.task]
|
| 291 |
|
| 292 |
+
# Print per-task sample counts up-front so the user can estimate runtime
|
| 293 |
+
# before tqdm starts.
|
| 294 |
+
print(f"[evaluate] tasks to evaluate: {tasks_to_eval}", flush=True)
|
| 295 |
+
for _t in tasks_to_eval:
|
| 296 |
+
_n = sum(1 for s in dataset.samples if s["task"] == _t)
|
| 297 |
+
print(f"[evaluate] {_t:<11s}: {_n:,} samples "
|
| 298 |
+
f"(~{_n // max(args.batch_size, 1):,} batches)", flush=True)
|
| 299 |
+
|
| 300 |
all_results = {}
|
| 301 |
|
| 302 |
for task in tasks_to_eval:
|
| 303 |
logger.info(f"\nEvaluating task: {task.upper()}")
|
| 304 |
+
print(f"[evaluate] === phase: inference (task={task}) ===", flush=True)
|
| 305 |
|
| 306 |
predictions = run_inference(
|
| 307 |
model = model,
|
gcp_cli.docx
ADDED
|
Binary file (15 kB). View file
|
|
|
scripts/_build_eval_notebook.py
ADDED
|
@@ -0,0 +1,875 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Build scripts/cxrvlm_colab_eval.ipynb from cell sources defined here.
|
| 3 |
+
|
| 4 |
+
Run:
|
| 5 |
+
python scripts/_build_eval_notebook.py
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
NB_PATH = Path(__file__).parent / "cxrvlm_colab_eval.ipynb"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def md(cell_id, src):
|
| 15 |
+
return {
|
| 16 |
+
"cell_type": "markdown",
|
| 17 |
+
"id": cell_id,
|
| 18 |
+
"metadata": {},
|
| 19 |
+
"source": src.splitlines(keepends=True),
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def code(cell_id, src):
|
| 24 |
+
return {
|
| 25 |
+
"cell_type": "code",
|
| 26 |
+
"id": cell_id,
|
| 27 |
+
"metadata": {},
|
| 28 |
+
"execution_count": None,
|
| 29 |
+
"outputs": [],
|
| 30 |
+
"source": src.splitlines(keepends=True),
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# ─────────────────────────────────────────────────────────────────────
|
| 35 |
+
# Cell sources
|
| 36 |
+
# ─────────────────────────────────────────────────────────────────────
|
| 37 |
+
|
| 38 |
+
CELLS = []
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
CELLS.append(md("eval-0", """\
|
| 42 |
+
# CXR-VLM — Evaluation Notebook (Colab T4)
|
| 43 |
+
|
| 44 |
+
Standalone evaluation + inference for a trained CXR-VLM run.
|
| 45 |
+
|
| 46 |
+
What it does:
|
| 47 |
+
1. Pulls project code from `<HF_USER>/cxr-vlm-code`.
|
| 48 |
+
2. Pulls the chosen dataset from `<HF_USER>/cxr-vlm-data` (same layout the trainer used).
|
| 49 |
+
3. Pulls a trained run checkpoint from `<HF_USER>/cxr-vlm-runs/<RUN_ID>/stage2/{best|last}/`.
|
| 50 |
+
4. Pulls the run's config snapshot (`<RUN_ID>/configs/{model,train}_config.yaml`) so the model is rebuilt **with the exact same architecture / report_mode / image_mode** as training.
|
| 51 |
+
5. **Auto-detects the GPU** (T4 / L4 / 3090 / A10 / A100 / H100) and patches the configs accordingly — fp16+SDPA on Turing, bf16+FA2 on Ampere/Ada, batch size scaled to VRAM. Also patches local dataset paths.
|
| 52 |
+
6. Runs `python -m evaluation.evaluate` on the **test split** for all available tasks.
|
| 53 |
+
7. Saves predictions + metrics under `RESULTS_DIR/<RUN_ID>/` (and optionally uploads them to `<HF_USER>/cxr-vlm-runs/<RUN_ID>/results/`).
|
| 54 |
+
|
| 55 |
+
Set the variables in **section 0** and run all cells top-to-bottom.
|
| 56 |
+
|
| 57 |
+
**Want interactive inference on individual images?** Use the companion notebook **`cxrvlm_colab_inference.ipynb`** — same model pull, but with image preview + free-form prompts.
|
| 58 |
+
"""))
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
CELLS.append(md("eval-select-md", """\
|
| 62 |
+
## 0. Select run + dataset + options
|
| 63 |
+
|
| 64 |
+
Change the variables in the cell below. `RUN_ID` decides which trained model is pulled; everything else (dataset name, report/image mode) defaults to whatever was used at training time (read from the run's config snapshot on HF), but you can override.
|
| 65 |
+
"""))
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
CELLS.append(code("eval-select", """\
|
| 69 |
+
# ── Platform ─────────────────────────────────────────────────────
|
| 70 |
+
PLATFORM = 'colab' # 'kaggle' | 'colab' | 'lightning' | 'gcp' | 'local'
|
| 71 |
+
|
| 72 |
+
# ── Source repos on HuggingFace ──────────────────────────────────
|
| 73 |
+
HF_USER = 'hieu3636' # owner of cxr-vlm-{code,data,runs}
|
| 74 |
+
HF_CODE_REPO = f'{HF_USER}/cxr-vlm-code'
|
| 75 |
+
HF_DATA_REPO = f'{HF_USER}/cxr-vlm-data'
|
| 76 |
+
HF_RUNS_REPO = f'{HF_USER}/cxr-vlm-runs'
|
| 77 |
+
|
| 78 |
+
# ── Which trained run to evaluate ────────────────────────────────
|
| 79 |
+
# This MUST be an existing folder on HF_RUNS_REPO.
|
| 80 |
+
# Example: 'MIMIC-CXR_resized_run_1' | 'IU-Xray_run_2' | 'MIMIC-CXR_run_3'
|
| 81 |
+
RUN_ID = 'MIMIC-CXR_resized_run_1'
|
| 82 |
+
|
| 83 |
+
# Which stage-2 checkpoint to load.
|
| 84 |
+
# 'best' → {RUN_ID}/stage2/best/ (final / best eval_loss)
|
| 85 |
+
# 'last' → {RUN_ID}/stage2/last/ (most recent intermediate save)
|
| 86 |
+
CKPT_PICK = 'best'
|
| 87 |
+
|
| 88 |
+
# ── Dataset (auto-derived from RUN_ID prefix; override if needed) ──
|
| 89 |
+
# Supported: 'MIMIC-CXR' | 'MIMIC-CXR_resized' | 'IU-Xray'
|
| 90 |
+
DATASET_NAME = None # None → auto-detect from RUN_ID prefix
|
| 91 |
+
REPORT_MODE = None # None → read from run's saved train_config.yaml
|
| 92 |
+
IMAGE_MODE = None # None → read from run's saved train_config.yaml
|
| 93 |
+
|
| 94 |
+
# ── What to evaluate ─────────────────────────────────────────────
|
| 95 |
+
TASK = 'all' # 'all' | 'findings' | 'impression' | 'report' | 'vqa'
|
| 96 |
+
SPLIT = 'test' # always 'test' for this notebook; here for visibility
|
| 97 |
+
BATCH_SIZE = None # None → auto from GPU profile (T4:1, L4:4, A100:8, H100:16)
|
| 98 |
+
MAX_NEW_TOKENS = 300
|
| 99 |
+
|
| 100 |
+
# ── LLM-as-judge for VQA (optional; needs OPENAI_API_KEY) ────────
|
| 101 |
+
LLM_JUDGE = False
|
| 102 |
+
LLM_JUDGE_MODEL = 'gpt-4o-mini'
|
| 103 |
+
LLM_JUDGE_BASE_URL = None # e.g. Gemini OpenAI-compat endpoint
|
| 104 |
+
LLM_JUDGE_MAX_SAMPLES = None # cap cost; None → all VQA samples
|
| 105 |
+
|
| 106 |
+
# ── Output ───────────────────────────────────────────────────────
|
| 107 |
+
# Local folder where predictions_*.json + metrics_summary.json land.
|
| 108 |
+
# Files end up at {LOCAL_RESULTS_DIR}/{RUN_ID}/...
|
| 109 |
+
LOCAL_RESULTS_DIR = 'results'
|
| 110 |
+
|
| 111 |
+
# Push the results folder back to HF_RUNS_REPO under
|
| 112 |
+
# {RUN_ID}/results/predictions_*.json + metrics_summary.json
|
| 113 |
+
UPLOAD_RESULTS_TO_HF = True
|
| 114 |
+
|
| 115 |
+
# ── Auto-derive DATASET_NAME from RUN_ID prefix if not set ───────
|
| 116 |
+
if DATASET_NAME is None:
|
| 117 |
+
for cand in ('MIMIC-CXR_resized', 'MIMIC-CXR', 'IU-Xray'):
|
| 118 |
+
if RUN_ID.startswith(cand + '_run_'):
|
| 119 |
+
DATASET_NAME = cand
|
| 120 |
+
break
|
| 121 |
+
assert DATASET_NAME is not None, \\
|
| 122 |
+
f"Cannot auto-derive DATASET_NAME from RUN_ID={RUN_ID!r}. " \\
|
| 123 |
+
"Expected prefix one of: MIMIC-CXR_resized / MIMIC-CXR / IU-Xray. Set DATASET_NAME explicitly."
|
| 124 |
+
|
| 125 |
+
assert PLATFORM in ('kaggle', 'colab', 'lightning', 'gcp', 'local')
|
| 126 |
+
assert DATASET_NAME in ('MIMIC-CXR', 'MIMIC-CXR_resized', 'IU-Xray')
|
| 127 |
+
assert CKPT_PICK in ('best', 'last')
|
| 128 |
+
assert TASK in ('all', 'findings', 'impression', 'report', 'vqa')
|
| 129 |
+
|
| 130 |
+
print(f'PLATFORM = {PLATFORM}')
|
| 131 |
+
print(f'RUN_ID = {RUN_ID} (ckpt: stage2/{CKPT_PICK})')
|
| 132 |
+
print(f'DATASET_NAME = {DATASET_NAME}')
|
| 133 |
+
print(f'TASK = {TASK} SPLIT = {SPLIT}')
|
| 134 |
+
print(f'LOCAL_RESULTS_DIR = {LOCAL_RESULTS_DIR} (upload→HF: {UPLOAD_RESULTS_TO_HF})')
|
| 135 |
+
"""))
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
CELLS.append(md("eval-env-md", """\
|
| 139 |
+
## 1. Environment + pip install (matches the training notebook)
|
| 140 |
+
"""))
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
CELLS.append(code("eval-env", """\
|
| 144 |
+
import os
|
| 145 |
+
os.environ['CUDA_VISIBLE_DEVICES'] = '0' # single-GPU
|
| 146 |
+
os.environ['TOKENIZERS_PARALLELISM'] = 'false'
|
| 147 |
+
os.environ['BITSANDBYTES_NOWELCOME'] = '1'
|
| 148 |
+
os.environ['HF_HUB_DISABLE_PROGRESS_BARS'] = '1'
|
| 149 |
+
os.environ['TRANSFORMERS_VERBOSITY'] = 'warning'
|
| 150 |
+
os.environ['PYTHONUNBUFFERED'] = '1'
|
| 151 |
+
|
| 152 |
+
import sys, shutil, subprocess
|
| 153 |
+
from pathlib import Path
|
| 154 |
+
"""))
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
CELLS.append(code("eval-pip", """\
|
| 158 |
+
import os as _os
|
| 159 |
+
|
| 160 |
+
# Same dependency set as the training notebook — keeps load_checkpoint /
|
| 161 |
+
# evaluate.py running against the exact stack the model was trained with.
|
| 162 |
+
!pip uninstall -y -q torchao transformers bitsandbytes peft accelerate
|
| 163 |
+
|
| 164 |
+
# Let pip pick latest bnb that matches Colab's CUDA + triton.
|
| 165 |
+
!pip install -q -U bitsandbytes
|
| 166 |
+
|
| 167 |
+
# Pin transformers / peft to the same window the trainer uses.
|
| 168 |
+
!pip install -q \\
|
| 169 |
+
'transformers>=4.46,<4.50' \\
|
| 170 |
+
'peft>=0.13,<0.15' \\
|
| 171 |
+
'accelerate>=1.0' \\
|
| 172 |
+
'huggingface_hub>=0.27,<1.0' \\
|
| 173 |
+
omegaconf sentencepiece 'protobuf>=3.20' \\
|
| 174 |
+
nltk rouge-score bert-score sacrebleu
|
| 175 |
+
|
| 176 |
+
# Best-effort flash-attn install for Ampere+/Ada GPUs (L4, 3090, A10, A100, H100).
|
| 177 |
+
# Silent fail is OK — cxr_vlm.py auto-falls-back to SDPA if FA2 isn't importable.
|
| 178 |
+
# Skipped entirely on Turing (T4/V100) since FA2 requires sm_80+.
|
| 179 |
+
import torch as _t
|
| 180 |
+
if _t.cuda.is_available() and _t.cuda.get_device_capability(0) >= (8, 0):
|
| 181 |
+
print('[pip] Ampere+/Ada detected -> trying flash-attn (5-10 min if building from source)')
|
| 182 |
+
!pip install -q flash-attn --no-build-isolation 2>&1 | tail -5
|
| 183 |
+
else:
|
| 184 |
+
print('[pip] Pre-Ampere GPU (or no CUDA) -> skipping flash-attn install')
|
| 185 |
+
"""))
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
CELLS.append(code("eval-versions", """\
|
| 189 |
+
import torch, transformers, bitsandbytes, peft, accelerate, huggingface_hub, httpx
|
| 190 |
+
|
| 191 |
+
print('torch :', torch.__version__, '| cuda:', torch.cuda.is_available())
|
| 192 |
+
print('transformers :', transformers.__version__)
|
| 193 |
+
print('bitsandbytes :', bitsandbytes.__version__)
|
| 194 |
+
print('peft :', peft.__version__)
|
| 195 |
+
print('accelerate :', accelerate.__version__)
|
| 196 |
+
print('huggingface_hub:', huggingface_hub.__version__)
|
| 197 |
+
print('httpx :', httpx.__version__)
|
| 198 |
+
|
| 199 |
+
# httpx 0.28+ compat shim — same patch as the training notebook.
|
| 200 |
+
# transformers <=4.50 calls httpx.Client.head(..., allow_redirects=True)
|
| 201 |
+
# which httpx 0.28 removed; translate the kwarg at the call site.
|
| 202 |
+
def _patch_httpx():
|
| 203 |
+
if tuple(int(x) for x in httpx.__version__.split('.')[:2]) < (0, 28):
|
| 204 |
+
return
|
| 205 |
+
if getattr(httpx.Client, '_cxr_vlm_compat_patched', False):
|
| 206 |
+
return
|
| 207 |
+
def _make(orig):
|
| 208 |
+
def patched(self, *args, **kwargs):
|
| 209 |
+
if 'allow_redirects' in kwargs:
|
| 210 |
+
kwargs['follow_redirects'] = kwargs.pop('allow_redirects')
|
| 211 |
+
kwargs.pop('proxies', None)
|
| 212 |
+
return orig(self, *args, **kwargs)
|
| 213 |
+
return patched
|
| 214 |
+
for cls in (httpx.Client, httpx.AsyncClient):
|
| 215 |
+
for m in ('request', 'get', 'head', 'post', 'put',
|
| 216 |
+
'patch', 'delete', 'options'):
|
| 217 |
+
if hasattr(cls, m):
|
| 218 |
+
setattr(cls, m, _make(getattr(cls, m)))
|
| 219 |
+
httpx.Client._cxr_vlm_compat_patched = True
|
| 220 |
+
print(f'httpx {httpx.__version__}: monkey-patched allow_redirects -> follow_redirects')
|
| 221 |
+
|
| 222 |
+
_patch_httpx()
|
| 223 |
+
|
| 224 |
+
assert torch.cuda.is_available(), 'CUDA not available — refusing to evaluate on CPU.'
|
| 225 |
+
_p = torch.cuda.get_device_properties(0)
|
| 226 |
+
print(f'\\nGPU : {_p.name} ({_p.total_memory/1e9:.1f} GB)')
|
| 227 |
+
print(f'Compute cap : sm_{_p.major}{_p.minor} (BF16 ok: {torch.cuda.is_bf16_supported()})')
|
| 228 |
+
"""))
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
CELLS.append(md("eval-paths-md", """\
|
| 232 |
+
## 2. Paths + pull code + pull dataset
|
| 233 |
+
|
| 234 |
+
Identical to the training notebook for sections that overlap (HF code + per-dataset payload).
|
| 235 |
+
"""))
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
CELLS.append(code("eval-paths", """\
|
| 239 |
+
# ── 1) WORK dir + HF_TOKEN bootstrap (platform-specific) ───────────
|
| 240 |
+
if PLATFORM == 'kaggle':
|
| 241 |
+
from kaggle_secrets import UserSecretsClient
|
| 242 |
+
os.environ['HF_TOKEN'] = UserSecretsClient().get_secret('HF_TOKEN')
|
| 243 |
+
WORK = Path('/kaggle/working')
|
| 244 |
+
elif PLATFORM == 'colab':
|
| 245 |
+
from google.colab import userdata
|
| 246 |
+
os.environ['HF_TOKEN'] = userdata.get('HF_TOKEN')
|
| 247 |
+
WORK = Path('/content')
|
| 248 |
+
elif PLATFORM == 'lightning':
|
| 249 |
+
WORK = Path('/teamspace/studios/this_studio')
|
| 250 |
+
elif PLATFORM == 'gcp':
|
| 251 |
+
WORK = Path('/workspace')
|
| 252 |
+
else: # 'local'
|
| 253 |
+
WORK = Path.home() / 'cxr-vlm-work'
|
| 254 |
+
WORK.mkdir(parents=True, exist_ok=True)
|
| 255 |
+
|
| 256 |
+
assert os.environ.get('HF_TOKEN'), \\
|
| 257 |
+
'HF_TOKEN missing — set it via the platform secrets UI before re-running.'
|
| 258 |
+
|
| 259 |
+
from huggingface_hub import snapshot_download, hf_hub_download, HfApi
|
| 260 |
+
|
| 261 |
+
# ── 2) Code: flat folder, snapshot_download ──
|
| 262 |
+
print(f'Pulling code from {HF_CODE_REPO} …')
|
| 263 |
+
CODE_SRC = Path(snapshot_download(
|
| 264 |
+
repo_id = HF_CODE_REPO,
|
| 265 |
+
repo_type = 'model',
|
| 266 |
+
token = os.environ['HF_TOKEN'],
|
| 267 |
+
local_dir = str(WORK / 'cxr-vlm-code'),
|
| 268 |
+
))
|
| 269 |
+
|
| 270 |
+
# ── 3) Data: layout depends on DATASET_NAME (same logic as train notebook) ──
|
| 271 |
+
DATA_SRC = WORK / 'data'
|
| 272 |
+
DATA_SRC.mkdir(parents=True, exist_ok=True)
|
| 273 |
+
|
| 274 |
+
if DATASET_NAME == 'MIMIC-CXR_resized':
|
| 275 |
+
import tarfile
|
| 276 |
+
mr_dir = DATA_SRC / 'MIMIC-CXR_resized'
|
| 277 |
+
mr_dir.mkdir(parents=True, exist_ok=True)
|
| 278 |
+
files_dir = mr_dir / 'files'
|
| 279 |
+
|
| 280 |
+
manifests_present = all(
|
| 281 |
+
(mr_dir / f).is_file() for f in ('manifest_train.csv', 'manifest_val.csv', 'manifest_test.csv')
|
| 282 |
+
)
|
| 283 |
+
if manifests_present and files_dir.is_dir() and any(files_dir.glob('p*')):
|
| 284 |
+
print(f'{mr_dir} already populated — skipping download.')
|
| 285 |
+
else:
|
| 286 |
+
api = HfApi(token=os.environ['HF_TOKEN'])
|
| 287 |
+
all_files = api.list_repo_files(repo_id=HF_DATA_REPO, repo_type='dataset')
|
| 288 |
+
mr_files = [f for f in all_files if f.startswith('MIMIC-CXR_resized/')]
|
| 289 |
+
tar_files = sorted(f for f in mr_files if f.endswith('.tar'))
|
| 290 |
+
meta_files = [f for f in mr_files if not f.endswith('.tar')]
|
| 291 |
+
print(f'MIMIC-CXR_resized on HF: {len(tar_files)} tar shards + {len(meta_files)} metadata files')
|
| 292 |
+
|
| 293 |
+
# Manifests / vqa / SHARDS.txt
|
| 294 |
+
snapshot_download(
|
| 295 |
+
repo_id = HF_DATA_REPO,
|
| 296 |
+
repo_type = 'dataset',
|
| 297 |
+
allow_patterns = ['MIMIC-CXR_resized/*.csv',
|
| 298 |
+
'MIMIC-CXR_resized/*.json',
|
| 299 |
+
'MIMIC-CXR_resized/*.txt',
|
| 300 |
+
'MIMIC-CXR_resized/vqa/**'],
|
| 301 |
+
token = os.environ['HF_TOKEN'],
|
| 302 |
+
local_dir = str(DATA_SRC),
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
# Tar shards — sequential extract + delete to keep peak disk low.
|
| 306 |
+
for i, tf in enumerate(tar_files, 1):
|
| 307 |
+
print(f' [{i}/{len(tar_files)}] {tf}')
|
| 308 |
+
tar_path = Path(hf_hub_download(
|
| 309 |
+
repo_id=HF_DATA_REPO, repo_type='dataset',
|
| 310 |
+
filename=tf, token=os.environ['HF_TOKEN'],
|
| 311 |
+
local_dir=str(DATA_SRC),
|
| 312 |
+
))
|
| 313 |
+
with tarfile.open(tar_path) as t:
|
| 314 |
+
t.extractall(mr_dir)
|
| 315 |
+
tar_path.unlink(missing_ok=True)
|
| 316 |
+
print(f' done. {mr_dir} ready.')
|
| 317 |
+
|
| 318 |
+
else:
|
| 319 |
+
# MIMIC-CXR / IU-Xray — single zip per dataset.
|
| 320 |
+
import zipfile
|
| 321 |
+
zip_name = f'{DATASET_NAME}.zip'
|
| 322 |
+
marker = DATA_SRC / DATASET_NAME
|
| 323 |
+
|
| 324 |
+
if not marker.exists():
|
| 325 |
+
print(f'Pulling {zip_name} from HF …')
|
| 326 |
+
zpath = hf_hub_download(
|
| 327 |
+
repo_id = HF_DATA_REPO,
|
| 328 |
+
filename = zip_name,
|
| 329 |
+
repo_type = 'dataset',
|
| 330 |
+
token = os.environ['HF_TOKEN'],
|
| 331 |
+
local_dir = str(DATA_SRC),
|
| 332 |
+
)
|
| 333 |
+
print(f' unzipping -> {DATA_SRC}')
|
| 334 |
+
with zipfile.ZipFile(zpath) as zf:
|
| 335 |
+
zf.extractall(DATA_SRC)
|
| 336 |
+
try:
|
| 337 |
+
os.remove(zpath)
|
| 338 |
+
except OSError:
|
| 339 |
+
pass
|
| 340 |
+
else:
|
| 341 |
+
print(f'{marker} already present — skipping download.')
|
| 342 |
+
|
| 343 |
+
print(f'Contents of {DATA_SRC}: {sorted(os.listdir(DATA_SRC))}')
|
| 344 |
+
|
| 345 |
+
# ── 4) Copy code into writable PROJECT dir + chdir ─────────────────
|
| 346 |
+
PROJECT = WORK / 'cxr_vlm'
|
| 347 |
+
if CODE_SRC.resolve() != PROJECT.resolve() and not PROJECT.exists():
|
| 348 |
+
shutil.copytree(CODE_SRC, PROJECT)
|
| 349 |
+
|
| 350 |
+
os.chdir(PROJECT)
|
| 351 |
+
sys.path.insert(0, str(PROJECT))
|
| 352 |
+
print('CODE_SRC :', CODE_SRC)
|
| 353 |
+
print('DATA_SRC :', DATA_SRC)
|
| 354 |
+
print('PROJECT :', PROJECT)
|
| 355 |
+
print('WORK :', WORK)
|
| 356 |
+
"""))
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
CELLS.append(md("eval-find-md", """\
|
| 360 |
+
## 3. Locate the dataset payload on disk
|
| 361 |
+
"""))
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
CELLS.append(code("eval-find", """\
|
| 365 |
+
# Same dataset-payload finders as the training notebook. Filling in the
|
| 366 |
+
# variables the config cell below will consume.
|
| 367 |
+
|
| 368 |
+
def find_split_parent(root: Path) -> Path:
|
| 369 |
+
for cand in [root, root / 'MIMIC-CXR', root / 'data' / 'MIMIC-CXR']:
|
| 370 |
+
if (cand / 'train').exists() and (cand / 'valid').exists() and (cand / 'test').exists():
|
| 371 |
+
return cand
|
| 372 |
+
for p in root.rglob('train'):
|
| 373 |
+
if p.is_dir() and (p.parent / 'valid').exists() and (p.parent / 'test').exists():
|
| 374 |
+
return p.parent
|
| 375 |
+
raise FileNotFoundError('Could not find train/ valid/ test/ under ' + str(root))
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
def find_mimic_resized_root(root: Path) -> Path:
|
| 379 |
+
for cand in [root / 'MIMIC-CXR_resized', root, *root.rglob('MIMIC-CXR_resized')]:
|
| 380 |
+
if (cand / 'manifest_train.csv').is_file():
|
| 381 |
+
return cand
|
| 382 |
+
raise FileNotFoundError(
|
| 383 |
+
f'Could not find MIMIC-CXR_resized payload under {root}. '
|
| 384 |
+
'Expected manifest_train.csv (alongside manifest_val.csv / manifest_test.csv).'
|
| 385 |
+
)
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
def find_iu_dirs(root: Path):
|
| 389 |
+
for cand in [root / 'IU-Xray', *root.rglob('IU-Xray')]:
|
| 390 |
+
if not cand.is_dir():
|
| 391 |
+
continue
|
| 392 |
+
imgs = cand / 'images'
|
| 393 |
+
lbls = cand / 'labels'
|
| 394 |
+
if imgs.is_dir() and lbls.is_dir() and any(lbls.glob('*.xml')):
|
| 395 |
+
return imgs, lbls
|
| 396 |
+
legacy = lbls / 'ecgen-radiology'
|
| 397 |
+
if imgs.is_dir() and legacy.is_dir() and any(legacy.glob('*.xml')):
|
| 398 |
+
return imgs, legacy
|
| 399 |
+
img_dir = lbl_dir = None
|
| 400 |
+
for cand in [root / 'images', *root.rglob('images')]:
|
| 401 |
+
if cand.is_dir() and any(cand.glob('CXR*.png')):
|
| 402 |
+
img_dir = cand; break
|
| 403 |
+
for cand in [root / 'labels', *root.rglob('labels')]:
|
| 404 |
+
if cand.is_dir() and any(cand.glob('*.xml')):
|
| 405 |
+
lbl_dir = cand; break
|
| 406 |
+
if lbl_dir is None:
|
| 407 |
+
for cand in root.rglob('ecgen-radiology'):
|
| 408 |
+
if cand.is_dir() and any(cand.glob('*.xml')):
|
| 409 |
+
lbl_dir = cand; break
|
| 410 |
+
return img_dir, lbl_dir
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
CXR_ROOT = None
|
| 414 |
+
VQA_ROOT = None
|
| 415 |
+
MR_ROOT = None
|
| 416 |
+
IU_IMAGES_DIR = None
|
| 417 |
+
IU_LABELS_DIR = None
|
| 418 |
+
|
| 419 |
+
if DATASET_NAME == 'MIMIC-CXR':
|
| 420 |
+
CXR_ROOT = find_split_parent(DATA_SRC)
|
| 421 |
+
print('MIMIC-CXR root:', CXR_ROOT)
|
| 422 |
+
for s in ('train', 'valid', 'test'):
|
| 423 |
+
d = CXR_ROOT / s
|
| 424 |
+
assert d.exists(), f'Missing split dir: {d}'
|
| 425 |
+
print(f' {s:<6s} -> {d}')
|
| 426 |
+
|
| 427 |
+
for p in DATA_SRC.rglob('MIMIC-Ext-MIMIC-CXR-VQA'):
|
| 428 |
+
cand = p / 'dataset'
|
| 429 |
+
if cand.exists() and (cand / 'train.json').exists():
|
| 430 |
+
VQA_ROOT = cand; break
|
| 431 |
+
if VQA_ROOT is None:
|
| 432 |
+
print('VQA root: NOT FOUND -> VQA task will be skipped')
|
| 433 |
+
else:
|
| 434 |
+
print('VQA root:', VQA_ROOT)
|
| 435 |
+
|
| 436 |
+
elif DATASET_NAME == 'MIMIC-CXR_resized':
|
| 437 |
+
MR_ROOT = find_mimic_resized_root(DATA_SRC)
|
| 438 |
+
print('MIMIC-CXR_resized root:', MR_ROOT)
|
| 439 |
+
for cf in ('manifest_train.csv', 'manifest_val.csv', 'manifest_test.csv'):
|
| 440 |
+
f = MR_ROOT / cf
|
| 441 |
+
print(f' {cf}: {"OK" if f.is_file() else "MISSING"}')
|
| 442 |
+
for sub in ('files', 'vqa'):
|
| 443 |
+
d = MR_ROOT / sub
|
| 444 |
+
print(f' {sub:<5s}: {"OK" if d.is_dir() else "MISSING"} ({d})')
|
| 445 |
+
|
| 446 |
+
else: # IU-Xray
|
| 447 |
+
IU_IMAGES_DIR, IU_LABELS_DIR = find_iu_dirs(DATA_SRC)
|
| 448 |
+
assert IU_IMAGES_DIR is not None, f'IU images/ not found under {DATA_SRC}'
|
| 449 |
+
assert IU_LABELS_DIR is not None, f'IU labels/ (with *.xml) not found under {DATA_SRC}'
|
| 450 |
+
print('IU images dir:', IU_IMAGES_DIR, '->', len(list(IU_IMAGES_DIR.glob('*.png'))), 'PNGs')
|
| 451 |
+
print('IU labels dir:', IU_LABELS_DIR, '->', len(list(IU_LABELS_DIR.glob('*.xml'))), 'XMLs')
|
| 452 |
+
"""))
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
CELLS.append(md("eval-pull-run-md", """\
|
| 456 |
+
## 4. Pull trained checkpoint + run config snapshot from HF Runs repo
|
| 457 |
+
|
| 458 |
+
Layout on `HF_RUNS_REPO`:
|
| 459 |
+
|
| 460 |
+
```
|
| 461 |
+
{RUN_ID}/
|
| 462 |
+
configs/{model,train}_config.yaml ← snapshot taken at training time
|
| 463 |
+
stage2/best/ checkpoint_projection.pt + checkpoint_lora/ [+ checkpoint_chexpert_classifier.pt]
|
| 464 |
+
stage2/last/ same shape, intermediate save
|
| 465 |
+
```
|
| 466 |
+
|
| 467 |
+
We pull `{RUN_ID}/configs/` and `{RUN_ID}/stage2/{CKPT_PICK}/` into `{WORK}/run_pull/{RUN_ID}/`.
|
| 468 |
+
"""))
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
CELLS.append(code("eval-pull-run", """\
|
| 472 |
+
from huggingface_hub import snapshot_download
|
| 473 |
+
|
| 474 |
+
RUN_PULL_ROOT = WORK / 'run_pull'
|
| 475 |
+
RUN_PULL_ROOT.mkdir(parents=True, exist_ok=True)
|
| 476 |
+
|
| 477 |
+
print(f'Pulling {RUN_ID}/{{configs,stage2/{CKPT_PICK}}} from {HF_RUNS_REPO} …')
|
| 478 |
+
snapshot_download(
|
| 479 |
+
repo_id = HF_RUNS_REPO,
|
| 480 |
+
repo_type = 'model',
|
| 481 |
+
token = os.environ['HF_TOKEN'],
|
| 482 |
+
allow_patterns = [
|
| 483 |
+
f'{RUN_ID}/configs/**',
|
| 484 |
+
f'{RUN_ID}/run_meta.json',
|
| 485 |
+
f'{RUN_ID}/stage2/{CKPT_PICK}/**',
|
| 486 |
+
],
|
| 487 |
+
local_dir = str(RUN_PULL_ROOT),
|
| 488 |
+
)
|
| 489 |
+
|
| 490 |
+
RUN_DIR_PULLED = RUN_PULL_ROOT / RUN_ID
|
| 491 |
+
assert RUN_DIR_PULLED.is_dir(), f'Pull failed — {RUN_DIR_PULLED} missing.'
|
| 492 |
+
|
| 493 |
+
CKPT_DIR_PULLED = RUN_DIR_PULLED / 'stage2' / CKPT_PICK
|
| 494 |
+
PROJ_PT = CKPT_DIR_PULLED / 'checkpoint_projection.pt'
|
| 495 |
+
LORA_DIR = CKPT_DIR_PULLED / 'checkpoint_lora'
|
| 496 |
+
CHEXPERT_PT = CKPT_DIR_PULLED / 'checkpoint_chexpert_classifier.pt'
|
| 497 |
+
|
| 498 |
+
assert PROJ_PT.is_file(), \\
|
| 499 |
+
f'Projection weights not found at {PROJ_PT}. ' \\
|
| 500 |
+
f'Check that {RUN_ID}/stage2/{CKPT_PICK}/ exists on {HF_RUNS_REPO}.'
|
| 501 |
+
assert (LORA_DIR / 'adapter_config.json').is_file(), \\
|
| 502 |
+
f'LoRA adapter_config.json not found in {LORA_DIR}. Stage-2 checkpoint partial?'
|
| 503 |
+
|
| 504 |
+
print()
|
| 505 |
+
print(f' projection : {PROJ_PT} ({PROJ_PT.stat().st_size/1e6:.1f} MB)')
|
| 506 |
+
print(f' lora : {LORA_DIR}/ ({sum(p.stat().st_size for p in LORA_DIR.rglob("*") if p.is_file())/1e6:.1f} MB)')
|
| 507 |
+
print(f' chexpert : {CHEXPERT_PT} (exists: {CHEXPERT_PT.is_file()})')
|
| 508 |
+
|
| 509 |
+
# Saved configs (may or may not exist on older runs)
|
| 510 |
+
SAVED_CFG_DIR = RUN_DIR_PULLED / 'configs'
|
| 511 |
+
SAVED_TRAIN_CFG = SAVED_CFG_DIR / 'train_config.yaml'
|
| 512 |
+
SAVED_MODEL_CFG = SAVED_CFG_DIR / 'model_config.yaml'
|
| 513 |
+
print()
|
| 514 |
+
print(f' saved train_cfg : {SAVED_TRAIN_CFG} (exists: {SAVED_TRAIN_CFG.is_file()})')
|
| 515 |
+
print(f' saved model_cfg : {SAVED_MODEL_CFG} (exists: {SAVED_MODEL_CFG.is_file()})')
|
| 516 |
+
"""))
|
| 517 |
+
|
| 518 |
+
|
| 519 |
+
CELLS.append(md("eval-gpu-md", """\
|
| 520 |
+
## 5. Auto-detect GPU profile
|
| 521 |
+
|
| 522 |
+
Mirrors the training notebook: picks precision (bf16/fp16), attention backend (FA2/SDPA), and an eval batch size based on the actual GPU's compute capability + VRAM.
|
| 523 |
+
|
| 524 |
+
| Bucket | Examples | Precision | Attn | Eval batch |
|
| 525 |
+
|---|---|---|---|---|
|
| 526 |
+
| 70+ GB | A100/H100 80GB | bf16 | FA2 (if installed) | 16 |
|
| 527 |
+
| 35–69 GB | A100 40GB | bf16 | FA2 | 8 |
|
| 528 |
+
| 22–34 GB | 3090 / L4 / A10 24GB | bf16 | FA2 | 4 |
|
| 529 |
+
| 14–21 GB | T4 / V100 16GB | fp16 | SDPA | 1 |
|
| 530 |
+
|
| 531 |
+
The eval batch sizes are smaller than training's because generation builds a KV cache that scales with `batch × max_new_tokens`. Override by setting `BATCH_SIZE` in section 0 to a concrete number.
|
| 532 |
+
"""))
|
| 533 |
+
|
| 534 |
+
|
| 535 |
+
CELLS.append(code("eval-gpu", """\
|
| 536 |
+
import torch
|
| 537 |
+
|
| 538 |
+
assert torch.cuda.is_available(), 'CUDA not available — refusing to write a CPU profile.'
|
| 539 |
+
_props = torch.cuda.get_device_properties(0)
|
| 540 |
+
_cap = (_props.major, _props.minor)
|
| 541 |
+
_vram_gb = _props.total_memory / 1e9
|
| 542 |
+
_bf16_ok = torch.cuda.is_bf16_supported()
|
| 543 |
+
_fa2_ok = _cap >= (8, 0) # FA2 needs Ampere+ (sm_80 or newer)
|
| 544 |
+
|
| 545 |
+
# Detect whether flash-attn package is actually importable. FA2 falls back to
|
| 546 |
+
# SDPA inside cxr_vlm.py if missing, but knowing here lets us print clearly.
|
| 547 |
+
_flash_attn_installed = False
|
| 548 |
+
if _fa2_ok:
|
| 549 |
+
try:
|
| 550 |
+
import flash_attn # noqa: F401
|
| 551 |
+
_flash_attn_installed = True
|
| 552 |
+
except Exception:
|
| 553 |
+
_flash_attn_installed = False
|
| 554 |
+
|
| 555 |
+
print(f'GPU : {_props.name} ({_vram_gb:.1f} GB)')
|
| 556 |
+
print(f'Compute cap : sm_{_cap[0]}{_cap[1]}')
|
| 557 |
+
print(f'BF16 native : {_bf16_ok}')
|
| 558 |
+
print(f'FA2 capable : {_fa2_ok} flash-attn installed: {_flash_attn_installed}')
|
| 559 |
+
|
| 560 |
+
# Eval batch size — smaller than training because generation builds a KV cache
|
| 561 |
+
# that scales with batch × max_new_tokens.
|
| 562 |
+
if _vram_gb >= 70:
|
| 563 |
+
GPU_LABEL, _EVAL_BS, _NW = 'A100/H100 80GB', 16, 16
|
| 564 |
+
elif _vram_gb >= 35:
|
| 565 |
+
GPU_LABEL, _EVAL_BS, _NW = 'A100 40GB', 8, 12
|
| 566 |
+
elif _vram_gb >= 22:
|
| 567 |
+
GPU_LABEL, _EVAL_BS, _NW = 'RTX 3090 / L4 / A10 24GB', 4, 8
|
| 568 |
+
elif _vram_gb >= 14:
|
| 569 |
+
GPU_LABEL, _EVAL_BS, _NW = 'T4 / V100 (15-16GB)', 1, 2
|
| 570 |
+
else:
|
| 571 |
+
GPU_LABEL, _EVAL_BS, _NW = f'unknown ({_vram_gb:.0f}GB) - conservative', 1, 2
|
| 572 |
+
|
| 573 |
+
PROFILE = dict(
|
| 574 |
+
label = GPU_LABEL,
|
| 575 |
+
bf16 = bool(_bf16_ok),
|
| 576 |
+
fp16 = not _bf16_ok,
|
| 577 |
+
torch_dtype = 'bfloat16' if _bf16_ok else 'float16',
|
| 578 |
+
bnb_4bit_compute_dtype = 'bfloat16' if _bf16_ok else 'float16',
|
| 579 |
+
attn_implementation = 'flash_attention_2' if (_fa2_ok and _flash_attn_installed) else 'sdpa',
|
| 580 |
+
per_device_eval_batch_size = _EVAL_BS,
|
| 581 |
+
dataloader_num_workers = _NW,
|
| 582 |
+
)
|
| 583 |
+
|
| 584 |
+
# Allow the section-0 selector to override (e.g. BATCH_SIZE=8 to push harder).
|
| 585 |
+
if BATCH_SIZE is not None:
|
| 586 |
+
PROFILE['per_device_eval_batch_size'] = int(BATCH_SIZE)
|
| 587 |
+
print(f'BATCH_SIZE override: section 0 forced batch={BATCH_SIZE}')
|
| 588 |
+
|
| 589 |
+
# Final BATCH_SIZE the rest of the notebook (eval-run cell) will use.
|
| 590 |
+
BATCH_SIZE = PROFILE['per_device_eval_batch_size']
|
| 591 |
+
|
| 592 |
+
print(f'\\n-> Profile : {PROFILE["label"]}')
|
| 593 |
+
for k, v in PROFILE.items():
|
| 594 |
+
if k == 'label': continue
|
| 595 |
+
print(f' {k:<32s} = {v}')
|
| 596 |
+
"""))
|
| 597 |
+
|
| 598 |
+
|
| 599 |
+
CELLS.append(md("eval-cfg-md", """\
|
| 600 |
+
## 6. Build configs
|
| 601 |
+
|
| 602 |
+
Strategy: start from the run's saved config snapshot if present (so `report_mode`, `image_mode`, `lora.r`, `num_image_tokens`, etc. match training). If absent, fall back to the repo defaults. Then patch:
|
| 603 |
+
- dataset paths to the local download
|
| 604 |
+
- compute (precision + attn backend + batch) from the auto-detected `PROFILE`
|
| 605 |
+
- HF Hub tracker → uses `HF_RUNS_REPO` so the results upload lands under `{RUN_ID}/results/`
|
| 606 |
+
- pin `run_id` to `RUN_ID` so `evaluate.py` writes under `{LOCAL_RESULTS_DIR}/{RUN_ID}/`
|
| 607 |
+
"""))
|
| 608 |
+
|
| 609 |
+
|
| 610 |
+
CELLS.append(code("eval-cfg", """\
|
| 611 |
+
from omegaconf import OmegaConf
|
| 612 |
+
import torch
|
| 613 |
+
|
| 614 |
+
# ── 1) Load base configs: prefer the run's saved snapshot ─────────
|
| 615 |
+
if SAVED_TRAIN_CFG.is_file():
|
| 616 |
+
train_cfg = OmegaConf.load(SAVED_TRAIN_CFG)
|
| 617 |
+
print(f'train_cfg <- {SAVED_TRAIN_CFG}')
|
| 618 |
+
else:
|
| 619 |
+
train_cfg = OmegaConf.load(PROJECT / 'configs' / 'train_config.yaml')
|
| 620 |
+
print(f'train_cfg <- repo default (no snapshot on HF)')
|
| 621 |
+
|
| 622 |
+
if SAVED_MODEL_CFG.is_file():
|
| 623 |
+
model_cfg = OmegaConf.load(SAVED_MODEL_CFG)
|
| 624 |
+
print(f'model_cfg <- {SAVED_MODEL_CFG}')
|
| 625 |
+
else:
|
| 626 |
+
model_cfg = OmegaConf.load(PROJECT / 'configs' / 'model_config.yaml')
|
| 627 |
+
print(f'model_cfg <- repo default (no snapshot on HF)')
|
| 628 |
+
|
| 629 |
+
# ── 2) Allow notebook overrides for report/image mode ─────────────
|
| 630 |
+
if REPORT_MODE is not None:
|
| 631 |
+
train_cfg.data.report_mode = REPORT_MODE
|
| 632 |
+
if IMAGE_MODE is not None:
|
| 633 |
+
train_cfg.data.image_mode = IMAGE_MODE
|
| 634 |
+
print(f'report_mode = {train_cfg.data.report_mode} image_mode = {train_cfg.data.image_mode}')
|
| 635 |
+
|
| 636 |
+
# ── 3) Patch dataset paths for the local download ────────────────
|
| 637 |
+
train_cfg.data.dataset_name = DATASET_NAME
|
| 638 |
+
|
| 639 |
+
if DATASET_NAME == 'MIMIC-CXR':
|
| 640 |
+
train_cfg.data.mimic_cxr_root = str(CXR_ROOT)
|
| 641 |
+
train_cfg.data.mimic_auto_build = True
|
| 642 |
+
|
| 643 |
+
_cx = (sorted(DATA_SRC.rglob('*chexpert*.csv'))
|
| 644 |
+
or sorted(DATA_SRC.rglob('*chexbert*.csv')))
|
| 645 |
+
train_cfg.data.mimic_chexpert_csv = str(_cx[0]) if _cx else None
|
| 646 |
+
train_cfg.data.mimic_vqa_root = str(VQA_ROOT) if VQA_ROOT is not None else None
|
| 647 |
+
|
| 648 |
+
out_dir = PROJECT / 'data' / 'data_files'
|
| 649 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 650 |
+
train_cfg.data.instruct_json = str(out_dir / 'mimic_cxr_instruct_unified.json')
|
| 651 |
+
|
| 652 |
+
elif DATASET_NAME == 'MIMIC-CXR_resized':
|
| 653 |
+
train_cfg.data.mimic_cxr_resized.root = str(MR_ROOT)
|
| 654 |
+
train_cfg.data.mimic_cxr_resized.manifest_dir = None
|
| 655 |
+
train_cfg.data.mimic_cxr_resized.vqa_dir = None
|
| 656 |
+
train_cfg.data.mimic_cxr_resized.reports_root = None
|
| 657 |
+
train_cfg.data.mimic_cxr_resized.auto_build = True
|
| 658 |
+
out_dir = PROJECT / 'data' / 'data_files'
|
| 659 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 660 |
+
train_cfg.data.mimic_cxr_resized.instruct_json = str(out_dir / 'mimic_cxr_resized_instruct.json')
|
| 661 |
+
|
| 662 |
+
else: # IU-Xray
|
| 663 |
+
train_cfg.data.iu_xray.images_dir = str(IU_IMAGES_DIR)
|
| 664 |
+
train_cfg.data.iu_xray.labels_dir = str(IU_LABELS_DIR)
|
| 665 |
+
train_cfg.data.iu_xray.auto_build = True
|
| 666 |
+
out_dir = PROJECT / 'data' / 'data_files'
|
| 667 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 668 |
+
train_cfg.data.iu_xray.instruct_json = str(out_dir / 'iu_xray_instruct.json')
|
| 669 |
+
|
| 670 |
+
train_cfg.data.train_split = 'train'
|
| 671 |
+
train_cfg.data.val_split = 'validate'
|
| 672 |
+
train_cfg.data.test_split = 'test'
|
| 673 |
+
|
| 674 |
+
# ── 4) Apply auto-detected compute profile (overrides saved config) ──
|
| 675 |
+
train_cfg.training.fp16 = PROFILE['fp16']
|
| 676 |
+
train_cfg.training.bf16 = PROFILE['bf16']
|
| 677 |
+
train_cfg.training.per_device_train_batch_size = PROFILE['per_device_eval_batch_size']
|
| 678 |
+
train_cfg.training.per_device_eval_batch_size = PROFILE['per_device_eval_batch_size']
|
| 679 |
+
train_cfg.training.dataloader_num_workers = PROFILE['dataloader_num_workers']
|
| 680 |
+
train_cfg.training.dataloader_pin_memory = True
|
| 681 |
+
|
| 682 |
+
# Disable feature cache for eval (test images haven't been precomputed).
|
| 683 |
+
train_cfg.data.feature_cache_dir = None
|
| 684 |
+
|
| 685 |
+
# 4-bit QLoRA — must match how the trainer set it up for the saved LoRA to load.
|
| 686 |
+
model_cfg.llm.load_in_4bit = True
|
| 687 |
+
model_cfg.llm.load_in_8bit = False
|
| 688 |
+
model_cfg.llm.attn_implementation = PROFILE['attn_implementation']
|
| 689 |
+
model_cfg.llm.torch_dtype = PROFILE['torch_dtype']
|
| 690 |
+
model_cfg.llm.bnb_4bit_compute_dtype = PROFILE['bnb_4bit_compute_dtype']
|
| 691 |
+
model_cfg.llm.bnb_4bit_quant_type = 'nf4'
|
| 692 |
+
model_cfg.llm.bnb_4bit_use_double_quant = True
|
| 693 |
+
model_cfg.llm.gradient_checkpointing = False # eval-only - no backward pass
|
| 694 |
+
|
| 695 |
+
# ── 5) CheXpert classifier ───────────────────────────────────────
|
| 696 |
+
# The training notebook keeps it disabled (oracle PNU from CSV/manifest).
|
| 697 |
+
# Only enable here if the trained run actually has a learned classifier
|
| 698 |
+
# checkpoint saved alongside.
|
| 699 |
+
if CHEXPERT_PT.is_file():
|
| 700 |
+
model_cfg.chexpert_classifier.enabled = True
|
| 701 |
+
print(f'CheXpert classifier checkpoint found at {CHEXPERT_PT} -> enabled')
|
| 702 |
+
else:
|
| 703 |
+
model_cfg.chexpert_classifier.enabled = False
|
| 704 |
+
print('No CheXpert classifier checkpoint -> disabled (oracle PNU from CSV/manifest)')
|
| 705 |
+
|
| 706 |
+
# ── 6) HF Hub tracker — points results uploads at HF_RUNS_REPO ──
|
| 707 |
+
CKPT_ROOT = WORK / 'ckpt_eval'
|
| 708 |
+
CKPT_ROOT.mkdir(parents=True, exist_ok=True)
|
| 709 |
+
train_cfg.training.output_root = str(CKPT_ROOT)
|
| 710 |
+
|
| 711 |
+
if UPLOAD_RESULTS_TO_HF:
|
| 712 |
+
train_cfg.hf_hub.enabled = True
|
| 713 |
+
train_cfg.hf_hub.repo_id = HF_RUNS_REPO
|
| 714 |
+
train_cfg.hf_hub.token_env = 'HF_TOKEN'
|
| 715 |
+
train_cfg.hf_hub.private = True
|
| 716 |
+
else:
|
| 717 |
+
train_cfg.hf_hub.enabled = False
|
| 718 |
+
|
| 719 |
+
train_cfg.hf_hub.run_state_file = str(CKPT_ROOT / 'run_id.txt')
|
| 720 |
+
|
| 721 |
+
# Pin RUN_ID so resolve_run_id picks it up exactly.
|
| 722 |
+
Path(train_cfg.hf_hub.run_state_file).write_text(RUN_ID)
|
| 723 |
+
|
| 724 |
+
# ── 7) Save patched configs into the project so the subprocess sees them ──
|
| 725 |
+
OmegaConf.save(train_cfg, PROJECT / 'configs' / 'train_config.yaml')
|
| 726 |
+
OmegaConf.save(model_cfg, PROJECT / 'configs' / 'model_config.yaml')
|
| 727 |
+
|
| 728 |
+
print('--- train_cfg.data ---'); print(OmegaConf.to_yaml(train_cfg.data))
|
| 729 |
+
print('--- train_cfg.training ---');print(OmegaConf.to_yaml(train_cfg.training))
|
| 730 |
+
print('--- train_cfg.hf_hub ---'); print(OmegaConf.to_yaml(train_cfg.hf_hub))
|
| 731 |
+
print('--- model_cfg.llm ---'); print(OmegaConf.to_yaml(model_cfg.llm))
|
| 732 |
+
"""))
|
| 733 |
+
|
| 734 |
+
|
| 735 |
+
CELLS.append(md("eval-run-md", """\
|
| 736 |
+
## 7. Run evaluation
|
| 737 |
+
|
| 738 |
+
Calls `python -m evaluation.evaluate` as a subprocess. It will:
|
| 739 |
+
1. Build the unified instruct JSON for the chosen `(report_mode, image_mode)` if missing.
|
| 740 |
+
2. Build the model from the patched configs.
|
| 741 |
+
3. Load the projection + LoRA from `CKPT_DIR_PULLED`.
|
| 742 |
+
4. Iterate the **test** split, generate predictions, score every task.
|
| 743 |
+
5. Write `{LOCAL_RESULTS_DIR}/{RUN_ID}/predictions_*.json` + `metrics_summary.json`.
|
| 744 |
+
6. If `UPLOAD_RESULTS_TO_HF=True`, upload the folder to `{RUN_ID}/results/` on `HF_RUNS_REPO`.
|
| 745 |
+
"""))
|
| 746 |
+
|
| 747 |
+
|
| 748 |
+
CELLS.append(code("eval-run", """\
|
| 749 |
+
import shlex
|
| 750 |
+
|
| 751 |
+
RESULTS_DIR_LOCAL = WORK / LOCAL_RESULTS_DIR
|
| 752 |
+
RESULTS_DIR_LOCAL.mkdir(parents=True, exist_ok=True)
|
| 753 |
+
|
| 754 |
+
# evaluate.py expects --checkpoint to be a `<dir>/<name>_projection.pt` path;
|
| 755 |
+
# it derives the LoRA dir + CheXpert classifier path from the same folder.
|
| 756 |
+
CKPT_ARG = str(PROJ_PT)
|
| 757 |
+
|
| 758 |
+
extra = ''
|
| 759 |
+
if LLM_JUDGE:
|
| 760 |
+
extra += ' --llm_judge'
|
| 761 |
+
extra += f' --llm_judge_model {shlex.quote(LLM_JUDGE_MODEL)}'
|
| 762 |
+
if LLM_JUDGE_BASE_URL:
|
| 763 |
+
extra += f' --llm_judge_base_url {shlex.quote(LLM_JUDGE_BASE_URL)}'
|
| 764 |
+
if LLM_JUDGE_MAX_SAMPLES:
|
| 765 |
+
extra += f' --llm_judge_max_samples {int(LLM_JUDGE_MAX_SAMPLES)}'
|
| 766 |
+
|
| 767 |
+
if not UPLOAD_RESULTS_TO_HF:
|
| 768 |
+
extra += ' --no_hf_upload'
|
| 769 |
+
|
| 770 |
+
print(f'Evaluating run_id : {RUN_ID}')
|
| 771 |
+
print(f'Checkpoint : {CKPT_ARG}')
|
| 772 |
+
print(f'Task : {TASK} (split={SPLIT})')
|
| 773 |
+
print(f'Local results dir : {RESULTS_DIR_LOCAL}/{RUN_ID}/')
|
| 774 |
+
print(f'Upload to HF : {UPLOAD_RESULTS_TO_HF}')
|
| 775 |
+
print()
|
| 776 |
+
|
| 777 |
+
# NOTE: do NOT set HF_HUB_DISABLE_PROGRESS_BARS=1 here. On a cold cache the
|
| 778 |
+
# 4-bit Vicuna shard download is ~13GB and takes minutes on Colab T4 — hiding
|
| 779 |
+
# the bar makes the cell look frozen. TQDM_MININTERVAL=1.0 forces the per-task
|
| 780 |
+
# tqdm in run_inference to refresh every 1s.
|
| 781 |
+
!TRANSFORMERS_VERBOSITY=warning TOKENIZERS_PARALLELISM=false BITSANDBYTES_NOWELCOME=1 \\
|
| 782 |
+
PYTHONUNBUFFERED=1 TQDM_MININTERVAL=1.0 \\
|
| 783 |
+
python -u -m evaluation.evaluate \\
|
| 784 |
+
--model_config configs/model_config.yaml \\
|
| 785 |
+
--train_config configs/train_config.yaml \\
|
| 786 |
+
--checkpoint "{CKPT_ARG}" \\
|
| 787 |
+
--run_id "{RUN_ID}" \\
|
| 788 |
+
--task {TASK} \\
|
| 789 |
+
--split {SPLIT} \\
|
| 790 |
+
--batch_size {BATCH_SIZE} \\
|
| 791 |
+
--max_new_tokens {MAX_NEW_TOKENS} \\
|
| 792 |
+
--output_dir "{RESULTS_DIR_LOCAL}" \\
|
| 793 |
+
--device cuda{extra}
|
| 794 |
+
"""))
|
| 795 |
+
|
| 796 |
+
|
| 797 |
+
CELLS.append(md("eval-summary-md", """\
|
| 798 |
+
## 8. Show the metrics summary
|
| 799 |
+
"""))
|
| 800 |
+
|
| 801 |
+
|
| 802 |
+
CELLS.append(code("eval-summary", """\
|
| 803 |
+
import json as _json
|
| 804 |
+
|
| 805 |
+
_summary_path = RESULTS_DIR_LOCAL / RUN_ID / 'metrics_summary.json'
|
| 806 |
+
if not _summary_path.is_file():
|
| 807 |
+
print(f'No metrics_summary.json at {_summary_path}. Did evaluate.py error out?')
|
| 808 |
+
else:
|
| 809 |
+
summary = _json.loads(_summary_path.read_text())
|
| 810 |
+
print(f'Dataset : {summary.get("dataset_name")}')
|
| 811 |
+
print(f'Run ID : {summary.get("run_id")}')
|
| 812 |
+
print(f'Split : {summary.get("split")}')
|
| 813 |
+
print()
|
| 814 |
+
for task, metrics in (summary.get('metrics') or {}).items():
|
| 815 |
+
print(f'─── {task.upper()} ───')
|
| 816 |
+
for k, v in metrics.items():
|
| 817 |
+
if isinstance(v, float):
|
| 818 |
+
print(f' {k:<22s}: {v:.4f}')
|
| 819 |
+
else:
|
| 820 |
+
print(f' {k:<22s}: {v}')
|
| 821 |
+
print()
|
| 822 |
+
|
| 823 |
+
# List per-task prediction files for convenience
|
| 824 |
+
print('Per-task prediction files:')
|
| 825 |
+
for f in sorted((RESULTS_DIR_LOCAL / RUN_ID).glob('predictions_*.json')):
|
| 826 |
+
n = len(_json.loads(f.read_text()))
|
| 827 |
+
print(f' {f.name} ({n} samples)')
|
| 828 |
+
"""))
|
| 829 |
+
|
| 830 |
+
|
| 831 |
+
CELLS.append(md("eval-cleanup-md", """\
|
| 832 |
+
### Done
|
| 833 |
+
|
| 834 |
+
Final artifacts (also pushed to HF if `UPLOAD_RESULTS_TO_HF=True`):
|
| 835 |
+
|
| 836 |
+
```
|
| 837 |
+
{LOCAL_RESULTS_DIR}/{RUN_ID}/
|
| 838 |
+
predictions_findings.json
|
| 839 |
+
predictions_impression.json
|
| 840 |
+
predictions_vqa.json (MIMIC datasets only)
|
| 841 |
+
metrics_summary.json
|
| 842 |
+
```
|
| 843 |
+
|
| 844 |
+
For free-form inference on individual images (with image preview, custom VQA questions, etc.) use the separate **`cxrvlm_colab_inference.ipynb`** notebook.
|
| 845 |
+
"""))
|
| 846 |
+
|
| 847 |
+
|
| 848 |
+
# ─────────────────────────────────────────────────────────────────────
|
| 849 |
+
# Write notebook
|
| 850 |
+
# ─────────────────────────────────────────────────────────────────────
|
| 851 |
+
|
| 852 |
+
nb = {
|
| 853 |
+
"cells": CELLS,
|
| 854 |
+
"metadata": {
|
| 855 |
+
"accelerator": "GPU",
|
| 856 |
+
"colab": {
|
| 857 |
+
"gpuType": "T4",
|
| 858 |
+
"provenance": [],
|
| 859 |
+
"machine_shape": "hm",
|
| 860 |
+
},
|
| 861 |
+
"kernelspec": {
|
| 862 |
+
"display_name": "Python 3",
|
| 863 |
+
"name": "python3",
|
| 864 |
+
},
|
| 865 |
+
"language_info": {
|
| 866 |
+
"name": "python",
|
| 867 |
+
"version": "3.10",
|
| 868 |
+
},
|
| 869 |
+
},
|
| 870 |
+
"nbformat": 4,
|
| 871 |
+
"nbformat_minor": 5,
|
| 872 |
+
}
|
| 873 |
+
|
| 874 |
+
NB_PATH.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
|
| 875 |
+
print(f"wrote {NB_PATH} ({len(CELLS)} cells)")
|
scripts/_build_inference_notebook.py
ADDED
|
@@ -0,0 +1,676 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Build scripts/cxrvlm_colab_inference.ipynb from cell sources defined here.
|
| 3 |
+
|
| 4 |
+
Run:
|
| 5 |
+
python scripts/_build_inference_notebook.py
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
NB_PATH = Path(__file__).parent / "cxrvlm_colab_inference.ipynb"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def md(cell_id, src):
|
| 15 |
+
return {
|
| 16 |
+
"cell_type": "markdown",
|
| 17 |
+
"id": cell_id,
|
| 18 |
+
"metadata": {},
|
| 19 |
+
"source": src.splitlines(keepends=True),
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def code(cell_id, src):
|
| 24 |
+
return {
|
| 25 |
+
"cell_type": "code",
|
| 26 |
+
"id": cell_id,
|
| 27 |
+
"metadata": {},
|
| 28 |
+
"execution_count": None,
|
| 29 |
+
"outputs": [],
|
| 30 |
+
"source": src.splitlines(keepends=True),
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
CELLS = []
|
| 35 |
+
|
| 36 |
+
# ─────────────────────────────────────────────────────────────────────
|
| 37 |
+
|
| 38 |
+
CELLS.append(md("inf-0", """\
|
| 39 |
+
# CXR-VLM — Inference Notebook (Colab T4 / GCP)
|
| 40 |
+
|
| 41 |
+
Free-form inference on individual chest X-ray images. Use this when you want to:
|
| 42 |
+
- Run the trained model on a **new image** (not in the test split).
|
| 43 |
+
- Ask **custom VQA questions**.
|
| 44 |
+
- See the image + generated text side-by-side for qualitative checks.
|
| 45 |
+
- Iterate on prompts without re-running the heavy `evaluate.py` pipeline.
|
| 46 |
+
|
| 47 |
+
For aggregate test-set metrics use the companion notebook **`cxrvlm_colab_eval.ipynb`**.
|
| 48 |
+
|
| 49 |
+
### Sections
|
| 50 |
+
1. Selectors (which trained model to load + compute device)
|
| 51 |
+
2. Env + pip
|
| 52 |
+
3. Pull code from HF
|
| 53 |
+
4. Pull trained checkpoint + config snapshot from HF
|
| 54 |
+
5. Auto-detect GPU profile (bf16+FA2 on Ampere+/Ada, fp16+SDPA on Turing)
|
| 55 |
+
6. Build configs (4-bit Vicuna with the profile)
|
| 56 |
+
7. **Load model in-kernel** (one-time, ~5–8 min on T4 cold cache, ~3 min on L4)
|
| 57 |
+
8. **Inference helpers** (single image → findings / impression / report / VQA / full cascade)
|
| 58 |
+
9. **Examples** — single image, VQA, batch folder
|
| 59 |
+
"""))
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
CELLS.append(md("inf-select-md", """\
|
| 63 |
+
## 0. Select trained run + device
|
| 64 |
+
"""))
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
CELLS.append(code("inf-select", """\
|
| 68 |
+
# ── Platform ─────────────────────────────────────────────────────
|
| 69 |
+
PLATFORM = 'colab' # 'kaggle' | 'colab' | 'lightning' | 'gcp' | 'local'
|
| 70 |
+
|
| 71 |
+
# ── Source repos on HuggingFace ──────────────────────────────────
|
| 72 |
+
HF_USER = 'hieu3636'
|
| 73 |
+
HF_CODE_REPO = f'{HF_USER}/cxr-vlm-code'
|
| 74 |
+
HF_RUNS_REPO = f'{HF_USER}/cxr-vlm-runs'
|
| 75 |
+
|
| 76 |
+
# ── Which trained run to use ─────────────────────────────────────
|
| 77 |
+
RUN_ID = 'MIMIC-CXR_resized_run_1'
|
| 78 |
+
CKPT_PICK = 'best' # 'best' | 'last'
|
| 79 |
+
|
| 80 |
+
# ── Generation defaults (override per-call later if desired) ─────
|
| 81 |
+
MAX_NEW_TOKENS = 300
|
| 82 |
+
TEMPERATURE = 0.1
|
| 83 |
+
DO_SAMPLE = False # greedy by default — deterministic
|
| 84 |
+
NUM_BEAMS = 1
|
| 85 |
+
|
| 86 |
+
assert PLATFORM in ('kaggle', 'colab', 'lightning', 'gcp', 'local')
|
| 87 |
+
assert CKPT_PICK in ('best', 'last')
|
| 88 |
+
|
| 89 |
+
print(f'PLATFORM = {PLATFORM}')
|
| 90 |
+
print(f'RUN_ID = {RUN_ID} (ckpt: stage2/{CKPT_PICK})')
|
| 91 |
+
"""))
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
CELLS.append(md("inf-env-md", "## 1. Environment + pip install"))
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
CELLS.append(code("inf-env", """\
|
| 98 |
+
import os
|
| 99 |
+
os.environ['CUDA_VISIBLE_DEVICES'] = '0'
|
| 100 |
+
os.environ['TOKENIZERS_PARALLELISM'] = 'false'
|
| 101 |
+
os.environ['BITSANDBYTES_NOWELCOME'] = '1'
|
| 102 |
+
os.environ['TRANSFORMERS_VERBOSITY'] = 'warning'
|
| 103 |
+
os.environ['PYTHONUNBUFFERED'] = '1'
|
| 104 |
+
|
| 105 |
+
import sys, shutil, subprocess
|
| 106 |
+
from pathlib import Path
|
| 107 |
+
"""))
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
CELLS.append(code("inf-pip", """\
|
| 111 |
+
# Same dependency window as the training/eval notebooks.
|
| 112 |
+
!pip uninstall -y -q torchao transformers bitsandbytes peft accelerate
|
| 113 |
+
!pip install -q -U bitsandbytes
|
| 114 |
+
!pip install -q \\
|
| 115 |
+
'transformers>=4.46,<4.50' \\
|
| 116 |
+
'peft>=0.13,<0.15' \\
|
| 117 |
+
'accelerate>=1.0' \\
|
| 118 |
+
'huggingface_hub>=0.27,<1.0' \\
|
| 119 |
+
omegaconf sentencepiece 'protobuf>=3.20' \\
|
| 120 |
+
pillow matplotlib
|
| 121 |
+
|
| 122 |
+
# Best-effort flash-attn install for Ampere+/Ada GPUs (L4, 3090, A10, A100, H100).
|
| 123 |
+
# Silent fail is OK — cxr_vlm.py auto-falls-back to SDPA if FA2 isn't importable.
|
| 124 |
+
import torch as _t
|
| 125 |
+
if _t.cuda.is_available() and _t.cuda.get_device_capability(0) >= (8, 0):
|
| 126 |
+
print('[pip] Ampere+/Ada detected -> trying flash-attn (5-10 min if building from source)')
|
| 127 |
+
!pip install -q flash-attn --no-build-isolation 2>&1 | tail -5
|
| 128 |
+
else:
|
| 129 |
+
print('[pip] Pre-Ampere GPU (or no CUDA) -> skipping flash-attn install')
|
| 130 |
+
"""))
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
CELLS.append(code("inf-versions", """\
|
| 134 |
+
import torch, transformers, peft, huggingface_hub, httpx
|
| 135 |
+
|
| 136 |
+
print('torch :', torch.__version__, '| cuda:', torch.cuda.is_available())
|
| 137 |
+
print('transformers :', transformers.__version__)
|
| 138 |
+
print('peft :', peft.__version__)
|
| 139 |
+
print('huggingface_hub:', huggingface_hub.__version__)
|
| 140 |
+
print('httpx :', httpx.__version__)
|
| 141 |
+
|
| 142 |
+
# httpx 0.28+ shim — same as the other notebooks.
|
| 143 |
+
def _patch_httpx():
|
| 144 |
+
if tuple(int(x) for x in httpx.__version__.split('.')[:2]) < (0, 28):
|
| 145 |
+
return
|
| 146 |
+
if getattr(httpx.Client, '_cxr_vlm_compat_patched', False):
|
| 147 |
+
return
|
| 148 |
+
def _make(orig):
|
| 149 |
+
def patched(self, *args, **kwargs):
|
| 150 |
+
if 'allow_redirects' in kwargs:
|
| 151 |
+
kwargs['follow_redirects'] = kwargs.pop('allow_redirects')
|
| 152 |
+
kwargs.pop('proxies', None)
|
| 153 |
+
return orig(self, *args, **kwargs)
|
| 154 |
+
return patched
|
| 155 |
+
for cls in (httpx.Client, httpx.AsyncClient):
|
| 156 |
+
for m in ('request', 'get', 'head', 'post', 'put',
|
| 157 |
+
'patch', 'delete', 'options'):
|
| 158 |
+
if hasattr(cls, m):
|
| 159 |
+
setattr(cls, m, _make(getattr(cls, m)))
|
| 160 |
+
httpx.Client._cxr_vlm_compat_patched = True
|
| 161 |
+
|
| 162 |
+
_patch_httpx()
|
| 163 |
+
|
| 164 |
+
assert torch.cuda.is_available(), 'CUDA not available.'
|
| 165 |
+
_p = torch.cuda.get_device_properties(0)
|
| 166 |
+
print(f'GPU : {_p.name} ({_p.total_memory/1e9:.1f} GB)')
|
| 167 |
+
"""))
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
CELLS.append(md("inf-paths-md", "## 2. Paths + pull code"))
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
CELLS.append(code("inf-paths", """\
|
| 174 |
+
# WORK dir + HF_TOKEN bootstrap (platform-specific).
|
| 175 |
+
if PLATFORM == 'kaggle':
|
| 176 |
+
from kaggle_secrets import UserSecretsClient
|
| 177 |
+
os.environ['HF_TOKEN'] = UserSecretsClient().get_secret('HF_TOKEN')
|
| 178 |
+
WORK = Path('/kaggle/working')
|
| 179 |
+
elif PLATFORM == 'colab':
|
| 180 |
+
from google.colab import userdata
|
| 181 |
+
os.environ['HF_TOKEN'] = userdata.get('HF_TOKEN')
|
| 182 |
+
WORK = Path('/content')
|
| 183 |
+
elif PLATFORM == 'lightning':
|
| 184 |
+
WORK = Path('/teamspace/studios/this_studio')
|
| 185 |
+
elif PLATFORM == 'gcp':
|
| 186 |
+
# Vertex AI Workbench → /home/jupyter. Generic GCE VM → /workspace.
|
| 187 |
+
# Pick whichever exists; create the latter if neither is writable.
|
| 188 |
+
for _cand in (Path('/home/jupyter'), Path('/workspace')):
|
| 189 |
+
if _cand.exists() or os.access(_cand.parent, os.W_OK):
|
| 190 |
+
WORK = _cand
|
| 191 |
+
break
|
| 192 |
+
else:
|
| 193 |
+
WORK = Path.home() / 'cxr-vlm-work'
|
| 194 |
+
else: # 'local'
|
| 195 |
+
WORK = Path.home() / 'cxr-vlm-work'
|
| 196 |
+
WORK.mkdir(parents=True, exist_ok=True)
|
| 197 |
+
|
| 198 |
+
assert os.environ.get('HF_TOKEN'), \\
|
| 199 |
+
'HF_TOKEN missing — set it via the platform secrets UI before re-running.'
|
| 200 |
+
|
| 201 |
+
from huggingface_hub import snapshot_download
|
| 202 |
+
|
| 203 |
+
print(f'Pulling code from {HF_CODE_REPO} …')
|
| 204 |
+
CODE_SRC = Path(snapshot_download(
|
| 205 |
+
repo_id = HF_CODE_REPO,
|
| 206 |
+
repo_type = 'model',
|
| 207 |
+
token = os.environ['HF_TOKEN'],
|
| 208 |
+
local_dir = str(WORK / 'cxr-vlm-code'),
|
| 209 |
+
))
|
| 210 |
+
|
| 211 |
+
PROJECT = WORK / 'cxr_vlm'
|
| 212 |
+
if CODE_SRC.resolve() != PROJECT.resolve() and not PROJECT.exists():
|
| 213 |
+
shutil.copytree(CODE_SRC, PROJECT)
|
| 214 |
+
|
| 215 |
+
os.chdir(PROJECT)
|
| 216 |
+
sys.path.insert(0, str(PROJECT))
|
| 217 |
+
print('PROJECT :', PROJECT)
|
| 218 |
+
print('WORK :', WORK)
|
| 219 |
+
"""))
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
CELLS.append(md("inf-pull-run-md", """\
|
| 223 |
+
## 3. Pull trained checkpoint + config snapshot
|
| 224 |
+
|
| 225 |
+
Same as the eval notebook: pulls `{RUN_ID}/configs/` + `{RUN_ID}/stage2/{best|last}/` from `HF_RUNS_REPO`. No dataset payload needed for free-form inference.
|
| 226 |
+
"""))
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
CELLS.append(code("inf-pull-run", """\
|
| 230 |
+
from huggingface_hub import snapshot_download
|
| 231 |
+
|
| 232 |
+
RUN_PULL_ROOT = WORK / 'run_pull'
|
| 233 |
+
RUN_PULL_ROOT.mkdir(parents=True, exist_ok=True)
|
| 234 |
+
|
| 235 |
+
print(f'Pulling {RUN_ID}/{{configs,stage2/{CKPT_PICK}}} from {HF_RUNS_REPO} …')
|
| 236 |
+
snapshot_download(
|
| 237 |
+
repo_id = HF_RUNS_REPO,
|
| 238 |
+
repo_type = 'model',
|
| 239 |
+
token = os.environ['HF_TOKEN'],
|
| 240 |
+
allow_patterns = [
|
| 241 |
+
f'{RUN_ID}/configs/**',
|
| 242 |
+
f'{RUN_ID}/run_meta.json',
|
| 243 |
+
f'{RUN_ID}/stage2/{CKPT_PICK}/**',
|
| 244 |
+
],
|
| 245 |
+
local_dir = str(RUN_PULL_ROOT),
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
RUN_DIR_PULLED = RUN_PULL_ROOT / RUN_ID
|
| 249 |
+
CKPT_DIR_PULLED = RUN_DIR_PULLED / 'stage2' / CKPT_PICK
|
| 250 |
+
PROJ_PT = CKPT_DIR_PULLED / 'checkpoint_projection.pt'
|
| 251 |
+
LORA_DIR = CKPT_DIR_PULLED / 'checkpoint_lora'
|
| 252 |
+
CHEXPERT_PT = CKPT_DIR_PULLED / 'checkpoint_chexpert_classifier.pt'
|
| 253 |
+
|
| 254 |
+
assert PROJ_PT.is_file(), f'Projection weights not found at {PROJ_PT}.'
|
| 255 |
+
assert (LORA_DIR / 'adapter_config.json').is_file(), \\
|
| 256 |
+
f'LoRA adapter_config.json not found in {LORA_DIR}.'
|
| 257 |
+
|
| 258 |
+
print(f' projection : {PROJ_PT} ({PROJ_PT.stat().st_size/1e6:.1f} MB)')
|
| 259 |
+
print(f' lora : {LORA_DIR}/ ({sum(p.stat().st_size for p in LORA_DIR.rglob("*") if p.is_file())/1e6:.1f} MB)')
|
| 260 |
+
print(f' chexpert : {CHEXPERT_PT} (exists: {CHEXPERT_PT.is_file()})')
|
| 261 |
+
|
| 262 |
+
SAVED_CFG_DIR = RUN_DIR_PULLED / 'configs'
|
| 263 |
+
SAVED_TRAIN_CFG = SAVED_CFG_DIR / 'train_config.yaml'
|
| 264 |
+
SAVED_MODEL_CFG = SAVED_CFG_DIR / 'model_config.yaml'
|
| 265 |
+
"""))
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
CELLS.append(md("inf-gpu-md", """\
|
| 269 |
+
## 4. Auto-detect GPU profile
|
| 270 |
+
|
| 271 |
+
Picks precision + attention backend based on the actual GPU:
|
| 272 |
+
- **Turing (T4, V100)** → fp16 + SDPA
|
| 273 |
+
- **Ampere+ / Ada (3090, L4, A10, A100, H100)** → bf16 + FA2 (if flash-attn installed; falls back to SDPA otherwise)
|
| 274 |
+
"""))
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
CELLS.append(code("inf-gpu", """\
|
| 278 |
+
import torch
|
| 279 |
+
|
| 280 |
+
assert torch.cuda.is_available(), 'CUDA not available.'
|
| 281 |
+
_p = torch.cuda.get_device_properties(0)
|
| 282 |
+
_cap = (_p.major, _p.minor)
|
| 283 |
+
_vram_gb = _p.total_memory / 1e9
|
| 284 |
+
_bf16_ok = torch.cuda.is_bf16_supported()
|
| 285 |
+
_fa2_ok = _cap >= (8, 0)
|
| 286 |
+
|
| 287 |
+
_flash_attn_installed = False
|
| 288 |
+
if _fa2_ok:
|
| 289 |
+
try:
|
| 290 |
+
import flash_attn # noqa: F401
|
| 291 |
+
_flash_attn_installed = True
|
| 292 |
+
except Exception:
|
| 293 |
+
_flash_attn_installed = False
|
| 294 |
+
|
| 295 |
+
PROFILE = dict(
|
| 296 |
+
label = _p.name,
|
| 297 |
+
torch_dtype = 'bfloat16' if _bf16_ok else 'float16',
|
| 298 |
+
bnb_4bit_compute_dtype = 'bfloat16' if _bf16_ok else 'float16',
|
| 299 |
+
attn_implementation = 'flash_attention_2' if (_fa2_ok and _flash_attn_installed) else 'sdpa',
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
print(f'GPU : {_p.name} ({_vram_gb:.1f} GB)')
|
| 303 |
+
print(f'Compute cap : sm_{_cap[0]}{_cap[1]} bf16 ok: {_bf16_ok} FA2 wheel: {_flash_attn_installed}')
|
| 304 |
+
print(f'-> precision: {PROFILE["torch_dtype"]} attn: {PROFILE["attn_implementation"]}')
|
| 305 |
+
"""))
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
CELLS.append(md("inf-cfg-md", "## 5. Build configs"))
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
CELLS.append(code("inf-cfg", """\
|
| 312 |
+
from omegaconf import OmegaConf
|
| 313 |
+
|
| 314 |
+
if SAVED_MODEL_CFG.is_file():
|
| 315 |
+
model_cfg = OmegaConf.load(SAVED_MODEL_CFG)
|
| 316 |
+
print(f'model_cfg <- {SAVED_MODEL_CFG}')
|
| 317 |
+
else:
|
| 318 |
+
model_cfg = OmegaConf.load(PROJECT / 'configs' / 'model_config.yaml')
|
| 319 |
+
print(f'model_cfg <- repo default')
|
| 320 |
+
|
| 321 |
+
if SAVED_TRAIN_CFG.is_file():
|
| 322 |
+
train_cfg = OmegaConf.load(SAVED_TRAIN_CFG)
|
| 323 |
+
else:
|
| 324 |
+
train_cfg = OmegaConf.load(PROJECT / 'configs' / 'train_config.yaml')
|
| 325 |
+
|
| 326 |
+
# Compute from auto-detected PROFILE (previous cell).
|
| 327 |
+
model_cfg.llm.load_in_4bit = True
|
| 328 |
+
model_cfg.llm.load_in_8bit = False
|
| 329 |
+
model_cfg.llm.attn_implementation = PROFILE['attn_implementation']
|
| 330 |
+
model_cfg.llm.torch_dtype = PROFILE['torch_dtype']
|
| 331 |
+
model_cfg.llm.bnb_4bit_compute_dtype = PROFILE['bnb_4bit_compute_dtype']
|
| 332 |
+
model_cfg.llm.bnb_4bit_quant_type = 'nf4'
|
| 333 |
+
model_cfg.llm.bnb_4bit_use_double_quant = True
|
| 334 |
+
model_cfg.llm.gradient_checkpointing = False # inference only
|
| 335 |
+
|
| 336 |
+
# CheXpert classifier — enable iff its checkpoint is pulled.
|
| 337 |
+
if CHEXPERT_PT.is_file():
|
| 338 |
+
model_cfg.chexpert_classifier.enabled = True
|
| 339 |
+
print(f'CheXpert classifier checkpoint found -> ENABLED')
|
| 340 |
+
else:
|
| 341 |
+
model_cfg.chexpert_classifier.enabled = False
|
| 342 |
+
print('No CheXpert classifier in this run -> DISABLED '
|
| 343 |
+
'(structured_findings will be None unless you pass it explicitly)')
|
| 344 |
+
|
| 345 |
+
print('\\nmodel_cfg.llm:')
|
| 346 |
+
print(OmegaConf.to_yaml(model_cfg.llm))
|
| 347 |
+
"""))
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
CELLS.append(md("inf-load-md", """\
|
| 351 |
+
## 6. Load model into kernel
|
| 352 |
+
|
| 353 |
+
This is the slow phase on cold cache: encoder + 4-bit Vicuna shard download + quantization. Run **once per session** and reuse the `model` object for all subsequent inferences.
|
| 354 |
+
|
| 355 |
+
Approx timing: ~8 min on T4 cold, ~3 min on L4 cold, seconds when cache is warm.
|
| 356 |
+
"""))
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
CELLS.append(code("inf-load", """\
|
| 360 |
+
import time, torch
|
| 361 |
+
from model import CXRVisionLanguageModel
|
| 362 |
+
from model.rad_dino import BioViLTEncoder
|
| 363 |
+
from utils.checkpoint import load_checkpoint
|
| 364 |
+
|
| 365 |
+
print('[1/3] Building model (Vicuna-7B 4-bit + RAD-DINO + LoRA)…')
|
| 366 |
+
print(' On T4 cold cache this takes ~5–8 min for the shard download + quantization.')
|
| 367 |
+
_t0 = time.time()
|
| 368 |
+
model = CXRVisionLanguageModel(model_cfg)
|
| 369 |
+
print(f' built in {time.time()-_t0:.1f}s', flush=True)
|
| 370 |
+
|
| 371 |
+
print(f'[2/3] Loading checkpoint from {PROJ_PT.name} …')
|
| 372 |
+
_t0 = time.time()
|
| 373 |
+
load_checkpoint(model, str(PROJ_PT))
|
| 374 |
+
print(f' loaded in {time.time()-_t0:.1f}s', flush=True)
|
| 375 |
+
|
| 376 |
+
print('[3/3] Moving to cuda + eval()')
|
| 377 |
+
_t0 = time.time()
|
| 378 |
+
model = model.to('cuda').eval()
|
| 379 |
+
print(f' ready in {time.time()-_t0:.1f}s', flush=True)
|
| 380 |
+
|
| 381 |
+
# Cache transform + chexpert helpers for reuse.
|
| 382 |
+
TRANSFORM = BioViLTEncoder.get_transform('val')
|
| 383 |
+
print('\\nModel ready. VRAM used:',
|
| 384 |
+
f'{torch.cuda.memory_allocated()/1e9:.2f} GB / {torch.cuda.get_device_properties(0).total_memory/1e9:.1f} GB')
|
| 385 |
+
"""))
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
CELLS.append(md("inf-helpers-md", """\
|
| 389 |
+
## 7. Inference helpers
|
| 390 |
+
|
| 391 |
+
Two convenience functions:
|
| 392 |
+
|
| 393 |
+
- `predict(image, task, question=None, structured_findings=None, ...)` — single image, single task.
|
| 394 |
+
- `predict_report(image, ...)` — cascade: generates findings first, then feeds the model's own findings as context for impression. Returns both sections plus the merged report.
|
| 395 |
+
|
| 396 |
+
`image` can be a filesystem path (str/Path) or a `PIL.Image.Image`.
|
| 397 |
+
|
| 398 |
+
### About `structured_findings` (the PNU prompt block)
|
| 399 |
+
|
| 400 |
+
Your training run baked **oracle CheXpert labels from the CSV/manifest** into the prompt as the `Positive / Negative / Uncertain Abnormalities` block. At inference time you don't have GT labels — you have three choices:
|
| 401 |
+
|
| 402 |
+
1. **`None` (default)** — no PNU block prepended. The model sees only the image + the task instruction. This is the realistic deployment mode.
|
| 403 |
+
2. **Pass a PNU string yourself** — for oracle ablation or testing how much PNU helps:
|
| 404 |
+
```
|
| 405 |
+
structured_findings='Positive Abnormalities: Cardiomegaly\\nNegative Abnormalities: No Finding, ...\\nUncertain Abnormalities: None'
|
| 406 |
+
```
|
| 407 |
+
3. **Train a CheXpert classifier** (Stage 0 in the training pipeline) and load it via `model_cfg.chexpert_classifier.checkpoint` — your current runs do not have one, so this path is disabled in section 4.
|
| 408 |
+
"""))
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
CELLS.append(code("inf-helpers", """\
|
| 412 |
+
import torch
|
| 413 |
+
from pathlib import Path
|
| 414 |
+
from typing import Optional, Union
|
| 415 |
+
from PIL import Image
|
| 416 |
+
|
| 417 |
+
from data.prompt_templates import (
|
| 418 |
+
build_findings_prompt, build_impression_prompt,
|
| 419 |
+
build_report_prompt, build_vqa_prompt,
|
| 420 |
+
)
|
| 421 |
+
|
| 422 |
+
ImageLike = Union[str, Path, Image.Image]
|
| 423 |
+
|
| 424 |
+
|
| 425 |
+
def _to_tensor(image: ImageLike) -> torch.Tensor:
|
| 426 |
+
'''Load + transform a CXR into a (1, C, H, W) tensor on cuda.'''
|
| 427 |
+
if isinstance(image, (str, Path)):
|
| 428 |
+
img = Image.open(image).convert('RGB')
|
| 429 |
+
else:
|
| 430 |
+
img = image.convert('RGB') if image.mode != 'RGB' else image
|
| 431 |
+
t = TRANSFORM(img) # (C, H, W)
|
| 432 |
+
return t.unsqueeze(0).to('cuda') # (1, C, H, W)
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
@torch.no_grad()
|
| 436 |
+
def predict(
|
| 437 |
+
image: ImageLike,
|
| 438 |
+
task: str,
|
| 439 |
+
question: Optional[str] = None,
|
| 440 |
+
structured_findings: Optional[str] = None,
|
| 441 |
+
max_new_tokens: int = None,
|
| 442 |
+
temperature: float = None,
|
| 443 |
+
do_sample: bool = None,
|
| 444 |
+
num_beams: int = None,
|
| 445 |
+
) -> str:
|
| 446 |
+
'''Run the model on one image.
|
| 447 |
+
|
| 448 |
+
task ∈ {'findings', 'impression', 'report', 'vqa'}.
|
| 449 |
+
For task='vqa', `question` is required.
|
| 450 |
+
'''
|
| 451 |
+
assert task in ('findings', 'impression', 'report', 'vqa'), f'bad task: {task}'
|
| 452 |
+
if task == 'vqa':
|
| 453 |
+
assert question, 'VQA requires `question`.'
|
| 454 |
+
|
| 455 |
+
sf = structured_findings
|
| 456 |
+
if task == 'findings':
|
| 457 |
+
prompt = build_findings_prompt(sf, randomize=False)
|
| 458 |
+
elif task == 'impression':
|
| 459 |
+
prompt = build_impression_prompt(sf, randomize=False)
|
| 460 |
+
elif task == 'report':
|
| 461 |
+
prompt = build_report_prompt(sf, randomize=False)
|
| 462 |
+
else:
|
| 463 |
+
prompt = build_vqa_prompt(question, sf)
|
| 464 |
+
|
| 465 |
+
out = model.generate(
|
| 466 |
+
images = _to_tensor(image),
|
| 467 |
+
prompts = [prompt],
|
| 468 |
+
max_new_tokens = max_new_tokens if max_new_tokens is not None else MAX_NEW_TOKENS,
|
| 469 |
+
temperature = temperature if temperature is not None else TEMPERATURE,
|
| 470 |
+
do_sample = do_sample if do_sample is not None else DO_SAMPLE,
|
| 471 |
+
num_beams = num_beams if num_beams is not None else NUM_BEAMS,
|
| 472 |
+
)
|
| 473 |
+
return out[0]
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
@torch.no_grad()
|
| 477 |
+
def predict_report(
|
| 478 |
+
image: ImageLike,
|
| 479 |
+
structured_findings: Optional[str] = None,
|
| 480 |
+
max_new_tokens: int = None,
|
| 481 |
+
):
|
| 482 |
+
'''Cascade: generate findings first, then feed them as context for impression.
|
| 483 |
+
|
| 484 |
+
Matches the `split_cascade` training recipe but with model-generated (not
|
| 485 |
+
GT) findings — i.e. a real end-to-end cascade (no GT leakage).
|
| 486 |
+
|
| 487 |
+
Returns dict {findings, impression, report}.
|
| 488 |
+
'''
|
| 489 |
+
# 1) Findings (uses PNU context only — None by default)
|
| 490 |
+
findings = predict(image, task='findings',
|
| 491 |
+
structured_findings=structured_findings,
|
| 492 |
+
max_new_tokens=max_new_tokens)
|
| 493 |
+
|
| 494 |
+
# 2) Impression: feed the generated findings as the structured_findings
|
| 495 |
+
# context — same shape as the cascade trainer saw at training time
|
| 496 |
+
# but with the model's own findings instead of GT.
|
| 497 |
+
impression = predict(image, task='impression',
|
| 498 |
+
structured_findings=findings,
|
| 499 |
+
max_new_tokens=max_new_tokens)
|
| 500 |
+
|
| 501 |
+
return {
|
| 502 |
+
'findings': findings,
|
| 503 |
+
'impression': impression,
|
| 504 |
+
'report': f'Findings: {findings}\\n\\nImpression: {impression}',
|
| 505 |
+
}
|
| 506 |
+
"""))
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
CELLS.append(md("inf-show-md", "## 8. Display helper (optional, pretty-prints image + outputs)"))
|
| 510 |
+
|
| 511 |
+
|
| 512 |
+
CELLS.append(code("inf-show", """\
|
| 513 |
+
import matplotlib.pyplot as plt
|
| 514 |
+
from PIL import Image as _PILImage
|
| 515 |
+
|
| 516 |
+
def show(image, title=None, max_size=512):
|
| 517 |
+
'''Render an image inline with optional title.'''
|
| 518 |
+
if isinstance(image, (str, Path)):
|
| 519 |
+
img = _PILImage.open(image).convert('RGB')
|
| 520 |
+
else:
|
| 521 |
+
img = image
|
| 522 |
+
img.thumbnail((max_size, max_size))
|
| 523 |
+
plt.figure(figsize=(6, 6))
|
| 524 |
+
plt.imshow(img)
|
| 525 |
+
plt.axis('off')
|
| 526 |
+
if title:
|
| 527 |
+
plt.title(title)
|
| 528 |
+
plt.show()
|
| 529 |
+
|
| 530 |
+
def pretty(d: dict, header: str = None):
|
| 531 |
+
'''Pretty-print a dict of {label: text}.'''
|
| 532 |
+
if header:
|
| 533 |
+
print('=' * 80)
|
| 534 |
+
print(header)
|
| 535 |
+
for k, v in d.items():
|
| 536 |
+
print('-' * 80)
|
| 537 |
+
print(f'[{k.upper()}]')
|
| 538 |
+
print(v)
|
| 539 |
+
print('=' * 80)
|
| 540 |
+
"""))
|
| 541 |
+
|
| 542 |
+
|
| 543 |
+
CELLS.append(md("inf-ex1-md", """\
|
| 544 |
+
## 9. Examples
|
| 545 |
+
|
| 546 |
+
### Example A — single image, all tasks
|
| 547 |
+
|
| 548 |
+
Edit `IMAGE_PATH` and `VQA_QUESTION` below, then run.
|
| 549 |
+
"""))
|
| 550 |
+
|
| 551 |
+
|
| 552 |
+
CELLS.append(code("inf-ex1", """\
|
| 553 |
+
# Pick an image — anywhere on disk. Colab: drag-and-drop into the Files pane
|
| 554 |
+
# (left sidebar) and use '/content/<filename>'. Local: any path that exists.
|
| 555 |
+
IMAGE_PATH = '/content/sample_cxr.jpg'
|
| 556 |
+
|
| 557 |
+
# Optional PNU override (see helpers section for format). None → no PNU block.
|
| 558 |
+
STRUCTURED_FINDINGS = None
|
| 559 |
+
|
| 560 |
+
# A clinical question for the VQA call below.
|
| 561 |
+
VQA_QUESTION = 'Is there any pleural effusion visible in this chest X-ray?'
|
| 562 |
+
|
| 563 |
+
if not Path(IMAGE_PATH).is_file():
|
| 564 |
+
print(f'!! {IMAGE_PATH} does not exist. Upload an image or change IMAGE_PATH.')
|
| 565 |
+
else:
|
| 566 |
+
show(IMAGE_PATH, title=Path(IMAGE_PATH).name)
|
| 567 |
+
|
| 568 |
+
if STRUCTURED_FINDINGS:
|
| 569 |
+
print('Structured findings (PNU) override:')
|
| 570 |
+
print(STRUCTURED_FINDINGS)
|
| 571 |
+
print()
|
| 572 |
+
|
| 573 |
+
findings = predict(IMAGE_PATH, task='findings',
|
| 574 |
+
structured_findings=STRUCTURED_FINDINGS)
|
| 575 |
+
impression = predict(IMAGE_PATH, task='impression',
|
| 576 |
+
structured_findings=STRUCTURED_FINDINGS)
|
| 577 |
+
vqa_ans = predict(IMAGE_PATH, task='vqa', question=VQA_QUESTION,
|
| 578 |
+
structured_findings=STRUCTURED_FINDINGS)
|
| 579 |
+
|
| 580 |
+
pretty({
|
| 581 |
+
'findings': findings,
|
| 582 |
+
'impression': impression,
|
| 583 |
+
f'vqa ({VQA_QUESTION!r})': vqa_ans,
|
| 584 |
+
}, header=Path(IMAGE_PATH).name)
|
| 585 |
+
"""))
|
| 586 |
+
|
| 587 |
+
|
| 588 |
+
CELLS.append(md("inf-ex2-md", """\
|
| 589 |
+
### Example B — cascade report (findings → impression on model output)
|
| 590 |
+
"""))
|
| 591 |
+
|
| 592 |
+
|
| 593 |
+
CELLS.append(code("inf-ex2", """\
|
| 594 |
+
if Path(IMAGE_PATH).is_file():
|
| 595 |
+
out = predict_report(IMAGE_PATH)
|
| 596 |
+
show(IMAGE_PATH, title='Cascade report')
|
| 597 |
+
pretty(out, header=f'Cascade: {Path(IMAGE_PATH).name}')
|
| 598 |
+
"""))
|
| 599 |
+
|
| 600 |
+
|
| 601 |
+
CELLS.append(md("inf-ex3-md", """\
|
| 602 |
+
### Example C — batch a folder of images
|
| 603 |
+
|
| 604 |
+
Drops all results into `BATCH_OUT_JSON` for downstream analysis.
|
| 605 |
+
"""))
|
| 606 |
+
|
| 607 |
+
|
| 608 |
+
CELLS.append(code("inf-ex3", """\
|
| 609 |
+
import json
|
| 610 |
+
from tqdm.auto import tqdm
|
| 611 |
+
|
| 612 |
+
BATCH_FOLDER = '/content/cxr_inbox' # folder of *.jpg / *.png
|
| 613 |
+
BATCH_TASK = 'report' # 'findings' | 'impression' | 'report' | 'vqa' | 'cascade'
|
| 614 |
+
BATCH_QUESTION = None # only used when BATCH_TASK == 'vqa'
|
| 615 |
+
BATCH_OUT_JSON = WORK / f'inference_{RUN_ID}_{BATCH_TASK}.json'
|
| 616 |
+
|
| 617 |
+
EXTS = {'.jpg', '.jpeg', '.png'}
|
| 618 |
+
folder = Path(BATCH_FOLDER)
|
| 619 |
+
imgs = sorted(p for p in folder.rglob('*') if p.suffix.lower() in EXTS) \\
|
| 620 |
+
if folder.is_dir() else []
|
| 621 |
+
print(f'{len(imgs)} image(s) under {folder}')
|
| 622 |
+
|
| 623 |
+
results = []
|
| 624 |
+
for p in tqdm(imgs, desc=f'Inference [{BATCH_TASK}]'):
|
| 625 |
+
try:
|
| 626 |
+
if BATCH_TASK == 'cascade':
|
| 627 |
+
out = predict_report(p)
|
| 628 |
+
else:
|
| 629 |
+
out = {BATCH_TASK: predict(p, task=BATCH_TASK,
|
| 630 |
+
question=BATCH_QUESTION)}
|
| 631 |
+
results.append({'image': str(p), **out})
|
| 632 |
+
except Exception as e:
|
| 633 |
+
results.append({'image': str(p), 'error': f'{type(e).__name__}: {e}'})
|
| 634 |
+
|
| 635 |
+
if results:
|
| 636 |
+
BATCH_OUT_JSON.write_text(json.dumps(results, indent=2))
|
| 637 |
+
print(f'Wrote {len(results)} entries -> {BATCH_OUT_JSON}')
|
| 638 |
+
"""))
|
| 639 |
+
|
| 640 |
+
|
| 641 |
+
CELLS.append(md("inf-done-md", """\
|
| 642 |
+
### Tips
|
| 643 |
+
|
| 644 |
+
- Loading the model takes 5–8 min on T4 cold cache. After that, each generation is ~3–8 s.
|
| 645 |
+
- To switch to a different trained run: change `RUN_ID` in section 0, re-run sections 3–5 (skip the env / pip sections).
|
| 646 |
+
- For deterministic outputs keep `DO_SAMPLE=False`. For diversity, set `DO_SAMPLE=True, TEMPERATURE=0.7`.
|
| 647 |
+
- `predict_report()` is the realistic end-to-end pipeline (no GT leakage). `predict(task='report')` only works on runs trained with `report_mode='merged'`.
|
| 648 |
+
"""))
|
| 649 |
+
|
| 650 |
+
|
| 651 |
+
# ─────────────────────────────────────────────────────────────────────
|
| 652 |
+
|
| 653 |
+
nb = {
|
| 654 |
+
"cells": CELLS,
|
| 655 |
+
"metadata": {
|
| 656 |
+
"accelerator": "GPU",
|
| 657 |
+
"colab": {
|
| 658 |
+
"gpuType": "T4",
|
| 659 |
+
"provenance": [],
|
| 660 |
+
"machine_shape": "hm",
|
| 661 |
+
},
|
| 662 |
+
"kernelspec": {
|
| 663 |
+
"display_name": "Python 3",
|
| 664 |
+
"name": "python3",
|
| 665 |
+
},
|
| 666 |
+
"language_info": {
|
| 667 |
+
"name": "python",
|
| 668 |
+
"version": "3.10",
|
| 669 |
+
},
|
| 670 |
+
},
|
| 671 |
+
"nbformat": 4,
|
| 672 |
+
"nbformat_minor": 5,
|
| 673 |
+
}
|
| 674 |
+
|
| 675 |
+
NB_PATH.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
|
| 676 |
+
print(f"wrote {NB_PATH} ({len(CELLS)} cells)")
|
scripts/cxrvlm_colab_eval.ipynb
ADDED
|
@@ -0,0 +1,917 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "markdown",
|
| 5 |
+
"id": "eval-0",
|
| 6 |
+
"metadata": {},
|
| 7 |
+
"source": [
|
| 8 |
+
"# CXR-VLM — Evaluation Notebook (Colab T4)\n",
|
| 9 |
+
"\n",
|
| 10 |
+
"Standalone evaluation + inference for a trained CXR-VLM run.\n",
|
| 11 |
+
"\n",
|
| 12 |
+
"What it does:\n",
|
| 13 |
+
"1. Pulls project code from `<HF_USER>/cxr-vlm-code`.\n",
|
| 14 |
+
"2. Pulls the chosen dataset from `<HF_USER>/cxr-vlm-data` (same layout the trainer used).\n",
|
| 15 |
+
"3. Pulls a trained run checkpoint from `<HF_USER>/cxr-vlm-runs/<RUN_ID>/stage2/{best|last}/`.\n",
|
| 16 |
+
"4. Pulls the run's config snapshot (`<RUN_ID>/configs/{model,train}_config.yaml`) so the model is rebuilt **with the exact same architecture / report_mode / image_mode** as training.\n",
|
| 17 |
+
"5. **Auto-detects the GPU** (T4 / L4 / 3090 / A10 / A100 / H100) and patches the configs accordingly — fp16+SDPA on Turing, bf16+FA2 on Ampere/Ada, batch size scaled to VRAM. Also patches local dataset paths.\n",
|
| 18 |
+
"6. Runs `python -m evaluation.evaluate` on the **test split** for all available tasks.\n",
|
| 19 |
+
"7. Saves predictions + metrics under `RESULTS_DIR/<RUN_ID>/` (and optionally uploads them to `<HF_USER>/cxr-vlm-runs/<RUN_ID>/results/`).\n",
|
| 20 |
+
"\n",
|
| 21 |
+
"Set the variables in **section 0** and run all cells top-to-bottom.\n",
|
| 22 |
+
"\n",
|
| 23 |
+
"**Want interactive inference on individual images?** Use the companion notebook **`cxrvlm_colab_inference.ipynb`** — same model pull, but with image preview + free-form prompts.\n"
|
| 24 |
+
]
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"cell_type": "markdown",
|
| 28 |
+
"id": "eval-select-md",
|
| 29 |
+
"metadata": {},
|
| 30 |
+
"source": [
|
| 31 |
+
"## 0. Select run + dataset + options\n",
|
| 32 |
+
"\n",
|
| 33 |
+
"Change the variables in the cell below. `RUN_ID` decides which trained model is pulled; everything else (dataset name, report/image mode) defaults to whatever was used at training time (read from the run's config snapshot on HF), but you can override.\n"
|
| 34 |
+
]
|
| 35 |
+
},
|
| 36 |
+
{
|
| 37 |
+
"cell_type": "code",
|
| 38 |
+
"id": "eval-select",
|
| 39 |
+
"metadata": {},
|
| 40 |
+
"execution_count": null,
|
| 41 |
+
"outputs": [],
|
| 42 |
+
"source": [
|
| 43 |
+
"# ── Platform ─────────────────────────────────────────────────────\n",
|
| 44 |
+
"PLATFORM = 'colab' # 'kaggle' | 'colab' | 'lightning' | 'gcp' | 'local'\n",
|
| 45 |
+
"\n",
|
| 46 |
+
"# ── Source repos on HuggingFace ──────────────────────────────────\n",
|
| 47 |
+
"HF_USER = 'hieu3636' # owner of cxr-vlm-{code,data,runs}\n",
|
| 48 |
+
"HF_CODE_REPO = f'{HF_USER}/cxr-vlm-code'\n",
|
| 49 |
+
"HF_DATA_REPO = f'{HF_USER}/cxr-vlm-data'\n",
|
| 50 |
+
"HF_RUNS_REPO = f'{HF_USER}/cxr-vlm-runs'\n",
|
| 51 |
+
"\n",
|
| 52 |
+
"# ── Which trained run to evaluate ────────────────────────────────\n",
|
| 53 |
+
"# This MUST be an existing folder on HF_RUNS_REPO.\n",
|
| 54 |
+
"# Example: 'MIMIC-CXR_resized_run_1' | 'IU-Xray_run_2' | 'MIMIC-CXR_run_3'\n",
|
| 55 |
+
"RUN_ID = 'MIMIC-CXR_resized_run_1'\n",
|
| 56 |
+
"\n",
|
| 57 |
+
"# Which stage-2 checkpoint to load.\n",
|
| 58 |
+
"# 'best' → {RUN_ID}/stage2/best/ (final / best eval_loss)\n",
|
| 59 |
+
"# 'last' → {RUN_ID}/stage2/last/ (most recent intermediate save)\n",
|
| 60 |
+
"CKPT_PICK = 'best'\n",
|
| 61 |
+
"\n",
|
| 62 |
+
"# ── Dataset (auto-derived from RUN_ID prefix; override if needed) ──\n",
|
| 63 |
+
"# Supported: 'MIMIC-CXR' | 'MIMIC-CXR_resized' | 'IU-Xray'\n",
|
| 64 |
+
"DATASET_NAME = None # None → auto-detect from RUN_ID prefix\n",
|
| 65 |
+
"REPORT_MODE = None # None → read from run's saved train_config.yaml\n",
|
| 66 |
+
"IMAGE_MODE = None # None → read from run's saved train_config.yaml\n",
|
| 67 |
+
"\n",
|
| 68 |
+
"# ── What to evaluate ─────────────────────────────────────────────\n",
|
| 69 |
+
"TASK = 'all' # 'all' | 'findings' | 'impression' | 'report' | 'vqa'\n",
|
| 70 |
+
"SPLIT = 'test' # always 'test' for this notebook; here for visibility\n",
|
| 71 |
+
"BATCH_SIZE = None # None → auto from GPU profile (T4:1, L4:4, A100:8, H100:16)\n",
|
| 72 |
+
"MAX_NEW_TOKENS = 300\n",
|
| 73 |
+
"\n",
|
| 74 |
+
"# ── LLM-as-judge for VQA (optional; needs OPENAI_API_KEY) ────────\n",
|
| 75 |
+
"LLM_JUDGE = False\n",
|
| 76 |
+
"LLM_JUDGE_MODEL = 'gpt-4o-mini'\n",
|
| 77 |
+
"LLM_JUDGE_BASE_URL = None # e.g. Gemini OpenAI-compat endpoint\n",
|
| 78 |
+
"LLM_JUDGE_MAX_SAMPLES = None # cap cost; None → all VQA samples\n",
|
| 79 |
+
"\n",
|
| 80 |
+
"# ── Output ───────────────────────────────────────────────────────\n",
|
| 81 |
+
"# Local folder where predictions_*.json + metrics_summary.json land.\n",
|
| 82 |
+
"# Files end up at {LOCAL_RESULTS_DIR}/{RUN_ID}/...\n",
|
| 83 |
+
"LOCAL_RESULTS_DIR = 'results'\n",
|
| 84 |
+
"\n",
|
| 85 |
+
"# Push the results folder back to HF_RUNS_REPO under\n",
|
| 86 |
+
"# {RUN_ID}/results/predictions_*.json + metrics_summary.json\n",
|
| 87 |
+
"UPLOAD_RESULTS_TO_HF = True\n",
|
| 88 |
+
"\n",
|
| 89 |
+
"# ── Auto-derive DATASET_NAME from RUN_ID prefix if not set ───────\n",
|
| 90 |
+
"if DATASET_NAME is None:\n",
|
| 91 |
+
" for cand in ('MIMIC-CXR_resized', 'MIMIC-CXR', 'IU-Xray'):\n",
|
| 92 |
+
" if RUN_ID.startswith(cand + '_run_'):\n",
|
| 93 |
+
" DATASET_NAME = cand\n",
|
| 94 |
+
" break\n",
|
| 95 |
+
" assert DATASET_NAME is not None, \\\n",
|
| 96 |
+
" f\"Cannot auto-derive DATASET_NAME from RUN_ID={RUN_ID!r}. \" \\\n",
|
| 97 |
+
" \"Expected prefix one of: MIMIC-CXR_resized / MIMIC-CXR / IU-Xray. Set DATASET_NAME explicitly.\"\n",
|
| 98 |
+
"\n",
|
| 99 |
+
"assert PLATFORM in ('kaggle', 'colab', 'lightning', 'gcp', 'local')\n",
|
| 100 |
+
"assert DATASET_NAME in ('MIMIC-CXR', 'MIMIC-CXR_resized', 'IU-Xray')\n",
|
| 101 |
+
"assert CKPT_PICK in ('best', 'last')\n",
|
| 102 |
+
"assert TASK in ('all', 'findings', 'impression', 'report', 'vqa')\n",
|
| 103 |
+
"\n",
|
| 104 |
+
"print(f'PLATFORM = {PLATFORM}')\n",
|
| 105 |
+
"print(f'RUN_ID = {RUN_ID} (ckpt: stage2/{CKPT_PICK})')\n",
|
| 106 |
+
"print(f'DATASET_NAME = {DATASET_NAME}')\n",
|
| 107 |
+
"print(f'TASK = {TASK} SPLIT = {SPLIT}')\n",
|
| 108 |
+
"print(f'LOCAL_RESULTS_DIR = {LOCAL_RESULTS_DIR} (upload→HF: {UPLOAD_RESULTS_TO_HF})')\n"
|
| 109 |
+
]
|
| 110 |
+
},
|
| 111 |
+
{
|
| 112 |
+
"cell_type": "markdown",
|
| 113 |
+
"id": "eval-env-md",
|
| 114 |
+
"metadata": {},
|
| 115 |
+
"source": [
|
| 116 |
+
"## 1. Environment + pip install (matches the training notebook)\n"
|
| 117 |
+
]
|
| 118 |
+
},
|
| 119 |
+
{
|
| 120 |
+
"cell_type": "code",
|
| 121 |
+
"id": "eval-env",
|
| 122 |
+
"metadata": {},
|
| 123 |
+
"execution_count": null,
|
| 124 |
+
"outputs": [],
|
| 125 |
+
"source": [
|
| 126 |
+
"import os\n",
|
| 127 |
+
"os.environ['CUDA_VISIBLE_DEVICES'] = '0' # single-GPU\n",
|
| 128 |
+
"os.environ['TOKENIZERS_PARALLELISM'] = 'false'\n",
|
| 129 |
+
"os.environ['BITSANDBYTES_NOWELCOME'] = '1'\n",
|
| 130 |
+
"os.environ['HF_HUB_DISABLE_PROGRESS_BARS'] = '1'\n",
|
| 131 |
+
"os.environ['TRANSFORMERS_VERBOSITY'] = 'warning'\n",
|
| 132 |
+
"os.environ['PYTHONUNBUFFERED'] = '1'\n",
|
| 133 |
+
"\n",
|
| 134 |
+
"import sys, shutil, subprocess\n",
|
| 135 |
+
"from pathlib import Path\n"
|
| 136 |
+
]
|
| 137 |
+
},
|
| 138 |
+
{
|
| 139 |
+
"cell_type": "code",
|
| 140 |
+
"id": "eval-pip",
|
| 141 |
+
"metadata": {},
|
| 142 |
+
"execution_count": null,
|
| 143 |
+
"outputs": [],
|
| 144 |
+
"source": [
|
| 145 |
+
"import os as _os\n",
|
| 146 |
+
"\n",
|
| 147 |
+
"# Same dependency set as the training notebook — keeps load_checkpoint /\n",
|
| 148 |
+
"# evaluate.py running against the exact stack the model was trained with.\n",
|
| 149 |
+
"!pip uninstall -y -q torchao transformers bitsandbytes peft accelerate\n",
|
| 150 |
+
"\n",
|
| 151 |
+
"# Let pip pick latest bnb that matches Colab's CUDA + triton.\n",
|
| 152 |
+
"!pip install -q -U bitsandbytes\n",
|
| 153 |
+
"\n",
|
| 154 |
+
"# Pin transformers / peft to the same window the trainer uses.\n",
|
| 155 |
+
"!pip install -q \\\n",
|
| 156 |
+
" 'transformers>=4.46,<4.50' \\\n",
|
| 157 |
+
" 'peft>=0.13,<0.15' \\\n",
|
| 158 |
+
" 'accelerate>=1.0' \\\n",
|
| 159 |
+
" 'huggingface_hub>=0.27,<1.0' \\\n",
|
| 160 |
+
" omegaconf sentencepiece 'protobuf>=3.20' \\\n",
|
| 161 |
+
" nltk rouge-score bert-score sacrebleu\n",
|
| 162 |
+
"\n",
|
| 163 |
+
"# Best-effort flash-attn install for Ampere+/Ada GPUs (L4, 3090, A10, A100, H100).\n",
|
| 164 |
+
"# Silent fail is OK — cxr_vlm.py auto-falls-back to SDPA if FA2 isn't importable.\n",
|
| 165 |
+
"# Skipped entirely on Turing (T4/V100) since FA2 requires sm_80+.\n",
|
| 166 |
+
"import torch as _t\n",
|
| 167 |
+
"if _t.cuda.is_available() and _t.cuda.get_device_capability(0) >= (8, 0):\n",
|
| 168 |
+
" print('[pip] Ampere+/Ada detected -> trying flash-attn (5-10 min if building from source)')\n",
|
| 169 |
+
" !pip install -q flash-attn --no-build-isolation 2>&1 | tail -5\n",
|
| 170 |
+
"else:\n",
|
| 171 |
+
" print('[pip] Pre-Ampere GPU (or no CUDA) -> skipping flash-attn install')\n"
|
| 172 |
+
]
|
| 173 |
+
},
|
| 174 |
+
{
|
| 175 |
+
"cell_type": "code",
|
| 176 |
+
"id": "eval-versions",
|
| 177 |
+
"metadata": {},
|
| 178 |
+
"execution_count": null,
|
| 179 |
+
"outputs": [],
|
| 180 |
+
"source": [
|
| 181 |
+
"import torch, transformers, bitsandbytes, peft, accelerate, huggingface_hub, httpx\n",
|
| 182 |
+
"\n",
|
| 183 |
+
"print('torch :', torch.__version__, '| cuda:', torch.cuda.is_available())\n",
|
| 184 |
+
"print('transformers :', transformers.__version__)\n",
|
| 185 |
+
"print('bitsandbytes :', bitsandbytes.__version__)\n",
|
| 186 |
+
"print('peft :', peft.__version__)\n",
|
| 187 |
+
"print('accelerate :', accelerate.__version__)\n",
|
| 188 |
+
"print('huggingface_hub:', huggingface_hub.__version__)\n",
|
| 189 |
+
"print('httpx :', httpx.__version__)\n",
|
| 190 |
+
"\n",
|
| 191 |
+
"# httpx 0.28+ compat shim — same patch as the training notebook.\n",
|
| 192 |
+
"# transformers <=4.50 calls httpx.Client.head(..., allow_redirects=True)\n",
|
| 193 |
+
"# which httpx 0.28 removed; translate the kwarg at the call site.\n",
|
| 194 |
+
"def _patch_httpx():\n",
|
| 195 |
+
" if tuple(int(x) for x in httpx.__version__.split('.')[:2]) < (0, 28):\n",
|
| 196 |
+
" return\n",
|
| 197 |
+
" if getattr(httpx.Client, '_cxr_vlm_compat_patched', False):\n",
|
| 198 |
+
" return\n",
|
| 199 |
+
" def _make(orig):\n",
|
| 200 |
+
" def patched(self, *args, **kwargs):\n",
|
| 201 |
+
" if 'allow_redirects' in kwargs:\n",
|
| 202 |
+
" kwargs['follow_redirects'] = kwargs.pop('allow_redirects')\n",
|
| 203 |
+
" kwargs.pop('proxies', None)\n",
|
| 204 |
+
" return orig(self, *args, **kwargs)\n",
|
| 205 |
+
" return patched\n",
|
| 206 |
+
" for cls in (httpx.Client, httpx.AsyncClient):\n",
|
| 207 |
+
" for m in ('request', 'get', 'head', 'post', 'put',\n",
|
| 208 |
+
" 'patch', 'delete', 'options'):\n",
|
| 209 |
+
" if hasattr(cls, m):\n",
|
| 210 |
+
" setattr(cls, m, _make(getattr(cls, m)))\n",
|
| 211 |
+
" httpx.Client._cxr_vlm_compat_patched = True\n",
|
| 212 |
+
" print(f'httpx {httpx.__version__}: monkey-patched allow_redirects -> follow_redirects')\n",
|
| 213 |
+
"\n",
|
| 214 |
+
"_patch_httpx()\n",
|
| 215 |
+
"\n",
|
| 216 |
+
"assert torch.cuda.is_available(), 'CUDA not available — refusing to evaluate on CPU.'\n",
|
| 217 |
+
"_p = torch.cuda.get_device_properties(0)\n",
|
| 218 |
+
"print(f'\\nGPU : {_p.name} ({_p.total_memory/1e9:.1f} GB)')\n",
|
| 219 |
+
"print(f'Compute cap : sm_{_p.major}{_p.minor} (BF16 ok: {torch.cuda.is_bf16_supported()})')\n"
|
| 220 |
+
]
|
| 221 |
+
},
|
| 222 |
+
{
|
| 223 |
+
"cell_type": "markdown",
|
| 224 |
+
"id": "eval-paths-md",
|
| 225 |
+
"metadata": {},
|
| 226 |
+
"source": [
|
| 227 |
+
"## 2. Paths + pull code + pull dataset\n",
|
| 228 |
+
"\n",
|
| 229 |
+
"Identical to the training notebook for sections that overlap (HF code + per-dataset payload).\n"
|
| 230 |
+
]
|
| 231 |
+
},
|
| 232 |
+
{
|
| 233 |
+
"cell_type": "code",
|
| 234 |
+
"id": "eval-paths",
|
| 235 |
+
"metadata": {},
|
| 236 |
+
"execution_count": null,
|
| 237 |
+
"outputs": [],
|
| 238 |
+
"source": [
|
| 239 |
+
"# ── 1) WORK dir + HF_TOKEN bootstrap (platform-specific) ───────────\n",
|
| 240 |
+
"if PLATFORM == 'kaggle':\n",
|
| 241 |
+
" from kaggle_secrets import UserSecretsClient\n",
|
| 242 |
+
" os.environ['HF_TOKEN'] = UserSecretsClient().get_secret('HF_TOKEN')\n",
|
| 243 |
+
" WORK = Path('/kaggle/working')\n",
|
| 244 |
+
"elif PLATFORM == 'colab':\n",
|
| 245 |
+
" from google.colab import userdata\n",
|
| 246 |
+
" os.environ['HF_TOKEN'] = userdata.get('HF_TOKEN')\n",
|
| 247 |
+
" WORK = Path('/content')\n",
|
| 248 |
+
"elif PLATFORM == 'lightning':\n",
|
| 249 |
+
" WORK = Path('/teamspace/studios/this_studio')\n",
|
| 250 |
+
"elif PLATFORM == 'gcp':\n",
|
| 251 |
+
" WORK = Path('/workspace')\n",
|
| 252 |
+
"else: # 'local'\n",
|
| 253 |
+
" WORK = Path.home() / 'cxr-vlm-work'\n",
|
| 254 |
+
"WORK.mkdir(parents=True, exist_ok=True)\n",
|
| 255 |
+
"\n",
|
| 256 |
+
"assert os.environ.get('HF_TOKEN'), \\\n",
|
| 257 |
+
" 'HF_TOKEN missing — set it via the platform secrets UI before re-running.'\n",
|
| 258 |
+
"\n",
|
| 259 |
+
"from huggingface_hub import snapshot_download, hf_hub_download, HfApi\n",
|
| 260 |
+
"\n",
|
| 261 |
+
"# ── 2) Code: flat folder, snapshot_download ──\n",
|
| 262 |
+
"print(f'Pulling code from {HF_CODE_REPO} …')\n",
|
| 263 |
+
"CODE_SRC = Path(snapshot_download(\n",
|
| 264 |
+
" repo_id = HF_CODE_REPO,\n",
|
| 265 |
+
" repo_type = 'model',\n",
|
| 266 |
+
" token = os.environ['HF_TOKEN'],\n",
|
| 267 |
+
" local_dir = str(WORK / 'cxr-vlm-code'),\n",
|
| 268 |
+
"))\n",
|
| 269 |
+
"\n",
|
| 270 |
+
"# ── 3) Data: layout depends on DATASET_NAME (same logic as train notebook) ──\n",
|
| 271 |
+
"DATA_SRC = WORK / 'data'\n",
|
| 272 |
+
"DATA_SRC.mkdir(parents=True, exist_ok=True)\n",
|
| 273 |
+
"\n",
|
| 274 |
+
"if DATASET_NAME == 'MIMIC-CXR_resized':\n",
|
| 275 |
+
" import tarfile\n",
|
| 276 |
+
" mr_dir = DATA_SRC / 'MIMIC-CXR_resized'\n",
|
| 277 |
+
" mr_dir.mkdir(parents=True, exist_ok=True)\n",
|
| 278 |
+
" files_dir = mr_dir / 'files'\n",
|
| 279 |
+
"\n",
|
| 280 |
+
" manifests_present = all(\n",
|
| 281 |
+
" (mr_dir / f).is_file() for f in ('manifest_train.csv', 'manifest_val.csv', 'manifest_test.csv')\n",
|
| 282 |
+
" )\n",
|
| 283 |
+
" if manifests_present and files_dir.is_dir() and any(files_dir.glob('p*')):\n",
|
| 284 |
+
" print(f'{mr_dir} already populated — skipping download.')\n",
|
| 285 |
+
" else:\n",
|
| 286 |
+
" api = HfApi(token=os.environ['HF_TOKEN'])\n",
|
| 287 |
+
" all_files = api.list_repo_files(repo_id=HF_DATA_REPO, repo_type='dataset')\n",
|
| 288 |
+
" mr_files = [f for f in all_files if f.startswith('MIMIC-CXR_resized/')]\n",
|
| 289 |
+
" tar_files = sorted(f for f in mr_files if f.endswith('.tar'))\n",
|
| 290 |
+
" meta_files = [f for f in mr_files if not f.endswith('.tar')]\n",
|
| 291 |
+
" print(f'MIMIC-CXR_resized on HF: {len(tar_files)} tar shards + {len(meta_files)} metadata files')\n",
|
| 292 |
+
"\n",
|
| 293 |
+
" # Manifests / vqa / SHARDS.txt\n",
|
| 294 |
+
" snapshot_download(\n",
|
| 295 |
+
" repo_id = HF_DATA_REPO,\n",
|
| 296 |
+
" repo_type = 'dataset',\n",
|
| 297 |
+
" allow_patterns = ['MIMIC-CXR_resized/*.csv',\n",
|
| 298 |
+
" 'MIMIC-CXR_resized/*.json',\n",
|
| 299 |
+
" 'MIMIC-CXR_resized/*.txt',\n",
|
| 300 |
+
" 'MIMIC-CXR_resized/vqa/**'],\n",
|
| 301 |
+
" token = os.environ['HF_TOKEN'],\n",
|
| 302 |
+
" local_dir = str(DATA_SRC),\n",
|
| 303 |
+
" )\n",
|
| 304 |
+
"\n",
|
| 305 |
+
" # Tar shards — sequential extract + delete to keep peak disk low.\n",
|
| 306 |
+
" for i, tf in enumerate(tar_files, 1):\n",
|
| 307 |
+
" print(f' [{i}/{len(tar_files)}] {tf}')\n",
|
| 308 |
+
" tar_path = Path(hf_hub_download(\n",
|
| 309 |
+
" repo_id=HF_DATA_REPO, repo_type='dataset',\n",
|
| 310 |
+
" filename=tf, token=os.environ['HF_TOKEN'],\n",
|
| 311 |
+
" local_dir=str(DATA_SRC),\n",
|
| 312 |
+
" ))\n",
|
| 313 |
+
" with tarfile.open(tar_path) as t:\n",
|
| 314 |
+
" t.extractall(mr_dir)\n",
|
| 315 |
+
" tar_path.unlink(missing_ok=True)\n",
|
| 316 |
+
" print(f' done. {mr_dir} ready.')\n",
|
| 317 |
+
"\n",
|
| 318 |
+
"else:\n",
|
| 319 |
+
" # MIMIC-CXR / IU-Xray — single zip per dataset.\n",
|
| 320 |
+
" import zipfile\n",
|
| 321 |
+
" zip_name = f'{DATASET_NAME}.zip'\n",
|
| 322 |
+
" marker = DATA_SRC / DATASET_NAME\n",
|
| 323 |
+
"\n",
|
| 324 |
+
" if not marker.exists():\n",
|
| 325 |
+
" print(f'Pulling {zip_name} from HF …')\n",
|
| 326 |
+
" zpath = hf_hub_download(\n",
|
| 327 |
+
" repo_id = HF_DATA_REPO,\n",
|
| 328 |
+
" filename = zip_name,\n",
|
| 329 |
+
" repo_type = 'dataset',\n",
|
| 330 |
+
" token = os.environ['HF_TOKEN'],\n",
|
| 331 |
+
" local_dir = str(DATA_SRC),\n",
|
| 332 |
+
" )\n",
|
| 333 |
+
" print(f' unzipping -> {DATA_SRC}')\n",
|
| 334 |
+
" with zipfile.ZipFile(zpath) as zf:\n",
|
| 335 |
+
" zf.extractall(DATA_SRC)\n",
|
| 336 |
+
" try:\n",
|
| 337 |
+
" os.remove(zpath)\n",
|
| 338 |
+
" except OSError:\n",
|
| 339 |
+
" pass\n",
|
| 340 |
+
" else:\n",
|
| 341 |
+
" print(f'{marker} already present — skipping download.')\n",
|
| 342 |
+
"\n",
|
| 343 |
+
"print(f'Contents of {DATA_SRC}: {sorted(os.listdir(DATA_SRC))}')\n",
|
| 344 |
+
"\n",
|
| 345 |
+
"# ── 4) Copy code into writable PROJECT dir + chdir ─────────────────\n",
|
| 346 |
+
"PROJECT = WORK / 'cxr_vlm'\n",
|
| 347 |
+
"if CODE_SRC.resolve() != PROJECT.resolve() and not PROJECT.exists():\n",
|
| 348 |
+
" shutil.copytree(CODE_SRC, PROJECT)\n",
|
| 349 |
+
"\n",
|
| 350 |
+
"os.chdir(PROJECT)\n",
|
| 351 |
+
"sys.path.insert(0, str(PROJECT))\n",
|
| 352 |
+
"print('CODE_SRC :', CODE_SRC)\n",
|
| 353 |
+
"print('DATA_SRC :', DATA_SRC)\n",
|
| 354 |
+
"print('PROJECT :', PROJECT)\n",
|
| 355 |
+
"print('WORK :', WORK)\n"
|
| 356 |
+
]
|
| 357 |
+
},
|
| 358 |
+
{
|
| 359 |
+
"cell_type": "markdown",
|
| 360 |
+
"id": "eval-find-md",
|
| 361 |
+
"metadata": {},
|
| 362 |
+
"source": [
|
| 363 |
+
"## 3. Locate the dataset payload on disk\n"
|
| 364 |
+
]
|
| 365 |
+
},
|
| 366 |
+
{
|
| 367 |
+
"cell_type": "code",
|
| 368 |
+
"id": "eval-find",
|
| 369 |
+
"metadata": {},
|
| 370 |
+
"execution_count": null,
|
| 371 |
+
"outputs": [],
|
| 372 |
+
"source": [
|
| 373 |
+
"# Same dataset-payload finders as the training notebook. Filling in the\n",
|
| 374 |
+
"# variables the config cell below will consume.\n",
|
| 375 |
+
"\n",
|
| 376 |
+
"def find_split_parent(root: Path) -> Path:\n",
|
| 377 |
+
" for cand in [root, root / 'MIMIC-CXR', root / 'data' / 'MIMIC-CXR']:\n",
|
| 378 |
+
" if (cand / 'train').exists() and (cand / 'valid').exists() and (cand / 'test').exists():\n",
|
| 379 |
+
" return cand\n",
|
| 380 |
+
" for p in root.rglob('train'):\n",
|
| 381 |
+
" if p.is_dir() and (p.parent / 'valid').exists() and (p.parent / 'test').exists():\n",
|
| 382 |
+
" return p.parent\n",
|
| 383 |
+
" raise FileNotFoundError('Could not find train/ valid/ test/ under ' + str(root))\n",
|
| 384 |
+
"\n",
|
| 385 |
+
"\n",
|
| 386 |
+
"def find_mimic_resized_root(root: Path) -> Path:\n",
|
| 387 |
+
" for cand in [root / 'MIMIC-CXR_resized', root, *root.rglob('MIMIC-CXR_resized')]:\n",
|
| 388 |
+
" if (cand / 'manifest_train.csv').is_file():\n",
|
| 389 |
+
" return cand\n",
|
| 390 |
+
" raise FileNotFoundError(\n",
|
| 391 |
+
" f'Could not find MIMIC-CXR_resized payload under {root}. '\n",
|
| 392 |
+
" 'Expected manifest_train.csv (alongside manifest_val.csv / manifest_test.csv).'\n",
|
| 393 |
+
" )\n",
|
| 394 |
+
"\n",
|
| 395 |
+
"\n",
|
| 396 |
+
"def find_iu_dirs(root: Path):\n",
|
| 397 |
+
" for cand in [root / 'IU-Xray', *root.rglob('IU-Xray')]:\n",
|
| 398 |
+
" if not cand.is_dir():\n",
|
| 399 |
+
" continue\n",
|
| 400 |
+
" imgs = cand / 'images'\n",
|
| 401 |
+
" lbls = cand / 'labels'\n",
|
| 402 |
+
" if imgs.is_dir() and lbls.is_dir() and any(lbls.glob('*.xml')):\n",
|
| 403 |
+
" return imgs, lbls\n",
|
| 404 |
+
" legacy = lbls / 'ecgen-radiology'\n",
|
| 405 |
+
" if imgs.is_dir() and legacy.is_dir() and any(legacy.glob('*.xml')):\n",
|
| 406 |
+
" return imgs, legacy\n",
|
| 407 |
+
" img_dir = lbl_dir = None\n",
|
| 408 |
+
" for cand in [root / 'images', *root.rglob('images')]:\n",
|
| 409 |
+
" if cand.is_dir() and any(cand.glob('CXR*.png')):\n",
|
| 410 |
+
" img_dir = cand; break\n",
|
| 411 |
+
" for cand in [root / 'labels', *root.rglob('labels')]:\n",
|
| 412 |
+
" if cand.is_dir() and any(cand.glob('*.xml')):\n",
|
| 413 |
+
" lbl_dir = cand; break\n",
|
| 414 |
+
" if lbl_dir is None:\n",
|
| 415 |
+
" for cand in root.rglob('ecgen-radiology'):\n",
|
| 416 |
+
" if cand.is_dir() and any(cand.glob('*.xml')):\n",
|
| 417 |
+
" lbl_dir = cand; break\n",
|
| 418 |
+
" return img_dir, lbl_dir\n",
|
| 419 |
+
"\n",
|
| 420 |
+
"\n",
|
| 421 |
+
"CXR_ROOT = None\n",
|
| 422 |
+
"VQA_ROOT = None\n",
|
| 423 |
+
"MR_ROOT = None\n",
|
| 424 |
+
"IU_IMAGES_DIR = None\n",
|
| 425 |
+
"IU_LABELS_DIR = None\n",
|
| 426 |
+
"\n",
|
| 427 |
+
"if DATASET_NAME == 'MIMIC-CXR':\n",
|
| 428 |
+
" CXR_ROOT = find_split_parent(DATA_SRC)\n",
|
| 429 |
+
" print('MIMIC-CXR root:', CXR_ROOT)\n",
|
| 430 |
+
" for s in ('train', 'valid', 'test'):\n",
|
| 431 |
+
" d = CXR_ROOT / s\n",
|
| 432 |
+
" assert d.exists(), f'Missing split dir: {d}'\n",
|
| 433 |
+
" print(f' {s:<6s} -> {d}')\n",
|
| 434 |
+
"\n",
|
| 435 |
+
" for p in DATA_SRC.rglob('MIMIC-Ext-MIMIC-CXR-VQA'):\n",
|
| 436 |
+
" cand = p / 'dataset'\n",
|
| 437 |
+
" if cand.exists() and (cand / 'train.json').exists():\n",
|
| 438 |
+
" VQA_ROOT = cand; break\n",
|
| 439 |
+
" if VQA_ROOT is None:\n",
|
| 440 |
+
" print('VQA root: NOT FOUND -> VQA task will be skipped')\n",
|
| 441 |
+
" else:\n",
|
| 442 |
+
" print('VQA root:', VQA_ROOT)\n",
|
| 443 |
+
"\n",
|
| 444 |
+
"elif DATASET_NAME == 'MIMIC-CXR_resized':\n",
|
| 445 |
+
" MR_ROOT = find_mimic_resized_root(DATA_SRC)\n",
|
| 446 |
+
" print('MIMIC-CXR_resized root:', MR_ROOT)\n",
|
| 447 |
+
" for cf in ('manifest_train.csv', 'manifest_val.csv', 'manifest_test.csv'):\n",
|
| 448 |
+
" f = MR_ROOT / cf\n",
|
| 449 |
+
" print(f' {cf}: {\"OK\" if f.is_file() else \"MISSING\"}')\n",
|
| 450 |
+
" for sub in ('files', 'vqa'):\n",
|
| 451 |
+
" d = MR_ROOT / sub\n",
|
| 452 |
+
" print(f' {sub:<5s}: {\"OK\" if d.is_dir() else \"MISSING\"} ({d})')\n",
|
| 453 |
+
"\n",
|
| 454 |
+
"else: # IU-Xray\n",
|
| 455 |
+
" IU_IMAGES_DIR, IU_LABELS_DIR = find_iu_dirs(DATA_SRC)\n",
|
| 456 |
+
" assert IU_IMAGES_DIR is not None, f'IU images/ not found under {DATA_SRC}'\n",
|
| 457 |
+
" assert IU_LABELS_DIR is not None, f'IU labels/ (with *.xml) not found under {DATA_SRC}'\n",
|
| 458 |
+
" print('IU images dir:', IU_IMAGES_DIR, '->', len(list(IU_IMAGES_DIR.glob('*.png'))), 'PNGs')\n",
|
| 459 |
+
" print('IU labels dir:', IU_LABELS_DIR, '->', len(list(IU_LABELS_DIR.glob('*.xml'))), 'XMLs')\n"
|
| 460 |
+
]
|
| 461 |
+
},
|
| 462 |
+
{
|
| 463 |
+
"cell_type": "markdown",
|
| 464 |
+
"id": "eval-pull-run-md",
|
| 465 |
+
"metadata": {},
|
| 466 |
+
"source": [
|
| 467 |
+
"## 4. Pull trained checkpoint + run config snapshot from HF Runs repo\n",
|
| 468 |
+
"\n",
|
| 469 |
+
"Layout on `HF_RUNS_REPO`:\n",
|
| 470 |
+
"\n",
|
| 471 |
+
"```\n",
|
| 472 |
+
"{RUN_ID}/\n",
|
| 473 |
+
" configs/{model,train}_config.yaml ← snapshot taken at training time\n",
|
| 474 |
+
" stage2/best/ checkpoint_projection.pt + checkpoint_lora/ [+ checkpoint_chexpert_classifier.pt]\n",
|
| 475 |
+
" stage2/last/ same shape, intermediate save\n",
|
| 476 |
+
"```\n",
|
| 477 |
+
"\n",
|
| 478 |
+
"We pull `{RUN_ID}/configs/` and `{RUN_ID}/stage2/{CKPT_PICK}/` into `{WORK}/run_pull/{RUN_ID}/`.\n"
|
| 479 |
+
]
|
| 480 |
+
},
|
| 481 |
+
{
|
| 482 |
+
"cell_type": "code",
|
| 483 |
+
"id": "eval-pull-run",
|
| 484 |
+
"metadata": {},
|
| 485 |
+
"execution_count": null,
|
| 486 |
+
"outputs": [],
|
| 487 |
+
"source": [
|
| 488 |
+
"from huggingface_hub import snapshot_download\n",
|
| 489 |
+
"\n",
|
| 490 |
+
"RUN_PULL_ROOT = WORK / 'run_pull'\n",
|
| 491 |
+
"RUN_PULL_ROOT.mkdir(parents=True, exist_ok=True)\n",
|
| 492 |
+
"\n",
|
| 493 |
+
"print(f'Pulling {RUN_ID}/{{configs,stage2/{CKPT_PICK}}} from {HF_RUNS_REPO} …')\n",
|
| 494 |
+
"snapshot_download(\n",
|
| 495 |
+
" repo_id = HF_RUNS_REPO,\n",
|
| 496 |
+
" repo_type = 'model',\n",
|
| 497 |
+
" token = os.environ['HF_TOKEN'],\n",
|
| 498 |
+
" allow_patterns = [\n",
|
| 499 |
+
" f'{RUN_ID}/configs/**',\n",
|
| 500 |
+
" f'{RUN_ID}/run_meta.json',\n",
|
| 501 |
+
" f'{RUN_ID}/stage2/{CKPT_PICK}/**',\n",
|
| 502 |
+
" ],\n",
|
| 503 |
+
" local_dir = str(RUN_PULL_ROOT),\n",
|
| 504 |
+
")\n",
|
| 505 |
+
"\n",
|
| 506 |
+
"RUN_DIR_PULLED = RUN_PULL_ROOT / RUN_ID\n",
|
| 507 |
+
"assert RUN_DIR_PULLED.is_dir(), f'Pull failed — {RUN_DIR_PULLED} missing.'\n",
|
| 508 |
+
"\n",
|
| 509 |
+
"CKPT_DIR_PULLED = RUN_DIR_PULLED / 'stage2' / CKPT_PICK\n",
|
| 510 |
+
"PROJ_PT = CKPT_DIR_PULLED / 'checkpoint_projection.pt'\n",
|
| 511 |
+
"LORA_DIR = CKPT_DIR_PULLED / 'checkpoint_lora'\n",
|
| 512 |
+
"CHEXPERT_PT = CKPT_DIR_PULLED / 'checkpoint_chexpert_classifier.pt'\n",
|
| 513 |
+
"\n",
|
| 514 |
+
"assert PROJ_PT.is_file(), \\\n",
|
| 515 |
+
" f'Projection weights not found at {PROJ_PT}. ' \\\n",
|
| 516 |
+
" f'Check that {RUN_ID}/stage2/{CKPT_PICK}/ exists on {HF_RUNS_REPO}.'\n",
|
| 517 |
+
"assert (LORA_DIR / 'adapter_config.json').is_file(), \\\n",
|
| 518 |
+
" f'LoRA adapter_config.json not found in {LORA_DIR}. Stage-2 checkpoint partial?'\n",
|
| 519 |
+
"\n",
|
| 520 |
+
"print()\n",
|
| 521 |
+
"print(f' projection : {PROJ_PT} ({PROJ_PT.stat().st_size/1e6:.1f} MB)')\n",
|
| 522 |
+
"print(f' lora : {LORA_DIR}/ ({sum(p.stat().st_size for p in LORA_DIR.rglob(\"*\") if p.is_file())/1e6:.1f} MB)')\n",
|
| 523 |
+
"print(f' chexpert : {CHEXPERT_PT} (exists: {CHEXPERT_PT.is_file()})')\n",
|
| 524 |
+
"\n",
|
| 525 |
+
"# Saved configs (may or may not exist on older runs)\n",
|
| 526 |
+
"SAVED_CFG_DIR = RUN_DIR_PULLED / 'configs'\n",
|
| 527 |
+
"SAVED_TRAIN_CFG = SAVED_CFG_DIR / 'train_config.yaml'\n",
|
| 528 |
+
"SAVED_MODEL_CFG = SAVED_CFG_DIR / 'model_config.yaml'\n",
|
| 529 |
+
"print()\n",
|
| 530 |
+
"print(f' saved train_cfg : {SAVED_TRAIN_CFG} (exists: {SAVED_TRAIN_CFG.is_file()})')\n",
|
| 531 |
+
"print(f' saved model_cfg : {SAVED_MODEL_CFG} (exists: {SAVED_MODEL_CFG.is_file()})')\n"
|
| 532 |
+
]
|
| 533 |
+
},
|
| 534 |
+
{
|
| 535 |
+
"cell_type": "markdown",
|
| 536 |
+
"id": "eval-gpu-md",
|
| 537 |
+
"metadata": {},
|
| 538 |
+
"source": [
|
| 539 |
+
"## 5. Auto-detect GPU profile\n",
|
| 540 |
+
"\n",
|
| 541 |
+
"Mirrors the training notebook: picks precision (bf16/fp16), attention backend (FA2/SDPA), and an eval batch size based on the actual GPU's compute capability + VRAM.\n",
|
| 542 |
+
"\n",
|
| 543 |
+
"| Bucket | Examples | Precision | Attn | Eval batch |\n",
|
| 544 |
+
"|---|---|---|---|---|\n",
|
| 545 |
+
"| 70+ GB | A100/H100 80GB | bf16 | FA2 (if installed) | 16 |\n",
|
| 546 |
+
"| 35–69 GB | A100 40GB | bf16 | FA2 | 8 |\n",
|
| 547 |
+
"| 22–34 GB | 3090 / L4 / A10 24GB | bf16 | FA2 | 4 |\n",
|
| 548 |
+
"| 14–21 GB | T4 / V100 16GB | fp16 | SDPA | 1 |\n",
|
| 549 |
+
"\n",
|
| 550 |
+
"The eval batch sizes are smaller than training's because generation builds a KV cache that scales with `batch × max_new_tokens`. Override by setting `BATCH_SIZE` in section 0 to a concrete number.\n"
|
| 551 |
+
]
|
| 552 |
+
},
|
| 553 |
+
{
|
| 554 |
+
"cell_type": "code",
|
| 555 |
+
"id": "eval-gpu",
|
| 556 |
+
"metadata": {},
|
| 557 |
+
"execution_count": null,
|
| 558 |
+
"outputs": [],
|
| 559 |
+
"source": [
|
| 560 |
+
"import torch\n",
|
| 561 |
+
"\n",
|
| 562 |
+
"assert torch.cuda.is_available(), 'CUDA not available — refusing to write a CPU profile.'\n",
|
| 563 |
+
"_props = torch.cuda.get_device_properties(0)\n",
|
| 564 |
+
"_cap = (_props.major, _props.minor)\n",
|
| 565 |
+
"_vram_gb = _props.total_memory / 1e9\n",
|
| 566 |
+
"_bf16_ok = torch.cuda.is_bf16_supported()\n",
|
| 567 |
+
"_fa2_ok = _cap >= (8, 0) # FA2 needs Ampere+ (sm_80 or newer)\n",
|
| 568 |
+
"\n",
|
| 569 |
+
"# Detect whether flash-attn package is actually importable. FA2 falls back to\n",
|
| 570 |
+
"# SDPA inside cxr_vlm.py if missing, but knowing here lets us print clearly.\n",
|
| 571 |
+
"_flash_attn_installed = False\n",
|
| 572 |
+
"if _fa2_ok:\n",
|
| 573 |
+
" try:\n",
|
| 574 |
+
" import flash_attn # noqa: F401\n",
|
| 575 |
+
" _flash_attn_installed = True\n",
|
| 576 |
+
" except Exception:\n",
|
| 577 |
+
" _flash_attn_installed = False\n",
|
| 578 |
+
"\n",
|
| 579 |
+
"print(f'GPU : {_props.name} ({_vram_gb:.1f} GB)')\n",
|
| 580 |
+
"print(f'Compute cap : sm_{_cap[0]}{_cap[1]}')\n",
|
| 581 |
+
"print(f'BF16 native : {_bf16_ok}')\n",
|
| 582 |
+
"print(f'FA2 capable : {_fa2_ok} flash-attn installed: {_flash_attn_installed}')\n",
|
| 583 |
+
"\n",
|
| 584 |
+
"# Eval batch size — smaller than training because generation builds a KV cache\n",
|
| 585 |
+
"# that scales with batch × max_new_tokens.\n",
|
| 586 |
+
"if _vram_gb >= 70:\n",
|
| 587 |
+
" GPU_LABEL, _EVAL_BS, _NW = 'A100/H100 80GB', 16, 16\n",
|
| 588 |
+
"elif _vram_gb >= 35:\n",
|
| 589 |
+
" GPU_LABEL, _EVAL_BS, _NW = 'A100 40GB', 8, 12\n",
|
| 590 |
+
"elif _vram_gb >= 22:\n",
|
| 591 |
+
" GPU_LABEL, _EVAL_BS, _NW = 'RTX 3090 / L4 / A10 24GB', 4, 8\n",
|
| 592 |
+
"elif _vram_gb >= 14:\n",
|
| 593 |
+
" GPU_LABEL, _EVAL_BS, _NW = 'T4 / V100 (15-16GB)', 1, 2\n",
|
| 594 |
+
"else:\n",
|
| 595 |
+
" GPU_LABEL, _EVAL_BS, _NW = f'unknown ({_vram_gb:.0f}GB) - conservative', 1, 2\n",
|
| 596 |
+
"\n",
|
| 597 |
+
"PROFILE = dict(\n",
|
| 598 |
+
" label = GPU_LABEL,\n",
|
| 599 |
+
" bf16 = bool(_bf16_ok),\n",
|
| 600 |
+
" fp16 = not _bf16_ok,\n",
|
| 601 |
+
" torch_dtype = 'bfloat16' if _bf16_ok else 'float16',\n",
|
| 602 |
+
" bnb_4bit_compute_dtype = 'bfloat16' if _bf16_ok else 'float16',\n",
|
| 603 |
+
" attn_implementation = 'flash_attention_2' if (_fa2_ok and _flash_attn_installed) else 'sdpa',\n",
|
| 604 |
+
" per_device_eval_batch_size = _EVAL_BS,\n",
|
| 605 |
+
" dataloader_num_workers = _NW,\n",
|
| 606 |
+
")\n",
|
| 607 |
+
"\n",
|
| 608 |
+
"# Allow the section-0 selector to override (e.g. BATCH_SIZE=8 to push harder).\n",
|
| 609 |
+
"if BATCH_SIZE is not None:\n",
|
| 610 |
+
" PROFILE['per_device_eval_batch_size'] = int(BATCH_SIZE)\n",
|
| 611 |
+
" print(f'BATCH_SIZE override: section 0 forced batch={BATCH_SIZE}')\n",
|
| 612 |
+
"\n",
|
| 613 |
+
"# Final BATCH_SIZE the rest of the notebook (eval-run cell) will use.\n",
|
| 614 |
+
"BATCH_SIZE = PROFILE['per_device_eval_batch_size']\n",
|
| 615 |
+
"\n",
|
| 616 |
+
"print(f'\\n-> Profile : {PROFILE[\"label\"]}')\n",
|
| 617 |
+
"for k, v in PROFILE.items():\n",
|
| 618 |
+
" if k == 'label': continue\n",
|
| 619 |
+
" print(f' {k:<32s} = {v}')\n"
|
| 620 |
+
]
|
| 621 |
+
},
|
| 622 |
+
{
|
| 623 |
+
"cell_type": "markdown",
|
| 624 |
+
"id": "eval-cfg-md",
|
| 625 |
+
"metadata": {},
|
| 626 |
+
"source": [
|
| 627 |
+
"## 6. Build configs\n",
|
| 628 |
+
"\n",
|
| 629 |
+
"Strategy: start from the run's saved config snapshot if present (so `report_mode`, `image_mode`, `lora.r`, `num_image_tokens`, etc. match training). If absent, fall back to the repo defaults. Then patch:\n",
|
| 630 |
+
"- dataset paths to the local download\n",
|
| 631 |
+
"- compute (precision + attn backend + batch) from the auto-detected `PROFILE`\n",
|
| 632 |
+
"- HF Hub tracker → uses `HF_RUNS_REPO` so the results upload lands under `{RUN_ID}/results/`\n",
|
| 633 |
+
"- pin `run_id` to `RUN_ID` so `evaluate.py` writes under `{LOCAL_RESULTS_DIR}/{RUN_ID}/`\n"
|
| 634 |
+
]
|
| 635 |
+
},
|
| 636 |
+
{
|
| 637 |
+
"cell_type": "code",
|
| 638 |
+
"id": "eval-cfg",
|
| 639 |
+
"metadata": {},
|
| 640 |
+
"execution_count": null,
|
| 641 |
+
"outputs": [],
|
| 642 |
+
"source": [
|
| 643 |
+
"from omegaconf import OmegaConf\n",
|
| 644 |
+
"import torch\n",
|
| 645 |
+
"\n",
|
| 646 |
+
"# ── 1) Load base configs: prefer the run's saved snapshot ─────────\n",
|
| 647 |
+
"if SAVED_TRAIN_CFG.is_file():\n",
|
| 648 |
+
" train_cfg = OmegaConf.load(SAVED_TRAIN_CFG)\n",
|
| 649 |
+
" print(f'train_cfg <- {SAVED_TRAIN_CFG}')\n",
|
| 650 |
+
"else:\n",
|
| 651 |
+
" train_cfg = OmegaConf.load(PROJECT / 'configs' / 'train_config.yaml')\n",
|
| 652 |
+
" print(f'train_cfg <- repo default (no snapshot on HF)')\n",
|
| 653 |
+
"\n",
|
| 654 |
+
"if SAVED_MODEL_CFG.is_file():\n",
|
| 655 |
+
" model_cfg = OmegaConf.load(SAVED_MODEL_CFG)\n",
|
| 656 |
+
" print(f'model_cfg <- {SAVED_MODEL_CFG}')\n",
|
| 657 |
+
"else:\n",
|
| 658 |
+
" model_cfg = OmegaConf.load(PROJECT / 'configs' / 'model_config.yaml')\n",
|
| 659 |
+
" print(f'model_cfg <- repo default (no snapshot on HF)')\n",
|
| 660 |
+
"\n",
|
| 661 |
+
"# ── 2) Allow notebook overrides for report/image mode ─────────────\n",
|
| 662 |
+
"if REPORT_MODE is not None:\n",
|
| 663 |
+
" train_cfg.data.report_mode = REPORT_MODE\n",
|
| 664 |
+
"if IMAGE_MODE is not None:\n",
|
| 665 |
+
" train_cfg.data.image_mode = IMAGE_MODE\n",
|
| 666 |
+
"print(f'report_mode = {train_cfg.data.report_mode} image_mode = {train_cfg.data.image_mode}')\n",
|
| 667 |
+
"\n",
|
| 668 |
+
"# ── 3) Patch dataset paths for the local download ────────────────\n",
|
| 669 |
+
"train_cfg.data.dataset_name = DATASET_NAME\n",
|
| 670 |
+
"\n",
|
| 671 |
+
"if DATASET_NAME == 'MIMIC-CXR':\n",
|
| 672 |
+
" train_cfg.data.mimic_cxr_root = str(CXR_ROOT)\n",
|
| 673 |
+
" train_cfg.data.mimic_auto_build = True\n",
|
| 674 |
+
"\n",
|
| 675 |
+
" _cx = (sorted(DATA_SRC.rglob('*chexpert*.csv'))\n",
|
| 676 |
+
" or sorted(DATA_SRC.rglob('*chexbert*.csv')))\n",
|
| 677 |
+
" train_cfg.data.mimic_chexpert_csv = str(_cx[0]) if _cx else None\n",
|
| 678 |
+
" train_cfg.data.mimic_vqa_root = str(VQA_ROOT) if VQA_ROOT is not None else None\n",
|
| 679 |
+
"\n",
|
| 680 |
+
" out_dir = PROJECT / 'data' / 'data_files'\n",
|
| 681 |
+
" out_dir.mkdir(parents=True, exist_ok=True)\n",
|
| 682 |
+
" train_cfg.data.instruct_json = str(out_dir / 'mimic_cxr_instruct_unified.json')\n",
|
| 683 |
+
"\n",
|
| 684 |
+
"elif DATASET_NAME == 'MIMIC-CXR_resized':\n",
|
| 685 |
+
" train_cfg.data.mimic_cxr_resized.root = str(MR_ROOT)\n",
|
| 686 |
+
" train_cfg.data.mimic_cxr_resized.manifest_dir = None\n",
|
| 687 |
+
" train_cfg.data.mimic_cxr_resized.vqa_dir = None\n",
|
| 688 |
+
" train_cfg.data.mimic_cxr_resized.reports_root = None\n",
|
| 689 |
+
" train_cfg.data.mimic_cxr_resized.auto_build = True\n",
|
| 690 |
+
" out_dir = PROJECT / 'data' / 'data_files'\n",
|
| 691 |
+
" out_dir.mkdir(parents=True, exist_ok=True)\n",
|
| 692 |
+
" train_cfg.data.mimic_cxr_resized.instruct_json = str(out_dir / 'mimic_cxr_resized_instruct.json')\n",
|
| 693 |
+
"\n",
|
| 694 |
+
"else: # IU-Xray\n",
|
| 695 |
+
" train_cfg.data.iu_xray.images_dir = str(IU_IMAGES_DIR)\n",
|
| 696 |
+
" train_cfg.data.iu_xray.labels_dir = str(IU_LABELS_DIR)\n",
|
| 697 |
+
" train_cfg.data.iu_xray.auto_build = True\n",
|
| 698 |
+
" out_dir = PROJECT / 'data' / 'data_files'\n",
|
| 699 |
+
" out_dir.mkdir(parents=True, exist_ok=True)\n",
|
| 700 |
+
" train_cfg.data.iu_xray.instruct_json = str(out_dir / 'iu_xray_instruct.json')\n",
|
| 701 |
+
"\n",
|
| 702 |
+
"train_cfg.data.train_split = 'train'\n",
|
| 703 |
+
"train_cfg.data.val_split = 'validate'\n",
|
| 704 |
+
"train_cfg.data.test_split = 'test'\n",
|
| 705 |
+
"\n",
|
| 706 |
+
"# ── 4) Apply auto-detected compute profile (overrides saved config) ──\n",
|
| 707 |
+
"train_cfg.training.fp16 = PROFILE['fp16']\n",
|
| 708 |
+
"train_cfg.training.bf16 = PROFILE['bf16']\n",
|
| 709 |
+
"train_cfg.training.per_device_train_batch_size = PROFILE['per_device_eval_batch_size']\n",
|
| 710 |
+
"train_cfg.training.per_device_eval_batch_size = PROFILE['per_device_eval_batch_size']\n",
|
| 711 |
+
"train_cfg.training.dataloader_num_workers = PROFILE['dataloader_num_workers']\n",
|
| 712 |
+
"train_cfg.training.dataloader_pin_memory = True\n",
|
| 713 |
+
"\n",
|
| 714 |
+
"# Disable feature cache for eval (test images haven't been precomputed).\n",
|
| 715 |
+
"train_cfg.data.feature_cache_dir = None\n",
|
| 716 |
+
"\n",
|
| 717 |
+
"# 4-bit QLoRA — must match how the trainer set it up for the saved LoRA to load.\n",
|
| 718 |
+
"model_cfg.llm.load_in_4bit = True\n",
|
| 719 |
+
"model_cfg.llm.load_in_8bit = False\n",
|
| 720 |
+
"model_cfg.llm.attn_implementation = PROFILE['attn_implementation']\n",
|
| 721 |
+
"model_cfg.llm.torch_dtype = PROFILE['torch_dtype']\n",
|
| 722 |
+
"model_cfg.llm.bnb_4bit_compute_dtype = PROFILE['bnb_4bit_compute_dtype']\n",
|
| 723 |
+
"model_cfg.llm.bnb_4bit_quant_type = 'nf4'\n",
|
| 724 |
+
"model_cfg.llm.bnb_4bit_use_double_quant = True\n",
|
| 725 |
+
"model_cfg.llm.gradient_checkpointing = False # eval-only - no backward pass\n",
|
| 726 |
+
"\n",
|
| 727 |
+
"# ── 5) CheXpert classifier ───────────────────────────────────────\n",
|
| 728 |
+
"# The training notebook keeps it disabled (oracle PNU from CSV/manifest).\n",
|
| 729 |
+
"# Only enable here if the trained run actually has a learned classifier\n",
|
| 730 |
+
"# checkpoint saved alongside.\n",
|
| 731 |
+
"if CHEXPERT_PT.is_file():\n",
|
| 732 |
+
" model_cfg.chexpert_classifier.enabled = True\n",
|
| 733 |
+
" print(f'CheXpert classifier checkpoint found at {CHEXPERT_PT} -> enabled')\n",
|
| 734 |
+
"else:\n",
|
| 735 |
+
" model_cfg.chexpert_classifier.enabled = False\n",
|
| 736 |
+
" print('No CheXpert classifier checkpoint -> disabled (oracle PNU from CSV/manifest)')\n",
|
| 737 |
+
"\n",
|
| 738 |
+
"# ── 6) HF Hub tracker — points results uploads at HF_RUNS_REPO ──\n",
|
| 739 |
+
"CKPT_ROOT = WORK / 'ckpt_eval'\n",
|
| 740 |
+
"CKPT_ROOT.mkdir(parents=True, exist_ok=True)\n",
|
| 741 |
+
"train_cfg.training.output_root = str(CKPT_ROOT)\n",
|
| 742 |
+
"\n",
|
| 743 |
+
"if UPLOAD_RESULTS_TO_HF:\n",
|
| 744 |
+
" train_cfg.hf_hub.enabled = True\n",
|
| 745 |
+
" train_cfg.hf_hub.repo_id = HF_RUNS_REPO\n",
|
| 746 |
+
" train_cfg.hf_hub.token_env = 'HF_TOKEN'\n",
|
| 747 |
+
" train_cfg.hf_hub.private = True\n",
|
| 748 |
+
"else:\n",
|
| 749 |
+
" train_cfg.hf_hub.enabled = False\n",
|
| 750 |
+
"\n",
|
| 751 |
+
"train_cfg.hf_hub.run_state_file = str(CKPT_ROOT / 'run_id.txt')\n",
|
| 752 |
+
"\n",
|
| 753 |
+
"# Pin RUN_ID so resolve_run_id picks it up exactly.\n",
|
| 754 |
+
"Path(train_cfg.hf_hub.run_state_file).write_text(RUN_ID)\n",
|
| 755 |
+
"\n",
|
| 756 |
+
"# ── 7) Save patched configs into the project so the subprocess sees them ──\n",
|
| 757 |
+
"OmegaConf.save(train_cfg, PROJECT / 'configs' / 'train_config.yaml')\n",
|
| 758 |
+
"OmegaConf.save(model_cfg, PROJECT / 'configs' / 'model_config.yaml')\n",
|
| 759 |
+
"\n",
|
| 760 |
+
"print('--- train_cfg.data ---'); print(OmegaConf.to_yaml(train_cfg.data))\n",
|
| 761 |
+
"print('--- train_cfg.training ---');print(OmegaConf.to_yaml(train_cfg.training))\n",
|
| 762 |
+
"print('--- train_cfg.hf_hub ---'); print(OmegaConf.to_yaml(train_cfg.hf_hub))\n",
|
| 763 |
+
"print('--- model_cfg.llm ---'); print(OmegaConf.to_yaml(model_cfg.llm))\n"
|
| 764 |
+
]
|
| 765 |
+
},
|
| 766 |
+
{
|
| 767 |
+
"cell_type": "markdown",
|
| 768 |
+
"id": "eval-run-md",
|
| 769 |
+
"metadata": {},
|
| 770 |
+
"source": [
|
| 771 |
+
"## 7. Run evaluation\n",
|
| 772 |
+
"\n",
|
| 773 |
+
"Calls `python -m evaluation.evaluate` as a subprocess. It will:\n",
|
| 774 |
+
"1. Build the unified instruct JSON for the chosen `(report_mode, image_mode)` if missing.\n",
|
| 775 |
+
"2. Build the model from the patched configs.\n",
|
| 776 |
+
"3. Load the projection + LoRA from `CKPT_DIR_PULLED`.\n",
|
| 777 |
+
"4. Iterate the **test** split, generate predictions, score every task.\n",
|
| 778 |
+
"5. Write `{LOCAL_RESULTS_DIR}/{RUN_ID}/predictions_*.json` + `metrics_summary.json`.\n",
|
| 779 |
+
"6. If `UPLOAD_RESULTS_TO_HF=True`, upload the folder to `{RUN_ID}/results/` on `HF_RUNS_REPO`.\n"
|
| 780 |
+
]
|
| 781 |
+
},
|
| 782 |
+
{
|
| 783 |
+
"cell_type": "code",
|
| 784 |
+
"id": "eval-run",
|
| 785 |
+
"metadata": {},
|
| 786 |
+
"execution_count": null,
|
| 787 |
+
"outputs": [],
|
| 788 |
+
"source": [
|
| 789 |
+
"import shlex\n",
|
| 790 |
+
"\n",
|
| 791 |
+
"RESULTS_DIR_LOCAL = WORK / LOCAL_RESULTS_DIR\n",
|
| 792 |
+
"RESULTS_DIR_LOCAL.mkdir(parents=True, exist_ok=True)\n",
|
| 793 |
+
"\n",
|
| 794 |
+
"# evaluate.py expects --checkpoint to be a `<dir>/<name>_projection.pt` path;\n",
|
| 795 |
+
"# it derives the LoRA dir + CheXpert classifier path from the same folder.\n",
|
| 796 |
+
"CKPT_ARG = str(PROJ_PT)\n",
|
| 797 |
+
"\n",
|
| 798 |
+
"extra = ''\n",
|
| 799 |
+
"if LLM_JUDGE:\n",
|
| 800 |
+
" extra += ' --llm_judge'\n",
|
| 801 |
+
" extra += f' --llm_judge_model {shlex.quote(LLM_JUDGE_MODEL)}'\n",
|
| 802 |
+
" if LLM_JUDGE_BASE_URL:\n",
|
| 803 |
+
" extra += f' --llm_judge_base_url {shlex.quote(LLM_JUDGE_BASE_URL)}'\n",
|
| 804 |
+
" if LLM_JUDGE_MAX_SAMPLES:\n",
|
| 805 |
+
" extra += f' --llm_judge_max_samples {int(LLM_JUDGE_MAX_SAMPLES)}'\n",
|
| 806 |
+
"\n",
|
| 807 |
+
"if not UPLOAD_RESULTS_TO_HF:\n",
|
| 808 |
+
" extra += ' --no_hf_upload'\n",
|
| 809 |
+
"\n",
|
| 810 |
+
"print(f'Evaluating run_id : {RUN_ID}')\n",
|
| 811 |
+
"print(f'Checkpoint : {CKPT_ARG}')\n",
|
| 812 |
+
"print(f'Task : {TASK} (split={SPLIT})')\n",
|
| 813 |
+
"print(f'Local results dir : {RESULTS_DIR_LOCAL}/{RUN_ID}/')\n",
|
| 814 |
+
"print(f'Upload to HF : {UPLOAD_RESULTS_TO_HF}')\n",
|
| 815 |
+
"print()\n",
|
| 816 |
+
"\n",
|
| 817 |
+
"# NOTE: do NOT set HF_HUB_DISABLE_PROGRESS_BARS=1 here. On a cold cache the\n",
|
| 818 |
+
"# 4-bit Vicuna shard download is ~13GB and takes minutes on Colab T4 — hiding\n",
|
| 819 |
+
"# the bar makes the cell look frozen. TQDM_MININTERVAL=1.0 forces the per-task\n",
|
| 820 |
+
"# tqdm in run_inference to refresh every 1s.\n",
|
| 821 |
+
"!TRANSFORMERS_VERBOSITY=warning TOKENIZERS_PARALLELISM=false BITSANDBYTES_NOWELCOME=1 \\\n",
|
| 822 |
+
"PYTHONUNBUFFERED=1 TQDM_MININTERVAL=1.0 \\\n",
|
| 823 |
+
"python -u -m evaluation.evaluate \\\n",
|
| 824 |
+
" --model_config configs/model_config.yaml \\\n",
|
| 825 |
+
" --train_config configs/train_config.yaml \\\n",
|
| 826 |
+
" --checkpoint \"{CKPT_ARG}\" \\\n",
|
| 827 |
+
" --run_id \"{RUN_ID}\" \\\n",
|
| 828 |
+
" --task {TASK} \\\n",
|
| 829 |
+
" --split {SPLIT} \\\n",
|
| 830 |
+
" --batch_size {BATCH_SIZE} \\\n",
|
| 831 |
+
" --max_new_tokens {MAX_NEW_TOKENS} \\\n",
|
| 832 |
+
" --output_dir \"{RESULTS_DIR_LOCAL}\" \\\n",
|
| 833 |
+
" --device cuda{extra}\n"
|
| 834 |
+
]
|
| 835 |
+
},
|
| 836 |
+
{
|
| 837 |
+
"cell_type": "markdown",
|
| 838 |
+
"id": "eval-summary-md",
|
| 839 |
+
"metadata": {},
|
| 840 |
+
"source": [
|
| 841 |
+
"## 8. Show the metrics summary\n"
|
| 842 |
+
]
|
| 843 |
+
},
|
| 844 |
+
{
|
| 845 |
+
"cell_type": "code",
|
| 846 |
+
"id": "eval-summary",
|
| 847 |
+
"metadata": {},
|
| 848 |
+
"execution_count": null,
|
| 849 |
+
"outputs": [],
|
| 850 |
+
"source": [
|
| 851 |
+
"import json as _json\n",
|
| 852 |
+
"\n",
|
| 853 |
+
"_summary_path = RESULTS_DIR_LOCAL / RUN_ID / 'metrics_summary.json'\n",
|
| 854 |
+
"if not _summary_path.is_file():\n",
|
| 855 |
+
" print(f'No metrics_summary.json at {_summary_path}. Did evaluate.py error out?')\n",
|
| 856 |
+
"else:\n",
|
| 857 |
+
" summary = _json.loads(_summary_path.read_text())\n",
|
| 858 |
+
" print(f'Dataset : {summary.get(\"dataset_name\")}')\n",
|
| 859 |
+
" print(f'Run ID : {summary.get(\"run_id\")}')\n",
|
| 860 |
+
" print(f'Split : {summary.get(\"split\")}')\n",
|
| 861 |
+
" print()\n",
|
| 862 |
+
" for task, metrics in (summary.get('metrics') or {}).items():\n",
|
| 863 |
+
" print(f'─── {task.upper()} ───')\n",
|
| 864 |
+
" for k, v in metrics.items():\n",
|
| 865 |
+
" if isinstance(v, float):\n",
|
| 866 |
+
" print(f' {k:<22s}: {v:.4f}')\n",
|
| 867 |
+
" else:\n",
|
| 868 |
+
" print(f' {k:<22s}: {v}')\n",
|
| 869 |
+
" print()\n",
|
| 870 |
+
"\n",
|
| 871 |
+
" # List per-task prediction files for convenience\n",
|
| 872 |
+
" print('Per-task prediction files:')\n",
|
| 873 |
+
" for f in sorted((RESULTS_DIR_LOCAL / RUN_ID).glob('predictions_*.json')):\n",
|
| 874 |
+
" n = len(_json.loads(f.read_text()))\n",
|
| 875 |
+
" print(f' {f.name} ({n} samples)')\n"
|
| 876 |
+
]
|
| 877 |
+
},
|
| 878 |
+
{
|
| 879 |
+
"cell_type": "markdown",
|
| 880 |
+
"id": "eval-cleanup-md",
|
| 881 |
+
"metadata": {},
|
| 882 |
+
"source": [
|
| 883 |
+
"### Done\n",
|
| 884 |
+
"\n",
|
| 885 |
+
"Final artifacts (also pushed to HF if `UPLOAD_RESULTS_TO_HF=True`):\n",
|
| 886 |
+
"\n",
|
| 887 |
+
"```\n",
|
| 888 |
+
"{LOCAL_RESULTS_DIR}/{RUN_ID}/\n",
|
| 889 |
+
" predictions_findings.json\n",
|
| 890 |
+
" predictions_impression.json\n",
|
| 891 |
+
" predictions_vqa.json (MIMIC datasets only)\n",
|
| 892 |
+
" metrics_summary.json\n",
|
| 893 |
+
"```\n",
|
| 894 |
+
"\n",
|
| 895 |
+
"For free-form inference on individual images (with image preview, custom VQA questions, etc.) use the separate **`cxrvlm_colab_inference.ipynb`** notebook.\n"
|
| 896 |
+
]
|
| 897 |
+
}
|
| 898 |
+
],
|
| 899 |
+
"metadata": {
|
| 900 |
+
"accelerator": "GPU",
|
| 901 |
+
"colab": {
|
| 902 |
+
"gpuType": "T4",
|
| 903 |
+
"provenance": [],
|
| 904 |
+
"machine_shape": "hm"
|
| 905 |
+
},
|
| 906 |
+
"kernelspec": {
|
| 907 |
+
"display_name": "Python 3",
|
| 908 |
+
"name": "python3"
|
| 909 |
+
},
|
| 910 |
+
"language_info": {
|
| 911 |
+
"name": "python",
|
| 912 |
+
"version": "3.10"
|
| 913 |
+
}
|
| 914 |
+
},
|
| 915 |
+
"nbformat": 4,
|
| 916 |
+
"nbformat_minor": 5
|
| 917 |
+
}
|
scripts/cxrvlm_colab_inference.ipynb
ADDED
|
@@ -0,0 +1,755 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "markdown",
|
| 5 |
+
"id": "inf-0",
|
| 6 |
+
"metadata": {},
|
| 7 |
+
"source": [
|
| 8 |
+
"# CXR-VLM — Inference Notebook (Colab T4 / GCP)\n",
|
| 9 |
+
"\n",
|
| 10 |
+
"Free-form inference on individual chest X-ray images. Use this when you want to:\n",
|
| 11 |
+
"- Run the trained model on a **new image** (not in the test split).\n",
|
| 12 |
+
"- Ask **custom VQA questions**.\n",
|
| 13 |
+
"- See the image + generated text side-by-side for qualitative checks.\n",
|
| 14 |
+
"- Iterate on prompts without re-running the heavy `evaluate.py` pipeline.\n",
|
| 15 |
+
"\n",
|
| 16 |
+
"For aggregate test-set metrics use the companion notebook **`cxrvlm_colab_eval.ipynb`**.\n",
|
| 17 |
+
"\n",
|
| 18 |
+
"### Sections\n",
|
| 19 |
+
"1. Selectors (which trained model to load + compute device)\n",
|
| 20 |
+
"2. Env + pip\n",
|
| 21 |
+
"3. Pull code from HF\n",
|
| 22 |
+
"4. Pull trained checkpoint + config snapshot from HF\n",
|
| 23 |
+
"5. Auto-detect GPU profile (bf16+FA2 on Ampere+/Ada, fp16+SDPA on Turing)\n",
|
| 24 |
+
"6. Build configs (4-bit Vicuna with the profile)\n",
|
| 25 |
+
"7. **Load model in-kernel** (one-time, ~5–8 min on T4 cold cache, ~3 min on L4)\n",
|
| 26 |
+
"8. **Inference helpers** (single image → findings / impression / report / VQA / full cascade)\n",
|
| 27 |
+
"9. **Examples** — single image, VQA, batch folder\n"
|
| 28 |
+
]
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
"cell_type": "markdown",
|
| 32 |
+
"id": "inf-select-md",
|
| 33 |
+
"metadata": {},
|
| 34 |
+
"source": [
|
| 35 |
+
"## 0. Select trained run + device\n"
|
| 36 |
+
]
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"cell_type": "code",
|
| 40 |
+
"id": "inf-select",
|
| 41 |
+
"metadata": {},
|
| 42 |
+
"execution_count": null,
|
| 43 |
+
"outputs": [],
|
| 44 |
+
"source": [
|
| 45 |
+
"# ── Platform ─────────────────────────────────────────────────────\n",
|
| 46 |
+
"PLATFORM = 'colab' # 'kaggle' | 'colab' | 'lightning' | 'gcp' | 'local'\n",
|
| 47 |
+
"\n",
|
| 48 |
+
"# ── Source repos on HuggingFace ──────────────────────────────────\n",
|
| 49 |
+
"HF_USER = 'hieu3636'\n",
|
| 50 |
+
"HF_CODE_REPO = f'{HF_USER}/cxr-vlm-code'\n",
|
| 51 |
+
"HF_RUNS_REPO = f'{HF_USER}/cxr-vlm-runs'\n",
|
| 52 |
+
"\n",
|
| 53 |
+
"# ── Which trained run to use ─────────────────────────────────────\n",
|
| 54 |
+
"RUN_ID = 'MIMIC-CXR_resized_run_1'\n",
|
| 55 |
+
"CKPT_PICK = 'best' # 'best' | 'last'\n",
|
| 56 |
+
"\n",
|
| 57 |
+
"# ── Generation defaults (override per-call later if desired) ─────\n",
|
| 58 |
+
"MAX_NEW_TOKENS = 300\n",
|
| 59 |
+
"TEMPERATURE = 0.1\n",
|
| 60 |
+
"DO_SAMPLE = False # greedy by default — deterministic\n",
|
| 61 |
+
"NUM_BEAMS = 1\n",
|
| 62 |
+
"\n",
|
| 63 |
+
"assert PLATFORM in ('kaggle', 'colab', 'lightning', 'gcp', 'local')\n",
|
| 64 |
+
"assert CKPT_PICK in ('best', 'last')\n",
|
| 65 |
+
"\n",
|
| 66 |
+
"print(f'PLATFORM = {PLATFORM}')\n",
|
| 67 |
+
"print(f'RUN_ID = {RUN_ID} (ckpt: stage2/{CKPT_PICK})')\n"
|
| 68 |
+
]
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
"cell_type": "markdown",
|
| 72 |
+
"id": "inf-env-md",
|
| 73 |
+
"metadata": {},
|
| 74 |
+
"source": [
|
| 75 |
+
"## 1. Environment + pip install"
|
| 76 |
+
]
|
| 77 |
+
},
|
| 78 |
+
{
|
| 79 |
+
"cell_type": "code",
|
| 80 |
+
"id": "inf-env",
|
| 81 |
+
"metadata": {},
|
| 82 |
+
"execution_count": null,
|
| 83 |
+
"outputs": [],
|
| 84 |
+
"source": [
|
| 85 |
+
"import os\n",
|
| 86 |
+
"os.environ['CUDA_VISIBLE_DEVICES'] = '0'\n",
|
| 87 |
+
"os.environ['TOKENIZERS_PARALLELISM'] = 'false'\n",
|
| 88 |
+
"os.environ['BITSANDBYTES_NOWELCOME'] = '1'\n",
|
| 89 |
+
"os.environ['TRANSFORMERS_VERBOSITY'] = 'warning'\n",
|
| 90 |
+
"os.environ['PYTHONUNBUFFERED'] = '1'\n",
|
| 91 |
+
"\n",
|
| 92 |
+
"import sys, shutil, subprocess\n",
|
| 93 |
+
"from pathlib import Path\n"
|
| 94 |
+
]
|
| 95 |
+
},
|
| 96 |
+
{
|
| 97 |
+
"cell_type": "code",
|
| 98 |
+
"id": "inf-pip",
|
| 99 |
+
"metadata": {},
|
| 100 |
+
"execution_count": null,
|
| 101 |
+
"outputs": [],
|
| 102 |
+
"source": [
|
| 103 |
+
"# Same dependency window as the training/eval notebooks.\n",
|
| 104 |
+
"!pip uninstall -y -q torchao transformers bitsandbytes peft accelerate\n",
|
| 105 |
+
"!pip install -q -U bitsandbytes\n",
|
| 106 |
+
"!pip install -q \\\n",
|
| 107 |
+
" 'transformers>=4.46,<4.50' \\\n",
|
| 108 |
+
" 'peft>=0.13,<0.15' \\\n",
|
| 109 |
+
" 'accelerate>=1.0' \\\n",
|
| 110 |
+
" 'huggingface_hub>=0.27,<1.0' \\\n",
|
| 111 |
+
" omegaconf sentencepiece 'protobuf>=3.20' \\\n",
|
| 112 |
+
" pillow matplotlib\n",
|
| 113 |
+
"\n",
|
| 114 |
+
"# Best-effort flash-attn install for Ampere+/Ada GPUs (L4, 3090, A10, A100, H100).\n",
|
| 115 |
+
"# Silent fail is OK — cxr_vlm.py auto-falls-back to SDPA if FA2 isn't importable.\n",
|
| 116 |
+
"import torch as _t\n",
|
| 117 |
+
"if _t.cuda.is_available() and _t.cuda.get_device_capability(0) >= (8, 0):\n",
|
| 118 |
+
" print('[pip] Ampere+/Ada detected -> trying flash-attn (5-10 min if building from source)')\n",
|
| 119 |
+
" !pip install -q flash-attn --no-build-isolation 2>&1 | tail -5\n",
|
| 120 |
+
"else:\n",
|
| 121 |
+
" print('[pip] Pre-Ampere GPU (or no CUDA) -> skipping flash-attn install')\n"
|
| 122 |
+
]
|
| 123 |
+
},
|
| 124 |
+
{
|
| 125 |
+
"cell_type": "code",
|
| 126 |
+
"id": "inf-versions",
|
| 127 |
+
"metadata": {},
|
| 128 |
+
"execution_count": null,
|
| 129 |
+
"outputs": [],
|
| 130 |
+
"source": [
|
| 131 |
+
"import torch, transformers, peft, huggingface_hub, httpx\n",
|
| 132 |
+
"\n",
|
| 133 |
+
"print('torch :', torch.__version__, '| cuda:', torch.cuda.is_available())\n",
|
| 134 |
+
"print('transformers :', transformers.__version__)\n",
|
| 135 |
+
"print('peft :', peft.__version__)\n",
|
| 136 |
+
"print('huggingface_hub:', huggingface_hub.__version__)\n",
|
| 137 |
+
"print('httpx :', httpx.__version__)\n",
|
| 138 |
+
"\n",
|
| 139 |
+
"# httpx 0.28+ shim — same as the other notebooks.\n",
|
| 140 |
+
"def _patch_httpx():\n",
|
| 141 |
+
" if tuple(int(x) for x in httpx.__version__.split('.')[:2]) < (0, 28):\n",
|
| 142 |
+
" return\n",
|
| 143 |
+
" if getattr(httpx.Client, '_cxr_vlm_compat_patched', False):\n",
|
| 144 |
+
" return\n",
|
| 145 |
+
" def _make(orig):\n",
|
| 146 |
+
" def patched(self, *args, **kwargs):\n",
|
| 147 |
+
" if 'allow_redirects' in kwargs:\n",
|
| 148 |
+
" kwargs['follow_redirects'] = kwargs.pop('allow_redirects')\n",
|
| 149 |
+
" kwargs.pop('proxies', None)\n",
|
| 150 |
+
" return orig(self, *args, **kwargs)\n",
|
| 151 |
+
" return patched\n",
|
| 152 |
+
" for cls in (httpx.Client, httpx.AsyncClient):\n",
|
| 153 |
+
" for m in ('request', 'get', 'head', 'post', 'put',\n",
|
| 154 |
+
" 'patch', 'delete', 'options'):\n",
|
| 155 |
+
" if hasattr(cls, m):\n",
|
| 156 |
+
" setattr(cls, m, _make(getattr(cls, m)))\n",
|
| 157 |
+
" httpx.Client._cxr_vlm_compat_patched = True\n",
|
| 158 |
+
"\n",
|
| 159 |
+
"_patch_httpx()\n",
|
| 160 |
+
"\n",
|
| 161 |
+
"assert torch.cuda.is_available(), 'CUDA not available.'\n",
|
| 162 |
+
"_p = torch.cuda.get_device_properties(0)\n",
|
| 163 |
+
"print(f'GPU : {_p.name} ({_p.total_memory/1e9:.1f} GB)')\n"
|
| 164 |
+
]
|
| 165 |
+
},
|
| 166 |
+
{
|
| 167 |
+
"cell_type": "markdown",
|
| 168 |
+
"id": "inf-paths-md",
|
| 169 |
+
"metadata": {},
|
| 170 |
+
"source": [
|
| 171 |
+
"## 2. Paths + pull code"
|
| 172 |
+
]
|
| 173 |
+
},
|
| 174 |
+
{
|
| 175 |
+
"cell_type": "code",
|
| 176 |
+
"id": "inf-paths",
|
| 177 |
+
"metadata": {},
|
| 178 |
+
"execution_count": null,
|
| 179 |
+
"outputs": [],
|
| 180 |
+
"source": [
|
| 181 |
+
"# WORK dir + HF_TOKEN bootstrap (platform-specific).\n",
|
| 182 |
+
"if PLATFORM == 'kaggle':\n",
|
| 183 |
+
" from kaggle_secrets import UserSecretsClient\n",
|
| 184 |
+
" os.environ['HF_TOKEN'] = UserSecretsClient().get_secret('HF_TOKEN')\n",
|
| 185 |
+
" WORK = Path('/kaggle/working')\n",
|
| 186 |
+
"elif PLATFORM == 'colab':\n",
|
| 187 |
+
" from google.colab import userdata\n",
|
| 188 |
+
" os.environ['HF_TOKEN'] = userdata.get('HF_TOKEN')\n",
|
| 189 |
+
" WORK = Path('/content')\n",
|
| 190 |
+
"elif PLATFORM == 'lightning':\n",
|
| 191 |
+
" WORK = Path('/teamspace/studios/this_studio')\n",
|
| 192 |
+
"elif PLATFORM == 'gcp':\n",
|
| 193 |
+
" # Vertex AI Workbench → /home/jupyter. Generic GCE VM → /workspace.\n",
|
| 194 |
+
" # Pick whichever exists; create the latter if neither is writable.\n",
|
| 195 |
+
" for _cand in (Path('/home/jupyter'), Path('/workspace')):\n",
|
| 196 |
+
" if _cand.exists() or os.access(_cand.parent, os.W_OK):\n",
|
| 197 |
+
" WORK = _cand\n",
|
| 198 |
+
" break\n",
|
| 199 |
+
" else:\n",
|
| 200 |
+
" WORK = Path.home() / 'cxr-vlm-work'\n",
|
| 201 |
+
"else: # 'local'\n",
|
| 202 |
+
" WORK = Path.home() / 'cxr-vlm-work'\n",
|
| 203 |
+
"WORK.mkdir(parents=True, exist_ok=True)\n",
|
| 204 |
+
"\n",
|
| 205 |
+
"assert os.environ.get('HF_TOKEN'), \\\n",
|
| 206 |
+
" 'HF_TOKEN missing — set it via the platform secrets UI before re-running.'\n",
|
| 207 |
+
"\n",
|
| 208 |
+
"from huggingface_hub import snapshot_download\n",
|
| 209 |
+
"\n",
|
| 210 |
+
"print(f'Pulling code from {HF_CODE_REPO} …')\n",
|
| 211 |
+
"CODE_SRC = Path(snapshot_download(\n",
|
| 212 |
+
" repo_id = HF_CODE_REPO,\n",
|
| 213 |
+
" repo_type = 'model',\n",
|
| 214 |
+
" token = os.environ['HF_TOKEN'],\n",
|
| 215 |
+
" local_dir = str(WORK / 'cxr-vlm-code'),\n",
|
| 216 |
+
"))\n",
|
| 217 |
+
"\n",
|
| 218 |
+
"PROJECT = WORK / 'cxr_vlm'\n",
|
| 219 |
+
"if CODE_SRC.resolve() != PROJECT.resolve() and not PROJECT.exists():\n",
|
| 220 |
+
" shutil.copytree(CODE_SRC, PROJECT)\n",
|
| 221 |
+
"\n",
|
| 222 |
+
"os.chdir(PROJECT)\n",
|
| 223 |
+
"sys.path.insert(0, str(PROJECT))\n",
|
| 224 |
+
"print('PROJECT :', PROJECT)\n",
|
| 225 |
+
"print('WORK :', WORK)\n"
|
| 226 |
+
]
|
| 227 |
+
},
|
| 228 |
+
{
|
| 229 |
+
"cell_type": "markdown",
|
| 230 |
+
"id": "inf-pull-run-md",
|
| 231 |
+
"metadata": {},
|
| 232 |
+
"source": [
|
| 233 |
+
"## 3. Pull trained checkpoint + config snapshot\n",
|
| 234 |
+
"\n",
|
| 235 |
+
"Same as the eval notebook: pulls `{RUN_ID}/configs/` + `{RUN_ID}/stage2/{best|last}/` from `HF_RUNS_REPO`. No dataset payload needed for free-form inference.\n"
|
| 236 |
+
]
|
| 237 |
+
},
|
| 238 |
+
{
|
| 239 |
+
"cell_type": "code",
|
| 240 |
+
"id": "inf-pull-run",
|
| 241 |
+
"metadata": {},
|
| 242 |
+
"execution_count": null,
|
| 243 |
+
"outputs": [],
|
| 244 |
+
"source": [
|
| 245 |
+
"from huggingface_hub import snapshot_download\n",
|
| 246 |
+
"\n",
|
| 247 |
+
"RUN_PULL_ROOT = WORK / 'run_pull'\n",
|
| 248 |
+
"RUN_PULL_ROOT.mkdir(parents=True, exist_ok=True)\n",
|
| 249 |
+
"\n",
|
| 250 |
+
"print(f'Pulling {RUN_ID}/{{configs,stage2/{CKPT_PICK}}} from {HF_RUNS_REPO} …')\n",
|
| 251 |
+
"snapshot_download(\n",
|
| 252 |
+
" repo_id = HF_RUNS_REPO,\n",
|
| 253 |
+
" repo_type = 'model',\n",
|
| 254 |
+
" token = os.environ['HF_TOKEN'],\n",
|
| 255 |
+
" allow_patterns = [\n",
|
| 256 |
+
" f'{RUN_ID}/configs/**',\n",
|
| 257 |
+
" f'{RUN_ID}/run_meta.json',\n",
|
| 258 |
+
" f'{RUN_ID}/stage2/{CKPT_PICK}/**',\n",
|
| 259 |
+
" ],\n",
|
| 260 |
+
" local_dir = str(RUN_PULL_ROOT),\n",
|
| 261 |
+
")\n",
|
| 262 |
+
"\n",
|
| 263 |
+
"RUN_DIR_PULLED = RUN_PULL_ROOT / RUN_ID\n",
|
| 264 |
+
"CKPT_DIR_PULLED = RUN_DIR_PULLED / 'stage2' / CKPT_PICK\n",
|
| 265 |
+
"PROJ_PT = CKPT_DIR_PULLED / 'checkpoint_projection.pt'\n",
|
| 266 |
+
"LORA_DIR = CKPT_DIR_PULLED / 'checkpoint_lora'\n",
|
| 267 |
+
"CHEXPERT_PT = CKPT_DIR_PULLED / 'checkpoint_chexpert_classifier.pt'\n",
|
| 268 |
+
"\n",
|
| 269 |
+
"assert PROJ_PT.is_file(), f'Projection weights not found at {PROJ_PT}.'\n",
|
| 270 |
+
"assert (LORA_DIR / 'adapter_config.json').is_file(), \\\n",
|
| 271 |
+
" f'LoRA adapter_config.json not found in {LORA_DIR}.'\n",
|
| 272 |
+
"\n",
|
| 273 |
+
"print(f' projection : {PROJ_PT} ({PROJ_PT.stat().st_size/1e6:.1f} MB)')\n",
|
| 274 |
+
"print(f' lora : {LORA_DIR}/ ({sum(p.stat().st_size for p in LORA_DIR.rglob(\"*\") if p.is_file())/1e6:.1f} MB)')\n",
|
| 275 |
+
"print(f' chexpert : {CHEXPERT_PT} (exists: {CHEXPERT_PT.is_file()})')\n",
|
| 276 |
+
"\n",
|
| 277 |
+
"SAVED_CFG_DIR = RUN_DIR_PULLED / 'configs'\n",
|
| 278 |
+
"SAVED_TRAIN_CFG = SAVED_CFG_DIR / 'train_config.yaml'\n",
|
| 279 |
+
"SAVED_MODEL_CFG = SAVED_CFG_DIR / 'model_config.yaml'\n"
|
| 280 |
+
]
|
| 281 |
+
},
|
| 282 |
+
{
|
| 283 |
+
"cell_type": "markdown",
|
| 284 |
+
"id": "inf-gpu-md",
|
| 285 |
+
"metadata": {},
|
| 286 |
+
"source": [
|
| 287 |
+
"## 4. Auto-detect GPU profile\n",
|
| 288 |
+
"\n",
|
| 289 |
+
"Picks precision + attention backend based on the actual GPU:\n",
|
| 290 |
+
"- **Turing (T4, V100)** → fp16 + SDPA\n",
|
| 291 |
+
"- **Ampere+ / Ada (3090, L4, A10, A100, H100)** → bf16 + FA2 (if flash-attn installed; falls back to SDPA otherwise)\n"
|
| 292 |
+
]
|
| 293 |
+
},
|
| 294 |
+
{
|
| 295 |
+
"cell_type": "code",
|
| 296 |
+
"id": "inf-gpu",
|
| 297 |
+
"metadata": {},
|
| 298 |
+
"execution_count": null,
|
| 299 |
+
"outputs": [],
|
| 300 |
+
"source": [
|
| 301 |
+
"import torch\n",
|
| 302 |
+
"\n",
|
| 303 |
+
"assert torch.cuda.is_available(), 'CUDA not available.'\n",
|
| 304 |
+
"_p = torch.cuda.get_device_properties(0)\n",
|
| 305 |
+
"_cap = (_p.major, _p.minor)\n",
|
| 306 |
+
"_vram_gb = _p.total_memory / 1e9\n",
|
| 307 |
+
"_bf16_ok = torch.cuda.is_bf16_supported()\n",
|
| 308 |
+
"_fa2_ok = _cap >= (8, 0)\n",
|
| 309 |
+
"\n",
|
| 310 |
+
"_flash_attn_installed = False\n",
|
| 311 |
+
"if _fa2_ok:\n",
|
| 312 |
+
" try:\n",
|
| 313 |
+
" import flash_attn # noqa: F401\n",
|
| 314 |
+
" _flash_attn_installed = True\n",
|
| 315 |
+
" except Exception:\n",
|
| 316 |
+
" _flash_attn_installed = False\n",
|
| 317 |
+
"\n",
|
| 318 |
+
"PROFILE = dict(\n",
|
| 319 |
+
" label = _p.name,\n",
|
| 320 |
+
" torch_dtype = 'bfloat16' if _bf16_ok else 'float16',\n",
|
| 321 |
+
" bnb_4bit_compute_dtype = 'bfloat16' if _bf16_ok else 'float16',\n",
|
| 322 |
+
" attn_implementation = 'flash_attention_2' if (_fa2_ok and _flash_attn_installed) else 'sdpa',\n",
|
| 323 |
+
")\n",
|
| 324 |
+
"\n",
|
| 325 |
+
"print(f'GPU : {_p.name} ({_vram_gb:.1f} GB)')\n",
|
| 326 |
+
"print(f'Compute cap : sm_{_cap[0]}{_cap[1]} bf16 ok: {_bf16_ok} FA2 wheel: {_flash_attn_installed}')\n",
|
| 327 |
+
"print(f'-> precision: {PROFILE[\"torch_dtype\"]} attn: {PROFILE[\"attn_implementation\"]}')\n"
|
| 328 |
+
]
|
| 329 |
+
},
|
| 330 |
+
{
|
| 331 |
+
"cell_type": "markdown",
|
| 332 |
+
"id": "inf-cfg-md",
|
| 333 |
+
"metadata": {},
|
| 334 |
+
"source": [
|
| 335 |
+
"## 5. Build configs"
|
| 336 |
+
]
|
| 337 |
+
},
|
| 338 |
+
{
|
| 339 |
+
"cell_type": "code",
|
| 340 |
+
"id": "inf-cfg",
|
| 341 |
+
"metadata": {},
|
| 342 |
+
"execution_count": null,
|
| 343 |
+
"outputs": [],
|
| 344 |
+
"source": [
|
| 345 |
+
"from omegaconf import OmegaConf\n",
|
| 346 |
+
"\n",
|
| 347 |
+
"if SAVED_MODEL_CFG.is_file():\n",
|
| 348 |
+
" model_cfg = OmegaConf.load(SAVED_MODEL_CFG)\n",
|
| 349 |
+
" print(f'model_cfg <- {SAVED_MODEL_CFG}')\n",
|
| 350 |
+
"else:\n",
|
| 351 |
+
" model_cfg = OmegaConf.load(PROJECT / 'configs' / 'model_config.yaml')\n",
|
| 352 |
+
" print(f'model_cfg <- repo default')\n",
|
| 353 |
+
"\n",
|
| 354 |
+
"if SAVED_TRAIN_CFG.is_file():\n",
|
| 355 |
+
" train_cfg = OmegaConf.load(SAVED_TRAIN_CFG)\n",
|
| 356 |
+
"else:\n",
|
| 357 |
+
" train_cfg = OmegaConf.load(PROJECT / 'configs' / 'train_config.yaml')\n",
|
| 358 |
+
"\n",
|
| 359 |
+
"# Compute from auto-detected PROFILE (previous cell).\n",
|
| 360 |
+
"model_cfg.llm.load_in_4bit = True\n",
|
| 361 |
+
"model_cfg.llm.load_in_8bit = False\n",
|
| 362 |
+
"model_cfg.llm.attn_implementation = PROFILE['attn_implementation']\n",
|
| 363 |
+
"model_cfg.llm.torch_dtype = PROFILE['torch_dtype']\n",
|
| 364 |
+
"model_cfg.llm.bnb_4bit_compute_dtype = PROFILE['bnb_4bit_compute_dtype']\n",
|
| 365 |
+
"model_cfg.llm.bnb_4bit_quant_type = 'nf4'\n",
|
| 366 |
+
"model_cfg.llm.bnb_4bit_use_double_quant = True\n",
|
| 367 |
+
"model_cfg.llm.gradient_checkpointing = False # inference only\n",
|
| 368 |
+
"\n",
|
| 369 |
+
"# CheXpert classifier — enable iff its checkpoint is pulled.\n",
|
| 370 |
+
"if CHEXPERT_PT.is_file():\n",
|
| 371 |
+
" model_cfg.chexpert_classifier.enabled = True\n",
|
| 372 |
+
" print(f'CheXpert classifier checkpoint found -> ENABLED')\n",
|
| 373 |
+
"else:\n",
|
| 374 |
+
" model_cfg.chexpert_classifier.enabled = False\n",
|
| 375 |
+
" print('No CheXpert classifier in this run -> DISABLED '\n",
|
| 376 |
+
" '(structured_findings will be None unless you pass it explicitly)')\n",
|
| 377 |
+
"\n",
|
| 378 |
+
"print('\\nmodel_cfg.llm:')\n",
|
| 379 |
+
"print(OmegaConf.to_yaml(model_cfg.llm))\n"
|
| 380 |
+
]
|
| 381 |
+
},
|
| 382 |
+
{
|
| 383 |
+
"cell_type": "markdown",
|
| 384 |
+
"id": "inf-load-md",
|
| 385 |
+
"metadata": {},
|
| 386 |
+
"source": [
|
| 387 |
+
"## 6. Load model into kernel\n",
|
| 388 |
+
"\n",
|
| 389 |
+
"This is the slow phase on cold cache: encoder + 4-bit Vicuna shard download + quantization. Run **once per session** and reuse the `model` object for all subsequent inferences.\n",
|
| 390 |
+
"\n",
|
| 391 |
+
"Approx timing: ~8 min on T4 cold, ~3 min on L4 cold, seconds when cache is warm.\n"
|
| 392 |
+
]
|
| 393 |
+
},
|
| 394 |
+
{
|
| 395 |
+
"cell_type": "code",
|
| 396 |
+
"id": "inf-load",
|
| 397 |
+
"metadata": {},
|
| 398 |
+
"execution_count": null,
|
| 399 |
+
"outputs": [],
|
| 400 |
+
"source": [
|
| 401 |
+
"import time, torch\n",
|
| 402 |
+
"from model import CXRVisionLanguageModel\n",
|
| 403 |
+
"from model.rad_dino import BioViLTEncoder\n",
|
| 404 |
+
"from utils.checkpoint import load_checkpoint\n",
|
| 405 |
+
"\n",
|
| 406 |
+
"print('[1/3] Building model (Vicuna-7B 4-bit + RAD-DINO + LoRA)…')\n",
|
| 407 |
+
"print(' On T4 cold cache this takes ~5–8 min for the shard download + quantization.')\n",
|
| 408 |
+
"_t0 = time.time()\n",
|
| 409 |
+
"model = CXRVisionLanguageModel(model_cfg)\n",
|
| 410 |
+
"print(f' built in {time.time()-_t0:.1f}s', flush=True)\n",
|
| 411 |
+
"\n",
|
| 412 |
+
"print(f'[2/3] Loading checkpoint from {PROJ_PT.name} …')\n",
|
| 413 |
+
"_t0 = time.time()\n",
|
| 414 |
+
"load_checkpoint(model, str(PROJ_PT))\n",
|
| 415 |
+
"print(f' loaded in {time.time()-_t0:.1f}s', flush=True)\n",
|
| 416 |
+
"\n",
|
| 417 |
+
"print('[3/3] Moving to cuda + eval()')\n",
|
| 418 |
+
"_t0 = time.time()\n",
|
| 419 |
+
"model = model.to('cuda').eval()\n",
|
| 420 |
+
"print(f' ready in {time.time()-_t0:.1f}s', flush=True)\n",
|
| 421 |
+
"\n",
|
| 422 |
+
"# Cache transform + chexpert helpers for reuse.\n",
|
| 423 |
+
"TRANSFORM = BioViLTEncoder.get_transform('val')\n",
|
| 424 |
+
"print('\\nModel ready. VRAM used:',\n",
|
| 425 |
+
" f'{torch.cuda.memory_allocated()/1e9:.2f} GB / {torch.cuda.get_device_properties(0).total_memory/1e9:.1f} GB')\n"
|
| 426 |
+
]
|
| 427 |
+
},
|
| 428 |
+
{
|
| 429 |
+
"cell_type": "markdown",
|
| 430 |
+
"id": "inf-helpers-md",
|
| 431 |
+
"metadata": {},
|
| 432 |
+
"source": [
|
| 433 |
+
"## 7. Inference helpers\n",
|
| 434 |
+
"\n",
|
| 435 |
+
"Two convenience functions:\n",
|
| 436 |
+
"\n",
|
| 437 |
+
"- `predict(image, task, question=None, structured_findings=None, ...)` — single image, single task.\n",
|
| 438 |
+
"- `predict_report(image, ...)` — cascade: generates findings first, then feeds the model's own findings as context for impression. Returns both sections plus the merged report.\n",
|
| 439 |
+
"\n",
|
| 440 |
+
"`image` can be a filesystem path (str/Path) or a `PIL.Image.Image`.\n",
|
| 441 |
+
"\n",
|
| 442 |
+
"### About `structured_findings` (the PNU prompt block)\n",
|
| 443 |
+
"\n",
|
| 444 |
+
"Your training run baked **oracle CheXpert labels from the CSV/manifest** into the prompt as the `Positive / Negative / Uncertain Abnormalities` block. At inference time you don't have GT labels — you have three choices:\n",
|
| 445 |
+
"\n",
|
| 446 |
+
"1. **`None` (default)** — no PNU block prepended. The model sees only the image + the task instruction. This is the realistic deployment mode.\n",
|
| 447 |
+
"2. **Pass a PNU string yourself** — for oracle ablation or testing how much PNU helps:\n",
|
| 448 |
+
" ```\n",
|
| 449 |
+
" structured_findings='Positive Abnormalities: Cardiomegaly\\nNegative Abnormalities: No Finding, ...\\nUncertain Abnormalities: None'\n",
|
| 450 |
+
" ```\n",
|
| 451 |
+
"3. **Train a CheXpert classifier** (Stage 0 in the training pipeline) and load it via `model_cfg.chexpert_classifier.checkpoint` — your current runs do not have one, so this path is disabled in section 4.\n"
|
| 452 |
+
]
|
| 453 |
+
},
|
| 454 |
+
{
|
| 455 |
+
"cell_type": "code",
|
| 456 |
+
"id": "inf-helpers",
|
| 457 |
+
"metadata": {},
|
| 458 |
+
"execution_count": null,
|
| 459 |
+
"outputs": [],
|
| 460 |
+
"source": [
|
| 461 |
+
"import torch\n",
|
| 462 |
+
"from pathlib import Path\n",
|
| 463 |
+
"from typing import Optional, Union\n",
|
| 464 |
+
"from PIL import Image\n",
|
| 465 |
+
"\n",
|
| 466 |
+
"from data.prompt_templates import (\n",
|
| 467 |
+
" build_findings_prompt, build_impression_prompt,\n",
|
| 468 |
+
" build_report_prompt, build_vqa_prompt,\n",
|
| 469 |
+
")\n",
|
| 470 |
+
"\n",
|
| 471 |
+
"ImageLike = Union[str, Path, Image.Image]\n",
|
| 472 |
+
"\n",
|
| 473 |
+
"\n",
|
| 474 |
+
"def _to_tensor(image: ImageLike) -> torch.Tensor:\n",
|
| 475 |
+
" '''Load + transform a CXR into a (1, C, H, W) tensor on cuda.'''\n",
|
| 476 |
+
" if isinstance(image, (str, Path)):\n",
|
| 477 |
+
" img = Image.open(image).convert('RGB')\n",
|
| 478 |
+
" else:\n",
|
| 479 |
+
" img = image.convert('RGB') if image.mode != 'RGB' else image\n",
|
| 480 |
+
" t = TRANSFORM(img) # (C, H, W)\n",
|
| 481 |
+
" return t.unsqueeze(0).to('cuda') # (1, C, H, W)\n",
|
| 482 |
+
"\n",
|
| 483 |
+
"\n",
|
| 484 |
+
"@torch.no_grad()\n",
|
| 485 |
+
"def predict(\n",
|
| 486 |
+
" image: ImageLike,\n",
|
| 487 |
+
" task: str,\n",
|
| 488 |
+
" question: Optional[str] = None,\n",
|
| 489 |
+
" structured_findings: Optional[str] = None,\n",
|
| 490 |
+
" max_new_tokens: int = None,\n",
|
| 491 |
+
" temperature: float = None,\n",
|
| 492 |
+
" do_sample: bool = None,\n",
|
| 493 |
+
" num_beams: int = None,\n",
|
| 494 |
+
") -> str:\n",
|
| 495 |
+
" '''Run the model on one image.\n",
|
| 496 |
+
"\n",
|
| 497 |
+
" task ∈ {'findings', 'impression', 'report', 'vqa'}.\n",
|
| 498 |
+
" For task='vqa', `question` is required.\n",
|
| 499 |
+
" '''\n",
|
| 500 |
+
" assert task in ('findings', 'impression', 'report', 'vqa'), f'bad task: {task}'\n",
|
| 501 |
+
" if task == 'vqa':\n",
|
| 502 |
+
" assert question, 'VQA requires `question`.'\n",
|
| 503 |
+
"\n",
|
| 504 |
+
" sf = structured_findings\n",
|
| 505 |
+
" if task == 'findings':\n",
|
| 506 |
+
" prompt = build_findings_prompt(sf, randomize=False)\n",
|
| 507 |
+
" elif task == 'impression':\n",
|
| 508 |
+
" prompt = build_impression_prompt(sf, randomize=False)\n",
|
| 509 |
+
" elif task == 'report':\n",
|
| 510 |
+
" prompt = build_report_prompt(sf, randomize=False)\n",
|
| 511 |
+
" else:\n",
|
| 512 |
+
" prompt = build_vqa_prompt(question, sf)\n",
|
| 513 |
+
"\n",
|
| 514 |
+
" out = model.generate(\n",
|
| 515 |
+
" images = _to_tensor(image),\n",
|
| 516 |
+
" prompts = [prompt],\n",
|
| 517 |
+
" max_new_tokens = max_new_tokens if max_new_tokens is not None else MAX_NEW_TOKENS,\n",
|
| 518 |
+
" temperature = temperature if temperature is not None else TEMPERATURE,\n",
|
| 519 |
+
" do_sample = do_sample if do_sample is not None else DO_SAMPLE,\n",
|
| 520 |
+
" num_beams = num_beams if num_beams is not None else NUM_BEAMS,\n",
|
| 521 |
+
" )\n",
|
| 522 |
+
" return out[0]\n",
|
| 523 |
+
"\n",
|
| 524 |
+
"\n",
|
| 525 |
+
"@torch.no_grad()\n",
|
| 526 |
+
"def predict_report(\n",
|
| 527 |
+
" image: ImageLike,\n",
|
| 528 |
+
" structured_findings: Optional[str] = None,\n",
|
| 529 |
+
" max_new_tokens: int = None,\n",
|
| 530 |
+
"):\n",
|
| 531 |
+
" '''Cascade: generate findings first, then feed them as context for impression.\n",
|
| 532 |
+
"\n",
|
| 533 |
+
" Matches the `split_cascade` training recipe but with model-generated (not\n",
|
| 534 |
+
" GT) findings — i.e. a real end-to-end cascade (no GT leakage).\n",
|
| 535 |
+
"\n",
|
| 536 |
+
" Returns dict {findings, impression, report}.\n",
|
| 537 |
+
" '''\n",
|
| 538 |
+
" # 1) Findings (uses PNU context only — None by default)\n",
|
| 539 |
+
" findings = predict(image, task='findings',\n",
|
| 540 |
+
" structured_findings=structured_findings,\n",
|
| 541 |
+
" max_new_tokens=max_new_tokens)\n",
|
| 542 |
+
"\n",
|
| 543 |
+
" # 2) Impression: feed the generated findings as the structured_findings\n",
|
| 544 |
+
" # context — same shape as the cascade trainer saw at training time\n",
|
| 545 |
+
" # but with the model's own findings instead of GT.\n",
|
| 546 |
+
" impression = predict(image, task='impression',\n",
|
| 547 |
+
" structured_findings=findings,\n",
|
| 548 |
+
" max_new_tokens=max_new_tokens)\n",
|
| 549 |
+
"\n",
|
| 550 |
+
" return {\n",
|
| 551 |
+
" 'findings': findings,\n",
|
| 552 |
+
" 'impression': impression,\n",
|
| 553 |
+
" 'report': f'Findings: {findings}\\n\\nImpression: {impression}',\n",
|
| 554 |
+
" }\n"
|
| 555 |
+
]
|
| 556 |
+
},
|
| 557 |
+
{
|
| 558 |
+
"cell_type": "markdown",
|
| 559 |
+
"id": "inf-show-md",
|
| 560 |
+
"metadata": {},
|
| 561 |
+
"source": [
|
| 562 |
+
"## 8. Display helper (optional, pretty-prints image + outputs)"
|
| 563 |
+
]
|
| 564 |
+
},
|
| 565 |
+
{
|
| 566 |
+
"cell_type": "code",
|
| 567 |
+
"id": "inf-show",
|
| 568 |
+
"metadata": {},
|
| 569 |
+
"execution_count": null,
|
| 570 |
+
"outputs": [],
|
| 571 |
+
"source": [
|
| 572 |
+
"import matplotlib.pyplot as plt\n",
|
| 573 |
+
"from PIL import Image as _PILImage\n",
|
| 574 |
+
"\n",
|
| 575 |
+
"def show(image, title=None, max_size=512):\n",
|
| 576 |
+
" '''Render an image inline with optional title.'''\n",
|
| 577 |
+
" if isinstance(image, (str, Path)):\n",
|
| 578 |
+
" img = _PILImage.open(image).convert('RGB')\n",
|
| 579 |
+
" else:\n",
|
| 580 |
+
" img = image\n",
|
| 581 |
+
" img.thumbnail((max_size, max_size))\n",
|
| 582 |
+
" plt.figure(figsize=(6, 6))\n",
|
| 583 |
+
" plt.imshow(img)\n",
|
| 584 |
+
" plt.axis('off')\n",
|
| 585 |
+
" if title:\n",
|
| 586 |
+
" plt.title(title)\n",
|
| 587 |
+
" plt.show()\n",
|
| 588 |
+
"\n",
|
| 589 |
+
"def pretty(d: dict, header: str = None):\n",
|
| 590 |
+
" '''Pretty-print a dict of {label: text}.'''\n",
|
| 591 |
+
" if header:\n",
|
| 592 |
+
" print('=' * 80)\n",
|
| 593 |
+
" print(header)\n",
|
| 594 |
+
" for k, v in d.items():\n",
|
| 595 |
+
" print('-' * 80)\n",
|
| 596 |
+
" print(f'[{k.upper()}]')\n",
|
| 597 |
+
" print(v)\n",
|
| 598 |
+
" print('=' * 80)\n"
|
| 599 |
+
]
|
| 600 |
+
},
|
| 601 |
+
{
|
| 602 |
+
"cell_type": "markdown",
|
| 603 |
+
"id": "inf-ex1-md",
|
| 604 |
+
"metadata": {},
|
| 605 |
+
"source": [
|
| 606 |
+
"## 9. Examples\n",
|
| 607 |
+
"\n",
|
| 608 |
+
"### Example A — single image, all tasks\n",
|
| 609 |
+
"\n",
|
| 610 |
+
"Edit `IMAGE_PATH` and `VQA_QUESTION` below, then run.\n"
|
| 611 |
+
]
|
| 612 |
+
},
|
| 613 |
+
{
|
| 614 |
+
"cell_type": "code",
|
| 615 |
+
"id": "inf-ex1",
|
| 616 |
+
"metadata": {},
|
| 617 |
+
"execution_count": null,
|
| 618 |
+
"outputs": [],
|
| 619 |
+
"source": [
|
| 620 |
+
"# Pick an image — anywhere on disk. Colab: drag-and-drop into the Files pane\n",
|
| 621 |
+
"# (left sidebar) and use '/content/<filename>'. Local: any path that exists.\n",
|
| 622 |
+
"IMAGE_PATH = '/content/sample_cxr.jpg'\n",
|
| 623 |
+
"\n",
|
| 624 |
+
"# Optional PNU override (see helpers section for format). None → no PNU block.\n",
|
| 625 |
+
"STRUCTURED_FINDINGS = None\n",
|
| 626 |
+
"\n",
|
| 627 |
+
"# A clinical question for the VQA call below.\n",
|
| 628 |
+
"VQA_QUESTION = 'Is there any pleural effusion visible in this chest X-ray?'\n",
|
| 629 |
+
"\n",
|
| 630 |
+
"if not Path(IMAGE_PATH).is_file():\n",
|
| 631 |
+
" print(f'!! {IMAGE_PATH} does not exist. Upload an image or change IMAGE_PATH.')\n",
|
| 632 |
+
"else:\n",
|
| 633 |
+
" show(IMAGE_PATH, title=Path(IMAGE_PATH).name)\n",
|
| 634 |
+
"\n",
|
| 635 |
+
" if STRUCTURED_FINDINGS:\n",
|
| 636 |
+
" print('Structured findings (PNU) override:')\n",
|
| 637 |
+
" print(STRUCTURED_FINDINGS)\n",
|
| 638 |
+
" print()\n",
|
| 639 |
+
"\n",
|
| 640 |
+
" findings = predict(IMAGE_PATH, task='findings',\n",
|
| 641 |
+
" structured_findings=STRUCTURED_FINDINGS)\n",
|
| 642 |
+
" impression = predict(IMAGE_PATH, task='impression',\n",
|
| 643 |
+
" structured_findings=STRUCTURED_FINDINGS)\n",
|
| 644 |
+
" vqa_ans = predict(IMAGE_PATH, task='vqa', question=VQA_QUESTION,\n",
|
| 645 |
+
" structured_findings=STRUCTURED_FINDINGS)\n",
|
| 646 |
+
"\n",
|
| 647 |
+
" pretty({\n",
|
| 648 |
+
" 'findings': findings,\n",
|
| 649 |
+
" 'impression': impression,\n",
|
| 650 |
+
" f'vqa ({VQA_QUESTION!r})': vqa_ans,\n",
|
| 651 |
+
" }, header=Path(IMAGE_PATH).name)\n"
|
| 652 |
+
]
|
| 653 |
+
},
|
| 654 |
+
{
|
| 655 |
+
"cell_type": "markdown",
|
| 656 |
+
"id": "inf-ex2-md",
|
| 657 |
+
"metadata": {},
|
| 658 |
+
"source": [
|
| 659 |
+
"### Example B — cascade report (findings → impression on model output)\n"
|
| 660 |
+
]
|
| 661 |
+
},
|
| 662 |
+
{
|
| 663 |
+
"cell_type": "code",
|
| 664 |
+
"id": "inf-ex2",
|
| 665 |
+
"metadata": {},
|
| 666 |
+
"execution_count": null,
|
| 667 |
+
"outputs": [],
|
| 668 |
+
"source": [
|
| 669 |
+
"if Path(IMAGE_PATH).is_file():\n",
|
| 670 |
+
" out = predict_report(IMAGE_PATH)\n",
|
| 671 |
+
" show(IMAGE_PATH, title='Cascade report')\n",
|
| 672 |
+
" pretty(out, header=f'Cascade: {Path(IMAGE_PATH).name}')\n"
|
| 673 |
+
]
|
| 674 |
+
},
|
| 675 |
+
{
|
| 676 |
+
"cell_type": "markdown",
|
| 677 |
+
"id": "inf-ex3-md",
|
| 678 |
+
"metadata": {},
|
| 679 |
+
"source": [
|
| 680 |
+
"### Example C — batch a folder of images\n",
|
| 681 |
+
"\n",
|
| 682 |
+
"Drops all results into `BATCH_OUT_JSON` for downstream analysis.\n"
|
| 683 |
+
]
|
| 684 |
+
},
|
| 685 |
+
{
|
| 686 |
+
"cell_type": "code",
|
| 687 |
+
"id": "inf-ex3",
|
| 688 |
+
"metadata": {},
|
| 689 |
+
"execution_count": null,
|
| 690 |
+
"outputs": [],
|
| 691 |
+
"source": [
|
| 692 |
+
"import json\n",
|
| 693 |
+
"from tqdm.auto import tqdm\n",
|
| 694 |
+
"\n",
|
| 695 |
+
"BATCH_FOLDER = '/content/cxr_inbox' # folder of *.jpg / *.png\n",
|
| 696 |
+
"BATCH_TASK = 'report' # 'findings' | 'impression' | 'report' | 'vqa' | 'cascade'\n",
|
| 697 |
+
"BATCH_QUESTION = None # only used when BATCH_TASK == 'vqa'\n",
|
| 698 |
+
"BATCH_OUT_JSON = WORK / f'inference_{RUN_ID}_{BATCH_TASK}.json'\n",
|
| 699 |
+
"\n",
|
| 700 |
+
"EXTS = {'.jpg', '.jpeg', '.png'}\n",
|
| 701 |
+
"folder = Path(BATCH_FOLDER)\n",
|
| 702 |
+
"imgs = sorted(p for p in folder.rglob('*') if p.suffix.lower() in EXTS) \\\n",
|
| 703 |
+
" if folder.is_dir() else []\n",
|
| 704 |
+
"print(f'{len(imgs)} image(s) under {folder}')\n",
|
| 705 |
+
"\n",
|
| 706 |
+
"results = []\n",
|
| 707 |
+
"for p in tqdm(imgs, desc=f'Inference [{BATCH_TASK}]'):\n",
|
| 708 |
+
" try:\n",
|
| 709 |
+
" if BATCH_TASK == 'cascade':\n",
|
| 710 |
+
" out = predict_report(p)\n",
|
| 711 |
+
" else:\n",
|
| 712 |
+
" out = {BATCH_TASK: predict(p, task=BATCH_TASK,\n",
|
| 713 |
+
" question=BATCH_QUESTION)}\n",
|
| 714 |
+
" results.append({'image': str(p), **out})\n",
|
| 715 |
+
" except Exception as e:\n",
|
| 716 |
+
" results.append({'image': str(p), 'error': f'{type(e).__name__}: {e}'})\n",
|
| 717 |
+
"\n",
|
| 718 |
+
"if results:\n",
|
| 719 |
+
" BATCH_OUT_JSON.write_text(json.dumps(results, indent=2))\n",
|
| 720 |
+
" print(f'Wrote {len(results)} entries -> {BATCH_OUT_JSON}')\n"
|
| 721 |
+
]
|
| 722 |
+
},
|
| 723 |
+
{
|
| 724 |
+
"cell_type": "markdown",
|
| 725 |
+
"id": "inf-done-md",
|
| 726 |
+
"metadata": {},
|
| 727 |
+
"source": [
|
| 728 |
+
"### Tips\n",
|
| 729 |
+
"\n",
|
| 730 |
+
"- Loading the model takes 5–8 min on T4 cold cache. After that, each generation is ~3–8 s.\n",
|
| 731 |
+
"- To switch to a different trained run: change `RUN_ID` in section 0, re-run sections 3–5 (skip the env / pip sections).\n",
|
| 732 |
+
"- For deterministic outputs keep `DO_SAMPLE=False`. For diversity, set `DO_SAMPLE=True, TEMPERATURE=0.7`.\n",
|
| 733 |
+
"- `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"
|
| 734 |
+
]
|
| 735 |
+
}
|
| 736 |
+
],
|
| 737 |
+
"metadata": {
|
| 738 |
+
"accelerator": "GPU",
|
| 739 |
+
"colab": {
|
| 740 |
+
"gpuType": "T4",
|
| 741 |
+
"provenance": [],
|
| 742 |
+
"machine_shape": "hm"
|
| 743 |
+
},
|
| 744 |
+
"kernelspec": {
|
| 745 |
+
"display_name": "Python 3",
|
| 746 |
+
"name": "python3"
|
| 747 |
+
},
|
| 748 |
+
"language_info": {
|
| 749 |
+
"name": "python",
|
| 750 |
+
"version": "3.10"
|
| 751 |
+
}
|
| 752 |
+
},
|
| 753 |
+
"nbformat": 4,
|
| 754 |
+
"nbformat_minor": 5
|
| 755 |
+
}
|
scripts/gcp_eval_entrypoint.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""GCP Vertex AI Custom Job entrypoint — **evaluation** (not training).
|
| 2 |
+
|
| 3 |
+
Mirrors gcp_entrypoint.py but:
|
| 4 |
+
1. Pulls dataset from HF Hub (same logic).
|
| 5 |
+
2. Pulls a trained checkpoint from <HF_RUNS_REPO>/<RUN_ID>/stage2/{best|last}/
|
| 6 |
+
into /workspace/run_pull/<RUN_ID>/stage2/<CKPT_PICK>/.
|
| 7 |
+
3. Pulls <RUN_ID>/configs/{model,train}_config.yaml (if present) so the model
|
| 8 |
+
is rebuilt with the EXACT architecture used at training time. Falls back
|
| 9 |
+
to the repo defaults when the snapshot is absent.
|
| 10 |
+
4. Patches the configs for the detected GPU (T4 → fp16+SDPA; L4/A100 →
|
| 11 |
+
bf16+FA2 if flash-attn installed) and pins paths.
|
| 12 |
+
5. Pins run_id.txt to RUN_ID so evaluate.py writes under {output_dir}/{RUN_ID}/.
|
| 13 |
+
6. Execs `python -m evaluation.evaluate --checkpoint ... --run_id ...
|
| 14 |
+
--task all --split test --output_dir /workspace/results`.
|
| 15 |
+
|
| 16 |
+
Required env vars:
|
| 17 |
+
HF_TOKEN — HuggingFace token (read code+data+runs, write runs)
|
| 18 |
+
DATASET_NAME — 'IU-Xray' | 'MIMIC-CXR' | 'MIMIC-CXR_resized'
|
| 19 |
+
RUN_ID — folder name on HF_RUNS_REPO to evaluate, e.g.
|
| 20 |
+
'MIMIC-CXR_resized_run_1'
|
| 21 |
+
|
| 22 |
+
Optional env vars (defaults shown):
|
| 23 |
+
HF_USER = hieu3636
|
| 24 |
+
HF_RUNS_REPO = hieu3636/cxr-vlm-runs
|
| 25 |
+
CKPT_PICK = best # 'best' | 'last' (stage2 sub-folder)
|
| 26 |
+
REPORT_MODE = # blank → use the run's saved snapshot
|
| 27 |
+
IMAGE_MODE = # blank → use the run's saved snapshot
|
| 28 |
+
TASK = all # 'all'|'findings'|'impression'|'report'|'vqa'
|
| 29 |
+
SPLIT = test
|
| 30 |
+
BATCH_SIZE = # blank → from GPU profile
|
| 31 |
+
MAX_NEW_TOKENS = 300
|
| 32 |
+
UPLOAD_RESULTS_TO_HF= 1 # set '0' to skip upload
|
| 33 |
+
LLM_JUDGE = 0 # set '1' to enable VQA judge
|
| 34 |
+
LLM_JUDGE_MODEL = gpt-4o-mini
|
| 35 |
+
LLM_JUDGE_BASE_URL = # for OpenAI-compat endpoints
|
| 36 |
+
LLM_JUDGE_MAX_SAMPLES= # cap samples for cost control
|
| 37 |
+
WORK = /workspace
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
from __future__ import annotations
|
| 41 |
+
|
| 42 |
+
import os
|
| 43 |
+
import shutil
|
| 44 |
+
import subprocess
|
| 45 |
+
import sys
|
| 46 |
+
import tarfile
|
| 47 |
+
import zipfile
|
| 48 |
+
from pathlib import Path
|
| 49 |
+
|
| 50 |
+
# Tame HF/transformers chatter so logs are readable in Cloud Logging.
|
| 51 |
+
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
|
| 52 |
+
os.environ.setdefault("BITSANDBYTES_NOWELCOME", "1")
|
| 53 |
+
os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
|
| 54 |
+
os.environ.setdefault("TRANSFORMERS_VERBOSITY", "warning")
|
| 55 |
+
os.environ.setdefault("PYTHONUNBUFFERED", "1")
|
| 56 |
+
os.environ.setdefault("CUDA_VISIBLE_DEVICES", "0")
|
| 57 |
+
# tqdm flushes ≥ every 1s — keeps Cloud Logging looking alive during inference.
|
| 58 |
+
os.environ.setdefault("TQDM_MININTERVAL", "1.0")
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def env(name: str, default: str | None = None, *, required: bool = False) -> str:
|
| 62 |
+
val = os.environ.get(name, default)
|
| 63 |
+
if required and not val:
|
| 64 |
+
sys.exit(f"[gcp_eval_entrypoint] ERROR: required env var {name} not set")
|
| 65 |
+
return val or ""
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# ── 1) Resolve config from env ────────────────────────────────────────────────
|
| 69 |
+
HF_TOKEN = env("HF_TOKEN", required=True)
|
| 70 |
+
DATASET_NAME = env("DATASET_NAME", required=True)
|
| 71 |
+
RUN_ID = env("RUN_ID", required=True)
|
| 72 |
+
HF_USER = env("HF_USER", "hieu3636")
|
| 73 |
+
HF_RUNS_REPO = env("HF_RUNS_REPO", "hieu3636/cxr-vlm-runs")
|
| 74 |
+
CKPT_PICK = env("CKPT_PICK", "best")
|
| 75 |
+
REPORT_MODE_OVERRIDE = env("REPORT_MODE", "")
|
| 76 |
+
IMAGE_MODE_OVERRIDE = env("IMAGE_MODE", "")
|
| 77 |
+
TASK = env("TASK", "all")
|
| 78 |
+
SPLIT = env("SPLIT", "test")
|
| 79 |
+
BATCH_SIZE_OVERRIDE = env("BATCH_SIZE", "")
|
| 80 |
+
MAX_NEW_TOKENS = int(env("MAX_NEW_TOKENS", "300"))
|
| 81 |
+
UPLOAD_RESULTS_TO_HF = env("UPLOAD_RESULTS_TO_HF", "1") not in ("0", "false", "False", "")
|
| 82 |
+
LLM_JUDGE = env("LLM_JUDGE", "0") in ("1", "true", "True")
|
| 83 |
+
LLM_JUDGE_MODEL = env("LLM_JUDGE_MODEL", "gpt-4o-mini")
|
| 84 |
+
LLM_JUDGE_BASE_URL = env("LLM_JUDGE_BASE_URL", "")
|
| 85 |
+
LLM_JUDGE_MAX_SAMPLES = env("LLM_JUDGE_MAX_SAMPLES", "")
|
| 86 |
+
WORK = Path(env("WORK", "/workspace"))
|
| 87 |
+
|
| 88 |
+
assert DATASET_NAME in ("IU-Xray", "MIMIC-CXR", "MIMIC-CXR_resized"), DATASET_NAME
|
| 89 |
+
assert CKPT_PICK in ("best", "last"), CKPT_PICK
|
| 90 |
+
assert TASK in ("all", "findings", "impression", "report", "vqa"), TASK
|
| 91 |
+
|
| 92 |
+
PROJECT = Path(__file__).resolve().parent.parent # /workspace/code
|
| 93 |
+
DATA_SRC = WORK / "data"
|
| 94 |
+
RUN_PULL_ROOT = WORK / "run_pull"
|
| 95 |
+
RESULTS_DIR = WORK / "results"
|
| 96 |
+
CKPT_ROOT = WORK / "ckpt_eval"
|
| 97 |
+
for d in (DATA_SRC, RUN_PULL_ROOT, RESULTS_DIR, CKPT_ROOT):
|
| 98 |
+
d.mkdir(parents=True, exist_ok=True)
|
| 99 |
+
|
| 100 |
+
print(f"[gcp_eval] PROJECT = {PROJECT}")
|
| 101 |
+
print(f"[gcp_eval] WORK = {WORK}")
|
| 102 |
+
print(f"[gcp_eval] DATASET_NAME = {DATASET_NAME}")
|
| 103 |
+
print(f"[gcp_eval] RUN_ID = {RUN_ID} (ckpt: stage2/{CKPT_PICK})")
|
| 104 |
+
print(f"[gcp_eval] TASK = {TASK} SPLIT = {SPLIT}")
|
| 105 |
+
print(f"[gcp_eval] UPLOAD_TO_HF = {UPLOAD_RESULTS_TO_HF}")
|
| 106 |
+
|
| 107 |
+
# ── 2) Download dataset payload from HF Hub ───────────────────────────────────
|
| 108 |
+
from huggingface_hub import HfApi, hf_hub_download, snapshot_download # noqa: E402
|
| 109 |
+
|
| 110 |
+
if DATASET_NAME == "MIMIC-CXR_resized":
|
| 111 |
+
mr_dir = DATA_SRC / "MIMIC-CXR_resized"
|
| 112 |
+
mr_dir.mkdir(parents=True, exist_ok=True)
|
| 113 |
+
files_dir = mr_dir / "files"
|
| 114 |
+
manifests_present = all(
|
| 115 |
+
(mr_dir / f).is_file()
|
| 116 |
+
for f in ("manifest_train.csv", "manifest_val.csv", "manifest_test.csv")
|
| 117 |
+
)
|
| 118 |
+
if manifests_present and files_dir.is_dir() and any(files_dir.glob("p*")):
|
| 119 |
+
print(f"[gcp_eval] {mr_dir} already populated — skipping download.")
|
| 120 |
+
else:
|
| 121 |
+
api = HfApi(token=HF_TOKEN)
|
| 122 |
+
all_files = api.list_repo_files(
|
| 123 |
+
repo_id=f"{HF_USER}/cxr-vlm-data", repo_type="dataset"
|
| 124 |
+
)
|
| 125 |
+
mr_files = [f for f in all_files if f.startswith("MIMIC-CXR_resized/")]
|
| 126 |
+
tar_files = sorted(f for f in mr_files if f.endswith(".tar"))
|
| 127 |
+
print(f"[gcp_eval] {len(tar_files)} tar shards on HF")
|
| 128 |
+
|
| 129 |
+
snapshot_download(
|
| 130 |
+
repo_id=f"{HF_USER}/cxr-vlm-data",
|
| 131 |
+
repo_type="dataset",
|
| 132 |
+
allow_patterns=[
|
| 133 |
+
"MIMIC-CXR_resized/*.csv",
|
| 134 |
+
"MIMIC-CXR_resized/*.json",
|
| 135 |
+
"MIMIC-CXR_resized/*.txt",
|
| 136 |
+
"MIMIC-CXR_resized/vqa/**",
|
| 137 |
+
],
|
| 138 |
+
token=HF_TOKEN,
|
| 139 |
+
local_dir=str(DATA_SRC),
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
for i, tf in enumerate(tar_files, 1):
|
| 143 |
+
print(f"[gcp_eval] [{i}/{len(tar_files)}] {tf}", flush=True)
|
| 144 |
+
tp = Path(hf_hub_download(
|
| 145 |
+
repo_id=f"{HF_USER}/cxr-vlm-data",
|
| 146 |
+
repo_type="dataset",
|
| 147 |
+
filename=tf,
|
| 148 |
+
token=HF_TOKEN,
|
| 149 |
+
local_dir=str(DATA_SRC),
|
| 150 |
+
))
|
| 151 |
+
with tarfile.open(tp) as t:
|
| 152 |
+
t.extractall(mr_dir)
|
| 153 |
+
tp.unlink(missing_ok=True)
|
| 154 |
+
print(f"[gcp_eval] {mr_dir} ready.")
|
| 155 |
+
|
| 156 |
+
DATA_ROOT_RESIZED = mr_dir
|
| 157 |
+
|
| 158 |
+
else:
|
| 159 |
+
zip_name = f"{DATASET_NAME}.zip"
|
| 160 |
+
marker = DATA_SRC / DATASET_NAME
|
| 161 |
+
if not marker.exists():
|
| 162 |
+
print(f"[gcp_eval] downloading {zip_name} ...")
|
| 163 |
+
zpath = hf_hub_download(
|
| 164 |
+
repo_id=f"{HF_USER}/cxr-vlm-data",
|
| 165 |
+
filename=zip_name,
|
| 166 |
+
repo_type="dataset",
|
| 167 |
+
token=HF_TOKEN,
|
| 168 |
+
local_dir=str(DATA_SRC),
|
| 169 |
+
)
|
| 170 |
+
with zipfile.ZipFile(zpath) as zf:
|
| 171 |
+
zf.extractall(DATA_SRC)
|
| 172 |
+
try:
|
| 173 |
+
os.remove(zpath)
|
| 174 |
+
except OSError:
|
| 175 |
+
pass
|
| 176 |
+
else:
|
| 177 |
+
print(f"[gcp_eval] {marker} already present — skipping download.")
|
| 178 |
+
|
| 179 |
+
print(f"[gcp_eval] DATA_SRC contents: {sorted(os.listdir(DATA_SRC))}")
|
| 180 |
+
|
| 181 |
+
# ── 3) Pull checkpoint + saved config snapshot from runs repo ────────────────
|
| 182 |
+
print(f"[gcp_eval] pulling {RUN_ID}/{{configs,stage2/{CKPT_PICK}}} from {HF_RUNS_REPO} …")
|
| 183 |
+
snapshot_download(
|
| 184 |
+
repo_id=HF_RUNS_REPO,
|
| 185 |
+
repo_type="model",
|
| 186 |
+
token=HF_TOKEN,
|
| 187 |
+
allow_patterns=[
|
| 188 |
+
f"{RUN_ID}/configs/**",
|
| 189 |
+
f"{RUN_ID}/run_meta.json",
|
| 190 |
+
f"{RUN_ID}/stage2/{CKPT_PICK}/**",
|
| 191 |
+
],
|
| 192 |
+
local_dir=str(RUN_PULL_ROOT),
|
| 193 |
+
)
|
| 194 |
+
RUN_DIR_PULLED = RUN_PULL_ROOT / RUN_ID
|
| 195 |
+
CKPT_DIR_PULLED = RUN_DIR_PULLED / "stage2" / CKPT_PICK
|
| 196 |
+
PROJ_PT = CKPT_DIR_PULLED / "checkpoint_projection.pt"
|
| 197 |
+
LORA_DIR = CKPT_DIR_PULLED / "checkpoint_lora"
|
| 198 |
+
CHEXPERT_PT = CKPT_DIR_PULLED / "checkpoint_chexpert_classifier.pt"
|
| 199 |
+
SAVED_CFG_DIR = RUN_DIR_PULLED / "configs"
|
| 200 |
+
SAVED_TRAIN_CFG = SAVED_CFG_DIR / "train_config.yaml"
|
| 201 |
+
SAVED_MODEL_CFG = SAVED_CFG_DIR / "model_config.yaml"
|
| 202 |
+
|
| 203 |
+
assert PROJ_PT.is_file(), f"projection weights not found: {PROJ_PT}"
|
| 204 |
+
assert (LORA_DIR / "adapter_config.json").is_file(), \
|
| 205 |
+
f"LoRA adapter_config.json missing in {LORA_DIR}"
|
| 206 |
+
print(f"[gcp_eval] projection : {PROJ_PT} ({PROJ_PT.stat().st_size/1e6:.1f} MB)")
|
| 207 |
+
print(f"[gcp_eval] lora : {LORA_DIR}/")
|
| 208 |
+
print(f"[gcp_eval] chexpert : exists={CHEXPERT_PT.is_file()}")
|
| 209 |
+
|
| 210 |
+
# ── 4) Build configs ─────────────────────────────────────────────────────────
|
| 211 |
+
import torch # noqa: E402
|
| 212 |
+
from omegaconf import OmegaConf # noqa: E402
|
| 213 |
+
|
| 214 |
+
repo_train_cfg_path = PROJECT / "configs" / "train_config.yaml"
|
| 215 |
+
repo_model_cfg_path = PROJECT / "configs" / "model_config.yaml"
|
| 216 |
+
|
| 217 |
+
if SAVED_TRAIN_CFG.is_file():
|
| 218 |
+
train_cfg = OmegaConf.load(SAVED_TRAIN_CFG)
|
| 219 |
+
print(f"[gcp_eval] train_cfg <- {SAVED_TRAIN_CFG}")
|
| 220 |
+
else:
|
| 221 |
+
train_cfg = OmegaConf.load(repo_train_cfg_path)
|
| 222 |
+
print(f"[gcp_eval] train_cfg <- repo default (no snapshot)")
|
| 223 |
+
|
| 224 |
+
if SAVED_MODEL_CFG.is_file():
|
| 225 |
+
model_cfg = OmegaConf.load(SAVED_MODEL_CFG)
|
| 226 |
+
print(f"[gcp_eval] model_cfg <- {SAVED_MODEL_CFG}")
|
| 227 |
+
else:
|
| 228 |
+
model_cfg = OmegaConf.load(repo_model_cfg_path)
|
| 229 |
+
print(f"[gcp_eval] model_cfg <- repo default (no snapshot)")
|
| 230 |
+
|
| 231 |
+
# Optional report/image mode overrides — leave blank to use saved values.
|
| 232 |
+
if REPORT_MODE_OVERRIDE:
|
| 233 |
+
train_cfg.data.report_mode = REPORT_MODE_OVERRIDE
|
| 234 |
+
if IMAGE_MODE_OVERRIDE:
|
| 235 |
+
train_cfg.data.image_mode = IMAGE_MODE_OVERRIDE
|
| 236 |
+
print(f"[gcp_eval] report_mode = {train_cfg.data.report_mode} image_mode = {train_cfg.data.image_mode}")
|
| 237 |
+
|
| 238 |
+
# Dataset paths (mirror gcp_entrypoint.py)
|
| 239 |
+
train_cfg.data.dataset_name = DATASET_NAME
|
| 240 |
+
train_cfg.data.max_images_per_sample = int(getattr(train_cfg.data, "max_images_per_sample", 2))
|
| 241 |
+
|
| 242 |
+
out_dir = PROJECT / "data" / "data_files"
|
| 243 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 244 |
+
|
| 245 |
+
if DATASET_NAME == "MIMIC-CXR_resized":
|
| 246 |
+
train_cfg.data.mimic_cxr_resized.root = str(DATA_ROOT_RESIZED)
|
| 247 |
+
train_cfg.data.mimic_cxr_resized.manifest_dir = None
|
| 248 |
+
train_cfg.data.mimic_cxr_resized.vqa_dir = None
|
| 249 |
+
train_cfg.data.mimic_cxr_resized.reports_root = None
|
| 250 |
+
train_cfg.data.mimic_cxr_resized.auto_build = True
|
| 251 |
+
train_cfg.data.mimic_cxr_resized.instruct_json = str(
|
| 252 |
+
out_dir / "mimic_cxr_resized_instruct.json")
|
| 253 |
+
elif DATASET_NAME == "MIMIC-CXR":
|
| 254 |
+
def _find_mimic_root(root: Path) -> Path:
|
| 255 |
+
for cand in [root / "MIMIC-CXR", root]:
|
| 256 |
+
if (cand / "train").exists() and (cand / "valid").exists() and (cand / "test").exists():
|
| 257 |
+
return cand
|
| 258 |
+
for p in root.rglob("train"):
|
| 259 |
+
if p.is_dir() and (p.parent / "valid").exists() and (p.parent / "test").exists():
|
| 260 |
+
return p.parent
|
| 261 |
+
raise FileNotFoundError(f"MIMIC-CXR train/valid/test not found under {root}")
|
| 262 |
+
cxr_root = _find_mimic_root(DATA_SRC)
|
| 263 |
+
train_cfg.data.mimic_cxr_root = str(cxr_root)
|
| 264 |
+
train_cfg.data.instruct_json = str(out_dir / "mimic_cxr_instruct_unified.json")
|
| 265 |
+
train_cfg.data.mimic_auto_build = True
|
| 266 |
+
_cx = sorted(DATA_SRC.rglob("*chexpert*.csv")) or sorted(DATA_SRC.rglob("*chexbert*.csv"))
|
| 267 |
+
train_cfg.data.mimic_chexpert_csv = str(_cx[0]) if _cx else None
|
| 268 |
+
_vqa = list(DATA_SRC.rglob("vqa"))
|
| 269 |
+
train_cfg.data.mimic_vqa_root = str(_vqa[0]) if _vqa else None
|
| 270 |
+
else: # IU-Xray
|
| 271 |
+
iu_root = DATA_SRC / "IU-Xray"
|
| 272 |
+
train_cfg.data.iu_xray.images_dir = str(iu_root / "images")
|
| 273 |
+
train_cfg.data.iu_xray.labels_dir = str(iu_root / "labels")
|
| 274 |
+
train_cfg.data.iu_xray.instruct_json = str(out_dir / "iu_xray_instruct.json")
|
| 275 |
+
train_cfg.data.iu_xray.auto_build = True
|
| 276 |
+
|
| 277 |
+
train_cfg.data.train_split = "train"
|
| 278 |
+
train_cfg.data.val_split = "validate"
|
| 279 |
+
train_cfg.data.test_split = "test"
|
| 280 |
+
train_cfg.data.feature_cache_dir = None
|
| 281 |
+
train_cfg.training.output_root = str(CKPT_ROOT)
|
| 282 |
+
|
| 283 |
+
# ── GPU auto-profile (eval batch sizes — smaller than training because the KV
|
| 284 |
+
# cache built during generation scales with batch × max_new_tokens). ──
|
| 285 |
+
assert torch.cuda.is_available(), "CUDA not available in container"
|
| 286 |
+
_props = torch.cuda.get_device_properties(0)
|
| 287 |
+
_cap = (_props.major, _props.minor)
|
| 288 |
+
_vram_gb = _props.total_memory / 1e9
|
| 289 |
+
_bf16_ok = torch.cuda.is_bf16_supported()
|
| 290 |
+
_fa2_ok = _cap >= (8, 0)
|
| 291 |
+
|
| 292 |
+
_flash_attn_installed = False
|
| 293 |
+
if _fa2_ok:
|
| 294 |
+
try:
|
| 295 |
+
import flash_attn # noqa: F401
|
| 296 |
+
_flash_attn_installed = True
|
| 297 |
+
except Exception:
|
| 298 |
+
_flash_attn_installed = False
|
| 299 |
+
|
| 300 |
+
if _vram_gb >= 70:
|
| 301 |
+
_label, _eval_bs, _nw = "A100/H100 80GB", 16, 16
|
| 302 |
+
elif _vram_gb >= 35:
|
| 303 |
+
_label, _eval_bs, _nw = "A100 40GB", 8, 12
|
| 304 |
+
elif _vram_gb >= 22:
|
| 305 |
+
_label, _eval_bs, _nw = "3090 / L4 / A10 (24GB)", 4, 8
|
| 306 |
+
elif _vram_gb >= 14:
|
| 307 |
+
_label, _eval_bs, _nw = "T4 / V100 (15-16GB)", 1, 2
|
| 308 |
+
else:
|
| 309 |
+
_label, _eval_bs, _nw = f"unknown ({_vram_gb:.0f}GB)", 1, 2
|
| 310 |
+
|
| 311 |
+
if BATCH_SIZE_OVERRIDE:
|
| 312 |
+
_eval_bs = int(BATCH_SIZE_OVERRIDE)
|
| 313 |
+
print(f"[gcp_eval] BATCH_SIZE override -> {_eval_bs}")
|
| 314 |
+
|
| 315 |
+
print(f"[gcp_eval] GPU: {_props.name} {_vram_gb:.1f}GB sm_{_cap[0]}{_cap[1]} "
|
| 316 |
+
f"bf16={_bf16_ok} fa2={_fa2_ok} fa2_wheel={_flash_attn_installed}")
|
| 317 |
+
print(f"[gcp_eval] -> profile {_label} eval_batch={_eval_bs}")
|
| 318 |
+
|
| 319 |
+
train_cfg.training.per_device_train_batch_size = _eval_bs
|
| 320 |
+
train_cfg.training.per_device_eval_batch_size = _eval_bs
|
| 321 |
+
train_cfg.training.dataloader_num_workers = _nw
|
| 322 |
+
train_cfg.training.fp16 = not _bf16_ok
|
| 323 |
+
train_cfg.training.bf16 = bool(_bf16_ok)
|
| 324 |
+
train_cfg.training.dataloader_pin_memory = True
|
| 325 |
+
train_cfg.training.dataloader_persistent_workers = True
|
| 326 |
+
|
| 327 |
+
model_cfg.llm.attn_implementation = (
|
| 328 |
+
"flash_attention_2" if (_fa2_ok and _flash_attn_installed) else "sdpa"
|
| 329 |
+
)
|
| 330 |
+
model_cfg.llm.gradient_checkpointing = False # eval — no backward
|
| 331 |
+
model_cfg.llm.torch_dtype = "bfloat16" if _bf16_ok else "float16"
|
| 332 |
+
model_cfg.llm.bnb_4bit_compute_dtype = "bfloat16" if _bf16_ok else "float16"
|
| 333 |
+
model_cfg.llm.bnb_4bit_quant_type = "nf4"
|
| 334 |
+
model_cfg.llm.bnb_4bit_use_double_quant = True
|
| 335 |
+
model_cfg.llm.load_in_8bit = False
|
| 336 |
+
model_cfg.llm.load_in_4bit = True
|
| 337 |
+
|
| 338 |
+
# CheXpert classifier — enable iff its checkpoint is pulled with the run.
|
| 339 |
+
if CHEXPERT_PT.is_file():
|
| 340 |
+
model_cfg.chexpert_classifier.enabled = True
|
| 341 |
+
print("[gcp_eval] CheXpert classifier checkpoint found -> enabled")
|
| 342 |
+
else:
|
| 343 |
+
model_cfg.chexpert_classifier.enabled = False
|
| 344 |
+
|
| 345 |
+
# HF Hub tracker
|
| 346 |
+
train_cfg.wandb.enabled = False
|
| 347 |
+
if UPLOAD_RESULTS_TO_HF:
|
| 348 |
+
train_cfg.hf_hub.enabled = True
|
| 349 |
+
train_cfg.hf_hub.repo_id = HF_RUNS_REPO
|
| 350 |
+
train_cfg.hf_hub.token_env = "HF_TOKEN"
|
| 351 |
+
train_cfg.hf_hub.private = True
|
| 352 |
+
else:
|
| 353 |
+
train_cfg.hf_hub.enabled = False
|
| 354 |
+
train_cfg.hf_hub.run_state_file = str(CKPT_ROOT / "run_id.txt")
|
| 355 |
+
|
| 356 |
+
# Pin run_id so resolve_run_id picks up exactly the one we're evaluating.
|
| 357 |
+
(CKPT_ROOT / "run_id.txt").write_text(RUN_ID)
|
| 358 |
+
print(f"[gcp_eval] pinned run_id = {RUN_ID}")
|
| 359 |
+
|
| 360 |
+
# Save patched configs back into the project so evaluate.py reads them.
|
| 361 |
+
OmegaConf.save(train_cfg, repo_train_cfg_path)
|
| 362 |
+
OmegaConf.save(model_cfg, repo_model_cfg_path)
|
| 363 |
+
print("[gcp_eval] configs patched.")
|
| 364 |
+
|
| 365 |
+
# ── 5) Launch evaluation ─────────────────────────────────────────────────────
|
| 366 |
+
cmd = [
|
| 367 |
+
"python", "-u", "-m", "evaluation.evaluate",
|
| 368 |
+
"--model_config", str(repo_model_cfg_path),
|
| 369 |
+
"--train_config", str(repo_train_cfg_path),
|
| 370 |
+
"--checkpoint", str(PROJ_PT),
|
| 371 |
+
"--run_id", RUN_ID,
|
| 372 |
+
"--task", TASK,
|
| 373 |
+
"--split", SPLIT,
|
| 374 |
+
"--batch_size", str(_eval_bs),
|
| 375 |
+
"--max_new_tokens", str(MAX_NEW_TOKENS),
|
| 376 |
+
"--output_dir", str(RESULTS_DIR),
|
| 377 |
+
"--device", "cuda",
|
| 378 |
+
]
|
| 379 |
+
if not UPLOAD_RESULTS_TO_HF:
|
| 380 |
+
cmd.append("--no_hf_upload")
|
| 381 |
+
if LLM_JUDGE:
|
| 382 |
+
cmd += ["--llm_judge", "--llm_judge_model", LLM_JUDGE_MODEL]
|
| 383 |
+
if LLM_JUDGE_BASE_URL:
|
| 384 |
+
cmd += ["--llm_judge_base_url", LLM_JUDGE_BASE_URL]
|
| 385 |
+
if LLM_JUDGE_MAX_SAMPLES:
|
| 386 |
+
cmd += ["--llm_judge_max_samples", LLM_JUDGE_MAX_SAMPLES]
|
| 387 |
+
|
| 388 |
+
print(f"[gcp_eval] launching: {' '.join(cmd)}", flush=True)
|
| 389 |
+
os.chdir(PROJECT)
|
| 390 |
+
sys.exit(subprocess.call(cmd))
|
scripts/vertex_eval_job.yaml
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Vertex AI Custom Job — CXR-VLM EVALUATION on L4
|
| 2 |
+
#
|
| 3 |
+
# Submit with:
|
| 4 |
+
# gcloud ai custom-jobs create \
|
| 5 |
+
# --region=us-central1 \
|
| 6 |
+
# --display-name=cxr-vlm-eval-run1 \
|
| 7 |
+
# --config=scripts/vertex_eval_job.yaml
|
| 8 |
+
#
|
| 9 |
+
# Watch logs (streaming):
|
| 10 |
+
# gcloud ai custom-jobs stream-logs <JOB_ID> --region=us-central1
|
| 11 |
+
#
|
| 12 |
+
# This reuses the SAME Docker image you trained with — no rebuild needed.
|
| 13 |
+
# Quota: uses CustomModelTrainingNvidiaL4Gpus (NOT global GPU quota), so it
|
| 14 |
+
# works even when Workbench / Compute Engine GPU creation is blocked.
|
| 15 |
+
#
|
| 16 |
+
# Output:
|
| 17 |
+
# - Predictions + metrics_summary.json land under
|
| 18 |
+
# /workspace/results/<RUN_ID>/ inside the container
|
| 19 |
+
# - With UPLOAD_RESULTS_TO_HF=1 they're also uploaded to
|
| 20 |
+
# hieu3636/cxr-vlm-runs/<RUN_ID>/results/
|
| 21 |
+
# (After the job ends the container is destroyed, so HF upload is what you'll
|
| 22 |
+
# actually retrieve afterward.)
|
| 23 |
+
#
|
| 24 |
+
# Typical wall-clock on L4 for MIMIC-CXR_resized (10k test samples, all tasks):
|
| 25 |
+
# ~1.5-2.5 hours total (3-5x faster than T4).
|
| 26 |
+
|
| 27 |
+
workerPoolSpecs:
|
| 28 |
+
- machineSpec:
|
| 29 |
+
machineType: g2-standard-8 # 8 vCPU, 32GB RAM, 1×L4 24GB
|
| 30 |
+
acceleratorType: NVIDIA_L4
|
| 31 |
+
acceleratorCount: 1
|
| 32 |
+
replicaCount: 1
|
| 33 |
+
diskSpec:
|
| 34 |
+
bootDiskType: pd-ssd
|
| 35 |
+
bootDiskSizeGb: 200 # enough for MIMIC-CXR_resized (~40GB extracted) + Vicuna cache
|
| 36 |
+
containerSpec:
|
| 37 |
+
imageUri: us-central1-docker.pkg.dev/cxr-vlm-thesis/cxr-vlm/cxr-vlm-env:cu128
|
| 38 |
+
# Same bootstrap pattern as the training job: pull code from HF Hub,
|
| 39 |
+
# then exec the eval entrypoint.
|
| 40 |
+
command:
|
| 41 |
+
- bash
|
| 42 |
+
- -c
|
| 43 |
+
- |
|
| 44 |
+
set -e
|
| 45 |
+
echo "[bootstrap] downloading code from HF Hub …"
|
| 46 |
+
python -c "
|
| 47 |
+
from huggingface_hub import snapshot_download
|
| 48 |
+
import os
|
| 49 |
+
snapshot_download('hieu3636/cxr-vlm-code',
|
| 50 |
+
repo_type='model',
|
| 51 |
+
token=os.environ['HF_TOKEN'],
|
| 52 |
+
local_dir='/workspace/code')
|
| 53 |
+
"
|
| 54 |
+
cd /workspace/code
|
| 55 |
+
echo "[bootstrap] exec scripts/gcp_eval_entrypoint.py …"
|
| 56 |
+
exec python scripts/gcp_eval_entrypoint.py
|
| 57 |
+
env:
|
| 58 |
+
# ── Required ────────────────────────────────────────────────────────────
|
| 59 |
+
- name: HF_TOKEN
|
| 60 |
+
value:
|
| 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_1 # which run on HF_RUNS_REPO to evaluate
|
| 65 |
+
# ── Optional ────────────────────────────────────────────────────────────
|
| 66 |
+
- name: HF_RUNS_REPO
|
| 67 |
+
value: hieu3636/cxr-vlm-runs
|
| 68 |
+
- name: CKPT_PICK
|
| 69 |
+
value: best # 'best' | 'last'
|
| 70 |
+
- name: TASK
|
| 71 |
+
value: all # 'all'|'findings'|'impression'|'report'|'vqa'
|
| 72 |
+
- name: SPLIT
|
| 73 |
+
value: test
|
| 74 |
+
- name: MAX_NEW_TOKENS
|
| 75 |
+
value: "300"
|
| 76 |
+
- name: UPLOAD_RESULTS_TO_HF
|
| 77 |
+
value: "1" # '0' to disable HF upload
|
| 78 |
+
# Leave REPORT_MODE / IMAGE_MODE blank → use the run's saved snapshot.
|
| 79 |
+
# Set explicitly only if you need to override (rare).
|
| 80 |
+
# - name: REPORT_MODE
|
| 81 |
+
# value: split_cascade
|
| 82 |
+
# - name: IMAGE_MODE
|
| 83 |
+
# value: all_views_split
|
| 84 |
+
# Leave BATCH_SIZE blank → auto from GPU profile (L4 -> 4).
|
| 85 |
+
# - name: BATCH_SIZE
|
| 86 |
+
# value: "8"
|
| 87 |
+
# LLM-as-judge for VQA (extra cost; needs OPENAI_API_KEY).
|
| 88 |
+
# - name: LLM_JUDGE
|
| 89 |
+
# value: "1"
|
| 90 |
+
# - name: OPENAI_API_KEY
|
| 91 |
+
# value: sk-...
|
| 92 |
+
# - name: LLM_JUDGE_MODEL
|
| 93 |
+
# value: gpt-4o-mini
|
| 94 |
+
|
| 95 |
+
scheduling:
|
| 96 |
+
# STANDARD on-demand. Eval is short (~2h) so spot interruption isn't worth
|
| 97 |
+
# the risk — evaluate.py doesn't checkpoint mid-task. If preempted you'd
|
| 98 |
+
# restart from the beginning.
|
| 99 |
+
strategy: STANDARD
|
| 100 |
+
# 6h ceiling. MIMIC-CXR_resized on L4 finishes in ~2h; raise this if
|
| 101 |
+
# you're evaluating the full MIMIC-CXR with VQA.
|
| 102 |
+
timeout: 21600s
|
scripts/vertex_job.yaml
CHANGED
|
@@ -19,7 +19,7 @@ workerPoolSpecs:
|
|
| 19 |
acceleratorCount: 1
|
| 20 |
replicaCount: 1
|
| 21 |
diskSpec:
|
| 22 |
-
bootDiskType: pd-
|
| 23 |
bootDiskSizeGb: 200
|
| 24 |
containerSpec:
|
| 25 |
imageUri: us-central1-docker.pkg.dev/cxr-vlm-thesis/cxr-vlm/cxr-vlm-env:cu128
|
|
@@ -44,7 +44,7 @@ workerPoolSpecs:
|
|
| 44 |
exec python scripts/gcp_entrypoint.py
|
| 45 |
env:
|
| 46 |
- name: HF_TOKEN
|
| 47 |
-
value:
|
| 48 |
- name: DATASET_NAME
|
| 49 |
value: MIMIC-CXR_resized
|
| 50 |
- name: REPORT_MODE
|
|
@@ -65,10 +65,10 @@ workerPoolSpecs:
|
|
| 65 |
# Scheduling: SPOT = ~70% cheaper, may be preempted. Repo has auto-resume so safe.
|
| 66 |
# Use STANDARD if you want guaranteed uninterrupted run (3.4× cost).
|
| 67 |
scheduling:
|
| 68 |
-
|
| 69 |
-
#
|
| 70 |
-
#
|
| 71 |
-
|
| 72 |
# Max wall-clock time the job is allowed to run. If you hit this, it's killed.
|
| 73 |
# 24h is a sane ceiling — refuses to silently run forever.
|
| 74 |
-
timeout:
|
|
|
|
| 19 |
acceleratorCount: 1
|
| 20 |
replicaCount: 1
|
| 21 |
diskSpec:
|
| 22 |
+
bootDiskType: pd-ssd
|
| 23 |
bootDiskSizeGb: 200
|
| 24 |
containerSpec:
|
| 25 |
imageUri: us-central1-docker.pkg.dev/cxr-vlm-thesis/cxr-vlm/cxr-vlm-env:cu128
|
|
|
|
| 44 |
exec python scripts/gcp_entrypoint.py
|
| 45 |
env:
|
| 46 |
- name: HF_TOKEN
|
| 47 |
+
value:
|
| 48 |
- name: DATASET_NAME
|
| 49 |
value: MIMIC-CXR_resized
|
| 50 |
- name: REPORT_MODE
|
|
|
|
| 65 |
# Scheduling: SPOT = ~70% cheaper, may be preempted. Repo has auto-resume so safe.
|
| 66 |
# Use STANDARD if you want guaranteed uninterrupted run (3.4× cost).
|
| 67 |
scheduling:
|
| 68 |
+
# STANDARD = on-demand (~$0.71/h for g2-standard-8). Switch to SPOT (~$0.21/h)
|
| 69 |
+
# after requesting `custom_model_training_preemptible_nvidia_l4_gpus` quota —
|
| 70 |
+
# it's a SEPARATE quota from the regular L4 one.
|
| 71 |
+
strategy: STANDARD
|
| 72 |
# Max wall-clock time the job is allowed to run. If you hit this, it's killed.
|
| 73 |
# 24h is a sane ceiling — refuses to silently run forever.
|
| 74 |
+
timeout: 200000s
|