diff --git a/.github/actions/install-optimization/action.yml b/.github/actions/install-optimization/action.yml index 21c6ff131..dfdfc05e3 100644 --- a/.github/actions/install-optimization/action.yml +++ b/.github/actions/install-optimization/action.yml @@ -17,6 +17,8 @@ runs: using: "composite" steps: - run : | + # install gurobipy + pip install -i https://pypi.gurobi.com gurobipy pip install -e .[cplex,cvx,matplotlib] pip install -U -c constraints.txt -r requirements-dev.txt shell: bash diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a26ab6a3f..c0db68d20 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -145,9 +145,9 @@ jobs: with: name: optimization${{ matrix.python-version }} path: ./o${{ matrix.python-version }}/* - - name: Optimization Unit Tests without cplex/cvxpy/matplotlib under Python ${{ matrix.python-version }} + - name: Optimization Unit Tests without cplex/cvxpy/matplotlib/gurobipy under Python ${{ matrix.python-version }} run: | - pip uninstall -y cplex cvxpy matplotlib + pip uninstall -y cplex cvxpy matplotlib gurobipy if [ "${{ github.event_name }}" == "schedule" ]; then export QISKIT_TESTS="run_slow" fi diff --git a/.pylintdict b/.pylintdict index 0839ba96e..ead95c5a9 100644 --- a/.pylintdict +++ b/.pylintdict @@ -46,6 +46,8 @@ getter goemans grover gset +gurobi +gurobipy hamiltonian hamiltonians hardcoded @@ -79,6 +81,7 @@ minimumeigenoptimizationresult networkx ndarray ndarrays +noop nosignatures np num @@ -129,6 +132,7 @@ stdout str subgraph submodules +sys th toctree todo @@ -139,6 +143,7 @@ upperbound variational vartype vqe +writelines xixj xs xuxv diff --git a/.pylintrc b/.pylintrc index b9e432bf9..b39ef4350 100644 --- a/.pylintrc +++ b/.pylintrc @@ -309,7 +309,7 @@ ignored-classes=optparse.Values,thread._local,_thread._local,QuantumCircuit # List of members which are set dynamically and missed by pylint inference # system, and so shouldn't trigger E1101 when accessed. Python regular # expressions are accepted. -generated-members= +generated-members=gurobipy.*,gp.* # List of decorators that produce context managers, such as # contextlib.contextmanager. Add to this list to register other decorators that diff --git a/qiskit_optimization/algorithms/__init__.py b/qiskit_optimization/algorithms/__init__.py index a47367387..b44ce361c 100644 --- a/qiskit_optimization/algorithms/__init__.py +++ b/qiskit_optimization/algorithms/__init__.py @@ -47,6 +47,7 @@ GoemansWilliamsonOptimizationResult GroverOptimizationResult GroverOptimizer + GurobiOptimizer IntermediateResult MeanAggregator MinimumEigenOptimizationResult @@ -68,6 +69,7 @@ from .goemans_williamson_optimizer import (GoemansWilliamsonOptimizer, GoemansWilliamsonOptimizationResult) from .grover_optimizer import GroverOptimizer, GroverOptimizationResult +from .gurobi_optimizer import GurobiOptimizer from .minimum_eigen_optimizer import (MinimumEigenOptimizer, MinimumEigenOptimizationResult) from .multistart_optimizer import MultiStartOptimizer from .optimization_algorithm import (OptimizationAlgorithm, OptimizationResult, @@ -83,7 +85,7 @@ "OptimizationResultStatus", "BaseAggregator", "CplexOptimizer", "CobylaOptimizer", "GoemansWilliamsonOptimizer", "GoemansWilliamsonOptimizationResult", "GroverOptimizer", "GroverOptimizationResult", - "MeanAggregator", + "GurobiOptimizer", "MeanAggregator", "MinimumEigenOptimizer", "MinimumEigenOptimizationResult", "RecursiveMinimumEigenOptimizer", "RecursiveMinimumEigenOptimizationResult", "IntermediateResult", "SlsqpOptimizer", "SlsqpOptimizationResult", "SolutionSample", diff --git a/qiskit_optimization/algorithms/gurobi_optimizer.py b/qiskit_optimization/algorithms/gurobi_optimizer.py new file mode 100644 index 000000000..3e82e8caa --- /dev/null +++ b/qiskit_optimization/algorithms/gurobi_optimizer.py @@ -0,0 +1,146 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2020, 2021. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""The Gurobi optimizer wrapped to be used within Qiskit's optimization module.""" + +import logging + +from qiskit.exceptions import MissingOptionalLibraryError +from .optimization_algorithm import OptimizationAlgorithm, OptimizationResult +from ..exceptions import QiskitOptimizationError +from ..problems.quadratic_program import QuadraticProgram + +logger = logging.getLogger(__name__) + +try: + import gurobipy as gp + _HAS_GUROBI = True +except ImportError: + _HAS_GUROBI = False + + +class GurobiOptimizer(OptimizationAlgorithm): + """The Gurobi optimizer wrapped as an Qiskit :class:`OptimizationAlgorithm`. + + This class provides a wrapper for ``gurobipy`` (https://pypi.gurobi.com) + to be used within the optimization module. + + Examples: + >>> from qiskit_optimization.problems import QuadraticProgram + >>> from qiskit_optimization.algorithms import GurobiOptimizer + >>> problem = QuadraticProgram() + >>> # specify problem here, if gurobi is installed + >>> optimizer = GurobiOptimizer() if GurobiOptimizer.is_gurobi_installed() else None + >>> # Suppress gurobipy print info to stdout + >>> import sys + >>> class DevNull: + ... def noop(*args, **kwargs): pass + ... close = write = flush = writelines = noop + >>> sys.stdout = DevNull() + >>> result = optimizer.solve(problem) + """ + + def __init__(self, disp: bool = False) -> None: + """Initializes the GurobiOptimizer. + + Args: + disp: Whether to print Gurobi output or not. + + Raises: + MissingOptionalLibraryError: Gurobi is not installed. + """ + if not _HAS_GUROBI: + raise MissingOptionalLibraryError( + libname='GUROBI', + name='GurobiOptimizer', + pip_install="pip install -i https://pypi.gurobi.com gurobipy") + + self._disp = disp + + @staticmethod + def is_gurobi_installed(): + """ Returns True if gurobi is installed """ + return _HAS_GUROBI + + @property + def disp(self) -> bool: + """Returns the display setting. + + Returns: + Whether to print Gurobi information or not. + """ + return self._disp + + @disp.setter + def disp(self, disp: bool): + """Set the display setting. + Args: + disp: The display setting. + """ + self._disp = disp + + # pylint:disable=unused-argument + def get_compatibility_msg(self, problem: QuadraticProgram) -> str: + """Checks whether a given problem can be solved with this optimizer. + + Returns ``''`` since Gurobi accepts all problems that can be modeled using the + ``QuadraticProgram``. Gurobi will also solve non-convex problems. + + Args: + problem: The optimization problem to check compatibility. + + Returns: + An empty string. + """ + return '' + + def solve(self, problem: QuadraticProgram) -> OptimizationResult: + """Tries to solves the given problem using the optimizer. + + Runs the optimizer to try to solve the optimization problem. If problem is not convex, + this optimizer may raise an exception due to incompatibility, depending on the settings. + + Args: + problem: The problem to be solved. + + Returns: + The result of the optimizer applied to the problem. + + Raises: + QiskitOptimizationError: If the problem is incompatible with the optimizer. + """ + + # convert to Gurobi problem + model = problem.to_gurobipy() + + # Enable non-convex + model.Params.NonConvex = 2 + + # set display setting + + if not self.disp: + model.Params.OutputFlag = 0 + + # solve problem + try: + model.optimize() + except gp.GurobiError as ex: + raise QiskitOptimizationError(str(ex)) from ex + + # create results + result = OptimizationResult(x=model.X, fval=model.ObjVal, + variables=problem.variables, + status=self._get_feasibility_status(problem, model.X), + raw_results=model) + + # return solution + return result diff --git a/qiskit_optimization/problems/quadratic_program.py b/qiskit_optimization/problems/quadratic_program.py index 0a60106d8..83d257c35 100644 --- a/qiskit_optimization/problems/quadratic_program.py +++ b/qiskit_optimization/problems/quadratic_program.py @@ -35,6 +35,20 @@ from docplex.mp.model_reader import ModelReader from docplex.mp.quad import QuadExpr from docplex.mp.vartype import BinaryVarType, ContinuousVarType, IntegerVarType + +try: + import gurobipy as gp + from gurobipy import Model as GurobiModel + _HAS_GUROBI = True +except ImportError: + _HAS_GUROBI = False + + class GurobiModel: # type: ignore + """ Empty GurobiModel class + Replacement if gurobipy.Model is not present. + """ + pass + from numpy import ndarray, zeros from scipy.sparse import spmatrix @@ -811,6 +825,146 @@ def maximize(self, self._objective = QuadraticObjective(self, constant, linear, quadratic, QuadraticObjective.Sense.MAXIMIZE) + def from_gurobipy(self, model: GurobiModel) -> None: + """Loads this quadratic program from a gurobipy model. + + Note that this supports only basic functions of gurobipy as follows: + - quadratic objective function + - linear / quadratic constraints + - binary / integer / continuous variables + + Args: + model: The gurobipy model to be loaded. + + Raises: + MissingOptionalLibraryError: gurobipy not installed + QiskitOptimizationError: if the model contains unsupported elements. + """ + if not _HAS_GUROBI: + raise MissingOptionalLibraryError( + libname='GUROBI', + name='GurobiOptimizer', + pip_install="pip install -i https://pypi.gurobi.com gurobipy") + + # clear current problem + self.clear() + + # Update the model to make sure everything works as expected + model.update() + + # get name + self.name = model.ModelName + + # get variables + # keep track of names separately, since gurobipy allows to have None names. + var_names = {} + for x in model.getVars(): + if x.vtype == gp.GRB.CONTINUOUS: + x_new = self.continuous_var(x.lb, x.ub, x.VarName) + elif x.vtype == gp.GRB.BINARY: + x_new = self.binary_var(x.VarName) + elif x.vtype == gp.GRB.INTEGER: + x_new = self.integer_var(x.lb, x.ub, x.VarName) + else: + raise QiskitOptimizationError( + "Unsupported variable type: {} {}".format(x.VarName, x.vtype)) + var_names[x] = x_new.name + + # objective sense + minimize = model.ModelSense == gp.GRB.MINIMIZE + + # Retrieve the objective + objective = model.getObjective() + has_quadratic_objective = False + + # Retrieve the linear part in case it is a quadratic objective + if isinstance(objective, gp.QuadExpr): + linear_part = objective.getLinExpr() + has_quadratic_objective = True + else: + linear_part = objective + + # Get the constant + constant = linear_part.getConstant() + + # get linear part of objective + linear = {} + for i in range(linear_part.size()): + linear[var_names[linear_part.getVar(i)]] = linear_part.getCoeff(i) + + # get quadratic part of objective + quadratic = {} + if has_quadratic_objective: + for i in range(objective.size()): + x = var_names[objective.getVar1(i)] + y = var_names[objective.getVar2(i)] + v = objective.getCoeff(i) + quadratic[x, y] = v + + # set objective + if minimize: + self.minimize(constant, linear, quadratic) + else: + self.maximize(constant, linear, quadratic) + + # check whether there are any general constraints + if model.NumSOS > 0 or model.NumGenConstrs > 0: + raise QiskitOptimizationError( + 'Unsupported constraint: SOS or General Constraint') + + # get linear constraints + for constraint in model.getConstrs(): + name = constraint.ConstrName + sense = constraint.Sense + + left_expr = model.getRow(constraint) + rhs = constraint.RHS + + lhs = {} + for i in range(left_expr.size()): + lhs[var_names[left_expr.getVar(i)]] = left_expr.getCoeff(i) + + if sense == gp.GRB.EQUAL: + self.linear_constraint(lhs, '==', rhs, name) + elif sense == gp.GRB.GREATER_EQUAL: + self.linear_constraint(lhs, '>=', rhs, name) + elif sense == gp.GRB.LESS_EQUAL: + self.linear_constraint(lhs, '<=', rhs, name) + else: + raise QiskitOptimizationError( + "Unsupported constraint sense: {}".format(constraint)) + + # get quadratic constraints + for constraint in model.getQConstrs(): + name = constraint.QCName + sense = constraint.QCSense + + left_expr = model.getQCRow(constraint) + rhs = constraint.QCRHS + + linear = {} + quadratic = {} + + linear_part = left_expr.getLinExpr() + for i in range(linear_part.size()): + linear[var_names[linear_part.getVar(i)]] = linear_part.getCoeff(i) + + for i in range(left_expr.size()): + x = var_names[left_expr.getVar1(i)] + y = var_names[left_expr.getVar2(i)] + v = left_expr.getCoeff(i) + quadratic[x, y] = v + + if sense == gp.GRB.EQUAL: + self.quadratic_constraint(linear, quadratic, '==', rhs, name) + elif sense == gp.GRB.GREATER_EQUAL: + self.quadratic_constraint(linear, quadratic, '>=', rhs, name) + elif sense == gp.GRB.LESS_EQUAL: + self.quadratic_constraint(linear, quadratic, '<=', rhs, name) + else: + raise QiskitOptimizationError( + "Unsupported constraint sense: {}".format(constraint)) + def from_docplex(self, model: Model) -> None: """Loads this quadratic program from a docplex model. @@ -967,6 +1121,97 @@ def from_docplex(self, model: Model) -> None: raise QiskitOptimizationError( "Unsupported constraint sense: {}".format(constraint)) + def to_gurobipy(self) -> GurobiModel: + """Returns a gurobipy model corresponding to this quadratic program + + Returns: + The gurobipy model corresponding to this quadratic program. + + Raises: + MissingOptionalLibraryError: gurobipy not installed + QiskitOptimizationError: if non-supported elements (should never happen). + """ + if not _HAS_GUROBI: + raise MissingOptionalLibraryError( + libname='GUROBI', + name='GurobiOptimizer', + pip_install="pip install -i https://pypi.gurobi.com gurobipy") + + # initialize model + mdl = gp.Model(self.name) + + # add variables + var = {} + for idx, x in enumerate(self.variables): + if x.vartype == Variable.Type.CONTINUOUS: + var[idx] = mdl.addVar(vtype=gp.GRB.CONTINUOUS, lb=x.lowerbound, ub=x.upperbound, + name=x.name) + elif x.vartype == Variable.Type.BINARY: + var[idx] = mdl.addVar(vtype=gp.GRB.BINARY, name=x.name) + elif x.vartype == Variable.Type.INTEGER: + var[idx] = mdl.addVar(vtype=gp.GRB.INTEGER, lb=x.lowerbound, ub=x.upperbound, + name=x.name) + else: + # should never happen + raise QiskitOptimizationError('Unsupported variable type: {}'.format(x.vartype)) + + # add objective + objective = self.objective.constant + for i, v in self.objective.linear.to_dict().items(): + objective += v * var[cast(int, i)] + for (i, j), v in self.objective.quadratic.to_dict().items(): + objective += v * var[cast(int, i)] * var[cast(int, j)] + if self.objective.sense == QuadraticObjective.Sense.MINIMIZE: + mdl.setObjective(objective, sense=gp.GRB.MINIMIZE) + else: + mdl.setObjective(objective, sense=gp.GRB.MAXIMIZE) + + # add linear constraints + for i, l_constraint in enumerate(self.linear_constraints): + name = l_constraint.name + rhs = l_constraint.rhs + if rhs == 0 and l_constraint.linear.coefficients.nnz == 0: + continue + linear_expr = 0 + for j, v in l_constraint.linear.to_dict().items(): + linear_expr += v * var[cast(int, j)] + sense = l_constraint.sense + if sense == Constraint.Sense.EQ: + mdl.addConstr(linear_expr == rhs, name=name) + elif sense == Constraint.Sense.GE: + mdl.addConstr(linear_expr >= rhs, name=name) + elif sense == Constraint.Sense.LE: + mdl.addConstr(linear_expr <= rhs, name=name) + else: + # should never happen + raise QiskitOptimizationError("Unsupported constraint sense: {}".format(sense)) + + # add quadratic constraints + for i, q_constraint in enumerate(self.quadratic_constraints): + name = q_constraint.name + rhs = q_constraint.rhs + if rhs == 0 \ + and q_constraint.linear.coefficients.nnz == 0 \ + and q_constraint.quadratic.coefficients.nnz == 0: + continue + quadratic_expr = 0 + for j, v in q_constraint.linear.to_dict().items(): + quadratic_expr += v * var[cast(int, j)] + for (j, k), v in q_constraint.quadratic.to_dict().items(): + quadratic_expr += v * var[cast(int, j)] * var[cast(int, k)] + sense = q_constraint.sense + if sense == Constraint.Sense.EQ: + mdl.addConstr(quadratic_expr == rhs, name=name) + elif sense == Constraint.Sense.GE: + mdl.addConstr(quadratic_expr >= rhs, name=name) + elif sense == Constraint.Sense.LE: + mdl.addConstr(quadratic_expr <= rhs, name=name) + else: + # should never happen + raise QiskitOptimizationError("Unsupported constraint sense: {}".format(sense)) + + return mdl + def to_docplex(self) -> Model: """Returns a docplex model corresponding to this quadratic program. @@ -1056,6 +1301,7 @@ def export_as_lp_string(self) -> str: Returns: A string representing the quadratic program. """ + return self.to_docplex().export_as_lp_string() def pprint_as_string(self) -> str: diff --git a/test/algorithms/test_gurobi_optimizer.py b/test/algorithms/test_gurobi_optimizer.py new file mode 100644 index 000000000..c4eed172e --- /dev/null +++ b/test/algorithms/test_gurobi_optimizer.py @@ -0,0 +1,62 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2018, 2021. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +""" Test Gurobi Optimizer """ + +import unittest +from test.optimization_test_case import QiskitOptimizationTestCase +from ddt import ddt, data +from qiskit.exceptions import MissingOptionalLibraryError +from qiskit_optimization.algorithms import GurobiOptimizer +from qiskit_optimization.problems import QuadraticProgram + + +@ddt +class TestGurobiOptimizer(QiskitOptimizationTestCase): + """Gurobi Optimizer Tests.""" + + def setUp(self): + super().setUp() + try: + self.gurobi_optimizer = GurobiOptimizer(disp=False) + except MissingOptionalLibraryError as ex: + self.skipTest(str(ex)) + + @data( + ('op_ip1.lp', [0, 2], 6), + ('op_mip1.lp', [1, 1, 0], 6), + ('op_lp1.lp', [0.25, 1.75], 5.8750) + ) + def test_gurobi_optimizer(self, config): + """ Gurobi Optimizer Test """ + try: + # unpack configuration + filename, x, fval = config + + # load optimization problem + problem = QuadraticProgram() + lp_file = self.get_resource_path(filename, 'algorithms/resources') + problem.read_from_lp_file(lp_file) + + # solve problem with gurobi + result = self.gurobi_optimizer.solve(problem) + + # analyze results + self.assertAlmostEqual(result.fval, fval) + for i in range(problem.get_num_vars()): + self.assertAlmostEqual(result.x[i], x[i]) + except MissingOptionalLibraryError as ex: + self.skipTest(str(ex)) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/problems/test_quadratic_program.py b/test/problems/test_quadratic_program.py index ad6d274e1..9c1b705ea 100644 --- a/test/problems/test_quadratic_program.py +++ b/test/problems/test_quadratic_program.py @@ -803,6 +803,77 @@ def test_docplex(self): self.assertDictEqual(c.quadratic.to_dict(use_name=True), {('x0', 'x1'): 1}) self.assertEqual(c.sense, senses[i]) + def test_gurobipy(self): + """test from_gurobipy and to_gurobipy""" + try: + import gurobipy as gp + except ImportError as ex: + self.skipTest("gurobipy not installed: {}".format(str(ex))) + return + q_p = QuadraticProgram('test') + q_p.binary_var(name='x') + q_p.integer_var(name='y', lowerbound=-2, upperbound=4) + q_p.continuous_var(name='z', lowerbound=-1.5, upperbound=3.2) + q_p.minimize(constant=1, linear={'x': 1, 'y': 2}, + quadratic={('x', 'y'): -1, ('z', 'z'): 2}) + q_p.linear_constraint({'x': 2, 'z': -1}, '==', 1) + q_p.quadratic_constraint({'x': 2, 'z': -1}, {('y', 'z'): 3}, '==', 1) + q_p2 = QuadraticProgram() + q_p2.from_gurobipy(q_p.to_gurobipy()) + self.assertEqual(q_p.export_as_lp_string(), q_p2.export_as_lp_string()) + + mod = gp.Model('test') + x = mod.addVar(vtype=gp.GRB.BINARY, name='x') + y = mod.addVar(vtype=gp.GRB.INTEGER, lb=-2, ub=4, name='y') + z = mod.addVar(vtype=gp.GRB.CONTINUOUS, lb=-1.5, ub=3.2, name='z') + mod.setObjective(1 + x + 2 * y - x * y + 2 * z * z) + mod.optimize() + mod.addConstr(2 * x - z == 1, name='c0') + mod.addConstr(2 * x - z + 3 * y * z == 1, name='q0') + + # Here I am unsure what to do, let's come back to it later + # self.assertEqual(q_p.export_as_lp_string(), mod.export_as_lp_string()) + + with self.assertRaises(QiskitOptimizationError): + mod = gp.Model() + mod.addVar(vtype=gp.GRB.SEMIINT, lb=1, name='x') + q_p.from_gurobipy(mod) + + with self.assertRaises(QiskitOptimizationError): + mod = gp.Model() + x = mod.addVar(vtype=gp.GRB.BINARY, name='x') + y = mod.addVar(vtype=gp.GRB.BINARY, name='y') + mod.addConstr((x == 1) >> (x + y <= 1)) + q_p.from_gurobipy(mod) + + # test from_gurobipy without explicit variable names + mod = gp.Model() + x = mod.addVar(vtype=gp.GRB.BINARY) + y = mod.addVar(vtype=gp.GRB.CONTINUOUS) + z = mod.addVar(vtype=gp.GRB.INTEGER) + mod.setObjective(x + y + z + x * y + y * z + x * z) + mod.optimize() + mod.addConstr(x + y == z) # linear EQ + mod.addConstr(x + y >= z) # linear GE + mod.addConstr(x + y <= z) # linear LE + mod.addConstr(x * y == z) # quadratic EQ + mod.addConstr(x * y >= z) # quadratic GE + mod.addConstr(x * y <= z) # quadratic LE + q_p = QuadraticProgram() + q_p.from_gurobipy(mod) + var_names = [v.name for v in q_p.variables] + self.assertListEqual(var_names, ['C0', 'C1', 'C2']) + senses = [Constraint.Sense.EQ, Constraint.Sense.GE, Constraint.Sense.LE] + for i, c in enumerate(q_p.linear_constraints): + self.assertDictEqual(c.linear.to_dict(use_name=True), {'C0': 1, 'C1': 1, 'C2': -1}) + self.assertEqual(c.rhs, 0) + self.assertEqual(c.sense, senses[i]) + for i, c in enumerate(q_p.quadratic_constraints): + self.assertEqual(c.rhs, 0) + self.assertDictEqual(c.linear.to_dict(use_name=True), {'C2': -1}) + self.assertDictEqual(c.quadratic.to_dict(use_name=True), {('C0', 'C1'): 1}) + self.assertEqual(c.sense, senses[i]) + def test_substitute_variables(self): """test substitute variables""" q_p = QuadraticProgram('test')