Skip to content

Commit 6502628

Browse files
authored
Support nonlinear constraints with Gurobi 12 (#95)
Closes #93 * Implement support for NLExpr objects in add_constrs * Add a page to the users guide covering how to add nonlinear constraints to a model using add_constrs. * Show how to create nonlinear constraints not of the form y = f(x) by introducing temporary variables.
1 parent 873662e commit 6502628

File tree

7 files changed

+375
-0
lines changed

7 files changed

+375
-0
lines changed

docs/source/index.rst

+1
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ If you encounter issues with Gurobi or ``gurobipy`` please contact
7878

7979
performance
8080
naming
81+
nonlinear
8182
advanced
8283
typing
8384

docs/source/nonlinear.rst

+119
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
Adding Nonlinear Constraints
2+
============================
3+
4+
Gurobi 12 supports adding nonlinear constraints, using the ``NLExpr`` object to
5+
capture nonlinear expressions. ``gurobipy-pandas`` supports adding a ``Series``
6+
of nonlinear constraints to a model via ``add_constrs``. Note that ``gurobipy``
7+
only supports nonlinear constraints of the form :math:`y = f(\bar{x})` and
8+
``gurobipy-pandas`` applies the same restriction.
9+
10+
.. note::
11+
12+
To add nonlinear constraints, you must have at least ``gurobipy`` version
13+
12.0.0 installed.
14+
15+
Nonlinear Equality Constraints
16+
------------------------------
17+
18+
This example builds the constraint set :math:`y_i = \log(\frac{1}{x_i})`, for
19+
each :math:`i` in the index.
20+
21+
.. doctest:: [nonlinear]
22+
23+
>>> import pandas as pd
24+
>>> import gurobipy as gp
25+
>>> from gurobipy import GRB
26+
>>> from gurobipy import nlfunc
27+
>>> import gurobipy_pandas as gppd
28+
>>> gppd.set_interactive()
29+
30+
>>> model = gp.Model()
31+
>>> index = pd.RangeIndex(5)
32+
>>> x = gppd.add_vars(model, index, lb=1.0, name="x")
33+
>>> y = gppd.add_vars(model, index, lb=-GRB.INFINITY, name="y")
34+
35+
You can create nonlinear expressions using standard Python operators. A
36+
nonlinear expression is any expression which is not a polynomial of degree at
37+
most 2. For example, dividing a constant by a series of ``Var`` objects produces
38+
a series of nonlinear expressions.
39+
40+
.. doctest:: [nonlinear]
41+
42+
>>> 1 / x
43+
0 1.0 / x[0]
44+
1 1.0 / x[1]
45+
2 1.0 / x[2]
46+
3 1.0 / x[3]
47+
4 1.0 / x[4]
48+
Name: x, dtype: object
49+
50+
The ``nlfunc`` module of gurobipy is used to create nonlinear expressions
51+
involving mathematical functions. You can use ``apply`` to construct a series
52+
representing the logarithm of the above result.
53+
54+
.. doctest:: [nonlinear]
55+
56+
>>> (1 / x).apply(nlfunc.log)
57+
0 log(1.0 / x[0])
58+
1 log(1.0 / x[1])
59+
2 log(1.0 / x[2])
60+
3 log(1.0 / x[3])
61+
4 log(1.0 / x[4])
62+
Name: x, dtype: object
63+
64+
This series of expressions can be added as constraints using ``add_constrs``.
65+
Note that you can only provide nonlinear constraints in the form :math:`y =
66+
f(\bar{x})` where :math:`f` may be a univariate or multivariate compound
67+
function. Therefore the ``lhs`` argument must be a series of ``Var`` objects,
68+
while the ``sense`` argument must be ``GRB.EQUAL``.
69+
70+
.. doctest:: [nonlinear]
71+
72+
>>> gppd.add_constrs(model, y, GRB.EQUAL, (1 / x).apply(nlfunc.log), name="log_x")
73+
0 <gurobi.GenConstr 0>
74+
1 <gurobi.GenConstr 1>
75+
2 <gurobi.GenConstr 2>
76+
3 <gurobi.GenConstr 3>
77+
4 <gurobi.GenConstr 4>
78+
Name: log_x, dtype: object
79+
80+
Nonlinear Inequality Constraints
81+
--------------------------------
82+
83+
As noted above, nonlinear constraints can only be provided in the form :math:`y=
84+
f(\bar{x})`. To formulate inequality constraints, you must introduce bounded
85+
intermediate variables. The following example formulates the set of constraints
86+
:math:`\log(x_i^2 + 1) \le 2` by introducing intermediate variables :math:`z_i`
87+
with no lower bound and an upper bound of :math:`2.0`.
88+
89+
.. doctest:: [nonlinear]
90+
91+
>>> z = gppd.add_vars(model, index, lb=-GRB.INFINITY, ub=2.0, name="z")
92+
>>> constrs = gppd.add_constrs(model, z, GRB.EQUAL, (x**2 + 1).apply(nlfunc.log))
93+
94+
To see the effect of this constraint, you can set a maximization objective
95+
:math:`\sum_{i=0}^{4} x_i` and solve the resulting model. You will find that the
96+
original variables :math:`x_i` are maximized to :math:`\sqrt{e^2 - 1}` in
97+
the optimal solution. The intermediate variables :math:`z_i` are at their upper
98+
bounds, indicating that the constraint is satisfied with equality.
99+
100+
.. doctest:: [nonlinear]
101+
102+
>>> model.setObjective(x.sum(), sense=GRB.MAXIMIZE)
103+
>>> model.optimize() # doctest: +ELLIPSIS
104+
Gurobi Optimizer ...
105+
Optimal solution found ...
106+
>>> x.gppd.X.round(3)
107+
0 2.528
108+
1 2.528
109+
2 2.528
110+
3 2.528
111+
4 2.528
112+
Name: x, dtype: float64
113+
>>> z.gppd.X.round(3)
114+
0 2.0
115+
1 2.0
116+
2 2.0
117+
3 2.0
118+
4 2.0
119+
Name: z, dtype: float64

src/gurobipy_pandas/constraints.py

+10
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from gurobipy_pandas.util import create_names, gppd_global_options
1414

1515
CONSTRAINT_SENSES = frozenset([GRB.LESS_EQUAL, GRB.EQUAL, GRB.GREATER_EQUAL])
16+
GUROBIPY_MAJOR_VERSION, *_ = gp.gurobi.version()
1617

1718

1819
def add_constrs_from_dataframe(
@@ -138,6 +139,15 @@ def _add_constr(model, lhs, sense, rhs, name):
138139
name = ""
139140
if not isinstance(sense, str) or sense[0] not in CONSTRAINT_SENSES:
140141
raise ValueError(f"'{sense}' is not a valid constraint sense")
142+
if GUROBIPY_MAJOR_VERSION >= 12:
143+
# Check for nonlinear constraints; accept only y = f(x)
144+
if isinstance(lhs, gp.NLExpr):
145+
raise ValueError("Nonlinear constraints must be in the form y = f(x)")
146+
if isinstance(rhs, gp.NLExpr):
147+
if isinstance(lhs, gp.Var) and sense == GRB.EQUAL:
148+
return model.addGenConstrNL(lhs, rhs, name=name)
149+
else:
150+
raise ValueError("Nonlinear constraints must be in the form y = f(x)")
141151
if isinstance(lhs, gp.QuadExpr) or isinstance(rhs, gp.QuadExpr):
142152
return model.addQConstr(lhs, sense, rhs, name=name)
143153
return model.addLConstr(lhs, sense, rhs, name=name)

tests/test_api.py

+136
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,19 @@
33
tests of data types, errors, etc, are done on the lower-level functions.
44
"""
55

6+
import math
7+
import unittest
8+
9+
import gurobipy as gp
610
import pandas as pd
711
from gurobipy import GRB
812
from pandas.testing import assert_index_equal, assert_series_equal
913

1014
import gurobipy_pandas as gppd
1115
from tests.utils import GurobiModelTestCase
1216

17+
GUROBIPY_MAJOR_VERSION, *_ = gp.gurobi.version()
18+
1319

1420
class TestAddVars(GurobiModelTestCase):
1521
def test_from_dataframe(self):
@@ -269,6 +275,136 @@ def test_sense_series(self):
269275
self.assertEqual(constr.RHS, -1.0)
270276

271277

278+
@unittest.skipIf(
279+
GUROBIPY_MAJOR_VERSION < 12,
280+
"Nonlinear constraints are only supported for Gurobi 12 and later",
281+
)
282+
class TestNonlinear(GurobiModelTestCase):
283+
def assert_approx_equal(self, value, expected, tolerance=1e-6):
284+
difference = abs(value - expected)
285+
self.assertLessEqual(difference, tolerance)
286+
287+
def test_log(self):
288+
# max sum y_i
289+
# s.t. y_i = log(x_i)
290+
# 1.0 <= x <= 2.0
291+
from gurobipy import nlfunc
292+
293+
index = pd.RangeIndex(3)
294+
x = gppd.add_vars(self.model, index, lb=1.0, ub=2.0, name="x")
295+
y = gppd.add_vars(self.model, index, obj=1.0, name="y")
296+
self.model.ModelSense = GRB.MAXIMIZE
297+
298+
gppd.add_constrs(self.model, y, GRB.EQUAL, x.apply(nlfunc.log), name="log_x")
299+
300+
self.model.optimize()
301+
self.assert_approx_equal(self.model.ObjVal, 3 * math.log(2.0))
302+
303+
def test_inequality(self):
304+
# max sum x_i
305+
# s.t. log2(x_i^2 + 1) <= 2.0
306+
# 0.0 <= x <= 1.0
307+
#
308+
# Formulated as
309+
#
310+
# max sum x_i
311+
# s.t. log2(x_i^2 + 1) == z_i
312+
# 0.0 <= x <= 1.0
313+
# -GRB.INFINITY <= z_i <= 2
314+
from gurobipy import nlfunc
315+
316+
index = pd.RangeIndex(3)
317+
x = gppd.add_vars(self.model, index, name="x")
318+
z = gppd.add_vars(self.model, index, lb=-GRB.INFINITY, ub=2.0, name="z")
319+
self.model.setObjective(x.sum(), sense=GRB.MAXIMIZE)
320+
321+
gppd.add_constrs(self.model, z, GRB.EQUAL, (x**2 + 1).apply(nlfunc.log))
322+
323+
self.model.optimize()
324+
self.model.write("model.lp")
325+
self.model.write("model.sol")
326+
327+
x_sol = x.gppd.X
328+
z_sol = z.gppd.X
329+
for i in range(3):
330+
self.assert_approx_equal(x_sol[i], math.sqrt(math.exp(2.0) - 1))
331+
self.assert_approx_equal(z_sol[i], 2.0)
332+
333+
def test_wrong_usage(self):
334+
index = pd.RangeIndex(3)
335+
x = gppd.add_vars(self.model, index, name="x")
336+
y = gppd.add_vars(self.model, index, name="y")
337+
338+
with self.assertRaisesRegex(
339+
gp.GurobiError, "Objective must be linear or quadratic"
340+
):
341+
self.model.setObjective((x / y).sum())
342+
343+
with self.assertRaisesRegex(
344+
ValueError, "Nonlinear constraints must be in the form"
345+
):
346+
gppd.add_constrs(self.model, y, GRB.LESS_EQUAL, x**4)
347+
348+
with self.assertRaisesRegex(
349+
ValueError, "Nonlinear constraints must be in the form"
350+
):
351+
gppd.add_constrs(self.model, y + x**4, GRB.EQUAL, 1)
352+
353+
with self.assertRaisesRegex(
354+
ValueError, "Nonlinear constraints must be in the form"
355+
):
356+
gppd.add_constrs(self.model, y**4, GRB.EQUAL, x)
357+
358+
with self.assertRaisesRegex(
359+
ValueError, "Nonlinear constraints must be in the form"
360+
):
361+
x.to_frame().gppd.add_constrs(self.model, "x**3 == 1", name="bad")
362+
363+
def test_eval(self):
364+
index = pd.RangeIndex(3)
365+
df = (
366+
gppd.add_vars(self.model, index, name="x")
367+
.to_frame()
368+
.gppd.add_vars(self.model, name="y")
369+
.gppd.add_constrs(self.model, "y == x**3", name="nlconstr")
370+
)
371+
372+
self.model.update()
373+
for row in df.itertuples(index=False):
374+
self.assert_nlconstr_equal(
375+
row.nlconstr,
376+
row.y,
377+
[
378+
(GRB.OPCODE_POW, -1.0, -1),
379+
(GRB.OPCODE_VARIABLE, row.x, 0),
380+
(GRB.OPCODE_CONSTANT, 3.0, 0),
381+
],
382+
)
383+
384+
def test_frame(self):
385+
from gurobipy import nlfunc
386+
387+
index = pd.RangeIndex(3)
388+
df = (
389+
gppd.add_vars(self.model, index, name="x")
390+
.to_frame()
391+
.gppd.add_vars(self.model, name="y")
392+
.assign(exp_x=lambda df: df["x"].apply(nlfunc.exp))
393+
.gppd.add_constrs(self.model, "y", GRB.EQUAL, "exp_x", name="nlconstr")
394+
)
395+
396+
self.model.update()
397+
for row in df.itertuples(index=False):
398+
self.assert_nlconstr_equal(
399+
row.nlconstr,
400+
row.y,
401+
[
402+
(GRB.OPCODE_EXP, -1.0, -1),
403+
(GRB.OPCODE_VARIABLE, row.x, 0),
404+
],
405+
)
406+
407+
272408
class TestDataValidation(GurobiModelTestCase):
273409
# Test that we throw some more informative errors, instead of obscure
274410
# ones from the underlying gurobipy library

0 commit comments

Comments
 (0)