Bidirectional Conditional Cycle Loss / cycle_bench.py

Unverified

Raw ⬇ ZIP
  1import json, random
  2from pathlib import Path
  3import numpy as np
  4import torch
  5import torch.nn as nn
  6import sys
  7sys.path.insert(0, '/home/maxwelhelp/all/math2nn')
  8from bench import sweep_baseline, make_report
  9
 10META = {"name": "bidirectional_conditional_compatibility", "domain": "paired categorical conditionals", "description": "Synthetic positive joint with two neural conditional directions; evaluates compatibility regularization on held-out quadruples."}
 11SEEDS = tuple(range(8))
 12SWEEP_SEEDS = (0, 1, 2, 3)
 13NX = NY = 4
 14RAW = np.array([[1.0, 2.0, .7, 1.4], [2.1, .8, 1.8, .5], [.6, 1.7, 2.4, 1.1], [1.3, .9, 1.5, 2.2]], dtype=np.float64)
 15JOINT = RAW / RAW.sum()
 16
 17
 18def get_dataset(seed, n_train=400, n_test=400):
 19    rng = np.random.default_rng(1009 + seed)
 20    z = rng.choice(NX * NY, n_train + n_test, p=JOINT.ravel())
 21    x, y = z // NY, z % NY
 22    return {"xtr": x[:n_train].astype(np.int64), "ytr": y[:n_train].astype(np.int64),
 23            "xte": x[n_train:].astype(np.int64), "yte": y[n_train:].astype(np.int64),
 24            "task": "classification", "metric": "bidirectional_test_nll", "input_shape": (2,), "out_dim": 4}
 25
 26
 27class TwoConditionalMLP(nn.Module):
 28    def __init__(self, hidden=16):
 29        super().__init__()
 30        self.q = nn.Sequential(nn.Linear(NY, hidden), nn.Tanh(), nn.Linear(hidden, NX))
 31        self.r = nn.Sequential(nn.Linear(NX, hidden), nn.Tanh(), nn.Linear(hidden, NY))
 32
 33    def log_probs(self):
 34        ey = torch.eye(NY, device=next(self.parameters()).device)
 35        ex = torch.eye(NX, device=next(self.parameters()).device)
 36        return torch.log_softmax(self.q(ey), 1), torch.log_softmax(self.r(ex), 1)
 37
 38
 39def cycle_delta(lq, lr, x1, x2, y1, y2):
 40    return (lq[y1, x1] + lr[x2, y1] + lq[y2, x2] + lr[x1, y2]
 41            - lr[x1, y1] - lq[y2, x1] - lr[x2, y2] - lq[y1, x2])
 42
 43
 44def quadruples(seed):
 45    rows = [(x1,x2,y1,y2) for x1 in range(NX) for x2 in range(NX) if x1 != x2
 46            for y1 in range(NY) for y2 in range(NY) if y1 != y2]
 47    rng = np.random.default_rng(7001 + seed)
 48    p = rng.permutation(len(rows)); cut = len(rows)//2
 49    a = np.asarray(rows, dtype=np.int64)
 50    return torch.tensor(a[p[:cut]]), torch.tensor(a[p[cut:]])
 51
 52
 53def fit(seed, lr, lam, return_model=False):
 54    random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)
 55    ds = get_dataset(seed)
 56    # CPU is intentional here: the custom categorical benchmark is tiny and deterministic.
 57    dev = torch.device('cpu')
 58    model = TwoConditionalMLP().to(dev)
 59    opt = torch.optim.Adam(model.parameters(), lr=lr)
 60    x = torch.tensor(ds['xtr'], dtype=torch.long); y = torch.tensor(ds['ytr'], dtype=torch.long)
 61    trainq, heldq = quadruples(seed)
 62    for ep in range(70):
 63        perm = torch.randperm(len(x))
 64        for i in range(0, len(x), 128):
 65            ix = perm[i:i+128]
 66            lq, lrlog = model.log_probs()
 67            task = -.5 * (lq[y[ix], x[ix]].mean() + lrlog[x[ix], y[ix]].mean())
 68            d = cycle_delta(lq, lrlog, trainq[:,0], trainq[:,1], trainq[:,2], trainq[:,3])
 69            loss = task + lam * .5 * d.square().mean()
 70            opt.zero_grad(); loss.backward(); opt.step()
 71    with torch.no_grad():
 72        lq, lrlog = model.log_probs()
 73        xt = torch.tensor(ds['xte']); yt = torch.tensor(ds['yte'])
 74        nll = float(-.5 * (lq[yt, xt].mean() + lrlog[xt, yt].mean()))
 75        allq = torch.tensor([(x1,x2,y1,y2) for x1 in range(NX) for x2 in range(NX) if x1 != x2
 76                             for y1 in range(NY) for y2 in range(NY) if y1 != y2])
 77        dheld = cycle_delta(lq, lrlog, heldq[:,0], heldq[:,1], heldq[:,2], heldq[:,3]).numpy()
 78        dall = cycle_delta(lq, lrlog, allq[:,0], allq[:,1], allq[:,2], allq[:,3]).numpy()
 79        # Empirical observed conditionals provide an independent data-derived reference.
 80        counts = np.ones((NX, NY), dtype=np.float64) * 1e-3
 81        for xx, yy in zip(ds['xtr'], ds['ytr']): counts[xx, yy] += 1
 82        oq = torch.tensor((counts / counts.sum(0, keepdims=True)).T, dtype=torch.float32).log()
 83        orun = torch.tensor((counts / counts.sum(1, keepdims=True)), dtype=torch.float32).log()
 84        dob = cycle_delta(oq, orun, allq[:,0], allq[:,1], allq[:,2], allq[:,3]).numpy()
 85    out = {'metric': nll, 'heldout_p95_abs_delta': float(np.percentile(np.abs(dheld),95)),
 86           'heldout_mean_abs_delta': float(np.mean(np.abs(dheld))), 'all_mean_abs_delta': float(np.mean(np.abs(dall))),
 87           'observed_empirical_mean_abs_delta': float(np.mean(np.abs(dob)))}
 88    if return_model: out['model'] = model
 89    return out
 90
 91
 92def base_train(cfg, seed): return fit(seed, cfg['lr'], 0.0)['metric']
 93
 94def idea_train(cfg, seed): return fit(seed, cfg['lr'], cfg['lambda'])['metric']
 95
 96def aggregate(rows):
 97    vals = [r if isinstance(r, (float, int)) else r['metric'] for r in rows]
 98    return {'per_seed': [float(v) for v in vals], 'mean': float(np.mean(vals)),
 99            'std': float(np.std(vals, ddof=1))}
100
101
102def main():
103    # The union is shared: all idea learning rates are evaluated by baseline too.
104    lrs = [0.01, 0.03, 0.1]
105    base_grid = [{'lr': lr} for lr in lrs]
106    sweep = sweep_baseline(lambda cfg: (lambda seed: base_train(cfg, seed)), base_grid, seeds=SWEEP_SEEDS)
107    # Select by the harness's baseline sweep result, then use the same 3-config budget for idea.
108    best_lr = sweep['best_cfg']['lr']
109    idea_grid = [{'lr': lr, 'lambda': lam} for lr, lam in [(best_lr, .1), (best_lr, .3), (best_lr, .6)]]
110    idea_cfg_results = []
111    for cfg in idea_grid:
112        rows = [fit(s, cfg['lr'], cfg['lambda']) for s in SEEDS]
113        idea_cfg_results.append((cfg, aggregate(rows)))
114    best_cfg, idea = min(idea_cfg_results, key=lambda z: z[1]['mean'])
115    idea_details = [fit(s, best_cfg['lr'], best_cfg['lambda']) for s in SEEDS]
116    idea = aggregate(idea_details)
117    # Full paired baseline at the selected common learning rate.
118    base_details = [fit(s, best_lr, 0.0) for s in SEEDS]
119    base_full = aggregate(base_details)
120    base_block = {'sweep': sweep, 'best_config': {'lr': best_lr}, 'full': base_full}
121    sig = {'prediction': 'cycle penalty lowers held-out 95th-percentile |Delta| while retaining task NLL',
122           'baseline_heldout_p95': float(np.mean([r['heldout_p95_abs_delta'] for r in base_details])),
123           'idea_heldout_p95': float(np.mean([r['heldout_p95_abs_delta'] for r in idea_details])),
124           'baseline_test_nll': base_full['mean'], 'idea_test_nll': idea['mean'],
125           'observed_empirical_cycle_mean': float(np.mean([r['observed_empirical_mean_abs_delta'] for r in base_details])),
126           'confirmed': bool(np.mean([r['heldout_p95_abs_delta'] for r in idea_details]) <
127                             .8*np.mean([r['heldout_p95_abs_delta'] for r in base_details]))}
128    report = make_report('custom:bidirectional_conditional_compatibility', 'two_conditional_mlp', base_block, idea,
129                         {'custom_track': {'name': META['name'], 'file': 'cycle_bench.py', 'domain': META['domain']}, **sig})
130    Path('bench_report.json').write_text(json.dumps(report, indent=2))
131    print(json.dumps(report, indent=2))
132
133if __name__ == '__main__': main()