polystep 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. polystep/__init__.py +176 -0
  2. polystep/_compiled.py +334 -0
  3. polystep/_step_blockwise.py +994 -0
  4. polystep/_step_momentum.py +128 -0
  5. polystep/_step_monolithic.py +1070 -0
  6. polystep/adaptive_subspace.py +762 -0
  7. polystep/api.py +307 -0
  8. polystep/ask_tell.py +164 -0
  9. polystep/benchmarks/__init__.py +62 -0
  10. polystep/benchmarks/baselines.py +604 -0
  11. polystep/benchmarks/rl/__init__.py +13 -0
  12. polystep/benchmarks/rl/cartpole.py +260 -0
  13. polystep/benchmarks/rl/gym_evaluator.py +304 -0
  14. polystep/benchmarks/rl/metrics.py +53 -0
  15. polystep/benchmarks/rl/policies.py +133 -0
  16. polystep/benchmarks/utils.py +1262 -0
  17. polystep/blockwise.py +549 -0
  18. polystep/cma.py +400 -0
  19. polystep/cma_subspace.py +404 -0
  20. polystep/cost_nn.py +633 -0
  21. polystep/costs.py +86 -0
  22. polystep/dynamics.py +138 -0
  23. polystep/epsilon.py +217 -0
  24. polystep/geometry.py +410 -0
  25. polystep/hybrid_subspace.py +1335 -0
  26. polystep/layers/__init__.py +16 -0
  27. polystep/layers/attention.py +243 -0
  28. polystep/layers/rnn.py +276 -0
  29. polystep/objectives/__init__.py +25 -0
  30. polystep/objectives/base.py +77 -0
  31. polystep/objectives/synthetic.py +232 -0
  32. polystep/optimizer.py +1405 -0
  33. polystep/projection/__init__.py +5 -0
  34. polystep/projection/sparse.py +291 -0
  35. polystep/py.typed +0 -0
  36. polystep/quadratic_model.py +285 -0
  37. polystep/solver.py +654 -0
  38. polystep/solvers/__init__.py +34 -0
  39. polystep/solvers/_prelude.py +129 -0
  40. polystep/solvers/base.py +77 -0
  41. polystep/solvers/greedy.py +159 -0
  42. polystep/solvers/kl_softmax.py +200 -0
  43. polystep/solvers/sinkhorn.py +522 -0
  44. polystep/solvers/softmax.py +151 -0
  45. polystep/solvers/tempered_softmax.py +97 -0
  46. polystep/subspace.py +694 -0
  47. polystep/transform.py +365 -0
  48. polystep-0.7.0.dist-info/METADATA +285 -0
  49. polystep-0.7.0.dist-info/RECORD +52 -0
  50. polystep-0.7.0.dist-info/WHEEL +5 -0
  51. polystep-0.7.0.dist-info/licenses/LICENSE +191 -0
  52. polystep-0.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,5 @@
1
+ """Projection module for memory-efficient large-scale subspace compression."""
2
+
3
+ from .sparse import SparseRandomProjection
4
+
5
+ __all__ = ["SparseRandomProjection"]
@@ -0,0 +1,291 @@
1
+ """Sparse random projection for memory-efficient large-scale subspace.
2
+
3
+ Implements an Achlioptas-style sparse projection (Rademacher entries at
4
+ randomly sampled positions) at the very-sparse density of Li, Hastie &
5
+ Church (2006), preserving Johnson-Lindenstrauss distance guarantees
6
+ while using O(nnz) memory.
7
+
8
+ References:
9
+ Achlioptas, D. (2003). "Database-friendly random projections:
10
+ Johnson-Lindenstrauss with binary coins."
11
+ Li, P., Hastie, T. & Church, K. (2006). "Very sparse random
12
+ projections."
13
+ """
14
+
15
+ import math
16
+ import warnings
17
+ from typing import Optional
18
+
19
+ import torch
20
+
21
+
22
+ # Below this ratio of subspace-dim to source-dim, the Johnson-
23
+ # Lindenstrauss distance guarantees stop holding for typical
24
+ # optimization workloads. Empirical floor: GPT-2 124M projected to
25
+ # 128-dim (ratio ~ 1e-6) collapsed to random predictions in early
26
+ # experiments.
27
+ _EXTREME_COMPRESSION_RATIO = 1e-5
28
+
29
+
30
+ class SparseRandomProjection:
31
+ """Sparse random projection for memory-efficient large-scale subspace.
32
+
33
+ Achlioptas-style sparse projection: each column has Rademacher (+1/-1)
34
+ entries at randomly sampled positions, scaled by 1/sqrt(nnz_per_col).
35
+ The default density ``1/sqrt(full_dim)`` follows Li, Hastie & Church
36
+ (2006), which preserves JL distance guarantees while keeping memory
37
+ at O(nnz).
38
+
39
+ The projection matrix P is (full_dim x subspace_dim) with:
40
+ - Each column has exactly nnz_per_col = density * full_dim nonzeros
41
+ - Values are +1 or -1 (Rademacher) scaled by 1/sqrt(nnz_per_col)
42
+
43
+ Memory usage: O(density * full_dim * subspace_dim) = O(nnz)
44
+ For 1M params, rank-256, density 1/sqrt(1M) = 0.1%:
45
+ - Dense: 1M * 256 * 4 bytes = 1GB
46
+ - Sparse: 1M * 0.001 * 256 * 4 bytes = 1MB
47
+
48
+ Attributes:
49
+ full_dim: Dimension of the full parameter space.
50
+ subspace_dim: Dimension of the low-rank subspace.
51
+ density: Fraction of nonzeros per column (default: 1/sqrt(full_dim)).
52
+ seed: Random seed for reproducibility.
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ full_dim: int,
58
+ subspace_dim: int,
59
+ density: Optional[float] = None,
60
+ seed: int = 0,
61
+ ):
62
+ """Initialize sparse random projection.
63
+
64
+ Args:
65
+ full_dim: Dimension of the full parameter space.
66
+ subspace_dim: Dimension of the low-rank subspace.
67
+ density: Fraction of nonzeros per column. Default: 1/sqrt(full_dim).
68
+ seed: Random seed for reproducibility.
69
+ """
70
+ self.full_dim = full_dim
71
+ self.subspace_dim = subspace_dim
72
+ self.density = density if density is not None else 1.0 / math.sqrt(full_dim)
73
+ self.seed = seed
74
+
75
+ # Warn loudly at extreme compression ratios -- see
76
+ # ``_EXTREME_COMPRESSION_RATIO`` above.
77
+ if full_dim > 0:
78
+ ratio = subspace_dim / full_dim
79
+ if ratio < _EXTREME_COMPRESSION_RATIO:
80
+ warnings.warn(
81
+ f"SparseRandomProjection: subspace_dim={subspace_dim} / "
82
+ f"full_dim={full_dim} = {ratio:.2e} is below the empirical "
83
+ f"floor ({_EXTREME_COMPRESSION_RATIO:.0e}) where the "
84
+ f"projection preserves distances meaningfully. The paper "
85
+ f"reports GPT-2 124M with 128-dim projection collapsed to "
86
+ f"random predictions in this regime. Consider increasing "
87
+ f"subspace_dim.",
88
+ stacklevel=2,
89
+ )
90
+
91
+ # Compute nnz per column (at least 1)
92
+ self._nnz_per_col = max(1, int(self.density * full_dim))
93
+
94
+ # Lazy initialization: indices and values created on first use
95
+ self._indices: Optional[torch.Tensor] = None
96
+ self._values: Optional[torch.Tensor] = None
97
+ self._device: Optional[torch.device] = None
98
+ self._dtype: Optional[torch.dtype] = None
99
+ self._sparse_matrix: Optional[torch.Tensor] = None
100
+
101
+ def _init_sparse_matrix(self, device: torch.device, dtype: torch.dtype) -> None:
102
+ """Initialize sparse projection matrix on demand.
103
+
104
+ Creates row indices via vectorized random sampling and Rademacher
105
+ values (+1/-1) scaled by 1/sqrt(nnz_per_col).
106
+
107
+ Uses ``torch.randint`` for row index generation which samples with
108
+ replacement. At typical sparsity (density = 1/sqrt(n)), the collision
109
+ rate is negligible: for n=786K and nnz_per_col=886, expected collisions
110
+ per column is ~0.5 (0.06% of entries). Any duplicate indices in a
111
+ column are summed by ``coalesce()``, which marginally reduces that
112
+ column's effective nonzeros but does not affect JL distance
113
+ preservation guarantees in practice.
114
+
115
+ Args:
116
+ device: Device for the sparse matrix.
117
+ dtype: Data type for values.
118
+ """
119
+ # Use CPU generator for consistent behavior (avoids CUDA generator issues)
120
+ generator = torch.Generator(device="cpu")
121
+ generator.manual_seed(self.seed)
122
+
123
+ total_nnz = self._nnz_per_col * self.subspace_dim
124
+
125
+ # Scale factor for unit variance
126
+ scale = 1.0 / math.sqrt(self._nnz_per_col)
127
+
128
+ # Vectorized row index sampling (with replacement -- negligible collisions)
129
+ row_indices = torch.randint(
130
+ 0,
131
+ self.full_dim,
132
+ (total_nnz,),
133
+ generator=generator,
134
+ )
135
+
136
+ # Column indices: each column gets nnz_per_col consecutive entries
137
+ col_indices = (
138
+ torch.arange(self.subspace_dim)
139
+ .unsqueeze(1)
140
+ .expand(
141
+ -1,
142
+ self._nnz_per_col,
143
+ )
144
+ .reshape(-1)
145
+ )
146
+
147
+ # Rademacher values: +1 or -1 with equal probability, vectorized
148
+ signs = (
149
+ torch.randint(
150
+ 0,
151
+ 2,
152
+ (total_nnz,),
153
+ generator=generator,
154
+ )
155
+ * 2
156
+ - 1
157
+ )
158
+ values = signs.to(dtype) * scale
159
+
160
+ # Store indices as (2, nnz) for sparse_coo_tensor
161
+ self._indices = torch.stack([row_indices, col_indices]).to(device)
162
+ self._values = values.to(device)
163
+ self._device = device
164
+ self._dtype = dtype
165
+
166
+ def _get_sparse_matrix(self, device: torch.device, dtype: torch.dtype) -> torch.Tensor:
167
+ """Get sparse projection matrix, initializing if needed.
168
+
169
+ Args:
170
+ device: Target device.
171
+ dtype: Target dtype.
172
+
173
+ Returns:
174
+ Sparse COO tensor of shape (full_dim, subspace_dim).
175
+ """
176
+ # Initialize on first use or if device/dtype changed.
177
+ if self._indices is None or self._device != device or self._dtype != dtype:
178
+ self._init_sparse_matrix(device, dtype)
179
+ self._sparse_matrix = None
180
+
181
+ if self._sparse_matrix is None:
182
+ self._sparse_matrix = torch.sparse_coo_tensor(
183
+ self._indices,
184
+ self._values,
185
+ size=(self.full_dim, self.subspace_dim),
186
+ device=device,
187
+ dtype=dtype,
188
+ ).coalesce()
189
+
190
+ return self._sparse_matrix
191
+
192
+ def project(self, coords: torch.Tensor) -> torch.Tensor:
193
+ """Project subspace coordinates to full parameter space.
194
+
195
+ Computes: full = P @ coords where P is (full_dim x subspace_dim).
196
+
197
+ Args:
198
+ coords: Subspace coordinates of shape (subspace_dim,) or (batch, subspace_dim).
199
+
200
+ Returns:
201
+ Full parameter space vector of shape (full_dim,) or (batch, full_dim).
202
+ """
203
+ is_1d = coords.dim() == 1
204
+ if is_1d:
205
+ coords = coords.unsqueeze(0) # (1, subspace_dim)
206
+
207
+ # Get sparse matrix
208
+ P = self._get_sparse_matrix(coords.device, coords.dtype)
209
+
210
+ # Sparse-dense matmul: P @ coords.T -> (full_dim, batch)
211
+ # coords is (batch, subspace_dim), need (subspace_dim, batch)
212
+ result = torch.sparse.mm(P, coords.T) # (full_dim, batch)
213
+ result = result.T # (batch, full_dim)
214
+
215
+ if is_1d:
216
+ result = result.squeeze(0)
217
+
218
+ return result
219
+
220
+ def project_transpose(self, full: torch.Tensor) -> torch.Tensor:
221
+ """Project full parameters back to subspace (transpose).
222
+
223
+ Computes: coords = P^T @ full where P is (full_dim x subspace_dim).
224
+
225
+ Args:
226
+ full: Full parameter space vector of shape (full_dim,) or (batch, full_dim).
227
+
228
+ Returns:
229
+ Subspace coordinates of shape (subspace_dim,) or (batch, subspace_dim).
230
+ """
231
+ is_1d = full.dim() == 1
232
+ if is_1d:
233
+ full = full.unsqueeze(0) # (1, full_dim)
234
+
235
+ # Get sparse matrix and transpose
236
+ P = self._get_sparse_matrix(full.device, full.dtype)
237
+ P_T = P.t() # (subspace_dim, full_dim)
238
+
239
+ # Sparse-dense matmul: P^T @ full.T -> (subspace_dim, batch)
240
+ result = torch.sparse.mm(P_T, full.T) # (subspace_dim, batch)
241
+ result = result.T # (batch, subspace_dim)
242
+
243
+ if is_1d:
244
+ result = result.squeeze(0)
245
+
246
+ return result
247
+
248
+ @property
249
+ def dtype(self) -> Optional[torch.dtype]:
250
+ """Data type of the projection matrix (None if not yet initialized)."""
251
+ return self._dtype
252
+
253
+ @property
254
+ def device(self) -> Optional[torch.device]:
255
+ """Device of the projection matrix (None if not yet initialized)."""
256
+ return self._device
257
+
258
+ @property
259
+ def memory_bytes(self) -> int:
260
+ """Estimated memory usage in bytes.
261
+
262
+ Memory breakdown:
263
+ - Indices: 2 * nnz * 8 bytes (int64 for row and col)
264
+ - Values: nnz * element_size bytes (depends on dtype)
265
+
266
+ Returns:
267
+ Estimated memory usage in bytes.
268
+ """
269
+ total_nnz = self._nnz_per_col * self.subspace_dim
270
+ # 2 index arrays (row, col) with int64
271
+ indices_bytes = 2 * total_nnz * 8
272
+ element_size = self._dtype.itemsize if self._dtype else 4
273
+ values_bytes = total_nnz * element_size
274
+ return indices_bytes + values_bytes
275
+
276
+ @property
277
+ def nnz(self) -> int:
278
+ """Total number of nonzeros in projection matrix."""
279
+ return self._nnz_per_col * self.subspace_dim
280
+
281
+ @property
282
+ def actual_density(self) -> float:
283
+ """Actual density (nnz / total_elements)."""
284
+ return self.nnz / (self.full_dim * self.subspace_dim)
285
+
286
+ def __repr__(self) -> str:
287
+ return (
288
+ f"SparseRandomProjection(full_dim={self.full_dim}, "
289
+ f"subspace_dim={self.subspace_dim}, density={self.density:.4f}, "
290
+ f"nnz_per_col={self._nnz_per_col}, memory={self.memory_bytes / 1e6:.2f}MB)"
291
+ )
polystep/py.typed ADDED
File without changes
@@ -0,0 +1,285 @@
1
+ """Quadratic model extraction from orthoplex cost evaluations.
2
+
3
+ Extracts finite-difference gradient, Hessian diagonal, and Newton step
4
+ from the (P, V, K) loss tensor produced by orthoplex probe evaluations.
5
+ All functions are pure tensor operations with no side effects.
6
+
7
+ Vertex ordering convention (orthoplex):
8
+ Vertices 0..d-1: +e_0, +e_1, ..., +e_{d-1}
9
+ Vertices d..2d-1: -e_0, -e_1, ..., -e_{d-1}
10
+ Pair for direction i: vertex i (+) and vertex i+d (-)
11
+ """
12
+
13
+ import torch
14
+
15
+
16
+ def extract_fd_gradient(
17
+ losses_3d: torch.Tensor,
18
+ scales: torch.Tensor,
19
+ probe_radius: float,
20
+ pdim: int,
21
+ ) -> torch.Tensor:
22
+ """Extract finite-difference gradient from orthoplex cost evaluations.
23
+
24
+ Central difference at each probe scale, averaged for robustness:
25
+ g_i = mean_k[(L(+s_k*d_i) - L(-s_k*d_i)) / (2 * s_k * r)]
26
+
27
+ Args:
28
+ losses_3d: Loss values of shape (P, V, K) where V = 2*pdim.
29
+ scales: Probe scale factors of shape (K,).
30
+ probe_radius: Probe distance multiplier.
31
+ pdim: Particle dimension (number of orthoplex directions).
32
+
33
+ Returns:
34
+ Gradient in rotated frame of shape (P, pdim).
35
+ """
36
+ assert losses_3d.shape[1] == 2 * pdim, (
37
+ f"FD gradient needs orthoplex vertices (V == 2*pdim); got V={losses_3d.shape[1]}, pdim={pdim}"
38
+ )
39
+ fwd = losses_3d[:, :pdim, :] # (P, pdim, K) at +directions
40
+ bwd = losses_3d[:, pdim:, :] # (P, pdim, K) at -directions
41
+
42
+ # Central difference at each scale
43
+ denom = (2.0 * scales * probe_radius).unsqueeze(0).unsqueeze(0) # (1, 1, K)
44
+ grad_per_scale = (fwd - bwd) / denom.clamp(min=1e-10) # (P, pdim, K)
45
+
46
+ return grad_per_scale.mean(dim=-1) # (P, pdim)
47
+
48
+
49
+ def extract_fd_hessian_diag(
50
+ losses_3d: torch.Tensor,
51
+ scales: torch.Tensor,
52
+ probe_radius: float,
53
+ pdim: int,
54
+ ) -> torch.Tensor:
55
+ """Extract diagonal Hessian from orthoplex cost evaluations.
56
+
57
+ Uses the symmetry property of the quadratic model:
58
+ L(+s) + L(-s) = 2*L(0) + H_ii * s^2
59
+ Regresses the symmetric sum on s^2 to estimate H_ii.
60
+
61
+ Args:
62
+ losses_3d: Loss values of shape (P, V, K) where V = 2*pdim.
63
+ scales: Probe scale factors of shape (K,).
64
+ probe_radius: Probe distance multiplier.
65
+ pdim: Particle dimension.
66
+
67
+ Returns:
68
+ Diagonal Hessian in rotated frame of shape (P, pdim).
69
+ """
70
+ assert losses_3d.shape[1] == 2 * pdim, (
71
+ f"FD Hessian needs orthoplex vertices (V == 2*pdim); got V={losses_3d.shape[1]}, pdim={pdim}"
72
+ )
73
+ fwd = losses_3d[:, :pdim, :] # (P, pdim, K)
74
+ bwd = losses_3d[:, pdim:, :] # (P, pdim, K)
75
+
76
+ # Symmetric sum: L(+s) + L(-s) = 2a + H*s^2
77
+ sym_sum = fwd + bwd # (P, pdim, K)
78
+
79
+ s_sq = (scales * probe_radius) ** 2 # (K,)
80
+
81
+ # Linear regression of sym_sum on s_sq to get slope = H
82
+ # Center for numerical stability
83
+ s_sq_mean = s_sq.mean()
84
+ sym_mean = sym_sum.mean(dim=-1, keepdim=True) # (P, pdim, 1)
85
+
86
+ s_centered = s_sq - s_sq_mean # (K,)
87
+ y_centered = sym_sum - sym_mean # (P, pdim, K)
88
+
89
+ # slope = sum(s_centered * y_centered) / sum(s_centered^2)
90
+ numerator = (s_centered.unsqueeze(0).unsqueeze(0) * y_centered).sum(dim=-1) # (P, pdim)
91
+ denominator = (s_centered**2).sum().clamp(min=1e-10)
92
+
93
+ return numerator / denominator # (P, pdim) = Hessian diagonal
94
+
95
+
96
+ def compute_newton_step(
97
+ gradient: torch.Tensor,
98
+ hessian_diag: torch.Tensor,
99
+ max_step_norm: float = 10.0,
100
+ hessian_reg: float = 1e-4,
101
+ ) -> torch.Tensor:
102
+ """Compute a diagonal Newton step in the rotated frame.
103
+
104
+ Where curvature is above ``hessian_reg`` returns ``-g_i / H_i``; where it
105
+ is at or below ``hessian_reg`` (small-positive or nonpositive) the Hessian
106
+ is floored to ``hessian_reg`` (a small gradient step, never an ascent step).
107
+ The full step is then clipped to ``max_step_norm``.
108
+
109
+ Args:
110
+ gradient: FD gradient of shape ``(P, pdim)``.
111
+ hessian_diag: Diagonal Hessian of shape ``(P, pdim)``.
112
+ max_step_norm: Maximum step norm (trust region bound).
113
+ hessian_reg: Floor applied to curvature entries at or below it.
114
+
115
+ Returns:
116
+ Newton step in rotated frame of shape ``(P, pdim)``.
117
+ """
118
+ # Where curvature is positive use H_i directly; otherwise fall back to
119
+ # the regulariser so the direction stays a descent step.
120
+ H_safe = torch.where(
121
+ hessian_diag > hessian_reg,
122
+ hessian_diag,
123
+ torch.full_like(hessian_diag, hessian_reg),
124
+ )
125
+ delta = -gradient / H_safe # (P, pdim)
126
+
127
+ # Clamp step norm
128
+ norms = torch.norm(delta, dim=-1, keepdim=True).clamp(min=1e-10)
129
+ scale = torch.clamp(max_step_norm / norms, max=1.0)
130
+ return delta * scale
131
+
132
+
133
+ def compute_predicted_improvement(
134
+ gradient: torch.Tensor,
135
+ hessian_diag: torch.Tensor,
136
+ step: torch.Tensor,
137
+ ) -> torch.Tensor:
138
+ """Predict improvement from quadratic model: dL = g.delta + 0.5*delta.H.delta.
139
+
140
+ Args:
141
+ gradient: FD gradient of shape (P, pdim).
142
+ hessian_diag: Diagonal Hessian of shape (P, pdim).
143
+ step: Step vector in rotated frame of shape (P, pdim).
144
+
145
+ Returns:
146
+ Predicted loss change per particle of shape (P,). Negative = improvement.
147
+ """
148
+ linear = (gradient * step).sum(dim=-1)
149
+ quadratic = 0.5 * (hessian_diag * step**2).sum(dim=-1)
150
+ return linear + quadratic
151
+
152
+
153
+ def apply_newton_refinement(
154
+ X_bary: torch.Tensor,
155
+ losses_3d: torch.Tensor,
156
+ scales: torch.Tensor,
157
+ probe_radius: float,
158
+ pdim: int,
159
+ rot_mats: torch.Tensor,
160
+ X_current: torch.Tensor,
161
+ alpha: float = 0.3,
162
+ max_step_norm: float = 1.0,
163
+ hessian_reg: float = 1e-4,
164
+ ) -> torch.Tensor:
165
+ """Apply post-OT Newton refinement using the quadratic model from probe evaluations.
166
+
167
+ After the OT barycentric projection gives a coarse update direction, this
168
+ function uses second-order information already available from the probe
169
+ evaluations to compute a Newton correction step, improving per-step
170
+ convergence without additional forward passes.
171
+
172
+ Steps:
173
+ 1. Extract FD gradient and diagonal Hessian from losses_3d (rotated frame)
174
+ 2. Compute Newton step in rotated frame: delta_rot = -g / (H + reg)
175
+ 3. Transform Newton step to original space: delta_orig = rot_mats @ delta_rot
176
+ 4. Newton-predicted minimum from the probe center: X_newton = X_current + delta_orig
177
+ 5. Blend: X_refined = (1 - alpha) * X_bary + alpha * X_newton
178
+
179
+ Args:
180
+ X_bary: Post-OT barycentric position, shape (P, pdim).
181
+ losses_3d: Probe loss values, shape (P, V, K) where V = 2*pdim.
182
+ scales: Probe scale factors, shape (K,).
183
+ probe_radius: Probe distance multiplier.
184
+ pdim: Particle dimension (number of orthoplex directions).
185
+ rot_mats: Rotation matrices, shape (P, pdim, pdim).
186
+ X_current: Probe-center position the quadratic model is built around,
187
+ shape (P, pdim). The Newton step is measured from here, so the
188
+ predicted minimum is X_current + delta_orig.
189
+ alpha: Blending weight. 0 = pure OT, 1 = pure Newton correction.
190
+ max_step_norm: Maximum Newton correction norm (trust region bound).
191
+ hessian_reg: Regularization for near-zero Hessian entries.
192
+
193
+ Returns:
194
+ Refined position of shape (P, pdim).
195
+ """
196
+ # 1. Extract gradient and Hessian in rotated frame
197
+ gradient = extract_fd_gradient(losses_3d, scales, probe_radius, pdim)
198
+ hessian_diag = extract_fd_hessian_diag(losses_3d, scales, probe_radius, pdim)
199
+
200
+ # 2. Compute Newton step in rotated frame
201
+ delta_rot = compute_newton_step(
202
+ gradient,
203
+ hessian_diag,
204
+ max_step_norm=max_step_norm,
205
+ hessian_reg=hessian_reg,
206
+ )
207
+
208
+ # 3. Transform Newton step to original space: delta_orig = rot_mats @ delta_rot
209
+ # rot_mats: (P, pdim, pdim), delta_rot: (P, pdim)
210
+ delta_orig = torch.einsum("bij,bj->bi", rot_mats, delta_rot)
211
+
212
+ # 4. Newton-predicted minimum, anchored at the probe center X_current (the
213
+ # point the quadratic is built around). X_bary already carries the transport
214
+ # step, so anchoring the Newton step there would double-count that move.
215
+ X_newton = X_current + delta_orig
216
+
217
+ # 5. Blend the OT result with the Newton prediction
218
+ X_refined = (1.0 - alpha) * X_bary + alpha * X_newton
219
+
220
+ # 6. Descent gate: keep the blend only where the quadratic model predicts it
221
+ # is no worse than the pure-OT step, else fall back to X_bary. The model is
222
+ # diagonal in the rotated frame, so measure both steps there from X_current.
223
+ rot_mats_t = rot_mats.transpose(-1, -2)
224
+ ot_rot = torch.einsum("bij,bj->bi", rot_mats_t, X_bary - X_current)
225
+ refined_rot = torch.einsum("bij,bj->bi", rot_mats_t, X_refined - X_current)
226
+ pred_ot = (gradient * ot_rot + 0.5 * hessian_diag * ot_rot**2).sum(dim=-1)
227
+ pred_refined = (gradient * refined_rot + 0.5 * hessian_diag * refined_rot**2).sum(dim=-1)
228
+ accept = (pred_refined <= pred_ot).unsqueeze(-1)
229
+ return torch.where(accept, X_refined, X_bary)
230
+
231
+
232
+ def update_trust_region(
233
+ predicted_improvement: torch.Tensor,
234
+ actual_improvement: torch.Tensor,
235
+ current_radius: float,
236
+ expand_threshold: float = 0.75,
237
+ shrink_threshold: float = 0.25,
238
+ expand_factor: float = 1.5,
239
+ shrink_factor: float = 0.5,
240
+ min_radius: float = 0.1,
241
+ max_radius: float = 3.0,
242
+ ) -> float:
243
+ """Update trust region multiplier based on predicted vs actual improvement.
244
+
245
+ Both predicted and actual improvement use the same sign convention:
246
+ negative = loss decreased (improvement). The ratio actual/predicted
247
+ should be positive and near 1.0 when the quadratic model is accurate.
248
+
249
+ Args:
250
+ predicted_improvement: Predicted loss change (P,). Negative = improvement.
251
+ actual_improvement: Actual loss change scalar or (1,). Negative = improvement.
252
+ current_radius: Current trust region multiplier.
253
+ expand_threshold: Ratio above which to expand.
254
+ shrink_threshold: Ratio below which to shrink.
255
+ expand_factor: Radius expansion multiplier.
256
+ shrink_factor: Radius shrink multiplier.
257
+ min_radius: Minimum allowed multiplier.
258
+ max_radius: Maximum allowed multiplier.
259
+
260
+ Returns:
261
+ Updated trust region multiplier.
262
+ """
263
+ pred = predicted_improvement.mean().item()
264
+ actual = actual_improvement.mean().item()
265
+
266
+ if abs(pred) < 1e-10:
267
+ return current_radius
268
+
269
+ ratio = actual / pred
270
+
271
+ # Clamp ratio to prevent extreme updates from noisy estimates
272
+ ratio = max(-2.0, min(ratio, 5.0))
273
+
274
+ # Negative ratio: actual went opposite direction from prediction - aggressive shrink
275
+ if ratio < 0:
276
+ return max(current_radius * shrink_factor * 0.5, min_radius)
277
+
278
+ # Only expand when the model predicted an improvement (pred < 0) and reality
279
+ # matched it. An accurate but worsening step (pred > 0, actual > 0) also gives
280
+ # ratio ~1 and must not grow the region.
281
+ if ratio > expand_threshold and pred < 0:
282
+ return min(current_radius * expand_factor, max_radius)
283
+ elif ratio < shrink_threshold:
284
+ return max(current_radius * shrink_factor, min_radius)
285
+ return current_radius