File size: 39,007 Bytes
aade474
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
873c05e
 
 
 
 
 
 
 
 
 
aade474
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5a3b157
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aade474
5a3b157
aade474
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37bb3d9
 
 
 
 
 
aade474
 
 
 
 
 
 
 
 
 
 
 
 
873c05e
 
 
 
 
aade474
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5a3b157
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
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
"""
Build scripts/cxrvlm_colab_eval.ipynb from cell sources defined here.

Run:
    python scripts/_build_eval_notebook.py
"""

import json
from pathlib import Path

NB_PATH = Path(__file__).parent / "cxrvlm_colab_eval.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),
    }


# ─────────────────────────────────────────────────────────────────────
# Cell sources
# ─────────────────────────────────────────────────────────────────────

CELLS = []


CELLS.append(md("eval-0", """\
# CXR-VLM — Evaluation Notebook (Colab T4)

Standalone evaluation + inference for a trained CXR-VLM run.

What it does:
1. Pulls project code from `<HF_USER>/cxr-vlm-code`.
2. Pulls the chosen dataset from `<HF_USER>/cxr-vlm-data` (same layout the trainer used).
3. Pulls a trained run checkpoint from `<HF_USER>/cxr-vlm-runs/<RUN_ID>/stage2/{best|last}/`.
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.
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.
6. Runs `python -m evaluation.evaluate` on the **test split** for all available tasks.
7. Saves predictions + metrics under `RESULTS_DIR/<RUN_ID>/` (and optionally uploads them to `<HF_USER>/cxr-vlm-runs/<RUN_ID>/results/`).

Set the variables in **section 0** and run all cells top-to-bottom.

**Want interactive inference on individual images?** Use the companion notebook **`cxrvlm_colab_inference.ipynb`** — same model pull, but with image preview + free-form prompts.
"""))


CELLS.append(md("eval-select-md", """\
## 0. Select run + dataset + options

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


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

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

# ── Which trained run to evaluate ────────────────────────────────
# This MUST be an existing folder on HF_RUNS_REPO.
# Example: 'MIMIC-CXR_resized_run_1' | 'IU-Xray_run_2' | 'MIMIC-CXR_run_3'
RUN_ID        = 'MIMIC-CXR_resized_run_1'

# Which stage-2 checkpoint to load.
#   'best' → {RUN_ID}/stage2/best/  (final / best eval_loss)
#   'last' → {RUN_ID}/stage2/last/  (most recent intermediate save)
CKPT_PICK     = 'best'

# ── Dataset (auto-derived from RUN_ID prefix; override if needed) ──
# Supported: 'MIMIC-CXR' | 'MIMIC-CXR_resized' | 'IU-Xray'
DATASET_NAME  = None        # None → auto-detect from RUN_ID prefix
REPORT_MODE   = None        # None → read from run's saved train_config.yaml
IMAGE_MODE    = None        # None → read from run's saved train_config.yaml

# ── What to evaluate ─────────────────────────────────────────────
TASK          = 'all'       # 'all' | 'findings' | 'impression' | 'report' | 'vqa'
SPLIT         = 'test'      # always 'test' for this notebook; here for visibility
BATCH_SIZE    = None        # None → auto from GPU profile (T4:1, L4:4, A100:8, H100:16)
MAX_NEW_TOKENS = 300

# ── Metric config (cross-paper comparability) ────────────────────
# BERTScore: roberta-large + rescale → low, paper-comparable scores (~0.3-0.5).
#   Set BERTSCORE_RESCALE=False (and/or 'distilbert-base-uncased') for the old
#   raw scores (~0.8) — those are NOT comparable across papers.
# METEOR: 'nltk' is easy but scores higher than papers; 'pycoco' is comparable
#   (needs Java + `pip install pycocoevalcap`).
BERTSCORE_MODEL   = 'roberta-large'
BERTSCORE_RESCALE = True
METEOR_IMPL       = 'nltk'   # 'nltk' | 'pycoco'

# ── LLM-as-judge for VQA (optional; needs OPENAI_API_KEY) ────────
LLM_JUDGE             = False
LLM_JUDGE_MODEL       = 'gpt-4o-mini'
LLM_JUDGE_BASE_URL    = None          # e.g. Gemini OpenAI-compat endpoint
LLM_JUDGE_MAX_SAMPLES = None          # cap cost; None → all VQA samples

# ── Output ───────────────────────────────────────────────────────
# Local folder where predictions_*.json + metrics_summary.json land.
# Files end up at  {LOCAL_RESULTS_DIR}/{RUN_ID}/...
LOCAL_RESULTS_DIR = 'results'

# Push the results folder back to HF_RUNS_REPO under
#   {RUN_ID}/results/predictions_*.json + metrics_summary.json
UPLOAD_RESULTS_TO_HF = True

# ── Auto-derive DATASET_NAME from RUN_ID prefix if not set ───────
if DATASET_NAME is None:
    for cand in ('MIMIC-CXR_resized', 'MIMIC-CXR', 'IU-Xray'):
        if RUN_ID.startswith(cand + '_run_'):
            DATASET_NAME = cand
            break
    assert DATASET_NAME is not None, \\
        f"Cannot auto-derive DATASET_NAME from RUN_ID={RUN_ID!r}. " \\
        "Expected prefix one of: MIMIC-CXR_resized / MIMIC-CXR / IU-Xray. Set DATASET_NAME explicitly."

assert PLATFORM     in ('kaggle', 'colab', 'lightning', 'gcp', 'local')
assert DATASET_NAME in ('MIMIC-CXR', 'MIMIC-CXR_resized', 'IU-Xray')
assert CKPT_PICK    in ('best', 'last')
assert TASK         in ('all', 'findings', 'impression', 'report', 'vqa')

print(f'PLATFORM      = {PLATFORM}')
print(f'RUN_ID        = {RUN_ID}     (ckpt: stage2/{CKPT_PICK})')
print(f'DATASET_NAME  = {DATASET_NAME}')
print(f'TASK          = {TASK}   SPLIT = {SPLIT}')
print(f'LOCAL_RESULTS_DIR = {LOCAL_RESULTS_DIR}   (upload→HF: {UPLOAD_RESULTS_TO_HF})')
"""))


CELLS.append(md("eval-env-md", """\
## 1. Environment + pip install (matches the training notebook)
"""))


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

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


CELLS.append(code("eval-pip", """\
import os as _os

# Same dependency set as the training notebook — keeps load_checkpoint /
# evaluate.py running against the exact stack the model was trained with.
!pip uninstall -y -q torchao transformers bitsandbytes peft accelerate

# Let pip pick latest bnb that matches Colab's CUDA + triton.
!pip install -q -U bitsandbytes

# Pin transformers / peft to the same window the trainer uses.
!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' \\
    nltk rouge-score bert-score sacrebleu

# 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.
# Skipped entirely on Turing (T4/V100) since FA2 requires sm_80+.
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("eval-versions", """\
import torch, transformers, bitsandbytes, peft, accelerate, huggingface_hub, httpx

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

# httpx 0.28+ compat shim — same patch as the training notebook.
# transformers <=4.50 calls httpx.Client.head(..., allow_redirects=True)
# which httpx 0.28 removed; translate the kwarg at the call site.
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
    print(f'httpx {httpx.__version__}: monkey-patched allow_redirects -> follow_redirects')

_patch_httpx()

assert torch.cuda.is_available(), 'CUDA not available — refusing to evaluate on CPU.'
_p = torch.cuda.get_device_properties(0)
print(f'\\nGPU         : {_p.name}  ({_p.total_memory/1e9:.1f} GB)')
print(f'Compute cap : sm_{_p.major}{_p.minor}  (BF16 ok: {torch.cuda.is_bf16_supported()})')
"""))


CELLS.append(md("eval-paths-md", """\
## 2. Paths + pull code + pull dataset

Identical to the training notebook for sections that overlap (HF code + per-dataset payload).
"""))


CELLS.append(code("eval-paths", """\
# ── 1) 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':
    WORK = Path('/workspace')
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, hf_hub_download, HfApi

# ── 2) Code: flat folder, 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'),
))

# ── 3) Data: layout depends on DATASET_NAME (same logic as train notebook) ──
DATA_SRC = WORK / 'data'
DATA_SRC.mkdir(parents=True, exist_ok=True)

if DATASET_NAME == 'MIMIC-CXR_resized':
    import tarfile
    mr_dir = DATA_SRC / 'MIMIC-CXR_resized'
    mr_dir.mkdir(parents=True, exist_ok=True)
    files_dir = mr_dir / 'files'

    manifests_present = all(
        (mr_dir / f).is_file() for f in ('manifest_train.csv', 'manifest_val.csv', 'manifest_test.csv')
    )
    if manifests_present and files_dir.is_dir() and any(files_dir.glob('p*')):
        print(f'{mr_dir} already populated — skipping download.')
    else:
        api = HfApi(token=os.environ['HF_TOKEN'])
        all_files = api.list_repo_files(repo_id=HF_DATA_REPO, repo_type='dataset')
        mr_files  = [f for f in all_files if f.startswith('MIMIC-CXR_resized/')]
        tar_files = sorted(f for f in mr_files if f.endswith('.tar'))
        meta_files = [f for f in mr_files if not f.endswith('.tar')]
        print(f'MIMIC-CXR_resized on HF: {len(tar_files)} tar shards + {len(meta_files)} metadata files')

        # Manifests / vqa / SHARDS.txt
        snapshot_download(
            repo_id        = HF_DATA_REPO,
            repo_type      = 'dataset',
            allow_patterns = ['MIMIC-CXR_resized/*.csv',
                              'MIMIC-CXR_resized/*.json',
                              'MIMIC-CXR_resized/*.txt',
                              'MIMIC-CXR_resized/vqa/**'],
            token          = os.environ['HF_TOKEN'],
            local_dir      = str(DATA_SRC),
        )

        # Tar shards — sequential extract + delete to keep peak disk low.
        for i, tf in enumerate(tar_files, 1):
            print(f'  [{i}/{len(tar_files)}] {tf}')
            tar_path = Path(hf_hub_download(
                repo_id=HF_DATA_REPO, repo_type='dataset',
                filename=tf, token=os.environ['HF_TOKEN'],
                local_dir=str(DATA_SRC),
            ))
            with tarfile.open(tar_path) as t:
                t.extractall(mr_dir)
            tar_path.unlink(missing_ok=True)
        print(f'  done. {mr_dir} ready.')

else:
    # MIMIC-CXR / IU-Xray — single zip per dataset.
    import zipfile
    zip_name = f'{DATASET_NAME}.zip'
    marker   = DATA_SRC / DATASET_NAME

    if not marker.exists():
        print(f'Pulling {zip_name} from HF …')
        zpath = hf_hub_download(
            repo_id   = HF_DATA_REPO,
            filename  = zip_name,
            repo_type = 'dataset',
            token     = os.environ['HF_TOKEN'],
            local_dir = str(DATA_SRC),
        )
        print(f'  unzipping -> {DATA_SRC}')
        with zipfile.ZipFile(zpath) as zf:
            zf.extractall(DATA_SRC)
        try:
            os.remove(zpath)
        except OSError:
            pass
    else:
        print(f'{marker} already present — skipping download.')

print(f'Contents of {DATA_SRC}: {sorted(os.listdir(DATA_SRC))}')

# ── 4) Copy code into writable PROJECT dir + chdir ─────────────────
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('CODE_SRC :', CODE_SRC)
print('DATA_SRC :', DATA_SRC)
print('PROJECT  :', PROJECT)
print('WORK     :', WORK)
"""))


CELLS.append(md("eval-find-md", """\
## 3. Locate the dataset payload on disk
"""))


CELLS.append(code("eval-find", """\
# Same dataset-payload finders as the training notebook. Filling in the
# variables the config cell below will consume.

def find_split_parent(root: Path) -> Path:
    for cand in [root, root / 'MIMIC-CXR', root / 'data' / 'MIMIC-CXR']:
        if (cand / 'train').exists() and (cand / 'valid').exists() and (cand / 'test').exists():
            return cand
    for p in root.rglob('train'):
        if p.is_dir() and (p.parent / 'valid').exists() and (p.parent / 'test').exists():
            return p.parent
    raise FileNotFoundError('Could not find train/ valid/ test/ under ' + str(root))


def find_mimic_resized_root(root: Path) -> Path:
    for cand in [root / 'MIMIC-CXR_resized', root, *root.rglob('MIMIC-CXR_resized')]:
        if (cand / 'manifest_train.csv').is_file():
            return cand
    raise FileNotFoundError(
        f'Could not find MIMIC-CXR_resized payload under {root}. '
        'Expected manifest_train.csv (alongside manifest_val.csv / manifest_test.csv).'
    )


def find_iu_dirs(root: Path):
    for cand in [root / 'IU-Xray', *root.rglob('IU-Xray')]:
        if not cand.is_dir():
            continue
        imgs = cand / 'images'
        lbls = cand / 'labels'
        if imgs.is_dir() and lbls.is_dir() and any(lbls.glob('*.xml')):
            return imgs, lbls
        legacy = lbls / 'ecgen-radiology'
        if imgs.is_dir() and legacy.is_dir() and any(legacy.glob('*.xml')):
            return imgs, legacy
    img_dir = lbl_dir = None
    for cand in [root / 'images', *root.rglob('images')]:
        if cand.is_dir() and any(cand.glob('CXR*.png')):
            img_dir = cand; break
    for cand in [root / 'labels', *root.rglob('labels')]:
        if cand.is_dir() and any(cand.glob('*.xml')):
            lbl_dir = cand; break
    if lbl_dir is None:
        for cand in root.rglob('ecgen-radiology'):
            if cand.is_dir() and any(cand.glob('*.xml')):
                lbl_dir = cand; break
    return img_dir, lbl_dir


CXR_ROOT      = None
VQA_ROOT      = None
MR_ROOT       = None
IU_IMAGES_DIR = None
IU_LABELS_DIR = None

if DATASET_NAME == 'MIMIC-CXR':
    CXR_ROOT = find_split_parent(DATA_SRC)
    print('MIMIC-CXR root:', CXR_ROOT)
    for s in ('train', 'valid', 'test'):
        d = CXR_ROOT / s
        assert d.exists(), f'Missing split dir: {d}'
        print(f'  {s:<6s} -> {d}')

    for p in DATA_SRC.rglob('MIMIC-Ext-MIMIC-CXR-VQA'):
        cand = p / 'dataset'
        if cand.exists() and (cand / 'train.json').exists():
            VQA_ROOT = cand; break
    if VQA_ROOT is None:
        print('VQA root: NOT FOUND -> VQA task will be skipped')
    else:
        print('VQA root:', VQA_ROOT)

elif DATASET_NAME == 'MIMIC-CXR_resized':
    MR_ROOT = find_mimic_resized_root(DATA_SRC)
    print('MIMIC-CXR_resized root:', MR_ROOT)
    for cf in ('manifest_train.csv', 'manifest_val.csv', 'manifest_test.csv'):
        f = MR_ROOT / cf
        print(f'  {cf}: {"OK" if f.is_file() else "MISSING"}')
    for sub in ('files', 'vqa'):
        d = MR_ROOT / sub
        print(f'  {sub:<5s}: {"OK" if d.is_dir() else "MISSING"}  ({d})')

else:  # IU-Xray
    IU_IMAGES_DIR, IU_LABELS_DIR = find_iu_dirs(DATA_SRC)
    assert IU_IMAGES_DIR is not None, f'IU images/ not found under {DATA_SRC}'
    assert IU_LABELS_DIR is not None, f'IU labels/ (with *.xml) not found under {DATA_SRC}'
    print('IU images dir:', IU_IMAGES_DIR, '->', len(list(IU_IMAGES_DIR.glob('*.png'))), 'PNGs')
    print('IU labels dir:', IU_LABELS_DIR, '->', len(list(IU_LABELS_DIR.glob('*.xml'))), 'XMLs')
"""))


CELLS.append(md("eval-pull-run-md", """\
## 4. Pull trained checkpoint + run config snapshot from HF Runs repo

Layout on `HF_RUNS_REPO`:

```
{RUN_ID}/
  configs/{model,train}_config.yaml   ← snapshot taken at training time
  stage2/best/   checkpoint_projection.pt + checkpoint_lora/ [+ checkpoint_chexpert_classifier.pt]
  stage2/last/   same shape, intermediate save
```

We pull `{RUN_ID}/configs/` and `{RUN_ID}/stage2/{CKPT_PICK}/` into `{WORK}/run_pull/{RUN_ID}/`.
"""))


CELLS.append(code("eval-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
assert RUN_DIR_PULLED.is_dir(), f'Pull failed — {RUN_DIR_PULLED} missing.'

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}. ' \\
    f'Check that {RUN_ID}/stage2/{CKPT_PICK}/ exists on {HF_RUNS_REPO}.'
assert (LORA_DIR / 'adapter_config.json').is_file(), \\
    f'LoRA adapter_config.json not found in {LORA_DIR}. Stage-2 checkpoint partial?'

print()
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 configs (may or may not exist on older runs)
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'
print()
print(f'  saved train_cfg : {SAVED_TRAIN_CFG}  (exists: {SAVED_TRAIN_CFG.is_file()})')
print(f'  saved model_cfg : {SAVED_MODEL_CFG}  (exists: {SAVED_MODEL_CFG.is_file()})')
"""))


CELLS.append(md("eval-gpu-md", """\
## 5. Auto-detect GPU profile

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.

| Bucket | Examples | Precision | Attn | Eval batch |
|---|---|---|---|---|
| 70+ GB | A100/H100 80GB | bf16 | FA2 (if installed) | 16 |
| 35–69 GB | A100 40GB | bf16 | FA2 | 8 |
| 22–34 GB | 3090 / L4 / A10 24GB | bf16 | FA2 | 4 |
| 14–21 GB | T4 / V100 16GB | fp16 | SDPA | 1 |

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


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

assert torch.cuda.is_available(), 'CUDA not available — refusing to write a CPU profile.'
_props   = torch.cuda.get_device_properties(0)
_cap     = (_props.major, _props.minor)
_vram_gb = _props.total_memory / 1e9
_bf16_ok = torch.cuda.is_bf16_supported()
_fa2_ok  = _cap >= (8, 0)        # FA2 needs Ampere+ (sm_80 or newer)

# Detect whether flash-attn package is actually importable. FA2 falls back to
# SDPA inside cxr_vlm.py if missing, but knowing here lets us print clearly.
_flash_attn_installed = False
if _fa2_ok:
    try:
        import flash_attn  # noqa: F401
        _flash_attn_installed = True
    except Exception:
        _flash_attn_installed = False

print(f'GPU         : {_props.name}  ({_vram_gb:.1f} GB)')
print(f'Compute cap : sm_{_cap[0]}{_cap[1]}')
print(f'BF16 native : {_bf16_ok}')
print(f'FA2 capable : {_fa2_ok}   flash-attn installed: {_flash_attn_installed}')

# Eval batch size — smaller than training because generation builds a KV cache
# that scales with batch × max_new_tokens.
if _vram_gb >= 70:
    GPU_LABEL, _EVAL_BS, _NW = 'A100/H100 80GB',         16, 16
elif _vram_gb >= 35:
    GPU_LABEL, _EVAL_BS, _NW = 'A100 40GB',               8, 12
elif _vram_gb >= 22:
    GPU_LABEL, _EVAL_BS, _NW = 'RTX 3090 / L4 / A10 24GB', 4, 8
elif _vram_gb >= 14:
    GPU_LABEL, _EVAL_BS, _NW = 'T4 / V100 (15-16GB)',     1, 2
else:
    GPU_LABEL, _EVAL_BS, _NW = f'unknown ({_vram_gb:.0f}GB) - conservative', 1, 2

PROFILE = dict(
    label                       = GPU_LABEL,
    bf16                        = bool(_bf16_ok),
    fp16                        = not _bf16_ok,
    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',
    per_device_eval_batch_size  = _EVAL_BS,
    dataloader_num_workers      = _NW,
)

# Allow the section-0 selector to override (e.g. BATCH_SIZE=8 to push harder).
if BATCH_SIZE is not None:
    PROFILE['per_device_eval_batch_size'] = int(BATCH_SIZE)
    print(f'BATCH_SIZE override: section 0 forced batch={BATCH_SIZE}')

# Final BATCH_SIZE the rest of the notebook (eval-run cell) will use.
BATCH_SIZE = PROFILE['per_device_eval_batch_size']

print(f'\\n-> Profile  : {PROFILE["label"]}')
for k, v in PROFILE.items():
    if k == 'label': continue
    print(f'    {k:<32s} = {v}')
"""))


CELLS.append(md("eval-cfg-md", """\
## 6. Build configs

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:
- dataset paths to the local download
- compute (precision + attn backend + batch) from the auto-detected `PROFILE`
- HF Hub tracker → uses `HF_RUNS_REPO` so the results upload lands under `{RUN_ID}/results/`
- pin `run_id` to `RUN_ID` so `evaluate.py` writes under `{LOCAL_RESULTS_DIR}/{RUN_ID}/`
"""))


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

# ── 1) Load base configs: prefer the run's saved snapshot ─────────
if SAVED_TRAIN_CFG.is_file():
    train_cfg = OmegaConf.load(SAVED_TRAIN_CFG)
    print(f'train_cfg <- {SAVED_TRAIN_CFG}')
else:
    train_cfg = OmegaConf.load(PROJECT / 'configs' / 'train_config.yaml')
    print(f'train_cfg <- repo default (no snapshot on HF)')

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 (no snapshot on HF)')

# ── 2) Allow notebook overrides for report/image mode ─────────────
if REPORT_MODE is not None:
    train_cfg.data.report_mode = REPORT_MODE
if IMAGE_MODE is not None:
    train_cfg.data.image_mode = IMAGE_MODE
print(f'report_mode = {train_cfg.data.report_mode}   image_mode = {train_cfg.data.image_mode}')

# ── 3) Patch dataset paths for the local download ────────────────
train_cfg.data.dataset_name = DATASET_NAME

if DATASET_NAME == 'MIMIC-CXR':
    train_cfg.data.mimic_cxr_root   = str(CXR_ROOT)
    train_cfg.data.mimic_auto_build = True

    _cx = (sorted(DATA_SRC.rglob('*chexpert*.csv'))
           or sorted(DATA_SRC.rglob('*chexbert*.csv')))
    train_cfg.data.mimic_chexpert_csv = str(_cx[0]) if _cx else None
    train_cfg.data.mimic_vqa_root     = str(VQA_ROOT) if VQA_ROOT is not None else None

    out_dir = PROJECT / 'data' / 'data_files'
    out_dir.mkdir(parents=True, exist_ok=True)
    train_cfg.data.instruct_json = str(out_dir / 'mimic_cxr_instruct_unified.json')

elif DATASET_NAME == 'MIMIC-CXR_resized':
    train_cfg.data.mimic_cxr_resized.root          = str(MR_ROOT)
    train_cfg.data.mimic_cxr_resized.manifest_dir  = None
    train_cfg.data.mimic_cxr_resized.vqa_dir       = None
    train_cfg.data.mimic_cxr_resized.reports_root  = None
    train_cfg.data.mimic_cxr_resized.auto_build    = True
    out_dir = PROJECT / 'data' / 'data_files'
    out_dir.mkdir(parents=True, exist_ok=True)
    train_cfg.data.mimic_cxr_resized.instruct_json = str(out_dir / 'mimic_cxr_resized_instruct.json')

else:  # IU-Xray
    train_cfg.data.iu_xray.images_dir = str(IU_IMAGES_DIR)
    train_cfg.data.iu_xray.labels_dir = str(IU_LABELS_DIR)
    train_cfg.data.iu_xray.auto_build = True
    out_dir = PROJECT / 'data' / 'data_files'
    out_dir.mkdir(parents=True, exist_ok=True)
    train_cfg.data.iu_xray.instruct_json = str(out_dir / 'iu_xray_instruct.json')

train_cfg.data.train_split = 'train'
train_cfg.data.val_split   = 'validate'
train_cfg.data.test_split  = 'test'

# ── 4) Apply auto-detected compute profile (overrides saved config) ──
train_cfg.training.fp16 = PROFILE['fp16']
train_cfg.training.bf16 = PROFILE['bf16']
train_cfg.training.per_device_train_batch_size = PROFILE['per_device_eval_batch_size']
train_cfg.training.per_device_eval_batch_size  = PROFILE['per_device_eval_batch_size']
train_cfg.training.dataloader_num_workers      = PROFILE['dataloader_num_workers']
train_cfg.training.dataloader_pin_memory       = True

# Disable feature cache for eval (test images haven't been precomputed).
train_cfg.data.feature_cache_dir = None

# 4-bit QLoRA — must match how the trainer set it up for the saved LoRA to load.
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     # eval-only - no backward pass

# ── 5) CheXpert classifier ───────────────────────────────────────
# The training notebook keeps it disabled (oracle PNU from CSV/manifest).
# Only enable here if the trained run actually has a learned classifier
# checkpoint saved alongside.
if CHEXPERT_PT.is_file():
    model_cfg.chexpert_classifier.enabled = True
    print(f'CheXpert classifier checkpoint found at {CHEXPERT_PT} -> enabled')
else:
    model_cfg.chexpert_classifier.enabled = False
    print('No CheXpert classifier checkpoint -> disabled (oracle PNU from CSV/manifest)')

# ── 6) HF Hub tracker — points results uploads at HF_RUNS_REPO ──
CKPT_ROOT = WORK / 'ckpt_eval'
CKPT_ROOT.mkdir(parents=True, exist_ok=True)
train_cfg.training.output_root = str(CKPT_ROOT)

if UPLOAD_RESULTS_TO_HF:
    train_cfg.hf_hub.enabled        = True
    train_cfg.hf_hub.repo_id        = HF_RUNS_REPO
    train_cfg.hf_hub.token_env      = 'HF_TOKEN'
    train_cfg.hf_hub.private        = True
else:
    train_cfg.hf_hub.enabled = False

train_cfg.hf_hub.run_state_file = str(CKPT_ROOT / 'run_id.txt')

# Pin RUN_ID so resolve_run_id picks it up exactly.
Path(train_cfg.hf_hub.run_state_file).write_text(RUN_ID)

# ── 7) Save patched configs into the project so the subprocess sees them ──
OmegaConf.save(train_cfg, PROJECT / 'configs' / 'train_config.yaml')
OmegaConf.save(model_cfg, PROJECT / 'configs' / 'model_config.yaml')

print('--- train_cfg.data ---');    print(OmegaConf.to_yaml(train_cfg.data))
print('--- train_cfg.training ---');print(OmegaConf.to_yaml(train_cfg.training))
print('--- train_cfg.hf_hub ---');  print(OmegaConf.to_yaml(train_cfg.hf_hub))
print('--- model_cfg.llm ---');     print(OmegaConf.to_yaml(model_cfg.llm))
"""))


CELLS.append(md("eval-verify-md", """\
## 7. Verify dataset before running eval

Quick pre-flight check: triggers the instruct-JSON builder (if not cached), then prints **per-split × per-task** sample counts. Catches issues like "VQA = 0 samples" (path-format mismatch in the builder) **before** spending 2h on an eval that has nothing to evaluate.

If `vqa` column shows 0 in the test split:
- the model was likely **not trained on VQA** either (same JSON cache used both ways)
- options: skip VQA via `TASK='findings'` then run a second job with `TASK='impression'`, OR fix the builder + retrain.
"""))


CELLS.append(code("eval-verify", """\
import json as _json
from collections import Counter
from utils.dataset_resolver import resolve_dataset_spec

# Reload the patched config snapshot the eval subprocess will see.
train_cfg = OmegaConf.load(PROJECT / 'configs' / 'train_config.yaml')
spec = resolve_dataset_spec(train_cfg)
print(f'Dataset      : {spec.dataset_name}')
print(f'Instruct JSON: {spec.instruct_json}')
print(f'Image root   : {spec.image_root}')
print(f'Tasks (cfg)  : {spec.tasks}')
print()

# Load the JSON the dataset module will read.
samples = _json.loads(open(spec.instruct_json, encoding='utf-8').read())
print(f'Total samples in JSON: {len(samples):,}')

# Cross-tab: (split, task) -> count
ctab = Counter((s['split'], s['task']) for s in samples)
splits = sorted({k[0] for k in ctab})
tasks  = sorted({k[1] for k in ctab})

# Pretty table
col_w = max(10, max(len(t) for t in tasks) + 2)
hdr = f'{\"split\":<10} | ' + ' | '.join(f'{t:>{col_w}}' for t in tasks) + ' |     total'
print(hdr); print('-' * len(hdr))
for sp in splits:
    row_vals = [ctab.get((sp, t), 0) for t in tasks]
    total = sum(row_vals)
    print(f'{sp:<10} | ' + ' | '.join(f'{v:>{col_w},}' for v in row_vals)
          + f' | {total:>9,}')

# Loud warning if VQA is expected but missing.
test_vqa = ctab.get(('test', 'vqa'), 0)
if 'vqa' in spec.tasks and test_vqa == 0:
    print()
    print('!! WARNING: vqa task is configured but TEST split has 0 vqa samples.')
    print('   This usually means the dataset builder dropped all VQA rows due to')
    print('   path-format mismatch between vqa/*.json and manifest_*.csv.')
    print('   Check the builder log above for the line:')
    print('     [mimic_cxr_resized_builder] vqa added/dropped : N / M')
    print('   If N=0 the model was likely NOT trained on VQA either — same JSON cache.')
elif 'vqa' in spec.tasks:
    print(f'\\nVQA in test split: {test_vqa:,} samples — OK')
"""))


CELLS.append(md("eval-run-md", """\
## 8. Run evaluation

Calls `python -m evaluation.evaluate` as a subprocess. It will:
1. Build the unified instruct JSON for the chosen `(report_mode, image_mode)` if missing.
2. Build the model from the patched configs.
3. Load the projection + LoRA from `CKPT_DIR_PULLED`.
4. Iterate the **test** split, generate predictions, score every task.
5. Write `{LOCAL_RESULTS_DIR}/{RUN_ID}/predictions_*.json` + `metrics_summary.json`.
6. If `UPLOAD_RESULTS_TO_HF=True`, upload the folder to `{RUN_ID}/results/` on `HF_RUNS_REPO`.
"""))


CELLS.append(code("eval-run", """\
import shlex

RESULTS_DIR_LOCAL = WORK / LOCAL_RESULTS_DIR
RESULTS_DIR_LOCAL.mkdir(parents=True, exist_ok=True)

# evaluate.py forwards --checkpoint to utils.checkpoint.load_checkpoint,
# which reads <dir>/checkpoint_projection.pt + <dir>/checkpoint_lora/.
# Pass the DIRECTORY — passing the .pt file makes load_checkpoint mis-derive
# the filename (checkpoint_projection_projection.pt) and silently skip both
# projection AND LoRA, leaving you with raw 4-bit Vicuna.
CKPT_ARG = str(CKPT_DIR_PULLED)

extra = ''
if LLM_JUDGE:
    extra += ' --llm_judge'
    extra += f' --llm_judge_model {shlex.quote(LLM_JUDGE_MODEL)}'
    if LLM_JUDGE_BASE_URL:
        extra += f' --llm_judge_base_url {shlex.quote(LLM_JUDGE_BASE_URL)}'
    if LLM_JUDGE_MAX_SAMPLES:
        extra += f' --llm_judge_max_samples {int(LLM_JUDGE_MAX_SAMPLES)}'

if not UPLOAD_RESULTS_TO_HF:
    extra += ' --no_hf_upload'

# Metric comparability flags
extra += f' --bertscore_model {shlex.quote(BERTSCORE_MODEL)}'
extra += '' if BERTSCORE_RESCALE else ' --no-bertscore_rescale'
extra += f' --meteor_impl {METEOR_IMPL}'

print(f'Evaluating run_id    : {RUN_ID}')
print(f'Checkpoint           : {CKPT_ARG}')
print(f'Task                 : {TASK}   (split={SPLIT})')
print(f'Local results dir    : {RESULTS_DIR_LOCAL}/{RUN_ID}/')
print(f'Upload to HF         : {UPLOAD_RESULTS_TO_HF}')
print()

# NOTE: do NOT set HF_HUB_DISABLE_PROGRESS_BARS=1 here. On a cold cache the
# 4-bit Vicuna shard download is ~13GB and takes minutes on Colab T4 — hiding
# the bar makes the cell look frozen. TQDM_MININTERVAL=1.0 forces the per-task
# tqdm in run_inference to refresh every 1s.
!TRANSFORMERS_VERBOSITY=warning TOKENIZERS_PARALLELISM=false BITSANDBYTES_NOWELCOME=1 \\
PYTHONUNBUFFERED=1 TQDM_MININTERVAL=1.0 \\
python -u -m evaluation.evaluate \\
    --model_config    configs/model_config.yaml \\
    --train_config    configs/train_config.yaml \\
    --checkpoint      "{CKPT_ARG}" \\
    --run_id          "{RUN_ID}" \\
    --task            {TASK} \\
    --split           {SPLIT} \\
    --batch_size      {BATCH_SIZE} \\
    --max_new_tokens  {MAX_NEW_TOKENS} \\
    --output_dir      "{RESULTS_DIR_LOCAL}" \\
    --device          cuda{extra}
"""))


CELLS.append(md("eval-summary-md", """\
## 9. Show the metrics summary
"""))


CELLS.append(code("eval-summary", """\
import json as _json

_summary_path = RESULTS_DIR_LOCAL / RUN_ID / 'metrics_summary.json'
if not _summary_path.is_file():
    print(f'No metrics_summary.json at {_summary_path}. Did evaluate.py error out?')
else:
    summary = _json.loads(_summary_path.read_text())
    print(f'Dataset    : {summary.get("dataset_name")}')
    print(f'Run ID     : {summary.get("run_id")}')
    print(f'Split      : {summary.get("split")}')
    print()
    for task, metrics in (summary.get('metrics') or {}).items():
        print(f'─── {task.upper()} ───')
        for k, v in metrics.items():
            if isinstance(v, float):
                print(f'  {k:<22s}: {v:.4f}')
            else:
                print(f'  {k:<22s}: {v}')
        print()

    # List per-task prediction files for convenience
    print('Per-task prediction files:')
    for f in sorted((RESULTS_DIR_LOCAL / RUN_ID).glob('predictions_*.json')):
        n = len(_json.loads(f.read_text()))
        print(f'  {f.name}  ({n} samples)')
"""))


CELLS.append(md("eval-cleanup-md", """\
### Done

Final artifacts (also pushed to HF if `UPLOAD_RESULTS_TO_HF=True`):

```
{LOCAL_RESULTS_DIR}/{RUN_ID}/
  predictions_findings.json
  predictions_impression.json
  predictions_vqa.json       (MIMIC datasets only)
  metrics_summary.json
```

For free-form inference on individual images (with image preview, custom VQA questions, etc.) use the separate **`cxrvlm_colab_inference.ipynb`** notebook.
"""))


# ─────────────────────────────────────────────────────────────────────
# Write notebook
# ─────────────────────────────────────────────────────────────────────

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