Polyconvex rotation-frame Jacobian loss / experiment.py

Mechanism confirmed, baseline not beaten

Raw ⬇ ZIP
  1import json, math, random, time
  2from pathlib import Path
  3import numpy as np
  4import torch
  5import torch.nn as nn
  6from torch.func import jacrev, vmap
  7
  8SEED = 1234
  9random.seed(SEED); np.random.seed(SEED); torch.manual_seed(SEED)
 10torch.set_default_dtype(torch.float32)
 11device = "cuda" if torch.cuda.is_available() else "cpu"
 12
 13
 14def cofactor(a):
 15    # Stable 3x3 cofactor via cross products; unlike det(A) A^{-T},
 16    # this remains finite near singular matrices.
 17    c0 = torch.cross(a[..., :, 1], a[..., :, 2], dim=-1)
 18    c1 = torch.cross(a[..., :, 2], a[..., :, 0], dim=-1)
 19    c2 = torch.cross(a[..., :, 0], a[..., :, 1], dim=-1)
 20    return torch.stack((c0, c1, c2), dim=-1)
 21
 22
 23def polar_so3(a):
 24    u, _, vh = torch.linalg.svd(a, full_matrices=False)
 25    q = u @ vh
 26    sign = torch.where(torch.linalg.det(q) < 0, -torch.ones_like(q[..., 0, 0]), torch.ones_like(q[..., 0, 0]))
 27    # Flip the last right-singular vector when the polar factor is a reflection.
 28    vh = vh.clone()
 29    vh[..., -1, :] = vh[..., -1, :] * sign[..., None]
 30    return u @ vh
 31
 32
 33def target_map(x, amp=1.0):
 34    # Smooth orientation-preserving twist with radial compression.
 35    z = x[..., 2]
 36    th = amp * 1.8 * z
 37    c, s = torch.cos(th), torch.sin(th)
 38    xx, yy = 0.68 * x[..., 0], 0.68 * x[..., 1]
 39    return torch.stack((c*xx - s*yy, s*xx + c*yy, x[..., 2]), -1)
 40
 41
 42class Warp(nn.Module):
 43    def __init__(self, with_rotation=False):
 44        super().__init__()
 45        self.with_rotation = with_rotation
 46        self.body = nn.Sequential(nn.Linear(3, 48), nn.Tanh(), nn.Linear(48, 48), nn.Tanh())
 47        self.phi = nn.Linear(48, 3)
 48        self.rot = nn.Linear(48, 3) if with_rotation else None
 49        # Start close to identity, but retain trainability.
 50        nn.init.zeros_(self.phi.weight); nn.init.zeros_(self.phi.bias)
 51        if self.rot is not None:
 52            nn.init.zeros_(self.rot.weight); nn.init.zeros_(self.rot.bias)
 53
 54    def forward(self, x):
 55        h = self.body(x)
 56        y = self.phi(h) + x
 57        if self.with_rotation:
 58            # Stable SO(3) frame: exponential map avoids SVD gradients at
 59            # repeated singular values of the identity initialization.
 60            w = self.rot(h)
 61            z = torch.zeros_like(w[..., 0])
 62            wx = torch.stack((z, -w[..., 2], w[..., 1],
 63                              w[..., 2], z, -w[..., 0],
 64                              -w[..., 1], w[..., 0], z), dim=-1)
 65            wx = wx.reshape(*w.shape[:-1], 3, 3)
 66            return y, torch.matrix_exp(wx)
 67        return y
 68
 69
 70def jacobian(model, x):
 71    # Per-point forward deformation Jacobian; create_graph enables regularizer training.
 72    if model.with_rotation:
 73        f = lambda z: model(z)[0]
 74    else:
 75        f = model
 76    return vmap(jacrev(f))(x)
 77
 78
 79def identity_check():
 80    g = torch.Generator(device=device).manual_seed(SEED + 10)
 81    a = torch.randn(8, 3, 3, device=device, generator=g)
 82    q, _ = torch.linalg.qr(torch.randn(8, 3, 3, device=device, generator=g))
 83    # QR can contain reflections; make proper rotations.
 84    q[..., :, -1] *= torch.where(torch.linalg.det(q) < 0, -1., 1.)[:, None]
 85    lhs_c = cofactor(q.transpose(-1, -2) @ a)
 86    rhs_c = q.transpose(-1, -2) @ cofactor(a)
 87    lhs_d = torch.linalg.det(q.transpose(-1, -2) @ a)
 88    rhs_d = torch.linalg.det(a)
 89    return {"cofactor_max_abs": float((lhs_c-rhs_c).abs().max()),
 90            "det_max_abs": float((lhs_d-rhs_d).abs().max()),
 91            "rotation_det_max_abs": float((torch.linalg.det(q)-1).abs().max())}
 92
 93
 94def run_variant(kind, amp, steps=500):
 95    torch.manual_seed(SEED + int(amp*10) + (0 if kind == "baseline" else 100))
 96    model = Warp(with_rotation=(kind == "polyconvex")).to(device)
 97    opt = torch.optim.Adam(model.parameters(), lr=1e-3)
 98    # Fixed collocation set gives an equal-step comparison.
 99    gen = torch.Generator(device=device).manual_seed(SEED + 77)
100    x = (torch.rand(96, 3, device=device, generator=gen) * 2 - 1).requires_grad_(True)
101    y = target_map(x, amp).detach()
102    lam = 0.10
103    det_lam = 0.015
104    start = time.time(); history = []
105    for step in range(steps):
106        opt.zero_grad(set_to_none=True)
107        if kind == "baseline":
108            pred = model(x); J = jacobian(model, x)
109            data = ((pred-y)**2).mean()
110            reg = ((J-torch.eye(3, device=device))**2).mean()
111            d = torch.linalg.det(J)
112            # Ordinary baseline includes the same basic positive-Jacobian barrier.
113            barrier = (-torch.log(torch.clamp(d, min=1e-4)) + 20.0*torch.relu(-d)**2).mean()
114            loss = data + lam*reg + det_lam*barrier
115        else:
116            pred, R = model(x); J = jacobian(model, x)
117            data = ((pred-y)**2).mean()
118            U = R.transpose(-1,-2) @ J
119            C = cofactor(U); d = torch.linalg.det(J)
120            # Explicit convex quadratic in lifted variables plus determinant barrier.
121            eye = torch.eye(3, device=device)
122            pc = ((U-eye)**2).mean() + 0.5*((C-eye)**2).mean() + 0.5*((d-1)**2).mean()
123            barrier = (-torch.log(torch.clamp(d, min=1e-4)) + 20.0*torch.relu(-d)**2).mean()
124            loss = data + lam*pc + det_lam*barrier
125        if not torch.isfinite(loss):
126            break
127        loss.backward(); torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0); opt.step()
128        if step in (0, 99, 249, steps-1): history.append(float(data.detach()))
129    if kind == "polyconvex": pred, R = model(x)
130    else: pred = model(x)
131    J = jacobian(model, x).detach(); d = torch.linalg.det(J)
132    err = ((pred-y)**2).mean().item()
133    fold = (d <= 0).float().mean().item()
134    cond = torch.linalg.cond(J).nan_to_num(1e6).mean().item()
135    dmin = d.min().item()
136    return {"final_task_mse": err, "fold_fraction": fold, "mean_condition": cond,
137            "min_det": dmin, "task_history": history, "seconds": time.time()-start}
138
139
140def main():
141    global device
142    checks = identity_check()
143    results = {"device": device, "identity_check": checks, "runs": {}}
144    for amp in (1.0, 2.0):
145        for kind in ("baseline", "polyconvex"):
146            try:
147                results["runs"][f"{kind}_amp{amp}"] = run_variant(kind, amp)
148            except Exception as e:
149                # CUDA OOM or unsupported autodiff falls back to a CPU rerun.
150                if device == "cuda":
151                    device = "cpu"
152                    torch.set_default_device("cpu")
153                    results["device_fallback"] = str(e)
154                    results["runs"][f"{kind}_amp{amp}"] = run_variant(kind, amp)
155                else:
156                    raise
157    Path("results.json").write_text(json.dumps(results, indent=2))
158    print(json.dumps(results, indent=2))
159
160if __name__ == "__main__": main()