convitom commited on
Commit ·
c61f01a
1
Parent(s): e166fd8
- configs/model_config.yaml +20 -3
- configs/train_config.yaml +16 -0
- data/collator.py +33 -12
- data/dataset.py +38 -9
- evaluation/evaluate.py +1 -0
- model/cxr_vlm.py +66 -13
- scripts/_apply_notebook_edits.py +350 -0
- scripts/cxrvlm_colab_train.ipynb +283 -25
- scripts/precompute_image_features.py +182 -0
- training/train.py +16 -0
configs/model_config.yaml
CHANGED
|
@@ -11,9 +11,9 @@
|
|
| 11 |
# - vit : timm ViT-B/16 ImageNet — generic fallback if above fail.
|
| 12 |
image_encoder:
|
| 13 |
name: "microsoft/rad-dino" # informational; backend below drives loading
|
| 14 |
-
backend: "
|
| 15 |
frozen: true # freeze encoder during training
|
| 16 |
-
img_size:
|
| 17 |
output_dim: 768 # patch feature dimension (768 for all backends)
|
| 18 |
|
| 19 |
# ── MLP Projection (Alignment Layer) ────────
|
|
@@ -30,8 +30,25 @@ llm:
|
|
| 30 |
hidden_size: 4096
|
| 31 |
load_in_8bit: false # 8-bit quantization (breaks on newer CUDA)
|
| 32 |
load_in_4bit: true # QLoRA NF4 — recommended for T4 15GB
|
| 33 |
-
torch_dtype: "float16"
|
| 34 |
device_map: "auto"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
# ── LoRA Config ──────────────────────────────
|
| 37 |
lora:
|
|
|
|
| 11 |
# - vit : timm ViT-B/16 ImageNet — generic fallback if above fail.
|
| 12 |
image_encoder:
|
| 13 |
name: "microsoft/rad-dino" # informational; backend below drives loading
|
| 14 |
+
backend: "rad-dino" # "auto" | "rad_dino" | "biovilt" | "vit"
|
| 15 |
frozen: true # freeze encoder during training
|
| 16 |
+
img_size: 518 # input image resolution (RAD-DINO native is 518)
|
| 17 |
output_dim: 768 # patch feature dimension (768 for all backends)
|
| 18 |
|
| 19 |
# ── MLP Projection (Alignment Layer) ────────
|
|
|
|
| 30 |
hidden_size: 4096
|
| 31 |
load_in_8bit: false # 8-bit quantization (breaks on newer CUDA)
|
| 32 |
load_in_4bit: true # QLoRA NF4 — recommended for T4 15GB
|
| 33 |
+
torch_dtype: "float16" # Ampere+ should override to "bfloat16"
|
| 34 |
device_map: "auto"
|
| 35 |
+
# ── Attention backend ──
|
| 36 |
+
# "auto" → let transformers decide.
|
| 37 |
+
# "flash_attention_2" → 2–3× speedup, Ampere+ only, needs `pip install flash-attn`.
|
| 38 |
+
# "sdpa" → Torch 2 native, works on T4 (memory-efficient fallback), good default.
|
| 39 |
+
# "eager" → legacy slow path.
|
| 40 |
+
# The Colab notebook auto-picks: FA2 on Ampere+/Ada, SDPA on T4.
|
| 41 |
+
# A failed flash_attention_2 import auto-falls-back to sdpa in cxr_vlm.py.
|
| 42 |
+
attn_implementation: "auto"
|
| 43 |
+
# ── 4-bit (QLoRA) tuning ──
|
| 44 |
+
# Compute dtype for dequantized matmuls. null → tracks torch_dtype.
|
| 45 |
+
# On Ampere+/Ada set "bfloat16" for better numerical stability.
|
| 46 |
+
bnb_4bit_compute_dtype: null
|
| 47 |
+
bnb_4bit_quant_type: "nf4" # nf4 (default) | fp4
|
| 48 |
+
bnb_4bit_use_double_quant: true # extra ~0.4 bit/param savings
|
| 49 |
+
# Gradient checkpointing: saves activation memory at ~25% step-time cost.
|
| 50 |
+
# On ≥24GB GPUs with 4-bit + LoRA + FA2 you can turn this off.
|
| 51 |
+
gradient_checkpointing: true
|
| 52 |
|
| 53 |
# ── LoRA Config ──────────────────────────────
|
| 54 |
lora:
|
configs/train_config.yaml
CHANGED
|
@@ -130,6 +130,14 @@ data:
|
|
| 130 |
val_split: "validate"
|
| 131 |
test_split: "test"
|
| 132 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
# ── Task Mix Ratios ───────────────────────────
|
| 134 |
# For IU-Xray the `vqa` task is ignored automatically (dataset has no VQA),
|
| 135 |
# and remaining task weights are renormalized to sum to 1.
|
|
@@ -171,6 +179,14 @@ training:
|
|
| 171 |
bf16: false # set true on A100/H100 (better numerical stability, no GradScaler)
|
| 172 |
cutoff_len: 512 # max token length per sample
|
| 173 |
dataloader_num_workers: 4 # bump to 8–16 on A100 hosts with more CPU cores
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
# ── checkpointing ──
|
| 175 |
# "steps": save every `save_steps`; best (lowest eval_loss) is kept, older
|
| 176 |
# overwritten. `save_total_limit=1` → at most 2 folders live (best + latest).
|
|
|
|
| 130 |
val_split: "validate"
|
| 131 |
test_split: "test"
|
| 132 |
|
| 133 |
+
# Optional: directory containing per-image precomputed patch features
|
| 134 |
+
# ({feature_cache_dir}/{image_relpath}.pt → (P, 768) tensor). When set
|
| 135 |
+
# and a cache file exists, dataset.py skips the JPEG/transform and the
|
| 136 |
+
# frozen image encoder is bypassed (model detects the (P, 768) shape).
|
| 137 |
+
# Build via: python -m scripts.precompute_image_features --cache_dir ...
|
| 138 |
+
# null → disabled (default).
|
| 139 |
+
feature_cache_dir: null
|
| 140 |
+
|
| 141 |
# ── Task Mix Ratios ───────────────────────────
|
| 142 |
# For IU-Xray the `vqa` task is ignored automatically (dataset has no VQA),
|
| 143 |
# and remaining task weights are renormalized to sum to 1.
|
|
|
|
| 179 |
bf16: false # set true on A100/H100 (better numerical stability, no GradScaler)
|
| 180 |
cutoff_len: 512 # max token length per sample
|
| 181 |
dataloader_num_workers: 4 # bump to 8–16 on A100 hosts with more CPU cores
|
| 182 |
+
dataloader_pin_memory: true # faster H2D copies; ~free win
|
| 183 |
+
dataloader_persistent_workers: true # keep workers alive between epochs
|
| 184 |
+
# (auto-disabled when num_workers=0)
|
| 185 |
+
# Optimizer choice. "adamw_torch" (fp32 moments) ↔ "paged_adamw_8bit" (bnb
|
| 186 |
+
# block-wise 8-bit moments, ~4× less VRAM, no measurable quality loss per
|
| 187 |
+
# Dettmers ICLR'22). 8-bit needs bitsandbytes + Ampere+ for best perf —
|
| 188 |
+
# the auto-detect cell in scripts/cxrvlm_colab_train.ipynb sets this.
|
| 189 |
+
optim: "adamw_torch"
|
| 190 |
# ── checkpointing ──
|
| 191 |
# "steps": save every `save_steps`; best (lowest eval_loss) is kept, older
|
| 192 |
# overwritten. `save_total_limit=1` → at most 2 folders live (best + latest).
|
data/collator.py
CHANGED
|
@@ -3,6 +3,13 @@ collator.py
|
|
| 3 |
-----------
|
| 4 |
Custom DataCollator that handles variable-length sequences and
|
| 5 |
stacks images into a batch tensor.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
from dataclasses import dataclass
|
|
@@ -13,23 +20,37 @@ import torch
|
|
| 13 |
@dataclass
|
| 14 |
class CXRDataCollator:
|
| 15 |
"""
|
| 16 |
-
Collates a list of dataset samples into a batch.
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
"""
|
| 22 |
pad_token_id: int = 0
|
| 23 |
|
| 24 |
def __call__(self, samples: List[Dict]) -> Dict[str, torch.Tensor]:
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
return {
|
| 31 |
-
"images":
|
| 32 |
-
"input_ids":
|
| 33 |
-
"attention_mask":
|
| 34 |
-
"labels":
|
| 35 |
}
|
|
|
|
| 3 |
-----------
|
| 4 |
Custom DataCollator that handles variable-length sequences and
|
| 5 |
stacks images into a batch tensor.
|
| 6 |
+
|
| 7 |
+
Dynamic padding: input_ids / labels / attention_mask are padded to the
|
| 8 |
+
maximum length WITHIN EACH BATCH, not to `cutoff_len`. Batches drawn
|
| 9 |
+
mostly from short tasks (e.g. VQA) skip the wasted compute on padded
|
| 10 |
+
positions entirely — Llama still runs every matmul on every position,
|
| 11 |
+
so cutting off the padding tail is a direct FLOP saving (~1.5–2× on
|
| 12 |
+
this project's task mix; see commit notes).
|
| 13 |
"""
|
| 14 |
|
| 15 |
from dataclasses import dataclass
|
|
|
|
| 20 |
@dataclass
|
| 21 |
class CXRDataCollator:
|
| 22 |
"""
|
| 23 |
+
Collates a list of dataset samples into a batch with **dynamic padding**.
|
| 24 |
|
| 25 |
+
Args:
|
| 26 |
+
pad_token_id: token id used to pad input_ids. labels are padded
|
| 27 |
+
with -100 (HF Trainer's ignore index for cross-entropy).
|
| 28 |
"""
|
| 29 |
pad_token_id: int = 0
|
| 30 |
|
| 31 |
def __call__(self, samples: List[Dict]) -> Dict[str, torch.Tensor]:
|
| 32 |
+
# Images have fixed shape per dataset config (single-image (C,H,W),
|
| 33 |
+
# multi-image (N,C,H,W), or cached features (P,D)/(N,P,D)) — torch.stack
|
| 34 |
+
# works for any of them.
|
| 35 |
+
images = torch.stack([s["image"] for s in samples])
|
| 36 |
+
|
| 37 |
+
# ── Dynamic text padding ─────────────────────────────────────────
|
| 38 |
+
max_len = max(s["input_ids"].size(0) for s in samples)
|
| 39 |
+
B = len(samples)
|
| 40 |
+
|
| 41 |
+
input_ids = torch.full((B, max_len), self.pad_token_id, dtype=torch.long)
|
| 42 |
+
attention_mask = torch.zeros((B, max_len), dtype=torch.long)
|
| 43 |
+
labels = torch.full((B, max_len), -100, dtype=torch.long)
|
| 44 |
+
|
| 45 |
+
for i, s in enumerate(samples):
|
| 46 |
+
L = s["input_ids"].size(0)
|
| 47 |
+
input_ids[i, :L] = s["input_ids"]
|
| 48 |
+
attention_mask[i, :L] = 1
|
| 49 |
+
labels[i, :L] = s["labels"]
|
| 50 |
|
| 51 |
return {
|
| 52 |
+
"images": images, # (B, ...) image-shape-dependent
|
| 53 |
+
"input_ids": input_ids, # (B, max_len)
|
| 54 |
+
"attention_mask": attention_mask, # (B, max_len)
|
| 55 |
+
"labels": labels, # (B, max_len)
|
| 56 |
}
|
data/dataset.py
CHANGED
|
@@ -112,6 +112,7 @@ class CXRInstructDataset(Dataset):
|
|
| 112 |
cutoff_len: int = 512,
|
| 113 |
task_weights: Optional[Dict[str, float]] = None,
|
| 114 |
max_images: int = 1, # >1 only useful in multi_image_merged mode
|
|
|
|
| 115 |
):
|
| 116 |
self.image_root = Path(image_root)
|
| 117 |
self.tokenizer = tokenizer
|
|
@@ -120,6 +121,12 @@ class CXRInstructDataset(Dataset):
|
|
| 120 |
self.split = split
|
| 121 |
self.cutoff_len = cutoff_len
|
| 122 |
self.max_images = max(1, int(max_images))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
self.task_weights = task_weights or {
|
| 125 |
"findings": 0.4,
|
|
@@ -235,10 +242,15 @@ class CXRInstructDataset(Dataset):
|
|
| 235 |
target = training_sample["target"],
|
| 236 |
)
|
| 237 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
return {
|
| 239 |
"image": image,
|
| 240 |
"input_ids": input_ids,
|
| 241 |
-
"attention_mask": (input_ids
|
| 242 |
"labels": labels,
|
| 243 |
"task": sample["task"], # for per-task logging
|
| 244 |
}
|
|
@@ -267,12 +279,24 @@ class CXRInstructDataset(Dataset):
|
|
| 267 |
|
| 268 |
def _load_image(self, image_path: str) -> torch.Tensor:
|
| 269 |
"""
|
| 270 |
-
Load and transform a chest X-ray image
|
|
|
|
| 271 |
|
| 272 |
Args:
|
| 273 |
image_path: relative path from image_root
|
| 274 |
e.g. "files/p10/p10000032/s50414267/02aa804e.jpg"
|
|
|
|
|
|
|
|
|
|
| 275 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
full_path = self.image_root / image_path
|
| 277 |
image = Image.open(full_path).convert("RGB")
|
| 278 |
|
|
@@ -300,9 +324,15 @@ class CXRInstructDataset(Dataset):
|
|
| 300 |
Tokenize prompt+target. Labels have -100 for prompt tokens
|
| 301 |
(so loss is only computed on target tokens).
|
| 302 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
Returns:
|
| 304 |
-
input_ids: (
|
| 305 |
-
labels: (
|
| 306 |
"""
|
| 307 |
full_text = prompt + " " + target
|
| 308 |
prompt_encoded = self.tokenizer.encode(prompt, add_special_tokens=True)
|
|
@@ -311,19 +341,18 @@ class CXRInstructDataset(Dataset):
|
|
| 311 |
add_special_tokens = True,
|
| 312 |
max_length = self.cutoff_len,
|
| 313 |
truncation = True,
|
| 314 |
-
padding
|
| 315 |
)
|
| 316 |
|
| 317 |
input_ids = torch.tensor(full_encoded, dtype=torch.long)
|
| 318 |
|
| 319 |
-
# Labels: -100 for prompt tokens, actual token ids for target tokens
|
|
|
|
|
|
|
| 320 |
labels = input_ids.clone()
|
| 321 |
prompt_len = min(len(prompt_encoded), self.cutoff_len)
|
| 322 |
labels[:prompt_len] = -100
|
| 323 |
|
| 324 |
-
# Also mask padding
|
| 325 |
-
labels[input_ids == self.tokenizer.pad_token_id] = -100
|
| 326 |
-
|
| 327 |
return input_ids, labels
|
| 328 |
|
| 329 |
|
|
|
|
| 112 |
cutoff_len: int = 512,
|
| 113 |
task_weights: Optional[Dict[str, float]] = None,
|
| 114 |
max_images: int = 1, # >1 only useful in multi_image_merged mode
|
| 115 |
+
feature_cache_dir: Optional[str] = None,
|
| 116 |
):
|
| 117 |
self.image_root = Path(image_root)
|
| 118 |
self.tokenizer = tokenizer
|
|
|
|
| 121 |
self.split = split
|
| 122 |
self.cutoff_len = cutoff_len
|
| 123 |
self.max_images = max(1, int(max_images))
|
| 124 |
+
# When set, _load_image first checks {feature_cache_dir}/{relpath}.pt
|
| 125 |
+
# and returns the cached (P, 768) patch-feature tensor instead of the
|
| 126 |
+
# raw image. The model detects this by tensor last-dim and skips the
|
| 127 |
+
# frozen encoder entirely. Safe because no random augmentation is
|
| 128 |
+
# applied (Resize + ToTensor + Normalize are deterministic).
|
| 129 |
+
self.feature_cache_dir = Path(feature_cache_dir) if feature_cache_dir else None
|
| 130 |
|
| 131 |
self.task_weights = task_weights or {
|
| 132 |
"findings": 0.4,
|
|
|
|
| 242 |
target = training_sample["target"],
|
| 243 |
)
|
| 244 |
|
| 245 |
+
# `input_ids` is un-padded at this point. The collator builds the
|
| 246 |
+
# final attention_mask (1 for real tokens, 0 for batch-level
|
| 247 |
+
# padding) after stacking. We pass an all-ones placeholder of the
|
| 248 |
+
# correct per-sample length so other parts of the pipeline that
|
| 249 |
+
# peek at the dataset output still see the expected key.
|
| 250 |
return {
|
| 251 |
"image": image,
|
| 252 |
"input_ids": input_ids,
|
| 253 |
+
"attention_mask": torch.ones_like(input_ids),
|
| 254 |
"labels": labels,
|
| 255 |
"task": sample["task"], # for per-task logging
|
| 256 |
}
|
|
|
|
| 279 |
|
| 280 |
def _load_image(self, image_path: str) -> torch.Tensor:
|
| 281 |
"""
|
| 282 |
+
Load and transform a chest X-ray image, or load pre-computed patch
|
| 283 |
+
features when a feature cache hits.
|
| 284 |
|
| 285 |
Args:
|
| 286 |
image_path: relative path from image_root
|
| 287 |
e.g. "files/p10/p10000032/s50414267/02aa804e.jpg"
|
| 288 |
+
|
| 289 |
+
Returns either (C, H, W) for raw images or (P, 768) when a cached
|
| 290 |
+
feature file exists at `{feature_cache_dir}/{image_path}.pt`.
|
| 291 |
"""
|
| 292 |
+
# ── Fast path: pre-computed patch features ────────────────────────
|
| 293 |
+
if self.feature_cache_dir is not None:
|
| 294 |
+
cache_path = self.feature_cache_dir / (image_path + ".pt")
|
| 295 |
+
if cache_path.is_file():
|
| 296 |
+
# Load tensor — should be (P, 768) for a single image.
|
| 297 |
+
return torch.load(cache_path, map_location="cpu", weights_only=True)
|
| 298 |
+
|
| 299 |
+
# ── Slow path: read JPEG + transform ──────────────────────────────
|
| 300 |
full_path = self.image_root / image_path
|
| 301 |
image = Image.open(full_path).convert("RGB")
|
| 302 |
|
|
|
|
| 324 |
Tokenize prompt+target. Labels have -100 for prompt tokens
|
| 325 |
(so loss is only computed on target tokens).
|
| 326 |
|
| 327 |
+
Returns UN-PADDED tensors of variable length ≤ cutoff_len. The
|
| 328 |
+
CXRDataCollator pads them to the max length within each batch,
|
| 329 |
+
so short batches (e.g. all-VQA) skip the padded compute entirely.
|
| 330 |
+
Per-sample padding is no longer applied here; that masking lives
|
| 331 |
+
in the collator now.
|
| 332 |
+
|
| 333 |
Returns:
|
| 334 |
+
input_ids: (L,) L ≤ cutoff_len
|
| 335 |
+
labels: (L,) with -100 for prompt positions
|
| 336 |
"""
|
| 337 |
full_text = prompt + " " + target
|
| 338 |
prompt_encoded = self.tokenizer.encode(prompt, add_special_tokens=True)
|
|
|
|
| 341 |
add_special_tokens = True,
|
| 342 |
max_length = self.cutoff_len,
|
| 343 |
truncation = True,
|
| 344 |
+
# No padding — collator pads to max-in-batch (dynamic padding).
|
| 345 |
)
|
| 346 |
|
| 347 |
input_ids = torch.tensor(full_encoded, dtype=torch.long)
|
| 348 |
|
| 349 |
+
# Labels: -100 for prompt tokens, actual token ids for target tokens.
|
| 350 |
+
# (Padding masking now happens in the collator since there's no
|
| 351 |
+
# padding at this stage.)
|
| 352 |
labels = input_ids.clone()
|
| 353 |
prompt_len = min(len(prompt_encoded), self.cutoff_len)
|
| 354 |
labels[:prompt_len] = -100
|
| 355 |
|
|
|
|
|
|
|
|
|
|
| 356 |
return input_ids, labels
|
| 357 |
|
| 358 |
|
evaluation/evaluate.py
CHANGED
|
@@ -247,6 +247,7 @@ def main():
|
|
| 247 |
cutoff_len = train_cfg.training.cutoff_len,
|
| 248 |
task_weights = spec.task_weights,
|
| 249 |
max_images = spec.max_images,
|
|
|
|
| 250 |
)
|
| 251 |
|
| 252 |
# Build task list, intersected with what's available for this dataset.
|
|
|
|
| 247 |
cutoff_len = train_cfg.training.cutoff_len,
|
| 248 |
task_weights = spec.task_weights,
|
| 249 |
max_images = spec.max_images,
|
| 250 |
+
feature_cache_dir = getattr(train_cfg.data, "feature_cache_dir", None) or None,
|
| 251 |
)
|
| 252 |
|
| 253 |
# Build task list, intersected with what's available for this dataset.
|
model/cxr_vlm.py
CHANGED
|
@@ -118,19 +118,45 @@ class CXRVisionLanguageModel(nn.Module):
|
|
| 118 |
torch_dtype = llm_dtype,
|
| 119 |
device_map = model_cfg.llm.device_map,
|
| 120 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
if getattr(model_cfg.llm, "load_in_8bit", False):
|
| 122 |
llm_kwargs["quantization_config"] = BitsAndBytesConfig(load_in_8bit=True)
|
| 123 |
elif getattr(model_cfg.llm, "load_in_4bit", False):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
llm_kwargs["quantization_config"] = BitsAndBytesConfig(
|
| 125 |
load_in_4bit = True,
|
| 126 |
-
bnb_4bit_quant_type = "nf4",
|
| 127 |
-
bnb_4bit_compute_dtype =
|
| 128 |
-
bnb_4bit_use_double_quant = True,
|
| 129 |
)
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
# Resize embeddings to include the newly added <image> special token
|
| 135 |
if len(self.tokenizer) > self.llm.get_input_embeddings().weight.size(0):
|
| 136 |
self.llm.resize_token_embeddings(len(self.tokenizer))
|
|
@@ -151,9 +177,13 @@ class CXRVisionLanguageModel(nn.Module):
|
|
| 151 |
self.llm = get_peft_model(self.llm, lora_cfg)
|
| 152 |
self.llm.print_trainable_parameters()
|
| 153 |
|
| 154 |
-
# Gradient checkpointing to save activations memory
|
| 155 |
-
|
| 156 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
|
| 158 |
# ────────────────────────────────────────────────────────────────────────
|
| 159 |
# Forward pass
|
|
@@ -161,10 +191,20 @@ class CXRVisionLanguageModel(nn.Module):
|
|
| 161 |
|
| 162 |
def _encode_images(self, images: torch.Tensor) -> torch.Tensor:
|
| 163 |
"""
|
| 164 |
-
Run the (frozen) image encoder
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
|
| 166 |
-
|
| 167 |
-
(B,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
|
| 169 |
In multi-image mode all N views are encoded independently and their
|
| 170 |
patch features are concatenated along the patch axis. MLP Projection's
|
|
@@ -172,6 +212,19 @@ class CXRVisionLanguageModel(nn.Module):
|
|
| 172 |
tokens, so the downstream shape (B, 32, 4096) is identical regardless
|
| 173 |
of N — no other module needs to know about multi-image.
|
| 174 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
if images.dim() == 4:
|
| 176 |
return self.image_encoder(images)
|
| 177 |
if images.dim() == 5:
|
|
|
|
| 118 |
torch_dtype = llm_dtype,
|
| 119 |
device_map = model_cfg.llm.device_map,
|
| 120 |
)
|
| 121 |
+
# attn_implementation: "flash_attention_2" (Ampere+ with flash-attn pkg),
|
| 122 |
+
# "sdpa" (Torch 2 native; works on T4), "eager" (legacy), or "auto" → let
|
| 123 |
+
# transformers pick. Falls back gracefully if FA2 is requested but not
|
| 124 |
+
# installed.
|
| 125 |
+
_attn_impl = getattr(model_cfg.llm, "attn_implementation", "auto")
|
| 126 |
+
if _attn_impl and _attn_impl != "auto":
|
| 127 |
+
llm_kwargs["attn_implementation"] = _attn_impl
|
| 128 |
if getattr(model_cfg.llm, "load_in_8bit", False):
|
| 129 |
llm_kwargs["quantization_config"] = BitsAndBytesConfig(load_in_8bit=True)
|
| 130 |
elif getattr(model_cfg.llm, "load_in_4bit", False):
|
| 131 |
+
# Allow explicit override of compute dtype; default tracks llm_dtype.
|
| 132 |
+
_bnb_compute = getattr(model_cfg.llm, "bnb_4bit_compute_dtype", None)
|
| 133 |
+
if _bnb_compute:
|
| 134 |
+
bnb_dtype = _DTYPE_MAP.get(_bnb_compute, llm_dtype)
|
| 135 |
+
else:
|
| 136 |
+
bnb_dtype = llm_dtype if llm_dtype != torch.float32 else torch.float16
|
| 137 |
llm_kwargs["quantization_config"] = BitsAndBytesConfig(
|
| 138 |
load_in_4bit = True,
|
| 139 |
+
bnb_4bit_quant_type = getattr(model_cfg.llm, "bnb_4bit_quant_type", "nf4"),
|
| 140 |
+
bnb_4bit_compute_dtype = bnb_dtype,
|
| 141 |
+
bnb_4bit_use_double_quant = getattr(model_cfg.llm, "bnb_4bit_use_double_quant", True),
|
| 142 |
)
|
| 143 |
+
try:
|
| 144 |
+
self.llm = LlamaForCausalLM.from_pretrained(
|
| 145 |
+
model_cfg.llm.name,
|
| 146 |
+
**llm_kwargs,
|
| 147 |
+
)
|
| 148 |
+
except (ImportError, ValueError) as e:
|
| 149 |
+
# FA2 requested but flash-attn missing / GPU unsupported → fall back to SDPA.
|
| 150 |
+
if "attn_implementation" in llm_kwargs and llm_kwargs["attn_implementation"] == "flash_attention_2":
|
| 151 |
+
print(f"[cxr_vlm] flash_attention_2 unavailable ({type(e).__name__}: {e}); "
|
| 152 |
+
f"falling back to sdpa")
|
| 153 |
+
llm_kwargs["attn_implementation"] = "sdpa"
|
| 154 |
+
self.llm = LlamaForCausalLM.from_pretrained(
|
| 155 |
+
model_cfg.llm.name,
|
| 156 |
+
**llm_kwargs,
|
| 157 |
+
)
|
| 158 |
+
else:
|
| 159 |
+
raise
|
| 160 |
# Resize embeddings to include the newly added <image> special token
|
| 161 |
if len(self.tokenizer) > self.llm.get_input_embeddings().weight.size(0):
|
| 162 |
self.llm.resize_token_embeddings(len(self.tokenizer))
|
|
|
|
| 177 |
self.llm = get_peft_model(self.llm, lora_cfg)
|
| 178 |
self.llm.print_trainable_parameters()
|
| 179 |
|
| 180 |
+
# Gradient checkpointing to save activations memory. On big-VRAM GPUs
|
| 181 |
+
# (≥24GB) with 4-bit + LoRA + FA2 the activations fit without it, so
|
| 182 |
+
# set `model_cfg.llm.gradient_checkpointing=false` to trade ~25% speed
|
| 183 |
+
# back for the saved memory.
|
| 184 |
+
if getattr(model_cfg.llm, "gradient_checkpointing", True):
|
| 185 |
+
self.llm.gradient_checkpointing_enable()
|
| 186 |
+
self.llm.enable_input_require_grads()
|
| 187 |
|
| 188 |
# ────────────────────────────────────────────────────────────────────────
|
| 189 |
# Forward pass
|
|
|
|
| 191 |
|
| 192 |
def _encode_images(self, images: torch.Tensor) -> torch.Tensor:
|
| 193 |
"""
|
| 194 |
+
Run the (frozen) image encoder, or skip it when patch features were
|
| 195 |
+
precomputed and cached. Supported input shapes:
|
| 196 |
+
|
| 197 |
+
Raw images:
|
| 198 |
+
(B, C, H, W) → (B, num_patches, 768) single-image
|
| 199 |
+
(B, N, C, H, W) → (B, N*num_patches, 768) multi-image
|
| 200 |
|
| 201 |
+
Cached patch features (encoder is frozen, so this is loss-free):
|
| 202 |
+
(B, P, 768) → returned as-is single-image
|
| 203 |
+
(B, N, P, 768) → reshaped to (B, N*P, 768) multi-image
|
| 204 |
+
|
| 205 |
+
Disambiguation rule: a tensor whose LAST dim equals the encoder's
|
| 206 |
+
feature dim (768) is treated as cached features. Raw images last
|
| 207 |
+
dim equals the image size (e.g. 448), so the two never collide.
|
| 208 |
|
| 209 |
In multi-image mode all N views are encoded independently and their
|
| 210 |
patch features are concatenated along the patch axis. MLP Projection's
|
|
|
|
| 212 |
tokens, so the downstream shape (B, 32, 4096) is identical regardless
|
| 213 |
of N — no other module needs to know about multi-image.
|
| 214 |
"""
|
| 215 |
+
feature_dim = self.image_encoder.output_dim
|
| 216 |
+
if images.size(-1) == feature_dim:
|
| 217 |
+
# Pre-encoded path: encoder bypass.
|
| 218 |
+
if images.dim() == 3: # (B, P, D)
|
| 219 |
+
return images
|
| 220 |
+
if images.dim() == 4: # (B, N, P, D)
|
| 221 |
+
B, N, P, D = images.shape
|
| 222 |
+
return images.reshape(B, N * P, D) # (B, N*P, D)
|
| 223 |
+
raise ValueError(
|
| 224 |
+
f"Unexpected cached-feature shape: {tuple(images.shape)} "
|
| 225 |
+
f"(last dim = {feature_dim}, expected 3D or 4D)"
|
| 226 |
+
)
|
| 227 |
+
# Raw-image path: run frozen encoder.
|
| 228 |
if images.dim() == 4:
|
| 229 |
return self.image_encoder(images)
|
| 230 |
if images.dim() == 5:
|
scripts/_apply_notebook_edits.py
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""One-shot helper to surgically edit the Colab training notebook.
|
| 2 |
+
|
| 3 |
+
Replaces cell-cfg with the GPU auto-profile version and inserts a new
|
| 4 |
+
'pre-compute image features' cell after it. Idempotent — re-running
|
| 5 |
+
replaces the new cell rather than duplicating it.
|
| 6 |
+
|
| 7 |
+
Run from project root:
|
| 8 |
+
python scripts/_apply_notebook_edits.py
|
| 9 |
+
"""
|
| 10 |
+
import json
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
NB_PATH = Path(__file__).resolve().parent / "cxrvlm_colab_train.ipynb"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
NEW_CFG_SRC = r'''from omegaconf import OmegaConf
|
| 17 |
+
import torch
|
| 18 |
+
|
| 19 |
+
train_cfg = OmegaConf.load(PROJECT / 'configs' / 'train_config.yaml')
|
| 20 |
+
model_cfg = OmegaConf.load(PROJECT / 'configs' / 'model_config.yaml')
|
| 21 |
+
|
| 22 |
+
# ── dataset selector ──
|
| 23 |
+
train_cfg.data.dataset_name = DATASET_NAME
|
| 24 |
+
|
| 25 |
+
# ── training-scheme switches (thesis ablations) ──
|
| 26 |
+
# report_mode: 'split' → 2 tasks (findings + impression separately)
|
| 27 |
+
# 'merged' → 1 task (full report "Findings: ...\n\nImpression: ...")
|
| 28 |
+
# 'split_cascade' → split, but impression's context = GT findings
|
| 29 |
+
# image_mode : 'all_views_split' | 'frontal_only_split' | 'multi_image_merged'
|
| 30 |
+
train_cfg.data.report_mode = 'split'
|
| 31 |
+
train_cfg.data.image_mode = 'all_views_split'
|
| 32 |
+
train_cfg.data.max_images_per_sample = 2 # only used in multi_image_merged
|
| 33 |
+
|
| 34 |
+
# ── dataset-specific paths ──
|
| 35 |
+
if DATASET_NAME == 'MIMIC-CXR':
|
| 36 |
+
train_cfg.data.mimic_cxr_root = str(CXR_ROOT)
|
| 37 |
+
# Base path; the resolver suffixes __{report_mode}__{image_mode} and
|
| 38 |
+
# auto-builds (PNU CheXpert + VQA) via data.mimic_cxr_builder.
|
| 39 |
+
train_cfg.data.instruct_json = str(mimic_json_path)
|
| 40 |
+
train_cfg.data.mimic_auto_build = True
|
| 41 |
+
|
| 42 |
+
# RaDialog / U-MultiClass abnormality guidance: locate the CheXpert
|
| 43 |
+
# label CSV so the builder can bake the PNU structured_findings string.
|
| 44 |
+
_cx = (sorted(DATA_SRC.rglob('*chexpert*.csv'))
|
| 45 |
+
or sorted(DATA_SRC.rglob('*chexbert*.csv')))
|
| 46 |
+
train_cfg.data.mimic_chexpert_csv = str(_cx[0]) if _cx else None
|
| 47 |
+
print('CheXpert CSV :', train_cfg.data.mimic_chexpert_csv
|
| 48 |
+
or 'NOT FOUND — PNU abnormality guidance DISABLED!')
|
| 49 |
+
|
| 50 |
+
# VQA pairs ({train,valid,test}.json) → abnormality-guided VQA.
|
| 51 |
+
train_cfg.data.mimic_vqa_root = str(VQA_ROOT) if VQA_ROOT is not None else None
|
| 52 |
+
print('VQA root :', train_cfg.data.mimic_vqa_root or '(none — VQA skipped)')
|
| 53 |
+
|
| 54 |
+
elif DATASET_NAME == 'MIMIC-CXR_resized':
|
| 55 |
+
# The MIMIC-CXR_resized builder is manifest-driven: it reads
|
| 56 |
+
# `manifest_{train,val,test}.csv` for split + the 14 chex_* labels
|
| 57 |
+
# (PNU bucketed directly from the CSV, no separate chexpert.csv needed),
|
| 58 |
+
# uses `report_relpath` from the manifest to find each .txt, and pulls
|
| 59 |
+
# VQA from `vqa/{vqa,vqa_val,vqa_test}.json`.
|
| 60 |
+
train_cfg.data.mimic_cxr_resized.root = str(MR_ROOT)
|
| 61 |
+
train_cfg.data.mimic_cxr_resized.manifest_dir = None # null → defaults to root
|
| 62 |
+
train_cfg.data.mimic_cxr_resized.vqa_dir = None # null → {root}/vqa
|
| 63 |
+
train_cfg.data.mimic_cxr_resized.reports_root = None # null → auto-probe {root} then {root}/reports
|
| 64 |
+
train_cfg.data.mimic_cxr_resized.instruct_json = str(mr_json_path)
|
| 65 |
+
train_cfg.data.mimic_cxr_resized.auto_build = True
|
| 66 |
+
|
| 67 |
+
else: # IU-Xray
|
| 68 |
+
train_cfg.data.iu_xray.images_dir = str(IU_IMAGES_DIR)
|
| 69 |
+
train_cfg.data.iu_xray.labels_dir = str(IU_LABELS_DIR)
|
| 70 |
+
train_cfg.data.iu_xray.instruct_json = str(iu_json_path)
|
| 71 |
+
train_cfg.data.iu_xray.auto_build = True
|
| 72 |
+
|
| 73 |
+
train_cfg.data.train_split = 'train'
|
| 74 |
+
train_cfg.data.val_split = 'validate'
|
| 75 |
+
train_cfg.data.test_split = 'test'
|
| 76 |
+
|
| 77 |
+
# ── checkpoint root (Persistence keeps /content/ckpt/) ──
|
| 78 |
+
CKPT_ROOT = WORK / 'ckpt'
|
| 79 |
+
train_cfg.training.output_root = str(CKPT_ROOT)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# ─────────────────────────────────────────────────────────────────────────
|
| 83 |
+
# ── GPU auto-profile ────────────────────────────────────────────────────
|
| 84 |
+
# Pick batch size / precision / attention backend / GC / optimizer based on
|
| 85 |
+
# what the current GPU can actually do. Override anything below this block
|
| 86 |
+
# if you want to force a specific setting.
|
| 87 |
+
#
|
| 88 |
+
# Profile rules (compute capability + total VRAM):
|
| 89 |
+
# T4 (sm_75, 15GB) → FP16 + SDPA + GC ON + bs=1 accum=16 + fp32 AdamW
|
| 90 |
+
# 3090/L4/A10 (sm_80+, 24GB) → BF16 + FA2 + GC ON + bs=8 accum=2 + 8-bit AdamW
|
| 91 |
+
# A100 40GB (sm_80, 40GB) → BF16 + FA2 + GC OFF + bs=8 accum=2 + 8-bit AdamW
|
| 92 |
+
# A100/H100 80GB (sm_80+, 80G) → BF16 + FA2 + GC OFF + bs=8 accum=2 + 8-bit AdamW
|
| 93 |
+
# unknown → conservative T4-style profile
|
| 94 |
+
#
|
| 95 |
+
# Why GC ON for 24GB? Bigger batch amortizes the ~25-30% GC overhead.
|
| 96 |
+
# Math (eff batch = 16):
|
| 97 |
+
# GC OFF, bs=4, accum=4 → 4 × T = 4.0T per eff-batch
|
| 98 |
+
# GC ON, bs=8, accum=2 → 2 × 1.5T × 1.3 = 3.9T per eff-batch ✓
|
| 99 |
+
# Sub-linear GPU scaling (time(bs=8) ≈ 1.5 × time(bs=4), not 2×) is what
|
| 100 |
+
# tips the balance. On 40GB+ there's room without GC so we skip it there.
|
| 101 |
+
|
| 102 |
+
assert torch.cuda.is_available(), 'CUDA not available — refusing to write a CPU profile.'
|
| 103 |
+
_props = torch.cuda.get_device_properties(0)
|
| 104 |
+
_cap = (_props.major, _props.minor)
|
| 105 |
+
_vram_gb = _props.total_memory / 1e9
|
| 106 |
+
_bf16_ok = torch.cuda.is_bf16_supported()
|
| 107 |
+
_fa2_ok = _cap >= (8, 0) # FA2 needs Ampere+ (sm_80 or newer)
|
| 108 |
+
|
| 109 |
+
print(f'GPU : {_props.name} ({_vram_gb:.1f} GB)')
|
| 110 |
+
print(f'Compute cap : sm_{_cap[0]}{_cap[1]}')
|
| 111 |
+
print(f'BF16 native : {_bf16_ok}')
|
| 112 |
+
print(f'FA2 capable : {_fa2_ok}')
|
| 113 |
+
|
| 114 |
+
# Try to detect whether flash-attn package is actually importable. If FA2 is
|
| 115 |
+
# requested by the profile but the wheel isn't installed, cxr_vlm.py will
|
| 116 |
+
# auto-fall-back to sdpa, but we surface it here so the user knows.
|
| 117 |
+
_flash_attn_installed = False
|
| 118 |
+
if _fa2_ok:
|
| 119 |
+
try:
|
| 120 |
+
import flash_attn # noqa: F401
|
| 121 |
+
_flash_attn_installed = True
|
| 122 |
+
except Exception:
|
| 123 |
+
_flash_attn_installed = False
|
| 124 |
+
print(f'flash-attn : {"installed" if _flash_attn_installed else "NOT installed (will fall back to sdpa)"}')
|
| 125 |
+
|
| 126 |
+
# ── Pick profile ─────────────────────────────────────────────────────────
|
| 127 |
+
if _vram_gb >= 70: # A100/H100 80GB
|
| 128 |
+
_profile = dict(
|
| 129 |
+
label='A100/H100 80GB',
|
| 130 |
+
per_device_train_batch_size=8, per_device_eval_batch_size=8,
|
| 131 |
+
gradient_accumulation_steps=2, dataloader_num_workers=16,
|
| 132 |
+
gradient_checkpointing=False,
|
| 133 |
+
)
|
| 134 |
+
elif _vram_gb >= 35: # A100 40GB
|
| 135 |
+
_profile = dict(
|
| 136 |
+
label='A100 40GB',
|
| 137 |
+
per_device_train_batch_size=8, per_device_eval_batch_size=8,
|
| 138 |
+
gradient_accumulation_steps=2, dataloader_num_workers=12,
|
| 139 |
+
gradient_checkpointing=False,
|
| 140 |
+
)
|
| 141 |
+
elif _vram_gb >= 22: # 3090 / L4 / A10 24GB
|
| 142 |
+
# GC ON + bigger batch beats GC OFF + smaller batch on throughput here.
|
| 143 |
+
# Per-eff-batch wall time (eff=16): 4×T (GC OFF, bs=4) vs ~3.9×T (GC ON,
|
| 144 |
+
# bs=8) — sub-linear scaling means bs=8 step is ~1.5×T, not 2×T, so the
|
| 145 |
+
# GC overhead (~1.3×) is more than paid back.
|
| 146 |
+
_profile = dict(
|
| 147 |
+
label='RTX 3090 / L4 / A10 (24GB)',
|
| 148 |
+
per_device_train_batch_size=8, per_device_eval_batch_size=8,
|
| 149 |
+
gradient_accumulation_steps=2, dataloader_num_workers=8,
|
| 150 |
+
gradient_checkpointing=True,
|
| 151 |
+
)
|
| 152 |
+
elif _vram_gb >= 14: # T4 / V100 16GB
|
| 153 |
+
_profile = dict(
|
| 154 |
+
label='T4 / V100 (15-16GB)',
|
| 155 |
+
per_device_train_batch_size=1, per_device_eval_batch_size=1,
|
| 156 |
+
gradient_accumulation_steps=16, dataloader_num_workers=2,
|
| 157 |
+
gradient_checkpointing=True,
|
| 158 |
+
)
|
| 159 |
+
else: # tiny / unknown
|
| 160 |
+
_profile = dict(
|
| 161 |
+
label=f'unknown ({_vram_gb:.0f}GB) — conservative',
|
| 162 |
+
per_device_train_batch_size=1, per_device_eval_batch_size=1,
|
| 163 |
+
gradient_accumulation_steps=16, dataloader_num_workers=2,
|
| 164 |
+
gradient_checkpointing=True,
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
# Precision: BF16 on Ampere+, FP16 on Turing (T4) and older.
|
| 168 |
+
_profile['bf16'] = bool(_bf16_ok)
|
| 169 |
+
_profile['fp16'] = not _bf16_ok
|
| 170 |
+
|
| 171 |
+
# Attention backend: FA2 if Ampere+ AND flash-attn wheel present, else SDPA.
|
| 172 |
+
_profile['attn_implementation'] = (
|
| 173 |
+
'flash_attention_2' if (_fa2_ok and _flash_attn_installed) else 'sdpa'
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
# 8-bit AdamW: bnb's paged_adamw_8bit cuts optimizer-state VRAM ~4× with no
|
| 177 |
+
# measurable quality loss. Skip on Turing where bnb paged optimizer perf is
|
| 178 |
+
# weaker — keep adamw_torch there.
|
| 179 |
+
_profile['optim'] = 'paged_adamw_8bit' if _cap >= (8, 0) else 'adamw_torch'
|
| 180 |
+
|
| 181 |
+
# 4-bit compute dtype tracks precision.
|
| 182 |
+
_profile['bnb_4bit_compute_dtype'] = 'bfloat16' if _bf16_ok else 'float16'
|
| 183 |
+
_profile['torch_dtype'] = 'bfloat16' if _bf16_ok else 'float16'
|
| 184 |
+
|
| 185 |
+
print(f'\n→ Profile : {_profile["label"]}')
|
| 186 |
+
for k, v in _profile.items():
|
| 187 |
+
if k == 'label': continue
|
| 188 |
+
print(f' {k:<32}= {v}')
|
| 189 |
+
|
| 190 |
+
# ── Write profile into the configs ───────────────────────────────────────
|
| 191 |
+
train_cfg.training.per_device_train_batch_size = _profile['per_device_train_batch_size']
|
| 192 |
+
train_cfg.training.per_device_eval_batch_size = _profile['per_device_eval_batch_size']
|
| 193 |
+
train_cfg.training.gradient_accumulation_steps = _profile['gradient_accumulation_steps']
|
| 194 |
+
train_cfg.training.dataloader_num_workers = _profile['dataloader_num_workers']
|
| 195 |
+
train_cfg.training.fp16 = _profile['fp16']
|
| 196 |
+
train_cfg.training.bf16 = _profile['bf16']
|
| 197 |
+
train_cfg.training.dataloader_pin_memory = True
|
| 198 |
+
train_cfg.training.dataloader_persistent_workers = True
|
| 199 |
+
train_cfg.training.optim = _profile['optim']
|
| 200 |
+
# Ensure stage2 still uses the same per-run epoch count we want.
|
| 201 |
+
train_cfg.stage2.num_epochs = 5
|
| 202 |
+
|
| 203 |
+
model_cfg.llm.attn_implementation = _profile['attn_implementation']
|
| 204 |
+
model_cfg.llm.gradient_checkpointing = _profile['gradient_checkpointing']
|
| 205 |
+
model_cfg.llm.torch_dtype = _profile['torch_dtype']
|
| 206 |
+
model_cfg.llm.bnb_4bit_compute_dtype = _profile['bnb_4bit_compute_dtype']
|
| 207 |
+
model_cfg.llm.bnb_4bit_quant_type = 'nf4'
|
| 208 |
+
model_cfg.llm.bnb_4bit_use_double_quant = True
|
| 209 |
+
|
| 210 |
+
# ── task weights (sampling ratio enforced by WeightedRandomSampler) ──
|
| 211 |
+
# Defaults in train_config.yaml: 0.30 / 0.20 / 0.50 (RRG ≈ VQA, impression
|
| 212 |
+
# lower because in split_cascade mode it sees GT findings as input).
|
| 213 |
+
# Resolver auto-renormalizes and drops vqa for IU-Xray. Override here only
|
| 214 |
+
# if you want to experiment per-run, e.g.:
|
| 215 |
+
# train_cfg.tasks.findings_generation.weight = 0.30
|
| 216 |
+
# train_cfg.tasks.impression_generation.weight = 0.20
|
| 217 |
+
# train_cfg.tasks.vqa.weight = 0.50
|
| 218 |
+
|
| 219 |
+
# ── wandb off ──
|
| 220 |
+
train_cfg.wandb.enabled = False
|
| 221 |
+
|
| 222 |
+
# ── HuggingFace Hub run tracking ──
|
| 223 |
+
train_cfg.hf_hub.enabled = True
|
| 224 |
+
train_cfg.hf_hub.repo_id = 'hieu3636/cxr-vlm-runs' # <<< EDIT ME
|
| 225 |
+
train_cfg.hf_hub.token_env = 'HF_TOKEN'
|
| 226 |
+
train_cfg.hf_hub.private = True
|
| 227 |
+
train_cfg.hf_hub.run_state_file = str(CKPT_ROOT / 'run_id.txt')
|
| 228 |
+
|
| 229 |
+
# ── 4-bit QLoRA ──
|
| 230 |
+
model_cfg.llm.load_in_8bit = False
|
| 231 |
+
model_cfg.llm.load_in_4bit = True
|
| 232 |
+
# Oracle PNU path does NOT use the CheXpert classifier module (labels come
|
| 233 |
+
# from the GT csv/manifest baked into the prompt). Keep it disabled until
|
| 234 |
+
# you wire the learned classifier for realistic inference.
|
| 235 |
+
model_cfg.chexpert_classifier.enabled = False
|
| 236 |
+
|
| 237 |
+
OmegaConf.save(train_cfg, PROJECT / 'configs' / 'train_config.yaml')
|
| 238 |
+
OmegaConf.save(model_cfg, PROJECT / 'configs' / 'model_config.yaml')
|
| 239 |
+
|
| 240 |
+
print('--- train_cfg.data ---'); print(OmegaConf.to_yaml(train_cfg.data))
|
| 241 |
+
print('--- train_cfg.tasks ---'); print(OmegaConf.to_yaml(train_cfg.tasks))
|
| 242 |
+
print('--- train_cfg.training ---');print(OmegaConf.to_yaml(train_cfg.training))
|
| 243 |
+
print('--- train_cfg.hf_hub ---'); print(OmegaConf.to_yaml(train_cfg.hf_hub))
|
| 244 |
+
print('--- model_cfg.llm ---'); print(OmegaConf.to_yaml(model_cfg.llm))
|
| 245 |
+
'''
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
FEATURE_CACHE_SRC = r'''# ─── Optional: pre-compute image patch features (skip frozen encoder forward) ──
|
| 249 |
+
#
|
| 250 |
+
# The image encoder is frozen + the transform is deterministic, so encoding the
|
| 251 |
+
# same image every step is wasted work. Run this ONCE per dataset to cache
|
| 252 |
+
# (P, 768) patch tensors under {WORK}/feature_cache/{DATASET_NAME}/ and the
|
| 253 |
+
# training loop will load them instead of re-encoding.
|
| 254 |
+
#
|
| 255 |
+
# Set CACHE_FEATURES = False to skip (e.g. first time you set up the run, want
|
| 256 |
+
# the smoke test to use the raw path, or you're debugging the encoder).
|
| 257 |
+
#
|
| 258 |
+
# Disk usage: ~3 MB per image (P=1024 patches × 768 dim × fp16). For ~30k
|
| 259 |
+
# unique images that's ~90 GB — make sure WORK has the room, or set
|
| 260 |
+
# CACHE_FEATURES=False on tight quotas.
|
| 261 |
+
|
| 262 |
+
CACHE_FEATURES = True
|
| 263 |
+
|
| 264 |
+
if CACHE_FEATURES:
|
| 265 |
+
feature_cache_dir = WORK / 'feature_cache' / DATASET_NAME
|
| 266 |
+
feature_cache_dir.mkdir(parents=True, exist_ok=True)
|
| 267 |
+
train_cfg.data.feature_cache_dir = str(feature_cache_dir)
|
| 268 |
+
OmegaConf.save(train_cfg, PROJECT / 'configs' / 'train_config.yaml')
|
| 269 |
+
|
| 270 |
+
# Re-running this cell is safe: --overwrite is OFF by default so cached
|
| 271 |
+
# files are skipped. To force a full rebuild, add `--overwrite` below.
|
| 272 |
+
print(f'feature_cache_dir = {feature_cache_dir}')
|
| 273 |
+
!python -m scripts.precompute_image_features \
|
| 274 |
+
--model_config configs/model_config.yaml \
|
| 275 |
+
--train_config configs/train_config.yaml \
|
| 276 |
+
--cache_dir "{feature_cache_dir}" \
|
| 277 |
+
--batch_size 16
|
| 278 |
+
else:
|
| 279 |
+
train_cfg.data.feature_cache_dir = None
|
| 280 |
+
OmegaConf.save(train_cfg, PROJECT / 'configs' / 'train_config.yaml')
|
| 281 |
+
print('Feature cache DISABLED. Training will run the image encoder every step.')
|
| 282 |
+
'''
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def src_to_lines(s: str):
|
| 286 |
+
"""Convert a string into Jupyter's list-of-lines source representation."""
|
| 287 |
+
lines = s.split("\n")
|
| 288 |
+
return [ln + "\n" for ln in lines[:-1]] + ([lines[-1]] if lines[-1] else [])
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
def main():
|
| 292 |
+
with open(NB_PATH, "r", encoding="utf-8") as f:
|
| 293 |
+
nb = json.load(f)
|
| 294 |
+
|
| 295 |
+
# Find cell-cfg index
|
| 296 |
+
cfg_idx = None
|
| 297 |
+
for i, c in enumerate(nb["cells"]):
|
| 298 |
+
if c.get("id") == "cell-cfg":
|
| 299 |
+
cfg_idx = i
|
| 300 |
+
break
|
| 301 |
+
if cfg_idx is None:
|
| 302 |
+
raise RuntimeError("cell-cfg not found in notebook")
|
| 303 |
+
print(f"cell-cfg at index {cfg_idx}")
|
| 304 |
+
|
| 305 |
+
# Replace cell-cfg
|
| 306 |
+
nb["cells"][cfg_idx]["source"] = src_to_lines(NEW_CFG_SRC)
|
| 307 |
+
nb["cells"][cfg_idx]["outputs"] = []
|
| 308 |
+
nb["cells"][cfg_idx]["execution_count"] = None
|
| 309 |
+
|
| 310 |
+
# Remove any pre-existing feature-cache cells (idempotent re-run)
|
| 311 |
+
nb["cells"] = [
|
| 312 |
+
c for c in nb["cells"]
|
| 313 |
+
if c.get("id") not in ("cell-feature-cache", "cell-feature-cache-md")
|
| 314 |
+
]
|
| 315 |
+
|
| 316 |
+
# Re-find cell-cfg index (may have shifted if we removed earlier ones — but
|
| 317 |
+
# those would have been after it, so index is stable)
|
| 318 |
+
for i, c in enumerate(nb["cells"]):
|
| 319 |
+
if c.get("id") == "cell-cfg":
|
| 320 |
+
cfg_idx = i
|
| 321 |
+
break
|
| 322 |
+
|
| 323 |
+
# Insert markdown + code cells after cell-cfg
|
| 324 |
+
md_cell = {
|
| 325 |
+
"cell_type": "markdown",
|
| 326 |
+
"id": "cell-feature-cache-md",
|
| 327 |
+
"metadata": {},
|
| 328 |
+
"source": ["## 4b. Pre-compute image features (optional speedup)\n"],
|
| 329 |
+
}
|
| 330 |
+
code_cell = {
|
| 331 |
+
"cell_type": "code",
|
| 332 |
+
"id": "cell-feature-cache",
|
| 333 |
+
"metadata": {},
|
| 334 |
+
"execution_count": None,
|
| 335 |
+
"outputs": [],
|
| 336 |
+
"source": src_to_lines(FEATURE_CACHE_SRC),
|
| 337 |
+
}
|
| 338 |
+
nb["cells"].insert(cfg_idx + 1, md_cell)
|
| 339 |
+
nb["cells"].insert(cfg_idx + 2, code_cell)
|
| 340 |
+
|
| 341 |
+
with open(NB_PATH, "w", encoding="utf-8") as f:
|
| 342 |
+
json.dump(nb, f, indent=1, ensure_ascii=False)
|
| 343 |
+
f.write("\n")
|
| 344 |
+
|
| 345 |
+
print(f"Wrote {NB_PATH}")
|
| 346 |
+
print(f"New cell count: {len(nb['cells'])}")
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
if __name__ == "__main__":
|
| 350 |
+
main()
|
scripts/cxrvlm_colab_train.ipynb
CHANGED
|
@@ -90,30 +90,7 @@
|
|
| 90 |
},
|
| 91 |
"outputId": "9f59fa97-9392-4d73-da56-b6f791fe1d37"
|
| 92 |
},
|
| 93 |
-
"source": [
|
| 94 |
-
"!pip uninstall -y -q torchao transformers bitsandbytes peft accelerate\n",
|
| 95 |
-
"\n",
|
| 96 |
-
"# Let pip pick latest bnb that matches Colab's CUDA 12.8 + triton 3.x\n",
|
| 97 |
-
"!pip install -q -U bitsandbytes\n",
|
| 98 |
-
"\n",
|
| 99 |
-
"# Transformers ≥4.46 fixed the frozenset bug; don't over-pin\n",
|
| 100 |
-
"!pip install -q \\\n",
|
| 101 |
-
" 'transformers>=4.46,<4.50' \\\n",
|
| 102 |
-
" 'peft>=0.13,<0.15' \\\n",
|
| 103 |
-
" 'accelerate>=1.0'\n",
|
| 104 |
-
"\n",
|
| 105 |
-
"!pip install -q \\\n",
|
| 106 |
-
" 'huggingface_hub>=0.24,<0.27' \\\n",
|
| 107 |
-
" omegaconf sentencepiece 'protobuf>=3.20' \\\n",
|
| 108 |
-
" nltk rouge-score bert-score sacrebleu\n",
|
| 109 |
-
"\n",
|
| 110 |
-
"import torch, transformers, bitsandbytes, peft, accelerate\n",
|
| 111 |
-
"print('torch :', torch.__version__, '| cuda:', torch.cuda.is_available())\n",
|
| 112 |
-
"print('transformers :', transformers.__version__)\n",
|
| 113 |
-
"print('bitsandbytes :', bitsandbytes.__version__)\n",
|
| 114 |
-
"print('peft :', peft.__version__)\n",
|
| 115 |
-
"print('accelerate :', accelerate.__version__)"
|
| 116 |
-
],
|
| 117 |
"execution_count": null,
|
| 118 |
"outputs": [
|
| 119 |
{
|
|
@@ -294,11 +271,292 @@
|
|
| 294 |
},
|
| 295 |
"outputId": "80ddabe3-bc8b-4d14-94e2-26ff9e64970c"
|
| 296 |
},
|
| 297 |
-
"source":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
"execution_count": null,
|
| 299 |
"outputs": [],
|
| 300 |
"id": "cell-cfg"
|
| 301 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
{
|
| 303 |
"cell_type": "markdown",
|
| 304 |
"metadata": {
|
|
|
|
| 90 |
},
|
| 91 |
"outputId": "9f59fa97-9392-4d73-da56-b6f791fe1d37"
|
| 92 |
},
|
| 93 |
+
"source": "!pip uninstall -y -q torchao transformers bitsandbytes peft accelerate httpx\n\n# Let pip pick latest bnb that matches Colab's CUDA 12.8 + triton 3.x\n!pip install -q -U bitsandbytes\n\n# httpx ≥0.28 REMOVED the `allow_redirects` kwarg (renamed to `follow_redirects`\n# back in 0.21). huggingface_hub still passes `allow_redirects=...` in some\n# code paths → `Client.head() got an unexpected keyword argument 'allow_redirects'`.\n# Pin httpx<0.28 to keep the legacy kwarg working until hub catches up.\n!pip install -q 'httpx>=0.24,<0.28'\n\n# Transformers ≥4.46 fixed the frozenset bug; don't over-pin\n!pip install -q \\\n 'transformers>=4.46,<4.50' \\\n 'peft>=0.13,<0.15' \\\n 'accelerate>=1.0'\n\n!pip install -q \\\n 'huggingface_hub>=0.27,<2.0' \\\n omegaconf sentencepiece 'protobuf>=3.20' \\\n nltk rouge-score bert-score sacrebleu\n\nimport torch, transformers, bitsandbytes, peft, accelerate, huggingface_hub, httpx\nprint('torch :', torch.__version__, '| cuda:', torch.cuda.is_available())\nprint('transformers :', transformers.__version__)\nprint('bitsandbytes :', bitsandbytes.__version__)\nprint('peft :', peft.__version__)\nprint('accelerate :', accelerate.__version__)\nprint('huggingface_hub:', huggingface_hub.__version__)\nprint('httpx :', httpx.__version__, ' (must be <0.28)')\nassert tuple(int(x) for x in httpx.__version__.split('.')[:2]) < (0, 28), \\\n 'httpx>=0.28 will break huggingface_hub. Re-run this cell or restart runtime.'",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
"execution_count": null,
|
| 95 |
"outputs": [
|
| 96 |
{
|
|
|
|
| 271 |
},
|
| 272 |
"outputId": "80ddabe3-bc8b-4d14-94e2-26ff9e64970c"
|
| 273 |
},
|
| 274 |
+
"source": [
|
| 275 |
+
"from omegaconf import OmegaConf\n",
|
| 276 |
+
"import torch\n",
|
| 277 |
+
"\n",
|
| 278 |
+
"train_cfg = OmegaConf.load(PROJECT / 'configs' / 'train_config.yaml')\n",
|
| 279 |
+
"model_cfg = OmegaConf.load(PROJECT / 'configs' / 'model_config.yaml')\n",
|
| 280 |
+
"\n",
|
| 281 |
+
"# ── dataset selector ──\n",
|
| 282 |
+
"train_cfg.data.dataset_name = DATASET_NAME\n",
|
| 283 |
+
"\n",
|
| 284 |
+
"# ── training-scheme switches (thesis ablations) ──\n",
|
| 285 |
+
"# report_mode: 'split' → 2 tasks (findings + impression separately)\n",
|
| 286 |
+
"# 'merged' → 1 task (full report \"Findings: ...\\n\\nImpression: ...\")\n",
|
| 287 |
+
"# 'split_cascade' → split, but impression's context = GT findings\n",
|
| 288 |
+
"# image_mode : 'all_views_split' | 'frontal_only_split' | 'multi_image_merged'\n",
|
| 289 |
+
"train_cfg.data.report_mode = 'split'\n",
|
| 290 |
+
"train_cfg.data.image_mode = 'all_views_split'\n",
|
| 291 |
+
"train_cfg.data.max_images_per_sample = 2 # only used in multi_image_merged\n",
|
| 292 |
+
"\n",
|
| 293 |
+
"# ── dataset-specific paths ──\n",
|
| 294 |
+
"if DATASET_NAME == 'MIMIC-CXR':\n",
|
| 295 |
+
" train_cfg.data.mimic_cxr_root = str(CXR_ROOT)\n",
|
| 296 |
+
" # Base path; the resolver suffixes __{report_mode}__{image_mode} and\n",
|
| 297 |
+
" # auto-builds (PNU CheXpert + VQA) via data.mimic_cxr_builder.\n",
|
| 298 |
+
" train_cfg.data.instruct_json = str(mimic_json_path)\n",
|
| 299 |
+
" train_cfg.data.mimic_auto_build = True\n",
|
| 300 |
+
"\n",
|
| 301 |
+
" # RaDialog / U-MultiClass abnormality guidance: locate the CheXpert\n",
|
| 302 |
+
" # label CSV so the builder can bake the PNU structured_findings string.\n",
|
| 303 |
+
" _cx = (sorted(DATA_SRC.rglob('*chexpert*.csv'))\n",
|
| 304 |
+
" or sorted(DATA_SRC.rglob('*chexbert*.csv')))\n",
|
| 305 |
+
" train_cfg.data.mimic_chexpert_csv = str(_cx[0]) if _cx else None\n",
|
| 306 |
+
" print('CheXpert CSV :', train_cfg.data.mimic_chexpert_csv\n",
|
| 307 |
+
" or 'NOT FOUND — PNU abnormality guidance DISABLED!')\n",
|
| 308 |
+
"\n",
|
| 309 |
+
" # VQA pairs ({train,valid,test}.json) → abnormality-guided VQA.\n",
|
| 310 |
+
" train_cfg.data.mimic_vqa_root = str(VQA_ROOT) if VQA_ROOT is not None else None\n",
|
| 311 |
+
" print('VQA root :', train_cfg.data.mimic_vqa_root or '(none — VQA skipped)')\n",
|
| 312 |
+
"\n",
|
| 313 |
+
"elif DATASET_NAME == 'MIMIC-CXR_resized':\n",
|
| 314 |
+
" # The MIMIC-CXR_resized builder is manifest-driven: it reads\n",
|
| 315 |
+
" # `manifest_{train,val,test}.csv` for split + the 14 chex_* labels\n",
|
| 316 |
+
" # (PNU bucketed directly from the CSV, no separate chexpert.csv needed),\n",
|
| 317 |
+
" # uses `report_relpath` from the manifest to find each .txt, and pulls\n",
|
| 318 |
+
" # VQA from `vqa/{vqa,vqa_val,vqa_test}.json`.\n",
|
| 319 |
+
" train_cfg.data.mimic_cxr_resized.root = str(MR_ROOT)\n",
|
| 320 |
+
" train_cfg.data.mimic_cxr_resized.manifest_dir = None # null → defaults to root\n",
|
| 321 |
+
" train_cfg.data.mimic_cxr_resized.vqa_dir = None # null → {root}/vqa\n",
|
| 322 |
+
" train_cfg.data.mimic_cxr_resized.reports_root = None # null → auto-probe {root} then {root}/reports\n",
|
| 323 |
+
" train_cfg.data.mimic_cxr_resized.instruct_json = str(mr_json_path)\n",
|
| 324 |
+
" train_cfg.data.mimic_cxr_resized.auto_build = True\n",
|
| 325 |
+
"\n",
|
| 326 |
+
"else: # IU-Xray\n",
|
| 327 |
+
" train_cfg.data.iu_xray.images_dir = str(IU_IMAGES_DIR)\n",
|
| 328 |
+
" train_cfg.data.iu_xray.labels_dir = str(IU_LABELS_DIR)\n",
|
| 329 |
+
" train_cfg.data.iu_xray.instruct_json = str(iu_json_path)\n",
|
| 330 |
+
" train_cfg.data.iu_xray.auto_build = True\n",
|
| 331 |
+
"\n",
|
| 332 |
+
"train_cfg.data.train_split = 'train'\n",
|
| 333 |
+
"train_cfg.data.val_split = 'validate'\n",
|
| 334 |
+
"train_cfg.data.test_split = 'test'\n",
|
| 335 |
+
"\n",
|
| 336 |
+
"# ── checkpoint root (Persistence keeps /content/ckpt/) ──\n",
|
| 337 |
+
"CKPT_ROOT = WORK / 'ckpt'\n",
|
| 338 |
+
"train_cfg.training.output_root = str(CKPT_ROOT)\n",
|
| 339 |
+
"\n",
|
| 340 |
+
"\n",
|
| 341 |
+
"# ─────────────────────────────────────────────────────────────────────────\n",
|
| 342 |
+
"# ── GPU auto-profile ────────────────────────────────────────────────────\n",
|
| 343 |
+
"# Pick batch size / precision / attention backend / GC / optimizer based on\n",
|
| 344 |
+
"# what the current GPU can actually do. Override anything below this block\n",
|
| 345 |
+
"# if you want to force a specific setting.\n",
|
| 346 |
+
"#\n",
|
| 347 |
+
"# Profile rules (compute capability + total VRAM):\n",
|
| 348 |
+
"# T4 (sm_75, 15GB) → FP16 + SDPA + GC ON + bs=1 accum=16 + fp32 AdamW\n",
|
| 349 |
+
"# 3090/L4/A10 (sm_80+, 24GB) → BF16 + FA2 + GC ON + bs=8 accum=2 + 8-bit AdamW\n",
|
| 350 |
+
"# A100 40GB (sm_80, 40GB) → BF16 + FA2 + GC OFF + bs=8 accum=2 + 8-bit AdamW\n",
|
| 351 |
+
"# A100/H100 80GB (sm_80+, 80G) → BF16 + FA2 + GC OFF + bs=8 accum=2 + 8-bit AdamW\n",
|
| 352 |
+
"# unknown → conservative T4-style profile\n",
|
| 353 |
+
"#\n",
|
| 354 |
+
"# Why GC ON for 24GB? Bigger batch amortizes the ~25-30% GC overhead.\n",
|
| 355 |
+
"# Math (eff batch = 16):\n",
|
| 356 |
+
"# GC OFF, bs=4, accum=4 → 4 × T = 4.0T per eff-batch\n",
|
| 357 |
+
"# GC ON, bs=8, accum=2 → 2 × 1.5T × 1.3 = 3.9T per eff-batch ✓\n",
|
| 358 |
+
"# Sub-linear GPU scaling (time(bs=8) ≈ 1.5 × time(bs=4), not 2×) is what\n",
|
| 359 |
+
"# tips the balance. On 40GB+ there's room without GC so we skip it there.\n",
|
| 360 |
+
"\n",
|
| 361 |
+
"assert torch.cuda.is_available(), 'CUDA not available — refusing to write a CPU profile.'\n",
|
| 362 |
+
"_props = torch.cuda.get_device_properties(0)\n",
|
| 363 |
+
"_cap = (_props.major, _props.minor)\n",
|
| 364 |
+
"_vram_gb = _props.total_memory / 1e9\n",
|
| 365 |
+
"_bf16_ok = torch.cuda.is_bf16_supported()\n",
|
| 366 |
+
"_fa2_ok = _cap >= (8, 0) # FA2 needs Ampere+ (sm_80 or newer)\n",
|
| 367 |
+
"\n",
|
| 368 |
+
"print(f'GPU : {_props.name} ({_vram_gb:.1f} GB)')\n",
|
| 369 |
+
"print(f'Compute cap : sm_{_cap[0]}{_cap[1]}')\n",
|
| 370 |
+
"print(f'BF16 native : {_bf16_ok}')\n",
|
| 371 |
+
"print(f'FA2 capable : {_fa2_ok}')\n",
|
| 372 |
+
"\n",
|
| 373 |
+
"# Try to detect whether flash-attn package is actually importable. If FA2 is\n",
|
| 374 |
+
"# requested by the profile but the wheel isn't installed, cxr_vlm.py will\n",
|
| 375 |
+
"# auto-fall-back to sdpa, but we surface it here so the user knows.\n",
|
| 376 |
+
"_flash_attn_installed = False\n",
|
| 377 |
+
"if _fa2_ok:\n",
|
| 378 |
+
" try:\n",
|
| 379 |
+
" import flash_attn # noqa: F401\n",
|
| 380 |
+
" _flash_attn_installed = True\n",
|
| 381 |
+
" except Exception:\n",
|
| 382 |
+
" _flash_attn_installed = False\n",
|
| 383 |
+
"print(f'flash-attn : {\"installed\" if _flash_attn_installed else \"NOT installed (will fall back to sdpa)\"}')\n",
|
| 384 |
+
"\n",
|
| 385 |
+
"# ── Pick profile ─────────────────────────────────────────────────────────\n",
|
| 386 |
+
"if _vram_gb >= 70: # A100/H100 80GB\n",
|
| 387 |
+
" _profile = dict(\n",
|
| 388 |
+
" label='A100/H100 80GB',\n",
|
| 389 |
+
" per_device_train_batch_size=8, per_device_eval_batch_size=8,\n",
|
| 390 |
+
" gradient_accumulation_steps=2, dataloader_num_workers=16,\n",
|
| 391 |
+
" gradient_checkpointing=False,\n",
|
| 392 |
+
" )\n",
|
| 393 |
+
"elif _vram_gb >= 35: # A100 40GB\n",
|
| 394 |
+
" _profile = dict(\n",
|
| 395 |
+
" label='A100 40GB',\n",
|
| 396 |
+
" per_device_train_batch_size=8, per_device_eval_batch_size=8,\n",
|
| 397 |
+
" gradient_accumulation_steps=2, dataloader_num_workers=12,\n",
|
| 398 |
+
" gradient_checkpointing=False,\n",
|
| 399 |
+
" )\n",
|
| 400 |
+
"elif _vram_gb >= 22: # 3090 / L4 / A10 24GB\n",
|
| 401 |
+
" # GC ON + bigger batch beats GC OFF + smaller batch on throughput here.\n",
|
| 402 |
+
" # Per-eff-batch wall time (eff=16): 4×T (GC OFF, bs=4) vs ~3.9×T (GC ON,\n",
|
| 403 |
+
" # bs=8) — sub-linear scaling means bs=8 step is ~1.5×T, not 2×T, so the\n",
|
| 404 |
+
" # GC overhead (~1.3×) is more than paid back.\n",
|
| 405 |
+
" _profile = dict(\n",
|
| 406 |
+
" label='RTX 3090 / L4 / A10 (24GB)',\n",
|
| 407 |
+
" per_device_train_batch_size=8, per_device_eval_batch_size=8,\n",
|
| 408 |
+
" gradient_accumulation_steps=2, dataloader_num_workers=8,\n",
|
| 409 |
+
" gradient_checkpointing=True,\n",
|
| 410 |
+
" )\n",
|
| 411 |
+
"elif _vram_gb >= 14: # T4 / V100 16GB\n",
|
| 412 |
+
" _profile = dict(\n",
|
| 413 |
+
" label='T4 / V100 (15-16GB)',\n",
|
| 414 |
+
" per_device_train_batch_size=1, per_device_eval_batch_size=1,\n",
|
| 415 |
+
" gradient_accumulation_steps=16, dataloader_num_workers=2,\n",
|
| 416 |
+
" gradient_checkpointing=True,\n",
|
| 417 |
+
" )\n",
|
| 418 |
+
"else: # tiny / unknown\n",
|
| 419 |
+
" _profile = dict(\n",
|
| 420 |
+
" label=f'unknown ({_vram_gb:.0f}GB) — conservative',\n",
|
| 421 |
+
" per_device_train_batch_size=1, per_device_eval_batch_size=1,\n",
|
| 422 |
+
" gradient_accumulation_steps=16, dataloader_num_workers=2,\n",
|
| 423 |
+
" gradient_checkpointing=True,\n",
|
| 424 |
+
" )\n",
|
| 425 |
+
"\n",
|
| 426 |
+
"# Precision: BF16 on Ampere+, FP16 on Turing (T4) and older.\n",
|
| 427 |
+
"_profile['bf16'] = bool(_bf16_ok)\n",
|
| 428 |
+
"_profile['fp16'] = not _bf16_ok\n",
|
| 429 |
+
"\n",
|
| 430 |
+
"# Attention backend: FA2 if Ampere+ AND flash-attn wheel present, else SDPA.\n",
|
| 431 |
+
"_profile['attn_implementation'] = (\n",
|
| 432 |
+
" 'flash_attention_2' if (_fa2_ok and _flash_attn_installed) else 'sdpa'\n",
|
| 433 |
+
")\n",
|
| 434 |
+
"\n",
|
| 435 |
+
"# 8-bit AdamW: bnb's paged_adamw_8bit cuts optimizer-state VRAM ~4× with no\n",
|
| 436 |
+
"# measurable quality loss. Skip on Turing where bnb paged optimizer perf is\n",
|
| 437 |
+
"# weaker — keep adamw_torch there.\n",
|
| 438 |
+
"_profile['optim'] = 'paged_adamw_8bit' if _cap >= (8, 0) else 'adamw_torch'\n",
|
| 439 |
+
"\n",
|
| 440 |
+
"# 4-bit compute dtype tracks precision.\n",
|
| 441 |
+
"_profile['bnb_4bit_compute_dtype'] = 'bfloat16' if _bf16_ok else 'float16'\n",
|
| 442 |
+
"_profile['torch_dtype'] = 'bfloat16' if _bf16_ok else 'float16'\n",
|
| 443 |
+
"\n",
|
| 444 |
+
"print(f'\\n→ Profile : {_profile[\"label\"]}')\n",
|
| 445 |
+
"for k, v in _profile.items():\n",
|
| 446 |
+
" if k == 'label': continue\n",
|
| 447 |
+
" print(f' {k:<32}= {v}')\n",
|
| 448 |
+
"\n",
|
| 449 |
+
"# ── Write profile into the configs ───────────────────────────────────────\n",
|
| 450 |
+
"train_cfg.training.per_device_train_batch_size = _profile['per_device_train_batch_size']\n",
|
| 451 |
+
"train_cfg.training.per_device_eval_batch_size = _profile['per_device_eval_batch_size']\n",
|
| 452 |
+
"train_cfg.training.gradient_accumulation_steps = _profile['gradient_accumulation_steps']\n",
|
| 453 |
+
"train_cfg.training.dataloader_num_workers = _profile['dataloader_num_workers']\n",
|
| 454 |
+
"train_cfg.training.fp16 = _profile['fp16']\n",
|
| 455 |
+
"train_cfg.training.bf16 = _profile['bf16']\n",
|
| 456 |
+
"train_cfg.training.dataloader_pin_memory = True\n",
|
| 457 |
+
"train_cfg.training.dataloader_persistent_workers = True\n",
|
| 458 |
+
"train_cfg.training.optim = _profile['optim']\n",
|
| 459 |
+
"# Ensure stage2 still uses the same per-run epoch count we want.\n",
|
| 460 |
+
"train_cfg.stage2.num_epochs = 5\n",
|
| 461 |
+
"\n",
|
| 462 |
+
"model_cfg.llm.attn_implementation = _profile['attn_implementation']\n",
|
| 463 |
+
"model_cfg.llm.gradient_checkpointing = _profile['gradient_checkpointing']\n",
|
| 464 |
+
"model_cfg.llm.torch_dtype = _profile['torch_dtype']\n",
|
| 465 |
+
"model_cfg.llm.bnb_4bit_compute_dtype = _profile['bnb_4bit_compute_dtype']\n",
|
| 466 |
+
"model_cfg.llm.bnb_4bit_quant_type = 'nf4'\n",
|
| 467 |
+
"model_cfg.llm.bnb_4bit_use_double_quant = True\n",
|
| 468 |
+
"\n",
|
| 469 |
+
"# ── task weights (sampling ratio enforced by WeightedRandomSampler) ──\n",
|
| 470 |
+
"# Defaults in train_config.yaml: 0.30 / 0.20 / 0.50 (RRG ≈ VQA, impression\n",
|
| 471 |
+
"# lower because in split_cascade mode it sees GT findings as input).\n",
|
| 472 |
+
"# Resolver auto-renormalizes and drops vqa for IU-Xray. Override here only\n",
|
| 473 |
+
"# if you want to experiment per-run, e.g.:\n",
|
| 474 |
+
"# train_cfg.tasks.findings_generation.weight = 0.30\n",
|
| 475 |
+
"# train_cfg.tasks.impression_generation.weight = 0.20\n",
|
| 476 |
+
"# train_cfg.tasks.vqa.weight = 0.50\n",
|
| 477 |
+
"\n",
|
| 478 |
+
"# ── wandb off ──\n",
|
| 479 |
+
"train_cfg.wandb.enabled = False\n",
|
| 480 |
+
"\n",
|
| 481 |
+
"# ── HuggingFace Hub run tracking ──\n",
|
| 482 |
+
"train_cfg.hf_hub.enabled = True\n",
|
| 483 |
+
"train_cfg.hf_hub.repo_id = 'hieu3636/cxr-vlm-runs' # <<< EDIT ME\n",
|
| 484 |
+
"train_cfg.hf_hub.token_env = 'HF_TOKEN'\n",
|
| 485 |
+
"train_cfg.hf_hub.private = True\n",
|
| 486 |
+
"train_cfg.hf_hub.run_state_file = str(CKPT_ROOT / 'run_id.txt')\n",
|
| 487 |
+
"\n",
|
| 488 |
+
"# ── 4-bit QLoRA ──\n",
|
| 489 |
+
"model_cfg.llm.load_in_8bit = False\n",
|
| 490 |
+
"model_cfg.llm.load_in_4bit = True\n",
|
| 491 |
+
"# Oracle PNU path does NOT use the CheXpert classifier module (labels come\n",
|
| 492 |
+
"# from the GT csv/manifest baked into the prompt). Keep it disabled until\n",
|
| 493 |
+
"# you wire the learned classifier for realistic inference.\n",
|
| 494 |
+
"model_cfg.chexpert_classifier.enabled = False\n",
|
| 495 |
+
"\n",
|
| 496 |
+
"OmegaConf.save(train_cfg, PROJECT / 'configs' / 'train_config.yaml')\n",
|
| 497 |
+
"OmegaConf.save(model_cfg, PROJECT / 'configs' / 'model_config.yaml')\n",
|
| 498 |
+
"\n",
|
| 499 |
+
"print('--- train_cfg.data ---'); print(OmegaConf.to_yaml(train_cfg.data))\n",
|
| 500 |
+
"print('--- train_cfg.tasks ---'); print(OmegaConf.to_yaml(train_cfg.tasks))\n",
|
| 501 |
+
"print('--- train_cfg.training ---');print(OmegaConf.to_yaml(train_cfg.training))\n",
|
| 502 |
+
"print('--- train_cfg.hf_hub ---'); print(OmegaConf.to_yaml(train_cfg.hf_hub))\n",
|
| 503 |
+
"print('--- model_cfg.llm ---'); print(OmegaConf.to_yaml(model_cfg.llm))\n"
|
| 504 |
+
],
|
| 505 |
"execution_count": null,
|
| 506 |
"outputs": [],
|
| 507 |
"id": "cell-cfg"
|
| 508 |
},
|
| 509 |
+
{
|
| 510 |
+
"cell_type": "markdown",
|
| 511 |
+
"id": "cell-feature-cache-md",
|
| 512 |
+
"metadata": {},
|
| 513 |
+
"source": [
|
| 514 |
+
"## 4b. Pre-compute image features (optional speedup)\n"
|
| 515 |
+
]
|
| 516 |
+
},
|
| 517 |
+
{
|
| 518 |
+
"cell_type": "code",
|
| 519 |
+
"id": "cell-feature-cache",
|
| 520 |
+
"metadata": {},
|
| 521 |
+
"execution_count": null,
|
| 522 |
+
"outputs": [],
|
| 523 |
+
"source": [
|
| 524 |
+
"# ─── Optional: pre-compute image patch features (skip frozen encoder forward) ──\n",
|
| 525 |
+
"#\n",
|
| 526 |
+
"# The image encoder is frozen + the transform is deterministic, so encoding the\n",
|
| 527 |
+
"# same image every step is wasted work. Run this ONCE per dataset to cache\n",
|
| 528 |
+
"# (P, 768) patch tensors under {WORK}/feature_cache/{DATASET_NAME}/ and the\n",
|
| 529 |
+
"# training loop will load them instead of re-encoding.\n",
|
| 530 |
+
"#\n",
|
| 531 |
+
"# Set CACHE_FEATURES = False to skip (e.g. first time you set up the run, want\n",
|
| 532 |
+
"# the smoke test to use the raw path, or you're debugging the encoder).\n",
|
| 533 |
+
"#\n",
|
| 534 |
+
"# Disk usage: ~3 MB per image (P=1024 patches × 768 dim × fp16). For ~30k\n",
|
| 535 |
+
"# unique images that's ~90 GB — make sure WORK has the room, or set\n",
|
| 536 |
+
"# CACHE_FEATURES=False on tight quotas.\n",
|
| 537 |
+
"\n",
|
| 538 |
+
"CACHE_FEATURES = True\n",
|
| 539 |
+
"\n",
|
| 540 |
+
"if CACHE_FEATURES:\n",
|
| 541 |
+
" feature_cache_dir = WORK / 'feature_cache' / DATASET_NAME\n",
|
| 542 |
+
" feature_cache_dir.mkdir(parents=True, exist_ok=True)\n",
|
| 543 |
+
" train_cfg.data.feature_cache_dir = str(feature_cache_dir)\n",
|
| 544 |
+
" OmegaConf.save(train_cfg, PROJECT / 'configs' / 'train_config.yaml')\n",
|
| 545 |
+
"\n",
|
| 546 |
+
" # Re-running this cell is safe: --overwrite is OFF by default so cached\n",
|
| 547 |
+
" # files are skipped. To force a full rebuild, add `--overwrite` below.\n",
|
| 548 |
+
" print(f'feature_cache_dir = {feature_cache_dir}')\n",
|
| 549 |
+
" !python -m scripts.precompute_image_features \\\n",
|
| 550 |
+
" --model_config configs/model_config.yaml \\\n",
|
| 551 |
+
" --train_config configs/train_config.yaml \\\n",
|
| 552 |
+
" --cache_dir \"{feature_cache_dir}\" \\\n",
|
| 553 |
+
" --batch_size 16\n",
|
| 554 |
+
"else:\n",
|
| 555 |
+
" train_cfg.data.feature_cache_dir = None\n",
|
| 556 |
+
" OmegaConf.save(train_cfg, PROJECT / 'configs' / 'train_config.yaml')\n",
|
| 557 |
+
" print('Feature cache DISABLED. Training will run the image encoder every step.')\n"
|
| 558 |
+
]
|
| 559 |
+
},
|
| 560 |
{
|
| 561 |
"cell_type": "markdown",
|
| 562 |
"metadata": {
|
scripts/precompute_image_features.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
precompute_image_features.py
|
| 3 |
+
----------------------------
|
| 4 |
+
One-shot pre-computation of frozen image-encoder patch features.
|
| 5 |
+
|
| 6 |
+
The image encoder (BioViL-T / RAD-DINO / ViT) is frozen at training time and
|
| 7 |
+
the dataset uses a deterministic transform (Resize + ToTensor + Normalize),
|
| 8 |
+
so the same image always produces the same (P, 768) patch feature tensor.
|
| 9 |
+
Running the encoder every step wastes I/O (re-decoding JPEG) and a small but
|
| 10 |
+
non-trivial slice of GPU compute.
|
| 11 |
+
|
| 12 |
+
This script walks the unified instruct JSON, encodes each UNIQUE image path
|
| 13 |
+
exactly once, and writes a `.pt` file under `feature_cache_dir` mirroring the
|
| 14 |
+
relative image path. At training time, set `data.feature_cache_dir` in
|
| 15 |
+
train_config.yaml — `data/dataset.py` loads the cached tensor on hit and
|
| 16 |
+
`model/cxr_vlm.py` detects the (P, 768) shape and skips the encoder.
|
| 17 |
+
|
| 18 |
+
Typical usage:
|
| 19 |
+
|
| 20 |
+
python -m scripts.precompute_image_features \
|
| 21 |
+
--model_config configs/model_config.yaml \
|
| 22 |
+
--train_config configs/train_config.yaml \
|
| 23 |
+
--cache_dir cache/image_features \
|
| 24 |
+
--batch_size 16
|
| 25 |
+
|
| 26 |
+
After this finishes, edit train_config.yaml:
|
| 27 |
+
data:
|
| 28 |
+
feature_cache_dir: "cache/image_features"
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
import argparse
|
| 32 |
+
import json
|
| 33 |
+
import sys
|
| 34 |
+
from pathlib import Path
|
| 35 |
+
|
| 36 |
+
import torch
|
| 37 |
+
from omegaconf import OmegaConf
|
| 38 |
+
|
| 39 |
+
# project root on path
|
| 40 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 41 |
+
|
| 42 |
+
from model.rad_dino import BioViLTEncoder
|
| 43 |
+
from utils.dataset_resolver import resolve_dataset_spec
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def parse_args():
|
| 47 |
+
p = argparse.ArgumentParser(description="Pre-compute & cache patch features")
|
| 48 |
+
p.add_argument("--model_config", default="configs/model_config.yaml")
|
| 49 |
+
p.add_argument("--train_config", default="configs/train_config.yaml")
|
| 50 |
+
p.add_argument(
|
| 51 |
+
"--cache_dir", required=True,
|
| 52 |
+
help="Output dir. Each image is saved as {cache_dir}/{image_relpath}.pt"
|
| 53 |
+
)
|
| 54 |
+
p.add_argument("--batch_size", type=int, default=16)
|
| 55 |
+
p.add_argument(
|
| 56 |
+
"--device", default="cuda",
|
| 57 |
+
help="cuda | cpu (cpu is fine — encoder is small)"
|
| 58 |
+
)
|
| 59 |
+
p.add_argument(
|
| 60 |
+
"--limit", type=int, default=None,
|
| 61 |
+
help="Cap total images processed (useful for smoke tests)"
|
| 62 |
+
)
|
| 63 |
+
p.add_argument(
|
| 64 |
+
"--overwrite", action="store_true",
|
| 65 |
+
help="Re-encode and overwrite existing .pt files (default: skip)"
|
| 66 |
+
)
|
| 67 |
+
return p.parse_args()
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def collect_image_paths(instruct_json: str) -> list:
|
| 71 |
+
"""Walk the unified JSON, return sorted list of unique image relpaths
|
| 72 |
+
(across ALL splits). Multi-image samples contribute each path separately."""
|
| 73 |
+
with open(instruct_json, "r", encoding="utf-8") as f:
|
| 74 |
+
samples = json.load(f)
|
| 75 |
+
|
| 76 |
+
seen = set()
|
| 77 |
+
for s in samples:
|
| 78 |
+
if s.get("image_paths"):
|
| 79 |
+
for p in s["image_paths"]:
|
| 80 |
+
seen.add(p)
|
| 81 |
+
elif s.get("image_path"):
|
| 82 |
+
seen.add(s["image_path"])
|
| 83 |
+
return sorted(seen)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def main():
|
| 87 |
+
args = parse_args()
|
| 88 |
+
|
| 89 |
+
train_cfg = OmegaConf.load(args.train_config)
|
| 90 |
+
model_cfg = OmegaConf.load(args.model_config)
|
| 91 |
+
spec = resolve_dataset_spec(train_cfg)
|
| 92 |
+
|
| 93 |
+
cache_dir = Path(args.cache_dir).resolve()
|
| 94 |
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
| 95 |
+
print(f"cache_dir : {cache_dir}")
|
| 96 |
+
print(f"image_root : {spec.image_root}")
|
| 97 |
+
print(f"instruct_json : {spec.instruct_json}")
|
| 98 |
+
|
| 99 |
+
# ── Build encoder (frozen, in inference dtype) ───────────────────────
|
| 100 |
+
_DTYPE_MAP = {"float16": torch.float16, "bfloat16": torch.bfloat16, "float32": torch.float32}
|
| 101 |
+
enc_dtype = _DTYPE_MAP.get(model_cfg.llm.torch_dtype, torch.float32)
|
| 102 |
+
encoder = BioViLTEncoder(
|
| 103 |
+
frozen = True,
|
| 104 |
+
img_size = model_cfg.image_encoder.img_size,
|
| 105 |
+
backend = getattr(model_cfg.image_encoder, "backend", "auto"),
|
| 106 |
+
dtype = enc_dtype,
|
| 107 |
+
).to(args.device).eval()
|
| 108 |
+
|
| 109 |
+
transform = BioViLTEncoder.get_transform("val")
|
| 110 |
+
|
| 111 |
+
# ── Collect unique image paths ───────────────────────────────────────
|
| 112 |
+
image_paths = collect_image_paths(spec.instruct_json)
|
| 113 |
+
print(f"unique images : {len(image_paths)}")
|
| 114 |
+
if args.limit:
|
| 115 |
+
image_paths = image_paths[: args.limit]
|
| 116 |
+
print(f"(limited to {len(image_paths)})")
|
| 117 |
+
|
| 118 |
+
# ── Encode in batches ────────────────────────────────────────────────
|
| 119 |
+
from PIL import Image
|
| 120 |
+
import time
|
| 121 |
+
image_root = Path(spec.image_root)
|
| 122 |
+
done = 0
|
| 123 |
+
skipped = 0
|
| 124 |
+
failed = 0
|
| 125 |
+
t0 = time.time()
|
| 126 |
+
|
| 127 |
+
def _flush_batch(batch_paths, batch_tensors):
|
| 128 |
+
nonlocal done
|
| 129 |
+
if not batch_tensors:
|
| 130 |
+
return
|
| 131 |
+
x = torch.stack(batch_tensors).to(args.device)
|
| 132 |
+
with torch.no_grad():
|
| 133 |
+
with torch.autocast(
|
| 134 |
+
"cuda" if args.device.startswith("cuda") else "cpu",
|
| 135 |
+
dtype = enc_dtype if enc_dtype != torch.float32 else torch.float32,
|
| 136 |
+
enabled = enc_dtype != torch.float32,
|
| 137 |
+
):
|
| 138 |
+
feats = encoder(x) # (B, P, 768)
|
| 139 |
+
feats = feats.to(torch.float16).cpu() # fp16 .pt → smaller on disk
|
| 140 |
+
for rel, f in zip(batch_paths, feats):
|
| 141 |
+
out = cache_dir / (rel + ".pt")
|
| 142 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 143 |
+
torch.save(f.contiguous(), out)
|
| 144 |
+
done += 1
|
| 145 |
+
|
| 146 |
+
pending_paths, pending_tensors = [], []
|
| 147 |
+
for i, rel in enumerate(image_paths):
|
| 148 |
+
out_path = cache_dir / (rel + ".pt")
|
| 149 |
+
if out_path.is_file() and not args.overwrite:
|
| 150 |
+
skipped += 1
|
| 151 |
+
continue
|
| 152 |
+
try:
|
| 153 |
+
img = Image.open(image_root / rel).convert("RGB")
|
| 154 |
+
t = transform(img)
|
| 155 |
+
pending_paths.append(rel)
|
| 156 |
+
pending_tensors.append(t)
|
| 157 |
+
except Exception as e:
|
| 158 |
+
failed += 1
|
| 159 |
+
print(f" [skip] {rel}: {type(e).__name__}: {e}")
|
| 160 |
+
continue
|
| 161 |
+
|
| 162 |
+
if len(pending_tensors) >= args.batch_size:
|
| 163 |
+
_flush_batch(pending_paths, pending_tensors)
|
| 164 |
+
pending_paths, pending_tensors = [], []
|
| 165 |
+
|
| 166 |
+
if (i + 1) % 500 == 0:
|
| 167 |
+
elapsed = time.time() - t0
|
| 168 |
+
rate = (done + skipped) / max(elapsed, 1e-6)
|
| 169 |
+
print(f" [{i+1:>6}/{len(image_paths)}] done={done} "
|
| 170 |
+
f"skipped={skipped} failed={failed} ({rate:.1f} img/s)")
|
| 171 |
+
|
| 172 |
+
_flush_batch(pending_paths, pending_tensors)
|
| 173 |
+
|
| 174 |
+
elapsed = time.time() - t0
|
| 175 |
+
print(f"\nFinished. encoded={done} skipped(existing)={skipped} failed={failed} "
|
| 176 |
+
f"elapsed={elapsed/60:.1f} min")
|
| 177 |
+
print(f"\nNext: set this in configs/train_config.yaml under `data:` ↓")
|
| 178 |
+
print(f" feature_cache_dir: \"{cache_dir}\"")
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
if __name__ == "__main__":
|
| 182 |
+
main()
|
training/train.py
CHANGED
|
@@ -335,6 +335,16 @@ def _build_training_args(train_cfg, stage_cfg, out_dir, run_name, *, enable_best
|
|
| 335 |
report_to = "wandb" if train_cfg.wandb.enabled else "none",
|
| 336 |
run_name = run_name,
|
| 337 |
dataloader_num_workers = getattr(tr, "dataloader_num_workers", 4),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
remove_unused_columns = False,
|
| 339 |
)
|
| 340 |
if save_strategy == "steps":
|
|
@@ -434,6 +444,10 @@ class HFBestLastCallback(TrainerCallback):
|
|
| 434 |
|
| 435 |
def _build_datasets(spec: DatasetSpec, train_cfg, model, transform_train, transform_val):
|
| 436 |
"""Construct train + val CXRInstructDataset instances from a DatasetSpec."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 437 |
train_ds = CXRInstructDataset(
|
| 438 |
data_path = spec.instruct_json,
|
| 439 |
image_root = spec.image_root,
|
|
@@ -444,6 +458,7 @@ def _build_datasets(spec: DatasetSpec, train_cfg, model, transform_train, transf
|
|
| 444 |
cutoff_len = train_cfg.training.cutoff_len,
|
| 445 |
task_weights = spec.task_weights,
|
| 446 |
max_images = spec.max_images,
|
|
|
|
| 447 |
)
|
| 448 |
val_ds = CXRInstructDataset(
|
| 449 |
data_path = spec.instruct_json,
|
|
@@ -455,6 +470,7 @@ def _build_datasets(spec: DatasetSpec, train_cfg, model, transform_train, transf
|
|
| 455 |
cutoff_len = train_cfg.training.cutoff_len,
|
| 456 |
task_weights = spec.task_weights,
|
| 457 |
max_images = spec.max_images,
|
|
|
|
| 458 |
)
|
| 459 |
return train_ds, val_ds
|
| 460 |
|
|
|
|
| 335 |
report_to = "wandb" if train_cfg.wandb.enabled else "none",
|
| 336 |
run_name = run_name,
|
| 337 |
dataloader_num_workers = getattr(tr, "dataloader_num_workers", 4),
|
| 338 |
+
dataloader_pin_memory = getattr(tr, "dataloader_pin_memory", True),
|
| 339 |
+
dataloader_persistent_workers = (
|
| 340 |
+
getattr(tr, "dataloader_persistent_workers", True)
|
| 341 |
+
and getattr(tr, "dataloader_num_workers", 4) > 0
|
| 342 |
+
),
|
| 343 |
+
# `paged_adamw_8bit` (bnb) cuts optimizer-state VRAM ~4× with no
|
| 344 |
+
# measurable quality loss (Dettmers ICLR'22). Default keeps the
|
| 345 |
+
# legacy fp32 AdamW for backward-compat; the auto-detect cell in
|
| 346 |
+
# the Colab notebook switches it on for Ampere+ GPUs.
|
| 347 |
+
optim = getattr(tr, "optim", "adamw_torch"),
|
| 348 |
remove_unused_columns = False,
|
| 349 |
)
|
| 350 |
if save_strategy == "steps":
|
|
|
|
| 444 |
|
| 445 |
def _build_datasets(spec: DatasetSpec, train_cfg, model, transform_train, transform_val):
|
| 446 |
"""Construct train + val CXRInstructDataset instances from a DatasetSpec."""
|
| 447 |
+
feature_cache_dir = getattr(train_cfg.data, "feature_cache_dir", None) or None
|
| 448 |
+
if feature_cache_dir:
|
| 449 |
+
print(f"[_build_datasets] feature_cache_dir = {feature_cache_dir} "
|
| 450 |
+
f"(encoder bypass on cache hit)")
|
| 451 |
train_ds = CXRInstructDataset(
|
| 452 |
data_path = spec.instruct_json,
|
| 453 |
image_root = spec.image_root,
|
|
|
|
| 458 |
cutoff_len = train_cfg.training.cutoff_len,
|
| 459 |
task_weights = spec.task_weights,
|
| 460 |
max_images = spec.max_images,
|
| 461 |
+
feature_cache_dir = feature_cache_dir,
|
| 462 |
)
|
| 463 |
val_ds = CXRInstructDataset(
|
| 464 |
data_path = spec.instruct_json,
|
|
|
|
| 470 |
cutoff_len = train_cfg.training.cutoff_len,
|
| 471 |
task_weights = spec.task_weights,
|
| 472 |
max_images = spec.max_images,
|
| 473 |
+
feature_cache_dir = feature_cache_dir,
|
| 474 |
)
|
| 475 |
return train_ds, val_ds
|
| 476 |
|