Callbacks
This tutorial was generated using Literate.jl. Download the source as a .jl file.
This tutorial demonstrates the various solver-independent and solver-dependent callbacks supported by JuMP, including lazy constraints, user cuts, heuristic solutions, and solver-agnostic callback code using package extensions.
Learning intentions:
- Intercept the branch-and-bound process to add lazy constraints, user cuts, and heuristic solutions that guide the solver toward the optimal integer solution
- Distinguish between solver-independent callbacks (lazy constraints, user cuts, heuristic solutions) and solver-dependent callbacks that call into a solver's native C API
- Write solver-agnostic callback code using Julia package extensions so that a single package transparently supports multiple solvers
Required packages
This tutorial uses the following packages:
using JuMP
import Gurobi
import Ipopt
import Random
import TestThis tutorial uses the MathOptInterface API. By default, JuMP exports the MOI symbol as an alias for the MathOptInterface.jl package. We recommend making this more explicit in your code by adding the following lines:
import MathOptInterface as MOILazy constraints
Here is an example of using a lazy constraint callback with Gurobi. For more information about lazy constraints, see the Lazy constraints section of the manual.
function example_lazy_constraint()
model = Model(Gurobi.Optimizer)
set_silent(model)
@variable(model, 0 <= x <= 2.5, Int)
@variable(model, 0 <= y <= 2.5, Int)
@objective(model, Max, y)
lazy_called = false
function my_callback_function(cb_data)
lazy_called = true
x_val = callback_value(cb_data, x)
y_val = callback_value(cb_data, y)
println("Called from (x, y) = ($x_val, $y_val)")
status = callback_node_status(cb_data, model)
if status == MOI.CALLBACK_NODE_STATUS_FRACTIONAL
println(" - Solution is integer infeasible!")
elseif status == MOI.CALLBACK_NODE_STATUS_INTEGER
println(" - Solution is integer feasible!")
else
@assert status == MOI.CALLBACK_NODE_STATUS_UNKNOWN
println(" - I don't know if the solution is integer feasible :(")
end
if y_val - x_val > 1 + 1e-6
con = @build_constraint(y - x <= 1)
println("Adding $(con)")
MOI.submit(model, MOI.LazyConstraint(cb_data), con)
elseif y_val + x_val > 3 + 1e-6
con = @build_constraint(y + x <= 3)
println("Adding $(con)")
MOI.submit(model, MOI.LazyConstraint(cb_data), con)
end
return
end
set_attribute(model, MOI.LazyConstraintCallback(), my_callback_function)
optimize!(model)
assert_is_solved_and_feasible(model)
Test.@test lazy_called
Test.@test value(x) == 1
Test.@test value(y) == 2
println("Optimal solution (x, y) = ($(value(x)), $(value(y)))")
return
end
example_lazy_constraint()Set parameter WLSAccessID
Set parameter WLSSecret
Set parameter LicenseID to value 722777
WLS license 722777 - registered to JuMP Development
Called from (x, y) = (-0.0, 2.0)
- Solution is integer feasible!
Adding ScalarConstraint{AffExpr, MathOptInterface.LessThan{Float64}}(y - x, MathOptInterface.LessThan{Float64}(1.0))
Called from (x, y) = (2.0, 2.0)
- Solution is integer feasible!
Adding ScalarConstraint{AffExpr, MathOptInterface.LessThan{Float64}}(y + x, MathOptInterface.LessThan{Float64}(3.0))
Called from (x, y) = (2.0, 2.0)
- Solution is integer feasible!
Adding ScalarConstraint{AffExpr, MathOptInterface.LessThan{Float64}}(y + x, MathOptInterface.LessThan{Float64}(3.0))
Called from (x, y) = (2.0, 2.0)
- Solution is integer feasible!
Adding ScalarConstraint{AffExpr, MathOptInterface.LessThan{Float64}}(y + x, MathOptInterface.LessThan{Float64}(3.0))
Called from (x, y) = (-0.0, 2.0)
- Solution is integer feasible!
Adding ScalarConstraint{AffExpr, MathOptInterface.LessThan{Float64}}(y - x, MathOptInterface.LessThan{Float64}(1.0))
Called from (x, y) = (1.0, 2.0)
- Solution is integer feasible!
Optimal solution (x, y) = (1.0, 2.0)User-cuts
Here is an example of using a user cut callback with Gurobi. For more information about user cuts, see the User cuts section of the manual.
function example_user_cut_constraint()
Random.seed!(1)
N = 30
item_weights, item_values = rand(N), rand(N)
model = Model(Gurobi.Optimizer)
set_silent(model)
# Turn off "Cuts" parameter so that our new one must be called. In real
# models, you should leave "Cuts" turned on.
set_attribute(model, "Cuts", 0)
@variable(model, x[1:N], Bin)
@constraint(model, sum(item_weights[i] * x[i] for i in 1:N) <= 10)
@objective(model, Max, sum(item_values[i] * x[i] for i in 1:N))
callback_called = false
function my_callback_function(cb_data)
callback_called = true
x_vals = callback_value.(Ref(cb_data), x)
accumulated = sum(item_weights[i] for i in 1:N if x_vals[i] > 1e-4)
println("Called with accumulated = $(accumulated)")
n_terms = sum(1 for i in 1:N if x_vals[i] > 1e-4)
if accumulated > 10
con = @build_constraint(
sum(x[i] for i in 1:N if x_vals[i] > 0.5) <= n_terms - 1
)
println("Adding $(con)")
MOI.submit(model, MOI.UserCut(cb_data), con)
end
end
set_attribute(model, MOI.UserCutCallback(), my_callback_function)
optimize!(model)
assert_is_solved_and_feasible(model)
Test.@test callback_called
@show callback_called
return
end
example_user_cut_constraint()Set parameter WLSAccessID
Set parameter WLSSecret
Set parameter LicenseID to value 722777
WLS license 722777 - registered to JuMP Development
Called with accumulated = 10.37975831721494
Adding ScalarConstraint{AffExpr, MathOptInterface.LessThan{Float64}}(x[1] + x[2] + x[3] + x[4] + x[5] + x[7] + x[8] + x[9] + x[10] + x[11] + x[12] + x[13] + x[14] + x[16] + x[17] + x[18] + x[20] + x[22] + x[23] + x[25] + x[26] + x[28] + x[29] + x[30], MathOptInterface.LessThan{Float64}(23.0))
Called with accumulated = 10.37975831721494
Adding ScalarConstraint{AffExpr, MathOptInterface.LessThan{Float64}}(x[1] + x[2] + x[3] + x[4] + x[5] + x[7] + x[8] + x[9] + x[10] + x[12] + x[13] + x[14] + x[16] + x[17] + x[18] + x[20] + x[22] + x[23] + x[25] + x[26] + x[28] + x[29] + x[30], MathOptInterface.LessThan{Float64}(23.0))
Called with accumulated = 10.37975831721494
Adding ScalarConstraint{AffExpr, MathOptInterface.LessThan{Float64}}(x[1] + x[2] + x[3] + x[4] + x[5] + x[7] + x[8] + x[9] + x[10] + x[11] + x[12] + x[13] + x[14] + x[16] + x[17] + x[18] + x[20] + x[22] + x[23] + x[25] + x[26] + x[29] + x[30], MathOptInterface.LessThan{Float64}(23.0))
Called with accumulated = 10.37975831721494
Adding ScalarConstraint{AffExpr, MathOptInterface.LessThan{Float64}}(x[1] + x[2] + x[3] + x[4] + x[5] + x[7] + x[8] + x[9] + x[10] + x[12] + x[13] + x[14] + x[16] + x[17] + x[18] + x[20] + x[22] + x[23] + x[25] + x[26] + x[28] + x[29] + x[30], MathOptInterface.LessThan{Float64}(23.0))
Called with accumulated = 10.37975831721494
Adding ScalarConstraint{AffExpr, MathOptInterface.LessThan{Float64}}(x[1] + x[2] + x[3] + x[4] + x[5] + x[7] + x[8] + x[9] + x[10] + x[12] + x[13] + x[14] + x[16] + x[17] + x[18] + x[20] + x[22] + x[23] + x[25] + x[26] + x[28] + x[29] + x[30], MathOptInterface.LessThan{Float64}(23.0))
Called with accumulated = 10.37975831721494
Adding ScalarConstraint{AffExpr, MathOptInterface.LessThan{Float64}}(x[1] + x[2] + x[3] + x[4] + x[5] + x[7] + x[8] + x[9] + x[10] + x[12] + x[13] + x[14] + x[16] + x[17] + x[18] + x[20] + x[22] + x[23] + x[25] + x[26] + x[28] + x[29] + x[30], MathOptInterface.LessThan{Float64}(23.0))
Called with accumulated = 10.585271197221452
Adding ScalarConstraint{AffExpr, MathOptInterface.LessThan{Float64}}(x[1] + x[2] + x[3] + x[4] + x[5] + x[7] + x[8] + x[9] + x[10] + x[11] + x[12] + x[13] + x[14] + x[16] + x[17] + x[18] + x[20] + x[22] + x[23] + x[25] + x[26] + x[29] + x[30], MathOptInterface.LessThan{Float64}(23.0))
Called with accumulated = 10.37975831721494
Adding ScalarConstraint{AffExpr, MathOptInterface.LessThan{Float64}}(x[1] + x[2] + x[3] + x[4] + x[5] + x[7] + x[8] + x[9] + x[10] + x[12] + x[13] + x[14] + x[16] + x[17] + x[18] + x[20] + x[22] + x[23] + x[25] + x[26] + x[28] + x[29] + x[30], MathOptInterface.LessThan{Float64}(23.0))
callback_called = trueHeuristic solutions
Here is an example of using a heuristic solution callback with Gurobi. For more information about heuristic solutions, see the Heuristic solutions section of the manual.
function example_heuristic_solution()
Random.seed!(1)
N = 30
item_weights, item_values = rand(N), rand(N)
model = Model(Gurobi.Optimizer)
set_silent(model)
# Turn off "Heuristics" parameter so that our new one must be called. In
# real models, you should leave "Heuristics" turned on.
set_attribute(model, "Heuristics", 0)
@variable(model, x[1:N], Bin)
@constraint(model, sum(item_weights[i] * x[i] for i in 1:N) <= 10)
@objective(model, Max, sum(item_values[i] * x[i] for i in 1:N))
callback_called = false
function my_callback_function(cb_data)
callback_called = true
x_vals = callback_value.(Ref(cb_data), x)
ret =
MOI.submit(model, MOI.HeuristicSolution(cb_data), x, floor.(x_vals))
println("Heuristic solution status = $(ret)")
Test.@test ret in (
MOI.HEURISTIC_SOLUTION_ACCEPTED,
MOI.HEURISTIC_SOLUTION_REJECTED,
)
end
set_attribute(model, MOI.HeuristicCallback(), my_callback_function)
optimize!(model)
assert_is_solved_and_feasible(model)
Test.@test callback_called
return
end
example_heuristic_solution()Set parameter WLSAccessID
Set parameter WLSSecret
Set parameter LicenseID to value 722777
WLS license 722777 - registered to JuMP Development
Heuristic solution status = HEURISTIC_SOLUTION_ACCEPTED
Heuristic solution status = HEURISTIC_SOLUTION_REJECTEDSolver-dependent callback
Some solvers expose solver-dependent callbacks. The syntax of the callback depends on the solver. Typically, this requires you to interact with the low-level C API of the solver. If a solver supports a solver-dependent callback this will be documented in the README of the solver wrapper.
Here's an example of Gurobi's:
function example_solver_dependent_callback_gurobi()
model = direct_model(Gurobi.Optimizer())
@variable(model, 0 <= x <= 2.5, Int)
@variable(model, 0 <= y <= 2.5, Int)
@objective(model, Max, y)
cb_calls = Cint[]
function my_callback_function(cb_data, cb_where::Cint)
# You can reference variables outside the function as normal
push!(cb_calls, cb_where)
# You can select where the callback is run
if cb_where == Gurobi.GRB_CB_MIPNODE
# You can query a callback attribute using GRBcbget
resultP = Ref{Cint}()
Gurobi.GRBcbget(
cb_data,
cb_where,
Gurobi.GRB_CB_MIPNODE_STATUS,
resultP,
)
if resultP[] != Gurobi.GRB_OPTIMAL
return # Solution is something other than optimal.
end
elseif cb_where != Gurobi.GRB_CB_MIPSOL
return
end
# Before querying `callback_value`, you must call:
Gurobi.load_callback_variable_primal(cb_data, cb_where)
x_val = callback_value(cb_data, x)
y_val = callback_value(cb_data, y)
# You can submit solver-independent MathOptInterface attributes such as
# lazy constraints, user-cuts, and heuristic solutions.
if y_val - x_val > 1 + 1e-6
con = @build_constraint(y - x <= 1)
MOI.submit(model, MOI.LazyConstraint(cb_data), con)
elseif y_val + x_val > 3 + 1e-6
con = @build_constraint(y + x <= 3)
MOI.submit(model, MOI.LazyConstraint(cb_data), con)
end
# You can terminate the callback as follows:
Gurobi.GRBterminate(backend(model))
return
end
# You _must_ set this parameter if using lazy constraints.
set_attribute(model, "LazyConstraints", 1)
set_attribute(model, Gurobi.CallbackFunction(), my_callback_function)
optimize!(model)
Test.@test termination_status(model) == MOI.INTERRUPTED
return
end
example_solver_dependent_callback_gurobi()Set parameter WLSAccessID
Set parameter WLSSecret
Set parameter LicenseID to value 722777
WLS license 722777 - registered to JuMP Development
Set parameter LazyConstraints to value 1
Gurobi Optimizer version 13.0.2 build v13.0.2rc1 (linux64 - "Ubuntu 24.04.4 LTS")
CPU model: AMD EPYC 9V74 80-Core Processor, instruction set [SSE2|AVX|AVX2]
Thread count: 2 physical cores, 4 logical processors, using up to 4 threads
Non-default parameters:
LazyConstraints 1
WLS license 722777 - registered to JuMP Development
Optimize a model with 0 rows, 2 columns and 0 nonzeros (Max)
Model fingerprint: 0x1cb4e750
Model has 1 linear objective coefficients
Variable types: 0 continuous, 2 integer (0 binary)
Coefficient statistics:
Matrix range [0e+00, 0e+00]
Objective range [1e+00, 1e+00]
Bounds range [2e+00, 2e+00]
RHS range [0e+00, 0e+00]
Presolve time: 0.00s
Explored 0 nodes (0 simplex iterations) in 0.02 seconds (0.00 work units)
Thread count was 1 (of 4 available processors)
Solution count 0
Solve interrupted
Best objective -, best bound -, gap -
User-callback calls 33, time in user-callback 0.05 secAnd here is an example of Ipopt's:
function example_solver_dependent_callback_ipopt()
model = Model(Ipopt.Optimizer)
set_silent(model)
@variable(model, x >= 1)
@objective(model, Min, x + 0.5)
x_vals = Float64[]
function my_callback(
alg_mod::Cint,
iter_count::Cint,
obj_value::Float64,
inf_pr::Float64,
inf_du::Float64,
mu::Float64,
d_norm::Float64,
regularization_size::Float64,
alpha_du::Float64,
alpha_pr::Float64,
ls_trials::Cint,
)
push!(x_vals, callback_value(model, x))
Test.@test isapprox(obj_value, 1.0 * x_vals[end] + 0.5, atol = 1e-1)
# return `true` to keep going, or `false` to terminate the optimization.
return iter_count < 1
end
set_attribute(model, Ipopt.CallbackFunction(), my_callback)
optimize!(model)
termination_status(model)
Test.@test length(x_vals) == 2
return x_vals
end
example_solver_dependent_callback_ipopt()2-element Vector{Float64}:
1.0
1.000150414108681Using solver-dependent callbacks
If you want to write a package that is solver-independent, but you also want to use a solver-dependent callback, write a package extension.
To proceed, assume that we have a package named MyPackage:
module MyPackage
import JuMP
add_callback(model) = add_callback(model, typeof(JuMP.unsafe_backend(model)))
add_callback(model, ::Type{T}) where {T} = error("Unsupported optimizer: $T")
function build_model(optimizer)
model = JuMP.Model(optimizer)
add_callback(model)
return model
end
end # MyPackageMain.MyPackageThis package defines a public build_model function. Inside the function it calls add_callback, which defaults to throwing an error.
Now, assume we want to add callbacks for Gurobi and Ipopt.
In /ext/MyPackageGurobiExt.jl define:
module MyPackageGurobiExt
# In a real example, change `..MyPackage` to `MyPackage`. The dots are needed
# only because of how we've structured this documentation.
import ..MyPackage
import Gurobi
import JuMP
function MyPackage.add_callback(model::JuMP.Model, ::Type{Gurobi.Optimizer})
function callback(cb_data, cb_where)
if rand() < 0.5
Gurobi.GRBterminate(JuMP.backend(model))
end
return
end
JuMP.set_attribute(model, Gurobi.CallbackFunction(), callback)
return
end
end # MyPackageGurobiExtMain.MyPackageGurobiExtand in /ext/MyPackageIpoptExt.jl define:
module MyPackageIpoptExt
# In a real example, change `..MyPackage` to `MyPackage`. The dots are needed
# only because of how we've structured this documentation.
import ..MyPackage
import Ipopt
import JuMP
function MyPackage.add_callback(model::JuMP.Model, ::Type{Ipopt.Optimizer})
function callback(args...)
return rand() < 0.5
end
JuMP.set_attribute(model, Ipopt.CallbackFunction(), callback)
return
end
end # MyPackageIpoptExtMain.MyPackageIpoptExtFinally, change the Project.toml of MyPackage to include an [extensions] section:
[weakdeps]
Gurobi = "2e9cd046-0924-5485-92f1-d5272153d98b"
Ipopt = "b6b21f68-93f8-5de0-b562-5493be1d77c9"
[extensions]
MyPackageGurobiExt = "Gurobi"
MyPackageIpoptExt = "Ipopt"Now, build_model can be called with Gurobi.Optimizer:
MyPackage.build_model(Gurobi.Optimizer)A JuMP Model
├ solver: Gurobi
├ objective_sense: FEASIBILITY_SENSE
├ num_variables: 0
├ num_constraints: 0
└ Names registered in the model: noneand with Ipopt.Optimizer:
MyPackage.build_model(Ipopt.Optimizer)A JuMP Model
├ solver: Ipopt
├ objective_sense: FEASIBILITY_SENSE
├ num_variables: 0
├ num_constraints: 0
└ Names registered in the model: noneAnd you didn't need to add Gurobi or Ipopt as dependencies to MyPackage.