AbstractPhil commited on
Commit
46f26dc
Β·
verified Β·
1 Parent(s): e9d858a

Update analyze_weights.py

Browse files
Files changed (1) hide show
  1. analyze_weights.py +270 -33
analyze_weights.py CHANGED
@@ -19,6 +19,10 @@ CKPT = "checkpoints/geolip_core_best.pt"
19
  OUT_DIR = "analysis_out"
20
  BATCH = 256
21
 
 
 
 
 
22
  CIFAR_MEAN = (0.4914, 0.4822, 0.4465)
23
  CIFAR_STD = (0.2470, 0.2435, 0.2616)
24
 
@@ -98,14 +102,108 @@ labels = torch.cat(all_labels)
98
  preds = torch.cat(all_preds)
99
  logits = torch.cat(all_logits)
100
 
101
- anchors = model.constellation.anchors.detach().float().cpu()
102
- anchors_n = F.normalize(anchors, dim=-1)
103
- n_anchors = anchors.shape[0]
104
  embs_n = F.normalize(embs, dim=-1)
105
-
106
  val_acc = (preds == labels).float().mean().item() * 100
107
  print(f" Val accuracy: {val_acc:.1f}%")
108
  print(f" Embeddings: {embs.shape}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  print(f" Anchors: {anchors.shape}")
110
 
111
  # ══════════════════════════════════════════════════════════════════
@@ -329,13 +427,102 @@ if HAS_PLT:
329
  '#e6194b', '#3cb44b', '#4363d8', '#f58231', '#911eb4',
330
  '#42d4f4', '#f032e6', '#bfef45', '#469990', '#dcbeff']
331
  else:
332
- cmap = plt.cm.get_cmap('tab20', min(N_CLASSES, 20))
333
- CLASS_COLORS = [matplotlib.colors.rgb2hex(cmap(i % 20)) for i in range(N_CLASSES)]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
334
 
335
  print(f"\n{'='*70}")
336
  print("VISUALIZATIONS")
337
  print(f"{'='*70}")
338
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
  # PCA basis
340
  embs_c = embs_n[:5000] - embs_n[:5000].mean(0, keepdim=True)
341
  _, _, Vt = torch.linalg.svd(embs_c, full_matrices=False)
@@ -345,6 +532,24 @@ if HAS_PLT:
345
  anch_3d = (anchors_n @ Vt[:3].T).numpy()
346
  proj_labels = labels.numpy()
347
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
348
  # ── [1] PCA embedding space ──
349
  print(" [1/8] PCA projection...")
350
  fig, ax = plt.subplots(1, 1, figsize=(12, 10))
@@ -353,18 +558,21 @@ if HAS_PLT:
353
  if mask.sum() == 0: continue
354
  lbl = CLASS_NAMES[c] if N_CLASSES <= 20 else None
355
  ax.scatter(proj_2d[:5000][mask, 0], proj_2d[:5000][mask, 1],
356
- c=CLASS_COLORS[c], s=4, alpha=0.3, label=lbl)
357
  ax.scatter(anch_2d[:, 0], anch_2d[:, 1],
358
- c='black', s=60, marker='*', zorder=5, label='anchors')
 
 
359
  if N_CLASSES <= 20:
360
  ax.legend(fontsize=7, markerscale=2, loc='upper right', ncol=2)
361
  ax.set_title(f'GeoLIP Core β€” PCA Embedding Space ({ds_name})\n'
362
  f'val={val_acc:.1f}% | {total_params:,} params | '
363
  f'CV={v_cv:.4f} | {n_active}/{n_anchors} anchors', fontsize=11)
364
  ax.set_xlabel('PC1'); ax.set_ylabel('PC2')
365
- ax.grid(True, alpha=0.2)
 
366
  plt.tight_layout()
367
- plt.savefig(f'{OUT_DIR}/01_pca_embedding_space.png', dpi=200)
368
  plt.close()
369
 
370
  # ── [2] Triangulation connections ──
@@ -375,14 +583,14 @@ if HAS_PLT:
375
  a_idx = nearest[i].item()
376
  ax.plot([proj_2d[i, 0], anch_2d[a_idx, 0]],
377
  [proj_2d[i, 1], anch_2d[a_idx, 1]],
378
- c=CLASS_COLORS[labels[i].item()], alpha=0.06, linewidth=0.4)
379
  for c in range(N_CLASSES):
380
  mask = proj_labels[:5000] == c
381
  if mask.sum() == 0: continue
382
  ax.scatter(proj_2d[:5000][mask, 0], proj_2d[:5000][mask, 1],
383
- c=CLASS_COLORS[c], s=3, alpha=0.25)
384
  ax.scatter(anch_2d[:, 0], anch_2d[:, 1],
385
- c='black', s=80, marker='*', zorder=5)
386
  if n_anchors <= 128:
387
  for a in range(n_anchors):
388
  a_mask = nearest == a
@@ -392,11 +600,16 @@ if HAS_PLT:
392
  fontsize=4, ha='center', va='center',
393
  color='white', fontweight='bold',
394
  bbox=dict(boxstyle='round,pad=0.1',
395
- fc=CLASS_COLORS[dom_class], alpha=0.7))
 
 
 
 
396
  ax.set_title(f'Triangulation: Image β†’ Nearest Anchor ({ds_name})', fontsize=11)
397
- ax.grid(True, alpha=0.2)
 
398
  plt.tight_layout()
399
- plt.savefig(f'{OUT_DIR}/02_triangulation_connections.png', dpi=200)
400
  plt.close()
401
 
402
  # ── [3] 3D sphere ──
@@ -409,16 +622,25 @@ if HAS_PLT:
409
  if mask.sum() == 0: continue
410
  ax.scatter(proj_3d[:n_3d][mask, 0], proj_3d[:n_3d][mask, 1],
411
  proj_3d[:n_3d][mask, 2],
412
- c=CLASS_COLORS[c], s=3, alpha=0.25,
413
  label=CLASS_NAMES[c] if N_CLASSES <= 20 else None)
414
  ax.scatter(anch_3d[:, 0], anch_3d[:, 1], anch_3d[:, 2],
415
- c='black', s=40, marker='*', zorder=5)
 
 
416
  if N_CLASSES <= 20:
417
  ax.legend(fontsize=6, markerscale=2, loc='upper left', ncol=2)
418
  ax.set_title(f'3D PCA β€” Constellation on the Sphere\n'
419
  f'{n_anchors} anchors, {N_CLASSES} classes', fontsize=11)
 
 
 
 
 
 
 
420
  plt.tight_layout()
421
- plt.savefig(f'{OUT_DIR}/03_3d_sphere.png', dpi=200)
422
  plt.close()
423
 
424
  # ── [4] Anchor-Class heatmap ──
@@ -436,7 +658,7 @@ if HAS_PLT:
436
 
437
  h = max(6, N_CLASSES * 0.12)
438
  fig, ax = plt.subplots(1, 1, figsize=(16, h))
439
- im = ax.imshow(assign_sorted.numpy(), aspect='auto', cmap='YlOrRd')
440
  if N_CLASSES <= 30:
441
  ax.set_yticks(range(N_CLASSES))
442
  ax.set_yticklabels(CLASS_NAMES, fontsize=max(4, 9 - N_CLASSES // 15))
@@ -444,7 +666,7 @@ if HAS_PLT:
444
  ax.set_title(f'Class β†’ Anchor Assignment ({ds_name})', fontsize=11)
445
  plt.colorbar(im, ax=ax, shrink=0.8)
446
  plt.tight_layout()
447
- plt.savefig(f'{OUT_DIR}/04_anchor_class_heatmap.png', dpi=200)
448
  plt.close()
449
 
450
  # ── [5] Triangulation profiles ──
@@ -475,7 +697,7 @@ if HAS_PLT:
475
  tag = "all classes" if N_CLASSES <= 10 else "5 worst + 5 best"
476
  plt.suptitle(f'Triangulation Fingerprints ({tag})', fontsize=12)
477
  plt.tight_layout()
478
- plt.savefig(f'{OUT_DIR}/05_triangulation_profiles.png', dpi=200)
479
  plt.close()
480
 
481
  # ── [6] Anchor utilization ──
@@ -484,11 +706,11 @@ if HAS_PLT:
484
 
485
  sorted_counts, _ = counts.sort(descending=True)
486
  ax1.bar(range(n_anchors), sorted_counts.numpy(),
487
- color=['#2196F3' if c > 0 else '#F44336' for c in sorted_counts], width=1.0)
488
  ax1.set_xlabel('Anchor (sorted)')
489
  ax1.set_ylabel('Assigned samples')
490
  ax1.set_title(f'Anchor Utilization ({n_active}/{n_anchors} active)')
491
- ax1.axhline(y=len(labels) / n_anchors, color='gray', linestyle='--', alpha=0.5)
492
 
493
  # Per-class anchor entropy
494
  entropies = []
@@ -508,7 +730,7 @@ if HAS_PLT:
508
  ax2.set_yticklabels(CLASS_NAMES, fontsize=8)
509
  ax2.set_xlabel('Anchor assignment entropy')
510
  else:
511
- ax2.hist(entropies, bins=30, color='steelblue', edgecolor='white')
512
  ax2.set_xlabel('Anchor assignment entropy')
513
  ax2.set_ylabel('Number of classes')
514
 
@@ -518,7 +740,7 @@ if HAS_PLT:
518
  gini = (1 - 2 * cum.sum() / (len(c_sorted) * c_sorted.sum() + 1e-8)).item()
519
  ax2.set_title(f'Anchor Spread (Gini={gini:.3f})')
520
  plt.tight_layout()
521
- plt.savefig(f'{OUT_DIR}/06_anchor_utilization.png', dpi=200)
522
  plt.close()
523
 
524
  # ── [7] Patchwork compartment responses ──
@@ -551,7 +773,7 @@ if HAS_PLT:
551
  class_labels_show.append(CLASS_NAMES[c])
552
  if not class_means: continue
553
  class_means = np.stack(class_means)
554
- ax.imshow(class_means, aspect='auto', cmap='viridis')
555
  ax.set_yticks(range(len(class_labels_show)))
556
  ax.set_yticklabels(class_labels_show, fontsize=6)
557
  ax.set_title(f'Comp {k}', fontsize=9)
@@ -559,7 +781,7 @@ if HAS_PLT:
559
  axes_flat[k].set_visible(False)
560
  plt.suptitle('Patchwork Compartment Responses by Class', fontsize=12)
561
  plt.tight_layout()
562
- plt.savefig(f'{OUT_DIR}/07_patchwork_compartments.png', dpi=200)
563
  plt.close()
564
 
565
  # ── [8] Confusion matrix ──
@@ -571,26 +793,26 @@ if HAS_PLT:
571
 
572
  if N_CLASSES <= 20:
573
  fig, ax = plt.subplots(1, 1, figsize=(8, 7))
574
- im = ax.imshow(conf_pct.numpy(), cmap='Blues', vmin=0, vmax=100)
575
  for i in range(N_CLASSES):
576
  for j in range(N_CLASSES):
577
  v = conf_pct[i, j].item()
578
  ax.text(j, i, f'{v:.0f}', ha='center', va='center',
579
  fontsize=max(4, 8 - N_CLASSES // 5),
580
- color='white' if v > 50 else 'black')
581
  ax.set_xticks(range(N_CLASSES))
582
  ax.set_yticks(range(N_CLASSES))
583
  ax.set_xticklabels(CLASS_NAMES, rotation=45, ha='right', fontsize=7)
584
  ax.set_yticklabels(CLASS_NAMES, fontsize=7)
585
  else:
586
  fig, ax = plt.subplots(1, 1, figsize=(14, 12))
587
- im = ax.imshow(conf_pct.numpy(), cmap='Blues', vmin=0, vmax=100)
588
  ax.set_xlabel('Predicted class')
589
  ax.set_ylabel('True class')
590
  ax.set_title(f'Confusion Matrix β€” {val_acc:.1f}% ({ds_name})', fontsize=11)
591
  plt.colorbar(im, ax=ax, shrink=0.8)
592
  plt.tight_layout()
593
- plt.savefig(f'{OUT_DIR}/08_confusion_matrix.png', dpi=200)
594
  plt.close()
595
 
596
  print(f"\n βœ“ All 8 visualizations saved to {OUT_DIR}/")
@@ -633,4 +855,19 @@ else:
633
 
634
  print(f"\n{'='*70}")
635
  print("ANALYSIS COMPLETE")
636
- print(f"{'='*70}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  OUT_DIR = "analysis_out"
20
  BATCH = 256
21
 
22
+ # ── HuggingFace push ──
23
+ HF_REPO_ID = "AbstractPhil/geolip-constellation-core"
24
+ HF_PUSH = True
25
+
26
  CIFAR_MEAN = (0.4914, 0.4822, 0.4465)
27
  CIFAR_STD = (0.2470, 0.2435, 0.2616)
28
 
 
102
  preds = torch.cat(all_preds)
103
  logits = torch.cat(all_logits)
104
 
 
 
 
105
  embs_n = F.normalize(embs, dim=-1)
 
106
  val_acc = (preds == labels).float().mean().item() * 100
107
  print(f" Val accuracy: {val_acc:.1f}%")
108
  print(f" Embeddings: {embs.shape}")
109
+
110
+ # ══════════════════════════════════════════════════════════════════
111
+ # ANCHOR PUSH β€” drag anchors to where the data lives
112
+ # ══════════════════════════════════════════════════════════════════
113
+
114
+ N_PUSH_STEPS = 30
115
+ PUSH_LR = 0.5
116
+
117
+ print(f"\n Pushing anchors toward CLASS centroids ({N_PUSH_STEPS} steps, lr={PUSH_LR})...")
118
+
119
+ # Before stats
120
+ anchors_before = model.constellation.anchors.detach().float().cpu().clone()
121
+ anch_n_before = F.normalize(anchors_before, dim=-1)
122
+ cos_before = (embs_n @ anch_n_before.T).max(dim=1).values.mean().item()
123
+ print(f" Before: mean nearest_cos = {cos_before:.4f}")
124
+
125
+ # Push using class centroids
126
+ emb_device = embs.to(DEVICE)
127
+ lbl_device = labels.to(DEVICE)
128
+
129
+ if hasattr(model, 'push_anchors_to_centroids'):
130
+ for step in range(N_PUSH_STEPS):
131
+ moved = model.push_anchors_to_centroids(emb_device, lbl_device, lr=PUSH_LR)
132
+ if (step + 1) % 10 == 0:
133
+ an_tmp = F.normalize(model.constellation.anchors.detach().float().cpu(), dim=-1)
134
+ c_tmp = (embs_n @ an_tmp.T).max(dim=1).values.mean().item()
135
+ print(f" Step {step+1:3d}: nearest_cos = {c_tmp:.4f}, moved = {moved}")
136
+ else:
137
+ # Inline class-centroid push
138
+ with torch.no_grad():
139
+ anchors_param = model.constellation.anchors.data
140
+ emb_dev = F.normalize(emb_device, dim=-1)
141
+
142
+ # Compute class centroids once
143
+ classes = lbl_device.unique()
144
+ n_cls = classes.shape[0]
145
+ centroids = []
146
+ for c in classes:
147
+ mask = lbl_device == c
148
+ centroids.append(F.normalize(emb_dev[mask].mean(0, keepdim=True), dim=-1))
149
+ centroids = torch.cat(centroids, dim=0) # (C, D)
150
+
151
+ # Assign anchors to classes round-robin
152
+ n_a = anchors_param.shape[0]
153
+ anchors_per_class = n_a // n_cls
154
+
155
+ for step in range(N_PUSH_STEPS):
156
+ an = F.normalize(anchors_param, dim=-1)
157
+ cos_ac = an @ centroids.T # (A, C)
158
+
159
+ # Greedy assign
160
+ assigned = torch.full((n_a,), -1, dtype=torch.long, device=DEVICE)
161
+ cls_count = torch.zeros(n_cls, dtype=torch.long, device=DEVICE)
162
+ _, flat_idx = cos_ac.flatten().sort(descending=True)
163
+ for idx in flat_idx:
164
+ a = (idx // n_cls).item()
165
+ c_idx = (idx % n_cls).item()
166
+ if assigned[a] >= 0: continue
167
+ if cls_count[c_idx] >= anchors_per_class + 1: continue
168
+ assigned[a] = c_idx
169
+ cls_count[c_idx] += 1
170
+ if (assigned >= 0).all(): break
171
+ unassigned = (assigned < 0).nonzero(as_tuple=True)[0]
172
+ if len(unassigned) > 0:
173
+ assigned[unassigned] = (an[unassigned] @ centroids.T).argmax(dim=1)
174
+
175
+ # Push each anchor toward its class centroid
176
+ for a in range(n_a):
177
+ target = centroids[assigned[a].item()]
178
+ rank = (assigned[:a] == assigned[a]).sum().item()
179
+ if rank > 0:
180
+ noise = torch.randn_like(target) * 0.05
181
+ noise = noise - (noise * target).sum() * target
182
+ target = F.normalize((target + noise).unsqueeze(0), dim=-1).squeeze(0)
183
+ anchors_param[a] = F.normalize(
184
+ (an[a] + PUSH_LR * (target - an[a])).unsqueeze(0), dim=-1).squeeze(0)
185
+
186
+ if (step + 1) % 10 == 0:
187
+ an_tmp = F.normalize(anchors_param, dim=-1)
188
+ c_tmp = (emb_dev @ an_tmp.T).max(dim=1).values.mean().item()
189
+ print(f" Step {step+1:3d}: nearest_cos = {c_tmp:.4f}")
190
+
191
+ # After stats
192
+ anchors = model.constellation.anchors.detach().float().cpu()
193
+ anchors_n = F.normalize(anchors, dim=-1)
194
+ n_anchors = anchors.shape[0]
195
+
196
+ cos_after = (embs_n @ anchors_n.T).max(dim=1).values.mean().item()
197
+ drift = (F.normalize(anchors_before, dim=-1) - anchors_n).norm(dim=-1).mean().item()
198
+ print(f" After: mean nearest_cos = {cos_after:.4f} (Ξ”={cos_after - cos_before:+.4f})")
199
+ print(f" Anchor drift: {drift:.4f}")
200
+
201
+ # Re-triangulate with pushed anchors
202
+ with torch.no_grad():
203
+ new_cos = embs_n @ anchors_n.T
204
+ tris = 1.0 - new_cos
205
+ nearest = new_cos.argmax(dim=1)
206
+
207
  print(f" Anchors: {anchors.shape}")
208
 
209
  # ══════════════════════════════════════════════════════════════════
 
427
  '#e6194b', '#3cb44b', '#4363d8', '#f58231', '#911eb4',
428
  '#42d4f4', '#f032e6', '#bfef45', '#469990', '#dcbeff']
429
  else:
430
+ # Vibrant HSV spiral β€” 100 distinct saturated colors
431
+ import colorsys
432
+ CLASS_COLORS = []
433
+ for i in range(N_CLASSES):
434
+ # Golden angle rotation for max hue separation
435
+ hue = (i * 0.618033988749895) % 1.0
436
+ # Alternate saturation/value for neighboring hues
437
+ sat = 0.75 + 0.25 * (i % 3) / 2
438
+ val = 0.85 + 0.15 * ((i + 1) % 2)
439
+ r, g, b = colorsys.hsv_to_rgb(hue, sat, val)
440
+ CLASS_COLORS.append(f'#{int(r*255):02x}{int(g*255):02x}{int(b*255):02x}')
441
+
442
+ # Dark theme for all plots β€” makes colors pop
443
+ plt.style.use('dark_background')
444
+ plt.rcParams.update({
445
+ 'figure.facecolor': '#1a1a2e',
446
+ 'axes.facecolor': '#16213e',
447
+ 'axes.edgecolor': '#444466',
448
+ 'axes.labelcolor': '#e0e0e0',
449
+ 'text.color': '#e0e0e0',
450
+ 'xtick.color': '#aaaacc',
451
+ 'ytick.color': '#aaaacc',
452
+ 'grid.color': '#333355',
453
+ 'legend.facecolor': '#1a1a2e',
454
+ 'legend.edgecolor': '#444466',
455
+ })
456
 
457
  print(f"\n{'='*70}")
458
  print("VISUALIZATIONS")
459
  print(f"{'='*70}")
460
 
461
+ def save_fig(filename, dpi=200):
462
+ plt.savefig(f'{OUT_DIR}/{filename}', dpi=dpi)
463
+
464
+ # ── Sphere grid helpers ──
465
+ def draw_sphere_grid_2d(ax, radius, n_meridians=24):
466
+ """Draw sphere reference grid β€” UNMISSABLE."""
467
+ print(f" >>> DRAWING 2D GRID: radius={radius:.4f}, lw=5, white+cyan")
468
+ theta = np.linspace(0, 2 * np.pi, 500)
469
+ xr = radius * np.cos(theta)
470
+ yr = radius * np.sin(theta)
471
+
472
+ # Cyan glow (fat, behind)
473
+ ax.plot(xr, yr, color='#00e5ff', alpha=0.6, lw=9, zorder=49)
474
+ # White ring on top
475
+ ax.plot(xr, yr, color='white', alpha=1.0, lw=5, zorder=50,
476
+ solid_capstyle='round')
477
+
478
+ # Inner rings β€” dashed cyan, thick
479
+ for frac in [0.5, 0.75]:
480
+ ax.plot(frac * xr, frac * yr,
481
+ color='#00e5ff', alpha=0.5, lw=2, linestyle='--', zorder=50)
482
+
483
+ # Meridian ticks β€” chunky white
484
+ for i in range(n_meridians):
485
+ a = 2 * np.pi * i / n_meridians
486
+ r0, r1 = radius * 0.92, radius * 1.08
487
+ ax.plot([r0*np.cos(a), r1*np.cos(a)],
488
+ [r0*np.sin(a), r1*np.sin(a)],
489
+ color='white', alpha=0.8, lw=2, zorder=50)
490
+
491
+ # Crosshairs
492
+ s = radius * 1.15
493
+ ax.plot([-s, s], [0, 0], color='#00e5ff', alpha=0.3, lw=1.5, zorder=49)
494
+ ax.plot([0, 0], [-s, s], color='#00e5ff', alpha=0.3, lw=1.5, zorder=49)
495
+
496
+ # Text label proving it rendered
497
+ ax.text(radius * 0.72, radius * 0.72, f'r={radius:.2f}',
498
+ color='#00e5ff', fontsize=10, fontweight='bold',
499
+ alpha=0.9, zorder=51)
500
+
501
+ def draw_sphere_grid_3d(ax, radius, n_lines=16):
502
+ """Draw a wireframe sphere in 3D PCA space β€” THICK."""
503
+ print(f" >>> DRAWING 3D WIREFRAME: radius={radius:.4f}, lw=1.2+3")
504
+ theta = np.linspace(0, 2 * np.pi, 80)
505
+ phi = np.linspace(0, np.pi, 40)
506
+
507
+ # Latitude rings
508
+ for p in np.linspace(0, np.pi, n_lines + 1)[1:-1]:
509
+ r = radius * np.sin(p)
510
+ z = radius * np.cos(p)
511
+ ax.plot(r * np.cos(theta), r * np.sin(theta),
512
+ z * np.ones_like(theta),
513
+ color='white', alpha=0.4, lw=1.2)
514
+
515
+ # Longitude meridians
516
+ for t in np.linspace(0, 2 * np.pi, n_lines, endpoint=False):
517
+ x = radius * np.sin(phi) * np.cos(t)
518
+ y = radius * np.sin(phi) * np.sin(t)
519
+ z = radius * np.cos(phi)
520
+ ax.plot(x, y, z, color='white', alpha=0.4, lw=1.2)
521
+
522
+ # Equator β€” bright cyan, extra thick
523
+ ax.plot(radius * np.cos(theta), radius * np.sin(theta),
524
+ np.zeros_like(theta), color='#00e5ff', alpha=0.9, lw=3)
525
+
526
  # PCA basis
527
  embs_c = embs_n[:5000] - embs_n[:5000].mean(0, keepdim=True)
528
  _, _, Vt = torch.linalg.svd(embs_c, full_matrices=False)
 
532
  anch_3d = (anchors_n @ Vt[:3].T).numpy()
533
  proj_labels = labels.numpy()
534
 
535
+ # Compute sphere radius from projected data
536
+ emb_radii_2d = np.sqrt(proj_2d[:5000, 0]**2 + proj_2d[:5000, 1]**2)
537
+ sphere_r_2d = np.percentile(emb_radii_2d, 95)
538
+
539
+ emb_radii_3d = np.sqrt((proj_3d[:3000]**2).sum(axis=1))
540
+ sphere_r_3d = np.percentile(emb_radii_3d, 95)
541
+
542
+ # Sanity: if projections are tiny, use data range instead
543
+ data_range_2d = max(np.abs(proj_2d[:5000]).max(), np.abs(anch_2d).max())
544
+ data_range_3d = max(np.abs(proj_3d[:3000]).max(), np.abs(anch_3d).max())
545
+ if sphere_r_2d < 0.01:
546
+ sphere_r_2d = data_range_2d * 0.9
547
+ if sphere_r_3d < 0.01:
548
+ sphere_r_3d = data_range_3d * 0.9
549
+
550
+ print(f" Sphere radius (2D): {sphere_r_2d:.4f} (3D): {sphere_r_3d:.4f}")
551
+ print(f" Data range (2D): {data_range_2d:.4f} (3D): {data_range_3d:.4f}")
552
+
553
  # ── [1] PCA embedding space ──
554
  print(" [1/8] PCA projection...")
555
  fig, ax = plt.subplots(1, 1, figsize=(12, 10))
 
558
  if mask.sum() == 0: continue
559
  lbl = CLASS_NAMES[c] if N_CLASSES <= 20 else None
560
  ax.scatter(proj_2d[:5000][mask, 0], proj_2d[:5000][mask, 1],
561
+ c=CLASS_COLORS[c], s=4, alpha=0.5, label=lbl, zorder=2)
562
  ax.scatter(anch_2d[:, 0], anch_2d[:, 1],
563
+ c='#FFD700', s=60, marker='*', edgecolors='white', linewidths=0.3, zorder=5, label='anchors')
564
+ # Grid drawn LAST β€” on top of everything
565
+ draw_sphere_grid_2d(ax, sphere_r_2d)
566
  if N_CLASSES <= 20:
567
  ax.legend(fontsize=7, markerscale=2, loc='upper right', ncol=2)
568
  ax.set_title(f'GeoLIP Core β€” PCA Embedding Space ({ds_name})\n'
569
  f'val={val_acc:.1f}% | {total_params:,} params | '
570
  f'CV={v_cv:.4f} | {n_active}/{n_anchors} anchors', fontsize=11)
571
  ax.set_xlabel('PC1'); ax.set_ylabel('PC2')
572
+ ax.set_aspect('equal')
573
+ ax.grid(True, alpha=0.15, color='#555577')
574
  plt.tight_layout()
575
+ save_fig('01_pca_embedding_space.png')
576
  plt.close()
577
 
578
  # ── [2] Triangulation connections ──
 
583
  a_idx = nearest[i].item()
584
  ax.plot([proj_2d[i, 0], anch_2d[a_idx, 0]],
585
  [proj_2d[i, 1], anch_2d[a_idx, 1]],
586
+ c=CLASS_COLORS[labels[i].item()], alpha=0.1, linewidth=0.5)
587
  for c in range(N_CLASSES):
588
  mask = proj_labels[:5000] == c
589
  if mask.sum() == 0: continue
590
  ax.scatter(proj_2d[:5000][mask, 0], proj_2d[:5000][mask, 1],
591
+ c=CLASS_COLORS[c], s=5, alpha=0.4, zorder=2)
592
  ax.scatter(anch_2d[:, 0], anch_2d[:, 1],
593
+ c='#FFD700', s=80, marker='*', edgecolors='white', linewidths=0.3, zorder=5)
594
  if n_anchors <= 128:
595
  for a in range(n_anchors):
596
  a_mask = nearest == a
 
600
  fontsize=4, ha='center', va='center',
601
  color='white', fontweight='bold',
602
  bbox=dict(boxstyle='round,pad=0.1',
603
+ fc=CLASS_COLORS[dom_class],
604
+ ec='#FFD700', linewidth=0.5,
605
+ alpha=0.85))
606
+ # Grid drawn LAST
607
+ draw_sphere_grid_2d(ax, sphere_r_2d)
608
  ax.set_title(f'Triangulation: Image β†’ Nearest Anchor ({ds_name})', fontsize=11)
609
+ ax.set_aspect('equal')
610
+ ax.grid(True, alpha=0.15, color='#555577')
611
  plt.tight_layout()
612
+ save_fig('02_triangulation_connections.png')
613
  plt.close()
614
 
615
  # ── [3] 3D sphere ──
 
622
  if mask.sum() == 0: continue
623
  ax.scatter(proj_3d[:n_3d][mask, 0], proj_3d[:n_3d][mask, 1],
624
  proj_3d[:n_3d][mask, 2],
625
+ c=CLASS_COLORS[c], s=5, alpha=0.4,
626
  label=CLASS_NAMES[c] if N_CLASSES <= 20 else None)
627
  ax.scatter(anch_3d[:, 0], anch_3d[:, 1], anch_3d[:, 2],
628
+ c='#FFD700', s=40, marker='*', edgecolors='white', linewidths=0.3, zorder=5)
629
+ # Wireframe drawn AFTER data β€” 3D has no zorder, draw order is render order
630
+ draw_sphere_grid_3d(ax, sphere_r_3d)
631
  if N_CLASSES <= 20:
632
  ax.legend(fontsize=6, markerscale=2, loc='upper left', ncol=2)
633
  ax.set_title(f'3D PCA β€” Constellation on the Sphere\n'
634
  f'{n_anchors} anchors, {N_CLASSES} classes', fontsize=11)
635
+ try:
636
+ ax.set_box_aspect([1, 1, 1])
637
+ except AttributeError:
638
+ pass # older matplotlib
639
+ ax.xaxis.pane.fill = False
640
+ ax.yaxis.pane.fill = False
641
+ ax.zaxis.pane.fill = False
642
  plt.tight_layout()
643
+ save_fig('03_3d_sphere.png')
644
  plt.close()
645
 
646
  # ── [4] Anchor-Class heatmap ──
 
658
 
659
  h = max(6, N_CLASSES * 0.12)
660
  fig, ax = plt.subplots(1, 1, figsize=(16, h))
661
+ im = ax.imshow(assign_sorted.numpy(), aspect='auto', cmap='inferno')
662
  if N_CLASSES <= 30:
663
  ax.set_yticks(range(N_CLASSES))
664
  ax.set_yticklabels(CLASS_NAMES, fontsize=max(4, 9 - N_CLASSES // 15))
 
666
  ax.set_title(f'Class β†’ Anchor Assignment ({ds_name})', fontsize=11)
667
  plt.colorbar(im, ax=ax, shrink=0.8)
668
  plt.tight_layout()
669
+ save_fig('04_anchor_class_heatmap.png')
670
  plt.close()
671
 
672
  # ── [5] Triangulation profiles ──
 
697
  tag = "all classes" if N_CLASSES <= 10 else "5 worst + 5 best"
698
  plt.suptitle(f'Triangulation Fingerprints ({tag})', fontsize=12)
699
  plt.tight_layout()
700
+ save_fig('05_triangulation_profiles.png')
701
  plt.close()
702
 
703
  # ── [6] Anchor utilization ──
 
706
 
707
  sorted_counts, _ = counts.sort(descending=True)
708
  ax1.bar(range(n_anchors), sorted_counts.numpy(),
709
+ color=['#00BCD4' if c > 0 else '#FF5252' for c in sorted_counts], width=1.0)
710
  ax1.set_xlabel('Anchor (sorted)')
711
  ax1.set_ylabel('Assigned samples')
712
  ax1.set_title(f'Anchor Utilization ({n_active}/{n_anchors} active)')
713
+ ax1.axhline(y=len(labels) / n_anchors, color='#888899', linestyle='--', alpha=0.5)
714
 
715
  # Per-class anchor entropy
716
  entropies = []
 
730
  ax2.set_yticklabels(CLASS_NAMES, fontsize=8)
731
  ax2.set_xlabel('Anchor assignment entropy')
732
  else:
733
+ ax2.hist(entropies, bins=30, color='#00BCD4', edgecolor='#333355')
734
  ax2.set_xlabel('Anchor assignment entropy')
735
  ax2.set_ylabel('Number of classes')
736
 
 
740
  gini = (1 - 2 * cum.sum() / (len(c_sorted) * c_sorted.sum() + 1e-8)).item()
741
  ax2.set_title(f'Anchor Spread (Gini={gini:.3f})')
742
  plt.tight_layout()
743
+ save_fig('06_anchor_utilization.png')
744
  plt.close()
745
 
746
  # ── [7] Patchwork compartment responses ──
 
773
  class_labels_show.append(CLASS_NAMES[c])
774
  if not class_means: continue
775
  class_means = np.stack(class_means)
776
+ ax.imshow(class_means, aspect='auto', cmap='plasma')
777
  ax.set_yticks(range(len(class_labels_show)))
778
  ax.set_yticklabels(class_labels_show, fontsize=6)
779
  ax.set_title(f'Comp {k}', fontsize=9)
 
781
  axes_flat[k].set_visible(False)
782
  plt.suptitle('Patchwork Compartment Responses by Class', fontsize=12)
783
  plt.tight_layout()
784
+ save_fig('07_patchwork_compartments.png')
785
  plt.close()
786
 
787
  # ── [8] Confusion matrix ──
 
793
 
794
  if N_CLASSES <= 20:
795
  fig, ax = plt.subplots(1, 1, figsize=(8, 7))
796
+ im = ax.imshow(conf_pct.numpy(), cmap='magma', vmin=0, vmax=100)
797
  for i in range(N_CLASSES):
798
  for j in range(N_CLASSES):
799
  v = conf_pct[i, j].item()
800
  ax.text(j, i, f'{v:.0f}', ha='center', va='center',
801
  fontsize=max(4, 8 - N_CLASSES // 5),
802
+ color='black' if v > 60 else '#e0e0e0')
803
  ax.set_xticks(range(N_CLASSES))
804
  ax.set_yticks(range(N_CLASSES))
805
  ax.set_xticklabels(CLASS_NAMES, rotation=45, ha='right', fontsize=7)
806
  ax.set_yticklabels(CLASS_NAMES, fontsize=7)
807
  else:
808
  fig, ax = plt.subplots(1, 1, figsize=(14, 12))
809
+ im = ax.imshow(conf_pct.numpy(), cmap='magma', vmin=0, vmax=100)
810
  ax.set_xlabel('Predicted class')
811
  ax.set_ylabel('True class')
812
  ax.set_title(f'Confusion Matrix β€” {val_acc:.1f}% ({ds_name})', fontsize=11)
813
  plt.colorbar(im, ax=ax, shrink=0.8)
814
  plt.tight_layout()
815
+ save_fig('08_confusion_matrix.png')
816
  plt.close()
817
 
818
  print(f"\n βœ“ All 8 visualizations saved to {OUT_DIR}/")
 
855
 
856
  print(f"\n{'='*70}")
857
  print("ANALYSIS COMPLETE")
858
+ print(f"{'='*70}")
859
+
860
+ # ══════════════════════════════════════════════════════════════════
861
+ # PUSH IMAGES TO HUGGINGFACE
862
+ # ══════════════════════════════════════════════════════════════════
863
+
864
+ if HF_PUSH:
865
+ from huggingface_hub import upload_folder
866
+ print(f"\n Uploading {OUT_DIR}/ β†’ {HF_REPO_ID}/analysis/ ...")
867
+ upload_folder(
868
+ repo_id=HF_REPO_ID,
869
+ folder_path=OUT_DIR,
870
+ path_in_repo="analysis",
871
+ commit_message=f"Analysis: val={val_acc:.1f}% CV={v_cv:.4f} {n_active}/{n_anchors} anchors",
872
+ )
873
+ print(f" βœ“ Done: https://huggingface.co/{HF_REPO_ID}/tree/main/analysis")