Wasserstein-DRO and Lipschitz regularization¶
Every introduction to Wasserstein-DRO eventually says some version of "and this is equivalent to regularization". I wanted to watch that equivalence happen on real numbers instead of taking it on trust, because the statement is easy to state and surprisingly easy to misread: it is not that the robust value is approximately a penalty, and it is not that the penalty is whatever norm your favourite paper happens to use. It is a statement about the Lipschitz modulus of the loss, measured in the same ground metric that defines the transport cost.
The clean version, for a type-1 ball of radius \(\rho\) around a nominal \(\hat{P}\) and a loss \(\ell\) that is Lipschitz with respect to the ground metric:
Wu, Li and Mao1 prove this in a much broader setting than the one used here — general Wasserstein balls, general decision criteria, and an equivalence that is exact rather than an upper bound. This page checks the humble finite-support special case that Optora actually solves.
What the discrete dual is really doing¶
WassersteinAmbiguitySet does not know
anything about Lipschitz constants. It solves the exact discrete dual
and the regularization identity falls out of it. Since \(c_{ii} = 0\), once \(\gamma\) is large enough that no point can profitably be transported anywhere, the inner maximum is attained at \(j = i\) for every \(i\), and the whole objective collapses to \(\mathbb{E}_{\hat{P}}[\ell] + \gamma\rho\) — strictly increasing in \(\gamma\). The smallest \(\gamma\) at which that happens is
which is exactly the Lipschitz modulus of the loss restricted to the sample. So the minimizer never sits above \(L_n\), and for every radius below a positive threshold it sits exactly at \(L_n\), giving
That is the whole equivalence: robustifying the empirical risk over a small Wasserstein ball is penalizing the empirical Lipschitz modulus, with \(\rho\) as the regularization weight. The only thing separating it from the population statement above is the gap between \(L_n\) and \(\mathrm{Lip}(\ell)\).
The two ways the gap closes¶
Writing the surrogate with the true constant, the gap decomposes into a term that vanishes as the sample refines and a term that vanishes as the ball shrinks:
| Limit | What closes the gap | What the script plots |
|---|---|---|
| \(\rho \to 0\) | the exact value is concave in \(\rho\) and the surrogate is its tangent at the origin, so the correction dies | left panel: the normalized gap flattens onto \(\mathrm{Lip}(\ell) - L_n\) |
| \(n \to \infty\) | the sample sees steeper and steeper secants, so \(L_n \uparrow \mathrm{Lip}(\ell)\) | right panel: that plateau falls toward zero |
The script divides the gap by \(\rho\) throughout. Dividing is the honest choice: the raw gap shrinks with \(\rho\) no matter what the loss looks like, which would make the small-radius panel a tautology rather than a test.
Why softplus¶
The loss is \(\ell(z) = \log(1 + e^z)\) on a deterministic quantile discretization of \(\mathcal{N}(0, 1)\), with \(c_{ij} = |z_i - z_j|\).
It is exactly \(1\)-Lipschitz — its derivative is the sigmoid, whose supremum is \(1\) — but that slope is only ever approached, never attained on a bounded set. So \(L_n < 1\) strictly at every finite sample size, and the sample-size axis has something to show.
A piecewise-linear loss would hide the effect
Take \(\ell(z) = |z|\) instead. Any two support points on the same side
of the origin already realize the full slope, so \(L_n = \mathrm{Lip}\)
at essentially any sample size and the small-radius gap is zero to
machine precision. That is a much better unit test than a plot — and
it is one, in
tests/dro/test_wasserstein_dro.py::test_small_radius_dual_equals_lipschitz_regularized_expectation.
Reading the output¶
The printed table gives \(L_n\), the measured normalized gap, and the predicted deficiency \(1 - L_n\) side by side; they agree to about \(10^{-3}\) across every sample size. The figure says the same thing twice:
- Left: the normalized gap against radius, one curve per sample size, each with a dotted line at its own \(1 - L_n\). The curves sit on their dotted lines for roughly four decades of radius and only peel upward once the ball is large enough to start saturating at \(\max_i \ell_i\) — the regime where robustness stops behaving like a linear penalty at all.
- Right: the small-radius plateau against sample size, on log-log axes, with the closed-form \(1 - L_n\) overlaid. The two lines are indistinguishable, which is the real claim: the solver is not approximating the regularized problem, it is solving it.
The dual is piecewise linear, so converged stays False
The objective above is a minimum of finitely many affine functions of
\(\gamma\), and its minimizer is a kink. A fixed-step gradient descent
cannot make the gradient norm small there — it oscillates across the
kink forever and reports converged=False no matter how long it runs.
That is fine here, and worth understanding rather than working around:
the slopes on both sides of the kink are \(O(\rho)\) in the small-radius
regime, so the value is accurate to roughly \(10^{-3}\) even though the
gradient never is. It is also why an occasional measured gap comes
out a hair negative when it should be exactly zero.
Source¶
"""Wasserstein-DRO as Lipschitz regularization.
Wasserstein-DRO is often justified by an equivalence: on a type-1
Wasserstein ball built from a ground metric, robustifying an expected loss
is the same as penalizing that loss's Lipschitz modulus. The linear
surrogate this suggests is
sup_{q : W(q, nominal) <= radius} E_q[loss] ~ E_nominal[loss]
+ radius * Lip(loss)
which is exactly the "robustness = regularization" reading of the ball
radius: `radius` buys nothing but a penalty proportional to how fast the
loss can change per unit of transport.
On a finite support the identity is sharper than an approximation. The
exact discrete dual solved by `WassersteinAmbiguitySet` is minimized at
`gamma = L_n` for every sufficiently small radius, where
L_n = max_{i != j} |loss_i - loss_j| / cost_ij
is the Lipschitz modulus of the loss *restricted to the sample*. The dual
value is then exactly `E_nominal[loss] + radius * L_n`, so the gap against
the surrogate built from the true constant `Lip(loss)` is
gap(radius) = radius * (Lip(loss) - L_n) + (concavity correction)
The concavity correction vanishes as the radius shrinks; the first term
vanishes as the sample refines and `L_n -> Lip(loss)`. This script measures
both limits, using `softplus` as a loss whose Lipschitz constant is exactly
1 (its derivative is the sigmoid) but is only attained asymptotically, so
`L_n < 1` strictly at every finite sample size.
Reference:
Qinyu Wu, Jonathan Yu-Meng Li, Tiantian Mao, "On Generalization and
Regularization via Wasserstein Distributionally Robust Optimization",
Management Science (2025). arXiv:2212.05716.
Run:
python examples/05_wasserstein_lipschitz_equivalence.py
"""
import matplotlib.pyplot as plt
import torch
from _plotting import save_figure
from optora.dro import WassersteinAmbiguitySet
LIPSCHITZ_CONSTANT = 1.0
RADII = torch.logspace(-4.0, 0.5, steps=12, dtype=torch.float64)
RADIUS_SWEEP_SIZES = (16, 64, 256)
SAMPLE_SIZES = (8, 16, 32, 64, 128, 256)
SMALL_RADIUS = 1e-3
def empirical_measure(
num_samples: int,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""Build a deterministic `num_samples`-point discretization of N(0, 1).
Support points are the midpoint quantiles of the standard normal, so
the sample refines deterministically as `num_samples` grows and no seed
is involved. The ground cost is the Euclidean metric on the support,
which makes the ambiguity set a type-1 Wasserstein ball.
Args:
num_samples: Number of support points.
Returns:
A tuple `(nominal, loss, cost)` holding the uniform empirical
distribution, the softplus loss evaluated on the support, and the
pairwise ground cost matrix.
"""
quantiles = (torch.arange(num_samples, dtype=torch.float64) + 0.5) / num_samples
support = torch.special.ndtri(quantiles)
loss = torch.nn.functional.softplus(support)
cost = torch.abs(support.unsqueeze(-1) - support.unsqueeze(-2))
nominal = torch.full_like(support, 1.0 / num_samples)
return nominal, loss, cost
def empirical_lipschitz_constant(loss: torch.Tensor, cost: torch.Tensor) -> float:
"""Return the largest loss increment per unit of transport on the sample."""
increments = torch.abs(loss.unsqueeze(-1) - loss.unsqueeze(-2))
# The diagonal has a zero increment; the added identity only keeps 0/0 out.
separation = cost + torch.eye(cost.shape[-1], dtype=cost.dtype)
return torch.max(increments / separation).item()
def normalized_gap(
nominal: torch.Tensor, loss: torch.Tensor, cost: torch.Tensor, radius: float
) -> float:
"""Return `(surrogate - exact dual value) / radius` at `radius`.
Dividing by the radius removes the trivial part of the convergence: the
absolute gap shrinks with the radius no matter what, whereas the
normalized gap isolates the Lipschitz deficiency `Lip(loss) - L_n` that
the small-radius limit should expose.
"""
ambiguity_set = WassersteinAmbiguitySet(nominal=nominal, cost=cost, radius=radius)
exact = ambiguity_set.worst_case_expectation(loss).item()
surrogate = torch.sum(nominal * loss).item() + radius * LIPSCHITZ_CONSTANT
return (surrogate - exact) / radius
def main() -> None:
fig, (ax_radius, ax_samples) = plt.subplots(1, 2, figsize=(11, 4))
print("normalized gap (surrogate - exact) / radius, by sample size")
for num_samples in RADIUS_SWEEP_SIZES:
nominal, loss, cost = empirical_measure(num_samples)
lipschitz = empirical_lipschitz_constant(loss, cost)
gaps = [normalized_gap(nominal, loss, cost, radius.item()) for radius in RADII]
deficiency = LIPSCHITZ_CONSTANT - lipschitz
print(
f" n={num_samples:<4} L_n={lipschitz:.6f} "
f"Lip - L_n={deficiency:.6f} "
f"gap(radius->0)={gaps[0]:.6f} gap(radius={RADII[-1]:.3f})={gaps[-1]:.6f}"
)
assert min(gaps) > -1e-3
assert abs(gaps[0] - deficiency) < 5e-3
line = ax_radius.semilogx(RADII, gaps, marker="o", label=f"n = {num_samples}")
ax_radius.axhline(deficiency, color=line[0].get_color(), linestyle=":")
ax_radius.set_xlabel("Wasserstein radius")
ax_radius.set_ylabel("(surrogate - exact) / radius")
ax_radius.set_title("small radius: gap flattens at Lip - L_n")
ax_radius.legend()
print(f"\nsmall-radius limit at radius={SMALL_RADIUS}, by sample size")
measured_gaps = []
deficiencies = []
for num_samples in SAMPLE_SIZES:
nominal, loss, cost = empirical_measure(num_samples)
lipschitz = empirical_lipschitz_constant(loss, cost)
gap = normalized_gap(nominal, loss, cost, SMALL_RADIUS)
measured_gaps.append(gap)
deficiencies.append(LIPSCHITZ_CONSTANT - lipschitz)
print(
f" n={num_samples:<4} L_n={lipschitz:.6f} "
f"measured gap={gap:.6f} Lip - L_n={LIPSCHITZ_CONSTANT - lipschitz:.6f}"
)
decrements = torch.diff(torch.tensor(measured_gaps, dtype=torch.float64))
is_decreasing = bool(torch.all(decrements < 0.0))
print(f"gap decreases as the sample refines: {is_decreasing}")
assert is_decreasing
ax_samples.loglog(
SAMPLE_SIZES, measured_gaps, marker="o", label="measured gap / radius"
)
ax_samples.loglog(
SAMPLE_SIZES,
deficiencies,
marker="x",
linestyle="--",
label="Lip - L_n (predicted)",
)
ax_samples.set_xlabel("sample size n")
ax_samples.set_ylabel("(surrogate - exact) / radius")
ax_samples.set_title("growing sample: Lipschitz deficiency vanishes")
ax_samples.legend()
fig.tight_layout()
save_figure(fig, "05_wasserstein_lipschitz_equivalence")
if __name__ == "__main__":
main()
-
Qinyu Wu, Jonathan Yu-Meng Li and Tiantian Mao, "On Generalization and Regularization via Wasserstein Distributionally Robust Optimization", Management Science (2025). arXiv:2212.05716. ↩