OT Primitive Universal Flow / ot_primitive_experiment.py

Mechanism confirmed, baseline not beaten

Raw ⬇ ZIP
  1import json, math, random
  2import numpy as np
  3import torch
  4import torch.nn as nn
  5
  6SEED = 2359
  7random.seed(SEED); np.random.seed(SEED); torch.manual_seed(SEED)
  8try:
  9    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
 10    if device.type == 'cuda': torch.cuda.get_device_properties(0)
 11except Exception:
 12    device = torch.device('cpu')
 13
 14
 15def exact_gaussian_ot(x, m1, s1, m2, s2):
 16    # In Euclidean space, for diagonal covariances, T(x)=m2+D2 D1^-1 (x-m1).
 17    return (x - m1) * (s2 / s1) + m2
 18
 19
 20def gaussian_sanity():
 21    g = torch.Generator().manual_seed(SEED)
 22    m1 = torch.tensor([-0.7, 0.4]); s1 = torch.tensor([1.3, 0.7])
 23    m2 = torch.tensor([1.1, -0.8]); s2 = torch.tensor([0.8, 1.4])
 24    x = torch.randn(20000, 2, generator=g) * s1 + m1
 25    y = exact_gaussian_ot(x, m1, s1, m2, s2)
 26    mean_err = float((y.mean(0) - m2).norm())
 27    cov = torch.cov(y.T)
 28    cov_err = float((cov - torch.diag(s2*s2)).norm())
 29    delta = torch.tensor([2.0, -1.5]); d2 = float(delta @ delta)
 30    scaling = []
 31    for L in [1, 2, 4, 8, 16]:
 32        observed = L * 0.5 * float((delta / L @ (delta / L)))
 33        predicted = d2 / (2 * L)
 34        scaling.append({'L': L, 'predicted': predicted, 'observed': observed,
 35                        'relative_error': abs(predicted-observed)/predicted})
 36    return {'gaussian_mean_error': mean_err, 'gaussian_covariance_error': cov_err,
 37            'translation_cost_scaling': scaling}
 38
 39
 40def moons(n, noise=0.08, seed=0):
 41    rng = np.random.RandomState(seed)
 42    t = rng.rand(n) * math.pi
 43    a = np.stack([np.cos(t), np.sin(t)], 1)
 44    b = np.stack([1-np.cos(t), 1-np.sin(t)-0.45], 1)
 45    z = np.concatenate([a, b], 0)[:n]
 46    z += noise * rng.randn(*z.shape)
 47    return torch.tensor(z, dtype=torch.float32)
 48
 49
 50def mmd_rbf(x, y):
 51    dxx = torch.cdist(x, x).square(); dyy = torch.cdist(y, y).square(); dxy = torch.cdist(x, y).square()
 52    ans = 0.
 53    for h in (0.3, 0.6, 1.0, 1.8, 3.0):
 54        ans = ans + torch.exp(-dxx/(2*h*h)).mean() + torch.exp(-dyy/(2*h*h)).mean() - 2*torch.exp(-dxy/(2*h*h)).mean()
 55    return ans / 5
 56
 57
 58def sliced_wasserstein(x, y, projections=32):
 59    th = torch.arange(projections, device=x.device, dtype=x.dtype) * (math.pi / projections)
 60    dirs = torch.stack([torch.cos(th), torch.sin(th)], 1)
 61    a = torch.sort(x @ dirs.T, 0).values
 62    b = torch.sort(y @ dirs.T, 0).values
 63    return float((a-b).abs().mean().item())
 64
 65
 66class PotentialLayer(nn.Module):
 67    # T(x)=x+grad psi(x), the Euclidean Exp_x(grad psi) adaptation.
 68    def __init__(self, hidden=24):
 69        super().__init__()
 70        self.net = nn.Sequential(nn.Linear(2, hidden), nn.Tanh(), nn.Linear(hidden, hidden), nn.Tanh(), nn.Linear(hidden, 1))
 71        # Small initial displacement makes the composition near identity.
 72        nn.init.zeros_(self.net[-1].weight); nn.init.zeros_(self.net[-1].bias)
 73
 74    def forward(self, x, create_graph=True):
 75        x = x.requires_grad_(True)
 76        psi = self.net(x).sum()
 77        v = torch.autograd.grad(psi, x, create_graph=create_graph, retain_graph=create_graph)[0]
 78        return x + v
 79
 80
 81class ResidualLayer(nn.Module):
 82    def __init__(self, hidden=24):
 83        super().__init__()
 84        self.net = nn.Sequential(nn.Linear(2, hidden), nn.Tanh(), nn.Linear(hidden, hidden), nn.Tanh(), nn.Linear(hidden, 2))
 85        nn.init.zeros_(self.net[-1].weight); nn.init.zeros_(self.net[-1].bias)
 86
 87    def forward(self, x): return x + self.net(x)
 88
 89
 90class OTFlow(nn.Module):
 91    def __init__(self, potential=True, layers=2):
 92        super().__init__()
 93        cls = PotentialLayer if potential else ResidualLayer
 94        self.layers = nn.ModuleList([cls() for _ in range(layers)])
 95        self.potential = potential
 96
 97    def forward(self, x, create_graph=True):
 98        for layer in self.layers:
 99            x = layer(x, create_graph) if self.potential else layer(x)
100        return x
101
102
103def train_and_measure(potential, source, target, steps=260):
104    model = OTFlow(potential=potential).to(device)
105    opt = torch.optim.Adam(model.parameters(), lr=0.008)
106    x, ytar = source.to(device), target.to(device)
107    for step in range(steps):
108        y = model(x, True)
109        # OT displacement plus the implementation's distribution discrepancy.
110        loss = 0.5*(y-x).square().sum(1).mean() + 12*mmd_rbf(y, ytar)
111        opt.zero_grad(); loss.backward(); torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0); opt.step()
112    # Potential maps require input derivatives at evaluation time; disable only parameter grads.
113    with torch.enable_grad():
114        y = model(x.detach(), False)
115        mm = float(mmd_rbf(y, ytar).item())
116        sw = sliced_wasserstein(y, ytar)
117        disp = float(0.5*(y-x.detach()).square().sum(1).mean().item())
118    # Jacobian determinant on a small held-out batch tests local invertibility.
119    q = source[:48].to(device).requires_grad_(True)
120    dets = []
121    for i in range(2):
122        row = torch.autograd.grad(model(q, True)[:, i].sum(), q, retain_graph=True)[0]
123        if i == 0: jac0 = row
124        else: jac1 = row
125    J = torch.stack([jac0, jac1], 2)
126    dets = torch.linalg.det(J).detach()
127    return {'mmd': mm, 'sliced_wasserstein': sw, 'displacement_cost': disp,
128            'jacobian_positive_fraction': float((dets > 0).float().mean().item()),
129            'jacobian_min': float(dets.min().item()), 'jacobian_max': float(dets.max().item())}
130
131
132def main():
133    global device
134    sanity = gaussian_sanity()
135    source = moons(192, seed=SEED+1)
136    gen = torch.Generator().manual_seed(SEED+2)
137    target = torch.randn(192, 2, generator=gen) * 1.05
138    try:
139        baseline = train_and_measure(False, source, target)
140        idea = train_and_measure(True, source, target)
141    except Exception:
142        device = torch.device('cpu')
143        baseline = train_and_measure(False, source, target)
144        idea = train_and_measure(True, source, target)
145    result = {'device': str(device), 'sanity_check': sanity,
146              'mini_experiment': {'baseline_unconstrained_residual': baseline, 'ot_gradient_potential': idea},
147              'settings': {'seed': SEED, 'samples': 192, 'layers': 2, 'steps': 260, 'mmd_weight': 12}}
148    with open('results.json', 'w') as f: json.dump(result, f, indent=2)
149    print(json.dumps(result, indent=2))
150
151if __name__ == '__main__': main()