A matrix-friendly approach to Gurobi



This post will go over how to setup a simple LP with Gurobi using a matrix-friendly approach, i.e., utilize numpy. This post is a ~5min read. For the full code, see this pastebin.

I am running Gurobi Optimizer version 9.1.1 build v9.1.1rc0 (mac64).
Problem definition
We will solve the linear program (LP), $$\begin{aligned} \min~& c^Tx\\ \text{s.t.}~& Ax \leq b \\ & x \in \mathbb{R}^n. \end{aligned}$$ Our focus is a simple economic dispatch problem where \(n = 3\), and we have the demand and limit constraints, $$\begin{aligned} x_1 + x_2 &\geq d_1 \\ x_1 + x_3 &\geq d_2 \\ x_2 + x_3 &\geq d_3 \\ x_0 &\geq \mathrm{lb}_0 \\ x_i &\leq \mathrm{ub}_i, \ \forall i \in \{1,\ldots,n\}. \end{aligned}$$ Here, variables \(x_i\) will be the thermal generator for region \(i\). We assume region(s) can supply energy to the various demands, denoted by \(d_i\). The lower bound is included for the sake of exploring Gurobi.

If the terminology above is alien, feel free to (safely) ignore the particular problem and focus on the general LP instead.

We begin by setting up and solving the model. First, note the use of addMVars() (where the M stands for matrix) over addVar() or addVars(). I like the matrix version since it returns a numpy array, which is useful for performing matrix and vector operations. Second, note the generous naming of variables and constraints. I believe this is (optional) good practice for when you want to extract or modify specific parts of the MILP. Third, the constants are arbitrary and are chosen to ensure the MILP is feasible.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import numpy as np
import gurobipy as gp

# define Gurobi model
m = gp.Model("Unit Commitment")

n = 3
ubs = [100, 90, 80]
demands = [185, 150, 150]
lb = 90

# define variables
x = m.addMVar(n, ub=ubs, name="x")

# add constraints and names
m.addConstr(sum(x[0], x[1]) >= demands[0], name="demand_0")
m.addConstr(sum(x[0], x[2]) >= demands[1], name="demand_1")
m.addConstr(sum(x[1], x[2]) >= demands[2], name="demand_2")
m.addConstr(x[0] >= lb, name="lb")

# define objective function
c = np.array([10, 8, 9])
m.setObjective(c@x, gp.GRB.MINIMIZE)

# solve
m.optimize()

Extracting solutions and modifying problems
To see how the LP performed, we want to first check if the LP was feasible or not (more statuses listed in here). From there, one can extract the primal and dual (assuming an LP and not mixed-integer LP) solutions and the objective function, as shown below.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# check status
if m.status == gp.GRB.INFEASIBLE:
    print("infeasible")
elif m.status == gp.GRB.OPTIMAL:
    print("feasible")

    x_sol = x.X
    y_sol = np.array([
        m.getAttr("Pi", [m.getConstrByName("demand_{}".format(i))])[0]
        for i in range(n)
    ])
    val = m.objVal

Now, let us say we want to two modifications: a new generator and relax the lower bound constraint.

This new generator is the variable \(x_4\). Let's assume the fourth generator has a cost of $8 per unit of power and is connected to all regions. We will limit it to 20 units of power. We can either write a new LP from scatch or extend the previous one. In the latter case, we will extend the cost vector with an element of 8 and append a new column and row to the constraint matrix, as well as extend the RHS vector \(b\) with an 8.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# gather existing constraints 
constrs_list = []
for cs in m.getConstrs():
    sense = cs.sense
    name = cs.ConstrName
    if "demand_" in name:
        constrs_list.append(cs)
print(constrs_list)
col = gp.Column(np.ones(len(constrs_list)), constrs_list)

# add new variable
x3 = m.addVar(obj=8, name='x[3]', column=col)
m.update()
x = m.getVars()

# add new upper bound constraints
m.addConstr(x[3] <= 20)

# modify RHS
m.setAttr("RHS", m.getConstrByName("lb"), 50)

Two things to note. First, we include update() to allow Gurobi to process the changes (update is useful for examining the model, but need not be called for optimizing). We do this so we can retrieve our updated list of objects. Note that when we used getVars(), we return a list of Gurobi variables rather than MVar. Therefore, we cannot run x.X like we did in Line 7 in the second code snippet to retrieve the array. Rather, we must go through each individual element, i.e., x[i].X.
Performance enhancements
When we solve a Gurobi model, there may be a superfluous amount of output for someone trying to just perform optimization. One may also seek performance improvements by using multiple threads. Here, we state four useful options. More information and other settings can be found here.

Finally, we show how to retrieve the LP itself. This may be useful when one wants to embed an LP into another LP or form the LP dual.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
m.setParam('OutputFlag', 0)
m.setParam(gp.GRB.Param.Threads, 4)
m.setParam(gp.GRB.Param.TimeLimit, 60*60) # 1h time limit
m.setParam(gp.GRB.Param.Crossover, 0)
m.update()

# retrieve (c,A,b)
c = m.getAttr('Obj', m.getVars())
A = m.getA() # returns sparse array
b = m.getAttr('RHS', m.getConstrs())