Tangent-Branch Neural Evasion Layer / bench_tangent_stage2.py

Mechanism confirmed, baseline not beaten

Raw ⬇ ZIP
  1from __future__ import annotations
  2import sys, json, math, itertools
  3from pathlib import Path
  4import numpy as np
  5import torch
  6import torch.nn as nn
  7
  8sys.path.insert(0, "/home/maxwelhelp/all/math2nn")
  9from bench import make_model, train_model, sweep_baseline, make_report
 10from bench.protocol import evaluate
 11
 12META = {"name":"tangent_evasion_dynamics", "domain":"dynamics", "description":"2D evader policy with pursuer exclusion disk and tangent-branch safety structure."}
 13RADIUS = .8
 14CENTER = np.array([0., 0.], dtype=np.float32)
 15GOAL = np.array([3., 0.], dtype=np.float32)
 16DT = .08
 17VMAX = 1.0
 18
 19
 20def rot(theta):
 21    c,s=np.cos(theta),np.sin(theta)
 22    return np.array([[c,-s],[s,c]], dtype=np.float32)
 23
 24def tangent(center, target, radius, side):
 25    z=center-target; d=np.linalg.norm(z)
 26    if d <= radius: return None
 27    a=np.arcsin(np.clip(radius/d,-1,1))
 28    return target + np.sqrt(d*d-radius*radius)/d * (rot(-side*a) @ z)
 29
 30def label_velocity(x, y, heading):
 31    """Analytic branch reference, used only to generate task observations/labels."""
 32    p=np.array([x,y], dtype=np.float32); h=np.array([np.cos(heading),np.sin(heading)], dtype=np.float32)
 33    candidates=[]
 34    for side in (-1,1):
 35        s=tangent(CENTER, GOAL, RADIUS, side)
 36        if s is None: continue
 37        vec=s-p; ang=abs(np.arctan2(np.cross(h,vec),np.dot(h,vec)))
 38        candidates.append((ang+np.linalg.norm(GOAL-s),s))
 39    if np.linalg.norm(p-CENTER) < 1.35 and candidates:
 40        s=min(candidates,key=lambda z:z[0])[1]
 41        v=s-p
 42    else: v=GOAL-p
 43    n=np.linalg.norm(v)
 44    return (v/max(n,1e-6)*VMAX).astype(np.float32)
 45
 46def get_dataset(seed, n_train, n_test):
 47    rng=np.random.RandomState(seed)
 48    def make(n):
 49        X=np.empty((n,24),np.float32); Y=np.empty((n,2),np.float32)
 50        for i in range(n):
 51            # Include safe and near-boundary states, with heading as the third GRU channel.
 52            a=rng.uniform(-np.pi,np.pi); rad=rng.uniform(.82,2.0)
 53            x,y=rad*np.cos(a),rad*np.sin(a)
 54            heading=rng.uniform(-np.pi,np.pi)
 55            state=np.tile(np.array([x,y,heading],np.float32),(8,1))
 56            state[:,2]+=rng.normal(0,.025,8).astype(np.float32)
 57            X[i]=state.reshape(-1)
 58            Y[i]=label_velocity(x,y,heading)
 59        return X,Y
 60    xtr,ytr=make(n_train); xte,yte=make(n_test)
 61    return {"xtr":xtr,"ytr":ytr,"xte":xte,"yte":yte,"task":"regression","metric":"mse","input_shape":(24,),"out_dim":2}
 62
 63
 64def project_np(pos, vel):
 65    p=np.asarray(pos,float); v=np.asarray(vel,float).copy(); d=np.linalg.norm(p-CENTER)
 66    if d>1e-9:
 67        n=(p-CENTER)/d
 68        if d <= RADIUS + DT*np.linalg.norm(v) and np.dot(v,n)<0: v-=np.dot(v,n)*n
 69    z=np.linalg.norm(v)
 70    return v if z<=VMAX else v/z*VMAX
 71
 72def tangent_project_torch(x, out):
 73    # Network input is (x,y,heading) repeated; compute active-disk half-space projection.
 74    p=x[:, -1, :2]; v=out
 75    d=torch.linalg.vector_norm(p,dim=1,keepdim=True).clamp_min(1e-6)
 76    n=p/d
 77    inward=(v*n).sum(1,keepdim=True)
 78    active=(d <= RADIUS + DT*torch.linalg.vector_norm(v,dim=1,keepdim=True)).float()
 79    v=v-active*torch.minimum(inward,torch.zeros_like(inward))*n
 80    speed=torch.linalg.vector_norm(v,dim=1,keepdim=True).clamp_min(1e-6)
 81    return v*torch.minimum(torch.ones_like(speed), VMAX/speed)
 82
 83def run(seed, lr, idea, epochs=15):
 84    torch.manual_seed(10000+int(seed)); np.random.seed(10000+int(seed))
 85    ds=get_dataset(int(seed),400,200)
 86    net=make_model("rnn_small", ds["input_shape"], ds["out_dim"])
 87    device="cuda" if torch.cuda.is_available() else "cpu"
 88    try:
 89        net=net.to(device); opt=torch.optim.Adam(net.parameters(),lr=lr)
 90        xtr=torch.as_tensor(ds["xtr"],device=device); ytr=torch.as_tensor(ds["ytr"],device=device)
 91        lossf=nn.MSELoss()
 92        for _ in range(epochs):
 93            net.train(); perm=torch.randperm(len(xtr),device=device)
 94            for j in range(0,len(xtr),128):
 95                ix=perm[j:j+128]; raw=net(xtr[ix])
 96                pred=tangent_project_torch(xtr[ix].view(-1,8,3),raw) if idea else raw
 97                loss=lossf(pred,ytr[ix]); opt.zero_grad(); loss.backward(); opt.step()
 98        net.eval(); xt=torch.as_tensor(ds["xte"],device=device)
 99        with torch.no_grad():
100            raw=net(xt); pred=tangent_project_torch(xt.view(-1,8,3),raw) if idea else raw
101            metric=float(lossf(pred,torch.as_tensor(ds["yte"],device=device)))
102        return metric
103    except Exception:
104        # Explicit CPU fallback, matching the bench's robust-device requirement.
105        torch.cuda.empty_cache() if torch.cuda.is_available() else None
106        torch.manual_seed(10000+int(seed)); net=make_model("rnn_small",ds["input_shape"],2)
107        opt=torch.optim.Adam(net.parameters(),lr=lr); xtr=torch.as_tensor(ds["xtr"]); ytr=torch.as_tensor(ds["ytr"])
108        for _ in range(epochs):
109            for j in range(0,400,128):
110                raw=net(xtr[j:j+128]); pred=tangent_project_torch(xtr[j:j+128].view(-1,8,3),raw) if idea else raw
111                loss=nn.functional.mse_loss(pred,ytr[j:j+128]); opt.zero_grad(); loss.backward(); opt.step()
112        with torch.no_grad():
113            raw=net(torch.as_tensor(ds["xte"])); pred=tangent_project_torch(torch.as_tensor(ds["xte"]).view(-1,8,3),raw) if idea else raw
114            return float(nn.functional.mse_loss(pred,torch.as_tensor(ds["yte"])))
115
116def main():
117    # Equal union search space: every idea LR is also evaluated by baseline.
118    grid=[{"lr":1e-3},{"lr":3e-3},{"lr":1e-2}]
119    base=sweep_baseline(lambda c: lambda s: run(s,c["lr"],False),grid)
120    # Idea is run at best baseline LR and two nearby grid settings; all are already baseline-swept.
121    idea_cfgs=[{"lr":c["lr"]} for c in grid]
122    vals=[]
123    for c in idea_cfgs:
124        r=evaluate(lambda s,c=c: run(s,c["lr"],True),tuple(range(8)))
125        vals.append((r["mean"],c,r))
126    _,best_cfg,idea=min(vals,key=lambda z:z[0])
127    # Trained-model behavior signature: one-step disk penetration on held-out states.
128    def penetration(seed, isidea):
129        ds=get_dataset(seed,400,200); x=torch.as_tensor(ds["xte"]); torch.manual_seed(10000+seed); net=make_model("rnn_small",(24,),2); opt=torch.optim.Adam(net.parameters(),lr=best_cfg["lr"])
130        for _ in range(15):
131            for j in range(0,400,128):
132                raw=net(torch.as_tensor(ds["xtr"])[j:j+128]); p=tangent_project_torch(torch.as_tensor(ds["xtr"])[j:j+128].view(-1,8,3),raw) if isidea else raw
133                l=nn.functional.mse_loss(p,torch.as_tensor(ds["ytr"])[j:j+128]); opt.zero_grad();l.backward();opt.step()
134        with torch.no_grad(): out=net(x); out=tangent_project_torch(x.view(-1,8,3),out) if isidea else out
135        pos=x[:,-3:-1].numpy(); nxt=pos+DT*out.numpy(); return float(np.maximum(0,RADIUS-np.linalg.norm(nxt-CENTER,axis=1)).max())
136    bp=float(np.mean([penetration(s,False) for s in range(8)])); ip=float(np.mean([penetration(s,True) for s in range(8)]))
137    sig={"predicted":"projection keeps one-step disk penetration at numerical zero","observed_baseline_max_penetration":bp,"observed_idea_max_penetration":ip,"confirmed":ip < 1e-6 and ip <= bp+1e-8}
138    rep=make_report("tangent_evasion_dynamics","rnn_small",base,idea,{**sig,"custom_track":{"name":"tangent_evasion_dynamics","file":"bench_tangent_stage2.py","domain":"dynamics"},"idea_sweep":[{"cfg":c,"mean":m} for m,c,_ in vals]})
139    Path("bench_report.json").write_text(json.dumps(rep,indent=2))
140    print(json.dumps(rep,indent=2))
141if __name__=="__main__": main()