Sensitivity-Conditioned Neural ODE Pruning / sensitivity_pruning_experiment.py

Failed on benchmark

Raw ⬇ ZIP
  1import json, math, random
  2from pathlib import Path
  3import numpy as np
  4
  5SEED = 2218
  6np.random.seed(SEED); random.seed(SEED)
  7try:
  8    import torch
  9    torch.manual_seed(SEED)
 10    if torch.cuda.is_available(): torch.cuda.manual_seed_all(SEED)
 11except Exception:
 12    torch = None
 13
 14
 15def weighted_scores(J, groups, noise=1.0):
 16    """Return tr(J_g'WJ_g) and Frobenius leave-one-group residuals."""
 17    A = np.asarray(J, float) / noise
 18    traces=[]; residuals=[]
 19    blocks=[A[:, g] if isinstance(g, slice) else A[:, list(g)] for g in groups]
 20    for i, block in enumerate(blocks):
 21        traces.append(float(np.sum(block * block)))
 22        others=[blocks[j] for j in range(len(blocks)) if j != i]
 23        if not others:
 24            residuals.append(float(np.sum(block * block))); continue
 25        other=np.concatenate(others, axis=1)
 26        q,_=np.linalg.qr(other, mode='reduced')
 27        proj=q @ (q.T @ block)
 28        residuals.append(float(np.sum((block-proj)**2)))
 29    return np.array(traces), np.array(residuals)
 30
 31
 32def mechanism_check():
 33    # Three quantitative predictions directly implied by I=J'WJ and projection.
 34    rng=np.random.default_rng(SEED)
 35    base=rng.normal(size=(30,3)); base[:,1]=base[:,0] # exact redundant pair
 36    groups=[slice(0,1),slice(1,2),slice(2,3)]
 37    # Prediction 1: scaling sensitivity by alpha scales information and residual by alpha^2.
 38    alphas=np.array([.25,.5,1.,2.,4.])
 39    tr=[]; rr=[]
 40    for a in alphas:
 41        t,r=weighted_scores(a*base, groups)
 42        tr.append(t[2]); rr.append(r[2])
 43    tr=np.array(tr); rr=np.array(rr)
 44    slope_I=np.polyfit(np.log(alphas), np.log(tr), 1)[0]
 45    slope_R=np.polyfit(np.log(alphas), np.log(rr), 1)[0]
 46    # Prediction 2: exact collinearity has zero incremental rank; residual rises as sin^2(angle).
 47    angles=np.array([0., .1, .2, .4, .8, 1.2])
 48    residual_angle=[]
 49    x=base[:,0]
 50    # Use one fixed orthogonal direction so only the prescribed angle changes.
 51    z=rng.normal(size=x.size); z-=z@x/(x@x)*x; z/=np.linalg.norm(z)
 52    for ang in angles:
 53        J=np.column_stack([x, math.cos(ang)*x+math.sin(ang)*z, base[:,2]])
 54        residual_angle.append(weighted_scores(J, groups)[1][1])
 55    residual_angle=np.array(residual_angle)
 56    ratio=np.divide(residual_angle[1:], np.sin(angles[1:])**2, out=np.zeros(5), where=np.sin(angles[1:])!=0)
 57    cv=float(np.std(ratio)/np.mean(ratio))
 58    # Prediction 3: inverse noise std weighting makes information scale as 1/sigma^2.
 59    sigmas=np.array([.5,1.,2.,4.]); infos=[]
 60    for s in sigmas: infos.append(weighted_scores(base, groups, noise=s)[0][2])
 61    noise_slope=np.polyfit(np.log(sigmas), np.log(infos), 1)[0]
 62    return {
 63        'alpha_values':alphas.tolist(), 'information_values':tr.tolist(), 'residual_values':rr.tolist(),
 64        'predicted_alpha_exponent':2.0, 'observed_information_exponent':float(slope_I),
 65        'observed_residual_exponent':float(slope_R),
 66        'angles_rad':angles.tolist(), 'angle_residuals':residual_angle.tolist(),
 67        'predicted_angle_law':'r = C sin(angle)^2', 'angle_law_ratio_cv':cv,
 68        'noise_sigmas':sigmas.tolist(), 'noise_information':infos,
 69        'predicted_noise_exponent':-2.0, 'observed_noise_exponent':float(noise_slope),
 70        'pass': bool(abs(slope_I-2)<.03 and abs(slope_R-2)<.03 and cv<.03 and abs(noise_slope+2)<.03)
 71    }
 72
 73
 74class UnitODE(torch.nn.Module):
 75    def __init__(self, width):
 76        super().__init__(); self.width=width
 77        self.w=torch.nn.Parameter(torch.randn(width)*.5)
 78        self.b=torch.nn.Parameter(torch.randn(width)*.1)
 79        self.a=torch.nn.Parameter(torch.randn(width)*.5/width**.5)
 80    def rhs(self,x): return (self.a*torch.tanh(self.w*x+self.b)).sum(-1,keepdim=True)
 81    def forward(self,x0,dt,steps):
 82        x=x0
 83        out=[x]
 84        for _ in range(steps):
 85            k1=self.rhs(x); k2=self.rhs(x+dt*k1/2); k3=self.rhs(x+dt*k2/2); k4=self.rhs(x+dt*k3)
 86            x=x+dt*(k1+2*k2+2*k3+k4)/6; out.append(x)
 87        return torch.cat(out,dim=1)
 88
 89
 90def train(width, x0, target, epochs=350, keep=None, device='cpu'):
 91    model=UnitODE(width).to(device)
 92    opt=torch.optim.Adam(model.parameters(),lr=.025)
 93    dt=float(2.5/(target.shape[1]-1))
 94    x0=x0.to(device); target=target.to(device)
 95    for _ in range(epochs):
 96        opt.zero_grad(); pred=model(x0,dt,target.shape[1]-1)
 97        loss=((pred-target)**2).mean(); loss.backward(); opt.step()
 98    return model
 99
100
101def unit_sensitivity(model, x0, times, device):
102    # Observed-output sensitivity columns using finite differences of actual trajectories.
103    # This is equivalent to local S propagated through the fixed-step ODE integrator.
104    model.eval(); x0=x0.to(next(model.parameters()).device); dt=float(2.5/(len(times)-1)); base=model(x0,dt,len(times)-1).detach().cpu().numpy().ravel()
105    cols=[]
106    with torch.no_grad():
107        for j in range(model.width):
108            vals=[]
109            for p in [model.w,model.b,model.a]:
110                old=float(p[j]); eps=1e-4*max(1.,abs(old)); p[j]=old+eps
111                vals.append((model(x0,dt,len(times)-1).detach().cpu().numpy().ravel()-base)/eps)
112                p[j]=old
113            cols.append(np.stack(vals,axis=1))
114    return np.stack(cols,axis=1), base
115
116
117def prune_and_score(kind, width=16, retain=8, device='cpu'):
118    t=np.linspace(0,2.5,31); x0=torch.tensor([[.7]],dtype=torch.float32); true=torch.zeros(1,len(t)); true[0,0]=.7
119    # stable nonlinear target generated by known scalar dynamics, integrated with RK4 externally
120    x=.7
121    for k in range(30):
122        f=lambda q: -0.8*q+0.35*q*q
123        k1=f(x); k2=f(x+(t[1]-t[0])*k1/2); k3=f(x+(t[1]-t[0])*k2/2); k4=f(x+(t[1]-t[0])*k3)
124        x += (t[1]-t[0])*(k1+2*k2+2*k3+k4)/6; true[0,k+1]=x
125    model=train(width,x0,true,device=device)
126    J,_=unit_sensitivity(model,x0,t,device)
127    # J shape observations, units, 3 params; group score sums parameter columns
128    groups=[slice(3*j,3*j+3) for j in range(width)]
129    Jflat=J.reshape(J.shape[0],-1)
130    traces,res=weighted_scores(Jflat,groups)
131    if kind=='sensitivity': chosen=np.argsort(traces+res)[:width-retain]
132    elif kind=='random': chosen=np.random.default_rng(SEED).choice(width, width-retain, replace=False)
133    elif kind=='magnitude': chosen=np.argsort(np.array([abs(model.a[j].item())+abs(model.w[j].item())+abs(model.b[j].item()) for j in range(width)]))[:width-retain]
134    else: chosen=np.arange(width-retain)
135    mask=np.ones(width,dtype=bool); mask[chosen]=False
136    # Evaluate the selected compact subnetwork, then retrain it at the same budget.
137    compact=UnitODE(retain).to(device)
138    with torch.no_grad():
139        keep=np.flatnonzero(mask)
140        compact.w.copy_(model.w[keep]); compact.b.copy_(model.b[keep]); compact.a.copy_(model.a[keep])
141    opt=torch.optim.Adam(compact.parameters(),lr=.025)
142    xd=x0.to(device); yd=true.to(device)
143    for _ in range(350):
144        opt.zero_grad(); pred0=compact(xd,2.5/30,30); loss=((pred0-yd)**2).mean(); loss.backward(); opt.step()
145    with torch.no_grad(): pred=compact(xd,2.5/30,30).cpu().numpy()[0]
146    mse=float(np.mean((pred-true.numpy()[0])**2)); longerr=float(abs(pred[-1]-true.numpy()[0,-1]))
147    return {'retained_units':retain,'parameters':3*retain,'mse':mse,'terminal_error':longerr,
148            'removed_mean_information':float(np.mean(traces[chosen])),'removed_mean_residual':float(np.mean(res[chosen]))}
149
150
151def main():
152    device='cuda' if torch is not None and torch.cuda.is_available() else 'cpu'
153    try:
154        mech=mechanism_check(); exp={k:prune_and_score(k,device=device) for k in ['sensitivity','magnitude','random']}
155    except Exception as e:
156        device='cpu'; mech=mechanism_check(); exp={k:prune_and_score(k,device=device) for k in ['sensitivity','magnitude','random']}
157        exp['error_fallback']=repr(e)
158    out={'seed':SEED,'device':device,'mechanism':mech,'pruning_experiment':exp}
159    Path('results.json').write_text(json.dumps(out,indent=2))
160    print(json.dumps(out,indent=2))
161if __name__=='__main__': main()