Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ConstrainedQuadraticModel.add_linear_constraints() method #1307

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 113 additions & 1 deletion dimod/constrained/constrained.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Add linear constraints defined by
Add linear constraints defined by the following upper-boundary and equality conditions

to clarify ub and eq


.. math::

A_{ub}x \le b_{ub}, \\
A_{eq}x = b_{eq}

where `x` is a vector of decision variables,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
where `x` is a vector of decision variables,
where :math:`x` is a vector of decision variables,

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's the same formatting, you only need to use :math: when you want to use latex etc.

: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 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
constraints are generated.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean here something like, "Labels are generated for any unlabeled constraints"?


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

You 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
<BLANKLINE>
Objective
0
<BLANKLINE>
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
<BLANKLINE>
Bounds
<BLANKLINE>

We can also use :class:`str`-labelled variables
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
We can also use :class:`str`-labelled variables
You can also label the variables:

Is it helpful to point out that labels are strings? Would they be something else?

Copy link
Member Author

@arcondello arcondello Jan 30, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am distinguishing them from the int-labelled ones from before, but I can clarify the intent


>>> 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
<BLANKLINE>
Objective
0
<BLANKLINE>
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
<BLANKLINE>
Bounds
<BLANKLINE>

.. 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,
Expand Down
55 changes: 54 additions & 1 deletion dimod/constrained/cyconstrained.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions dimod/cyutilities.pxd
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions docs/reference/models.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
features:
- |
Add ``ConstrainedQuadraticModel.add_linear_constraints()`` method.
116 changes: 116 additions & 0 deletions tests/test_constrained.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down