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.
- polystep/__init__.py +176 -0
- polystep/_compiled.py +334 -0
- polystep/_step_blockwise.py +994 -0
- polystep/_step_momentum.py +128 -0
- polystep/_step_monolithic.py +1070 -0
- polystep/adaptive_subspace.py +762 -0
- polystep/api.py +307 -0
- polystep/ask_tell.py +164 -0
- polystep/benchmarks/__init__.py +62 -0
- polystep/benchmarks/baselines.py +604 -0
- polystep/benchmarks/rl/__init__.py +13 -0
- polystep/benchmarks/rl/cartpole.py +260 -0
- polystep/benchmarks/rl/gym_evaluator.py +304 -0
- polystep/benchmarks/rl/metrics.py +53 -0
- polystep/benchmarks/rl/policies.py +133 -0
- polystep/benchmarks/utils.py +1262 -0
- polystep/blockwise.py +549 -0
- polystep/cma.py +400 -0
- polystep/cma_subspace.py +404 -0
- polystep/cost_nn.py +633 -0
- polystep/costs.py +86 -0
- polystep/dynamics.py +138 -0
- polystep/epsilon.py +217 -0
- polystep/geometry.py +410 -0
- polystep/hybrid_subspace.py +1335 -0
- polystep/layers/__init__.py +16 -0
- polystep/layers/attention.py +243 -0
- polystep/layers/rnn.py +276 -0
- polystep/objectives/__init__.py +25 -0
- polystep/objectives/base.py +77 -0
- polystep/objectives/synthetic.py +232 -0
- polystep/optimizer.py +1405 -0
- polystep/projection/__init__.py +5 -0
- polystep/projection/sparse.py +291 -0
- polystep/py.typed +0 -0
- polystep/quadratic_model.py +285 -0
- polystep/solver.py +654 -0
- polystep/solvers/__init__.py +34 -0
- polystep/solvers/_prelude.py +129 -0
- polystep/solvers/base.py +77 -0
- polystep/solvers/greedy.py +159 -0
- polystep/solvers/kl_softmax.py +200 -0
- polystep/solvers/sinkhorn.py +522 -0
- polystep/solvers/softmax.py +151 -0
- polystep/solvers/tempered_softmax.py +97 -0
- polystep/subspace.py +694 -0
- polystep/transform.py +365 -0
- polystep-0.7.0.dist-info/METADATA +285 -0
- polystep-0.7.0.dist-info/RECORD +52 -0
- polystep-0.7.0.dist-info/WHEEL +5 -0
- polystep-0.7.0.dist-info/licenses/LICENSE +191 -0
- polystep-0.7.0.dist-info/top_level.txt +1 -0
|
@@ -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
|