Robust Parameter-Update Envelope / mlp_comparison.py

Unverified

Raw ⬇ ZIP
  1"""Small endpoint-vs-envelope optimizer comparison.
  2
  3Uses a two-layer MLP on a fixed synthetic regression batch. Both methods form
  4exactly the same gradient proposal; the baseline checks constraints only at
  5the endpoint, while the envelope method scans interpolation points and halves
  6until all sampled points are feasible.
  7"""
  8import json
  9import random
 10from pathlib import Path
 11import numpy as np
 12import torch
 13
 14SEED = 584
 15random.seed(SEED)
 16np.random.seed(SEED)
 17torch.manual_seed(SEED)
 18try:
 19    if torch.cuda.is_available():
 20        torch.cuda.manual_seed_all(SEED)
 21    DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
 22except Exception:
 23    DEVICE = torch.device("cpu")
 24
 25
 26def make_data():
 27    g = torch.Generator(device="cpu").manual_seed(SEED)
 28    x = torch.randn(96, 2, generator=g)
 29    y = (torch.sin(2.0 * x[:, :1]) + 0.5 * x[:, 1:2] ** 2).float()
 30    return x.to(DEVICE), y.to(DEVICE)
 31
 32
 33class MLP(torch.nn.Module):
 34    def __init__(self):
 35        super().__init__()
 36        self.l1 = torch.nn.Linear(2, 16)
 37        self.l2 = torch.nn.Linear(16, 1)
 38
 39    def forward(self, x):
 40        h = torch.tanh(self.l1(x))
 41        return self.l2(h), h
 42
 43
 44def flat_params(model):
 45    return torch.cat([p.detach().reshape(-1) for p in model.parameters()])
 46
 47
 48def set_flat(model, flat):
 49    pos = 0
 50    with torch.no_grad():
 51        for p in model.parameters():
 52            n = p.numel()
 53            p.copy_(flat[pos:pos+n].reshape_as(p))
 54            pos += n
 55
 56
 57def evaluate(model, x, y, flat=None):
 58    old = flat_params(model)
 59    if flat is not None:
 60        set_flat(model, flat)
 61    with torch.no_grad():
 62        pred, h = model(x)
 63        loss = torch.mean((pred - y) ** 2)
 64        rms = torch.sqrt(torch.mean(h * h) + 1e-12)
 65    set_flat(model, old)
 66    return float(loss.cpu()), float(rms.cpu())
 67
 68
 69def constraints(model, x, y, flat, loss_limit, rms_limit):
 70    loss, rms = evaluate(model, x, y, flat)
 71    return max(loss - loss_limit, rms - rms_limit), loss, rms
 72
 73
 74def train(mode, lr, steps=45, K=8):
 75    torch.manual_seed(SEED)
 76    model = MLP().to(DEVICE)
 77    x, y = make_data()
 78    initial = flat_params(model)
 79    base_loss, base_rms = evaluate(model, x, y, initial)
 80    # Small safety margins make this a loss-growth/activation envelope,
 81    # rather than requiring the current point to be exactly on a boundary.
 82    loss_margin, rms_margin = 0.015, 0.10
 83    accepted, violations, losses, step_sizes = 0, [], [], []
 84    for _ in range(steps):
 85        model.zero_grad(set_to_none=True)
 86        pred, _ = model(x)
 87        loss = torch.mean((pred - y) ** 2)
 88        loss.backward()
 89        grad = torch.cat([p.grad.detach().reshape(-1) for p in model.parameters()])
 90        current = flat_params(model)
 91        proposal = current - lr * grad
 92        cur_loss, cur_rms = evaluate(model, x, y, current)
 93        loss_limit = cur_loss + loss_margin
 94        rms_limit = max(base_rms + rms_margin, cur_rms + rms_margin)
 95
 96        def scan(scale):
 97            vals = []
 98            for s in torch.linspace(0, 1, K + 1, device=DEVICE):
 99                v, pl, pr = constraints(model, x, y, current + s * scale * (proposal-current), loss_limit, rms_limit)
100                vals.append((v, pl, pr))
101            return max(z[0] for z in vals), max(z[1] for z in vals), max(z[2] for z in vals)
102
103        endpoint_v, endpoint_peak_loss, endpoint_peak_rms = scan(1.0) if mode == "envelope" else constraints(model, x, y, proposal, loss_limit, rms_limit)
104        scale = 1.0
105        if mode == "endpoint":
106            accepted_now = endpoint_v <= 1e-8
107            observed_v, peak_loss, peak_rms = endpoint_v, endpoint_peak_loss if isinstance(endpoint_peak_loss, float) else endpoint_v, endpoint_peak_rms if isinstance(endpoint_peak_rms, float) else 0.0
108        else:
109            observed_v, peak_loss, peak_rms = scan(scale)
110            while observed_v > 1e-8 and scale > 2.0 ** -12:
111                scale *= 0.5
112                observed_v, peak_loss, peak_rms = scan(scale)
113            accepted_now = observed_v <= 1e-8
114        if accepted_now:
115            set_flat(model, current + scale * (proposal-current))
116            accepted += 1
117        step_sizes.append(scale if accepted_now else 0.0)
118        violations.append(float(observed_v))
119        losses.append(evaluate(model, x, y)[0])
120    return {
121        "mode": mode, "lr": lr, "K": K, "final_loss": losses[-1],
122        "best_loss": min(losses), "accepted_steps": accepted,
123        "mean_accepted_fraction": float(np.mean([z for z in step_sizes if z > 0])) if accepted else 0.0,
124        "max_recorded_constraint_violation": max(violations),
125        "positive_violation_steps": sum(v > 1e-7 for v in violations),
126        "loss_curve": losses,
127    }
128
129
130def main():
131    results = []
132    for lr in (0.25, 0.5, 1.0, 2.0):
133        results += [train("endpoint", lr), train("envelope", lr, K=8), train("envelope", lr, K=16)]
134    out = {"seed": SEED, "device": str(DEVICE), "results": results}
135    Path("mlp_comparison_results.json").write_text(json.dumps(out, indent=2))
136    print(json.dumps(out, indent=2))
137
138if __name__ == "__main__":
139    main()