Nonreciprocal Brownian Optimizer / bench_experiment.py

Mechanism confirmed, baseline not beaten

Raw ⬇ ZIP
  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, evaluate, sweep_baseline, make_report
  8
  9SEED0 = 1505
 10EPOCHS = 15
 11BATCH = 128
 12# This is the complete shared search-space union. The idea is evaluated at all
 13# three learning rates; baseline is also evaluated at all three rates.
 14LR_GRID = [0.0015, 0.003, 0.006]
 15WD_GRID = [0.0, 1e-4]
 16# Fixed a priori stable, moderate asymmetric coupling; nearby settings vary lr.
 17K1, K2 = 0.20, 0.05
 18
 19
 20def seed_all(seed):
 21    random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)
 22    if torch.cuda.is_available():
 23        torch.cuda.manual_seed_all(seed)
 24
 25
 26def device_for():
 27    return "cuda" if torch.cuda.is_available() else "cpu"
 28
 29
 30def batches(n, batch, rng):
 31    order = rng.permutation(n)
 32    for i in range(0, n, batch):
 33        yield order[i:i+batch]
 34
 35
 36def baseline_run(cfg, seed, collect=False):
 37    seed_all(seed)
 38    ds = get_dataset("tabular", seed, n_train=4000, n_test=1000)
 39    dev = device_for()
 40    try:
 41        net = make_model("mlp_tiny", ds["input_shape"], ds["out_dim"]).to(dev)
 42        opt = torch.optim.Adam(net.parameters(), lr=float(cfg["lr"]),
 43                               weight_decay=float(cfg["weight_decay"]))
 44        lossf = nn.MSELoss()
 45        rng = np.random.default_rng(seed + 991)
 46        net.train()
 47        for ep in range(EPOCHS):
 48            for ix in batches(len(ds["xtr"]), BATCH, rng):
 49                x = ds["xtr"][ix].to(dev); y = ds["ytr"][ix].to(dev)
 50                opt.zero_grad(set_to_none=True)
 51                lossf(net(x), y).backward(); opt.step()
 52        net.eval()
 53        with torch.no_grad():
 54            val = float(lossf(net(ds["xte"].to(dev)), ds["yte"].to(dev)).cpu())
 55        return val
 56    except Exception:
 57        # Robust CPU retry, including CUDA/cuDNN allocation failures.
 58        if dev != "cuda": raise
 59        torch.cuda.empty_cache()
 60        os.environ["CUDA_VISIBLE_DEVICES"] = ""
 61        return baseline_run(cfg, seed, collect)
 62
 63
 64def idea_run(cfg, seed, collect=False):
 65    seed_all(seed)
 66    ds = get_dataset("tabular", seed, n_train=4000, n_test=1000)
 67    dev = device_for()
 68    try:
 69        a = make_model("mlp_tiny", ds["input_shape"], ds["out_dim"]).to(dev)
 70        b = make_model("mlp_tiny", ds["input_shape"], ds["out_dim"]).to(dev)
 71        b.load_state_dict(a.state_dict())
 72        # SGD is the displayed Brownian update; baseline uses standard Adam.
 73        pa, pb = list(a.parameters()), list(b.parameters())
 74        lr = float(cfg["lr"]); wd = float(cfg["weight_decay"])
 75        rng1 = np.random.default_rng(seed + 1771); rng2 = np.random.default_rng(seed + 2771)
 76        lossf = nn.MSELoss(); history = []
 77        a.train(); b.train()
 78        for ep in range(EPOCHS):
 79            # Anneal directed coupling and effective temperature to zero.
 80            frac = 1.0 - ep / max(1, EPOCHS - 1)
 81            k1, k2 = K1 * frac, K2 * frac
 82            for ix1, ix2 in zip(batches(len(ds["xtr"]), BATCH, rng1), batches(len(ds["xtr"]), BATCH, rng2)):
 83                x1=ds["xtr"][ix1].to(dev); y1=ds["ytr"][ix1].to(dev)
 84                x2=ds["xtr"][ix2].to(dev); y2=ds["ytr"][ix2].to(dev)
 85                la = lossf(a(x1), y1); lb = lossf(b(x2), y2)
 86                ga = torch.autograd.grad(la, pa, create_graph=False)
 87                gb = torch.autograd.grad(lb, pb, create_graph=False)
 88                with torch.no_grad():
 89                    for u,v,gu,gv in zip(pa,pb,ga,gb):
 90                        # Couplings are applied as parameter forces; a small
 91                        # shared weight decay is the only common regularizer.
 92                        du = gu + k1*(u-v) + wd*u
 93                        dv = gv + k2*(v-u) + wd*v
 94                        u.add_(du, alpha=-lr); v.add_(dv, alpha=-lr)
 95            if collect:
 96                with torch.no_grad():
 97                    va = torch.cat([p.detach().flatten().cpu() for p in pa])
 98                    vb = torch.cat([p.detach().flatten().cpu() for p in pb])
 99                    history.append((va, vb))
100        a.eval(); b.eval()
101        with torch.no_grad():
102            avg = [(u+v)*0.5 for u,v in zip(pa,pb)]
103            # Evaluate the trained averaged system identically on the task.
104            pred = ds["xte"].to(dev)
105            out = torch.zeros((len(pred),1), device=dev)
106            for xpart, opart in [(None,None)]:
107                # Functional evaluation avoids changing either trained replica.
108                from torch.func import functional_call
109                sd = {n:(p+q)*0.5 for (n,p),(n2,q) in zip(a.named_parameters(), b.named_parameters())}
110                out = functional_call(a, sd, (pred,))
111            val = float(lossf(out, ds["yte"].to(dev)).cpu())
112        if collect: return val, history
113        return val
114    except Exception:
115        if dev != "cuda": raise
116        torch.cuda.empty_cache(); os.environ["CUDA_VISIBLE_DEVICES"] = ""
117        return idea_run(cfg, seed, collect)
118
119
120def main():
121    # Baseline's decisive standard knobs (Adam lr and weight decay) are swept.
122    grid = [{"lr": lr, "weight_decay": wd} for lr in LR_GRID for wd in WD_GRID]
123    base = sweep_baseline(lambda c: (lambda s: baseline_run(c, s)), grid)
124    # Full paired idea sweep at the same learning-rate union; weight decay is
125    # held at the baseline winner's value and coupling is fixed a priori.
126    wd = float(base["best_cfg"]["weight_decay"])
127    idea_grid = [{"lr": lr, "weight_decay": wd} for lr in LR_GRID]
128    idea_scores = []
129    for c in idea_grid:
130        r = evaluate(lambda s, c=c: idea_run(c, s), seeds=tuple(range(8)))
131        idea_scores.append({"cfg": c, "result": r})
132    best = min(idea_scores, key=lambda z: z["result"]["mean"])
133    # Model-behaviour signature: area/circulation in the two-replica state,
134    # measured from every epoch's trained parameter vectors on seed 0.
135    sig_val, traj = idea_run(best["cfg"], 0, collect=True)
136    if len(traj) > 2:
137        x1=np.array([float(z[0][0]) for z in traj]); x2=np.array([float(z[1][0]) for z in traj])
138        area=float(np.mean(x1[:-1]*x2[1:]-x2[:-1]*x1[1:]))
139        sep=float(np.mean([torch.linalg.vector_norm(z[0]-z[1]).item() for z in traj]))
140    else: area=0.0; sep=0.0
141    # The predicted direction is nonzero circulation for k1 != k2. Confirmed
142    # only if it is distinguishable from numerical zero; no claimed task win.
143    signature={"prediction":"nonzero replica circulation when k1!=k2",
144               "k1":K1,"k2":K2,"observed_epoch_area_seed0":area,
145               "observed_mean_replica_separation":sep,
146               "confirmed": bool(abs(area)>1e-12 and sep>1e-8)}
147    report=make_report("tabular","mlp_tiny",base,best["result"],extra=signature)
148    report["idea_sweep"]=idea_scores
149    report["protocol_notes"]={"epochs":EPOCHS,"batch":BATCH,"paired_seeds":list(range(8)),
150      "track_rationale":"tabular is the prescribed structural track for optimizer modifications",
151      "equal_budget_note":"same epochs and minibatch budget per replica; the idea uses two replicas and thus approximately 2x parameter-update compute"}
152    with open("bench_report.json","w") as f: json.dump(report,f,indent=2)
153    print(json.dumps(report,indent=2))
154
155if __name__ == "__main__": main()