Contractive projected residual dynamics / experiment.py

✓✓ Beats tuned baseline

Raw ⬇ ZIP
  1import json, math, random
  2import numpy as np
  3import torch
  4from torch import nn
  5
  6SEED = 7
  7random.seed(SEED); np.random.seed(SEED); torch.manual_seed(SEED)
  8try:
  9    device = "cuda" if torch.cuda.is_available() else "cpu"
 10    if device == "cuda":
 11        torch.cuda.manual_seed_all(SEED)
 12except Exception:
 13    device = "cpu"
 14
 15
 16def tangent_project(x, q, lo=-1.0, hi=1.0):
 17    """Projection of q onto the tangent cone of [lo,hi]^d at x."""
 18    p = q.clone()
 19    p = torch.where(x <= lo, torch.clamp(p, min=0), p)
 20    p = torch.where(x >= hi, torch.clamp(p, max=0), p)
 21    return p
 22
 23
 24def numerical_math_check():
 25    # Same input-dependent forcing cancels in the difference.  The drift is -Mx,
 26    # M positive semidefinite, and the projected Euler flow stays in the box.
 27    rng = np.random.default_rng(SEED)
 28    d, trials, steps = 5, 1000, 2000
 29    R = rng.normal(size=(d, d)); M = R.T @ R / d + .15*np.eye(d)
 30    ratios = []; max_box = 0.; violations = 0
 31    h = 0.01
 32    for _ in range(trials):
 33        x = rng.uniform(-1, 1, d); z = rng.uniform(-1, 1, d)
 34        # common bounded forcing represents identical v(t)
 35        force = rng.normal(size=d) * .2
 36        initial = np.linalg.norm(x-z)
 37        for _ in range(steps):
 38            qx = -M @ x + force; qz = -M @ z + force
 39            px = qx.copy(); pz = qz.copy()
 40            px[x <= -1] = np.maximum(px[x <= -1], 0); px[x >= 1] = np.minimum(px[x >= 1], 0)
 41            pz[z <= -1] = np.maximum(pz[z <= -1], 0); pz[z >= 1] = np.minimum(pz[z >= 1], 0)
 42            x = np.clip(x + h*px, -1, 1); z = np.clip(z + h*pz, -1, 1)
 43            max_box = max(max_box, float(np.max(np.abs(x))), float(np.max(np.abs(z))))
 44        final = np.linalg.norm(x-z)
 45        ratios.append(final / (initial + 1e-12))
 46        violations += int(final > initial + 2e-7)
 47    ratios = np.asarray(ratios)
 48    return {"trials": trials, "max_distance_ratio": float(ratios.max()),
 49            "mean_distance_ratio": float(ratios.mean()), "violations": violations,
 50            "max_abs_state": max_box, "step": h, "duration": steps*h}
 51
 52
 53class VanillaResidual(nn.Module):
 54    def __init__(self, inp, hid):
 55        super().__init__(); self.hid = hid
 56        self.wx = nn.Linear(hid, hid, bias=False); self.wv = nn.Linear(inp, hid)
 57        nn.init.normal_(self.wx.weight, std=.35/math.sqrt(hid))
 58    def forward(self, seq):
 59        x = torch.zeros(seq.size(0), self.hid, device=seq.device)
 60        for t in range(seq.size(1)):
 61            x = x + .08*torch.tanh(self.wx(x) + self.wv(seq[:,t]))
 62        return x
 63
 64class ProjectedDissipative(nn.Module):
 65    def __init__(self, inp, hid):
 66        super().__init__(); self.hid=hid; self.h=.08
 67        self.L = nn.Parameter(torch.randn(hid, hid)*(.18/math.sqrt(hid)))
 68        self.B = nn.Linear(inp, hid); self.bias=nn.Parameter(torch.zeros(hid))
 69    def forward(self, seq):
 70        x=torch.zeros(seq.size(0), self.hid, device=seq.device)
 71        # positive lambda makes the drift strictly dissipative
 72        M=self.L.T@self.L + .12*torch.eye(self.hid, device=seq.device)
 73        for t in range(seq.size(1)):
 74            q=-(x@M.T) + self.B(seq[:,t]) + self.bias
 75            p=tangent_project(x,q)
 76            x=torch.clamp(x+self.h*p,-1,1)
 77        return x
 78
 79
 80def train_model(model, train, target, test, ytest, steps=300):
 81    model.to(device); opt=torch.optim.Adam(model.parameters(), lr=3e-3); lossfn=nn.BCEWithLogitsLoss()
 82    head=nn.Linear(model.hid,1).to(device); opt=torch.optim.Adam(list(model.parameters())+list(head.parameters()),lr=3e-3)
 83    xtr=train.to(device); y=target.to(device)
 84    losses=[]
 85    for k in range(steps):
 86        opt.zero_grad(); pred=head(model(xtr)).squeeze(-1); loss=lossfn(pred,y); loss.backward(); opt.step()
 87        if k in (0, steps-1): losses.append(float(loss.detach().cpu()))
 88    with torch.no_grad():
 89        acc=((torch.sigmoid(head(model(test.to(device))).squeeze(-1))>.5)==ytest.to(device)).float().mean().item()
 90        # response to a small initial-state perturbation, using identical sequence
 91        def run_with_initial(s, initial):
 92            x=initial
 93            if isinstance(model, VanillaResidual):
 94                for t in range(s.size(1)): x=x+.08*torch.tanh(model.wx(x)+model.wv(s[:,t]))
 95            else:
 96                M=model.L.T@model.L+.12*torch.eye(model.hid,device=device)
 97                for t in range(s.size(1)):
 98                    q=-(x@M.T)+model.B(s[:,t])+model.bias; x=torch.clamp(x+model.h*tangent_project(x,q),-1,1)
 99            return x
100        s=test[:32].to(device); a=torch.zeros(32,model.hid,device=device); b=a+1e-3*torch.randn_like(a)
101        ratio=(run_with_initial(s,b)-run_with_initial(s,a)).norm(dim=1).div((b-a).norm(dim=1)).mean().item()
102        final=model(test[:128].to(device)); state_norm=final.norm(dim=1).mean().item(); max_abs=final.abs().max().item()
103    return {"loss_start":losses[0],"loss_final":losses[-1],"accuracy":acc,"perturbation_ratio":ratio,"mean_final_norm":state_norm,"max_abs_final":max_abs,"parameters":sum(p.numel() for p in model.parameters())+sum(p.numel() for p in head.parameters())}
104
105
106def main():
107    math_result=numerical_math_check()
108    # Long-horizon sign-of-sum task; one scalar input per time step.
109    ntr, nte, T = 256, 128, 200
110    train=torch.randn(ntr,T,1); test=torch.randn(nte,T,1)
111    y=(train.sum(1).squeeze(1)>0).float(); yt=(test.sum(1).squeeze(1)>0).float()
112    baseline=train_model(VanillaResidual(1,16),train,y,test,yt)
113    idea=train_model(ProjectedDissipative(1,16),train,y,test,yt)
114    out={"device":device,"math_check":math_result,"baseline":baseline,"idea":idea}
115    print(json.dumps(out, indent=2))
116    with open("results.json","w") as f: json.dump(out,f,indent=2)
117
118if __name__ == "__main__":
119    try: main()
120    except Exception as e:
121        if device == "cuda":
122            device="cpu"; print("CUDA failed, rerun with CPU", repr(e)); main()
123        else: raise