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,522 @@
1
+ """Log-domain Sinkhorn solver for entropic optimal transport.
2
+
3
+ PolyStep's OT problems are (n particles x m=V polytope vertices) with V small
4
+ (2*particle_dim), so the cost is O(n*V) -- a full-rank log-domain solve is
5
+ already optimal and there is no large dense kernel to approximate away.
6
+ """
7
+
8
+ import functools
9
+ import math
10
+ import warnings
11
+ from dataclasses import dataclass
12
+ from typing import List, Optional, Union
13
+
14
+ import torch
15
+
16
+ from ..costs import scale_cost_matrix
17
+ from ._prelude import align_dual, align_marginal, recenter_cost, sanitize_cost, validate_positive
18
+
19
+
20
+ @dataclass
21
+ class SinkhornResult:
22
+ """Output from a Sinkhorn solve.
23
+
24
+ Attributes:
25
+ f: First dual potential of shape (n,). Together with ``g``, these
26
+ dual potentials encode the optimal transport solution. They can
27
+ be reused as warm-start initializations for the next solve,
28
+ which typically reduces iterations from ~100 to ~10.
29
+ g: Second dual potential of shape (m,). See ``f``.
30
+ converged: Whether the solver converged within tolerance.
31
+ n_iters: Number of iterations actually run.
32
+ ent_reg_cost: Entropic dual objective <f, a> + <g, b> - eps*sum_ij P_ij
33
+ (true plan mass), reported in the original cost frame. Equals the
34
+ regularized transport cost at convergence.
35
+ errors: Per-check marginal errors (for diagnostics).
36
+ """
37
+
38
+ f: torch.Tensor
39
+ g: torch.Tensor
40
+ converged: bool
41
+ n_iters: int
42
+ ent_reg_cost: float
43
+ errors: Optional[List[float]] = None
44
+
45
+ # Internal fields for lazy .matrix computation
46
+ _eps: float = float("nan")
47
+ _cost_matrix: Optional[torch.Tensor] = None # (n, m)
48
+
49
+ @functools.cached_property
50
+ def matrix(self) -> torch.Tensor:
51
+ """Transport plan, computed lazily: P_ij = exp((f_i + g_j - C_ij) / eps)."""
52
+ log_P = (self.f.unsqueeze(1) + self.g.unsqueeze(0) - self._cost_matrix) / self._eps
53
+ return torch.exp(log_P)
54
+
55
+
56
+ @dataclass
57
+ class SinkhornSolver:
58
+ """Full-rank log-domain Sinkhorn solver for entropic optimal transport.
59
+
60
+ Solves the entropic optimal transport problem by alternating row and
61
+ column scaling in log domain. The entropic regularization parameter
62
+ ``epsilon`` controls the trade-off between transport cost minimization
63
+ and entropy maximization: higher epsilon gives a smoother, more diffuse
64
+ transport plan (easier to solve but less precise), while lower epsilon
65
+ gives a sharper plan closer to exact OT (but harder to solve numerically).
66
+
67
+ The solver converges when the marginal constraint violation falls below
68
+ ``threshold``. Warm-starting with previous dual potentials (f, g) from
69
+ ``SinkhornResult`` typically reduces iterations from ~100 to ~10.
70
+
71
+ Attributes:
72
+ epsilon: Entropic regularization strength.
73
+ max_iterations: Maximum number of Sinkhorn iterations.
74
+ threshold: Convergence threshold on marginal error.
75
+ Set <= 0 for fixed-iteration mode (no early stopping).
76
+ check_every: Check convergence every N iterations.
77
+ compile: Whether to use torch.compile for hot paths (requires CUDA).
78
+ """
79
+
80
+ epsilon: float = 0.1
81
+ max_iterations: int = 2000
82
+ threshold: float = 1e-6
83
+ check_every: int = 10
84
+ compile: bool = False
85
+ omega: float = 1.0
86
+ anderson_depth: int = 0 # 0 = disabled, >0 = ring buffer depth for Anderson acceleration
87
+ adaptive_omega: bool = False # False = static omega, True = residual-ratio dynamic omega (Lehmann 2022)
88
+ data_dependent_init: bool = False # False = zeros init, True = cost-mean init for cold starts
89
+
90
+ def __post_init__(self):
91
+ """Initialize compiled function registry and validate parameters."""
92
+ if self.epsilon <= 0:
93
+ raise ValueError(
94
+ f"epsilon must be > 0, got {self.epsilon}. "
95
+ f"A zero or negative epsilon causes division by zero in log-domain Sinkhorn iterations."
96
+ )
97
+ if self.omega < 0.5 or self.omega > 1.95:
98
+ raise ValueError(
99
+ f"omega must be in [0.5, 1.95], got {self.omega}. "
100
+ f"Values < 0.5 cause divergence; values > 1.95 are numerically unstable. "
101
+ f"Recommended range: [1.0, 1.8] for acceleration."
102
+ )
103
+ if self.check_every < 1:
104
+ raise ValueError(
105
+ f"check_every must be >= 1, got {self.check_every}. It is the modulus for periodic convergence checks."
106
+ )
107
+ if self.max_iterations < 1:
108
+ raise ValueError(
109
+ f"max_iterations must be >= 1, got {self.max_iterations}. "
110
+ f"Zero iterations leave the duals at their init and return an infeasible plan."
111
+ )
112
+
113
+ from .._compiled import CompiledFunctions
114
+
115
+ self._compiled = CompiledFunctions(compile=self.compile and torch.cuda.is_available())
116
+
117
+ def solve(
118
+ self,
119
+ cost_matrix: torch.Tensor,
120
+ a: Optional[torch.Tensor] = None,
121
+ b: Optional[torch.Tensor] = None,
122
+ init_f: Optional[torch.Tensor] = None,
123
+ init_g: Optional[torch.Tensor] = None,
124
+ scale_cost: Optional[Union[str, float]] = None,
125
+ init_eps: Optional[float] = None,
126
+ ) -> SinkhornResult:
127
+ """Solve entropic OT problem.
128
+
129
+ Solves: min_P <C, P> + eps * KL(P || a x b)
130
+ s.t. P 1 = a, P^T 1 = b, P >= 0
131
+
132
+ Args:
133
+ cost_matrix: Cost matrix C of shape (n, m).
134
+ a: Source marginal of shape (n,). Defaults to uniform 1/n.
135
+ b: Target marginal of shape (m,). Defaults to uniform 1/m.
136
+ init_f: Warm-start first dual potential of shape (n,).
137
+ init_g: Warm-start second dual potential of shape (m,).
138
+ scale_cost: Cost scaling: 'mean', 'max_cost', float, or None.
139
+ init_eps: Epsilon under which ``init_f`` / ``init_g`` were
140
+ computed by a previous solve. When provided and different
141
+ from ``self.epsilon``, the duals are rescaled by
142
+ ``self.epsilon / init_eps`` (holding the log-domain scaling
143
+ ``u = f / eps`` fixed). This is a numerical *heuristic*, not
144
+ an exact warm start -- entropic cost-unit potentials are not
145
+ homogeneous in epsilon. Benchmarks
146
+ (``experiments/scripts/bench_eps_rescale.py``) show it is
147
+ ~neutral for well-scaled costs and modestly reduces
148
+ non-converged solves in the large-cost / small-epsilon
149
+ regime, where un-rescaled duals risk ``exp`` overflow.
150
+
151
+ Returns:
152
+ SinkhornResult with dual potentials and transport plan access.
153
+ """
154
+ # Schedules mutate ``self.epsilon`` per step, so re-validate here (a
155
+ # plain float check, no host sync) rather than only at construction.
156
+ validate_positive(
157
+ self.epsilon,
158
+ "epsilon",
159
+ "A zero/negative epsilon divides by zero in log-domain Sinkhorn.",
160
+ )
161
+ return self._solve_full_rank(
162
+ cost_matrix,
163
+ a,
164
+ b,
165
+ init_f,
166
+ init_g,
167
+ scale_cost,
168
+ init_eps,
169
+ )
170
+
171
+ def _solve_full_rank(
172
+ self,
173
+ cost_matrix: torch.Tensor,
174
+ a: Optional[torch.Tensor],
175
+ b: Optional[torch.Tensor],
176
+ init_f: Optional[torch.Tensor],
177
+ init_g: Optional[torch.Tensor],
178
+ scale_cost: Optional[Union[str, float]],
179
+ init_eps: Optional[float] = None,
180
+ ) -> SinkhornResult:
181
+ """Full-rank log-domain Sinkhorn iterations."""
182
+ # Shared prelude: FP32 promotion (BF16's 7 mantissa bits collapse the
183
+ # log-sum-exp row-max trick past ~15 nats) + device-side non-finite
184
+ # cost handling (no host sync), then device/dtype-aligned marginals.
185
+ cost_matrix = sanitize_cost(cost_matrix)
186
+ n, m = cost_matrix.shape
187
+ if n == 0 or m == 0:
188
+ raise ValueError(
189
+ f"Sinkhorn received an empty cost matrix (shape {(n, m)}); "
190
+ "at least one source and target point are required."
191
+ )
192
+ device, dtype = cost_matrix.device, cost_matrix.dtype
193
+ # Balanced Sinkhorn needs sum(a) == sum(b). Only check when the caller
194
+ # gave both marginals; the default uniform a=b=None is balanced, so the
195
+ # hot path stays free of a host sync.
196
+ marginals_given = a is not None and b is not None
197
+ a = align_marginal(a, n, device, dtype, "a")
198
+ b = align_marginal(b, m, device, dtype, "b")
199
+ if marginals_given and not torch.isclose(a.sum(), b.sum(), rtol=1e-3, atol=1e-6):
200
+ warnings.warn(
201
+ f"Sinkhorn marginals have unequal total mass (a.sum()={a.sum().item():.6g}, "
202
+ f"b.sum()={b.sum().item():.6g}); balanced OT is infeasible, so 'converged' "
203
+ f"and the returned plan may be meaningless. Use KLSoftmaxSolver for unbalanced OT.",
204
+ stacklevel=2,
205
+ )
206
+
207
+ # Scale cost matrix (division creates a new tensor; no clone needed)
208
+ cost_matrix = scale_cost_matrix(cost_matrix, scale_cost)
209
+ # Recenter to min(C)=0 so the log-sum-exp and the exp((f+g-C)/eps) plan
210
+ # keep FP32 precision when |C| is much larger than eps. cost_shift is
211
+ # added back into ent_reg_cost below.
212
+ cost_matrix, cost_shift = recenter_cost(cost_matrix)
213
+
214
+ # Log kernel: log_K = -C / eps
215
+ eps = self.epsilon
216
+ log_K = -cost_matrix / eps
217
+
218
+ log_a = torch.log(torch.clamp(a, min=1e-30))
219
+ log_b = torch.log(torch.clamp(b, min=1e-30))
220
+
221
+ # Initialize dual potentials with warm-start validation
222
+ # Data-dependent initialization: set initial duals from cost matrix means
223
+ # Only when no warm-start provided. Applied AFTER cost scaling on the scaled matrix.
224
+ if self.data_dependent_init and init_f is None and init_g is None:
225
+ f = -cost_matrix.mean(dim=1)
226
+ g = -cost_matrix.mean(dim=0)
227
+ else:
228
+ # align_dual moves the warm start onto (device, dtype) and clones
229
+ # it; it returns None on a shape mismatch -> fall back to zeros.
230
+ f = align_dual(init_f, n, device, dtype, "init_f")
231
+ g = align_dual(init_g, m, device, dtype, "init_g")
232
+ if f is None:
233
+ f = torch.zeros(n, device=device, dtype=dtype)
234
+ if g is None:
235
+ g = torch.zeros(m, device=device, dtype=dtype)
236
+
237
+ # Rescale the warm-started duals when the caller changed epsilon since
238
+ # the previous solve (see ``init_eps`` docstring above). Do this before
239
+ # the clamp below so a large ``eps/init_eps`` ratio stays bounded and
240
+ # cannot blow the plan to Inf/NaN.
241
+ if init_eps is not None and init_eps > 0 and (init_f is not None or init_g is not None):
242
+ if abs(init_eps - eps) / max(eps, 1e-9) > 1e-6:
243
+ scale_factor = eps / init_eps
244
+ f = f * scale_factor
245
+ g = g * scale_factor
246
+
247
+ # Validate warm-started dual potentials. Dual potentials scale with the
248
+ # cost matrix magnitude, not epsilon. ``cost_scale`` is kept on-device so
249
+ # the clamp does not sync every solve. Runs after the rescale above so the
250
+ # bound holds.
251
+ cost_scale = cost_matrix.abs().max().clamp(min=1e-6)
252
+ max_abs_dual = 10.0 * cost_scale
253
+ if not (torch.isfinite(f).all() and torch.isfinite(g).all()):
254
+ f.zero_()
255
+ g.zero_()
256
+ else:
257
+ f.clamp_(-max_abs_dual, max_abs_dual)
258
+ g.clamp_(-max_abs_dual, max_abs_dual)
259
+
260
+ # Re-center using the only valid dual gauge ``f -> f + c, g -> g - c``,
261
+ # which leaves ``f_i + g_j`` (and hence the plan ``P``) unchanged. The
262
+ # half-difference shift balances the two potentials' magnitudes so the
263
+ # warm-start clamp above keeps them bounded across a long schedule.
264
+ # NB: independent mean subtraction (``f -= f.mean(); g -= g.mean()``)
265
+ # is NOT a valid gauge and perturbs the iterate under overrelaxation.
266
+ c = 0.5 * (g.mean() - f.mean())
267
+ f = f + c
268
+ g = g - c
269
+
270
+ # Determine if we should check convergence
271
+ fixed_mode = self.threshold <= 0
272
+
273
+ if fixed_mode:
274
+ if self.anderson_depth > 0:
275
+ warnings.warn(
276
+ "anderson_depth > 0 has no effect in fixed-iteration mode "
277
+ "(threshold <= 0). Anderson acceleration is only supported "
278
+ "in convergence-checking mode.",
279
+ stacklevel=2,
280
+ )
281
+ if self.adaptive_omega:
282
+ warnings.warn(
283
+ "adaptive_omega=True has no effect in fixed-iteration mode "
284
+ "(threshold <= 0). Adaptive omega is only supported "
285
+ "in convergence-checking mode.",
286
+ stacklevel=2,
287
+ )
288
+
289
+ converged = False
290
+ n_iters = 0
291
+ errors: List[float] = []
292
+
293
+ omega = self.omega
294
+
295
+ # Pin the iteration loop inside an autocast-disabled FP32
296
+ # region so a caller running under ``autocast(bfloat16)`` can't
297
+ # demote our log-sum-exp intermediates.
298
+ with torch.no_grad(), torch.amp.autocast("cuda", enabled=False), torch.amp.autocast("cpu", enabled=False):
299
+ if fixed_mode:
300
+ # Fixed-iteration path: compiled body with post-loop NaN check.
301
+ # Warm-started solver with well-conditioned cost matrix rarely
302
+ # diverges mid-iteration; checking only at the end avoids
303
+ # GPU-CPU sync overhead from periodic isfinite() reductions.
304
+ sinkhorn_iter = self._compiled.sinkhorn_iter
305
+ for i in range(self.max_iterations):
306
+ f, g = sinkhorn_iter(f, g, log_K, log_a, log_b, eps, omega)
307
+ n_iters = i + 1
308
+ # Fixed mode: a finite result is success. converged=False here
309
+ # would make ProgressiveEpsilon inflate epsilon.
310
+ if not (torch.isfinite(f).all() and torch.isfinite(g).all()):
311
+ f.zero_()
312
+ g.zero_()
313
+ else:
314
+ converged = True
315
+ else:
316
+ # Convergence-checking path: stays fully eager with overrelaxation
317
+ # Anderson acceleration: ring buffer for iterate mixing
318
+ if self.anderson_depth > 0:
319
+ aa_history_x = [] # list of (f, g) pairs
320
+ aa_history_r = [] # list of (r_f, r_g) residual pairs
321
+
322
+ # Adaptive omega: residual-ratio estimator state (Lehmann 2022)
323
+ if self.adaptive_omega:
324
+ prev_err = None
325
+
326
+ # Divergence detector for static omega. Track consecutive
327
+ # growths of ``|f|.max + |g|.max``; if the iterate norm
328
+ # keeps growing across ``_divergence_patience`` checks,
329
+ # back omega off to 1.0 (Lehmann 2022's proven-safe value).
330
+ _divergence_prev_norm = float("inf")
331
+ _divergence_growth_count = 0
332
+ _divergence_patience = 3
333
+ # Latch the divergence back-off so the adaptive estimator below
334
+ # cannot re-raise omega in the same check.
335
+ _omega_capped = False
336
+
337
+ def dual_objective(fv, gv):
338
+ # D(f,g) = <f,a> + <g,b> - eps * sum_ij exp((f_i+g_j-C_ij)/eps).
339
+ # The true Sinkhorn Lyapunov: unlike <f,a>+<g,b> alone, it is
340
+ # valid off the marginal constraint (sum(P) != 1).
341
+ log_P = fv.unsqueeze(1) / eps + gv.unsqueeze(0) / eps + log_K
342
+ mass_p = torch.exp(torch.logsumexp(log_P.reshape(-1), dim=0))
343
+ return (fv * a).sum() + (gv * b).sum() - eps * mass_p
344
+
345
+ for i in range(self.max_iterations):
346
+ f_target = eps * (log_a - torch.logsumexp(log_K + g.unsqueeze(0) / eps, dim=1))
347
+ f_new = (1 - omega) * f + omega * f_target
348
+ g_target = eps * (log_b - torch.logsumexp(log_K + f_new.unsqueeze(1) / eps, dim=0))
349
+ g_new = (1 - omega) * g + omega * g_target
350
+
351
+ # Anderson acceleration
352
+ if self.anderson_depth > 0 and (i + 1) % self.check_every == 0:
353
+ r_f = f_new - f
354
+ r_g = g_new - g
355
+ aa_history_x.append((f.clone(), g.clone()))
356
+ aa_history_r.append((r_f.clone(), r_g.clone()))
357
+ m_depth = self.anderson_depth
358
+ if len(aa_history_x) > m_depth + 1:
359
+ aa_history_x.pop(0)
360
+ aa_history_r.pop(0)
361
+
362
+ if len(aa_history_r) >= 2:
363
+ k = len(aa_history_r) - 1
364
+ # Build residual difference matrix
365
+ delta_r = torch.stack(
366
+ [
367
+ torch.cat(
368
+ [
369
+ aa_history_r[j + 1][0] - aa_history_r[j][0],
370
+ aa_history_r[j + 1][1] - aa_history_r[j][1],
371
+ ]
372
+ )
373
+ for j in range(k)
374
+ ],
375
+ dim=1,
376
+ ) # (n+m, k)
377
+ current_r = torch.cat([r_f, r_g]) # (n+m,)
378
+
379
+ # Tikhonov-regularized least squares via the augmented
380
+ # system [dR; sqrt(lambda) I] alpha = [r; 0], solved by
381
+ # QR (more stable than the normal equations). lambda
382
+ # scales with ||dR||^2 to track conditioning.
383
+ try:
384
+ lam = 1e-8 * (delta_r * delta_r).sum().clamp(min=1e-30)
385
+ aug_A = torch.cat(
386
+ [
387
+ delta_r,
388
+ torch.sqrt(lam) * torch.eye(k, device=delta_r.device, dtype=delta_r.dtype),
389
+ ],
390
+ dim=0,
391
+ )
392
+ aug_b = torch.cat(
393
+ [current_r, torch.zeros(k, device=delta_r.device, dtype=delta_r.dtype)]
394
+ )
395
+ alpha = torch.linalg.lstsq(aug_A, aug_b.unsqueeze(1)).solution.squeeze(1) # (k,)
396
+
397
+ # Guard against NaN/Inf and huge alpha from ill-conditioning
398
+ if torch.isfinite(alpha).all() and alpha.norm() < 1e3:
399
+ delta_x = torch.stack(
400
+ [
401
+ torch.cat(
402
+ [
403
+ aa_history_x[j + 1][0] - aa_history_x[j][0],
404
+ aa_history_x[j + 1][1] - aa_history_x[j][1],
405
+ ]
406
+ )
407
+ for j in range(k)
408
+ ],
409
+ dim=1,
410
+ )
411
+ combined = torch.cat([f_new, g_new]) - delta_x @ alpha
412
+ # Validate combined result before assigning
413
+ if torch.isfinite(combined).all():
414
+ # Accept the Anderson step only when its dual
415
+ # objective beats the plain iterate and does
416
+ # not regress below the previous iterate. The
417
+ # previous-iterate check stays monotone under
418
+ # overrelaxation, where the plain SOR step is
419
+ # not. Device-side compare avoids a sync.
420
+ f_combined = combined[:n]
421
+ g_combined = combined[n:]
422
+ lyap_prev = dual_objective(f, g)
423
+ lyap_plain = dual_objective(f_new, g_new)
424
+ lyap_combined = dual_objective(f_combined, g_combined)
425
+ accept = (lyap_combined >= lyap_plain - 1e-6) & (
426
+ lyap_combined >= lyap_prev - 1e-6
427
+ )
428
+ f_new = torch.where(accept, f_combined, f_new)
429
+ g_new = torch.where(accept, g_combined, g_new)
430
+ except RuntimeError:
431
+ pass # Fall back to standard iterate on solver failure (expected for ill-conditioned problems)
432
+
433
+ f, g = f_new, g_new
434
+ n_iters = i + 1
435
+
436
+ # Convergence and divergence check (every check_every iterations
437
+ # to avoid GPU-CPU sync on every iteration). All scalar
438
+ # measurements are batched into a single device->host
439
+ # transfer per check to amortize the sync cost.
440
+ if (i + 1) % self.check_every == 0:
441
+ # Divergence check
442
+ if torch.isnan(f).any() or torch.isinf(f).any() or torch.isnan(g).any() or torch.isinf(g).any():
443
+ f.zero_()
444
+ g.zero_()
445
+ break
446
+
447
+ log_P_row = f.unsqueeze(1) / eps + log_K + g.unsqueeze(0) / eps
448
+ marginal_a = torch.exp(torch.logsumexp(log_P_row, dim=1))
449
+ marginal_b = torch.exp(torch.logsumexp(log_P_row, dim=0))
450
+
451
+ # Batch all scalar measurements into one transfer.
452
+ err_a_t = torch.max(torch.abs(marginal_a - a))
453
+ err_b_t = torch.max(torch.abs(marginal_b - b))
454
+ if omega > 1.5:
455
+ dual_norm_t = f.abs().max() + g.abs().max()
456
+ else:
457
+ dual_norm_t = err_a_t # placeholder, value unused
458
+ err_a, err_b, dual_norm_v = torch.stack([err_a_t, err_b_t, dual_norm_t]).tolist()
459
+ err = max(err_a, err_b)
460
+
461
+ # Static-omega divergence detector. Lehmann et al.
462
+ # 2022 give a safe range ``omega in (0, 2 - rho)``
463
+ # where rho is the linearised spectral radius.
464
+ # Empirically ``omega <= 1.5`` is safe on
465
+ # well-conditioned C; only monitor at the aggressive
466
+ # end. Require both sustained growth (>5% per check)
467
+ # and three-in-a-row to avoid firing on the benign
468
+ # iterate ripples that occur near the fixed point.
469
+ if omega > 1.5:
470
+ if dual_norm_v > _divergence_prev_norm * 1.05:
471
+ _divergence_growth_count += 1
472
+ if _divergence_growth_count >= _divergence_patience:
473
+ warnings.warn(
474
+ f"Sinkhorn divergence detected with "
475
+ f"omega={omega:.2f} after "
476
+ f"{_divergence_patience} consecutive "
477
+ f"growth checks (>5% per check); "
478
+ f"backing omega off to 1.0 (safe).",
479
+ stacklevel=2,
480
+ )
481
+ omega = 1.0
482
+ _omega_capped = True
483
+ _divergence_growth_count = 0
484
+ else:
485
+ _divergence_growth_count = 0
486
+ _divergence_prev_norm = dual_norm_v
487
+
488
+ # Adaptive omega via the Lehmann residual-ratio estimator
489
+ # (arXiv:2012.12562): estimate the linear rate from the
490
+ # ratio of successive marginal errors and pick the optimal
491
+ # overrelaxation. Converges to omega_opt instead of
492
+ # oscillating like a fixed step-up/step-down heuristic.
493
+ if self.adaptive_omega and not _omega_capped:
494
+ if prev_err is not None and prev_err > 1e-12 and err > 0.0:
495
+ r = min(err / prev_err, 0.99)
496
+ omega = 2.0 / (1.0 + math.sqrt(1.0 - r ** (1.0 / self.check_every)))
497
+ omega = min(max(omega, 1.0), 1.95)
498
+ prev_err = err
499
+
500
+ errors.append(err)
501
+ if err < self.threshold:
502
+ converged = True
503
+ break
504
+
505
+ # Dual objective <f,a> + <g,b> - eps*sum_ij P_ij, using the true plan mass
506
+ # (equals eps*sum(a) only at convergence). cost_shift*sum(a) undoes the
507
+ # recenter so the value is in the original cost frame.
508
+ log_P_final = f.unsqueeze(1) / eps + log_K + g.unsqueeze(0) / eps
509
+ plan_mass = torch.exp(torch.logsumexp(log_P_final.reshape(-1), dim=0))
510
+ ent_reg_cost = ((f * a).sum() + (g * b).sum() - eps * plan_mass + cost_shift * a.sum()).item()
511
+
512
+ result = SinkhornResult(
513
+ f=f,
514
+ g=g,
515
+ converged=converged,
516
+ n_iters=n_iters,
517
+ ent_reg_cost=ent_reg_cost,
518
+ errors=errors if errors else None,
519
+ _eps=eps,
520
+ _cost_matrix=cost_matrix,
521
+ )
522
+ return result
@@ -0,0 +1,151 @@
1
+ """SoftmaxSolver: direct softmax weighting for subspace modes.
2
+
3
+ Replaces iterative Sinkhorn OT with a single softmax(-C/epsilon) pass.
4
+ Mathematically equivalent when the OT target marginal constraint is
5
+ naturally satisfied (few particles in subspace mode). See paper Section 5.10.
6
+
7
+ Key properties:
8
+ - Row sums of transport matrix equal source marginal a
9
+ - No dual potentials (f, g are None)
10
+ - Single iteration (converged=True, n_iters=1)
11
+ - Numerical stability via PyTorch's built-in softmax (subtracts row-max)
12
+ """
13
+
14
+ import warnings
15
+ from dataclasses import dataclass
16
+ from typing import Optional, Union
17
+
18
+ import torch
19
+
20
+ from ..costs import scale_cost_matrix
21
+ from ._prelude import align_marginal, recenter_cost, sanitize_cost, validate_positive
22
+ from .base import SolverResult
23
+
24
+
25
+ # Below this ratio of `epsilon` to `max|C|`, ``-C/epsilon`` overflows
26
+ # before ``torch.softmax`` gets a chance to subtract the row max.
27
+ # Tested empirically at FP32 / BF16: ratios above ~1e-6 stay finite.
28
+ _TINY_EPSILON_RATIO = 1e-6
29
+
30
+ SoftmaxResult = SolverResult
31
+
32
+
33
+ @dataclass
34
+ class SoftmaxSolver:
35
+ """Direct softmax weighting solver for subspace modes.
36
+
37
+ Computes transport weights via ``softmax(-C / epsilon)`` and scales
38
+ by the source marginal to produce a transport matrix whose row sums
39
+ equal ``a``. This is equivalent to entropic OT when the target marginal
40
+ constraint is naturally satisfied (few particles, subspace mode).
41
+
42
+ Attributes:
43
+ epsilon: Temperature parameter (entropic regularization strength).
44
+ Controls sharpness of the softmax: lower epsilon gives sharper
45
+ (more selective) weights.
46
+ compile: Placeholder for API compatibility with SinkhornSolver.
47
+ Currently unused since softmax is a single torch op.
48
+ """
49
+
50
+ epsilon: float = 0.1
51
+ compile: bool = False
52
+
53
+ def solve(
54
+ self,
55
+ cost_matrix: torch.Tensor,
56
+ a: Optional[torch.Tensor] = None,
57
+ b: Optional[torch.Tensor] = None,
58
+ init_f: Optional[torch.Tensor] = None,
59
+ init_g: Optional[torch.Tensor] = None,
60
+ scale_cost: Optional[Union[str, float]] = None,
61
+ ) -> SolverResult:
62
+ """Compute softmax weights from cost matrix.
63
+
64
+ Args:
65
+ cost_matrix: Cost matrix C of shape (P, V).
66
+ a: Source marginal of shape (P,). Defaults to uniform 1/P.
67
+ b: Target marginal (accepted but ignored for softmax).
68
+ init_f: Warm-start dual potential (accepted but ignored).
69
+ init_g: Warm-start dual potential (accepted but ignored).
70
+ scale_cost: Cost scaling: 'mean', 'max_cost', or float divisor.
71
+
72
+ Returns:
73
+ SolverResult with transport matrix, cost, and metadata.
74
+
75
+ Raises:
76
+ ValueError: If epsilon <= 0.
77
+ """
78
+ validate_positive(
79
+ self.epsilon,
80
+ "epsilon",
81
+ "epsilon is the temperature in softmax(-C/epsilon).",
82
+ )
83
+
84
+ P, V = cost_matrix.shape
85
+
86
+ # SoftmaxSolver is one-sided: it only enforces row sums equal `a`.
87
+ # If a caller passes a non-uniform `b` they probably meant to call
88
+ # SinkhornSolver; warn loudly so the constraint isn't silently dropped.
89
+ if b is not None:
90
+ uniform = torch.full_like(b, 1.0 / V)
91
+ if not torch.allclose(b.to(uniform.dtype), uniform, atol=1e-6):
92
+ warnings.warn(
93
+ "SoftmaxSolver ignores the target marginal `b`: it only "
94
+ "enforces row sums equal to the source marginal `a`. "
95
+ "Pass solver='sinkhorn' for two-sided OT.",
96
+ stacklevel=2,
97
+ )
98
+
99
+ # FP32 promotion (softmax subtracts the row max only after an outer
100
+ # autocast may have downcast -C/epsilon) + finite-cost handling
101
+ # (+Inf models a hard "never pick this vertex" constraint).
102
+ cost_matrix = sanitize_cost(cost_matrix)
103
+ device, dtype = cost_matrix.device, cost_matrix.dtype
104
+ a = align_marginal(a, P, device, dtype)
105
+
106
+ C = scale_cost_matrix(cost_matrix, scale_cost)
107
+ # softmax is shift-invariant, so recentering leaves the weights unchanged
108
+ # while min(C)=0 avoids a +inf logit that would NaN the softmax at tiny eps.
109
+ C, cost_shift = recenter_cost(C)
110
+
111
+ # ``epsilon > 0`` is enough to avoid division-by-zero, but
112
+ # eps=1e-30 with cost_max~10 still overflows -C/epsilon before
113
+ # softmax can subtract the row max. Warn at extreme ratios, but avoid a
114
+ # host sync every solve: only run the reduction when epsilon drops below
115
+ # the smallest value already checked (a new, sharper regime).
116
+ # Only runs on an epsilon decrease, so a fixed-epsilon cost blowup is not caught.
117
+ min_checked = getattr(self, "_min_eps_checked", None)
118
+ if min_checked is None or self.epsilon < min_checked:
119
+ self._min_eps_checked = self.epsilon
120
+ cost_max = C.detach().abs().max().item() if C.numel() > 0 else 0.0
121
+ if cost_max > 0 and self.epsilon < _TINY_EPSILON_RATIO * cost_max:
122
+ warnings.warn(
123
+ f"SoftmaxSolver epsilon={self.epsilon:.2e} is very small "
124
+ f"relative to the cost-matrix scale (max |C|={cost_max:.2e}); "
125
+ f"-C/epsilon may underflow / overflow before the row-max "
126
+ f"subtraction inside torch.softmax. Consider rescaling the "
127
+ f"cost or raising epsilon.",
128
+ stacklevel=2,
129
+ )
130
+
131
+ # PyTorch's softmax subtracts the row max internally for stability.
132
+ # Pin the whole block inside an autocast-disabled context so an outer
133
+ # mixed-precision region can't downcast intermediates back to BF16.
134
+ with torch.amp.autocast("cuda", enabled=False), torch.amp.autocast("cpu", enabled=False):
135
+ W = torch.softmax(-C / self.epsilon, dim=-1)
136
+
137
+ # Row sums equal source marginal a
138
+ transport = W * a.to(W.dtype).unsqueeze(-1)
139
+
140
+ # Undo the recenter shift so the reported cost is <C_scaled, transport>.
141
+ ent_cost = ((C * transport).sum() + cost_shift * a.to(W.dtype).sum()).item()
142
+
143
+ return SolverResult(
144
+ matrix=transport,
145
+ cost=ent_cost,
146
+ f=None,
147
+ g=None,
148
+ converged=True,
149
+ n_iters=1,
150
+ ent_reg_cost=ent_cost,
151
+ )