Equation-addressable equilibrium layer / bench_experiment.py

Failed on benchmark

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, train_model, evaluate, sweep_baseline, make_report
  8
  9SEEDS = tuple(range(8))
 10SWEEP_SEEDS = (0, 1, 2, 3)
 11EPOCHS = 12
 12NTR, NTE = 400, 200
 13LR_GRID = [1e-3, 3e-3, 1e-2]
 14
 15
 16def seed_all(seed):
 17    random.seed(seed)
 18    np.random.seed(seed)
 19    torch.manual_seed(seed)
 20    if torch.cuda.is_available():
 21        torch.cuda.manual_seed_all(seed)
 22
 23
 24class VariableBaseline(nn.Module):
 25    """Standard variable-only predictor: one learned state readout."""
 26    def __init__(self, width=48):
 27        super().__init__()
 28        self.encoder = nn.Sequential(nn.Linear(24, width), nn.Tanh(),
 29                                     nn.Linear(width, width), nn.Tanh())
 30        self.readout = nn.Linear(width, 1)
 31
 32    def forward(self, x):
 33        return self.readout(self.encoder(x))
 34
 35
 36class EquationAddressable(nn.Module):
 37    """Implicit bipartite layer with three mechanism residuals and two variables.
 38
 39    f0 and f1 both address variable z0 through different learned mechanisms;
 40    f2 addresses z1 and couples to z0. The forward pass minimizes all residuals
 41    simultaneously by differentiable damped residual-gradient iterations.
 42    """
 43    def __init__(self, width=48, steps=8, step_size=0.20):
 44        super().__init__()
 45        self.encoder = nn.Sequential(nn.Linear(24, width), nn.Tanh(),
 46                                     nn.Linear(width, width), nn.Tanh())
 47        self.mechanisms = nn.ModuleList([
 48            nn.Sequential(nn.Linear(width + 2, width), nn.Tanh(), nn.Linear(width, 1))
 49            for _ in range(3)
 50        ])
 51        self.steps = int(steps)
 52        self.step_size = float(step_size)
 53
 54    def residuals(self, z, h):
 55        q = torch.cat([h, z], dim=1)
 56        a = [m(q)[:, 0] for m in self.mechanisms]
 57        # Bipartite incidence: f0--z0, f1--(z0,z1), f2--z1.
 58        return torch.stack([z[:, 0] - a[0],
 59                            z[:, 0] + 0.5 * z[:, 1] - a[1],
 60                            z[:, 1] - a[2]], dim=1)
 61
 62    def solve(self, h, intervention=None, xi=None, steps=None):
 63        # Residual-gradient equilibrium updates need a graph even during eval.
 64        with torch.enable_grad():
 65            return self._solve_grad(h, intervention, xi, steps)
 66
 67    def _solve_grad(self, h, intervention=None, xi=None, steps=None):
 68        z = torch.zeros(h.shape[0], 2, device=h.device, dtype=h.dtype)
 69        nsteps = self.steps if steps is None else int(steps)
 70        for _ in range(nsteps):
 71            z.requires_grad_(True)
 72            r = self.residuals(z, h)
 73            if intervention is not None:
 74                j, v = intervention
 75                rr = r.clone()
 76                rr[:, j] = z[:, v] - xi
 77            else:
 78                rr = r
 79            loss = 0.5 * (rr * rr).sum()
 80            grad = torch.autograd.grad(loss, z, create_graph=True)[0]
 81            z = z - self.step_size * grad
 82        return z
 83
 84    def forward(self, x):
 85        h = self.encoder(x)
 86        return self.solve(h)[:, :1]
 87
 88
 89def train_one(kind, seed, lr, steps=8, step_size=0.20, return_model=False):
 90    seed_all(seed)
 91    ds = get_dataset('dynamics', seed, n_train=NTR, n_test=NTE)
 92    if kind == 'baseline':
 93        net = VariableBaseline()
 94    else:
 95        net = EquationAddressable(steps=steps, step_size=step_size)
 96    net, metric, hist = train_model(net, ds, epochs=EPOCHS, lr=lr, batch=128,
 97                                    weight_decay=0.0, log=lambda *_: None)
 98    if net is None:
 99        raise RuntimeError('benchmark training failed')
100    if return_model:
101        return float(metric), net, ds
102    return float(metric)
103
104
105def baseline_factory(cfg):
106    return lambda seed: train_one('baseline', seed, float(cfg['lr']))
107
108
109def idea_factory(cfg):
110    return lambda seed: train_one('idea', seed, float(cfg['lr']),
111                                  steps=int(cfg['steps']), step_size=float(cfg['step_size']))
112
113
114def signature(idea_model, baseline_model, ds):
115    """Re-test the stage-1 prediction on trained models, not analytic equations."""
116    device = next(idea_model.parameters()).device
117    x = ds['xte'].to(device)[:64]
118    with torch.enable_grad():
119        h = idea_model.encoder(x)
120        with torch.no_grad():
121            obs = idea_model.solve(h)
122        xi = obs[:, 0].median().detach()
123        # Two distinct equation replacements fix the same trained variable value.
124        a = idea_model.solve(h, intervention=(0, 0), xi=xi, steps=12).detach()
125        b = idea_model.solve(h, intervention=(1, 0), xi=xi, steps=12).detach()
126    with torch.no_grad():
127        base_pred = baseline_model(x)[:, 0]
128    target_a = float((a[:, 0] - xi).abs().mean())
129    target_b = float((b[:, 0] - xi).abs().mean())
130    downstream = float((a[:, 1] - b[:, 1]).abs().mean())
131    ordinary = float((obs[:, 0] - base_pred).abs().mean())
132    return {
133        'prediction': 'same xi imposed by different equation replacements yields different downstream state',
134        'n_samples': 64,
135        'xi': float(xi),
136        'trained_model_target_error_f0': target_a,
137        'trained_model_target_error_f1': target_b,
138        'trained_model_downstream_separation': downstream,
139        'baseline_vs_observational_prediction_abs_gap': ordinary,
140        'confirmed': bool(max(target_a, target_b) < 0.08 and downstream > 0.01)
141    }
142
143
144def main():
145    # Baseline and idea use the same lr union; baseline is swept on four seeds.
146    grid = [{'lr': lr} for lr in LR_GRID]
147    base = sweep_baseline(baseline_factory, grid, seeds=SWEEP_SEEDS)
148    best_lr = float(base['best_cfg']['lr'])
149    idea_grid = [
150        {'lr': best_lr, 'steps': 8, 'step_size': 0.20},
151        {'lr': LR_GRID[max(0, LR_GRID.index(best_lr)-1)], 'steps': 8, 'step_size': 0.20},
152        {'lr': LR_GRID[min(len(LR_GRID)-1, LR_GRID.index(best_lr)+1)], 'steps': 8, 'step_size': 0.20},
153    ]
154    # Deduplicate if the best is at an edge while retaining three nearby trials when possible.
155    unique = []
156    for c in idea_grid:
157        if c not in unique: unique.append(c)
158    idea_grid = unique
159    idea_runs = []
160    for cfg in idea_grid:
161        r = evaluate(idea_factory(cfg), seeds=SEEDS)
162        idea_runs.append({'cfg': cfg, 'result': r})
163    chosen = min(idea_runs, key=lambda q: q['result']['mean'])
164    # Refit one paired seed for the behavior signature using the selected setting.
165    _, im, ds = train_one('idea', 0, chosen['cfg']['lr'], chosen['cfg']['steps'], chosen['cfg']['step_size'], True)
166    _, bm, _ = train_one('baseline', 0, best_lr, return_model=True)
167    sig = signature(im, bm, ds)
168    report = make_report('dynamics', 'rnn_small', base, chosen['result'], {
169        'mechanism_signature': sig,
170        'track_justification': 'Dynamics is structurally matched: the built-in task is an actuated pendulum rollout whose target depends on coupled state-transition mechanisms.',
171        'baseline_sweep_union_lr': LR_GRID,
172        'idea_sweep': idea_runs,
173        'idea_selected_cfg': chosen['cfg'],
174        'parameter_counts': {'baseline': sum(p.numel() for p in bm.parameters()), 'idea': sum(p.numel() for p in im.parameters())}
175    })
176    report['comparison']['idea_sweep_results'] = idea_runs
177    with open('bench_report.json', 'w') as f: json.dump(report, f, indent=2)
178    print(json.dumps(report, indent=2))
179
180
181if __name__ == '__main__':
182    main()