diff --git a/docs/src/manual/solutions.md b/docs/src/manual/solutions.md index bed1c878bf0..8010c84b017 100644 --- a/docs/src/manual/solutions.md +++ b/docs/src/manual/solutions.md @@ -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 @@ -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 @@ -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 @@ -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) @@ -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 +``` diff --git a/docs/src/reference/solutions.md b/docs/src/reference/solutions.md index a9bcadb6d3a..c1b559d1f74 100644 --- a/docs/src/reference/solutions.md +++ b/docs/src/reference/solutions.md @@ -72,3 +72,9 @@ SensitivityReport lp_objective_perturbation_range lp_rhs_perturbation_range ``` + +## Feasibility + +```@docs +primal_feasibility_report +``` diff --git a/src/JuMP.jl b/src/JuMP.jl index 4c097356790..19f3b84f268 100644 --- a/src/JuMP.jl +++ b/src/JuMP.jl @@ -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 diff --git a/src/feasibility_checker.jl b/src/feasibility_checker.jl new file mode 100644 index 00000000000..b99fb1a3acd --- /dev/null +++ b/src/feasibility_checker.jl @@ -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 diff --git a/test/feasibility_checker.jl b/test/feasibility_checker.jl new file mode 100644 index 00000000000..cbe473efd5e --- /dev/null +++ b/test/feasibility_checker.jl @@ -0,0 +1,271 @@ +# 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/. + +module TestFeasibilityChecker + +using JuMP +using Test + +function test_unsupported() + @test_throws( + ErrorException, + JuMP._distance_to_set([1.0, 1.0], MOI.Complements(1)), + ) +end + +function test_lessthan() + @test JuMP._distance_to_set(1.0, MOI.LessThan(2.0)) ≈ 0.0 + @test JuMP._distance_to_set(1.0, MOI.LessThan(0.5)) ≈ 0.5 +end + +function test_greaterthan() + @test JuMP._distance_to_set(1.0, MOI.GreaterThan(2.0)) ≈ 1.0 + @test JuMP._distance_to_set(1.0, MOI.GreaterThan(0.5)) ≈ 0.0 +end + +function test_equalto() + @test JuMP._distance_to_set(1.0, MOI.EqualTo(2.0)) ≈ 1.0 + @test JuMP._distance_to_set(1.0, MOI.EqualTo(0.5)) ≈ 0.5 +end + +function test_interval() + @test JuMP._distance_to_set(1.0, MOI.Interval(1.0, 2.0)) ≈ 0.0 + @test JuMP._distance_to_set(0.5, MOI.Interval(1.0, 2.0)) ≈ 0.5 + @test JuMP._distance_to_set(2.75, MOI.Interval(1.0, 2.0)) ≈ 0.75 +end + +function test_zeroone() + @test JuMP._distance_to_set(0.6, MOI.ZeroOne()) ≈ 0.4 + @test JuMP._distance_to_set(-0.01, MOI.ZeroOne()) ≈ 0.01 + @test JuMP._distance_to_set(1.01, MOI.ZeroOne()) ≈ 0.01 +end + +function test_integer() + @test JuMP._distance_to_set(0.6, MOI.Integer()) ≈ 0.4 + @test JuMP._distance_to_set(3.1, MOI.Integer()) ≈ 0.1 + @test JuMP._distance_to_set(-0.01, MOI.Integer()) ≈ 0.01 + @test JuMP._distance_to_set(1.01, MOI.Integer()) ≈ 0.01 +end + +function test_semicontinuous() + s = MOI.Semicontinuous(2.0, 4.0) + @test JuMP._distance_to_set(-2.0, s) ≈ 2.0 + @test JuMP._distance_to_set(0.5, s) ≈ 0.5 + @test JuMP._distance_to_set(1.9, s) ≈ 0.1 + @test JuMP._distance_to_set(2.1, s) ≈ 0.0 + @test JuMP._distance_to_set(4.1, s) ≈ 0.1 +end + +function test_semiintger() + s = MOI.Semiinteger(1.9, 4.0) + @test JuMP._distance_to_set(-2.0, s) ≈ 2.0 + @test JuMP._distance_to_set(0.5, s) ≈ 0.5 + @test JuMP._distance_to_set(1.9, s) ≈ 0.1 + @test JuMP._distance_to_set(2.1, s) ≈ 0.1 + @test JuMP._distance_to_set(4.1, s) ≈ 0.1 +end + +function test_nonnegatives() + @test_throws( + DimensionMismatch, + JuMP._distance_to_set([-1.0, 1.0], MOI.Nonnegatives(1)) + ) + @test JuMP._distance_to_set([-1.0, 1.0], MOI.Nonnegatives(2)) ≈ 1.0 +end + +function test_nonpositives() + @test_throws( + DimensionMismatch, + JuMP._distance_to_set([-1.0, 1.0], MOI.Nonpositives(1)) + ) + @test JuMP._distance_to_set([-1.0, 1.0], MOI.Nonpositives(2)) ≈ 1.0 +end + +function test_reals() + @test_throws( + DimensionMismatch, + JuMP._distance_to_set([-1.0, 1.0], MOI.Reals(1)) + ) + @test JuMP._distance_to_set([-1.0, 1.0], MOI.Reals(2)) ≈ 0.0 +end + +function test_zeros() + @test_throws( + DimensionMismatch, + JuMP._distance_to_set([-1.0, 1.0], MOI.Zeros(1)) + ) + @test JuMP._distance_to_set([-1.0, 1.0], MOI.Zeros(2)) ≈ sqrt(2) +end + +function test_no_solution() + model = Model() + @variable(model, x, Bin) + @test_throws ErrorException primal_feasibility_report(model) +end + +function test_primal_solution() + model = Model(() -> MOIU.MockOptimizer(MOIU.Model{Float64}())) + @variable(model, x, Bin) + optimize!(model) + mock = backend(model).optimizer.model + MOI.set(mock, MOI.TerminationStatus(), MOI.OPTIMAL) + MOI.set(mock, MOI.PrimalStatus(), MOI.FEASIBLE_POINT) + MOI.set(mock, MOI.VariablePrimal(), optimizer_index(x), 1.0) + report = primal_feasibility_report(model) + @test isempty(report) +end + +function test_feasible() + model = Model() + @variable(model, x, Bin) + @variable(model, 0 <= y <= 2, Int) + @variable(model, z == 0.5) + @constraint(model, x + y + z >= 0.5) + report = + primal_feasibility_report(model, Dict(x => 0.0, y => 0.0, z => 0.5)) + @test isempty(report) +end + +function test_missing() + model = Model() + @variable(model, x, Bin) + @variable(model, 0 <= y <= 2, Int) + @variable(model, z == 0.5) + @constraint(model, x + y + z >= 0.5) + report = + primal_feasibility_report(model, Dict(z => 0.0), skip_missing = true) + @test report[FixRef(z)] == 0.5 + @test length(report) == 1 +end + +function test_missing_error() + model = Model() + @variable(model, x, Bin) + @variable(model, 0 <= y <= 2, Int) + point = Dict(x => 0.1) + err = ErrorException( + "point does not contain a value for variable $(y). Provide a value, " * + "or pass `skip_missing = true`.", + ) + @test_throws err primal_feasibility_report(model, point) +end + +function test_bounds() + model = Model() + @variable(model, x, Bin) + @variable(model, 0 <= y <= 2, Int) + @variable(model, z == 0.5) + point = Dict(x => 0.1, y => 2.1, z => 0.0) + report = primal_feasibility_report(model, point) + @test report[BinaryRef(x)] ≈ 0.1 + @test report[UpperBoundRef(y)] ≈ 0.1 + @test report[IntegerRef(y)] ≈ 0.1 + @test report[FixRef(z)] ≈ 0.5 + @test length(report) == 4 +end + +function test_scalar_affine() + model = Model() + @variable(model, x) + @constraint(model, c1, x <= 0.5) + @constraint(model, c2, x >= 1.25) + @constraint(model, c3, x == 1.1) + @constraint(model, c4, 0 <= x <= 0.5) + report = primal_feasibility_report(model, Dict(x => 1.0)) + @test report[c1] ≈ 0.5 + @test report[c2] ≈ 0.25 + @test report[c3] ≈ 0.1 + @test report[c4] ≈ 0.5 + @test length(report) == 4 +end + +function test_scalar_quadratic() + model = Model() + @variable(model, x) + @constraint(model, c1, x^2 + x <= 0.5) + @constraint(model, c2, x^2 - x >= 1.25) + @constraint(model, c3, x^2 + x == 1.1) + @constraint(model, c4, 0 <= x^2 + x <= 0.5) + report = primal_feasibility_report(model, Dict(x => 1.0)) + @test report[c1] ≈ 1.5 + @test report[c2] ≈ 1.25 + @test report[c3] ≈ 0.9 + @test report[c4] ≈ 1.5 + @test length(report) == 4 +end + +function test_vector() + model = Model() + @variable(model, x[1:3]) + @constraint(model, c1, x in MOI.Nonnegatives(3)) + @constraint(model, c2, x in MOI.Nonpositives(3)) + @constraint(model, c3, x in MOI.Reals(3)) + @constraint(model, c4, x in MOI.Zeros(3)) + point = Dict(x[1] => 1.0, x[2] => -1.0, x[3] => 0.0) + report = primal_feasibility_report(model, point) + @test report[c1] ≈ 1.0 + @test report[c2] ≈ 1.0 + @test !haskey(report, c3) + @test report[c4] ≈ sqrt(2) + @test length(report) == 3 +end + +function test_vector_affine() + model = Model() + @variable(model, x[1:3]) + @constraint(model, c1, 2 * x in MOI.Nonnegatives(3)) + @constraint(model, c2, 2 * x in MOI.Nonpositives(3)) + @constraint(model, c3, 2 * x in MOI.Reals(3)) + @constraint(model, c4, 2 * x in MOI.Zeros(3)) + point = Dict(x[1] => 1.0, x[2] => -1.0, x[3] => 0.0) + report = primal_feasibility_report(model, point) + @test report[c1] ≈ 2.0 + @test report[c2] ≈ 2.0 + @test !haskey(report, c3) + @test report[c4] ≈ sqrt(8) + @test length(report) == 3 +end + +function test_nonlinear() + model = Model() + @variable(model, x) + @NLconstraint(model, c1, sin(x) <= 0.0) + @NLconstraint(model, c2, sin(x) <= 1.0) + @NLconstraint(model, c3, exp(x) >= 2.0) + @NLconstraint(model, c4, x + x^2 - x^3 == 0.5) + report = primal_feasibility_report(model, Dict(x => 0.5)) + @test report[c1] ≈ sin(0.5) + @test !haskey(report, c2) + @test report[c3] ≈ 2 - exp(0.5) + @test report[c4] ≈ 0.125 +end + +function test_nonlinear_missing() + model = Model() + @variable(model, x) + @NLconstraint(model, c1, sin(x) <= 0.0) + @test_throws( + ErrorException( + "`skip_missing = true` is not allowed when nonlinear constraints " * + "are present.", + ), + primal_feasibility_report(model, Dict(x => 0.5); skip_missing = true) + ) +end + +function runtests() + for name in names(@__MODULE__; all = true) + if !startswith("$(name)", "test_") + continue + end + @testset "$(name)" begin + getfield(@__MODULE__, name)() + end + end +end + +end + +TestFeasibilityChecker.runtests()