Skip to content

Multiplier Convention ​

CommonSolve.solve(nlp, solver) returns the multipliers of the NLP in the statistics: multipliers (one per constraint row lcon ≤ c(x) ≤ ucon), multipliers_L and multipliers_U (one per variable, for the bounds lvar ≤ x ≤ uvar). The NLP solvers do not agree on their signs. CTSolvers brings them to one convention, the same for every solver, so that the duals of an optimal control problem do not depend on the solver, nor on the way the problem is written.

The convention is the one of the whole ecosystem, stated in ct-conventions, SGN1–SGN6 (discussed in OptimalControl.jl#1014, implemented in CTSolvers.jl#288).

The convention ​

A multiplier belongs to a constraint and to a problem, not to the way they are written.

  1. min f and max -f are the same problem: they have the same multipliers. A maximisation max F is treated as the minimisation min -F (SGN1).

  2. Inequalities have nonnegative multipliers. The bound multipliers zL and zU are nonnegative, and so is the multiplier of a constraint row with a single finite bound: g(x) ≤ 0 and -g(x) ≥ 0 are the same constraint, with the same multiplier (SGN2).

  3. An equality keeps its sign, the one of the stationarity below: c(x) = 0 and -c(x) = 0 have opposite multipliers, as they must, since an equality has no preferred side (SGN3).

For the equivalent minimisation of F_min (F_min = F for min F, -F for max F):

where y = μU − μL on a row, with μL, μU ≥ 0 the multipliers of its lower and upper side. In the statistics returned by CTSolvers:

Row or boundReturned multiplier
c(x) ≤ u (only an upper bound)μU ≥ 0
c(x) ≥ l (only a lower bound)μL ≥ 0
c(x) = b (equality)y, signed
l ≤ c(x) ≤ u (two finite bounds)y = μU − μL: ≥ 0 when the upper side is active, ≤ 0 when the lower side is
x ≥ lvar, x ≤ uvarzL ≥ 0, zU ≥ 0

Two-sided rows

For a row with two finite bounds, the signed value y = μU − μL is kept: at most one side is active, and its sign tells which. Splitting it into a pair (μL, μU) would need another field in the statistics.

Example: eight ways of writing the same problem ​

The problem of the sign probe (probe/signs/ in the repository): an equality, an inequality, an upper bound written as a bound or as a row, an active lower bound, and a fixed variable. Its solution and multipliers are known in closed form.

julia
using CTSolvers: Solvers
using CommonSolve: solve
using ADNLPModels: ADNLPModel
using NLPModelsIpopt, MadNLP, UnoSolver

f(x) = (x[1] - 3)^2 + (x[2] - 1)^2 - x[3] + x[4] + 2x[5]
c(x) = x[1] + x[2] - 3            # = 0
g(x) = x[1]^2 + x[2]^2 - 6        # ≤ 0, active at the solution

function nlp(; sense, ineq)
    sg = ineq === :le ? 1.0 : -1.0                  # g ≤ 0, or -g ≥ 0
    lg, ug = ineq === :le ? (-Inf, 0.0) : (0.0, Inf)
    return ADNLPModel(
        sense === :min ? f : x -> -f(x),
        [1.0, 1.0, 0.0, 0.5, 0.5],
        [0.0, 0.0, -5.0, 0.0, 0.5], [Inf, Inf, 1.0, 5.0, 0.5],   # x₃ ≤ 1, x₄ ≥ 0, x₅ = 1/2
        x -> [c(x), sg * g(x)], [0.0, lg], [0.0, ug];
        minimize=(sense === :min),
    )
end

solvers = [
    Solvers.Ipopt(; print_level=0, tol=1e-10),
    Solvers.MadNLP(; tol=1e-10),
    Solvers.Uno(; primal_tolerance=1e-10, dual_tolerance=1e-10),
]
rows = []
for solver in solvers, sense in (:min, :max), ineq in (:le, :ge)
    s = solve(nlp(; sense, ineq), solver; display=false)
    push!(rows, (
        solver=nameof(typeof(solver)), sense, ineq,
        y=round.(s.multipliers; digits=4),
        zU3=round(s.multipliers_U[3]; digits=4),
        zL4=round(s.multipliers_L[4]; digits=4),
    ))
end
rows
12-element Vector{Any}:
 (solver = :Ipopt, sense = :min, ineq = :le, y = [0.5359, 0.1547], zU3 = 1.0, zL4 = 1.0)
 (solver = :Ipopt, sense = :min, ineq = :ge, y = [0.5359, 0.1547], zU3 = 1.0, zL4 = 1.0)
 (solver = :Ipopt, sense = :max, ineq = :le, y = [0.5359, 0.1547], zU3 = 1.0, zL4 = 1.0)
 (solver = :Ipopt, sense = :max, ineq = :ge, y = [0.5359, 0.1547], zU3 = 1.0, zL4 = 1.0)
 (solver = :MadNLP, sense = :min, ineq = :le, y = [0.5359, 0.1547], zU3 = 1.0, zL4 = 1.0)
 (solver = :MadNLP, sense = :min, ineq = :ge, y = [0.5359, 0.1547], zU3 = 1.0, zL4 = 1.0)
 (solver = :MadNLP, sense = :max, ineq = :le, y = [0.5359, 0.1547], zU3 = 1.0, zL4 = 1.0)
 (solver = :MadNLP, sense = :max, ineq = :ge, y = [0.5359, 0.1547], zU3 = 1.0, zL4 = 1.0)
 (solver = :Uno, sense = :min, ineq = :le, y = [0.5359, 0.1547], zU3 = 1.0, zL4 = 1.0)
 (solver = :Uno, sense = :min, ineq = :ge, y = [0.5359, 0.1547], zU3 = 1.0, zL4 = 1.0)
 (solver = :Uno, sense = :max, ineq = :le, y = [0.5359, 0.1547], zU3 = 1.0, zL4 = 1.0)
 (solver = :Uno, sense = :max, ineq = :ge, y = [0.5359, 0.1547], zU3 = 1.0, zL4 = 1.0)

Every line gives the same numbers: y = (0.5359, 0.1547) for the equality and the inequality, zU = 1 on x₃, zL = 1 on x₄.

What each solver returns natively ​

CTSolvers applies Solvers.normalize_multipliers! at the end of each solve. It calls Solvers.reference_multipliers!, one method per solver, then makes the multiplier of every one-sided lower row nonnegative. The native conventions, measured by the tests of CTSolvers on every way of writing the problem above (NLPModelsIpopt 0.11, MadNLP 0.10, MadNCL 0.2, UnoSolver 0.3):

SolverStationarity (min)max FChange applied
Ipopt∇F + Jᵀy − zL + zU = 0multipliers of F as written: all change signnegate everything for max
Uno∇F + Jᵀy + zL + zU = 0 (zL ≤ 0)as written: all change signnegate zL; negate everything for max
MadNLP, MadNCL∇F + Jᵀy − zL + zU = 0rows as written, bounds of the minimisationnegate y for max
Knitro∇F + Jᵀλᶜ + λᵇ = 0, F as writtenas writtenswap zL and zU for min; negate y for max

Knitro is not tested

Knitro needs a license, so its convention is derived, not measured: from its manual (stationarity with the objective as written, λ ≥ 0 on an active upper side, λ ≤ 0 on an active lower side) and from NLPModelsKnitro, which maps the bound multiplier λᵇ = zU − zL as zL = max(λᵇ, 0), zU = −min(λᵇ, 0) (its own test test_maximize checks zU = 1 for max x with x ≤ 1 active). Please report a mismatch.

The tests pin these native conventions separately from the normalised output: if a solver changes its convention upstream, the test of its native convention fails and names the solver and its version; if only the normalised output is wrong, the bug is in CTSolvers.

Raw multipliers ​

Every solver has the option multipliers: :normalized (the default) returns the convention above, :native the raw multipliers of the backend, in its own convention. The option is consumed by CTSolvers, never passed to the backend.

julia
s = solve(nlp(; sense=:max, ineq=:ge), Solvers.Ipopt(; print_level=0, multipliers=:native);
    display=false)
round.(s.multipliers; digits=4)    # Ipopt, raw: the multipliers of -f as written, with y = μU − μL
2-element Vector{Float64}:
 -0.5359
  0.1547

Compared with the normalised (0.5359, 0.1547): the equality changes sign (Ipopt returns the multipliers of the objective as written, -f), and the inequality, changed twice (by the sense, and by the row -g ≥ 0, a lower side), comes back to +0.1547.

Through OptimalControl, the option is given to solve like any other solver option.

Downstream ​

  • Optimal control (CTDirect): the duals of the discretised problem are built from these multipliers, so dual(sol, ocp, :label) has the same sign for min and max, and a path constraint with a single bound has a nonnegative dual.

  • Indirect methods (CTFlows): Flow(ocp, law; constraint=:label, multiplier=μ) returns the same duals, built from μ (see the CTFlows documentation, Constraint duals of a flow).