Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

First pass at a feasibility checker. #2466

Merged
merged 23 commits into from
Mar 22, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 63 additions & 4 deletions docs/src/manual/solutions.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ FEASIBLE_POINT::ResultStatusCode = 1
```
Other common returns are `MOI.NO_SOLUTION`, and `MOI.INFEASIBILITY_CERTIFICATE`.
The first means that the solver doesn't have a solution to return, and the
second means that the primal solution is a certificate of dual infeasbility (a
second means that the primal solution is a certificate of dual infeasbility (a
primal unbounded ray).

You can also use [`has_values`](@ref), which returns `true` if there is a
Expand Down Expand Up @@ -200,7 +200,7 @@ FEASIBLE_POINT::ResultStatusCode = 1
```
Other common returns are `MOI.NO_SOLUTION`, and `MOI.INFEASIBILITY_CERTIFICATE`.
The first means that the solver doesn't have a solution to return, and the
second means that the dual solution is a certificate of primal infeasbility (a
second means that the dual solution is a certificate of primal infeasbility (a
dual unbounded ray).

You can also use [`has_duals`](@ref), which returns `true` if there is a
Expand Down Expand Up @@ -258,7 +258,7 @@ And data, a 2-element Array{Float64,1}:

## Recommended workflow

The recommended workflow for solving a model and querying the solution is
The recommended workflow for solving a model and querying the solution is
something like the following:
```jldoctest solutions
if termination_status(model) == MOI.OPTIMAL
Expand Down Expand Up @@ -427,7 +427,7 @@ For instance, this is how you can use this functionality:

```julia
using JuMP
model = Model() # You must use a solver that supports conflict refining/IIS
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)
Expand Down Expand Up @@ -489,3 +489,62 @@ for i in 2:result_count(model)
end
end
```

## Checking feasibility of solutions

To check the feasibility of a primal solution, use
[`primal_feasibility_report`](@ref), which takes a `model`, a dictionary mapping
each variable to a primal solution value (defaults to the last solved solution),
and a tolerance `atol` (defaults to `0.0`).

The function returns a dictionary which maps the infeasible constraint
references to the distance between the primal value of the constraint and the
nearest point in the corresponding set. A point is classed as infeasible if the
distance is greater than the supplied tolerance `atol`.

```@meta
# Add a filter here because the output of the dictionary is not ordered, and
# changes in printing order will cause the doctest to fail.
```
```jldoctest feasibility; filter=[r"x.+?\=\> 0.1", r"c1.+? \=\> 0.01"]
julia> model = Model(GLPK.Optimizer);

julia> @variable(model, x >= 1, Int);

julia> @variable(model, y);

julia> @constraint(model, c1, x + y <= 1.95);

julia> point = Dict(x => 1.9, y => 0.06);

julia> primal_feasibility_report(model, point)
Dict{Any,Float64} with 2 entries:
c1 : x + y ≤ 1.95 => 0.01
x integer => 0.1

julia> primal_feasibility_report(model, point; atol = 0.02)
Dict{Any,Float64} with 1 entry:
x integer => 0.1
```

If the point is feasible, an empty dictionary is returned:
```jldoctest feasibility
julia> primal_feasibility_report(model, Dict(x => 1.0, y => 0.0))
Dict{Any,Float64} with 0 entries
```

To use the primal solution from a solve, omit the `point` argument:
```jldoctest feasibility
julia> optimize!(model)

julia> primal_feasibility_report(model)
Dict{Any,Float64} with 0 entries
```

Pass `skip_mising = true` to skip constraints which contain variables that are
not in `point`:
```jldoctest feasibility
julia> primal_feasibility_report(model, Dict(x => 2.1); skip_missing = true)
Dict{Any,Float64} with 1 entry:
x integer => 0.1
```
6 changes: 6 additions & 0 deletions docs/src/reference/solutions.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,9 @@ SensitivityReport
lp_objective_perturbation_range
lp_rhs_perturbation_range
```

## Feasibility

```@docs
primal_feasibility_report
```
1 change: 1 addition & 0 deletions src/JuMP.jl
Original file line number Diff line number Diff line change
Expand Up @@ -1281,6 +1281,7 @@ include("lp_sensitivity.jl")
include("lp_sensitivity2.jl")
include("callbacks.jl")
include("file_formats.jl")
include("feasibility_checker.jl")

# JuMP exports everything except internal symbols, which are defined as those
# whose name starts with an underscore. Macros whose names start with
Expand Down
212 changes: 212 additions & 0 deletions src/feasibility_checker.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
# Copyright 2017, Iain Dunning, Joey Huchette, Miles Lubin, and contributors
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.

using LinearAlgebra

function _last_primal_solution(model::Model)
if !has_values(model)
error(
"No primal solution is available. You must provide a point at " *
"which to check feasibility.",
)
end
return Dict(v => value(v) for v in all_variables(model))
end

"""
primal_feasibility_report(
model::Model,
point::AbstractDict{VariableRef,Float64} = _last_primal_solution(model),
atol::Float64 = 0.0,
skip_missing::Bool = false,
)::Dict{Any,Float64}

Given a dictionary `point`, which maps variables to primal values, return a
dictionary whose keys are the constraints with an infeasibility greater than the
supplied tolerance `atol`. The value corresponding to each key is the respective
infeasibilility. Infeasibility is defined as the distance between the primal
value of the constraint (see `MOI.ConstraintPrimal`) and the nearest point by
Euclidean distance in the corresponding set.

## Notes

* If `skip_missing = true`, constraints containing variables that are not in
`point` will be ignored.
* If `skip_missing = false` and a partial primal solution is provided, an error
will be thrown.
* If no point is provided, the primal solution from the last time the model was
solved is used.

## Examples

```jldoctest; setup=:(using JuMP)
julia> model = Model();

julia> @variable(model, 0.5 <= x <= 1);

julia> primal_feasibility_report(model, Dict(x => 0.2))
Dict{Any,Float64} with 1 entry:
x ≥ 0.5 => 0.3
```
"""
function primal_feasibility_report(
model::Model,
point::AbstractDict{VariableRef,Float64} = _last_primal_solution(model);
atol::Float64 = 0.0,
skip_missing::Bool = false,
)
function point_f(x::VariableRef)
fx = get(point, x, missing)
if ismissing(fx) && !skip_missing
error(
"point does not contain a value for variable $x. Provide a " *
"value, or pass `skip_missing = true`.",
)
end
return fx
end
violated_constraints = Dict{Any,Float64}()
for (F, S) in list_of_constraint_types(model)
_add_infeasible_constraints(
model,
F,
S,
violated_constraints,
point_f,
atol,
)
end
if num_nl_constraints(model) > 0
if skip_missing
error(
"`skip_missing = true` is not allowed when nonlinear " *
"constraints are present.",
)
end
_add_infeasible_nonlinear_constraints(
model,
violated_constraints,
point_f,
atol,
)
end
return violated_constraints
end

function _add_infeasible_constraints(
model::Model,
::Type{F},
::Type{S},
violated_constraints::Dict{Any,Float64},
point_f::Function,
atol::Float64,
) where {F,S}
for con in all_constraints(model, F, S)
obj = constraint_object(con)
d = _distance_to_set(value.(obj.func, point_f), obj.set)
if d > atol
violated_constraints[con] = d
end
end
return
end

function _add_infeasible_nonlinear_constraints(
model::Model,
violated_constraints::Dict{Any,Float64},
point_f::Function,
atol::Float64,
)
evaluator = NLPEvaluator(model)
MOI.initialize(evaluator, Symbol[])
g = zeros(num_nl_constraints(model))
MOI.eval_constraint(evaluator, g, point_f.(all_variables(model)))
for (i, con) in enumerate(model.nlp_data.nlconstr)
d = max(0.0, con.lb - g[i], g[i] - con.ub)
if d > atol
cref =
ConstraintRef(model, NonlinearConstraintIndex(i), ScalarShape())
violated_constraints[cref] = d
end
end
return
end

function _distance_to_set(::Any, set::MOI.AbstractSet)
return error(
"Feasibility checker for set type $(typeof(set)) has not been " *
"implemented yet.",
)
end

_distance_to_set(::Missing, ::MOI.AbstractSet) = 0.0

###
### MOI.AbstractScalarSets
###

function _distance_to_set(x::T, set::MOI.LessThan{T}) where {T<:Real}
return max(x - set.upper, zero(T))
end

function _distance_to_set(x::T, set::MOI.GreaterThan{T}) where {T<:Real}
return max(set.lower - x, zero(T))
end

function _distance_to_set(x::T, set::MOI.EqualTo{T}) where {T<:Number}
return abs(set.value - x)
end

function _distance_to_set(x::T, set::MOI.Interval{T}) where {T<:Real}
return max(x - set.upper, set.lower - x, zero(T))
end

function _distance_to_set(x::T, ::MOI.ZeroOne) where {T<:Real}
return min(abs(x - zero(T)), abs(x - one(T)))
end

function _distance_to_set(x::T, ::MOI.Integer) where {T<:Real}
return abs(x - round(x))
end

function _distance_to_set(x::T, set::MOI.Semicontinuous{T}) where {T<:Real}
return min(max(x - set.upper, set.lower - x, zero(T)), abs(x))
end

function _distance_to_set(x::T, set::MOI.Semiinteger{T}) where {T<:Real}
d = max(ceil(set.lower) - x, x - floor(set.upper), abs(x - round(x)))
return min(d, abs(x))
end

###
### MOI.AbstractVectorSets
###

function _check_dimension(v::AbstractVector, s)
if length(v) != MOI.dimension(s)
throw(DimensionMismatch("Mismatch between value and set"))
end
return
end

function _distance_to_set(x::Vector{T}, set::MOI.Nonnegatives) where {T<:Real}
_check_dimension(x, set)
return LinearAlgebra.norm(max(-xi, zero(T)) for xi in x)
end

function _distance_to_set(x::Vector{T}, set::MOI.Nonpositives) where {T<:Real}
_check_dimension(x, set)
return LinearAlgebra.norm(max(xi, zero(T)) for xi in x)
end

function _distance_to_set(x::Vector{T}, set::MOI.Zeros) where {T<:Number}
_check_dimension(x, set)
return LinearAlgebra.norm(x)
end

function _distance_to_set(x::Vector{T}, set::MOI.Reals) where {T<:Real}
_check_dimension(x, set)
return zero(T)
end
Loading