Skip to content

ot.solve(..., reg_type="L2") reports a value that is not the optimum of the solved problem #896

Open

Description

@xihaian251

Summary

For balanced quadratic-regularized OT, ot.solve returns the optimal plan of

$$ \min_{P\ge 0}\ \langle M,P\rangle+\frac{\lambda}{2}|P|_F^2 \quad\text{s.t.}\quad P\mathbf 1=a,\ P^\top \mathbf 1=b, $$

but reports

$$ \texttt{res.value}=\langle M,P\rangle+\lambda|P|_F^2 =J(P)+\frac{\lambda}{2}|P|_F^2 . $$

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||^2

Actual: 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.Omega defines $\Omega(T)=\frac{\gamma}{2}|T|^2$; delta_Omega(X)=\sum[\max(X,0)]^2/(2\gamma), and get_plan_from_dual returns $P=[\alpha+\beta-M]_+/\gamma$. With gamma=reg, the solver called by ot.solve minimizes the half-squared primal.
  • On the support of the returned plan, $\alpha_i+\beta_j=M_{ij}+\lambda P_{ij}$, hence

$$ a^\top\alpha+b^\top\beta=\langle M,P\rangle+\lambda|P|^2=\texttt{res.value}. $$

In other words, the reported value is the affine part of the L2 dual; the conjugate term $\sum_j\delta_\Omega(X_{:j})=\frac{\lambda}{2}|P|^2$ is omitted. The dual objective itself is 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 calling ot.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 $\langle M,P\rangle+\lambda|P|^2$ (the no-half formula currently in 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, at P=[[3/10,1/5],[1/5,3/10]] (returned plan);
  • no-half optimum: 2.975 = 119/40, at P=[[11/40,9/40],[9/40,11/40]];
  • reported value: 3.0, which is neither optimum (and is 0.025 above 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") and ot.solve_bary_sample(..., reg_type="L2") (the latter also uses the value for stopping_criterion="loss"). The solve_sample and solve_bary_sample propagation 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.0 through 0.9.7.post1, plus master 0.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 $\Omega(\gamma)=\sum\gamma^2$ and points the reader to ot.smooth.smooth_ot_dual, whose Omega() is $\frac{\gamma}{2}\sum\gamma^2$. The ot.solve docstring writes $\lambda R(T)$ without defining $R$. This contradiction is likely why the value line survived four years: the solver and the value line encode two different conventions.

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 $R(T)=\frac12|T|_F^2$, and a correction of the user-guide formula to $\Omega(\gamma)=\frac12\sum\gamma^2$. This keeps every plan unchanged and makes 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 $\Omega=\sum\gamma^2$ as authoritative and passing 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

  1. anveshachuri commented on Nov 4, 2026

    @anveshachuri

    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?

  2. xihaian251 commented on Nov 5, 2026

    @xihaian251
    ContributorAuthor

    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:

    1. 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.value must equal the objective recomputed by the test from the returned plan(s), i.e. ⟨M,P⟩ + (reg/2)‖P‖² — and for solve_bary_sample, Σ_k w_k applied to res.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.

    2. 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 :531 is the lone outlier:

      • ot/smooth.py:223 — SquaredL2.Omega(T) = 0.5 * gamma * sum(T**2), and smooth_ot_dual is called from ot/solvers/_linear.py with gamma=reg (not 2*reg);
      • ot/solvers/_linear.py:489 — the entropy branch reports reg * sum(plan*log(plan+1e-16)), which is exactly its own NegEntropy.Omega at ot/smooth.py:204. So one branch of the same function already reports its own Omega, and only the L2 branch reports 2 × Omega;
      • the other quadratic regularizers in POT all carry the ½: _lbfgs.py:63 (sum((G-c)**2)/2), _lbfgs.py marg_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 passing 2*reg to smooth_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. With stopping_criterion="loss" that scalar is the BCD convergence signal (ot/solvers/_bary.py:149 sums list_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 default tol_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.

  3. anveshachuri commented on Nov 5, 2026

    @anveshachuri

    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:

    M reg plan optimal value
    [[0, 1], [1, 0]] 10 [[0.3, 0.2], [0.2, 0.3]] 1.7
    [[0, 1], [1, 0]] 4 [[0.375, 0.125], [0.125, 0.375]] 0.875
    [[1, 2], [3, 4]] 1 [[0.25, 0.25], [0.25, 0.25]] 2.625

    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!

  4. xihaian251 commented on Nov 5, 2026

    @xihaian251
    ContributorAuthor

    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 just P(t) = [[t, 1/2-t], [1/2-t, t]] on 0 <= t <= 1/2, ||P(t)||_F^2 = 4t^2 - 2t + 1/2, and d2J/dt2 = 4*reg > 0 so 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/2 is constant along the whole segment, so the linear term contributes nothing to where the optimum is and t* = 1/4 is 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/10 vs 119/40 for 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 minimiser t = 11/40 with a matching value, the property test recomputes 137/80 and passes, while your 17/10 fails. I'd have left that hole in.

    Two things that fell out of checking your cases:

    1. Your third instance is where I found the tolerance has to be 1e-5, not tighter — POT's reported value there is 2.6250007324 on the fixed code, the 7.3e-7 being smooth_ot_dual's own convergence error (visible in value_linear, before any reporting formula). A 1e-9 assertion on a hand-derived constant would have failed a correct implementation.
    2. Auditing the neighbouring KL branch to make sure I wasn't repeating the same class of mistake caught an error in my own PR: I had written R(T) = sum T log T for "KL" and "entropy", but reg_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 constant reg*(sum a log a + sum b log b) — measured 0.627741162610 against predicted 0.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 KL when the marginals have unit mass. Two smaller inconsistencies did turn up (the documented c reference measure is silently ignored by all three balanced regularized branches while the unbalanced ones honour it; and mass=False here vs mass=True in _lbfgs.reg_kl diverge 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions