Cohomological Jacobian Flattening / stage2_coh_bench.py

Mechanism confirmed, baseline not beaten

Raw ⬇ ZIP
  1import sys, json, 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_model, evaluate, sweep_baseline, make_report
  9
 10TRACK = "dynamics"
 11MODEL = "rnn_small"
 12DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
 13
 14class Potential(nn.Module):
 15    def __init__(self, dim):
 16        super().__init__()
 17        self.net = nn.Sequential(nn.Linear(dim, 32), nn.Tanh(), nn.Linear(32, 1))
 18    def forward(self, x):
 19        z = self.net(x)
 20        return z - z.mean()                 # minibatch gauge fixing
 21
 22def seed_all(seed):
 23    random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)
 24    if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed)
 25
 26def make_next(x, pred):
 27    # A learned one-coordinate state transition: retain the observed window and
 28    # replace its final angle by the model's next-angle prediction.
 29    z = x.clone()
 30    z[:, -3] = pred[:, 0]
 31    return z
 32
 33def train_one(seed, cfg, mode, capture=False):
 34    global DEVICE
 35    seed_all(seed)
 36    ds = get_dataset(TRACK, seed, n_train=400, n_test=200)
 37    net = make_model(MODEL, ds["input_shape"], ds["out_dim"])
 38    pot = Potential(ds["xtr"].shape[1]) if mode == "coh" else None
 39    c = nn.Parameter(torch.tensor(0.0)) if mode == "coh" else None
 40    params = list(net.parameters()) + ([] if pot is None else list(pot.parameters()) + [c])
 41    opt = torch.optim.Adam(params, lr=cfg["lr"])
 42    dev = DEVICE
 43    try:
 44        net.to(dev); dsx, dsy = ds["xtr"].to(dev), ds["ytr"].to(dev)
 45        if pot is not None: pot.to(dev); c.data = c.data.to(dev)
 46        for ep in range(cfg["epochs"]):
 47            net.train()
 48            perm = torch.randperm(len(dsx), device=dev)
 49            for ii in range(0, len(dsx), 64):
 50                x = dsx[perm[ii:ii+64]].detach().requires_grad_(True)
 51                pred = net(x)
 52                task = ((pred - dsy[perm[ii:ii+64]]) ** 2).mean()
 53                # selected 1D unstable-coordinate log Jacobian proxy
 54                g = torch.autograd.grad(pred[:, 0].sum(), x, create_graph=True)[0]
 55                ell = torch.log(g[:, -3].abs() + 1e-3)
 56                if mode == "none":
 57                    reg = 0.0
 58                elif mode == "point":
 59                    reg = ((ell - c0(ell)) ** 2).mean()
 60                else:
 61                    nxt = make_next(x, pred)
 62                    reg = (ell - pot(nxt)[:, 0] + pot(x)[:, 0] - c) .pow(2).mean()
 63                loss = task + cfg["lam"] * reg
 64                opt.zero_grad(); loss.backward(); torch.nn.utils.clip_grad_norm_(params, 5.0); opt.step()
 65        net.eval()
 66        with torch.no_grad():
 67            metric = float(((net(ds["xte"].to(dev)) - ds["yte"].to(dev))**2).mean())
 68        if capture:
 69            return metric, net, pot, c, ds
 70        return metric
 71    except RuntimeError:
 72        # Explicit CPU fallback for constrained CUDA/cuDNN environments.
 73        if dev != "cpu":
 74            DEVICE = "cpu"
 75            return train_one(seed, cfg, mode, capture)
 76        raise
 77
 78def c0(ell):
 79    return ell.mean().detach()  # pointwise constant baseline, no learned potential
 80
 81def signature(net, pot, c, ds, seed=0):
 82    if pot is None: return {"confirmed": False, "reason": "no potential"}
 83    net.eval(); pot.eval(); dev = next(net.parameters()).device
 84    x = ds["xte"][:48].to(dev)
 85    rows = []
 86    for k in (1, 4, 8):
 87        z = x.clone(); s = torch.zeros(len(z), device=dev)
 88        for _ in range(k):
 89            z.requires_grad_(True); p = net(z)
 90            gg = torch.autograd.grad(p[:,0].sum(), z, create_graph=False)[0]
 91            ell = torch.log(gg[:, -3].abs() + 1e-3)
 92            s += ell
 93            z = make_next(z.detach(), p.detach())
 94        with torch.no_grad():
 95            r = s - (pot(z)[:,0] - pot(x)[:,0] + k*c)
 96            rows.append({"k": k, "observed_std_R_over_k": float((r/k).std()),
 97                         "observed_std_ell_minus_c": float((s/k-c).std())})
 98    vals = np.array([q["observed_std_R_over_k"] for q in rows])
 99    # Stage-1 prediction: coboundary residual should not grow linearly with horizon.
100    confirmed = bool(vals[-1] <= 1.25 * vals[0] + 1e-8)
101    return {"prediction": "finite-horizon residual per step remains bounded rather than accumulating linearly",
102            "observed": rows, "confirmed": confirmed}
103
104def main():
105    # Same union is used by baseline sweep and idea settings (parity).
106    grid = [{"lr": lr, "lam": lam, "epochs": 10} for lr in (1e-3, 3e-3, 1e-2) for lam in (1e-3, 1e-2)]
107    cache = {}
108    def maker(mode):
109        def f(cfg):
110            return lambda seed: train_one(seed, cfg, mode)
111        return f
112    base = sweep_baseline(maker("point"), grid)
113    best = base["best_cfg"]
114    # Best baseline config plus two nearby settings are all represented in grid.
115    idea_grid = [best, {"lr": 1e-3, "lam": best["lam"], "epochs": 10},
116                 {"lr": 1e-2, "lam": best["lam"], "epochs": 10}]
117    idea_runs = []
118    chosen = idea_grid[0]
119    for cfg in idea_grid:
120        r = evaluate(maker("coh")(cfg))
121        idea_runs.append({"cfg": cfg, "result": r})
122    chosen_run = min(idea_runs, key=lambda q: q["result"]["mean"])
123    idea = chosen_run["result"]
124    # Refit one paired seed for a mechanism signature from trained networks.
125    m, n, p, cc, ds = train_one(0, chosen_run["cfg"], "coh", True)
126    sig = signature(n, p, cc, ds)
127    report = make_report(TRACK, MODEL, base, idea, {"mechanism_signature": sig,
128        "idea_config_sweep": idea_runs, "device": DEVICE,
129        "primary_metric": "test MSE"})
130    report["baseline"]["method"] = "pointwise constant selected-coordinate log-Jacobian"
131    report["idea"]["method"] = "cohomological potential residual"
132    report["protocol_notes"] = "8 paired seeds; baseline sweep uses 4 seeds then full reevaluation; shared lr/lambda union"
133    Path("bench_report.json").write_text(json.dumps(report, indent=2))
134    print(json.dumps(report, indent=2))
135
136if __name__ == "__main__": main()