Repository navigation
Description
Summary
For balanced quadratic-regularized OT, ot.solve returns the optimal plan of
but reports
res.plan and res.value_linear are correct; res.value is not the optimal value of the problem that was solved. In the exact example below, res.value = 3.0 while the true optimum is 1.7.
User impact: any downstream use of res.value as a loss, a benchmark number, or a model-selection criterion is affected; solve_sample and solve_bary_sample inherit the same scalar. Transport plans are not affected.
Minimal reproduction
import numpy as np
import ot
print(ot.__version__, ot.__file__) # 0.9.8.dev / checkout
M = np.array([[0.0, 1.0], [1.0, 0.0]])
a = np.array([0.5, 0.5])
b = np.array([0.5, 0.5])
reg = 10.0
res = ot.solve(M, a, b, reg=reg, reg_type="L2")
P = np.asarray(res.plan)
J = np.sum(M * P) + 0.5 * reg * np.sum(P ** 2)
print(P) # [[0.3 0.2]
# [0.2 0.3]]
print(res.value_linear) # 0.4
print(res.value) # 3.0
print(J) # 1.7
print(res.value - J) # 1.3 == 0.5 * reg * ||P||^2Actual: res.value = 3.0, res.value_linear = 0.4, res.plan = [[0.3, 0.2], [0.2, 0.3]].
Expected: the plan is correct; res.value should be the optimal value 17/10 = 1.7 (or, if the maintainers intend the alternative convention discussed below, the plan should be a different matrix and the value should be 2.975). It cannot be 3.0 in either case.
The plan is the exact KKT solution for this instance (the solver returns [[3/10, 1/5], [1/5, 3/10]] to machine precision), so this is not a convergence artifact.
Mathematical basis
-
ot/smooth.py:SquaredL2.Omegadefines$\Omega(T)=\frac{\gamma}{2}|T|^2$ ;delta_Omega(X)=\sum[\max(X,0)]^2/(2\gamma), andget_plan_from_dualreturns$P=[\alpha+\beta-M]_+/\gamma$ . Withgamma=reg, the solver called byot.solveminimizes the half-squared primal. - On the support of the returned plan,
$\alpha_i+\beta_j=M_{ij}+\lambda P_{ij}$ , hence
In other words, the reported value is the affine part of the L2 dual; the conjugate term 1.7.
- This matches the cited paper (Blondel, Seguy & Rolet, AISTATS 2018, Table 1) and POT's other L2 paths (
reg_div="l2"is documented and implemented as half-squared), and it is bit-identical to callingot.smooth.smooth_ot_dual(a, b, M, reg=reg)directly.
Why this is not only a documentation/convention question
Even if one redefines the intended objective as docs/source/user_guide.rst), the current outputs are still not consistent: the returned plan is the half-squared minimizer, not the no-half minimizer, and the reported value is the no-half objective at that suboptimal plan. For the same instance:
- half-squared optimum:
1.7, atP=[[3/10,1/5],[1/5,3/10]](returned plan); - no-half optimum:
2.975 = 119/40, atP=[[11/40,9/40],[9/40,11/40]]; - reported value:
3.0, which is neither optimum (and is0.025above the no-half optimum).
So no convention makes both plan and value correct. The plan/value pair has to be made consistent one way or the other.
A note on visibility: when the two problems happen to share a minimizer (sparse/vertex plans, small regularization, or cost structures that force a vertex), the reported value can coincide numerically with the no-half optimum. That does not make the half-squared value correct: it is still larger than the half-squared optimum by exactly λ/2‖P‖², because sharing a plan does not mean the two objectives have the same value. In that regime the bug is hidden only if one interprets the value under the no-half convention; when regularization genuinely moves the plan, the no-half value is also visibly non-optimal.
A second exact instance isolates the value error from the convention question (there the two conventions happen to share the minimizer):
M=[[1,2],[3,4]], a=b=(1/2,1/2), reg=1
plan = 1/4 * ones(2,2) (correct)
true J_half = 21/8 = 2.625
reported value = 2.7500008 (+0.125 = reg/2*||plan||^2)
A third exact instance ($M=\begin{pmatrix}0&1\1&0\end{pmatrix}$, uniform marginals, reg=4) gives plan [[3/8,1/8],[1/8,3/8]], true optimum 7/8, reported 3/2, and the no-half optimum 23/16 = 1.4375; again 3/2 is neither.
Scope
ot.solve(..., reg_type="L2")and delegates:ot.solve_sample(..., reg_type="L2")andot.solve_bary_sample(..., reg_type="L2")(the latter also uses the value forstopping_criterion="loss"). Thesolve_sampleandsolve_bary_samplepropagation was verified in small runs; the barycenter support itself was unchanged because the BCD update uses the plans, while the reported loss is inflated.- Affected releases:
0.9.0through0.9.7.post1, plus master0.9.8.dev(the line was introduced in PR [MRG] New API for OT solver (with pre-computed ground cost matrix) #388, 2022-12-15). - Backends: verified on NumPy, PyTorch and JAX; the expression is backend-generic (TensorFlow/CuPy not tested here).
- The transport plans are not affected; only the reported value (and the barycenter loss criterion).
Documentation issue
docs/source/user_guide.rst defines the quadratic regularizer as ot.smooth.smooth_ot_dual, whose Omega() is ot.solve docstring writes
Suggested fix
Minimal, non-breaking:
- value = value_linear + reg * nx.sum(plan**2)
+ value = value_linear + 0.5 * reg * nx.sum(plan**2)plus a statement in the ot.solve/ot.solve_sample/ot.solve_bary_sample docstrings that value the true optimal value; it matches ot.smooth, the cited paper, and POT's "half-squared" unbalanced convention. I can open a PR with this change and a regression test (exact rational instances, NumPy/PyTorch/JAX) if maintainers agree.
The alternative — treating 2*reg to smooth_ot_dual — would change every L2 plan returned since 0.9.0 and break the current bit-identical agreement with ot.smooth.smooth_ot_dual. That looks like a versioned behavior change rather than a bug fix, so I would prefer to confirm the intended convention before touching the plan.
Relation to existing work
PR #895 (open) notes this as an “independent pre-existing issue” in its open questions but deliberately does not fix it. This issue is meant to track it separately and provide the exact counterexample and convention analysis. PR #874 (merged) is the precedent for fixing the reported value of an OT solver without changing plans.
Environment
Python 3.13.1, numpy 2.4.6, scipy 1.16.3, POT master 5cb82c9 (0.9.8.dev)
Plan and value independently checked with exact rational KKT enumeration (SymPy),
CVXPY (Clarabel), and Wolfram Language Minimize.
Activity
anveshachuri commentedon Nov 4, 2026
thanks for this clear write-up! i reproduced it and have the one-line fix ready locally with tests. since you offered to open a pr, i didn't want to step on your work. would you like to take it, or would it be helpful if i opened mine?
xihaian251 commentedon Nov 5, 2026
Thanks for flagging this before opening yours, and for reproducing it independently — that's appreciated.
I'm happy to take the formal PR since the issue is filed here, but I'd rather we don't duplicate the work, so two concrete things:
Would you share your test cases? I'll fold them in and credit you explicitly in the PR description. I'm converging on a property-based assertion rather than a hardcoded expected number:
res.valuemust equal the objective recomputed by the test from the returned plan(s), i.e.⟨M,P⟩ + (reg/2)‖P‖²— and forsolve_bary_sample,Σ_k w_kapplied tores.list_res. That way the test pins the mathematical contract instead of freezing one trajectory or an iteration count, and it fails on the current code while passing on the fix. If your assertions differ, I'd genuinely like to compare before I open it — yours may be cleaner.The convention still needs a maintainer's confirmation, and that's the part that gates the PR. Reading current master (
90484ae), the evidence is that half-squared is the house contract and:531is the lone outlier:ot/smooth.py:223—SquaredL2.Omega(T) = 0.5 * gamma * sum(T**2), andsmooth_ot_dualis called fromot/solvers/_linear.pywithgamma=reg(not2*reg);ot/solvers/_linear.py:489— the entropy branch reportsreg * sum(plan*log(plan+1e-16)), which is exactly its ownNegEntropy.Omegaatot/smooth.py:204. So one branch of the same function already reports its ownOmega, and only the L2 branch reports2 × Omega;_lbfgs.py:63(sum((G-c)**2)/2),_lbfgs.pymarg_l2, and_mm.py:204(reg * 0.5 * sum((G-c)**2)).If the maintainers confirm
Ω = (γ/2)‖T‖², the fix is the one line and plans are untouched. The alternative — treating‖T‖²as authoritative and passing2*regtosmooth_ot_dual— would change every L2 plan returned since 0.9.0, which reads as a versioned behaviour change rather than a bug fix, so I don't want to do that unilaterally.One thing worth surfacing before any of us opens a PR, because it affects how the change should be described: the reported value is not purely cosmetic for
solve_bary_sample. Withstopping_criterion="loss"that scalar is the BCD convergence signal (ot/solvers/_bary.py:149sumslist_res[k].value), so correcting it can move the stopping point. On one instance I measured the converged barycenter changing on the numpy backend at the defaulttol_bary=1e-5, while not moving on torch or jax and not moving at other tolerances either way. So whichever PR lands here should disclose that rather than present the change as having no user-visible effect.I'll open a draft once the convention is confirmed. Thanks again for the quick and gracious reply — sorry for the collision on this one.
anveshachuri commentedon Nov 5, 2026
sounds good, please take the pr! here are my cases, all with a = b = (0.5, 0.5). each expected value was worked out by hand:
your property-based check is nice because it isn't tied to one trajectory. i'd keep a few of these hardcoded values alongside it though: a test that recomputes the objective shares the same convention as the fix, so it can't catch a wrong convention, while a hand-derived optimum can (once the maintainers confirm the half-squared one). i ran these on numpy and torch, and i didn't check
solve_bary_sample, so your note about the stopping point is news to me. thanks for flagging it!Pin the L2 optimum to hand-derived values, drop unreachable skip hand…
xihaian251 commentedon Nov 5, 2026
All three of your cases check out — I re-derived them independently on the transport polytope rather than trusting either of us, and got the same plans and the same optima. For
a = b = (0.5, 0.5)the polytope is justP(t) = [[t, 1/2-t], [1/2-t, t]]on0 <= t <= 1/2,||P(t)||_F^2 = 4t^2 - 2t + 1/2, andd2J/dt2 = 4*reg > 0so the stationary point is the unique global min:M=[[0,1],[1,0]],reg=10→t* = 3/10,<M,P> = 2/5,||P||^2 = 13/50,J = 17/10✓M=[[0,1],[1,0]],reg=4→t* = 3/8,<M,P> = 1/4,||P||^2 = 5/16,J = 7/8✓M=[[1,2],[3,4]],reg=1→t* = 1/4,J = 21/8✓That third one is the interesting one:
<M,P(t)> = 5/2is constant along the whole segment, so the linear term contributes nothing to where the optimum is andt* = 1/4is set entirely by the norm term. Easy to get the right number for the wrong reason there.Your point about the property test is right and it's now in #914 — I've kept your three as a parametrized hand-computed test alongside it. One refinement on the mechanism worth raising, because it changes what the tests are allowed to claim: the hand values assume the half-squared convention too (
17/10vs119/40for the no-half optimum), so they don't detect a wrong convention either — only the maintainers can settle that, which is what the PR is asking. What your cases do buy is anchoring: the property test evaluates the objective at whatever plan comes back, so a wrong plan paired with a self-consistent value still satisfies it. Concretely, if the solver returned the no-half minimisert = 11/40with a matching value, the property test recomputes137/80and passes, while your17/10fails. I'd have left that hole in.Two things that fell out of checking your cases:
1e-5, not tighter — POT's reported value there is2.6250007324on the fixed code, the7.3e-7beingsmooth_ot_dual's own convergence error (visible invalue_linear, before any reporting formula). A1e-9assertion on a hand-derived constant would have failed a correct implementation.R(T) = sum T log Tfor"KL"and"entropy", butreg_type="KL"actually reports<M,P> + reg*KL(P || a b^T). Both names hit the same solver and return the same plan, and differ by the plan-independent constantreg*(sum a log a + sum b log b)— measured0.627741162610against predicted0.627741162589. So the KL value is correct as written; my docstring wasn't.The KL branch itself is not an analogue of this bug — its reported value is a genuine optimum under either reading of
KLwhen the marginals have unit mass. Two smaller inconsistencies did turn up (the documentedcreference measure is silently ignored by all three balanced regularized branches while the unbalanced ones honour it; andmass=Falsehere vsmass=Truein_lbfgs.reg_kldiverge once total mass isn't 1), but both look like questions about intent rather than arithmetic, so I'm not filing anything off a guess.Thanks again — the suggestion genuinely improved the test suite.