Finite-Horizon Walk Reciprocity Control / bench_walk_reciprocity.py

Mechanism confirmed, baseline not beaten

Raw ⬇ ZIP
  1import sys, json, math, random
  2from pathlib import Path
  3import numpy as np
  4sys.path.insert(0, '/home/maxwelhelp/all/math2nn')
  5import torch
  6from bench import get_dataset, make_model, sweep_baseline, make_report
  7
  8SEEDS = tuple(range(8))
  9L = 4
 10G = 0.8
 11BATCH = 128
 12EPOCHS = 12
 13LRS = [1e-3, 3e-3, 6e-3]
 14WDS = [0.0, 1e-4]
 15LAMBDAS = [0.05, 0.2, 0.8]
 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
 22
 23def walk_energy(A, L=L, g=G):
 24    s = torch.linalg.matrix_norm(A, ord=2) + 1e-6
 25    A = A / s
 26    v = A
 27    r = torch.zeros((), device=A.device, dtype=A.dtype)
 28    for k in range(1, L + 1):
 29        d = v - v.transpose(0, 1)
 30        r = r + (g ** (2*k-2)) * (d*d).sum() / A.shape[0]
 31        v = A @ v
 32    return r
 33
 34
 35def interaction(model):
 36    # GRU recurrent gate matrix is the local directed interaction proxy.
 37    w = model.rnn.weight_hh_l0
 38    h = w.shape[1]
 39    return w[2*h:3*h, :]
 40
 41
 42def train_one(seed, lr, wd, lam=0.0, target=0.0, return_model=False):
 43    seed_all(seed)
 44    ds = get_dataset('dynamics', seed, n_train=400, n_test=200)
 45    net = make_model('rnn_small', ds['input_shape'], ds['out_dim'])
 46    device = 'cuda' if torch.cuda.is_available() else 'cpu'
 47    try:
 48        net = net.to(device)
 49        xtr, ytr = ds['xtr'].to(device), ds['ytr'].to(device)
 50        xte, yte = ds['xte'].to(device), ds['yte'].to(device)
 51        opt = torch.optim.Adam(net.parameters(), lr=lr, weight_decay=wd)
 52        hist = []; e_hist = []
 53        for ep in range(EPOCHS):
 54            net.train(); perm = torch.randperm(len(xtr), device=device); total = 0.0
 55            for start in range(0, len(xtr), BATCH):
 56                idx = perm[start:start+BATCH]
 57                pred = net(xtr[idx]); task = ((pred-ytr[idx])**2).mean()
 58                r = walk_energy(interaction(net))
 59                loss = task + lam * torch.relu(r-target)**2
 60                opt.zero_grad(set_to_none=True); loss.backward(); opt.step()
 61                total += float(task.detach()) * len(idx)
 62            hist.append(total / len(xtr)); e_hist.append(float(walk_energy(interaction(net)).detach().cpu()))
 63        net.eval()
 64        with torch.no_grad():
 65            metric = float(((net(xte)-yte)**2).mean().cpu())
 66            observed_r = float(walk_energy(interaction(net)).cpu())
 67        out = {'seed': seed, 'metric': metric, 'final_train_loss': hist[-1],
 68               'walk_energy': observed_r, 'lambda': lam, 'lr': lr, 'weight_decay': wd,
 69               'epochs': EPOCHS, 'device': device}
 70        if return_model: out['_model'] = net
 71        return out
 72    except (RuntimeError, torch.cuda.OutOfMemoryError):
 73        if device == 'cuda':
 74            torch.cuda.empty_cache()
 75            seed_all(seed)
 76            net = make_model('rnn_small', ds['input_shape'], ds['out_dim']).to('cpu')
 77            xtr, ytr = ds['xtr'], ds['ytr']; xte, yte = ds['xte'], ds['yte']
 78            opt = torch.optim.Adam(net.parameters(), lr=lr, weight_decay=wd)
 79            hist=[]
 80            for ep in range(EPOCHS):
 81                perm=torch.randperm(len(xtr)); total=0.0
 82                for start in range(0,len(xtr),BATCH):
 83                    idx=perm[start:start+BATCH]; task=((net(xtr[idx])-ytr[idx])**2).mean()
 84                    r=walk_energy(interaction(net)); loss=task+lam*torch.relu(r-target)**2
 85                    opt.zero_grad(set_to_none=True); loss.backward(); opt.step(); total += float(task.detach())*len(idx)
 86                hist.append(total/len(xtr))
 87            net.eval()
 88            with torch.no_grad(): metric=float(((net(xte)-yte)**2).mean()); observed_r=float(walk_energy(interaction(net)))
 89            out={'seed':seed,'metric':metric,'final_train_loss':hist[-1],'walk_energy':observed_r,'lambda':lam,'lr':lr,'weight_decay':wd,'epochs':EPOCHS,'device':'cpu'}
 90            if return_model: out['_model']=net
 91            return out
 92        raise
 93
 94
 95def base_fn(cfg):
 96    return lambda seed: train_one(seed, cfg['lr'], cfg['weight_decay'], 0.0, 0.0)['metric']
 97
 98
 99def run():
100    # Calibrate target from a separate baseline first-epoch-like collection.
101    cal = [train_one(s, 3e-3, 0.0, 0.0) for s in SEEDS]
102    c = float(np.median([r['walk_energy'] for r in cal]) / (1-math.sqrt(1-G*G)))
103    target = c * (1-math.sqrt(1-G*G))
104    grid = [{'lr': lr, 'weight_decay': wd} for lr in LRS for wd in WDS]
105    # Harness sweep is used for baseline selection; each config runs paired seeds.
106    sweep = sweep_baseline(lambda cfg: base_fn(cfg), grid, seeds=SEEDS[:4])
107    best_cfg = sweep['best_cfg']
108    idea_cfgs = [{'lr': best_cfg['lr'], 'weight_decay': best_cfg.get('weight_decay',0.0), 'lambda': z} for z in LAMBDAS]
109    idea_runs = []
110    for cfg in idea_cfgs:
111        rows = [train_one(s, cfg['lr'], cfg['weight_decay'], cfg['lambda'], target) for s in SEEDS]
112        idea_runs.append({'config': cfg, 'mean_metric': float(np.mean([r['metric'] for r in rows])), 'per_seed': rows})
113    chosen = min(idea_runs, key=lambda z:z['mean_metric'])
114    base_block = {'best_cfg': best_cfg, 'sweep': sweep['sweep'], 'full': sweep['full']}
115    idea_block = {'best_config': chosen['config'], 'sweep': idea_runs, 'per_seed': [r['metric'] for r in chosen['per_seed']]}
116    base_rows = [dict(seed=s, metric=m) for s,m in zip(SEEDS, sweep['full']['per_seed'])]
117    base_sig_rows = [train_one(s, best_cfg['lr'], best_cfg.get('weight_decay',0.0), 0.0, 0.0) for s in SEEDS]
118    # Signature is measured on independently trained models and tests predicted suppression.
119    idea_metric_rows = [train_one(s, chosen['config']['lr'], chosen['config']['weight_decay'], chosen['config']['lambda'], target) for s in SEEDS]
120    pairs = [(b['walk_energy'], i['walk_energy']) for b,i in zip(base_sig_rows, idea_metric_rows)]
121    base_e = float(np.mean([x[0] for x in pairs])); idea_e = float(np.mean([x[1] for x in pairs]))
122    sig = {'prediction': 'walk regularization lowers finite-horizon forward/backward energy',
123           'baseline_walk_energy_mean': base_e, 'idea_walk_energy_mean': idea_e,
124           'relative_change_pct': 100*(idea_e-base_e)/max(base_e,1e-12),
125           'confirmed': bool(idea_e < base_e)}
126    report = make_report('dynamics', 'rnn_small', base_block, idea_block, {'mechanism_signature': sig})
127    report['calibration'] = {'g': G, 'L': L, 'phi_star': 1-math.sqrt(1-G*G), 'c_median': c, 'target': target}
128    Path('bench_report.json').write_text(json.dumps(report, indent=2))
129    print(json.dumps(report, indent=2))
130
131if __name__ == '__main__': run()