Prototype-Mixture Block Drafter / prototype_mixture_experiment.py

Mechanism works

Raw ⬇ ZIP
  1import json
  2import random
  3import numpy as np
  4import torch
  5import torch.nn as nn
  6
  7SEED = 236
  8random.seed(SEED); np.random.seed(SEED); torch.manual_seed(SEED)
  9torch.set_num_threads(4)
 10DEVICE = 'cuda' if torch.cuda.is_available() else 'cpu'
 11
 12
 13def tv(p, q):
 14    return 0.5 * torch.abs(p - q).sum(dim=-1)
 15
 16
 17def make_data(n, vocab=24, modes=3, hdim=3, noise=0.35, seed=236):
 18    g = np.random.default_rng(seed)
 19    # Distinct recurring continuation distributions, with modest within-mode variation.
 20    centers = g.dirichlet(np.full(vocab, 0.25), size=modes).astype('float32')
 21    labels = g.integers(0, modes, size=n)
 22    p = np.empty((n, vocab), dtype='float32')
 23    for i, z in enumerate(labels):
 24        concentration = 70.0 * centers[z] + 0.25
 25        p[i] = g.dirichlet(concentration).astype('float32')
 26    # Anchor representation is informative but imperfect: one-hot mode signal + Gaussian noise.
 27    h = np.eye(modes, hdim, dtype='float32')[labels]
 28    h += noise * g.normal(size=h.shape).astype('float32')
 29    return torch.tensor(h), torch.tensor(p), torch.tensor(labels), torch.tensor(centers)
 30
 31
 32class SharedProposal(nn.Module):
 33    def __init__(self, vocab):
 34        super().__init__()
 35        self.logits = nn.Parameter(torch.zeros(vocab))
 36    def forward(self, h):
 37        return self.logits.softmax(-1).expand(h.shape[0], -1)
 38
 39
 40class RoutedPrototypes(nn.Module):
 41    def __init__(self, hdim, vocab, k):
 42        super().__init__()
 43        self.prototypes = nn.Parameter(torch.randn(k, vocab) * 0.1)
 44        self.router = nn.Linear(hdim, k)
 45    def forward(self, h):
 46        q = self.prototypes.softmax(-1)
 47        pi = self.router(h).softmax(-1)
 48        mixture = pi @ q
 49        return mixture, q, pi
 50
 51
 52def train_shared(h, p, steps=900, lr=0.08):
 53    m = SharedProposal(p.shape[1]).to(DEVICE)
 54    opt = torch.optim.Adam(m.parameters(), lr=lr)
 55    h, p = h.to(DEVICE), p.to(DEVICE)
 56    for _ in range(steps):
 57        loss = tv(p, m(h)).mean()
 58        opt.zero_grad(); loss.backward(); opt.step()
 59    return m
 60
 61
 62def train_routed(h, p, k, steps=1200, lr=0.06, entropy=0.002):
 63    m = RoutedPrototypes(h.shape[1], p.shape[1], k).to(DEVICE)
 64    opt = torch.optim.Adam(m.parameters(), lr=lr)
 65    h, p = h.to(DEVICE), p.to(DEVICE)
 66    for _ in range(steps):
 67        mixture, q, pi = m(h)
 68        # The paper's soft surrogate, with a small positive entropy penalty.
 69        distances = 0.5 * torch.abs(p[:, None, :] - q[None, :, :]).sum(-1)
 70        loss = (pi * distances).sum(-1).mean() + entropy * (-(pi * (pi + 1e-8).log()).sum(-1).mean())
 71        opt.zero_grad(); loss.backward(); opt.step()
 72    return m
 73
 74
 75def evaluate(shared, routed, h, p, labels):
 76    hdev, pdev = h.to(DEVICE), p.to(DEVICE)
 77    with torch.no_grad():
 78        qb = shared(hdev)
 79        mix, q, pi = routed(hdev)
 80        d = 0.5 * torch.abs(pdev[:, None, :] - q[None, :, :]).sum(-1)
 81        oracle = d.min(-1).values
 82        selected = d.gather(1, pi.argmax(-1, keepdim=True)).squeeze(1)
 83        actual_mix = tv(pdev, mix)
 84        # A simple rejection/verification proxy: maximal coupling acceptance.
 85        out = {
 86            'shared_tv': tv(pdev, qb).mean().item(),
 87            'oracle_prototype_tv': oracle.mean().item(),
 88            'routed_selected_tv': selected.mean().item(),
 89            'mixture_tv': actual_mix.mean().item(),
 90            'oracle_reduction_vs_shared': (tv(pdev, qb).mean() - oracle.mean()).item(),
 91            'routed_reduction_vs_shared': (tv(pdev, qb).mean() - selected.mean()).item(),
 92            'mixture_reduction_vs_shared': (tv(pdev, qb).mean() - actual_mix.mean()).item(),
 93            'shared_accept_proxy': (1-tv(pdev, qb)).mean().item(),
 94            'routed_accept_proxy': (1-selected).mean().item(),
 95            'mixture_accept_proxy': (1-actual_mix).mean().item(),
 96            'router_accuracy': (pi.argmax(-1).cpu() == labels).float().mean().item(),
 97            'prototype_count': q.shape[0],
 98        }
 99    return out
100
101
102def exact_math_check():
103    # Two sharply separated distributions: K=1 has a nonzero information floor,
104    # while two prototypes attain zero oracle distance.
105    p = torch.tensor([[0.92, 0.04, 0.02, 0.02], [0.03, 0.03, 0.04, 0.90]])
106    q_shared = p.mean(0, keepdim=True).expand_as(p)
107    q_proto = p.clone()
108    l1 = tv(p, q_shared).mean().item()
109    lk = tv(p, q_proto).min(-1).values.mean().item()
110    return {'two_mode_L1': l1, 'two_mode_L2_oracle': lk, 'reduction': l1-lk,
111            'bound_sanity': bool(l1 >= lk - 1e-8 and lk < 1e-8)}
112
113
114def main():
115    math = exact_math_check()
116    htr, ptr, ytr, _ = make_data(1800, seed=SEED)
117    hte, pte, yte, _ = make_data(900, seed=SEED+1)
118    # Use the same underlying centers for a fair held-out test by generating test labels
119    # from training centers with fresh within-mode samples.
120    rng = np.random.default_rng(SEED+1)
121    centers = np.array(make_data(1, seed=SEED)[3])
122    labels = rng.integers(0, 3, size=900)
123    ptest = np.array([rng.dirichlet(70*centers[z] + .25) for z in labels]).astype('float32')
124    htest = np.eye(3, 3, dtype='float32')[labels] + .35*rng.normal(size=(900,3)).astype('float32')
125    hte, pte, yte = torch.tensor(htest), torch.tensor(ptest), torch.tensor(labels)
126    shared = train_shared(htr, ptr)
127    results = {'device': DEVICE, 'math_check': math, 'train_n': len(htr), 'test_n': len(hte)}
128    # Train on identical data and compare K=2 and K=3 to the standard single head.
129    for k in (2, 3):
130        routed = train_routed(htr, ptr, k)
131        results[f'K{k}'] = evaluate(shared, routed, hte, pte, yte)
132    # Parameter/FLOP accounting for the final vocabulary heads (shared hidden compute).
133    results['head_parameter_multiplier_K3'] = 3.0
134    results['note'] = 'Output-head-only multiplier; shared hidden/router compute is not included in this synthetic proxy.'
135    print(json.dumps(results, indent=2, sort_keys=True))
136
137if __name__ == '__main__':
138    try:
139        main()
140    except (RuntimeError, torch.cuda.OutOfMemoryError) as e:
141        if DEVICE == 'cuda':
142            print(json.dumps({'cuda_error': str(e), 'fallback': 'rerun with CUDA_VISIBLE_DEVICES empty'}))
143            raise
144        raise