Affine-symmetry-free GMM latent prior / bench_run.py

Mechanism confirmed, baseline not beaten

Raw ⬇ ZIP
  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()