Modeling an optimization problem
This chapter illustrates the modeling capabilities of the Xpress Python interface. It shows how to create variables, constraints of different types, add an objective function, and solving and retrieving a problem's solution. It also shows how to read or write a problem from/to a file.
Getting started
The Xpress Python module is imported as follows:
import xpress
A complete list of methods and constants available in the module is obtained by running the Python command dir(xpress). Because all types and methods must be called by prepending "xpress.", it is advisable to alias the module name upon import:
import xpress as xp
We assume that this is the way the module is imported from now on. It is also possible to import all methods and types to avoid prepending the module name or its alias, but this practice is usually advised against:
from xpress import *
Creating a problem
Create an empty optimization problem myproblem as follows:
myproblem = xp.problem ()
A name can be assigned to a problem upon creation:
myproblem = xp.problem (name = "My first problem")
The problem has no variables or constraint at this point.
Variables
The Xpress type var allows for creating optimization variables. Note that variables are not tied to a problem but may exist globally in a Python program. In order for them to be included into a problem, they have to be explicitly added to that problem. Below is the complete declaration with the list of all parameters (all of them are optional):
var (name, lb, ub, threshold, vartype)
The parameters are:
- name is a Python UTF-8 string containing the name of the variable (its ASCII version will be saved if written onto a file); a default name is assigned if the user does not specify it;
- lb is the lower bound (0 by default);
- ub is the upper bound (+inf is the default);
- threshold is the threshold for semi-continuous, semi-integer, and partially integer variables; it must be between its lower and its upper bound; it has no default, so if a variable is defined as partially integer the threshold must be specified;
- vartype is the variable type, one of the six following types:
- xpress.continuous for continuous variables;
- xpress.binary for binary variables (lower and upper bound are further restricted to 0 and 1);
- xpress.integer for integer variables;
- xpress.semicontinuous for semi-continuous variables;
- xpress.semiinteger for semi-integer variables;
- xpress.partiallyinteger for partially integer variables.
The features of each variable are accessible as members of the associated object: after declaring a variable with x = xpress.var(), its name, lower and upper bound can be accessed via x.name, x.lb, and x.ub. Note that, after a variable x has been added to one or more problems, a change in its feature will not be reflected in these problems, but only in the problems to which this variable is added subsequently.
One or more variables (or vectors of variables) can be added to a problem with the addVariable method:
v = xp.var (lb = -1, ub = 2) m.addVariable (v) x = [xp.var (ub = 10) for i in range (10)] y = [xp.var (ub = 10, vartype = xp.integer) for i in range (10)] m.addVariable (x,y)
By default, variables added to an Xpress problems are constrained to be nonnegative. In order to add a free variable, one must specify its lower bound to be -∞ as follows:
v = xp.var (lb = -xp.infinity)
Constraints
Linear, quadratic, and nonlinear constraints can be specified as follows:
constraint (constraint, body, lb, ub, sense, rhs, name)
The parameters are:
- constraint is the full-form constraint, such as x1 + 2 * x2 <= 4;
- body is the body of the constraint, such as 3 * x1 + x2 (it may contain constants);
- lb is the lower bound on the body of the constraint;
- ub is the upper bound on the body of the constraint;
- sense is the sense of the constraint, one among xpress.leq, xpress.geq, xpress.eq, and xpress.range; in the first three cases, the parameter rhs must be specified; only in the fourth case must lb and ub be specified;
- rhs is the right-hand side of the constraint;
- name is the name of the constraint. Parameters lb, ub, and rhs must be constant.
A constraint can be specified more naturally as a condition on an expression:
myconstr = x1 + x2 * (x2 + 1) <= 4 myconstr2 = xp.exp (xp.sin (x1)) + x2 * (x2**5 + 1) <= 4
One or more constraints (or vectors of constraints) can be added to a problem via the addConstraint method:
m.addConstraint (myconstr) m.addConstraint (v1 + xp.tan (v2) <= 3) m.addConstraint (x[i] + y[i] <= 2 for i in range (10))
In order to help formulate compact problems, the Sum operator of the xpress module can be used to express sums of expressions. Its argument is a list of expressions:
m.addConstraint (xp.Sum ([y[i] for i in range (10)]) <= 1) m.addConstraint (xp.Sum ([x[i]**5 for i in range (9)]) <= x[9])
When handling variables or expressions, it is advised to use the Sum operator in the Xpress module rather than the native Python operator, for reasons of efficiency.
As for variables, an object of type constraint allows for read/write access of its features via its members name, body, lb, and ub. The same caveat for variables holds here: any change to an object's members will only have an effect in the problems to which a constraint is added after the change.
Objective function
The objective function is any expression, so it can be constructed as for constraints. The method problem.setObjective can be used to set (or replace if one has been specified before) the objective function of a problem. The definition of setObjective is as follows:
setObjective (objective, sense)
where objective is the expression defining the new objective and sense is either xpress.minimize or xpress.maximize. Examples follows (in the first, the objective function is to be minimized as per default, while the second example specifies the optimization sense as maximization).
m.setObjective (xp.Sum ([y[i]**2 for i in range (10)])) m.setObjective (v1 + 3 * v2, sense = xp.maximize)
Special Ordered Sets (SOSs)
A Special Order Set (SOS) is a modeling tool for constraining a small number of consecutive variables in a vector to be nonzero. The Xpress Python interface allows for defining a SOS as follows:
sos (indices, weights, type, name)
The first argument, indices, is a list of variables, while weights is a list of floating point numbers. The type of SOS (either 1 or 2) is specified by type. While indices and weights are mandatory parameters, type and name are not; type is set to a default of 1 when not specified. Examples follow:
set1 = xp.sos (x, [0.5 + i*0.1 for i in range(10)], type = 2) set2 = xp.sos ([y[i] for i in range(5)], [i+1 for i in range(5)]) set3 = xp.sos ([v1, v2], [2, 5], 2)
One or more SOS can be added to a problem via the problem.addSOS method:
set1 = xp.sos (x, [0.5 + i*0.1 for i in range(10)], type = 2) m.addSOS (set1) n = 10 w = [xp.var () for i in range (n)] m.addSOS ([xp.sos ([w[i],w[i+1]], [2,3], type = 2) for i in range (n-1)])
The name member of a SOS object can be read and written by the user.
Indicator constraints
Indicator constraints are defined by a binary variable, called the indicator, and a constraint. Depending on the value of the indicator, the constraint is enforced or relaxed.
For instance, if the constraint x + y ≥ 3 should only be enforced if the binary variable u is equal to 1, then (u=1 → x+y≥3) is an indicator constraint.
An indicator constraint in Python can be added to a problem with the addIndicator as follows (note the "==" as the symbol for equality):
m.addIndicator (vb == 1, v1 + v2 >= 4)
Modeling and solving nonlinear problems
Version 8.3 of the Xpress Optimizer suite introduces nonlinear modeling in the Python interface. It allows for creating and solving nonlinear, possibly nonconvex problems with similar functions as for linear, quadratic, and conic problems and their mixed integer counterpart.
A nonlinear problem can be defined by creating one or more variables and then adding constraints and an objective function. This can be done using the same Python calls as one would do for other problems. The available operators are +, -, *, /, ** (which is the Python equivalent for the power operator, " ^ "). Univariate functions can also be used from the following list: sin, cos, tan, asin, acos, atan, exp, log, log10, abs, sign, and sqrt. Multivariate functions are min and max, which can receive an arbitrary number of arguments.
Examples of nonlinear constraints are as follows:
import xpress as xp import math x = xp.var () p = xp.problem () p.addVariable (x) # polynomial constraint p.addConstraint (x**4 + 2 * x**2 - 5 >= 0) # A terrible way to constrain x to be integer p.addConstraint (xp.sin (math.pi * x) == 0) p.addConstraint (x**2 * xp.sign (x) <= 4)
Note that non-native mathematical functions such as log and sin must be prefixed with xpress or its alias, xp in this case. This can be avoided by importing all symbols from xpress using the import * command as follows
from xpress import * x = var() a = sin(x)
but this hides namespaces and is usually frowned upon.
User functions are also accepted in the Python interface, and must be specified with the keyword user and the function as the first argument. They are handled in the Nonlinear solver in a transparent way, so all is needed is to define a Python function to be run as the user function and specify it in the problem with user, as in the following example:
import xpress as xp import math def mynorm (x1, x2): return math.sqrt (x1**2 + x2**2) def myfun (v1, v2, v3): return v1 / v2 + math.cos (v3) x,y = xp.var (), xp.var () p = xp.problem () p.addVariables (x,y) p.setObjective (user (mynorm, x, y)) p.addConstraint (x+y >= 2) p.addConstraint (user (myfun, x**2, x**3, 1/y) <= 3)
As a final word of caution, solving nonlinear problem requires a preprocessing step that is transparent to the user except for two steps: first, if the objective function has a nonlinear component f(x) then a new constraint (called objective transfer row or objtransrow) and a new variable, the objective transfer column or objtranscol) are called that are defined as follows:
objtransrow: - objtranscol + f(x) = 0
The resulting problem is equivalent in that the set of optimal (resp. feasible) solutions of this problem will be the same as those of the original problem. The user, however, will notice an increase by one of both the number of rows and of columns when a nonlinear objective function is set.
The second caveat is about yet another variable that may be added to the problem for reasons having to do with one of the Xpress Nonlinear solvers. This variable is called equalscol and it is fixed to 1. Its existence and value are therefore of no interest to the user.
The reader can find more information on this in the Xpress Nonlinear reference manual.
Solving a problem
Simply call solve() to solve an optimization problem that was either built or read from a file. The type of solver is determined based on the type of problem: if at least one integer variable was declared, then the problem will be solved as a mixed integer (linear, quadratically constrained, or nonlinear) problem, while if all variables are continuous the problem is solved as a continuous optimization problem. If the problem is nonlinear in that it contains non-quadratic, non-conic nonlinear constraints, then the appropriate nonlinear solver of the Xpress Optimization suite will be called. Note that in case of a nonconvex quadratic problem, the Xpress Nonlinear solver will be applied as the Xpress Optimizer solver cannot handle such problems.
m.solve ()
The status of a problem after solution can be inquired via the functions getProbStatus() and getProbStatusString() as follows:
import xpress as xp m = xp.problem () m.read ("example3.lp") m.solve () print ("problem status: ", m.getProbStatus ()) print ("problem status, explained: ", m.getProbStatusString ())
The meaning of the returned value is explained in the Optimizer's reference manual. Note that the value and string returned by the above functions reflect the type of problem as input by the user, not the way the problem was last solved. If the user creates a MILP and then solves it as an LP with the flag "l", then both getProbStatus() and getProbStatusString() yield the status of the MILP rather than the LP relaxation. At all effects, the call p.getProbStatus() returns p.attributes.lpstatus if p has continuous variables and p.attributes.mipstatus if p has integer variables.
The output of the solver when reading and solving a problem is the same as with other interfaces of the Xpress Optimizer. The verbosity level is determined by the control outputlog, which is 1 by default. To turn off the solver's output, it should be set to zero (see Chapter Controls and Attributes for how to set a control).
Querying a problem
It is useful, after solving a problem, to obtain the value of an optimal solution. After solving a continuous or mixed integer problem, the two methods getSolution and getSlack return the vector (of portions thereof) of an optimal solution or the slack of the constraints. If an optimal solution was not found but a feasible solution is available, these methods will return data based on this solution. They can be used in multiple ways as shown in the following examples:
import xpress as xp v1 = xp.var (name = 'Var1') x = [xp.var (lb = -1, ub = 1, vartype = xp.integer) for i in range(10)] m = xp.problem () m.addVariable (v1, x) [...] # add constraints and objective m.solve() print (m.getSolution ()) # prints a list with an optimal solution print ("v1 is", m.getSolution (v1)) # only prints the value of v1 a = m.getSolution (x) # gets the values of all variables in the vector x b = m.getSolution (0:4) # gets the value of v1 and x[0], x[1], x[2] c = m.getSolution ('Var1') # gets the value of v1 by its name
Consider the last five lines. The first of them returns a list of ncol floating point scalars, where ncol is the number of variables of the problem (nrow is the number of constraints, the size of the vector returned by getSlack) containing the full solution. The second example retrieves the value of the single variable v1. The third example returns an array of the same size as x with the value of all variables of the list x. The fourth example shows that a range of indices can be specified in order to obtain a vector of values without specifying the corresponding variables. Recall that the column and row indices begin at 0. Finally, the last line shows that a variable can be passed by name.
The method getSlack works similarly, except constraints, integer indices, or constraint names must be passed. The following examples illustrate a few possible uses.
import xpress as xp N = 10 x = [xp.var (vartype = xp.binary) for i in range(N)] m = xp.problem () m.addVariable (x) con1 = xp.Sum (x[i] * i for i in range (N)) <= N) con2 = (x[i] >= x[i+1] for i in range (N-1)) m.addConstraints (con1, con2) m.setObjective (xp.Sum (x[i] for i in range (N)) m.solve () print (m.getSlack ()) # prints a list of slacks for all N constraints print ("slack_1 is", m.getSlack (con1)) # only prints the slack of con1 a = m.getSlack (con2) # gets the slack of N-1 constraints con2 b = m.getSlack (0:2) # gets the slack of con1 and con2[0]
In addition, for problems with only continuous variables, the two methods getDual and getRCost return the the vector (or a portion thereof) of dual variables and reduced costs, respectively. Their usage is similar to that for getSolution and getSlack.
Note that the inner workings of the Python interface obtain a copy of the whole solution, slack, dual, or reduced cost vectors, even if only one element is requested. It is therefore advisable that instead of repeated calls (for instance, in a loop) to getSolution, getSlack, etc. only one call is made and the result is stored in a list to be consulted in the loop. Hence, in the following example:
import xpress as xp n = 10000 N = range (n) x = [xp.var () for i in N] p = xp.problem () p.addVariable (x) m.addConstraints (xp.Sum (x[i] * i for i in N) <= n)) m.setObjective (xp.Sum (x[i] for i in N) m.solve () for i in N: if m.getSolution (x[i]) > 1e-3: print (i)
the last three lines should be substituted as follows, as this will prevent repeatedly copying a large (10,000) vector:
sol = m.getSolution () for i in N: if sol[i] > 1e-3: print (i)
Reading and writing a problem
After creating an empty problem, one can read a problem from a file via the read method, which only takes the file name as its argument. An already-built problem can be written to a file with the write method. Its arguments are similar to those in the Xpress Optimizer API function XPRSwriteprob, to which we refer.
import xpress as xp m = xp.problem () m.read ("example2.lp") m.solve () print (m.getSolution ()) m2 = xp.problem () v1 = xp.var () v2 = xp.var (vartype = xp.integer) m2.addVariable (v1, v2) m2.addConstraint (v1 + v2 <= 4) m2.setObjective (v1**2 + v2) m2.write ("twovarsproblem", "lp")
Hints for building models efficiently
The Xpress Python interface allows for creating optimization models using methods described in this and other sections. As happens with other interpreted languages, using explicit loops may result in a slow Python script. When using the Xpress Python interface, this can be noticeable in large optimization models if multiple calls to addVariable, addConstraint, or addSOS are made. For this reason, the Xpress module allows for generators and list, dictionaries, and sequences as arguments to these methods, to ensure faster execution.
Let us consider an example:
import xpress as xp N = 100000 S = range(N) x = [xp.var() for i in S] y = [xp.var(vartype = xp.binary) for i in S] for i in S: m.addVariable (x[i]) m.addVariable (y[i]) for i in S: m.addConstraint (x[i] <= y[i]) m.solve()
While the declaration of x and y is correct and efficient, the two subsequent loops are very inefficient: they imply 2N calls to addVariable and N calls to addConstraint. Both methods add some overhead due to the conversion of Python object into data that can be read by the Optimizer, and the total overhead can be large.
Most methods of the Xpress Python interface allow for passing sequences (lists, dictionaries, NumPy arrays, etc.) as parameters, and are automatically recognized as such. Hence the first loop can be replaced by two calls to addVariable:
m.addVariable (x) m.addVariable (y)
or, more compact and slightly more efficient:
m.addVariable (x,y)
The largest gain in performance, though, comes from replacing the second loop with a single call to addConstraint:
m.addConstraint (x[i] <= y[i] for i in S)
This line is equivalent to the second loop above, and it is much faster and more elegant.
When declaring x and y as NumPy vectors, an equally efficient and even more compact model can be written:
import xpress as xp import numpy as np N = 100000 S = range(N) x = np.array ([xp.var () for i in S]) y = np.array ([xp.var (vartype = xp.binary) for i in S]) m.addVariable (x,y) m.addConstraint (x <= y) m.solve()
See Chapter Using Python numerical libraries for more information on how to use NumPy arrays in the Xpress Python interface.