File size: 29,982 Bytes
815a64a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Builds scripts/cxrvlm_eos_test.ipynb — A/B test for the EOS-in-labels fix.

Hypothesis (from dataset.py:392): training labels don't end with </s>, so the
model never learned to emit EOS, so generation runs to max_new_tokens and
loops on high-prob template phrases.

Test design (cheap — ~30-45 min on L4):
  1. Load existing trained checkpoint (run_id picked in selectors).
  2. Pick 5 test images + their GT findings.
  3. PHASE A — generate on the 5 images with several settings, record:
       avg_gen_tokens, hit_max_rate, distinct_sentence_ratio,
       sample outputs.
  4. Mini-FT for 1 epoch on 100 train samples using a dataset SUBCLASS that
     appends `tokenizer.eos_token` to every target (the only change).
  5. PHASE B — re-generate on the SAME 5 images with the SAME settings.
  6. Print before/after comparison table + side-by-side text.

Interpretation:
  - If hit_max_rate drops sharply and avg_gen_tokens decreases → EOS hypothesis
    confirmed dominant (the model CAN learn EOS in ~100 LoRA steps).
  - If barely changes → another factor (greedy bias, prompt mismatch, quant
    noise) is more dominant than EOS-in-labels.

Run from repo root:
  python scripts/_build_eos_test_notebook.py
"""

from pathlib import Path
import nbformat as nbf


def md(text: str):
    return nbf.v4.new_markdown_cell(text)


def code(text: str):
    return nbf.v4.new_code_cell(text)


cells = []

# ─── Title ──────────────────────────────────────────────────────────────────
cells.append(md("""# CXR-VLM — EOS Fix A/B Test

**Hypothesis:** `data/dataset.py` tokenizes `prompt + " " + target` without appending `</s>` to the target. `LlamaTokenizer.encode(add_special_tokens=True)` adds BOS but NOT EOS, so labels never include EOS → model never learns "report finished → stop" → at inference it runs to `max_new_tokens` and loops on high-probability template phrases.

**Test (~30-45 min on L4, ~60-90 min on T4):**
1. Load the existing trained checkpoint.
2. Pick 5 test images + their GT findings text.
3. **PHASE A (BEFORE)** — generate with current model. Record `avg_gen_tokens`, `hit_max_rate` (% of samples that ran out of token budget), `distinct_sentence_ratio` (1.0 = no repeats, <0.5 = heavy loop).
4. **Mini fine-tune** for 1 epoch on 100 training samples using a dataset subclass that appends `tokenizer.eos_token` — this is the ONLY change.
5. **PHASE B (AFTER)** — re-generate the same 5 images with the same settings. Recompute metrics.
6. Compare. If `hit_max_rate` drops sharply → EOS hypothesis confirmed dominant. If barely changes → another factor (greedy bias / quantization / prompt mismatch) is more important than missing-EOS.

Run this notebook standalone; it pulls code + checkpoint + a small dataset slice from HF.
"""))

# ─── Selectors ──────────────────────────────────────────────────────────────
cells.append(md("## 0. Selectors"))
cells.append(code("""# ── Platform ─────────────────────────────────────────────────────
PLATFORM = 'colab'          # 'kaggle' | 'colab' | 'lightning' | 'gcp' | 'local'

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

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

# ── Test setup ───────────────────────────────────────────────────
NUM_TEST_IMAGES   = 5                  # generate before+after on these
NUM_TRAIN_SAMPLES = 100                # mini-FT budget
TASK              = 'findings'         # which task to test on
MAX_NEW_TOKENS    = 300                # generation cap (proxy for "didn't EOS")

# ── Mini fine-tune hparams ───────────────────────────────────────
FT_LR              = 2e-5
FT_BATCH_SIZE      = 2                 # keep tiny — L4 has 24GB but model is 4-bit + LoRA
FT_EPOCHS          = 1
FT_GRAD_ACCUM      = 4                 # effective batch = 8

# ── Generation settings (used IDENTICALLY in BEFORE and AFTER) ───
GEN_DO_SAMPLE = False                  # greedy → makes EOS effect very visible
GEN_NUM_BEAMS = 1                      # NO beam search — isolates the EOS variable

assert PLATFORM in ('kaggle', 'colab', 'lightning', 'gcp', 'local')
print(f'PLATFORM={PLATFORM}  RUN_ID={RUN_ID}/{CKPT_PICK}')
print(f'Test: {NUM_TEST_IMAGES} images   FT: {NUM_TRAIN_SAMPLES} samples × {FT_EPOCHS} epoch(s)')
"""))

# ─── Env + pip ──────────────────────────────────────────────────────────────
cells.append(md("## 1. Env + pip"))
cells.append(code("""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, json, time, random, re
from pathlib import Path
"""))

cells.append(code("""!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

import torch as _t
if _t.cuda.is_available() and _t.cuda.get_device_capability(0) >= (8, 0):
    print('[pip] Ampere+/Ada -> flash-attn install (may take 5-10 min)')
    !pip install -q flash-attn --no-build-isolation 2>&1 | tail -5
else:
    print('[pip] T4/V100 -> skipping flash-attn')
"""))

cells.append(code("""import torch, transformers, peft, huggingface_hub, httpx
print('torch       :', torch.__version__, '| cuda:', torch.cuda.is_available())
print('transformers:', transformers.__version__)
print('peft        :', peft.__version__)

# httpx 0.28+ shim
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 required'
_p = torch.cuda.get_device_properties(0)
print(f'GPU: {_p.name}  ({_p.total_memory/1e9:.1f} GB)')
"""))

# ─── Paths + code/checkpoint/data pull ──────────────────────────────────────
cells.append(md("## 2. Paths + pull code + checkpoint + data slice"))
cells.append(code("""# WORK + HF_TOKEN
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':
    for c in (Path('/home/jupyter'), Path('/workspace')):
        if c.exists() or os.access(c.parent, os.W_OK):
            WORK = c; break
    else:
        WORK = Path.home() / 'cxr-vlm-work'
else:
    WORK = Path.home() / 'cxr-vlm-work'
WORK.mkdir(parents=True, exist_ok=True)
assert os.environ.get('HF_TOKEN'), 'HF_TOKEN missing in platform secrets'

from huggingface_hub import snapshot_download, hf_hub_download

print('Pulling code …')
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)
"""))

cells.append(code("""# Pull checkpoint (configs + stage2/{CKPT_PICK})
RUN_PULL_ROOT = WORK / 'run_pull'
RUN_PULL_ROOT.mkdir(parents=True, exist_ok=True)

print(f'Pulling {RUN_ID}/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
assert (CKPT_DIR_PULLED / 'checkpoint_projection.pt').is_file()
assert (CKPT_DIR_PULLED / 'checkpoint_lora' / 'adapter_config.json').is_file()
print('checkpoint OK')

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

cells.append(code("""# Pull a thin dataset slice: manifests + instruct JSON + 1 image tar shard.
# 1 shard ≈ a few thousand resized JPGs, far more than we need.
import tarfile

DATA_SRC = WORK / 'data_src'
DATA_DIR = DATA_SRC / 'MIMIC-CXR_resized'
DATA_DIR.mkdir(parents=True, exist_ok=True)

# Metadata (CSV manifests + instruct JSONs)
print('Pulling manifests + instruct JSONs …')
snapshot_download(
    repo_id=HF_DATA_REPO, repo_type='dataset',
    token=os.environ['HF_TOKEN'],
    allow_patterns=[
        'MIMIC-CXR_resized/*.csv',
        'MIMIC-CXR_resized/*.json',
        'MIMIC-CXR_resized/*.txt',
    ],
    local_dir=str(DATA_SRC),
)

# List tar shards on HF and pull the smallest one (usually train shard 0)
from huggingface_hub import HfApi
api = HfApi(token=os.environ['HF_TOKEN'])
all_files = api.list_repo_files(repo_id=HF_DATA_REPO, repo_type='dataset')
shards = sorted(f for f in all_files
                if f.startswith('MIMIC-CXR_resized/') and f.endswith('.tar'))
assert shards, 'No tar shards found on HF data repo.'

# Prefer a train shard for FT samples
train_shard = next((s for s in shards if 'train' in s.lower()), shards[0])
print(f'Pulling 1 tar shard: {train_shard}')
shard_path = Path(hf_hub_download(
    repo_id=HF_DATA_REPO, repo_type='dataset',
    filename=train_shard, token=os.environ['HF_TOKEN'],
    local_dir=str(DATA_SRC),
))
with tarfile.open(shard_path) as t:
    t.extractall(DATA_DIR)
shard_path.unlink(missing_ok=True)
print(f'Data ready under {DATA_DIR}')
print('Top-level entries:', sorted(p.name for p in DATA_DIR.iterdir())[:10])
"""))

# ─── Build configs + load model ─────────────────────────────────────────────
cells.append(md("## 3. GPU profile + build configs + load model"))
cells.append(code("""import torch
_p = torch.cuda.get_device_properties(0)
_cap = (_p.major, _p.minor)
_bf16_ok = torch.cuda.is_bf16_supported()
_fa2_ok = _cap >= (8, 0)
_fa2_installed = False
if _fa2_ok:
    try:
        import flash_attn; _fa2_installed = True
    except Exception:
        pass

PROFILE = dict(
    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 _fa2_installed) else 'sdpa',
)
print(f'GPU={_p.name}  cap=sm_{_cap[0]}{_cap[1]}  bf16={_bf16_ok}  FA2={_fa2_installed}')
print(f'-> dtype={PROFILE["torch_dtype"]}  attn={PROFILE["attn_implementation"]}')
"""))

cells.append(code("""from omegaconf import OmegaConf

model_cfg = OmegaConf.load(SAVED_MODEL_CFG) if SAVED_MODEL_CFG.is_file() \\
            else OmegaConf.load(PROJECT / 'configs' / 'model_config.yaml')
train_cfg = OmegaConf.load(SAVED_TRAIN_CFG) if SAVED_TRAIN_CFG.is_file() \\
            else OmegaConf.load(PROJECT / 'configs' / 'train_config.yaml')

# 4-bit Vicuna + profile
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
# Keep grad checkpointing ON during the mini-FT — saves VRAM, irrelevant for gen.
model_cfg.llm.gradient_checkpointing    = True

# CheXpert classifier off — not needed for this test
model_cfg.chexpert_classifier.enabled = False
print('configs ready')
"""))

cells.append(code("""import time
from model import CXRVisionLanguageModel
from model.rad_dino import BioViLTEncoder
from utils.checkpoint import load_checkpoint

print('[1/3] Building model … (cold cache: 5-9 min)')
t0 = time.time()
model = CXRVisionLanguageModel(model_cfg)
print(f'      built in {time.time()-t0:.1f}s')

print(f'[2/3] Loading checkpoint from {CKPT_DIR_PULLED} …')
t0 = time.time()
# CRITICAL: pass the DIRECTORY, not the .pt file (load_checkpoint splits suffix
# off the stem; passing checkpoint_projection.pt silently skips both).
load_checkpoint(model, str(CKPT_DIR_PULLED))
print(f'      loaded in {time.time()-t0:.1f}s')

print('[3/3] cuda + eval()')
model = model.to('cuda')
model.eval()
TRANSFORM = BioViLTEncoder.get_transform('val')
print(f'VRAM: {torch.cuda.memory_allocated()/1e9:.2f} GB')
"""))

# ─── Build test set ─────────────────────────────────────────────────────────
cells.append(md("""## 4. Build the test set (5 images with GT findings)

We sample test images from the instruct JSON that was used at training time. We need:
1. An `image_path` that resolves under `DATA_DIR` (so the file is on disk after our 1-shard pull).
2. A non-empty `target` to compare against qualitatively.
3. `task == TASK` (default `'findings'`)."""))

cells.append(code("""from utils.dataset_resolver import resolve_dataset_spec

# Point the config at our pulled data dir, then let the resolver pick the right
# instruct JSON (it auto-builds if missing, matching the report/image mode that
# the trained model expects).
train_cfg.data.dataset_name = 'MIMIC-CXR_resized'
train_cfg.data.mimic_cxr_resized.root = str(DATA_DIR)

spec = resolve_dataset_spec(train_cfg)
INSTRUCT_JSON = spec.instruct_json
IMAGE_ROOT    = Path(spec.image_root)
print(f'report_mode={spec.report_mode}  image_mode={spec.image_mode}')
print('instruct JSON:', INSTRUCT_JSON)
print('image_root   :', IMAGE_ROOT)

all_entries = json.load(open(INSTRUCT_JSON))
print(f'{len(all_entries):,} total entries')
"""))

cells.append(code("""# Filter to entries whose image is actually on disk (we only pulled 1 shard).
def _img_present(entry):
    p = entry.get('image_path') or (entry.get('image_paths') or [None])[0]
    return p and (IMAGE_ROOT / p).is_file()

train_pool = [e for e in all_entries
              if e.get('split') == 'train' and e.get('task') == TASK
                 and e.get('target') and _img_present(e)]
test_pool  = [e for e in all_entries
              if e.get('split') in ('test', 'validate')
                 and e.get('task') == TASK
                 and e.get('target') and _img_present(e)]
# If test set isn't covered by our single shard, fall back to held-out train.
if len(test_pool) < NUM_TEST_IMAGES:
    print(f'(only {len(test_pool)} test/val entries in this shard; '
          f'using held-out train entries for the test set)')
    held_out  = train_pool[-NUM_TEST_IMAGES:]
    train_pool = train_pool[:-NUM_TEST_IMAGES]
    test_pool = held_out

random.seed(0)
random.shuffle(train_pool)
TRAIN_ENTRIES = train_pool[:NUM_TRAIN_SAMPLES]
TEST_ENTRIES  = test_pool[:NUM_TEST_IMAGES]

print(f'TRAIN_ENTRIES: {len(TRAIN_ENTRIES)}  TEST_ENTRIES: {len(TEST_ENTRIES)}')
assert len(TRAIN_ENTRIES) >= 10 and len(TEST_ENTRIES) >= 3, \\
    'Not enough samples in this shard — pull a second shard.'

for i, e in enumerate(TEST_ENTRIES):
    print(f'\\n[{i}] {e["image_path"]}')
    print(f'    GT ({len(e["target"].split())} words): {e["target"][:160]}…')
"""))

# ─── Metrics ────────────────────────────────────────────────────────────────
cells.append(md("""## 5. Metrics helper

Three signals to track:

- **`avg_gen_tokens`** — mean output length in tokens. If model never emits EOS, this saturates near `MAX_NEW_TOKENS`.
- **`hit_max_rate`** — fraction of samples where output length ≥ `MAX_NEW_TOKENS - 5`. Direct proxy for "didn't emit EOS".
- **`distinct_sentence_ratio`** — (# distinct sentences) / (total sentences). 1.0 = no repeats, < 0.5 = heavy loop."""))

cells.append(code("""def split_sentences(text: str):
    return [s.strip() for s in re.split(r'(?<=[.!?])\\s+', text.strip()) if s.strip()]

def measure_outputs(outputs, max_new_tokens, tokenizer):
    n = len(outputs)
    if n == 0:
        return {}
    lengths = [len(tokenizer.encode(o, add_special_tokens=False)) for o in outputs]
    sent_counts, distinct_ratios = [], []
    for o in outputs:
        ss = split_sentences(o)
        if not ss:
            sent_counts.append(0); distinct_ratios.append(1.0); continue
        # Normalize whitespace + de-id tokens for dedup
        norm = [re.sub(r'_+|\\s+', ' ', s.lower()) for s in ss]
        sent_counts.append(len(ss))
        distinct_ratios.append(len(set(norm)) / len(norm))
    return dict(
        n                       = n,
        avg_gen_tokens          = sum(lengths) / n,
        max_gen_tokens          = max(lengths),
        hit_max_rate            = sum(1 for L in lengths if L >= max_new_tokens - 5) / n,
        avg_sentences           = sum(sent_counts) / n,
        distinct_sentence_ratio = sum(distinct_ratios) / n,
    )

def fmt_metrics(label, m, mnt):
    print(f'{label:<10s}'
          f'  avg_tok={m["avg_gen_tokens"]:6.1f}'
          f'  max_tok={m["max_gen_tokens"]:4d}'
          f'  hit_max%={m["hit_max_rate"]*100:5.1f}'
          f'  sentences={m["avg_sentences"]:4.1f}'
          f'  distinct_sent%={m["distinct_sentence_ratio"]*100:5.1f}'
          f'  (cap={mnt})')
"""))

# ─── PHASE A — BEFORE ───────────────────────────────────────────────────────
cells.append(md("""## 6. PHASE A — BEFORE fine-tune

Generate on the 5 test images with the model as-is. Greedy + `num_beams=1` is intentional — it makes the EOS effect visible. Beam search would mask it."""))

cells.append(code("""from PIL import Image
from data.prompt_templates import (
    build_findings_prompt, build_impression_prompt,
    build_report_prompt,   build_vqa_prompt,
)

def _build_prompt(task, structured_findings=None, question=None):
    return {
        'findings':   lambda: build_findings_prompt(structured_findings, randomize=False),
        'impression': lambda: build_impression_prompt(structured_findings, randomize=False),
        'report':     lambda: build_report_prompt(structured_findings, randomize=False),
        'vqa':        lambda: build_vqa_prompt(question, structured_findings),
    }[task]()

@torch.no_grad()
def generate_on_test_set(entries, label, max_new_tokens=MAX_NEW_TOKENS):
    model.eval()
    outs = []
    for e in entries:
        img = Image.open(IMAGE_ROOT / e['image_path']).convert('RGB')
        img_t = TRANSFORM(img).unsqueeze(0).to('cuda')
        prompt = _build_prompt(e['task'])
        out = model.generate(
            images         = img_t,
            prompts        = [prompt],
            max_new_tokens = max_new_tokens,
            temperature    = 1.0,
            do_sample      = GEN_DO_SAMPLE,
            num_beams      = GEN_NUM_BEAMS,
        )[0]
        outs.append(out)
    metrics = measure_outputs(outs, max_new_tokens, model.tokenizer)
    fmt_metrics(label, metrics, max_new_tokens)
    return outs, metrics

print('Generating BEFORE …')
BEFORE_OUTS, BEFORE_METRICS = generate_on_test_set(TEST_ENTRIES, 'BEFORE')
"""))

cells.append(code("""# Show 2 example outputs side-by-side with GT
for i in range(min(2, len(TEST_ENTRIES))):
    print('═' * 80)
    print(f'Image: {TEST_ENTRIES[i]["image_path"]}')
    print('-' * 80, '\\nGT:'); print(TEST_ENTRIES[i]['target'])
    print('-' * 80, '\\nBEFORE generation:'); print(BEFORE_OUTS[i])
    print()
"""))

# ─── Mini fine-tune with EOS fix ────────────────────────────────────────────
cells.append(md("""## 7. Mini fine-tune — the only change is appending EOS to targets

We subclass `CXRInstructDataset` and override `_tokenize_with_labels` to append `tokenizer.eos_token` before encoding. Everything else (prompt format, LR, optimizer, model architecture) is identical to the original training. So any behavior change must come from the EOS.

100 samples × 1 epoch with grad_accum=4, batch=2 → ~12-13 optimizer steps. ~5-10 minutes on L4."""))

cells.append(code("""from data.dataset import CXRInstructDataset
from data.collator import CXRDataCollator
from torch.utils.data import Subset, DataLoader

class CXRInstructDataset_EOS(CXRInstructDataset):
    '''Same dataset as production, but targets get </s> appended.'''
    def _tokenize_with_labels(self, prompt: str, target: str):
        full_text      = prompt + ' ' + target + self.tokenizer.eos_token
        prompt_encoded = self.tokenizer.encode(prompt, add_special_tokens=True)
        full_encoded   = self.tokenizer.encode(
            full_text,
            add_special_tokens = True,
            max_length         = self.cutoff_len,
            truncation         = True,
        )
        input_ids = torch.tensor(full_encoded, dtype=torch.long)
        labels    = input_ids.clone()
        labels[: min(len(prompt_encoded), self.cutoff_len)] = -100
        return input_ids, labels

# Sanity-check: confirm the override actually appends EOS
_eos_id = model.tokenizer.eos_token_id
print('EOS token id =', _eos_id, '   token =', repr(model.tokenizer.eos_token))
"""))

cells.append(code("""# Build train dataset using the SAME instruct JSON; restrict to our 100 entries.
# Trick: write a temp JSON with just TRAIN_ENTRIES so we don't change CXRInstructDataset filter logic.
import tempfile

tmp_json = WORK / 'tmp_train_subset.json'
tmp_json.write_text(json.dumps(TRAIN_ENTRIES))

ft_dataset = CXRInstructDataset_EOS(
    data_path  = str(tmp_json),
    image_root = str(IMAGE_ROOT),
    tokenizer  = model.tokenizer,
    transform  = BioViLTEncoder.get_transform('train'),
    task       = TASK,
    split      = 'train',
    cutoff_len = 512,
)
print(f'FT dataset: {len(ft_dataset)} samples')

collator = CXRDataCollator(model.tokenizer.pad_token_id)
loader = DataLoader(
    ft_dataset,
    batch_size  = FT_BATCH_SIZE,
    shuffle     = True,
    collate_fn  = collator,
    num_workers = 0,
)
print(f'Steps per epoch: {len(loader)} (× {FT_EPOCHS} epoch(s), grad_accum={FT_GRAD_ACCUM})')
"""))

cells.append(code("""# Mini fine-tune loop. Trains projection + LoRA (everything that has requires_grad).
from torch.optim import AdamW

trainable = [p for p in model.parameters() if p.requires_grad]
n_trainable = sum(p.numel() for p in trainable)
print(f'Trainable params: {n_trainable/1e6:.1f}M')

optimizer = AdamW(trainable, lr=FT_LR)

model.train()
# QLoRA needs grad checkpointing kwarg
if hasattr(model.llm, 'gradient_checkpointing_enable'):
    model.llm.gradient_checkpointing_enable(gradient_checkpointing_kwargs={'use_reentrant': False})

step = 0
optimizer.zero_grad()
t0 = time.time()
for epoch in range(FT_EPOCHS):
    for batch_idx, batch in enumerate(loader):
        batch = {k: (v.cuda(non_blocking=True) if torch.is_tensor(v) else v)
                 for k, v in batch.items()}
        out = model(**{k: batch[k] for k in ('images', 'input_ids', 'attention_mask', 'labels')
                       if k in batch})
        loss = out['loss'] if isinstance(out, dict) else out.loss
        (loss / FT_GRAD_ACCUM).backward()

        if (batch_idx + 1) % FT_GRAD_ACCUM == 0 or batch_idx + 1 == len(loader):
            optimizer.step()
            optimizer.zero_grad()
            step += 1

        if batch_idx % 4 == 0:
            elapsed = time.time() - t0
            print(f'epoch {epoch+1}  batch {batch_idx+1}/{len(loader)}  '
                  f'loss={loss.item():.3f}  ({elapsed:.0f}s elapsed)')

print(f'\\n✔ Mini-FT done in {time.time()-t0:.0f}s, {step} optimizer steps')
"""))

# ─── PHASE B — AFTER ────────────────────────────────────────────────────────
cells.append(md("## 8. PHASE B — AFTER fine-tune (same images, same settings)"))

cells.append(code("""# Disable grad checkpointing for cleaner generate
if hasattr(model.llm, 'gradient_checkpointing_disable'):
    model.llm.gradient_checkpointing_disable()

print('Generating AFTER …')
AFTER_OUTS, AFTER_METRICS = generate_on_test_set(TEST_ENTRIES, 'AFTER')
"""))

cells.append(code("""# Show same 2 examples side-by-side: GT vs BEFORE vs AFTER
for i in range(min(2, len(TEST_ENTRIES))):
    print('═' * 80)
    print(f'Image: {TEST_ENTRIES[i]["image_path"]}')
    print('-' * 80, '\\nGT:');     print(TEST_ENTRIES[i]['target'])
    print('-' * 80, '\\nBEFORE:'); print(BEFORE_OUTS[i])
    print('-' * 80, '\\nAFTER :'); print(AFTER_OUTS[i])
    print()
"""))

# ─── Compare ────────────────────────────────────────────────────────────────
cells.append(md("""## 9. Verdict

| Signal | Hypothesis-confirmed direction |
|---|---|
| `avg_gen_tokens` | **down** (model stops earlier) |
| `hit_max_rate` | **down sharply** — fewer samples truncated at cap |
| `distinct_sentence_ratio` | **up** (less looping) |

If at least 2 of these 3 move strongly in the predicted direction after just 100 samples of fine-tune, that's solid evidence the missing EOS in training labels was the dominant cause. If they don't budge, the looping comes from another factor (quantization noise / greedy bias / data overfitting on boilerplate) that this fix alone won't solve."""))

cells.append(code("""print('═' * 80)
print('SUMMARY')
print('═' * 80)
fmt_metrics('BEFORE', BEFORE_METRICS, MAX_NEW_TOKENS)
fmt_metrics('AFTER ', AFTER_METRICS,  MAX_NEW_TOKENS)
print()

deltas = {
    'avg_gen_tokens         ': AFTER_METRICS['avg_gen_tokens']          - BEFORE_METRICS['avg_gen_tokens'],
    'hit_max_rate           ': AFTER_METRICS['hit_max_rate']            - BEFORE_METRICS['hit_max_rate'],
    'distinct_sentence_ratio': AFTER_METRICS['distinct_sentence_ratio'] - BEFORE_METRICS['distinct_sentence_ratio'],
}
print('Δ AFTER − BEFORE:')
for k, v in deltas.items():
    arrow = '↓' if v < 0 else ('↑' if v > 0 else '·')
    print(f'  {k}: {v:+.3f}  {arrow}')

print()
# Heuristic verdict
ok_len   = AFTER_METRICS['avg_gen_tokens']  < BEFORE_METRICS['avg_gen_tokens'] - 20
ok_hit   = AFTER_METRICS['hit_max_rate']    < BEFORE_METRICS['hit_max_rate']   - 0.15
ok_dist  = AFTER_METRICS['distinct_sentence_ratio'] > BEFORE_METRICS['distinct_sentence_ratio'] + 0.10
n_signals = sum([ok_len, ok_hit, ok_dist])

if n_signals >= 2:
    print(f'✔ {n_signals}/3 signals confirm — EOS-in-labels appears to be the dominant cause.')
    print('  Recommendation: apply the fix in dataset.py and retrain (full run).')
elif n_signals == 1:
    print(f'~ {n_signals}/3 signals — EOS contributes but is not alone.')
    print('  Likely co-factors: greedy decoding bias, quantization noise, boilerplate overfit.')
    print('  Recommendation: retrain with the fix AND switch eval to beam search (num_beams=4).')
else:
    print('✘ 0/3 signals — EOS alone is NOT the dominant cause.')
    print('  Try: (a) more FT samples or epochs, (b) bigger LR, (c) compare beam vs greedy on')
    print('  the BEFORE model — if beam fixes it, the issue is decoding not training.')
"""))

# ─── Build + write ──────────────────────────────────────────────────────────
nb = nbf.v4.new_notebook()
nb.cells = cells
nb.metadata = {
    'kernelspec': {'name': 'python3', 'display_name': 'Python 3'},
    'language_info': {'name': 'python'},
}

OUT = Path(__file__).resolve().parent / 'cxrvlm_eos_test.ipynb'
nbf.write(nb, OUT)
print(f'Wrote {OUT}  ({len(cells)} cells)')