Skip to content

Commit 5a0f496

Browse files
[MRG] Add backend-agnostic semi-discrete OT module and SGD-based solver in ot.semidiscrete (#812)
* Add backend-agnostic semi-discrete OT solver (DRAG) Introduces ot.semidiscrete: Projected Averaged SGD on the semi-dual, with an optional decreasing entropic-regularization schedule (DRAG) from Genans et al. 2025. Works with NumPy, PyTorch, JAX, CuPy and TensorFlow via ot.backend. - ot/semidiscrete.py: solve_semidiscrete, atom_weights, ot_map, c_transform. Closed-form gradient, no autograd graph through the loop; quadratic cost by default with custom-callable override. - ot/__init__.py: register the new submodule. - test/test_semidiscrete.py: convergence on three toy problems with known optimal potentials, helper-function contracts (row- stochasticity of atom_weights, identity for c_transform at g=0, shape and finiteness of ot_map), and solver options (warm-start, projection, log, polyak_average off, entropic regime, custom cost). All tests parametrized over the nx fixture (NumPy + PyTorch). - examples/others/plot_semidiscrete.py: gallery example on a small 2D toy problem with Laguerre cells, empirical cell masses and a Monte Carlo estimate of the semi-dual cost. - RELEASES.md: new-features entry under 0.9.7.dev0. * modified: README.md modified: RELEASES.md modified: examples/others/plot_semidiscrete.py Final small doc modifications. * Modified "proj_bound" to "max_cost" in the solve_semidiscrete, and added a more detailed explanation of the effect of this argument on convergence in the example scipt. * Edited the semi-discrete example script "max_cost" explanation. * Changed "proj_bound" to "max_cost" in test_semidiscrete.py * Add PR's number . * Address review on semi-discrete OT module: - Rename public functions to semidiscrete_{atom_weights,ot_map,c_transform} and align params with ot.solve_sample (X_target, sampler_source, a_target, metric, max_iter, max_cost) - metric accepts ot.dist strings (default 'sqeuclidean') or a callable - sampler_source accepts built-in strings ('unif', 'ball', 'normal', ...), default 'unif' - Full docstrings with equations and references to both papers - Examplel OT-map visualization with arrow and cell-mass plot - Tests: use nx.from_numpy, cover the string metric/sampler paths * Added my name in citation.cff --------- Co-authored-by: Rémi Flamary <remi.flamary@gmail.com>
1 parent 60cbe12 commit 5a0f496

7 files changed

Lines changed: 1169 additions & 0 deletions

File tree

CITATION.cff

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ authors:
8484
- given-names: Marco
8585
family-names: Corneli
8686
affiliation: Université Côte d'Azur
87+
- given-names: Ferdinand Genans
88+
affiliation: Sorbonne Université, LPSM, CNRS
8789
identifiers:
8890
- type: url
8991
value: 'https://github.com/PythonOT/POT'

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,3 +468,5 @@ Artificial Intelligence.
468468
[88] Bouveyron, C. & Corneli, M. (2026). [Scaling optimal transport to high-dimensional Gaussian distributions with application to domain adaptation](https://hal.science/hal-04930868v4/file/Article-OT-HDGauss-v4.pdf). Statistics and Computing 36.2 (2026): 88.
469469

470470
[89] Tipping, M.E. & Bishop, C.M. (1999). [Probabilistic principal component analysis]. Journal of the Royal Statistical Society Series B: Statistical Methodology 61.3 (1999): 611-622.
471+
472+
[90] Genans, F., Godichon-Baggioni, A., Vialard, F. X., & Wintenberger, O. (2025). [Decreasing Entropic Regularization Averaged Gradient for Semi-Discrete Optimal Transport](https://proceedings.neurips.cc/paper_files/paper/2025/file/d7efa12e98f5e0dd8b4f48cd60b4e3aa-Paper-Conference.pdf). Advances in Neural Information Processing Systems, 38, 146913-146949.

RELEASES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ This new release adds support for sparse cost matrices and a new lazy exact OT s
2626
- Add `ot.utils.apply_scaler` helper that dispatches preprocessing to a scaler object,
2727
a callable, or a no-op (PR #808)
2828
- Add optional `scaler` parameter to `sliced_wasserstein_distance` and `max_sliced_wasserstein_distance` (PR #808)
29+
- Add SGD based semi-discrete OT solver in `ot.semidiscrete` and a gallery example. (PR #812)
2930
- Add a numerically stable log-domain solver for entropic partial Wasserstein, selectable via the new `method` parameter of `entropic_partial_wasserstein` (`method='sinkhorn_log'`) or directly through `entropic_partial_wasserstein_logscale` (Issue #723)
3031
- Add cost functions between linear operators following
3132
[A Spectral-Grassmann Wasserstein metric for operator representations of dynamical systems](https://arxiv.org/pdf/2509.24920),
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
# -*- coding: utf-8 -*-
2+
r"""
3+
==================================
4+
Semi-discrete OT: a toy 2D problem
5+
==================================
6+
7+
This example shows the :mod:`ot.semidiscrete` solver on a small 2D problem:
8+
a uniform source on :math:`[0, 1]^2` and 15 random target atoms with uniform
9+
weights. With so few atoms the Laguerre cells can be drawn by brute force on
10+
a grid.
11+
12+
We call :func:`ot.semidiscrete.solve_semidiscrete` with its default
13+
arguments: the underlying algorithm is **Projected Averaged SGD**, and the
14+
default ``decreasing_reg=True`` adds the **DRAG** entropic-regularization
15+
schedule of [90]_, which improves convergence.
16+
17+
For the returned potential :math:`g` we report:
18+
19+
- the empirical Laguerre-cell masses (mean and max absolute deviation from
20+
:math:`1/15`);
21+
- the semi-dual objective
22+
:math:`\langle g, b\rangle + \mathbb{E}_X[\varphi_g(X)]` estimated by
23+
Monte Carlo, where the c-transform
24+
:math:`\varphi_g(x) = \min_j\big(c(x, y_j) - g_j\big)` is computed by
25+
:func:`ot.semidiscrete.semidiscrete_c_transform`. The solver **maximises** this
26+
objective.
27+
28+
.. [90] Genans, F., Godichon-Baggioni, A., Vialard, F.-X., Wintenberger, O.
29+
(2025). *Decreasing Entropic Regularization Averaged Gradient for
30+
Semi-Discrete Optimal Transport.* NeurIPS 2025.
31+
"""
32+
33+
# Author: Ferdinand Genans <genans.ferdinand@gmail.com>
34+
#
35+
# License: MIT License
36+
37+
# sphinx_gallery_thumbnail_number = 1
38+
39+
import numpy as np
40+
import matplotlib.pyplot as plt
41+
42+
from ot.semidiscrete import (
43+
solve_semidiscrete,
44+
semidiscrete_atom_weights,
45+
semidiscrete_c_transform,
46+
semidiscrete_ot_map,
47+
)
48+
49+
##############################################################################
50+
# Toy 2D problem
51+
# --------------
52+
53+
rng = np.random.default_rng(42)
54+
55+
56+
def source_sampler(batch_size):
57+
return rng.random((batch_size, 2))
58+
59+
60+
n_atoms = 15
61+
target_positions = 0.1 + 0.8 * np.random.default_rng(0).random((n_atoms, 2))
62+
63+
64+
def plot_laguerre_cells(target, g, ax, title, resolution=300, alpha=0.55):
65+
xs = np.linspace(0, 1, resolution)
66+
ys = np.linspace(0, 1, resolution)
67+
XX, YY = np.meshgrid(xs, ys)
68+
grid = np.stack([XX.ravel(), YY.ravel()], axis=1)
69+
labels = semidiscrete_atom_weights(target, grid, g, reg=0.0).argmax(axis=1)
70+
image = labels.reshape(resolution, resolution)
71+
cmap = plt.get_cmap("tab20", target.shape[0])
72+
ax.imshow(
73+
image,
74+
origin="lower",
75+
extent=(0, 1, 0, 1),
76+
cmap=cmap,
77+
alpha=alpha,
78+
vmin=-0.5,
79+
vmax=target.shape[0] - 0.5,
80+
interpolation="nearest",
81+
)
82+
# Target points share the colour of their Laguerre cell.
83+
ax.scatter(
84+
target[:, 0],
85+
target[:, 1],
86+
s=80,
87+
c=[cmap(i) for i in range(target.shape[0])],
88+
edgecolor="black",
89+
linewidths=1.2,
90+
zorder=3,
91+
)
92+
ax.set_title(title)
93+
ax.set_aspect("equal")
94+
ax.set_xlim(0, 1)
95+
ax.set_ylim(0, 1)
96+
97+
98+
##############################################################################
99+
# Solve and visualise
100+
# -------------------
101+
#
102+
# A single call to :func:`solve_semidiscrete` runs DRAG with the default
103+
# arguments (``decreasing_reg=True``). We show the initial Voronoi cells
104+
# (:math:`g = 0`) next to the Laguerre cells at the optimum.
105+
# With the squared-Euclidean cost (default ``metric='sqeuclidean'``), the cost
106+
# between a source point in :math:`[0, 1]^2` and an atom is
107+
# :math:`\|x - y\|^2 \le 2`. We clip the potential to
108+
# ``[-max_cost, max_cost] = [-2, 2]``, the localizing set where an optimal
109+
# potential lies ([90]_, Lemma 1), which speeds up convergence.
110+
g_drag = solve_semidiscrete(
111+
target_positions,
112+
source_sampler,
113+
max_iter=20_000,
114+
batch_size=32,
115+
max_cost=2.0,
116+
)
117+
118+
fig, axes = plt.subplots(1, 2, figsize=(11, 5.5))
119+
plot_laguerre_cells(target_positions, np.zeros(n_atoms), axes[0], "Voronoi (g = 0)")
120+
plot_laguerre_cells(target_positions, g_drag, axes[1], "Approximated OT Laguerre cells")
121+
plt.tight_layout()
122+
plt.show()
123+
124+
125+
##############################################################################
126+
# Transport map over the Laguerre cells
127+
# -------------------------------------
128+
#
129+
# :func:`semidiscrete_ot_map` with ``reg=0`` is the hard Monge map: every
130+
# source point is sent to the atom of its Laguerre cell. Overlaying the map
131+
# (arrows on a source grid) on the *faded* cells shows each cell's mass
132+
# collapsing onto its atom -- a direct illustration of the mapping function.
133+
134+
gx = np.linspace(0.04, 0.96, 14)
135+
grid = np.stack([a.ravel() for a in np.meshgrid(gx, gx)], axis=1)
136+
mapped = semidiscrete_ot_map(target_positions, grid, g_drag, reg=0.0)
137+
labels = semidiscrete_atom_weights(target_positions, grid, g_drag, reg=0.0).argmax(
138+
axis=1
139+
)
140+
141+
cmap = plt.get_cmap("tab20", n_atoms)
142+
fig, ax = plt.subplots(figsize=(6.5, 6.5))
143+
plot_laguerre_cells(
144+
target_positions, g_drag, ax, "Approximated OT map over Laguerre cells", alpha=0.22
145+
)
146+
ax.quiver(
147+
grid[:, 0],
148+
grid[:, 1],
149+
mapped[:, 0] - grid[:, 0],
150+
mapped[:, 1] - grid[:, 1],
151+
angles="xy",
152+
scale_units="xy",
153+
scale=1,
154+
width=0.005,
155+
headwidth=4,
156+
headlength=5,
157+
color=[cmap(i) for i in labels],
158+
zorder=2,
159+
)
160+
plt.tight_layout()
161+
plt.show()
162+
163+
164+
##############################################################################
165+
# Cell masses and Monte Carlo cost
166+
# --------------------------------
167+
#
168+
# At the optimum each Laguerre cell should carry mass :math:`1/15`. We report
169+
# the empirical mass error and the semi-dual objective
170+
#
171+
# .. math::
172+
# \mathcal{S}(g) = \langle g, b\rangle + \mathbb{E}_X[\varphi_g(X)]
173+
#
174+
# estimated by Monte Carlo. The solver maximises :math:`\mathcal{S}`.
175+
176+
177+
def cell_masses(target, g, sampler, n_samples=100_000):
178+
labels = semidiscrete_atom_weights(target, sampler(n_samples), g, reg=0.0).argmax(
179+
axis=1
180+
)
181+
counts = np.bincount(labels, minlength=target.shape[0])
182+
return counts / n_samples
183+
184+
185+
def mc_cost(target, g, sampler, n_samples=100_000):
186+
b = np.full(target.shape[0], 1.0 / target.shape[0])
187+
samples = sampler(n_samples)
188+
return float(g @ b + semidiscrete_c_transform(target, samples, g, reg=0.0).mean())
189+
190+
191+
target_mass = 1.0 / n_atoms
192+
m_drag = cell_masses(target_positions, g_drag, source_sampler)
193+
cost_drag = mc_cost(target_positions, g_drag, source_sampler)
194+
195+
print(f"Target mass per cell: {target_mass:.4f}")
196+
print(
197+
f"DRAG — mean abs. mass error: "
198+
f"{np.mean(np.abs(m_drag - target_mass)):.4f}"
199+
f" max: {np.max(np.abs(m_drag - target_mass)):.4f}"
200+
f" semi-dual cost (MC): {cost_drag:.5f}"
201+
)
202+
203+
204+
##############################################################################
205+
# Laguerre-cell masses
206+
# --------------------
207+
#
208+
# At the optimum every cell carries the same mass :math:`1/15`. The bar plot
209+
# shows the empirical mass per cell against this ground truth (dashed line):
210+
# every cell sits close to the theoretical value.
211+
212+
cmap = plt.get_cmap("tab20", n_atoms)
213+
fig, ax = plt.subplots(figsize=(7.5, 4))
214+
ax.bar(
215+
np.arange(n_atoms),
216+
m_drag,
217+
color=[cmap(i) for i in range(n_atoms)],
218+
edgecolor="black",
219+
linewidth=0.6,
220+
)
221+
ax.axhline(
222+
target_mass,
223+
ls="--",
224+
color="black",
225+
lw=1.5,
226+
label="theoretical mass per cell at the optimum",
227+
)
228+
ax.set_ylim(0, 1.6 * target_mass)
229+
ax.set_xticks(np.arange(n_atoms))
230+
ax.set_xlabel("atom index")
231+
ax.set_ylabel("Laguerre-cell mass")
232+
ax.set_title("Approximated OT: Laguerre-cell masses")
233+
ax.legend()
234+
plt.tight_layout()
235+
plt.show()

ot/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from . import gaussian
3838
from . import lowrank
3939
from . import gmm
40+
from . import semidiscrete
4041
from . import sgot
4142

4243
# OT functions
@@ -156,6 +157,7 @@
156157
"factored",
157158
"lowrank",
158159
"gmm",
160+
"semidiscrete",
159161
"sgot",
160162
"binary_search_circle",
161163
"wasserstein_circle",

0 commit comments

Comments
 (0)