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,129 @@
1
+ """Shared entry-point preparation for the OT / weighting solvers.
2
+
3
+ Every log-sum-exp / softmax solver needs the same preamble before it can run:
4
+ promote half precision to FP32 (BF16's 7 mantissa bits collapse the row-max
5
+ trick once the cost spread exceeds ~15 nats), replace non-finite costs with a
6
+ finite penalty, default and device/dtype-align the marginals, and coerce
7
+ warm-start duals onto the cost tensor. Centralizing it keeps the variants from
8
+ drifting apart (e.g. one solver gaining FP32 promotion while another silently
9
+ NaNs on the same input).
10
+
11
+ Design note - no per-step host syncs: ``sanitize_cost`` is branch-free (no
12
+ ``.item()`` / ``.all()`` in a Python ``if``), and ``align_marginal`` /
13
+ ``align_dual`` only move tensors (``.to`` is a no-op when already aligned).
14
+ Value checks that would force a device->host sync are intentionally omitted so
15
+ the hot path (a fresh solve every optimizer step) stays GPU-resident.
16
+ """
17
+
18
+ import warnings
19
+ from typing import Optional
20
+
21
+ import torch
22
+
23
+
24
+ def validate_positive(value: float, name: str, context: str = "") -> None:
25
+ """Raise ValueError unless ``value > 0`` (a plain Python-float check).
26
+
27
+ Solvers store their temperature (``epsilon`` / ``tau``) as a mutable
28
+ attribute that schedules overwrite per step, so this is re-checked inside
29
+ ``solve()`` rather than only at construction.
30
+ """
31
+ if not value > 0:
32
+ msg = f"{name} must be > 0, got {value}."
33
+ if context:
34
+ msg += " " + context
35
+ raise ValueError(msg)
36
+
37
+
38
+ def sanitize_cost(cost_matrix: torch.Tensor) -> torch.Tensor:
39
+ """Promote half precision to FP32 and replace non-finite costs, on-device.
40
+
41
+ Non-finite entries (a hard-constraint ``+inf`` or an upstream NaN) are
42
+ replaced with ``2 * max|finite| + 1`` (floored at ``1e6``) so a masked
43
+ vertex still gets near-zero weight without sending ``-C/eps`` to ``-inf``
44
+ and NaN-ing the whole row. Branch-free: no host sync on the finite path.
45
+ """
46
+ if cost_matrix.dtype in (torch.bfloat16, torch.float16):
47
+ cost_matrix = cost_matrix.to(torch.float32)
48
+ if cost_matrix.numel() == 0:
49
+ return cost_matrix
50
+ finite = torch.isfinite(cost_matrix)
51
+ max_finite = torch.where(finite, cost_matrix, cost_matrix.new_zeros(())).abs().amax()
52
+ penalty = torch.clamp(max_finite * 2.0 + 1.0, min=1e6)
53
+ return torch.where(finite, cost_matrix, penalty)
54
+
55
+
56
+ def recenter_cost(cost_matrix: torch.Tensor):
57
+ """Subtract the per-matrix minimum so ``|C|`` stays bounded.
58
+
59
+ The entropic-OT plan is invariant to a constant shift of the cost:
60
+ ``softmax(-C/eps)`` is shift-invariant, and a shift only translates the
61
+ dual potentials, leaving the plan ``P`` unchanged. Keeping ``min(C)=0``
62
+ preserves FP32 precision in the log-sum-exp and the ``exp((f+g-C)/eps)``
63
+ reconstruction when ``|C|`` is much larger than ``eps``, and removes the
64
+ ``+inf`` logit that would NaN a softmax at tiny ``eps``.
65
+
66
+ Returns ``(shifted_cost, shift)`` with ``shift = C.min()``. Add
67
+ ``shift * a.sum()`` back to any reported ``<C, P>`` or dual value (plan
68
+ mass ``sum(P)`` equals ``a.sum()``). One reduction, no host sync.
69
+ """
70
+ if cost_matrix.numel() == 0:
71
+ return cost_matrix, cost_matrix.new_zeros(())
72
+ shift = cost_matrix.amin()
73
+ return cost_matrix - shift, shift
74
+
75
+
76
+ def align_marginal(
77
+ a: Optional[torch.Tensor],
78
+ n: int,
79
+ device: torch.device,
80
+ dtype: torch.dtype,
81
+ name: str = "a",
82
+ ) -> torch.Tensor:
83
+ """Return a length-``n`` marginal on ``(device, dtype)``.
84
+
85
+ ``None`` -> uniform ``1/n``. A provided marginal is moved onto the cost
86
+ tensor (a no-op when already aligned), shape-checked, and value-checked
87
+ (finite, nonnegative, positive total mass) so a malformed marginal fails
88
+ loudly instead of being silently clamped to an infeasible plan before the
89
+ ``log``. The value checks host-sync, so they run *only* on the user-supplied
90
+ path: the integrated optimizer passes ``a=None`` for its uniform marginal
91
+ (see :func:`solver.PolyStep.init_state`), keeping the per-step solve
92
+ sync-free.
93
+ """
94
+ if a is None:
95
+ return torch.full((n,), 1.0 / n, device=device, dtype=dtype)
96
+ a = a.to(device=device, dtype=dtype)
97
+ if a.shape != (n,):
98
+ raise ValueError(f"marginal {name} must have shape ({n},), got {tuple(a.shape)}.")
99
+ if not torch.isfinite(a).all():
100
+ raise ValueError(f"marginal {name} contains non-finite entries (NaN/Inf).")
101
+ if (a < 0).any():
102
+ raise ValueError(f"marginal {name} has negative entries; a transport marginal must be nonnegative.")
103
+ if not a.sum() > 0:
104
+ raise ValueError(f"marginal {name} has nonpositive total mass; it must sum to a positive value.")
105
+ return a
106
+
107
+
108
+ def align_dual(
109
+ init: Optional[torch.Tensor],
110
+ n: int,
111
+ device: torch.device,
112
+ dtype: torch.dtype,
113
+ name: str = "init",
114
+ ) -> Optional[torch.Tensor]:
115
+ """Move a warm-start dual onto ``(device, dtype)``, or ``None`` on mismatch.
116
+
117
+ Returns a fresh (cloned) tensor the caller may mutate in place, or ``None``
118
+ when the shape does not match (caller falls back to a zero init, after a
119
+ warning). The ``.to`` is a no-op when the dual is already aligned.
120
+ """
121
+ if init is None:
122
+ return None
123
+ if init.shape != (n,):
124
+ warnings.warn(
125
+ f"warm-start {name} shape mismatch: expected ({n},), got {tuple(init.shape)}. Falling back to zeros.",
126
+ stacklevel=2,
127
+ )
128
+ return None
129
+ return init.to(device=device, dtype=dtype).clone()
@@ -0,0 +1,77 @@
1
+ """Solver abstraction layer: Protocol and base result type.
2
+
3
+ Defines the ``Solver`` protocol for pluggable OT/weighting solvers and the
4
+ ``SolverResult`` dataclass that all solvers return. Uses structural typing
5
+ (Protocol) so any class with a matching ``.solve()`` signature qualifies
6
+ without explicit inheritance.
7
+ """
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Optional, Union
11
+
12
+ import torch
13
+
14
+
15
+ @dataclass
16
+ class SolverResult:
17
+ """Base result from any solver.
18
+
19
+ Attributes:
20
+ matrix: Transport plan / weight matrix of shape (P, V).
21
+ cost: Regularized cost scalar.
22
+ f: First dual potential (Sinkhorn) or None (softmax).
23
+ g: Second dual potential (Sinkhorn) or None (softmax).
24
+ converged: Whether the solver converged within tolerance.
25
+ n_iters: Number of iterations actually run.
26
+ ent_reg_cost: Entropic regularized cost of the transport plan. For the
27
+ Sinkhorn dual this is <f, a> + <g, b> - eps * sum_ij P_ij (true plan
28
+ mass; equals the converged value when sum(P) == sum(a)).
29
+ """
30
+
31
+ matrix: torch.Tensor
32
+ cost: float
33
+ f: Optional[torch.Tensor] = None
34
+ g: Optional[torch.Tensor] = None
35
+ converged: bool = True
36
+ n_iters: int = 1
37
+ ent_reg_cost: float = 0.0
38
+
39
+
40
+ class Solver:
41
+ """Protocol for pluggable solvers.
42
+
43
+ Any class that implements ``solve()`` with the following signature
44
+ qualifies as a ``Solver`` via structural typing (duck typing). This
45
+ is intentionally NOT a ``typing.Protocol`` subclass to avoid runtime
46
+ metaclass conflicts with ``@dataclass``; conformance is checked by
47
+ the type checker via structural subtyping.
48
+
49
+ Attributes:
50
+ epsilon: Entropic regularization / temperature parameter.
51
+ """
52
+
53
+ epsilon: float
54
+
55
+ def solve(
56
+ self,
57
+ cost_matrix: torch.Tensor,
58
+ a: Optional[torch.Tensor] = None,
59
+ b: Optional[torch.Tensor] = None,
60
+ init_f: Optional[torch.Tensor] = None,
61
+ init_g: Optional[torch.Tensor] = None,
62
+ scale_cost: Optional[Union[str, float]] = None,
63
+ ) -> SolverResult:
64
+ """Solve the weighting / transport problem.
65
+
66
+ Args:
67
+ cost_matrix: Cost matrix C of shape (n, m).
68
+ a: Source marginal of shape (n,). Defaults to uniform 1/n.
69
+ b: Target marginal of shape (m,). Defaults to uniform 1/m.
70
+ init_f: Warm-start first dual potential of shape (n,).
71
+ init_g: Warm-start second dual potential of shape (m,).
72
+ scale_cost: Cost scaling: 'mean', 'max_cost', or float divisor.
73
+
74
+ Returns:
75
+ SolverResult with transport plan and cost.
76
+ """
77
+ raise NotImplementedError
@@ -0,0 +1,159 @@
1
+ """Greedy solvers: deterministic update rules for ablation studies.
2
+
3
+ Provides two non-iterative solvers that bypass entropic OT entirely:
4
+
5
+ - ``MinCostGreedySolver``: Each particle moves to its single lowest-cost vertex.
6
+ - ``TopKMeanSolver``: Each particle moves to the uniform average of its k
7
+ lowest-cost vertices.
8
+
9
+ Both produce transport matrices compatible with the standard barycentric
10
+ projection (Eq. 7) and conform to the ``Solver`` protocol.
11
+ """
12
+
13
+ from dataclasses import dataclass
14
+ from typing import Optional, Union
15
+
16
+ import torch
17
+
18
+ from ..costs import scale_cost_matrix
19
+ from .base import SolverResult
20
+
21
+
22
+ @dataclass
23
+ class MinCostGreedySolver:
24
+ """Greedy argmin solver: each particle moves to its lowest-cost vertex.
25
+
26
+ For each row i of the cost matrix, assigns all mass ``a[i]`` to the
27
+ single vertex with minimum cost: ``T[i, argmin_v C[i,v]] = a[i]``,
28
+ zero elsewhere.
29
+
30
+ Attributes:
31
+ epsilon: Accepted for API compatibility; unused by greedy assignment.
32
+ compile: Accepted for API compatibility; unused.
33
+ """
34
+
35
+ epsilon: float = 0.1
36
+ compile: bool = False
37
+
38
+ def solve(
39
+ self,
40
+ cost_matrix: torch.Tensor,
41
+ a: Optional[torch.Tensor] = None,
42
+ b: Optional[torch.Tensor] = None,
43
+ init_f: Optional[torch.Tensor] = None,
44
+ init_g: Optional[torch.Tensor] = None,
45
+ scale_cost: Optional[Union[str, float]] = None,
46
+ ) -> SolverResult:
47
+ """Compute greedy argmin assignment from cost matrix.
48
+
49
+ Args:
50
+ cost_matrix: Cost matrix C of shape (P, V).
51
+ a: Source marginal of shape (P,). Defaults to uniform 1/P.
52
+ b: Target marginal (accepted but ignored).
53
+ init_f: Warm-start dual potential (accepted but ignored).
54
+ init_g: Warm-start dual potential (accepted but ignored).
55
+ scale_cost: Cost scaling: 'mean', 'max_cost', or float divisor.
56
+
57
+ Returns:
58
+ SolverResult with sparse transport matrix (one non-zero per row).
59
+ """
60
+ P, V = cost_matrix.shape
61
+ device = cost_matrix.device
62
+ dtype = cost_matrix.dtype
63
+
64
+ if a is None:
65
+ a = torch.ones(P, device=device, dtype=dtype) / P
66
+
67
+ C = scale_cost_matrix(cost_matrix.clone(), scale_cost)
68
+
69
+ # Greedy: each particle picks the single lowest-cost vertex
70
+ min_indices = C.argmin(dim=-1) # (P,)
71
+ transport = torch.zeros(P, V, device=device, dtype=dtype)
72
+ transport.scatter_(1, min_indices.unsqueeze(1), a.unsqueeze(1))
73
+
74
+ ent_cost = (C * transport).sum().item()
75
+
76
+ return SolverResult(
77
+ matrix=transport,
78
+ cost=ent_cost,
79
+ f=None,
80
+ g=None,
81
+ converged=True,
82
+ n_iters=1,
83
+ ent_reg_cost=ent_cost,
84
+ )
85
+
86
+
87
+ @dataclass
88
+ class TopKMeanSolver:
89
+ """Top-K mean solver: each particle moves to the uniform average of its k best vertices.
90
+
91
+ For each row i, finds the k vertices with lowest cost and assigns
92
+ equal mass ``a[i] / k`` to each: ``T[i,v] = a[i] / k`` for the top-k
93
+ vertices, zero elsewhere.
94
+
95
+ When V < k, gracefully falls back to using all V vertices.
96
+
97
+ Attributes:
98
+ epsilon: Accepted for API compatibility; unused.
99
+ compile: Accepted for API compatibility; unused.
100
+ k: Number of lowest-cost vertices to average over. Default 3.
101
+ """
102
+
103
+ epsilon: float = 0.1
104
+ compile: bool = False
105
+ k: int = 3
106
+
107
+ def solve(
108
+ self,
109
+ cost_matrix: torch.Tensor,
110
+ a: Optional[torch.Tensor] = None,
111
+ b: Optional[torch.Tensor] = None,
112
+ init_f: Optional[torch.Tensor] = None,
113
+ init_g: Optional[torch.Tensor] = None,
114
+ scale_cost: Optional[Union[str, float]] = None,
115
+ ) -> SolverResult:
116
+ """Compute top-k uniform assignment from cost matrix.
117
+
118
+ Args:
119
+ cost_matrix: Cost matrix C of shape (P, V).
120
+ a: Source marginal of shape (P,). Defaults to uniform 1/P.
121
+ b: Target marginal (accepted but ignored).
122
+ init_f: Warm-start dual potential (accepted but ignored).
123
+ init_g: Warm-start dual potential (accepted but ignored).
124
+ scale_cost: Cost scaling: 'mean', 'max_cost', or float divisor.
125
+
126
+ Returns:
127
+ SolverResult with transport matrix (k non-zeros per row).
128
+ """
129
+ P, V = cost_matrix.shape
130
+ device = cost_matrix.device
131
+ dtype = cost_matrix.dtype
132
+
133
+ if a is None:
134
+ a = torch.ones(P, device=device, dtype=dtype) / P
135
+
136
+ C = scale_cost_matrix(cost_matrix.clone(), scale_cost)
137
+
138
+ # Graceful fallback when fewer vertices than k
139
+ k_eff = min(self.k, V)
140
+
141
+ # Top-k: find k lowest-cost vertex indices per particle
142
+ _, topk_indices = C.topk(k_eff, dim=-1, largest=False) # (P, k_eff)
143
+
144
+ # Uniform mass over top-k vertices
145
+ transport = torch.zeros(P, V, device=device, dtype=dtype)
146
+ mass_per_vertex = a.unsqueeze(1) / k_eff # (P, 1)
147
+ transport.scatter_(1, topk_indices, mass_per_vertex.expand_as(topk_indices))
148
+
149
+ ent_cost = (C * transport).sum().item()
150
+
151
+ return SolverResult(
152
+ matrix=transport,
153
+ cost=ent_cost,
154
+ f=None,
155
+ g=None,
156
+ converged=True,
157
+ n_iters=1,
158
+ ent_reg_cost=ent_cost,
159
+ )
@@ -0,0 +1,200 @@
1
+ """KL-penalized one-sided OT solver (interpolates softmax ↔ Sinkhorn).
2
+
3
+ Implements the soft-target-marginal formulation:
4
+
5
+ min_P <C, P> + epsilon * H(P) + lam * KL(P^T 1 || b)
6
+ s.t. P 1 = a
7
+
8
+ with `lam ∈ [0, ∞]`. The two limits are exact:
9
+
10
+ - `lam = 0` ≡ ``SoftmaxSolver`` (only row marginal enforced).
11
+ - `lam -> ∞` ≡ ``SinkhornSolver`` (both row and column marginals).
12
+
13
+ Algorithm (log-domain alternating updates):
14
+
15
+ α = lam / (lam + epsilon) ∈ [0, 1]
16
+ f_i = epsilon * (log a_i - LSE_j((g_j - C_ij) / epsilon)) # exact
17
+ g_j = α * epsilon * (log b_j - LSE_i((f_i - C_ij) / epsilon)) # soft
18
+
19
+ Setting α = 0 freezes g at zero, recovering ``SoftmaxSolver`` (one
20
+ iteration suffices). Setting α = 1 (lam = ∞) recovers standard
21
+ Sinkhorn alternating projections. Intermediate α produces a smooth
22
+ interpolation, with `KL(P^T 1 || b)` decreasing monotonically as α
23
+ grows.
24
+
25
+ The α-scaling matches the scaling-algorithm form for unbalanced OT in
26
+ Chizat, Peyré, Schmitzer & Vialard, *Scaling Algorithms for Unbalanced
27
+ Optimal Transport Problems*, Math. Comp. 87 (2018), arXiv:1607.05816.
28
+ We also support `lam = inf` explicitly (the user-facing default for
29
+ "go to full Sinkhorn") so downstream code can pass `float('inf')`
30
+ without arithmetic on infinity.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import math
36
+ import warnings
37
+ from dataclasses import dataclass, field
38
+ from typing import Optional, Union
39
+
40
+ import torch
41
+
42
+ from ..costs import scale_cost_matrix
43
+ from ._prelude import align_marginal, recenter_cost, sanitize_cost
44
+ from .base import SolverResult
45
+
46
+
47
+ @dataclass
48
+ class KLSoftmaxSolver:
49
+ """KL-penalized one-sided entropic OT solver.
50
+
51
+ Attributes:
52
+ epsilon: Entropic regularization (temperature). Must be > 0.
53
+ lam: KL penalty weight on the column marginal.
54
+ `0` reduces to ``SoftmaxSolver``; `inf` reduces to
55
+ ``SinkhornSolver``. Must be >= 0.
56
+ max_iterations: Maximum dual-update iterations.
57
+ threshold: Convergence tolerance on max(|Δf|, |Δg|).
58
+ compile: Placeholder for API compatibility (unused).
59
+ """
60
+
61
+ epsilon: float = 0.1
62
+ lam: float = float("inf")
63
+ max_iterations: int = 2000
64
+ threshold: float = 1e-6
65
+ compile: bool = False
66
+
67
+ # KL(P^T 1 || b) recorded on every solve() call; None until the first solve.
68
+ last_marginal_violation: Optional[float] = field(default=None, init=False, repr=False)
69
+
70
+ def __post_init__(self) -> None:
71
+ if self.epsilon <= 0:
72
+ raise ValueError(
73
+ f"epsilon must be > 0, got {self.epsilon}. epsilon is the entropic temperature and must be positive."
74
+ )
75
+ if self.lam < 0:
76
+ raise ValueError(f"lam must be >= 0, got {self.lam}. lam is the KL penalty on the column marginal.")
77
+ if self.threshold < 0:
78
+ raise ValueError(f"threshold must be >= 0, got {self.threshold}")
79
+ if self.max_iterations < 1:
80
+ raise ValueError(f"max_iterations must be >= 1, got {self.max_iterations}")
81
+
82
+ @property
83
+ def alpha(self) -> float:
84
+ """The KL-scaling coefficient α = lam / (lam + epsilon) ∈ [0, 1]."""
85
+ if self.lam == 0.0:
86
+ return 0.0
87
+ if math.isinf(self.lam):
88
+ return 1.0
89
+ return float(self.lam / (self.lam + self.epsilon))
90
+
91
+ def solve(
92
+ self,
93
+ cost_matrix: torch.Tensor,
94
+ a: Optional[torch.Tensor] = None,
95
+ b: Optional[torch.Tensor] = None,
96
+ init_f: Optional[torch.Tensor] = None,
97
+ init_g: Optional[torch.Tensor] = None,
98
+ scale_cost: Optional[Union[str, float]] = None,
99
+ ) -> SolverResult:
100
+ # Shared prelude: FP32 promotion for LSE stability plus device-side
101
+ # non-finite handling, then device/dtype-aligned marginals. Sanitizing
102
+ # before scaling keeps a +Inf penalty out of the 'mean'/'max_cost' scale.
103
+ cost_matrix = sanitize_cost(cost_matrix)
104
+ n, m = cost_matrix.shape
105
+ device, dtype = cost_matrix.device, cost_matrix.dtype
106
+ a = align_marginal(a, n, device, dtype, "a")
107
+ b = align_marginal(b, m, device, dtype, "b")
108
+
109
+ # Optional cost rescaling (on the already-sanitized, finite cost).
110
+ C = scale_cost_matrix(cost_matrix, scale_cost)
111
+ # Recenter to min(C)=0 so the log-sum-exp updates and the exp((f+g-C)/eps)
112
+ # plan keep FP32 precision when |C| is much larger than eps.
113
+ C, cost_shift = recenter_cost(C)
114
+
115
+ eps = float(self.epsilon)
116
+ alpha = self.alpha
117
+
118
+ log_a = a.clamp(min=1e-30).log()
119
+ log_b = b.clamp(min=1e-30).log()
120
+
121
+ # Disable any outer mixed-precision autocast inside the iteration -
122
+ # downcast LSE to BF16 collapses the dual potentials.
123
+ with torch.amp.autocast("cuda", enabled=False), torch.amp.autocast("cpu", enabled=False):
124
+ f = (
125
+ init_f.to(dtype=dtype, device=device).clone()
126
+ if init_f is not None
127
+ else torch.zeros(n, device=device, dtype=dtype)
128
+ )
129
+ g = (
130
+ init_g.to(dtype=dtype, device=device).clone()
131
+ if init_g is not None
132
+ else torch.zeros(m, device=device, dtype=dtype)
133
+ )
134
+
135
+ # Special-case alpha == 0 (softmax limit): single closed-form.
136
+ if alpha == 0.0:
137
+ f = eps * (log_a - torch.logsumexp(-C / eps, dim=1))
138
+ g = torch.zeros_like(g)
139
+ converged = True
140
+ n_iters = 1
141
+ else:
142
+ converged = False
143
+ n_iters = self.max_iterations
144
+ # Only sync the convergence flag once per ``check_every``
145
+ # iterations to keep the dual updates GPU-resident.
146
+ check_every = max(1, self.max_iterations // 20)
147
+ threshold = float(self.threshold)
148
+ for it in range(self.max_iterations):
149
+ # f-update: exact row-marginal enforcement.
150
+ f_new = eps * (log_a - torch.logsumexp((g.unsqueeze(0) - C) / eps, dim=1))
151
+ # g-update: α-fraction of full-Sinkhorn target.
152
+ g_target = eps * (log_b - torch.logsumexp((f_new.unsqueeze(1) - C) / eps, dim=0))
153
+ g_new = alpha * g_target
154
+
155
+ if (it + 1) % check_every == 0 or it == self.max_iterations - 1:
156
+ delta = torch.maximum(
157
+ (f_new - f).abs().amax(),
158
+ (g_new - g).abs().amax(),
159
+ )
160
+ if delta.item() < threshold:
161
+ f, g = f_new, g_new
162
+ converged = True
163
+ n_iters = it + 1
164
+ break
165
+ f, g = f_new, g_new
166
+
167
+ log_P = (f.unsqueeze(1) + g.unsqueeze(0) - C) / eps
168
+ P = log_P.exp()
169
+
170
+ # Numerical hygiene
171
+ if not torch.isfinite(P).all():
172
+ warnings.warn(
173
+ "KLSoftmaxSolver produced non-finite transport entries; consider raising epsilon or lowering lam.",
174
+ stacklevel=2,
175
+ )
176
+ P = torch.where(torch.isfinite(P), P, torch.zeros_like(P))
177
+
178
+ # Undo the recenter shift so cost is <C_scaled, P> (sum(P) == a.sum()).
179
+ cost = ((C * P).sum() + cost_shift * a.sum()).item()
180
+
181
+ # Theorem 4.1 instrumentation: KL(P^T 1 || b).
182
+ # P^T 1 is the realized column marginal; b is the target.
183
+ # KL = sum_j q_j * log(q_j / b_j) with q = P^T 1.
184
+ try:
185
+ col_marginal = P.sum(dim=0).clamp(min=1e-30)
186
+ b_safe = b.clamp(min=1e-30)
187
+ kl = (col_marginal * (col_marginal.log() - b_safe.log())).sum().item()
188
+ self.last_marginal_violation = float(kl)
189
+ except Exception: # noqa: BLE001
190
+ self.last_marginal_violation = float("nan")
191
+
192
+ return SolverResult(
193
+ matrix=P,
194
+ cost=cost,
195
+ f=f,
196
+ g=g,
197
+ converged=converged,
198
+ n_iters=n_iters,
199
+ ent_reg_cost=cost,
200
+ )