Skip to content
Open
5 changes: 4 additions & 1 deletion Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"]
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: `ExhaustiveSampler()`, 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:

Expand Down Expand Up @@ -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).
Expand Down
173 changes: 173 additions & 0 deletions ext/AbstractGPsDisjunctiveProgramming.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
module AbstractGPsDisjunctiveProgramming

import AbstractGPs
import AbstractGPs.KernelFunctions
import DisjunctiveProgramming as DP

################################################################################
# SAMPLER CONFIG
################################################################################
# The concrete sampler behind DP.GPSampler; the base package only
# carries the function stub, so constructing one requires AbstractGPs.
struct _GPSampler{F} <: DP.AbstractMBMSampler
f::F
std_dev_margin::Float64
frac_supports::Float64
detect_uniform_M::Bool
initial_supports::Union{Int, Vector{Float64}}

function _GPSampler(
f::F;
std_dev_margin::Real = 2.5,
frac_supports::Real = 0.25,
detect_uniform_M::Bool = true,
initial_supports = 4
) where {F}
f isa Union{Nothing, AbstractGPs.GP} || error(
"`f` must be an `AbstractGPs.GP` prior, e.g. " *
"`GP(Matern52Kernel())`.")
std_dev_margin >= 0 || error("`std_dev_margin` must be nonnegative.")
0 < frac_supports <= 1 || error("`frac_supports` must be in `(0, 1]`.")
if initial_supports isa Int
initial_supports >= 2 ||
error("`initial_supports` must be at least 2.")
else
initial_supports = collect(Float64, initial_supports)
(!isempty(initial_supports) &&
all(frac -> 0 <= frac <= 1, initial_supports)) ||
error("`initial_supports` must be fractions in `[0, 1]`.")
end
new{F}(f, Float64(std_dev_margin), Float64(frac_supports),
detect_uniform_M, initial_supports)
end
end

DP.GPSampler(f = nothing; kwargs...) = _GPSampler(f; kwargs...)

Comment on lines +7 to +46

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pulsipher is this ok?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is in the right direction, but it is committing type piracy. The overloading should use a type specific to AbstractGPs. Do they have an abstract type you can use? Perhaps you can pass an AbstractGP

################################################################################
# GP FITTING
################################################################################
# Normalized to [0, 1]^d so one lengthscale works across dimensions.
# An independent parameter contributes one coordinate; a dependent
# group contributes its joint-support column.
function _support_coords(grids::Tuple)::Vector{Vector{Float64}}
axis_coords = map(grids) do g
g isa AbstractMatrix ? [g[:, j] for j in axes(g, 2)] :
[[v] for v in g]
end
coords = [reduce(vcat, getindex.(axis_coords, Tuple(I)))
for I in vec(CartesianIndices(length.(axis_coords)))]
dims = eachindex(first(coords))
mins = [minimum(c[d] for c in coords) for d in dims]
ranges = [max(maximum(c[d] for c in coords) - mins[d], eps())
for d in dims]
return [[(c[d] - mins[d]) / ranges[d] for d in dims]
for c in coords]
end

# Defaults behind GPSampler(): candidate lengthscales for the
# marginal-likelihood fit (relative to the unit box) and the
# observation-noise nugget.
const _LENGTHSCALES = [0.05, 0.1, 0.2, 0.4, 0.8]
const _JITTER = 1e-8

# A user prior is used as given; the default squared exponential has
# its lengthscale selected by marginal likelihood
function _fit_posterior(
sampler::_GPSampler,
X::Vector{Vector{Float64}},
y::Vector{Float64}
)
sampler.f === nothing ||
return AbstractGPs.posterior(sampler.f(X, _JITTER), y)
best_posterior, best_log_prob = nothing, -Inf
for lengthscale in _LENGTHSCALES
kernel = KernelFunctions.with_lengthscale(
KernelFunctions.SqExponentialKernel(), lengthscale)
finite_gp = AbstractGPs.GP(kernel)(X, _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::_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)
posterior = _fit_posterior(
sampler, X[solved_indices], (y .- y_mean) ./ y_scale)
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::_GPSampler,
objectives::AbstractArray,
sub::DP.GDPSubmodel,
method::DP._MBM,
support_grids::Tuple
)
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 initial count, or user-given fractions
fractions = sampler.initial_supports isa Int ?
range(0, 1, length = sampler.initial_supports) :
sampler.initial_supports
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
probes = collect(values(solved))
all(==(first(probes)), probes) && return first(probes)
end
solve_target = min(ceil(Int, sampler.frac_supports * n), n)
X = _support_coords(support_grids)
while length(solved) < solve_target
means, sds = _mean_sd(sampler, X, solved)
acquisition = means .+ sampler.std_dev_margin .* 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.std_dev_margin * sds[index], 0.0))
end
return M_vals
end

end
100 changes: 64 additions & 36 deletions ext/InfiniteDisjunctiveProgramming.jl
Original file line number Diff line number Diff line change
Expand Up @@ -238,56 +238,84 @@ function DP.prepare_max_M_objective(
return obj.set.lower - obj_func
end

# Constant interpolation
function _interpolate(
grids::NTuple{N, AbstractVector{<:Real}},
values::AbstractArray{<:Real, N}
) where {N}
# Candidate indices along one axis of the M array: the corners of the
# grid cell bracketing a scalar query (independent parameter), or the
# column matching a joint-support query (dependent group; every column
# when the query is off-support, so the estimate stays conservative)
function _axis_candidates(grid::AbstractVector{<:Real}, arg::Real)
lo = clamp(searchsortedlast(grid, arg), 1, length(grid) - 1)
return lo:(lo + 1)
end
function _axis_candidates(grid::AbstractMatrix{<:Real}, arg)
j = findfirst(k -> isapprox(view(grid, :, k), arg, atol = 1e-10),
axes(grid, 2))
return isnothing(j) ? axes(grid, 2) : (j:j)
end

# Constant interpolation: max of `values` over the candidate indices
function _interpolate(grids::Tuple, values::AbstractArray{<:Real})
# mimic the call form of Interpolations.jl's interpolation
return (args...) -> _interpolate_at(grids, values, args)
return (args...) -> maximum(
values[I...] for I in Iterators.product(
map(_axis_candidates, grids, args)...))
end

# The infinite parameters of `mini_expr` and their supports, in the
# ascending order of `parameter_refs`. An independent parameter gives
# its sorted support vector; a dependent group gives the matrix whose
# columns are its joint supports. Grids are read off the mini model so
# their column order matches the transcription axes; the returned
# prefs are the main-model parameters.
function _support_grids(
sub::DP.GDPSubmodel, mini_expr::JuMP.AbstractJuMPScalar)
reverse_map = Dict(ws[1] => v for (v, ws) in sub.fwd_map)
Comment thread
dnguyen227 marked this conversation as resolved.
mini_prefs = InfiniteOpt.parameter_refs(mini_expr)
prefs = Tuple(getindex.(Ref(reverse_map), p) for p in mini_prefs)
return prefs, Tuple(InfiniteOpt.supports(p) for p in mini_prefs)
end

function _interpolate_at(
grids::NTuple{N, AbstractVector{<:Real}},
values::AbstractArray{<:Real, N},
args::NTuple{N, <:Real}
) where {N}
# lower-corner cell index per dimension
idx_lo = ntuple(d ->
clamp(searchsortedlast(grids[d], args[d]),1, length(grids[d]) - 1), N
# Solve the M subproblem exactly at every support
function DP.sample_M_values(
sampler::DP.ExhaustiveSampler,
objectives::AbstractArray,
sub::DP.GDPSubmodel,
method::DP._MBM,
support_grids::Tuple
)
# max over the 2^N corners; bit d of k picks lower or upper
return maximum(
values[ntuple(d -> idx_lo[d] +((k >> (d - 1)) & 1), N)...]
for k in 0:(2^N - 1)
)
M_vals = Array{Float64}(undef, size(objectives))
for I in eachindex(objectives)
m = DP.raw_M(sub, objectives[I], method)
Comment thread
dnguyen227 marked this conversation as resolved.
m === nothing && return nothing
M_vals[I] = m
end
return M_vals
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.
# 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(
Comment thread
dnguyen227 marked this conversation as resolved.
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}}())
prefs, grids = _support_grids(sub, mini_expr)
M_vals = DP.sample_M_values(method.sampler, objectives,
inner_sub, method, grids)
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)
main = JuMP.owner_model(first(prefs))
grids = Tuple(InfiniteOpt.supports(p) for p in prefs)
main = JuMP.owner_model(first(keys(sub.fwd_map)))
param_func = InfiniteOpt.build_parameter_function(
error, _interpolate(grids, M_vals), prefs)
return InfiniteOpt.add_parameter_function(main, param_func)
Expand Down
Loading
Loading