From 54772122a987956891a122357c52cbcd704ad096 Mon Sep 17 00:00:00 2001 From: Alexander Condello Date: Fri, 27 Jan 2023 15:24:20 -0800 Subject: [PATCH 1/3] Add ConstrainedQuadraticModel.add_linear_constraints() method --- dimod/constrained/constrained.py | 114 ++++++++++++++++- dimod/constrained/cyconstrained.pyx | 55 ++++++++- dimod/cyutilities.pxd | 9 ++ docs/reference/models.rst | 1 + ...d_linear_constraints-532d0d1d18916748.yaml | 4 + tests/test_constrained.py | 116 ++++++++++++++++++ 6 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 releasenotes/notes/CQM.add_linear_constraints-532d0d1d18916748.yaml diff --git a/dimod/constrained/constrained.py b/dimod/constrained/constrained.py index cf9e91d72..1101f224a 100644 --- a/dimod/constrained/constrained.py +++ b/dimod/constrained/constrained.py @@ -69,7 +69,7 @@ from dimod.serialization.fileview import SpooledTemporaryFile, _BytesIO from dimod.serialization.fileview import load, read_header, write_header from dimod.sym import Comparison, Sense -from dimod.typing import Bias, Variable, SamplesLike +from dimod.typing import ArrayLike, Bias, Variable, SamplesLike from dimod.utilities import new_variable_label from dimod.variables import serialize_variable, deserialize_variable, Variables from dimod.vartypes import as_vartype, Vartype, VartypeLike @@ -688,6 +688,118 @@ def add_discrete_from_model(self, self.discrete.add(label) return label + def add_linear_constraints(self, A: ArrayLike, sense: Union[Sense, str], b: ArrayLike, + *, + variable_labels: Optional[Iterable[Variable]] = None, + constraint_labels: Optional[Iterable[Hashable]] = None, + ): + r"""Add multiple linear constraints to the model. + + Add linear constraints defined by + + .. math:: + + A_{ub}x \le b_{ub}, \\ + A_{eq}x = b_{eq} + + where `x` is a vector of binary decision variables, + :math:`b_{ub}` and :math:`b_{eq}` are vectors, + and :math:`A_{ub}` and :math:`A_{eq}` are matrices. + + Args: + A: Each row of ``A`` specifies the coefficients of a linear constraint. + sense: One of `<=`, `>=`, `==`. + b: An array of left-hand-sides of the constraints. + variable_labels: The variable labels corresponding to the columns of `A`. + If ``None``, defaults to ``range(A.shape[1])``. + constraint_labels: The labels of the constraints. Labels of additional + constraints are generated. + + Example: + + Consider a binary linear program subject to the following constraints + + .. math:: + x_0 + 2x_1 - 7x_2 = 2 \\ + x_0 + x_2 = 0 \\ + -x_0 + x_1 \le 1 \\ + 3x_1 + x_2 \le 3 + + We can specify the constraints using matrices and vectors + + >>> cqm = dimod.ConstrainedQuadraticModel() + >>> cqm.add_variables("BINARY", 3) + >>> A_eq = [[1, 2, -7], [1, 0, 1]] + >>> b_eq = [2, 0] + >>> cqm.add_linear_constraints(A_eq, "==", b_eq) + >>> A_ub = [[-1, 1, 0], [0, 3, 1]] + >>> b_ub = [1, 3] + >>> cqm.add_linear_constraints(A_ub, "<=", b_ub) + >>> print(cqm) + Constrained quadratic model: 3 variables, 4 constraints, 9 biases + + Objective + 0 + + Constraints + 0: Binary(0) + 2*Binary(1) - 7*Binary(2) == 2.0 + 1: Binary(0) + Binary(2) == 0.0 + 2: -Binary(0) + Binary(1) <= 1.0 + 3: 3*Binary(1) + Binary(2) <= 3.0 + + Bounds + + + We can also use :class:`str`-labelled variables + + >>> cqm = dimod.ConstrainedQuadraticModel() + >>> cqm.add_variables("BINARY", ["x0", "x1", "x2"]) + >>> A_eq = [[1, 2, -7], [1, 0, 1]] + >>> b_eq = [2, 0] + >>> cqm.add_linear_constraints(A_eq, "==", b_eq, variable_labels=["x0", "x1", "x2"]) + >>> A_ub = [[-1, 1, 0], [0, 3, 1]] + >>> b_ub = [1, 3] + >>> cqm.add_linear_constraints(A_ub, "<=", b_ub, variable_labels=["x0", "x1", "x2"]) + >>> print(cqm) + Constrained quadratic model: 3 variables, 4 constraints, 9 biases + + Objective + 0 + + Constraints + 0: Binary('x0') + 2*Binary('x1') - 7*Binary('x2') == 2.0 + 1: Binary('x0') + Binary('x2') == 0.0 + 2: -Binary('x0') + Binary('x1') <= 1.0 + 3: 3*Binary('x1') + Binary('x2') <= 3.0 + + Bounds + + + .. versionadded:: 0.12.4 + + """ + A = np.asarray(A) + if A.ndim != 2: + raise ValueError("expected A to be a 2d array-like") + if np.issubdtype(A.dtype, np.unsignedinteger): + A = np.asarray(A, dtype=float) + + b = np.asarray(b) + if b.ndim != 1: + raise ValueError("expected b to be a 1d array-like") + if np.issubdtype(b.dtype, np.unsignedinteger): + b = np.asarray(b, dtype=float) + + # we could also check for complex, to raise a better error message, + # but that seems like enough of an edge case that we should let it + # fail lazily. + + return super().add_linear_constraints( + A, sense, b, + variable_labels=variable_labels, + constraint_labels=constraint_labels, + ) + def add_variable(self, vartype: VartypeLike, v: Optional[Variable] = None, *, lower_bound: Optional[float] = None, diff --git a/dimod/constrained/cyconstrained.pyx b/dimod/constrained/cyconstrained.pyx index a1b33cdbe..4759f4a4c 100644 --- a/dimod/constrained/cyconstrained.pyx +++ b/dimod/constrained/cyconstrained.pyx @@ -20,6 +20,8 @@ import typing from copy import deepcopy +cimport cython + from cython.operator cimport preincrement as inc, dereference as deref from libc.math cimport ceil, floor from libcpp.unordered_set cimport unordered_set @@ -32,7 +34,7 @@ from dimod.binary import BinaryQuadraticModel from dimod.constrained.expression import ObjectiveView, ConstraintView from dimod.cyqmbase cimport cyQMBase from dimod.cyqmbase.cyqmbase_float64 import BIAS_DTYPE, INDEX_DTYPE -from dimod.cyutilities cimport as_numpy_float +from dimod.cyutilities cimport as_numpy_float, ConstNumeric, ConstNumeric2 from dimod.cyutilities cimport cppvartype from dimod.libcpp.abc cimport QuadraticModelBase as cppQuadraticModelBase from dimod.libcpp.constrained_quadratic_model cimport Sense as cppSense, Penalty as cppPenalty, Constraint as cppConstraint @@ -280,6 +282,57 @@ cdef class cyConstrainedQuadraticModel: self.cppcqm.add_variable(vt, lb, ub) return v + # dev note: we explicitly don't want contiguous to allow fancy indexing + @cython.boundscheck(False) + @cython.wraparound(False) + def add_linear_constraints(self, ConstNumeric[:, :] A, object sense, ConstNumeric2[:] b, + *, + variable_labels=None, constraint_labels=None): + + # Input parsing for A, b + cdef Py_ssize_t num_constraints = A.shape[0] + cdef Py_ssize_t num_variables = A.shape[1] + if b.shape[0] != num_constraints: + raise ValueError("the number of rows in A must equal the number of values in b") + + # Input parsing for sense + cdef cppSense sense_ = cppsense(sense) + + # Input parsing for variable labels + if variable_labels is None: + variable_labels = range(num_variables) + + cdef vector[index_type] variables # map input variable row to internal index + variables.reserve(num_variables) + for v in variable_labels: + variables.push_back(self.variables.index(v)) + if variables.size() != num_variables: + raise ValueError("expected the length of variable_labels to equal the number of columns in A") + + # Input parsing for constraint_labels + # if the list is too short, we start generating random labels + if constraint_labels is None: + constraint_labels = itertools.repeat(None) + else: + constraint_labels = itertools.chain(constraint_labels, itertools.repeat(None)) + + # At this point we've checked everything, so it should be safe to just + # add new constraints to the model, because we shouldn't be able to get + # a malformed model partway through + cdef Py_ssize_t ci, vi + for ci in range(num_constraints): + constraint = self.cppcqm.new_constraint() + + constraint.set_sense(sense_) + constraint.set_rhs(b[ci]) + + for vi in range(num_variables): + if A[ci, vi]: + constraint.add_linear(variables[vi], A[ci, vi]) + + self.cppcqm.add_constraint(move(constraint)) + self.constraint_labels._append(next(constraint_labels)) + def change_vartype(self, vartype, v): vartype = as_vartype(vartype, extended=True) cdef cppVartype vt = cppvartype(vartype) diff --git a/dimod/cyutilities.pxd b/dimod/cyutilities.pxd index 6f1ff78c0..9087adcdc 100644 --- a/dimod/cyutilities.pxd +++ b/dimod/cyutilities.pxd @@ -66,6 +66,15 @@ ctypedef fused ConstNumeric: const float const double +# for when there are two input arguments of different types +ctypedef fused ConstNumeric2: + const signed char + const signed short + const signed int + const signed long long + const float + const double + cdef object as_numpy_float(cython.floating) diff --git a/docs/reference/models.rst b/docs/reference/models.rst index 3297d9b3b..fa52fab9a 100644 --- a/docs/reference/models.rst +++ b/docs/reference/models.rst @@ -168,6 +168,7 @@ CQM Methods ~ConstrainedQuadraticModel.add_discrete_from_comparison ~ConstrainedQuadraticModel.add_discrete_from_iterable ~ConstrainedQuadraticModel.add_discrete_from_model + ~ConstrainedQuadraticModel.add_linear_constraints ~ConstrainedQuadraticModel.add_variable ~ConstrainedQuadraticModel.check_feasible ~ConstrainedQuadraticModel.fix_variable diff --git a/releasenotes/notes/CQM.add_linear_constraints-532d0d1d18916748.yaml b/releasenotes/notes/CQM.add_linear_constraints-532d0d1d18916748.yaml new file mode 100644 index 000000000..c27ce1567 --- /dev/null +++ b/releasenotes/notes/CQM.add_linear_constraints-532d0d1d18916748.yaml @@ -0,0 +1,4 @@ +--- +features: + - | + Add ``ConstrainedQuadraticModel.add_linear_constraints()`` method. diff --git a/tests/test_constrained.py b/tests/test_constrained.py index d6ea3fb2e..1e5fbc500 100644 --- a/tests/test_constrained.py +++ b/tests/test_constrained.py @@ -213,6 +213,122 @@ def test_simple(self): cqm.add_discrete('abc') +class TestAddLinearConstraints(unittest.TestCase): + def test_simple(self): + A = [[0, 1, 1], [0, 0, 1], [1, 0, 0]] + b = [1, 2, 3] + + cqm = dimod.CQM() + cqm.add_variables("BINARY", 3) + cqm.add_linear_constraints(A, '==', b, constraint_labels='abc') + + self.assertEqual(cqm.num_constraints(), 3) + + self.assertEqual(cqm.constraints['a'].rhs, 1) + self.assertEqual(cqm.constraints['a'].sense, Sense.Eq) + self.assertTrue(cqm.constraints['a'].lhs.is_linear()) + self.assertEqual(cqm.constraints['a'].lhs.linear, {1: 1, 2: 1}) + + self.assertEqual(cqm.constraints['b'].rhs, 2) + self.assertEqual(cqm.constraints['b'].sense, Sense.Eq) + self.assertTrue(cqm.constraints['b'].lhs.is_linear()) + self.assertEqual(cqm.constraints['b'].lhs.linear, {2: 1}) + + self.assertEqual(cqm.constraints['c'].rhs, 3) + self.assertEqual(cqm.constraints['c'].sense, Sense.Eq) + self.assertTrue(cqm.constraints['c'].lhs.is_linear()) + self.assertEqual(cqm.constraints['c'].lhs.linear, {0: 1}) + + def test_empty_row(self): + A = [[0, 1, 1], [0, 0, 0]] + b = [1, 2] + + cqm = dimod.CQM() + cqm.add_variables("BINARY", 3) + cqm.add_linear_constraints(A, '<=', b, constraint_labels='abc') # allow too many labels + + self.assertEqual(cqm.num_constraints(), 2) + + self.assertEqual(cqm.constraints['a'].rhs, 1) + self.assertEqual(cqm.constraints['a'].sense, Sense.Le) + self.assertTrue(cqm.constraints['a'].lhs.is_linear()) + self.assertEqual(cqm.constraints['a'].lhs.linear, {1: 1, 2: 1}) + + self.assertEqual(cqm.constraints['b'].rhs, 2) + self.assertEqual(cqm.constraints['b'].sense, Sense.Le) + self.assertTrue(cqm.constraints['b'].lhs.is_linear()) + self.assertEqual(cqm.constraints['b'].lhs.linear, {}) + + def test_different_dtype(self): + A = np.asarray([[0, 1, 1], [0, 0, 0]], dtype=int) + b = np.asarray([1, 2], dtype=float) + + cqm = dimod.CQM() + cqm.add_variables("BINARY", 3) + cqm.add_linear_constraints(A, '<=', b, constraint_labels='ab') + + self.assertEqual(cqm.num_constraints(), 2) + + self.assertEqual(cqm.constraints['a'].rhs, 1) + self.assertEqual(cqm.constraints['a'].sense, Sense.Le) + self.assertTrue(cqm.constraints['a'].lhs.is_linear()) + self.assertEqual(cqm.constraints['a'].lhs.linear, {1: 1, 2: 1}) + + self.assertEqual(cqm.constraints['b'].rhs, 2) + self.assertEqual(cqm.constraints['b'].sense, Sense.Le) + self.assertTrue(cqm.constraints['b'].lhs.is_linear()) + self.assertEqual(cqm.constraints['b'].lhs.linear, {}) + + def test_unsigned(self): + A = np.asarray([[0, 1, 1], [3, 4, 5]], dtype=np.uint32) + b = np.asarray([1, 2], dtype=np.uint8) + + cqm = dimod.CQM() + cqm.add_variables("BINARY", 3) + cqm.add_linear_constraints(A, '<=', b, constraint_labels='ab') + + self.assertEqual(cqm.num_constraints(), 2) + + self.assertEqual(cqm.constraints['a'].rhs, 1) + self.assertEqual(cqm.constraints['a'].sense, Sense.Le) + self.assertTrue(cqm.constraints['a'].lhs.is_linear()) + self.assertEqual(cqm.constraints['a'].lhs.linear, {1: 1, 2: 1}) + + self.assertEqual(cqm.constraints['b'].rhs, 2) + self.assertEqual(cqm.constraints['b'].sense, Sense.Le) + self.assertTrue(cqm.constraints['b'].lhs.is_linear()) + self.assertEqual(cqm.constraints['b'].lhs.linear, {0: 3.0, 1: 4.0, 2: 5.0}) + + def test_variable_labels(self): + A = [[0, 1, 1], [3, 4, 5]] + b = [1, 2] + + cqm = dimod.CQM() + cqm.add_variables("BINARY", 'abc') + cqm.add_linear_constraints(A, '<=', b, + constraint_labels=[0], # under-labelled + variable_labels='cba') + + self.assertEqual(cqm.constraints[0].rhs, 1) + self.assertEqual(cqm.constraints[0].sense, Sense.Le) + self.assertTrue(cqm.constraints[0].lhs.is_linear()) + self.assertEqual(cqm.constraints[0].lhs.linear, {'b': 1, 'a': 1}) + + self.assertEqual(cqm.constraints[1].rhs, 2) + self.assertEqual(cqm.constraints[1].sense, Sense.Le) + self.assertTrue(cqm.constraints[1].lhs.is_linear()) + self.assertEqual(cqm.constraints[1].lhs.linear, {'c': 3.0, 'b': 4.0, 'a': 5.0}) + + def test_bad_variable_labels(self): + A = [[0, 1, 1], [3, 4, 5]] + b = [1, 2] + + cqm = dimod.CQM() + cqm.add_variables("BINARY", 'abc') + with self.assertRaises(ValueError): + cqm.add_linear_constraints(A, '<=', b, variable_labels='cb') + + class TestSoftConstraint(unittest.TestCase): def test_constraint_manipulation(self): # soft constraints should survive relabeling and removal From cc6e1e7305b8ec78dc0a195212da91c60bd283fe Mon Sep 17 00:00:00 2001 From: Alexander Condello Date: Fri, 27 Jan 2023 15:41:04 -0800 Subject: [PATCH 2/3] Fix variable type in CQM.add_linear_constraints description --- dimod/constrained/constrained.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dimod/constrained/constrained.py b/dimod/constrained/constrained.py index 1101f224a..5d537928e 100644 --- a/dimod/constrained/constrained.py +++ b/dimod/constrained/constrained.py @@ -702,7 +702,7 @@ def add_linear_constraints(self, A: ArrayLike, sense: Union[Sense, str], b: Arra A_{ub}x \le b_{ub}, \\ A_{eq}x = b_{eq} - where `x` is a vector of binary decision variables, + where `x` is a vector of decision variables, :math:`b_{ub}` and :math:`b_{eq}` are vectors, and :math:`A_{ub}` and :math:`A_{eq}` are matrices. From ef353a43ad1490ea7722e2536d215579901b7827 Mon Sep 17 00:00:00 2001 From: Alexander Condello Date: Mon, 30 Jan 2023 08:46:53 -0800 Subject: [PATCH 3/3] Apply docstring suggestions from code review Co-authored-by: Joel Pasvolsky <34041130+JoelPasvolsky@users.noreply.github.com> --- dimod/constrained/constrained.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dimod/constrained/constrained.py b/dimod/constrained/constrained.py index 5d537928e..9ca21f99f 100644 --- a/dimod/constrained/constrained.py +++ b/dimod/constrained/constrained.py @@ -709,7 +709,7 @@ def add_linear_constraints(self, A: ArrayLike, sense: Union[Sense, str], b: Arra Args: A: Each row of ``A`` specifies the coefficients of a linear constraint. sense: One of `<=`, `>=`, `==`. - b: An array of left-hand-sides of the constraints. + b: An array of right-hand-sides of the constraints. variable_labels: The variable labels corresponding to the columns of `A`. If ``None``, defaults to ``range(A.shape[1])``. constraint_labels: The labels of the constraints. Labels of additional @@ -725,7 +725,7 @@ def add_linear_constraints(self, A: ArrayLike, sense: Union[Sense, str], b: Arra -x_0 + x_1 \le 1 \\ 3x_1 + x_2 \le 3 - We can specify the constraints using matrices and vectors + You can specify the constraints using matrices and vectors: >>> cqm = dimod.ConstrainedQuadraticModel() >>> cqm.add_variables("BINARY", 3)