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
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
+ ]