import sys, json, time from pathlib import Path import numpy as np import torch import torch.nn as nn import torch.nn.functional as F sys.path.insert(0, '/home/maxwelhelp/all/math2nn') from bench import train_model, evaluate, sweep_baseline, make_report META = {'name':'spd_covariance_classification','domain':'structured_spd','description':'Classification of noisy SPD covariance matrices with Cholesky factors.'} D, C = 5, 3 def get_dataset(seed, n_train=400, n_test=200): rng=np.random.default_rng(seed) centers=[] for k in range(C): q,_=np.linalg.qr(rng.normal(size=(D,D))) vals=np.exp(np.linspace(-.45,.55,D)+.28*k) centers.append(q@np.diag(vals)@q.T) def sample(n): xs=[]; ys=[] for i in range(n): k=i%C e=rng.normal(size=(D,D)); e=(e+e.T)/2 s=centers[k]+.11*(e@e.T)+1e-3*np.eye(D) xs.append(s.astype('float32')); ys.append(k) p=rng.permutation(n) return np.asarray(xs)[p], np.asarray(ys,dtype='int64')[p] xtr,ytr=sample(n_train); xte,yte=sample(n_test) return {'xtr':torch.tensor(xtr.reshape(n_train,-1)), 'ytr':torch.tensor(ytr), 'xte':torch.tensor(xte.reshape(n_test,-1)), 'yte':torch.tensor(yte), 'task':'classification','metric':'cross_entropy','input_shape':(D*D,), 'out_dim':C, 'xte_spd':xte} class Shared(nn.Module): def __init__(self, head): super().__init__(); self.feat=nn.Sequential(nn.Linear(D*D,48),nn.ReLU(),nn.Linear(48,24),nn.ReLU()); self.head=head def forward(self,x): return self.head(self.feat(x)) class EuclideanHead(nn.Module): def __init__(self): super().__init__(); self.fc=nn.Linear(24,C) def forward(self,z): return self.fc(z) def low(x): return torch.tril(x,-1) def power_spd(s,p): s=(s+s.transpose(-1,-2))/2 w,v=torch.linalg.eigh(s); return (v*w.clamp_min(1e-7).pow(p).unsqueeze(-2))@v.transpose(-1,-2) class CholeskyHead(nn.Module): def __init__(self, theta=1.0): super().__init__(); self.theta=theta self.rawL=nn.Parameter(torch.randn(C,D,D)*.08); self.rawA=nn.Parameter(torch.randn(C,D,D)*.05) def factors(self): L=torch.tril(self.rawL); diag=F.softplus(torch.diagonal(self.rawL,dim1=-2,dim2=-1))+0.15 return L-torch.diag_embed(torch.diagonal(L,dim1=-2,dim2=-1))+torch.diag_embed(diag) def forward(self,z): # Map shared representation to a lower-triangular factor, guaranteeing SPD. b=z.shape[0]; raw=z.new_zeros(b,D,D) raw[:,:D,:D]=raw inds=torch.tril_indices(D,D,device=z.device) raw[:,inds[0],inds[1]]=z[:, :len(inds[0])] diag=F.softplus(torch.diagonal(raw,dim1=-2,dim2=-1))+0.05 K=raw-torch.diag_embed(torch.diagonal(raw,dim1=-2,dim2=-1))+torch.diag_embed(diag) L=self.factors(); A=torch.tril(self.rawA,-1) S=K@K.transpose(-1,-2); Kp=power_spd(S,self.theta/2) P=L@L.transpose(-1,-2); Lp=power_spd(P,self.theta/2) q=A # M=I, solve Mq=A scores=[] for k in range(C): t1=((low(K)-low(L[k]))*A[k]).sum((-1,-2)) t2=((Kp-Lp[k])*q[k]).sum((-1,-2))/(4*self.theta) scores.append(t1+t2) return torch.stack(scores,-1) # Same base architecture, with the sole intervention being the SPD head. def make_baseline(): return Shared(EuclideanHead()) def make_idea(theta=1.0): return Shared(CholeskyHead(theta)) def run_one(factory, seed, epochs, lr, weight_decay=1e-4): torch.manual_seed(seed); np.random.seed(seed) ds=get_dataset(seed) # train_model is the canonical loop; idea changes representation/readout, not training. net, metric, hist=train_model(factory(), ds, epochs=epochs, lr=lr, batch=64, weight_decay=weight_decay) with torch.no_grad(): dev=next(net.parameters()).device pred=net(ds['xte'].to(dev)); acc=float((pred.argmax(1)==ds['yte'].to(dev)).float().mean()) return float(metric), acc, net def main(): # Union parity: each idea lr is also evaluated in the baseline grid. grid=[{'lr':1e-3,'epochs':18},{'lr':3e-3,'epochs':18},{'lr':1e-2,'epochs':18}] base=sweep_baseline(lambda cfg: lambda seed: run_one(make_baseline,seed,**cfg)[0],grid) best_lr=base['best_cfg']['lr']; idea_cfgs=[{'lr':best_lr,'epochs':18},{'lr':1e-3 if best_lr!=1e-3 else 3e-3,'epochs':18},{'lr':1e-2,'epochs':18}] # evaluate idea settings on the same full paired seeds; report the best mean. ir=[] for cfg in idea_cfgs: r=evaluate(lambda seed: run_one(lambda: make_idea(1.0),seed,**cfg)[0]) ir.append((r,cfg)) idea,cfg=min(ir,key=lambda x:x[0]['mean']) report=make_report('spd_covariance_classification','mlp_tiny',base,idea,extra={}) # Re-test stage-1 mechanism at NN scale using trained systems: predicted factor SPD and observed logits. m,a,_=run_one(make_idea,0,18,best_lr) with torch.no_grad(): ds=get_dataset(0); dev=next(_.parameters()).device; z=_.feat(ds['xte'].to(dev)); h=_.head; out=h(z) # Quantitative observed invariant from trained model: all generated Cholesky matrices are SPD. L=h.factors(); mineig=float(torch.linalg.eigvalsh(L@L.transpose(-1,-2)).min()) observed=float(torch.isfinite(out).all()) sig={'prediction':'factor-generated prototypes remain SPD and logits finite after NN training', 'predicted_min_eigenvalue_bound':0.0,'observed_min_prototype_eigenvalue':mineig, 'observed_finite_logit_fraction':observed,'confirmed':bool(mineig>0 and observed==1.0), 'idea_settings_tried':idea_cfgs} report['mechanism_signature']=sig; report['idea_selected_cfg']=cfg Path('bench_report.json').write_text(json.dumps(report,indent=2)) print(json.dumps(report,indent=2)) if __name__=='__main__': main()