Affine-symmetry-free GMM latent prior / bench_run.py
Mechanism confirmed, baseline not beaten
1import sys, json, math, random
2from itertools import permutations
3import numpy as np
4import torch
5from torch import nn
6import torch.nn.functional as F
7
8sys.path.insert(0, '/home/maxwelhelp/all/math2nn')
9from bench import sweep_baseline, evaluate, make_report
10from latent_mixture_transport_local import get_dataset
11
12TRACK = 'latent_mixture_transport'
13MODEL = 'mlp_tiny'
14SEEDS = tuple(range(8))
15SWEEP_SEEDS = (0, 1, 2, 3)
16K, ZDIM = 4, 2
17
18def seed_all(s):
19 random.seed(s); np.random.seed(s); torch.manual_seed(s)
20 if torch.cuda.is_available(): torch.cuda.manual_seed_all(s)
21
22def get_device():
23 try:
24 if torch.cuda.is_available():
25 torch.zeros(1, device='cuda')
26 return torch.device('cuda')
27 except Exception:
28 pass
29 return torch.device('cpu')
30
31def cov_from(raw, floor=0.08):
32 L = torch.tril(raw)
33 d = torch.diagonal(L, dim1=1, dim2=2)
34 L = L - torch.diag_embed(d) + torch.diag_embed(F.softplus(d) + 0.15)
35 return L @ L.transpose(1, 2) + floor * torch.eye(ZDIM, device=raw.device)
36
37def sym_penalty(w, mu, cov, eps=0.7, lc=0.4, lw=0.8):
38 out = torch.zeros((), device=mu.device)
39 ident = tuple(range(K))
40 for p in permutations(range(K)):
41 if p == ident: continue
42 q = torch.tensor(p, device=mu.device)
43 dist = torch.linalg.vector_norm(mu - mu[q], dim=1)
44 dist = dist + lc * torch.linalg.matrix_norm(cov - cov[q], dim=(1, 2)) + lw * torch.abs(w - w[q])
45 out = out + F.softplus(eps - dist).sum()
46 return out / (K * (K - 1))
47
48def mixture_nll(z, logits, mu, raw):
49 cov = cov_from(raw)
50 inv = torch.linalg.inv(cov)
51 diff = z[:, None, :] - mu[None, :, :]
52 q = torch.einsum('nkd,kde,nke->nk', diff, inv, diff)
53 ld = torch.logdet(cov)
54 lp = F.log_softmax(logits, 0)[None, :] - 0.5 * (q + ld[None, :] + ZDIM * math.log(2 * math.pi))
55 return -torch.logsumexp(lp, 1).mean()
56
57class MixtureAE(nn.Module):
58 def __init__(self, inp, out):
59 super().__init__()
60 self.enc = nn.Sequential(nn.Linear(inp, 64), nn.Tanh(), nn.Linear(64, ZDIM))
61 self.dec = nn.Sequential(nn.Linear(ZDIM, 64), nn.Tanh(), nn.Linear(64, out))
62 self.logits = nn.Parameter(torch.zeros(K))
63 self.mu = nn.Parameter(torch.randn(K, ZDIM) * 0.7)
64 self.raw = nn.Parameter(torch.randn(K, ZDIM, ZDIM) * 0.05)
65 def forward(self, x):
66 z = self.enc(x)
67 return self.dec(z), z
68 def signatures(self):
69 return F.softmax(self.logits, 0), self.mu, cov_from(self.raw)
70
71def run_one(seed, cfg, idea):
72 seed_all(seed)
73 ds = get_dataset(seed, 400, 200)
74 dev = get_device()
75 xtr = torch.as_tensor(ds['xtr'], dtype=torch.float32, device=dev)
76 ytr = torch.as_tensor(ds['ytr'], dtype=torch.float32, device=dev)
77 xte = torch.as_tensor(ds['xte'], dtype=torch.float32, device=dev)
78 yte = torch.as_tensor(ds['yte'], dtype=torch.float32, device=dev)
79 net = MixtureAE(xtr.shape[1], ytr.shape[1]).to(dev)
80 opt = torch.optim.Adam(net.parameters(), lr=cfg['lr'], weight_decay=cfg.get('weight_decay', 0.0))
81 bs = 64
82 for _ in range(cfg['epochs']):
83 order = torch.randperm(len(xtr), device=dev)
84 for ix in order.split(bs):
85 pred, z = net(xtr[ix])
86 w, mu, cov = net.signatures()
87 loss = F.mse_loss(pred, ytr[ix]) + 0.03 * mixture_nll(z, net.logits, mu, net.raw)
88 if idea:
89 loss = loss + cfg['eta'] * sym_penalty(w, mu, cov, cfg['eps'], cfg['lc'], cfg['lw'])
90 opt.zero_grad(); loss.backward(); torch.nn.utils.clip_grad_norm_(net.parameters(), 5.0); opt.step()
91 with torch.no_grad():
92 pred, z = net(xte)
93 metric = F.mse_loss(pred, yte).item()
94 w, mu, cov = net.signatures()
95 sig = {'mean_pair_min': float(torch.pdist(mu).min().item()), 'weight_std': float(w.std().item()), 'mixture_nll': float(mixture_nll(net.enc(xte), net.logits, mu, net.raw).item())}
96 return metric, sig
97
98def train_fn(cfg, idea):
99 def f(seed): return run_one(seed, cfg, idea)[0]
100 return f
101
102def eval_with_sig(cfg, idea):
103 vals=[]; sigs=[]
104 for s in SEEDS:
105 v, sig = run_one(s, cfg, idea); vals.append(v); sigs.append(sig)
106 return {'mean': float(np.mean(vals)), 'std': float(np.std(vals)), 'per_seed': vals, 'signatures': sigs, 'cfg': cfg}
107
108def main():
109 # Union parity: every lr is evaluated by both methods; baseline also sweeps its weight decay.
110 grid = []
111 for lr in [0.001, 0.003, 0.006]:
112 for wd in [0.0, 1e-4]: grid.append({'lr':lr, 'weight_decay':wd, 'epochs':12})
113 base = sweep_baseline(lambda c: train_fn(c, False), grid, seeds=SWEEP_SEEDS)
114 idea_cfgs = [dict(base['best_cfg'], eta=e, eps=.7, lc=.4, lw=.8) for e in [.03, .08, .15]]
115 # Include all lr/step sizes tried by the idea in the baseline sweep (already present in grid).
116 idea_runs = [eval_with_sig(c, True) for c in idea_cfgs]
117 idea = min(idea_runs, key=lambda r:r['mean'])
118 rep = make_report(TRACK, MODEL, base, idea, {'mechanism_signature': {
119 'prediction': 'signature separation penalty increases minimum component-mean separation and lowers residual component similarity',
120 'baseline_mean_pair_min': float(np.mean([x['mean_pair_min'] for x in eval_with_sig(base['best_cfg'], False)['signatures']])),
121 'idea_mean_pair_min': float(np.mean([x['mean_pair_min'] for x in idea['signatures']])),
122 'predicted_direction': 'idea_mean_pair_min > baseline_mean_pair_min',
123 'confirmed': bool(np.mean([x['mean_pair_min'] for x in idea['signatures']]) > np.mean([x['mean_pair_min'] for x in eval_with_sig(base['best_cfg'], False)['signatures']]))
124 }, 'track_rationale': 'latent_mixture_transport directly contains Gaussian-mixture component structure; built-in tracks do not.'}, )
125 with open('bench_report.json','w') as f: json.dump(rep,f,indent=2)
126 print(json.dumps(rep, indent=2))
127
128if __name__ == '__main__': main()