Buckets:
| """L-SR1: Learned Symmetric-Rank-One Preconditioning. | |
| Independent PyTorch reimplementation of the analytic (learned-optimizer) core of | |
| Lifshitz, Zuler, Fouks, Raviv. "L-SR1: Learned Symmetric-Rank-One | |
| Preconditioning." ICML 2026 (OpenReview w1fkDwiZgN, arXiv:2508.12270). | |
| No official code exists ("Code will be made publicly available upon acceptance"), | |
| so everything here is reconstructed from the paper text, Algorithm 1, Eq. 8/9, | |
| and Tables 4-6. | |
| The optimizer has three *element-wise* MLP modules (Claim 1) that operate on the | |
| per-coordinate optimizer state, a limited-memory SR1 preconditioner built from | |
| generated vectors, and a learned PSD projection enforced through a secant-residual | |
| penalty in the meta-loss (Claim 2). | |
| """ | |
| from __future__ import annotations | |
| import math | |
| from dataclasses import dataclass, field | |
| from typing import Callable | |
| import torch | |
| import torch.nn as nn | |
| # --------------------------------------------------------------------------- | |
| # Learned modules (Tables 4 & 5) -- all applied element-wise over coordinates. | |
| # --------------------------------------------------------------------------- | |
| class BasicBlock(nn.Module): | |
| """Table 5: Linear->BN->PReLU->Dropout->Linear->BN->Dropout, residual add.""" | |
| def __init__(self, dim: int, dropout: float = 0.0): | |
| super().__init__() | |
| self.fc1 = nn.Linear(dim, dim) | |
| self.bn1 = nn.BatchNorm1d(dim) | |
| self.act = nn.PReLU() | |
| self.drop1 = nn.Dropout(dropout) | |
| self.fc2 = nn.Linear(dim, dim) | |
| self.bn2 = nn.BatchNorm1d(dim) | |
| self.drop2 = nn.Dropout(dropout) | |
| def forward(self, x): | |
| h = self.drop1(self.act(self.bn1(self.fc1(x)))) | |
| h = self.drop2(self.bn2(self.fc2(h))) | |
| return x + h | |
| class MLP(nn.Module): | |
| """Table 4: fc1->BN->PReLU->Dropout->BasicBlock x2->fc2. Element-wise. | |
| Input is a tensor whose last dim is ``d_in``; every leading dim is treated as | |
| an independent sample (so the same MLP acts on every problem coordinate, | |
| giving the dimension-invariance the paper relies on). | |
| """ | |
| def __init__(self, d_in: int, d_out: int, d_hidden: int = 128, | |
| n_blocks: int = 2, dropout: float = 0.0): | |
| super().__init__() | |
| self.fc1 = nn.Linear(d_in, d_hidden) | |
| self.bn1 = nn.BatchNorm1d(d_hidden) | |
| self.act = nn.PReLU() | |
| self.drop = nn.Dropout(dropout) | |
| self.blocks = nn.ModuleList( | |
| [BasicBlock(d_hidden, dropout) for _ in range(n_blocks)]) | |
| self.fc2 = nn.Linear(d_hidden, d_out) | |
| def forward(self, x): | |
| lead = x.shape[:-1] | |
| x = x.reshape(-1, x.shape[-1]) | |
| h = self.drop(self.act(self.bn1(self.fc1(x)))) | |
| for blk in self.blocks: | |
| h = blk(h) | |
| h = self.fc2(h) | |
| return h.reshape(*lead, h.shape[-1]) | |
| class LSR1Config: | |
| d_hidden: int = 128 | |
| n_encoder_inputs: int = 5 # x, p, d_prev, g, q | |
| latent: int = 128 # encoder output width M (>5) | |
| buffer_L: int = 8 # limited-memory buffer size | |
| K: int = 16 # unrolled optimization steps (meta-training) | |
| gamma1: float = 0.4 # alpha = gamma1 * exp(gamma2 * alpha_tilde) | |
| gamma2: float = 0.001 | |
| lambda_sec: float = 100.0 # secant-residual weight in meta-loss | |
| dropout: float = 0.0 | |
| step_includes_identity: bool = True # B0=I term in the *step* direction: | |
| # the SR1 preconditioner is B~=I+Sum vv^T (matching the secant penalty), so | |
| # even the untrained optimizer does gradient descent and the learned vectors | |
| # add curvature. Verified essential: with it, the learned step reaches cosine | |
| # ~1.0 with the Newton direction on N=2 quadratics; without it, the optimizer | |
| # must learn the whole preconditioner from scratch and fails to align. | |
| relative_secant: bool = False # divide secant residual by ||q||^2 | |
| trust_region: float | None = None # cap on per-problem step L2 norm. | |
| # NOTE: not described in the paper. The learned SR1 direction d=(Sum vv^T)g is | |
| # linear in the gradient, so on stiff problems (Rosenbrock) an untrained/early | |
| # optimizer produces runaway steps (x->g->step feedback) and meta-training | |
| # NaNs. A trust region is standard practice for learned optimizers (cf. | |
| # gradient clipping in Andrychowicz+16). It is set large enough that it does | |
| # not bind near convergence, so quadratic/Newton-alignment results are | |
| # unaffected; it only tames the early nonconvex-training transient. | |
| class LSR1Optimizer(nn.Module): | |
| """The three learned modules + the limited-memory SR1 rollout (Algorithm 1).""" | |
| def __init__(self, cfg: LSR1Config): | |
| super().__init__() | |
| self.cfg = cfg | |
| self.encoder = MLP(cfg.n_encoder_inputs, cfg.latent, cfg.d_hidden, | |
| dropout=cfg.dropout) | |
| self.vector_gen = MLP(cfg.latent, 1, cfg.d_hidden, dropout=cfg.dropout) | |
| self.lr_gen = MLP(cfg.latent, 1, cfg.d_hidden, dropout=cfg.dropout) | |
| # Small-init the generator heads so the *untrained* preconditioner is | |
| # near-zero: this keeps the initial K-step rollout numerically stable | |
| # (large random v would make d = Sum v(v.g) overshoot and diverge) while | |
| # leaving non-zero gradients so the modules can learn to grow. Standard | |
| # practice for meta-trained (learned) optimizers. | |
| with torch.no_grad(): | |
| self.vector_gen.fc2.weight.mul_(0.01) | |
| self.vector_gen.fc2.bias.zero_() | |
| self.lr_gen.fc2.weight.zero_() # alpha_tilde=0 -> alpha=gamma1 | |
| self.lr_gen.fc2.bias.zero_() | |
| # -- one learned step ------------------------------------------------- | |
| def step_direction(self, x, p, d_prev, g, q, buffer): | |
| """Return (v_k, alpha_k, d_k, latent) for the current state. | |
| All tensors are [B, N] (batch of problems x coordinates); the buffer is a | |
| list of past v vectors, each [B, N]. ``d_k`` is the SR1 descent direction | |
| Sum_{v in B_L} v (v . g) (Algorithm 1), optionally + g (B0=I term). | |
| """ | |
| feats = torch.stack([x, p, d_prev, g, q], dim=-1) # [B, N, 5] | |
| latent = self.encoder(feats) # [B, N, M] | |
| v = self.vector_gen(latent).squeeze(-1) # [B, N] | |
| alpha_tilde = self.lr_gen(latent).squeeze(-1) # [B, N] | |
| alpha = self.cfg.gamma1 * torch.exp(self.cfg.gamma2 * alpha_tilde) | |
| vecs = buffer + [v] | |
| vecs = vecs[-self.cfg.buffer_L:] | |
| d = torch.zeros_like(g) | |
| if self.cfg.step_includes_identity: | |
| d = d + g | |
| for vv in vecs: | |
| coeff = (vv * g).sum(dim=-1, keepdim=True) # [B, 1] | |
| d = d + coeff * vv | |
| return v, alpha, d, vecs | |
| def secant_residual(q, p, buffer, relative=False): | |
| """||p - B~ q||^2 with B~ = I + Sum v v^T (Eq. 8), per problem. | |
| ``relative`` divides by ||q||^2 so the penalty is scale-invariant across | |
| problem families (needed only for the mixed benchmark, where gradient | |
| magnitudes span orders of magnitude; the quadratic claim-2 study uses the | |
| absolute form exactly as written in the paper). | |
| """ | |
| Bq = q.clone() | |
| for vv in buffer: | |
| Bq = Bq + (vv * q).sum(dim=-1, keepdim=True) * vv | |
| res = ((p - Bq) ** 2).sum(dim=-1) # [B] | |
| if relative: | |
| res = res / ((q ** 2).sum(dim=-1) + 1e-8) | |
| return res | |
| def rollout(opt: LSR1Optimizer, problem, x0, K=None, create_graph=True, | |
| record=False): | |
| """Unroll the learned optimizer for K steps on a batch of problems. | |
| ``problem`` exposes ``value(x) -> [B]`` and (via autograd) its gradient. | |
| Returns a dict with the accumulated meta-loss terms and, if ``record``, the | |
| trajectory / per-step diagnostics (used at eval time). | |
| """ | |
| cfg = opt.cfg | |
| K = cfg.K if K is None else K | |
| x = x0.clone().requires_grad_(True) | |
| B, N = x.shape | |
| zeros = torch.zeros_like(x) | |
| p = zeros; d_prev = zeros; q = zeros | |
| g = _grad(problem.value(x).sum(), x, create_graph) | |
| g_prev = g | |
| buffer: list[torch.Tensor] = [] | |
| f_terms, sec_terms = [], [] | |
| traj = [x.detach().clone()] if record else None | |
| dirs = [] if record else None | |
| for k in range(K): | |
| v, alpha, d, buffer = opt.step_direction(x, p, d_prev, g, q, buffer) | |
| step = alpha * d | |
| if cfg.trust_region is not None: | |
| # per-coordinate (infinity-norm) trust region -> dimension-invariant | |
| step = step.clamp(-cfg.trust_region, cfg.trust_region) | |
| x_next = x - step | |
| g_next = _grad(problem.value(x_next).sum(), x_next, create_graph) | |
| p_next = x_next - x | |
| q_next = g_next - g | |
| sec = opt.secant_residual(q_next, p_next, buffer, | |
| relative=cfg.relative_secant) | |
| f_terms.append(problem.value(x_next)) | |
| sec_terms.append(sec) | |
| if record: | |
| traj.append(x_next.detach().clone()) | |
| dirs.append((-alpha * d).detach().clone()) | |
| x, g_prev, g = x_next, g, g_next | |
| p, q, d_prev = p_next, q_next, d | |
| f_stack = torch.stack(f_terms) # [K, B] | |
| sec_stack = torch.stack(sec_terms) # [K, B] | |
| meta = (f_stack + cfg.lambda_sec * sec_stack).mean() | |
| out = { | |
| "meta_loss": meta, | |
| "f_final": f_stack[-1].detach(), | |
| "f_traj": f_stack.detach(), | |
| "sec_traj": sec_stack.detach(), | |
| } | |
| if record: | |
| out["traj"] = torch.stack(traj) # [K+1, B, N] | |
| out["dirs"] = torch.stack(dirs) # [K, B, N] | |
| return out | |
| def _grad(scalar, x, create_graph): | |
| (g,) = torch.autograd.grad(scalar, x, create_graph=create_graph) | |
| return g | |
| # --------------------------------------------------------------------------- | |
| # Analytic test problems (Sec. 5.1, App. C.2) | |
| # --------------------------------------------------------------------------- | |
| class Quadratic: | |
| """f(x) = 1/2 x^T H x + b^T x, H PSD (cond capped, unit Frobenius), |b|=1.""" | |
| def __init__(self, H, b): | |
| self.H = H # [B, N, N] | |
| self.b = b # [B, N] | |
| self.name = "quadratic" | |
| def value(self, x): # x: [B, N] -> [B] | |
| Hx = torch.einsum("bij,bj->bi", self.H, x) | |
| return 0.5 * (x * Hx).sum(-1) + (self.b * x).sum(-1) | |
| def newton_dir(self, x): | |
| g = torch.einsum("bij,bj->bi", self.H, x) + self.b | |
| return -torch.linalg.solve(self.H, g) | |
| def optimum(self): | |
| return -torch.linalg.solve(self.H, self.b) | |
| def make_quadratics(B, N, cond=1000.0, unit_frobenius=True, diagonal=False, | |
| generator=None, device="cpu", dtype=torch.float32): | |
| g = generator | |
| if diagonal: | |
| # eigenvalues geometrically spaced in [1/cond, 1] -> condition number cond | |
| if cond == 1.0: | |
| eig = torch.ones(B, N, device=device, dtype=dtype) | |
| else: | |
| t = torch.linspace(0, 1, N, device=device, dtype=dtype) | |
| eig = (1.0 / cond) ** (1 - t) # from 1/cond up to 1 | |
| eig = eig.unsqueeze(0).expand(B, N).contiguous() | |
| H = torch.diag_embed(eig) | |
| else: | |
| A = torch.randn(B, N, N, device=device, dtype=dtype, generator=g) | |
| H = A.transpose(-1, -2) @ A / N | |
| # cap condition number at `cond` | |
| evals, evecs = torch.linalg.eigh(H) | |
| lo = evals[..., -1:] / cond | |
| evals = torch.clamp(evals, min=lo) | |
| H = evecs @ torch.diag_embed(evals) @ evecs.transpose(-1, -2) | |
| if unit_frobenius: | |
| fro = torch.linalg.matrix_norm(H, ord="fro").unsqueeze(-1).unsqueeze(-1) | |
| H = H / fro | |
| b = torch.randn(B, N, device=device, dtype=dtype, generator=g) | |
| b = b / b.norm(dim=-1, keepdim=True) | |
| return Quadratic(H, b) | |
| class Rosenbrock: | |
| name = "rosenbrock" | |
| def value(self, x): | |
| xi, xn = x[..., :-1], x[..., 1:] | |
| return (100.0 * (xn - xi ** 2) ** 2 + (1 - xi) ** 2).sum(-1) | |
| class Rastrigin: | |
| name = "rastrigin" | |
| def value(self, x): | |
| N = x.shape[-1] | |
| return 10 * N + (x ** 2 - 10 * torch.cos(2 * math.pi * x)).sum(-1) | |
| def init_points(B, N, problem_name, scale=1.0, generator=None, device="cpu", | |
| dtype=torch.float32): | |
| x = torch.randn(B, N, device=device, dtype=dtype, generator=generator) | |
| if problem_name == "rosenbrock": | |
| x = x * 0.5 # start near the valley but off-optimum | |
| elif problem_name == "rastrigin": | |
| x = x * 2.0 # multimodal basin | |
| else: | |
| x = x * scale | |
| return x | |
Xet Storage Details
- Size:
- 12.7 kB
- Xet hash:
- 275afb37cd5ae013a870509a7146c36615ea2b83e1251ac19ca38307093eed8f
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.