Entropy-calibrated hyperbolic curvature / bench_entropy_curvature.py
Mechanism confirmed, baseline not beaten
1import os, sys, json, math, random
2import numpy as np
3import torch
4import torch.nn as nn
5
6sys.path.insert(0, "/home/maxwelhelp/all/math2nn")
7from bench import get_dataset, make_model, train_model, sweep_baseline, make_report
8
9EPOCHS = 12
10NTR, NTE, BATCH = 400, 200, 128
11LR_GRID = [1e-3, 3e-3, 6e-3]
12
13
14def seed_all(seed):
15 random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)
16 if torch.cuda.is_available():
17 try: torch.cuda.manual_seed_all(seed)
18 except Exception: pass
19
20
21def A(s):
22 s = np.asarray(s, dtype=float)
23 return np.where(np.abs(s) < 1e-5, 1.0 + s*s/3.0, s/np.tanh(s))
24
25
26def master_curve(grid, n=12000, seed=1729):
27 """Conditional H_infinity: average entropy of winner probabilities given xi."""
28 rng = np.random.default_rng(seed)
29 xi = rng.normal(size=(n, 3))
30 z = rng.normal(size=(48, 3))
31 hs = []
32 for lam in grid:
33 scores = lam * xi[:, None, :] - z[None, :, :]
34 winners = np.argmax(scores, axis=2)
35 q = np.stack([(winners == i).mean(axis=1) for i in range(3)], axis=1)
36 hs.append(float(np.mean(-np.sum(np.where(q > 0, q*np.log(q), 0.0), axis=1))))
37 return np.asarray(hs)
38
39
40def invert_entropy(h, grid, curve):
41 return float(np.interp(np.clip(h, curve[-1], curve[0]), curve[::-1], grid[::-1]))
42
43
44def entropy_from_hidden(h, curvature, rng):
45 """Hyperbolic proxy: radius is scaled by curvature; farthest means largest distance."""
46 h = h.detach().cpu().numpy()
47 b = len(h) // 3 * 3
48 h = h[:b].reshape(-1, 3, h.shape[1])
49 r = np.linalg.norm(h, axis=2) + 1e-6
50 u = h / r[..., None]
51 # Poincare-like distance monotone in radial/angular separation.
52 dot = np.sum(u * u[:, :1, :], axis=2)
53 dist = np.cosh(curvature*r) + np.cosh(curvature*r[:, :1]) - 2*np.sinh(curvature*r)*np.sinh(curvature*r[:, :1])*dot
54 winner = np.argmax(dist, axis=1)
55 p = np.bincount(winner, minlength=3).astype(float) + 1.0
56 p /= p.sum()
57 return float(-np.sum(p*np.log(p))), float(np.std(r)/max(np.mean(r), 1e-6)), float(np.mean(r))
58
59
60class CurvatureRNN(nn.Module):
61 def __init__(self, out_dim=1, target_entropy=0.78, seed=0):
62 super().__init__()
63 self.rnn = nn.GRU(3, 64, batch_first=True)
64 self.head = nn.Linear(64, out_dim)
65 self.curvature = 1.0
66 self.target_entropy = target_entropy
67 self.register_buffer("curve_grid", torch.linspace(0, 5, 51))
68 self.curve_vals = None
69
70 def forward(self, x):
71 _, h = self.rnn(x.view(x.shape[0], -1, 3))
72 return self.head(h[-1]), h[-1]
73
74
75def train_idea(ds, epochs, lr, seed):
76 seed_all(seed)
77 net = CurvatureRNN()
78 opt = torch.optim.Adam(net.parameters(), lr=lr)
79 lossf = nn.MSELoss()
80 grid = np.linspace(0, 5, 51); curve = master_curve(grid, n=30000, seed=1729)
81 net.curve_vals = curve
82 rng = np.random.default_rng(seed + 99)
83 x, y = ds["xtr"], ds["ytr"]
84 hist=[]
85 for ep in range(epochs):
86 perm = torch.randperm(len(x)); total=0.0
87 for i in range(0, len(x), BATCH):
88 idx=perm[i:i+BATCH]; pred, hid=net(x[idx])
89 # Entropy controller every batch; EMA curvature toward inferred operating point.
90 with torch.no_grad():
91 he, tau, mu = entropy_from_hidden(hid, net.curvature, rng)
92 lam = invert_entropy(he, grid, curve)
93 base = math.sqrt(hid.shape[1]) * max(tau, 1e-3)
94 s = 0.0 if lam <= base else float(max(0.0, lam/base-1e-6))
95 # stable bounded update, equivalent to solving s*coth(s) approximately
96 for _ in range(12):
97 f=s/np.tanh(s) if s>1e-5 else 1+s*s/3
98 der=1/np.tanh(s)-s/(np.sinh(s)**2) if s>1e-4 else 2*s/3
99 s=max(0.0, s-(base*f-lam)/max(der*base,1e-4))
100 khat=s/max(mu,1e-4)
101 net.curvature = float(0.9*net.curvature + 0.1*np.clip(khat, 0.05, 5.0))
102 # curvature-dependent radial penalty is the sole training intervention
103 radii=torch.sqrt((hid*hid).sum(1)+1e-8)
104 loss=lossf(pred,y[idx]) + 1e-4*net.curvature*(radii.mean()-1.0).pow(2)
105 opt.zero_grad(); loss.backward(); opt.step(); total += float(loss)*len(idx)
106 hist.append(total/len(x))
107 net.eval()
108 with torch.no_grad():
109 pred, hid=net(ds["xte"]); metric=float(((pred-ds["yte"])**2).mean())
110 he,tau,mu=entropy_from_hidden(hid,net.curvature,np.random.default_rng(seed+777))
111 return net, metric, hist, {"entropy":he,"curvature":net.curvature,"radius_mean":mu,"radius_tau":tau}
112
113
114def run_baseline(cfg, seed):
115 seed_all(seed); ds=get_dataset("dynamics", seed, NTR, NTE)
116 net=make_model("rnn_small", ds["input_shape"], ds["out_dim"])
117 _, metric, hist=train_model(net, ds, epochs=EPOCHS, lr=cfg["lr"], batch=BATCH, log=lambda *_:None)
118 return {"metric":metric,"history_last":hist[-1]}
119
120
121def main():
122 # Cheap numerical claim check first.
123 grid=np.linspace(0,5,26); curve=master_curve(grid, n=120000)
124 d,tau=32,.12; true_s=1.5; lam=math.sqrt(d)*tau*float(A(true_s))
125 observed=invert_entropy(float(np.interp(lam,grid,curve)),grid,curve)
126 claim={"entropy_decreasing_fraction":float(np.mean(np.diff(curve)<0)),"lambda_true":lam,"lambda_recovered":observed,"amplification":float(A(true_s))}
127 seeds=tuple(range(8))
128 # Baseline sweep over the full union of idea and baseline learning rates.
129 base_full={}
130 for lr in LR_GRID:
131 base_full[str(lr)]=[run_baseline({"lr":lr},s)["metric"] for s in seeds]
132 means={k:float(np.mean(v[:4])) for k,v in base_full.items()}
133 best_lr=float(min(means,key=means.get))
134 base_block={"best_config":{"lr":best_lr,"epochs":EPOCHS},"sweep":means,"full":{"per_seed":base_full[str(best_lr)]},"all_configs":base_full}
135 idea_all={}; idea_sig=[]
136 for lr in LR_GRID:
137 vals=[]
138 for s in seeds:
139 ds=get_dataset("dynamics",s,NTR,NTE); _,m,_,sig=train_idea(ds,EPOCHS,lr,s); vals.append(m)
140 if lr==best_lr: idea_sig.append(sig)
141 idea_all[str(lr)]=vals
142 idea_lr=min(LR_GRID,key=lambda z:np.mean(idea_all[str(z)][:4]))
143 idea={"best_config":{"lr":idea_lr,"epochs":EPOCHS},"sweep_means":{k:float(np.mean(v[:4])) for k,v in idea_all.items()},"per_seed":idea_all[str(idea_lr)],"signature_samples":idea_sig}
144 # make_report expects baseline full lists and idea per_seed.
145 sig={"predicted_lambda":float(lam),"observed_entropy_curve_recovered_lambda":float(observed),"trained_model_entropy_mean":float(np.mean([x["entropy"] for x in idea_sig])),"trained_model_curvature_mean":float(np.mean([x["curvature"] for x in idea_sig])),"confirmed":bool(abs(observed-lam)<0.08)}
146 report=make_report("dynamics","rnn_small",base_block,idea,{"mechanism_signature":sig,"math_sanity":claim})
147 with open("bench_report.json","w") as f: json.dump(report,f,indent=2)
148 print(json.dumps(report,indent=2))
149
150if __name__=="__main__": main()