Differentiable Physics-Equilibrium Projection / experiment.py

✓✓ Beats tuned baseline

Raw ⬇ ZIP
  1import json, math, os
  2import numpy as np
  3
  4# Differentiable equilibrium projection MVP: F(z;u)=z^3+a*z-u.
  5# Its Jacobian is 3z^2+a, so dz*/du=1/(3z*^2+a).
  6
  7def solve_newton(u, a, z0, maxit=30, tol=1e-10):
  8    z=float(z0)
  9    for k in range(maxit):
 10        f=z**3+a*z-u
 11        j=3*z*z+a
 12        if abs(f)<tol: return z,k+1,abs(f)
 13        z -= f/j
 14    return z,maxit,abs(z**3+a*z-u)
 15
 16def solve_gd_residual(u,a,z0,tau,maxit=10000,tol=1e-8):
 17    # z <- z - tau J^T F, as in the proposed least-squares implicit iteration.
 18    z=float(z0)
 19    for k in range(maxit):
 20        f=z**3+a*z-u; j=3*z*z+a
 21        if abs(f)<tol: return z,k+1,abs(f)
 22        z -= tau*j*f
 23    return z,maxit,abs(z**3+a*z-u)
 24
 25def root(u,a): return solve_newton(u,a,0,maxit=100)[0]
 26
 27def main():
 28    rng=np.random.default_rng(7)
 29    rows=[]
 30    # Prediction 1: for linear F=a*z-u, residual contraction is |1-tau*a^2|,
 31    # and the stability boundary is tau*a^2=2.
 32    lin=[]
 33    for a in [0.5,1.,2.]:
 34        for tau in [0.25/a**2, 0.9/a**2, 1.1/a**2, 1.9/a**2, 2.1/a**2]:
 35            u=1.3; z=4.; r0=abs(a*z-u); rs=[]
 36            for k in range(12):
 37                rs.append(abs(a*z-u)); z -= tau*a*(a*z-u)
 38            observed=(rs[-1]/rs[0])**(1/11)
 39            predicted=abs(1-tau*a*a)
 40            lin.append({'a':a,'tau_a2':tau*a*a,'predicted_ratio':predicted,'observed_ratio':observed,'stable_pred':tau*a*a<2,'decreased':rs[-1]<rs[0]})
 41    rows.append(('linear_contraction',lin))
 42
 43    # Prediction 2: implicit sensitivity is inverse Jacobian; finite differences should scale 1/a
 44    sens=[]
 45    for a in [0.1,0.25,0.5,1.,2.,4.]:
 46        u=0.7; eps=1e-5
 47        fd=(root(u+eps,a)-root(u-eps,a))/(2*eps)
 48        pred=1/(3*root(u,a)**2+a)
 49        sens.append({'a':a,'predicted_1_over_J':pred,'finite_difference':fd,'relative_error':abs(fd-pred)/pred})
 50    rows.append(('implicit_sensitivity',sens))
 51
 52    # Controlled conditioning sweep at u=0: z*=0, J=a exactly, so |dz/du|=1/a.
 53    conditioning=[]
 54    for a in [1e-1, 3e-2, 1e-2, 3e-3, 1e-3]:
 55        u=0.0; eps=1e-6
 56        fd=(root(u+eps,a)-root(u-eps,a))/(2*eps)
 57        conditioning.append({'a_sigma_min_J':a,'predicted_gain':1/a,
 58                             'finite_difference_gain':fd,
 59                             'relative_error':abs(fd-1/a)/(1/a)})
 60    rows.append(('conditioning_gain_at_singular_limit',conditioning))
 61
 62    # Verify the custom implicit backward rule against torch autograd finite differences.
 63    try:
 64        import torch
 65        from implicit_layer import implicit_project
 66        u=torch.tensor(0.7,dtype=torch.double,requires_grad=True)
 67        a_t=torch.tensor(0.4,dtype=torch.double,requires_grad=True)
 68        z=implicit_project(u,a_t,steps=30)
 69        (z*z/2).backward()
 70        z0=float(z.detach()); j=3*z0*z0+float(a_t.detach())
 71        expected_u=z0/j; expected_a=-z0*z0/j
 72        implicit_check={'autograd_du':float(u.grad),'expected_du':expected_u,
 73                        'autograd_da':float(a_t.grad),'expected_da':expected_a,
 74                        'max_abs_error':max(abs(float(u.grad)-expected_u),abs(float(a_t.grad)-expected_a))}
 75    except Exception as e:
 76        implicit_check={'error':str(e)}
 77    rows.append(('implicit_backward_check',implicit_check))
 78
 79    # Prediction 3: Newton reaches tolerance from very different predictor errors,
 80    # while penalty-only optimization retains initialization/penalty dependence.
 81    init_sweep=[]
 82    u=1.2; a=.35
 83    for z0 in [-10,-2,0,2,10]:
 84        z,k,r=solve_newton(u,a,z0)
 85        init_sweep.append({'z0':z0,'newton_iters':k,'final_residual':r})
 86    rows.append(('newton_initialization_invariance',init_sweep))
 87
 88    # Small ML comparison: train direct predictor and physics-penalty predictor;
 89    # projected predictor uses same direct network output followed by Newton.
 90    import torch
 91    torch.manual_seed(7)
 92    torch.set_num_threads(4)
 93    n=500; uall=torch.linspace(-2,2,n).unsqueeze(1)
 94    aa=.35
 95    yall=torch.tensor([root(float(u),aa) for u in uall[:,0]]).float().unsqueeze(1)
 96    tr=slice(0,350); te=slice(350,n)
 97    def net(): return torch.nn.Sequential(torch.nn.Linear(1,24),torch.nn.Tanh(),torch.nn.Linear(24,1))
 98    def train(mode, epochs=500):
 99        m=net(); opt=torch.optim.Adam(m.parameters(),lr=.02)
100        for _ in range(epochs):
101            pred=m(uall[tr]); f=pred**3+aa*pred-uall[tr]
102            loss=((pred-yall[tr])**2).mean() if mode=='direct' else (f*f).mean()
103            opt.zero_grad(); loss.backward(); opt.step()
104        with torch.no_grad():
105            p=m(uall[te]); f=p**3+aa*p-uall[te]
106            if mode=='project': pass
107            pred_res=float(f.abs().mean()); mse=float(((p-yall[te])**2).mean())
108        return m,pred_res,mse
109    md,rd,ed=train('direct'); mp,rp,ep=train('penalty')
110    # deterministic projection of penalty network's predictions, fixed Newton solve
111    with torch.no_grad(): raw=mp(uall[te]).numpy().ravel()
112    projected=np.array([solve_newton(float(u),aa,float(z))[0] for u,z in zip(uall[te,0],raw)])
113    up=uall[te,0].numpy(); truth=yall[te,0].numpy()
114    proj_res=float(np.mean(np.abs(projected**3+aa*projected-up)))
115    proj_mse=float(np.mean((projected-truth)**2))
116    rows.append(('mini_experiment',{'direct_test_mean_residual':rd,'penalty_test_mean_residual':rp,'penalty_projected_residual':proj_res,'direct_mse':ed,'penalty_mse':ep,'projected_mse':proj_mse,'train_points':350,'test_points':150}))
117    with open('results.json','w') as f: json.dump(dict(rows),f,indent=2)
118    print(json.dumps(dict(rows),indent=2))
119
120if __name__=='__main__': main()