Gurobi12 reports obj_value < obj_bound in a Min-Program?

See the comments at the bottom of this code.

import JuMP, Gurobi
CR = JuMP.Model(() -> Gurobi.Optimizer())
JuMP.@variable(CR, x[1:3])
JuMP.set_lower_bound(x[1], exp(1)); JuMP.set_upper_bound(x[1], exp(2))
JuMP.set_lower_bound(x[3], 1e-6)
JuMP.@objective(CR, Min, -x[1] * x[2] - log(x[3]) + x[2]^2/4)
JuMP.optimize!(CR) # see the following Gurobi's log
# Solution count 1: -11.993
# Model is unbounded
# Best objective -1.199295535608e+01, best bound -, gap -
JuMP.termination_status(CR) # DUAL_INFEASIBLE
JuMP.objective_value(CR) # -11.992955356078266
JuMP.objective_bound(CR) # 1.0e100
# This relation is illogical in a Min-Program
# It should always holds that obj_value >= obj_bound ???
# I think in this case the outcome of `JuMP.objective_bound(CR)` should be something like -1.0e100

Your problem is unbounded.

primal_status(model) is NO_SOLUTION, thus, calling objective_value and objective_bound is undefined behavior.

Please remember to _always_use is_solved_and_feasible, or check the termination status and the primal status before querying a primal solution.

julia> solution_summary(model)
* Solver : Gurobi

* Status
  Result count       : 1
  Termination status : DUAL_INFEASIBLE
  Message from the solver:
  "Model was proven to be unbounded. Important note: an unbounded status indicates the presence of an unbounded ray that allows the objective to improve without limit. It says nothing about whether the model has a feasible solution. If you require information on feasibility, you should set the objective to zero and reoptimize."

* Candidate solution (result #1)
  Primal status      : NO_SOLUTION
  Dual status        : NO_SOLUTION
  Objective value    : -1.19930e+01
  Objective bound    : 1.00000e+100
  Dual objective value : 1.00000e+100

Ok I see.
Now here is a new problem.

import JuMP
import Gurobi
GRB_ENV = Gurobi.Env()
CR = JuMP.Model(() -> Gurobi.Optimizer(GRB_ENV))
JuMP.@variable(CR, x)
JuMP.@objective(CR, Min, (x - 1.) * x * (x + 1.))
JuMP.optimize!(CR) # see the continual logging
# here you hit `ctrl + C`
# Then you query
JuMP.termination_status(CR) # it shows OPTIMIZE_NOT_CALLED
# if I recall correctly, this should be INTERRUPTED ???

I’ve only encountered these in my life


Enum MathOptInterface.TerminationStatusCode:
OPTIMIZE_NOT_CALLED = 0 # exclusively only occurs when you forget to `optimize!(model)`
OPTIMAL = 1
INFEASIBLE = 2
DUAL_INFEASIBLE = 3
LOCALLY_SOLVED = 4
INFEASIBLE_OR_UNBOUNDED = 6
TIME_LIMIT = 12
SLOW_PROGRESS = 19
NUMERICAL_ERROR = 20
INTERRUPTED = 23 # happens when you press `ctrl + C` to manually stop the solver, but you could fetch solutions

I mean, if I recall correctly, if I interrupt Gurobi through my keyboard.
I could query the current solution like JuMP.value(x).
But now it only gives me the error.
This is not what it used to be!

As a comparison


import JuMP
import Gurobi
GRB_ENV = Gurobi.Env()
CR = JuMP.Model(() -> Gurobi.Optimizer(GRB_ENV))
JuMP.set_attribute(CR, "TIME_LIMIT", 4.)
JuMP.@variable(CR, x)
JuMP.@objective(CR, Min, (x - 1.) * x * (x + 1.))
JuMP.optimize!(CR) # see the continual logging
# then it terminates
@assert JuMP.termination_status(CR) == JuMP.TIME_LIMIT
x = JuMP.value(x) # -302.7000000000001
ub = JuMP.objective_value(CR) # -2.773527798300003e7
lb = JuMP.objective_bound(CR) # -5.36870912e14
lb < ub && println("This is sane 😄")
# Comment: This is the correct behavior since I can query the status (even before Gurobi returns to Julia)

I can reproduce the CTRL+C thing. It may be a change in Gurobi 12 that we need to fix in Gurobi.jl. Open an issue

I’ll say again in bold: Please remember to always use is_solved_and_feasible , or check the termination status and the primal status before querying a primal solution. It is NOT sufficient to check only termination_status, although it worked in this case.

1 Like

Is this problem exclusive to Gurobi?
It seems that this happens with Ipopt too.
“this” means if you manually interrupt the solver, then the termination_status is not JuMP.INTERRUPTED but see below:

julia> JuMP.optimize!(CR)

******************************************************************************
This program contains Ipopt, a library for large-scale nonlinear optimization.
 Ipopt is released as open source code under the Eclipse Public License (EPL).
         For more information visit https://github.com/coin-or/Ipopt
******************************************************************************

This is Ipopt version 3.14.14, running with linear solver MUMPS 5.6.2.

Number of nonzeros in equality constraint Jacobian...:        0
Number of nonzeros in inequality constraint Jacobian.:        0
Number of nonzeros in Lagrangian Hessian.............:        1

ERROR: InterruptException:
Stacktrace:
  [1] eval_objective_gradient(model::Ipopt.Optimizer, grad::Vector{Float64}, x::Vector{Float64})
    @ Ipopt K:\judepot1113\packages\Ipopt\KZuET\src\MOI_wrapper.jl:872
  [2] (::Ipopt.var"#eval_grad_f_cb#4"{Ipopt.Optimizer})(x::Vector{Float64}, grad_f::Vector{Float64})
    @ Ipopt K:\judepot1113\packages\Ipopt\KZuET\src\MOI_wrapper.jl:981
  [3] _Eval_Grad_F_CB(n::Int32, x_ptr::Ptr{Float64}, ::Int32, grad_f::Ptr{Float64}, user_data::Ptr{Nothing})
    @ Ipopt K:\judepot1113\packages\Ipopt\KZuET\src\C_wrapper.jl:56
  [4] IpoptSolve
    @ K:\judepot1113\packages\Ipopt\KZuET\src\C_wrapper.jl:399 [inlined]
  [5] optimize!(model::Ipopt.Optimizer)
    @ Ipopt K:\judepot1113\packages\Ipopt\KZuET\src\MOI_wrapper.jl:1131
  [6] optimize!
    @ K:\judepot1113\packages\MathOptInterface\TYq6d\src\Bridges\bridge_optimizer.jl:367 [inlined]
  [7] optimize!
    @ K:\judepot1113\packages\MathOptInterface\TYq6d\src\MathOptInterface.jl:122 [inlined]
  [8] optimize!(m::MathOptInterface.Utilities.CachingOptimizer{…})
    @ MathOptInterface.Utilities K:\judepot1113\packages\MathOptInterface\TYq6d\src\Utilities\cachingoptimizer.jl:327
  [9] optimize!(model::JuMP.Model; ignore_optimize_hook::Bool, _differentiation_backend::MathOptInterface.Nonlinear.SparseReverseMode, kwargs::@Kwargs{})
    @ JuMP K:\judepot1113\packages\JuMP\xlp0s\src\optimizer_interface.jl:595
 [10] optimize!(model::JuMP.Model)
    @ JuMP K:\judepot1113\packages\JuMP\xlp0s\src\optimizer_interface.jl:546
 [11] top-level scope
    @ REPL[7]:1
Some type information was truncated. Use `show(err)` to see complete types.

julia> JuMP.termination_status(CR)
OPTIMIZE_NOT_CALLED::TerminationStatusCode = 0

I think at least in literal meaning this is incorrect.
JuMP should reports JuMP.INTERRUPTED.
And OPTIMIZE_NOT_CALLED should be exclusively refer to the situation when I forget to write JuMP.optimize!(model).
I think this is my correct memory about JuMP in the past 2 years.

I think JuMP.primal_status is somewhat unimportant, given the presence of JuMP.has_values. I’ve just written a rigorous procedure about this, please see About JuMP.is_solved_and_feasible.

In most cases, if you interrupt the solver mid-solve, it won’t have a solution to return to you. So in the Ipopt case, theres essentially no difference between reporting INTERRUPTED and OPTIMIZE_NOT_CALLED. But yes, we could do better at handling this.

I’ve opened issues for Make CTRL+C work properly · Issue #460 · jump-dev/Ipopt.jl · GitHub and GitHub · Where software is built

I think JuMP.primal_status is somewhat unimportant, given the presence of JuMP.has_values

I opened a PR to remove has_values from the documentation [docs] remove has_values and has_duals from documentation by odow · Pull Request #3961 · jump-dev/JuMP.jl · GitHub. Please don’t use it. primal_status is much more important.

According to my experience, this is not the case, at least for Gurobi.
The following 2 cases should be similar:

  1. I set a TimeLimit at 4 seconds for Gurobi. Then After 4 seconds, after normality check (i.e. something like assert_is_solved_and...), I can get the current solution (which should be good, as Gurobi spend 4 seconds to reach).
  2. I haven’t set attributes. I let Gurobi run 4 seconds. Then I use Ctrl+C to interrupt it. After normality check, I can also get the current solution.

This was my knowledge about Gurobi used in conjunction with JuMP. And I think this is the ideal behavior. (Gurobi 12’s behavior is disquieting for me😑)

If you are solving an LP and stop with a time limit, it probably won’t have a feasible point to return. If you are solving a MIP, it depends whether Gurobi found a primal feasible point. It might find one in 1 second, or it might take 1000 years. You should not rely on the fact that Gurobi will have a solution when stopped early.

I’ve fixed the CTRL+C issue with Gurobi: Improve handling of SIGINT interrupts by odow · Pull Request #621 · jump-dev/Gurobi.jl · GitHub. It may have been a change in Julia, not Gurobi. But regardless, I’m about to tag a new version.

Actually I think this is 99% of the case. Since Gurobi tends to use heuristic first and it can indeed generate a feasible solution quickly at first, such that the primal bound can be updated soon.

Here’s a “small” model with 1024 variables. Can you find a feasible solution?

julia> using JuMP, Gurobi, Downloads

julia> Downloads.download(
           "https://miplib.zib.de/WebData/instances/supportcase30.mps.gz",
           "/tmp/supportcase30.mps.gz",
       )
"/tmp/supportcase30.mps.gz"

julia> model = read_from_file("/tmp/supportcase30.mps.gz")
supportcase30
├ solver: none
├ objective_sense: MIN_SENSE
│ └ objective_function_type: AffExpr
├ num_variables: 1024
├ num_constraints: 4100
│ ├ AffExpr in MOI.EqualTo{Float64}: 4
│ ├ AffExpr in MOI.LessThan{Float64}: 1024
│ ├ VariableRef in MOI.GreaterThan{Float64}: 1024
│ ├ VariableRef in MOI.LessThan{Float64}: 1024
│ └ VariableRef in MOI.Integer: 1024
└ Names registered in the model: none

julia> set_optimizer(model, Gurobi.Optimizer)
Set parameter LicenseID to value 890341

julia> optimize!(model)
Gurobi Optimizer version 12.0.1 build v12.0.1rc0 (mac64[x86] - Darwin 24.1.0 24B83)

CPU model: Intel(R) Core(TM) i5-8259U CPU @ 2.30GHz
Thread count: 4 physical cores, 8 logical processors, using up to 8 threads

Optimize a model with 1028 rows, 1024 columns and 12288 nonzeros
Model fingerprint: 0x93460ed6
Variable types: 0 continuous, 1024 integer (0 binary)
Coefficient statistics:
  Matrix range     [1e+00, 1e+00]
  Objective range  [0e+00, 0e+00]
  Bounds range     [1e+00, 1e+00]
  RHS range        [1e+00, 3e+01]
Presolve time: 0.01s
Presolved: 1028 rows, 1024 columns, 12288 nonzeros
Variable types: 0 continuous, 1024 integer (1024 binary)

Root relaxation: objective 0.000000e+00, 2883 iterations, 0.31 seconds (0.48 work units)

    Nodes    |    Current Node    |     Objective Bounds      |     Work
 Expl Unexpl |  Obj  Depth IntInf | Incumbent    BestBd   Gap | It/Node Time

     0     0    0.00000    0  473          -    0.00000      -     -    1s
     0     0    0.00000    0  463          -    0.00000      -     -    1s
     0     0    0.00000    0  466          -    0.00000      -     -    1s
     0     0    0.00000    0  473          -    0.00000      -     -    3s
     0     0    0.00000    0  473          -    0.00000      -     -    4s
     0     2    0.00000    0  473          -    0.00000      -     -    6s
    45   114    0.00000    7  431          -    0.00000      -  1395   11s
   240   266 infeasible   30               -    0.00000      -   754   18s
   455   312 infeasible   25               -    0.00000      -   610   23s
   697   340 infeasible   22               -    0.00000      -   581   28s
   897   366    0.00000   23  417          -    0.00000      -   616   33s
^C
Explored 1150 nodes (720861 simplex iterations) in 33.83 seconds (83.09 work units)
Thread count was 8 (of 8 available processors)

Solution count 0

Solve interrupted
Best objective -, best bound 0.000000000000e+00, gap -

User-callback calls 3772, time in user-callback 0.00 sec
1 Like

This case is spot on!

Well, from the Gurobi logging we know that it fails to offer a primal feasible solution.
But importantly it can offer a valid dual bound, as shown in best bound 0.000000000000e+00.

In this case I’m expecting:
After I hit ctrl + C, I call a normality check

@assert JuMP_objective_bound_works_properly(model)

If the above test is passed, I query

println( JuMP.objective_bound(model) )

I’m expecting that 0.0 occurs in julia REPL, as a valid dual bound.

The latest release of Gurobi.jl returns

julia> solution_summary(model)
* Solver : Gurobi

* Status
  Result count       : 0
  Termination status : INTERRUPTED
  Message from the solver:
  "Optimization was terminated by the user."

* Candidate solution (result #1)
  Primal status      : NO_SOLUTION
  Dual status        : NO_SOLUTION
  Objective bound    : 0.00000e+00
  Relative gap       : 1.00000e+100

* Work counters
  Solve time (sec)   : 3.38293e+01
  Simplex iterations : 720861
  Barrier iterations : 0
  Node count         : 1150

Yes, this info is valuable.

For future reference purposes, here I devise a “tough” disjointed bilinear problem parameterized by N::Int. (Such that we don’t need to download hard cases from miplib.) The problem has no constraint other than simple bounds on decisions. It’s style is very simple so that JuMP won’t feel strenuous to model it.

import JuMP, Ipopt, Gurobi
import Random, LinearAlgebra, SparseArrays
N = 40 # 18 seconds
N = 50 # 754 seconds
N = 80 # > 8500 seconds, already very tough
Random.seed!(1)
Q = 1. * SparseArrays.dropzeros(
    SparseArrays.spdiagm(
        [i => rand(-9:9, N - i) for i in 0:div(N, 2)]... # would be easier by increase `2`
    )
) # generate a random sparse quad-obj-coeff matrix
m = JuMP.Model();
JuMP.@variable(m, rand(-7:-3) <= y[1:N] <= rand(3:7));
JuMP.@variable(m, rand(-9:-5) <= x[1:N] <= rand(5:9));
JuMP.@objective(m, Min, LinearAlgebra.dot(x, Q, y));
JuMP.set_optimizer(m, Gurobi.Optimizer) # Gurobi will use a MIP method
JuMP.optimize!(m); JuMP.solution_summary(m)
JuMP.set_optimizer(m, Ipopt.Optimizer) # Ipopt uses NLP method
JuMP.optimize!(m); JuMP.solution_summary(m)

Another easier dense case that is directly derived from a simple BQP (boolean quadratic programming):

import LinearAlgebra.dot as dot
import JuMP, Gurobi
import Random
N = 20 # easier 
N = 80 # hard
Random.seed!(1)
Q = rand(-1:.0017:1, N, N)
m = JuMP.Model(Gurobi.Optimizer)
JuMP.@variable(m, x[1:N], Bin)
JuMP.@variable(m, y[1:N], Bin)
# the following 4 lines are classic BQP cuts
JuMP.@variable(m, z[1:N, 1:N] >= 0)
JuMP.@constraint(m, [j = 1:N], view(z, :, j) .<= x)
JuMP.@constraint(m, [i = 1:N], view(z, i, :) .<= y)
JuMP.@constraint(m, [i = 1:N, j = 1:N], z[i, j] >= x[i] + y[j] - 1)
JuMP.@objective(m, Min, dot(Q, z))
JuMP.optimize!(m)