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.