convitom commited on
Commit
c61f01a
·
1 Parent(s): e166fd8
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: "auto" # "auto" | "rad_dino" | "biovilt" | "vit"
15
  frozen: true # freeze encoder during training
16
- img_size: 448 # 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,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
- Handles:
19
- - Padding input_ids, attention_mask, labels to same length
20
- - Stacking image tensors
21
  """
22
  pad_token_id: int = 0
23
 
24
  def __call__(self, samples: List[Dict]) -> Dict[str, torch.Tensor]:
25
- images = torch.stack([s["image"] for s in samples])
26
- input_ids = torch.stack([s["input_ids"] for s in samples])
27
- attention_masks = torch.stack([s["attention_mask"] for s in samples])
28
- labels = torch.stack([s["labels"] for s in samples])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
  return {
31
- "images": images, # (B, C, H, W)
32
- "input_ids": input_ids, # (B, seq_len)
33
- "attention_mask": attention_masks, # (B, seq_len)
34
- "labels": labels, # (B, seq_len)
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 != self.tokenizer.pad_token_id).long(),
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: (cutoff_len,)
305
- labels: (cutoff_len,) with -100 for prompt positions
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 = "max_length",
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 = llm_dtype if llm_dtype != torch.float32 else torch.float16,
128
- bnb_4bit_use_double_quant = True,
129
  )
130
- self.llm = LlamaForCausalLM.from_pretrained(
131
- model_cfg.llm.name,
132
- **llm_kwargs,
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
- self.llm.gradient_checkpointing_enable()
156
- self.llm.enable_input_require_grads()
 
 
 
 
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. Supports two input shapes:
 
 
 
 
 
165
 
166
- (B, C, H, W) → (B, num_patches, 768) single-image mode
167
- (B, N, C, H, W) (B, N*num_patches, 768) multi-image mode
 
 
 
 
 
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": "from omegaconf import OmegaConf\n\ntrain_cfg = OmegaConf.load(PROJECT / 'configs' / 'train_config.yaml')\nmodel_cfg = OmegaConf.load(PROJECT / 'configs' / 'model_config.yaml')\n\n# ── dataset selector ──\ntrain_cfg.data.dataset_name = DATASET_NAME\n\n# ── training-scheme switches (thesis ablations) ──\n# report_mode: 'split' → 2 tasks (findings + impression separately)\n# 'merged' → 1 task (full report \"Findings: ...\\n\\nImpression: ...\")\n# 'split_cascade' → split, but impression's context = GT findings\n# image_mode : 'all_views_split' | 'frontal_only_split' | 'multi_image_merged'\ntrain_cfg.data.report_mode = 'split'\ntrain_cfg.data.image_mode = 'all_views_split'\ntrain_cfg.data.max_images_per_sample = 2 # only used in multi_image_merged\n\n# ── dataset-specific paths ──\nif DATASET_NAME == 'MIMIC-CXR':\n train_cfg.data.mimic_cxr_root = str(CXR_ROOT)\n # Base path; the resolver suffixes __{report_mode}__{image_mode} and\n # auto-builds (PNU CheXpert + VQA) via data.mimic_cxr_builder.\n train_cfg.data.instruct_json = str(mimic_json_path)\n train_cfg.data.mimic_auto_build = True\n\n # RaDialog / U-MultiClass abnormality guidance: locate the CheXpert\n # label CSV so the builder can bake the PNU structured_findings string.\n _cx = (sorted(DATA_SRC.rglob('*chexpert*.csv'))\n or sorted(DATA_SRC.rglob('*chexbert*.csv')))\n train_cfg.data.mimic_chexpert_csv = str(_cx[0]) if _cx else None\n print('CheXpert CSV :', train_cfg.data.mimic_chexpert_csv\n or 'NOT FOUND — PNU abnormality guidance DISABLED!')\n\n # VQA pairs ({train,valid,test}.json) → abnormality-guided VQA.\n train_cfg.data.mimic_vqa_root = str(VQA_ROOT) if VQA_ROOT is not None else None\n print('VQA root :', train_cfg.data.mimic_vqa_root or '(none — VQA skipped)')\n\nelif DATASET_NAME == 'MIMIC-CXR_resized':\n # The MIMIC-CXR_resized builder is manifest-driven: it reads\n # `manifest_{train,val,test}.csv` for split + the 14 chex_* labels\n # (PNU bucketed directly from the CSV, no separate chexpert.csv needed),\n # uses `report_relpath` from the manifest to find each .txt, and pulls\n # VQA from `vqa/{vqa,vqa_val,vqa_test}.json`.\n train_cfg.data.mimic_cxr_resized.root = str(MR_ROOT)\n train_cfg.data.mimic_cxr_resized.manifest_dir = None # null → defaults to root\n train_cfg.data.mimic_cxr_resized.vqa_dir = None # null → {root}/vqa\n train_cfg.data.mimic_cxr_resized.reports_root = None # null → auto-probe {root} then {root}/reports\n train_cfg.data.mimic_cxr_resized.instruct_json = str(mr_json_path)\n train_cfg.data.mimic_cxr_resized.auto_build = True\n\nelse: # IU-Xray\n train_cfg.data.iu_xray.images_dir = str(IU_IMAGES_DIR)\n train_cfg.data.iu_xray.labels_dir = str(IU_LABELS_DIR)\n train_cfg.data.iu_xray.instruct_json = str(iu_json_path)\n train_cfg.data.iu_xray.auto_build = True\n\ntrain_cfg.data.train_split = 'train'\ntrain_cfg.data.val_split = 'validate'\ntrain_cfg.data.test_split = 'test'\n\n# ── checkpoint root (Persistence keeps /content/ckpt/) ──\nCKPT_ROOT = WORK / 'ckpt'\ntrain_cfg.training.output_root = str(CKPT_ROOT)\n\n# ── batching / GPU profile ──────────────────────────────────────────────\n# Default below is tuned for **L4 (24GB)** or **RTX 3090 (24GB)**: BF16 compute,\n# batch 4, 4-bit Vicuna (~3.5GB) + LoRA + projection leaves ~18GB for activations.\n# Effective batch = per_device_train_batch_size * gradient_accumulation_steps.\n#\n# To switch GPU: comment out the active profile and uncomment the target one.\n#\n# ─── L4 / RTX 3090 (24GB) ← ACTIVE / DEFAULT ────────────────────────────\ntrain_cfg.training.per_device_train_batch_size = 4\ntrain_cfg.training.per_device_eval_batch_size = 4\ntrain_cfg.training.gradient_accumulation_steps = 4 # effective batch = 16\ntrain_cfg.training.fp16 = False\ntrain_cfg.training.bf16 = True # Ampere+/Ada → native BF16, no GradScaler\ntrain_cfg.training.dataloader_num_workers = 8\n#\n# ─── T4 (15-16GB, Colab free) ────────────────────────────────────────────\n# Turing architecture: NO BF16 support → must use FP16. Smaller batch.\n# train_cfg.training.per_device_train_batch_size = 1\n# train_cfg.training.per_device_eval_batch_size = 1\n# train_cfg.training.gradient_accumulation_steps = 16 # effective batch = 16\n# train_cfg.training.fp16 = True\n# train_cfg.training.bf16 = False\n# train_cfg.training.dataloader_num_workers = 2 # Colab free ≈ 2 CPU cores\n#\n# ─── A100 40GB ───────────────────────────────────────────────────────────\n# Plenty of headroom: bigger batch, fewer accumulation steps → faster steps.\n# train_cfg.training.per_device_train_batch_size = 8\n# train_cfg.training.per_device_eval_batch_size = 8\n# train_cfg.training.gradient_accumulation_steps = 2 # effective batch = 16\n# train_cfg.training.fp16 = False\n# train_cfg.training.bf16 = True\n# train_cfg.training.dataloader_num_workers = 16\n# # Optional: turn off 4-bit quant — Vicuna fp16 (~14GB) fits easily on A100.\n# # Slightly better quality, faster per step. Set\n# # `model_cfg.llm.load_in_4bit = False` in the QLoRA section below.\n\ntrain_cfg.stage2.num_epochs = 5\n\n# ── task weights (sampling ratio enforced by WeightedRandomSampler) ──\n# Defaults in train_config.yaml: 0.30 / 0.20 / 0.50 (RRG ≈ VQA, impression\n# lower because in split_cascade mode it sees GT findings as input).\n# Resolver auto-renormalizes and drops vqa for IU-Xray. Override here only\n# if you want to experiment per-run, e.g.:\n# train_cfg.tasks.findings_generation.weight = 0.30\n# train_cfg.tasks.impression_generation.weight = 0.20\n# train_cfg.tasks.vqa.weight = 0.50\n\n# ── wandb off ──\ntrain_cfg.wandb.enabled = False\n\n# ── HuggingFace Hub run tracking ──\ntrain_cfg.hf_hub.enabled = True\ntrain_cfg.hf_hub.repo_id = 'hieu3636/cxr-vlm-runs' # <<< EDIT ME\ntrain_cfg.hf_hub.token_env = 'HF_TOKEN'\ntrain_cfg.hf_hub.private = True\ntrain_cfg.hf_hub.run_state_file = str(CKPT_ROOT / 'run_id.txt')\n\n# ── 4-bit QLoRA ──\nmodel_cfg.llm.load_in_8bit = False\nmodel_cfg.llm.load_in_4bit = True\n# Oracle PNU path does NOT use the CheXpert classifier module (labels come\n# from the GT csv/manifest baked into the prompt). Keep it disabled until\n# you wire the learned classifier for realistic inference.\nmodel_cfg.chexpert_classifier.enabled = False\n\nOmegaConf.save(train_cfg, PROJECT / 'configs' / 'train_config.yaml')\nOmegaConf.save(model_cfg, PROJECT / 'configs' / 'model_config.yaml')\n\nprint('--- train_cfg.data ---'); print(OmegaConf.to_yaml(train_cfg.data))\nprint('--- train_cfg.tasks ---'); print(OmegaConf.to_yaml(train_cfg.tasks))\nprint('--- train_cfg.training ---');print(OmegaConf.to_yaml(train_cfg.training))\nprint('--- train_cfg.hf_hub ---'); print(OmegaConf.to_yaml(train_cfg.hf_hub))\nprint('--- model_cfg.llm ---'); print(OmegaConf.to_yaml(model_cfg.llm))",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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