diff --git a/Project.toml b/Project.toml index bdec9be..868b4bc 100644 --- a/Project.toml +++ b/Project.toml @@ -8,12 +8,15 @@ JuMP = "4076af6c-e467-56ae-b986-b466b2749572" Reexport = "189a3867-3050-52da-a836-e630ba90ab69" [weakdeps] +AbstractGPs = "99985d1d-32ba-4be9-9821-2ec096f28918" InfiniteOpt = "20393b10-9daf-11e9-18c9-8db751c92c57" [extensions] +AbstractGPsDisjunctiveProgramming = "AbstractGPs" InfiniteDisjunctiveProgramming = "InfiniteOpt" [compat] +AbstractGPs = "0.5" Aqua = "0.8" JuMP = "1.18" Reexport = "1" @@ -30,4 +33,4 @@ Ipopt = "b6b21f68-93f8-5de0-b562-5493be1d77c9" Juniper = "2ddba703-00a4-53a7-87a5-e8b9971dde84" [targets] -test = ["Aqua", "HiGHS", "Test", "Juniper", "Ipopt", "InfiniteOpt"] +test = ["Aqua", "HiGHS", "Test", "Juniper", "Ipopt", "InfiniteOpt", "AbstractGPs"] diff --git a/README.md b/README.md index 543d1f7..97c29a2 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,7 @@ The following reformulation methods are currently supported: - `optimizer`: Optimizer to use when solving subproblems to determine M values. This is a required value. - `default_M`: Default big-M value to use if no big-M is specified for a logical variable (1e9). + - `sampler`: M-value sampler for infinite models. Default: `nothing`, which solves an M subproblem at every support. Pass a `GPSampler` to instead solve a subset of the supports and fill the rest with a conservative Gaussian-process estimate. Ignored for finite models. 5. [P-Split](https://arxiv.org/abs/2202.05198): This method reformulates each disjunct constraint into P constraints, each with a partitioned group defined by the user. This method requires that terms in the constraint be convex additively seperable with respect to each variable. The `PSplit` struct is created with the following required arguments: @@ -223,6 +224,8 @@ optimize!(model, gdp_method = Hull()) value(W) ``` +When the `MBM` reformulation is used on an infinite model, an M subproblem is solved at every support by default. Loading [AbstractGPs.jl](https://github.com/JuliaGaussianProcesses/AbstractGPs.jl) (`using AbstractGPs`) enables an additional extension that instead solves M at a subset of the supports and fills the rest with a conservative Gaussian-process estimate, which can substantially reduce the number of subproblem solves. To opt in, pass a `GPSampler` via the `sampler` keyword, e.g. `MBM(optimizer, sampler = GPSampler())` (squared exponential kernel with the lengthscale selected by marginal likelihood), `GPSampler(Matern52Kernel())` (a custom kernel, lengthscale selected the same way), or `GPSampler(Matern52Kernel(), lengthscales = [0.2])` (lengthscale pinned). The filled values are heuristic upper estimates rather than certificates; see the `GPSampler` docstring for the tuning keywords (`kappa`, `budget`, `detect_uniform_M`, `lengthscales`, `jitter`, `seeds`). + ## Release Notes Prior to `v0.4.0`, the package did not leverage the JuMP extension capabilities and was not as robust. For these earlier releases, refer to [Perez, Joshi, and Grossmann, 2023](https://arxiv.org/abs/2304.10492v1) and the following [JuliaCon 2022 Talk](https://www.youtube.com/watch?v=AMIrgTTfUkI). diff --git a/ext/AbstractGPsDisjunctiveProgramming.jl b/ext/AbstractGPsDisjunctiveProgramming.jl new file mode 100644 index 0000000..00af68c --- /dev/null +++ b/ext/AbstractGPsDisjunctiveProgramming.jl @@ -0,0 +1,119 @@ +module AbstractGPsDisjunctiveProgramming + +import AbstractGPs +import AbstractGPs.KernelFunctions +import DisjunctiveProgramming as DP + +################################################################################ +# GP FITTING +################################################################################ +# Normalized to [0, 1]^d so one lengthscale works across dimensions +function _support_coords( + grids::Tuple{Vararg{Vector{Float64}}} + )::Vector{Vector{Float64}} + indices = CartesianIndices(length.(grids)) + mins = [minimum(g) for g in grids] + ranges = [max(maximum(g) - minimum(g), eps()) for g in grids] + return [[(grids[d][I[d]] - mins[d]) / ranges[d] + for d in 1:length(grids)] for I in vec(indices)] +end + +# Lengthscale selected by marginal likelihood over the candidates +function _fit_posterior( + kernel::KernelFunctions.Kernel, + X::Vector{Vector{Float64}}, + y::Vector{Float64}, + sampler::DP.GPSampler + ) + best_posterior, best_log_prob = nothing, -Inf + for lengthscale in sampler.lengthscales + scaled_kernel = KernelFunctions.with_lengthscale( + kernel, lengthscale) + finite_gp = AbstractGPs.GP(scaled_kernel)(X, sampler.jitter) + log_prob = AbstractGPs.logpdf(finite_gp, y) + if log_prob > best_log_prob + best_posterior = AbstractGPs.posterior(finite_gp, y) + best_log_prob = log_prob + end + end + return best_posterior +end + +function _mean_sd( + sampler::DP.GPSampler, + X::Vector{Vector{Float64}}, + solved::Dict{Int, Float64} + ) + solved_indices = collect(keys(solved)) + y = [solved[i] for i in solved_indices] + y_mean = sum(y) / length(y) + # floored so near-equal solved values still cushion the filled ones + y_scale = max(sqrt(sum(abs2, y .- y_mean) / max(length(y) - 1, 1)), + 1e-2 * abs(y_mean), 1e-8) + kernel = something(sampler.kernel, + KernelFunctions.SqExponentialKernel()) + posterior = _fit_posterior( + kernel, X[solved_indices], (y .- y_mean) ./ y_scale, sampler) + posterior_mean = AbstractGPs.mean(posterior, X) + posterior_var = max.(AbstractGPs.var(posterior, X), 0.0) + return posterior_mean .* y_scale .+ y_mean, + sqrt.(posterior_var) .* y_scale +end + +################################################################################ +# M VALUE SAMPLING +################################################################################ +# Solve M at max-UCB selected supports, fill the rest with the bound +function DP.sample_M_values( + sampler::DP.GPSampler, + objectives::AbstractArray, + sub::DP.GDPSubmodel, + method::DP._MBM, + support_grids::Function + ) + indices = collect(CartesianIndices(objectives)) + n = length(indices) + solved = Dict{Int, Float64}() + solve_at(index::Int) = begin + M_val = DP.raw_M(sub, objectives[indices[index]], method) + M_val === nothing && return false + solved[index] = M_val + return true + end + # an evenly spaced seed count, or user-given seed fractions + fractions = sampler.seeds isa Int ? + range(0, 1, length = sampler.seeds) : sampler.seeds + for index in unique(1 .+ round.(Int, fractions .* (n - 1))) + solve_at(index) || return nothing + end + if sampler.detect_uniform_M + # a uniform M needs no fit, and so no support grid either + probes = collect(values(solved)) + all(==(first(probes)), probes) && return first(probes) + end + budget = min(ceil(Int, sampler.budget * n), n) + X = _support_coords(support_grids()) + while length(solved) < budget + means, sds = _mean_sd(sampler, X, solved) + acquisition = means .+ sampler.kappa .* sds + for index in keys(solved) + acquisition[index] = -Inf + end + solve_at(argmax(acquisition)) || return nothing + end + M_vals = Array{Float64}(undef, size(objectives)) + if length(solved) == n # nothing left to estimate + for (index, I) in enumerate(indices) + M_vals[I] = solved[index] + end + return M_vals + end + means, sds = _mean_sd(sampler, X, solved) + for (index, I) in enumerate(indices) # exact M values are nonnegative + M_vals[I] = get(solved, index, + max(means[index] + sampler.kappa * sds[index], 0.0)) + end + return M_vals +end + +end diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 5f77dce..83aed38 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -38,6 +38,67 @@ function DP.requires_disaggregation(vref::InfiniteOpt.GeneralVariableRef) return !_is_parameter(vref) end +# Bound info for parameter refs in disjunct constraints: parameter +# functions report their support extrema, finite parameters a point, +# other parameters (-Inf, Inf). Returns nothing for variables. +function _parameter_bound_info( + vref::InfiniteOpt.GeneralVariableRef + )::Union{Nothing, Tuple{Float64, Float64}} + _is_parameter(vref) || return nothing + dvref = InfiniteOpt.dispatch_variable_ref(vref) + if dvref isa InfiniteOpt.ParameterFunctionRef + prefs = InfiniteOpt.parameter_list(dvref) + length(prefs) == 1 || return (-Inf, Inf) + supports = InfiniteOpt.supports(first(prefs)) + isempty(supports) && return (-Inf, Inf) + func = InfiniteOpt.raw_function(dvref) + func_values = [func(s) for s in supports] + return (minimum(func_values), maximum(func_values)) + elseif dvref isa InfiniteOpt.FiniteParameterRef + value = InfiniteOpt.parameter_value(dvref) + return (value, value) + end + return (-Inf, Inf) +end + +function DP.set_variable_bound_info( + vref::InfiniteOpt.GeneralVariableRef, ::DP.BigM) + info = _parameter_bound_info(vref) + info === nothing || return info + lb = JuMP.has_lower_bound(vref) ? JuMP.lower_bound(vref) : -Inf + ub = JuMP.has_upper_bound(vref) ? JuMP.upper_bound(vref) : Inf + return lb, ub +end + +# Hull and PSplit require finite bounds that include 0 +function _zero_inclusive_bounds( + vref::InfiniteOpt.GeneralVariableRef, + info::Union{Nothing, Tuple{Float64, Float64}}, + method_name::String + )::Tuple{Float64, Float64} + info === nothing || return (min(0, info[1]), max(0, info[2])) + if !JuMP.has_lower_bound(vref) || !JuMP.has_upper_bound(vref) + error("Variable $vref must have both lower and upper " * + "bounds defined when using the $method_name " * + "reformulation.") + end + return (min(0, JuMP.lower_bound(vref)), + max(0, JuMP.upper_bound(vref))) +end + +function DP.set_variable_bound_info( + vref::InfiniteOpt.GeneralVariableRef, ::DP.Hull) + return _zero_inclusive_bounds(vref, + _parameter_bound_info(vref), "Hull") +end + +function DP.set_variable_bound_info( + vref::InfiniteOpt.GeneralVariableRef, + ::Union{DP.PSplit, DP._PSplit}) + return _zero_inclusive_bounds(vref, + _parameter_bound_info(vref), "PSplit") +end + function DP.VariableProperties(vref::InfiniteOpt.GeneralVariableRef) info = DP.get_variable_info(vref) name = JuMP.name(vref) @@ -263,31 +324,62 @@ function _interpolate_at( ) end -# Transcribe mini_expr, solve per support on the transcribed JuMP -# model, and aggregate to a scalar if uniform, else to a parameter -# function on main. +# The infinite parameters of `mini_expr` and their supports, in the +# ascending order of `parameter_refs`. Only defined when M varies over +# the supports, so it is deferred until sample_M_values needs it. +function _support_grids( + sub::DP.GDPSubmodel, mini_expr::JuMP.AbstractJuMPScalar) + reverse_map = Dict(ws[1] => v for (v, ws) in sub.fwd_map) + prefs = Tuple(get(reverse_map, p) do + error("MBM cannot build a support grid over `$p`, which " * + "is a group of dependent infinite parameters, so M " * + "must not vary over its supports.") + end for p in InfiniteOpt.parameter_refs(mini_expr)) + return prefs, Tuple(InfiniteOpt.supports(p) for p in prefs) +end + +# Solve the M subproblem exactly at every support +function DP.sample_M_values( + sampler::Nothing, + objectives::AbstractArray, + sub::DP.GDPSubmodel, + method::DP._MBM, + support_grids::Function + ) + M_vals = Array{Float64}(undef, size(objectives)) + for I in eachindex(objectives) + m = DP.raw_M(sub, objectives[I], method) + m === nothing && return nothing + M_vals[I] = m + end + return M_vals +end + +# Transcribe mini_expr, compute the per-support M values with the +# method's sampler, and aggregate to a scalar if uniform, else to a +# parameter function on main. function DP.raw_M( sub::DP.GDPSubmodel{<:InfiniteOpt.InfiniteModel}, mini_expr::JuMP.AbstractJuMPScalar, method::DP._MBM ) objectives = InfiniteOpt.transformation_expression(mini_expr) - transcribed = InfiniteOpt.transformation_model(sub.model) - inner_sub = DP.GDPSubmodel(transcribed,JuMP.VariableRef[], - Dict{JuMP.VariableRef, Vector{JuMP.VariableRef}}() - ) - M_vals = Array{typeof(method.default_M)}(undef, size(objectives)) - for I in eachindex(objectives) - m = DP.raw_M(inner_sub, objectives[I], method) - m === nothing && return nothing - M_vals[I] = m + # transcription orders the dimensions by parameter group, which is + # not the ascending order `parameter_refs` gives the grids below + group_idxs = InfiniteOpt.parameter_group_int_indices(mini_expr) + if length(group_idxs) > 1 && ndims(objectives) == length(group_idxs) + objectives = permutedims(objectives, sortperm(group_idxs)) end + transcribed = InfiniteOpt.transformation_model(sub.model) + inner_sub = DP.GDPSubmodel(transcribed, JuMP.VariableRef[], + Dict{JuMP.VariableRef, Vector{JuMP.VariableRef}}()) + M_vals = DP.sample_M_values(method.sampler, objectives, + inner_sub, method, () -> _support_grids(sub, mini_expr)[2]) + M_vals === nothing && return nothing + M_vals isa Number && return M_vals all(==(first(M_vals)), M_vals) && return first(M_vals) - mini_prefs = InfiniteOpt.parameter_refs(mini_expr) - reverse_map = Dict(ws[1] => v for (v, ws) in sub.fwd_map) - prefs = Tuple(reverse_map[p] for p in mini_prefs) + prefs, grids = _support_grids(sub, mini_expr) main = JuMP.owner_model(first(prefs)) - grids = Tuple(InfiniteOpt.supports(p) for p in prefs) param_func = InfiniteOpt.build_parameter_function( error, _interpolate(grids, M_vals), prefs) return InfiniteOpt.add_parameter_function(main, param_func) diff --git a/src/datatypes.jl b/src/datatypes.jl index bdcd4c2..ef82bce 100644 --- a/src/datatypes.jl +++ b/src/datatypes.jl @@ -368,21 +368,120 @@ struct BigM{T} <: AbstractReformulationMethod end """ - MBM{O, T, L <: LogicalVariableRef} <: AbstractReformulationMethod + MBM{O, T} <: AbstractReformulationMethod A type for using the multiple big-M reformulation approach for disjunctive constraints. **Fields** - `optimizer::O`: Optimizer to use when solving mini-models (required). - `default_M::T`: Default big-M value to use if no big-M is specified for a logical variable (1e9). +- `sampler::Any`: M-value sampler for infinite models (`nothing`). + `nothing` solves an M subproblem at every support; a + [`GPSampler`](@ref) solves a subset of the supports and fills the + rest with a conservative Gaussian-process estimate (see + [`sample_M_values`](@ref)). Ignored for finite models. """ mutable struct MBM{O, T} <: AbstractReformulationMethod optimizer::O default_M::T - + sampler::Any + # Constructor with optimizer (required) and optional default_M - function MBM(optimizer::O, default_M::T = 1e9) where {O, T} - new{O, T}(optimizer, default_M) + function MBM( + optimizer::O, default_M::T = 1e9; sampler = nothing + ) where {O, T} + new{O, T}(optimizer, default_M, sampler) + end +end + +""" + GPSampler( + kernel = nothing; + kappa::Real = 2.5, + budget::Real = 0.25, + detect_uniform_M::Bool = true, + lengthscales = [0.05, 0.1, 0.2, 0.4, 0.8], + jitter::Real = 1e-8, + seeds = 4 + ) + +A Gaussian-process M sampler for the `sampler` field of [`MBM`](@ref) +on infinite models. Instead of solving an M subproblem at every +support, it solves the seed supports, then the supports selected by +an upper-confidence-bound acquisition until the budget is spent, and +fills the remaining supports with the posterior upper confidence +bound `mean + kappa * sd`. The filled values are heuristic upper +estimates of the exact M values, not certificates. Using it requires +that AbstractGPs be loaded. + +**Arguments** +- `kernel`: Covariance kernel for the GP fit; `nothing` (the + default) uses a squared exponential kernel. The lengthscale is + selected from `lengthscales` by marginal likelihood. +- `kappa::Real`: Upper-confidence-bound multiplier used to select the + next support to solve and to fill unsolved supports (2.5). +- `budget::Real`: Fraction of the supports to solve exactly, in + `(0, 1]` (0.25). The seed supports are always solved; + `budget = 1.0` solves every support. +- `detect_uniform_M::Bool`: If `true` (the default), M values that + agree at the seed supports are taken to be uniform and used for + every support. Set it to `false` to always fit the GP, which + leaves the usual `kappa * sd` cushion on the unsolved supports at + the cost of the extra solves, and which requires that every + infinite parameter have a support grid. +- `lengthscales`: Candidate lengthscales for the marginal-likelihood + kernel fit, relative to support coordinates normalized to + `[0, 1]` ([0.05, 0.1, 0.2, 0.4, 0.8]). Give a single candidate to + pin the lengthscale. +- `jitter::Real`: Observation-noise nugget added to the GP prior + when fitting (1e-8). +- `seeds`: Number of evenly spaced supports solved before the first + GP fit (4), or a vector of fractions in `[0, 1]` giving their + positions along the support grid. With `detect_uniform_M`, evenly + spaced seeds can read a periodic M as uniform; pass unevenly + spaced fractions to guard against that. + +**Example** +```julia +julia> using DisjunctiveProgramming, InfiniteOpt, AbstractGPs, HiGHS + +julia> method = MBM(HiGHS.Optimizer, sampler = GPSampler(kappa = 4.0)) +``` +""" +struct GPSampler + # Typed Any so base needs no AbstractGPs dependency + kernel::Any + kappa::Float64 + budget::Float64 + detect_uniform_M::Bool + lengthscales::Vector{Float64} + jitter::Float64 + seeds::Union{Int, Vector{Float64}} + + function GPSampler( + kernel = nothing; + kappa::Real = 2.5, + budget::Real = 0.25, + detect_uniform_M::Bool = true, + lengthscales = [0.05, 0.1, 0.2, 0.4, 0.8], + jitter::Real = 1e-8, + seeds = 4 + ) + kappa >= 0 || error("`kappa` must be nonnegative.") + 0 < budget <= 1 || error("`budget` must be in `(0, 1]`.") + lengthscales = collect(Float64, lengthscales) + (!isempty(lengthscales) && all(>(0), lengthscales)) || + error("`lengthscales` must be positive and nonempty.") + jitter >= 0 || error("`jitter` must be nonnegative.") + if seeds isa Int + seeds >= 2 || error("`seeds` must be at least 2.") + else + seeds = collect(Float64, seeds) + (!isempty(seeds) && all(f -> 0 <= f <= 1, seeds)) || + error("`seeds` must be fractions in `[0, 1]`.") + end + new(kernel, Float64(kappa), Float64(budget), + detect_uniform_M, lengthscales, Float64(jitter), seeds) end end @@ -390,6 +489,7 @@ mutable struct _MBM{O, T, M <: JuMP.AbstractModel} <: AbstractReformulationMetho optimizer::O M::Dict{LogicalVariableRef{M}, Any} default_M::T + sampler::Any subproblem_indicators::Vector{LogicalVariableRef{M}} # Cached submodels: indicator => GDPSubmodel. # Typed Any so extensions can store different types. @@ -400,6 +500,7 @@ mutable struct _MBM{O, T, M <: JuMP.AbstractModel} <: AbstractReformulationMetho method.optimizer, Dict{LogicalVariableRef{M}, Any}(), method.default_M, + method.sampler, Vector{LogicalVariableRef{M}}(), Dict{LogicalVariableRef{M}, Any}() ) diff --git a/src/extension_api.jl b/src/extension_api.jl index ad3566f..aa1c517 100644 --- a/src/extension_api.jl +++ b/src/extension_api.jl @@ -38,3 +38,29 @@ Y(t, x) ``` """ function InfiniteLogical end + +""" + sample_M_values(sampler, objectives, sub, method, support_grids) + +Compute the MBM M values at the transcription supports of an infinite +model. `sampler` is the `sampler` field of [`MBM`](@ref), +`objectives` is the array of per-support objective expressions, `sub` +is the transcribed submodel wrapped as a `GDPSubmodel`, `method` is +the `_MBM` data, and `support_grids` is a function returning the +support vectors of the infinite parameters. It is a function because +the supports are only well defined once M is known to vary over them, +so methods that return early (or never need coordinates) must not +call it. Returns an array of M values shaped like `objectives`, a +scalar when M is uniform across the supports, or `nothing` if an M +subproblem is infeasible. Extensions implement methods that dispatch +on `sampler`: `nothing` solves an M subproblem at every support, +while a [`GPSampler`](@ref) solves a subset of the supports and +fills the rest with a Gaussian-process upper confidence bound. +""" +function sample_M_values(sampler, objectives, sub, method, support_grids) + error("Unrecognized `sampler` value `$(repr(sampler))` for MBM " * + "on an infinite model. Use `nothing` to solve an M " * + "subproblem at every support, or a `GPSampler` (with " * + "AbstractGPs loaded) to estimate M values with a " * + "Gaussian process.") +end diff --git a/src/mbm.jl b/src/mbm.jl index 23c5096..8f74269 100644 --- a/src/mbm.jl +++ b/src/mbm.jl @@ -87,7 +87,9 @@ function reformulate_disjunct_constraint( }, method::_MBM ) - ref_cons = reformulate_disjunction(model, con, MBM(method.optimizer)) + ref_cons = reformulate_disjunction(model, con, MBM( + method.optimizer, method.default_M, + sampler = method.sampler)) new_ref_cons = Vector{JuMP.AbstractConstraint}() for ref_con in ref_cons append!(new_ref_cons, diff --git a/test/extensions/AbstractGPsDisjunctiveProgramming.jl b/test/extensions/AbstractGPsDisjunctiveProgramming.jl new file mode 100644 index 0000000..2f0e818 --- /dev/null +++ b/test/extensions/AbstractGPsDisjunctiveProgramming.jl @@ -0,0 +1,248 @@ +using InfiniteOpt, HiGHS, AbstractGPs +import DisjunctiveProgramming as DP + +function test_gp_sampler_kwargs() + @test MBM(HiGHS.Optimizer).sampler === nothing + sampler = GPSampler() + @test sampler.kernel === nothing + @test sampler.kappa == 2.5 + @test sampler.budget == 0.25 + @test sampler.detect_uniform_M + @test sampler.lengthscales == [0.05, 0.1, 0.2, 0.4, 0.8] + @test sampler.jitter == 1e-8 + @test sampler.seeds == 4 + kern = SqExponentialKernel() + sampler = GPSampler(kern, kappa = 4.0, budget = 0.1, + detect_uniform_M = false, lengthscales = (0.1, 0.3), + jitter = 1e-6, seeds = [0.0, 0.3, 1.0]) + @test sampler.kernel === kern + @test sampler.kappa == 4.0 + @test sampler.budget == 0.1 + @test !sampler.detect_uniform_M + @test sampler.lengthscales == [0.1, 0.3] + @test sampler.jitter == 1e-6 + @test sampler.seeds == [0.0, 0.3, 1.0] + @test GPSampler(seeds = 6).seeds == 6 + @test MBM(HiGHS.Optimizer, sampler = sampler).sampler === sampler + @test_throws ErrorException GPSampler(kappa = -1) + @test_throws ErrorException GPSampler(budget = 0) + @test_throws ErrorException GPSampler(budget = 1.5) + @test_throws ErrorException GPSampler(lengthscales = Float64[]) + @test_throws ErrorException GPSampler(lengthscales = [-0.1]) + @test_throws ErrorException GPSampler(jitter = -1) + @test_throws ErrorException GPSampler(seeds = 1) + @test_throws ErrorException GPSampler(seeds = [1.5]) + @test_throws ErrorException GPSampler(seeds = Float64[]) +end + +# Mirror of test_raw_M_infinite_scalar: uniform seed M values collapse +# to the exactly-solved scalar under the GP sampler +function test_gp_raw_M_scalar() + model = InfiniteGDPModel() + @infinite_parameter(model, t ∈ [0, 1], num_supports = 5) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, Y[1:2], InfiniteLogical(t)) + @constraint(model, con, x >= 5, Disjunct(Y[1])) + @constraint(model, con2, x <= 3, Disjunct(Y[2])) + @disjunction(model, Y) + mbm = DP._MBM( + MBM(HiGHS.Optimizer, sampler = GPSampler()), model) + sub = DP.copy_model_with_constraints( + model, DP.DisjunctConstraintRef[con2], mbm) + obj = DP.prepare_max_M_objective( + model, JuMP.constraint_object(con), sub) + @test DP.raw_M(sub, obj, mbm) == 5.0 +end + +# With budget = 1.0 every support is solved exactly, so the GP +# sampler must reproduce the exact grid parameter function +function test_gp_raw_M_matches_exact() + function pfunc_values(sampler, supports) + model = InfiniteGDPModel() + @infinite_parameter(model, t ∈ [0, 1], supports = supports) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, Y[1:2], InfiniteLogical(t)) + @parameter_function(model, f == t -> 2*t) + @constraint(model, con, x <= f, Disjunct(Y[1])) + @constraint(model, con2, x >= 0.5, Disjunct(Y[2])) + @disjunction(model, Y) + mbm = DP._MBM( + MBM(HiGHS.Optimizer, sampler = sampler), model) + sub = DP.copy_model_with_constraints( + model, DP.DisjunctConstraintRef[con2], mbm) + obj = DP.prepare_max_M_objective( + model, JuMP.constraint_object(con), sub) + M = DP.raw_M(sub, obj, mbm) + @test M isa InfiniteOpt.GeneralVariableRef + return [InfiniteOpt.raw_function(M)(t_val) for t_val in supports] + end + supports = [0.0, 0.25, 0.5, 0.75, 1.0] + exact_vals = pfunc_values(nothing, supports) + # default kernel, user kernel, and pinned lengthscale all solve + # the same supports here, so the M values match exactly + @test pfunc_values(GPSampler(budget = 1.0), supports) == + exact_vals + @test pfunc_values( + GPSampler(SqExponentialKernel(), budget = 1.0), supports) == + exact_vals + @test pfunc_values(GPSampler(SqExponentialKernel(), + lengthscales = [0.2], budget = 1.0), supports) == exact_vals +end + +# an empty disjunct region makes the M subproblems infeasible; both +# samplers propagate that up to the reformulation error +function test_gp_infeasible_disjunct() + function build() + model = InfiniteGDPModel(HiGHS.Optimizer) + set_silent(model) + @infinite_parameter(model, t ∈ [0, 1], num_supports = 5) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, Y[1:2], InfiniteLogical(t)) + @parameter_function(model, f == t -> 2*t) + @constraint(model, x <= f, Disjunct(Y[1])) + @constraint(model, x >= 8, Disjunct(Y[2])) + @constraint(model, x <= 3, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, 𝔼(x, t)) + return model + end + for sampler in (nothing, GPSampler()) + model = build() + @test_throws ErrorException optimize!(model, + gdp_method = MBM(HiGHS.Optimizer, sampler = sampler)) + end +end + +# optimum (10) needs M(t) >= 10 - 2t pointwise; the GP fill is heuristic +function test_gp_mbm_solve_equivalence() + function solve_with(sampler) + model = InfiniteGDPModel(HiGHS.Optimizer) + set_silent(model) + @infinite_parameter(model, t ∈ [0, 1], num_supports = 20) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, Y[1:2], InfiniteLogical(t)) + @parameter_function(model, f == t -> 2*t) + @constraint(model, x <= f, Disjunct(Y[1])) + @constraint(model, x >= 0.5, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, 𝔼(x, t)) + optimize!(model, + gdp_method = MBM(HiGHS.Optimizer, sampler = sampler)) + @test termination_status(model) == MOI.OPTIMAL + return objective_value(model) + end + obj_exact = solve_with(nothing) + obj_gp = solve_with(GPSampler()) + obj_tuned = solve_with(GPSampler(kappa = 4.0, budget = 0.2)) + @test obj_exact β‰ˆ 10.0 atol = 1e-4 + # over-M can't raise the optimum, under-M can only shave it a bit + @test obj_gp <= obj_exact + 1e-6 + @test obj_gp β‰ˆ obj_exact atol = 1e-2 + @test obj_tuned <= obj_exact + 1e-6 + @test obj_tuned β‰ˆ obj_exact atol = 1e-2 +end + +# Seed placement vs a periodic M. With f(t) = 2|cos(2*pi*t)| on these +# supports, M = 10 - f is 8 at supports 1, 3, 5 and 10 at supports +# 2, 4. Seeds that only hit the M = 8 supports alias the periodic M +# to uniform 8, which caps x at 8 and cuts the optimum from 10 down +# to 9; the default and denser seed grids see both values and stay +# exact. +function test_gp_periodic_M_seeds() + supports = [0.0, 0.25, 0.5, 0.75, 1.0] + function solve_with(sampler) + model = InfiniteGDPModel(HiGHS.Optimizer) + set_silent(model) + @infinite_parameter(model, t ∈ [0, 1], supports = supports) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, Y[1:2], InfiniteLogical(t)) + @parameter_function(model, f == t -> 2 * abs(cos(2 * pi * t))) + @constraint(model, x <= f, Disjunct(Y[1])) + @constraint(model, x >= 0.5, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, 𝔼(x, t)) + optimize!(model, + gdp_method = MBM(HiGHS.Optimizer, sampler = sampler)) + return objective_value(model) + end + @test solve_with(nothing) β‰ˆ 10.0 atol = 1e-6 + @test solve_with(GPSampler()) β‰ˆ 10.0 atol = 1e-6 + @test solve_with(GPSampler(seeds = 5)) β‰ˆ 10.0 atol = 1e-6 + @test solve_with(GPSampler(seeds = [0.0, 0.5, 1.0])) β‰ˆ + 9.0 atol = 1e-6 +end + +# With detection off the uniform M is not collapsed to a scalar: the +# GP is fit and the unsolved supports keep their kappa * sd cushion, +# which must sit above the M that detection would have returned. +function test_gp_detect_uniform_M_off() + function raw_M_with(detect) + model = InfiniteGDPModel() + @infinite_parameter(model, t ∈ [0, 1], num_supports = 20) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, Y[1:2], InfiniteLogical(t)) + @constraint(model, con, x >= 5, Disjunct(Y[1])) + @constraint(model, con2, x <= 3, Disjunct(Y[2])) + @disjunction(model, Y) + mbm = DP._MBM(MBM(HiGHS.Optimizer, + sampler = GPSampler(detect_uniform_M = detect)), model) + sub = DP.copy_model_with_constraints( + model, DP.DisjunctConstraintRef[con2], mbm) + obj = DP.prepare_max_M_objective( + model, JuMP.constraint_object(con), sub) + return DP.raw_M(sub, obj, mbm) + end + @test raw_M_with(true) == 5.0 + M = raw_M_with(false) + @test M isa InfiniteOpt.GeneralVariableRef + raw_fn = InfiniteOpt.raw_function(M) + vals = [raw_fn(t) for t in range(0, 1, length = 20)] + @test all(vals .>= 5.0 - 1e-6) + @test maximum(vals) > 5.0 +end + +# Dependent parameters have no support grid, so turning detection off +# leaves the GP with nothing to fit over +function test_gp_detect_uniform_M_off_dependent() + model = InfiniteGDPModel() + @infinite_parameter(model, ΞΎ[1:2] ∈ [0, 1], num_supports = 4) + @variable(model, 0 <= x <= 10, Infinite(ΞΎ)) + @variable(model, Y[1:2], InfiniteLogical(ΞΎ)) + @constraint(model, con, x >= 5, Disjunct(Y[1])) + @constraint(model, con2, x <= 3, Disjunct(Y[2])) + @disjunction(model, Y) + mbm = DP._MBM(MBM(HiGHS.Optimizer, + sampler = GPSampler(detect_uniform_M = false)), model) + sub = DP.copy_model_with_constraints( + model, DP.DisjunctConstraintRef[con2], mbm) + obj = DP.prepare_max_M_objective( + model, JuMP.constraint_object(con), sub) + @test_throws ErrorException DP.raw_M(sub, obj, mbm) +end + +function test_gp_unknown_sampler_error() + model = InfiniteGDPModel(HiGHS.Optimizer) + set_silent(model) + @infinite_parameter(model, t ∈ [0, 1], num_supports = 5) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, Y[1:2], InfiniteLogical(t)) + @parameter_function(model, f == t -> 2*t) + @constraint(model, x <= f, Disjunct(Y[1])) + @constraint(model, x >= 0.5, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, 𝔼(x, t)) + @test_throws ErrorException optimize!(model, + gdp_method = MBM(HiGHS.Optimizer, sampler = :grid)) +end + +@testset "AbstractGPsDisjunctiveProgramming" begin + test_gp_sampler_kwargs() + test_gp_raw_M_scalar() + test_gp_raw_M_matches_exact() + test_gp_mbm_solve_equivalence() + test_gp_periodic_M_seeds() + test_gp_detect_uniform_M_off() + test_gp_detect_uniform_M_off_dependent() + test_gp_unknown_sampler_error() + test_gp_infeasible_disjunct() +end diff --git a/test/extensions/InfiniteDisjunctiveProgramming.jl b/test/extensions/InfiniteDisjunctiveProgramming.jl index 47bb884..6e96d8c 100644 --- a/test/extensions/InfiniteDisjunctiveProgramming.jl +++ b/test/extensions/InfiniteDisjunctiveProgramming.jl @@ -121,6 +121,36 @@ function test_requires_disaggregation() @test DP.requires_disaggregation(y) == true end +# Bound info for parameter refs in disjunct constraints: parameter +# functions report their support extrema, finite parameters a point, +# other parameters (-Inf, Inf); Hull/PSplit clamp the bounds to +# include 0 and error when a variable is missing bounds. +function test_parameter_bound_info() + model = InfiniteGDPModel() + @infinite_parameter(model, t in [0, 1], supports = [0.0, 0.5, 1.0]) + @infinite_parameter(model, s in [0, 1]) + @finite_parameter(model, p == 2.0) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, y, Infinite(t)) + @parameter_function(model, pf == t -> 2t - 1) + @parameter_function(model, pf2 == (t, s) -> t + s) + @parameter_function(model, pf3 == s -> s) + @test DP.set_variable_bound_info(pf, BigM()) == (-1.0, 1.0) + @test DP.set_variable_bound_info(p, BigM()) == (2.0, 2.0) + @test DP.set_variable_bound_info(t, BigM()) == (-Inf, Inf) + @test DP.set_variable_bound_info(x, BigM()) == (0.0, 10.0) + @test DP.set_variable_bound_info(y, BigM()) == (-Inf, Inf) + # multi-parameter and support-less parameter functions fall back + @test DP.set_variable_bound_info(pf2, BigM()) == (-Inf, Inf) + @test DP.set_variable_bound_info(pf3, BigM()) == (-Inf, Inf) + # Hull and PSplit clamp the bounds to include 0 + @test DP.set_variable_bound_info(pf, Hull()) == (-1.0, 1.0) + @test DP.set_variable_bound_info(p, Hull()) == (0.0, 2.0) + @test DP.set_variable_bound_info(x, Hull()) == (0.0, 10.0) + @test DP.set_variable_bound_info(p, PSplit([[x]])) == (0.0, 2.0) + @test_throws ErrorException DP.set_variable_bound_info(y, Hull()) +end + function test_all_variables_infiniteopt() model = InfiniteGDPModel() @infinite_parameter(model, t ∈ [0, 1]) @@ -413,6 +443,53 @@ function test_raw_M_infinite_param_function() end end +# raw_M over two infinite parameters with different support counts. +# Transcription orders the objective dimensions by parameter group, +# which need not be the ascending order of the grids, so the M values +# must be permuted to line up. Setup: x(t, s) in [0, 10], +# disj1: x <= t + s, disj2: x >= 0.5. Slack r(x) = x - t - s +# maximized over x in [0.5, 10]: 10 - t - s. +function test_raw_M_infinite_two_params() + model = InfiniteGDPModel() + @infinite_parameter(model, t ∈ [0, 1], supports = [0.0, 0.5, 1.0]) + @infinite_parameter(model, s ∈ [0, 1], supports = [0.0, 1.0]) + @variable(model, 0 <= x <= 10, Infinite(t, s)) + @variable(model, Y[1:2], InfiniteLogical(t, s)) + @constraint(model, con, x <= t + s, Disjunct(Y[1])) + @constraint(model, con2, x >= 0.5, Disjunct(Y[2])) + @disjunction(model, Y) + mbm = DP._MBM(MBM(HiGHS.Optimizer), model) + sub = DP.copy_model_with_constraints( + model, DP.DisjunctConstraintRef[con2], mbm) + obj = DP.prepare_max_M_objective( + model, JuMP.constraint_object(con), sub) + M = DP.raw_M(sub, obj, mbm) + @test M isa InfiniteOpt.GeneralVariableRef + raw_fn = InfiniteOpt.raw_function(M) + for t_val in [0.0, 0.5, 1.0], s_val in [0.0, 1.0] + @test raw_fn(t_val, s_val) >= 10.0 - t_val - s_val - 1e-6 + end +end + +# Dependent parameters have no per-parameter support grid, so raw_M +# must not need one when M does not vary. Setup as in +# test_raw_M_infinite_scalar, over a dependent parameter array. +function test_raw_M_infinite_dependent_params() + model = InfiniteGDPModel() + @infinite_parameter(model, ΞΎ[1:2] ∈ [0, 1], num_supports = 4) + @variable(model, 0 <= x <= 10, Infinite(ΞΎ)) + @variable(model, Y[1:2], InfiniteLogical(ΞΎ)) + @constraint(model, con, x >= 5, Disjunct(Y[1])) + @constraint(model, con2, x <= 3, Disjunct(Y[2])) + @disjunction(model, Y) + mbm = DP._MBM(MBM(HiGHS.Optimizer), model) + sub = DP.copy_model_with_constraints( + model, DP.DisjunctConstraintRef[con2], mbm) + obj = DP.prepare_max_M_objective( + model, JuMP.constraint_object(con), sub) + @test DP.raw_M(sub, obj, mbm) == 5.0 +end + # Piecewise-constant max-of-corners: returns the maximum value over # the 2^n corners of the cell containing the query. function test_interpolate() @@ -806,6 +883,7 @@ end test_is_parameter() test_is_parameter_concrete_dispatches() test_requires_disaggregation() + test_parameter_bound_info() test_variable_properties_infiniteopt() test_variable_properties_from_expr() test_variable_properties_from_quad_expr() @@ -834,6 +912,8 @@ end test_interpolate() test_raw_M_infinite_scalar() test_raw_M_infinite_param_function() + test_raw_M_infinite_two_params() + test_raw_M_infinite_dependent_params() test_mbm_finite_and_integer_var() test_mbm_infinite_simple() test_mbm_infinite_param_dependent() diff --git a/test/runtests.jl b/test/runtests.jl index 06e8813..8034f6e 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -24,4 +24,5 @@ include("constraints/disjunction.jl") include("print.jl") include("solve.jl") include("extensions/InfiniteDisjunctiveProgramming.jl") +include("extensions/AbstractGPsDisjunctiveProgramming.jl")