JorgeAV commited on
Commit
a05b0f9
·
verified ·
1 Parent(s): 3d28d08

Add complete Phase 3 training script with generative decoder + open-ended VQA

Browse files
Files changed (1) hide show
  1. train_phase3.py +1014 -3
train_phase3.py CHANGED
@@ -29,7 +29,1018 @@ Phase 3 hyperparameters (from ARCHITECTURE.md):
29
  Usage:
30
  python train_phase3.py
31
  python train_phase3.py --epochs 10 --core_lr 5e-5
32
-
33
- Prerequisites:
34
- Phase 2 must be complete with a saved checkpoint at JorgeAV/MR-JEPA
35
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  Usage:
30
  python train_phase3.py
31
  python train_phase3.py --epochs 10 --core_lr 5e-5
 
 
 
32
  """
33
+
34
+ import os
35
+ import sys
36
+ import json
37
+ import math
38
+ import copy
39
+ import logging
40
+ import argparse
41
+ from collections import defaultdict
42
+
43
+ import numpy as np
44
+ import torch
45
+ import torch.nn as nn
46
+ import torch.nn.functional as F
47
+ from torch.optim import AdamW
48
+ from torch.utils.data import Dataset, DataLoader
49
+ from PIL import Image
50
+
51
+ logging.basicConfig(
52
+ level=logging.INFO,
53
+ format="%(asctime)s | %(levelname)s | %(message)s",
54
+ datefmt="%H:%M:%S",
55
+ )
56
+ log = logging.getLogger("mrjepa-p3")
57
+
58
+
59
+ # ══════════════════════════════════════════════════════════════════════════
60
+ # OPEN-ENDED DATASET (DocVQA / ChartQA / TextVQA)
61
+ # ══════════════════════════════════════════════════════════════════════════
62
+
63
+ class OpenEndedDataset(Dataset):
64
+ """Dataset for open-ended VQA benchmarks (Phase 3 generative training)."""
65
+
66
+ def __init__(self, benchmark, split, max_samples=0, transform=None,
67
+ tokenizer=None, max_len=192, max_gen_len=64):
68
+ from datasets import load_dataset
69
+
70
+ self.benchmark = benchmark
71
+ self.transform = transform
72
+ self.tokenizer = tokenizer
73
+ self.max_len = max_len
74
+ self.max_gen_len = max_gen_len
75
+
76
+ log.info(f"Loading {benchmark} {split}...")
77
+
78
+ if benchmark == "docvqa":
79
+ ds = load_dataset("lmms-lab/DocVQA", "DocVQA", split=split)
80
+ elif benchmark == "chartqa":
81
+ ds = load_dataset("lmms-lab/ChartQA", split=split)
82
+ elif benchmark == "textvqa":
83
+ ds = load_dataset("lmms-lab/textvqa", split=split)
84
+ else:
85
+ raise ValueError(f"Unknown benchmark: {benchmark}")
86
+
87
+ if max_samples > 0:
88
+ ds = ds.select(range(min(max_samples, len(ds))))
89
+
90
+ self.data = ds
91
+ log.info(f"Loaded {len(ds)} samples from {benchmark} {split}")
92
+
93
+ def __len__(self):
94
+ return len(self.data)
95
+
96
+ def __getitem__(self, idx):
97
+ row = self.data[idx]
98
+
99
+ # Image
100
+ img = row.get("image")
101
+ if img is None:
102
+ img = Image.new("RGB", (256, 256), "white")
103
+ else:
104
+ img = img.convert("RGB")
105
+
106
+ # Question
107
+ question = row["question"]
108
+
109
+ # Answer (target for generative head)
110
+ if self.benchmark == "docvqa":
111
+ answers = row.get("answers", [""])
112
+ answer = answers[0] if answers else ""
113
+ all_answers = answers
114
+ elif self.benchmark == "chartqa":
115
+ answer = str(row.get("answer", ""))
116
+ all_answers = [answer]
117
+ elif self.benchmark == "textvqa":
118
+ answers = row.get("answers", [""])
119
+ # Use most common answer
120
+ from collections import Counter
121
+ answer_counts = Counter(a.lower().strip() for a in answers)
122
+ answer = answer_counts.most_common(1)[0][0] if answer_counts else ""
123
+ all_answers = answers
124
+ else:
125
+ answer = ""
126
+ all_answers = [""]
127
+
128
+ # OCR tokens (TextVQA provides them; others we skip for now)
129
+ ocr_tokens = row.get("ocr_tokens", [])
130
+ ocr_text = " ".join(ocr_tokens[:50]) if ocr_tokens else ""
131
+
132
+ # Build text: question + optional OCR context
133
+ text = question
134
+ if ocr_text:
135
+ text += f" [OCR: {ocr_text}]"
136
+
137
+ return {
138
+ "image": img,
139
+ "text": text,
140
+ "answer": answer,
141
+ "all_answers": all_answers,
142
+ "benchmark": self.benchmark,
143
+ "ocr_text": ocr_text,
144
+ "question_type": row.get("type", row.get("question_types", [""])),
145
+ }
146
+
147
+
148
+ def collate_open_ended(batch, transform, tokenizer, max_len, max_gen_len):
149
+ """Collate function for open-ended VQA batches."""
150
+ images = [s["image"] for s in batch]
151
+ texts = [s["text"] for s in batch]
152
+ answers = [s["answer"] for s in batch]
153
+
154
+ # Process images
155
+ if hasattr(transform, '__call__') and not hasattr(transform, 'feature_extractor'):
156
+ pixel_values = torch.stack([transform(img) for img in images])
157
+ else:
158
+ pixel_values = transform(images=images, return_tensors="pt")["pixel_values"]
159
+
160
+ # Tokenize questions
161
+ tok = tokenizer(
162
+ texts, padding="max_length", truncation=True,
163
+ max_length=max_len, return_tensors="pt"
164
+ )
165
+
166
+ # Tokenize answers (generative targets)
167
+ # Add BOS/EOS tokens if available
168
+ answer_texts = [a if a else " " for a in answers]
169
+ gen_tok = tokenizer(
170
+ answer_texts, padding="max_length", truncation=True,
171
+ max_length=max_gen_len, return_tensors="pt"
172
+ )
173
+
174
+ return {
175
+ "pixel_values": pixel_values,
176
+ "input_ids": tok["input_ids"],
177
+ "attention_mask": tok["attention_mask"],
178
+ "gen_target_ids": gen_tok["input_ids"],
179
+ "gen_attention_mask": gen_tok["attention_mask"],
180
+ "batch_size": len(batch),
181
+ "benchmarks": [s["benchmark"] for s in batch],
182
+ "all_answers": [s["all_answers"] for s in batch],
183
+ "question_types": [s.get("question_type", "") for s in batch],
184
+ }
185
+
186
+
187
+ # ══════════════════════════════════════════════════════════════════════════
188
+ # GENERATIVE HEAD (Lightweight Transformer Decoder)
189
+ # ══════════════════════════════════════════════════════════════════════════
190
+
191
+ class GenerativeDecoderLayer(nn.Module):
192
+ """Transformer decoder layer with cross-attention to latent state and evidence."""
193
+
194
+ def __init__(self, hidden_dim, num_heads, dropout=0.1):
195
+ super().__init__()
196
+ # Causal self-attention
197
+ self.self_attn = nn.MultiheadAttention(
198
+ embed_dim=hidden_dim, num_heads=num_heads,
199
+ dropout=dropout, batch_first=True,
200
+ )
201
+ self.self_attn_norm = nn.LayerNorm(hidden_dim)
202
+
203
+ # Cross-attention to latent state z_K
204
+ self.state_cross_attn = nn.MultiheadAttention(
205
+ embed_dim=hidden_dim, num_heads=num_heads,
206
+ dropout=dropout, batch_first=True,
207
+ )
208
+ self.state_cross_norm = nn.LayerNorm(hidden_dim)
209
+
210
+ # Cross-attention to evidence memory
211
+ self.evidence_cross_attn = nn.MultiheadAttention(
212
+ embed_dim=hidden_dim, num_heads=num_heads,
213
+ dropout=dropout, batch_first=True,
214
+ )
215
+ self.evidence_cross_norm = nn.LayerNorm(hidden_dim)
216
+
217
+ # FFN
218
+ self.ffn = nn.Sequential(
219
+ nn.Linear(hidden_dim, hidden_dim * 4),
220
+ nn.GELU(),
221
+ nn.Dropout(dropout),
222
+ nn.Linear(hidden_dim * 4, hidden_dim),
223
+ nn.Dropout(dropout),
224
+ )
225
+ self.ffn_norm = nn.LayerNorm(hidden_dim)
226
+
227
+ def forward(self, x, z_final, evidence, causal_mask=None):
228
+ # Causal self-attention
229
+ r = x
230
+ x2 = self.self_attn_norm(x)
231
+ x2, _ = self.self_attn(x2, x2, x2, attn_mask=causal_mask)
232
+ x = r + x2
233
+
234
+ # Cross-attention to latent state
235
+ r = x
236
+ x2 = self.state_cross_norm(x)
237
+ x2, _ = self.state_cross_attn(x2, z_final, z_final)
238
+ x = r + x2
239
+
240
+ # Cross-attention to evidence
241
+ r = x
242
+ x2 = self.evidence_cross_norm(x)
243
+ x2, _ = self.evidence_cross_attn(x2, evidence, evidence)
244
+ x = r + x2
245
+
246
+ # FFN
247
+ r = x
248
+ x = r + self.ffn(self.ffn_norm(x))
249
+ return x
250
+
251
+
252
+ class GenerativeHead(nn.Module):
253
+ """
254
+ Lightweight generative decoder for Phase 3.
255
+
256
+ Cross-attends to z_K and evidence memory to generate short answers.
257
+ Uses the text encoder's tokenizer vocabulary.
258
+ """
259
+
260
+ def __init__(self, hidden_dim, vocab_size, num_layers=4, num_heads=12,
261
+ max_gen_len=64, dropout=0.1):
262
+ super().__init__()
263
+ self.hidden_dim = hidden_dim
264
+ self.vocab_size = vocab_size
265
+ self.max_gen_len = max_gen_len
266
+
267
+ # Token embedding + positional encoding
268
+ self.token_embedding = nn.Embedding(vocab_size, hidden_dim)
269
+ self.pos_embedding = nn.Embedding(max_gen_len, hidden_dim)
270
+
271
+ # Decoder layers
272
+ self.layers = nn.ModuleList([
273
+ GenerativeDecoderLayer(hidden_dim, num_heads, dropout)
274
+ for _ in range(num_layers)
275
+ ])
276
+
277
+ # Output
278
+ self.output_norm = nn.LayerNorm(hidden_dim)
279
+ self.lm_head = nn.Linear(hidden_dim, vocab_size, bias=False)
280
+
281
+ # Tie weights
282
+ self.lm_head.weight = self.token_embedding.weight
283
+
284
+ def forward(self, z_final, evidence, target_ids, pad_token_id=0):
285
+ """Teacher-forced forward pass."""
286
+ B, seq_len = target_ids.shape
287
+ device = target_ids.device
288
+
289
+ positions = torch.arange(seq_len, device=device).unsqueeze(0)
290
+ x = self.token_embedding(target_ids) + self.pos_embedding(positions)
291
+
292
+ # Causal mask
293
+ causal_mask = torch.triu(
294
+ torch.ones(seq_len, seq_len, device=device, dtype=torch.bool),
295
+ diagonal=1
296
+ )
297
+
298
+ for layer in self.layers:
299
+ x = layer(x, z_final, evidence, causal_mask)
300
+
301
+ logits = self.lm_head(self.output_norm(x))
302
+
303
+ # Loss: next-token prediction
304
+ shift_logits = logits[:, :-1].contiguous()
305
+ shift_labels = target_ids[:, 1:].contiguous()
306
+
307
+ # Mask padding tokens
308
+ loss = F.cross_entropy(
309
+ shift_logits.view(-1, self.vocab_size),
310
+ shift_labels.view(-1),
311
+ ignore_index=pad_token_id,
312
+ )
313
+
314
+ return logits, loss
315
+
316
+ @torch.no_grad()
317
+ def generate(self, z_final, evidence, start_token_id, max_length=64, eos_token_id=None):
318
+ """Autoregressive generation."""
319
+ B = z_final.size(0)
320
+ device = z_final.device
321
+
322
+ generated = torch.full((B, 1), start_token_id, dtype=torch.long, device=device)
323
+
324
+ for step in range(max_length - 1):
325
+ seq_len = generated.size(1)
326
+ positions = torch.arange(seq_len, device=device).unsqueeze(0)
327
+ x = self.token_embedding(generated) + self.pos_embedding(positions)
328
+
329
+ causal_mask = torch.triu(
330
+ torch.ones(seq_len, seq_len, device=device, dtype=torch.bool),
331
+ diagonal=1
332
+ )
333
+
334
+ for layer in self.layers:
335
+ x = layer(x, z_final, evidence, causal_mask)
336
+
337
+ logits = self.lm_head(self.output_norm(x[:, -1:]))
338
+ next_token = logits.argmax(dim=-1)
339
+ generated = torch.cat([generated, next_token], dim=1)
340
+
341
+ if eos_token_id is not None and (next_token == eos_token_id).all():
342
+ break
343
+
344
+ return generated
345
+
346
+
347
+ # ══════════════════════════════════════════════════════════════════════════
348
+ # EVALUATION FUNCTIONS (Phase 3 Metrics)
349
+ # ══════════════════════════════════════════════════════════════════════════
350
+
351
+ def normalized_levenshtein(s1, s2):
352
+ """Normalized Levenshtein distance."""
353
+ s1 = s1.lower().strip()
354
+ s2 = s2.lower().strip()
355
+ if s1 == s2:
356
+ return 0.0
357
+ len1, len2 = len(s1), len(s2)
358
+ if len1 == 0 or len2 == 0:
359
+ return 1.0
360
+ matrix = [[0] * (len2 + 1) for _ in range(len1 + 1)]
361
+ for i in range(len1 + 1):
362
+ matrix[i][0] = i
363
+ for j in range(len2 + 1):
364
+ matrix[0][j] = j
365
+ for i in range(1, len1 + 1):
366
+ for j in range(1, len2 + 1):
367
+ cost = 0 if s1[i-1] == s2[j-1] else 1
368
+ matrix[i][j] = min(matrix[i-1][j]+1, matrix[i][j-1]+1, matrix[i-1][j-1]+cost)
369
+ return matrix[len1][len2] / max(len1, len2)
370
+
371
+
372
+ def compute_anls(predictions, ground_truths, threshold=0.5):
373
+ """ANLS metric for DocVQA."""
374
+ scores = []
375
+ for pred, gts in zip(predictions, ground_truths):
376
+ max_score = 0.0
377
+ for gt in gts:
378
+ nl_dist = normalized_levenshtein(str(pred), str(gt))
379
+ score = 1.0 - nl_dist if nl_dist < threshold else 0.0
380
+ max_score = max(max_score, score)
381
+ scores.append(max_score)
382
+ return np.mean(scores) * 100 if scores else 0.0
383
+
384
+
385
+ def compute_vqa_accuracy(predictions, ground_truths):
386
+ """VQA accuracy for TextVQA."""
387
+ scores = []
388
+ for pred, gts in zip(predictions, ground_truths):
389
+ pred_norm = str(pred).lower().strip()
390
+ matching = sum(1 for gt in gts if str(gt).lower().strip() == pred_norm)
391
+ scores.append(min(matching / 3.0, 1.0))
392
+ return np.mean(scores) * 100 if scores else 0.0
393
+
394
+
395
+ def compute_relaxed_accuracy(predictions, ground_truths, tolerance=0.05):
396
+ """Relaxed accuracy for ChartQA."""
397
+ correct = []
398
+ for pred, gt in zip(predictions, ground_truths):
399
+ pred_str = str(pred).strip().lower()
400
+ gt_str = str(gt).strip().lower()
401
+ try:
402
+ gt_val = float(gt_str.replace(',', '').replace('%', ''))
403
+ pred_val = float(pred_str.replace(',', '').replace('%', ''))
404
+ if gt_val == 0:
405
+ is_correct = abs(pred_val) <= tolerance
406
+ else:
407
+ is_correct = abs(pred_val - gt_val) / abs(gt_val) <= tolerance
408
+ except (ValueError, ZeroDivisionError):
409
+ is_correct = pred_str == gt_str
410
+ correct.append(is_correct)
411
+ return np.mean(correct) * 100 if correct else 0.0
412
+
413
+
414
+ # ══════════════════════════════════════════════════════════════════════════
415
+ # PHASE 3 MAIN
416
+ # ══════════════════════════════════════════════════════════════════════════
417
+
418
+ def download_phase2_checkpoint(hub_model_id, run_name="hybrid_main"):
419
+ from huggingface_hub import hf_hub_download
420
+ path = hf_hub_download(
421
+ repo_id=hub_model_id,
422
+ filename=f"checkpoints/{run_name}_best.pt",
423
+ repo_type="model"
424
+ )
425
+ log.info(f"Downloaded Phase 2 checkpoint: {path}")
426
+ return path
427
+
428
+
429
+ def main():
430
+ parser = argparse.ArgumentParser(description="MR-JEPA Phase 3 Training")
431
+ parser.add_argument("--checkpoint", type=str, default=None)
432
+ parser.add_argument("--hub_model_id", default="JorgeAV/MR-JEPA")
433
+ parser.add_argument("--run_name", default="hybrid_main_phase3")
434
+ parser.add_argument("--phase2_run", default="hybrid_main")
435
+ parser.add_argument("--epochs", type=int, default=10)
436
+ parser.add_argument("--batch_size", type=int, default=16)
437
+ parser.add_argument("--grad_accum", type=int, default=8)
438
+ parser.add_argument("--core_lr", type=float, default=5e-5)
439
+ parser.add_argument("--backbone_lr", type=float, default=5e-6)
440
+ parser.add_argument("--text_lr", type=float, default=5e-6)
441
+ parser.add_argument("--gen_weight", type=float, default=0.5,
442
+ help="Weight for generative loss relative to task loss")
443
+ parser.add_argument("--max_eval_samples", type=int, default=500)
444
+ parser.add_argument("--max_gen_len", type=int, default=64)
445
+ parser.add_argument("--max_train_samples", type=int, default=0,
446
+ help="0 = all samples")
447
+ parser.add_argument("--output_dir", default="./outputs/mrjepa_phase3")
448
+ parser.add_argument("--trackio_space", default="JorgeAV/MR-JEPA-Trackio")
449
+ args = parser.parse_args()
450
+
451
+ # ── Download Phase 1 training script (has all model definitions) ──
452
+ log.info("Downloading Phase 1 training script for model definitions...")
453
+ from huggingface_hub import hf_hub_download
454
+ p1_script = hf_hub_download(
455
+ repo_id=args.hub_model_id, filename="train_mrjepa.py", repo_type="model"
456
+ )
457
+ import importlib.util
458
+ spec = importlib.util.spec_from_file_location("train_mrjepa", p1_script)
459
+ p1 = importlib.util.module_from_spec(spec)
460
+ spec.loader.exec_module(p1)
461
+
462
+ # ── Load Phase 2 checkpoint ──
463
+ if args.checkpoint and os.path.exists(args.checkpoint):
464
+ ckpt_path = args.checkpoint
465
+ else:
466
+ ckpt_path = download_phase2_checkpoint(args.hub_model_id, args.phase2_run)
467
+
468
+ log.info(f"Loading Phase 2 checkpoint: {ckpt_path}")
469
+ ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=False)
470
+
471
+ saved_cfg = ckpt["config"]
472
+ cfg = p1.Config()
473
+ for k, v in saved_cfg.items():
474
+ if hasattr(cfg, k):
475
+ setattr(cfg, k, v)
476
+
477
+ cfg.phase = 3
478
+ cfg.epochs = args.epochs
479
+ cfg.batch_size = args.batch_size
480
+ cfg.grad_accum = args.grad_accum
481
+ cfg.lr = args.core_lr
482
+ cfg.backbone_lr = args.backbone_lr
483
+ cfg.output_dir = args.output_dir
484
+ cfg.run_name = args.run_name
485
+ cfg.freeze_backbone = True # Will unfreeze manually below
486
+ cfg.freeze_text = True
487
+ cfg.max_eval_samples = args.max_eval_samples
488
+ cfg.resolve()
489
+
490
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
491
+ log.info(f"Device: {device}")
492
+ os.makedirs(cfg.output_dir, exist_ok=True)
493
+
494
+ # ── Initialize Trackio ──
495
+ import trackio
496
+ trackio.init(
497
+ name=args.run_name,
498
+ project="MR-JEPA",
499
+ space_id=args.trackio_space,
500
+ config={
501
+ "phase": 3, "epochs": args.epochs,
502
+ "core_lr": args.core_lr, "backbone_lr": args.backbone_lr,
503
+ "text_lr": args.text_lr, "gen_weight": args.gen_weight,
504
+ "batch_size": args.batch_size, "grad_accum": args.grad_accum,
505
+ "backbone": cfg.backbone, "K": cfg.K,
506
+ "use_jepa": cfg.use_jepa, "loss_fn": cfg.loss_fn,
507
+ "max_gen_len": args.max_gen_len,
508
+ "phase2_best_acc": ckpt.get("eval_acc", "unknown"),
509
+ }
510
+ )
511
+ log.info(f"Trackio initialized → https://huggingface.co/spaces/{args.trackio_space}")
512
+
513
+ # ── Build model ──
514
+ log.info("Building model...")
515
+ model = p1.MRJEPAModel(cfg)
516
+ model.evidence.load_state_dict(ckpt["evidence"])
517
+ model.rollout.load_state_dict(ckpt["rollout"])
518
+ model.disc.load_state_dict(ckpt["disc"])
519
+ model.target.t_ev.load_state_dict(ckpt["target_ev"])
520
+ model.target.t_ro.load_state_dict(ckpt["target_ro"])
521
+ log.info(f"Loaded Phase 2 weights (epoch={ckpt.get('epoch','?')}, "
522
+ f"eval_acc={ckpt.get('eval_acc','?')}%)")
523
+
524
+ # ── Add generative head ──
525
+ tokenizer = model.txt.tokenizer
526
+ # Use len(tokenizer) not tokenizer.vocab_size — Qwen3 has special tokens
527
+ # beyond vocab_size (pad_token_id=151643 >= vocab_size=151643)
528
+ actual_vocab_size = len(tokenizer)
529
+ log.info(f"Adding generative head: actual_vocab_size={actual_vocab_size}, "
530
+ f"hidden_dim={cfg.rollout_dim}, layers=4")
531
+
532
+ gen_head = GenerativeHead(
533
+ hidden_dim=cfg.rollout_dim,
534
+ vocab_size=actual_vocab_size,
535
+ num_layers=4,
536
+ num_heads=cfg.predictor_heads,
537
+ max_gen_len=args.max_gen_len,
538
+ dropout=0.1,
539
+ )
540
+ model.gen_head = gen_head
541
+
542
+ # ── Unfreeze backbone layers (same as Phase 2 — keep them unfrozen) ──
543
+ log.info("Unfreezing last 6 visual layers, last 4 text layers")
544
+ model.vis.unfreeze_last(6)
545
+ model.txt.unfreeze_last(4)
546
+
547
+ model = model.to(device)
548
+ total_p = sum(p.numel() for p in model.parameters())
549
+ train_p = sum(p.numel() for p in model.parameters() if p.requires_grad)
550
+ log.info(f"Total: {total_p:,} | Trainable: {train_p:,} ({100*train_p/total_p:.1f}%)")
551
+ trackio.log({
552
+ "model/total_params": total_p,
553
+ "model/trainable_params": train_p,
554
+ "model/trainable_pct": 100 * train_p / total_p
555
+ })
556
+
557
+ # ── Build datasets ──
558
+ transform = model.vis.get_transform()
559
+
560
+ # MC dataset (ScienceQA) — keep JEPA + task loss
561
+ mc_max = args.max_train_samples if args.max_train_samples > 0 else 0
562
+ train_mc_ds = p1.ScienceQADataset(
563
+ "train", max_samples=mc_max, transform=transform, tokenizer=tokenizer,
564
+ max_len=cfg.max_text_len, max_opts=cfg.max_options
565
+ )
566
+ eval_mc_ds = p1.ScienceQADataset(
567
+ "test", max_samples=cfg.max_eval_samples, transform=transform,
568
+ tokenizer=tokenizer, max_len=cfg.max_text_len, max_opts=cfg.max_options
569
+ )
570
+
571
+ mc_coll = lambda batch: p1.collate_fn(
572
+ batch, transform, tokenizer, cfg.max_text_len, cfg.max_options
573
+ )
574
+ train_mc_dl = DataLoader(
575
+ train_mc_ds, batch_size=cfg.batch_size, shuffle=True,
576
+ num_workers=2, collate_fn=mc_coll, pin_memory=True, drop_last=True
577
+ )
578
+ eval_mc_dl = DataLoader(
579
+ eval_mc_ds, batch_size=cfg.batch_size, shuffle=False,
580
+ num_workers=2, collate_fn=mc_coll, pin_memory=True
581
+ )
582
+
583
+ # Open-ended datasets
584
+ max_open_train = args.max_train_samples if args.max_train_samples > 0 else 5000
585
+
586
+ # DocVQA — use validation as training (5349 samples, no explicit train split available)
587
+ train_docvqa_ds = OpenEndedDataset(
588
+ "docvqa", "validation", max_samples=max_open_train,
589
+ transform=transform, tokenizer=tokenizer,
590
+ max_len=cfg.max_text_len, max_gen_len=args.max_gen_len
591
+ )
592
+ # ChartQA — use test (2500 samples)
593
+ train_chartqa_ds = OpenEndedDataset(
594
+ "chartqa", "test", max_samples=max_open_train,
595
+ transform=transform, tokenizer=tokenizer,
596
+ max_len=cfg.max_text_len, max_gen_len=args.max_gen_len
597
+ )
598
+ # TextVQA — use train split (34602 samples, has OCR tokens)
599
+ train_textvqa_ds = OpenEndedDataset(
600
+ "textvqa", "train", max_samples=max_open_train,
601
+ transform=transform, tokenizer=tokenizer,
602
+ max_len=cfg.max_text_len, max_gen_len=args.max_gen_len
603
+ )
604
+
605
+ # Evaluation splits
606
+ eval_docvqa_ds = OpenEndedDataset(
607
+ "docvqa", "validation", max_samples=args.max_eval_samples,
608
+ transform=transform, tokenizer=tokenizer,
609
+ max_len=cfg.max_text_len, max_gen_len=args.max_gen_len
610
+ )
611
+ eval_chartqa_ds = OpenEndedDataset(
612
+ "chartqa", "test", max_samples=args.max_eval_samples,
613
+ transform=transform, tokenizer=tokenizer,
614
+ max_len=cfg.max_text_len, max_gen_len=args.max_gen_len
615
+ )
616
+ eval_textvqa_ds = OpenEndedDataset(
617
+ "textvqa", "validation", max_samples=args.max_eval_samples,
618
+ transform=transform, tokenizer=tokenizer,
619
+ max_len=cfg.max_text_len, max_gen_len=args.max_gen_len
620
+ )
621
+
622
+ open_coll = lambda batch: collate_open_ended(
623
+ batch, transform, tokenizer, cfg.max_text_len, args.max_gen_len
624
+ )
625
+
626
+ train_open_dls = {
627
+ "docvqa": DataLoader(
628
+ train_docvqa_ds, batch_size=cfg.batch_size, shuffle=True,
629
+ num_workers=2, collate_fn=open_coll, pin_memory=True, drop_last=True
630
+ ),
631
+ "chartqa": DataLoader(
632
+ train_chartqa_ds, batch_size=cfg.batch_size, shuffle=True,
633
+ num_workers=2, collate_fn=open_coll, pin_memory=True, drop_last=True
634
+ ),
635
+ "textvqa": DataLoader(
636
+ train_textvqa_ds, batch_size=cfg.batch_size, shuffle=True,
637
+ num_workers=2, collate_fn=open_coll, pin_memory=True, drop_last=True
638
+ ),
639
+ }
640
+
641
+ eval_open_dls = {
642
+ "docvqa": DataLoader(
643
+ eval_docvqa_ds, batch_size=cfg.batch_size, shuffle=False,
644
+ num_workers=2, collate_fn=open_coll, pin_memory=True
645
+ ),
646
+ "chartqa": DataLoader(
647
+ eval_chartqa_ds, batch_size=cfg.batch_size, shuffle=False,
648
+ num_workers=2, collate_fn=open_coll, pin_memory=True
649
+ ),
650
+ "textvqa": DataLoader(
651
+ eval_textvqa_ds, batch_size=cfg.batch_size, shuffle=False,
652
+ num_workers=2, collate_fn=open_coll, pin_memory=True
653
+ ),
654
+ }
655
+
656
+ # ── Optimizer with parameter groups ──
657
+ backbone_params = [p for p in model.vis.parameters() if p.requires_grad]
658
+ text_params = [p for p in model.txt.parameters() if p.requires_grad]
659
+ bb_txt_ids = {id(p) for p in backbone_params + text_params}
660
+ core_params = [p for p in model.parameters() if p.requires_grad and id(p) not in bb_txt_ids]
661
+
662
+ param_groups = [
663
+ {"params": core_params, "lr": args.core_lr},
664
+ {"params": backbone_params, "lr": args.backbone_lr},
665
+ {"params": text_params, "lr": args.text_lr},
666
+ ]
667
+ log.info(f"Optimizer: core={len(core_params)} @ {args.core_lr}, "
668
+ f"backbone={len(backbone_params)} @ {args.backbone_lr}, "
669
+ f"text={len(text_params)} @ {args.text_lr}")
670
+
671
+ optimizer = AdamW(param_groups, weight_decay=cfg.weight_decay)
672
+
673
+ # Estimate total steps across all dataloaders
674
+ mc_steps_per_epoch = len(train_mc_dl)
675
+ open_steps_per_epoch = sum(len(dl) for dl in train_open_dls.values())
676
+ total_batches_per_epoch = mc_steps_per_epoch + open_steps_per_epoch
677
+ total_steps = cfg.epochs * total_batches_per_epoch // cfg.grad_accum
678
+ warmup_steps = int(total_steps * 0.1) # Phase 3: 10% warmup
679
+
680
+ def lr_lambda(step):
681
+ if step < warmup_steps:
682
+ return step / max(warmup_steps, 1)
683
+ progress = (step - warmup_steps) / max(total_steps - warmup_steps, 1)
684
+ return 0.01 + 0.99 * 0.5 * (1 + math.cos(math.pi * progress))
685
+
686
+ scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)
687
+
688
+ # ── Pad token ID for generative loss masking ──
689
+ pad_token_id = tokenizer.pad_token_id
690
+ if pad_token_id is None:
691
+ pad_token_id = tokenizer.eos_token_id if tokenizer.eos_token_id is not None else 0
692
+ log.info(f"Pad token ID for gen loss: {pad_token_id}")
693
+
694
+ log.info(f"Phase 3: {cfg.epochs} epochs")
695
+ log.info(f" MC batches/epoch: {mc_steps_per_epoch}")
696
+ log.info(f" Open batches/epoch: {open_steps_per_epoch}")
697
+ log.info(f" Total opt steps: ~{total_steps}, warmup: {warmup_steps}")
698
+
699
+ global_step = 0
700
+ best_composite = 0.0
701
+ amp_dtype = torch.bfloat16 if cfg.bf16 else torch.float32
702
+ trainable = [p for p in model.parameters() if p.requires_grad]
703
+
704
+ try:
705
+ for epoch in range(cfg.epochs):
706
+ model.train()
707
+ epoch_losses = defaultdict(list)
708
+ epoch_mc_correct = 0
709
+ epoch_mc_total = 0
710
+ optimizer.zero_grad()
711
+ batch_count = 0
712
+
713
+ # ── Phase 3A: MC training (ScienceQA) — JEPA + task loss ──
714
+ log.info(f"Phase 3 Epoch {epoch}: MC training on ScienceQA...")
715
+ for batch_idx, batch in enumerate(train_mc_dl):
716
+ batch = {k: v.to(device) if isinstance(v, torch.Tensor) else v
717
+ for k, v in batch.items()}
718
+
719
+ with torch.autocast(device_type="cuda", dtype=amp_dtype,
720
+ enabled=cfg.bf16 and device.type == "cuda"):
721
+ losses, preds = model(**batch)
722
+ loss = losses["total"] / cfg.grad_accum
723
+
724
+ loss.backward()
725
+ batch_count += 1
726
+
727
+ if batch_count % cfg.grad_accum == 0:
728
+ nn.utils.clip_grad_norm_(trainable, cfg.max_grad_norm)
729
+ optimizer.step(); scheduler.step(); optimizer.zero_grad()
730
+ model.update_target(global_step, total_steps)
731
+ global_step += 1
732
+
733
+ for k, v in losses.items():
734
+ if isinstance(v, torch.Tensor):
735
+ epoch_losses[f"mc_{k}"].append(v.item())
736
+ epoch_mc_correct += (preds == batch["labels"]).sum().item()
737
+ epoch_mc_total += batch["batch_size"]
738
+
739
+ if batch_idx % 100 == 0:
740
+ avg = {k: np.mean(v[-100:]) for k, v in epoch_losses.items() if k.startswith("mc_")}
741
+ mc_acc = epoch_mc_correct / max(epoch_mc_total, 1) * 100
742
+ log.info(f"P3 E{epoch} MC B{batch_idx}/{mc_steps_per_epoch} | "
743
+ f"loss={avg.get('mc_total',0):.4f} | acc={mc_acc:.1f}%")
744
+ trackio.log({
745
+ "train/mc_loss": avg.get("mc_total", 0),
746
+ "train/mc_jepa": avg.get("mc_jepa", 0),
747
+ "train/mc_task": avg.get("mc_task", 0),
748
+ "train/mc_accuracy": mc_acc,
749
+ "train/lr": scheduler.get_last_lr()[0],
750
+ "train/epoch": epoch, "train/step": global_step,
751
+ })
752
+
753
+ # ── Phase 3B: Open-ended training (DocVQA, ChartQA, TextVQA) ──
754
+ log.info(f"Phase 3 Epoch {epoch}: Open-ended training...")
755
+ epoch_gen_losses = defaultdict(list)
756
+
757
+ # Interleave open-ended datasets
758
+ open_iters = {name: iter(dl) for name, dl in train_open_dls.items()}
759
+ open_active = set(open_iters.keys())
760
+ open_batch_idx = 0
761
+
762
+ while open_active:
763
+ for name in list(open_active):
764
+ try:
765
+ batch = next(open_iters[name])
766
+ except StopIteration:
767
+ open_active.discard(name)
768
+ continue
769
+
770
+ batch_t = {k: v.to(device) if isinstance(v, torch.Tensor) else v
771
+ for k, v in batch.items()}
772
+
773
+ with torch.autocast(device_type="cuda", dtype=amp_dtype,
774
+ enabled=cfg.bf16 and device.type == "cuda"):
775
+ # Forward through perception + reasoning
776
+ vis_tok = model.vis(batch_t["pixel_values"]).float()
777
+ txt_tok = model.txt(batch_t["input_ids"],
778
+ batch_t["attention_mask"]).float()
779
+ evidence, _, ev_mask = model.evidence(vis_tok, txt_tok,
780
+ batch_t["attention_mask"])
781
+
782
+ if model._use_rollout:
783
+ traj, z_final, z_proj = model.rollout(evidence)
784
+ else:
785
+ B = batch_t["batch_size"]
786
+ z0 = model.rollout.init_tokens.expand(B, -1, -1) + \
787
+ model.rollout.z0_proj(F.adaptive_avg_pool1d(
788
+ evidence.permute(0,2,1),
789
+ model.rollout.num_tokens
790
+ ).permute(0,2,1))
791
+ z_final = z0
792
+ z_proj = model.rollout.out_proj(z0).unsqueeze(1)
793
+
794
+ # JEPA loss (still active in Phase 3)
795
+ jepa_loss_val = torch.tensor(0.0, device=device)
796
+ if model._use_jepa:
797
+ target_proj = model.target(
798
+ vis_tok.detach(), txt_tok.detach(),
799
+ batch_t["attention_mask"].detach()
800
+ )
801
+ jepa_losses = model.jepa_loss(
802
+ z_proj, target_proj,
803
+ torch.tensor(0.0, device=device)
804
+ )
805
+ jepa_loss_val = jepa_losses["jepa"] + jepa_losses["reg"]
806
+
807
+ # Generative loss
808
+ gen_logits, gen_loss = model.gen_head(
809
+ z_final, evidence, batch_t["gen_target_ids"],
810
+ pad_token_id=pad_token_id
811
+ )
812
+
813
+ # Total loss for open-ended: JEPA + generative
814
+ total_loss = (cfg.jepa_weight * jepa_loss_val +
815
+ args.gen_weight * gen_loss)
816
+ loss = total_loss / cfg.grad_accum
817
+
818
+ loss.backward()
819
+ batch_count += 1
820
+
821
+ if batch_count % cfg.grad_accum == 0:
822
+ nn.utils.clip_grad_norm_(trainable, cfg.max_grad_norm)
823
+ optimizer.step(); scheduler.step(); optimizer.zero_grad()
824
+ model.update_target(global_step, total_steps)
825
+ global_step += 1
826
+
827
+ epoch_gen_losses[f"{name}_gen"].append(gen_loss.item())
828
+ epoch_gen_losses[f"{name}_total"].append(total_loss.item())
829
+ epoch_losses["gen_total"].append(total_loss.item())
830
+
831
+ open_batch_idx += 1
832
+ if open_batch_idx % 100 == 0:
833
+ avg_gen = {k: np.mean(v[-100:]) for k, v in epoch_gen_losses.items()}
834
+ log.info(f"P3 E{epoch} OPEN B{open_batch_idx} | " +
835
+ " | ".join(f"{k}={v:.4f}" for k, v in avg_gen.items()))
836
+ trackio.log({
837
+ f"train/{k}": v for k, v in avg_gen.items()
838
+ })
839
+
840
+ # ── Epoch-end evaluation ──
841
+ log.info(f"Phase 3 Epoch {epoch}: Evaluating...")
842
+
843
+ # MC eval (ScienceQA)
844
+ mc_eval_acc = p1.evaluate(model, eval_mc_dl, device, cfg)
845
+ log.info(f" ScienceQA eval accuracy: {mc_eval_acc:.1f}%")
846
+
847
+ # Open-ended eval
848
+ eval_results = evaluate_generative(
849
+ model, eval_open_dls, device, cfg, tokenizer,
850
+ pad_token_id, args.max_gen_len, amp_dtype
851
+ )
852
+
853
+ for bm, metrics in eval_results.items():
854
+ for mk, mv in metrics.items():
855
+ log.info(f" {bm} {mk}: {mv:.2f}")
856
+
857
+ # Composite score (average of all metrics)
858
+ all_scores = [mc_eval_acc]
859
+ for bm, metrics in eval_results.items():
860
+ all_scores.extend(metrics.values())
861
+ composite = np.mean(all_scores)
862
+
863
+ log.info(f"=== Phase 3 Epoch {epoch} | MC: {mc_eval_acc:.1f}% | "
864
+ f"Composite: {composite:.1f} ===")
865
+
866
+ trackio.log({
867
+ "eval/scienceqa_accuracy": mc_eval_acc,
868
+ "eval/composite_score": composite,
869
+ "eval/epoch": epoch,
870
+ **{f"eval/{bm}_{mk}": mv
871
+ for bm, metrics in eval_results.items()
872
+ for mk, mv in metrics.items()},
873
+ })
874
+
875
+ # Save best
876
+ if composite > best_composite:
877
+ best_composite = composite
878
+ save_phase3_checkpoint(
879
+ model, cfg, epoch, mc_eval_acc, eval_results,
880
+ composite, is_best=True
881
+ )
882
+ log.info(f"New best composite: {best_composite:.1f}")
883
+
884
+ log.info(f"Phase 3 complete. Best composite score: {best_composite:.1f}")
885
+
886
+ finally:
887
+ trackio.log({
888
+ "final/best_composite": best_composite,
889
+ "final/phase": 3,
890
+ "final/total_steps": global_step
891
+ })
892
+ log.info("Finishing Trackio...")
893
+ trackio.finish()
894
+
895
+ # ── Push results to Hub ──
896
+ if cfg.push_to_hub:
897
+ push_phase3_results(cfg, args, best_composite, eval_results)
898
+
899
+
900
+ @torch.no_grad()
901
+ def evaluate_generative(model, eval_dls, device, cfg, tokenizer,
902
+ pad_token_id, max_gen_len, amp_dtype):
903
+ """Evaluate on open-ended benchmarks via generation."""
904
+ model.eval()
905
+ results = {}
906
+
907
+ # Get start token ID
908
+ start_token_id = tokenizer.bos_token_id
909
+ if start_token_id is None:
910
+ start_token_id = tokenizer.cls_token_id or 1
911
+ eos_token_id = tokenizer.eos_token_id
912
+
913
+ for benchmark, dl in eval_dls.items():
914
+ predictions = []
915
+ ground_truths = []
916
+
917
+ for batch in dl:
918
+ batch_t = {k: v.to(device) if isinstance(v, torch.Tensor) else v
919
+ for k, v in batch.items()}
920
+
921
+ with torch.autocast(device_type="cuda", dtype=amp_dtype,
922
+ enabled=cfg.bf16 and device.type == "cuda"):
923
+ vis_tok = model.vis(batch_t["pixel_values"]).float()
924
+ txt_tok = model.txt(batch_t["input_ids"],
925
+ batch_t["attention_mask"]).float()
926
+ evidence, _, _ = model.evidence(vis_tok, txt_tok,
927
+ batch_t["attention_mask"])
928
+
929
+ if model._use_rollout:
930
+ _, z_final, _ = model.rollout(evidence)
931
+ else:
932
+ B = batch_t["batch_size"]
933
+ z_final = model.rollout.init_tokens.expand(B, -1, -1) + \
934
+ model.rollout.z0_proj(F.adaptive_avg_pool1d(
935
+ evidence.permute(0,2,1),
936
+ model.rollout.num_tokens
937
+ ).permute(0,2,1))
938
+
939
+ # Generate answers
940
+ gen_ids = model.gen_head.generate(
941
+ z_final, evidence, start_token_id,
942
+ max_length=max_gen_len, eos_token_id=eos_token_id
943
+ )
944
+
945
+ # Decode
946
+ for i in range(gen_ids.size(0)):
947
+ pred_text = tokenizer.decode(
948
+ gen_ids[i], skip_special_tokens=True
949
+ ).strip()
950
+ predictions.append(pred_text)
951
+
952
+ ground_truths.extend(batch["all_answers"])
953
+
954
+ # Compute metrics
955
+ if benchmark == "docvqa":
956
+ score = compute_anls(predictions, ground_truths)
957
+ results[benchmark] = {"anls": score}
958
+ elif benchmark == "chartqa":
959
+ # Ground truths are single strings wrapped in lists
960
+ gt_flat = [gt[0] if isinstance(gt, list) else gt for gt in ground_truths]
961
+ score = compute_relaxed_accuracy(predictions, gt_flat)
962
+ results[benchmark] = {"relaxed_accuracy": score}
963
+ elif benchmark == "textvqa":
964
+ score = compute_vqa_accuracy(predictions, ground_truths)
965
+ results[benchmark] = {"vqa_accuracy": score}
966
+
967
+ log.info(f" {benchmark}: {results[benchmark]}")
968
+
969
+ model.train()
970
+ return results
971
+
972
+
973
+ def save_phase3_checkpoint(model, cfg, epoch, mc_acc, open_results, composite, is_best=False):
974
+ """Save Phase 3 checkpoint."""
975
+ tag = "best" if is_best else f"epoch{epoch}"
976
+ path = os.path.join(cfg.output_dir, f"checkpoint_{tag}.pt")
977
+
978
+ state = {
979
+ "evidence": model.evidence.state_dict(),
980
+ "rollout": model.rollout.state_dict(),
981
+ "disc": model.disc.state_dict(),
982
+ "gen_head": model.gen_head.state_dict(),
983
+ "target_ev": model.target.t_ev.state_dict(),
984
+ "target_ro": model.target.t_ro.state_dict(),
985
+ "config": cfg.__dict__,
986
+ "epoch": epoch,
987
+ "mc_eval_acc": mc_acc,
988
+ "open_results": open_results,
989
+ "composite_score": composite,
990
+ "phase": 3,
991
+ }
992
+ torch.save(state, path)
993
+ log.info(f"Saved Phase 3 checkpoint: {path} (composite={composite:.1f})")
994
+
995
+
996
+ def push_phase3_results(cfg, args, best_composite, eval_results):
997
+ """Push Phase 3 results and checkpoint to Hub."""
998
+ try:
999
+ from huggingface_hub import HfApi
1000
+ api = HfApi()
1001
+
1002
+ results = {
1003
+ "run_name": cfg.run_name,
1004
+ "phase": 3,
1005
+ "backbone": cfg.backbone,
1006
+ "K": cfg.K,
1007
+ "use_jepa": cfg.use_jepa,
1008
+ "loss_fn": cfg.loss_fn,
1009
+ "best_composite_score": best_composite,
1010
+ "epochs": cfg.epochs,
1011
+ "core_lr": args.core_lr,
1012
+ "backbone_lr": args.backbone_lr,
1013
+ "text_lr": args.text_lr,
1014
+ "gen_weight": args.gen_weight,
1015
+ "batch_size": cfg.batch_size,
1016
+ "grad_accum": cfg.grad_accum,
1017
+ "open_results": {k: v for k, v in (eval_results or {}).items()},
1018
+ }
1019
+
1020
+ result_path = os.path.join(cfg.output_dir, f"results_{cfg.run_name}.json")
1021
+ with open(result_path, "w") as f:
1022
+ json.dump(results, f, indent=2)
1023
+
1024
+ api.upload_file(
1025
+ path_or_fileobj=result_path,
1026
+ path_in_repo=f"results/{cfg.run_name}.json",
1027
+ repo_id=cfg.hub_model_id,
1028
+ repo_type="model",
1029
+ )
1030
+
1031
+ best_ckpt = os.path.join(cfg.output_dir, "checkpoint_best.pt")
1032
+ if os.path.exists(best_ckpt):
1033
+ api.upload_file(
1034
+ path_or_fileobj=best_ckpt,
1035
+ path_in_repo=f"checkpoints/{cfg.run_name}_best.pt",
1036
+ repo_id=cfg.hub_model_id,
1037
+ repo_type="model",
1038
+ )
1039
+
1040
+ log.info(f"Pushed Phase 3 results to {cfg.hub_model_id}")
1041
+ except Exception as e:
1042
+ log.error(f"Push failed: {e}")
1043
+
1044
+
1045
+ if __name__ == "__main__":
1046
+ main()