""" 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 , 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 `` 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 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)')