Update analyze_weights.py
Browse files- 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 |
-
|
| 333 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 357 |
ax.scatter(anch_2d[:, 0], anch_2d[:, 1],
|
| 358 |
-
c='
|
|
|
|
|
|
|
| 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.
|
|
|
|
| 366 |
plt.tight_layout()
|
| 367 |
-
|
| 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.
|
| 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=
|
| 384 |
ax.scatter(anch_2d[:, 0], anch_2d[:, 1],
|
| 385 |
-
c='
|
| 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],
|
|
|
|
|
|
|
|
|
|
|
|
|
| 396 |
ax.set_title(f'Triangulation: Image β Nearest Anchor ({ds_name})', fontsize=11)
|
| 397 |
-
ax.
|
|
|
|
| 398 |
plt.tight_layout()
|
| 399 |
-
|
| 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=
|
| 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='
|
|
|
|
|
|
|
| 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 |
-
|
| 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='
|
| 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 |
-
|
| 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 |
-
|
| 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=['#
|
| 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='
|
| 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='
|
| 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 |
-
|
| 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='
|
| 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 |
-
|
| 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='
|
| 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='
|
| 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='
|
| 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 |
-
|
| 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")
|