Differentially Passive Neural Blocks / experiment.py

Failed on benchmark

Raw ⬇ ZIP
  1import json
  2import math
  3import random
  4import numpy as np
  5import torch
  6import torch.nn as nn
  7
  8SEED = 2975
  9random.seed(SEED)
 10np.random.seed(SEED)
 11torch.manual_seed(SEED)
 12
 13device = "cuda" if torch.cuda.is_available() else "cpu"
 14try:
 15    if device == "cuda":
 16        torch.cuda.empty_cache()
 17except Exception:
 18    device = "cpu"
 19torch.set_default_dtype(torch.float64)
 20
 21# A residual neural block, z+eta*r(z,u), with an exact state Jacobian.
 22class ResidualBlock(nn.Module):
 23    def __init__(self, d=2, hidden=16, eta=0.25, constrained=False, alpha=0.20):
 24        super().__init__()
 25        self.d, self.eta, self.constrained, self.alpha = d, eta, constrained, alpha
 26        self.net = nn.Sequential(nn.Linear(2*d, hidden), nn.Tanh(), nn.Linear(hidden, d))
 27        if constrained:
 28            # This is an explicit safe residual parameterization: the learned
 29            # nonlinear correction is bounded, while the state Jacobian is
 30            # damped by a fixed contractive skip coefficient.
 31            self.skip = math.sqrt(1.0 - alpha)
 32        else:
 33            self.skip = 1.0
 34
 35    def forward(self, z, u):
 36        correction = self.eta * self.net(torch.cat([z, u], dim=-1))
 37        return self.skip * z + correction
 38
 39def jacobian_z(model, z, u):
 40    z = z.detach().requires_grad_(True)
 41    y = model(z[None], u[None])[0]
 42    rows = []
 43    for i in range(y.numel()):
 44        rows.append(torch.autograd.grad(y[i], z, retain_graph=True)[0])
 45    return torch.stack(rows)
 46
 47def max_eig_tensor(a):
 48    return torch.linalg.eigvalsh((a+a.T)/2)[-1]
 49
 50def max_eig_symmetric(a):
 51    return float(max_eig_tensor(a).detach().cpu())
 52
 53def train(model, constrained, epochs=350):
 54    # Same tiny regression task for both blocks. It intentionally includes a
 55    # mildly expansive target, making the robustness/accuracy tradeoff visible.
 56    g = torch.Generator(device=device).manual_seed(SEED)
 57    z = (torch.rand(96, 2, generator=g, device=device)*2-1)
 58    u = (torch.rand(96, 2, generator=g, device=device)*2-1)
 59    target = 1.08*z + 0.25*u + 0.04*torch.sin(2*z)
 60    opt = torch.optim.Adam(model.parameters(), lr=3e-3)
 61    for epoch in range(epochs):
 62        opt.zero_grad()
 63        pred = model(z, u)
 64        task = ((pred-target)**2).mean()
 65        # Exact per-sample Jacobian penalty, corresponding to
 66        # M^T P M-P <= -alpha P with P=I.
 67        viol = []
 68        for k in range(0, len(z), 8):
 69            M = jacobian_z(model, z[k], u[k])
 70            q = max_eig_tensor(M.T @ M - (1-model.alpha)*torch.eye(2, device=device))
 71            viol.append(torch.nn.functional.softplus(q + 0.02)**2)
 72        penalty = torch.stack(viol).mean()
 73        loss = task + (2.0*penalty if constrained else 0.0)
 74        loss.backward()
 75        opt.step()
 76    return float(task.detach().cpu()), float(penalty.detach().cpu())
 77
 78def evaluate(model, alpha):
 79    g = torch.Generator(device=device).manual_seed(SEED+9)
 80    points = torch.rand(64, 2, generator=g, device=device)*2-1
 81    inputs = torch.rand(64, 2, generator=g, device=device)*2-1
 82    eigs=[]
 83    for z,u in zip(points, inputs):
 84        M=jacobian_z(model,z,u)
 85        eigs.append(max_eig_symmetric(M.T@M-(1-alpha)*torch.eye(2,device=device)))
 86    # Identical-input trajectories from paired hidden states.
 87    z1 = torch.tensor([0.65,-0.45], device=device)
 88    delta = torch.tensor([1e-3,-1.2e-3], device=device)
 89    z2 = z1 + delta
 90    u = torch.tensor([0.2,-0.1], device=device)
 91    ratios=[]
 92    for _ in range(20):
 93        old=float(torch.linalg.vector_norm(z2-z1).detach().cpu())
 94        z1,z2=model(z1[None],u[None])[0],model(z2[None],u[None])[0]
 95        ratios.append(float((torch.linalg.vector_norm(z2-z1)/old).detach().cpu()))
 96    return {
 97        "max_dense_generalized_violation": float(max(eigs)),
 98        "mean_dense_generalized_violation": float(np.mean(eigs)),
 99        "max_trajectory_ratio": float(max(ratios)),
100        "mean_trajectory_ratio": float(np.mean(ratios)),
101        "final_distance_ratio": float(np.prod(ratios)),
102        "ratios": ratios,
103    }
104
105def direct_math_check():
106    # M=0.8I exactly satisfies M^T M-I <= -0.36I (alpha=.30), while
107    # M=1.1I violates it and distances grow by 1.1 each step.
108    alpha=.30
109    good=torch.eye(2)*0.8
110    bad=torch.eye(2)*1.1
111    good_margin=max_eig_symmetric(good.T@good-(1-alpha)*torch.eye(2))
112    bad_margin=max_eig_symmetric(bad.T@bad-(1-alpha)*torch.eye(2))
113    dgood=1.0; dbad=1.0
114    for _ in range(12): dgood*=.8; dbad*=1.1
115    return {"alpha":alpha,"contractive_matrix_largest_eigenvalue":good_margin,
116            "expansive_matrix_largest_eigenvalue":bad_margin,
117            "contractive_distance_after_12":dgood,
118            "expansive_distance_after_12":dbad}
119
120def main():
121    global device
122    results={"seed":SEED,"device":device,"direct_math_check":direct_math_check()}
123    for name, constrained in [("baseline",False),("idea",True)]:
124        try:
125            model=ResidualBlock(constrained=constrained).to(device)
126            task, penalty=train(model, constrained)
127            results[name]={"train_task_mse":task,"train_penalty":penalty,**evaluate(model, .20)}
128        except Exception as e:
129            if device == "cuda":
130                device="cpu"; torch.set_default_device("cpu")
131                model=ResidualBlock(constrained=constrained)
132                task, penalty=train(model, constrained)
133                results[name]={"train_task_mse":task,"train_penalty":penalty,**evaluate(model,.20)}
134            else: raise
135    with open("results.json","w") as f: json.dump(results,f,indent=2)
136    print(json.dumps(results,indent=2))
137
138if __name__ == "__main__": main()