JorgeAV commited on
Commit
f1842cf
·
verified ·
1 Parent(s): 206e1ad

Add Phase 4 training: SmolLM2-135M decoder + bridge MLP

Browse files
Files changed (1) hide show
  1. train_phase4.py +1126 -0
train_phase4.py ADDED
@@ -0,0 +1,1126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ MR-JEPA Phase 4 — SmolLM2-135M Generative Decoder
4
+
5
+ Replaces the random-init 4-layer transformer decoder (which produced 0% generative
6
+ metrics after 10+ epochs) with SmolLM2-135M-Instruct as a pre-trained LM decoder.
7
+
8
+ Architecture (BLIP-2 / LLaVA-1.5 pattern):
9
+ z_K (768d) ──→ Bridge MLP (768→576→576) ──→ visual soft prompt tokens
10
+ evidence (N×768d) ──→ same Bridge MLP ──→ evidence soft prompt tokens
11
+ [vis_tokens, ev_tokens, text_tokens] ──→ SmolLM2-135M ──→ next-token prediction
12
+
13
+ Training recipe (2-stage, following LLaVA/BLIP-2):
14
+ Stage 1: Freeze SmolLM2, train only bridge MLP. LR=1e-3.
15
+ Stage 2: Unfreeze SmolLM2, joint fine-tuning. LR=2e-5, cosine decay.
16
+
17
+ Key improvements over Phase 3.x:
18
+ 1. Pre-trained 30-layer LM decoder (135M params) vs random-init 4-layer (7M params)
19
+ 2. LLaVA-1.5 two-layer MLP bridge (nonlinear alignment) vs none
20
+ 3. Label smoothing (ε=0.1) to combat repetition collapse
21
+ 4. Repetition penalty + nucleus sampling in evaluation
22
+ 5. SmolLM2 tokenizer (49K vocab, ChatML) vs Qwen3 tokenizer (152K vocab)
23
+ 6. Proper label masking: -100 for visual prefix, pad tokens
24
+
25
+ Resumes JEPA/Evidence/Rollout/Disc from Phase 3.1 checkpoint.
26
+ SmolLM2-135M loaded fresh from HuggingFace Hub.
27
+
28
+ Usage:
29
+ python train_phase4.py
30
+ python train_phase4.py --stage 1 --epochs 5 --bridge_lr 1e-3
31
+ python train_phase4.py --stage 2 --epochs 10 --lm_lr 2e-5
32
+ """
33
+
34
+ import os
35
+ import sys
36
+ import json
37
+ import math
38
+ import copy
39
+ import random
40
+ import logging
41
+ import argparse
42
+ from collections import defaultdict, Counter
43
+
44
+ import numpy as np
45
+ import torch
46
+ import torch.nn as nn
47
+ import torch.nn.functional as F
48
+ from torch.optim import AdamW
49
+ from torch.utils.data import Dataset, DataLoader
50
+ from PIL import Image
51
+
52
+ logging.basicConfig(
53
+ level=logging.INFO,
54
+ format="%(asctime)s | %(levelname)s | %(message)s",
55
+ datefmt="%H:%M:%S",
56
+ )
57
+ log = logging.getLogger("mrjepa-p4")
58
+
59
+
60
+ # ══════════════════════════════════════════════════════════════════════════
61
+ # BRIDGE MODULE: JEPA latent space → SmolLM2 embedding space
62
+ # ══════════════════════════════════════════════════════════════════════════
63
+
64
+ class VisionLanguageBridge(nn.Module):
65
+ """
66
+ LLaVA-1.5 style 2-layer MLP connector.
67
+ Projects JEPA representations (768d) into SmolLM2 space (576d).
68
+
69
+ Applied to both z_K (global JEPA latent) and evidence tokens.
70
+ The nonlinear projection is critical — BLIP-2 showed linear works,
71
+ LLaVA-1.5 showed MLP is significantly better for VQA.
72
+ """
73
+ def __init__(self, jepa_dim=768, lm_dim=576):
74
+ super().__init__()
75
+ self.proj = nn.Sequential(
76
+ nn.Linear(jepa_dim, lm_dim),
77
+ nn.GELU(),
78
+ nn.Linear(lm_dim, lm_dim),
79
+ )
80
+ # Initialize close to identity-like mapping
81
+ nn.init.xavier_uniform_(self.proj[0].weight, gain=0.5)
82
+ nn.init.zeros_(self.proj[0].bias)
83
+ nn.init.xavier_uniform_(self.proj[2].weight, gain=0.1)
84
+ nn.init.zeros_(self.proj[2].bias)
85
+
86
+ def forward(self, features):
87
+ """
88
+ Args:
89
+ features: [B, N, 768] — either z_K or evidence tokens
90
+ Returns:
91
+ projected: [B, N, 576] — in SmolLM2 embedding space
92
+ """
93
+ return self.proj(features)
94
+
95
+
96
+ # ══════════════════════════════════════════════════════════════════════════
97
+ # SmolLM2 GENERATIVE DECODER
98
+ # ══════════════════════════════════════════════════════════════════════════
99
+
100
+ class SmolLMDecoder(nn.Module):
101
+ """
102
+ Wraps SmolLM2-135M-Instruct as the generative decoder.
103
+
104
+ Architecture:
105
+ 1. Bridge MLP projects z_K + evidence from JEPA space (768d) to LM space (576d)
106
+ 2. Projected tokens are prepended as "soft visual prompts" before text tokens
107
+ 3. SmolLM2 processes [vis_prefix | text_tokens] with causal attention
108
+ 4. Loss computed only on answer tokens (visual prefix masked with -100)
109
+
110
+ This follows the BLIP-2 / LLaVA pattern exactly:
111
+ "projected query embeddings are prepended to the input text embeddings.
112
+ They function as soft visual prompts that condition the LLM on visual
113
+ representation." — Li et al., BLIP-2 §3.3
114
+ """
115
+ def __init__(self, jepa_dim=768, freeze_lm=True, label_smoothing=0.1,
116
+ num_evidence_tokens=8):
117
+ super().__init__()
118
+ from transformers import AutoModelForCausalLM, AutoTokenizer
119
+
120
+ log.info("Loading SmolLM2-135M-Instruct...")
121
+ self.tokenizer = AutoTokenizer.from_pretrained(
122
+ "HuggingFaceTB/SmolLM2-135M-Instruct"
123
+ )
124
+ if self.tokenizer.pad_token is None:
125
+ self.tokenizer.pad_token = self.tokenizer.eos_token
126
+
127
+ self.lm = AutoModelForCausalLM.from_pretrained(
128
+ "HuggingFaceTB/SmolLM2-135M-Instruct",
129
+ torch_dtype=torch.bfloat16,
130
+ )
131
+
132
+ self.lm_dim = self.lm.config.hidden_size # 576
133
+ self.vocab_size = self.lm.config.vocab_size # 49152
134
+
135
+ log.info(f"SmolLM2: hidden={self.lm_dim}, vocab={self.vocab_size}, "
136
+ f"layers={self.lm.config.num_hidden_layers}")
137
+
138
+ if freeze_lm:
139
+ for p in self.lm.parameters():
140
+ p.requires_grad = False
141
+ log.info("SmolLM2 weights frozen (Stage 1: train bridge only)")
142
+ else:
143
+ log.info("SmolLM2 weights trainable (Stage 2: full fine-tuning)")
144
+
145
+ # Bridge MLP: JEPA space → SmolLM2 space
146
+ self.bridge = VisionLanguageBridge(jepa_dim, self.lm_dim)
147
+
148
+ # How many evidence tokens to use as soft prompts
149
+ # (subsample from 64 to avoid very long prefix)
150
+ self.num_evidence_tokens = num_evidence_tokens
151
+ if num_evidence_tokens < 64:
152
+ self.ev_pool = nn.Linear(jepa_dim, jepa_dim) # learned pooling
153
+ else:
154
+ self.ev_pool = None
155
+
156
+ self.label_smoothing = label_smoothing
157
+ self.freeze_lm = freeze_lm
158
+
159
+ def unfreeze_lm(self):
160
+ """Unfreeze SmolLM2 for Stage 2 fine-tuning."""
161
+ for p in self.lm.parameters():
162
+ p.requires_grad = True
163
+ self.freeze_lm = False
164
+ log.info("SmolLM2 unfrozen for Stage 2")
165
+
166
+ def _subsample_evidence(self, evidence):
167
+ """Subsample evidence tokens from 64 → num_evidence_tokens."""
168
+ B, N, D = evidence.shape
169
+ if N <= self.num_evidence_tokens:
170
+ return evidence
171
+ # Learned attention pooling
172
+ if self.ev_pool is not None:
173
+ # Use strided selection + learned projection
174
+ stride = N // self.num_evidence_tokens
175
+ indices = torch.arange(0, N, stride, device=evidence.device)[:self.num_evidence_tokens]
176
+ return evidence[:, indices]
177
+ return evidence[:, :self.num_evidence_tokens]
178
+
179
+ def prepare_inputs(self, z_final, evidence, questions, answers=None,
180
+ max_answer_len=32):
181
+ """
182
+ Prepare inputs for SmolLM2 forward pass.
183
+
184
+ Args:
185
+ z_final: [B, N_state, 768] — JEPA latent states
186
+ evidence: [B, N_ev, 768] — evidence memory tokens
187
+ questions: list[str] — question texts
188
+ answers: list[str] or None — answer texts (None for generation)
189
+ max_answer_len: int — max tokens for answer
190
+
191
+ Returns:
192
+ inputs_embeds: [B, N_vis + N_text, 576]
193
+ attention_mask: [B, N_vis + N_text]
194
+ labels: [B, N_vis + N_text] or None
195
+ n_vis_tokens: int — number of visual prefix tokens
196
+ """
197
+ device = z_final.device
198
+ B = z_final.size(0)
199
+
200
+ # 1. Project JEPA features to LM space
201
+ vis_embeds = self.bridge(z_final) # [B, N_state, 576]
202
+
203
+ ev_sub = self._subsample_evidence(evidence) # [B, N_ev_sub, 768]
204
+ ev_embeds = self.bridge(ev_sub) # [B, N_ev_sub, 576]
205
+
206
+ # Concatenate visual prefix: [z_K tokens | evidence tokens]
207
+ vis_prefix = torch.cat([vis_embeds, ev_embeds], dim=1) # [B, N_vis, 576]
208
+ n_vis = vis_prefix.size(1)
209
+
210
+ # 2. Tokenize text
211
+ if answers is not None:
212
+ # Training: "Question: {q}\nAnswer: {a}<|im_end|>"
213
+ texts = []
214
+ for q, a in zip(questions, answers):
215
+ texts.append(f"Question: {q}\nAnswer: {a}")
216
+ tok = self.tokenizer(
217
+ texts, padding="max_length", truncation=True,
218
+ max_length=192 + max_answer_len,
219
+ return_tensors="pt",
220
+ ).to(device)
221
+ else:
222
+ # Generation: "Question: {q}\nAnswer:"
223
+ texts = [f"Question: {q}\nAnswer:" for q in questions]
224
+ tok = self.tokenizer(
225
+ texts, padding="max_length", truncation=True,
226
+ max_length=192,
227
+ return_tensors="pt",
228
+ ).to(device)
229
+
230
+ # 3. Get text token embeddings (bypass embedding table)
231
+ text_embeds = self.lm.model.embed_tokens(tok["input_ids"]) # [B, L, 576]
232
+
233
+ # 4. Prepend visual soft prompts — cast to LM dtype (bfloat16)
234
+ lm_dtype = text_embeds.dtype
235
+ vis_prefix = vis_prefix.to(lm_dtype)
236
+ inputs_embeds = torch.cat([vis_prefix, text_embeds], dim=1) # [B, N_vis+L, 576]
237
+
238
+ # 5. Extend attention mask
239
+ vis_mask = torch.ones(B, n_vis, device=device, dtype=tok["attention_mask"].dtype)
240
+ attention_mask = torch.cat([vis_mask, tok["attention_mask"]], dim=1)
241
+
242
+ # 6. Build labels (if training)
243
+ labels = None
244
+ if answers is not None:
245
+ # Visual prefix → -100 (ignore)
246
+ vis_labels = torch.full((B, n_vis), -100, device=device, dtype=torch.long)
247
+
248
+ # Text labels: shift by 1 for next-token prediction
249
+ text_labels = tok["input_ids"].clone()
250
+ # Mask padding tokens
251
+ text_labels[text_labels == self.tokenizer.pad_token_id] = -100
252
+
253
+ # Find where the answer starts to only compute loss on answer tokens
254
+ # We mask the question part too — only train on answer generation
255
+ for i, (q, a) in enumerate(zip(questions, answers)):
256
+ q_text = f"Question: {q}\nAnswer:"
257
+ q_tok = self.tokenizer(q_text, add_special_tokens=False)
258
+ q_len = len(q_tok["input_ids"])
259
+ # Mask question prefix in labels
260
+ text_labels[i, :min(q_len, text_labels.size(1))] = -100
261
+
262
+ labels = torch.cat([vis_labels, text_labels], dim=1)
263
+
264
+ return inputs_embeds, attention_mask, labels, n_vis
265
+
266
+ def forward(self, z_final, evidence, questions, answers,
267
+ max_answer_len=32):
268
+ """
269
+ Training forward pass.
270
+
271
+ Returns:
272
+ loss: scalar tensor — CE loss with label smoothing
273
+ logits: [B, L, V] — for debugging
274
+ """
275
+ inputs_embeds, attention_mask, labels, n_vis = self.prepare_inputs(
276
+ z_final, evidence, questions, answers, max_answer_len
277
+ )
278
+
279
+ outputs = self.lm(
280
+ inputs_embeds=inputs_embeds,
281
+ attention_mask=attention_mask,
282
+ labels=labels,
283
+ )
284
+
285
+ # Apply label smoothing manually if needed
286
+ if self.label_smoothing > 0 and labels is not None:
287
+ # Recompute loss with label smoothing — use float32 for stability
288
+ logits = outputs.logits.float()
289
+ shift_logits = logits[..., :-1, :].contiguous()
290
+ shift_labels = labels[..., 1:].contiguous()
291
+
292
+ loss = F.cross_entropy(
293
+ shift_logits.view(-1, self.vocab_size),
294
+ shift_labels.view(-1),
295
+ ignore_index=-100,
296
+ label_smoothing=self.label_smoothing,
297
+ )
298
+ else:
299
+ loss = outputs.loss
300
+
301
+ return loss, outputs.logits
302
+
303
+ @torch.no_grad()
304
+ def generate(self, z_final, evidence, questions,
305
+ max_new_tokens=32, temperature=0.7, top_p=0.9,
306
+ repetition_penalty=1.3, no_repeat_ngram_size=3):
307
+ """
308
+ Generate answers with nucleus sampling + repetition penalty.
309
+
310
+ Returns:
311
+ predictions: list[str] — decoded answer strings
312
+ """
313
+ device = z_final.device
314
+ B = z_final.size(0)
315
+
316
+ # Prepare inputs (no answers → generation mode)
317
+ inputs_embeds, attention_mask, _, n_vis = self.prepare_inputs(
318
+ z_final, evidence, questions, answers=None
319
+ )
320
+
321
+ # Generate token by token with sampling
322
+ generated_ids = []
323
+ past_key_values = None
324
+ cur_embeds = inputs_embeds
325
+ cur_mask = attention_mask
326
+
327
+ # Track generated tokens for repetition penalty
328
+ all_generated = [[] for _ in range(B)]
329
+
330
+ for step in range(max_new_tokens):
331
+ outputs = self.lm(
332
+ inputs_embeds=cur_embeds,
333
+ attention_mask=cur_mask,
334
+ past_key_values=past_key_values,
335
+ use_cache=True,
336
+ )
337
+
338
+ next_logits = outputs.logits[:, -1, :] # [B, V]
339
+ past_key_values = outputs.past_key_values
340
+
341
+ # Apply repetition penalty
342
+ if repetition_penalty != 1.0:
343
+ for b in range(B):
344
+ for token_id in set(all_generated[b]):
345
+ if next_logits[b, token_id] > 0:
346
+ next_logits[b, token_id] /= repetition_penalty
347
+ else:
348
+ next_logits[b, token_id] *= repetition_penalty
349
+
350
+ # Apply no-repeat n-gram blocking
351
+ if no_repeat_ngram_size > 0 and len(all_generated[0]) >= no_repeat_ngram_size - 1:
352
+ for b in range(B):
353
+ gen = all_generated[b]
354
+ if len(gen) >= no_repeat_ngram_size - 1:
355
+ ngram_prefix = tuple(gen[-(no_repeat_ngram_size - 1):])
356
+ # Find all n-grams in history and block their continuations
357
+ for i in range(len(gen) - no_repeat_ngram_size + 1):
358
+ if tuple(gen[i:i + no_repeat_ngram_size - 1]) == ngram_prefix:
359
+ blocked = gen[i + no_repeat_ngram_size - 1]
360
+ next_logits[b, blocked] = float('-inf')
361
+
362
+ # Temperature scaling + nucleus sampling
363
+ if temperature > 0:
364
+ next_logits = next_logits / temperature
365
+
366
+ # Top-p (nucleus) sampling
367
+ sorted_logits, sorted_indices = torch.sort(next_logits, descending=True)
368
+ cumulative_probs = torch.cumsum(F.softmax(sorted_logits, dim=-1), dim=-1)
369
+
370
+ # Remove tokens with cumulative prob > top_p
371
+ sorted_mask = cumulative_probs - F.softmax(sorted_logits, dim=-1) >= top_p
372
+ sorted_logits[sorted_mask] = float('-inf')
373
+
374
+ # Sample
375
+ probs = F.softmax(sorted_logits, dim=-1)
376
+ sampled_idx = torch.multinomial(probs, 1) # [B, 1]
377
+ next_tokens = sorted_indices.gather(1, sampled_idx) # [B, 1]
378
+ else:
379
+ next_tokens = next_logits.argmax(dim=-1, keepdim=True) # [B, 1]
380
+
381
+ generated_ids.append(next_tokens)
382
+
383
+ # Update tracking
384
+ for b in range(B):
385
+ all_generated[b].append(next_tokens[b, 0].item())
386
+
387
+ # Check for EOS
388
+ if (next_tokens == self.tokenizer.eos_token_id).all():
389
+ break
390
+
391
+ # Prepare next step input (only the new token embedding)
392
+ cur_embeds = self.lm.model.embed_tokens(next_tokens)
393
+ cur_mask = torch.cat([
394
+ cur_mask,
395
+ torch.ones(B, 1, device=device, dtype=cur_mask.dtype)
396
+ ], dim=1)
397
+
398
+ # Decode
399
+ if generated_ids:
400
+ gen_tensor = torch.cat(generated_ids, dim=1) # [B, T]
401
+ predictions = []
402
+ for i in range(B):
403
+ text = self.tokenizer.decode(gen_tensor[i], skip_special_tokens=True)
404
+ # Clean up: take only up to first newline or period for short answers
405
+ text = text.strip()
406
+ predictions.append(text)
407
+ else:
408
+ predictions = [""] * B
409
+
410
+ return predictions
411
+
412
+
413
+ # ══════════════════════════════════════════════════════════════════════════
414
+ # OPEN-ENDED DATASET (reused from Phase 3.x)
415
+ # ══════════════════════════════════════════════════════════════════════════
416
+
417
+ class OpenEndedDataset(Dataset):
418
+ def __init__(self, benchmark, split, max_samples=0, transform=None,
419
+ tokenizer=None, max_len=192):
420
+ from datasets import load_dataset
421
+ self.benchmark = benchmark
422
+ self.transform = transform
423
+ self.tokenizer = tokenizer
424
+ self.max_len = max_len
425
+ log.info(f"Loading {benchmark} {split}...")
426
+ if benchmark == "docvqa":
427
+ ds = load_dataset("lmms-lab/DocVQA", "DocVQA", split=split)
428
+ elif benchmark == "chartqa":
429
+ ds = load_dataset("lmms-lab/ChartQA", split=split)
430
+ elif benchmark == "textvqa":
431
+ ds = load_dataset("lmms-lab/textvqa", split=split)
432
+ else:
433
+ raise ValueError(f"Unknown benchmark: {benchmark}")
434
+ if max_samples > 0:
435
+ ds = ds.select(range(min(max_samples, len(ds))))
436
+ self.data = ds
437
+ log.info(f"Loaded {len(ds)} samples from {benchmark} {split}")
438
+
439
+ def __len__(self):
440
+ return len(self.data)
441
+
442
+ def __getitem__(self, idx):
443
+ row = self.data[idx]
444
+ img = row.get("image")
445
+ if img is None:
446
+ img = Image.new("RGB", (256, 256), "white")
447
+ else:
448
+ img = img.convert("RGB")
449
+ question = row["question"]
450
+ if self.benchmark == "docvqa":
451
+ answers = row.get("answers", [""])
452
+ answer = answers[0] if answers else ""
453
+ all_answers = answers
454
+ elif self.benchmark == "chartqa":
455
+ answer = str(row.get("answer", ""))
456
+ all_answers = [answer]
457
+ elif self.benchmark == "textvqa":
458
+ answers = row.get("answers", [""])
459
+ answer_counts = Counter(a.lower().strip() for a in answers)
460
+ answer = answer_counts.most_common(1)[0][0] if answer_counts else ""
461
+ all_answers = answers
462
+ else:
463
+ answer = ""
464
+ all_answers = [""]
465
+ ocr_tokens = row.get("ocr_tokens", [])
466
+ ocr_text = " ".join(ocr_tokens[:50]) if ocr_tokens else ""
467
+ text = question
468
+ if ocr_text:
469
+ text += f" [OCR: {ocr_text}]"
470
+ return {
471
+ "image": img, "text": text, "answer": answer,
472
+ "all_answers": all_answers, "benchmark": self.benchmark,
473
+ }
474
+
475
+
476
+ def collate_open_ended_p4(batch, transform, qwen_tokenizer, max_len):
477
+ """Collate for Phase 4 — we only need image, question text, and answer string."""
478
+ images = [s["image"] for s in batch]
479
+ texts = [s["text"] for s in batch]
480
+ answers = [s["answer"] for s in batch]
481
+
482
+ if hasattr(transform, '__call__') and not hasattr(transform, 'feature_extractor'):
483
+ pixel_values = torch.stack([transform(img) for img in images])
484
+ else:
485
+ pixel_values = transform(images=images, return_tensors="pt")["pixel_values"]
486
+
487
+ # Tokenize with Qwen tokenizer (for the JEPA text encoder)
488
+ tok = qwen_tokenizer(texts, padding="max_length", truncation=True,
489
+ max_length=max_len, return_tensors="pt")
490
+
491
+ return {
492
+ "pixel_values": pixel_values,
493
+ "input_ids": tok["input_ids"],
494
+ "attention_mask": tok["attention_mask"],
495
+ "questions": texts,
496
+ "answers": answers,
497
+ "batch_size": len(batch),
498
+ "benchmarks": [s["benchmark"] for s in batch],
499
+ "all_answers": [s["all_answers"] for s in batch],
500
+ }
501
+
502
+
503
+ # ══════════════════════════════════════════════════════════════════════════
504
+ # EVALUATION METRICS (same as Phase 3.x)
505
+ # ══════════════════════════════════════════════════════════════════════════
506
+
507
+ def normalized_levenshtein(s1, s2):
508
+ s1, s2 = s1.lower().strip(), s2.lower().strip()
509
+ if s1 == s2: return 0.0
510
+ l1, l2 = len(s1), len(s2)
511
+ if l1 == 0 or l2 == 0: return 1.0
512
+ m = [[0]*(l2+1) for _ in range(l1+1)]
513
+ for i in range(l1+1): m[i][0] = i
514
+ for j in range(l2+1): m[0][j] = j
515
+ for i in range(1,l1+1):
516
+ for j in range(1,l2+1):
517
+ c = 0 if s1[i-1]==s2[j-1] else 1
518
+ m[i][j] = min(m[i-1][j]+1, m[i][j-1]+1, m[i-1][j-1]+c)
519
+ return m[l1][l2]/max(l1,l2)
520
+
521
+ def compute_anls(predictions, ground_truths, threshold=0.5):
522
+ scores = []
523
+ for pred, gts in zip(predictions, ground_truths):
524
+ mx = max((1.0-normalized_levenshtein(str(pred),str(gt))
525
+ if normalized_levenshtein(str(pred),str(gt))<threshold else 0.0)
526
+ for gt in gts) if gts else 0.0
527
+ scores.append(mx)
528
+ return np.mean(scores)*100 if scores else 0.0
529
+
530
+ def compute_vqa_accuracy(predictions, ground_truths):
531
+ scores = []
532
+ for pred, gts in zip(predictions, ground_truths):
533
+ pn = str(pred).lower().strip()
534
+ scores.append(min(sum(1 for gt in gts if str(gt).lower().strip()==pn)/3.0, 1.0))
535
+ return np.mean(scores)*100 if scores else 0.0
536
+
537
+ def compute_relaxed_accuracy(predictions, ground_truths, tolerance=0.05):
538
+ correct = []
539
+ for pred, gt in zip(predictions, ground_truths):
540
+ ps, gs = str(pred).strip().lower(), str(gt).strip().lower()
541
+ try:
542
+ gv = float(gs.replace(',','').replace('%',''))
543
+ pv = float(ps.replace(',','').replace('%',''))
544
+ correct.append(abs(pv-gv)/abs(gv)<=tolerance if gv!=0 else abs(pv)<=tolerance)
545
+ except (ValueError,ZeroDivisionError):
546
+ correct.append(ps==gs)
547
+ return np.mean(correct)*100 if correct else 0.0
548
+
549
+
550
+ # ══════════════════════════════════════════════════════════════════════════
551
+ # DOWNLOAD & LOAD JEPA CHECKPOINT
552
+ # ══════════════════════════════════════════════════════════════════════════
553
+
554
+ def download_checkpoint(hub_model_id, filename):
555
+ from huggingface_hub import hf_hub_download
556
+ path = hf_hub_download(repo_id=hub_model_id, filename=filename, repo_type="model")
557
+ log.info(f"Downloaded checkpoint: {path}")
558
+ return path
559
+
560
+
561
+ def load_jepa_model(hub_model_id, ckpt_filename, device):
562
+ """Load Phase 3.1 JEPA model (everything except gen_head)."""
563
+ # Import model definitions from Phase 1 script
564
+ from huggingface_hub import hf_hub_download
565
+ p1_script = hf_hub_download(repo_id=hub_model_id, filename="train_mrjepa.py", repo_type="model")
566
+ import importlib.util
567
+ spec = importlib.util.spec_from_file_location("train_mrjepa", p1_script)
568
+ p1 = importlib.util.module_from_spec(spec)
569
+ spec.loader.exec_module(p1)
570
+
571
+ # Load checkpoint
572
+ ckpt_path = download_checkpoint(hub_model_id, ckpt_filename)
573
+ ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=False)
574
+
575
+ # Build config
576
+ saved_cfg = ckpt["config"]
577
+ cfg = p1.Config()
578
+ for k, v in saved_cfg.items():
579
+ if hasattr(cfg, k):
580
+ setattr(cfg, k, v)
581
+ cfg.resolve()
582
+
583
+ # Build model
584
+ model = p1.MRJEPAModel(cfg)
585
+ model.evidence.load_state_dict(ckpt["evidence"])
586
+ model.rollout.load_state_dict(ckpt["rollout"])
587
+ model.disc.load_state_dict(ckpt["disc"])
588
+ model.target.t_ev.load_state_dict(ckpt["target_ev"])
589
+ model.target.t_ro.load_state_dict(ckpt["target_ro"])
590
+
591
+ log.info(f"Loaded JEPA weights from {ckpt_filename} "
592
+ f"(epoch={ckpt.get('epoch','?')}, score={ckpt.get('composite_score','?')})")
593
+
594
+ return model, cfg, p1
595
+
596
+
597
+ # ══════════════════════════════════════════════════════════════════════════
598
+ # GENERATIVE EVALUATION
599
+ # ══════════════════════════════════════════════════════════════════════════
600
+
601
+ @torch.no_grad()
602
+ def evaluate_generative(jepa_model, decoder, eval_dls, device, cfg,
603
+ amp_dtype, max_new_tokens=32):
604
+ """Evaluate open-ended benchmarks using SmolLM2 generation."""
605
+ jepa_model.eval()
606
+ decoder.eval()
607
+ results = {}
608
+
609
+ for benchmark, dl in eval_dls.items():
610
+ predictions, ground_truths = [], []
611
+
612
+ for batch in dl:
613
+ bt = {k: v.to(device) if isinstance(v, torch.Tensor) else v
614
+ for k, v in batch.items()}
615
+
616
+ with torch.autocast(device_type="cuda", dtype=amp_dtype,
617
+ enabled=cfg.bf16 and device.type == "cuda"):
618
+ vis_tok = jepa_model.vis(bt["pixel_values"]).float()
619
+ txt_tok = jepa_model.txt(bt["input_ids"], bt["attention_mask"]).float()
620
+ evidence, _, _ = jepa_model.evidence(vis_tok, txt_tok, bt["attention_mask"])
621
+
622
+ if jepa_model._use_rollout:
623
+ _, z_final, _ = jepa_model.rollout(evidence)
624
+ else:
625
+ B2 = bt["batch_size"]
626
+ z_final = jepa_model.rollout.init_tokens.expand(B2,-1,-1) + \
627
+ jepa_model.rollout.z0_proj(
628
+ F.adaptive_avg_pool1d(evidence.permute(0,2,1),
629
+ jepa_model.rollout.num_tokens).permute(0,2,1))
630
+
631
+ preds = decoder.generate(
632
+ z_final.float(), evidence.float(), bt["questions"],
633
+ max_new_tokens=max_new_tokens,
634
+ temperature=0.7, top_p=0.9,
635
+ repetition_penalty=1.3, no_repeat_ngram_size=3,
636
+ )
637
+
638
+ predictions.extend(preds)
639
+ ground_truths.extend(batch["all_answers"])
640
+
641
+ # Log samples
642
+ for j in range(min(5, len(predictions))):
643
+ gt_sample = ground_truths[j] if j < len(ground_truths) else "?"
644
+ log.info(f" [{benchmark}] pred: '{predictions[j][:80]}' | gt: '{gt_sample}'")
645
+
646
+ if benchmark == "docvqa":
647
+ results[benchmark] = {"anls": compute_anls(predictions, ground_truths)}
648
+ elif benchmark == "chartqa":
649
+ gt_flat = [g[0] if isinstance(g, list) else g for g in ground_truths]
650
+ results[benchmark] = {"relaxed_accuracy": compute_relaxed_accuracy(predictions, gt_flat)}
651
+ elif benchmark == "textvqa":
652
+ results[benchmark] = {"vqa_accuracy": compute_vqa_accuracy(predictions, ground_truths)}
653
+
654
+ jepa_model.train()
655
+ decoder.train()
656
+ return results
657
+
658
+
659
+ # ══════════════════════════════════════════════════════════════════════════
660
+ # MAIN TRAINING
661
+ # ══════════════════════════════════════════════════════════════════════════
662
+
663
+ def main():
664
+ parser = argparse.ArgumentParser(description="MR-JEPA Phase 4: SmolLM2 Decoder")
665
+ parser.add_argument("--hub_model_id", default="JorgeAV/MR-JEPA")
666
+ parser.add_argument("--ckpt", default="checkpoints/hybrid_main_phase3_1_best.pt",
667
+ help="JEPA checkpoint to load")
668
+ parser.add_argument("--run_name", default="phase4_smollm2")
669
+ parser.add_argument("--stage", type=int, default=1, choices=[1, 2],
670
+ help="1=freeze LM train bridge, 2=unfreeze all")
671
+ parser.add_argument("--epochs", type=int, default=5)
672
+ parser.add_argument("--batch_size", type=int, default=4)
673
+ parser.add_argument("--grad_accum", type=int, default=32)
674
+ parser.add_argument("--bridge_lr", type=float, default=1e-3,
675
+ help="Bridge MLP learning rate (Stage 1)")
676
+ parser.add_argument("--lm_lr", type=float, default=2e-5,
677
+ help="SmolLM2 learning rate (Stage 2)")
678
+ parser.add_argument("--core_lr", type=float, default=5e-5,
679
+ help="JEPA core module learning rate")
680
+ parser.add_argument("--backbone_lr", type=float, default=5e-6)
681
+ parser.add_argument("--text_lr", type=float, default=5e-6)
682
+ parser.add_argument("--label_smoothing", type=float, default=0.1)
683
+ parser.add_argument("--num_evidence_tokens", type=int, default=8,
684
+ help="Evidence tokens as soft prompts (subsample from 64)")
685
+ parser.add_argument("--max_answer_len", type=int, default=32)
686
+ parser.add_argument("--max_eval_samples", type=int, default=200)
687
+ parser.add_argument("--max_train_samples", type=int, default=5000)
688
+ parser.add_argument("--gen_weight", type=float, default=2.0)
689
+ parser.add_argument("--output_dir", default="./outputs/mrjepa_phase4")
690
+ parser.add_argument("--trackio_space", default="JorgeAV/MR-JEPA-Trackio")
691
+ # Auto-transition: run Stage 1 for N epochs, then Stage 2 for M epochs
692
+ parser.add_argument("--stage1_epochs", type=int, default=3,
693
+ help="Auto-transition: Stage 1 epochs (0=skip)")
694
+ parser.add_argument("--stage2_epochs", type=int, default=7,
695
+ help="Auto-transition: Stage 2 epochs (0=skip)")
696
+ parser.add_argument("--auto_transition", action="store_true", default=True,
697
+ help="Auto-transition from Stage 1 → Stage 2")
698
+ args = parser.parse_args()
699
+
700
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
701
+ log.info(f"Device: {device}")
702
+ os.makedirs(args.output_dir, exist_ok=True)
703
+
704
+ # ── Load JEPA model ──
705
+ jepa_model, cfg, p1 = load_jepa_model(args.hub_model_id, args.ckpt, device)
706
+
707
+ # Unfreeze backbone/text layers (same as Phase 3.x)
708
+ jepa_model.vis.unfreeze_last(6)
709
+ jepa_model.txt.unfreeze_last(4)
710
+ jepa_model = jepa_model.to(device)
711
+
712
+ # ── Build SmolLM2 decoder ──
713
+ freeze_lm = (args.stage == 1) if not args.auto_transition else True
714
+ decoder = SmolLMDecoder(
715
+ jepa_dim=cfg.rollout_dim,
716
+ freeze_lm=freeze_lm,
717
+ label_smoothing=args.label_smoothing,
718
+ num_evidence_tokens=args.num_evidence_tokens,
719
+ ).to(device)
720
+
721
+ # ── Trackio ──
722
+ import trackio
723
+ trackio.init(
724
+ name=args.run_name, project="MR-JEPA", space_id=args.trackio_space,
725
+ config={
726
+ "phase": "4", "stage": args.stage,
727
+ "auto_transition": args.auto_transition,
728
+ "stage1_epochs": args.stage1_epochs,
729
+ "stage2_epochs": args.stage2_epochs,
730
+ "bridge_lr": args.bridge_lr, "lm_lr": args.lm_lr,
731
+ "core_lr": args.core_lr, "backbone_lr": args.backbone_lr,
732
+ "label_smoothing": args.label_smoothing,
733
+ "num_evidence_tokens": args.num_evidence_tokens,
734
+ "gen_weight": args.gen_weight,
735
+ "decoder": "SmolLM2-135M-Instruct",
736
+ "decoder_params": "135M", "bridge": "LLaVA-1.5 MLP",
737
+ }
738
+ )
739
+ log.info(f"Trackio → https://huggingface.co/spaces/{args.trackio_space}")
740
+
741
+ # ── Parameter counts ──
742
+ jepa_p = sum(p.numel() for p in jepa_model.parameters())
743
+ jepa_tp = sum(p.numel() for p in jepa_model.parameters() if p.requires_grad)
744
+ dec_p = sum(p.numel() for p in decoder.parameters())
745
+ dec_tp = sum(p.numel() for p in decoder.parameters() if p.requires_grad)
746
+ log.info(f"JEPA: {jepa_p:,} total, {jepa_tp:,} trainable")
747
+ log.info(f"Decoder: {dec_p:,} total, {dec_tp:,} trainable")
748
+ log.info(f"Combined: {jepa_p + dec_p:,} total, {jepa_tp + dec_tp:,} trainable")
749
+
750
+ # ── Datasets ──
751
+ qwen_tokenizer = jepa_model.txt.tokenizer
752
+ transform = jepa_model.vis.get_transform()
753
+
754
+ # MC dataset (ScienceQA)
755
+ mc_max = 0 # all samples
756
+ train_mc_ds = p1.ScienceQADataset("train", max_samples=mc_max, transform=transform,
757
+ tokenizer=qwen_tokenizer, max_len=cfg.max_text_len,
758
+ max_opts=cfg.max_options)
759
+ eval_mc_ds = p1.ScienceQADataset("test", max_samples=args.max_eval_samples,
760
+ transform=transform, tokenizer=qwen_tokenizer,
761
+ max_len=cfg.max_text_len, max_opts=cfg.max_options)
762
+ mc_coll = lambda batch: p1.collate_fn(batch, transform, qwen_tokenizer,
763
+ cfg.max_text_len, cfg.max_options)
764
+ train_mc_dl = DataLoader(train_mc_ds, batch_size=args.batch_size, shuffle=True,
765
+ num_workers=2, collate_fn=mc_coll, pin_memory=True, drop_last=True)
766
+ eval_mc_dl = DataLoader(eval_mc_ds, batch_size=args.batch_size, shuffle=False,
767
+ num_workers=2, collate_fn=mc_coll, pin_memory=True)
768
+
769
+ # Open-ended datasets
770
+ open_coll = lambda batch: collate_open_ended_p4(batch, transform, qwen_tokenizer,
771
+ cfg.max_text_len)
772
+ train_open_dls = {}
773
+ eval_open_dls = {}
774
+ for bm, tr_split, ev_split in [("docvqa", "validation", "validation"),
775
+ ("chartqa", "test", "test"),
776
+ ("textvqa", "train", "validation")]:
777
+ train_open_dls[bm] = DataLoader(
778
+ OpenEndedDataset(bm, tr_split, max_samples=args.max_train_samples,
779
+ transform=transform, tokenizer=qwen_tokenizer,
780
+ max_len=cfg.max_text_len),
781
+ batch_size=args.batch_size, shuffle=True, num_workers=2,
782
+ collate_fn=open_coll, pin_memory=True, drop_last=True)
783
+ eval_open_dls[bm] = DataLoader(
784
+ OpenEndedDataset(bm, ev_split, max_samples=args.max_eval_samples,
785
+ transform=transform, tokenizer=qwen_tokenizer,
786
+ max_len=cfg.max_text_len),
787
+ batch_size=args.batch_size, shuffle=False, num_workers=2,
788
+ collate_fn=open_coll, pin_memory=True)
789
+
790
+ # ── Training ──
791
+ pad_token_id = qwen_tokenizer.pad_token_id or 0
792
+ amp_dtype = torch.bfloat16 if cfg.bf16 else torch.float32
793
+
794
+ total_epochs = args.stage1_epochs + args.stage2_epochs if args.auto_transition else args.epochs
795
+
796
+ def run_training_stage(stage, num_epochs, start_epoch=0):
797
+ """Run one training stage."""
798
+ log.info(f"\n{'='*60}")
799
+ log.info(f"STAGE {stage}: {'Freeze LM, train bridge' if stage==1 else 'Unfreeze all, joint fine-tuning'}")
800
+ log.info(f"{'='*60}")
801
+
802
+ if stage == 2:
803
+ decoder.unfreeze_lm()
804
+
805
+ # Build optimizer for this stage
806
+ bridge_params = list(decoder.bridge.parameters())
807
+ if decoder.ev_pool is not None:
808
+ bridge_params += list(decoder.ev_pool.parameters())
809
+
810
+ param_groups = []
811
+
812
+ # Bridge always trains
813
+ param_groups.append({
814
+ "params": bridge_params,
815
+ "lr": args.bridge_lr if stage == 1 else args.bridge_lr * 0.1,
816
+ "name": "bridge",
817
+ })
818
+
819
+ # JEPA core (evidence, rollout, disc)
820
+ jepa_core_params = [p for n, p in jepa_model.named_parameters()
821
+ if p.requires_grad and 'vis.' not in n and 'txt.' not in n]
822
+ if jepa_core_params:
823
+ param_groups.append({
824
+ "params": jepa_core_params,
825
+ "lr": args.core_lr if stage == 2 else args.core_lr * 0.1,
826
+ "name": "jepa_core",
827
+ })
828
+
829
+ # Backbone (visual)
830
+ bb_params = [p for p in jepa_model.vis.parameters() if p.requires_grad]
831
+ if bb_params:
832
+ param_groups.append({
833
+ "params": bb_params,
834
+ "lr": args.backbone_lr,
835
+ "name": "backbone",
836
+ })
837
+
838
+ # Text encoder
839
+ txt_params = [p for p in jepa_model.txt.parameters() if p.requires_grad]
840
+ if txt_params:
841
+ param_groups.append({
842
+ "params": txt_params,
843
+ "lr": args.text_lr,
844
+ "name": "text_encoder",
845
+ })
846
+
847
+ # SmolLM2 (Stage 2 only)
848
+ if stage == 2:
849
+ lm_params = [p for p in decoder.lm.parameters() if p.requires_grad]
850
+ if lm_params:
851
+ param_groups.append({
852
+ "params": lm_params,
853
+ "lr": args.lm_lr,
854
+ "name": "smollm2",
855
+ })
856
+
857
+ # Log param groups
858
+ for pg in param_groups:
859
+ n_params = sum(p.numel() for p in pg["params"])
860
+ log.info(f" {pg['name']}: {n_params:,} params, lr={pg['lr']:.2e}")
861
+
862
+ optimizer = AdamW(param_groups, weight_decay=0.05)
863
+
864
+ mc_steps = len(train_mc_dl)
865
+ open_steps = sum(len(dl) for dl in train_open_dls.values())
866
+ total_steps = num_epochs * (mc_steps + open_steps) // args.grad_accum
867
+ warmup_steps = int(total_steps * 0.1)
868
+
869
+ def lr_lambda(step):
870
+ if step < warmup_steps:
871
+ return step / max(warmup_steps, 1)
872
+ progress = (step - warmup_steps) / max(total_steps - warmup_steps, 1)
873
+ return 0.01 + 0.99 * 0.5 * (1 + math.cos(math.pi * progress))
874
+
875
+ scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)
876
+
877
+ global_step = 0
878
+ best_composite = 0.0
879
+ all_trainable = ([p for p in jepa_model.parameters() if p.requires_grad] +
880
+ [p for p in decoder.parameters() if p.requires_grad])
881
+
882
+ for epoch in range(num_epochs):
883
+ abs_epoch = start_epoch + epoch
884
+ jepa_model.train()
885
+ decoder.train()
886
+ epoch_losses = defaultdict(list)
887
+ epoch_mc_correct, epoch_mc_total = 0, 0
888
+ optimizer.zero_grad()
889
+ batch_count = 0
890
+
891
+ # ── MC training ──
892
+ log.info(f" Stage {stage} Epoch {epoch}/{num_epochs}: MC training...")
893
+ for bi, batch in enumerate(train_mc_dl):
894
+ batch = {k: v.to(device) if isinstance(v, torch.Tensor) else v
895
+ for k, v in batch.items()}
896
+ with torch.autocast(device_type="cuda", dtype=amp_dtype,
897
+ enabled=cfg.bf16 and device.type == "cuda"):
898
+ losses, preds = jepa_model(**batch)
899
+ loss = losses["total"] / args.grad_accum
900
+ loss.backward()
901
+ batch_count += 1
902
+ if batch_count % args.grad_accum == 0:
903
+ nn.utils.clip_grad_norm_(all_trainable, cfg.max_grad_norm)
904
+ optimizer.step(); scheduler.step(); optimizer.zero_grad()
905
+ jepa_model.update_target(global_step, total_steps)
906
+ global_step += 1
907
+ for k, v in losses.items():
908
+ if isinstance(v, torch.Tensor):
909
+ epoch_losses[f"mc_{k}"].append(v.item())
910
+ epoch_mc_correct += (preds == batch["labels"]).sum().item()
911
+ epoch_mc_total += batch["batch_size"]
912
+ if bi % 100 == 0:
913
+ avg = {k: np.mean(v[-100:]) for k, v in epoch_losses.items() if k.startswith("mc_")}
914
+ acc = epoch_mc_correct / max(epoch_mc_total, 1) * 100
915
+ log.info(f" S{stage} E{epoch} MC B{bi}/{mc_steps} | "
916
+ f"loss={avg.get('mc_total',0):.4f} | acc={acc:.1f}%")
917
+ trackio.log({"train/mc_loss": avg.get("mc_total", 0),
918
+ "train/mc_accuracy": acc,
919
+ "train/lr": scheduler.get_last_lr()[0],
920
+ "train/epoch": abs_epoch, "train/stage": stage,
921
+ "train/step": global_step})
922
+
923
+ # ── Open-ended training (generative) ──
924
+ log.info(f" Stage {stage} Epoch {epoch}: Open-ended training...")
925
+ gen_losses = defaultdict(list)
926
+ open_iters = {n: iter(dl) for n, dl in train_open_dls.items()}
927
+ open_active = set(open_iters.keys())
928
+ obi = 0
929
+
930
+ while open_active:
931
+ for name in list(open_active):
932
+ try:
933
+ batch = next(open_iters[name])
934
+ except StopIteration:
935
+ open_active.discard(name)
936
+ continue
937
+
938
+ bt = {k: v.to(device) if isinstance(v, torch.Tensor) else v
939
+ for k, v in batch.items()}
940
+
941
+ with torch.autocast(device_type="cuda", dtype=amp_dtype,
942
+ enabled=cfg.bf16 and device.type == "cuda"):
943
+ # JEPA encode
944
+ vis_tok = jepa_model.vis(bt["pixel_values"]).float()
945
+ txt_tok = jepa_model.txt(bt["input_ids"], bt["attention_mask"]).float()
946
+ evidence, _, _ = jepa_model.evidence(vis_tok, txt_tok, bt["attention_mask"])
947
+
948
+ if jepa_model._use_rollout:
949
+ traj, z_final, z_proj = jepa_model.rollout(evidence)
950
+ else:
951
+ B2 = bt["batch_size"]
952
+ z0 = jepa_model.rollout.init_tokens.expand(B2,-1,-1) + \
953
+ jepa_model.rollout.z0_proj(F.adaptive_avg_pool1d(
954
+ evidence.permute(0,2,1), jepa_model.rollout.num_tokens).permute(0,2,1))
955
+ z_final = z0
956
+ z_proj = jepa_model.rollout.out_proj(z0).unsqueeze(1)
957
+
958
+ # JEPA loss (keep training the rollout)
959
+ jepa_loss_val = torch.tensor(0.0, device=device)
960
+ if jepa_model._use_jepa:
961
+ target_proj = jepa_model.target(
962
+ vis_tok.detach(), txt_tok.detach(), bt["attention_mask"].detach())
963
+ jl = jepa_model.jepa_loss(z_proj, target_proj, torch.tensor(0.0, device=device))
964
+ jepa_loss_val = jl["jepa"] + jl["reg"]
965
+
966
+ # SmolLM2 generative loss
967
+ gen_loss, gen_logits = decoder(
968
+ z_final.float(), evidence.float(),
969
+ bt["questions"], bt["answers"],
970
+ max_answer_len=args.max_answer_len,
971
+ )
972
+
973
+ total_loss = cfg.jepa_weight * jepa_loss_val + args.gen_weight * gen_loss
974
+ loss = total_loss / args.grad_accum
975
+
976
+ loss.backward()
977
+ batch_count += 1
978
+ if batch_count % args.grad_accum == 0:
979
+ nn.utils.clip_grad_norm_(all_trainable, cfg.max_grad_norm)
980
+ optimizer.step(); scheduler.step(); optimizer.zero_grad()
981
+ jepa_model.update_target(global_step, total_steps)
982
+ global_step += 1
983
+
984
+ gen_losses[f"{name}_gen"].append(gen_loss.item())
985
+ gen_losses[f"{name}_total"].append(total_loss.item())
986
+ obi += 1
987
+ if obi % 50 == 0:
988
+ avg = {k: np.mean(v[-50:]) for k, v in gen_losses.items()}
989
+ log.info(f" S{stage} E{epoch} OPEN B{obi} | " +
990
+ " | ".join(f"{k}={v:.4f}" for k, v in avg.items()))
991
+ trackio.log({f"train/{k}": v for k, v in avg.items()})
992
+
993
+ # ── Evaluation ──
994
+ log.info(f" Stage {stage} Epoch {epoch}: Evaluating...")
995
+ mc_eval_acc = p1.evaluate(jepa_model, eval_mc_dl, device, cfg)
996
+ log.info(f" ScienceQA: {mc_eval_acc:.1f}%")
997
+
998
+ gen_results = evaluate_generative(
999
+ jepa_model, decoder, eval_open_dls, device, cfg, amp_dtype,
1000
+ max_new_tokens=args.max_answer_len,
1001
+ )
1002
+ for bm, metrics in gen_results.items():
1003
+ for mk, mv in metrics.items():
1004
+ log.info(f" {bm} {mk}: {mv:.2f}%")
1005
+
1006
+ all_scores = [mc_eval_acc] + [v for m in gen_results.values() for v in m.values()]
1007
+ composite = np.mean(all_scores)
1008
+ log.info(f"{'='*40}")
1009
+ log.info(f"Stage {stage} Epoch {epoch} | MC: {mc_eval_acc:.1f}% | Composite: {composite:.1f}")
1010
+ log.info(f"{'='*40}")
1011
+
1012
+ trackio.log({
1013
+ "eval/scienceqa_accuracy": mc_eval_acc,
1014
+ "eval/composite_score": composite,
1015
+ "eval/epoch": abs_epoch, "eval/stage": stage,
1016
+ **{f"eval/{bm}_{mk}": mv for bm, m in gen_results.items() for mk, mv in m.items()},
1017
+ })
1018
+
1019
+ if composite > best_composite:
1020
+ best_composite = composite
1021
+ save_phase4_checkpoint(
1022
+ jepa_model, decoder, cfg, args, abs_epoch,
1023
+ mc_eval_acc, gen_results, composite, stage,
1024
+ )
1025
+ log.info(f" ★ New best composite: {best_composite:.1f}")
1026
+
1027
+ return best_composite
1028
+
1029
+ # ── Execute training ──
1030
+ best_overall = 0.0
1031
+
1032
+ try:
1033
+ if args.auto_transition:
1034
+ # Stage 1: Freeze LM, train bridge
1035
+ if args.stage1_epochs > 0:
1036
+ s1_best = run_training_stage(stage=1, num_epochs=args.stage1_epochs, start_epoch=0)
1037
+ best_overall = max(best_overall, s1_best)
1038
+
1039
+ # Stage 2: Unfreeze all
1040
+ if args.stage2_epochs > 0:
1041
+ s2_best = run_training_stage(stage=2, num_epochs=args.stage2_epochs,
1042
+ start_epoch=args.stage1_epochs)
1043
+ best_overall = max(best_overall, s2_best)
1044
+ else:
1045
+ best_overall = run_training_stage(stage=args.stage, num_epochs=args.epochs)
1046
+
1047
+ log.info(f"\nPhase 4 complete. Best composite: {best_overall:.1f}")
1048
+
1049
+ finally:
1050
+ trackio.log({"final/best_composite": best_overall, "final/phase": "4"})
1051
+ trackio.finish()
1052
+
1053
+ # Push final results
1054
+ push_phase4_results(cfg, args, best_overall)
1055
+
1056
+
1057
+ def save_phase4_checkpoint(jepa_model, decoder, cfg, args, epoch,
1058
+ mc_acc, gen_results, composite, stage):
1059
+ """Save combined checkpoint."""
1060
+ path = os.path.join(args.output_dir, "checkpoint_best.pt")
1061
+ torch.save({
1062
+ "evidence": jepa_model.evidence.state_dict(),
1063
+ "rollout": jepa_model.rollout.state_dict(),
1064
+ "disc": jepa_model.disc.state_dict(),
1065
+ "target_ev": jepa_model.target.t_ev.state_dict(),
1066
+ "target_ro": jepa_model.target.t_ro.state_dict(),
1067
+ "bridge": decoder.bridge.state_dict(),
1068
+ "ev_pool": decoder.ev_pool.state_dict() if decoder.ev_pool is not None else None,
1069
+ "smollm2": decoder.lm.state_dict(),
1070
+ "config": cfg.__dict__,
1071
+ "phase4_args": vars(args),
1072
+ "epoch": epoch, "stage": stage,
1073
+ "mc_eval_acc": mc_acc,
1074
+ "gen_results": gen_results,
1075
+ "composite_score": composite,
1076
+ "phase": "4",
1077
+ }, path)
1078
+ log.info(f"Saved Phase 4 checkpoint: {path} (composite={composite:.1f})")
1079
+
1080
+
1081
+ def push_phase4_results(cfg, args, best_composite):
1082
+ """Push results and checkpoint to Hub."""
1083
+ try:
1084
+ from huggingface_hub import HfApi
1085
+ api = HfApi()
1086
+
1087
+ results = {
1088
+ "run_name": args.run_name, "phase": "4",
1089
+ "decoder": "SmolLM2-135M-Instruct",
1090
+ "bridge": "LLaVA-1.5 MLP (768→576→576)",
1091
+ "backbone": cfg.backbone, "K": cfg.K,
1092
+ "best_composite_score": best_composite,
1093
+ "stage1_epochs": args.stage1_epochs,
1094
+ "stage2_epochs": args.stage2_epochs,
1095
+ "bridge_lr": args.bridge_lr, "lm_lr": args.lm_lr,
1096
+ "core_lr": args.core_lr, "label_smoothing": args.label_smoothing,
1097
+ "num_evidence_tokens": args.num_evidence_tokens,
1098
+ "gen_weight": args.gen_weight,
1099
+ }
1100
+ rp = os.path.join(args.output_dir, f"results_{args.run_name}.json")
1101
+ with open(rp, "w") as f:
1102
+ json.dump(results, f, indent=2)
1103
+
1104
+ api.upload_file(path_or_fileobj=rp,
1105
+ path_in_repo=f"results/{args.run_name}.json",
1106
+ repo_id=args.hub_model_id, repo_type="model")
1107
+
1108
+ best_ckpt = os.path.join(args.output_dir, "checkpoint_best.pt")
1109
+ if os.path.exists(best_ckpt):
1110
+ api.upload_file(path_or_fileobj=best_ckpt,
1111
+ path_in_repo=f"checkpoints/{args.run_name}_best.pt",
1112
+ repo_id=args.hub_model_id, repo_type="model")
1113
+
1114
+ # Also upload the training script
1115
+ script_path = os.path.abspath(__file__)
1116
+ api.upload_file(path_or_fileobj=script_path,
1117
+ path_in_repo="train_phase4.py",
1118
+ repo_id=args.hub_model_id, repo_type="model")
1119
+
1120
+ log.info(f"Pushed Phase 4 results to {args.hub_model_id}")
1121
+ except Exception as e:
1122
+ log.error(f"Push failed: {e}")
1123
+
1124
+
1125
+ if __name__ == "__main__":
1126
+ main()