JorgeAV commited on
Commit
5c15aea
·
verified ·
1 Parent(s): 2df74fc

Phase 2: Add SOTA visual diagnostics (13 types) with Trackio image logging

Browse files

Visual diagnostics added:
1. PCA Feature Maps (V-JEPA 2.1 style, arxiv:2603.14482)
2. Multi-head Attention Heatmaps (DINO style, arxiv:2104.14294)
3. RankMe Score anti-collapse monitoring (arxiv:2210.02885)
4. Per-dimension Variance bars (VICReg style, arxiv:2105.04906)
5. Latent Trajectory PCA (z₀→z₁→z₂→z₃)
6. Temporal Straightness metric (LeWorldModel Eq.9, arxiv:2603.19312)
7. Evidence Gate Activation Heatmaps
8. Token Norm Maps (DINOv2+Registers, arxiv:2309.16588)
9. Cross-Attention Weights (Perceiver Resampler)
10. Eigenspectrum Plot (singular value distribution)
11. Feature Similarity Heatmap overlay on input images
12. Rollout PCA per step
13. Rollout Comparison Grid (image vs PCA per step)

All logged via trackio.Image(fig_to_pil(fig)) - tested and verified.

Files changed (1) hide show
  1. train_phase2.py +706 -67
train_phase2.py CHANGED
@@ -1,14 +1,23 @@
1
  #!/usr/bin/env python3
2
  """
3
- MR-JEPA Phase 2 Training — Perception Fine-tuning
4
 
5
  Loads the best Phase 1 checkpoint and unfreezes:
6
  - Last 6 DINOv3-L layers (LR: 1e-5)
7
  - Last 4 Qwen3-Embedding layers (LR: 1e-5)
8
  - Reasoning core continues at 1e-4
9
 
10
- Everything else is identical to Phase 1 same JEPA objective, same data.
11
- The key difference is the 3-group optimizer with differential learning rates.
 
 
 
 
 
 
 
 
 
12
 
13
  Usage:
14
  python train_phase2.py --checkpoint checkpoints/hybrid_main_best.pt
@@ -34,30 +43,664 @@ import torch.nn.functional as F
34
  from torch.optim import AdamW
35
  from torch.utils.data import DataLoader
36
 
 
 
 
 
 
 
 
 
 
 
 
37
  logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s", datefmt="%H:%M:%S")
38
  log = logging.getLogger("mrjepa-p2")
39
 
40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  def download_phase1_checkpoint(hub_model_id: str, run_name: str = "hybrid_main"):
42
- """Download Phase 1 best checkpoint from Hub."""
43
  from huggingface_hub import hf_hub_download
44
- path = hf_hub_download(
45
- repo_id=hub_model_id,
46
- filename=f"checkpoints/{run_name}_best.pt",
47
- repo_type="model",
48
- )
49
  log.info(f"Downloaded checkpoint: {path}")
50
  return path
51
 
52
 
53
  def main():
54
  parser = argparse.ArgumentParser(description="MR-JEPA Phase 2 Training")
55
- parser.add_argument("--checkpoint", type=str, default=None,
56
- help="Local path to Phase 1 checkpoint. If not given, downloads from Hub.")
57
  parser.add_argument("--hub_model_id", default="JorgeAV/MR-JEPA")
58
  parser.add_argument("--run_name", default="hybrid_main_phase2")
59
- parser.add_argument("--phase1_run", default="hybrid_main",
60
- help="Name of the Phase 1 run (for checkpoint download)")
61
  parser.add_argument("--epochs", type=int, default=10)
62
  parser.add_argument("--batch_size", type=int, default=16)
63
  parser.add_argument("--grad_accum", type=int, default=8)
@@ -67,23 +710,18 @@ def main():
67
  parser.add_argument("--unfreeze_visual_layers", type=int, default=6)
68
  parser.add_argument("--unfreeze_text_layers", type=int, default=4)
69
  parser.add_argument("--max_eval_samples", type=int, default=500)
 
70
  parser.add_argument("--output_dir", default="./outputs/mrjepa_phase2")
71
  args = parser.parse_args()
72
 
73
- # ── Import Phase 1 module (download from Hub) ──
74
  log.info("Downloading Phase 1 training script...")
75
  from huggingface_hub import hf_hub_download
76
- p1_script = hf_hub_download(
77
- repo_id=args.hub_model_id,
78
- filename="train_mrjepa.py",
79
- repo_type="model",
80
- )
81
  import importlib.util
82
  spec = importlib.util.spec_from_file_location("train_mrjepa", p1_script)
83
  p1 = importlib.util.module_from_spec(spec)
84
  spec.loader.exec_module(p1)
85
 
86
- # ── Load checkpoint ──
87
  if args.checkpoint and os.path.exists(args.checkpoint):
88
  ckpt_path = args.checkpoint
89
  else:
@@ -92,14 +730,12 @@ def main():
92
  log.info(f"Loading Phase 1 checkpoint: {ckpt_path}")
93
  ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=False)
94
 
95
- # ── Reconstruct config ──
96
  saved_cfg = ckpt["config"]
97
  cfg = p1.Config()
98
  for k, v in saved_cfg.items():
99
  if hasattr(cfg, k):
100
  setattr(cfg, k, v)
101
 
102
- # Phase 2 overrides
103
  cfg.phase = 2
104
  cfg.epochs = args.epochs
105
  cfg.batch_size = args.batch_size
@@ -108,7 +744,7 @@ def main():
108
  cfg.backbone_lr = args.backbone_lr
109
  cfg.output_dir = args.output_dir
110
  cfg.run_name = args.run_name
111
- cfg.freeze_backbone = True # Will unfreeze selectively below
112
  cfg.freeze_text = True
113
  cfg.max_eval_samples = args.max_eval_samples
114
  cfg.resolve()
@@ -117,15 +753,22 @@ def main():
117
  log.info(f"Device: {device}")
118
  os.makedirs(cfg.output_dir, exist_ok=True)
119
 
120
- # ── Trackio ──
121
- try:
122
- import trackio
123
- trackio.init(name=args.run_name, project="MR-JEPA")
124
- log.info("Trackio initialized")
125
- except Exception as e:
126
- log.warning(f"Trackio init failed: {e}")
 
 
 
 
 
 
 
 
127
 
128
- # ── Build model & load weights ──
129
  log.info("Building model...")
130
  model = p1.MRJEPAModel(cfg)
131
  model.evidence.load_state_dict(ckpt["evidence"])
@@ -135,7 +778,6 @@ def main():
135
  model.target.t_ro.load_state_dict(ckpt["target_ro"])
136
  log.info(f"Loaded Phase 1 weights (epoch={ckpt.get('epoch','?')}, eval_acc={ckpt.get('eval_acc','?')}%)")
137
 
138
- # ── Unfreeze perception ──
139
  log.info(f"Unfreezing last {args.unfreeze_visual_layers} visual layers, "
140
  f"last {args.unfreeze_text_layers} text layers")
141
  model.vis.unfreeze_last(args.unfreeze_visual_layers)
@@ -145,8 +787,9 @@ def main():
145
  total_p = sum(p.numel() for p in model.parameters())
146
  train_p = sum(p.numel() for p in model.parameters() if p.requires_grad)
147
  log.info(f"Total: {total_p:,} | Trainable: {train_p:,} ({100*train_p/total_p:.1f}%)")
 
 
148
 
149
- # ── Data ──
150
  transform = model.vis.get_transform()
151
  tokenizer = model.txt.tokenizer
152
  train_ds = p1.ScienceQADataset("train", transform=transform, tokenizer=tokenizer,
@@ -160,10 +803,8 @@ def main():
160
  eval_dl = DataLoader(eval_ds, batch_size=cfg.batch_size, shuffle=False,
161
  num_workers=2, collate_fn=coll, pin_memory=True)
162
 
163
- # ── 3-group optimizer ──
164
  backbone_params = [p for p in model.vis.parameters() if p.requires_grad]
165
  text_params = [p for p in model.txt.parameters() if p.requires_grad]
166
- # Core = everything trainable that's not backbone or text
167
  bb_txt_ids = {id(p) for p in backbone_params + text_params}
168
  core_params = [p for p in model.parameters() if p.requires_grad and id(p) not in bb_txt_ids]
169
 
@@ -188,8 +829,11 @@ def main():
188
 
189
  scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)
190
 
191
- # ── Training loop ──
 
 
192
  log.info(f"Phase 2: {cfg.epochs} epochs, {len(train_dl)} batches/epoch, ga={cfg.grad_accum}")
 
193
  global_step = 0
194
  best_acc = ckpt.get("eval_acc", 0.0)
195
  amp_dtype = torch.bfloat16 if cfg.bf16 else torch.float32
@@ -204,36 +848,26 @@ def main():
204
 
205
  for batch_idx, batch in enumerate(train_dl):
206
  batch = {k: v.to(device) if isinstance(v, torch.Tensor) else v for k, v in batch.items()}
207
-
208
- # Phase 2: backbone is unfrozen, so no torch.no_grad() wrapper
209
- # The model.forward() still wraps vis/txt in no_grad — we need to override
210
- # We do this by temporarily disabling the no_grad in forward
211
- # Actually, looking at model.forward(), vis and txt are inside torch.no_grad()
212
- # We need to patch this for Phase 2. Let's override forward behavior:
213
  vis_tok = model.vis(batch["pixel_values"]).float()
214
  txt_tok = model.txt(batch["input_ids"], batch["attention_mask"]).float()
215
 
216
  with torch.autocast(device_type="cuda", dtype=amp_dtype, enabled=cfg.bf16 and device.type == "cuda"):
217
  evidence, _, ev_mask = model.evidence(vis_tok, txt_tok, batch["attention_mask"])
218
-
219
  if model._use_rollout:
220
  traj, z_final, z_proj = model.rollout(evidence)
221
  else:
222
  B = batch["batch_size"]
223
  z0 = model.rollout.init_tokens.expand(B, -1, -1) + \
224
  model.rollout.z0_proj(F.adaptive_avg_pool1d(
225
- evidence.permute(0,2,1), model.rollout.num_tokens
226
- ).permute(0,2,1))
227
  z_final = z0
228
  z_proj = model.rollout.out_proj(z0).unsqueeze(1)
229
 
230
- # Target (still no_grad)
231
  if model._use_jepa:
232
  target_proj = model.target(vis_tok.detach(), txt_tok.detach(), batch["attention_mask"].detach())
233
  else:
234
  target_proj = None
235
 
236
- # Options
237
  opt_emb = model.encode_options(batch["opt_input_ids"], batch["opt_attention_mask"])
238
  opt_emb = opt_emb.view(batch["batch_size"], cfg.max_options, -1)
239
  logits = model.disc(z_final, opt_emb, batch["opt_mask"])
@@ -243,18 +877,19 @@ def main():
243
  losses = model.jepa_loss(z_proj, target_proj, task_loss)
244
  else:
245
  losses = {"total": task_loss, "jepa": torch.tensor(0.0), "task": task_loss, "reg": torch.tensor(0.0)}
246
-
247
  loss = losses["total"] / cfg.grad_accum
248
 
249
  loss.backward()
250
 
251
  if (batch_idx + 1) % cfg.grad_accum == 0:
252
  nn.utils.clip_grad_norm_(trainable, cfg.max_grad_norm)
253
- optimizer.step()
254
- scheduler.step()
255
- optimizer.zero_grad()
256
  model.update_target(global_step, total_steps)
257
  global_step += 1
 
 
 
 
258
 
259
  preds = logits.argmax(dim=-1)
260
  for k, v in losses.items():
@@ -270,32 +905,36 @@ def main():
270
  log.info(f"P2 E{epoch} B{batch_idx}/{len(train_dl)} | "
271
  f"loss={avg.get('total',0):.4f} jepa={avg.get('jepa',0):.4f} "
272
  f"task={avg.get('task',0):.4f} | acc={acc:.1f}%")
273
- try:
274
- import trackio
275
- trackio.log({
276
- "train/loss": avg.get("total", 0),
277
- "train/jepa_loss": avg.get("jepa", 0),
278
- "train/task_loss": avg.get("task", 0),
279
- "train/accuracy": acc,
280
- "train/step": global_step,
281
- })
282
- except: pass
283
-
284
- # Eval
285
  eval_acc = p1.evaluate(model, eval_dl, device, cfg)
286
  train_acc = epoch_correct / max(epoch_total, 1) * 100
287
  log.info(f"=== Phase 2 Epoch {epoch} | Train: {train_acc:.1f}% | Eval: {eval_acc:.1f}% ===")
 
 
288
 
289
- try:
290
- import trackio
291
- trackio.log({"eval/accuracy": eval_acc, "eval/epoch": epoch})
292
- except: pass
 
293
 
294
  if eval_acc > best_acc:
295
  best_acc = eval_acc
296
  p1.save_checkpoint(model, cfg, epoch, eval_acc, is_best=True)
 
297
 
298
  log.info(f"Phase 2 complete. Best eval accuracy: {best_acc:.1f}%")
 
 
299
  if cfg.push_to_hub:
300
  p1.push_results(cfg, best_acc)
301
 
 
1
  #!/usr/bin/env python3
2
  """
3
+ MR-JEPA Phase 2 Training — Perception Fine-tuning + SOTA Visual Diagnostics
4
 
5
  Loads the best Phase 1 checkpoint and unfreezes:
6
  - Last 6 DINOv3-L layers (LR: 1e-5)
7
  - Last 4 Qwen3-Embedding layers (LR: 1e-5)
8
  - Reasoning core continues at 1e-4
9
 
10
+ Visual diagnostics logged to Trackio (state-of-the-art for JEPA):
11
+ 1. PCA Feature Maps (V-JEPA 2.1 style) patch features → RGB via PCA
12
+ 2. Multi-head Attention Heatmaps (DINO style) — CLS attention per head overlaid on image
13
+ 3. RankMe Score (anti-collapse) — effective rank of embedding matrix
14
+ 4. Per-dimension Variance (VICReg style) — collapse detection per dim
15
+ 5. Latent Trajectory PCA — z₀→z₁→z₂→z₃ projected to 2D
16
+ 6. Temporal Straightness (LeWM Eq. 9) — trajectory coherence metric
17
+ 7. Evidence Gate Activation Heatmaps — gate values per rollout step
18
+ 8. Token Norm Maps (DINOv2-Reg style) — artifact detection
19
+ 9. Cross-Attention Weights in Perceiver — which evidence each query attends to
20
+ 10. Eigenspectrum Plot — singular value distribution of latent space
21
 
22
  Usage:
23
  python train_phase2.py --checkpoint checkpoints/hybrid_main_best.pt
 
43
  from torch.optim import AdamW
44
  from torch.utils.data import DataLoader
45
 
46
+ # ── Matplotlib non-interactive backend (MUST be before pyplot import) ──
47
+ import matplotlib
48
+ matplotlib.use("Agg")
49
+ import matplotlib.pyplot as plt
50
+ import matplotlib.colors as mcolors
51
+ import seaborn as sns
52
+ from sklearn.decomposition import PCA as SklearnPCA
53
+
54
+ from PIL import Image as PILImage
55
+ import io
56
+
57
  logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s", datefmt="%H:%M:%S")
58
  log = logging.getLogger("mrjepa-p2")
59
 
60
 
61
+ def fig_to_pil(fig: plt.Figure) -> PILImage.Image:
62
+ """Convert matplotlib figure to PIL Image for Trackio logging.
63
+ Trackio accepts PIL.Image but NOT matplotlib.figure.Figure directly."""
64
+ buf = io.BytesIO()
65
+ fig.savefig(buf, format='png', dpi=120, bbox_inches='tight', pad_inches=0.1)
66
+ buf.seek(0)
67
+ img = PILImage.open(buf).copy()
68
+ buf.close()
69
+ return img
70
+
71
+
72
+ # ══════════════════════════════════════════════════════════════════════════
73
+ # SOTA VISUALIZATION FUNCTIONS (Papers: I-JEPA, V-JEPA 2.1, DINO,
74
+ # LeWorldModel, RankMe, VICReg, DINOv2+Registers)
75
+ # ══════════════════════════════════════════════════════════════════════════
76
+
77
+ def rankme(Z: torch.Tensor, epsilon: float = 1e-7) -> float:
78
+ """
79
+ RankMe: effective rank of embedding matrix via Shannon entropy of singular values.
80
+ Source: "RankMe: Assessing the Downstream Performance of Pretrained Self-Supervised
81
+ Representations by Their Rank" (arxiv:2210.02885)
82
+
83
+ Z: (N, D) — batch of embeddings
84
+ Returns scalar effective rank (higher = less collapsed)
85
+ """
86
+ if Z.dim() == 3:
87
+ Z = Z.reshape(-1, Z.size(-1))
88
+ Z_centered = Z - Z.mean(0, keepdim=True)
89
+ try:
90
+ _, S, _ = torch.linalg.svd(Z_centered.float(), full_matrices=False)
91
+ p = S / (S.sum() + epsilon) + epsilon
92
+ return torch.exp(-torch.sum(p * torch.log(p))).item()
93
+ except:
94
+ return 0.0
95
+
96
+
97
+ def vicreg_collapse_stats(Z: torch.Tensor) -> dict:
98
+ """
99
+ VICReg-style per-dimension variance monitoring.
100
+ Source: "VICReg: Variance-Invariance-Covariance Regularization" (arxiv:2105.04906)
101
+
102
+ Z: (N, D) — monitor each dimension's std
103
+ Collapsed dims have std < 0.1
104
+ """
105
+ if Z.dim() == 3:
106
+ Z = Z.reshape(-1, Z.size(-1))
107
+ std_per_dim = Z.float().std(0) # (D,)
108
+ return {
109
+ "min_std": std_per_dim.min().item(),
110
+ "mean_std": std_per_dim.mean().item(),
111
+ "max_std": std_per_dim.max().item(),
112
+ "collapsed_dims": (std_per_dim < 0.1).sum().item(),
113
+ "total_dims": std_per_dim.size(0),
114
+ "std_values": std_per_dim.detach().cpu().numpy(),
115
+ }
116
+
117
+
118
+ def temporal_straightness(z_seq: torch.Tensor) -> float:
119
+ """
120
+ Temporal straightness metric from LeWorldModel (Eq. 9, Appendix H, Fig. 17).
121
+ Source: "Le World Model" (arxiv:2603.19312)
122
+
123
+ Measures geometric coherence of latent trajectory.
124
+ z_seq: (B, K+1, D) or (B, K+1, N, D) — latent trajectory
125
+ Returns mean cosine similarity between consecutive velocity vectors.
126
+ Higher = more coherent (straighter) trajectory.
127
+ """
128
+ if z_seq.dim() == 4:
129
+ z_seq = z_seq.mean(dim=2) # Pool tokens → (B, K+1, D)
130
+ v = z_seq[:, 1:] - z_seq[:, :-1] # (B, K, D)
131
+ if v.size(1) < 2:
132
+ return 0.0
133
+ v_norm = F.normalize(v.float(), dim=-1)
134
+ cos_sim = (v_norm[:, :-1] * v_norm[:, 1:]).sum(-1) # (B, K-1)
135
+ return cos_sim.mean().item()
136
+
137
+
138
+ def pca_feature_map(patch_features: torch.Tensor, grid_h: int, grid_w: int) -> np.ndarray:
139
+ """
140
+ PCA RGB feature map visualization (V-JEPA 2.1 style).
141
+ Source: "Revisiting Feature Prediction for Learning Visual Representations
142
+ from Video" (arxiv:2603.14482), Section 2.2, Figs. 1, 3.
143
+
144
+ Maps first 3 principal components of patch features → RGB channels.
145
+ Semantic objects share PCA colors regardless of position.
146
+
147
+ patch_features: (N_patches, D)
148
+ Returns: (grid_h, grid_w, 3) uint8 array suitable for display
149
+ """
150
+ feats = patch_features.float().detach().cpu().numpy()
151
+ n_components = min(3, feats.shape[0], feats.shape[1])
152
+ if n_components < 3:
153
+ return np.zeros((grid_h, grid_w, 3), dtype=np.uint8)
154
+ pca = SklearnPCA(n_components=3)
155
+ pca_feats = pca.fit_transform(feats) # (N, 3)
156
+ # Normalize each component to [0, 1]
157
+ for c in range(3):
158
+ vmin, vmax = pca_feats[:, c].min(), pca_feats[:, c].max()
159
+ if vmax - vmin > 1e-8:
160
+ pca_feats[:, c] = (pca_feats[:, c] - vmin) / (vmax - vmin)
161
+ else:
162
+ pca_feats[:, c] = 0.5
163
+ n_patches = pca_feats.shape[0]
164
+ if n_patches != grid_h * grid_w:
165
+ actual_side = int(math.sqrt(n_patches))
166
+ grid_h = grid_w = actual_side
167
+ pca_img = (pca_feats[:grid_h * grid_w].reshape(grid_h, grid_w, 3) * 255).astype(np.uint8)
168
+ return pca_img
169
+
170
+
171
+ def eigenspectrum_plot(Z: torch.Tensor, title: str = "Eigenspectrum") -> plt.Figure:
172
+ """
173
+ Plot singular value spectrum of embedding matrix.
174
+ Source: "RankMe" (arxiv:2210.02885), used in DINO, I-JEPA for collapse monitoring.
175
+
176
+ Healthy representations show flat spectrum; collapsed ones show sharp decay.
177
+ """
178
+ if Z.dim() == 3:
179
+ Z = Z.reshape(-1, Z.size(-1))
180
+ Z_c = Z.float() - Z.float().mean(0, keepdim=True)
181
+ try:
182
+ _, S, _ = torch.linalg.svd(Z_c, full_matrices=False)
183
+ s = S.detach().cpu().numpy()
184
+ except:
185
+ s = np.ones(10)
186
+ fig, ax = plt.subplots(figsize=(6, 4))
187
+ ax.semilogy(s[:min(100, len(s))], 'b-', linewidth=1.5)
188
+ ax.fill_between(range(min(100, len(s))), s[:min(100, len(s))], alpha=0.15, color='blue')
189
+ ax.set_xlabel("Singular Value Index", fontsize=10)
190
+ ax.set_ylabel("Singular Value (log)", fontsize=10)
191
+ ax.set_title(title, fontsize=12, fontweight='bold')
192
+ ax.grid(True, alpha=0.3)
193
+ fig.tight_layout()
194
+ return fig
195
+
196
+
197
+ def per_dim_variance_plot(std_values: np.ndarray, title: str = "Per-Dimension Std") -> plt.Figure:
198
+ """
199
+ VICReg-style per-dimension standard deviation bar plot.
200
+ Source: "VICReg" (arxiv:2105.04906), Section 4.
201
+
202
+ Each bar = std of one embedding dimension across the batch.
203
+ Target: all dims near γ=1.0. Dims with std→0 are collapsed.
204
+ """
205
+ fig, ax = plt.subplots(figsize=(8, 3))
206
+ n = min(len(std_values), 200)
207
+ colors = ['red' if v < 0.1 else ('orange' if v < 0.3 else 'steelblue') for v in std_values[:n]]
208
+ ax.bar(range(n), std_values[:n], color=colors, width=1.0, edgecolor='none')
209
+ ax.axhline(y=1.0, color='green', linestyle='--', alpha=0.7, label='Target γ=1.0')
210
+ ax.axhline(y=0.1, color='red', linestyle='--', alpha=0.5, label='Collapse threshold')
211
+ ax.set_xlabel("Dimension Index", fontsize=9)
212
+ ax.set_ylabel("Std", fontsize=9)
213
+ ax.set_title(title, fontsize=11, fontweight='bold')
214
+ ax.legend(fontsize=8)
215
+ ax.set_xlim(-0.5, n - 0.5)
216
+ fig.tight_layout()
217
+ return fig
218
+
219
+
220
+ def trajectory_pca_plot(trajectory: torch.Tensor, title: str = "Latent Trajectory") -> plt.Figure:
221
+ """
222
+ Latent trajectory visualization via PCA projection.
223
+ Source: I-JEPA + LeWorldModel trajectory analysis.
224
+
225
+ trajectory: (K+1, N_tokens, D) — single sample trajectory
226
+ Projects step centroids to 2D, draws arrows showing reasoning evolution.
227
+ """
228
+ K_plus_1, N_s, D = trajectory.shape
229
+ centroids = trajectory.mean(dim=1).float().detach().cpu().numpy()
230
+
231
+ if K_plus_1 < 2:
232
+ fig, ax = plt.subplots(figsize=(5, 5))
233
+ ax.set_title(title)
234
+ return fig
235
+
236
+ centered = centroids - centroids.mean(axis=0)
237
+ try:
238
+ pca = SklearnPCA(n_components=2)
239
+ coords = pca.fit_transform(centered)
240
+ except:
241
+ coords = centered[:, :2]
242
+
243
+ fig, ax = plt.subplots(figsize=(6, 6))
244
+ colors = plt.cm.viridis(np.linspace(0, 1, K_plus_1))
245
+
246
+ for k in range(K_plus_1 - 1):
247
+ ax.annotate("", xy=coords[k+1], xytext=coords[k],
248
+ arrowprops=dict(arrowstyle="->", color=colors[k], lw=2.5))
249
+
250
+ for k in range(K_plus_1):
251
+ ax.scatter(coords[k, 0], coords[k, 1], c=[colors[k]], s=150,
252
+ zorder=5, edgecolors='black', linewidth=1.5)
253
+ label = 'z₀' if k == 0 else f'z_{k}'
254
+ ax.annotate(label, (coords[k, 0], coords[k, 1]),
255
+ textcoords="offset points", xytext=(10, 10),
256
+ fontsize=12, fontweight='bold', color=colors[k])
257
+
258
+ ax.set_xlabel("PC1", fontsize=10)
259
+ ax.set_ylabel("PC2", fontsize=10)
260
+ ax.set_title(title, fontsize=12, fontweight='bold')
261
+ ax.grid(True, alpha=0.3)
262
+ fig.tight_layout()
263
+ return fig
264
+
265
+
266
+ def attention_heatmap_overlay(
267
+ attn_weights: torch.Tensor,
268
+ original_image: np.ndarray,
269
+ grid_h: int = 16, grid_w: int = 16,
270
+ title: str = "Attention Heatmap",
271
+ num_heads_to_show: int = 4,
272
+ ) -> plt.Figure:
273
+ """
274
+ DINO-style multi-head self-attention heatmap overlay.
275
+ Source: "Emerging Properties in Self-Supervised Vision Transformers"
276
+ (arxiv:2104.14294), Section 4.2.2, Fig. 3.
277
+ """
278
+ if attn_weights.dim() == 2:
279
+ attn_weights = attn_weights.unsqueeze(0)
280
+
281
+ n_heads = min(attn_weights.size(0), num_heads_to_show)
282
+ fig, axes = plt.subplots(1, n_heads + 1, figsize=(4 * (n_heads + 1), 4))
283
+ if n_heads + 1 == 1:
284
+ axes = [axes]
285
+
286
+ axes[0].imshow(original_image)
287
+ axes[0].set_title("Input Image", fontsize=10)
288
+ axes[0].axis('off')
289
+
290
+ head_colors = ['Reds', 'Blues', 'Greens', 'Purples', 'Oranges', 'YlOrRd']
291
+
292
+ for h in range(n_heads):
293
+ attn = attn_weights[h].float().detach().cpu().numpy()
294
+ if attn.ndim == 2:
295
+ attn_map = attn[0]
296
+ else:
297
+ attn_map = attn
298
+ n_tokens = attn_map.shape[0]
299
+ side = int(math.sqrt(n_tokens))
300
+ if side * side != n_tokens:
301
+ side = grid_h
302
+ attn_2d = attn_map[:side*side].reshape(side, side)
303
+ attn_2d = (attn_2d - attn_2d.min()) / (attn_2d.max() - attn_2d.min() + 1e-8)
304
+ attn_resized = np.array(PILImage.fromarray((attn_2d * 255).astype(np.uint8)).resize(
305
+ (original_image.shape[1], original_image.shape[0]), PILImage.BILINEAR)) / 255.0
306
+ axes[h + 1].imshow(original_image)
307
+ axes[h + 1].imshow(attn_resized, cmap=head_colors[h % len(head_colors)], alpha=0.6)
308
+ axes[h + 1].set_title(f"Head {h}", fontsize=10)
309
+ axes[h + 1].axis('off')
310
+
311
+ fig.suptitle(title, fontsize=12, fontweight='bold')
312
+ fig.tight_layout()
313
+ return fig
314
+
315
+
316
+ def evidence_gate_heatmap(gate_values: list, title: str = "Evidence Gate Activations") -> plt.Figure:
317
+ """Evidence gate activation visualization per rollout step."""
318
+ K = len(gate_values)
319
+ if K == 0:
320
+ fig, ax = plt.subplots()
321
+ ax.text(0.5, 0.5, "No gates recorded", ha='center', va='center')
322
+ return fig
323
+
324
+ fig, axes = plt.subplots(1, K, figsize=(4 * K, 4))
325
+ if K == 1:
326
+ axes = [axes]
327
+
328
+ for k, gv in enumerate(gate_values):
329
+ if isinstance(gv, torch.Tensor):
330
+ gv = gv.float().detach().cpu().numpy()
331
+ if gv.ndim == 3:
332
+ gv = gv.mean(0)
333
+ mean_gate = gv.mean(axis=-1) if gv.ndim == 2 else gv
334
+ im = axes[k].imshow(mean_gate.reshape(1, -1) if mean_gate.ndim == 1 else mean_gate,
335
+ cmap='YlOrRd', aspect='auto', vmin=0, vmax=1)
336
+ axes[k].set_title(f'Step {k+1}: μ={mean_gate.mean():.3f}', fontsize=10)
337
+ axes[k].set_xlabel("Token Index")
338
+ fig.colorbar(im, ax=axes[k], fraction=0.046, pad=0.04)
339
+
340
+ fig.suptitle(title, fontsize=12, fontweight='bold')
341
+ fig.tight_layout()
342
+ return fig
343
+
344
+
345
+ def token_norm_map(tokens: torch.Tensor, grid_h: int = 16, grid_w: int = 16,
346
+ title: str = "Token Norm Map") -> plt.Figure:
347
+ """
348
+ Token norm map for artifact detection (DINOv2+Registers style).
349
+ Source: "Vision Transformers Need Registers" (arxiv:2309.16588).
350
+ """
351
+ if tokens.dim() == 3:
352
+ tokens = tokens[0]
353
+ norms = tokens.float().norm(dim=-1).detach().cpu().numpy()
354
+ n_tokens = len(norms)
355
+ side = int(math.sqrt(n_tokens))
356
+ if side * side != n_tokens:
357
+ side = grid_h
358
+ norm_map = norms[:side*side].reshape(side, side)
359
+
360
+ fig, axes = plt.subplots(1, 2, figsize=(10, 4))
361
+ im = axes[0].imshow(norm_map, cmap='hot', aspect='auto')
362
+ axes[0].set_title(f"{title}\nμ={norms.mean():.2f}, σ={norms.std():.2f}", fontsize=10)
363
+ fig.colorbar(im, ax=axes[0])
364
+ axes[1].hist(norms, bins=50, color='steelblue', edgecolor='white', alpha=0.8)
365
+ axes[1].axvline(norms.mean(), color='red', linestyle='--', label=f'Mean: {norms.mean():.2f}')
366
+ axes[1].axvline(norms.mean() + 2*norms.std(), color='orange', linestyle='--',
367
+ label=f'+2σ: {norms.mean() + 2*norms.std():.2f}')
368
+ axes[1].set_title("Norm Distribution", fontsize=10)
369
+ axes[1].set_xlabel("||token||₂")
370
+ axes[1].legend(fontsize=8)
371
+ fig.suptitle(title, fontsize=12, fontweight='bold')
372
+ fig.tight_layout()
373
+ return fig
374
+
375
+
376
+ def cross_attention_weights_plot(attn_weights: torch.Tensor,
377
+ title: str = "Perceiver Cross-Attention") -> plt.Figure:
378
+ """Cross-attention weights in the Perceiver Resampler."""
379
+ if attn_weights.dim() == 3:
380
+ attn = attn_weights.float().mean(0).detach().cpu().numpy()
381
+ else:
382
+ attn = attn_weights.float().detach().cpu().numpy()
383
+ fig, ax = plt.subplots(figsize=(8, 6))
384
+ sns.heatmap(attn, cmap='viridis', ax=ax, xticklabels=False, yticklabels=False)
385
+ ax.set_xlabel("Key Tokens (Evidence: Visual | Text)", fontsize=10)
386
+ ax.set_ylabel("Query Tokens (Latent)", fontsize=10)
387
+ ax.set_title(title, fontsize=12, fontweight='bold')
388
+ fig.tight_layout()
389
+ return fig
390
+
391
+
392
+ def rollout_comparison_grid(images: list, pca_maps: list, titles: list = None,
393
+ suptitle: str = "Latent Rollout PCA Maps") -> plt.Figure:
394
+ """
395
+ Grid comparing original images with PCA feature maps at each rollout step.
396
+ Source: LeWorldModel Fig. 7 + V-JEPA 2.1 Fig. 3.
397
+ """
398
+ n_samples = min(len(images), 4)
399
+ n_steps = len(pca_maps[0]) if pca_maps else 0
400
+ cols = 1 + n_steps
401
+ fig, axes = plt.subplots(n_samples, cols, figsize=(3 * cols, 3 * n_samples))
402
+ if n_samples == 1:
403
+ axes = axes.reshape(1, -1)
404
+ for i in range(n_samples):
405
+ axes[i, 0].imshow(images[i])
406
+ axes[i, 0].set_title("Input" if i == 0 else "", fontsize=9)
407
+ axes[i, 0].axis('off')
408
+ for k in range(n_steps):
409
+ if k < len(pca_maps[i]):
410
+ axes[i, k+1].imshow(pca_maps[i][k])
411
+ axes[i, k+1].set_title(f"z_{k}" if i == 0 else "", fontsize=9)
412
+ axes[i, k+1].axis('off')
413
+ fig.suptitle(suptitle, fontsize=13, fontweight='bold')
414
+ fig.tight_layout()
415
+ return fig
416
+
417
+
418
+ # ══════════════════════════════════════════════════════════════════════════
419
+ # HOOK-BASED DIAGNOSTIC COLLECTOR
420
+ # ══════════════════════════════════════════════════════════════════════════
421
+
422
+ class DiagnosticCollector:
423
+ """Collects intermediate activations via hooks for visualization."""
424
+ def __init__(self):
425
+ self.gate_values = []
426
+ self.cross_attn_weights = []
427
+ self.hooks = []
428
+
429
+ def attach(self, model):
430
+ self.clear()
431
+ if hasattr(model, 'rollout') and hasattr(model.rollout, 'predictor'):
432
+ for i, block in enumerate(model.rollout.predictor):
433
+ if hasattr(block, 'gate') and block.gate is not None:
434
+ def make_gate_hook(layer_idx):
435
+ def hook(module, input, output):
436
+ self.gate_values.append(output.detach().cpu())
437
+ return hook
438
+ h = block.gate.proj.register_forward_hook(make_gate_hook(i))
439
+ self.hooks.append(h)
440
+ if hasattr(model, 'evidence') and hasattr(model.evidence, 'layers'):
441
+ last_layer = model.evidence.layers[-1]
442
+ if hasattr(last_layer, 'xa'):
443
+ def xa_hook(module, input, output):
444
+ if isinstance(output, tuple) and len(output) > 1:
445
+ self.cross_attn_weights.append(output[1].detach().cpu())
446
+ h = last_layer.xa.register_forward_hook(xa_hook)
447
+ self.hooks.append(h)
448
+
449
+ def clear(self):
450
+ self.gate_values = []
451
+ self.cross_attn_weights = []
452
+
453
+ def detach(self):
454
+ for h in self.hooks:
455
+ h.remove()
456
+ self.hooks = []
457
+
458
+
459
+ # ══════════════════════════════════════════════════════════════════════════
460
+ # MAIN VISUALIZATION LOGGING FUNCTION
461
+ # ══════════════════════════════════════════════════════════════════════════
462
+
463
+ def log_visual_diagnostics(model, batch, device, cfg, global_step, epoch,
464
+ diagnostics_collector=None, vis_interval=100):
465
+ """
466
+ Generate and log all visual diagnostics to Trackio.
467
+
468
+ Implements SOTA visualizations from:
469
+ - I-JEPA (attention maps), V-JEPA 2.1 (PCA feature maps)
470
+ - LeWorldModel (trajectory, temporal straightness)
471
+ - RankMe (effective rank), VICReg (per-dim variance)
472
+ - DINOv2+Registers (token norm maps)
473
+ """
474
+ import trackio
475
+
476
+ model.eval()
477
+ log_dict = {}
478
+
479
+ try:
480
+ with torch.no_grad():
481
+ vis_tok = model.vis(batch["pixel_values"].to(device)).float()
482
+ txt_tok = model.txt(batch["input_ids"].to(device),
483
+ batch["attention_mask"].to(device)).float()
484
+ evidence, kv, ev_mask = model.evidence(vis_tok, txt_tok,
485
+ batch["attention_mask"].to(device))
486
+
487
+ if model._use_rollout:
488
+ traj, z_final, z_proj = model.rollout(evidence)
489
+ else:
490
+ traj = evidence.unsqueeze(1)
491
+ z_final = evidence
492
+ z_proj = evidence.unsqueeze(1)
493
+
494
+ # ── RankMe Score (anti-collapse) ──
495
+ evidence_rank = rankme(evidence)
496
+ z_final_rank = rankme(z_final)
497
+ vis_rank = rankme(vis_tok)
498
+ log_dict["diagnostics/rankme_evidence"] = evidence_rank
499
+ log_dict["diagnostics/rankme_z_final"] = z_final_rank
500
+ log_dict["diagnostics/rankme_visual"] = vis_rank
501
+
502
+ # ── VICReg Collapse Stats ──
503
+ ev_stats = vicreg_collapse_stats(evidence)
504
+ zf_stats = vicreg_collapse_stats(z_final)
505
+ log_dict["diagnostics/evidence_min_std"] = ev_stats["min_std"]
506
+ log_dict["diagnostics/evidence_mean_std"] = ev_stats["mean_std"]
507
+ log_dict["diagnostics/evidence_collapsed_dims"] = ev_stats["collapsed_dims"]
508
+ log_dict["diagnostics/z_final_min_std"] = zf_stats["min_std"]
509
+ log_dict["diagnostics/z_final_mean_std"] = zf_stats["mean_std"]
510
+ log_dict["diagnostics/z_final_collapsed_dims"] = zf_stats["collapsed_dims"]
511
+
512
+ # ── Temporal Straightness (LeWM Eq. 9) ──
513
+ if traj.dim() == 4:
514
+ straightness = temporal_straightness(traj)
515
+ log_dict["diagnostics/temporal_straightness"] = straightness
516
+ centroids = traj.mean(dim=2)
517
+ for k in range(centroids.size(1) - 1):
518
+ dist = torch.norm(centroids[:, k+1] - centroids[:, k], dim=-1).mean().item()
519
+ log_dict[f"diagnostics/step_distance_z{k}_to_z{k+1}"] = dist
520
+
521
+ # ── PCA Feature Maps (V-JEPA 2.1) ──
522
+ n_vis_patches = vis_tok.size(1)
523
+ grid_side = int(math.sqrt(n_vis_patches))
524
+
525
+ pca_ctx = pca_feature_map(vis_tok[0], grid_side, grid_side)
526
+ fig_pca_ctx = plt.figure(figsize=(4, 4))
527
+ plt.imshow(pca_ctx); plt.title("Context Encoder PCA", fontsize=11, fontweight='bold')
528
+ plt.axis('off'); plt.tight_layout()
529
+ log_dict["visuals/pca/context_encoder"] = trackio.Image(fig_to_pil(fig_pca_ctx),
530
+ caption=f"step={global_step}")
531
+ plt.close(fig_pca_ctx)
532
+
533
+ pca_ev = pca_feature_map(evidence[0], 8, 8)
534
+ fig_pca_ev = plt.figure(figsize=(4, 4))
535
+ plt.imshow(pca_ev); plt.title("Evidence Memory PCA", fontsize=11, fontweight='bold')
536
+ plt.axis('off'); plt.tight_layout()
537
+ log_dict["visuals/pca/evidence_memory"] = trackio.Image(fig_to_pil(fig_pca_ev),
538
+ caption=f"step={global_step}")
539
+ plt.close(fig_pca_ev)
540
+
541
+ if traj.dim() == 4 and traj.size(1) > 1:
542
+ rollout_pcas = []
543
+ for k in range(traj.size(1)):
544
+ n_tok = traj.size(2)
545
+ side_k = max(int(math.sqrt(n_tok)), 1)
546
+ rollout_pcas.append(pca_feature_map(traj[0, k], side_k, side_k))
547
+ fig_rollout = plt.figure(figsize=(4 * len(rollout_pcas), 4))
548
+ for k, pca_k in enumerate(rollout_pcas):
549
+ ax = fig_rollout.add_subplot(1, len(rollout_pcas), k + 1)
550
+ ax.imshow(pca_k); ax.set_title(f"z_{k}", fontsize=10); ax.axis('off')
551
+ fig_rollout.suptitle("Rollout PCA per Step", fontsize=12, fontweight='bold')
552
+ fig_rollout.tight_layout()
553
+ log_dict["visuals/pca/rollout_steps"] = trackio.Image(fig_to_pil(fig_rollout),
554
+ caption=f"step={global_step}")
555
+ plt.close(fig_rollout)
556
+
557
+ # ── Eigenspectrum Plot ──
558
+ fig_eigen_ev = eigenspectrum_plot(evidence, "Evidence Eigenspectrum")
559
+ log_dict["visuals/eigenspectrum/evidence"] = trackio.Image(fig_to_pil(fig_eigen_ev),
560
+ caption=f"RankMe={evidence_rank:.1f}, step={global_step}")
561
+ plt.close(fig_eigen_ev)
562
+
563
+ fig_eigen_zf = eigenspectrum_plot(z_final, "z_final Eigenspectrum")
564
+ log_dict["visuals/eigenspectrum/z_final"] = trackio.Image(fig_to_pil(fig_eigen_zf),
565
+ caption=f"RankMe={z_final_rank:.1f}, step={global_step}")
566
+ plt.close(fig_eigen_zf)
567
+
568
+ # ── Per-Dimension Variance Plot (VICReg) ──
569
+ fig_vardim_ev = per_dim_variance_plot(ev_stats["std_values"],
570
+ f"Evidence Per-Dim Std (collapsed={ev_stats['collapsed_dims']})")
571
+ log_dict["visuals/collapse/evidence_std"] = trackio.Image(fig_to_pil(fig_vardim_ev),
572
+ caption=f"step={global_step}")
573
+ plt.close(fig_vardim_ev)
574
+
575
+ fig_vardim_zf = per_dim_variance_plot(zf_stats["std_values"],
576
+ f"z_final Per-Dim Std (collapsed={zf_stats['collapsed_dims']})")
577
+ log_dict["visuals/collapse/z_final_std"] = trackio.Image(fig_to_pil(fig_vardim_zf),
578
+ caption=f"step={global_step}")
579
+ plt.close(fig_vardim_zf)
580
+
581
+ # ── Latent Trajectory PCA ──
582
+ if traj.dim() == 4 and traj.size(1) > 1:
583
+ fig_traj = trajectory_pca_plot(traj[0],
584
+ f"Latent Trajectory (straightness={straightness:.3f})")
585
+ log_dict["visuals/trajectory/pca"] = trackio.Image(fig_to_pil(fig_traj),
586
+ caption=f"K={traj.size(1)-1}, step={global_step}")
587
+ plt.close(fig_traj)
588
+
589
+ # ── Token Norm Maps (DINOv2+Registers) ──
590
+ fig_norm_vis = token_norm_map(vis_tok[0], grid_side, grid_side, "Visual Token Norms")
591
+ log_dict["visuals/norms/visual_tokens"] = trackio.Image(fig_to_pil(fig_norm_vis),
592
+ caption=f"step={global_step}")
593
+ plt.close(fig_norm_vis)
594
+
595
+ fig_norm_ev = token_norm_map(evidence[0], 8, 8, "Evidence Token Norms")
596
+ log_dict["visuals/norms/evidence_tokens"] = trackio.Image(fig_to_pil(fig_norm_ev),
597
+ caption=f"step={global_step}")
598
+ plt.close(fig_norm_ev)
599
+
600
+ # ── Cross-Attention Weights (hooks) ──
601
+ if diagnostics_collector and diagnostics_collector.cross_attn_weights:
602
+ attn_w = diagnostics_collector.cross_attn_weights[-1]
603
+ if attn_w is not None and attn_w.dim() >= 2:
604
+ if attn_w.dim() == 4: attn_w = attn_w[0]
605
+ elif attn_w.dim() == 3: attn_w = attn_w[0]
606
+ fig_xattn = cross_attention_weights_plot(attn_w, "Perceiver Cross-Attention (Last Layer)")
607
+ log_dict["visuals/attention/perceiver_xattn"] = trackio.Image(fig_to_pil(fig_xattn),
608
+ caption=f"step={global_step}")
609
+ plt.close(fig_xattn)
610
+
611
+ # ── Evidence Gate Heatmaps ──
612
+ if diagnostics_collector and diagnostics_collector.gate_values:
613
+ gate_vals = [gv[0] if gv.dim() == 3 else gv
614
+ for gv in diagnostics_collector.gate_values[-cfg.K:]]
615
+ if gate_vals:
616
+ fig_gates = evidence_gate_heatmap(gate_vals, "Evidence Gate Activations")
617
+ log_dict["visuals/gates/activations"] = trackio.Image(fig_to_pil(fig_gates),
618
+ caption=f"step={global_step}")
619
+ plt.close(fig_gates)
620
+ for k, gv in enumerate(gate_vals):
621
+ if isinstance(gv, torch.Tensor):
622
+ log_dict[f"diagnostics/gate_mean_step{k+1}"] = gv.float().mean().item()
623
+
624
+ # ── Input Image with Feature Similarity Heatmap (DINO style) ──
625
+ img_tensor = batch["pixel_values"][0]
626
+ mean = torch.tensor([0.485, 0.456, 0.406]).view(3, 1, 1)
627
+ std = torch.tensor([0.229, 0.224, 0.225]).view(3, 1, 1)
628
+ img_denorm = (img_tensor.cpu() * std + mean).clamp(0, 1)
629
+ img_np = (img_denorm.permute(1, 2, 0).numpy() * 255).astype(np.uint8)
630
+
631
+ vis_feats = vis_tok[0]
632
+ mean_feat = vis_feats.mean(0, keepdim=True)
633
+ sim = F.cosine_similarity(vis_feats, mean_feat.expand_as(vis_feats), dim=-1).detach().cpu()
634
+
635
+ n_patches = sim.size(0)
636
+ side = int(math.sqrt(n_patches))
637
+ if side * side == n_patches:
638
+ fig_attn_overlay, ax = plt.subplots(1, 2, figsize=(8, 4))
639
+ ax[0].imshow(img_np); ax[0].set_title("Input Image", fontsize=10); ax[0].axis('off')
640
+ attn_resized = np.array(PILImage.fromarray(
641
+ ((sim.numpy().reshape(side, side) - sim.min().item()) /
642
+ (sim.max().item() - sim.min().item() + 1e-8) * 255).astype(np.uint8)
643
+ ).resize((img_np.shape[1], img_np.shape[0]), PILImage.BILINEAR)) / 255.0
644
+ ax[1].imshow(img_np); ax[1].imshow(attn_resized, cmap='jet', alpha=0.5)
645
+ ax[1].set_title("Feature Similarity Heatmap", fontsize=10); ax[1].axis('off')
646
+ fig_attn_overlay.suptitle("Visual Feature Heatmap (DINO-style)", fontsize=12, fontweight='bold')
647
+ fig_attn_overlay.tight_layout()
648
+ log_dict["visuals/attention/feature_heatmap"] = trackio.Image(fig_to_pil(fig_attn_overlay),
649
+ caption=f"step={global_step}")
650
+ plt.close(fig_attn_overlay)
651
+
652
+ # ── Rollout Comparison Grid ──
653
+ if traj.dim() == 4 and traj.size(1) > 1:
654
+ n_show = min(batch["pixel_values"].size(0), 3)
655
+ images_for_grid = []
656
+ pcas_for_grid = []
657
+ for i in range(n_show):
658
+ img_i = batch["pixel_values"][i].cpu()
659
+ img_i_denorm = (img_i * std + mean).clamp(0, 1)
660
+ images_for_grid.append((img_i_denorm.permute(1, 2, 0).numpy() * 255).astype(np.uint8))
661
+ step_pcas = []
662
+ for k in range(traj.size(1)):
663
+ n_tok = traj.size(2)
664
+ side_k = max(int(math.sqrt(n_tok)), 1)
665
+ step_pcas.append(pca_feature_map(traj[i, k], side_k, side_k))
666
+ pcas_for_grid.append(step_pcas)
667
+ fig_grid = rollout_comparison_grid(images_for_grid, pcas_for_grid,
668
+ suptitle=f"Rollout PCA Grid (K={traj.size(1)-1})")
669
+ log_dict["visuals/rollout/comparison_grid"] = trackio.Image(fig_to_pil(fig_grid),
670
+ caption=f"step={global_step}")
671
+ plt.close(fig_grid)
672
+
673
+ trackio.log(log_dict)
674
+ log.info(f"Logged {len(log_dict)} visual diagnostics at step {global_step}")
675
+
676
+ except Exception as e:
677
+ log.warning(f"Visual diagnostics failed: {e}")
678
+ import traceback
679
+ traceback.print_exc()
680
+ finally:
681
+ model.train()
682
+ plt.close('all')
683
+ if diagnostics_collector:
684
+ diagnostics_collector.clear()
685
+
686
+
687
+ # ══════════════════════════════════════════════════════════════════════════
688
+ # PHASE 2 TRAINING
689
+ # ══════════════════════════════════════════════════════════════════════════
690
+
691
  def download_phase1_checkpoint(hub_model_id: str, run_name: str = "hybrid_main"):
 
692
  from huggingface_hub import hf_hub_download
693
+ path = hf_hub_download(repo_id=hub_model_id, filename=f"checkpoints/{run_name}_best.pt", repo_type="model")
 
 
 
 
694
  log.info(f"Downloaded checkpoint: {path}")
695
  return path
696
 
697
 
698
  def main():
699
  parser = argparse.ArgumentParser(description="MR-JEPA Phase 2 Training")
700
+ parser.add_argument("--checkpoint", type=str, default=None)
 
701
  parser.add_argument("--hub_model_id", default="JorgeAV/MR-JEPA")
702
  parser.add_argument("--run_name", default="hybrid_main_phase2")
703
+ parser.add_argument("--phase1_run", default="hybrid_main")
 
704
  parser.add_argument("--epochs", type=int, default=10)
705
  parser.add_argument("--batch_size", type=int, default=16)
706
  parser.add_argument("--grad_accum", type=int, default=8)
 
710
  parser.add_argument("--unfreeze_visual_layers", type=int, default=6)
711
  parser.add_argument("--unfreeze_text_layers", type=int, default=4)
712
  parser.add_argument("--max_eval_samples", type=int, default=500)
713
+ parser.add_argument("--vis_interval", type=int, default=100)
714
  parser.add_argument("--output_dir", default="./outputs/mrjepa_phase2")
715
  args = parser.parse_args()
716
 
 
717
  log.info("Downloading Phase 1 training script...")
718
  from huggingface_hub import hf_hub_download
719
+ p1_script = hf_hub_download(repo_id=args.hub_model_id, filename="train_mrjepa.py", repo_type="model")
 
 
 
 
720
  import importlib.util
721
  spec = importlib.util.spec_from_file_location("train_mrjepa", p1_script)
722
  p1 = importlib.util.module_from_spec(spec)
723
  spec.loader.exec_module(p1)
724
 
 
725
  if args.checkpoint and os.path.exists(args.checkpoint):
726
  ckpt_path = args.checkpoint
727
  else:
 
730
  log.info(f"Loading Phase 1 checkpoint: {ckpt_path}")
731
  ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=False)
732
 
 
733
  saved_cfg = ckpt["config"]
734
  cfg = p1.Config()
735
  for k, v in saved_cfg.items():
736
  if hasattr(cfg, k):
737
  setattr(cfg, k, v)
738
 
 
739
  cfg.phase = 2
740
  cfg.epochs = args.epochs
741
  cfg.batch_size = args.batch_size
 
744
  cfg.backbone_lr = args.backbone_lr
745
  cfg.output_dir = args.output_dir
746
  cfg.run_name = args.run_name
747
+ cfg.freeze_backbone = True
748
  cfg.freeze_text = True
749
  cfg.max_eval_samples = args.max_eval_samples
750
  cfg.resolve()
 
753
  log.info(f"Device: {device}")
754
  os.makedirs(cfg.output_dir, exist_ok=True)
755
 
756
+ import trackio
757
+ trackio.init(
758
+ name=args.run_name, project="MR-JEPA",
759
+ config={
760
+ "phase": 2, "epochs": args.epochs,
761
+ "core_lr": args.core_lr, "backbone_lr": args.backbone_lr, "text_lr": args.text_lr,
762
+ "batch_size": args.batch_size, "grad_accum": args.grad_accum,
763
+ "unfreeze_visual_layers": args.unfreeze_visual_layers,
764
+ "unfreeze_text_layers": args.unfreeze_text_layers,
765
+ "phase1_best_acc": ckpt.get("eval_acc", "unknown"),
766
+ "vis_interval": args.vis_interval,
767
+ "backbone": cfg.backbone, "K": cfg.K, "use_jepa": cfg.use_jepa, "loss_fn": cfg.loss_fn,
768
+ }
769
+ )
770
+ log.info("Trackio initialized with visual diagnostics")
771
 
 
772
  log.info("Building model...")
773
  model = p1.MRJEPAModel(cfg)
774
  model.evidence.load_state_dict(ckpt["evidence"])
 
778
  model.target.t_ro.load_state_dict(ckpt["target_ro"])
779
  log.info(f"Loaded Phase 1 weights (epoch={ckpt.get('epoch','?')}, eval_acc={ckpt.get('eval_acc','?')}%)")
780
 
 
781
  log.info(f"Unfreezing last {args.unfreeze_visual_layers} visual layers, "
782
  f"last {args.unfreeze_text_layers} text layers")
783
  model.vis.unfreeze_last(args.unfreeze_visual_layers)
 
787
  total_p = sum(p.numel() for p in model.parameters())
788
  train_p = sum(p.numel() for p in model.parameters() if p.requires_grad)
789
  log.info(f"Total: {total_p:,} | Trainable: {train_p:,} ({100*train_p/total_p:.1f}%)")
790
+ trackio.log({"model/total_params": total_p, "model/trainable_params": train_p,
791
+ "model/trainable_pct": 100 * train_p / total_p})
792
 
 
793
  transform = model.vis.get_transform()
794
  tokenizer = model.txt.tokenizer
795
  train_ds = p1.ScienceQADataset("train", transform=transform, tokenizer=tokenizer,
 
803
  eval_dl = DataLoader(eval_ds, batch_size=cfg.batch_size, shuffle=False,
804
  num_workers=2, collate_fn=coll, pin_memory=True)
805
 
 
806
  backbone_params = [p for p in model.vis.parameters() if p.requires_grad]
807
  text_params = [p for p in model.txt.parameters() if p.requires_grad]
 
808
  bb_txt_ids = {id(p) for p in backbone_params + text_params}
809
  core_params = [p for p in model.parameters() if p.requires_grad and id(p) not in bb_txt_ids]
810
 
 
829
 
830
  scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)
831
 
832
+ diag_collector = DiagnosticCollector()
833
+ diag_collector.attach(model)
834
+
835
  log.info(f"Phase 2: {cfg.epochs} epochs, {len(train_dl)} batches/epoch, ga={cfg.grad_accum}")
836
+ log.info(f"Visual diagnostics every {args.vis_interval} optimizer steps")
837
  global_step = 0
838
  best_acc = ckpt.get("eval_acc", 0.0)
839
  amp_dtype = torch.bfloat16 if cfg.bf16 else torch.float32
 
848
 
849
  for batch_idx, batch in enumerate(train_dl):
850
  batch = {k: v.to(device) if isinstance(v, torch.Tensor) else v for k, v in batch.items()}
 
 
 
 
 
 
851
  vis_tok = model.vis(batch["pixel_values"]).float()
852
  txt_tok = model.txt(batch["input_ids"], batch["attention_mask"]).float()
853
 
854
  with torch.autocast(device_type="cuda", dtype=amp_dtype, enabled=cfg.bf16 and device.type == "cuda"):
855
  evidence, _, ev_mask = model.evidence(vis_tok, txt_tok, batch["attention_mask"])
 
856
  if model._use_rollout:
857
  traj, z_final, z_proj = model.rollout(evidence)
858
  else:
859
  B = batch["batch_size"]
860
  z0 = model.rollout.init_tokens.expand(B, -1, -1) + \
861
  model.rollout.z0_proj(F.adaptive_avg_pool1d(
862
+ evidence.permute(0,2,1), model.rollout.num_tokens).permute(0,2,1))
 
863
  z_final = z0
864
  z_proj = model.rollout.out_proj(z0).unsqueeze(1)
865
 
 
866
  if model._use_jepa:
867
  target_proj = model.target(vis_tok.detach(), txt_tok.detach(), batch["attention_mask"].detach())
868
  else:
869
  target_proj = None
870
 
 
871
  opt_emb = model.encode_options(batch["opt_input_ids"], batch["opt_attention_mask"])
872
  opt_emb = opt_emb.view(batch["batch_size"], cfg.max_options, -1)
873
  logits = model.disc(z_final, opt_emb, batch["opt_mask"])
 
877
  losses = model.jepa_loss(z_proj, target_proj, task_loss)
878
  else:
879
  losses = {"total": task_loss, "jepa": torch.tensor(0.0), "task": task_loss, "reg": torch.tensor(0.0)}
 
880
  loss = losses["total"] / cfg.grad_accum
881
 
882
  loss.backward()
883
 
884
  if (batch_idx + 1) % cfg.grad_accum == 0:
885
  nn.utils.clip_grad_norm_(trainable, cfg.max_grad_norm)
886
+ optimizer.step(); scheduler.step(); optimizer.zero_grad()
 
 
887
  model.update_target(global_step, total_steps)
888
  global_step += 1
889
+ if global_step % args.vis_interval == 0 and global_step > 0:
890
+ log.info(f"Generating visual diagnostics at step {global_step}...")
891
+ log_visual_diagnostics(model, batch, device, cfg, global_step, epoch,
892
+ diagnostics_collector=diag_collector, vis_interval=args.vis_interval)
893
 
894
  preds = logits.argmax(dim=-1)
895
  for k, v in losses.items():
 
905
  log.info(f"P2 E{epoch} B{batch_idx}/{len(train_dl)} | "
906
  f"loss={avg.get('total',0):.4f} jepa={avg.get('jepa',0):.4f} "
907
  f"task={avg.get('task',0):.4f} | acc={acc:.1f}%")
908
+ trackio.log({
909
+ "train/loss": avg.get("total", 0), "train/jepa_loss": avg.get("jepa", 0),
910
+ "train/task_loss": avg.get("task", 0), "train/reg_loss": avg.get("reg", 0),
911
+ "train/accuracy": acc, "train/lr": lrs[0] if lrs else 0,
912
+ "train/backbone_lr": lrs[1] if len(lrs) > 1 else 0,
913
+ "train/text_lr": lrs[2] if len(lrs) > 2 else 0,
914
+ "train/ema_momentum": model.target.mom,
915
+ "train/epoch": epoch, "train/step": global_step,
916
+ })
917
+
 
 
918
  eval_acc = p1.evaluate(model, eval_dl, device, cfg)
919
  train_acc = epoch_correct / max(epoch_total, 1) * 100
920
  log.info(f"=== Phase 2 Epoch {epoch} | Train: {train_acc:.1f}% | Eval: {eval_acc:.1f}% ===")
921
+ trackio.log({"eval/accuracy": eval_acc, "eval/epoch": epoch,
922
+ "eval/train_accuracy": train_acc, "eval/best_accuracy": max(best_acc, eval_acc)})
923
 
924
+ log.info(f"Generating epoch-end visual diagnostics...")
925
+ diag_batch = next(iter(eval_dl))
926
+ diag_batch = {k: v.to(device) if isinstance(v, torch.Tensor) else v for k, v in diag_batch.items()}
927
+ log_visual_diagnostics(model, diag_batch, device, cfg, global_step, epoch,
928
+ diagnostics_collector=diag_collector, vis_interval=args.vis_interval)
929
 
930
  if eval_acc > best_acc:
931
  best_acc = eval_acc
932
  p1.save_checkpoint(model, cfg, epoch, eval_acc, is_best=True)
933
+ log.info(f"New best accuracy: {best_acc:.1f}%")
934
 
935
  log.info(f"Phase 2 complete. Best eval accuracy: {best_acc:.1f}%")
936
+ diag_collector.detach()
937
+ trackio.log({"final/best_accuracy": best_acc, "final/phase": 2, "final/total_steps": global_step})
938
  if cfg.push_to_hub:
939
  p1.push_results(cfg, best_acc)
940