Repository navigation
ot.solve(..., reg_type="L2") reports a value that is not the optimum of the solved problem #896
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