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,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()
|
polystep/solvers/base.py
ADDED
|
@@ -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
|
+
)
|