Horizon-Dependent Error Tubes for Recurrent Rollouts / stage2_tube_bench.py

Mechanism confirmed, baseline not beaten

Raw ⬇ ZIP
  1import json, random, sys
  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, sweep_baseline, make_report
  8from bench.protocol import evaluate
  9
 10SEEDS = tuple(range(8))
 11GRID = [
 12    {'lr': 0.001, 'weight_decay': 0.0},
 13    {'lr': 0.003, 'weight_decay': 0.0},
 14    {'lr': 0.006, 'weight_decay': 0.0},
 15    {'lr': 0.003, 'weight_decay': 1e-4},
 16]
 17
 18def seed_all(seed):
 19    random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)
 20    if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed)
 21
 22def baseline_train(cfg, seed):
 23    seed_all(seed)
 24    ds = get_dataset('dynamics', seed, n_train=400, n_test=200)
 25    model = make_model('rnn_small', ds['input_shape'], ds['out_dim'])
 26    try:
 27        res = train_model(model, ds, epochs=14, lr=cfg['lr'], batch=64,
 28                          weight_decay=cfg['weight_decay'], log=lambda *_: None)
 29        return float(res[1])
 30    except Exception:
 31        torch.cuda.empty_cache() if torch.cuda.is_available() else None
 32        res = train_model(model.cpu(), {k:(v.cpu() if torch.is_tensor(v) else v) for k,v in ds.items()},
 33                          epochs=14, lr=cfg['lr'], batch=64,
 34                          weight_decay=cfg['weight_decay'], log=lambda *_: None)
 35        return float(res[1])
 36
 37class TubeGRU(nn.Module):
 38    def __init__(self, hidden=64, wbar=0.02, lam=0.02):
 39        super().__init__()
 40        self.rnn = nn.GRU(3, hidden, batch_first=True)
 41        self.head = nn.Linear(hidden, 1)
 42        self.wbar, self.lam = wbar, lam
 43
 44    def forward(self, x, tube_loss=False):
 45        seq = x.view(x.shape[0], -1, 3)
 46        h = torch.zeros(1, x.shape[0], self.rnn.hidden_size, device=x.device)
 47        eb = torch.full((x.shape[0], self.rnn.hidden_size), 0.01, device=x.device)
 48        penalty = torch.zeros((), device=x.device)
 49        for j in range(seq.shape[1]):
 50            z = seq[:, j:j+1]
 51            _, hn = self.rnn(z, h)
 52            # A practical upper bound for the hidden-state Jacobian: absolute
 53            # recurrent matrix row sums, scaled by tanh/sigmoid local saturation.
 54            W = self.rnn.weight_hh_l0
 55            gain = W.abs().sum(dim=1).mean() / max(1, self.rnn.hidden_size)
 56            gain = torch.clamp(gain, 0.0, 0.99)
 57            eb = gain * eb + self.wbar
 58            if tube_loss:
 59                margin = eb.mean() * self.head.weight.abs().mean()
 60                penalty = penalty + torch.relu(margin - 0.05).pow(2)
 61            h = hn
 62        out = self.head(h[-1])
 63        return (out, self.lam * penalty / seq.shape[1]) if tube_loss else out
 64
 65def idea_train(cfg, seed):
 66    seed_all(seed)
 67    ds = get_dataset('dynamics', seed, n_train=400, n_test=200)
 68    model = TubeGRU()
 69    dev = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
 70    try:
 71        model.to(dev)
 72        xtr,ytr,xte,yte = [ds[k].to(dev) for k in ('xtr','ytr','xte','yte')]
 73        opt = torch.optim.Adam(model.parameters(), lr=cfg['lr'], weight_decay=cfg['weight_decay'])
 74        for _ in range(14):
 75            model.train()
 76            for ix in torch.randperm(len(xtr), device=dev).split(64):
 77                pred, reg = model(xtr[ix], True)
 78                loss = (pred-ytr[ix]).pow(2).mean() + reg
 79                opt.zero_grad(); loss.backward(); torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0); opt.step()
 80        model.eval()
 81        with torch.no_grad(): val = (model(xte)-yte).pow(2).mean().item()
 82        return float(val)
 83    except Exception:
 84        model.cpu(); xtr,ytr,xte,yte = [ds[k].cpu() for k in ('xtr','ytr','xte','yte')]
 85        opt = torch.optim.Adam(model.parameters(), lr=cfg['lr'], weight_decay=cfg['weight_decay'])
 86        for _ in range(14):
 87            for ix in torch.randperm(len(xtr)).split(64):
 88                pred,reg=model(xtr[ix],True); loss=(pred-ytr[ix]).pow(2).mean()+reg
 89                opt.zero_grad(); loss.backward(); opt.step()
 90        with torch.no_grad(): return float((model(xte)-yte).pow(2).mean())
 91
 92def signature(seed, cfg):
 93    seed_all(seed)
 94    ds = get_dataset('dynamics', seed, n_train=400, n_test=200)
 95    # Signature is deliberately measured on CPU: the shared GPU can reject
 96    # cuDNN host allocations, while this does not alter either benchmark side.
 97    dev = torch.device('cpu')
 98    m = TubeGRU().to(dev)
 99    xx, yy = ds['xtr'].to(dev), ds['ytr'].to(dev)
100    opt = torch.optim.Adam(m.parameters(), lr=cfg['lr'], weight_decay=cfg['weight_decay'])
101    for _ in range(6):
102        for ix in torch.randperm(len(xx)).split(64):
103            pred, reg = m(xx[ix], True)
104            loss = (pred - yy[ix]).pow(2).mean() + reg
105            opt.zero_grad(); loss.backward(); opt.step()
106    m.eval()
107    x = ds['xte'][:32].to(dev)
108    h = torch.zeros(1, len(x), 64)
109    eb = torch.full((len(x), 64), .01)
110    observed, predicted = [], []
111    with torch.no_grad():
112        for j in range(8):
113            z = x[:, j*3:(j+1)*3].view(len(x), 1, 3)
114            _, hn = m.rnn(z, h)
115            observed.append(float((hn-h).abs().mean()))
116            gain = float(m.rnn.weight_hh_l0.abs().sum(1).mean() / 64)
117            gain = min(gain, .99)
118            eb = gain * eb + .02
119            predicted.append(float(eb.mean()))
120            h = hn
121    ratios = np.asarray(observed) / np.maximum(np.asarray(predicted), 1e-9)
122    return {'observed_hidden_change_mean': float(np.mean(observed)),
123            'predicted_tube_mean': float(np.mean(predicted)),
124            'ratio_observed_over_predicted': float(np.mean(ratios)),
125            'confirmed': bool(0.1 < np.mean(ratios) < 10.0)}
126
127def main():
128    base=sweep_baseline(lambda c: lambda s: baseline_train(c,s), GRID, seeds=(0,1,2,3))
129    base_full=evaluate(lambda s: baseline_train(base['best_cfg'],s), SEEDS)
130    base['full']=base_full
131    idea_cfgs = [base['best_cfg'], {'lr': 0.003, 'weight_decay': 0.0}, {'lr': 0.001, 'weight_decay': 0.0}]
132    idea_runs = [{'cfg': c, 'result': evaluate(lambda s, cc=c: idea_train(cc, s), SEEDS)} for c in idea_cfgs]
133    best_i = min(idea_runs, key=lambda z: z['result']['mean'])
134    idea = best_i['result']
135    rep=make_report('dynamics','rnn_small',base,idea,signature(0,best_i['cfg']))
136    rep['idea_sweep'] = [{'cfg': z['cfg'], 'mean': z['result']['mean']} for z in idea_runs]
137    rep['idea_best_cfg'] = best_i['cfg']
138    rep['protocol_notes']='Baseline and idea use matched 64-unit GRU systems, same data, epochs, batch, optimizer, and shared lr/weight-decay grid; idea tested at three shared settings.'
139    with open('bench_report.json','w') as f: json.dump(rep,f,indent=2)
140    print(json.dumps(rep,indent=2))
141
142if __name__=='__main__': main()