Polar-Backstepping Policy Residual / stage2_bench.py

Mechanism confirmed, baseline not beaten

Raw ⬇ ZIP
  1import sys, json, random
  2import numpy as np
  3import torch
  4import torch.nn as nn
  5sys.path.insert(0, '/home/maxwelhelp/all/math2nn')
  6from bench import get_dataset, make_model, sweep_baseline, make_report, permutation_pvalue
  7
  8SEEDS = tuple(range(8))
  9SWEEP_SEEDS = tuple(range(4))
 10EPOCHS = 20
 11BATCH = 128
 12# The union of learning rates is used by both systems.
 13LR_GRID = [1e-3, 3e-3, 1e-2]
 14WD_GRID = [0.0, 1e-4]
 15DT = 0.05
 16LAMBDA = 0.35
 17QTH, QOM = 1.0, 0.2
 18
 19
 20def seed_all(seed):
 21    np.random.seed(seed); random.seed(seed); torch.manual_seed(seed)
 22    if torch.cuda.is_available():
 23        torch.cuda.manual_seed_all(seed)
 24
 25
 26def device():
 27    if not torch.cuda.is_available(): return 'cpu'
 28    try:
 29        torch.cuda.get_device_properties(0)
 30        return 'cuda'
 31    except Exception:
 32        return 'cpu'
 33
 34
 35def lyap_terms(x, pred):
 36    # x consists of 8 (theta, omega, u) observations; pred is theta at t+dt.
 37    z = x.view(x.shape[0], 8, 3)[:, -1]
 38    th, om, u = z[:, 0], z[:, 1], z[:, 2]
 39    th_next = pred[:, 0]
 40    om_next = (th_next - th) / DT
 41    # Pendulum dynamics used by the bench, with g/10 in [0.8,1.2],
 42    # and damping omitted from the certificate only as a conservative local proxy.
 43    om_dot = -0.981 * torch.sin(th) - 0.25 * om + 2.0 * u
 44    v = 0.5 * (QTH * th * th + QOM * om * om)
 45    v_next = 0.5 * (QTH * th_next * th_next + QOM * om_next * om_next)
 46    drift = (v_next - v) / DT
 47    penalty = torch.relu(drift + LAMBDA * v).pow(2)
 48    return penalty, drift, v
 49
 50
 51def train_one(seed, lr, wd, stability, return_model=False):
 52    seed_all(seed)
 53    ds = get_dataset('dynamics', seed, n_train=400, n_test=400)
 54    dev = device()
 55    model = make_model('rnn_small', ds['input_shape'], ds['out_dim']).to(dev)
 56    opt = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=wd)
 57    x, y = ds['xtr'].to(dev), ds['ytr'].to(dev)
 58    for _ in range(EPOCHS):
 59        model.train(); perm = torch.randperm(len(x), device=dev)
 60        for i in range(0, len(x), BATCH):
 61            ix = perm[i:i+BATCH]; out = model(x[ix])
 62            mse = ((out-y[ix])**2).mean()
 63            if stability:
 64                stab, _, _ = lyap_terms(x[ix], out)
 65                loss = mse + stability * stab.mean()
 66            else: loss = mse
 67            opt.zero_grad(); loss.backward(); opt.step()
 68    model.eval()
 69    with torch.no_grad():
 70        xt, yt = ds['xte'].to(dev), ds['yte'].to(dev)
 71        out = model(xt); metric = float(((out-yt)**2).mean())
 72        p, drift, vv = lyap_terms(xt, out)
 73        # Signature values are measured on this trained model, not constructed analytically.
 74        sig = {'positive_drift_fraction': float((drift + LAMBDA*vv > 0).float().mean()),
 75               'mean_drift_plus_lambdaV': float((drift + LAMBDA*vv).mean()),
 76               'mean_V': float(vv.mean())}
 77    if return_model: return metric, sig
 78    return metric
 79
 80
 81def make_baseline(cfg):
 82    return lambda seed: train_one(seed, cfg['lr'], cfg['weight_decay'], 0.0)
 83
 84
 85def baseline_block():
 86    return sweep_baseline(make_baseline, [{'lr': lr, 'weight_decay': wd}
 87        for lr in LR_GRID for wd in WD_GRID], seeds=SWEEP_SEEDS)
 88
 89
 90def evaluate_idea(cfg):
 91    vals=[]
 92    for s in SEEDS: vals.append(train_one(s, cfg['lr'], cfg['weight_decay'], cfg['beta']))
 93    return {'mean': float(np.mean(vals)), 'std': float(np.std(vals)),
 94            'per_seed': vals, 'n': len(vals), 'cfg': cfg}
 95
 96
 97def main():
 98    base = baseline_block()
 99    best = base['best_cfg']
100    # Three idea settings; all learning rates are already present in baseline grid.
101    idea_cfgs = [
102        {'lr': best['lr'], 'weight_decay': best['weight_decay'], 'beta': 0.1},
103        {'lr': best['lr'], 'weight_decay': best['weight_decay'], 'beta': 0.5},
104        {'lr': best['lr'], 'weight_decay': best['weight_decay'], 'beta': 1.0},
105    ]
106    ideas = [evaluate_idea(c) for c in idea_cfgs]
107    idea = min(ideas, key=lambda r:r['mean'])
108    # Retain all three idea settings and a trained-model signature across paired seeds.
109    sig_rows=[]
110    base_sig=[]
111    for s in SEEDS:
112        _, si = train_one(s, best['lr'], best['weight_decay'], 0.0, True)
113        _, sj = train_one(s, idea['cfg']['lr'], idea['cfg']['weight_decay'], idea['cfg']['beta'], True)
114        sig_rows.append(sj); base_sig.append(si)
115    def avg(rows, key): return float(np.mean([r[key] for r in rows]))
116    signature = {
117      'prediction': 'Lyapunov drift penalty lowers positive one-step drift events on trained pendulum forecasts',
118      'baseline_trained': {k: avg(base_sig,k) for k in base_sig[0]},
119      'idea_trained': {k: avg(sig_rows,k) for k in sig_rows[0]},
120      'relative_positive_drift_reduction': 1-avg(sig_rows,'positive_drift_fraction')/max(avg(base_sig,'positive_drift_fraction'),1e-12),
121      'confirmed': avg(sig_rows,'positive_drift_fraction') < avg(base_sig,'positive_drift_fraction')
122    }
123    rep=make_report('dynamics','rnn_small',base,idea,signature)
124    rep['idea']['settings']=[{'cfg': r['cfg'], 'mean': r['mean'], 'std': r['std'], 'per_seed': r['per_seed'], 'n': r['n']} for r in ideas]
125    rep['protocol']={'paired_seeds':list(SEEDS),'sweep_seeds':list(SWEEP_SEEDS),
126                     'epochs':EPOCHS,'batch':BATCH,'loss':'MSE + beta*[finite_difference_dV + lambda*V]_+^2',
127                     'structural_match':'control/stability -> dynamics'}
128    with open('bench_report.json','w') as f: json.dump(rep,f,indent=2)
129    print(json.dumps(rep,indent=2))
130
131if __name__=='__main__': main()