Passivity-Regularized Sequence Layer / bench_passivity.py

Mechanism confirmed, baseline not beaten

Raw ⬇ ZIP
  1import json
  2import random
  3import sys
  4from pathlib import Path
  5
  6import numpy as np
  7import torch
  8from torch import nn
  9
 10sys.path.insert(0, "/home/maxwelhelp/all/math2nn")
 11from bench import get_dataset
 12from bench.protocol import evaluate, make_report, sweep_baseline
 13
 14SEEDS = tuple(range(8))
 15SWEEP_SEEDS = tuple(range(4))
 16EPOCHS = 12
 17BATCH = 128
 18
 19
 20def seed_all(seed):
 21    random.seed(seed)
 22    np.random.seed(seed)
 23    torch.manual_seed(seed)
 24    if torch.cuda.is_available():
 25        torch.cuda.manual_seed_all(seed)
 26
 27
 28class MatchedRNN(nn.Module):
 29    """The bench rnn_small architecture, with optional trajectory exposure."""
 30    def __init__(self, hidden=64):
 31        super().__init__()
 32        self.rnn = nn.GRU(3, hidden, batch_first=True)
 33        self.head = nn.Linear(hidden, 1)
 34
 35    def forward(self, x, return_state=False):
 36        seq = x.view(x.shape[0], -1, 3)
 37        hidden, _ = self.rnn(seq)
 38        yseq = self.head(hidden).squeeze(-1)
 39        pred = self.head(hidden[:, -1])
 40        if return_state:
 41            return pred, yseq, hidden
 42        return pred
 43
 44
 45def normalized_dataset(ds):
 46    d = dict(ds)
 47    mu = ds["xtr"].mean(0, keepdim=True)
 48    sd = ds["xtr"].std(0, keepdim=True).clamp_min(1e-4)
 49    for k in ("xtr", "xte"):
 50        d[k] = (ds[k] - mu) / sd
 51    ym = ds["ytr"].mean()
 52    ys = ds["ytr"].std().clamp_min(1e-4)
 53    d["ytr"] = (ds["ytr"] - ym) / ys
 54    d["yte"] = (ds["yte"] - ym) / ys
 55    return d
 56
 57
 58def passivity_loss(x, out, h, gamma=0.1, paired=None, eta=0.1):
 59    # h0=0, and output energy is assigned to each recurrent step.
 60    h0 = torch.zeros(x.shape[0], 1, h.shape[-1], device=x.device, dtype=h.dtype)
 61    hall = torch.cat((h0, h), dim=1)
 62    u = x.view(x.shape[0], -1, 3)
 63    y = out
 64    r = (hall[:, 1:].square().sum(-1) + y.square()
 65         - hall[:, :-1].square().sum(-1) - u.square().sum(-1))
 66    loss = torch.relu(r).mean() + gamma * torch.relu(r.sum(1)).mean()
 67    stats = {
 68        "max_positive_residual": float(torch.relu(r).max().detach().cpu()),
 69        "mean_cumulative_residual": float(r.sum(1).mean().detach().cpu()),
 70        "mean_hidden_norm": float(h.norm(dim=-1).mean().detach().cpu()),
 71    }
 72    if paired is not None:
 73        x2, out2, h2 = paired
 74        dh0 = torch.zeros(x.shape[0], 1, h.shape[-1], device=x.device, dtype=h.dtype)
 75        dh = torch.cat((dh0, h2 - h), dim=1)
 76        du = (x2 - x).view(x.shape[0], -1, 3)
 77        dy = out2 - out
 78        ri = (dh[:, 1:].square().sum(-1) + dy.square()
 79              - dh[:, :-1].square().sum(-1) - du.square().sum(-1))
 80        inc = torch.relu(ri).mean()
 81        loss = loss + eta * inc
 82        stats["incremental_penalty"] = float(inc.detach().cpu())
 83    return loss, stats
 84
 85
 86def run_one(seed, lr, lam, paired, return_stats=False):
 87    seed_all(seed)
 88    device = "cuda" if torch.cuda.is_available() else "cpu"
 89    try:
 90        ds = normalized_dataset(get_dataset("dynamics", seed, n_train=400, n_test=200))
 91        model = MatchedRNN().to(device)
 92        opt = torch.optim.Adam(model.parameters(), lr=lr)
 93        xtr, ytr = ds["xtr"].to(device), ds["ytr"].to(device)
 94        for _ in range(EPOCHS):
 95            model.train()
 96            perm = torch.randperm(len(xtr), device=device)
 97            for start in range(0, len(xtr), BATCH):
 98                ix = perm[start:start+BATCH]
 99                x = xtr[ix]; target = ytr[ix]
100                pred, out, h = model(x, True)
101                pair = None
102                if paired:
103                    x2 = x + 0.02 * torch.randn_like(x)
104                    pred2, out2, h2 = model(x2, True)
105                    pair = (x2, out2, h2)
106                task = nn.functional.mse_loss(pred, target)
107                pl, _ = passivity_loss(x, out, h, paired=pair)
108                loss = task + lam * pl
109                opt.zero_grad(); loss.backward()
110                torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0)
111                opt.step()
112        model.eval()
113        with torch.no_grad():
114            pred, out, h = model(ds["xte"].to(device), True)
115            metric = float(nn.functional.mse_loss(pred, ds["yte"].to(device)).cpu())
116            _, st = passivity_loss(ds["xte"].to(device), out, h)
117            x = ds["xte"].to(device)
118            x2 = x + 0.02 * torch.randn_like(x)
119            _, _, h2 = model(x2, True)
120            amp = ((h2[:, -1] - h[:, -1]).norm(dim=1) /
121                   (x2 - x).view(x.shape[0], -1).norm(dim=1).clamp_min(1e-8)).mean()
122            st["amplification"] = float(amp.cpu())
123        return (metric, st) if return_stats else metric
124    except RuntimeError:
125        if device == "cuda":
126            torch.cuda.empty_cache()
127            old = torch.cuda.is_available
128            # Explicit CPU retry without changing the benchmark architecture.
129            torch.set_default_device("cpu")
130            try:
131                return run_one_cpu(seed, lr, lam, paired, return_stats)
132            finally:
133                torch.set_default_device("cpu")
134        raise
135
136
137def run_one_cpu(seed, lr, lam, paired, return_stats=False):
138    seed_all(seed)
139    ds = normalized_dataset(get_dataset("dynamics", seed, n_train=400, n_test=200))
140    model = MatchedRNN()
141    opt = torch.optim.Adam(model.parameters(), lr=lr)
142    xtr, ytr = ds["xtr"], ds["ytr"]
143    for _ in range(EPOCHS):
144        perm = torch.randperm(len(xtr))
145        for start in range(0, len(xtr), BATCH):
146            x = xtr[perm[start:start+BATCH]]; target = ytr[perm[start:start+BATCH]]
147            pred, out, h = model(x, True); pair = None
148            if paired:
149                x2 = x + 0.02 * torch.randn_like(x)
150                _, out2, h2 = model(x2, True); pair = (x2, out2, h2)
151            task = nn.functional.mse_loss(pred, target)
152            pl, _ = passivity_loss(x, out, h, paired=pair)
153            opt.zero_grad(); (task + lam * pl).backward()
154            torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0); opt.step()
155    model.eval()
156    with torch.no_grad():
157        x = ds["xte"]; pred, out, h = model(x, True)
158        metric = float(nn.functional.mse_loss(pred, ds["yte"]))
159        _, st = passivity_loss(x, out, h)
160        x2 = x + 0.02 * torch.randn_like(x); _, _, h2 = model(x2, True)
161        st["amplification"] = float(((h2[:, -1]-h[:, -1]).norm(dim=1) /
162            (x2-x).view(x.shape[0],-1).norm(dim=1).clamp_min(1e-8)).mean())
163    return (metric, st) if return_stats else metric
164
165
166def main():
167    # Search-space parity: every idea learning rate is also swept by baseline.
168    lrs = (1e-3, 3e-3, 1e-2)
169    grid = [{"lr": lr} for lr in lrs]
170    base = sweep_baseline(
171        lambda cfg: lambda seed: run_one(seed, cfg["lr"], 0.0, False),
172        grid, seeds=SWEEP_SEEDS)
173    best_lr = base["best_cfg"]["lr"]
174    # Three regularizer settings, with and without the optional paired term.
175    idea_grid = [{"lr": lr, "lam": lam, "paired": paired}
176                 for lr in lrs for lam in (1e-3, 1e-2, 1e-1)
177                 for paired in (False, True)]
178    scored = []
179    for cfg in idea_grid:
180        vals = [run_one(s, cfg["lr"], cfg["lam"], cfg["paired"])
181                for s in SWEEP_SEEDS]
182        scored.append((float(np.mean(vals)), cfg))
183    idea_cfg = min(scored, key=lambda z: z[0])[1]
184    idea = evaluate(lambda s: run_one(s, idea_cfg["lr"], idea_cfg["lam"], idea_cfg["paired"]),
185                    seeds=SEEDS)
186
187    # Signature is measured on separately trained baseline and idea systems.
188    base_stats = [run_one(s, best_lr, 0.0, False, True)[1] for s in SEEDS]
189    idea_stats = [run_one(s, idea_cfg["lr"], idea_cfg["lam"], idea_cfg["paired"], True)[1]
190                  for s in SEEDS]
191    b_res = float(np.mean([z["mean_cumulative_residual"] for z in base_stats]))
192    i_res = float(np.mean([z["mean_cumulative_residual"] for z in idea_stats]))
193    b_amp = float(np.mean([z["amplification"] for z in base_stats]))
194    i_amp = float(np.mean([z["amplification"] for z in idea_stats]))
195    signature = {
196        "baseline_cumulative_residual": b_res,
197        "idea_cumulative_residual": i_res,
198        "baseline_amplification": b_amp,
199        "idea_amplification": i_amp,
200        "predicted_direction": "passivity reduces residual and amplification",
201        "confirmed": bool(i_res < b_res and i_amp < b_amp),
202    }
203    report = make_report(
204        "dynamics", "rnn_small", {**base, "selected_cfg": base["best_cfg"]}, idea,
205        {"selected_idea_cfg": idea_cfg, "mechanism_signature": signature})
206    Path("bench_report.json").write_text(json.dumps(report, indent=2))
207    print(json.dumps(report, indent=2))
208
209
210if __name__ == "__main__":
211    main()