LP-Embedded Input-Convex MLP / bench_experiment.py

Mechanism confirmed, baseline not beaten

Raw ⬇ ZIP
  1import sys, json, random
  2import numpy as np
  3import torch
  4import torch.nn as nn
  5import torch.nn.functional as F
  6sys.path.insert(0, '/home/maxwelhelp/all/math2nn')
  7from bench import get_dataset, train_model, evaluate, sweep_baseline, make_report
  8
  9EPOCHS = 25
 10BATCH = 128
 11GRID = [{'lr': 1e-3}, {'lr': 3e-3}, {'lr': 1e-2}]
 12
 13
 14def seed_all(seed):
 15    np.random.seed(seed)
 16    random.seed(seed)
 17    torch.manual_seed(seed)
 18    if torch.cuda.is_available():
 19        try:
 20            torch.cuda.manual_seed_all(seed)
 21        except Exception:
 22            pass
 23
 24
 25class MatchedReLU(nn.Module):
 26    def __init__(self, d, width=32, depth=2):
 27        super().__init__()
 28        self.layers = nn.ModuleList()
 29        q = d
 30        for _ in range(depth):
 31            self.layers.append(nn.Linear(q, width))
 32            q = width
 33        self.out = nn.Linear(q, 1)
 34
 35    def forward(self, x):
 36        z = x
 37        for layer in self.layers:
 38            z = F.relu(layer(z))
 39        return self.out(z)
 40
 41
 42class ICNN(nn.Module):
 43    """Input-convex MLP with the same width/depth and direct input skips."""
 44    def __init__(self, d, width=32, depth=2):
 45        super().__init__()
 46        self.d, self.width, self.depth = d, width, depth
 47        self.A = nn.ParameterList()
 48        self.U = nn.ParameterList()
 49        self.b = nn.ParameterList()
 50        for k in range(depth):
 51            # z_0 is represented by a width-dimensional first affine embedding;
 52            # subsequent hidden-to-hidden matrices are nonnegative.
 53            self.A.append(nn.Parameter(torch.randn(width, width) * 0.12))
 54            self.U.append(nn.Parameter(torch.randn(width, d) * 0.12))
 55            self.b.append(nn.Parameter(torch.zeros(width)))
 56        self.aw = nn.Parameter(torch.zeros(width))
 57        self.u = nn.Parameter(torch.zeros(d))
 58        self.c = nn.Parameter(torch.zeros(1))
 59
 60    def forward(self, x):
 61        z = F.relu(x @ self.U[0].T + self.b[0])
 62        for k in range(1, self.depth):
 63            W = F.softplus(self.A[k]) + 1e-6
 64            z = F.relu(z @ W.T + x @ self.U[k].T + self.b[k])
 65        return (z @ F.softplus(self.aw) + x @ self.u + self.c).unsqueeze(-1)
 66
 67
 68def train_one(kind, cfg, seed, return_model=False):
 69    seed_all(seed)
 70    ds = get_dataset('tabular', seed=seed)
 71    d = int(ds['xtr'].shape[1])
 72    model = MatchedReLU(d) if kind == 'baseline' else ICNN(d)
 73    net, metric, hist = train_model(model, ds, epochs=EPOCHS,
 74                                    lr=cfg['lr'], batch=BATCH,
 75                                    weight_decay=0.0, log=lambda *_: None)
 76    if net is None:
 77        raise RuntimeError('training failed')
 78    if return_model:
 79        return float(metric), net, ds
 80    return float(metric)
 81
 82
 83def make_fn(kind, cfg):
 84    return lambda seed: train_one(kind, cfg, seed)
 85
 86
 87def mechanism_signature():
 88    # Re-test convexity on predictions from trained benchmark models, not toy weights.
 89    rows = []
 90    for seed in range(8):
 91        _, net, ds = train_one('idea', {'lr': 3e-3}, seed, return_model=True)
 92        device = next(net.parameters()).device
 93        x = ds['xte'][:96].to(device)
 94        rng = torch.Generator().manual_seed(9000 + seed)
 95        perm = torch.randperm(len(x), generator=rng)
 96        a, b = x, x[perm]
 97        t = 0.5
 98        with torch.no_grad():
 99            lhs = net(t*a + (1-t)*b).reshape(-1)
100            rhs = (t*net(a) + (1-t)*net(b)).reshape(-1)
101        gap = lhs - rhs
102        rows.append((float(gap.max()), float((gap > 1e-5).float().mean())))
103    max_gap = max(r[0] for r in rows)
104    rate = float(np.mean([r[1] for r in rows]))
105    return {
106        'claim': 'trained ICNN predictions satisfy midpoint Jensen convexity',
107        'predicted_max_violation': 0.0,
108        'observed_max_violation': max_gap,
109        'observed_violation_rate': rate,
110        'confirmed': bool(max_gap <= 1e-5 and rate == 0.0),
111        'n_models': 8
112    }
113
114
115def main():
116    base = sweep_baseline(lambda cfg: make_fn('baseline', cfg), GRID)
117    # Same three settings are run for the idea; best is selected on the same sweep seeds.
118    idea_sweep = []
119    for cfg in GRID:
120        r = evaluate(make_fn('idea', cfg), seeds=(0,1,2,3))
121        idea_sweep.append({'cfg': cfg, 'mean': r['mean']})
122    best_cfg = min(idea_sweep, key=lambda x: x['mean'])['cfg']
123    idea_full = evaluate(make_fn('idea', best_cfg))
124    idea_res = {'best_cfg': best_cfg, 'sweep': idea_sweep,
125                'full': idea_full, **idea_full}
126    report = make_report('tabular', 'mlp_med', base, idea_full,
127                         extra=mechanism_signature())
128    report['idea'] = idea_res
129    report['protocol_notes'] = {
130        'structural_match': 'tabular is the built-in track for architecture/regularization interventions',
131        'paired_seeds': list(range(8)),
132        'epochs': EPOCHS, 'batch': BATCH,
133        'baseline_and_idea_share_grid': True,
134        'baseline_architecture': '2-layer width-32 ReLU MLP',
135        'idea_architecture': '2-layer width-32 ICNN with softplus W>=0 and nonnegative output weights'
136    }
137    with open('bench_report.json', 'w') as f:
138        json.dump(report, f, indent=2)
139    print(json.dumps(report, indent=2))
140
141
142if __name__ == '__main__':
143    main()