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,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
|
+
)
|