convitom commited on
Commit
5a3b157
·
1 Parent(s): aade474
data/mimic_cxr_resized_builder.py CHANGED
@@ -289,7 +289,37 @@ def build_mimic_cxr_resized_instruct_json(
289
  "structured_findings": structured})
290
 
291
  # ── Pass 2: optional VQA attach ─────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
  n_vqa = n_vqa_dropped = 0
 
 
293
  if vqa_dir:
294
  vqa_dir = Path(vqa_dir)
295
  for fname, split_label in _VQA_FILES:
@@ -297,10 +327,27 @@ def build_mimic_cxr_resized_instruct_json(
297
  if not vqa_file.is_file():
298
  print(f"[mimic_cxr_resized_builder] VQA missing: {vqa_file} — skipping {split_label}")
299
  continue
300
- for row in json.load(open(vqa_file, encoding="utf-8")):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
  img_path = str(row.get("image_path", "")).lstrip("/")
302
- if img_path not in image_index:
 
303
  n_vqa_dropped += 1
 
 
304
  continue
305
  ans = row.get("answer", [])
306
  answer = (", ".join(map(str, ans)) if isinstance(ans, list)
@@ -315,7 +362,7 @@ def build_mimic_cxr_resized_instruct_json(
315
  subj_str = f"p{subj}" if subj and not subj.startswith("p") else subj
316
  study_str = f"s{study}" if study and not study.startswith("s") else study
317
  samples.append({
318
- "image_path": img_path, "image_paths": None,
319
  "task": "vqa", "target": answer,
320
  "question": row["question"],
321
  "structured_findings": structured,
@@ -325,6 +372,21 @@ def build_mimic_cxr_resized_instruct_json(
325
  })
326
  n_vqa += 1
327
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  # ── Write ───────────────────���───────────────────────────────────────────
329
  output_path.parent.mkdir(parents=True, exist_ok=True)
330
  with open(output_path, "w", encoding="utf-8") as f:
 
289
  "structured_findings": structured})
290
 
291
  # ── Pass 2: optional VQA attach ─────────────────────────────────────────
292
+ # Match policy: VQA `image_path` and manifest `image_relpath` should be the
293
+ # same string, but historical subset-build scripts differ in whether the
294
+ # `files/` prefix is included. Build a normalized image_index that maps
295
+ # BOTH the original key and its files/-stripped variant back to the
296
+ # canonical manifest path. On a lookup miss, also try stripping/adding
297
+ # `files/` from the VQA path. This handles the three known formats:
298
+ # manifest "files/p10/...", vqa "files/p10/..." → exact match
299
+ # manifest "files/p10/...", vqa "p10/..." → +files/ on VQA
300
+ # manifest "p10/...", vqa "files/p10/..." → -files/ on VQA
301
+ def _strip_files(p: str) -> str:
302
+ return p[len("files/"):] if p.startswith("files/") else p
303
+
304
+ norm_index: Dict[str, str] = {}
305
+ for k in image_index:
306
+ norm_index[k] = k # exact
307
+ norm_index[_strip_files(k)] = k # without files/
308
+
309
+ def _resolve_vqa_path(p: str) -> Optional[str]:
310
+ if p in norm_index:
311
+ return norm_index[p]
312
+ stripped = _strip_files(p)
313
+ if stripped in norm_index:
314
+ return norm_index[stripped]
315
+ with_prefix = f"files/{p}" if not p.startswith("files/") else p
316
+ if with_prefix in norm_index:
317
+ return norm_index[with_prefix]
318
+ return None
319
+
320
  n_vqa = n_vqa_dropped = 0
321
+ _logged_diag = False
322
+ _dropped_samples: List[str] = [] # for diagnostic on total drop
323
  if vqa_dir:
324
  vqa_dir = Path(vqa_dir)
325
  for fname, split_label in _VQA_FILES:
 
327
  if not vqa_file.is_file():
328
  print(f"[mimic_cxr_resized_builder] VQA missing: {vqa_file} — skipping {split_label}")
329
  continue
330
+ rows = json.load(open(vqa_file, encoding="utf-8"))
331
+
332
+ # One-shot diagnostic — print sample paths from both sides so any
333
+ # remaining mismatch is immediately visible in the log.
334
+ if not _logged_diag:
335
+ _logged_diag = True
336
+ print(f"[mimic_cxr_resized_builder] VQA path-format diagnostic "
337
+ f"(file: {fname}):")
338
+ _idx_samples = list(image_index.keys())[:3]
339
+ _vqa_samples = [str(r.get("image_path", "")).lstrip("/")
340
+ for r in rows[:3]]
341
+ print(f" manifest image_relpath samples : {_idx_samples}")
342
+ print(f" vqa image_path samples : {_vqa_samples}")
343
+
344
+ for row in rows:
345
  img_path = str(row.get("image_path", "")).lstrip("/")
346
+ canonical = _resolve_vqa_path(img_path)
347
+ if canonical is None:
348
  n_vqa_dropped += 1
349
+ if len(_dropped_samples) < 10:
350
+ _dropped_samples.append(img_path)
351
  continue
352
  ans = row.get("answer", [])
353
  answer = (", ".join(map(str, ans)) if isinstance(ans, list)
 
362
  subj_str = f"p{subj}" if subj and not subj.startswith("p") else subj
363
  study_str = f"s{study}" if study and not study.startswith("s") else study
364
  samples.append({
365
+ "image_path": canonical, "image_paths": None,
366
  "task": "vqa", "target": answer,
367
  "question": row["question"],
368
  "structured_findings": structured,
 
372
  })
373
  n_vqa += 1
374
 
375
+ # Loud warning if ALL vqa rows were dropped — this almost always means a
376
+ # path-format mismatch the heuristic above didn't cover, e.g. different
377
+ # file extension (.dcm vs .jpg) or different study/dicom id encoding.
378
+ if vqa_dir and n_vqa == 0 and n_vqa_dropped > 0:
379
+ print(f"[mimic_cxr_resized_builder] !! ALL {n_vqa_dropped:,} VQA rows "
380
+ f"dropped — path mismatch not handled by the heuristic.")
381
+ print(f" First 10 unresolved VQA image_paths:")
382
+ for p in _dropped_samples:
383
+ print(f" {p!r}")
384
+ print(f" First 5 manifest image_relpath keys:")
385
+ for k in list(image_index.keys())[:5]:
386
+ print(f" {k!r}")
387
+ print(f" Inspect both sets and adjust _resolve_vqa_path() in "
388
+ f"mimic_cxr_resized_builder.py.")
389
+
390
  # ── Write ───────────────────���───────────────────────────────────────────
391
  output_path.parent.mkdir(parents=True, exist_ok=True)
392
  with open(output_path, "w", encoding="utf-8") as f:
scripts/_build_eval_notebook.py CHANGED
@@ -732,8 +732,67 @@ print('--- model_cfg.llm ---'); print(OmegaConf.to_yaml(model_cfg.llm))
732
  """))
733
 
734
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
735
  CELLS.append(md("eval-run-md", """\
736
- ## 7. Run evaluation
737
 
738
  Calls `python -m evaluation.evaluate` as a subprocess. It will:
739
  1. Build the unified instruct JSON for the chosen `(report_mode, image_mode)` if missing.
@@ -795,7 +854,7 @@ python -u -m evaluation.evaluate \\
795
 
796
 
797
  CELLS.append(md("eval-summary-md", """\
798
- ## 8. Show the metrics summary
799
  """))
800
 
801
 
 
732
  """))
733
 
734
 
735
+ CELLS.append(md("eval-verify-md", """\
736
+ ## 7. Verify dataset before running eval
737
+
738
+ Quick pre-flight check: triggers the instruct-JSON builder (if not cached), then prints **per-split × per-task** sample counts. Catches issues like "VQA = 0 samples" (path-format mismatch in the builder) **before** spending 2h on an eval that has nothing to evaluate.
739
+
740
+ If `vqa` column shows 0 in the test split:
741
+ - the model was likely **not trained on VQA** either (same JSON cache used both ways)
742
+ - options: skip VQA via `TASK='findings'` then run a second job with `TASK='impression'`, OR fix the builder + retrain.
743
+ """))
744
+
745
+
746
+ CELLS.append(code("eval-verify", """\
747
+ import json as _json
748
+ from collections import Counter
749
+ from utils.dataset_resolver import resolve_dataset_spec
750
+
751
+ # Reload the patched config snapshot the eval subprocess will see.
752
+ train_cfg = OmegaConf.load(PROJECT / 'configs' / 'train_config.yaml')
753
+ spec = resolve_dataset_spec(train_cfg)
754
+ print(f'Dataset : {spec.dataset_name}')
755
+ print(f'Instruct JSON: {spec.instruct_json}')
756
+ print(f'Image root : {spec.image_root}')
757
+ print(f'Tasks (cfg) : {spec.tasks}')
758
+ print()
759
+
760
+ # Load the JSON the dataset module will read.
761
+ samples = _json.loads(open(spec.instruct_json, encoding='utf-8').read())
762
+ print(f'Total samples in JSON: {len(samples):,}')
763
+
764
+ # Cross-tab: (split, task) -> count
765
+ ctab = Counter((s['split'], s['task']) for s in samples)
766
+ splits = sorted({k[0] for k in ctab})
767
+ tasks = sorted({k[1] for k in ctab})
768
+
769
+ # Pretty table
770
+ col_w = max(10, max(len(t) for t in tasks) + 2)
771
+ hdr = f'{\"split\":<10} | ' + ' | '.join(f'{t:>{col_w}}' for t in tasks) + ' | total'
772
+ print(hdr); print('-' * len(hdr))
773
+ for sp in splits:
774
+ row_vals = [ctab.get((sp, t), 0) for t in tasks]
775
+ total = sum(row_vals)
776
+ print(f'{sp:<10} | ' + ' | '.join(f'{v:>{col_w},}' for v in row_vals)
777
+ + f' | {total:>9,}')
778
+
779
+ # Loud warning if VQA is expected but missing.
780
+ test_vqa = ctab.get(('test', 'vqa'), 0)
781
+ if 'vqa' in spec.tasks and test_vqa == 0:
782
+ print()
783
+ print('!! WARNING: vqa task is configured but TEST split has 0 vqa samples.')
784
+ print(' This usually means the dataset builder dropped all VQA rows due to')
785
+ print(' path-format mismatch between vqa/*.json and manifest_*.csv.')
786
+ print(' Check the builder log above for the line:')
787
+ print(' [mimic_cxr_resized_builder] vqa added/dropped : N / M')
788
+ print(' If N=0 the model was likely NOT trained on VQA either — same JSON cache.')
789
+ elif 'vqa' in spec.tasks:
790
+ print(f'\\nVQA in test split: {test_vqa:,} samples — OK')
791
+ """))
792
+
793
+
794
  CELLS.append(md("eval-run-md", """\
795
+ ## 8. Run evaluation
796
 
797
  Calls `python -m evaluation.evaluate` as a subprocess. It will:
798
  1. Build the unified instruct JSON for the chosen `(report_mode, image_mode)` if missing.
 
854
 
855
 
856
  CELLS.append(md("eval-summary-md", """\
857
+ ## 9. Show the metrics summary
858
  """))
859
 
860
 
scripts/cxrvlm_colab_eval.ipynb CHANGED
@@ -763,12 +763,79 @@
763
  "print('--- model_cfg.llm ---'); print(OmegaConf.to_yaml(model_cfg.llm))\n"
764
  ]
765
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
766
  {
767
  "cell_type": "markdown",
768
  "id": "eval-run-md",
769
  "metadata": {},
770
  "source": [
771
- "## 7. Run evaluation\n",
772
  "\n",
773
  "Calls `python -m evaluation.evaluate` as a subprocess. It will:\n",
774
  "1. Build the unified instruct JSON for the chosen `(report_mode, image_mode)` if missing.\n",
@@ -838,7 +905,7 @@
838
  "id": "eval-summary-md",
839
  "metadata": {},
840
  "source": [
841
- "## 8. Show the metrics summary\n"
842
  ]
843
  },
844
  {
 
763
  "print('--- model_cfg.llm ---'); print(OmegaConf.to_yaml(model_cfg.llm))\n"
764
  ]
765
  },
766
+ {
767
+ "cell_type": "markdown",
768
+ "id": "eval-verify-md",
769
+ "metadata": {},
770
+ "source": [
771
+ "## 7. Verify dataset before running eval\n",
772
+ "\n",
773
+ "Quick pre-flight check: triggers the instruct-JSON builder (if not cached), then prints **per-split × per-task** sample counts. Catches issues like \"VQA = 0 samples\" (path-format mismatch in the builder) **before** spending 2h on an eval that has nothing to evaluate.\n",
774
+ "\n",
775
+ "If `vqa` column shows 0 in the test split:\n",
776
+ "- the model was likely **not trained on VQA** either (same JSON cache used both ways)\n",
777
+ "- options: skip VQA via `TASK='findings'` then run a second job with `TASK='impression'`, OR fix the builder + retrain.\n"
778
+ ]
779
+ },
780
+ {
781
+ "cell_type": "code",
782
+ "id": "eval-verify",
783
+ "metadata": {},
784
+ "execution_count": null,
785
+ "outputs": [],
786
+ "source": [
787
+ "import json as _json\n",
788
+ "from collections import Counter\n",
789
+ "from utils.dataset_resolver import resolve_dataset_spec\n",
790
+ "\n",
791
+ "# Reload the patched config snapshot the eval subprocess will see.\n",
792
+ "train_cfg = OmegaConf.load(PROJECT / 'configs' / 'train_config.yaml')\n",
793
+ "spec = resolve_dataset_spec(train_cfg)\n",
794
+ "print(f'Dataset : {spec.dataset_name}')\n",
795
+ "print(f'Instruct JSON: {spec.instruct_json}')\n",
796
+ "print(f'Image root : {spec.image_root}')\n",
797
+ "print(f'Tasks (cfg) : {spec.tasks}')\n",
798
+ "print()\n",
799
+ "\n",
800
+ "# Load the JSON the dataset module will read.\n",
801
+ "samples = _json.loads(open(spec.instruct_json, encoding='utf-8').read())\n",
802
+ "print(f'Total samples in JSON: {len(samples):,}')\n",
803
+ "\n",
804
+ "# Cross-tab: (split, task) -> count\n",
805
+ "ctab = Counter((s['split'], s['task']) for s in samples)\n",
806
+ "splits = sorted({k[0] for k in ctab})\n",
807
+ "tasks = sorted({k[1] for k in ctab})\n",
808
+ "\n",
809
+ "# Pretty table\n",
810
+ "col_w = max(10, max(len(t) for t in tasks) + 2)\n",
811
+ "hdr = f'{\"split\":<10} | ' + ' | '.join(f'{t:>{col_w}}' for t in tasks) + ' | total'\n",
812
+ "print(hdr); print('-' * len(hdr))\n",
813
+ "for sp in splits:\n",
814
+ " row_vals = [ctab.get((sp, t), 0) for t in tasks]\n",
815
+ " total = sum(row_vals)\n",
816
+ " print(f'{sp:<10} | ' + ' | '.join(f'{v:>{col_w},}' for v in row_vals)\n",
817
+ " + f' | {total:>9,}')\n",
818
+ "\n",
819
+ "# Loud warning if VQA is expected but missing.\n",
820
+ "test_vqa = ctab.get(('test', 'vqa'), 0)\n",
821
+ "if 'vqa' in spec.tasks and test_vqa == 0:\n",
822
+ " print()\n",
823
+ " print('!! WARNING: vqa task is configured but TEST split has 0 vqa samples.')\n",
824
+ " print(' This usually means the dataset builder dropped all VQA rows due to')\n",
825
+ " print(' path-format mismatch between vqa/*.json and manifest_*.csv.')\n",
826
+ " print(' Check the builder log above for the line:')\n",
827
+ " print(' [mimic_cxr_resized_builder] vqa added/dropped : N / M')\n",
828
+ " print(' If N=0 the model was likely NOT trained on VQA either — same JSON cache.')\n",
829
+ "elif 'vqa' in spec.tasks:\n",
830
+ " print(f'\\nVQA in test split: {test_vqa:,} samples — OK')\n"
831
+ ]
832
+ },
833
  {
834
  "cell_type": "markdown",
835
  "id": "eval-run-md",
836
  "metadata": {},
837
  "source": [
838
+ "## 8. Run evaluation\n",
839
  "\n",
840
  "Calls `python -m evaluation.evaluate` as a subprocess. It will:\n",
841
  "1. Build the unified instruct JSON for the chosen `(report_mode, image_mode)` if missing.\n",
 
905
  "id": "eval-summary-md",
906
  "metadata": {},
907
  "source": [
908
+ "## 9. Show the metrics summary\n"
909
  ]
910
  },
911
  {
scripts/cxrvlm_lightningai_train.ipynb ADDED
The diff for this file is too large to render. See raw diff
 
scripts/gcp_finetune_vqa_entrypoint.py ADDED
@@ -0,0 +1,455 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GCP Vertex AI Custom Job entrypoint — **VQA finetune** from an existing run.
2
+
3
+ Take a fully-trained run (stage 2 done) and continue training Stage 2 with
4
+ **VQA-heavy task weights** + low LR + few epochs, into a NEW run_id (so the
5
+ original checkpoint stays untouched on HF Hub).
6
+
7
+ Continuation strategy:
8
+ - run_1/stage2/best/ ← already-trained projection + LoRA on HF
9
+ checkpoint_projection.pt
10
+ checkpoint_lora/
11
+ checkpoint_chexpert_classifier.pt (if present)
12
+
13
+ We download those and place them at:
14
+ run_2/stage1_projection/stage1_final_projection.pt ← renamed
15
+ run_2/stage1_projection/stage1_final_lora/ ← renamed
16
+ run_2/stage1_projection/stage1_final_chexpert_classifier.pt
17
+
18
+ Then train.py:
19
+ 1. detect_resume_point sees stage1_final_projection.pt → ("stage2", None)
20
+ → skips Stage 1 entirely
21
+ 2. Builds the model, calls load_checkpoint(stage1_final.pt) which loads
22
+ BOTH the projection AND the LoRA from the renamed files (load_checkpoint
23
+ derives the LoRA dir from the .pt stem).
24
+ 3. Runs Stage 2 fresh with the new task weights + LR + epochs, starting
25
+ from the loaded weights. Optimizer state is reset — that's deliberate
26
+ (different task mix; old momentum would point the wrong way).
27
+
28
+ Required env vars:
29
+ HF_TOKEN — HuggingFace token (read+write)
30
+ DATASET_NAME — 'IU-Xray' | 'MIMIC-CXR' | 'MIMIC-CXR_resized'
31
+ SOURCE_RUN_ID — run on HF_RUNS_REPO whose stage2/best is the seed
32
+ e.g. 'MIMIC-CXR_resized_run_1'
33
+ TARGET_RUN_ID — NEW run id to write into, e.g.
34
+ 'MIMIC-CXR_resized_run_2'
35
+
36
+ Optional env vars (defaults shown — tuned for "vqa-heavy with rehearsal"):
37
+ HF_USER = hieu3636
38
+ HF_RUNS_REPO = hieu3636/cxr-vlm-runs
39
+ SOURCE_CKPT_PICK = best # 'best' | 'last'
40
+ REPORT_MODE = # blank → from source run's snapshot
41
+ IMAGE_MODE = # blank → from source run's snapshot
42
+ W_FINDINGS = 0.15 # rehearsal weight
43
+ W_IMPRESSION = 0.10 # rehearsal weight
44
+ W_VQA = 0.75 # focus
45
+ S2_EPOCHS = 3
46
+ S2_LR = 5e-5 # 4× lower than original 2e-4
47
+ STRICT_VQA_REQUIRED = 1 # fail fast if VQA samples = 0
48
+ WORK = /workspace
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ import os
54
+ import shutil
55
+ import subprocess
56
+ import sys
57
+ import tarfile
58
+ import zipfile
59
+ from pathlib import Path
60
+
61
+ os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
62
+ os.environ.setdefault("BITSANDBYTES_NOWELCOME", "1")
63
+ os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
64
+ os.environ.setdefault("TRANSFORMERS_VERBOSITY", "warning")
65
+ os.environ.setdefault("PYTHONUNBUFFERED", "1")
66
+ os.environ.setdefault("CUDA_VISIBLE_DEVICES", "0")
67
+
68
+
69
+ def env(name: str, default: str | None = None, *, required: bool = False) -> str:
70
+ val = os.environ.get(name, default)
71
+ if required and not val:
72
+ sys.exit(f"[gcp_finetune_vqa] ERROR: required env var {name} not set")
73
+ return val or ""
74
+
75
+
76
+ # ── 1) Resolve config from env ────────────────────────────────────────────────
77
+ HF_TOKEN = env("HF_TOKEN", required=True)
78
+ DATASET_NAME = env("DATASET_NAME", required=True)
79
+ SOURCE_RUN_ID = env("SOURCE_RUN_ID", required=True)
80
+ TARGET_RUN_ID = env("TARGET_RUN_ID", required=True)
81
+ HF_USER = env("HF_USER", "hieu3636")
82
+ HF_RUNS_REPO = env("HF_RUNS_REPO", "hieu3636/cxr-vlm-runs")
83
+ SOURCE_CKPT_PICK = env("SOURCE_CKPT_PICK", "best")
84
+ REPORT_MODE_OVERRIDE = env("REPORT_MODE", "")
85
+ IMAGE_MODE_OVERRIDE = env("IMAGE_MODE", "")
86
+ W_FINDINGS = float(env("W_FINDINGS", "0.15"))
87
+ W_IMPRESSION = float(env("W_IMPRESSION", "0.10"))
88
+ W_VQA = float(env("W_VQA", "0.75"))
89
+ S2_EPOCHS = int(env("S2_EPOCHS", "3"))
90
+ S2_LR = float(env("S2_LR", "5e-5"))
91
+ STRICT_VQA_REQUIRED = env("STRICT_VQA_REQUIRED", "1") in ("1", "true", "True")
92
+ WORK = Path(env("WORK", "/workspace"))
93
+
94
+ assert DATASET_NAME in ("IU-Xray", "MIMIC-CXR", "MIMIC-CXR_resized"), DATASET_NAME
95
+ assert SOURCE_CKPT_PICK in ("best", "last"), SOURCE_CKPT_PICK
96
+ assert TARGET_RUN_ID != SOURCE_RUN_ID, \
97
+ f"TARGET_RUN_ID must differ from SOURCE_RUN_ID — refusing to overwrite the source run."
98
+
99
+ PROJECT = Path(__file__).resolve().parent.parent
100
+ DATA_SRC = WORK / "data"
101
+ RUN_PULL_ROOT = WORK / "run_pull"
102
+ CKPT_ROOT = WORK / "ckpt"
103
+ for d in (DATA_SRC, RUN_PULL_ROOT, CKPT_ROOT):
104
+ d.mkdir(parents=True, exist_ok=True)
105
+
106
+ print(f"[gcp_finetune_vqa] PROJECT = {PROJECT}")
107
+ print(f"[gcp_finetune_vqa] WORK = {WORK}")
108
+ print(f"[gcp_finetune_vqa] DATASET_NAME = {DATASET_NAME}")
109
+ print(f"[gcp_finetune_vqa] SOURCE_RUN_ID = {SOURCE_RUN_ID} (ckpt: stage2/{SOURCE_CKPT_PICK})")
110
+ print(f"[gcp_finetune_vqa] TARGET_RUN_ID = {TARGET_RUN_ID} (NEW — original stays untouched)")
111
+ print(f"[gcp_finetune_vqa] Task mix = findings:{W_FINDINGS} impression:{W_IMPRESSION} vqa:{W_VQA}")
112
+ print(f"[gcp_finetune_vqa] S2_EPOCHS={S2_EPOCHS} S2_LR={S2_LR}")
113
+
114
+ # ── 2) Download dataset payload from HF Hub ───────────────────────────────────
115
+ from huggingface_hub import HfApi, hf_hub_download, snapshot_download # noqa: E402
116
+
117
+ if DATASET_NAME == "MIMIC-CXR_resized":
118
+ mr_dir = DATA_SRC / "MIMIC-CXR_resized"
119
+ mr_dir.mkdir(parents=True, exist_ok=True)
120
+ files_dir = mr_dir / "files"
121
+ manifests_present = all(
122
+ (mr_dir / f).is_file()
123
+ for f in ("manifest_train.csv", "manifest_val.csv", "manifest_test.csv")
124
+ )
125
+ if manifests_present and files_dir.is_dir() and any(files_dir.glob("p*")):
126
+ print(f"[gcp_finetune_vqa] {mr_dir} already populated — skipping download.")
127
+ else:
128
+ api = HfApi(token=HF_TOKEN)
129
+ all_files = api.list_repo_files(
130
+ repo_id=f"{HF_USER}/cxr-vlm-data", repo_type="dataset"
131
+ )
132
+ tar_files = sorted(
133
+ f for f in all_files
134
+ if f.startswith("MIMIC-CXR_resized/") and f.endswith(".tar")
135
+ )
136
+ print(f"[gcp_finetune_vqa] {len(tar_files)} tar shards on HF")
137
+
138
+ snapshot_download(
139
+ repo_id=f"{HF_USER}/cxr-vlm-data",
140
+ repo_type="dataset",
141
+ allow_patterns=[
142
+ "MIMIC-CXR_resized/*.csv",
143
+ "MIMIC-CXR_resized/*.json",
144
+ "MIMIC-CXR_resized/*.txt",
145
+ "MIMIC-CXR_resized/vqa/**",
146
+ ],
147
+ token=HF_TOKEN,
148
+ local_dir=str(DATA_SRC),
149
+ )
150
+
151
+ for i, tf in enumerate(tar_files, 1):
152
+ print(f"[gcp_finetune_vqa] [{i}/{len(tar_files)}] {tf}", flush=True)
153
+ tp = Path(hf_hub_download(
154
+ repo_id=f"{HF_USER}/cxr-vlm-data",
155
+ repo_type="dataset",
156
+ filename=tf,
157
+ token=HF_TOKEN,
158
+ local_dir=str(DATA_SRC),
159
+ ))
160
+ with tarfile.open(tp) as t:
161
+ t.extractall(mr_dir)
162
+ tp.unlink(missing_ok=True)
163
+ print(f"[gcp_finetune_vqa] {mr_dir} ready.")
164
+ DATA_ROOT_RESIZED = mr_dir
165
+
166
+ else:
167
+ zip_name = f"{DATASET_NAME}.zip"
168
+ marker = DATA_SRC / DATASET_NAME
169
+ if not marker.exists():
170
+ print(f"[gcp_finetune_vqa] downloading {zip_name} ...")
171
+ zpath = hf_hub_download(
172
+ repo_id=f"{HF_USER}/cxr-vlm-data",
173
+ filename=zip_name,
174
+ repo_type="dataset",
175
+ token=HF_TOKEN,
176
+ local_dir=str(DATA_SRC),
177
+ )
178
+ with zipfile.ZipFile(zpath) as zf:
179
+ zf.extractall(DATA_SRC)
180
+ try:
181
+ os.remove(zpath)
182
+ except OSError:
183
+ pass
184
+ else:
185
+ print(f"[gcp_finetune_vqa] {marker} already present — skipping download.")
186
+
187
+ print(f"[gcp_finetune_vqa] DATA_SRC contents: {sorted(os.listdir(DATA_SRC))}")
188
+
189
+ # ── 3) Pull SOURCE run's stage2/best + config snapshot from HF runs repo ─────
190
+ print(f"[gcp_finetune_vqa] pulling {SOURCE_RUN_ID}/{{configs,stage2/{SOURCE_CKPT_PICK}}} "
191
+ f"from {HF_RUNS_REPO} …")
192
+ snapshot_download(
193
+ repo_id=HF_RUNS_REPO,
194
+ repo_type="model",
195
+ token=HF_TOKEN,
196
+ allow_patterns=[
197
+ f"{SOURCE_RUN_ID}/configs/**",
198
+ f"{SOURCE_RUN_ID}/run_meta.json",
199
+ f"{SOURCE_RUN_ID}/stage2/{SOURCE_CKPT_PICK}/**",
200
+ ],
201
+ local_dir=str(RUN_PULL_ROOT),
202
+ )
203
+ SRC_DIR = RUN_PULL_ROOT / SOURCE_RUN_ID
204
+ SRC_S2_DIR = SRC_DIR / "stage2" / SOURCE_CKPT_PICK
205
+ SRC_PROJ = SRC_S2_DIR / "checkpoint_projection.pt"
206
+ SRC_LORA = SRC_S2_DIR / "checkpoint_lora"
207
+ SRC_CHEXPRT = SRC_S2_DIR / "checkpoint_chexpert_classifier.pt"
208
+
209
+ assert SRC_PROJ.is_file(), f"source projection missing: {SRC_PROJ}"
210
+ assert (SRC_LORA / "adapter_config.json").is_file(), \
211
+ f"source LoRA dir missing: {SRC_LORA}"
212
+
213
+ # ── 4) Seed TARGET_RUN_ID dir: place source ckpt as stage1_final_* so that
214
+ # detect_resume_point() returns ("stage2", None) and load_checkpoint() picks
215
+ # up BOTH projection AND lora from the renamed files. ─────────────────────
216
+ TARGET_DIR = CKPT_ROOT / TARGET_RUN_ID
217
+ TGT_S1_DIR = TARGET_DIR / "stage1_projection"
218
+ TGT_S1_DIR.mkdir(parents=True, exist_ok=True)
219
+
220
+ shutil.copy2(SRC_PROJ, TGT_S1_DIR / "stage1_final_projection.pt")
221
+ print(f"[gcp_finetune_vqa] seeded projection -> {TGT_S1_DIR / 'stage1_final_projection.pt'}")
222
+
223
+ tgt_lora = TGT_S1_DIR / "stage1_final_lora"
224
+ if tgt_lora.exists():
225
+ shutil.rmtree(tgt_lora)
226
+ shutil.copytree(SRC_LORA, tgt_lora)
227
+ print(f"[gcp_finetune_vqa] seeded LoRA -> {tgt_lora}")
228
+
229
+ if SRC_CHEXPRT.is_file():
230
+ shutil.copy2(SRC_CHEXPRT, TGT_S1_DIR / "stage1_final_chexpert_classifier.pt")
231
+ print(f"[gcp_finetune_vqa] seeded chexpert -> "
232
+ f"{TGT_S1_DIR / 'stage1_final_chexpert_classifier.pt'}")
233
+
234
+ # ── 5) Build configs (start from source snapshot to preserve architecture) ───
235
+ import torch # noqa: E402
236
+ from omegaconf import OmegaConf # noqa: E402
237
+
238
+ SAVED_CFG_DIR = SRC_DIR / "configs"
239
+ SAVED_TRAIN_CFG = SAVED_CFG_DIR / "train_config.yaml"
240
+ SAVED_MODEL_CFG = SAVED_CFG_DIR / "model_config.yaml"
241
+ repo_train_cfg_path = PROJECT / "configs" / "train_config.yaml"
242
+ repo_model_cfg_path = PROJECT / "configs" / "model_config.yaml"
243
+
244
+ if SAVED_TRAIN_CFG.is_file():
245
+ train_cfg = OmegaConf.load(SAVED_TRAIN_CFG)
246
+ print(f"[gcp_finetune_vqa] train_cfg <- {SAVED_TRAIN_CFG}")
247
+ else:
248
+ train_cfg = OmegaConf.load(repo_train_cfg_path)
249
+ print(f"[gcp_finetune_vqa] train_cfg <- repo default")
250
+
251
+ if SAVED_MODEL_CFG.is_file():
252
+ model_cfg = OmegaConf.load(SAVED_MODEL_CFG)
253
+ print(f"[gcp_finetune_vqa] model_cfg <- {SAVED_MODEL_CFG}")
254
+ else:
255
+ model_cfg = OmegaConf.load(repo_model_cfg_path)
256
+
257
+ if REPORT_MODE_OVERRIDE:
258
+ train_cfg.data.report_mode = REPORT_MODE_OVERRIDE
259
+ if IMAGE_MODE_OVERRIDE:
260
+ train_cfg.data.image_mode = IMAGE_MODE_OVERRIDE
261
+ print(f"[gcp_finetune_vqa] report_mode={train_cfg.data.report_mode} image_mode={train_cfg.data.image_mode}")
262
+
263
+ # Dataset paths (mirror gcp_entrypoint.py)
264
+ train_cfg.data.dataset_name = DATASET_NAME
265
+ train_cfg.data.max_images_per_sample = int(getattr(train_cfg.data, "max_images_per_sample", 2))
266
+
267
+ out_dir = PROJECT / "data" / "data_files"
268
+ out_dir.mkdir(parents=True, exist_ok=True)
269
+
270
+ if DATASET_NAME == "MIMIC-CXR_resized":
271
+ train_cfg.data.mimic_cxr_resized.root = str(DATA_ROOT_RESIZED)
272
+ train_cfg.data.mimic_cxr_resized.manifest_dir = None
273
+ train_cfg.data.mimic_cxr_resized.vqa_dir = None
274
+ train_cfg.data.mimic_cxr_resized.reports_root = None
275
+ train_cfg.data.mimic_cxr_resized.auto_build = True
276
+ train_cfg.data.mimic_cxr_resized.instruct_json = str(
277
+ out_dir / "mimic_cxr_resized_instruct.json")
278
+ elif DATASET_NAME == "MIMIC-CXR":
279
+ def _find_mimic_root(root: Path) -> Path:
280
+ for cand in [root / "MIMIC-CXR", root]:
281
+ if (cand / "train").exists() and (cand / "valid").exists() and (cand / "test").exists():
282
+ return cand
283
+ for p in root.rglob("train"):
284
+ if p.is_dir() and (p.parent / "valid").exists() and (p.parent / "test").exists():
285
+ return p.parent
286
+ raise FileNotFoundError(f"MIMIC-CXR train/valid/test not found under {root}")
287
+ cxr_root = _find_mimic_root(DATA_SRC)
288
+ train_cfg.data.mimic_cxr_root = str(cxr_root)
289
+ train_cfg.data.instruct_json = str(out_dir / "mimic_cxr_instruct_unified.json")
290
+ train_cfg.data.mimic_auto_build = True
291
+ _cx = sorted(DATA_SRC.rglob("*chexpert*.csv")) or sorted(DATA_SRC.rglob("*chexbert*.csv"))
292
+ train_cfg.data.mimic_chexpert_csv = str(_cx[0]) if _cx else None
293
+ _vqa = list(DATA_SRC.rglob("vqa"))
294
+ train_cfg.data.mimic_vqa_root = str(_vqa[0]) if _vqa else None
295
+ else:
296
+ iu_root = DATA_SRC / "IU-Xray"
297
+ train_cfg.data.iu_xray.images_dir = str(iu_root / "images")
298
+ train_cfg.data.iu_xray.labels_dir = str(iu_root / "labels")
299
+ train_cfg.data.iu_xray.instruct_json = str(out_dir / "iu_xray_instruct.json")
300
+ train_cfg.data.iu_xray.auto_build = True
301
+
302
+ train_cfg.data.train_split = "train"
303
+ train_cfg.data.val_split = "validate"
304
+ train_cfg.data.test_split = "test"
305
+ train_cfg.data.feature_cache_dir = None
306
+ train_cfg.training.output_root = str(CKPT_ROOT)
307
+
308
+ # ── Task mix: VQA-heavy with rehearsal ───────────────────────────────────────
309
+ train_cfg.tasks.findings_generation.enabled = W_FINDINGS > 0
310
+ train_cfg.tasks.findings_generation.weight = W_FINDINGS
311
+ train_cfg.tasks.impression_generation.enabled = W_IMPRESSION > 0
312
+ train_cfg.tasks.impression_generation.weight = W_IMPRESSION
313
+ train_cfg.tasks.vqa.enabled = W_VQA > 0
314
+ train_cfg.tasks.vqa.weight = W_VQA
315
+
316
+ # ── Stage 1 disabled (we seeded its outputs from source). Stage 2 = short
317
+ # finetune with low LR. Stage1 ITC explicitly off — irrelevant here. ─────
318
+ train_cfg.stage1.enabled = False
319
+ if "itc" in train_cfg.stage1:
320
+ train_cfg.stage1.itc.enabled = False
321
+ train_cfg.stage2.enabled = True
322
+ train_cfg.stage2.num_epochs = S2_EPOCHS
323
+ train_cfg.stage2.learning_rate = S2_LR
324
+
325
+ # ── GPU auto-profile (mirrors gcp_entrypoint.py) ────────────────────────────
326
+ assert torch.cuda.is_available(), "CUDA not available in container"
327
+ _props = torch.cuda.get_device_properties(0)
328
+ _cap = (_props.major, _props.minor)
329
+ _vram_gb = _props.total_memory / 1e9
330
+ _bf16_ok = torch.cuda.is_bf16_supported()
331
+ _fa2_ok = _cap >= (8, 0)
332
+
333
+ _flash_attn_installed = False
334
+ if _fa2_ok:
335
+ try:
336
+ import flash_attn # noqa: F401
337
+ _flash_attn_installed = True
338
+ except Exception:
339
+ _flash_attn_installed = False
340
+
341
+ if _vram_gb >= 70:
342
+ _profile = dict(label="A100/H100 80GB",
343
+ per_device_train_batch_size=8, per_device_eval_batch_size=8,
344
+ gradient_accumulation_steps=2, dataloader_num_workers=16,
345
+ gradient_checkpointing=False)
346
+ elif _vram_gb >= 35:
347
+ _profile = dict(label="A100 40GB",
348
+ per_device_train_batch_size=8, per_device_eval_batch_size=8,
349
+ gradient_accumulation_steps=2, dataloader_num_workers=12,
350
+ gradient_checkpointing=False)
351
+ elif _vram_gb >= 22:
352
+ _profile = dict(label="3090 / L4 / A10 (24GB)",
353
+ per_device_train_batch_size=8, per_device_eval_batch_size=8,
354
+ gradient_accumulation_steps=2, dataloader_num_workers=8,
355
+ gradient_checkpointing=True)
356
+ elif _vram_gb >= 14:
357
+ _profile = dict(label="T4 / V100 (15-16GB)",
358
+ per_device_train_batch_size=1, per_device_eval_batch_size=1,
359
+ gradient_accumulation_steps=16, dataloader_num_workers=2,
360
+ gradient_checkpointing=True)
361
+ else:
362
+ _profile = dict(label=f"unknown ({_vram_gb:.0f}GB)",
363
+ per_device_train_batch_size=1, per_device_eval_batch_size=1,
364
+ gradient_accumulation_steps=16, dataloader_num_workers=2,
365
+ gradient_checkpointing=True)
366
+
367
+ _profile["bf16"] = bool(_bf16_ok)
368
+ _profile["fp16"] = not _bf16_ok
369
+ _profile["attn_implementation"] = (
370
+ "flash_attention_2" if (_fa2_ok and _flash_attn_installed) else "sdpa"
371
+ )
372
+ _profile["optim"] = "paged_adamw_8bit" if _cap >= (8, 0) else "adamw_torch"
373
+ _profile["bnb_4bit_compute_dtype"] = "bfloat16" if _bf16_ok else "float16"
374
+ _profile["torch_dtype"] = "bfloat16" if _bf16_ok else "float16"
375
+
376
+ print(f"[gcp_finetune_vqa] GPU: {_props.name} {_vram_gb:.1f}GB sm_{_cap[0]}{_cap[1]} "
377
+ f"bf16={_bf16_ok} fa2={_fa2_ok} fa2_wheel={_flash_attn_installed}")
378
+ print(f"[gcp_finetune_vqa] → {_profile['label']}")
379
+
380
+ train_cfg.training.per_device_train_batch_size = _profile["per_device_train_batch_size"]
381
+ train_cfg.training.per_device_eval_batch_size = _profile["per_device_eval_batch_size"]
382
+ train_cfg.training.gradient_accumulation_steps = _profile["gradient_accumulation_steps"]
383
+ train_cfg.training.dataloader_num_workers = _profile["dataloader_num_workers"]
384
+ train_cfg.training.fp16 = _profile["fp16"]
385
+ train_cfg.training.bf16 = _profile["bf16"]
386
+ train_cfg.training.dataloader_pin_memory = True
387
+ train_cfg.training.dataloader_persistent_workers = True
388
+ train_cfg.training.optim = _profile["optim"]
389
+
390
+ model_cfg.llm.attn_implementation = _profile["attn_implementation"]
391
+ model_cfg.llm.gradient_checkpointing = _profile["gradient_checkpointing"]
392
+ model_cfg.llm.torch_dtype = _profile["torch_dtype"]
393
+ model_cfg.llm.bnb_4bit_compute_dtype = _profile["bnb_4bit_compute_dtype"]
394
+ model_cfg.llm.bnb_4bit_quant_type = "nf4"
395
+ model_cfg.llm.bnb_4bit_use_double_quant = True
396
+ model_cfg.llm.load_in_8bit = False
397
+ model_cfg.llm.load_in_4bit = True
398
+
399
+ # CheXpert classifier — enable iff its checkpoint came along with source run.
400
+ if (TGT_S1_DIR / "stage1_final_chexpert_classifier.pt").is_file():
401
+ model_cfg.chexpert_classifier.enabled = True
402
+ else:
403
+ model_cfg.chexpert_classifier.enabled = False
404
+
405
+ # HF Hub uploads → TARGET_RUN_ID (so source run_1 stays untouched).
406
+ train_cfg.wandb.enabled = False
407
+ train_cfg.hf_hub.enabled = True
408
+ train_cfg.hf_hub.repo_id = HF_RUNS_REPO
409
+ train_cfg.hf_hub.token_env = "HF_TOKEN"
410
+ train_cfg.hf_hub.private = True
411
+ train_cfg.hf_hub.run_state_file = str(CKPT_ROOT / "run_id.txt")
412
+
413
+ # Pin run_id = target so resolve_run_id picks it up.
414
+ (CKPT_ROOT / "run_id.txt").write_text(TARGET_RUN_ID)
415
+ print(f"[gcp_finetune_vqa] pinned run_id = {TARGET_RUN_ID}")
416
+
417
+ OmegaConf.save(train_cfg, repo_train_cfg_path)
418
+ OmegaConf.save(model_cfg, repo_model_cfg_path)
419
+ print("[gcp_finetune_vqa] configs patched.")
420
+
421
+ # ── 6) Pre-flight check: VQA samples > 0 ─────────────────────────────────────
422
+ # Trigger the builder, then verify the train split actually has vqa samples.
423
+ # Bail fast — saves hours of compute if the path-fix didn't work.
424
+ import json as _json
425
+ from utils.dataset_resolver import resolve_dataset_spec # noqa: E402
426
+
427
+ print("[gcp_finetune_vqa] pre-flight: triggering dataset builder + verifying VQA …")
428
+ spec = resolve_dataset_spec(train_cfg)
429
+ samples = _json.load(open(spec.instruct_json, encoding="utf-8"))
430
+ train_counts = {}
431
+ for s in samples:
432
+ if s.get("split") == "train":
433
+ train_counts[s["task"]] = train_counts.get(s["task"], 0) + 1
434
+ print(f"[gcp_finetune_vqa] train-split task counts: {train_counts}")
435
+
436
+ if STRICT_VQA_REQUIRED and train_counts.get("vqa", 0) == 0:
437
+ print(f"[gcp_finetune_vqa] !! FATAL: train-split has 0 VQA samples.")
438
+ print(f" The dataset builder dropped all VQA rows — path-format mismatch.")
439
+ print(f" Inspect the builder log above for the line:")
440
+ print(f" [mimic_cxr_resized_builder] vqa added/dropped : N / M")
441
+ print(f" Fix the builder, push to HF, then re-submit. Set "
442
+ f"STRICT_VQA_REQUIRED=0 to bypass this check (not recommended).")
443
+ sys.exit(2)
444
+
445
+ # ── 7) Launch training (Stage 2 only — Stage 1 seeded from source) ──────────
446
+ cmd = [
447
+ "python", "-u", "-m", "training.train",
448
+ "--model_config", str(repo_model_cfg_path),
449
+ "--train_config", str(repo_train_cfg_path),
450
+ "--mode", "resume",
451
+ "--run_id", TARGET_RUN_ID,
452
+ ]
453
+ print(f"[gcp_finetune_vqa] launching: {' '.join(cmd)}", flush=True)
454
+ os.chdir(PROJECT)
455
+ sys.exit(subprocess.call(cmd))
scripts/vertex_finetune_vqa_job.yaml ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Vertex AI Custom Job — CXR-VLM **VQA finetune** on L4
2
+ #
3
+ # Takes an existing trained run (SOURCE_RUN_ID) and continues Stage 2 with
4
+ # VQA-heavy task weights into a NEW run (TARGET_RUN_ID). Source run on HF is
5
+ # untouched.
6
+ #
7
+ # Submit:
8
+ # gcloud ai custom-jobs create \
9
+ # --region=us-central1 \
10
+ # --display-name=cxr-vlm-vqa-finetune \
11
+ # --config=scripts/vertex_finetune_vqa_job.yaml
12
+ #
13
+ # Stream logs:
14
+ # gcloud ai custom-jobs stream-logs <JOB_ID> --region=us-central1
15
+ #
16
+ # Output:
17
+ # - Stage 2 checkpoints land at hieu3636/cxr-vlm-runs/<TARGET_RUN_ID>/stage2/{last,best}/
18
+ # - Original run_1 untouched.
19
+ #
20
+ # Pre-flight: the entrypoint refuses to launch training if the train split has
21
+ # 0 VQA samples after the builder runs (i.e. path-fix didn't work). You see
22
+ # the failure in ~30 min instead of after 3h of wasted GPU.
23
+
24
+ workerPoolSpecs:
25
+ - machineSpec:
26
+ machineType: g2-standard-8 # 8 vCPU, 32GB RAM, 1×L4 24GB
27
+ acceleratorType: NVIDIA_L4
28
+ acceleratorCount: 1
29
+ replicaCount: 1
30
+ diskSpec:
31
+ bootDiskType: pd-ssd
32
+ bootDiskSizeGb: 200
33
+ containerSpec:
34
+ imageUri: us-central1-docker.pkg.dev/cxr-vlm-thesis/cxr-vlm/cxr-vlm-env:cu128
35
+ command:
36
+ - bash
37
+ - -c
38
+ - |
39
+ set -e
40
+ echo "[bootstrap] downloading code from HF Hub …"
41
+ python -c "
42
+ from huggingface_hub import snapshot_download
43
+ import os
44
+ snapshot_download('hieu3636/cxr-vlm-code',
45
+ repo_type='model',
46
+ token=os.environ['HF_TOKEN'],
47
+ local_dir='/workspace/code')
48
+ "
49
+ cd /workspace/code
50
+ echo "[bootstrap] exec scripts/gcp_finetune_vqa_entrypoint.py …"
51
+ exec python scripts/gcp_finetune_vqa_entrypoint.py
52
+ env:
53
+ # ── Required ────────────────────────────────────────────────────────────
54
+ - name: HF_TOKEN
55
+ value:
56
+ - name: DATASET_NAME
57
+ value: MIMIC-CXR_resized
58
+ - name: SOURCE_RUN_ID
59
+ value: MIMIC-CXR_resized_run_1 # the trained run to fine-tune from
60
+ - name: TARGET_RUN_ID
61
+ value: MIMIC-CXR_resized_run_2 # NEW — won't collide with run_1
62
+ # ── Optional ────────────────────────────────────────────────────────────
63
+ - name: HF_RUNS_REPO
64
+ value: hieu3636/cxr-vlm-runs
65
+ - name: SOURCE_CKPT_PICK
66
+ value: best # 'best' | 'last'
67
+ # ── Task mix (VQA-heavy with rehearsal) ─────────────────────────────────
68
+ - name: W_FINDINGS
69
+ value: "0.15" # rehearsal — keeps findings skill
70
+ - name: W_IMPRESSION
71
+ value: "0.10" # rehearsal — keeps impression skill
72
+ - name: W_VQA
73
+ value: "0.75" # focus on VQA
74
+ # ── Schedule ────────────────────────────────────────────────────────────
75
+ - name: S2_EPOCHS
76
+ value: "3" # short finetune (was 7 originally)
77
+ - name: S2_LR
78
+ value: "5e-5" # 4× lower than original 2e-4
79
+ # Safety: refuse to train if VQA build still drops everything.
80
+ - name: STRICT_VQA_REQUIRED
81
+ value: "1"
82
+
83
+ scheduling:
84
+ strategy: STANDARD
85
+ # Stage 2 finetune (3 epochs, mixed batch ~80k samples) on L4 ≈ 3-4h.
86
+ # 6h ceiling leaves slack for data pull + builder.
87
+ timeout: 21600s