AbstractPhil commited on
Commit
51db555
Β·
verified Β·
1 Parent(s): f19fefa

Create experiment_3_dual_teacher_autograd.py

Browse files
experiment_2/experiment_3_dual_teacher_autograd.py ADDED
@@ -0,0 +1,689 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================================
2
+ # DUAL-TEACHER PROCRUSTES CONSENSUS DISTILLATION
3
+ #
4
+ # Teacher A: Raw Adam (0.626) β€” learned without geometric guidance
5
+ # Teacher B: Geometric (+spr+ort, 0.645) β€” learned with manifold control
6
+ #
7
+ # Both teachers encode the full training set.
8
+ # Procrustes alignment finds the shared geometric center.
9
+ # Student distills the consensus + trains with entropy config.
10
+ #
11
+ # Same pipeline as CaptionBERT 5-expert consensus, simplified to 2.
12
+ # ============================================================================
13
+
14
+ import math
15
+ import numpy as np
16
+ import torch
17
+ import torch.nn as nn
18
+ import torch.nn.functional as F
19
+
20
+ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
21
+
22
+ print("=" * 65)
23
+ print("DUAL-TEACHER PROCRUSTES CONSENSUS DISTILLATION")
24
+ print("=" * 65)
25
+ print(f" Device: {DEVICE}")
26
+
27
+
28
+ # ══════════════════════════════════════════════════════════════════
29
+ # GEOMETRIC PRIMITIVES
30
+ # ══════════════════════════════════════════════════════════════════
31
+
32
+ def tangential_projection(grad, embedding):
33
+ emb_n = F.normalize(embedding.detach().float(), dim=-1)
34
+ grad_f = grad.float()
35
+ radial = (grad_f * emb_n).sum(dim=-1, keepdim=True) * emb_n
36
+ return (grad_f - radial).to(grad.dtype), radial.to(grad.dtype)
37
+
38
+ def cayley_menger_vol2(pts):
39
+ pts = pts.float()
40
+ diff = pts.unsqueeze(-2) - pts.unsqueeze(-3)
41
+ d2 = (diff * diff).sum(-1)
42
+ B, V, _ = d2.shape
43
+ cm = torch.zeros(B, V+1, V+1, device=d2.device, dtype=torch.float32)
44
+ cm[:, 0, 1:] = 1; cm[:, 1:, 0] = 1; cm[:, 1:, 1:] = d2
45
+ s = (-1.0)**V; f = math.factorial(V-1)
46
+ return s / ((2.0**(V-1)) * f*f) * torch.linalg.det(cm)
47
+
48
+ def cv_loss(emb, target=0.2, n_samples=16):
49
+ B = emb.shape[0]
50
+ if B < 5: return torch.tensor(0.0, device=emb.device)
51
+ vols = []
52
+ for _ in range(n_samples):
53
+ idx = torch.randperm(B, device=emb.device)[:5]
54
+ v2 = cayley_menger_vol2(emb[idx].unsqueeze(0))
55
+ vols.append(torch.sqrt(F.relu(v2[0]) + 1e-12))
56
+ stacked = torch.stack(vols)
57
+ cv = stacked.std() / (stacked.mean() + 1e-8)
58
+ return (cv - target).abs()
59
+
60
+ @torch.no_grad()
61
+ def cv_metric(emb, n_samples=200):
62
+ B = emb.shape[0]
63
+ if B < 5: return 0.0
64
+ emb_f = emb.detach().float()
65
+ vols = []
66
+ for _ in range(n_samples):
67
+ idx = torch.randperm(B, device=emb.device)[:5]
68
+ v2 = cayley_menger_vol2(emb_f[idx].unsqueeze(0))
69
+ v = torch.sqrt(F.relu(v2[0]) + 1e-12).item()
70
+ if v > 0: vols.append(v)
71
+ if len(vols) < 10: return 0.0
72
+ a = torch.tensor(vols)
73
+ return float(a.std() / (a.mean() + 1e-8))
74
+
75
+ def anchor_spread_loss(anchors):
76
+ a_n = F.normalize(anchors, dim=-1)
77
+ sim = a_n @ a_n.T - torch.diag(torch.ones(anchors.shape[0], device=anchors.device))
78
+ return sim.pow(2).mean()
79
+
80
+ def anchor_entropy_loss(emb, anchors, sharpness=10.0):
81
+ a_n = F.normalize(anchors, dim=-1)
82
+ probs = F.softmax(emb @ a_n.T * sharpness, dim=-1)
83
+ return -(probs * (probs + 1e-12).log()).sum(-1).mean()
84
+
85
+ def anchor_ortho_loss(anchors):
86
+ a_n = F.normalize(anchors, dim=-1)
87
+ gram = a_n @ a_n.T
88
+ N = anchors.shape[0]
89
+ mask = ~torch.eye(N, dtype=bool, device=anchors.device)
90
+ return gram[mask].pow(2).mean()
91
+
92
+ def infonce(a, b, temperature=0.07):
93
+ a = F.normalize(a, dim=-1)
94
+ b = F.normalize(b, dim=-1)
95
+ logits = (a @ b.T) / temperature
96
+ labels = torch.arange(logits.shape[0], device=logits.device)
97
+ loss = (F.cross_entropy(logits, labels) + F.cross_entropy(logits.T, labels)) / 2
98
+ with torch.no_grad():
99
+ acc = (logits.argmax(-1) == labels).float().mean().item()
100
+ return loss, acc
101
+
102
+
103
+ # ── Backward filtering ──
104
+
105
+ class EmbeddingAutograd(torch.autograd.Function):
106
+ @staticmethod
107
+ def forward(ctx, x, embedding, anchors, tang, sep):
108
+ ctx.save_for_backward(embedding, anchors)
109
+ ctx.tang = tang; ctx.sep = sep
110
+ return x
111
+
112
+ @staticmethod
113
+ def backward(ctx, grad_output):
114
+ embedding, anchors = ctx.saved_tensors
115
+ emb_n = F.normalize(embedding.detach().float(), dim=-1)
116
+ anchors_n = F.normalize(anchors.detach().float(), dim=-1)
117
+ grad_f = grad_output.float()
118
+ tang_grad, norm_grad = tangential_projection(grad_f, emb_n)
119
+ corrected = tang_grad + (1.0 - ctx.tang) * norm_grad
120
+ if ctx.sep > 0:
121
+ cos_to = emb_n @ anchors_n.T
122
+ nearest = anchors_n[cos_to.argmax(dim=-1)]
123
+ toward = (corrected * nearest).sum(dim=-1, keepdim=True)
124
+ collapse = toward * nearest
125
+ corrected = corrected - ctx.sep * (toward > 0).float() * collapse
126
+ return corrected.to(grad_output.dtype), None, None, None, None
127
+
128
+
129
+ # ════════════��═════════════════════════════════════════════════════
130
+ # PROCRUSTES ALIGNMENT (production, from cotrain_bank.py)
131
+ # ══════════════════════════════════════════════════════════════════
132
+
133
+ def symmetric_inv_sqrt(cov, eps=1e-6):
134
+ evals, evecs = torch.linalg.eigh(cov)
135
+ evals = torch.clamp(evals, min=eps)
136
+ return evecs @ torch.diag(evals.rsqrt()) @ evecs.T
137
+
138
+ def procrustes_align(source, target, n_align=10000):
139
+ N = min(n_align, source.shape[0], target.shape[0])
140
+ S = source[:N].float(); T = target[:N].float()
141
+ s_mean = S.mean(0, keepdim=True); t_mean = T.mean(0, keepdim=True)
142
+ Sc = S - s_mean; Tc = T - t_mean; Ns = Sc.shape[0]
143
+ s_cov = (Sc.T @ Sc) / max(Ns - 1, 1)
144
+ t_cov = (Tc.T @ Tc) / max(Ns - 1, 1)
145
+ s_whiten = symmetric_inv_sqrt(s_cov)
146
+ t_whiten = symmetric_inv_sqrt(t_cov)
147
+ Sc_w = F.normalize(Sc @ s_whiten, dim=-1)
148
+ Tc_w = F.normalize(Tc @ t_whiten, dim=-1)
149
+ U, _, Vt = torch.linalg.svd(Tc_w.T @ Sc_w, full_matrices=False)
150
+ R = U @ Vt
151
+ cos_after = F.cosine_similarity(Sc_w @ R.T, Tc_w, dim=-1).mean().item()
152
+ return {"rotation": R, "source_mean": s_mean.squeeze(0),
153
+ "source_whitener": s_whiten, "cos_after": cos_after}
154
+
155
+ def apply_align(emb, info):
156
+ x = emb.float() - info["source_mean"]
157
+ x = x @ info["source_whitener"]
158
+ x = x @ info["rotation"].T
159
+ return x
160
+
161
+
162
+ # ══════════════════════════════════════════════════════════════════
163
+ # MODEL
164
+ # ══════════════════════════════════════════════════════════════════
165
+
166
+ class Constellation(nn.Module):
167
+ def __init__(self, n_anchors=30, d_embed=768, init_anchors=None):
168
+ super().__init__()
169
+ self.n_anchors = n_anchors
170
+ if init_anchors is not None:
171
+ self.anchors = nn.Parameter(init_anchors.clone())
172
+ else:
173
+ self.anchors = nn.Parameter(F.normalize(torch.randn(n_anchors, d_embed), dim=-1))
174
+ self.register_buffer("rigidity", torch.zeros(n_anchors))
175
+ self.register_buffer("visit_count", torch.zeros(n_anchors))
176
+
177
+ def triangulate(self, emb):
178
+ anchors_n = F.normalize(self.anchors, dim=-1)
179
+ cos_sim = emb @ anchors_n.T
180
+ return 1.0 - cos_sim, cos_sim.argmax(dim=-1)
181
+
182
+ @torch.no_grad()
183
+ def update_rigidity(self, tri_dist):
184
+ nearest = tri_dist.argmin(dim=-1)
185
+ for i in range(self.n_anchors):
186
+ mask = nearest == i
187
+ if mask.sum() < 5: continue
188
+ self.visit_count[i] += mask.sum().float()
189
+ spread = tri_dist[mask].std(dim=0).mean()
190
+ alpha = min(0.1, 10.0 / (self.visit_count[i] + 1))
191
+ self.rigidity[i] = (1 - alpha) * self.rigidity[i] + alpha / (spread + 0.01)
192
+
193
+ @torch.no_grad()
194
+ def geometry_snapshot(self):
195
+ a_n = F.normalize(self.anchors.detach(), dim=-1)
196
+ cos = a_n @ a_n.T
197
+ mask = ~torch.eye(self.n_anchors, dtype=bool, device=a_n.device)
198
+ return {"anchors": a_n.clone(), "rigidity": self.rigidity.clone(),
199
+ "mean_cos": cos[mask].mean().item(), "std_cos": cos[mask].std().item()}
200
+
201
+
202
+ class Patchwork(nn.Module):
203
+ def __init__(self, n_anchors=30, n_compartments=6, d_comp=64):
204
+ super().__init__()
205
+ self.n_compartments = n_compartments
206
+ assignments = torch.arange(n_anchors) % n_compartments
207
+ self.register_buffer("assignments", assignments)
208
+ self.compartments = nn.ModuleList()
209
+ for k in range(n_compartments):
210
+ n_k = (assignments == k).sum().item()
211
+ self.compartments.append(nn.Sequential(
212
+ nn.Linear(n_k, d_comp * 2), nn.GELU(),
213
+ nn.Linear(d_comp * 2, d_comp), nn.LayerNorm(d_comp)))
214
+
215
+ def forward(self, tri_dist):
216
+ parts = []
217
+ for k in range(self.n_compartments):
218
+ parts.append(self.compartments[k](tri_dist[:, self.assignments == k]))
219
+ return torch.cat(parts, dim=-1)
220
+
221
+
222
+ class PatchworkClassifier(nn.Module):
223
+ def __init__(self, n_classes=30, n_anchors=30, d_embed=768,
224
+ n_compartments=6, d_comp=64, d_hidden=256, init_anchors=None):
225
+ super().__init__()
226
+ self.backbone = nn.Sequential(
227
+ nn.Conv2d(1, 32, 3, padding=1), nn.GELU(), nn.MaxPool2d(2),
228
+ nn.Conv2d(32, 64, 3, padding=1), nn.GELU(), nn.MaxPool2d(2),
229
+ nn.Conv2d(64, 128, 3, padding=1), nn.GELU(), nn.AdaptiveAvgPool2d(1))
230
+ self.embed_proj = nn.Sequential(nn.Linear(128, d_embed), nn.LayerNorm(d_embed))
231
+ self.constellation = Constellation(n_anchors, d_embed, init_anchors)
232
+ self.patchwork = Patchwork(n_anchors, n_compartments, d_comp)
233
+ pw_dim = n_compartments * d_comp
234
+ self.mlp = nn.Sequential(
235
+ nn.Linear(pw_dim, d_hidden), nn.GELU(), nn.LayerNorm(d_hidden),
236
+ nn.Linear(d_hidden, d_hidden), nn.GELU(), nn.LayerNorm(d_hidden),
237
+ nn.Linear(d_hidden, n_classes))
238
+
239
+ def forward(self, x):
240
+ feat = self.backbone(x).flatten(1)
241
+ emb = F.normalize(self.embed_proj(feat), dim=-1)
242
+ tri, nearest = self.constellation.triangulate(emb)
243
+ return self.mlp(self.patchwork(tri)), emb, tri, nearest
244
+
245
+ def encode(self, x):
246
+ feat = self.backbone(x).flatten(1)
247
+ return F.normalize(self.embed_proj(feat), dim=-1)
248
+
249
+
250
+ # ══════════════════════════════════════════════════════════════════
251
+ # SHAPE RENDERERS
252
+ # ══════════════════════════════════════════════════════════════════
253
+
254
+ def _d(img,x0,y0,x1,y1,t=1):
255
+ n=max(int(max(abs(x1-x0),abs(y1-y0))*2),1);sz=img.shape[0]
256
+ for s in np.linspace(0,1,n):
257
+ px,py=int(x0+s*(x1-x0)),int(y0+s*(y1-y0))
258
+ for dx in range(-t,t+1):
259
+ for dy in range(-t,t+1):
260
+ nx,ny=px+dx,py+dy
261
+ if 0<=nx<sz and 0<=ny<sz: img[ny,nx]=1.0
262
+
263
+ def rpoly(nv,sz=32,p=0.15):
264
+ img=np.zeros((sz,sz),dtype=np.float32);cx,cy,r=sz/2,sz/2,sz*0.35
265
+ a=np.linspace(0,2*np.pi,nv,endpoint=False)+np.random.uniform(0,2*np.pi)
266
+ ri=r*(1+np.random.normal(0,p,nv))
267
+ pts=[(cx+ri[i]*np.cos(a[i]),cy+ri[i]*np.sin(a[i])) for i in range(nv)]
268
+ for i in range(nv): _d(img,*pts[i],*pts[(i+1)%nv])
269
+ return img
270
+
271
+ def rstar(np_,sz=32,p=0.12):
272
+ img=np.zeros((sz,sz),dtype=np.float32);cx,cy=sz/2,sz/2;ro,ri_=sz*0.38,sz*0.15
273
+ a=np.linspace(0,2*np.pi,np_*2,endpoint=False)+np.random.uniform(0,2*np.pi)
274
+ pts=[(cx+(ro if i%2==0 else ri_)*(1+np.random.normal(0,p))*np.cos(a[i]),
275
+ cy+(ro if i%2==0 else ri_)*(1+np.random.normal(0,p))*np.sin(a[i])) for i in range(len(a))]
276
+ for i in range(len(pts)): _d(img,*pts[i],*pts[(i+1)%len(pts)])
277
+ return img
278
+
279
+ def rcross(sz=32,p=0.15):
280
+ img=np.zeros((sz,sz),dtype=np.float32);cx,cy,arm=sz/2,sz/2,sz*0.3
281
+ for ab in [0,np.pi/2,np.pi,3*np.pi/2]:
282
+ a=ab+np.random.normal(0,p*0.3);r=arm*(1+np.random.normal(0,p))
283
+ _d(img,cx,cy,cx+r*np.cos(a),cy+r*np.sin(a),2)
284
+ return img
285
+
286
+ def rspiral(sz=32,p=0.1):
287
+ img=np.zeros((sz,sz),dtype=np.float32);cx,cy=sz/2,sz/2
288
+ for t in np.linspace(0,5*np.pi,200):
289
+ r=sz*0.015*t*(1+np.random.normal(0,p*0.3));x,y=int(cx+r*np.cos(t)),int(cy+r*np.sin(t))
290
+ if 0<=x<sz and 0<=y<sz: img[y,x]=1.0
291
+ return img
292
+
293
+ def rwave(sz=32,p=0.1):
294
+ img=np.zeros((sz,sz),dtype=np.float32);f=2+np.random.normal(0,0.3);amp=sz*0.15*(1+np.random.normal(0,p))
295
+ for x in range(sz):
296
+ y=int(sz/2+amp*np.sin(2*np.pi*f*x/sz))
297
+ if 0<=y<sz: img[y,x]=1.0
298
+ return img
299
+
300
+ def rheart(sz=32,p=0.1):
301
+ img=np.zeros((sz,sz),dtype=np.float32);cx,cy=sz/2,sz*0.45;s=sz*0.017*(1+np.random.normal(0,p))
302
+ for t in np.linspace(0,2*np.pi,300):
303
+ x=16*np.sin(t)**3;y=-(13*np.cos(t)-5*np.cos(2*t)-2*np.cos(3*t)-np.cos(4*t))
304
+ ix,iy=int(cx+x*s),int(cy+y*s)
305
+ if 0<=ix<sz and 0<=iy<sz: img[iy,ix]=1.0
306
+ return img
307
+
308
+ def rcrescent(sz=32,p=0.1):
309
+ img=np.zeros((sz,sz),dtype=np.float32);cx,cy,r=sz/2,sz/2,sz*0.35;r2=r*0.7;off=r*0.3
310
+ for a in np.linspace(0,2*np.pi,300):
311
+ x1,y1=cx+r*np.cos(a),cy+r*np.sin(a)
312
+ if math.sqrt((x1-cx-off)**2+(y1-cy)**2)>=r2*0.9:
313
+ ix,iy=int(x1),int(y1)
314
+ if 0<=ix<sz and 0<=iy<sz: img[iy,ix]=1.0
315
+ return img
316
+
317
+ def rellipse(sz=32,p=0.1):
318
+ img=np.zeros((sz,sz),dtype=np.float32);cx,cy=sz/2,sz/2
319
+ a,b=sz*0.38*(1+np.random.normal(0,p)),sz*0.22*(1+np.random.normal(0,p));rot=np.random.uniform(0,np.pi)
320
+ for t in np.linspace(0,2*np.pi,200):
321
+ x,y=a*np.cos(t),b*np.sin(t);ix,iy=int(cx+x*np.cos(rot)-y*np.sin(rot)),int(cy+x*np.sin(rot)+y*np.cos(rot))
322
+ if 0<=ix<sz and 0<=iy<sz: img[iy,ix]=1.0
323
+ return img
324
+
325
+ def rring(sz=32,p=0.1):
326
+ img=np.zeros((sz,sz),dtype=np.float32);cx,cy=sz/2,sz/2
327
+ r1,r2=sz*0.35*(1+np.random.normal(0,p)),sz*0.22*(1+np.random.normal(0,p))
328
+ for a in np.linspace(0,2*np.pi,300):
329
+ for r in [r1,r2]:
330
+ x,y=int(cx+r*np.cos(a)),int(cy+r*np.sin(a))
331
+ if 0<=x<sz and 0<=y<sz: img[y,x]=1.0
332
+ return img
333
+
334
+ def rarrow(sz=32,p=0.12):
335
+ img=np.zeros((sz,sz),dtype=np.float32);cx,cy=sz/2,sz/2
336
+ l=sz*0.35*(1+np.random.normal(0,p));h=l*0.35;a=np.random.uniform(0,2*np.pi)
337
+ x1,y1=cx-l*np.cos(a),cy-l*np.sin(a);x2,y2=cx+l*np.cos(a),cy+l*np.sin(a)
338
+ _d(img,x1,y1,x2,y2)
339
+ for da in [0.7,-0.7]: _d(img,x2,y2,x2-h*np.cos(a+da),y2-h*np.sin(a+da))
340
+ return img
341
+
342
+ def rchevron(sz=32,p=0.12):
343
+ img=np.zeros((sz,sz),dtype=np.float32);cx,cy=sz/2,sz/2
344
+ w,h=sz*0.3*(1+np.random.normal(0,p)),sz*0.25*(1+np.random.normal(0,p))
345
+ _d(img,cx-w,cy+h,cx,cy-h);_d(img,cx,cy-h,cx+w,cy+h)
346
+ return img
347
+
348
+ def rsemicirc(sz=32,p=0.1):
349
+ img=np.zeros((sz,sz),dtype=np.float32);cx,cy,r=sz/2,sz*0.6,sz*0.35
350
+ for a in np.linspace(np.pi,2*np.pi,150):
351
+ x,y=int(cx+r*np.cos(a)),int(cy+r*np.sin(a))
352
+ if 0<=x<sz and 0<=y<sz: img[y,x]=1.0
353
+ _d(img,cx-r,cy,cx+r,cy)
354
+ return img
355
+
356
+ def gen_one(c,sz=32):
357
+ if c==0: return rpoly(3,sz,0.20)
358
+ if c==1: return rpoly(4,sz,0.12)
359
+ if c==2: return rpoly(5,sz,0.15)
360
+ if c==3: return rpoly(6,sz,0.10)
361
+ if c==4: return rpoly(7,sz,0.10)
362
+ if c==5: return rpoly(8,sz,0.08)
363
+ if c==6: return rpoly(9,sz,0.08)
364
+ if c==7: return rpoly(10,sz,0.07)
365
+ if c==8: return rpoly(12,sz,0.06)
366
+ if c==9: return rpoly(32,sz,0.03)
367
+ if c==10: return rellipse(sz)
368
+ if c==11: return rspiral(sz)
369
+ if c==12: return rwave(sz)
370
+ if c==13: return rcrescent(sz)
371
+ if c==14: return rstar(3,sz)
372
+ if c==15: return rstar(4,sz)
373
+ if c==16: return rstar(5,sz)
374
+ if c==17: return rstar(6,sz)
375
+ if c==18: return rstar(7,sz)
376
+ if c==19: return rstar(8,sz)
377
+ if c==20: return rcross(sz)
378
+ if c==21: return rpoly(4,sz,0.10)
379
+ if c==22: return rarrow(sz)
380
+ if c==23: return rheart(sz)
381
+ if c==24: return rring(sz)
382
+ if c==25: return rsemicirc(sz)
383
+ if c==26: return rpoly(4,sz,0.15)
384
+ if c==27: return rpoly(4,sz,0.18)
385
+ if c==28: return rpoly(4,sz,0.10)
386
+ if c==29: return rchevron(sz)
387
+ return rpoly(3,sz)
388
+
389
+ def gen_data(n_per=500, sz=32):
390
+ imgs, labels = [], []
391
+ for _ in range(n_per):
392
+ for c in range(30):
393
+ imgs.append(gen_one(c, sz)); labels.append(c)
394
+ imgs = torch.tensor(np.array(imgs)).unsqueeze(1)
395
+ labels = torch.tensor(labels, dtype=torch.long)
396
+ perm = torch.randperm(len(labels))
397
+ return imgs[perm], labels[perm]
398
+
399
+
400
+ # ══════════════════════════════════════════════════════════════════
401
+ # GENERATE SHARED DATA
402
+ # ══════════════════════════════════════════════════════════════════
403
+
404
+ print(f"\n Generating data...")
405
+ torch.manual_seed(42); np.random.seed(42)
406
+ train_imgs, train_labels = gen_data(n_per=500)
407
+ val_imgs, val_labels = gen_data(n_per=100)
408
+ train_imgs, train_labels = train_imgs.to(DEVICE), train_labels.to(DEVICE)
409
+ val_imgs, val_labels = val_imgs.to(DEVICE), val_labels.to(DEVICE)
410
+ n_train, n_val = len(train_labels), len(val_labels)
411
+ print(f" Train: {n_train:,} Val: {n_val:,}")
412
+
413
+
414
+ # ══════════════════════════════════════════════════════════════════
415
+ # TRAIN TEACHER HELPER
416
+ # ══════════════════════════════════════════════════════════════════
417
+
418
+ def train_teacher(model, tag, use_autograd=True,
419
+ tang=0.01, sep=1.0, cv_w=0.001,
420
+ w_spread=0.0, w_ortho=0.0, w_entropy=0.0,
421
+ epochs=30):
422
+ optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
423
+ BATCH = 256
424
+ for epoch in range(epochs):
425
+ model.train()
426
+ perm = torch.randperm(n_train, device=DEVICE)
427
+ total_correct, n = 0, 0
428
+ for i in range(0, n_train, BATCH):
429
+ idx = perm[i:i+BATCH]
430
+ if len(idx) < 4: continue
431
+ logits, emb, tri, nearest = model(train_imgs[idx])
432
+ labels = train_labels[idx]
433
+ anchors = model.constellation.anchors
434
+ if use_autograd and (tang > 0 or sep > 0):
435
+ emb_g = EmbeddingAutograd.apply(emb, emb, anchors, tang, sep)
436
+ tri_g, _ = model.constellation.triangulate(emb_g)
437
+ logits = model.mlp(model.patchwork(tri_g))
438
+ l_cls = F.cross_entropy(logits, labels)
439
+ l_geo = torch.tensor(0.0, device=DEVICE)
440
+ if cv_w > 0: l_geo = l_geo + cv_w * cv_loss(emb)
441
+ if w_spread > 0: l_geo = l_geo + w_spread * anchor_spread_loss(anchors)
442
+ if w_ortho > 0: l_geo = l_geo + w_ortho * anchor_ortho_loss(anchors)
443
+ if w_entropy > 0: l_geo = l_geo + w_entropy * anchor_entropy_loss(emb, anchors)
444
+ (l_cls + l_geo).backward()
445
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
446
+ optimizer.step(); optimizer.zero_grad(set_to_none=True)
447
+ model.constellation.update_rigidity(tri.detach())
448
+ total_correct += (logits.argmax(-1) == labels).sum().item()
449
+ n += 1
450
+ model.eval()
451
+ with torch.no_grad():
452
+ vl, ve, _, _ = model(val_imgs)
453
+ v_acc = (vl.argmax(-1) == val_labels).float().mean().item()
454
+ v_cv = cv_metric(ve)
455
+ if (epoch+1) % 10 == 0 or epoch == 0:
456
+ print(f" {tag} E{epoch+1:2d}: t={total_correct/n_train:.3f} "
457
+ f"v={v_acc:.3f} cv={v_cv:.4f}")
458
+ return v_acc
459
+
460
+
461
+ # ══════════════════════════════════════════════════════════════════
462
+ # STAGE 1: TRAIN BOTH TEACHERS
463
+ # ══════════════════════════════════════════════════════════════════
464
+
465
+ print(f"\n{'='*65}")
466
+ print("STAGE 1A: TEACHER A β€” Raw Adam")
467
+ print(f"{'='*65}")
468
+ torch.manual_seed(42)
469
+ teacher_a = PatchworkClassifier(n_classes=30, n_anchors=30, d_embed=768).to(DEVICE)
470
+ va_a = train_teacher(teacher_a, "[A]", use_autograd=False, epochs=30)
471
+
472
+ print(f"\n{'='*65}")
473
+ print("STAGE 1B: TEACHER B β€” Geometric (+spr+ort)")
474
+ print(f"{'='*65}")
475
+ torch.manual_seed(42)
476
+ teacher_b = PatchworkClassifier(n_classes=30, n_anchors=30, d_embed=768).to(DEVICE)
477
+ va_b = train_teacher(teacher_b, "[B]", use_autograd=True,
478
+ tang=0.01, sep=1.0, cv_w=0.001,
479
+ w_spread=1e-3, w_ortho=1e-3, epochs=30)
480
+
481
+
482
+ # ══════════════════════════════════════════════════════════════════
483
+ # STAGE 2: EXTRACT EMBEDDINGS + GPA ALIGNMENT
484
+ # ══════════════════════════════════════════════════════════════════
485
+
486
+ print(f"\n{'='*65}")
487
+ print("STAGE 2: EXTRACT + PROCRUSTES ALIGN")
488
+ print(f"{'='*65}")
489
+
490
+ teacher_a.eval(); teacher_b.eval()
491
+ with torch.no_grad():
492
+ emb_a = teacher_a.encode(train_imgs) # (N, 768)
493
+ emb_b = teacher_b.encode(train_imgs) # (N, 768)
494
+ val_emb_a = teacher_a.encode(val_imgs)
495
+ val_emb_b = teacher_b.encode(val_imgs)
496
+
497
+ print(f" Teacher A embeddings: {emb_a.shape}")
498
+ print(f" Teacher B embeddings: {emb_b.shape}")
499
+ print(f" Raw cos(A, B): {F.cosine_similarity(emb_a[:1000], emb_b[:1000], dim=-1).mean():.4f}")
500
+
501
+ # GPA: iterative Procrustes to find geometric center
502
+ current = {"a": emb_a.float(), "b": emb_b.float()}
503
+ for gpa_iter in range(10):
504
+ mean_shape = (current["a"] + current["b"]) / 2
505
+ total_delta = 0.0
506
+ new_current = {}
507
+ for name in ["a", "b"]:
508
+ info = procrustes_align(current[name], mean_shape)
509
+ new_current[name] = apply_align(current[name], info)
510
+ total_delta += (new_current[name] - current[name]).pow(2).mean().item()
511
+ current = new_current
512
+ if (gpa_iter + 1) % 5 == 0 or gpa_iter == 0:
513
+ print(f" GPA iter {gpa_iter+1}: delta={total_delta:.8f}")
514
+ if total_delta < 1e-8:
515
+ print(f" Converged at iteration {gpa_iter+1}")
516
+ break
517
+
518
+ # Build consensus
519
+ mean_shape = (current["a"] + current["b"]) / 2
520
+ consensus = F.normalize(mean_shape, dim=-1)
521
+
522
+ # Align val embeddings too
523
+ val_aligned = {}
524
+ for name, emb_full in [("a", emb_a), ("b", emb_b)]:
525
+ info = procrustes_align(emb_full, mean_shape)
526
+ val_aligned[name] = apply_align(
527
+ val_emb_a if name == "a" else val_emb_b, info)
528
+
529
+ val_consensus = F.normalize((val_aligned["a"] + val_aligned["b"]) / 2, dim=-1)
530
+
531
+ # Check alignment quality
532
+ for name in ["a", "b"]:
533
+ aligned = current[name] if name == "a" else current["b"]
534
+ cos = F.cosine_similarity(consensus[:1000], F.normalize(aligned[:1000], dim=-1), dim=-1).mean()
535
+ print(f" cos(consensus, {name}): {cos:.4f}")
536
+
537
+ consensus_cv = cv_metric(consensus[:2000])
538
+ print(f" Consensus CV: {consensus_cv:.4f}")
539
+
540
+ # Extract consensus anchors: cluster consensus embeddings
541
+ # Use the per-class centroids as initial anchor positions
542
+ anchor_centroids = []
543
+ for c in range(30):
544
+ mask = train_labels == c
545
+ if mask.sum() > 0:
546
+ anchor_centroids.append(consensus[mask].mean(dim=0))
547
+ else:
548
+ anchor_centroids.append(torch.randn(768, device=DEVICE))
549
+ consensus_anchors = F.normalize(torch.stack(anchor_centroids), dim=-1)
550
+ print(f" Consensus anchors: {consensus_anchors.shape}")
551
+
552
+ # Also keep the pure geometric center from teacher anchors
553
+ geo_a = teacher_a.constellation.geometry_snapshot()
554
+ geo_b = teacher_b.constellation.geometry_snapshot()
555
+ print(f" Teacher A anchors cos: {geo_a['mean_cos']:.4f}")
556
+ print(f" Teacher B anchors cos: {geo_b['mean_cos']:.4f}")
557
+
558
+
559
+ # ══════════════════════════════════════════════════════════════════
560
+ # STAGE 3: STUDENT DISTILLATION
561
+ # ══════════════════════════════════════════════════════════════════
562
+
563
+ print(f"\n{'='*65}")
564
+ print("STAGE 3: STUDENT β€” Consensus distillation + classification")
565
+ print(f"{'='*65}")
566
+
567
+ torch.manual_seed(42)
568
+ model_student = PatchworkClassifier(
569
+ n_classes=30, n_anchors=30, d_embed=768,
570
+ init_anchors=consensus_anchors,
571
+ ).to(DEVICE)
572
+
573
+ optimizer = torch.optim.Adam(model_student.parameters(), lr=1e-3)
574
+ BATCH = 256
575
+ EPOCHS = 30
576
+
577
+ # Move consensus targets to device
578
+ train_targets = consensus.to(DEVICE) # (N, 768) L2-normalized
579
+ val_targets = val_consensus.to(DEVICE)
580
+
581
+ for epoch in range(EPOCHS):
582
+ model_student.train()
583
+ perm = torch.randperm(n_train, device=DEVICE)
584
+ total_loss, total_correct, n = 0, 0, 0
585
+
586
+ for i in range(0, n_train, BATCH):
587
+ idx = perm[i:i+BATCH]
588
+ if len(idx) < 4: continue
589
+
590
+ logits, emb, tri, nearest = model_student(train_imgs[idx])
591
+ labels = train_labels[idx]
592
+ tgt = train_targets[idx]
593
+ anchors = model_student.constellation.anchors
594
+
595
+ # Apply geometric autograd
596
+ emb_g = EmbeddingAutograd.apply(emb, emb, anchors, 0.01, 1.0)
597
+ tri_g, _ = model_student.constellation.triangulate(emb_g)
598
+ logits = model_student.mlp(model_student.patchwork(tri_g))
599
+
600
+ # Three losses:
601
+ # 1. Classification (task)
602
+ l_cls = F.cross_entropy(logits, labels)
603
+
604
+ # 2. Consensus distillation (InfoNCE + MSE)
605
+ l_nce, nce_acc = infonce(emb, tgt)
606
+ l_mse = F.mse_loss(emb, tgt)
607
+
608
+ # 3. Geometric (micro CV + entropy)
609
+ l_cv = cv_loss(emb, target=0.2)
610
+ l_ent = anchor_entropy_loss(emb, anchors)
611
+
612
+ loss = l_cls + 0.5 * l_nce + 0.5 * l_mse + 0.001 * l_cv + 1e-4 * l_ent
613
+
614
+ loss.backward()
615
+ torch.nn.utils.clip_grad_norm_(model_student.parameters(), 1.0)
616
+ optimizer.step(); optimizer.zero_grad(set_to_none=True)
617
+
618
+ model_student.constellation.update_rigidity(tri.detach())
619
+ total_correct += (logits.argmax(-1) == labels).sum().item()
620
+ total_loss += loss.item()
621
+ n += 1
622
+
623
+ train_acc = total_correct / n_train
624
+
625
+ # Validation
626
+ model_student.eval()
627
+ with torch.no_grad():
628
+ vl, ve, _, _ = model_student(val_imgs)
629
+ v_acc = (vl.argmax(-1) == val_labels).float().mean().item()
630
+ v_cv = cv_metric(ve)
631
+ v_cos = F.cosine_similarity(ve, val_targets, dim=-1).mean().item()
632
+
633
+ types = {"polygon": list(range(9)), "curve": list(range(9,14)),
634
+ "star": list(range(14,20)), "structure": list(range(20,30))}
635
+ ta = {}
636
+ for tname, tids in types.items():
637
+ tmask = torch.zeros(n_val, dtype=bool, device=DEVICE)
638
+ for tid in tids: tmask |= (val_labels == tid)
639
+ if tmask.sum() > 0:
640
+ ta[tname] = (vl.argmax(-1)[tmask] == val_labels[tmask]).float().mean().item()
641
+
642
+ if (epoch+1) % 5 == 0 or epoch == 0:
643
+ ta_str = " ".join(f"{t}={a:.2f}" for t, a in ta.items())
644
+ rig = model_student.constellation.rigidity
645
+ print(f" E{epoch+1:2d}: t={train_acc:.3f} v={v_acc:.3f} "
646
+ f"cos={v_cos:.3f} cv={v_cv:.4f} "
647
+ f"rig={rig.mean():.1f}/{rig.max():.1f} [{ta_str}]")
648
+
649
+
650
+ # ══════════════════════════════════════════════════════════════════
651
+ # FINAL COMPARISON
652
+ # ══════════════════════════════════════════════════════════════════
653
+
654
+ print(f"\n{'='*65}")
655
+ print("FINAL COMPARISON")
656
+ print(f"{'='*65}")
657
+
658
+ # Get final metrics for all models
659
+ model_student.eval(); teacher_a.eval(); teacher_b.eval()
660
+ with torch.no_grad():
661
+ results = {}
662
+ for name, mdl in [("Teacher_A", teacher_a), ("Teacher_B", teacher_b),
663
+ ("Student", model_student)]:
664
+ vl, ve, _, _ = mdl(val_imgs)
665
+ acc = (vl.argmax(-1) == val_labels).float().mean().item()
666
+ ta = {}
667
+ for tname, tids in types.items():
668
+ tmask = torch.zeros(n_val, dtype=bool, device=DEVICE)
669
+ for tid in tids: tmask |= (val_labels == tid)
670
+ if tmask.sum() > 0:
671
+ ta[tname] = (vl.argmax(-1)[tmask] == val_labels[tmask]).float().mean().item()
672
+ results[name] = {"acc": acc, "cv": cv_metric(ve), "types": ta}
673
+
674
+ print(f"\n {'Model':<15} {'v_acc':>6} {'cv':>7} {'poly':>5} {'curve':>5} {'star':>5} {'struct':>5}")
675
+ print(f" {'-'*55}")
676
+ for name, r in results.items():
677
+ ta = r["types"]
678
+ print(f" {name:<15} {r['acc']:>6.3f} {r['cv']:>7.4f} "
679
+ f"{ta.get('polygon',0):>5.2f} {ta.get('curve',0):>5.2f} "
680
+ f"{ta.get('star',0):>5.2f} {ta.get('structure',0):>5.2f}")
681
+
682
+ # Anchor drift from consensus
683
+ s_anchors = F.normalize(model_student.constellation.anchors.detach(), dim=-1)
684
+ drift = 1.0 - F.cosine_similarity(consensus_anchors, s_anchors, dim=-1)
685
+ print(f"\n Student anchor drift from consensus: mean={drift.mean():.4f} max={drift.max():.4f}")
686
+
687
+ print(f"\n{'='*65}")
688
+ print("DONE")
689
+ print(f"{'='*65}")