convitom commited on
Commit
aade474
·
1 Parent(s): 2c84a70

update vertex jobs and remove hardcoded secrets

Browse files
.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
- logger.info(f"Loading model from checkpoint: {args.checkpoint}")
 
 
 
 
 
 
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-balanced
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: REPLACE_WITH_YOUR_HF_TOKEN
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
- strategy: SPOT
69
- # Max time job can wait in queue for resources before failing.
70
- # 1 hour is reasonable for L4 in us-central1 (usually available immediately).
71
- maxWaitDuration: 3600s
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: 86400s
 
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