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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
img_path = str(row.get("image_path", "")).lstrip("/")
|
| 302 |
-
|
|
|
|
| 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":
|
| 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 |
-
##
|
| 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 |
-
##
|
| 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 |
-
"##
|
| 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 |
-
"##
|
| 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
|