diff --git a/Project.toml b/Project.toml index 3a0e3c2..7972cd8 100644 --- a/Project.toml +++ b/Project.toml @@ -18,7 +18,7 @@ Combinatorics = "1" HiGHS = "1" Ipopt = "1" JSON = "0.21" -MathOptInterface = "1.19" +MathOptInterface = "1.21" Polyhedra = "0.8" Test = "1" julia = "1.10" @@ -27,8 +27,8 @@ julia = "1.10" HiGHS = "87dc4568-4c63-4d18-b0c0-bb2238e4078b" Ipopt = "b6b21f68-93f8-5de0-b562-5493be1d77c9" JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" -Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" Polyhedra = "67491407-f73d-577b-9b50-8179a7c68029" +Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] -test = ["HiGHS", "Ipopt", "JSON", "Test", "Polyhedra"] +test = ["HiGHS", "Ipopt", "JSON", "Polyhedra", "Test"] diff --git a/README.md b/README.md index b3aef5d..33de98e 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ import HiGHS import MultiObjectiveAlgorithms as MOA model = JuMP.Model(() -> MOA.Optimizer(HiGHS.Optimizer)) set_attribute(model, MOA.Algorithm(), MOA.Dichotomy()) -set_attribute(model, MOA.SolutionLimit(), 4) +set_attribute(model, MOI.SolutionLimit(), 4) ``` For worked examples, see the [Simple multi-objective examples](https://jump.dev/JuMP.jl/stable/tutorials/linear/multi_objective_examples/) @@ -82,7 +82,7 @@ the solution process. * `MOA.ObjectivePriority(index::Int)` * `MOA.ObjectiveRelativeTolerance(index::Int)` * `MOA.ObjectiveWeight(index::Int)` - * `MOA.SolutionLimit()` + * `MOI.SolutionLimit()` * `MOI.TimeLimitSec()` Query the number of scalar subproblems that were solved using diff --git a/src/MultiObjectiveAlgorithms.jl b/src/MultiObjectiveAlgorithms.jl index b9498da..67ff919 100644 --- a/src/MultiObjectiveAlgorithms.jl +++ b/src/MultiObjectiveAlgorithms.jl @@ -120,6 +120,7 @@ mutable struct Optimizer <: MOI.AbstractOptimizer solutions::Vector{SolutionPoint} termination_status::MOI.TerminationStatusCode time_limit_sec::Union{Nothing,Float64} + solution_limit::Union{Nothing,Int} solve_time::Float64 ideal_point::Vector{Float64} compute_ideal_point::Bool @@ -134,6 +135,7 @@ mutable struct Optimizer <: MOI.AbstractOptimizer SolutionPoint[], MOI.OPTIMIZE_NOT_CALLED, nothing, + nothing, NaN, Float64[], default(ComputeIdealPoint()), @@ -187,6 +189,24 @@ function MOI.set(model::Optimizer, ::MOI.TimeLimitSec, ::Nothing) return end +### SolutionLimit + +const SolutionLimit = MOI.SolutionLimit + +MOI.supports(::Optimizer, ::MOI.SolutionLimit) = true + +MOI.get(model::Optimizer, ::MOI.SolutionLimit) = model.solution_limit + +function MOI.set(model::Optimizer, ::MOI.SolutionLimit, value::Integer) + model.solution_limit = Int(value) + return +end + +function MOI.set(model::Optimizer, ::MOI.SolutionLimit, ::Nothing) + model.solution_limit = nothing + return +end + ### SolveTimeSec function MOI.get(model::Optimizer, ::MOI.SolveTimeSec) @@ -263,17 +283,6 @@ function MOI.get(model::Optimizer, attr::AbstractAlgorithmAttribute) return MOI.get(model.algorithm, attr) end -""" - SolutionLimit <: AbstractAlgorithmAttribute -> Int - -Terminate the algorithm once the set number of solutions have been found. - -Defaults to `typemax(Int)`. -""" -struct SolutionLimit <: AbstractAlgorithmAttribute end - -default(::SolutionLimit) = typemax(Int) - """ ObjectivePriority(index::Int) <: AbstractAlgorithmAttribute -> Int diff --git a/src/algorithms/Dichotomy.jl b/src/algorithms/Dichotomy.jl index ba74728..a2916fe 100644 --- a/src/algorithms/Dichotomy.jl +++ b/src/algorithms/Dichotomy.jl @@ -16,13 +16,9 @@ Science 25(1), 73-78. * `MOI.TimeLimitSec()`: terminate if the time limit is exceeded and return the list of current solutions. - * `MOA.SolutionLimit()`: terminate once this many solutions have been found. + * `MOI.SolutionLimit()`: terminate once this many solutions have been found. """ -mutable struct Dichotomy <: AbstractAlgorithm - solution_limit::Union{Nothing,Int} - - Dichotomy() = new(nothing) -end +mutable struct Dichotomy <: AbstractAlgorithm end """ NISE() @@ -39,21 +35,10 @@ trade‐offs: An algorithm for bicriterion problems. Water Resources Research, ## Supported optimizer attributes - * `MOA.SolutionLimit()` + * `MOI.SolutionLimit()` """ NISE() = Dichotomy() -MOI.supports(::Dichotomy, ::SolutionLimit) = true - -function MOI.set(alg::Dichotomy, ::SolutionLimit, value) - alg.solution_limit = value - return -end - -function MOI.get(alg::Dichotomy, attr::SolutionLimit) - return something(alg.solution_limit, default(alg, attr)) -end - function _solve_weighted_sum( model::Optimizer, ::Dichotomy, @@ -100,7 +85,7 @@ function optimize_multiobjective!(algorithm::Dichotomy, model::Optimizer) if !(solutions[0.0] ≈ solutions[1.0]) push!(queue, (0.0, 1.0)) end - limit = MOI.get(algorithm, SolutionLimit()) + limit = something(MOI.get(model, MOI.SolutionLimit()), typemax(Int)) status = MOI.OPTIMAL while length(queue) > 0 && length(solutions) < limit if (ret = _check_premature_termination(model, start_time)) !== nothing diff --git a/src/algorithms/EpsilonConstraint.jl b/src/algorithms/EpsilonConstraint.jl index 74dc5b0..1db55a2 100644 --- a/src/algorithms/EpsilonConstraint.jl +++ b/src/algorithms/EpsilonConstraint.jl @@ -16,29 +16,20 @@ bi-objective programs. default is `1`, so that for a pure integer program this algorithm will enumerate all non-dominated solutions. - * `MOA.SolutionLimit()`: if this attribute is set then, instead of using the + * `MOI.SolutionLimit()`: if this attribute is set then, instead of using the `MOA.EpsilonConstraintStep`, with a slight abuse of notation, `EpsilonConstraint` divides the width of the first-objective's domain in - objective space by `SolutionLimit` to obtain the epsilon to use when - iterating. Thus, there can be at most `SolutionLimit` solutions returned, but - there may be fewer. + objective space by `MOI.SolutionLimit` to obtain the epsilon to use when + iterating. Thus, there can be at most `MOI.SolutionLimit` solutions returned, + but there may be fewer. + + * `MOI.TimeLimitSec()`: terminate if the time limit is exceeded and return the + list of current solutions. """ mutable struct EpsilonConstraint <: AbstractAlgorithm - solution_limit::Union{Nothing,Int} atol::Union{Nothing,Float64} - EpsilonConstraint() = new(nothing, nothing) -end - -MOI.supports(::EpsilonConstraint, ::SolutionLimit) = true - -function MOI.set(alg::EpsilonConstraint, ::SolutionLimit, value) - alg.solution_limit = value - return -end - -function MOI.get(alg::EpsilonConstraint, attr::SolutionLimit) - return something(alg.solution_limit, default(alg, attr)) + EpsilonConstraint() = new(nothing) end MOI.supports(::EpsilonConstraint, ::EpsilonConstraintStep) = true @@ -91,8 +82,9 @@ function minimize_multiobjective!( model.ideal_point .= min.(solution_1[1].y, solution_2[1].y) # Compute the epsilon that we will be incrementing by each iteration ε = MOI.get(algorithm, EpsilonConstraintStep()) - n_points = MOI.get(algorithm, SolutionLimit()) - if n_points != default(algorithm, SolutionLimit()) + n_points = MOI.get(model, MOI.SolutionLimit()) + if n_points === nothing + n_points = typemax(Int) ε = abs(right - left) / (n_points - 1) end solutions = SolutionPoint[only(solution_1), only(solution_2)] diff --git a/src/algorithms/RandomWeighting.jl b/src/algorithms/RandomWeighting.jl index 6563b0a..ac50554 100644 --- a/src/algorithms/RandomWeighting.jl +++ b/src/algorithms/RandomWeighting.jl @@ -14,67 +14,39 @@ random weights. * `MOI.TimeLimitSec()`: terminate if the time limit is exceeded and return the list of current solutions. - * `MOA.SolutionLimit()`: terminate once this many solutions have been found. + * `MOI.SolutionLimit()`: terminate once this many solutions have been found. At least one of these two limits must be set. """ -mutable struct RandomWeighting <: AbstractAlgorithm - solution_limit::Union{Nothing,Int} - RandomWeighting() = new(nothing) -end - -MOI.supports(::RandomWeighting, ::SolutionLimit) = true - -function MOI.set(alg::RandomWeighting, ::SolutionLimit, value) - alg.solution_limit = value - return -end - -function MOI.get(alg::RandomWeighting, attr::SolutionLimit) - return something(alg.solution_limit, default(alg, attr)) -end +mutable struct RandomWeighting <: AbstractAlgorithm end function optimize_multiobjective!(algorithm::RandomWeighting, model::Optimizer) if MOI.get(model, MOI.TimeLimitSec()) === nothing && algorithm.solution_limit === nothing - error("At least `MOI.TimeLimitSec` or `MOA.SolutionLimit` must be set") + error("At least `MOI.TimeLimitSec` or `MOI.SolutionLimit` must be set") end start_time = time() solutions = SolutionPoint[] sense = MOI.get(model, MOI.ObjectiveSense()) - P = MOI.output_dimension(model.f) variables = MOI.get(model.inner, MOI.ListOfVariableIndices()) - f = _scalarise(model.f, ones(P)) - MOI.set(model.inner, MOI.ObjectiveFunction{typeof(f)}(), f) - optimize_inner!(model) - status = MOI.get(model.inner, MOI.TerminationStatus()) - if _is_scalar_status_optimal(status) - X, Y = _compute_point(model, variables, model.f) - push!(solutions, SolutionPoint(X, Y)) - else - return status, nothing - end - # This double loop is a bit weird: - # * the inner loop fills up SolutionLimit number of solutions. Then we cut - # it back to nondominated. - # * then the outer loop goes again - while length(solutions) < MOI.get(algorithm, SolutionLimit()) - while length(solutions) < MOI.get(algorithm, SolutionLimit()) - ret = _check_premature_termination(model, start_time) - if ret !== nothing - return ret, filter_nondominated(sense, solutions) - end - weights = rand(P) - f = _scalarise(model.f, weights) - MOI.set(model.inner, MOI.ObjectiveFunction{typeof(f)}(), f) - optimize_inner!(model) - status = MOI.get(model.inner, MOI.TerminationStatus()) - if _is_scalar_status_optimal(status) - X, Y = _compute_point(model, variables, model.f) - push!(solutions, SolutionPoint(X, Y)) - end + limit = something(MOI.get(model, MOI.SolutionLimit()), typemax(Int)) + status = MOI.OPTIMAL + while length(solutions) < limit + if (ret = _check_premature_termination(model, start_time)) !== nothing + status = ret + break + end + weights = rand(MOI.output_dimension(model.f)) + f = _scalarise(model.f, weights) + MOI.set(model.inner, MOI.ObjectiveFunction{typeof(f)}(), f) + optimize_inner!(model) + if _is_scalar_status_optimal(model) + X, Y = _compute_point(model, variables, model.f) + push!(solutions, SolutionPoint(X, Y)) + end + if length(solutions) == limit + solutions = filter_nondominated(sense, solutions) end - solutions = filter_nondominated(sense, solutions) end - return MOI.OPTIMAL, filter_nondominated(sense, solutions) + return status, filter_nondominated(sense, solutions) end diff --git a/test/algorithms/Dichotomy.jl b/test/algorithms/Dichotomy.jl index 2fa8d07..938dcbd 100644 --- a/test/algorithms/Dichotomy.jl +++ b/test/algorithms/Dichotomy.jl @@ -28,12 +28,10 @@ end function test_Dichotomy_SolutionLimit() model = MOA.Optimizer(HiGHS.Optimizer) MOI.set(model, MOA.Algorithm(), MOA.Dichotomy()) - @test MOI.supports(MOA.Dichotomy(), MOA.SolutionLimit()) - @test MOI.supports(model, MOA.SolutionLimit()) - @test MOI.get(model, MOA.SolutionLimit()) == - MOA.default(MOA.SolutionLimit()) - MOI.set(model, MOA.SolutionLimit(), 1) - @test MOI.get(model, MOA.SolutionLimit()) == 1 + @test MOI.supports(model, MOI.SolutionLimit()) + @test MOI.get(model, MOI.SolutionLimit()) == nothing + MOI.set(model, MOI.SolutionLimit(), 1) + @test MOI.get(model, MOI.SolutionLimit()) == 1 return end @@ -343,7 +341,6 @@ function test_deprecated() nise = MOA.NISE() dichotomy = MOA.Dichotomy() @test nise isa typeof(dichotomy) - @test nise.solution_limit === dichotomy.solution_limit return end @@ -371,7 +368,7 @@ function test_quadratic() N = 2 model = MOA.Optimizer(Ipopt.Optimizer) MOI.set(model, MOA.Algorithm(), MOA.Dichotomy()) - MOI.set(model, MOA.SolutionLimit(), 10) + MOI.set(model, MOI.SolutionLimit(), 10) MOI.set(model, MOI.Silent(), true) w = MOI.add_variables(model, N) MOI.add_constraint.(model, w, MOI.GreaterThan(0.0)) diff --git a/test/algorithms/EpsilonConstraint.jl b/test/algorithms/EpsilonConstraint.jl index 0119113..d18ec01 100644 --- a/test/algorithms/EpsilonConstraint.jl +++ b/test/algorithms/EpsilonConstraint.jl @@ -40,7 +40,7 @@ function test_biobjective_knapsack() w = [80, 87, 68, 72, 66, 77, 99, 85, 70, 93, 98, 72, 100, 89, 67, 86, 91] model = MOA.Optimizer(HiGHS.Optimizer) MOI.set(model, MOA.Algorithm(), MOA.EpsilonConstraint()) - MOI.set(model, MOA.SolutionLimit(), 100) + MOI.set(model, MOI.SolutionLimit(), 100) MOI.set(model, MOI.Silent(), true) x = MOI.add_variables(model, length(w)) MOI.add_constraint.(model, x, MOI.ZeroOne()) @@ -170,7 +170,7 @@ function test_biobjective_knapsack_min() w = [80, 87, 68, 72, 66, 77, 99, 85, 70, 93, 98, 72, 100, 89, 67, 86, 91] model = MOA.Optimizer(HiGHS.Optimizer) MOI.set(model, MOA.Algorithm(), MOA.EpsilonConstraint()) - MOI.set(model, MOA.SolutionLimit(), 100) + MOI.set(model, MOI.SolutionLimit(), 100) MOI.set(model, MOI.Silent(), true) x = MOI.add_variables(model, length(w)) MOI.add_constraint.(model, x, MOI.ZeroOne()) @@ -215,8 +215,8 @@ function test_biobjective_knapsack_min_solution_limit() w = [80, 87, 68, 72, 66, 77, 99, 85, 70, 93, 98, 72, 100, 89, 67, 86, 91] model = MOA.Optimizer(HiGHS.Optimizer) MOI.set(model, MOA.Algorithm(), MOA.EpsilonConstraint()) - @test MOI.supports(model, MOA.SolutionLimit()) - MOI.set(model, MOA.SolutionLimit(), 3) + @test MOI.supports(model, MOI.SolutionLimit()) + MOI.set(model, MOI.SolutionLimit(), 3) MOI.set(model, MOI.Silent(), true) x = MOI.add_variables(model, length(w)) MOI.add_constraint.(model, x, MOI.ZeroOne()) @@ -315,7 +315,7 @@ function test_quadratic() N = 2 model = MOA.Optimizer(Ipopt.Optimizer) MOI.set(model, MOA.Algorithm(), MOA.EpsilonConstraint()) - MOI.set(model, MOA.SolutionLimit(), 10) + MOI.set(model, MOI.SolutionLimit(), 10) MOI.set(model, MOI.Silent(), true) w = MOI.add_variables(model, N) MOI.add_constraint.(model, w, MOI.GreaterThan(0.0)) @@ -343,7 +343,7 @@ function test_poor_numerics() N = 2 model = MOA.Optimizer(Ipopt.Optimizer) MOI.set(model, MOA.Algorithm(), MOA.EpsilonConstraint()) - MOI.set(model, MOA.SolutionLimit(), 10) + MOI.set(model, MOI.SolutionLimit(), 10) MOI.set(model, MOI.Silent(), true) w = MOI.add_variables(model, N) sharpe = MOI.add_variable(model) @@ -387,7 +387,7 @@ function test_vectornonlinearfunction() N = 2 model = MOA.Optimizer(Ipopt.Optimizer) MOI.set(model, MOA.Algorithm(), MOA.EpsilonConstraint()) - MOI.set(model, MOA.SolutionLimit(), 10) + MOI.set(model, MOI.SolutionLimit(), 10) MOI.set(model, MOI.Silent(), true) w = MOI.add_variables(model, N) MOI.add_constraint.(model, w, MOI.GreaterThan(0.0)) diff --git a/test/algorithms/RandomWeighting.jl b/test/algorithms/RandomWeighting.jl index 5c3b9a0..3cefd57 100644 --- a/test/algorithms/RandomWeighting.jl +++ b/test/algorithms/RandomWeighting.jl @@ -33,7 +33,7 @@ function test_error_attribute() MOI.set(model, MOI.ObjectiveFunction{typeof(f)}(), f) @test_throws( ErrorException( - "At least `MOI.TimeLimitSec` or `MOA.SolutionLimit` must be set", + "At least `MOI.TimeLimitSec` or `MOI.SolutionLimit` must be set", ), MOI.optimize!(model), ) @@ -50,7 +50,7 @@ function test_knapsack_min() w = Float64[557, 898, 148, 63, 78, 964, 246, 662, 386, 272] model = MOA.Optimizer(HiGHS.Optimizer) MOI.set(model, MOA.Algorithm(), MOA.RandomWeighting()) - MOI.set(model, MOA.SolutionLimit(), 3) + MOI.set(model, MOI.SolutionLimit(), 3) MOI.set(model, MOI.Silent(), true) x = MOI.add_variables(model, n) MOI.add_constraint.(model, x, MOI.ZeroOne()) @@ -97,7 +97,7 @@ function test_knapsack_max() w = Float64[557, 898, 148, 63, 78, 964, 246, 662, 386, 272] model = MOA.Optimizer(HiGHS.Optimizer) MOI.set(model, MOA.Algorithm(), MOA.RandomWeighting()) - MOI.set(model, MOA.SolutionLimit(), 3) + MOI.set(model, MOI.SolutionLimit(), 3) MOI.set(model, MOI.Silent(), true) x = MOI.add_variables(model, n) MOI.add_constraint.(model, x, MOI.ZeroOne()) @@ -174,8 +174,8 @@ function test_unbounded() model = MOA.Optimizer(HiGHS.Optimizer) MOI.set(model, MOA.Algorithm(), MOA.RandomWeighting()) MOI.set(model, MOI.Silent(), true) - @test MOI.supports(model, MOA.SolutionLimit()) - MOI.set(model, MOA.SolutionLimit(), 10) + @test MOI.supports(model, MOI.SolutionLimit()) + MOI.set(model, MOI.SolutionLimit(), 10) x = MOI.add_variables(model, 2) MOI.add_constraint.(model, x, MOI.GreaterThan(0.0)) f = MOI.Utilities.operate(vcat, Float64, 1.0 .* x...) diff --git a/test/test_model.jl b/test/test_model.jl index db61d86..d233816 100644 --- a/test/test_model.jl +++ b/test/test_model.jl @@ -234,6 +234,11 @@ function test_check_interrupt() return end +function test_SolutionLimit() + @test MOI.SolutionLimit === MOA.SolutionLimit + return +end + end # module TestModel.run_tests()