Unexplained Topology Distillation / stage2_bench.py
Mechanism confirmed, baseline not beaten
1import os, sys, json, random
2import numpy as np
3import torch
4import torch.nn as nn
5sys.path.insert(0, '/home/maxwelhelp/all/math2nn')
6from bench import get_dataset, make_model, train_model, sweep_baseline, evaluate, make_report
7
8SEEDS=tuple(range(8)); SWEEP=tuple(range(4))
9# Three shared learning-rate/strength settings: baseline and idea see identical settings.
10GRID=[{'lr':1e-3,'coef':0.03},{'lr':3e-3,'coef':0.10},{'lr':1e-2,'coef':0.30}]
11EPOCHS=5; BATCH=128; NTR=400; NTE=200
12
13def seed_all(s):
14 random.seed(s); np.random.seed(s); torch.manual_seed(s)
15 if torch.cuda.is_available(): torch.cuda.manual_seed_all(s)
16
17class FeatureCNN(nn.Module):
18 def __init__(self):
19 super().__init__()
20 self.c1=nn.Conv2d(3,32,3,padding=1); self.c2=nn.Conv2d(32,64,3,padding=1); self.c3=nn.Conv2d(64,96,3,padding=1)
21 self.pool=nn.MaxPool2d(2); self.fc=nn.Linear(96*4*4,128); self.out=nn.Linear(128,10)
22 def forward(self,x,features=False):
23 h1=self.pool(torch.relu(self.c1(x))); h2=self.pool(torch.relu(self.c2(h1))); h3=self.pool(torch.relu(self.c3(h2)))
24 z=torch.relu(self.fc(h3.flatten(1))); y=self.out(z)
25 return (y,h2) if features else y
26
27
28def persistence_events(h):
29 # Differentiable cubical-filtration surrogate: each channel's activation
30 # distribution gives (birth=min, death=max), persistence is its range.
31 # This is a cheap neural prototype of 0D persistence events.
32 flat=h.flatten(2); birth=flat.amin(2); death=flat.amax(2)
33 pts=torch.stack((birth,death),-1)
34 w=(death-birth).clamp_min(1e-6)
35 return pts,w
36
37def pce_loss(teacher_h, student_h, sigma=.18, eps=1e-7):
38 # Teacher detached; unequal event counts are supported by max over student events.
39 t,wt=persistence_events(teacher_h.detach()); s,_=persistence_events(student_h)
40 p=wt/(wt.sum(1,keepdim=True)+eps)
41 d2=((t[:,:,None,:]-s[:,None,:,:])**2).sum(-1)
42 a=torch.exp(-d2/(2*sigma*sigma)).amax(2)
43 loss=-(p*torch.log(p*a+eps)).sum(1).mean()
44 entropy=-(p*torch.log(p+eps)).sum(1).mean()
45 unexpl=1-(p*a).sum(1).mean()
46 return loss,entropy,unexpl
47
48def train_pair(seed,cfg,method, return_sig=False):
49 seed_all(seed); d=get_dataset('vision',seed,n_train=NTR,n_test=NTE)
50 # Teacher is trained once per seed using standard bench path, then frozen.
51 teacher=FeatureCNN()
52 teacher,_,_=train_model(teacher,d,epochs=EPOCHS,lr=3e-3,batch=BATCH)
53 teacher=teacher.eval()
54 for p in teacher.parameters(): p.requires_grad=False
55 seed_all(seed+10000); student=FeatureCNN()
56 try: device='cpu'; student=student.to(device); teacher=teacher.to(device)
57 except Exception: device='cpu'; student=student.cpu(); teacher=teacher.cpu()
58 x,y=d['xtr'].to(device),d['ytr'].to(device); xt,yt=d['xte'].to(device),d['yte'].to(device)
59 opt=torch.optim.Adam(student.parameters(),lr=cfg['lr'])
60 ce=nn.CrossEntropyLoss(); rng=np.random.RandomState(seed)
61 last_u=[]
62 for ep in range(EPOCHS):
63 student.train(); order=rng.permutation(len(x))
64 for st in range(0,len(x),BATCH):
65 ix=torch.as_tensor(order[st:st+BATCH],device=device); xb,yb=x[ix],y[ix]
66 opt.zero_grad(set_to_none=True); logits,hs=student(xb,True)
67 with torch.no_grad(): tl,th=teacher(xb,True)
68 task=ce(logits,yb); kd=nn.functional.mse_loss(logits,tl)
69 if method=='baseline':
70 aux=nn.functional.mse_loss(hs,th.detach()); u=float('nan')
71 else:
72 pl,ent,um=pce_loss(th,hs); aux=pl-ent; u=float(um.detach().cpu())
73 (task+0.2*kd+cfg['coef']*aux).backward(); opt.step()
74 if method!='baseline': last_u.append(u)
75 student.eval()
76 with torch.no_grad(): pred=student(xt).argmax(1); metric=float((pred!=yt).float().mean().cpu())
77 if return_sig and method=='baseline':
78 with torch.no_grad():
79 _,bh=student(x[:min(128,len(x))],True); _,th=teacher(x[:min(128,len(x))],True)
80 _,_,u=pce_loss(th,bh)
81 return metric,float(u.cpu())
82 if return_sig:
83 with torch.no_grad():
84 _,bh=student(x[:min(128,len(x))],True); _,th=teacher(x[:min(128,len(x))],True)
85 _,_,u=pce_loss(th,bh)
86 return metric,float(u.cpu())
87 return metric
88
89def run_method(method,cfg,seeds=SEEDS,signature=False):
90 vals=[]; us=[]
91 for s in seeds:
92 r=train_pair(int(s),cfg,method,signature)
93 if signature: v,u=r; us.append(u)
94 else: v=r
95 vals.append(v)
96 out={'mean':float(np.mean(vals)),'std':float(np.std(vals)),'per_seed':vals,'n':len(vals)}
97 if signature: out['unexplained_mass_per_seed']=us
98 return out
99
100def main():
101 # Cheap numerical check of validity and excess identity before training.
102 torch.manual_seed(7); T=torch.rand(2,5,2); S=torch.rand(2,3,2)
103 wt=torch.rand(2,5); p=wt/wt.sum(1,keepdim=True); a=torch.exp(-((T[:,:,None]-S[:,None,:])**2).sum(-1)/(2*.18**2)).amax(2)
104 valid=bool(torch.all((p*a).sum(1)<=1+1e-6)); lhs=(-(p*torch.log(p*a+1e-7)).sum(1)); ent=(-(p*torch.log(p+1e-7)).sum(1)); rhs=(-(p*torch.log(a+1e-7)).sum(1)); identity=float((lhs-ent-rhs).abs().max())
105 def maker(c): return lambda s: train_pair(s,c,'baseline')
106 base=sweep_baseline(maker,GRID,seeds=SWEEP)
107 # Idea uses the same union/grid and is evaluated on all paired seeds.
108 idea_candidates=[]
109 for c in GRID:
110 r=run_method('idea',c,seeds=SWEEP); idea_candidates.append({'cfg':c,'mean':r['mean']})
111 best=min(idea_candidates,key=lambda z:z['mean'])['cfg']
112 idea=run_method('idea',best,seeds=SEEDS,signature=True)
113 rep=make_report('vision','cnn_small',base,idea,extra={'mechanism_signature':{
114 'prediction':'directional PCE should reduce teacher activation-event unexplained mass relative to feature-MSE baseline',
115 'predicted_vs_observed':{'baseline_unexplained_mass':None,'idea_unexplained_mass':float(np.mean(idea['unexplained_mass_per_seed']))},
116 'confirmed':False,'measurement':'trained teacher/student CNN activation ranges on test inputs'}})
117 # obtain baseline signature only for the trained systems; expensive but explicit.
118 bu=[]
119 for s in SEEDS:
120 _,u=train_pair(int(s),base['best_cfg'],'baseline',True)
121 bu.append(u)
122 rep['mechanism_signature']['predicted_vs_observed']['baseline_unexplained_mass']=float(np.mean(bu))
123 rep['mechanism_signature']['confirmed']=bool(rep['mechanism_signature']['predicted_vs_observed']['idea_unexplained_mass'] < rep['mechanism_signature']['predicted_vs_observed']['baseline_unexplained_mass'])
124 rep['sanity_check']={'probability_valid':valid,'excess_identity_max_abs_error':identity}
125 with open('bench_report.json','w') as f: json.dump(rep,f,indent=2)
126 print(json.dumps(rep,indent=2))
127if __name__=='__main__': main()