Nonlinear Hydrodynamic Optimizer / hydro_experiment.py
Mechanism confirmed, baseline not beaten
1import json, math, os, random, time
2import numpy as np
3import torch
4from torch import nn
5
6SEED=1729
7
8def seed_all(s=SEED):
9 random.seed(s); np.random.seed(s); torch.manual_seed(s)
10 if torch.cuda.is_available(): torch.cuda.manual_seed_all(s)
11
12class HydroAllocator:
13 """Conservative path-current allocation of blockwise update activity."""
14 def __init__(self, m, alpha=0.15, D0=0.7, beta=1.0, noise=0.0, eps=1e-8):
15 self.m=m; self.alpha=alpha; self.D0=D0; self.beta=beta; self.noise=noise; self.eps=eps
16 self.n=np.ones(m, dtype=np.float64); self.var=np.zeros(m); self.mean=np.zeros(m)
17 def step(self, activity, rng):
18 a=np.asarray(activity, dtype=np.float64)
19 raw=a + self.eps
20 target=self.m*raw/raw.sum()
21 self.mean=0.95*self.mean+0.05*a
22 self.var=0.95*self.var+0.05*(a-self.mean)**2
23 # D(n) is positive nonlinear diffusivity; mobility is nonnegative.
24 nf=.5*(self.n[:-1]+self.n[1:])
25 D=self.D0*(1.0+self.beta*nf/self.m)
26 mobility=0.02 + self.var[:-1]+self.var[1:]
27 z=rng.normal(size=self.m-1)
28 J=-D*(self.n[1:]-self.n[:-1]) + self.noise*np.sqrt(mobility)*z
29 div=np.zeros(self.m); div[:-1] += J; div[1:] -= J
30 self.n=np.maximum(self.n-self.alpha*div, 1e-12)
31 self.n *= self.m/self.n.sum()
32 # Activity target is used as the local source only through a gentle blend;
33 # transport remains the defining conservative mechanism.
34 self.n=0.85*self.n+0.15*target
35 self.n *= self.m/self.n.sum()
36 return self.n.copy()
37
38class TinyMLP(nn.Module):
39 def __init__(self, d=20, h=32, out=3):
40 super().__init__(); self.layers=nn.ModuleList([nn.Linear(d,h),nn.Linear(h,h),nn.Linear(h,h),nn.Linear(h,out)])
41 def forward(self,x):
42 for i,l in enumerate(self.layers): x=l(x); x=torch.tanh(x) if i<3 else x
43 return x
44
45def make_data(n=2400):
46 g=np.random.default_rng(SEED); x=g.normal(size=(n,20)).astype('float32')
47 w=g.normal(size=(20,3)); logits=x@w + .5*np.sin(x[:,:3])@np.array([[1.,-1.,.5],[.5,.5,-1.],[1.,.2,.3]])
48 y=logits.argmax(1).astype('int64'); return torch.tensor(x),torch.tensor(y)
49
50def train(mode, device, steps=500):
51 seed_all(SEED); x,y=make_data(); x=x.to(device); y=y.to(device)
52 model=TinyMLP().to(device); params=list(model.parameters()); pindex={id(p): j for j,p in enumerate(params)}; blocks=[list(model.layers[i].parameters()) for i in range(4)]
53 # Adam moments, with hydrodynamic allocation multiplying the Adam direction.
54 m1=[torch.zeros_like(p) for p in params]; m2=[torch.zeros_like(p) for p in params]
55 allocator=HydroAllocator(4, alpha=.18, D0=.8, beta=2., noise=(.015 if mode=='hydro_noise' else 0.0)) if mode!='adam' else None
56 rng=np.random.default_rng(SEED+11); losses=[]; t0=time.time(); bs=64
57 for step in range(1,steps+1):
58 ix=((step-1)*bs)% (len(x)-bs); xb=x[ix:ix+bs]; yb=y[ix:ix+bs]
59 model.zero_grad(set_to_none=True); loss=nn.functional.cross_entropy(model(xb),yb); loss.backward()
60 activities=[]; dirs=[]
61 with torch.no_grad():
62 for bi,ps in enumerate(blocks):
63 sq=0.; norm=0.
64 for p in ps:
65 j=pindex[id(p)]; g=p.grad
66 m1[j].mul_(0.9).add_(g,alpha=.1); m2[j].mul_(0.999).addcmul_(g,g,value=.001)
67 d=m1[j]/(m2[j].sqrt()+1e-8); dirs.append((j,d))
68 sq += float((d*d).sum()); norm += float((p*p).sum())
69 activities.append(math.sqrt(sq)/(math.sqrt(norm)+1e-8))
70 if allocator is None: scales=np.ones(4)
71 else: scales=allocator.step(activities,rng)/(4/4)
72 # cosine schedule, same base update budget
73 lr=.012*0.5*(1+math.cos(math.pi*(step-1)/steps))
74 for bi,ps in enumerate(blocks):
75 scale=1.0 if allocator is None else float(scales[bi])
76 for p in ps:
77 j=pindex[id(p)]; p.add_(dirs[j][1],alpha=-lr*scale)
78 losses.append(float(loss.detach().cpu()))
79 with torch.no_grad(): final=float(nn.functional.cross_entropy(model(x),y).cpu())
80 return {'final_full_loss':final,'last50_mean':float(np.mean(losses[-50:])),'time_sec':time.time()-t0,
81 'loss_start':losses[0], 'loss_curve':losses, 'n':None if allocator is None else allocator.n.tolist()}
82
83def math_check():
84 # Deterministic linear path diffusion: n' = n - alpha D L n, zero-flux boundaries.
85 M=12; D=.73; nbar=1.; rng=np.random.default_rng(SEED)
86 # edge-current convention gives graph Laplacian with endpoint degree 1.
87 L=np.diag([1]+[2]*(M-2)+[1])-np.diag(np.ones(M-1),1)-np.diag(np.ones(M-1),-1)
88 lam=np.linalg.eigvalsh(L); lmax=float(lam[-1]); k=1
89 v=np.cos(np.pi*k*(np.arange(M)+.5)/M); v-=v.mean(); v/=np.linalg.norm(v)
90 def run(alpha, nstep=100):
91 q=v.copy(); amps=[]
92 for _ in range(nstep): amps.append(abs(q@v)); q=q-alpha*D*(L@q)
93 return np.array(amps)
94 a=.5/(D*lam[-1]); amps=run(a); slope=float(np.polyfit(np.arange(1,50),np.log(amps[1:50]),1)[0])
95 predicted=math.log(1-a*D*lam[k]); stable=run(2.02/(D*lmax),20)
96 # conservation under nonlinear/noisy allocator
97 h=HydroAllocator(M,alpha=.15); before=h.n.sum(); sums=[]
98 for _ in range(100): sums.append(h.step(rng.random(M),rng).sum())
99 return {'lambda_max':lmax,'alpha_critical_pred':2/(D*lmax),'decay_slope_observed':slope,
100 'decay_slope_predicted':predicted,'slope_ratio':slope/predicted,
101 'mass_error_max':float(np.max(np.abs(np.asarray(sums)-before))),
102 'above_boundary_max_abs':float(np.max(np.abs(stable))),
103 'stable_alpha_used':a,'unstable_alpha_used':2.02/(D*lmax)}
104
105def main():
106 seed_all(); mathres=math_check(); device='cuda' if torch.cuda.is_available() else 'cpu'
107 try:
108 results={m:train(m,device) for m in ['adam','hydro','hydro_noise']}
109 except Exception as e:
110 if device=='cuda':
111 device='cpu'; results={m:train(m,device) for m in ['adam','hydro','hydro_noise']}
112 else: raise
113 out={'device':device,'math':mathres,'training':results}
114 with open('results.json','w') as f: json.dump(out,f,indent=2)
115 print(json.dumps(out,indent=2))
116if __name__=='__main__': main()