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
polystep/solver.py
ADDED
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
"""PolyStep solver: gradient-free optimization via entropic OT.
|
|
2
|
+
|
|
3
|
+
Implements the core Sinkhorn Step algorithm that samples polytope vertices
|
|
4
|
+
around particles, solves entropic OT, and updates via barycentric projection.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Callable, List, Optional, Union
|
|
9
|
+
|
|
10
|
+
import torch
|
|
11
|
+
|
|
12
|
+
from ._compiled import CompiledFunctions
|
|
13
|
+
from .costs import compute_cost_matrix
|
|
14
|
+
from .epsilon import LinearEpsilon
|
|
15
|
+
from .geometry import get_random_rotation_matrices, POLYTOPE_MAP
|
|
16
|
+
from .solvers import SinkhornSolver
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class SolverState:
|
|
21
|
+
"""State of the Sinkhorn Step solver.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
X: Current particle positions of shape (num_particles, dim).
|
|
25
|
+
costs: Per-iteration entropic OT costs.
|
|
26
|
+
linear_convergence: Per-iteration convergence flags.
|
|
27
|
+
displacement_sqnorms: Per-iteration mean squared displacements.
|
|
28
|
+
a: Source marginal weights.
|
|
29
|
+
iteration_count: Number of completed iterations.
|
|
30
|
+
f: Dual potential for warm-start.
|
|
31
|
+
g: Dual potential for warm-start.
|
|
32
|
+
epsilon: Current epsilon value (for diagnostics).
|
|
33
|
+
base_params: Base state_dict for subspace mode (None when unused).
|
|
34
|
+
subspace: LowRankSubspace instance for subspace mode (None when unused).
|
|
35
|
+
block_duals: Per-block dual potentials for warm-start in block-wise mode.
|
|
36
|
+
List of (f_block, g_block) tuples, one per block. None when unused.
|
|
37
|
+
velocity: Momentum velocity tensor, same shape as X. None until first momentum step.
|
|
38
|
+
stagnation_count: Consecutive iterations with small relative loss change.
|
|
39
|
+
radius_multiplier: Adaptive radius scaling factor applied to step_radius.
|
|
40
|
+
prev_loss: Previous iteration loss for stagnation detection.
|
|
41
|
+
projection: Orthonormal projection matrix for adaptive subspace mode.
|
|
42
|
+
Shape (full_dim, subspace_dim). None when not using AdaptiveSubspace.
|
|
43
|
+
displacement_history: Rolling buffer of recent subspace displacement vectors.
|
|
44
|
+
Shape (history_size, subspace_dim). None when not using AdaptiveSubspace.
|
|
45
|
+
displacement_history_idx: Current write position in the rolling buffer.
|
|
46
|
+
displacement_history_count: Number of entries filled (0..history_size).
|
|
47
|
+
absorb_count: Total number of absorb events performed.
|
|
48
|
+
p_c: Evolution path for covariance adaptation.
|
|
49
|
+
Shape (subspace_dim,). Tracks cumulative displacement direction over
|
|
50
|
+
generations, used to update the covariance matrix. None until CMA enabled.
|
|
51
|
+
p_sigma: Evolution path for step-size adaptation.
|
|
52
|
+
Shape (subspace_dim,). Tracks cumulative step length for CSA
|
|
53
|
+
(Cumulative Step-size Adaptation). None until CMA enabled.
|
|
54
|
+
C_diag: Diagonal covariance for sep-CMA-ES.
|
|
55
|
+
Shape (subspace_dim,). Scales per-dimension search variance.
|
|
56
|
+
Full covariance would be C = diag(C_diag) in the diagonal case.
|
|
57
|
+
None until CMA enabled.
|
|
58
|
+
sigma: Global step-size controlled by CSA.
|
|
59
|
+
Replaces radius_multiplier when use_csa=True.
|
|
60
|
+
generation: CMA generation counter for hyperparameter scheduling.
|
|
61
|
+
Used in the Heaviside function computation for evolution path updates.
|
|
62
|
+
use_csa: Flag indicating CSA mode is active.
|
|
63
|
+
When True, sigma replaces the heuristic radius_multiplier.
|
|
64
|
+
prev_prev_f: Previous-previous dual potential f for dual momentum extrapolation.
|
|
65
|
+
Used with dual_momentum_beta > 0 to compute f_init = f + beta*(f - prev_prev_f).
|
|
66
|
+
None until at least 2 OT solves have completed. Reset on absorb/rotation/epsilon change.
|
|
67
|
+
prev_prev_g: Previous-previous dual potential g (matching prev_prev_f).
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
X: torch.Tensor
|
|
71
|
+
costs: List[float] = field(default_factory=list)
|
|
72
|
+
linear_convergence: List[bool] = field(default_factory=list)
|
|
73
|
+
displacement_sqnorms: List[float] = field(default_factory=list)
|
|
74
|
+
a: Optional[torch.Tensor] = None
|
|
75
|
+
iteration_count: int = 0
|
|
76
|
+
f: Optional[torch.Tensor] = None
|
|
77
|
+
g: Optional[torch.Tensor] = None
|
|
78
|
+
epsilon: Optional[float] = None
|
|
79
|
+
base_params: Optional[dict] = None
|
|
80
|
+
subspace: Optional[object] = None
|
|
81
|
+
block_duals: Optional[list] = None
|
|
82
|
+
velocity: Optional[torch.Tensor] = None
|
|
83
|
+
stagnation_count: int = 0
|
|
84
|
+
radius_multiplier: float = 1.0
|
|
85
|
+
prev_loss: float = float("inf")
|
|
86
|
+
# Adaptive subspace state
|
|
87
|
+
projection: Optional[torch.Tensor] = None
|
|
88
|
+
displacement_history: Optional[torch.Tensor] = None
|
|
89
|
+
displacement_history_idx: int = 0
|
|
90
|
+
displacement_history_count: int = 0
|
|
91
|
+
absorb_count: int = 0
|
|
92
|
+
# CMA-ES state
|
|
93
|
+
p_c: Optional[torch.Tensor] = None
|
|
94
|
+
p_sigma: Optional[torch.Tensor] = None
|
|
95
|
+
C_diag: Optional[torch.Tensor] = None
|
|
96
|
+
sigma: float = 1.0
|
|
97
|
+
generation: int = 0
|
|
98
|
+
use_csa: bool = False
|
|
99
|
+
# Running mean of ||p_sigma|| that calibrates CSA to the OT step scale,
|
|
100
|
+
# which is far below the sqrt(n) a Gaussian mutation would give.
|
|
101
|
+
csa_norm_ema: Optional[float] = None
|
|
102
|
+
# Dual potential momentum
|
|
103
|
+
prev_prev_f: Optional[torch.Tensor] = None
|
|
104
|
+
prev_prev_g: Optional[torch.Tensor] = None
|
|
105
|
+
# Epsilon under which ``f`` and ``g`` were computed by the previous
|
|
106
|
+
# solve. Lets the next ``SinkhornSolver.solve`` rescale the warm-
|
|
107
|
+
# started duals if the schedule moved epsilon between calls.
|
|
108
|
+
last_solve_eps: Optional[float] = None
|
|
109
|
+
# Trust region diagnostics
|
|
110
|
+
trust_region_multipliers: List[float] = field(default_factory=list)
|
|
111
|
+
# HybridSubspace state
|
|
112
|
+
hybrid_projections: Optional[dict] = None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class PolyStep:
|
|
117
|
+
"""Batch gradient-free solver for non-convex objectives using entropic OT.
|
|
118
|
+
|
|
119
|
+
The solver implements the Sinkhorn Step algorithm: for each iteration, it
|
|
120
|
+
samples polytope vertices (candidate directions) around current particle
|
|
121
|
+
positions, evaluates the objective at each vertex to build a cost matrix,
|
|
122
|
+
solves an entropic optimal transport problem to find an optimal assignment
|
|
123
|
+
between particles and vertices, and moves particles via barycentric
|
|
124
|
+
projection (weighted average of vertices using transport plan weights).
|
|
125
|
+
|
|
126
|
+
This is the low-level solver for direct optimization of scalar objectives
|
|
127
|
+
(e.g., Ackley, Rastrigin). For neural network training, use
|
|
128
|
+
``PolyStepOptimizer`` which wraps this with parameter management and
|
|
129
|
+
closure handling.
|
|
130
|
+
|
|
131
|
+
Example::
|
|
132
|
+
|
|
133
|
+
from polystep.solver import PolyStep
|
|
134
|
+
from polystep import Ackley
|
|
135
|
+
|
|
136
|
+
objective = Ackley(dim=10)
|
|
137
|
+
solver = PolyStep.create(objective, epsilon=0.5, max_iterations=100)
|
|
138
|
+
state = solver.run(torch.randn(50, 10))
|
|
139
|
+
print(f"Best cost: {min(state.costs):.4f}")
|
|
140
|
+
|
|
141
|
+
See Also:
|
|
142
|
+
``PolyStepOptimizer`` for neural network training with automatic
|
|
143
|
+
closure and parameter management.
|
|
144
|
+
|
|
145
|
+
Attributes:
|
|
146
|
+
objective_fn: Callable evaluating the objective at points.
|
|
147
|
+
dim: Problem dimensionality.
|
|
148
|
+
polytope_type: Type of polytope ('orthoplex', 'simplex', 'cube').
|
|
149
|
+
epsilon: Entropic regularization (float or LinearEpsilon).
|
|
150
|
+
ent_epsilon: Optional separate entropy for OT cost geometry.
|
|
151
|
+
scale_cost: Cost scaling strategy ('mean', 'max_cost', float, or None).
|
|
152
|
+
step_radius: Step distance multiplier (scaled by epsilon).
|
|
153
|
+
probe_radius: Probe distance multiplier (scaled by epsilon).
|
|
154
|
+
num_probe: Number of probe points per direction.
|
|
155
|
+
max_iterations: Maximum outer iterations.
|
|
156
|
+
min_iterations: Minimum outer iterations before convergence checks.
|
|
157
|
+
threshold: Convergence threshold on relative displacement change.
|
|
158
|
+
sinkhorn_max_iters: Max inner Sinkhorn iterations.
|
|
159
|
+
chunk_size: Chunk size for cost evaluation memory control.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
objective_fn: Callable
|
|
163
|
+
dim: int
|
|
164
|
+
polytope_type: str = "orthoplex"
|
|
165
|
+
epsilon: Union[float, LinearEpsilon] = 0.1
|
|
166
|
+
ent_epsilon: Optional[Union[float, LinearEpsilon]] = None
|
|
167
|
+
scale_cost: Optional[Union[str, float]] = 1.0
|
|
168
|
+
step_radius: float = 1.0
|
|
169
|
+
probe_radius: float = 2.0
|
|
170
|
+
# K=1 is empirically optimal for the softmax solver and is what
|
|
171
|
+
# every headline runner uses; matches PolyStepOptimizer's default.
|
|
172
|
+
# Multi-probe averaging adds variance reduction that the entropic
|
|
173
|
+
# regularization already provides.
|
|
174
|
+
num_probe: int = 1
|
|
175
|
+
max_iterations: int = 50
|
|
176
|
+
min_iterations: int = 5
|
|
177
|
+
threshold: float = 1e-3
|
|
178
|
+
sinkhorn_max_iters: int = 2000
|
|
179
|
+
chunk_size: Optional[int] = None
|
|
180
|
+
compile: bool = True
|
|
181
|
+
subspace: Optional[object] = None
|
|
182
|
+
nn_evaluator: Optional[object] = None
|
|
183
|
+
layout: Optional[object] = None
|
|
184
|
+
train_inputs: Optional[object] = None
|
|
185
|
+
train_targets: Optional[object] = None
|
|
186
|
+
block_strategy: str = "monolithic"
|
|
187
|
+
block_group_size: int = 2
|
|
188
|
+
|
|
189
|
+
def __post_init__(self):
|
|
190
|
+
"""Initialize derived state: polytope template, probes, solver, compiled fns."""
|
|
191
|
+
# Validate: subspace + block-wise not yet supported together
|
|
192
|
+
if self.subspace is not None and self.block_strategy != "monolithic":
|
|
193
|
+
raise NotImplementedError(
|
|
194
|
+
"Combined subspace + block-wise mode is not yet supported. "
|
|
195
|
+
"Use subspace or block_strategy independently."
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
self.polytope_vertices = POLYTOPE_MAP[self.polytope_type](self.dim, radius=1.0)
|
|
199
|
+
self.probes = torch.linspace(0, 1, self.num_probe + 2)[1 : self.num_probe + 1]
|
|
200
|
+
self.sinkhorn_solver = SinkhornSolver(
|
|
201
|
+
max_iterations=self.sinkhorn_max_iters,
|
|
202
|
+
compile=self.compile,
|
|
203
|
+
)
|
|
204
|
+
self._compiled = CompiledFunctions(compile=self.compile and torch.cuda.is_available())
|
|
205
|
+
|
|
206
|
+
# Create blocks if block-wise mode
|
|
207
|
+
self._blocks = None
|
|
208
|
+
if self.block_strategy != "monolithic" and self.layout is not None:
|
|
209
|
+
from .blockwise import create_per_layer_blocks, create_grouped_blocks
|
|
210
|
+
|
|
211
|
+
if self.block_strategy == "per_layer":
|
|
212
|
+
self._blocks = create_per_layer_blocks(
|
|
213
|
+
self.layout,
|
|
214
|
+
particle_dim=self.layout.particle_dim,
|
|
215
|
+
)
|
|
216
|
+
elif self.block_strategy == "grouped":
|
|
217
|
+
self._blocks = create_grouped_blocks(
|
|
218
|
+
self.layout,
|
|
219
|
+
group_size=self.block_group_size,
|
|
220
|
+
particle_dim=self.layout.particle_dim,
|
|
221
|
+
)
|
|
222
|
+
else:
|
|
223
|
+
raise ValueError(
|
|
224
|
+
f"Unknown block_strategy: {self.block_strategy!r}. Use 'monolithic', 'per_layer', or 'grouped'."
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
@classmethod
|
|
228
|
+
def create(
|
|
229
|
+
cls,
|
|
230
|
+
objective_fn: Callable,
|
|
231
|
+
dim: Optional[int] = None,
|
|
232
|
+
**kwargs,
|
|
233
|
+
) -> "PolyStep":
|
|
234
|
+
"""Factory method for creating a PolyStep solver.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
objective_fn: Objective function (should have .dim attribute if dim not provided).
|
|
238
|
+
dim: Problem dimensionality. If None, inferred from objective_fn.dim.
|
|
239
|
+
**kwargs: Additional PolyStep arguments.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
PolyStep instance.
|
|
243
|
+
"""
|
|
244
|
+
if dim is None:
|
|
245
|
+
dim = objective_fn.dim
|
|
246
|
+
|
|
247
|
+
return cls(objective_fn=objective_fn, dim=dim, **kwargs)
|
|
248
|
+
|
|
249
|
+
def warm_start(self) -> None:
|
|
250
|
+
"""Pre-compile all hot paths by running dummy inputs.
|
|
251
|
+
|
|
252
|
+
Triggers JIT compilation warmup for both the PolyStep geometry
|
|
253
|
+
functions and the inner Sinkhorn solver iteration. Call before
|
|
254
|
+
benchmarking to exclude compilation time from measurements.
|
|
255
|
+
"""
|
|
256
|
+
device = self.polytope_vertices.device
|
|
257
|
+
self._compiled.warm_start(dim=self.dim, device=device)
|
|
258
|
+
self.sinkhorn_solver._compiled.warm_start(dim=self.dim, device=device)
|
|
259
|
+
|
|
260
|
+
def init_state(
|
|
261
|
+
self,
|
|
262
|
+
X_init: torch.Tensor,
|
|
263
|
+
base_params: Optional[dict] = None,
|
|
264
|
+
) -> SolverState:
|
|
265
|
+
"""Initialize solver state.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
X_init: Initial particle positions of shape (num_particles, dim).
|
|
269
|
+
base_params: Optional base state_dict for subspace mode.
|
|
270
|
+
Required when ``self.subspace`` is set.
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
Initial SolverState.
|
|
274
|
+
"""
|
|
275
|
+
num_points = X_init.shape[0]
|
|
276
|
+
a = torch.ones(num_points, device=X_init.device, dtype=X_init.dtype) / num_points
|
|
277
|
+
|
|
278
|
+
state = SolverState(X=X_init.clone(), a=a)
|
|
279
|
+
|
|
280
|
+
if self.subspace is not None and base_params is not None:
|
|
281
|
+
state.base_params = base_params
|
|
282
|
+
state.subspace = self.subspace
|
|
283
|
+
|
|
284
|
+
# Initialize per-block dual potentials if block-wise mode
|
|
285
|
+
if self._blocks is not None:
|
|
286
|
+
state.block_duals = [(None, None) for _ in self._blocks]
|
|
287
|
+
|
|
288
|
+
return state
|
|
289
|
+
|
|
290
|
+
def _get_epsilon(self, iteration: int) -> float:
|
|
291
|
+
"""Resolve epsilon at current iteration."""
|
|
292
|
+
if isinstance(self.epsilon, LinearEpsilon):
|
|
293
|
+
return self.epsilon.at(iteration)
|
|
294
|
+
return self.epsilon
|
|
295
|
+
|
|
296
|
+
def _get_ent_epsilon(self, iteration: int) -> Optional[float]:
|
|
297
|
+
"""Resolve ent_epsilon at current iteration."""
|
|
298
|
+
if self.ent_epsilon is None:
|
|
299
|
+
return None
|
|
300
|
+
if isinstance(self.ent_epsilon, LinearEpsilon):
|
|
301
|
+
return self.ent_epsilon.at(iteration)
|
|
302
|
+
return self.ent_epsilon
|
|
303
|
+
|
|
304
|
+
@torch.inference_mode()
|
|
305
|
+
def step(
|
|
306
|
+
self,
|
|
307
|
+
state: SolverState,
|
|
308
|
+
generator: Optional[torch.Generator] = None,
|
|
309
|
+
) -> SolverState:
|
|
310
|
+
"""Run one iteration of the Sinkhorn Step algorithm.
|
|
311
|
+
|
|
312
|
+
1. Resolve epsilon and scale radii
|
|
313
|
+
2. Sample rotated polytope vertices and probe points
|
|
314
|
+
3. Compute cost matrix from objective evaluations at probes
|
|
315
|
+
4. Solve entropic OT with warm-started duals
|
|
316
|
+
5. Barycentric projection: X_new = sum(vertices * transport_weights)
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
state: Current solver state.
|
|
320
|
+
generator: Optional random generator for reproducibility.
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
Updated SolverState.
|
|
324
|
+
"""
|
|
325
|
+
iteration = state.iteration_count
|
|
326
|
+
X = state.X
|
|
327
|
+
device = X.device
|
|
328
|
+
|
|
329
|
+
# 1. Resolve epsilon and radii
|
|
330
|
+
current_eps = self._get_epsilon(iteration)
|
|
331
|
+
step_radius = self.step_radius * current_eps
|
|
332
|
+
probe_radius = self.probe_radius * current_eps
|
|
333
|
+
|
|
334
|
+
# Block-wise mode: independent OT solve per block
|
|
335
|
+
if self._blocks is not None and self.nn_evaluator is not None:
|
|
336
|
+
return self._step_blockwise(state, generator, current_eps, step_radius, probe_radius)
|
|
337
|
+
|
|
338
|
+
# --- Monolithic mode (original path) ---
|
|
339
|
+
|
|
340
|
+
# Move templates to device if needed
|
|
341
|
+
polytope_verts = self.polytope_vertices.to(device=device, dtype=X.dtype)
|
|
342
|
+
probes = self.probes.to(device=device, dtype=X.dtype)
|
|
343
|
+
|
|
344
|
+
# 2. Sample polytope and probes
|
|
345
|
+
# Pre-normalize for compiled path (avoid shape branch inside compiled fn)
|
|
346
|
+
if X.dim() == 1:
|
|
347
|
+
X = X.unsqueeze(0)
|
|
348
|
+
batch, dim = X.shape
|
|
349
|
+
|
|
350
|
+
# Generate rotation matrices eagerly (uses torch.Generator, not compilable)
|
|
351
|
+
rot_mats = get_random_rotation_matrices(
|
|
352
|
+
batch,
|
|
353
|
+
dim,
|
|
354
|
+
device=device,
|
|
355
|
+
dtype=X.dtype,
|
|
356
|
+
generator=generator,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
# Compiled rotation + translation
|
|
360
|
+
X_vertices, rotated = self._compiled.rotate_and_translate(
|
|
361
|
+
rot_mats,
|
|
362
|
+
polytope_verts,
|
|
363
|
+
X,
|
|
364
|
+
step_radius,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
# Compiled probe generation
|
|
368
|
+
X_probe = self._compiled.compute_probe_points(
|
|
369
|
+
X,
|
|
370
|
+
rotated,
|
|
371
|
+
probes,
|
|
372
|
+
probe_radius,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
# 3. Compute cost matrix
|
|
376
|
+
if state.subspace is not None and self.nn_evaluator is not None:
|
|
377
|
+
# Subspace mode: reconstruct full params from subspace probes
|
|
378
|
+
P, V, K, D = X_probe.shape
|
|
379
|
+
flat_probes = X_probe.reshape(P * V * K, D)
|
|
380
|
+
stacked_params = state.subspace.reconstruct_batch(
|
|
381
|
+
state.base_params,
|
|
382
|
+
flat_probes,
|
|
383
|
+
)
|
|
384
|
+
losses = self.nn_evaluator.evaluate(
|
|
385
|
+
stacked_params,
|
|
386
|
+
self.train_inputs,
|
|
387
|
+
self.train_targets,
|
|
388
|
+
)
|
|
389
|
+
cost_matrix = losses.reshape(P, V, K).mean(dim=-1)
|
|
390
|
+
else:
|
|
391
|
+
cost_matrix = compute_cost_matrix(
|
|
392
|
+
self.objective_fn,
|
|
393
|
+
X_probe,
|
|
394
|
+
chunk_size=self.chunk_size,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
# Sanitize cost matrix before OT solve
|
|
398
|
+
if not torch.isfinite(cost_matrix).all():
|
|
399
|
+
max_finite = cost_matrix[torch.isfinite(cost_matrix)]
|
|
400
|
+
penalty = max_finite.abs().max().item() * 2.0 + 1.0 if max_finite.numel() > 0 else 1e6
|
|
401
|
+
cost_matrix = torch.where(
|
|
402
|
+
torch.isfinite(cost_matrix),
|
|
403
|
+
cost_matrix,
|
|
404
|
+
torch.full_like(cost_matrix, penalty),
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
# 4. Resolve OT epsilon
|
|
408
|
+
ent_eps = self._get_ent_epsilon(iteration)
|
|
409
|
+
ot_epsilon = ent_eps if ent_eps is not None else current_eps
|
|
410
|
+
|
|
411
|
+
# 5. Solve entropic OT. Forward the previous solve's epsilon so the
|
|
412
|
+
# solver can rescale the warm-started duals when the schedule moved
|
|
413
|
+
# epsilon -- consistent with PolyStepOptimizer's monolithic step,
|
|
414
|
+
# which previously was the only path that passed init_eps.
|
|
415
|
+
self.sinkhorn_solver.epsilon = ot_epsilon
|
|
416
|
+
solve_kwargs = dict(
|
|
417
|
+
cost_matrix=cost_matrix,
|
|
418
|
+
a=state.a,
|
|
419
|
+
init_f=state.f,
|
|
420
|
+
init_g=state.g,
|
|
421
|
+
scale_cost=self.scale_cost,
|
|
422
|
+
)
|
|
423
|
+
if state.last_solve_eps is not None:
|
|
424
|
+
solve_kwargs["init_eps"] = state.last_solve_eps
|
|
425
|
+
ot_result = self.sinkhorn_solver.solve(**solve_kwargs)
|
|
426
|
+
state.last_solve_eps = ot_epsilon
|
|
427
|
+
|
|
428
|
+
# 6. Barycentric projection (compiled)
|
|
429
|
+
transport_matrix = ot_result.matrix # (batch, num_vertices)
|
|
430
|
+
X_new = self._compiled.barycentric_projection(
|
|
431
|
+
transport_matrix,
|
|
432
|
+
state.a,
|
|
433
|
+
X_vertices,
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
# NaN-safe state update - revert if X_new has NaN
|
|
437
|
+
_nan_reverted = not torch.isfinite(X_new).all()
|
|
438
|
+
if _nan_reverted:
|
|
439
|
+
X_new = X.clone()
|
|
440
|
+
|
|
441
|
+
# 7. Update state
|
|
442
|
+
disp_sqnorm = torch.mean(torch.sum((X_new - X) ** 2, dim=-1)).item()
|
|
443
|
+
|
|
444
|
+
state.X = X_new
|
|
445
|
+
state.costs.append(ot_result.ent_reg_cost)
|
|
446
|
+
state.linear_convergence.append(ot_result.converged)
|
|
447
|
+
state.displacement_sqnorms.append(disp_sqnorm)
|
|
448
|
+
state.iteration_count += 1
|
|
449
|
+
if _nan_reverted:
|
|
450
|
+
state.f = None
|
|
451
|
+
state.g = None
|
|
452
|
+
else:
|
|
453
|
+
state.f = ot_result.f.detach()
|
|
454
|
+
state.g = ot_result.g.detach()
|
|
455
|
+
state.epsilon = current_eps
|
|
456
|
+
|
|
457
|
+
return state
|
|
458
|
+
|
|
459
|
+
@torch.inference_mode()
|
|
460
|
+
def _step_blockwise(
|
|
461
|
+
self,
|
|
462
|
+
state: SolverState,
|
|
463
|
+
generator: Optional[torch.Generator],
|
|
464
|
+
current_eps: float,
|
|
465
|
+
step_radius: float,
|
|
466
|
+
probe_radius: float,
|
|
467
|
+
) -> SolverState:
|
|
468
|
+
"""Run one block-wise Sinkhorn Step iteration.
|
|
469
|
+
|
|
470
|
+
Each block gets its own polytope sampling, cost evaluation (full model
|
|
471
|
+
forward with only the current block perturbed), independent OT solve
|
|
472
|
+
with per-block warm-started duals, and barycentric projection.
|
|
473
|
+
|
|
474
|
+
Args:
|
|
475
|
+
state: Current solver state.
|
|
476
|
+
generator: Optional random generator for reproducibility.
|
|
477
|
+
current_eps: Current epsilon value.
|
|
478
|
+
step_radius: Step radius (already scaled by epsilon).
|
|
479
|
+
probe_radius: Probe radius (already scaled by epsilon).
|
|
480
|
+
|
|
481
|
+
Returns:
|
|
482
|
+
Updated SolverState.
|
|
483
|
+
"""
|
|
484
|
+
from .blockwise import split_particles, reassemble_blocks, compute_block_cost_matrix
|
|
485
|
+
from .blockwise import layout_flat_to_block_flat, blocks_to_layout_flat
|
|
486
|
+
|
|
487
|
+
X = state.X
|
|
488
|
+
device = X.device
|
|
489
|
+
blocks = self._blocks
|
|
490
|
+
|
|
491
|
+
# Convert layout-indexed flat to block-indexed before splitting.
|
|
492
|
+
total_flat_size = sum(b.flat_end - b.flat_start for b in blocks)
|
|
493
|
+
block_flat = layout_flat_to_block_flat(
|
|
494
|
+
X.reshape(-1),
|
|
495
|
+
blocks,
|
|
496
|
+
self.layout,
|
|
497
|
+
)
|
|
498
|
+
block_X_2d = block_flat.reshape(-1, X.shape[-1]) if X.dim() > 1 else block_flat
|
|
499
|
+
all_block_particles = split_particles(block_X_2d, blocks)
|
|
500
|
+
|
|
501
|
+
# Resolve OT epsilon
|
|
502
|
+
ent_eps = self._get_ent_epsilon(state.iteration_count)
|
|
503
|
+
ot_epsilon = ent_eps if ent_eps is not None else current_eps
|
|
504
|
+
|
|
505
|
+
updated_block_particles = []
|
|
506
|
+
new_block_duals = []
|
|
507
|
+
total_ent_cost = 0.0
|
|
508
|
+
all_converged = True
|
|
509
|
+
total_disp = 0.0
|
|
510
|
+
total_particles = 0
|
|
511
|
+
|
|
512
|
+
# Python loop over blocks (sequential per-block OT solves)
|
|
513
|
+
for block_idx, block in enumerate(blocks):
|
|
514
|
+
block_X = all_block_particles[block_idx]
|
|
515
|
+
block_dim = block.particle_dim
|
|
516
|
+
|
|
517
|
+
# Per-block polytope template and probes
|
|
518
|
+
block_polytope_verts = POLYTOPE_MAP[self.polytope_type](
|
|
519
|
+
block_dim,
|
|
520
|
+
device=device,
|
|
521
|
+
dtype=X.dtype,
|
|
522
|
+
radius=1.0,
|
|
523
|
+
)
|
|
524
|
+
block_probes = self.probes.to(device=device, dtype=X.dtype)
|
|
525
|
+
|
|
526
|
+
P_block = block.num_particles
|
|
527
|
+
if block_X.dim() == 1:
|
|
528
|
+
block_X = block_X.unsqueeze(0)
|
|
529
|
+
|
|
530
|
+
# Rotation matrices for this block
|
|
531
|
+
rot_mats = get_random_rotation_matrices(
|
|
532
|
+
P_block,
|
|
533
|
+
block_dim,
|
|
534
|
+
device=device,
|
|
535
|
+
dtype=X.dtype,
|
|
536
|
+
generator=generator,
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
# Rotate and translate
|
|
540
|
+
X_vertices, rotated = self._compiled.rotate_and_translate(
|
|
541
|
+
rot_mats,
|
|
542
|
+
block_polytope_verts,
|
|
543
|
+
block_X,
|
|
544
|
+
step_radius,
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
# Probe generation
|
|
548
|
+
X_probe = self._compiled.compute_probe_points(
|
|
549
|
+
block_X,
|
|
550
|
+
rotated,
|
|
551
|
+
block_probes,
|
|
552
|
+
probe_radius,
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
# Block cost: full forward, perturb only this block
|
|
556
|
+
cost_matrix = compute_block_cost_matrix(
|
|
557
|
+
block_idx=block_idx,
|
|
558
|
+
X_probe_block=X_probe,
|
|
559
|
+
all_block_particles=all_block_particles,
|
|
560
|
+
blocks=blocks,
|
|
561
|
+
layout=self.layout,
|
|
562
|
+
evaluator=self.nn_evaluator,
|
|
563
|
+
inputs=self.train_inputs,
|
|
564
|
+
targets=self.train_targets,
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
# Per-block OT solve with warm-started duals
|
|
568
|
+
self.sinkhorn_solver.epsilon = ot_epsilon
|
|
569
|
+
init_f, init_g = state.block_duals[block_idx]
|
|
570
|
+
ot_result = self.sinkhorn_solver.solve(
|
|
571
|
+
cost_matrix=cost_matrix,
|
|
572
|
+
a=torch.ones(P_block, device=device, dtype=X.dtype) / P_block,
|
|
573
|
+
init_f=init_f,
|
|
574
|
+
init_g=init_g,
|
|
575
|
+
scale_cost=self.scale_cost,
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
# Barycentric projection for this block
|
|
579
|
+
transport_matrix = ot_result.matrix
|
|
580
|
+
block_a = torch.ones(P_block, device=device, dtype=X.dtype) / P_block
|
|
581
|
+
X_new_block = self._compiled.barycentric_projection(
|
|
582
|
+
transport_matrix,
|
|
583
|
+
block_a,
|
|
584
|
+
X_vertices,
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
# Track per-block displacement
|
|
588
|
+
block_disp = torch.sum((X_new_block - block_X) ** 2, dim=-1).sum().item()
|
|
589
|
+
total_disp += block_disp
|
|
590
|
+
total_particles += P_block
|
|
591
|
+
|
|
592
|
+
updated_block_particles.append(X_new_block)
|
|
593
|
+
new_block_duals.append((ot_result.f.detach(), ot_result.g.detach()))
|
|
594
|
+
total_ent_cost += ot_result.ent_reg_cost
|
|
595
|
+
all_converged = all_converged and ot_result.converged
|
|
596
|
+
|
|
597
|
+
# Reassemble and convert back to layout-indexed format
|
|
598
|
+
full_flat = reassemble_blocks(updated_block_particles, blocks, total_flat_size)
|
|
599
|
+
layout_flat_new = blocks_to_layout_flat(full_flat, blocks, self.layout)
|
|
600
|
+
X_new = layout_flat_new.reshape(X.shape)
|
|
601
|
+
|
|
602
|
+
# Update state
|
|
603
|
+
disp_sqnorm = total_disp / total_particles if total_particles > 0 else 0.0
|
|
604
|
+
|
|
605
|
+
state.X = X_new
|
|
606
|
+
state.costs.append(total_ent_cost)
|
|
607
|
+
state.linear_convergence.append(all_converged)
|
|
608
|
+
state.displacement_sqnorms.append(disp_sqnorm)
|
|
609
|
+
state.iteration_count += 1
|
|
610
|
+
state.block_duals = new_block_duals
|
|
611
|
+
state.epsilon = current_eps
|
|
612
|
+
|
|
613
|
+
return state
|
|
614
|
+
|
|
615
|
+
def _converged(self, state: SolverState) -> bool:
|
|
616
|
+
"""Check if the solver has converged based on displacement change."""
|
|
617
|
+
it = state.iteration_count
|
|
618
|
+
if it < 3:
|
|
619
|
+
return False
|
|
620
|
+
d = state.displacement_sqnorms
|
|
621
|
+
return abs(d[-1] - d[-2]) / (abs(d[-2]) + 1e-10) < self.threshold
|
|
622
|
+
|
|
623
|
+
def _diverged(self, state: SolverState) -> bool:
|
|
624
|
+
"""Check if the solver has diverged (non-finite cost)."""
|
|
625
|
+
if not state.costs:
|
|
626
|
+
return False
|
|
627
|
+
import math
|
|
628
|
+
|
|
629
|
+
return not math.isfinite(state.costs[-1])
|
|
630
|
+
|
|
631
|
+
def run(
|
|
632
|
+
self,
|
|
633
|
+
X_init: torch.Tensor,
|
|
634
|
+
generator: Optional[torch.Generator] = None,
|
|
635
|
+
) -> SolverState:
|
|
636
|
+
"""Run the full Sinkhorn Step outer loop.
|
|
637
|
+
|
|
638
|
+
Args:
|
|
639
|
+
X_init: Initial particle positions of shape (num_particles, dim).
|
|
640
|
+
generator: Optional random generator for reproducibility.
|
|
641
|
+
|
|
642
|
+
Returns:
|
|
643
|
+
Final SolverState with converged particles.
|
|
644
|
+
"""
|
|
645
|
+
state = self.init_state(X_init)
|
|
646
|
+
|
|
647
|
+
for i in range(self.max_iterations):
|
|
648
|
+
state = self.step(state, generator=generator)
|
|
649
|
+
|
|
650
|
+
if i >= self.min_iterations:
|
|
651
|
+
if self._converged(state) or self._diverged(state):
|
|
652
|
+
break
|
|
653
|
+
|
|
654
|
+
return state
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Solver abstraction layer for polystep.
|
|
2
|
+
|
|
3
|
+
Pluggable solver implementations for the polytope step optimizer:
|
|
4
|
+
|
|
5
|
+
- ``Solver``: protocol defining the solver interface.
|
|
6
|
+
- ``SolverResult``: shared result dataclass.
|
|
7
|
+
- ``SinkhornSolver``: full-rank log-domain entropic OT solver.
|
|
8
|
+
- ``SinkhornResult``: result with dual potentials.
|
|
9
|
+
- ``SoftmaxSolver`` / ``SoftmaxResult``: one-sided softmax weighting.
|
|
10
|
+
- ``KLSoftmaxSolver``: KL-penalized interpolation between softmax and
|
|
11
|
+
Sinkhorn.
|
|
12
|
+
- ``TemperedSoftmaxSolver``: softmax with a fixed temperature.
|
|
13
|
+
- ``MinCostGreedySolver`` / ``TopKMeanSolver``: simple non-OT baselines.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from .base import Solver, SolverResult
|
|
17
|
+
from .greedy import MinCostGreedySolver, TopKMeanSolver
|
|
18
|
+
from .kl_softmax import KLSoftmaxSolver
|
|
19
|
+
from .sinkhorn import SinkhornSolver, SinkhornResult
|
|
20
|
+
from .softmax import SoftmaxSolver, SoftmaxResult
|
|
21
|
+
from .tempered_softmax import TemperedSoftmaxSolver
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Solver",
|
|
25
|
+
"SolverResult",
|
|
26
|
+
"MinCostGreedySolver",
|
|
27
|
+
"TopKMeanSolver",
|
|
28
|
+
"SinkhornSolver",
|
|
29
|
+
"SinkhornResult",
|
|
30
|
+
"SoftmaxSolver",
|
|
31
|
+
"SoftmaxResult",
|
|
32
|
+
"KLSoftmaxSolver",
|
|
33
|
+
"TemperedSoftmaxSolver",
|
|
34
|
+
]
|