Example: Mixed integer optimization (MILP)
This example demonstrates the extension of ConstraintTree
bounds structures to accommodate new kinds of problems. In particular, we create a new kind of Bound
that is restricting the value to be a full integer, and then solve a geometric problem with that.
Creating a custom bound
All bounds contained in constraints are subtypes of the abstract ConstraintTrees.Bound
. These include ConstraintTrees.EqualTo
and ConstraintTrees.Between
, but the types can be extended as necessary, given the final rewriting of the constraint system to JuMP can handle the new bounds.
Let's make a small "marker" bound for something that needs to be integer-ish, between 2 integers:
import ConstraintTrees as C
mutable struct IntegerFromTo <: C.Bound
from::Int
to::Int
end
We can now write e.g. a bound on the number on a thrown six-sided die as follows:
IntegerFromTo(1, 6)
Main.var"Main".IntegerFromTo(1, 6)
...and include this bound in constraints and variables:
dice_system = C.variables(keys = [:first_dice, :second_dice], bounds = IntegerFromTo(1, 6))
ConstraintTree with 2 elements:
:first_dice => Constraint(LinearValue(#= ... =#), Main.var"Main".IntegerFromTo(1, 6))
:second_dice => Constraint(LinearValue(#= ... =#), Main.var"Main".IntegerFromTo(1, 6))
Now the main thing that is left is to be able to translate this bound to JuMP for solving. We can slightly generalize our constraint-translation system from the previous examples for this purpose, by separating out the functions that create the constraints:
import JuMP
function jump_constraint(m, x, v::C.Value, b::C.EqualTo)
JuMP.@constraint(m, C.substitute(v, x) == b.equal_to)
end
function jump_constraint(m, x, v::C.Value, b::C.Between)
isinf(b.lower) || JuMP.@constraint(m, C.substitute(v, x) >= b.lower)
isinf(b.upper) || JuMP.@constraint(m, C.substitute(v, x) <= b.upper)
end
jump_constraint (generic function with 2 methods)
JuMP does not support direct integrality constraints, so we need to make a small digression with a slack variable:
function jump_constraint(m, x, v::C.Value, b::IntegerFromTo)
var = JuMP.@variable(m, integer = true)
JuMP.@constraint(m, var >= b.from)
JuMP.@constraint(m, var <= b.to)
JuMP.@constraint(m, C.substitute(v, x) == var)
end
function milp_optimized_vars(cs::C.ConstraintTree, objective::C.Value, optimizer)
model = JuMP.Model(optimizer)
JuMP.@variable(model, x[1:C.variable_count(cs)])
JuMP.@objective(model, JuMP.MAX_SENSE, C.substitute(objective, x))
C.traverse(cs) do c
isnothing(c.bound) || jump_constraint(model, x, c.value, c.bound)
end
JuMP.set_silent(model)
JuMP.optimize!(model)
JuMP.value.(model[:x])
end
milp_optimized_vars (generic function with 1 method)
Let's try to solve a tiny system with the dice first. What's the best value we can throw if the dice are thrown at least 1.5 points apart?
dice_system *=
:points_distance^C.Constraint(
dice_system.first_dice.value - dice_system.second_dice.value,
C.Between(1.5, Inf),
)
ConstraintTree with 3 elements:
:first_dice => Constraint(LinearValue(#= ... =#), Main.var"Main".IntegerFromTo(1, 6))
:points_distance => Constraint(LinearValue(#= ... =#), Between(1.5, Inf))
:second_dice => Constraint(LinearValue(#= ... =#), Main.var"Main".IntegerFromTo(1, 6))
For solving, we use GLPK (it has MILP capabilities).
import GLPK
dices_thrown = C.substitute_values(
dice_system,
milp_optimized_vars(
dice_system,
dice_system.first_dice.value + dice_system.second_dice.value,
GLPK.Optimizer,
),
)
Tree{Float64} with 3 elements:
:first_dice => 6.0
:points_distance => 2.0
:second_dice => 4.0
A note on pretty-printing of custom extensions
By default, pretty-printing via pretty
attempts to fall back to Base.show
for any value which has no explicit overload of pretty
. In particular, the bounds in our MILP system are formatted as follows:
C.pretty(dice_system)
┬─first_dice: 1.0*x[1]; Main.var"Main".IntegerFromTo(1, 6)
├─points_distance: 1.0*x[1] + -1.0*x[2] ∈ [1.5, Inf]
╰─second_dice: 1.0*x[2]; Main.var"Main".IntegerFromTo(1, 6)
To provide a prettier rendering, it is sufficient to provide a matching overload of pretty
:
function C.pretty(io::IO, x::IntegerFromTo; style_args...)
print(io, " ∈ {$(x.from) … $(x.to)}")
end
C.pretty(dice_system)
┬─first_dice: 1.0*x[1] ∈ {1 … 6}
├─points_distance: 1.0*x[1] + -1.0*x[2] ∈ [1.5, Inf]
╰─second_dice: 1.0*x[2] ∈ {1 … 6}
A more realistic example with geometry
Let's find the size of the smallest right-angled triangle with integer side sizes (aka a Pythagorean triple).
vars = C.variables(keys = [:a, :b, :c], bounds = IntegerFromTo(1, 100))
ConstraintTree with 3 elements:
:a => Constraint(LinearValue(#= ... =#), Main.var"Main".IntegerFromTo(1, 100))
:b => Constraint(LinearValue(#= ... =#), Main.var"Main".IntegerFromTo(1, 100))
:c => Constraint(LinearValue(#= ... =#), Main.var"Main".IntegerFromTo(1, 100))
For simpliclty, we make a shortcut for "values" in all variables:
v = C.map(C.value, vars, C.Value)
Tree{Value} with 3 elements:
:a => LinearValue([1], [1.0])
:b => LinearValue([2], [1.0])
:c => LinearValue([3], [1.0])
With that shortcut, the constraint tree constructs quite easily:
triangle_system =
:sides^vars *
:circumference^C.Constraint(sum(values(v))) *
:a_less_than_b^C.Constraint(v.b - v.a, (0, Inf)) *
:b_less_than_c^C.Constraint(v.c - v.b, (0, Inf)) *
:right_angled^C.Constraint(C.squared(v.a) + C.squared(v.b) - C.squared(v.c), 0.0)
C.pretty(triangle_system, format_variable = i -> ["", "A", "B", "C"][i+1])
┬─a_less_than_b: -1.0A + 1.0B ∈ [0.0, Inf]
├─b_less_than_c: -1.0B + 1.0C ∈ [0.0, Inf]
├─circumference: 1.0A + 1.0B + 1.0C
├─right_angled: 1.0AA + 1.0BB + -1.0CC = 0.0
╰─sides
╰─┬─a: 1.0A ∈ {1 … 100}
├─b: 1.0B ∈ {1 … 100}
╰─c: 1.0C ∈ {1 … 100}
We will need a solver that supports both quadratic and integer optimization:
import SCIP
triangle_sides =
C.substitute_values(
triangle_system,
milp_optimized_vars(
triangle_system,
-triangle_system.circumference.value,
SCIP.Optimizer,
),
).sides
Tree{Float64} with 3 elements:
:a => 3.0
:b => 4.0
:c => 5.0
This page was generated using Literate.jl.