Querying Solutions
So far we have seen all the elements and constructs related to writing a JuMP optimization model. In this section we reach the point of what to do with a solved problem. Suppose your model is named model
. Right after the call to optimize!(model)
, it's natural to ask JuMP questions about the finished optimization step. Typical questions include:
- Why has the optimization process stopped? Did it hit the time limit or run into numerical issues?
- Do I have a solution to my problem?
- Is it optimal?
- Do I have a dual solution?
- How sensitive is the solution to data perturbations?
JuMP follows closely the concepts defined in MathOptInterface (MOI) to answer user questions about a finished call to optimize!(model)
. There are three main steps in querying a solution:
First, we can query the termination_status
which will tell us why the optimization stopped. This could be due to a number of reasons. For example, the solver found an optimal solution, the problem was proven to be infeasible, or a user-provided limit such as a time limit was encountered. For more information, see the Termination statuses section below.
Second, we can query the primal_status
and the dual_status
, which will tell us what kind of results we have for our primal and dual solutions. This might be an optimal primal-dual pair, a primal solution without a corresponding dual solution, or a certificate of primal or dual infeasibility. For more information, see the Solution statuses section below.
Third, we can query value
and dual
to obtain the primal and dual values of the optimization variables and constraints (if there are values to be queried).
Termination statuses
The reason why the optimization of model
was finished is given by
termination_status(model)
This function will return a MOI.TerminationStatusCode
enum
. Common return values include MOI.OPTIMAL
, MOI.INFEASIBLE
, MOI.DUAL_INFEASIBLE
, and MOI.TIME_LIMIT
.
Note that a return status of MOI.DUAL_INFEASIBLE
does not guarantee that the primal is unbounded. When the dual is infeasible, the primal is unbounded if there exists a feasible primal solution.
We can receive a solver specific string explaining why the optimization stopped with raw_status
.
Solution statuses
These statuses indicate what kind of result is available to be queried with value
and dual
. It's possible that no result is available to be queried.
We can obtain these statuses by calling primal_status
for the primal status, and dual_status
for the dual status. Both will return a MOI.ResultStatusCode
enum
.
Common status situations are described in the MOI docs.
Obtaining solutions
Provided the primal status is not MOI.NO_SOLUTION
, the primal solution can be obtained by calling value
. For the dual solution, the function is dual
. Calling has_values
for the primal status and has_duals
for the dual solution is an equivalent way to check whether the status is MOI.NO_SOLUTION
.
It is important to note that if has_values
or has_duals
return false, calls to value
and dual
might throw an error or return arbitrary values.
The container type (e.g., scalar, vector, or matrix) of the returned solution (primal or dual) depends on the type of the variable or constraint. See AbstractShape
and dual_shape
for details.
To call value
or dual
on containers of VariableRef
or ConstraintRef
, use the broadcast syntax, e.g., value.(x)
.
The objective value of a solved problem can be obtained via objective_value
. The best known bound on the optimal objective value can be obtained via objective_bound
. If the solver supports it, the value of the dual objective can be obtained via dual_objective_value
.
The following is a recommended workflow for solving a model and querying the solution:
using JuMP
model = Model()
@variable(model, x[1:10] >= 0)
# ... other constraints ...
optimize!(model)
if termination_status(model) == MOI.OPTIMAL
optimal_solution = value.(x)
optimal_objective = objective_value(model)
elseif termination_status(model) == MOI.TIME_LIMIT && has_values(model)
suboptimal_solution = value.(x)
suboptimal_objective = objective_value(model)
else
error("The model was not solved correctly.")
end
Querying solution information after modifying a solved model is undefined behavior, and solvers may throw an error or return incorrect results. Modifications include adding, deleting, or modifying any variable, objective, or constraint. Instead of modify then query, query the results first, then modify the problem. For example:
model = Model(GLPK.Optimizer)
@variable(model, x >= 0)
optimize!(model)
# Bad:
set_lower_bound(x, 1)
@show value(x)
# Good:
x_val = value(x)
set_lower_bound(x, 1)
@show x_val
Accessing MathOptInterface attributes
MathOptInterface defines a large number of model attributes that can be queried. Examples include MOI.RelativeGap
and MOI.SimplexIterations
.
Some attributes can be directly accessed by getter functions. These include
To query these attributes, use:
using JuMP
model = Model()
# ...
optimize!(model)
@show relative_gap(model)
# or
@show MOI.get(model, MOI.RelativeGap())
@show simplex_iterations(model)
# or
@show MOI.get(model, MOI.SimplexIterations())
Sensitivity analysis for LP
Given an LP problem and an optimal solution corresponding to a basis, we can question how much an objective coefficient or standard form right-hand side coefficient (c.f., normalized_rhs
) can change without violating primal or dual feasibility of the basic solution.
Note that not all solvers compute the basis, and for sensitivity analysis, the solver interface must implement MOI.ConstraintBasisStatus
.
To give a simple example, we could analyze the sensitivity of the optimal solution to the following (non-degenerate) LP problem:
model = Model();
@variable(model, x[1:2])
set_lower_bound(x[2], -0.5)
set_upper_bound(x[2], 0.5)
@constraint(model, c1, x[1] + x[2] <= 1);
@constraint(model, c2, x[1] - x[2] <= 1);
@objective(model, Max, x[1]);
To analyze the sensitivity of the problem we could check the allowed perturbation ranges of, e.g., the cost coefficients and the right-hand side coefficient of the constraint c1
as follows:
julia> optimize!(model)
julia> value.(x)
2-element Array{Float64,1}:
1.0
0.0
julia> report = lp_sensitivity_report(model);
julia> x1_lo, x1_hi = report[x[1]]
(-1.0, Inf)
julia> println("The objective coefficient of x[1] could decrease by $(x1_lo) or increase by $(x1_hi).")
The objective coefficient of x[1] could decrease by -1.0 or increase by Inf.
julia> x2_lo, x2_hi = report[x[2]]
(-1.0, 1.0)
julia> println("The objective coefficient of x[2] could decrease by $(x2_lo) or increase by $(x2_hi).")
The objective coefficient of x[2] could decrease by -1.0 or increase by 1.0.
julia> c_lo, c_hi = report[c1]
(-1.0, 1.0)
julia> println("The RHS of c1 could decrease by $(c_lo) or increase by $(c_hi).")
The RHS of c1 could decrease by -1.0 or increase by 1.0.
The range associated with a variable is the range of the allowed perturbation of the corresponding objective coefficient. Note that the current primal solution remains optimal within this range; however the corresponding dual solution might change since a cost coefficient is perturbed. Similarly, the range associated with a constraint is the range of the allowed perturbation of the corresponding right-hand side coefficient. In this range the current dual solution remains optimal, but the optimal primal solution might change.
If the problem is degenerate, there are multiple optimal bases and hence these ranges might not be as intuitive and seem too narrow. E.g., a larger cost coefficient perturbation might not invalidate the optimality of the current primal solution. Moreover, if a problem is degenerate, due to finite precision, it can happen that, e.g., a perturbation seems to invalidate a basis even though it doesn't (again providing too narrow ranges). To prevent this, increase the atol
keyword argument to lp_sensitivity_report
. Note that this might make the ranges too wide for numerically challenging instances. Thus, do not blindly trust these ranges, especially not for highly degenerate or numerically unstable instances.
Conflicts
When the model you input is infeasible, some solvers can help you find the cause of this infeasibility by offering a conflict, i.e., a subset of the constraints that create this infeasibility. Depending on the solver, this can also be called an IIS (irreducible inconsistent subsystem).
The function compute_conflict!
is used to trigger the computation of a conflict. Once this process is finished, the attribute MOI.ConflictStatus
returns a MOI.ConflictStatusCode
.
If there is a conflict, you can query from each constraint whether it participates in the conflict or not using the attribute MOI.ConstraintConflictStatus
, which returns a MOI.ConflictParticipationStatusCode
.
For instance, this is how you can use this functionality:
using JuMP
model = Model() # You must use a solver that supports conflict refining/IIS computation, like CPLEX or Gurobi
@variable(model, x >= 0)
@constraint(model, c1, x >= 2)
@constraint(model, c2, x <= 1)
optimize!(model)
# termination_status(model) will likely be MOI.INFEASIBLE,
# depending on the solver
compute_conflict!(model)
if MOI.get(model, MOI.ConflictStatus()) != MOI.CONFLICT_FOUND
error("No conflict could be found for an infeasible model.")
end
# Both constraints should participate in the conflict.
MOI.get(model, MOI.ConstraintConflictStatus(), c1)
MOI.get(model, MOI.ConstraintConflictStatus(), c2)
Multiple solutions
Some solvers support returning multiple solutions. You can check how many solutions are available to query using result_count
.
Functions for querying the solutions, e.g., primal_status
and value
, all take an additional keyword argument result
which can be used to specify which result to return.
using JuMP
model = Model()
@variable(model, x[1:10] >= 0)
# ... other constraints ...
optimize!(model)
if termination_status(model) != MOI.OPTIMAL
error("The model was not solved correctly.")
end
num_results = result_count(model)
@assert has_values(model; result = num_results)
an_optimal_solution = value.(x; result = num_results)
an_optimal_objective = objective_value(model; result = num_results)