diff --git a/Project.toml b/Project.toml index bdec9be..ba1a492 100644 --- a/Project.toml +++ b/Project.toml @@ -8,14 +8,19 @@ 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" +KernelFunctions = "ec8451be-7e33-11e9-00cf-bbf324bd1392" [extensions] InfiniteDisjunctiveProgramming = "InfiniteOpt" +InfiniteGPDisjunctiveProgramming = ["InfiniteOpt", "AbstractGPs", "KernelFunctions"] [compat] +AbstractGPs = "0.5" Aqua = "0.8" JuMP = "1.18" +KernelFunctions = "0.10" Reexport = "1" julia = "1.10" Juniper = "0.9.3" @@ -30,4 +35,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", "KernelFunctions"] diff --git a/README.md b/README.md index 543d1f7..c3cd037 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). + - `M_sampler`: Strategy for computing M values across the supports of an infinite model. Default: `:auto`, which uses a Gaussian-process sampler (`GPSampler`) when AbstractGPs is loaded and otherwise solves an M subproblem at every support (`:exact`). 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`) activates 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. The filled values are heuristic upper estimates rather than certificates; see the `GPSampler` docstring for the tuning knobs (`kappa`, `budget`) and use `MBM(optimizer, M_sampler = :exact)` to force exact solves. + ## 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/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 5f77dce..38d0a82 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -263,31 +263,72 @@ 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 an M sampler needs it. +function _support_grids(sub::DP.GDPSubmodel, mini_expr) + 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 + +# Resolve `:auto` to the GP sampler when the GP extension is loaded +function _resolve_M_sampler(sampler) + sampler === :auto || return sampler + gp = Base.get_extension(DP, :InfiniteGPDisjunctiveProgramming) + return isnothing(gp) ? :exact : DP.GPSampler() +end + +# Solve the M subproblem exactly at every support +function DP.sample_M_values( + sampler::Symbol, + objectives::AbstractArray, + sub::DP.GDPSubmodel, + method::DP._MBM, + support_grids + ) + sampler === :exact || error( + "Unrecognized `M_sampler` `$(repr(sampler))` for MBM on an " * + "infinite model. Use `:auto`, `:exact`, or a `GPSampler`.") + 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 +# resolved M 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}}()) + sampler = _resolve_M_sampler(method.M_sampler) + M_vals = DP.sample_M_values(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/ext/InfiniteGPDisjunctiveProgramming.jl b/ext/InfiniteGPDisjunctiveProgramming.jl new file mode 100644 index 0000000..887f837 --- /dev/null +++ b/ext/InfiniteGPDisjunctiveProgramming.jl @@ -0,0 +1,133 @@ +module InfiniteGPDisjunctiveProgramming + +import InfiniteOpt, JuMP +import AbstractGPs, KernelFunctions +import DisjunctiveProgramming as DP + +################################################################################ +# GP SAMPLER +################################################################################ +# See the `GPSampler` docstring in `src/extension_api.jl` +struct GPSampler{K} + kappa::Float64 + budget::Float64 + min_solves::Int + kernel::K + detect_uniform_M::Bool +end + +function DP.GPSampler(; + kappa::Real = 2.5, + budget::Real = 0.25, + min_solves::Int = 6, + kernel = nothing, + detect_uniform_M::Bool = true + ) + kappa >= 0 || error("`kappa` must be nonnegative.") + 0 < budget <= 1 || error("`budget` must be in `(0, 1]`.") + min_solves >= 1 || error("`min_solves` must be at least 1.") + return GPSampler(Float64(kappa), Float64(budget), min_solves, + kernel, detect_uniform_M) +end + +################################################################################ +# GP FITTING +################################################################################ +const _LENGTHSCALES = (0.05, 0.1, 0.2, 0.4, 0.8) +const _JITTER = 1e-8 + +# Normalized to [0, 1]^d so one lengthscale works across dimensions +function _support_coords(grids) + idxs = CartesianIndices(length.(grids)) + los = [minimum(g) for g in grids] + rng = [max(maximum(g) - minimum(g), eps()) for g in grids] + return [[(grids[d][I[d]] - los[d]) / rng[d] for d in 1:length(grids)] + for I in vec(idxs)] +end + +# Lengthscale by marginal likelihood unless the user gave a kernel +function _fit_posterior(sampler::GPSampler, X, y) + isnothing(sampler.kernel) || return AbstractGPs.posterior( + AbstractGPs.GP(sampler.kernel)(X, _JITTER), y) + best_post, best_lp = nothing, -Inf + for ls in _LENGTHSCALES + kern = KernelFunctions.with_lengthscale( + KernelFunctions.SqExponentialKernel(), ls) + fx = AbstractGPs.GP(kern)(X, _JITTER) + lp = AbstractGPs.logpdf(fx, y) + if lp > best_lp + best_post, best_lp = AbstractGPs.posterior(fx, y), lp + end + end + return best_post +end + +function _mean_sd(sampler::GPSampler, X, solved) + lis = collect(keys(solved)) + y = [solved[li] for li in lis] + ybar = sum(y) / length(y) + # floored so near-equal solved values still cushion the filled ones + ystd = max(sqrt(sum(abs2, y .- ybar) / max(length(y) - 1, 1)), + 1e-2 * abs(ybar), 1e-8) + post = _fit_posterior(sampler, X[lis], (y .- ybar) ./ ystd) + mz = AbstractGPs.mean(post, X) + vz = max.(AbstractGPs.var(post, X), 0.0) + return mz .* ystd .+ ybar, sqrt.(vz) .* ystd +end + +################################################################################ +# M VALUE SAMPLING +################################################################################ +# Solve M at max-UCB selected supports, fill the rest with the bound +function DP.sample_M_values( + sampler::GPSampler, + objectives::AbstractArray, + sub::DP.GDPSubmodel, + method::DP._MBM, + support_grids + ) + idxs = collect(CartesianIndices(objectives)) + n = length(idxs) + solved = Dict{Int, Float64}() + solve_at(li) = begin + m = DP.raw_M(sub, objectives[idxs[li]], method) + m === nothing && return false + solved[li] = m + return true + end + # golden fractions; even spacing aliases with a periodic M + for s in unique([1, n, 1 + floor(Int, 0.618 * (n - 1)), + 1 + floor(Int, 0.382 * (n - 1))]) + solve_at(s) || 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 = clamp( + ceil(Int, sampler.budget * n), min(sampler.min_solves, n), n) + X = _support_coords(support_grids()) + while length(solved) < budget + ms, ss = _mean_sd(sampler, X, solved) + acq = ms .+ sampler.kappa .* ss + for li in keys(solved) + acq[li] = -Inf + end + solve_at(argmax(acq)) || return nothing + end + M_vals = Array{Float64}(undef, size(objectives)) + if length(solved) == n # nothing left to estimate + for (li, I) in enumerate(idxs) + M_vals[I] = solved[li] + end + return M_vals + end + ms, ss = _mean_sd(sampler, X, solved) + for (li, I) in enumerate(idxs) # exact M values are nonnegative + M_vals[I] = get(solved, li, max(ms[li] + sampler.kappa * ss[li], 0.0)) + end + return M_vals +end + +end diff --git a/src/datatypes.jl b/src/datatypes.jl index bdcd4c2..2bb9038 100644 --- a/src/datatypes.jl +++ b/src/datatypes.jl @@ -368,21 +368,28 @@ 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). +- `M_sampler::Any`: Strategy for computing M values across the supports + of an infinite model (`:auto`). `:auto` uses [`GPSampler`](@ref) when + the AbstractGPs extension is loaded and `:exact` otherwise; `:exact` + solves an M subproblem at every support. Ignored for finite models. """ mutable struct MBM{O, T} <: AbstractReformulationMethod optimizer::O default_M::T - + M_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; M_sampler = :auto + ) where {O, T} + new{O, T}(optimizer, default_M, M_sampler) end end @@ -390,6 +397,7 @@ mutable struct _MBM{O, T, M <: JuMP.AbstractModel} <: AbstractReformulationMetho optimizer::O M::Dict{LogicalVariableRef{M}, Any} default_M::T + M_sampler::Any subproblem_indicators::Vector{LogicalVariableRef{M}} # Cached submodels: indicator => GDPSubmodel. # Typed Any so extensions can store different types. @@ -400,6 +408,7 @@ mutable struct _MBM{O, T, M <: JuMP.AbstractModel} <: AbstractReformulationMetho method.optimizer, Dict{LogicalVariableRef{M}, Any}(), method.default_M, + method.M_sampler, Vector{LogicalVariableRef{M}}(), Dict{LogicalVariableRef{M}, Any}() ) diff --git a/src/extension_api.jl b/src/extension_api.jl index ad3566f..7c220b2 100644 --- a/src/extension_api.jl +++ b/src/extension_api.jl @@ -38,3 +38,64 @@ Y(t, x) ``` """ function InfiniteLogical end + +""" + GPSampler(; kappa = 2.5, budget = 0.25, min_solves = 6, + kernel = nothing, detect_uniform_M = true) + +Creates a Gaussian-process M sampler for [`MBM`](@ref) on infinite +models. Instead of solving an M subproblem at every support of the +infinite parameters, the sampler solves a subset of the supports +selected by an upper-confidence-bound acquisition 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; increase `kappa` for more +conservative estimates or use `MBM(...; M_sampler = :exact)` to solve +every support. This requires that InfiniteOpt and AbstractGPs be +imported first, in which case it is also the default M sampler (see +the `M_sampler` field of [`MBM`](@ref)). + +**Keyword Arguments** +- `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). +- `min_solves::Int`: Minimum number of exactly solved supports (6). +- `kernel`: Covariance kernel for the GP fit. Defaults to a squared + exponential kernel whose lengthscale is selected by maximizing the + marginal likelihood; pass a `KernelFunctions` kernel to override. +- `detect_uniform_M::Bool`: If `true` (the default), M values that + agree at the first few supports are taken to be uniform and used + for every support. This is cheap and is what makes infinite + parameters without a support grid (e.g. dependent ones) workable, + but it assumes M does not vary elsewhere. 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. + +**Example** +```julia +julia> using DisjunctiveProgramming, InfiniteOpt, AbstractGPs, HiGHS + +julia> method = MBM(HiGHS.Optimizer, M_sampler = GPSampler(kappa = 4.0)) +``` +""" +function GPSampler end + +""" + sample_M_values(sampler, objectives, sub, method, support_grids) + +Compute the MBM M values at the transcription supports of an infinite +model. `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 samplers 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 for their sampler types (e.g. [`GPSampler`](@ref)); the +`:exact` sampler solves every support. +""" +function sample_M_values end diff --git a/src/mbm.jl b/src/mbm.jl index 23c5096..4441d95 100644 --- a/src/mbm.jl +++ b/src/mbm.jl @@ -87,7 +87,8 @@ 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, M_sampler = method.M_sampler)) new_ref_cons = Vector{JuMP.AbstractConstraint}() for ref_con in ref_cons append!(new_ref_cons, diff --git a/test/extensions/InfiniteDisjunctiveProgramming.jl b/test/extensions/InfiniteDisjunctiveProgramming.jl index 47bb884..3526d62 100644 --- a/test/extensions/InfiniteDisjunctiveProgramming.jl +++ b/test/extensions/InfiniteDisjunctiveProgramming.jl @@ -376,7 +376,7 @@ function test_raw_M_infinite_scalar() @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) + mbm = DP._MBM(MBM(HiGHS.Optimizer, M_sampler = :exact), model) sub = DP.copy_model_with_constraints( model, DP.DisjunctConstraintRef[con2], mbm) obj = DP.prepare_max_M_objective( @@ -399,7 +399,7 @@ function test_raw_M_infinite_param_function() @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), model) + mbm = DP._MBM(MBM(HiGHS.Optimizer, M_sampler = :exact), model) sub = DP.copy_model_with_constraints( model, DP.DisjunctConstraintRef[con2], mbm) obj = DP.prepare_max_M_objective( @@ -413,6 +413,55 @@ 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, M_sampler = :exact), 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) + for sampler in (:exact, :auto) + mbm = DP._MBM(MBM(HiGHS.Optimizer, M_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) + @test DP.raw_M(sub, obj, mbm) == 5.0 + end +end + # Piecewise-constant max-of-corners: returns the maximum value over # the 2^n corners of the cell containing the query. function test_interpolate() @@ -834,6 +883,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/extensions/InfiniteGPDisjunctiveProgramming.jl b/test/extensions/InfiniteGPDisjunctiveProgramming.jl new file mode 100644 index 0000000..796507b --- /dev/null +++ b/test/extensions/InfiniteGPDisjunctiveProgramming.jl @@ -0,0 +1,241 @@ +using InfiniteOpt, HiGHS, AbstractGPs, KernelFunctions +import DisjunctiveProgramming as DP + +# Helpers to access internal functions of the two extensions +const IGDP = Base.get_extension(DP, :InfiniteGPDisjunctiveProgramming) +const IDP = Base.get_extension(DP, :InfiniteDisjunctiveProgramming) + +function test_gp_sampler_creation() + sampler = GPSampler() + @test sampler isa IGDP.GPSampler + @test sampler.kappa == 2.5 + @test sampler.budget == 0.25 + @test sampler.min_solves == 6 + @test isnothing(sampler.kernel) + @test sampler.detect_uniform_M + kern = with_lengthscale(SqExponentialKernel(), 0.3) + sampler = GPSampler(kappa = 4.0, budget = 0.1, min_solves = 3, + kernel = kern, detect_uniform_M = false) + @test sampler.kappa == 4.0 + @test sampler.budget == 0.1 + @test sampler.min_solves == 3 + @test sampler.kernel === kern + @test !sampler.detect_uniform_M + @test_throws ErrorException GPSampler(kappa = -1) + @test_throws ErrorException GPSampler(budget = 0) + @test_throws ErrorException GPSampler(budget = 1.5) + @test_throws ErrorException GPSampler(min_solves = 0) +end + +function test_gp_sampler_resolution() + # the GP extension is loaded, so :auto resolves to a GPSampler + @test IDP._resolve_M_sampler(:auto) isa IGDP.GPSampler + @test IDP._resolve_M_sampler(:exact) === :exact + sampler = GPSampler(kappa = 3.0) + @test IDP._resolve_M_sampler(sampler) === sampler + @test MBM(HiGHS.Optimizer).M_sampler === :auto + @test MBM(HiGHS.Optimizer, M_sampler = :exact).M_sampler === :exact +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), 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 few supports the budget floor covers every support, so the GP +# sampler solves all of them exactly and must reproduce the exact +# grid parameter function +function test_gp_raw_M_matches_exact() + function pfunc_values(M_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, M_sampler = M_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(:exact, supports) + @test pfunc_values(GPSampler(), supports) == exact_vals + # a user kernel skips the lengthscale fit but solves the same supports + kern = with_lengthscale(SqExponentialKernel(), 0.2) + @test pfunc_values(GPSampler(kernel = kern), 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 (:exact, GPSampler()) + model = build() + @test_throws ErrorException optimize!(model, + gdp_method = MBM(HiGHS.Optimizer, M_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(M_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, M_sampler = M_sampler)) + @test termination_status(model) == MOI.OPTIMAL + return objective_value(model) + end + obj_exact = solve_with(:exact) + obj_auto = solve_with(:auto) + obj_gp = 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_auto <= obj_exact + 1e-6 + @test obj_auto ≈ obj_exact atol = 1e-2 + @test obj_gp <= obj_exact + 1e-6 + @test obj_gp ≈ obj_exact atol = 1e-2 +end + +# A periodic M must not read as uniform. 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, so evenly spaced probes alias and collapse M to 8, +# which caps x at 8 and cuts the optimum from 10 down to 9. +function test_gp_periodic_M_not_uniform() + supports = [0.0, 0.25, 0.5, 0.75, 1.0] + function solve_with(M_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, M_sampler = M_sampler)) + return objective_value(model) + end + @test solve_with(:exact) ≈ 10.0 atol = 1e-6 + @test solve_with(GPSampler()) ≈ 10.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, + M_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, + M_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, M_sampler = :grid)) +end + +@testset "InfiniteGPDisjunctiveProgramming" begin + test_gp_sampler_creation() + test_gp_sampler_resolution() + test_gp_raw_M_scalar() + test_gp_raw_M_matches_exact() + test_gp_mbm_solve_equivalence() + test_gp_periodic_M_not_uniform() + 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/runtests.jl b/test/runtests.jl index 06e8813..569a12b 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/InfiniteGPDisjunctiveProgramming.jl")