convitom commited on
Commit
815a64a
·
1 Parent(s): 37bb3d9
configs/train_config.yaml CHANGED
@@ -222,7 +222,7 @@ training:
222
  # ── Stage 1: Train projection only ───────────
223
  stage1:
224
  enabled: true
225
- num_epochs: 2
226
  learning_rate: 1.0e-3
227
  freeze_llm: true
228
  freeze_encoder: true
@@ -252,7 +252,7 @@ stage1:
252
  # ── Stage 2: Full instruction tuning ─────────
253
  stage2:
254
  enabled: true
255
- num_epochs: 10
256
  learning_rate: 2.0e-4
257
  freeze_encoder: true
258
  freeze_llm: false # train via LoRA
 
222
  # ── Stage 1: Train projection only ───────────
223
  stage1:
224
  enabled: true
225
+ num_epochs: 3
226
  learning_rate: 1.0e-3
227
  freeze_llm: true
228
  freeze_encoder: true
 
252
  # ── Stage 2: Full instruction tuning ─────────
253
  stage2:
254
  enabled: true
255
+ num_epochs: 5
256
  learning_rate: 2.0e-4
257
  freeze_encoder: true
258
  freeze_llm: false # train via LoRA
data/dataset.py CHANGED
@@ -389,7 +389,13 @@ class CXRInstructDataset(Dataset):
389
  input_ids: (L,) L ≤ cutoff_len
390
  labels: (L,) with -100 for prompt positions
391
  """
392
- full_text = prompt + " " + target
 
 
 
 
 
 
393
  prompt_encoded = self.tokenizer.encode(prompt, add_special_tokens=True)
394
  full_encoded = self.tokenizer.encode(
395
  full_text,
 
389
  input_ids: (L,) L ≤ cutoff_len
390
  labels: (L,) with -100 for prompt positions
391
  """
392
+ # Append EOS to target so model learns to terminate generation.
393
+ # LlamaTokenizer.encode(add_special_tokens=True) prepends <s> (BOS)
394
+ # but does NOT append </s> (EOS) by default. Without an EOS in the
395
+ # labels, the model never learns "report is done → stop" and at
396
+ # inference time it runs until max_new_tokens, looping on high-prob
397
+ # template phrases. Adding it once here costs ~1 token per sample.
398
+ full_text = prompt + " " + target + self.tokenizer.eos_token
399
  prompt_encoded = self.tokenizer.encode(prompt, add_special_tokens=True)
400
  full_encoded = self.tokenizer.encode(
401
  full_text,
model/cxr_vlm.py CHANGED
@@ -429,6 +429,7 @@ class CXRVisionLanguageModel(nn.Module):
429
  temperature: float = 0.1,
430
  do_sample: bool = False,
431
  num_beams: int = 1,
 
432
  ) -> List[str]:
433
  """
434
  Generate text for a batch of images and prompts.
@@ -467,6 +468,12 @@ class CXRVisionLanguageModel(nn.Module):
467
  )
468
 
469
  # Generate
 
 
 
 
 
 
470
  output_ids = self.llm.generate(
471
  inputs_embeds = inputs_embeds,
472
  attention_mask = attention_mask,
@@ -476,6 +483,7 @@ class CXRVisionLanguageModel(nn.Module):
476
  num_beams = num_beams,
477
  pad_token_id = self.tokenizer.pad_token_id,
478
  eos_token_id = self.tokenizer.eos_token_id,
 
479
  )
480
 
481
  # Decode — strip the prompt part
 
429
  temperature: float = 0.1,
430
  do_sample: bool = False,
431
  num_beams: int = 1,
432
+ **gen_kwargs,
433
  ) -> List[str]:
434
  """
435
  Generate text for a batch of images and prompts.
 
468
  )
469
 
470
  # Generate
471
+ # Extra HF generate kwargs (repetition_penalty, no_repeat_ngram_size,
472
+ # top_p, top_k, length_penalty, early_stopping, …) flow through
473
+ # gen_kwargs so we don't have to add them one by one. Anti-repetition
474
+ # knobs in particular fix the greedy-decoding loop where the model
475
+ # latches onto a high-prob template phrase (e.g. "The vertebral body
476
+ # heights are preserved.") and emits it until max_new_tokens.
477
  output_ids = self.llm.generate(
478
  inputs_embeds = inputs_embeds,
479
  attention_mask = attention_mask,
 
483
  num_beams = num_beams,
484
  pad_token_id = self.tokenizer.pad_token_id,
485
  eos_token_id = self.tokenizer.eos_token_id,
486
+ **gen_kwargs,
487
  )
488
 
489
  # Decode — strip the prompt part
scripts/_build_eos_test_notebook.py ADDED
@@ -0,0 +1,673 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Builds scripts/cxrvlm_eos_test.ipynb — A/B test for the EOS-in-labels fix.
3
+
4
+ Hypothesis (from dataset.py:392): training labels don't end with </s>, so the
5
+ model never learned to emit EOS, so generation runs to max_new_tokens and
6
+ loops on high-prob template phrases.
7
+
8
+ Test design (cheap — ~30-45 min on L4):
9
+ 1. Load existing trained checkpoint (run_id picked in selectors).
10
+ 2. Pick 5 test images + their GT findings.
11
+ 3. PHASE A — generate on the 5 images with several settings, record:
12
+ avg_gen_tokens, hit_max_rate, distinct_sentence_ratio,
13
+ sample outputs.
14
+ 4. Mini-FT for 1 epoch on 100 train samples using a dataset SUBCLASS that
15
+ appends `tokenizer.eos_token` to every target (the only change).
16
+ 5. PHASE B — re-generate on the SAME 5 images with the SAME settings.
17
+ 6. Print before/after comparison table + side-by-side text.
18
+
19
+ Interpretation:
20
+ - If hit_max_rate drops sharply and avg_gen_tokens decreases → EOS hypothesis
21
+ confirmed dominant (the model CAN learn EOS in ~100 LoRA steps).
22
+ - If barely changes → another factor (greedy bias, prompt mismatch, quant
23
+ noise) is more dominant than EOS-in-labels.
24
+
25
+ Run from repo root:
26
+ python scripts/_build_eos_test_notebook.py
27
+ """
28
+
29
+ from pathlib import Path
30
+ import nbformat as nbf
31
+
32
+
33
+ def md(text: str):
34
+ return nbf.v4.new_markdown_cell(text)
35
+
36
+
37
+ def code(text: str):
38
+ return nbf.v4.new_code_cell(text)
39
+
40
+
41
+ cells = []
42
+
43
+ # ─── Title ──────────────────────────────────────────────────────────────────
44
+ cells.append(md("""# CXR-VLM — EOS Fix A/B Test
45
+
46
+ **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.
47
+
48
+ **Test (~30-45 min on L4, ~60-90 min on T4):**
49
+ 1. Load the existing trained checkpoint.
50
+ 2. Pick 5 test images + their GT findings text.
51
+ 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).
52
+ 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.
53
+ 5. **PHASE B (AFTER)** — re-generate the same 5 images with the same settings. Recompute metrics.
54
+ 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.
55
+
56
+ Run this notebook standalone; it pulls code + checkpoint + a small dataset slice from HF.
57
+ """))
58
+
59
+ # ─── Selectors ──────────────────────────────────────────────────────────────
60
+ cells.append(md("## 0. Selectors"))
61
+ cells.append(code("""# ── Platform ─────────────────────────────────────────────────────
62
+ PLATFORM = 'colab' # 'kaggle' | 'colab' | 'lightning' | 'gcp' | 'local'
63
+
64
+ # ── Source repos ─────────────────────────────────────────────────
65
+ HF_USER = 'hieu3636'
66
+ HF_CODE_REPO = f'{HF_USER}/cxr-vlm-code'
67
+ HF_RUNS_REPO = f'{HF_USER}/cxr-vlm-runs'
68
+ HF_DATA_REPO = f'{HF_USER}/cxr-vlm-data'
69
+
70
+ # ── Which trained run to start from ──────────────────────────────
71
+ RUN_ID = 'MIMIC-CXR_resized_run_1'
72
+ CKPT_PICK = 'best' # 'best' | 'last'
73
+
74
+ # ── Test setup ───────────────────────────────────────────────────
75
+ NUM_TEST_IMAGES = 5 # generate before+after on these
76
+ NUM_TRAIN_SAMPLES = 100 # mini-FT budget
77
+ TASK = 'findings' # which task to test on
78
+ MAX_NEW_TOKENS = 300 # generation cap (proxy for "didn't EOS")
79
+
80
+ # ── Mini fine-tune hparams ───────────────────────────────────────
81
+ FT_LR = 2e-5
82
+ FT_BATCH_SIZE = 2 # keep tiny — L4 has 24GB but model is 4-bit + LoRA
83
+ FT_EPOCHS = 1
84
+ FT_GRAD_ACCUM = 4 # effective batch = 8
85
+
86
+ # ── Generation settings (used IDENTICALLY in BEFORE and AFTER) ───
87
+ GEN_DO_SAMPLE = False # greedy → makes EOS effect very visible
88
+ GEN_NUM_BEAMS = 1 # NO beam search — isolates the EOS variable
89
+
90
+ assert PLATFORM in ('kaggle', 'colab', 'lightning', 'gcp', 'local')
91
+ print(f'PLATFORM={PLATFORM} RUN_ID={RUN_ID}/{CKPT_PICK}')
92
+ print(f'Test: {NUM_TEST_IMAGES} images FT: {NUM_TRAIN_SAMPLES} samples × {FT_EPOCHS} epoch(s)')
93
+ """))
94
+
95
+ # ─── Env + pip ──────────────────────────────────────────────────────────────
96
+ cells.append(md("## 1. Env + pip"))
97
+ cells.append(code("""import os
98
+ os.environ['CUDA_VISIBLE_DEVICES'] = '0'
99
+ os.environ['TOKENIZERS_PARALLELISM'] = 'false'
100
+ os.environ['BITSANDBYTES_NOWELCOME'] = '1'
101
+ os.environ['TRANSFORMERS_VERBOSITY'] = 'warning'
102
+ os.environ['PYTHONUNBUFFERED'] = '1'
103
+
104
+ import sys, shutil, subprocess, json, time, random, re
105
+ from pathlib import Path
106
+ """))
107
+
108
+ cells.append(code("""!pip uninstall -y -q torchao transformers bitsandbytes peft accelerate
109
+ !pip install -q -U bitsandbytes
110
+ !pip install -q \\
111
+ 'transformers>=4.46,<4.50' \\
112
+ 'peft>=0.13,<0.15' \\
113
+ 'accelerate>=1.0' \\
114
+ 'huggingface_hub>=0.27,<1.0' \\
115
+ omegaconf sentencepiece 'protobuf>=3.20' \\
116
+ pillow
117
+
118
+ import torch as _t
119
+ if _t.cuda.is_available() and _t.cuda.get_device_capability(0) >= (8, 0):
120
+ print('[pip] Ampere+/Ada -> flash-attn install (may take 5-10 min)')
121
+ !pip install -q flash-attn --no-build-isolation 2>&1 | tail -5
122
+ else:
123
+ print('[pip] T4/V100 -> skipping flash-attn')
124
+ """))
125
+
126
+ cells.append(code("""import torch, transformers, peft, huggingface_hub, httpx
127
+ print('torch :', torch.__version__, '| cuda:', torch.cuda.is_available())
128
+ print('transformers:', transformers.__version__)
129
+ print('peft :', peft.__version__)
130
+
131
+ # httpx 0.28+ shim
132
+ def _patch_httpx():
133
+ if tuple(int(x) for x in httpx.__version__.split('.')[:2]) < (0, 28):
134
+ return
135
+ if getattr(httpx.Client, '_cxr_vlm_compat_patched', False):
136
+ return
137
+ def _make(orig):
138
+ def patched(self, *args, **kwargs):
139
+ if 'allow_redirects' in kwargs:
140
+ kwargs['follow_redirects'] = kwargs.pop('allow_redirects')
141
+ kwargs.pop('proxies', None)
142
+ return orig(self, *args, **kwargs)
143
+ return patched
144
+ for cls in (httpx.Client, httpx.AsyncClient):
145
+ for m in ('request', 'get', 'head', 'post', 'put', 'patch', 'delete', 'options'):
146
+ if hasattr(cls, m):
147
+ setattr(cls, m, _make(getattr(cls, m)))
148
+ httpx.Client._cxr_vlm_compat_patched = True
149
+ _patch_httpx()
150
+
151
+ assert torch.cuda.is_available(), 'CUDA required'
152
+ _p = torch.cuda.get_device_properties(0)
153
+ print(f'GPU: {_p.name} ({_p.total_memory/1e9:.1f} GB)')
154
+ """))
155
+
156
+ # ─── Paths + code/checkpoint/data pull ──────────────────────────────────────
157
+ cells.append(md("## 2. Paths + pull code + checkpoint + data slice"))
158
+ cells.append(code("""# WORK + HF_TOKEN
159
+ if PLATFORM == 'kaggle':
160
+ from kaggle_secrets import UserSecretsClient
161
+ os.environ['HF_TOKEN'] = UserSecretsClient().get_secret('HF_TOKEN')
162
+ WORK = Path('/kaggle/working')
163
+ elif PLATFORM == 'colab':
164
+ from google.colab import userdata
165
+ os.environ['HF_TOKEN'] = userdata.get('HF_TOKEN')
166
+ WORK = Path('/content')
167
+ elif PLATFORM == 'lightning':
168
+ WORK = Path('/teamspace/studios/this_studio')
169
+ elif PLATFORM == 'gcp':
170
+ for c in (Path('/home/jupyter'), Path('/workspace')):
171
+ if c.exists() or os.access(c.parent, os.W_OK):
172
+ WORK = c; break
173
+ else:
174
+ WORK = Path.home() / 'cxr-vlm-work'
175
+ else:
176
+ WORK = Path.home() / 'cxr-vlm-work'
177
+ WORK.mkdir(parents=True, exist_ok=True)
178
+ assert os.environ.get('HF_TOKEN'), 'HF_TOKEN missing in platform secrets'
179
+
180
+ from huggingface_hub import snapshot_download, hf_hub_download
181
+
182
+ print('Pulling code …')
183
+ CODE_SRC = Path(snapshot_download(
184
+ repo_id=HF_CODE_REPO, repo_type='model',
185
+ token=os.environ['HF_TOKEN'],
186
+ local_dir=str(WORK / 'cxr-vlm-code'),
187
+ ))
188
+ PROJECT = WORK / 'cxr_vlm'
189
+ if CODE_SRC.resolve() != PROJECT.resolve() and not PROJECT.exists():
190
+ shutil.copytree(CODE_SRC, PROJECT)
191
+ os.chdir(PROJECT)
192
+ sys.path.insert(0, str(PROJECT))
193
+ print('PROJECT =', PROJECT)
194
+ """))
195
+
196
+ cells.append(code("""# Pull checkpoint (configs + stage2/{CKPT_PICK})
197
+ RUN_PULL_ROOT = WORK / 'run_pull'
198
+ RUN_PULL_ROOT.mkdir(parents=True, exist_ok=True)
199
+
200
+ print(f'Pulling {RUN_ID}/stage2/{CKPT_PICK} from {HF_RUNS_REPO} …')
201
+ snapshot_download(
202
+ repo_id=HF_RUNS_REPO, repo_type='model',
203
+ token=os.environ['HF_TOKEN'],
204
+ allow_patterns=[
205
+ f'{RUN_ID}/configs/**',
206
+ f'{RUN_ID}/run_meta.json',
207
+ f'{RUN_ID}/stage2/{CKPT_PICK}/**',
208
+ ],
209
+ local_dir=str(RUN_PULL_ROOT),
210
+ )
211
+ RUN_DIR_PULLED = RUN_PULL_ROOT / RUN_ID
212
+ CKPT_DIR_PULLED = RUN_DIR_PULLED / 'stage2' / CKPT_PICK
213
+ assert (CKPT_DIR_PULLED / 'checkpoint_projection.pt').is_file()
214
+ assert (CKPT_DIR_PULLED / 'checkpoint_lora' / 'adapter_config.json').is_file()
215
+ print('checkpoint OK')
216
+
217
+ SAVED_MODEL_CFG = RUN_DIR_PULLED / 'configs' / 'model_config.yaml'
218
+ SAVED_TRAIN_CFG = RUN_DIR_PULLED / 'configs' / 'train_config.yaml'
219
+ """))
220
+
221
+ cells.append(code("""# Pull a thin dataset slice: manifests + instruct JSON + 1 image tar shard.
222
+ # 1 shard ≈ a few thousand resized JPGs, far more than we need.
223
+ import tarfile
224
+
225
+ DATA_SRC = WORK / 'data_src'
226
+ DATA_DIR = DATA_SRC / 'MIMIC-CXR_resized'
227
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
228
+
229
+ # Metadata (CSV manifests + instruct JSONs)
230
+ print('Pulling manifests + instruct JSONs …')
231
+ snapshot_download(
232
+ repo_id=HF_DATA_REPO, repo_type='dataset',
233
+ token=os.environ['HF_TOKEN'],
234
+ allow_patterns=[
235
+ 'MIMIC-CXR_resized/*.csv',
236
+ 'MIMIC-CXR_resized/*.json',
237
+ 'MIMIC-CXR_resized/*.txt',
238
+ ],
239
+ local_dir=str(DATA_SRC),
240
+ )
241
+
242
+ # List tar shards on HF and pull the smallest one (usually train shard 0)
243
+ from huggingface_hub import HfApi
244
+ api = HfApi(token=os.environ['HF_TOKEN'])
245
+ all_files = api.list_repo_files(repo_id=HF_DATA_REPO, repo_type='dataset')
246
+ shards = sorted(f for f in all_files
247
+ if f.startswith('MIMIC-CXR_resized/') and f.endswith('.tar'))
248
+ assert shards, 'No tar shards found on HF data repo.'
249
+
250
+ # Prefer a train shard for FT samples
251
+ train_shard = next((s for s in shards if 'train' in s.lower()), shards[0])
252
+ print(f'Pulling 1 tar shard: {train_shard}')
253
+ shard_path = Path(hf_hub_download(
254
+ repo_id=HF_DATA_REPO, repo_type='dataset',
255
+ filename=train_shard, token=os.environ['HF_TOKEN'],
256
+ local_dir=str(DATA_SRC),
257
+ ))
258
+ with tarfile.open(shard_path) as t:
259
+ t.extractall(DATA_DIR)
260
+ shard_path.unlink(missing_ok=True)
261
+ print(f'Data ready under {DATA_DIR}')
262
+ print('Top-level entries:', sorted(p.name for p in DATA_DIR.iterdir())[:10])
263
+ """))
264
+
265
+ # ─── Build configs + load model ─────────────────────────────────────────────
266
+ cells.append(md("## 3. GPU profile + build configs + load model"))
267
+ cells.append(code("""import torch
268
+ _p = torch.cuda.get_device_properties(0)
269
+ _cap = (_p.major, _p.minor)
270
+ _bf16_ok = torch.cuda.is_bf16_supported()
271
+ _fa2_ok = _cap >= (8, 0)
272
+ _fa2_installed = False
273
+ if _fa2_ok:
274
+ try:
275
+ import flash_attn; _fa2_installed = True
276
+ except Exception:
277
+ pass
278
+
279
+ PROFILE = dict(
280
+ torch_dtype = 'bfloat16' if _bf16_ok else 'float16',
281
+ bnb_4bit_compute_dtype = 'bfloat16' if _bf16_ok else 'float16',
282
+ attn_implementation = 'flash_attention_2' if (_fa2_ok and _fa2_installed) else 'sdpa',
283
+ )
284
+ print(f'GPU={_p.name} cap=sm_{_cap[0]}{_cap[1]} bf16={_bf16_ok} FA2={_fa2_installed}')
285
+ print(f'-> dtype={PROFILE["torch_dtype"]} attn={PROFILE["attn_implementation"]}')
286
+ """))
287
+
288
+ cells.append(code("""from omegaconf import OmegaConf
289
+
290
+ model_cfg = OmegaConf.load(SAVED_MODEL_CFG) if SAVED_MODEL_CFG.is_file() \\
291
+ else OmegaConf.load(PROJECT / 'configs' / 'model_config.yaml')
292
+ train_cfg = OmegaConf.load(SAVED_TRAIN_CFG) if SAVED_TRAIN_CFG.is_file() \\
293
+ else OmegaConf.load(PROJECT / 'configs' / 'train_config.yaml')
294
+
295
+ # 4-bit Vicuna + profile
296
+ model_cfg.llm.load_in_4bit = True
297
+ model_cfg.llm.load_in_8bit = False
298
+ model_cfg.llm.attn_implementation = PROFILE['attn_implementation']
299
+ model_cfg.llm.torch_dtype = PROFILE['torch_dtype']
300
+ model_cfg.llm.bnb_4bit_compute_dtype = PROFILE['bnb_4bit_compute_dtype']
301
+ model_cfg.llm.bnb_4bit_quant_type = 'nf4'
302
+ model_cfg.llm.bnb_4bit_use_double_quant = True
303
+ # Keep grad checkpointing ON during the mini-FT — saves VRAM, irrelevant for gen.
304
+ model_cfg.llm.gradient_checkpointing = True
305
+
306
+ # CheXpert classifier off — not needed for this test
307
+ model_cfg.chexpert_classifier.enabled = False
308
+ print('configs ready')
309
+ """))
310
+
311
+ cells.append(code("""import time
312
+ from model import CXRVisionLanguageModel
313
+ from model.rad_dino import BioViLTEncoder
314
+ from utils.checkpoint import load_checkpoint
315
+
316
+ print('[1/3] Building model … (cold cache: 5-9 min)')
317
+ t0 = time.time()
318
+ model = CXRVisionLanguageModel(model_cfg)
319
+ print(f' built in {time.time()-t0:.1f}s')
320
+
321
+ print(f'[2/3] Loading checkpoint from {CKPT_DIR_PULLED} …')
322
+ t0 = time.time()
323
+ # CRITICAL: pass the DIRECTORY, not the .pt file (load_checkpoint splits suffix
324
+ # off the stem; passing checkpoint_projection.pt silently skips both).
325
+ load_checkpoint(model, str(CKPT_DIR_PULLED))
326
+ print(f' loaded in {time.time()-t0:.1f}s')
327
+
328
+ print('[3/3] cuda + eval()')
329
+ model = model.to('cuda')
330
+ model.eval()
331
+ TRANSFORM = BioViLTEncoder.get_transform('val')
332
+ print(f'VRAM: {torch.cuda.memory_allocated()/1e9:.2f} GB')
333
+ """))
334
+
335
+ # ─── Build test set ─────────────────────────────────────────────────────────
336
+ cells.append(md("""## 4. Build the test set (5 images with GT findings)
337
+
338
+ We sample test images from the instruct JSON that was used at training time. We need:
339
+ 1. An `image_path` that resolves under `DATA_DIR` (so the file is on disk after our 1-shard pull).
340
+ 2. A non-empty `target` to compare against qualitatively.
341
+ 3. `task == TASK` (default `'findings'`)."""))
342
+
343
+ cells.append(code("""from utils.dataset_resolver import resolve_dataset_spec
344
+
345
+ # Point the config at our pulled data dir, then let the resolver pick the right
346
+ # instruct JSON (it auto-builds if missing, matching the report/image mode that
347
+ # the trained model expects).
348
+ train_cfg.data.dataset_name = 'MIMIC-CXR_resized'
349
+ train_cfg.data.mimic_cxr_resized.root = str(DATA_DIR)
350
+
351
+ spec = resolve_dataset_spec(train_cfg)
352
+ INSTRUCT_JSON = spec.instruct_json
353
+ IMAGE_ROOT = Path(spec.image_root)
354
+ print(f'report_mode={spec.report_mode} image_mode={spec.image_mode}')
355
+ print('instruct JSON:', INSTRUCT_JSON)
356
+ print('image_root :', IMAGE_ROOT)
357
+
358
+ all_entries = json.load(open(INSTRUCT_JSON))
359
+ print(f'{len(all_entries):,} total entries')
360
+ """))
361
+
362
+ cells.append(code("""# Filter to entries whose image is actually on disk (we only pulled 1 shard).
363
+ def _img_present(entry):
364
+ p = entry.get('image_path') or (entry.get('image_paths') or [None])[0]
365
+ return p and (IMAGE_ROOT / p).is_file()
366
+
367
+ train_pool = [e for e in all_entries
368
+ if e.get('split') == 'train' and e.get('task') == TASK
369
+ and e.get('target') and _img_present(e)]
370
+ test_pool = [e for e in all_entries
371
+ if e.get('split') in ('test', 'validate')
372
+ and e.get('task') == TASK
373
+ and e.get('target') and _img_present(e)]
374
+ # If test set isn't covered by our single shard, fall back to held-out train.
375
+ if len(test_pool) < NUM_TEST_IMAGES:
376
+ print(f'(only {len(test_pool)} test/val entries in this shard; '
377
+ f'using held-out train entries for the test set)')
378
+ held_out = train_pool[-NUM_TEST_IMAGES:]
379
+ train_pool = train_pool[:-NUM_TEST_IMAGES]
380
+ test_pool = held_out
381
+
382
+ random.seed(0)
383
+ random.shuffle(train_pool)
384
+ TRAIN_ENTRIES = train_pool[:NUM_TRAIN_SAMPLES]
385
+ TEST_ENTRIES = test_pool[:NUM_TEST_IMAGES]
386
+
387
+ print(f'TRAIN_ENTRIES: {len(TRAIN_ENTRIES)} TEST_ENTRIES: {len(TEST_ENTRIES)}')
388
+ assert len(TRAIN_ENTRIES) >= 10 and len(TEST_ENTRIES) >= 3, \\
389
+ 'Not enough samples in this shard — pull a second shard.'
390
+
391
+ for i, e in enumerate(TEST_ENTRIES):
392
+ print(f'\\n[{i}] {e["image_path"]}')
393
+ print(f' GT ({len(e["target"].split())} words): {e["target"][:160]}…')
394
+ """))
395
+
396
+ # ─── Metrics ────────────────────────────────────────────────────────────────
397
+ cells.append(md("""## 5. Metrics helper
398
+
399
+ Three signals to track:
400
+
401
+ - **`avg_gen_tokens`** — mean output length in tokens. If model never emits EOS, this saturates near `MAX_NEW_TOKENS`.
402
+ - **`hit_max_rate`** — fraction of samples where output length ≥ `MAX_NEW_TOKENS - 5`. Direct proxy for "didn't emit EOS".
403
+ - **`distinct_sentence_ratio`** — (# distinct sentences) / (total sentences). 1.0 = no repeats, < 0.5 = heavy loop."""))
404
+
405
+ cells.append(code("""def split_sentences(text: str):
406
+ return [s.strip() for s in re.split(r'(?<=[.!?])\\s+', text.strip()) if s.strip()]
407
+
408
+ def measure_outputs(outputs, max_new_tokens, tokenizer):
409
+ n = len(outputs)
410
+ if n == 0:
411
+ return {}
412
+ lengths = [len(tokenizer.encode(o, add_special_tokens=False)) for o in outputs]
413
+ sent_counts, distinct_ratios = [], []
414
+ for o in outputs:
415
+ ss = split_sentences(o)
416
+ if not ss:
417
+ sent_counts.append(0); distinct_ratios.append(1.0); continue
418
+ # Normalize whitespace + de-id tokens for dedup
419
+ norm = [re.sub(r'_+|\\s+', ' ', s.lower()) for s in ss]
420
+ sent_counts.append(len(ss))
421
+ distinct_ratios.append(len(set(norm)) / len(norm))
422
+ return dict(
423
+ n = n,
424
+ avg_gen_tokens = sum(lengths) / n,
425
+ max_gen_tokens = max(lengths),
426
+ hit_max_rate = sum(1 for L in lengths if L >= max_new_tokens - 5) / n,
427
+ avg_sentences = sum(sent_counts) / n,
428
+ distinct_sentence_ratio = sum(distinct_ratios) / n,
429
+ )
430
+
431
+ def fmt_metrics(label, m, mnt):
432
+ print(f'{label:<10s}'
433
+ f' avg_tok={m["avg_gen_tokens"]:6.1f}'
434
+ f' max_tok={m["max_gen_tokens"]:4d}'
435
+ f' hit_max%={m["hit_max_rate"]*100:5.1f}'
436
+ f' sentences={m["avg_sentences"]:4.1f}'
437
+ f' distinct_sent%={m["distinct_sentence_ratio"]*100:5.1f}'
438
+ f' (cap={mnt})')
439
+ """))
440
+
441
+ # ─── PHASE A — BEFORE ───────────────────────────────────────────────────────
442
+ cells.append(md("""## 6. PHASE A — BEFORE fine-tune
443
+
444
+ 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."""))
445
+
446
+ cells.append(code("""from PIL import Image
447
+ from data.prompt_templates import (
448
+ build_findings_prompt, build_impression_prompt,
449
+ build_report_prompt, build_vqa_prompt,
450
+ )
451
+
452
+ def _build_prompt(task, structured_findings=None, question=None):
453
+ return {
454
+ 'findings': lambda: build_findings_prompt(structured_findings, randomize=False),
455
+ 'impression': lambda: build_impression_prompt(structured_findings, randomize=False),
456
+ 'report': lambda: build_report_prompt(structured_findings, randomize=False),
457
+ 'vqa': lambda: build_vqa_prompt(question, structured_findings),
458
+ }[task]()
459
+
460
+ @torch.no_grad()
461
+ def generate_on_test_set(entries, label, max_new_tokens=MAX_NEW_TOKENS):
462
+ model.eval()
463
+ outs = []
464
+ for e in entries:
465
+ img = Image.open(IMAGE_ROOT / e['image_path']).convert('RGB')
466
+ img_t = TRANSFORM(img).unsqueeze(0).to('cuda')
467
+ prompt = _build_prompt(e['task'])
468
+ out = model.generate(
469
+ images = img_t,
470
+ prompts = [prompt],
471
+ max_new_tokens = max_new_tokens,
472
+ temperature = 1.0,
473
+ do_sample = GEN_DO_SAMPLE,
474
+ num_beams = GEN_NUM_BEAMS,
475
+ )[0]
476
+ outs.append(out)
477
+ metrics = measure_outputs(outs, max_new_tokens, model.tokenizer)
478
+ fmt_metrics(label, metrics, max_new_tokens)
479
+ return outs, metrics
480
+
481
+ print('Generating BEFORE …')
482
+ BEFORE_OUTS, BEFORE_METRICS = generate_on_test_set(TEST_ENTRIES, 'BEFORE')
483
+ """))
484
+
485
+ cells.append(code("""# Show 2 example outputs side-by-side with GT
486
+ for i in range(min(2, len(TEST_ENTRIES))):
487
+ print('═' * 80)
488
+ print(f'Image: {TEST_ENTRIES[i]["image_path"]}')
489
+ print('-' * 80, '\\nGT:'); print(TEST_ENTRIES[i]['target'])
490
+ print('-' * 80, '\\nBEFORE generation:'); print(BEFORE_OUTS[i])
491
+ print()
492
+ """))
493
+
494
+ # ─── Mini fine-tune with EOS fix ────────────────────────────────────────────
495
+ cells.append(md("""## 7. Mini fine-tune — the only change is appending EOS to targets
496
+
497
+ 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.
498
+
499
+ 100 samples × 1 epoch with grad_accum=4, batch=2 → ~12-13 optimizer steps. ~5-10 minutes on L4."""))
500
+
501
+ cells.append(code("""from data.dataset import CXRInstructDataset
502
+ from data.collator import CXRDataCollator
503
+ from torch.utils.data import Subset, DataLoader
504
+
505
+ class CXRInstructDataset_EOS(CXRInstructDataset):
506
+ '''Same dataset as production, but targets get </s> appended.'''
507
+ def _tokenize_with_labels(self, prompt: str, target: str):
508
+ full_text = prompt + ' ' + target + self.tokenizer.eos_token
509
+ prompt_encoded = self.tokenizer.encode(prompt, add_special_tokens=True)
510
+ full_encoded = self.tokenizer.encode(
511
+ full_text,
512
+ add_special_tokens = True,
513
+ max_length = self.cutoff_len,
514
+ truncation = True,
515
+ )
516
+ input_ids = torch.tensor(full_encoded, dtype=torch.long)
517
+ labels = input_ids.clone()
518
+ labels[: min(len(prompt_encoded), self.cutoff_len)] = -100
519
+ return input_ids, labels
520
+
521
+ # Sanity-check: confirm the override actually appends EOS
522
+ _eos_id = model.tokenizer.eos_token_id
523
+ print('EOS token id =', _eos_id, ' token =', repr(model.tokenizer.eos_token))
524
+ """))
525
+
526
+ cells.append(code("""# Build train dataset using the SAME instruct JSON; restrict to our 100 entries.
527
+ # Trick: write a temp JSON with just TRAIN_ENTRIES so we don't change CXRInstructDataset filter logic.
528
+ import tempfile
529
+
530
+ tmp_json = WORK / 'tmp_train_subset.json'
531
+ tmp_json.write_text(json.dumps(TRAIN_ENTRIES))
532
+
533
+ ft_dataset = CXRInstructDataset_EOS(
534
+ data_path = str(tmp_json),
535
+ image_root = str(IMAGE_ROOT),
536
+ tokenizer = model.tokenizer,
537
+ transform = BioViLTEncoder.get_transform('train'),
538
+ task = TASK,
539
+ split = 'train',
540
+ cutoff_len = 512,
541
+ )
542
+ print(f'FT dataset: {len(ft_dataset)} samples')
543
+
544
+ collator = CXRDataCollator(model.tokenizer.pad_token_id)
545
+ loader = DataLoader(
546
+ ft_dataset,
547
+ batch_size = FT_BATCH_SIZE,
548
+ shuffle = True,
549
+ collate_fn = collator,
550
+ num_workers = 0,
551
+ )
552
+ print(f'Steps per epoch: {len(loader)} (× {FT_EPOCHS} epoch(s), grad_accum={FT_GRAD_ACCUM})')
553
+ """))
554
+
555
+ cells.append(code("""# Mini fine-tune loop. Trains projection + LoRA (everything that has requires_grad).
556
+ from torch.optim import AdamW
557
+
558
+ trainable = [p for p in model.parameters() if p.requires_grad]
559
+ n_trainable = sum(p.numel() for p in trainable)
560
+ print(f'Trainable params: {n_trainable/1e6:.1f}M')
561
+
562
+ optimizer = AdamW(trainable, lr=FT_LR)
563
+
564
+ model.train()
565
+ # QLoRA needs grad checkpointing kwarg
566
+ if hasattr(model.llm, 'gradient_checkpointing_enable'):
567
+ model.llm.gradient_checkpointing_enable(gradient_checkpointing_kwargs={'use_reentrant': False})
568
+
569
+ step = 0
570
+ optimizer.zero_grad()
571
+ t0 = time.time()
572
+ for epoch in range(FT_EPOCHS):
573
+ for batch_idx, batch in enumerate(loader):
574
+ batch = {k: (v.cuda(non_blocking=True) if torch.is_tensor(v) else v)
575
+ for k, v in batch.items()}
576
+ out = model(**{k: batch[k] for k in ('images', 'input_ids', 'attention_mask', 'labels')
577
+ if k in batch})
578
+ loss = out['loss'] if isinstance(out, dict) else out.loss
579
+ (loss / FT_GRAD_ACCUM).backward()
580
+
581
+ if (batch_idx + 1) % FT_GRAD_ACCUM == 0 or batch_idx + 1 == len(loader):
582
+ optimizer.step()
583
+ optimizer.zero_grad()
584
+ step += 1
585
+
586
+ if batch_idx % 4 == 0:
587
+ elapsed = time.time() - t0
588
+ print(f'epoch {epoch+1} batch {batch_idx+1}/{len(loader)} '
589
+ f'loss={loss.item():.3f} ({elapsed:.0f}s elapsed)')
590
+
591
+ print(f'\\n✔ Mini-FT done in {time.time()-t0:.0f}s, {step} optimizer steps')
592
+ """))
593
+
594
+ # ─── PHASE B — AFTER ────────────────────────────────────────────────────────
595
+ cells.append(md("## 8. PHASE B — AFTER fine-tune (same images, same settings)"))
596
+
597
+ cells.append(code("""# Disable grad checkpointing for cleaner generate
598
+ if hasattr(model.llm, 'gradient_checkpointing_disable'):
599
+ model.llm.gradient_checkpointing_disable()
600
+
601
+ print('Generating AFTER …')
602
+ AFTER_OUTS, AFTER_METRICS = generate_on_test_set(TEST_ENTRIES, 'AFTER')
603
+ """))
604
+
605
+ cells.append(code("""# Show same 2 examples side-by-side: GT vs BEFORE vs AFTER
606
+ for i in range(min(2, len(TEST_ENTRIES))):
607
+ print('═' * 80)
608
+ print(f'Image: {TEST_ENTRIES[i]["image_path"]}')
609
+ print('-' * 80, '\\nGT:'); print(TEST_ENTRIES[i]['target'])
610
+ print('-' * 80, '\\nBEFORE:'); print(BEFORE_OUTS[i])
611
+ print('-' * 80, '\\nAFTER :'); print(AFTER_OUTS[i])
612
+ print()
613
+ """))
614
+
615
+ # ─── Compare ────────────────────────────────────────────────────────────────
616
+ cells.append(md("""## 9. Verdict
617
+
618
+ | Signal | Hypothesis-confirmed direction |
619
+ |---|---|
620
+ | `avg_gen_tokens` | **down** (model stops earlier) |
621
+ | `hit_max_rate` | **down sharply** — fewer samples truncated at cap |
622
+ | `distinct_sentence_ratio` | **up** (less looping) |
623
+
624
+ 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."""))
625
+
626
+ cells.append(code("""print('═' * 80)
627
+ print('SUMMARY')
628
+ print('═' * 80)
629
+ fmt_metrics('BEFORE', BEFORE_METRICS, MAX_NEW_TOKENS)
630
+ fmt_metrics('AFTER ', AFTER_METRICS, MAX_NEW_TOKENS)
631
+ print()
632
+
633
+ deltas = {
634
+ 'avg_gen_tokens ': AFTER_METRICS['avg_gen_tokens'] - BEFORE_METRICS['avg_gen_tokens'],
635
+ 'hit_max_rate ': AFTER_METRICS['hit_max_rate'] - BEFORE_METRICS['hit_max_rate'],
636
+ 'distinct_sentence_ratio': AFTER_METRICS['distinct_sentence_ratio'] - BEFORE_METRICS['distinct_sentence_ratio'],
637
+ }
638
+ print('Δ AFTER − BEFORE:')
639
+ for k, v in deltas.items():
640
+ arrow = '↓' if v < 0 else ('↑' if v > 0 else '·')
641
+ print(f' {k}: {v:+.3f} {arrow}')
642
+
643
+ print()
644
+ # Heuristic verdict
645
+ ok_len = AFTER_METRICS['avg_gen_tokens'] < BEFORE_METRICS['avg_gen_tokens'] - 20
646
+ ok_hit = AFTER_METRICS['hit_max_rate'] < BEFORE_METRICS['hit_max_rate'] - 0.15
647
+ ok_dist = AFTER_METRICS['distinct_sentence_ratio'] > BEFORE_METRICS['distinct_sentence_ratio'] + 0.10
648
+ n_signals = sum([ok_len, ok_hit, ok_dist])
649
+
650
+ if n_signals >= 2:
651
+ print(f'✔ {n_signals}/3 signals confirm — EOS-in-labels appears to be the dominant cause.')
652
+ print(' Recommendation: apply the fix in dataset.py and retrain (full run).')
653
+ elif n_signals == 1:
654
+ print(f'~ {n_signals}/3 signals — EOS contributes but is not alone.')
655
+ print(' Likely co-factors: greedy decoding bias, quantization noise, boilerplate overfit.')
656
+ print(' Recommendation: retrain with the fix AND switch eval to beam search (num_beams=4).')
657
+ else:
658
+ print('✘ 0/3 signals — EOS alone is NOT the dominant cause.')
659
+ print(' Try: (a) more FT samples or epochs, (b) bigger LR, (c) compare beam vs greedy on')
660
+ print(' the BEFORE model — if beam fixes it, the issue is decoding not training.')
661
+ """))
662
+
663
+ # ─── Build + write ──────────────────────────────────────────────────────────
664
+ nb = nbf.v4.new_notebook()
665
+ nb.cells = cells
666
+ nb.metadata = {
667
+ 'kernelspec': {'name': 'python3', 'display_name': 'Python 3'},
668
+ 'language_info': {'name': 'python'},
669
+ }
670
+
671
+ OUT = Path(__file__).resolve().parent / 'cxrvlm_eos_test.ipynb'
672
+ nbf.write(nb, OUT)
673
+ print(f'Wrote {OUT} ({len(cells)} cells)')
scripts/cxrvlm_colab_inference.ipynb CHANGED
@@ -55,10 +55,15 @@
55
  "CKPT_PICK = 'best' # 'best' | 'last'\n",
56
  "\n",
57
  "# ── Generation defaults (override per-call later if desired) ─────\n",
58
- "MAX_NEW_TOKENS = 300\n",
59
- "TEMPERATURE = 0.1\n",
60
- "DO_SAMPLE = False # greedy by default — deterministic\n",
61
- "NUM_BEAMS = 1\n",
 
 
 
 
 
62
  "\n",
63
  "assert PLATFORM in ('kaggle', 'colab', 'lightning', 'gcp', 'local')\n",
64
  "assert CKPT_PICK in ('best', 'last')\n",
@@ -491,10 +496,12 @@
491
  " task: str,\n",
492
  " question: Optional[str] = None,\n",
493
  " structured_findings: Optional[str] = None,\n",
494
- " max_new_tokens: int = None,\n",
495
- " temperature: float = None,\n",
496
- " do_sample: bool = None,\n",
497
- " num_beams: int = None,\n",
 
 
498
  ") -> str:\n",
499
  " '''Run the model on one image.\n",
500
  "\n",
@@ -516,12 +523,14 @@
516
  " prompt = build_vqa_prompt(question, sf)\n",
517
  "\n",
518
  " out = model.generate(\n",
519
- " images = _to_tensor(image),\n",
520
- " prompts = [prompt],\n",
521
- " max_new_tokens = max_new_tokens if max_new_tokens is not None else MAX_NEW_TOKENS,\n",
522
- " temperature = temperature if temperature is not None else TEMPERATURE,\n",
523
- " do_sample = do_sample if do_sample is not None else DO_SAMPLE,\n",
524
- " num_beams = num_beams if num_beams is not None else NUM_BEAMS,\n",
 
 
525
  " )\n",
526
  " return out[0]\n",
527
  "\n",
 
55
  "CKPT_PICK = 'best' # 'best' | 'last'\n",
56
  "\n",
57
  "# ── Generation defaults (override per-call later if desired) ─────\n",
58
+ "MAX_NEW_TOKENS = 200 # report-length ceiling — caps loops too\n",
59
+ "TEMPERATURE = 0.1\n",
60
+ "DO_SAMPLE = False # greedy by default — deterministic\n",
61
+ "NUM_BEAMS = 1\n",
62
+ "# Anti-repetition (greedy decoding on a clinical LM tends to loop on template\n",
63
+ "# phrases — '___ at 11:30 a.m.', 'vertebral body heights preserved' — bumping\n",
64
+ "# these two breaks the loop without losing determinism).\n",
65
+ "REPETITION_PENALTY = 1.3\n",
66
+ "NO_REPEAT_NGRAM_SIZE = 4\n",
67
  "\n",
68
  "assert PLATFORM in ('kaggle', 'colab', 'lightning', 'gcp', 'local')\n",
69
  "assert CKPT_PICK in ('best', 'last')\n",
 
496
  " task: str,\n",
497
  " question: Optional[str] = None,\n",
498
  " structured_findings: Optional[str] = None,\n",
499
+ " max_new_tokens: int = None,\n",
500
+ " temperature: float = None,\n",
501
+ " do_sample: bool = None,\n",
502
+ " num_beams: int = None,\n",
503
+ " repetition_penalty: float = None,\n",
504
+ " no_repeat_ngram_size: int = None,\n",
505
  ") -> str:\n",
506
  " '''Run the model on one image.\n",
507
  "\n",
 
523
  " prompt = build_vqa_prompt(question, sf)\n",
524
  "\n",
525
  " out = model.generate(\n",
526
+ " images = _to_tensor(image),\n",
527
+ " prompts = [prompt],\n",
528
+ " max_new_tokens = max_new_tokens if max_new_tokens is not None else MAX_NEW_TOKENS,\n",
529
+ " temperature = temperature if temperature is not None else TEMPERATURE,\n",
530
+ " do_sample = do_sample if do_sample is not None else DO_SAMPLE,\n",
531
+ " num_beams = num_beams if num_beams is not None else NUM_BEAMS,\n",
532
+ " repetition_penalty = repetition_penalty if repetition_penalty is not None else REPETITION_PENALTY,\n",
533
+ " no_repeat_ngram_size = no_repeat_ngram_size if no_repeat_ngram_size is not None else NO_REPEAT_NGRAM_SIZE,\n",
534
  " )\n",
535
  " return out[0]\n",
536
  "\n",
scripts/cxrvlm_eos_test.ipynb ADDED
@@ -0,0 +1,840 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "ad40c0fb",
6
+ "metadata": {},
7
+ "source": [
8
+ "# CXR-VLM — EOS Fix A/B Test\n",
9
+ "\n",
10
+ "**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.\n",
11
+ "\n",
12
+ "**Test (~30-45 min on L4, ~60-90 min on T4):**\n",
13
+ "1. Load the existing trained checkpoint.\n",
14
+ "2. Pick 5 test images + their GT findings text.\n",
15
+ "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).\n",
16
+ "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.\n",
17
+ "5. **PHASE B (AFTER)** — re-generate the same 5 images with the same settings. Recompute metrics.\n",
18
+ "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.\n",
19
+ "\n",
20
+ "Run this notebook standalone; it pulls code + checkpoint + a small dataset slice from HF.\n"
21
+ ]
22
+ },
23
+ {
24
+ "cell_type": "markdown",
25
+ "id": "6a1882eb",
26
+ "metadata": {},
27
+ "source": [
28
+ "## 0. Selectors"
29
+ ]
30
+ },
31
+ {
32
+ "cell_type": "code",
33
+ "execution_count": null,
34
+ "id": "47da9294",
35
+ "metadata": {},
36
+ "outputs": [],
37
+ "source": [
38
+ "# ── Platform ─────────────────────────────────────────────────────\n",
39
+ "PLATFORM = 'colab' # 'kaggle' | 'colab' | 'lightning' | 'gcp' | 'local'\n",
40
+ "\n",
41
+ "# ── Source repos ─────────────────────────────────────────────────\n",
42
+ "HF_USER = 'hieu3636'\n",
43
+ "HF_CODE_REPO = f'{HF_USER}/cxr-vlm-code'\n",
44
+ "HF_RUNS_REPO = f'{HF_USER}/cxr-vlm-runs'\n",
45
+ "HF_DATA_REPO = f'{HF_USER}/cxr-vlm-data'\n",
46
+ "\n",
47
+ "# ── Which trained run to start from ──────────────────────────────\n",
48
+ "RUN_ID = 'MIMIC-CXR_resized_run_1'\n",
49
+ "CKPT_PICK = 'best' # 'best' | 'last'\n",
50
+ "\n",
51
+ "# ── Test setup ───────────────────────────────────────────────────\n",
52
+ "NUM_TEST_IMAGES = 5 # generate before+after on these\n",
53
+ "NUM_TRAIN_SAMPLES = 100 # mini-FT budget\n",
54
+ "TASK = 'findings' # which task to test on\n",
55
+ "MAX_NEW_TOKENS = 300 # generation cap (proxy for \"didn't EOS\")\n",
56
+ "\n",
57
+ "# ── Mini fine-tune hparams ───────────────────────────────────────\n",
58
+ "FT_LR = 2e-5\n",
59
+ "FT_BATCH_SIZE = 2 # keep tiny — L4 has 24GB but model is 4-bit + LoRA\n",
60
+ "FT_EPOCHS = 1\n",
61
+ "FT_GRAD_ACCUM = 4 # effective batch = 8\n",
62
+ "\n",
63
+ "# ── Generation settings (used IDENTICALLY in BEFORE and AFTER) ───\n",
64
+ "GEN_DO_SAMPLE = False # greedy → makes EOS effect very visible\n",
65
+ "GEN_NUM_BEAMS = 1 # NO beam search — isolates the EOS variable\n",
66
+ "\n",
67
+ "assert PLATFORM in ('kaggle', 'colab', 'lightning', 'gcp', 'local')\n",
68
+ "print(f'PLATFORM={PLATFORM} RUN_ID={RUN_ID}/{CKPT_PICK}')\n",
69
+ "print(f'Test: {NUM_TEST_IMAGES} images FT: {NUM_TRAIN_SAMPLES} samples × {FT_EPOCHS} epoch(s)')\n"
70
+ ]
71
+ },
72
+ {
73
+ "cell_type": "markdown",
74
+ "id": "1c9e3253",
75
+ "metadata": {},
76
+ "source": [
77
+ "## 1. Env + pip"
78
+ ]
79
+ },
80
+ {
81
+ "cell_type": "code",
82
+ "execution_count": null,
83
+ "id": "93eee293",
84
+ "metadata": {},
85
+ "outputs": [],
86
+ "source": [
87
+ "import os\n",
88
+ "os.environ['CUDA_VISIBLE_DEVICES'] = '0'\n",
89
+ "os.environ['TOKENIZERS_PARALLELISM'] = 'false'\n",
90
+ "os.environ['BITSANDBYTES_NOWELCOME'] = '1'\n",
91
+ "os.environ['TRANSFORMERS_VERBOSITY'] = 'warning'\n",
92
+ "os.environ['PYTHONUNBUFFERED'] = '1'\n",
93
+ "\n",
94
+ "import sys, shutil, subprocess, json, time, random, re\n",
95
+ "from pathlib import Path\n"
96
+ ]
97
+ },
98
+ {
99
+ "cell_type": "code",
100
+ "execution_count": null,
101
+ "id": "b7d23c73",
102
+ "metadata": {},
103
+ "outputs": [],
104
+ "source": [
105
+ "!pip uninstall -y -q torchao transformers bitsandbytes peft accelerate\n",
106
+ "!pip install -q -U bitsandbytes\n",
107
+ "!pip install -q \\\n",
108
+ " 'transformers>=4.46,<4.50' \\\n",
109
+ " 'peft>=0.13,<0.15' \\\n",
110
+ " 'accelerate>=1.0' \\\n",
111
+ " 'huggingface_hub>=0.27,<1.0' \\\n",
112
+ " omegaconf sentencepiece 'protobuf>=3.20' \\\n",
113
+ " pillow\n",
114
+ "\n",
115
+ "import torch as _t\n",
116
+ "if _t.cuda.is_available() and _t.cuda.get_device_capability(0) >= (8, 0):\n",
117
+ " print('[pip] Ampere+/Ada -> flash-attn install (may take 5-10 min)')\n",
118
+ " !pip install -q flash-attn --no-build-isolation 2>&1 | tail -5\n",
119
+ "else:\n",
120
+ " print('[pip] T4/V100 -> skipping flash-attn')\n"
121
+ ]
122
+ },
123
+ {
124
+ "cell_type": "code",
125
+ "execution_count": null,
126
+ "id": "3d4fc533",
127
+ "metadata": {},
128
+ "outputs": [],
129
+ "source": [
130
+ "import torch, transformers, peft, huggingface_hub, httpx\n",
131
+ "print('torch :', torch.__version__, '| cuda:', torch.cuda.is_available())\n",
132
+ "print('transformers:', transformers.__version__)\n",
133
+ "print('peft :', peft.__version__)\n",
134
+ "\n",
135
+ "# httpx 0.28+ shim\n",
136
+ "def _patch_httpx():\n",
137
+ " if tuple(int(x) for x in httpx.__version__.split('.')[:2]) < (0, 28):\n",
138
+ " return\n",
139
+ " if getattr(httpx.Client, '_cxr_vlm_compat_patched', False):\n",
140
+ " return\n",
141
+ " def _make(orig):\n",
142
+ " def patched(self, *args, **kwargs):\n",
143
+ " if 'allow_redirects' in kwargs:\n",
144
+ " kwargs['follow_redirects'] = kwargs.pop('allow_redirects')\n",
145
+ " kwargs.pop('proxies', None)\n",
146
+ " return orig(self, *args, **kwargs)\n",
147
+ " return patched\n",
148
+ " for cls in (httpx.Client, httpx.AsyncClient):\n",
149
+ " for m in ('request', 'get', 'head', 'post', 'put', 'patch', 'delete', 'options'):\n",
150
+ " if hasattr(cls, m):\n",
151
+ " setattr(cls, m, _make(getattr(cls, m)))\n",
152
+ " httpx.Client._cxr_vlm_compat_patched = True\n",
153
+ "_patch_httpx()\n",
154
+ "\n",
155
+ "assert torch.cuda.is_available(), 'CUDA required'\n",
156
+ "_p = torch.cuda.get_device_properties(0)\n",
157
+ "print(f'GPU: {_p.name} ({_p.total_memory/1e9:.1f} GB)')\n"
158
+ ]
159
+ },
160
+ {
161
+ "cell_type": "markdown",
162
+ "id": "370928ae",
163
+ "metadata": {},
164
+ "source": [
165
+ "## 2. Paths + pull code + checkpoint + data slice"
166
+ ]
167
+ },
168
+ {
169
+ "cell_type": "code",
170
+ "execution_count": null,
171
+ "id": "788cc5c2",
172
+ "metadata": {},
173
+ "outputs": [],
174
+ "source": [
175
+ "# WORK + HF_TOKEN\n",
176
+ "if PLATFORM == 'kaggle':\n",
177
+ " from kaggle_secrets import UserSecretsClient\n",
178
+ " os.environ['HF_TOKEN'] = UserSecretsClient().get_secret('HF_TOKEN')\n",
179
+ " WORK = Path('/kaggle/working')\n",
180
+ "elif PLATFORM == 'colab':\n",
181
+ " from google.colab import userdata\n",
182
+ " os.environ['HF_TOKEN'] = userdata.get('HF_TOKEN')\n",
183
+ " WORK = Path('/content')\n",
184
+ "elif PLATFORM == 'lightning':\n",
185
+ " WORK = Path('/teamspace/studios/this_studio')\n",
186
+ "elif PLATFORM == 'gcp':\n",
187
+ " for c in (Path('/home/jupyter'), Path('/workspace')):\n",
188
+ " if c.exists() or os.access(c.parent, os.W_OK):\n",
189
+ " WORK = c; break\n",
190
+ " else:\n",
191
+ " WORK = Path.home() / 'cxr-vlm-work'\n",
192
+ "else:\n",
193
+ " WORK = Path.home() / 'cxr-vlm-work'\n",
194
+ "WORK.mkdir(parents=True, exist_ok=True)\n",
195
+ "assert os.environ.get('HF_TOKEN'), 'HF_TOKEN missing in platform secrets'\n",
196
+ "\n",
197
+ "from huggingface_hub import snapshot_download, hf_hub_download\n",
198
+ "\n",
199
+ "print('Pulling code …')\n",
200
+ "CODE_SRC = Path(snapshot_download(\n",
201
+ " repo_id=HF_CODE_REPO, repo_type='model',\n",
202
+ " token=os.environ['HF_TOKEN'],\n",
203
+ " local_dir=str(WORK / 'cxr-vlm-code'),\n",
204
+ "))\n",
205
+ "PROJECT = WORK / 'cxr_vlm'\n",
206
+ "if CODE_SRC.resolve() != PROJECT.resolve() and not PROJECT.exists():\n",
207
+ " shutil.copytree(CODE_SRC, PROJECT)\n",
208
+ "os.chdir(PROJECT)\n",
209
+ "sys.path.insert(0, str(PROJECT))\n",
210
+ "print('PROJECT =', PROJECT)\n"
211
+ ]
212
+ },
213
+ {
214
+ "cell_type": "code",
215
+ "execution_count": null,
216
+ "id": "a12ff7f7",
217
+ "metadata": {},
218
+ "outputs": [],
219
+ "source": [
220
+ "# Pull checkpoint (configs + stage2/{CKPT_PICK})\n",
221
+ "RUN_PULL_ROOT = WORK / 'run_pull'\n",
222
+ "RUN_PULL_ROOT.mkdir(parents=True, exist_ok=True)\n",
223
+ "\n",
224
+ "print(f'Pulling {RUN_ID}/stage2/{CKPT_PICK} from {HF_RUNS_REPO} …')\n",
225
+ "snapshot_download(\n",
226
+ " repo_id=HF_RUNS_REPO, repo_type='model',\n",
227
+ " token=os.environ['HF_TOKEN'],\n",
228
+ " allow_patterns=[\n",
229
+ " f'{RUN_ID}/configs/**',\n",
230
+ " f'{RUN_ID}/run_meta.json',\n",
231
+ " f'{RUN_ID}/stage2/{CKPT_PICK}/**',\n",
232
+ " ],\n",
233
+ " local_dir=str(RUN_PULL_ROOT),\n",
234
+ ")\n",
235
+ "RUN_DIR_PULLED = RUN_PULL_ROOT / RUN_ID\n",
236
+ "CKPT_DIR_PULLED = RUN_DIR_PULLED / 'stage2' / CKPT_PICK\n",
237
+ "assert (CKPT_DIR_PULLED / 'checkpoint_projection.pt').is_file()\n",
238
+ "assert (CKPT_DIR_PULLED / 'checkpoint_lora' / 'adapter_config.json').is_file()\n",
239
+ "print('checkpoint OK')\n",
240
+ "\n",
241
+ "SAVED_MODEL_CFG = RUN_DIR_PULLED / 'configs' / 'model_config.yaml'\n",
242
+ "SAVED_TRAIN_CFG = RUN_DIR_PULLED / 'configs' / 'train_config.yaml'\n"
243
+ ]
244
+ },
245
+ {
246
+ "cell_type": "code",
247
+ "execution_count": null,
248
+ "id": "ad8e2aa3",
249
+ "metadata": {},
250
+ "outputs": [],
251
+ "source": [
252
+ "# Pull a thin dataset slice: manifests + instruct JSON + 1 image tar shard.\n",
253
+ "# 1 shard ≈ a few thousand resized JPGs, far more than we need.\n",
254
+ "import tarfile\n",
255
+ "\n",
256
+ "DATA_SRC = WORK / 'data_src'\n",
257
+ "DATA_DIR = DATA_SRC / 'MIMIC-CXR_resized'\n",
258
+ "DATA_DIR.mkdir(parents=True, exist_ok=True)\n",
259
+ "\n",
260
+ "# Metadata (CSV manifests + instruct JSONs)\n",
261
+ "print('Pulling manifests + instruct JSONs …')\n",
262
+ "snapshot_download(\n",
263
+ " repo_id=HF_DATA_REPO, repo_type='dataset',\n",
264
+ " token=os.environ['HF_TOKEN'],\n",
265
+ " allow_patterns=[\n",
266
+ " 'MIMIC-CXR_resized/*.csv',\n",
267
+ " 'MIMIC-CXR_resized/*.json',\n",
268
+ " 'MIMIC-CXR_resized/*.txt',\n",
269
+ " ],\n",
270
+ " local_dir=str(DATA_SRC),\n",
271
+ ")\n",
272
+ "\n",
273
+ "# List tar shards on HF and pull the smallest one (usually train shard 0)\n",
274
+ "from huggingface_hub import HfApi\n",
275
+ "api = HfApi(token=os.environ['HF_TOKEN'])\n",
276
+ "all_files = api.list_repo_files(repo_id=HF_DATA_REPO, repo_type='dataset')\n",
277
+ "shards = sorted(f for f in all_files\n",
278
+ " if f.startswith('MIMIC-CXR_resized/') and f.endswith('.tar'))\n",
279
+ "assert shards, 'No tar shards found on HF data repo.'\n",
280
+ "\n",
281
+ "# Prefer a train shard for FT samples\n",
282
+ "train_shard = next((s for s in shards if 'train' in s.lower()), shards[0])\n",
283
+ "print(f'Pulling 1 tar shard: {train_shard}')\n",
284
+ "shard_path = Path(hf_hub_download(\n",
285
+ " repo_id=HF_DATA_REPO, repo_type='dataset',\n",
286
+ " filename=train_shard, token=os.environ['HF_TOKEN'],\n",
287
+ " local_dir=str(DATA_SRC),\n",
288
+ "))\n",
289
+ "with tarfile.open(shard_path) as t:\n",
290
+ " t.extractall(DATA_DIR)\n",
291
+ "shard_path.unlink(missing_ok=True)\n",
292
+ "print(f'Data ready under {DATA_DIR}')\n",
293
+ "print('Top-level entries:', sorted(p.name for p in DATA_DIR.iterdir())[:10])\n"
294
+ ]
295
+ },
296
+ {
297
+ "cell_type": "markdown",
298
+ "id": "6c3666fd",
299
+ "metadata": {},
300
+ "source": [
301
+ "## 3. GPU profile + build configs + load model"
302
+ ]
303
+ },
304
+ {
305
+ "cell_type": "code",
306
+ "execution_count": null,
307
+ "id": "d6ac34e9",
308
+ "metadata": {},
309
+ "outputs": [],
310
+ "source": [
311
+ "import torch\n",
312
+ "_p = torch.cuda.get_device_properties(0)\n",
313
+ "_cap = (_p.major, _p.minor)\n",
314
+ "_bf16_ok = torch.cuda.is_bf16_supported()\n",
315
+ "_fa2_ok = _cap >= (8, 0)\n",
316
+ "_fa2_installed = False\n",
317
+ "if _fa2_ok:\n",
318
+ " try:\n",
319
+ " import flash_attn; _fa2_installed = True\n",
320
+ " except Exception:\n",
321
+ " pass\n",
322
+ "\n",
323
+ "PROFILE = dict(\n",
324
+ " torch_dtype = 'bfloat16' if _bf16_ok else 'float16',\n",
325
+ " bnb_4bit_compute_dtype = 'bfloat16' if _bf16_ok else 'float16',\n",
326
+ " attn_implementation = 'flash_attention_2' if (_fa2_ok and _fa2_installed) else 'sdpa',\n",
327
+ ")\n",
328
+ "print(f'GPU={_p.name} cap=sm_{_cap[0]}{_cap[1]} bf16={_bf16_ok} FA2={_fa2_installed}')\n",
329
+ "print(f'-> dtype={PROFILE[\"torch_dtype\"]} attn={PROFILE[\"attn_implementation\"]}')\n"
330
+ ]
331
+ },
332
+ {
333
+ "cell_type": "code",
334
+ "execution_count": null,
335
+ "id": "7072c0a6",
336
+ "metadata": {},
337
+ "outputs": [],
338
+ "source": [
339
+ "from omegaconf import OmegaConf\n",
340
+ "\n",
341
+ "model_cfg = OmegaConf.load(SAVED_MODEL_CFG) if SAVED_MODEL_CFG.is_file() \\\n",
342
+ " else OmegaConf.load(PROJECT / 'configs' / 'model_config.yaml')\n",
343
+ "train_cfg = OmegaConf.load(SAVED_TRAIN_CFG) if SAVED_TRAIN_CFG.is_file() \\\n",
344
+ " else OmegaConf.load(PROJECT / 'configs' / 'train_config.yaml')\n",
345
+ "\n",
346
+ "# 4-bit Vicuna + profile\n",
347
+ "model_cfg.llm.load_in_4bit = True\n",
348
+ "model_cfg.llm.load_in_8bit = False\n",
349
+ "model_cfg.llm.attn_implementation = PROFILE['attn_implementation']\n",
350
+ "model_cfg.llm.torch_dtype = PROFILE['torch_dtype']\n",
351
+ "model_cfg.llm.bnb_4bit_compute_dtype = PROFILE['bnb_4bit_compute_dtype']\n",
352
+ "model_cfg.llm.bnb_4bit_quant_type = 'nf4'\n",
353
+ "model_cfg.llm.bnb_4bit_use_double_quant = True\n",
354
+ "# Keep grad checkpointing ON during the mini-FT — saves VRAM, irrelevant for gen.\n",
355
+ "model_cfg.llm.gradient_checkpointing = True\n",
356
+ "\n",
357
+ "# CheXpert classifier off — not needed for this test\n",
358
+ "model_cfg.chexpert_classifier.enabled = False\n",
359
+ "print('configs ready')\n"
360
+ ]
361
+ },
362
+ {
363
+ "cell_type": "code",
364
+ "execution_count": null,
365
+ "id": "38a9fc47",
366
+ "metadata": {},
367
+ "outputs": [],
368
+ "source": [
369
+ "import time\n",
370
+ "from model import CXRVisionLanguageModel\n",
371
+ "from model.rad_dino import BioViLTEncoder\n",
372
+ "from utils.checkpoint import load_checkpoint\n",
373
+ "\n",
374
+ "print('[1/3] Building model … (cold cache: 5-9 min)')\n",
375
+ "t0 = time.time()\n",
376
+ "model = CXRVisionLanguageModel(model_cfg)\n",
377
+ "print(f' built in {time.time()-t0:.1f}s')\n",
378
+ "\n",
379
+ "print(f'[2/3] Loading checkpoint from {CKPT_DIR_PULLED} …')\n",
380
+ "t0 = time.time()\n",
381
+ "# CRITICAL: pass the DIRECTORY, not the .pt file (load_checkpoint splits suffix\n",
382
+ "# off the stem; passing checkpoint_projection.pt silently skips both).\n",
383
+ "load_checkpoint(model, str(CKPT_DIR_PULLED))\n",
384
+ "print(f' loaded in {time.time()-t0:.1f}s')\n",
385
+ "\n",
386
+ "print('[3/3] cuda + eval()')\n",
387
+ "model = model.to('cuda')\n",
388
+ "model.eval()\n",
389
+ "TRANSFORM = BioViLTEncoder.get_transform('val')\n",
390
+ "print(f'VRAM: {torch.cuda.memory_allocated()/1e9:.2f} GB')\n"
391
+ ]
392
+ },
393
+ {
394
+ "cell_type": "markdown",
395
+ "id": "ca325a5a",
396
+ "metadata": {},
397
+ "source": [
398
+ "## 4. Build the test set (5 images with GT findings)\n",
399
+ "\n",
400
+ "We sample test images from the instruct JSON that was used at training time. We need:\n",
401
+ "1. An `image_path` that resolves under `DATA_DIR` (so the file is on disk after our 1-shard pull).\n",
402
+ "2. A non-empty `target` to compare against qualitatively.\n",
403
+ "3. `task == TASK` (default `'findings'`)."
404
+ ]
405
+ },
406
+ {
407
+ "cell_type": "code",
408
+ "execution_count": null,
409
+ "id": "783d8217",
410
+ "metadata": {},
411
+ "outputs": [],
412
+ "source": [
413
+ "from utils.dataset_resolver import resolve_dataset_spec\n",
414
+ "\n",
415
+ "# Point the config at our pulled data dir, then let the resolver pick the right\n",
416
+ "# instruct JSON (it auto-builds if missing, matching the report/image mode that\n",
417
+ "# the trained model expects).\n",
418
+ "train_cfg.data.dataset_name = 'MIMIC-CXR_resized'\n",
419
+ "train_cfg.data.mimic_cxr_resized.root = str(DATA_DIR)\n",
420
+ "\n",
421
+ "spec = resolve_dataset_spec(train_cfg)\n",
422
+ "INSTRUCT_JSON = spec.instruct_json\n",
423
+ "IMAGE_ROOT = Path(spec.image_root)\n",
424
+ "print(f'report_mode={spec.report_mode} image_mode={spec.image_mode}')\n",
425
+ "print('instruct JSON:', INSTRUCT_JSON)\n",
426
+ "print('image_root :', IMAGE_ROOT)\n",
427
+ "\n",
428
+ "all_entries = json.load(open(INSTRUCT_JSON))\n",
429
+ "print(f'{len(all_entries):,} total entries')\n"
430
+ ]
431
+ },
432
+ {
433
+ "cell_type": "code",
434
+ "execution_count": null,
435
+ "id": "3c2b8f2c",
436
+ "metadata": {},
437
+ "outputs": [],
438
+ "source": [
439
+ "# Filter to entries whose image is actually on disk (we only pulled 1 shard).\n",
440
+ "def _img_present(entry):\n",
441
+ " p = entry.get('image_path') or (entry.get('image_paths') or [None])[0]\n",
442
+ " return p and (IMAGE_ROOT / p).is_file()\n",
443
+ "\n",
444
+ "train_pool = [e for e in all_entries\n",
445
+ " if e.get('split') == 'train' and e.get('task') == TASK\n",
446
+ " and e.get('target') and _img_present(e)]\n",
447
+ "test_pool = [e for e in all_entries\n",
448
+ " if e.get('split') in ('test', 'validate')\n",
449
+ " and e.get('task') == TASK\n",
450
+ " and e.get('target') and _img_present(e)]\n",
451
+ "# If test set isn't covered by our single shard, fall back to held-out train.\n",
452
+ "if len(test_pool) < NUM_TEST_IMAGES:\n",
453
+ " print(f'(only {len(test_pool)} test/val entries in this shard; '\n",
454
+ " f'using held-out train entries for the test set)')\n",
455
+ " held_out = train_pool[-NUM_TEST_IMAGES:]\n",
456
+ " train_pool = train_pool[:-NUM_TEST_IMAGES]\n",
457
+ " test_pool = held_out\n",
458
+ "\n",
459
+ "random.seed(0)\n",
460
+ "random.shuffle(train_pool)\n",
461
+ "TRAIN_ENTRIES = train_pool[:NUM_TRAIN_SAMPLES]\n",
462
+ "TEST_ENTRIES = test_pool[:NUM_TEST_IMAGES]\n",
463
+ "\n",
464
+ "print(f'TRAIN_ENTRIES: {len(TRAIN_ENTRIES)} TEST_ENTRIES: {len(TEST_ENTRIES)}')\n",
465
+ "assert len(TRAIN_ENTRIES) >= 10 and len(TEST_ENTRIES) >= 3, \\\n",
466
+ " 'Not enough samples in this shard — pull a second shard.'\n",
467
+ "\n",
468
+ "for i, e in enumerate(TEST_ENTRIES):\n",
469
+ " print(f'\\n[{i}] {e[\"image_path\"]}')\n",
470
+ " print(f' GT ({len(e[\"target\"].split())} words): {e[\"target\"][:160]}…')\n"
471
+ ]
472
+ },
473
+ {
474
+ "cell_type": "markdown",
475
+ "id": "ea68dfb1",
476
+ "metadata": {},
477
+ "source": [
478
+ "## 5. Metrics helper\n",
479
+ "\n",
480
+ "Three signals to track:\n",
481
+ "\n",
482
+ "- **`avg_gen_tokens`** — mean output length in tokens. If model never emits EOS, this saturates near `MAX_NEW_TOKENS`.\n",
483
+ "- **`hit_max_rate`** — fraction of samples where output length ≥ `MAX_NEW_TOKENS - 5`. Direct proxy for \"didn't emit EOS\".\n",
484
+ "- **`distinct_sentence_ratio`** — (# distinct sentences) / (total sentences). 1.0 = no repeats, < 0.5 = heavy loop."
485
+ ]
486
+ },
487
+ {
488
+ "cell_type": "code",
489
+ "execution_count": null,
490
+ "id": "6b6479cf",
491
+ "metadata": {},
492
+ "outputs": [],
493
+ "source": [
494
+ "def split_sentences(text: str):\n",
495
+ " return [s.strip() for s in re.split(r'(?<=[.!?])\\s+', text.strip()) if s.strip()]\n",
496
+ "\n",
497
+ "def measure_outputs(outputs, max_new_tokens, tokenizer):\n",
498
+ " n = len(outputs)\n",
499
+ " if n == 0:\n",
500
+ " return {}\n",
501
+ " lengths = [len(tokenizer.encode(o, add_special_tokens=False)) for o in outputs]\n",
502
+ " sent_counts, distinct_ratios = [], []\n",
503
+ " for o in outputs:\n",
504
+ " ss = split_sentences(o)\n",
505
+ " if not ss:\n",
506
+ " sent_counts.append(0); distinct_ratios.append(1.0); continue\n",
507
+ " # Normalize whitespace + de-id tokens for dedup\n",
508
+ " norm = [re.sub(r'_+|\\s+', ' ', s.lower()) for s in ss]\n",
509
+ " sent_counts.append(len(ss))\n",
510
+ " distinct_ratios.append(len(set(norm)) / len(norm))\n",
511
+ " return dict(\n",
512
+ " n = n,\n",
513
+ " avg_gen_tokens = sum(lengths) / n,\n",
514
+ " max_gen_tokens = max(lengths),\n",
515
+ " hit_max_rate = sum(1 for L in lengths if L >= max_new_tokens - 5) / n,\n",
516
+ " avg_sentences = sum(sent_counts) / n,\n",
517
+ " distinct_sentence_ratio = sum(distinct_ratios) / n,\n",
518
+ " )\n",
519
+ "\n",
520
+ "def fmt_metrics(label, m, mnt):\n",
521
+ " print(f'{label:<10s}'\n",
522
+ " f' avg_tok={m[\"avg_gen_tokens\"]:6.1f}'\n",
523
+ " f' max_tok={m[\"max_gen_tokens\"]:4d}'\n",
524
+ " f' hit_max%={m[\"hit_max_rate\"]*100:5.1f}'\n",
525
+ " f' sentences={m[\"avg_sentences\"]:4.1f}'\n",
526
+ " f' distinct_sent%={m[\"distinct_sentence_ratio\"]*100:5.1f}'\n",
527
+ " f' (cap={mnt})')\n"
528
+ ]
529
+ },
530
+ {
531
+ "cell_type": "markdown",
532
+ "id": "b8f16ed1",
533
+ "metadata": {},
534
+ "source": [
535
+ "## 6. PHASE A — BEFORE fine-tune\n",
536
+ "\n",
537
+ "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."
538
+ ]
539
+ },
540
+ {
541
+ "cell_type": "code",
542
+ "execution_count": null,
543
+ "id": "80e533c3",
544
+ "metadata": {},
545
+ "outputs": [],
546
+ "source": [
547
+ "from PIL import Image\n",
548
+ "from data.prompt_templates import (\n",
549
+ " build_findings_prompt, build_impression_prompt,\n",
550
+ " build_report_prompt, build_vqa_prompt,\n",
551
+ ")\n",
552
+ "\n",
553
+ "def _build_prompt(task, structured_findings=None, question=None):\n",
554
+ " return {\n",
555
+ " 'findings': lambda: build_findings_prompt(structured_findings, randomize=False),\n",
556
+ " 'impression': lambda: build_impression_prompt(structured_findings, randomize=False),\n",
557
+ " 'report': lambda: build_report_prompt(structured_findings, randomize=False),\n",
558
+ " 'vqa': lambda: build_vqa_prompt(question, structured_findings),\n",
559
+ " }[task]()\n",
560
+ "\n",
561
+ "@torch.no_grad()\n",
562
+ "def generate_on_test_set(entries, label, max_new_tokens=MAX_NEW_TOKENS):\n",
563
+ " model.eval()\n",
564
+ " outs = []\n",
565
+ " for e in entries:\n",
566
+ " img = Image.open(IMAGE_ROOT / e['image_path']).convert('RGB')\n",
567
+ " img_t = TRANSFORM(img).unsqueeze(0).to('cuda')\n",
568
+ " prompt = _build_prompt(e['task'])\n",
569
+ " out = model.generate(\n",
570
+ " images = img_t,\n",
571
+ " prompts = [prompt],\n",
572
+ " max_new_tokens = max_new_tokens,\n",
573
+ " temperature = 1.0,\n",
574
+ " do_sample = GEN_DO_SAMPLE,\n",
575
+ " num_beams = GEN_NUM_BEAMS,\n",
576
+ " )[0]\n",
577
+ " outs.append(out)\n",
578
+ " metrics = measure_outputs(outs, max_new_tokens, model.tokenizer)\n",
579
+ " fmt_metrics(label, metrics, max_new_tokens)\n",
580
+ " return outs, metrics\n",
581
+ "\n",
582
+ "print('Generating BEFORE …')\n",
583
+ "BEFORE_OUTS, BEFORE_METRICS = generate_on_test_set(TEST_ENTRIES, 'BEFORE')\n"
584
+ ]
585
+ },
586
+ {
587
+ "cell_type": "code",
588
+ "execution_count": null,
589
+ "id": "16f21302",
590
+ "metadata": {},
591
+ "outputs": [],
592
+ "source": [
593
+ "# Show 2 example outputs side-by-side with GT\n",
594
+ "for i in range(min(2, len(TEST_ENTRIES))):\n",
595
+ " print('═' * 80)\n",
596
+ " print(f'Image: {TEST_ENTRIES[i][\"image_path\"]}')\n",
597
+ " print('-' * 80, '\\nGT:'); print(TEST_ENTRIES[i]['target'])\n",
598
+ " print('-' * 80, '\\nBEFORE generation:'); print(BEFORE_OUTS[i])\n",
599
+ " print()\n"
600
+ ]
601
+ },
602
+ {
603
+ "cell_type": "markdown",
604
+ "id": "cf56b845",
605
+ "metadata": {},
606
+ "source": [
607
+ "## 7. Mini fine-tune — the only change is appending EOS to targets\n",
608
+ "\n",
609
+ "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.\n",
610
+ "\n",
611
+ "100 samples × 1 epoch with grad_accum=4, batch=2 → ~12-13 optimizer steps. ~5-10 minutes on L4."
612
+ ]
613
+ },
614
+ {
615
+ "cell_type": "code",
616
+ "execution_count": null,
617
+ "id": "3e34db1b",
618
+ "metadata": {},
619
+ "outputs": [],
620
+ "source": [
621
+ "from data.dataset import CXRInstructDataset\n",
622
+ "from data.collator import CXRDataCollator\n",
623
+ "from torch.utils.data import Subset, DataLoader\n",
624
+ "\n",
625
+ "class CXRInstructDataset_EOS(CXRInstructDataset):\n",
626
+ " '''Same dataset as production, but targets get </s> appended.'''\n",
627
+ " def _tokenize_with_labels(self, prompt: str, target: str):\n",
628
+ " full_text = prompt + ' ' + target + self.tokenizer.eos_token\n",
629
+ " prompt_encoded = self.tokenizer.encode(prompt, add_special_tokens=True)\n",
630
+ " full_encoded = self.tokenizer.encode(\n",
631
+ " full_text,\n",
632
+ " add_special_tokens = True,\n",
633
+ " max_length = self.cutoff_len,\n",
634
+ " truncation = True,\n",
635
+ " )\n",
636
+ " input_ids = torch.tensor(full_encoded, dtype=torch.long)\n",
637
+ " labels = input_ids.clone()\n",
638
+ " labels[: min(len(prompt_encoded), self.cutoff_len)] = -100\n",
639
+ " return input_ids, labels\n",
640
+ "\n",
641
+ "# Sanity-check: confirm the override actually appends EOS\n",
642
+ "_eos_id = model.tokenizer.eos_token_id\n",
643
+ "print('EOS token id =', _eos_id, ' token =', repr(model.tokenizer.eos_token))\n"
644
+ ]
645
+ },
646
+ {
647
+ "cell_type": "code",
648
+ "execution_count": null,
649
+ "id": "28d87bdb",
650
+ "metadata": {},
651
+ "outputs": [],
652
+ "source": [
653
+ "# Build train dataset using the SAME instruct JSON; restrict to our 100 entries.\n",
654
+ "# Trick: write a temp JSON with just TRAIN_ENTRIES so we don't change CXRInstructDataset filter logic.\n",
655
+ "import tempfile\n",
656
+ "\n",
657
+ "tmp_json = WORK / 'tmp_train_subset.json'\n",
658
+ "tmp_json.write_text(json.dumps(TRAIN_ENTRIES))\n",
659
+ "\n",
660
+ "ft_dataset = CXRInstructDataset_EOS(\n",
661
+ " data_path = str(tmp_json),\n",
662
+ " image_root = str(IMAGE_ROOT),\n",
663
+ " tokenizer = model.tokenizer,\n",
664
+ " transform = BioViLTEncoder.get_transform('train'),\n",
665
+ " task = TASK,\n",
666
+ " split = 'train',\n",
667
+ " cutoff_len = 512,\n",
668
+ ")\n",
669
+ "print(f'FT dataset: {len(ft_dataset)} samples')\n",
670
+ "\n",
671
+ "collator = CXRDataCollator(model.tokenizer.pad_token_id)\n",
672
+ "loader = DataLoader(\n",
673
+ " ft_dataset,\n",
674
+ " batch_size = FT_BATCH_SIZE,\n",
675
+ " shuffle = True,\n",
676
+ " collate_fn = collator,\n",
677
+ " num_workers = 0,\n",
678
+ ")\n",
679
+ "print(f'Steps per epoch: {len(loader)} (× {FT_EPOCHS} epoch(s), grad_accum={FT_GRAD_ACCUM})')\n"
680
+ ]
681
+ },
682
+ {
683
+ "cell_type": "code",
684
+ "execution_count": null,
685
+ "id": "f7175df4",
686
+ "metadata": {},
687
+ "outputs": [],
688
+ "source": [
689
+ "# Mini fine-tune loop. Trains projection + LoRA (everything that has requires_grad).\n",
690
+ "from torch.optim import AdamW\n",
691
+ "\n",
692
+ "trainable = [p for p in model.parameters() if p.requires_grad]\n",
693
+ "n_trainable = sum(p.numel() for p in trainable)\n",
694
+ "print(f'Trainable params: {n_trainable/1e6:.1f}M')\n",
695
+ "\n",
696
+ "optimizer = AdamW(trainable, lr=FT_LR)\n",
697
+ "\n",
698
+ "model.train()\n",
699
+ "# QLoRA needs grad checkpointing kwarg\n",
700
+ "if hasattr(model.llm, 'gradient_checkpointing_enable'):\n",
701
+ " model.llm.gradient_checkpointing_enable(gradient_checkpointing_kwargs={'use_reentrant': False})\n",
702
+ "\n",
703
+ "step = 0\n",
704
+ "optimizer.zero_grad()\n",
705
+ "t0 = time.time()\n",
706
+ "for epoch in range(FT_EPOCHS):\n",
707
+ " for batch_idx, batch in enumerate(loader):\n",
708
+ " batch = {k: (v.cuda(non_blocking=True) if torch.is_tensor(v) else v)\n",
709
+ " for k, v in batch.items()}\n",
710
+ " out = model(**{k: batch[k] for k in ('images', 'input_ids', 'attention_mask', 'labels')\n",
711
+ " if k in batch})\n",
712
+ " loss = out['loss'] if isinstance(out, dict) else out.loss\n",
713
+ " (loss / FT_GRAD_ACCUM).backward()\n",
714
+ "\n",
715
+ " if (batch_idx + 1) % FT_GRAD_ACCUM == 0 or batch_idx + 1 == len(loader):\n",
716
+ " optimizer.step()\n",
717
+ " optimizer.zero_grad()\n",
718
+ " step += 1\n",
719
+ "\n",
720
+ " if batch_idx % 4 == 0:\n",
721
+ " elapsed = time.time() - t0\n",
722
+ " print(f'epoch {epoch+1} batch {batch_idx+1}/{len(loader)} '\n",
723
+ " f'loss={loss.item():.3f} ({elapsed:.0f}s elapsed)')\n",
724
+ "\n",
725
+ "print(f'\\n✔ Mini-FT done in {time.time()-t0:.0f}s, {step} optimizer steps')\n"
726
+ ]
727
+ },
728
+ {
729
+ "cell_type": "markdown",
730
+ "id": "c7e18a80",
731
+ "metadata": {},
732
+ "source": [
733
+ "## 8. PHASE B — AFTER fine-tune (same images, same settings)"
734
+ ]
735
+ },
736
+ {
737
+ "cell_type": "code",
738
+ "execution_count": null,
739
+ "id": "43e2a5b8",
740
+ "metadata": {},
741
+ "outputs": [],
742
+ "source": [
743
+ "# Disable grad checkpointing for cleaner generate\n",
744
+ "if hasattr(model.llm, 'gradient_checkpointing_disable'):\n",
745
+ " model.llm.gradient_checkpointing_disable()\n",
746
+ "\n",
747
+ "print('Generating AFTER …')\n",
748
+ "AFTER_OUTS, AFTER_METRICS = generate_on_test_set(TEST_ENTRIES, 'AFTER')\n"
749
+ ]
750
+ },
751
+ {
752
+ "cell_type": "code",
753
+ "execution_count": null,
754
+ "id": "0212f1de",
755
+ "metadata": {},
756
+ "outputs": [],
757
+ "source": [
758
+ "# Show same 2 examples side-by-side: GT vs BEFORE vs AFTER\n",
759
+ "for i in range(min(2, len(TEST_ENTRIES))):\n",
760
+ " print('═' * 80)\n",
761
+ " print(f'Image: {TEST_ENTRIES[i][\"image_path\"]}')\n",
762
+ " print('-' * 80, '\\nGT:'); print(TEST_ENTRIES[i]['target'])\n",
763
+ " print('-' * 80, '\\nBEFORE:'); print(BEFORE_OUTS[i])\n",
764
+ " print('-' * 80, '\\nAFTER :'); print(AFTER_OUTS[i])\n",
765
+ " print()\n"
766
+ ]
767
+ },
768
+ {
769
+ "cell_type": "markdown",
770
+ "id": "3601f44b",
771
+ "metadata": {},
772
+ "source": [
773
+ "## 9. Verdict\n",
774
+ "\n",
775
+ "| Signal | Hypothesis-confirmed direction |\n",
776
+ "|---|---|\n",
777
+ "| `avg_gen_tokens` | **down** (model stops earlier) |\n",
778
+ "| `hit_max_rate` | **down sharply** — fewer samples truncated at cap |\n",
779
+ "| `distinct_sentence_ratio` | **up** (less looping) |\n",
780
+ "\n",
781
+ "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."
782
+ ]
783
+ },
784
+ {
785
+ "cell_type": "code",
786
+ "execution_count": null,
787
+ "id": "6cc53af9",
788
+ "metadata": {},
789
+ "outputs": [],
790
+ "source": [
791
+ "print('═' * 80)\n",
792
+ "print('SUMMARY')\n",
793
+ "print('═' * 80)\n",
794
+ "fmt_metrics('BEFORE', BEFORE_METRICS, MAX_NEW_TOKENS)\n",
795
+ "fmt_metrics('AFTER ', AFTER_METRICS, MAX_NEW_TOKENS)\n",
796
+ "print()\n",
797
+ "\n",
798
+ "deltas = {\n",
799
+ " 'avg_gen_tokens ': AFTER_METRICS['avg_gen_tokens'] - BEFORE_METRICS['avg_gen_tokens'],\n",
800
+ " 'hit_max_rate ': AFTER_METRICS['hit_max_rate'] - BEFORE_METRICS['hit_max_rate'],\n",
801
+ " 'distinct_sentence_ratio': AFTER_METRICS['distinct_sentence_ratio'] - BEFORE_METRICS['distinct_sentence_ratio'],\n",
802
+ "}\n",
803
+ "print('Δ AFTER − BEFORE:')\n",
804
+ "for k, v in deltas.items():\n",
805
+ " arrow = '↓' if v < 0 else ('↑' if v > 0 else '·')\n",
806
+ " print(f' {k}: {v:+.3f} {arrow}')\n",
807
+ "\n",
808
+ "print()\n",
809
+ "# Heuristic verdict\n",
810
+ "ok_len = AFTER_METRICS['avg_gen_tokens'] < BEFORE_METRICS['avg_gen_tokens'] - 20\n",
811
+ "ok_hit = AFTER_METRICS['hit_max_rate'] < BEFORE_METRICS['hit_max_rate'] - 0.15\n",
812
+ "ok_dist = AFTER_METRICS['distinct_sentence_ratio'] > BEFORE_METRICS['distinct_sentence_ratio'] + 0.10\n",
813
+ "n_signals = sum([ok_len, ok_hit, ok_dist])\n",
814
+ "\n",
815
+ "if n_signals >= 2:\n",
816
+ " print(f'✔ {n_signals}/3 signals confirm — EOS-in-labels appears to be the dominant cause.')\n",
817
+ " print(' Recommendation: apply the fix in dataset.py and retrain (full run).')\n",
818
+ "elif n_signals == 1:\n",
819
+ " print(f'~ {n_signals}/3 signals — EOS contributes but is not alone.')\n",
820
+ " print(' Likely co-factors: greedy decoding bias, quantization noise, boilerplate overfit.')\n",
821
+ " print(' Recommendation: retrain with the fix AND switch eval to beam search (num_beams=4).')\n",
822
+ "else:\n",
823
+ " print('✘ 0/3 signals — EOS alone is NOT the dominant cause.')\n",
824
+ " print(' Try: (a) more FT samples or epochs, (b) bigger LR, (c) compare beam vs greedy on')\n",
825
+ " print(' the BEFORE model — if beam fixes it, the issue is decoding not training.')\n"
826
+ ]
827
+ }
828
+ ],
829
+ "metadata": {
830
+ "kernelspec": {
831
+ "display_name": "Python 3",
832
+ "name": "python3"
833
+ },
834
+ "language_info": {
835
+ "name": "python"
836
+ }
837
+ },
838
+ "nbformat": 4,
839
+ "nbformat_minor": 5
840
+ }
scripts/vertex_job.yaml CHANGED
@@ -52,15 +52,15 @@ workerPoolSpecs:
52
  - name: IMAGE_MODE
53
  value: all_views_split
54
  - name: MODE
55
- value: resume
56
  - name: EXPLICIT_RUN_ID
57
- value: MIMIC-CXR_resized_run_1
58
  - name: HF_RUNS_REPO
59
  value: hieu3636/cxr-vlm-runs
60
  - name: S1_EPOCHS
61
- value: "2"
62
  - name: S2_EPOCHS
63
- value: "7"
64
 
65
  # Scheduling: SPOT = ~70% cheaper, may be preempted. Repo has auto-resume so safe.
66
  # Use STANDARD if you want guaranteed uninterrupted run (3.4× cost).
 
52
  - name: IMAGE_MODE
53
  value: all_views_split
54
  - name: MODE
55
+ value: fresh
56
  - name: EXPLICIT_RUN_ID
57
+ value: MIMIC-CXR_resized_run_3
58
  - name: HF_RUNS_REPO
59
  value: hieu3636/cxr-vlm-runs
60
  - name: S1_EPOCHS
61
+ value: "3"
62
  - name: S2_EPOCHS
63
+ value: "5"
64
 
65
  # Scheduling: SPOT = ~70% cheaper, may be preempted. Repo has auto-resume so safe.
66
  # Use STANDARD if you want guaranteed uninterrupted run (3.4× cost).