Commutant-Gap Controlled Stochastic Training / bench_commutant_gap.py

Failed on benchmark

Raw ⬇ ZIP
  1import sys, json, math, random
  2from pathlib import Path
  3import numpy as np
  4import torch
  5import torch.nn as nn
  6
  7sys.path.insert(0, "/home/maxwelhelp/all/math2nn")
  8from bench import get_dataset, make_report
  9
 10SEED0 = 2118
 11DEVICE = "cpu"
 12torch.set_num_threads(2)
 13
 14class EquivRNN(nn.Module):
 15    """Canonical rnn_small architecture, with optional SO(2) hidden Brownian noise."""
 16    def __init__(self, out_dim, kappa=0.0, target_gap=0.01):
 17        super().__init__()
 18        self.rnn = nn.GRU(3, 64, batch_first=True)
 19        self.head = nn.Linear(64, out_dim)
 20        self.kappa = float(kappa)
 21        self.target_gap = float(target_gap)
 22        self.gap = 1.0
 23        self.gap_history = []
 24
 25    def rotate_pairs(self, h, angles):
 26        # T is block diagonal with 2x2 SO(2) generators; this commutes with T.
 27        x = h.reshape(h.shape[0], 32, 2)
 28        c, s = torch.cos(angles), torch.sin(angles)
 29        a, b = x[..., 0], x[..., 1]
 30        return torch.stack((c * a - s * b, s * a + c * b), dim=-1).reshape_as(h)
 31
 32    @torch.no_grad()
 33    def update_gap(self, h):
 34        # Two replicas under a small commuting rotation. The normalized smallest
 35        # positive covariance eigenvalue is an empirical relaxation proxy.
 36        eps = 0.08
 37        ang = torch.full((h.shape[0], 32), eps, device=h.device)
 38        r = self.rotate_pairs(h, ang)
 39        d = (r - h).reshape(h.shape[0], -1)
 40        d = d - d.mean(0, keepdim=True)
 41        cov = (d.T @ d) / max(1, d.shape[0] - 1)
 42        vals = torch.diagonal(cov).clamp_min(0)
 43        pos = vals[vals > 1e-10]
 44        g = float((pos.min() / (vals.mean() + 1e-12)).clamp(0, 1)) if pos.numel() else 0.0
 45        self.gap = g
 46        self.gap_history.append(g)
 47
 48    def forward(self, x):
 49        seq = x.view(x.shape[0], -1, 3)
 50        _, h = self.rnn(seq)
 51        h = h[-1]
 52        if self.training and self.kappa > 0:
 53            self.update_gap(h.detach())
 54            # Halve kappa when the measured gap collapses; otherwise restore it.
 55            scale = 0.5 if self.gap < self.target_gap else 1.0
 56            std = math.sqrt(max(0.0, 2.0 * self.kappa * scale))
 57            angles = torch.randn((h.shape[0], 32), device=h.device) * std
 58            h = self.rotate_pairs(h, angles)
 59        return self.head(h)
 60
 61
 62def seed_all(seed):
 63    random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)
 64    if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed)
 65
 66
 67def train(seed, lr, weight_decay=0.0, kappa=0.0, epochs=3, n_train=400, n_test=400):
 68    seed_all(seed)
 69    ds = get_dataset("dynamics", seed, n_train=n_train, n_test=n_test)
 70    net = EquivRNN(ds["out_dim"], kappa=kappa).to(DEVICE)
 71    opt = torch.optim.Adam(net.parameters(), lr=lr, weight_decay=weight_decay)
 72    lossf = nn.MSELoss()
 73    x, y = ds["xtr"].to(DEVICE), ds["ytr"].to(DEVICE)
 74    for _ in range(epochs):
 75        net.train(); perm = torch.randperm(len(x), device=DEVICE)
 76        for i in range(0, len(x), 128):
 77            ix = perm[i:i+128]
 78            loss = lossf(net(x[ix]), y[ix])
 79            opt.zero_grad(); loss.backward(); opt.step()
 80    net.eval()
 81    with torch.no_grad():
 82        pred = net(ds["xte"].to(DEVICE))
 83        metric = float(((pred - ds["yte"].to(DEVICE)) ** 2).mean().cpu())
 84    return metric, net, ds
 85
 86
 87def math_sanity():
 88    # SO(2) Brownian averaging: the rank-2 anisotropy decays as exp(-4*kappa*t).
 89    k, times = 0.35, np.linspace(0, 4, 17)
 90    rng = np.random.default_rng(7); n = 30000
 91    obs = []
 92    for t in times:
 93        theta = rng.normal(0, math.sqrt(2*k*t), n)
 94        # q=(cos(2theta), sin(2theta)); its mean norm is exp(-4 k t).
 95        obs.append(float(np.hypot(np.mean(np.cos(2*theta)), np.mean(np.sin(2*theta)))))
 96    rate = -np.polyfit(times[2:], np.log(np.maximum(obs[2:], 1e-8)), 1)[0]
 97    pred = 4*k
 98    return {"predicted_rate": pred, "observed_rate": float(rate),
 99            "relative_error": float(abs(rate-pred)/pred),
100            "passed": bool(abs(rate-pred)/pred < 0.12)}
101
102
103def sweep():
104    # Union of all idea learning rates is also evaluated by baseline.
105    lrs = [1e-3, 3e-3, 1e-2]
106    wds = [0.0, 1e-4]
107    kappas = [0.002, 0.005, 0.01]
108    sweep_rows = []
109    for lr in lrs:
110        for wd in wds:
111            vals = [train(s, lr, wd, 0.0)[0] for s in range(4)]
112            sweep_rows.append({"cfg":{"lr":lr,"weight_decay":wd,"kappa":0.0}, "mean":float(np.mean(vals))})
113    best = min(sweep_rows, key=lambda z:z["mean"])["cfg"]
114    base_full = [train(s, best["lr"], best["weight_decay"], 0.0)[0] for s in range(8)]
115    # Idea sweep is three kappa values at the selected baseline lr/wd.
116    idea_rows = []
117    for kap in kappas:
118        vals = [train(s, best["lr"], best["weight_decay"], kap)[0] for s in range(8)]
119        idea_rows.append({"cfg":{"lr":best["lr"],"weight_decay":best["weight_decay"],"kappa":kap}, "mean":float(np.mean(vals)), "per_seed":vals})
120    best_i = min(idea_rows, key=lambda z:z["mean"])
121    return {"best_cfg":best, "sweep":sweep_rows, "full":{"mean":float(np.mean(base_full)),"std":float(np.std(base_full)),"per_seed":base_full,"n":8}}, best_i, idea_rows
122
123
124def signature(lr, weight_decay, kappa):
125    # Use a trained model's hidden representation, not a synthetic graph.
126    metric, net, ds = train(0, lr, weight_decay, kappa)
127    net.eval(); x = ds["xte"][:128].to(DEVICE)
128    with torch.no_grad(): _, h = net.rnn(x.view(len(x), -1, 3)); h = h[-1]
129    # Empirical anisotropy under cumulative Brownian commuting rotations.
130    rng = np.random.default_rng(991); ts = np.arange(1, 9, dtype=float)
131    vals=[]
132    z = h.detach().cpu().numpy().reshape(len(x),32,2)[:,0]
133    for t in ts:
134        th = rng.normal(0, math.sqrt(2*kappa*t), len(z))
135        a,b=z[:,0],z[:,1]; q=np.exp(2j*th)*(a+1j*b)**2
136        vals.append(abs(np.mean(q))/ (np.mean(a*a+b*b)+1e-8))
137    observed = -np.polyfit(ts, np.log(np.maximum(vals,1e-8)), 1)[0]
138    predicted = 4*kappa
139    return {"trained_model_metric":metric,"predicted_rate":predicted,"observed_rate":float(observed),"relative_error":float(abs(observed-predicted)/max(predicted,1e-8)),"confirmed":bool(abs(observed-predicted)/max(predicted,1e-8)<0.25),"mean_gap":float(np.mean(net.gap_history)) if net.gap_history else None}
140
141
142def main():
143    sanity = math_sanity()
144    base, best_i, rows = sweep()
145    idea = {"mean":float(best_i["mean"]),"std":float(np.std(best_i["per_seed"])),"per_seed":best_i["per_seed"],"n":8,"best_cfg":best_i["cfg"],"sweep":rows}
146    rep = make_report("dynamics", "rnn_small", base, idea, {"math_sanity":sanity,"nn_scale":signature(**best_i["cfg"])})
147    rep["track_justification"] = "Dynamics is the structural match because the idea targets recurrent hidden-state stability and controlled relaxation."
148    Path("bench_report.json").write_text(json.dumps(rep, indent=2))
149    print(json.dumps(rep, indent=2))
150
151if __name__ == "__main__":
152    try: main()
153    except RuntimeError as e:
154        if torch.cuda.is_available():
155            print("CUDA failed; rerun with CPU", str(e)[:200]); DEVICE = "cpu"; main()
156        else: raise