Weakest-Direction Information Margin for Latent-State Training / bench_experiment.py

Mechanism confirmed, baseline not beaten

Raw ⬇ ZIP
  1import sys, json, random
  2from pathlib import Path
  3import numpy as np
  4sys.path.insert(0, '/home/maxwelhelp/all/math2nn')
  5import torch
  6import torch.nn as nn
  7from bench import get_dataset, make_model, train_model, sweep_baseline, make_report
  8
  9SEEDS = tuple(range(8))
 10LRS = [1e-3, 3e-3, 6e-3]
 11EPOCHS = 8
 12BATCH = 128
 13PRIOR = 0.05
 14BETA = 0.01
 15TAU = 0.02
 16
 17
 18def seed_all(seed):
 19    random.seed(seed)
 20    np.random.seed(seed)
 21    torch.manual_seed(seed)
 22    if torch.cuda.is_available():
 23        torch.cuda.manual_seed_all(seed)
 24
 25
 26def baseline_fn(cfg):
 27    def run(seed):
 28        seed_all(seed)
 29        ds = get_dataset('dynamics', seed, n_train=300, n_test=150)
 30        net = make_model('rnn_small', ds['input_shape'], ds['out_dim'])
 31        _, metric, _ = train_model(net, ds, epochs=EPOCHS, lr=cfg['lr'],
 32                                   batch=BATCH, weight_decay=0.0,
 33                                   log=lambda *_: None)
 34        return float(metric) if metric is not None else float('inf')
 35    return run
 36
 37
 38def softmin(vals, tau):
 39    return -tau * torch.logsumexp(-vals / tau, dim=0)
 40
 41
 42def curvature_penalty(net, x):
 43    # Input Jacobian is measured on the actual recurrent model and actual benchmark
 44    # windows. For scalar output, J^T J is rank one per sample; prior makes H PD.
 45    x = x.detach().requires_grad_(True)
 46    out = net(x).reshape(-1)
 47    rows = []
 48    for i in range(len(out)):
 49        # Samples are independent, so one gradient of the summed scalar
 50        # yields every per-sample input Jacobian row.
 51        g = torch.autograd.grad(out.sum(), x, retain_graph=True,
 52                                create_graph=True)[0]
 53        rows = [g[j].reshape(-1) for j in range(len(g))]
 54    J = torch.stack(rows)
 55    H = PRIOR * torch.eye(J.shape[1], device=J.device, dtype=J.dtype) + J.T @ J / max(1, len(rows))
 56    ev = torch.linalg.eigvalsh(H)
 57    return softmin(ev, TAU), H.detach(), ev.detach()
 58
 59
 60def idea_run(seed, lr):
 61    seed_all(seed)
 62    ds = get_dataset('dynamics', seed, n_train=300, n_test=150)
 63    device = 'cuda' if torch.cuda.is_available() else 'cpu'
 64    for dev in ([device, 'cpu'] if device == 'cuda' else ['cpu']):
 65        try:
 66            net = make_model('rnn_small', ds['input_shape'], ds['out_dim']).to(dev)
 67            xtr, ytr = ds['xtr'].to(dev), ds['ytr'].to(dev)
 68            opt = torch.optim.Adam(net.parameters(), lr=lr)
 69            n = len(xtr)
 70            for _ in range(EPOCHS):
 71                net.train()
 72                perm = torch.randperm(n, device=dev)
 73                for start in range(0, n, BATCH):
 74                    idx = perm[start:start+BATCH]
 75                    pred = net(xtr[idx])
 76                    task = ((pred - ytr[idx]) ** 2).mean()
 77                    # Small subset keeps second-order autodiff affordable and fixed.
 78                    take = idx[:min(16, len(idx))]
 79                    margin, _, _ = curvature_penalty(net, xtr[take])
 80                    loss = task - BETA * margin
 81                    opt.zero_grad(set_to_none=True)
 82                    loss.backward()
 83                    opt.step()
 84            net.eval()
 85            with torch.no_grad():
 86                metric = float(((net(ds['xte'].to(dev)) - ds['yte'].to(dev)) ** 2).mean())
 87            return metric, net, dev
 88        except RuntimeError:
 89            if dev == 'cpu':
 90                raise
 91            torch.cuda.empty_cache()
 92    raise RuntimeError('training failed')
 93
 94
 95def idea_fn(cfg):
 96    return lambda seed: idea_run(seed, cfg['lr'])[0]
 97
 98
 99def measured_signature(base_lr, idea_lr):
100    # Re-train one paired seed and measure the trained systems' curvature and
101    # perturbation response using the same validation inputs.
102    seed = 0
103    seed_all(seed)
104    ds = get_dataset('dynamics', seed, n_train=300, n_test=150)
105    base = make_model('rnn_small', ds['input_shape'], ds['out_dim'])
106    base, _, _ = train_model(base, ds, epochs=EPOCHS, lr=base_lr, batch=BATCH, log=lambda *_: None)
107    imetric, idea, idev = idea_run(seed, idea_lr)
108    bdev = next(base.parameters()).device
109    xb = ds['xte'][:16].to(bdev)
110    xi = ds['xte'][:16].to(idev)
111    _, Hb, eb = curvature_penalty(base, xb)
112    _, Hi, ei = curvature_penalty(idea, xi)
113    # Parameter/input perturbation is observed model behaviour; compare output
114    # displacement under a fixed small perturbation of the input window.
115    eps = 1e-3
116    direction = torch.randn_like(xb)
117    direction = direction / direction.norm()
118    with torch.no_grad():
119        db = (base(xb + eps * direction) - base(xb)).norm().item()
120        di = (idea(xi + eps * direction.to(idev)) - idea(xi)).norm().item()
121    return {
122        'baseline_min_curvature': float(eb[0]),
123        'idea_min_curvature': float(ei[0]),
124        'baseline_trace_curvature': float(torch.trace(Hb)),
125        'idea_trace_curvature': float(torch.trace(Hi)),
126        'baseline_observed_perturbation': db,
127        'idea_observed_perturbation': di,
128        'predicted_inverse_margin_ratio': float(eb[0] / max(ei[0], 1e-12)),
129        'observed_perturbation_ratio': float(di / max(db, 1e-12)),
130        'confirmed': bool((ei[0] > eb[0]) and (di < db))
131    }
132
133
134def main():
135    grid = [{'lr': x} for x in LRS]
136    base = sweep_baseline(baseline_fn, grid, seeds=(0, 1, 2, 3))
137    best_lr = float(base['best_cfg']['lr'])
138    idea_grid = [{'lr': best_lr}, {'lr': LRS[max(0, LRS.index(best_lr)-1)]},
139                 {'lr': LRS[min(len(LRS)-1, LRS.index(best_lr)+1)]}]
140    idea_trials = []
141    for cfg in idea_grid:
142        vals = [float(idea_fn(cfg)(s)) for s in SEEDS]
143        idea_trials.append({'cfg': cfg, 'result': {'mean': float(np.mean(vals)), 'std': float(np.std(vals)), 'per_seed': vals, 'n': len(vals)}})
144    chosen = min(idea_trials, key=lambda z: z['result']['mean'])
145    report = make_report('dynamics', 'rnn_small', base, chosen['result'],
146                         {'mechanism_signature': measured_signature(best_lr, chosen['cfg']['lr']),
147                          'idea_trials': idea_trials,
148                          'structural_match': 'dynamics: recurrent actuated pendulum and stability/control task',
149                          'intervention': 'soft-min of input Jacobian Gauss-Newton curvature plus fixed prior'})
150    Path('bench_report.json').write_text(json.dumps(report, indent=2))
151    print(json.dumps(report, indent=2))
152
153
154if __name__ == '__main__':
155    main()