AbstractPhil commited on
Commit
571296c
Β·
verified Β·
1 Parent(s): add8ebd

Create experiment_4_multigenerational_autograd.py

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