ISS-Gated Positive Neural State Module / bench_experiment.py

Failed on benchmark

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, train_model, evaluate, sweep_baseline, make_report
  9
 10SEEDS = tuple(range(8))
 11EPOCHS = 12
 12BATCH = 128
 13# Shared union: baseline is evaluated at every lr/wd used by idea.
 14GRID = [
 15    {"lr": 1e-3, "weight_decay": 0.0},
 16    {"lr": 2e-3, "weight_decay": 0.0},
 17    {"lr": 3e-3, "weight_decay": 0.0},
 18    {"lr": 1e-3, "weight_decay": 1e-4},
 19    {"lr": 2e-3, "weight_decay": 1e-4},
 20    {"lr": 3e-3, "weight_decay": 1e-4},
 21]
 22IDEA_GRID = [
 23    {"lr": 1e-3, "weight_decay": 0.0, "lambda_iss": 0.0},
 24    {"lr": 2e-3, "weight_decay": 0.0, "lambda_iss": 0.01},
 25    {"lr": 3e-3, "weight_decay": 1e-4, "lambda_iss": 0.03},
 26]
 27
 28
 29def seed_all(seed):
 30    random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)
 31    if torch.cuda.is_available():
 32        try: torch.cuda.manual_seed_all(seed)
 33        except Exception: pass
 34
 35
 36class PositiveISSRNN(nn.Module):
 37    """Positive birth/death mass-action recurrent state with ISS residual."""
 38    def __init__(self, out_dim=1, hidden=64, dt=0.25):
 39        super().__init__()
 40        self.hidden, self.dt = hidden, dt
 41        self.rate = nn.Linear(3, 2 * hidden)
 42        self.readout = nn.Linear(hidden, out_dim)
 43        self.register_buffer("h_star", torch.ones(hidden))
 44        self.last_signature = {}
 45        self.last_v_tensor = None
 46        self.last_vdot_tensor = None
 47
 48    def forward(self, x, return_stats=False):
 49        seq = x.view(x.shape[0], -1, 3)
 50        h = torch.ones(x.shape[0], self.hidden, device=x.device)
 51        v_before, vdot_vals, h_vals = [], [], []
 52        for t in range(seq.shape[1]):
 53            u = torch.nn.functional.softplus(self.rate(seq[:, t])) + 1e-4
 54            prod, decay = u[:, :self.hidden], u[:, self.hidden:]
 55            # x -> 2x and x -> empty: f = prod - decay*h
 56            v = h * torch.log(torch.clamp(h / self.h_star, min=1e-8)) - h + self.h_star
 57            h_next = torch.clamp(h + self.dt * (prod - decay * h), min=1e-5)
 58            vn = h_next * torch.log(torch.clamp(h_next / self.h_star, min=1e-8)) - h_next + self.h_star
 59            v_before.append(v.mean()); vdot_vals.append(((vn.sum(1)-v.sum(1))/self.dt).mean())
 60            h = h_next; h_vals.append(h.detach())
 61        out = self.readout(h)
 62        self.last_v_tensor = torch.stack(v_before).mean()
 63        self.last_vdot_tensor = torch.stack(vdot_vals).mean()
 64        if return_stats:
 65            self.last_signature = {"V": float(torch.stack(v_before).mean().detach().cpu()),
 66                                   "Vdot": float(torch.stack(vdot_vals).mean().detach().cpu()),
 67                                   "h_max": float(torch.cat(h_vals).max().cpu()),
 68                                   "h_min": float(torch.cat(h_vals).min().cpu())}
 69        return out
 70
 71
 72def train_idea(ds, cfg, seed):
 73    seed_all(seed)
 74    net = PositiveISSRNN(out_dim=1)
 75    net.train()
 76    # The ISS penalty is the intervention, hence a local loop is appropriate.
 77    opt = torch.optim.Adam(net.parameters(), lr=cfg["lr"], weight_decay=cfg["weight_decay"])
 78    x, y = ds["xtr"], ds["ytr"]
 79    for _ in range(EPOCHS):
 80        perm = torch.randperm(len(x))
 81        for i in range(0, len(x), BATCH):
 82            ix = perm[i:i+BATCH]; pred = net(x[ix])
 83            task = ((pred-y[ix])**2).mean()
 84            # Approximate nominal c=0.02, bounded-rate gain k=0.02.
 85            # Penalize excessive positive free-energy derivative on observed states.
 86            residual = torch.relu(net.last_vdot_tensor + 0.02 * net.last_v_tensor)
 87            loss = task + cfg["lambda_iss"] * residual * residual
 88            opt.zero_grad(); loss.backward(); torch.nn.utils.clip_grad_norm_(net.parameters(), 5.0); opt.step()
 89    net.eval()
 90    with torch.no_grad():
 91        metric = float(((net(ds["xte"])-ds["yte"])**2).mean())
 92        net(ds["xte"], return_stats=True)
 93    return metric, net.last_signature
 94
 95
 96def base_fn(cfg):
 97    def run(seed):
 98        seed_all(seed); ds = get_dataset("dynamics", seed, n_train=400, n_test=200)
 99        net = make_model("rnn_small", ds["input_shape"], ds["out_dim"])
100        _, metric, _ = train_model(net, ds, epochs=EPOCHS, lr=cfg["lr"], batch=BATCH, weight_decay=cfg["weight_decay"], log=lambda *_: None)
101        return metric
102    return run
103
104
105def idea_fn(cfg, collect=False):
106    records = []
107    def run(seed):
108        ds = get_dataset("dynamics", seed, n_train=400, n_test=200)
109        metric, sig = train_idea(ds, cfg, seed)
110        if collect: records.append(sig)
111        return metric
112    return run, records
113
114
115def main():
116    # Baseline sweep uses exactly the same six settings' lr/wd union.
117    base = sweep_baseline(base_fn, GRID, seeds=SEEDS)
118    # Idea sweep over three settings; all corresponding lr/wd values are in base grid.
119    idea_trials = []
120    for cfg in IDEA_GRID:
121        fn, _ = idea_fn(cfg)
122        r = evaluate(fn, SEEDS)
123        idea_trials.append({"cfg": cfg, "mean": r["mean"], "result": r})
124    best = min(idea_trials, key=lambda z: z["mean"])
125    idea_res = best["result"]
126    # Re-run chosen idea settings for signature on all paired trained models.
127    fn, sigs = idea_fn(best["cfg"], collect=True)
128    idea_res = evaluate(fn, SEEDS)
129    baseline_full = base["full"]
130    # Trained baseline behavior at best configuration, for identical test inputs.
131    base_sigs = []
132    for seed in SEEDS:
133        seed_all(seed); ds = get_dataset("dynamics", seed, n_train=400, n_test=200)
134        net = make_model("rnn_small", ds["input_shape"], ds["out_dim"])
135        net, _, _ = train_model(net, ds, epochs=EPOCHS, lr=base["best_cfg"]["lr"], batch=BATCH, weight_decay=base["best_cfg"]["weight_decay"], log=lambda *_: None)
136        with torch.no_grad():
137            dev = next(net.parameters()).device
138            out = net(ds["xte"].to(dev))
139        base_sigs.append({"pred_std": float(out.std()), "pred_abs_mean": float(out.abs().mean())})
140    sig = {"prediction": {"claim": "bounded positive state should remain finite and ISS energy should not grow", "idea_mean_V": float(np.mean([s["V"] for s in sigs])), "idea_mean_Vdot": float(np.mean([s["Vdot"] for s in sigs])), "idea_h_min": float(np.min([s["h_min"] for s in sigs])), "idea_h_max": float(np.max([s["h_max"] for s in sigs])), "baseline_pred_abs_mean": float(np.mean([s["pred_abs_mean"] for s in base_sigs]))}, "confirmed": bool(all(s["h_min"] > 0 and np.isfinite(s["V"]) and np.isfinite(s["Vdot"]) for s in sigs))}
141    report = make_report("dynamics", "rnn_small", base, idea_res, sig)
142    report["idea_sweep"] = [{"cfg": z["cfg"], "mean": z["mean"]} for z in idea_trials]
143    Path("bench_report.json").write_text(json.dumps(report, indent=2))
144    print(json.dumps(report, indent=2))
145
146if __name__ == "__main__": main()