Semi-Passive Energy-Gated Optimizer / experiment.py
Beats tuned baseline
1import json, math, random, time
2from pathlib import Path
3import numpy as np
4
5SEED = 2871
6np.random.seed(SEED); random.seed(SEED)
7
8
9def sigmoid(x):
10 x = np.clip(x, -60.0, 60.0)
11 return 1.0 / (1.0 + np.exp(-x))
12
13
14def toy_checks():
15 # Prediction 1: q(E)=1/2 exactly at E=E_star and transition is about 4*tau.
16 E_star, tau = 2.0, 0.25
17 qstar = float(sigmoid((E_star-E_star)/tau))
18 e10 = E_star - tau*math.log(9.0)
19 e90 = E_star + tau*math.log(9.0)
20 transition_width = e90-e10
21
22 # Prediction 2: in the active, unforced regime dE/dt=-2*c*E,
23 # hence log(E) slope=-2c, independently of initial energy.
24 decay = []
25 for c in [0.2, 0.5, 1.0, 1.5]:
26 dt=1e-3; n=10000; v=math.sqrt(2*8.0); logs=[]; ts=[]
27 for k in range(n):
28 E=.5*v*v
29 if k % 10 == 0:
30 logs.append(math.log(E)); ts.append(k*dt)
31 q=sigmoid((E-E_star)/tau)
32 v += -dt*q*c*v
33 # fit only well-active portion, E > 3 E_star
34 mask=np.array([math.exp(x)>3*E_star for x in logs])
35 slope=np.polyfit(np.array(ts)[mask],np.array(logs)[mask],1)[0]
36 decay.append({'c':c,'predicted_log_slope':-2*c,'observed_log_slope':float(slope),
37 'relative_error':float(abs(slope+2*c)/(2*c))})
38
39 # Prediction 3: bounded adversarial forcing g=-G sign(v), active equilibrium
40 # has E*=G^2/(2c^2), from G*sqrt(2E)=2cE.
41 forced=[]; G=1.0
42 for c in [0.25,0.5,1.0,2.0]:
43 dt=2e-4; n=250000; v=0.05
44 vals=[]
45 # Choose a low threshold so every predicted equilibrium is well inside q≈1.
46 forced_E_star, forced_tau = 0.01, 0.001
47 for k in range(n):
48 E=.5*v*v; q=sigmoid((E-forced_E_star)/forced_tau)
49 g=-G*(1.0 if v >= 0 else -1.0)
50 v += dt*(-g-q*c*v)
51 if k >= n//2: vals.append(.5*v*v)
52 observed=float(np.mean(vals[-50000:]))
53 predicted=G*G/(2*c*c)
54 forced.append({'c':c,'predicted_plateau_E':predicted,
55 'observed_plateau_E':observed,
56 'relative_error':abs(observed-predicted)/predicted})
57 return {'gate_transition':{'E_star':E_star,'tau':tau,'predicted_q_at_Estar':.5,
58 'observed_q_at_Estar':qstar,
59 'predicted_width_10_to_90':4.3944491547*tau,
60 'observed_width_10_to_90':transition_width},
61 'unforced_decay':decay,'forced_plateau':forced}
62
63
64def train_benchmark():
65 # sklearn digits is a tiny, reproducible MNIST-like classification task.
66 import torch
67 import torch.nn as nn
68 from sklearn.datasets import load_digits
69 from sklearn.model_selection import train_test_split
70 torch.manual_seed(SEED); np.random.seed(SEED)
71 try:
72 device=torch.device('cuda' if torch.cuda.is_available() else 'cpu')
73 # Trigger a tiny allocation so CUDA initialization errors are caught.
74 if device.type=='cuda': torch.empty(1,device=device)
75 except Exception:
76 device=torch.device('cpu')
77 x,y=load_digits(return_X_y=True)
78 x=x.astype('float32')/16.0
79 xa,xb,ya,yb=train_test_split(x,y,test_size=.25,random_state=SEED,stratify=y)
80 tx=torch.tensor(xa); ty=torch.tensor(ya,dtype=torch.long)
81 vx=torch.tensor(xb); vy=torch.tensor(yb,dtype=torch.long)
82 def make():
83 return nn.Sequential(nn.Linear(64,96),nn.ReLU(),nn.Linear(96,64),nn.ReLU(),nn.Linear(64,10)).to(device)
84 def run(mode, lr):
85 torch.manual_seed(SEED)
86 model=make(); lossfn=nn.CrossEntropyLoss()
87 # Explicit velocity implements theta <- theta + v, matching the stated interface.
88 vel=[torch.zeros_like(p,device=device) for p in model.parameters()]
89 bs=128; losses=[]; energies=[]; gates=[]
90 t0=time.time()
91 for step in range(300):
92 idx=((torch.arange(bs)+step*bs) % len(tx))
93 out=model(tx[idx].to(device)); loss=lossfn(out,ty[idx].to(device))
94 gs=torch.autograd.grad(loss,tuple(model.parameters()))
95 E=.5*sum(float((v*v).sum().detach().cpu()) for v in vel)
96 q=float(sigmoid((E-0.02)/0.01)) if mode=='gated' else 0.0
97 clip_scale=1.0
98 if mode=='clip':
99 total_norm=torch.sqrt(sum((g*g).sum() for g in gs))
100 clip_scale=min(1.0, 1.0/(float(total_norm.detach().cpu())+1e-12))
101 with torch.no_grad():
102 for j,(p,g) in enumerate(zip(model.parameters(),gs)):
103 gg=g*clip_scale
104 vel[j].mul_(0.9*(1.0-lr*1.0*q)).add_(gg,alpha=-lr)
105 p.add_(vel[j])
106 losses.append(float(loss.detach().cpu())); energies.append(E); gates.append(q)
107 with torch.no_grad():
108 acc=float((model(vx.to(device)).argmax(1).cpu()==vy).float().mean())
109 return {'final_train_loss':losses[-1],'eval_accuracy':acc,'max_energy':max(energies),
110 'mean_last50_energy':float(np.mean(energies[-50:])),'mean_gate_last50':float(np.mean(gates[-50:])),
111 'seconds':time.time()-t0,'device':str(device)}
112 results={}
113 for lr in [0.03,0.1,0.3]:
114 for mode in ['momentum','clip','gated']:
115 try: results[f'{mode}_lr{lr}']=run(mode,lr)
116 except Exception as e:
117 results[f'{mode}_lr{lr}']={'error':repr(e)}
118 return results
119
120if __name__=='__main__':
121 out={'seed':SEED,'toy':toy_checks()}
122 try: out['benchmark']=train_benchmark()
123 except Exception as e: out['benchmark_error']=repr(e)
124 Path('results.json').write_text(json.dumps(out,indent=2))
125 print(json.dumps(out,indent=2))