Risk-Fitted Shrinkage Gate / risk_gate_experiment.py

Failed on benchmark

Raw ⬇ ZIP
  1import json
  2import math
  3import random
  4from pathlib import Path
  5
  6import numpy as np
  7import torch
  8
  9SEED = 474
 10random.seed(SEED)
 11np.random.seed(SEED)
 12torch.manual_seed(SEED)
 13
 14
 15def device_or_cpu():
 16    if torch.cuda.is_available():
 17        try:
 18            torch.zeros(1, device="cuda")
 19            return "cuda"
 20        except Exception:
 21            pass
 22    return "cpu"
 23
 24
 25class MonotoneSplineGate(torch.nn.Module):
 26    """delta(x)=s(|x|)x, with increasing knot values in [0,1]."""
 27    def __init__(self, knots, init_identity=False):
 28        super().__init__()
 29        self.register_buffer("knots", torch.tensor(knots, dtype=torch.float32))
 30        k = len(knots)
 31        # Positive increments in unconstrained space guarantee monotonicity.
 32        if init_identity:
 33            self.bias = torch.nn.Parameter(torch.tensor(5.0))
 34            self.raw_inc = torch.nn.Parameter(torch.full((k - 1,), -5.0))
 35        else:
 36            self.bias = torch.nn.Parameter(torch.tensor(-1.0))
 37            self.raw_inc = torch.nn.Parameter(torch.full((k - 1,), -1.0))
 38
 39    def knot_values(self):
 40        # sigmoid(bias + cumulative positive increments), hence monotone.
 41        increments = torch.nn.functional.softplus(self.raw_inc)
 42        logits = self.bias + torch.cat([torch.zeros(1, device=increments.device),
 43                                        torch.cumsum(increments, dim=0)])
 44        return torch.sigmoid(logits)
 45
 46    def spline(self, magnitude):
 47        k = self.knots.to(magnitude.device)
 48        vals = self.knot_values()
 49        # endpoint extrapolation is constant, as required for a bounded gate.
 50        idx = torch.bucketize(magnitude.detach().reshape(-1), k[1:-1]).reshape(magnitude.shape)
 51        idx = idx.clamp(0, len(k) - 2)
 52        lo, hi = k[idx], k[idx + 1]
 53        frac = ((magnitude - lo) / (hi - lo).clamp_min(1e-6)).clamp(0, 1)
 54        out = vals[idx] * (1 - frac) + vals[idx + 1] * frac
 55        out = torch.where(magnitude >= k[-1], vals[-1], out)
 56        return out
 57
 58    def slope(self, magnitude):
 59        k = self.knots.to(magnitude.device)
 60        vals = self.knot_values()
 61        idx = torch.bucketize(magnitude.detach().reshape(-1), k[1:-1]).reshape(magnitude.shape)
 62        idx = idx.clamp(0, len(k) - 2)
 63        out = (vals[idx + 1] - vals[idx]) / (k[idx + 1] - k[idx]).clamp_min(1e-6)
 64        return torch.where(magnitude >= k[-1], torch.zeros_like(out), out)
 65
 66    def forward(self, x):
 67        return self.spline(x.abs()) * x
 68
 69    def divergence(self, x):
 70        m = x.abs()
 71        return (self.spline(m) + m * self.slope(m)).sum(dim=-1)
 72
 73    def sure(self, x):
 74        d = x.shape[-1]
 75        return ((self(x) - x).square().sum(dim=-1) + 2 * self.divergence(x) - d).mean()
 76
 77
 78def finite_difference_divergence(gate, x, eps=1e-4):
 79    # Explicit Jacobian trace for a small tensor, used only as a math check.
 80    vals = []
 81    for j in range(x.numel()):
 82        xp, xm = x.clone(), x.clone()
 83        xp.reshape(-1)[j] += eps
 84        xm.reshape(-1)[j] -= eps
 85        vals.append(((gate(xp) - gate(xm)).reshape(-1)[j] / (2 * eps)).item())
 86    return sum(vals)
 87
 88
 89def fit_gate(x, steps=250):
 90    gate = MonotoneSplineGate(np.linspace(0, 4, 9)).to(x.device)
 91    opt = torch.optim.Adam(gate.parameters(), lr=0.04)
 92    for step in range(steps):
 93        opt.zero_grad()
 94        # Mild high-magnitude anchor prevents the unconstrained solution from
 95        # attenuating every coordinate when the batch is nearly all noise.
 96        loss = gate.sure(x) + 0.01 * (1 - gate.knot_values()[-1]).square()
 97        loss.backward()
 98        torch.nn.utils.clip_grad_norm_(gate.parameters(), 5.0)
 99        opt.step()
100    return gate
101
102
103def fixed_soft(x, threshold=1.0):
104    return torch.sign(x) * torch.relu(x.abs() - threshold)
105
106
107def risk(est, theta):
108    return (est - theta).square().mean().item()
109
110
111def run(device):
112    # Core calculus check: analytic divergence agrees with finite differences.
113    check_gate = MonotoneSplineGate(np.linspace(0, 4, 9)).to(device)
114    check_x = torch.tensor([[0.31, 1.17, 2.63, 3.71]], device=device)
115    div_formula = check_gate.divergence(check_x).item()
116    div_fd = finite_difference_divergence(check_gate, check_x).item() if False else finite_difference_divergence(check_gate, check_x)
117
118    # SURE unbiasedness check for a fixed smooth spline under Z=theta+N(0,I).
119    theta = torch.tensor([0.0, 0.5, 1.5, 3.0], device=device).repeat(256, 1)
120    z = theta + torch.randn_like(theta)
121    sure = ((check_gate(z) - z).square().sum(1) + 2 * check_gate.divergence(z) - z.shape[1]).mean().item() / z.shape[1]
122    actual = risk(check_gate(z), theta)
123    # Repeated draws check the expected SURE identity, not just one batch.
124    sure_rep, risk_rep = [], []
125    for _ in range(20):
126        zz = theta + torch.randn_like(theta)
127        sure_rep.append((((check_gate(zz) - zz).square().sum(1) + 2 * check_gate.divergence(zz) - zz.shape[1]).mean() / zz.shape[1]).item())
128        risk_rep.append(risk(check_gate(zz), theta))
129
130    # Adaptive normal-mean experiment: sparse and dense regimes, held-out risk.
131    results = {}
132    for name, scale in [("sparse", 0.35), ("dense", 2.0)]:
133        train_theta = scale * torch.randn(64, 128, device=device)
134        train_z = train_theta + torch.randn_like(train_theta)
135        gate = fit_gate(train_z)
136        test_theta = scale * torch.randn(256, 128, device=device)
137        test_z = test_theta + torch.randn_like(test_theta)
138        with torch.no_grad():
139            r_gate = risk(gate(test_z), test_theta)
140            r_id = risk(test_z, test_theta)
141            r_soft = risk(fixed_soft(test_z), test_theta)
142            train_sure = gate.sure(train_z).item() / train_z.shape[-1]
143            vals = gate.knot_values().detach().cpu().numpy().round(3).tolist()
144        results[name] = {"identity": r_id, "fixed_soft": r_soft,
145                         "risk_fitted_gate": r_gate, "train_sure_per_dim": train_sure,
146                         "knot_values": vals}
147
148    return {"device": device, "divergence_formula": div_formula,
149            "divergence_finite_difference": div_fd,
150            "fixed_gate_sure": sure, "fixed_gate_actual_risk": actual,
151            "fixed_gate_sure_repeated_mean": float(np.mean(sure_rep)),
152            "fixed_gate_actual_risk_repeated_mean": float(np.mean(risk_rep)),
153            "fixed_gate_sure_minus_risk_repeated": float(np.mean(sure_rep) - np.mean(risk_rep)),
154            "regimes": results}
155
156
157if __name__ == "__main__":
158    out = run(device_or_cpu())
159    print(json.dumps(out, indent=2))