Positive-cycle Jacobian penalty / experiment.py
Mechanism confirmed, baseline not beaten
1import json, math, random, time
2from pathlib import Path
3import numpy as np
4import torch
5from torch import nn
6
7SEED = 1102
8random.seed(SEED); np.random.seed(SEED); torch.manual_seed(SEED)
9torch.set_num_threads(min(8, torch.get_num_threads()))
10try:
11 device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
12 if device.type == 'cuda':
13 torch.zeros(1, device=device)
14except Exception:
15 device = torch.device('cpu')
16
17
18def toy_verification():
19 # A two-node positive cycle K has rho(K)=sqrt(ab), and tr(K^2)=2ab.
20 a, b = 1.8, 0.8
21 K = np.array([[0., a], [b, 0.]])
22 rho = math.sqrt(a*b)
23 trace2 = np.trace(K @ K)
24 # Prediction 1: trace exactly equals the sum of the two length-2 cycles.
25 p1_err = abs(trace2 - 2*a*b)
26 # Prediction 2: repeated positive feedback changes from decay to growth at gamma*rho=1.
27 gammas = np.array([0.35, 0.55, 0.70, 0.74, 0.76, 0.90, 1.10])
28 n = 30
29 growth = []
30 for g in gammas:
31 x = np.array([1., 0.])
32 for _ in range(n): x = g*K @ x
33 growth.append(float(np.linalg.norm(x)))
34 predicted_threshold = 1.0/rho
35 # Fit log(norm) vs steps at representative stable/unstable settings.
36 slopes = []
37 for g in [0.7, 0.9]:
38 x=np.array([1., 0.]); vals=[]
39 for _ in range(1, 16):
40 x=g*K@x; vals.append(np.log(np.linalg.norm(x)+1e-30))
41 slopes.append(float(np.polyfit(np.arange(1,16), vals, 1)[0]))
42 # Prediction 3: normalized r=2 closed-walk term scales exactly as tau^-2.
43 taus=np.array([0.5, 1., 2., 4.])
44 normalized = np.array([trace2/(t*t*2.) for t in taus])
45 slope_tau=float(np.polyfit(np.log(taus), np.log(normalized), 1)[0])
46 # A disconnected/feed-forward matrix has no 2-cycle and zero trace(K^2).
47 dag=np.array([[0., 2.0],[0., 0.]])
48 return {
49 'device': str(device),
50 'predictions': {
51 'trace_identity': {'predicted': 'tr(K^2)=2ab', 'observed': trace2, 'expected': 2*a*b, 'abs_error': p1_err},
52 'threshold': {'predicted_gamma': predicted_threshold, 'observed_rho': rho,
53 'gamma_sweep': gammas.tolist(), 'norm_after_30': growth,
54 'classification': ['decay' if x < 1 else 'growth' for x in growth],
55 'theory_log_slopes_at_gamma_0.7_0.9': [math.log(g*rho) for g in [0.7,0.9]],
56 'observed_log_slopes': slopes},
57 'temperature_scaling': {'predicted_loglog_slope': -2.0, 'observed_loglog_slope': slope_tau,
58 'taus': taus.tolist(), 'normalized_trace': normalized.tolist()},
59 'cycle_vanishes_in_dag': {'cycle_trace': float(np.trace(dag@dag)), 'predicted': 0.0}
60 }
61 }
62
63class IterCell(nn.Module):
64 def __init__(self):
65 super().__init__()
66 self.A=nn.Parameter(torch.tensor([[0.15, 0.85],[0.55, 0.10]], dtype=torch.float32))
67 self.B=nn.Parameter(torch.randn(2,1)*0.25)
68 self.out=nn.Linear(2,1)
69 def forward(self,x, return_jac=False):
70 h=torch.zeros(x.shape[0],2,device=x.device)
71 for _ in range(5): h=torch.tanh(h@self.A.T + x@self.B.T)
72 return self.out(h)
73 def cycle_penalty(self, x, tau=1.0):
74 # Exact per-example 2x2 block Jacobian for this small scalar-block MVP.
75 h=torch.zeros(x.shape[0],2,device=x.device)
76 for _ in range(4): h=torch.tanh(h@self.A.T + x@self.B.T)
77 z=h@self.A.T + x@self.B.T
78 d=(1-torch.tanh(z)**2).detach() # intermittent/stop-gradient Jacobian estimate
79 # J_ij = |d tanh(z_i)/d h_j|; batch average gives nonnegative block matrix.
80 J=d.mean(0)[:,None]*self.A.abs()
81 return torch.trace(J@J)/(tau*tau*2), float(torch.trace(J@J).detach().cpu())
82 def spectral_proxy(self):
83 # Same auxiliary cost class: penalize total squared Jacobian entries, not cycles.
84 return (self.A*self.A).mean()
85
86def train(kind, seed=SEED, steps=500):
87 torch.manual_seed(seed); np.random.seed(seed)
88 model=IterCell().to(device)
89 opt=torch.optim.Adam(model.parameters(),lr=0.015)
90 x=torch.randn(96,1,device=device)
91 y=2*x + 0.35*torch.sin(3*x)
92 t0=time.time(); losses=[]; penalties=[]
93 for step in range(steps):
94 # fixed stream makes variants directly comparable
95 xb=x[(step*13)%96:((step*13)%96)+32] if (step*13)%96+32<=96 else x[:32]
96 yb=y[(step*13)%96:((step*13)%96)+32] if (step*13)%96+32<=96 else y[:32]
97 pred=model(xb); loss=((pred-yb)**2).mean()
98 if kind=='cycle': pen,_=model.cycle_penalty(xb); loss=loss+0.04*pen
99 elif kind=='spectral': pen=model.spectral_proxy(); loss=loss+0.04*pen
100 else: pen=torch.tensor(0.,device=device)
101 opt.zero_grad(); loss.backward(); torch.nn.utils.clip_grad_norm_(model.parameters(),10.); opt.step()
102 losses.append(float(loss.detach().cpu())); penalties.append(float(pen.detach().cpu()))
103 with torch.no_grad():
104 pred=model(x); mse=float(((pred-y)**2).mean().cpu())
105 cp, raw=model.cycle_penalty(x)
106 sp=float(model.spectral_proxy().cpu())
107 return {'final_mse':mse,'final_loss':losses[-1],'mean_aux':float(np.mean(penalties[-100:])),
108 'closed_walk_trace2':raw,'spectral_proxy':sp,'seconds':time.time()-t0}
109
110def main():
111 out=toy_verification()
112 train_results={k:train(k) for k in ['none','spectral','cycle']}
113 out['mini_experiment']=train_results
114 Path('results.json').write_text(json.dumps(out,indent=2))
115 print(json.dumps(out,indent=2))
116
117if __name__=='__main__': main()