Warm-Started Exact Rank Pruning / stage2_bench.py

Mechanism confirmed, baseline not beaten

Raw ⬇ ZIP
  1import sys, json, time, random
  2from pathlib import Path
  3import numpy as np
  4import torch
  5import torch.nn as nn
  6from torch.utils.data import TensorDataset, DataLoader
  7
  8sys.path.insert(0, '/home/maxwelhelp/all/math2nn')
  9from bench import get_dataset, train_model, sweep_baseline, make_report
 10
 11OUT = Path('bench_report.json')
 12DEVICE = 'cuda' if torch.cuda.is_available() else 'cpu'
 13EPOCHS, NTR, NTE, BATCH = 15, 1200, 1000, 128
 14LR_GRID = [0.0015, 0.003, 0.006]
 15LAMBDA_GRID = [0.0, 1e-6, 3e-6]
 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
 23class FactorizedLinear(nn.Module):
 24    def __init__(self, din, dout, rank=32):
 25        super().__init__()
 26        self.din, self.dout, self.rank = din, dout, rank
 27        self.u = nn.Parameter(torch.empty(dout, rank))
 28        self.v = nn.Parameter(torch.empty(din, rank))
 29        self.bias = nn.Parameter(torch.zeros(dout))
 30        # Scale initialization gives a normal-sized product while retaining all columns.
 31        nn.init.normal_(self.u, 0.0, 0.12)
 32        nn.init.normal_(self.v, 0.0, 0.12)
 33
 34    def forward(self, x):
 35        return x @ self.v @ self.u.t() + self.bias
 36
 37
 38class FactorMLP(nn.Module):
 39    def __init__(self, rank=32):
 40        super().__init__()
 41        self.l1 = FactorizedLinear(10, 64, min(rank, 10))
 42        self.l2 = FactorizedLinear(64, 64, rank)
 43        self.l3 = FactorizedLinear(64, 1, 1)
 44
 45    def forward(self, x):
 46        return self.l3(torch.relu(self.l2(torch.relu(self.l1(x)))))
 47
 48
 49def make_net(seed):
 50    seed_all(seed)
 51    return FactorMLP(32)
 52
 53
 54def ds_for(seed):
 55    return get_dataset('tabular', seed, n_train=NTR, n_test=NTE)
 56
 57
 58def baseline_train(seed, cfg):
 59    seed_all(seed)
 60    d = ds_for(seed)
 61    net = make_net(seed)
 62    # train_model is the canonical baseline path; only the model is the shared factorized MLP.
 63    _, metric, _ = train_model(net, d, epochs=EPOCHS, lr=float(cfg['lr']),
 64                               batch=BATCH, weight_decay=float(cfg['weight_decay']), log=lambda *_: None)
 65    return float(metric)
 66
 67
 68SIGNATURES = {}
 69
 70def pairwise_prox(net, eta, lam, active):
 71    changed = 0
 72    gaps = []
 73    for layer in (net.l1, net.l2, net.l3):
 74        with torch.no_grad():
 75            # Exact shared paired rule: ||u_j||^2+||v_j||^2 <= 4 eta lambda.
 76            score = layer.u.square().sum(0) + layer.v.square().sum(0)
 77            keep = (score > 4.0 * eta * lam) & active.get(layer, torch.ones_like(score, dtype=torch.bool))
 78            changed += int((~keep & active.get(layer, torch.ones_like(keep))).sum().item())
 79            layer.u[:, ~keep] = 0; layer.v[:, ~keep] = 0
 80            active[layer] = keep
 81            if keep.any():
 82                nu = layer.u[:, keep].norm(dim=0)
 83                nv = layer.v[:, keep].norm(dim=0)
 84                a = torch.sqrt(nv / (nu + 1e-12))
 85                layer.u[:, keep] *= a
 86                layer.v[:, keep] /= a
 87                gaps.append(float((layer.u[:, keep].norm(dim=0) - layer.v[:, keep].norm(dim=0)).abs().mean().cpu()))
 88            # Prevent Adam momentum from resurrecting deleted paired columns.
 89            if hasattr(layer.u, '_optim_state'):
 90                pass
 91    return changed, float(np.mean(gaps)) if gaps else 0.0
 92
 93
 94def idea_train(seed, cfg):
 95    seed_all(seed); d = ds_for(seed); net = make_net(seed)
 96    try:
 97        dev = torch.device(DEVICE)
 98        net.to(dev); x, y = d['xtr'].to(dev), d['ytr'].to(dev)
 99        opt = torch.optim.Adam(net.parameters(), lr=float(cfg['lr']), weight_decay=float(cfg['weight_decay']))
100        loader = DataLoader(TensorDataset(x, y), batch_size=BATCH, shuffle=True,
101                            generator=torch.Generator(device='cpu').manual_seed(seed + 91))
102        active = {layer: torch.ones(layer.rank, dtype=torch.bool, device=dev)
103                  for layer in (net.l1, net.l2, net.l3)}
104        ranks, gaps, total_pruned = [], [], 0
105        # Warm-start continuation: lambda rises through four stages, preserving one model.
106        stages = [0.0, cfg['lambda'] / 3.0, cfg['lambda'], 3.0 * cfg['lambda']]
107        stage_counts = [EPOCHS // len(stages) + (i < EPOCHS % len(stages)) for i in range(len(stages))]
108        for i, lam in enumerate(stages):
109            for _ in range(stage_counts[i]):
110                for xb, yb in loader:
111                    opt.zero_grad(set_to_none=True)
112                    loss = (net(xb) - yb).square().mean() / 2
113                    loss.backward(); opt.step()
114                    # Use the configured learning rate as eta in the stated proximal rule.
115                    pruned, gap = pairwise_prox(net, float(cfg['lr']), float(lam), active)
116                    total_pruned += pruned; gaps.append(gap)
117                    for layer in active:
118                        with torch.no_grad():
119                            layer.u[:, ~active[layer]] = 0; layer.v[:, ~active[layer]] = 0
120            ranks.append(int(sum(int(a.sum().item()) for a in active.values())))
121        with torch.no_grad():
122            pred = net(d['xte'].to(dev)); metric = float((pred - d['yte'].to(dev)).square().mean().cpu())
123        sig = {'seed': seed, 'ranks_by_lambda_stage': ranks,
124               'observed_monotone': bool(all(ranks[i+1] <= ranks[i] for i in range(len(ranks)-1))),
125               'mean_balanced_norm_gap': float(np.mean(gaps)) if gaps else 0.0,
126               'pruned_columns': total_pruned}
127        SIGNATURES[seed] = sig
128        return metric
129    except Exception:
130        # Required robust GPU fallback, rebuilding on CPU after any CUDA/runtime error.
131        torch.cuda.empty_cache() if torch.cuda.is_available() else None
132        old = globals()['DEVICE']; globals()['DEVICE'] = 'cpu'
133        try: return idea_train(seed, cfg)
134        finally: globals()['DEVICE'] = old
135
136
137def main():
138    t0 = time.time()
139    # Baseline decisive knobs are both learning rate and weight decay; idea tries the same lr union.
140    grid = [{'lr': lr, 'weight_decay': wd} for lr in LR_GRID for wd in [0.0, 1e-4]]
141    base = sweep_baseline(lambda cfg: (lambda s: baseline_train(s, cfg)), grid)
142    best_lr = float(base['best_cfg']['lr']); best_wd = float(base['best_cfg']['weight_decay'])
143    idea_grid = [{'lr': best_lr, 'weight_decay': best_wd, 'lambda': z} for z in LAMBDA_GRID]
144    # Include nearby lr settings; each is already present in the baseline union sweep.
145    idea_grid = [{'lr': lr, 'weight_decay': best_wd, 'lambda': lam}
146                 for lr in LR_GRID for lam in LAMBDA_GRID]
147    idea_runs = []
148    best_idea = None
149    for cfg in idea_grid:
150        SIGNATURES.clear()
151        res = __import__('bench').evaluate(lambda s, c=cfg: idea_train(s, c))
152        idea_runs.append({'cfg': cfg, 'result': res, 'signature': dict(SIGNATURES)})
153        if best_idea is None or res['mean'] < best_idea['result']['mean']: best_idea = idea_runs[-1]
154    rep = make_report('tabular', 'mlp_tiny', base, best_idea['result'], {
155        'prediction': 'warm-started exact paired pruning should produce nonincreasing active rank and balanced factor norms',
156        'observed': best_idea['signature'],
157        'predicted_monotone_rank': True,
158        'predicted_balanced_gap': 0.0,
159        'confirmed': bool(all(v['observed_monotone'] for v in best_idea['signature'].values()) and
160                     np.mean([v['mean_balanced_norm_gap'] for v in best_idea['signature'].values()]) < 1e-5)
161    })
162    rep['idea_sweep'] = idea_runs
163    rep['runtime_sec'] = time.time() - t0
164    rep['device'] = DEVICE
165    OUT.write_text(json.dumps(rep, indent=2))
166    print(json.dumps(rep, indent=2))
167
168if __name__ == '__main__': main()