Warm-Started Exact Rank Pruning / stage2_bench.py
Mechanism confirmed, baseline not beaten
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()