Amortized low-rank Laplace hyperparameter marginalization / bench_experiment.py

Failed on benchmark

Raw ⬇ ZIP
  1import os, sys, json, time
  2import numpy as np
  3import torch
  4import torch.nn as nn
  5
  6sys.path.insert(0, '/home/maxwelhelp/all/math2nn')
  7from bench import get_dataset, make_model, train_model, evaluate, sweep_baseline, make_report
  8
  9ROOT = os.path.dirname(os.path.abspath(__file__))
 10DEVICE = 'cuda' if torch.cuda.is_available() else 'cpu'
 11SEEDS = tuple(range(8))
 12# Union of all learning rates is shared by both methods; wd is the Laplace prior knob.
 13GRID = [
 14    {'lr': 0.0015, 'weight_decay': 1e-4},
 15    {'lr': 0.0030, 'weight_decay': 1e-4},
 16    {'lr': 0.0060, 'weight_decay': 1e-4},
 17]
 18
 19
 20def seed_all(seed):
 21    np.random.seed(seed); torch.manual_seed(seed)
 22    if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed)
 23
 24
 25def baseline_fn(cfg):
 26    def run(seed):
 27        seed_all(seed)
 28        d = get_dataset('tabular', seed=seed, n_train=400, n_test=400)
 29        net = make_model('mlp_tiny', d['input_shape'], d['out_dim'])
 30        _, metric, _ = train_model(net, d, epochs=20, lr=cfg['lr'], batch=128,
 31                                   weight_decay=cfg['weight_decay'], log=lambda _: None)
 32        return metric
 33    return run
 34
 35
 36def flat_params(model):
 37    return torch.cat([p.detach().reshape(-1) for p in model.parameters()])
 38
 39
 40def set_flat_grads(model, g):
 41    off = 0
 42    for p in model.parameters():
 43        n = p.numel()
 44        p.grad = g[off:off+n].reshape_as(p).clone()
 45        off += n
 46
 47
 48def jacobian_subspace(model, x, rank=16):
 49    # Output-Jacobian range finder. For scalar regression, each sample gives one row.
 50    params = [p for p in model.parameters() if p.requires_grad]
 51    rows = []
 52    for i in range(len(x)):
 53        model.zero_grad(set_to_none=True)
 54        out = model(x[i:i+1]).reshape(() )
 55        gs = torch.autograd.grad(out, params, retain_graph=False, allow_unused=True)
 56        rows.append(torch.cat([(g if g is not None else torch.zeros_like(p)).reshape(-1)
 57                               for p, g in zip(params, gs)]))
 58    J = torch.stack(rows)
 59    # Exact small calibration SVD is only subspace construction; candidate/training updates reuse U.
 60    U, s, _ = torch.linalg.svd(J, full_matrices=False)
 61    # SVD returns left vectors; use right singular vectors as parameter subspace.
 62    _, s, Vh = torch.linalg.svd(J, full_matrices=False)
 63    r = min(rank, Vh.shape[0])
 64    return Vh[:r].T.contiguous(), s[:r] ** 2
 65
 66
 67def idea_train(cfg, seed, rank=16):
 68    seed_all(seed)
 69    d = get_dataset('tabular', seed=seed, n_train=400, n_test=400)
 70    model = make_model('mlp_tiny', d['input_shape'], d['out_dim'])
 71    # Use CPU for reliable Jacobians; training follows the same robust device fallback.
 72    xcal = d['xtr'][:64]
 73    U, eig = jacobian_subspace(model, xcal, rank)
 74    # Scale prior precision from weight decay and damp low-rank precision update.
 75    prior = 1.0 + cfg['weight_decay']
 76    Udev = U.to(DEVICE)
 77    eigdev = eig.to(DEVICE)
 78    try:
 79        model = model.to(DEVICE)
 80        xtr, ytr, xte, yte = [d[k].to(DEVICE) for k in ('xtr','ytr','xte','yte')]
 81        opt = torch.optim.Adam(model.parameters(), lr=cfg['lr'], weight_decay=cfg['weight_decay'])
 82        lossf = nn.MSELoss()
 83        bs = 128
 84        for _ in range(20):
 85            model.train(); perm = torch.randperm(len(xtr), device=DEVICE)
 86            for st in range(0, len(xtr), bs):
 87                ix = perm[st:st+bs]
 88                loss = lossf(model(xtr[ix]), ytr[ix])
 89                opt.zero_grad(); loss.backward()
 90                g = torch.cat([(p.grad if p.grad is not None else torch.zeros_like(p)).reshape(-1)
 91                               for p in model.parameters()])
 92                # Woodbury inverse for H=P+U Lambda U^T, applied to the gradient.
 93                # Diagonal P is scalar here, making the shared curvature operation explicit.
 94                lam = eigdev / max(float(cfg['noise']) if 'noise' in cfg else 1.0, 1e-6)
 95                K = torch.diag(1.0 / (lam + 1e-6)) + (Udev.T @ Udev) / prior
 96                z = torch.linalg.solve(K, Udev.T @ (g / prior))
 97                pre = g / prior - (Udev @ z) / prior
 98                set_flat_grads(model, pre)
 99                opt.step()
100        model.eval()
101        with torch.no_grad(): metric = float(((model(xte)-yte)**2).mean())
102        return metric, U, eig, model, d
103    except RuntimeError:
104        # CPU fallback mirrors the harness policy.
105        model = model.cpu(); d2 = {k:(v.cpu() if torch.is_tensor(v) else v) for k,v in d.items()}
106        return idea_train_cpu(cfg, seed, rank, U, eig, model, d2)
107
108
109def idea_train_cpu(cfg, seed, rank, U, eig, model, d):
110    xtr,ytr,xte,yte=[d[k] for k in ('xtr','ytr','xte','yte')]
111    U=U.cpu(); eig=eig.cpu(); opt=torch.optim.Adam(model.parameters(),lr=cfg['lr'],weight_decay=cfg['weight_decay']); lossf=nn.MSELoss()
112    for _ in range(20):
113        for st in range(0,len(xtr),128):
114            loss=lossf(model(xtr[st:st+128]),ytr[st:st+128]); opt.zero_grad(); loss.backward()
115            g=torch.cat([(p.grad if p.grad is not None else torch.zeros_like(p)).reshape(-1) for p in model.parameters()]); prior=1+cfg['weight_decay']; lam=eig
116            K=torch.diag(1/(lam+1e-6))+U.T@U/prior; z=torch.linalg.solve(K,U.T@(g/prior)); set_flat_grads(model,g/prior-U@z/prior); opt.step()
117    with torch.no_grad(): metric=float(((model(xte)-yte)**2).mean())
118    return metric,U,eig,model,d
119
120
121def idea_fn(cfg):
122    def run(seed): return idea_train(cfg, seed, rank=16)[0]
123    return run
124
125
126def main():
127    print('device', DEVICE)
128    t=time.perf_counter()
129    base=sweep_baseline(baseline_fn, GRID, seeds=(0,1,2,3))
130    # Idea gets exactly the same three configs and is evaluated on all paired seeds.
131    idea_scores=[]
132    chosen=base['best_cfg']
133    # small equal-sized idea sweep, selecting by the same four-seed tuning split
134    idea_sweep=[]
135    for cfg in GRID:
136        rr=evaluate(idea_fn(cfg), seeds=(0,1,2,3)); idea_sweep.append({'cfg':cfg,'mean':rr['mean']})
137    best_idea=min(idea_sweep,key=lambda z:z['mean'])['cfg']
138    idea=evaluate(idea_fn(best_idea), seeds=SEEDS)
139    # Signature is measured on trained models: retained Jacobian energy and held-out curvature action.
140    sig=[]
141    for s in (0,1,2,3):
142        m,U,eig,_net,d=idea_train(best_idea,s,16)
143        # Re-test the mechanism on the trained model, not on the calibration toy.
144        # The prediction is that the shared U retains substantial trained-model
145        # Jacobian energy on held-out examples.
146        params=[p for p in _net.parameters() if p.requires_grad]
147        rows=[]
148        xx=d['xte'][:64].to(next(_net.parameters()).device)
149        for i in range(len(xx)):
150            _net.zero_grad(set_to_none=True)
151            out=_net(xx[i:i+1]).reshape(())
152            gs=torch.autograd.grad(out,params,allow_unused=True)
153            rows.append(torch.cat([(g if g is not None else torch.zeros_like(p)).reshape(-1) for p,g in zip(params,gs)]))
154        Jt=torch.stack(rows).detach().cpu()
155        total=float((Jt*Jt).sum())
156        proj=float(((Jt@U.cpu())**2).sum())
157        ratio=proj/max(total,1e-12)
158        sig.append({'seed':s,'predicted_captured_fraction':ratio,'observed_total_energy':total,'projected_energy':proj})
159    mean_ratio=float(np.mean([z['predicted_captured_fraction'] for z in sig]))
160    signature={'type':'trained_model_jacobian_low_rank','rank':16,'samples':sig,'predicted_vs_observed_mean_ratio':mean_ratio,'confirmed':bool(mean_ratio>=0.5)}
161    report=make_report('tabular','mlp_tiny',{'best_cfg':base['best_cfg'],'sweep':base['sweep'],'full':base['full']},idea,{'idea_sweep':idea_sweep,'mechanism_signature':signature,'selection_note':'Both methods used the same lr/weight-decay union; idea selected on seeds 0-3.'})
162    report['runtime_sec']=time.perf_counter()-t
163    with open(os.path.join(ROOT,'bench_report.json'),'w') as f: json.dump(report,f,indent=2)
164    print(json.dumps(report,indent=2))
165
166if __name__=='__main__': main()