File size: 24,031 Bytes
aade474
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37bb3d9
aade474
37bb3d9
 
 
 
 
aade474
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
"""
Build scripts/cxrvlm_colab_inference.ipynb from cell sources defined here.

Run:
    python scripts/_build_inference_notebook.py
"""

import json
from pathlib import Path

NB_PATH = Path(__file__).parent / "cxrvlm_colab_inference.ipynb"


def md(cell_id, src):
    return {
        "cell_type": "markdown",
        "id": cell_id,
        "metadata": {},
        "source": src.splitlines(keepends=True),
    }


def code(cell_id, src):
    return {
        "cell_type": "code",
        "id": cell_id,
        "metadata": {},
        "execution_count": None,
        "outputs": [],
        "source": src.splitlines(keepends=True),
    }


CELLS = []

# ─────────────────────────────────────────────────────────────────────

CELLS.append(md("inf-0", """\
# CXR-VLM — Inference Notebook (Colab T4 / GCP)

Free-form inference on individual chest X-ray images. Use this when you want to:
- Run the trained model on a **new image** (not in the test split).
- Ask **custom VQA questions**.
- See the image + generated text side-by-side for qualitative checks.
- Iterate on prompts without re-running the heavy `evaluate.py` pipeline.

For aggregate test-set metrics use the companion notebook **`cxrvlm_colab_eval.ipynb`**.

### Sections
1. Selectors (which trained model to load + compute device)
2. Env + pip
3. Pull code from HF
4. Pull trained checkpoint + config snapshot from HF
5. Auto-detect GPU profile (bf16+FA2 on Ampere+/Ada, fp16+SDPA on Turing)
6. Build configs (4-bit Vicuna with the profile)
7. **Load model in-kernel** (one-time, ~5–8 min on T4 cold cache, ~3 min on L4)
8. **Inference helpers** (single image → findings / impression / report / VQA / full cascade)
9. **Examples** — single image, VQA, batch folder
"""))


CELLS.append(md("inf-select-md", """\
## 0. Select trained run + device
"""))


CELLS.append(code("inf-select", """\
# ── Platform ─────────────────────────────────────────────────────
PLATFORM = 'colab'          # 'kaggle' | 'colab' | 'lightning' | 'gcp' | 'local'

# ── Source repos on HuggingFace ──────────────────────────────────
HF_USER       = 'hieu3636'
HF_CODE_REPO  = f'{HF_USER}/cxr-vlm-code'
HF_RUNS_REPO  = f'{HF_USER}/cxr-vlm-runs'

# ── Which trained run to use ─────────────────────────────────────
RUN_ID        = 'MIMIC-CXR_resized_run_1'
CKPT_PICK     = 'best'                 # 'best' | 'last'

# ── Generation defaults (override per-call later if desired) ─────
MAX_NEW_TOKENS  = 300
TEMPERATURE     = 0.1
DO_SAMPLE       = False                # greedy by default — deterministic
NUM_BEAMS       = 1

assert PLATFORM  in ('kaggle', 'colab', 'lightning', 'gcp', 'local')
assert CKPT_PICK in ('best', 'last')

print(f'PLATFORM      = {PLATFORM}')
print(f'RUN_ID        = {RUN_ID}     (ckpt: stage2/{CKPT_PICK})')
"""))


CELLS.append(md("inf-env-md", "## 1. Environment + pip install"))


CELLS.append(code("inf-env", """\
import os
os.environ['CUDA_VISIBLE_DEVICES']            = '0'
os.environ['TOKENIZERS_PARALLELISM']          = 'false'
os.environ['BITSANDBYTES_NOWELCOME']          = '1'
os.environ['TRANSFORMERS_VERBOSITY']          = 'warning'
os.environ['PYTHONUNBUFFERED']                = '1'

import sys, shutil, subprocess
from pathlib import Path
"""))


CELLS.append(code("inf-pip", """\
# Same dependency window as the training/eval notebooks.
!pip uninstall -y -q torchao transformers bitsandbytes peft accelerate
!pip install -q -U bitsandbytes
!pip install -q \\
    'transformers>=4.46,<4.50' \\
    'peft>=0.13,<0.15' \\
    'accelerate>=1.0' \\
    'huggingface_hub>=0.27,<1.0' \\
    omegaconf sentencepiece 'protobuf>=3.20' \\
    pillow matplotlib

# Best-effort flash-attn install for Ampere+/Ada GPUs (L4, 3090, A10, A100, H100).
# Silent fail is OK — cxr_vlm.py auto-falls-back to SDPA if FA2 isn't importable.
import torch as _t
if _t.cuda.is_available() and _t.cuda.get_device_capability(0) >= (8, 0):
    print('[pip] Ampere+/Ada detected -> trying flash-attn (5-10 min if building from source)')
    !pip install -q flash-attn --no-build-isolation 2>&1 | tail -5
else:
    print('[pip] Pre-Ampere GPU (or no CUDA) -> skipping flash-attn install')
"""))


CELLS.append(code("inf-versions", """\
import torch, transformers, peft, huggingface_hub, httpx

print('torch          :', torch.__version__, '| cuda:', torch.cuda.is_available())
print('transformers   :', transformers.__version__)
print('peft           :', peft.__version__)
print('huggingface_hub:', huggingface_hub.__version__)
print('httpx          :', httpx.__version__)

# httpx 0.28+ shim — same as the other notebooks.
def _patch_httpx():
    if tuple(int(x) for x in httpx.__version__.split('.')[:2]) < (0, 28):
        return
    if getattr(httpx.Client, '_cxr_vlm_compat_patched', False):
        return
    def _make(orig):
        def patched(self, *args, **kwargs):
            if 'allow_redirects' in kwargs:
                kwargs['follow_redirects'] = kwargs.pop('allow_redirects')
            kwargs.pop('proxies', None)
            return orig(self, *args, **kwargs)
        return patched
    for cls in (httpx.Client, httpx.AsyncClient):
        for m in ('request', 'get', 'head', 'post', 'put',
                  'patch', 'delete', 'options'):
            if hasattr(cls, m):
                setattr(cls, m, _make(getattr(cls, m)))
    httpx.Client._cxr_vlm_compat_patched = True

_patch_httpx()

assert torch.cuda.is_available(), 'CUDA not available.'
_p = torch.cuda.get_device_properties(0)
print(f'GPU         : {_p.name}  ({_p.total_memory/1e9:.1f} GB)')
"""))


CELLS.append(md("inf-paths-md", "## 2. Paths + pull code"))


CELLS.append(code("inf-paths", """\
# WORK dir + HF_TOKEN bootstrap (platform-specific).
if PLATFORM == 'kaggle':
    from kaggle_secrets import UserSecretsClient
    os.environ['HF_TOKEN'] = UserSecretsClient().get_secret('HF_TOKEN')
    WORK = Path('/kaggle/working')
elif PLATFORM == 'colab':
    from google.colab import userdata
    os.environ['HF_TOKEN'] = userdata.get('HF_TOKEN')
    WORK = Path('/content')
elif PLATFORM == 'lightning':
    WORK = Path('/teamspace/studios/this_studio')
elif PLATFORM == 'gcp':
    # Vertex AI Workbench → /home/jupyter. Generic GCE VM → /workspace.
    # Pick whichever exists; create the latter if neither is writable.
    for _cand in (Path('/home/jupyter'), Path('/workspace')):
        if _cand.exists() or os.access(_cand.parent, os.W_OK):
            WORK = _cand
            break
    else:
        WORK = Path.home() / 'cxr-vlm-work'
else:  # 'local'
    WORK = Path.home() / 'cxr-vlm-work'
WORK.mkdir(parents=True, exist_ok=True)

assert os.environ.get('HF_TOKEN'), \\
    'HF_TOKEN missing — set it via the platform secrets UI before re-running.'

from huggingface_hub import snapshot_download

print(f'Pulling code from {HF_CODE_REPO} …')
CODE_SRC = Path(snapshot_download(
    repo_id   = HF_CODE_REPO,
    repo_type = 'model',
    token     = os.environ['HF_TOKEN'],
    local_dir = str(WORK / 'cxr-vlm-code'),
))

PROJECT = WORK / 'cxr_vlm'
if CODE_SRC.resolve() != PROJECT.resolve() and not PROJECT.exists():
    shutil.copytree(CODE_SRC, PROJECT)

os.chdir(PROJECT)
sys.path.insert(0, str(PROJECT))
print('PROJECT :', PROJECT)
print('WORK    :', WORK)
"""))


CELLS.append(md("inf-pull-run-md", """\
## 3. Pull trained checkpoint + config snapshot

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.
"""))


CELLS.append(code("inf-pull-run", """\
from huggingface_hub import snapshot_download

RUN_PULL_ROOT = WORK / 'run_pull'
RUN_PULL_ROOT.mkdir(parents=True, exist_ok=True)

print(f'Pulling {RUN_ID}/{{configs,stage2/{CKPT_PICK}}} from {HF_RUNS_REPO} …')
snapshot_download(
    repo_id   = HF_RUNS_REPO,
    repo_type = 'model',
    token     = os.environ['HF_TOKEN'],
    allow_patterns = [
        f'{RUN_ID}/configs/**',
        f'{RUN_ID}/run_meta.json',
        f'{RUN_ID}/stage2/{CKPT_PICK}/**',
    ],
    local_dir = str(RUN_PULL_ROOT),
)

RUN_DIR_PULLED = RUN_PULL_ROOT / RUN_ID
CKPT_DIR_PULLED = RUN_DIR_PULLED / 'stage2' / CKPT_PICK
PROJ_PT         = CKPT_DIR_PULLED / 'checkpoint_projection.pt'
LORA_DIR        = CKPT_DIR_PULLED / 'checkpoint_lora'
CHEXPERT_PT     = CKPT_DIR_PULLED / 'checkpoint_chexpert_classifier.pt'

assert PROJ_PT.is_file(), f'Projection weights not found at {PROJ_PT}.'
assert (LORA_DIR / 'adapter_config.json').is_file(), \\
    f'LoRA adapter_config.json not found in {LORA_DIR}.'

print(f'  projection : {PROJ_PT}      ({PROJ_PT.stat().st_size/1e6:.1f} MB)')
print(f'  lora       : {LORA_DIR}/    ({sum(p.stat().st_size for p in LORA_DIR.rglob("*") if p.is_file())/1e6:.1f} MB)')
print(f'  chexpert   : {CHEXPERT_PT}  (exists: {CHEXPERT_PT.is_file()})')

SAVED_CFG_DIR   = RUN_DIR_PULLED / 'configs'
SAVED_TRAIN_CFG = SAVED_CFG_DIR / 'train_config.yaml'
SAVED_MODEL_CFG = SAVED_CFG_DIR / 'model_config.yaml'
"""))


CELLS.append(md("inf-gpu-md", """\
## 4. Auto-detect GPU profile

Picks precision + attention backend based on the actual GPU:
- **Turing (T4, V100)** → fp16 + SDPA
- **Ampere+ / Ada (3090, L4, A10, A100, H100)** → bf16 + FA2 (if flash-attn installed; falls back to SDPA otherwise)
"""))


CELLS.append(code("inf-gpu", """\
import torch

assert torch.cuda.is_available(), 'CUDA not available.'
_p       = torch.cuda.get_device_properties(0)
_cap     = (_p.major, _p.minor)
_vram_gb = _p.total_memory / 1e9
_bf16_ok = torch.cuda.is_bf16_supported()
_fa2_ok  = _cap >= (8, 0)

_flash_attn_installed = False
if _fa2_ok:
    try:
        import flash_attn  # noqa: F401
        _flash_attn_installed = True
    except Exception:
        _flash_attn_installed = False

PROFILE = dict(
    label                  = _p.name,
    torch_dtype            = 'bfloat16' if _bf16_ok else 'float16',
    bnb_4bit_compute_dtype = 'bfloat16' if _bf16_ok else 'float16',
    attn_implementation    = 'flash_attention_2' if (_fa2_ok and _flash_attn_installed) else 'sdpa',
)

print(f'GPU         : {_p.name}  ({_vram_gb:.1f} GB)')
print(f'Compute cap : sm_{_cap[0]}{_cap[1]}   bf16 ok: {_bf16_ok}   FA2 wheel: {_flash_attn_installed}')
print(f'-> precision: {PROFILE["torch_dtype"]}   attn: {PROFILE["attn_implementation"]}')
"""))


CELLS.append(md("inf-cfg-md", "## 5. Build configs"))


CELLS.append(code("inf-cfg", """\
from omegaconf import OmegaConf

if SAVED_MODEL_CFG.is_file():
    model_cfg = OmegaConf.load(SAVED_MODEL_CFG)
    print(f'model_cfg <- {SAVED_MODEL_CFG}')
else:
    model_cfg = OmegaConf.load(PROJECT / 'configs' / 'model_config.yaml')
    print(f'model_cfg <- repo default')

if SAVED_TRAIN_CFG.is_file():
    train_cfg = OmegaConf.load(SAVED_TRAIN_CFG)
else:
    train_cfg = OmegaConf.load(PROJECT / 'configs' / 'train_config.yaml')

# Compute from auto-detected PROFILE (previous cell).
model_cfg.llm.load_in_4bit              = True
model_cfg.llm.load_in_8bit              = False
model_cfg.llm.attn_implementation       = PROFILE['attn_implementation']
model_cfg.llm.torch_dtype               = PROFILE['torch_dtype']
model_cfg.llm.bnb_4bit_compute_dtype    = PROFILE['bnb_4bit_compute_dtype']
model_cfg.llm.bnb_4bit_quant_type       = 'nf4'
model_cfg.llm.bnb_4bit_use_double_quant = True
model_cfg.llm.gradient_checkpointing    = False    # inference only

# CheXpert classifier — enable iff its checkpoint is pulled.
if CHEXPERT_PT.is_file():
    model_cfg.chexpert_classifier.enabled = True
    print(f'CheXpert classifier checkpoint found -> ENABLED')
else:
    model_cfg.chexpert_classifier.enabled = False
    print('No CheXpert classifier in this run -> DISABLED '
          '(structured_findings will be None unless you pass it explicitly)')

print('\\nmodel_cfg.llm:')
print(OmegaConf.to_yaml(model_cfg.llm))
"""))


CELLS.append(md("inf-load-md", """\
## 6. Load model into kernel

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.

Approx timing: ~8 min on T4 cold, ~3 min on L4 cold, seconds when cache is warm.
"""))


CELLS.append(code("inf-load", """\
import time, torch
from model import CXRVisionLanguageModel
from model.rad_dino import BioViLTEncoder
from utils.checkpoint import load_checkpoint

print('[1/3] Building model (Vicuna-7B 4-bit + RAD-DINO + LoRA)…')
print('      On T4 cold cache this takes ~5–8 min for the shard download + quantization.')
_t0 = time.time()
model = CXRVisionLanguageModel(model_cfg)
print(f'      built in {time.time()-_t0:.1f}s', flush=True)

print(f'[2/3] Loading checkpoint from {CKPT_DIR_PULLED} …')
_t0 = time.time()
# Pass the directory, not the .pt file: load_checkpoint splits suffix off
# the path stem, so passing checkpoint_projection.pt makes it look for
# checkpoint_projection_projection.pt (which does not exist) and silently
# skips both projection AND lora — model falls back to raw 4-bit Vicuna.
load_checkpoint(model, str(CKPT_DIR_PULLED))
print(f'      loaded in {time.time()-_t0:.1f}s', flush=True)

print('[3/3] Moving to cuda + eval()')
_t0 = time.time()
model = model.to('cuda').eval()
print(f'      ready in {time.time()-_t0:.1f}s', flush=True)

# Cache transform + chexpert helpers for reuse.
TRANSFORM = BioViLTEncoder.get_transform('val')
print('\\nModel ready. VRAM used:',
      f'{torch.cuda.memory_allocated()/1e9:.2f} GB / {torch.cuda.get_device_properties(0).total_memory/1e9:.1f} GB')
"""))


CELLS.append(md("inf-helpers-md", """\
## 7. Inference helpers

Two convenience functions:

- `predict(image, task, question=None, structured_findings=None, ...)` — single image, single task.
- `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.

`image` can be a filesystem path (str/Path) or a `PIL.Image.Image`.

### About `structured_findings` (the PNU prompt block)

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:

1. **`None` (default)** — no PNU block prepended. The model sees only the image + the task instruction. This is the realistic deployment mode.
2. **Pass a PNU string yourself** — for oracle ablation or testing how much PNU helps:
   ```
   structured_findings='Positive Abnormalities: Cardiomegaly\\nNegative Abnormalities: No Finding, ...\\nUncertain Abnormalities: None'
   ```
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.
"""))


CELLS.append(code("inf-helpers", """\
import torch
from pathlib import Path
from typing import Optional, Union
from PIL import Image

from data.prompt_templates import (
    build_findings_prompt, build_impression_prompt,
    build_report_prompt,   build_vqa_prompt,
)

ImageLike = Union[str, Path, Image.Image]


def _to_tensor(image: ImageLike) -> torch.Tensor:
    '''Load + transform a CXR into a (1, C, H, W) tensor on cuda.'''
    if isinstance(image, (str, Path)):
        img = Image.open(image).convert('RGB')
    else:
        img = image.convert('RGB') if image.mode != 'RGB' else image
    t = TRANSFORM(img)                                # (C, H, W)
    return t.unsqueeze(0).to('cuda')                  # (1, C, H, W)


@torch.no_grad()
def predict(
    image:               ImageLike,
    task:                str,
    question:            Optional[str] = None,
    structured_findings: Optional[str] = None,
    max_new_tokens:      int = None,
    temperature:         float = None,
    do_sample:           bool = None,
    num_beams:           int = None,
) -> str:
    '''Run the model on one image.

    task ∈ {'findings', 'impression', 'report', 'vqa'}.
    For task='vqa', `question` is required.
    '''
    assert task in ('findings', 'impression', 'report', 'vqa'), f'bad task: {task}'
    if task == 'vqa':
        assert question, 'VQA requires `question`.'

    sf = structured_findings
    if task == 'findings':
        prompt = build_findings_prompt(sf, randomize=False)
    elif task == 'impression':
        prompt = build_impression_prompt(sf, randomize=False)
    elif task == 'report':
        prompt = build_report_prompt(sf, randomize=False)
    else:
        prompt = build_vqa_prompt(question, sf)

    out = model.generate(
        images         = _to_tensor(image),
        prompts        = [prompt],
        max_new_tokens = max_new_tokens if max_new_tokens is not None else MAX_NEW_TOKENS,
        temperature    = temperature    if temperature    is not None else TEMPERATURE,
        do_sample      = do_sample      if do_sample      is not None else DO_SAMPLE,
        num_beams      = num_beams      if num_beams      is not None else NUM_BEAMS,
    )
    return out[0]


@torch.no_grad()
def predict_report(
    image:               ImageLike,
    structured_findings: Optional[str] = None,
    max_new_tokens:      int = None,
):
    '''Cascade: generate findings first, then feed them as context for impression.

    Matches the `split_cascade` training recipe but with model-generated (not
    GT) findings — i.e. a real end-to-end cascade (no GT leakage).

    Returns dict {findings, impression, report}.
    '''
    # 1) Findings (uses PNU context only — None by default)
    findings = predict(image, task='findings',
                       structured_findings=structured_findings,
                       max_new_tokens=max_new_tokens)

    # 2) Impression: feed the generated findings as the structured_findings
    #    context — same shape as the cascade trainer saw at training time
    #    but with the model's own findings instead of GT.
    impression = predict(image, task='impression',
                         structured_findings=findings,
                         max_new_tokens=max_new_tokens)

    return {
        'findings':   findings,
        'impression': impression,
        'report':     f'Findings: {findings}\\n\\nImpression: {impression}',
    }
"""))


CELLS.append(md("inf-show-md", "## 8. Display helper (optional, pretty-prints image + outputs)"))


CELLS.append(code("inf-show", """\
import matplotlib.pyplot as plt
from PIL import Image as _PILImage

def show(image, title=None, max_size=512):
    '''Render an image inline with optional title.'''
    if isinstance(image, (str, Path)):
        img = _PILImage.open(image).convert('RGB')
    else:
        img = image
    img.thumbnail((max_size, max_size))
    plt.figure(figsize=(6, 6))
    plt.imshow(img)
    plt.axis('off')
    if title:
        plt.title(title)
    plt.show()

def pretty(d: dict, header: str = None):
    '''Pretty-print a dict of {label: text}.'''
    if header:
        print('=' * 80)
        print(header)
    for k, v in d.items():
        print('-' * 80)
        print(f'[{k.upper()}]')
        print(v)
    print('=' * 80)
"""))


CELLS.append(md("inf-ex1-md", """\
## 9. Examples

### Example A — single image, all tasks

Edit `IMAGE_PATH` and `VQA_QUESTION` below, then run.
"""))


CELLS.append(code("inf-ex1", """\
# Pick an image — anywhere on disk. Colab: drag-and-drop into the Files pane
# (left sidebar) and use '/content/<filename>'. Local: any path that exists.
IMAGE_PATH = '/content/sample_cxr.jpg'

# Optional PNU override (see helpers section for format). None → no PNU block.
STRUCTURED_FINDINGS = None

# A clinical question for the VQA call below.
VQA_QUESTION = 'Is there any pleural effusion visible in this chest X-ray?'

if not Path(IMAGE_PATH).is_file():
    print(f'!! {IMAGE_PATH} does not exist. Upload an image or change IMAGE_PATH.')
else:
    show(IMAGE_PATH, title=Path(IMAGE_PATH).name)

    if STRUCTURED_FINDINGS:
        print('Structured findings (PNU) override:')
        print(STRUCTURED_FINDINGS)
        print()

    findings   = predict(IMAGE_PATH, task='findings',
                         structured_findings=STRUCTURED_FINDINGS)
    impression = predict(IMAGE_PATH, task='impression',
                         structured_findings=STRUCTURED_FINDINGS)
    vqa_ans    = predict(IMAGE_PATH, task='vqa', question=VQA_QUESTION,
                         structured_findings=STRUCTURED_FINDINGS)

    pretty({
        'findings':       findings,
        'impression':     impression,
        f'vqa ({VQA_QUESTION!r})': vqa_ans,
    }, header=Path(IMAGE_PATH).name)
"""))


CELLS.append(md("inf-ex2-md", """\
### Example B — cascade report (findings → impression on model output)
"""))


CELLS.append(code("inf-ex2", """\
if Path(IMAGE_PATH).is_file():
    out = predict_report(IMAGE_PATH)
    show(IMAGE_PATH, title='Cascade report')
    pretty(out, header=f'Cascade: {Path(IMAGE_PATH).name}')
"""))


CELLS.append(md("inf-ex3-md", """\
### Example C — batch a folder of images

Drops all results into `BATCH_OUT_JSON` for downstream analysis.
"""))


CELLS.append(code("inf-ex3", """\
import json
from tqdm.auto import tqdm

BATCH_FOLDER   = '/content/cxr_inbox'            # folder of *.jpg / *.png
BATCH_TASK     = 'report'                        # 'findings' | 'impression' | 'report' | 'vqa' | 'cascade'
BATCH_QUESTION = None                            # only used when BATCH_TASK == 'vqa'
BATCH_OUT_JSON = WORK / f'inference_{RUN_ID}_{BATCH_TASK}.json'

EXTS = {'.jpg', '.jpeg', '.png'}
folder = Path(BATCH_FOLDER)
imgs = sorted(p for p in folder.rglob('*') if p.suffix.lower() in EXTS) \\
       if folder.is_dir() else []
print(f'{len(imgs)} image(s) under {folder}')

results = []
for p in tqdm(imgs, desc=f'Inference [{BATCH_TASK}]'):
    try:
        if BATCH_TASK == 'cascade':
            out = predict_report(p)
        else:
            out = {BATCH_TASK: predict(p, task=BATCH_TASK,
                                       question=BATCH_QUESTION)}
        results.append({'image': str(p), **out})
    except Exception as e:
        results.append({'image': str(p), 'error': f'{type(e).__name__}: {e}'})

if results:
    BATCH_OUT_JSON.write_text(json.dumps(results, indent=2))
    print(f'Wrote {len(results)} entries -> {BATCH_OUT_JSON}')
"""))


CELLS.append(md("inf-done-md", """\
### Tips

- Loading the model takes 5–8 min on T4 cold cache. After that, each generation is ~3–8 s.
- To switch to a different trained run: change `RUN_ID` in section 0, re-run sections 3–5 (skip the env / pip sections).
- For deterministic outputs keep `DO_SAMPLE=False`. For diversity, set `DO_SAMPLE=True, TEMPERATURE=0.7`.
- `predict_report()` is the realistic end-to-end pipeline (no GT leakage). `predict(task='report')` only works on runs trained with `report_mode='merged'`.
"""))


# ─────────────────────────────────────────────────────────────────────

nb = {
    "cells": CELLS,
    "metadata": {
        "accelerator": "GPU",
        "colab": {
            "gpuType": "T4",
            "provenance": [],
            "machine_shape": "hm",
        },
        "kernelspec": {
            "display_name": "Python 3",
            "name": "python3",
        },
        "language_info": {
            "name": "python",
            "version": "3.10",
        },
    },
    "nbformat": 4,
    "nbformat_minor": 5,
}

NB_PATH.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
print(f"wrote {NB_PATH}  ({len(CELLS)} cells)")