Exact-Jacobian Flow Controller / run_experiment.py

Mechanism confirmed, baseline not beaten

Raw ⬇ ZIP
  1import json, math, random
  2from pathlib import Path
  3import numpy as np
  4import torch
  5from torch import nn
  6
  7SEED=17
  8random.seed(SEED); np.random.seed(SEED); torch.manual_seed(SEED)
  9try:
 10    device=torch.device('cuda' if torch.cuda.is_available() else 'cpu')
 11    if device.type=='cuda': torch.zeros(1,device=device)
 12except Exception:
 13    device=torch.device('cpu')
 14
 15torch.set_default_dtype(torch.float32)
 16
 17class Coupling(nn.Module):
 18    def __init__(self,d,mask,cond=2,hidden=32,clamp=1.5):
 19        super().__init__(); self.d=d; self.register_buffer('mask',mask.float()); self.clamp=clamp
 20        inp=d+cond+1
 21        self.net=nn.Sequential(nn.Linear(inp,hidden),nn.Tanh(),nn.Linear(hidden,hidden),nn.Tanh(),nn.Linear(hidden,2*d))
 22        # near identity initialization, but trainable
 23        nn.init.zeros_(self.net[-1].weight); nn.init.zeros_(self.net[-1].bias)
 24    def params_at(self,x,c,t):
 25        u=torch.cat([x*self.mask, c, t],-1); out=self.net(u)
 26        raw_s,b=out[...,:self.d],out[...,self.d:]
 27        s=self.clamp*torch.tanh(raw_s)* (1-self.mask)
 28        b=b*(1-self.mask)
 29        return s,b
 30    def forward(self,x,c,t):
 31        s,b=self.params_at(x,c,t); y=x*self.mask+(1-self.mask)*(x*torch.exp(s)+b)
 32        return y, s.sum(-1)
 33    def inverse(self,y,c,t):
 34        s,b=self.params_at(y,c,t); x=y*self.mask+(1-self.mask)*(y-b)*torch.exp(-s)
 35        return x, -s.sum(-1)
 36
 37class Flow(nn.Module):
 38    def __init__(self,d,K=4,cond=2):
 39        super().__init__(); masks=[]
 40        for k in range(K): masks.append(torch.tensor([(i+k)%2 for i in range(d)]))
 41        self.layers=nn.ModuleList([Coupling(d,m,cond) for m in masks]); self.d=d
 42    def forward(self,z,c,t):
 43        h=z; ld=torch.zeros(z.shape[0],device=z.device)
 44        for layer in self.layers:
 45            h,a=layer(h,c,t); ld=ld+a
 46        return h,ld
 47    def inverse(self,x,c,t):
 48        h=x; ld=torch.zeros(x.shape[0],device=x.device)
 49        for layer in reversed(self.layers):
 50            h,a=layer.inverse(h,c,t); ld=ld+a
 51        return h,ld
 52    def log_prob(self,x,c,t):
 53        z, invld=self.inverse(x,c,t)
 54        logq=-0.5*(z*z+math.log(2*math.pi)).sum(-1)
 55        return logq+invld
 56    def score(self,x,c,t):
 57        xx=x.detach().requires_grad_(True); lp=self.log_prob(xx,c,t).sum()
 58        return torch.autograd.grad(lp,xx)[0]
 59
 60class DiagMLP(nn.Module):
 61    def __init__(self,d,cond=2):
 62        super().__init__(); self.d=d; self.net=nn.Sequential(nn.Linear(cond+1,32),nn.Tanh(),nn.Linear(32,32),nn.Tanh(),nn.Linear(32,2*d))
 63    def log_prob(self,x,c,t):
 64        o=self.net(torch.cat([c,t],-1)); mu,raw=o[...,:self.d],o[...,self.d:]
 65        logstd=2*torch.tanh(raw); return (-0.5*((x-mu)/logstd.exp())**2-logstd-0.5*math.log(2*math.pi)).sum(-1)
 66    def score(self,x,c,t):
 67        xx=x.detach().requires_grad_(True); lp=self.log_prob(xx,c,t).sum(); return torch.autograd.grad(lp,xx)[0]
 68
 69def sample_data(n,d,device):
 70    # task c=(separation, parity feature); conditional symmetric two-component mixture
 71    sep=torch.rand(n,1,device=device)*1.5+1.5; aux=torch.sin(sep)
 72    c=torch.cat([sep,aux],-1); t=torch.zeros(n,1,device=device)
 73    sign=torch.where(torch.rand(n,1,device=device)<.5,-1.,1.)
 74    direction=torch.ones(1,d,device=device); direction[:,1::2]=-1
 75    means=sign*sep*direction
 76    x=means+0.35*torch.randn(n,d,device=device)
 77    return x,c,t
 78
 79def analytic_score(x,c):
 80    sep=c[:,0:1]; direction=torch.ones(1,x.shape[1],device=x.device); direction[:,1::2]=-1
 81    m=sep*direction; var=.35**2
 82    lp=torch.stack([-0.5*((x-mm)**2).sum(-1)/var for mm in [m,-m]],-1)
 83    w=torch.softmax(lp,-1)
 84    scores=torch.stack([-(x-mm)/var for mm in [m,-m]],1)
 85    return (w.unsqueeze(-1)*scores).sum(1)
 86
 87def train(model,d,steps=700):
 88    model.to(device); opt=torch.optim.Adam(model.parameters(),lr=2e-3)
 89    for i in range(steps):
 90        x,c,t=sample_data(96,d,device); loss=-model.log_prob(x,c,t).mean(); opt.zero_grad(); loss.backward(); opt.step()
 91    with torch.no_grad():
 92        x,c,t=sample_data(512,d,device); nll=-model.log_prob(x,c,t).mean().item()
 93    # score needs graph, use fixed batch
 94    x,c,t=sample_data(256,d,device); pred=model.score(x,c,t); truth=analytic_score(x,c)
 95    return nll, (pred-truth).pow(2).mean().sqrt().item()
 96
 97def math_checks():
 98    d=8; x=torch.randn(64,d,device=device); c=torch.randn(64,2,device=device); t=torch.randn(64,1,device=device)
 99    rows=[]
100    # constant alpha is imposed through a tiny custom coupling: test the exact formulas directly
101    for gamma in [0.,.25,.5,1.,1.5]:
102        mask=torch.tensor([1,0,1,0,1,0,1,0],device=device).float(); nB=int((1-mask).sum())
103        alpha=gamma*torch.ones_like(x)*(1-mask); beta=.13*torch.ones_like(x)*(1-mask)
104        y=x*mask+(1-mask)*(x*torch.exp(alpha)+beta)
105        xr=y*mask+(1-mask)*(y-beta)*torch.exp(-alpha)
106        recon=(xr-x).abs().max().item(); observed=y.shape[0]
107        # logdet is nB*gamma; additive K=3 prediction is 3*nB*gamma
108        one=alpha.sum(-1).mean().item(); comp=(3*alpha.sum(-1)).mean().item()
109        rows.append({'gamma':gamma,'pred_logdet':nB*gamma,'obs_logdet':one,'pred_3x':3*nB*gamma,'obs_3x':comp,'recon_max':recon})
110    # Direct Jacobian check on the implemented nonlinear coupling (single sample).
111    layer=Coupling(d, torch.tensor([1,0,1,0,1,0,1,0],device=device).float(), cond=2).to(device)
112    with torch.no_grad():
113        layer.net[-1].bias[:d].fill_(0.35)
114        layer.net[-1].bias[d:].fill_(0.11)
115    xx=torch.randn(d,device=device,requires_grad=True); cc=torch.randn(1,2,device=device); tt=torch.randn(1,1,device=device)
116    yy,ld=layer(xx[None],cc,tt); xr,ild=layer.inverse(yy,cc,tt)
117    J=torch.autograd.functional.jacobian(lambda u: layer(u[None],cc,tt)[0][0],xx)
118    sign,actual=torch.linalg.slogdet(J)
119    rows.append({'nonlinear_jacobian_logdet':actual.item(),'analytic_logdet':ld.item(),
120                 'jacobian_abs_error':abs(actual.item()-ld.item()),'jacobian_sign':sign.item(),
121                 'nonlinear_inverse_max_error':(xr[0]-xx).abs().max().item()})
122    return rows
123
124def main():
125    checks=math_checks(); results={'device':str(device),'math_checks':checks,'models':{}}
126    for d in [2,8]:
127        # reset reproducibly per dimension
128        torch.manual_seed(SEED+d)
129        results['models'][str(d)]={}
130        results['models'][str(d)]['flow']=train(Flow(d),d)
131        torch.manual_seed(SEED+d)
132        results['models'][str(d)]['diag_mlp']=train(DiagMLP(d),d)
133    Path('results.json').write_text(json.dumps(results,indent=2))
134    print(json.dumps(results,indent=2))
135
136if __name__=='__main__': main()