From 08bd6735df06df170c1d11f18b91089505d05224 Mon Sep 17 00:00:00 2001 From: William Bernoudy Date: Mon, 18 Nov 2024 21:28:34 -0800 Subject: [PATCH 1/2] Add _Model base class for Model and Expression --- dwave/optimization/expression/__init__.pxd | 15 + dwave/optimization/expression/__init__.py | 1 + dwave/optimization/expression/expression.pxd | 24 + dwave/optimization/expression/expression.pyi | 31 + dwave/optimization/expression/expression.pyx | 37 + dwave/optimization/model.pxd | 61 +- dwave/optimization/model.pyi | 87 +- dwave/optimization/model.pyx | 867 +++++++++---------- dwave/optimization/symbols.pxd | 4 +- dwave/optimization/symbols.pyx | 33 +- meson.build | 11 + tests/test_symbols.py | 66 +- 12 files changed, 686 insertions(+), 551 deletions(-) create mode 100644 dwave/optimization/expression/__init__.pxd create mode 100644 dwave/optimization/expression/__init__.py create mode 100644 dwave/optimization/expression/expression.pxd create mode 100644 dwave/optimization/expression/expression.pyi create mode 100644 dwave/optimization/expression/expression.pyx diff --git a/dwave/optimization/expression/__init__.pxd b/dwave/optimization/expression/__init__.pxd new file mode 100644 index 00000000..a3a49812 --- /dev/null +++ b/dwave/optimization/expression/__init__.pxd @@ -0,0 +1,15 @@ +# Copyright 2023 D-Wave Systems Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dwave.optimization.expression.expression cimport Expression diff --git a/dwave/optimization/expression/__init__.py b/dwave/optimization/expression/__init__.py new file mode 100644 index 00000000..8ce6b96e --- /dev/null +++ b/dwave/optimization/expression/__init__.py @@ -0,0 +1 @@ +from dwave.optimization.expression.expression import Expression diff --git a/dwave/optimization/expression/expression.pxd b/dwave/optimization/expression/expression.pxd new file mode 100644 index 00000000..3b7d5df2 --- /dev/null +++ b/dwave/optimization/expression/expression.pxd @@ -0,0 +1,24 @@ +# distutils: language = c++ + +# Copyright 2024 D-Wave Systems Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dwave.optimization.model cimport ArraySymbol, _Model + + +__all__ = ["Expression"] + + +cdef class Expression(_Model): + cdef readonly ArraySymbol output diff --git a/dwave/optimization/expression/expression.pyi b/dwave/optimization/expression/expression.pyi new file mode 100644 index 00000000..32b262d4 --- /dev/null +++ b/dwave/optimization/expression/expression.pyi @@ -0,0 +1,31 @@ +# Copyright 2024 D-Wave Systems Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Optional + +from dwave.optimization.model import _Model + + +_ShapeLike: typing.TypeAlias = typing.Union[int, collections.abc.Sequence[int]] + + +class Expression(_Model): + def __init__(self): ... + + def input(self, lower_bound: float, upper_bound: float, integral: bool, shape: Optional[tuple] = None): + + @property + def output(self) -> ArraySymbol: ... + + def set_output(self, value: ArraySymbol): ... diff --git a/dwave/optimization/expression/expression.pyx b/dwave/optimization/expression/expression.pyx new file mode 100644 index 00000000..07991335 --- /dev/null +++ b/dwave/optimization/expression/expression.pyx @@ -0,0 +1,37 @@ +# Copyright 2024 D-Wave Systems Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Optional + +from libcpp cimport bool + +from dwave.optimization.libcpp.array cimport Array as cppArray +from dwave.optimization.symbols cimport symbol_from_ptr + + +__all__ = ["Expression"] + + +cdef class Expression(_Model): + def __init__(self): + pass + + def input(self, lower_bound: float, upper_bound: float, bool integral, shape: Optional[tuple] = None): + """TODO""" + # avoid circular import + from dwave.optimization.symbols import Input + return Input(self, lower_bound, upper_bound, integral, shape=shape) + + def set_output(self, value: ArraySymbol): + self.output = value diff --git a/dwave/optimization/model.pxd b/dwave/optimization/model.pxd index 172fe1aa..6a795f7c 100644 --- a/dwave/optimization/model.pxd +++ b/dwave/optimization/model.pxd @@ -26,12 +26,10 @@ from dwave.optimization.libcpp.state cimport State as cppState __all__ = ["Model"] -cdef class Model: +cdef class _Model: cpdef bool is_locked(self) noexcept - cpdef Py_ssize_t num_decisions(self) noexcept cpdef Py_ssize_t num_nodes(self) noexcept - cpdef Py_ssize_t num_constraints(self) noexcept - + # Allow dynamic attributes on the Model class cdef dict __dict__ @@ -40,6 +38,31 @@ cdef class Model: cdef cppGraph _graph + cdef readonly States states + """States of the model. + + :ref:`States ` represent assignments of values + to a symbol. + + See also: + :ref:`States methods ` such as + :meth:`~dwave.optimization.model.States.size` and + :meth:`~dwave.optimization.model.States.resize`. + """ + + # The number of times "lock()" has been called. + cdef readonly Py_ssize_t _lock_count + + # Used to keep NumPy arrays that own data alive etc etc + # We could pair each of these with an expired_ptr for the node holding + # memory for easier cleanup later if that becomes a concern. + cdef object _data_sources + + +cdef class Model(_Model): + cpdef Py_ssize_t num_constraints(self) noexcept + cpdef Py_ssize_t num_decisions(self) noexcept + cdef readonly object objective # todo: cdef ArraySymbol? """Objective to be minimized. @@ -62,26 +85,6 @@ cdef class Model: Objective = -4.0 """ - cdef readonly States states - """States of the model. - - :ref:`States ` represent assignments of values - to a symbol. - - See also: - :ref:`States methods ` such as - :meth:`~dwave.optimization.model.States.size` and - :meth:`~dwave.optimization.model.States.resize`. - """ - - # The number of times "lock()" has been called. - cdef readonly Py_ssize_t _lock_count - - # Used to keep NumPy arrays that own data alive etc etc - # We could pair each of these with an expired_ptr for the node holding - # memory for easier cleanup later if that becomes a concern. - cdef object _data_sources - cdef class States: """The states/solutions of the model.""" @@ -91,7 +94,7 @@ cdef class States: cpdef resolve(self) cpdef Py_ssize_t size(self) except -1 - cdef Model _model(self) + cdef _Model _model(self) # In order to not create a circular reference, we only hold a weakref # to the model from the states. This introduces some overhead, but it @@ -114,7 +117,7 @@ cdef class States: cdef class Symbol: # Inheriting nodes must call this method from their __init__() - cdef void initialize_node(self, Model model, cppNode* node_ptr) noexcept + cdef void initialize_node(self, _Model model, cppNode* node_ptr) noexcept cpdef uintptr_t id(self) noexcept @@ -122,12 +125,12 @@ cdef class Symbol: cpdef bool expired(self) noexcept @staticmethod - cdef Symbol from_ptr(Model model, cppNode* ptr) + cdef Symbol from_ptr(_Model model, cppNode* ptr) # Hold on to a reference to the Model, both for access but also, importantly, # to ensure that the model doesn't get garbage collected unless all of # the observers have also been garbage collected. - cdef readonly Model model + cdef readonly _Model model # Hold Node* pointer. This is redundant as most observers will also hold # a pointer to their observed node with the correct type. But the cost @@ -145,7 +148,7 @@ cdef class Symbol: # also Symbols (probably a fair assumption) cdef class ArraySymbol(Symbol): # Inheriting symbols must call this method from their __init__() - cdef void initialize_arraynode(self, Model model, cppArrayNode* array_ptr) noexcept + cdef void initialize_arraynode(self, _Model model, cppArrayNode* array_ptr) noexcept # Hold ArrayNode* pointer. Again this is redundant, because we're also holding # a pointer to Node* and we can theoretically dynamic cast each time. diff --git a/dwave/optimization/model.pyi b/dwave/optimization/model.pyi index 7016e927..e3bcc8a6 100644 --- a/dwave/optimization/model.pyi +++ b/dwave/optimization/model.pyi @@ -26,17 +26,58 @@ from dwave.optimization.symbols import * _ShapeLike: typing.TypeAlias = typing.Union[int, collections.abc.Sequence[int]] -class Model: +class _Model: + @property + def states(self) -> States: ... + + def constant(self, array_like: numpy.typing.ArrayLike) -> Constant: ... + + @classmethod + def from_file( + cls, + file: typing.Union[typing.BinaryIO, collections.abc.ByteString, str], + *, + check_header: bool = True, + ) -> Model: ... + + def into_file( + self, + file: typing.Union[typing.BinaryIO, collections.abc.ByteString, str], + *, + max_num_states: int = 0, + only_decision: bool = False, + ): ... + + def is_locked(self) -> bool: ... + + def iter_symbols(self) -> collections.abc.Iterator[Symbol]: ... + def num_nodes(self) -> int: ... + def num_symbols(self) -> int: ... + + def remove_unused_symbols(self) -> int: ... + + def state_size(self) -> int: ... + + def to_file( + self, + *, + max_num_states: int = 0, + only_decision: bool = False, + ) -> typing.BinaryIO: ... + + # networkx might not be installed, so we just say we return an object. + def to_networkx(self) -> object: ... + + def unlock(self): ... + + +class Model(_Model): def __init__(self): ... @property def objective(self) -> ArraySymbol: ... - @property - def states(self) -> States: ... - def add_constraint(self, value: ArraySymbol) -> ArraySymbol: ... def binary(self, shape: typing.Optional[_ShapeLike] = None) -> BinaryVariable: ... - def constant(self, array_like: numpy.typing.ArrayLike) -> Constant: ... def decision_state_size(self) -> int: ... def disjoint_bit_sets( @@ -49,14 +90,6 @@ class Model: def feasible(self, index: int = 0) -> bool: ... - @classmethod - def from_file( - cls, - file: typing.Union[typing.BinaryIO, collections.abc.ByteString, str], - *, - check_header: bool = True, - ) -> Model: ... - def integer( self, shape: typing.Optional[_ShapeLike] = None, @@ -64,32 +97,18 @@ class Model: upper_bound: typing.Optional[int] = None, ) -> IntegerVariable: ... - def into_file( - self, - file: typing.Union[typing.BinaryIO, collections.abc.ByteString, str], - *, - max_num_states: int = 0, - only_decision: bool = False, - ): ... - - def is_locked(self) -> bool: ... def iter_constraints(self) -> collections.abc.Iterator[ArraySymbol]: ... def iter_decisions(self) -> collections.abc.Iterator[Symbol]: ... - def iter_symbols(self) -> collections.abc.Iterator[Symbol]: ... def list(self, n: int) -> ListVariable: ... def lock(self) -> contextlib.AbstractContextManager: ... def minimize(self, value: ArraySymbol): ... def num_constraints(self) -> int: ... def num_decisions(self) -> int: ... - def num_nodes(self) -> int: ... - def num_symbols(self) -> int: ... # dev note: this is underspecified, but it would be quite complex to fully # specify the linear/quadratic so let's leave it alone for now. def quadratic_model(self, x: ArraySymbol, quadratic, linear=None) -> QuadraticModel: ... - def remove_unused_symbols(self) -> int: ... - def set( self, n: int, @@ -97,20 +116,6 @@ class Model: max_size: typing.Optional[int] = None, ) -> SetVariable: ... - def state_size(self) -> int: ... - - def to_file( - self, - *, - max_num_states: int = 0, - only_decision: bool = False, - ) -> typing.BinaryIO: ... - - # networkx might not be installed, so we just say we return an object. - def to_networkx(self) -> object: ... - - def unlock(self): ... - class States: def __init__(self, model: Model): ... diff --git a/dwave/optimization/model.pyx b/dwave/optimization/model.pyx index ee5d4bbd..fb3d06a3 100644 --- a/dwave/optimization/model.pyx +++ b/dwave/optimization/model.pyx @@ -60,88 +60,7 @@ def locked(model): model.unlock() -cdef class Model: - """Nonlinear model. - - The nonlinear model represents a general optimization problem with an - :term:`objective function` and/or constraints over variables of various - types. - - The :class:`.Model` class can contain this model and its methods provide - convenient utilities for working with representations of a problem. - - Examples: - This example creates a model for a - :class:`flow-shop-scheduling ` - problem with two jobs on three machines. - - >>> from dwave.optimization.generators import flow_shop_scheduling - ... - >>> processing_times = [[10, 5, 7], [20, 10, 15]] - >>> model = flow_shop_scheduling(processing_times=processing_times) - """ - def __init__(self): - self.states = States(self) - - self._data_sources = [] - - def add_constraint(self, ArraySymbol value): - """Add a constraint to the model. - - Args: - value: Value that must evaluate to True for the state - of the model to be feasible. - - Returns: - The constraint symbol. - - Examples: - This example adds a single constraint to a model. - - >>> from dwave.optimization.model import Model - >>> model = Model() - >>> i = model.integer() - >>> c = model.constant(5) - >>> constraint_sym = model.add_constraint(i <= c) - - The returned constraint symbol can be assigned and evaluated - for a model state: - - >>> with model.lock(): - ... model.states.resize(1) - ... i.set_state(0, 1) # Feasible state - ... print(constraint_sym.state(0)) - 1.0 - >>> with model.lock(): - ... i.set_state(0, 6) # Infeasible state - ... print(constraint_sym.state(0)) - 0.0 - """ - if value is None: - raise ValueError("value cannot be None") - # TODO: shall we accept array valued constraints? - self._graph.add_constraint(value.array_ptr) - return value - - def binary(self, shape=None): - r"""Create a binary symbol as a decision variable. - - Args: - shape: Shape of the binary array to create. - - Returns: - A binary symbol. - - Examples: - This example creates a :math:`1 \times 20`-sized binary symbol. - - >>> from dwave.optimization.model import Model - >>> model = Model() - >>> x = model.binary((1,20)) - """ - from dwave.optimization.symbols import BinaryVariable #avoid circular import - return BinaryVariable(self, shape) - +cdef class _Model: def constant(self, array_like): r"""Create a constant symbol. @@ -164,117 +83,6 @@ cdef class Model: from dwave.optimization.symbols import Constant # avoid circular import return Constant(self, array_like) - def decision_state_size(self): - r"""An estimated size, in bytes, of the model's decision states. - - Examples: - This example checks the size of a model with one - :math:`10 \times 10`-sized integer symbol. - - >>> from dwave.optimization.model import Model - >>> model = Model() - >>> visit_site = model.integer((10, 10)) - >>> model.decision_state_size() - 800 - """ - return sum(sym.state_size() for sym in self.iter_decisions()) - - def disjoint_bit_sets(self, Py_ssize_t primary_set_size, Py_ssize_t num_disjoint_sets): - """Create a disjoint-sets symbol as a decision variable. - - Divides a set of the elements of ``range(primary_set_size)`` into - ``num_disjoint_sets`` ordered partitions, stored as bit sets (arrays - of length ``primary_set_size``, with ones at the indices of elements - currently in the set, and zeros elsewhere). The ordering of a set is - not semantically meaningful. - - Also creates from the symbol ``num_disjoint_sets`` extra successors - that output the disjoint sets as arrays. - - Args: - primary_set_size: Number of elements in the primary set that are - partitioned into disjoint sets. Must be non-negative. - num_disjoint_sets: Number of disjoint sets. Must be positive. - - Returns: - A tuple where the first element is the disjoint-sets symbol and - the second is a set of its newly added successors. - - Examples: - This example creates a symbol of 10 elements that is divided - into 4 sets. - - >>> from dwave.optimization.model import Model - >>> model = Model() - >>> parts_set, parts_subsets = model.disjoint_bit_sets(10, 4) - """ - - from dwave.optimization.symbols import DisjointBitSets, DisjointBitSet # avoid circular import - main = DisjointBitSets(self, primary_set_size, num_disjoint_sets) - sets = tuple(DisjointBitSet(main, i) for i in range(num_disjoint_sets)) - return main, sets - - def disjoint_lists(self, Py_ssize_t primary_set_size, Py_ssize_t num_disjoint_lists): - """Create a disjoint-lists symbol as a decision variable. - - Divides a set of the elements of ``range(primary_set_size)`` into - ``num_disjoint_lists`` ordered partitions. - - Also creates ``num_disjoint_lists`` extra successors from the - symbol that output the disjoint lists as arrays. - - Args: - primary_set_size: Number of elements in the primary set to - be partitioned into disjoint lists. - num_disjoint_lists: Number of disjoint lists. - - Returns: - A tuple where the first element is the disjoint-lists symbol - and the second is a list of its newly added successor nodes. - - Examples: - This example creates a symbol of 10 elements that is divided - into 4 lists. - - >>> from dwave.optimization.model import Model - >>> model = Model() - >>> destinations, routes = model.disjoint_lists(10, 4) - """ - from dwave.optimization.symbols import DisjointLists, DisjointList # avoid circular import - main = DisjointLists(self, primary_set_size, num_disjoint_lists) - lists = [DisjointList(main, i) for i in range(num_disjoint_lists)] - return main, lists - - def feasible(self, int index = 0): - """Check the feasibility of the state at the input index. - - Args: - index: index of the state to check for feasibility. - - Returns: - Feasibility of the state. - - Examples: - This example demonstrates checking the feasibility of a simple model with - feasible and infeasible states. - - >>> from dwave.optimization.model import Model - >>> model = Model() - >>> b = model.binary() - >>> model.add_constraint(b) # doctest: +ELLIPSIS - - >>> model.states.resize(2) - >>> b.set_state(0, 1) # Feasible - >>> b.set_state(1, 0) # Infeasible - >>> with model.lock(): - ... model.feasible(0) - True - >>> with model.lock(): - ... model.feasible(1) - False - """ - return all(sym.state(index) for sym in self.iter_constraints()) - @classmethod def from_file(cls, file, *, check_header = True, @@ -383,52 +191,6 @@ cdef class Model: return model - def input(self, lower_bound: float, upper_bound: float, bool integral): - """TODO""" - from dwave.optimization.symbols import Input - return Input(self, lower_bound, upper_bound, integral, shape=tuple()) - - def integer(self, shape=None, lower_bound=None, upper_bound=None): - r"""Create an integer symbol as a decision variable. - - Args: - shape: Shape of the integer array to create. - - lower_bound: Lower bound for the symbol, which is the - smallest allowed integer value. If None, the default - value is used. - upper_bound: Upper bound for the symbol, which is the - largest allowed integer value. If None, the default - value is used. - - Returns: - An integer symbol. - - Examples: - This example creates a :math:`20 \times 20`-sized integer symbol. - - >>> from dwave.optimization.model import Model - >>> model = Model() - >>> i = model.integer((20,20), lower_bound=-100, upper_bound=100) - """ - from dwave.optimization.symbols import IntegerVariable #avoid circular import - return IntegerVariable(self, shape, lower_bound, upper_bound) - - def _header_data(self, *, only_decision, max_num_states=float('inf')): - """The header data associated with the model (but not the states).""" - num_nodes = self.num_decisions() if only_decision else self.num_nodes() - num_states = max(0, min(self.states.size(), max_num_states)) - - decision_state_size = self.decision_state_size() - state_size = decision_state_size if only_decision else self.state_size() - - return dict( - decision_state_size=decision_state_size, - num_nodes=num_nodes, - state_size=state_size, - num_states=num_states, - ) - def into_file(self, file, *, Py_ssize_t max_num_states = 0, bool only_decision = False, @@ -564,87 +326,23 @@ cdef class Model: """ return self._lock_count > 0 - def iter_constraints(self): - """Iterate over all constraints in the model. + def iter_symbols(self): + """Iterate over all symbols in the model. Examples: - This example adds a single constraint to a model and iterates over it. + This example iterates over a model's symbols. >>> from dwave.optimization.model import Model >>> model = Model() - >>> i = model.integer() - >>> c = model.constant(5) - >>> model.add_constraint(i <= c) # doctest: +ELLIPSIS - - >>> constraints = next(model.iter_constraints()) + >>> i = model.integer(1, lower_bound=10) + >>> c = model.constant([[2, 3], [5, 6]]) + >>> symbol_1, symbol_2 = model.iter_symbols() """ - for i in range(self._graph.num_constraints()): - yield symbol_from_ptr(self, self._graph.constraints()[i]) + for i in range(self._graph.num_nodes()): + yield symbol_from_ptr(self, self._graph.nodes()[i].get()) - def iter_decisions(self): - """Iterate over all decision variables in the model. - - Examples: - This example adds a single decision symbol to a model and iterates over it. - - >>> from dwave.optimization.model import Model - >>> model = Model() - >>> i = model.integer() - >>> c = model.constant(5) - >>> model.add_constraint(i <= c) # doctest: +ELLIPSIS - - >>> decisions = next(model.iter_decisions()) - """ - cdef Py_ssize_t num_decisions = self.num_decisions() - cdef Py_ssize_t seen_decisions = 0 - - cdef Symbol symbol - for symbol in self.iter_symbols(): - if 0 <= symbol.node_ptr.topological_index() < num_decisions: - # we found a decision! - yield symbol - seen_decisions += 1 - - if seen_decisions >= num_decisions: - # we found them all - return - - def iter_symbols(self): - """Iterate over all symbols in the model. - - Examples: - This example iterates over a model's symbols. - - >>> from dwave.optimization.model import Model - >>> model = Model() - >>> i = model.integer(1, lower_bound=10) - >>> c = model.constant([[2, 3], [5, 6]]) - >>> symbol_1, symbol_2 = model.iter_symbols() - """ - for i in range(self._graph.num_nodes()): - yield symbol_from_ptr(self, self._graph.nodes()[i].get()) - - def list(self, n : int): - """Create a list symbol as a decision variable. - - Args: - n: Values in the list are permutations of ``range(n)``. - - Returns: - A list symbol. - - Examples: - This example creates a list symbol of 200 elements. - - >>> from dwave.optimization.model import Model - >>> model = Model() - >>> routes = model.list(200) - """ - from dwave.optimization.symbols import ListVariable # avoid circular import - return ListVariable(self, n) - - def lock(self): - """Lock the model. + def lock(self): + """Lock the model. No new symbols can be added to a locked model. @@ -687,77 +385,6 @@ cdef class Model: return locked(self) - def minimize(self, ArraySymbol value): - """Set the objective value to minimize. - - Optimization problems have an objective and/or constraints. The objective - expresses one or more aspects of the problem that should be minimized - (equivalent to maximization when multiplied by a minus sign). For example, - an optimized itinerary might minimize the value of distance traveled or - cost of transportation or travel time. - - Args: - value: Value for which to minimize the cost function. - - Examples: - This example minimizes a simple polynomial, :math:`y = i^2 - 4i`, - within bounds. - - >>> from dwave.optimization import Model - >>> model = Model() - >>> i = model.integer(lower_bound=-5, upper_bound=5) - >>> c = model.constant(4) - >>> y = i*i - c*i - >>> model.minimize(y) - """ - if value is None: - raise ValueError("value cannot be None") - if value.size() < 1: - raise ValueError("the value of an empty array is ambiguous") - if value.size() > 1: - raise ValueError("the value of an array with more than one element is ambiguous") - self._graph.set_objective(value.array_ptr) - self.objective = value - - cpdef Py_ssize_t num_constraints(self) noexcept: - """Number of constraints in the model. - - Examples: - This example checks the number of constraints in the model after - adding a couple of constraints. - - >>> from dwave.optimization.model import Model - >>> model = Model() - >>> i = model.integer() - >>> c = model.constant([5, -14]) - >>> model.add_constraint(i <= c[0]) # doctest: +ELLIPSIS - - >>> model.add_constraint(c[1] <= i) # doctest: +ELLIPSIS - - >>> model.num_constraints() - 2 - """ - return self._graph.num_constraints() - - cpdef Py_ssize_t num_decisions(self) noexcept: - """Number of independent decision nodes in the model. - - An array-of-integers symbol, for example, counts as a single - decision node. - - Examples: - This example checks the number of decisions in a model after - adding a single (size 20) decision symbol. - - >>> from dwave.optimization.model import Model - >>> model = Model() - >>> c = model.constant([1, 5, 8.4]) - >>> i = model.integer(20, upper_bound=100) - >>> model.num_decisions() - 1 - """ - return self._graph.num_decisions() - def num_edges(self): """Number of edges in the directed acyclic graph for the model. @@ -822,32 +449,6 @@ cdef class Model: """ return self.num_nodes() - def quadratic_model(self, ArraySymbol x, quadratic, linear=None): - """Create a quadratic model from an array and a quadratic model. - - Args: - x: An array. - - quadratic: Quadratic values for the quadratic model. - - linear: Linear values for the quadratic model. - - Returns: - A quadratic model. - - Examples: - This example creates a quadratic model. - - >>> from dwave.optimization.model import Model - >>> model = Model() - >>> x = model.binary(3) - >>> Q = {(0, 0): 0, (0, 1): 1, (0, 2): 2, (1, 1): 1, (1, 2): 3, (2, 2): 2} - >>> qm = model.quadratic_model(x, Q) - - """ - from dwave.optimization.symbols import QuadraticModel - return QuadraticModel(x, quadratic, linear) - def remove_unused_symbols(self): """Remove unused symbols from the model. @@ -901,28 +502,6 @@ cdef class Model: raise ValueError("cannot remove symbols from a locked model") return self._graph.remove_unused_nodes() - def set(self, Py_ssize_t n, Py_ssize_t min_size = 0, max_size = None): - """Create a set symbol as a decision variable. - - Args: - n: Values in the set are subsets of ``range(n)``. - min_size: Minimum set size. Defaults to ``0``. - max_size: Maximum set size. Defaults to ``n``. - - Returns: - A set symbol. - - Examples: - This example creates a set symbol of up to 4 elements - with values between 0 to 99. - - >>> from dwave.optimization.model import Model - >>> model = Model() - >>> destinations = model.set(100, max_size=4) - """ - from dwave.optimization.symbols import SetVariable # avoid circular import - return SetVariable(self, n, min_size, n if max_size is None else max_size) - def state_size(self): """An estimate of the size, in bytes, of all states in the model. @@ -1045,6 +624,424 @@ cdef class Model: self.states._states[i].resize(self.num_decisions()) +cdef class Model(_Model): + """Nonlinear model. + + The nonlinear model represents a general optimization problem with an + :term:`objective function` and/or constraints over variables of various + types. + + The :class:`.Model` class can contain this model and its methods provide + convenient utilities for working with representations of a problem. + + Examples: + This example creates a model for a + :class:`flow-shop-scheduling ` + problem with two jobs on three machines. + + >>> from dwave.optimization.generators import flow_shop_scheduling + ... + >>> processing_times = [[10, 5, 7], [20, 10, 15]] + >>> model = flow_shop_scheduling(processing_times=processing_times) + """ + def __init__(self): + self.states = States(self) + + self._data_sources = [] + + def add_constraint(self, ArraySymbol value): + """Add a constraint to the model. + + Args: + value: Value that must evaluate to True for the state + of the model to be feasible. + + Returns: + The constraint symbol. + + Examples: + This example adds a single constraint to a model. + + >>> from dwave.optimization.model import Model + >>> model = Model() + >>> i = model.integer() + >>> c = model.constant(5) + >>> constraint_sym = model.add_constraint(i <= c) + + The returned constraint symbol can be assigned and evaluated + for a model state: + + >>> with model.lock(): + ... model.states.resize(1) + ... i.set_state(0, 1) # Feasible state + ... print(constraint_sym.state(0)) + 1.0 + >>> with model.lock(): + ... i.set_state(0, 6) # Infeasible state + ... print(constraint_sym.state(0)) + 0.0 + """ + if value is None: + raise ValueError("value cannot be None") + # TODO: shall we accept array valued constraints? + self._graph.add_constraint(value.array_ptr) + return value + + def binary(self, shape=None): + r"""Create a binary symbol as a decision variable. + + Args: + shape: Shape of the binary array to create. + + Returns: + A binary symbol. + + Examples: + This example creates a :math:`1 \times 20`-sized binary symbol. + + >>> from dwave.optimization.model import Model + >>> model = Model() + >>> x = model.binary((1,20)) + """ + from dwave.optimization.symbols import BinaryVariable #avoid circular import + return BinaryVariable(self, shape) + + def decision_state_size(self): + r"""An estimated size, in bytes, of the model's decision states. + + Examples: + This example checks the size of a model with one + :math:`10 \times 10`-sized integer symbol. + + >>> from dwave.optimization.model import Model + >>> model = Model() + >>> visit_site = model.integer((10, 10)) + >>> model.decision_state_size() + 800 + """ + return sum(sym.state_size() for sym in self.iter_decisions()) + + def disjoint_bit_sets(self, Py_ssize_t primary_set_size, Py_ssize_t num_disjoint_sets): + """Create a disjoint-sets symbol as a decision variable. + + Divides a set of the elements of ``range(primary_set_size)`` into + ``num_disjoint_sets`` ordered partitions, stored as bit sets (arrays + of length ``primary_set_size``, with ones at the indices of elements + currently in the set, and zeros elsewhere). The ordering of a set is + not semantically meaningful. + + Also creates from the symbol ``num_disjoint_sets`` extra successors + that output the disjoint sets as arrays. + + Args: + primary_set_size: Number of elements in the primary set that are + partitioned into disjoint sets. Must be non-negative. + num_disjoint_sets: Number of disjoint sets. Must be positive. + + Returns: + A tuple where the first element is the disjoint-sets symbol and + the second is a set of its newly added successors. + + Examples: + This example creates a symbol of 10 elements that is divided + into 4 sets. + + >>> from dwave.optimization.model import Model + >>> model = Model() + >>> parts_set, parts_subsets = model.disjoint_bit_sets(10, 4) + """ + + from dwave.optimization.symbols import DisjointBitSets, DisjointBitSet # avoid circular import + main = DisjointBitSets(self, primary_set_size, num_disjoint_sets) + sets = tuple(DisjointBitSet(main, i) for i in range(num_disjoint_sets)) + return main, sets + + def disjoint_lists(self, Py_ssize_t primary_set_size, Py_ssize_t num_disjoint_lists): + """Create a disjoint-lists symbol as a decision variable. + + Divides a set of the elements of ``range(primary_set_size)`` into + ``num_disjoint_lists`` ordered partitions. + + Also creates ``num_disjoint_lists`` extra successors from the + symbol that output the disjoint lists as arrays. + + Args: + primary_set_size: Number of elements in the primary set to + be partitioned into disjoint lists. + num_disjoint_lists: Number of disjoint lists. + + Returns: + A tuple where the first element is the disjoint-lists symbol + and the second is a list of its newly added successor nodes. + + Examples: + This example creates a symbol of 10 elements that is divided + into 4 lists. + + >>> from dwave.optimization.model import Model + >>> model = Model() + >>> destinations, routes = model.disjoint_lists(10, 4) + """ + from dwave.optimization.symbols import DisjointLists, DisjointList # avoid circular import + main = DisjointLists(self, primary_set_size, num_disjoint_lists) + lists = [DisjointList(main, i) for i in range(num_disjoint_lists)] + return main, lists + + def feasible(self, int index = 0): + """Check the feasibility of the state at the input index. + + Args: + index: index of the state to check for feasibility. + + Returns: + Feasibility of the state. + + Examples: + This example demonstrates checking the feasibility of a simple model with + feasible and infeasible states. + + >>> from dwave.optimization.model import Model + >>> model = Model() + >>> b = model.binary() + >>> model.add_constraint(b) # doctest: +ELLIPSIS + + >>> model.states.resize(2) + >>> b.set_state(0, 1) # Feasible + >>> b.set_state(1, 0) # Infeasible + >>> with model.lock(): + ... model.feasible(0) + True + >>> with model.lock(): + ... model.feasible(1) + False + """ + return all(sym.state(index) for sym in self.iter_constraints()) + + def integer(self, shape=None, lower_bound=None, upper_bound=None): + r"""Create an integer symbol as a decision variable. + + Args: + shape: Shape of the integer array to create. + + lower_bound: Lower bound for the symbol, which is the + smallest allowed integer value. If None, the default + value is used. + upper_bound: Upper bound for the symbol, which is the + largest allowed integer value. If None, the default + value is used. + + Returns: + An integer symbol. + + Examples: + This example creates a :math:`20 \times 20`-sized integer symbol. + + >>> from dwave.optimization.model import Model + >>> model = Model() + >>> i = model.integer((20,20), lower_bound=-100, upper_bound=100) + """ + from dwave.optimization.symbols import IntegerVariable #avoid circular import + return IntegerVariable(self, shape, lower_bound, upper_bound) + + def _header_data(self, *, only_decision, max_num_states=float('inf')): + """The header data associated with the model (but not the states).""" + num_nodes = self.num_decisions() if only_decision else self.num_nodes() + num_states = max(0, min(self.states.size(), max_num_states)) + + decision_state_size = self.decision_state_size() + state_size = decision_state_size if only_decision else self.state_size() + + return dict( + decision_state_size=decision_state_size, + num_nodes=num_nodes, + state_size=state_size, + num_states=num_states, + ) + + def iter_constraints(self): + """Iterate over all constraints in the model. + + Examples: + This example adds a single constraint to a model and iterates over it. + + >>> from dwave.optimization.model import Model + >>> model = Model() + >>> i = model.integer() + >>> c = model.constant(5) + >>> model.add_constraint(i <= c) # doctest: +ELLIPSIS + + >>> constraints = next(model.iter_constraints()) + """ + for i in range(self._graph.num_constraints()): + yield symbol_from_ptr(self, self._graph.constraints()[i]) + + def iter_decisions(self): + """Iterate over all decision variables in the model. + + Examples: + This example adds a single decision symbol to a model and iterates over it. + + >>> from dwave.optimization.model import Model + >>> model = Model() + >>> i = model.integer() + >>> c = model.constant(5) + >>> model.add_constraint(i <= c) # doctest: +ELLIPSIS + + >>> decisions = next(model.iter_decisions()) + """ + cdef Py_ssize_t num_decisions = self.num_decisions() + cdef Py_ssize_t seen_decisions = 0 + + cdef Symbol symbol + for symbol in self.iter_symbols(): + if 0 <= symbol.node_ptr.topological_index() < num_decisions: + # we found a decision! + yield symbol + seen_decisions += 1 + + if seen_decisions >= num_decisions: + # we found them all + return + + def list(self, n : int): + """Create a list symbol as a decision variable. + + Args: + n: Values in the list are permutations of ``range(n)``. + + Returns: + A list symbol. + + Examples: + This example creates a list symbol of 200 elements. + + >>> from dwave.optimization.model import Model + >>> model = Model() + >>> routes = model.list(200) + """ + from dwave.optimization.symbols import ListVariable # avoid circular import + return ListVariable(self, n) + + def minimize(self, ArraySymbol value): + """Set the objective value to minimize. + + Optimization problems have an objective and/or constraints. The objective + expresses one or more aspects of the problem that should be minimized + (equivalent to maximization when multiplied by a minus sign). For example, + an optimized itinerary might minimize the value of distance traveled or + cost of transportation or travel time. + + Args: + value: Value for which to minimize the cost function. + + Examples: + This example minimizes a simple polynomial, :math:`y = i^2 - 4i`, + within bounds. + + >>> from dwave.optimization import Model + >>> model = Model() + >>> i = model.integer(lower_bound=-5, upper_bound=5) + >>> c = model.constant(4) + >>> y = i*i - c*i + >>> model.minimize(y) + """ + if value is None: + raise ValueError("value cannot be None") + if value.size() < 1: + raise ValueError("the value of an empty array is ambiguous") + if value.size() > 1: + raise ValueError("the value of an array with more than one element is ambiguous") + self._graph.set_objective(value.array_ptr) + self.objective = value + + cpdef Py_ssize_t num_constraints(self) noexcept: + """Number of constraints in the model. + + Examples: + This example checks the number of constraints in the model after + adding a couple of constraints. + + >>> from dwave.optimization.model import Model + >>> model = Model() + >>> i = model.integer() + >>> c = model.constant([5, -14]) + >>> model.add_constraint(i <= c[0]) # doctest: +ELLIPSIS + + >>> model.add_constraint(c[1] <= i) # doctest: +ELLIPSIS + + >>> model.num_constraints() + 2 + """ + return self._graph.num_constraints() + + cpdef Py_ssize_t num_decisions(self) noexcept: + """Number of independent decision nodes in the model. + + An array-of-integers symbol, for example, counts as a single + decision node. + + Examples: + This example checks the number of decisions in a model after + adding a single (size 20) decision symbol. + + >>> from dwave.optimization.model import Model + >>> model = Model() + >>> c = model.constant([1, 5, 8.4]) + >>> i = model.integer(20, upper_bound=100) + >>> model.num_decisions() + 1 + """ + return self._graph.num_decisions() + + def quadratic_model(self, ArraySymbol x, quadratic, linear=None): + """Create a quadratic model from an array and a quadratic model. + + Args: + x: An array. + + quadratic: Quadratic values for the quadratic model. + + linear: Linear values for the quadratic model. + + Returns: + A quadratic model. + + Examples: + This example creates a quadratic model. + + >>> from dwave.optimization.model import Model + >>> model = Model() + >>> x = model.binary(3) + >>> Q = {(0, 0): 0, (0, 1): 1, (0, 2): 2, (1, 1): 1, (1, 2): 3, (2, 2): 2} + >>> qm = model.quadratic_model(x, Q) + + """ + from dwave.optimization.symbols import QuadraticModel + return QuadraticModel(x, quadratic, linear) + + def set(self, Py_ssize_t n, Py_ssize_t min_size = 0, max_size = None): + """Create a set symbol as a decision variable. + + Args: + n: Values in the set are subsets of ``range(n)``. + min_size: Minimum set size. Defaults to ``0``. + max_size: Maximum set size. Defaults to ``n``. + + Returns: + A set symbol. + + Examples: + This example creates a set symbol of up to 4 elements + with values between 0 to 99. + + >>> from dwave.optimization.model import Model + >>> model = Model() + >>> destinations = model.set(100, max_size=4) + """ + from dwave.optimization.symbols import SetVariable # avoid circular import + return SetVariable(self, n, min_size, n if max_size is None else max_size) + + cdef class States: r"""States of a symbol in a model. @@ -1234,7 +1231,7 @@ cdef class States: return self._model().into_file(file, only_decision=True, max_num_states=self.size()) - cdef Model _model(self): + cdef _Model _model(self): """Get a ref-counted Model object.""" cdef Model m = self._model_ref() if m is None: @@ -1330,7 +1327,7 @@ cdef class Symbol: cls = type(self) return f"<{cls.__module__}.{cls.__qualname__} at {self.id():#x}>" - cdef void initialize_node(self, Model model, cppNode* node_ptr) noexcept: + cdef void initialize_node(self, _Model model, cppNode* node_ptr) noexcept: self.model = model self.node_ptr = node_ptr @@ -1762,7 +1759,7 @@ cdef class ArraySymbol(Symbol): # via their subclasses. raise ValueError("ArraySymbols cannot be constructed directly") - cdef void initialize_arraynode(self, Model model, cppArrayNode* array_ptr) noexcept: + cdef void initialize_arraynode(self, _Model model, cppArrayNode* array_ptr) noexcept: self.array_ptr = array_ptr self.initialize_node(model, array_ptr) diff --git a/dwave/optimization/symbols.pxd b/dwave/optimization/symbols.pxd index 88cb00eb..64c78e87 100644 --- a/dwave/optimization/symbols.pxd +++ b/dwave/optimization/symbols.pxd @@ -16,10 +16,10 @@ from libcpp.typeinfo cimport type_info -from dwave.optimization.model cimport Model +from dwave.optimization.model cimport _Model from dwave.optimization.libcpp.graph cimport Array as cppArray from dwave.optimization.libcpp.graph cimport Node as cppNode cdef void _register(object cls, const type_info& typeinfo) -cdef object symbol_from_ptr(Model model, cppNode* ptr) +cdef object symbol_from_ptr(_Model model, cppNode* ptr) diff --git a/dwave/optimization/symbols.pyx b/dwave/optimization/symbols.pyx index f8cada68..557613dc 100644 --- a/dwave/optimization/symbols.pyx +++ b/dwave/optimization/symbols.pyx @@ -93,7 +93,8 @@ from dwave.optimization.libcpp.nodes cimport ( WhereNode as cppWhereNode, XorNode as cppXorNode, ) -from dwave.optimization.model cimport ArraySymbol, Model, Symbol +from dwave.optimization.model cimport ArraySymbol, _Model, Model, Symbol +from dwave.optimization.expression cimport Expression ctypedef cppArrayNode* cppArrayNodePtr # Cython gets confused when templating pointers ctypedef cppNode* cppNodePtr @@ -174,7 +175,7 @@ cdef void _register(object cls, const type_info& typeinfo): _cpp_type_to_python[type_index(typeinfo)] = (cls) -cdef object symbol_from_ptr(Model model, cppNode* node_ptr): +cdef object symbol_from_ptr(_Model model, cppNode* node_ptr): """Create a Python/Cython symbol from a C++ Node*.""" # If it's null, either after the cast of just as given, then we can't get a symbol from it @@ -272,7 +273,7 @@ cdef class Add(ArraySymbol): if lhs.model is not rhs.model: raise ValueError("lhs and rhs do not share the same underlying model") - cdef Model model = lhs.model + cdef _Model model = lhs.model self.ptr = model._graph.emplace_node[cppAddNode](lhs.array_ptr, rhs.array_ptr) self.initialize_arraynode(model, self.ptr) @@ -1534,13 +1535,13 @@ cdef class Input(ArraySymbol): # TODO: implement serialization - def __init__(self, Model model, lower_bound: float, upper_bound: float, integral: bool, shape: Optional[tuple] = None): + def __init__(self, Expression expression, lower_bound: float, upper_bound: float, integral: bool, shape: Optional[tuple] = None): cdef vector[Py_ssize_t] vshape = _as_cppshape(tuple() if shape is None else shape) # Get an observing pointer to the C++ InputNode - self.ptr = model._graph.emplace_node[cppInputNode](vshape, lower_bound, upper_bound, integral) + self.ptr = expression._graph.emplace_node[cppInputNode](vshape, lower_bound, upper_bound, integral) - self.initialize_arraynode(model, self.ptr) + self.initialize_arraynode(expression, self.ptr) @staticmethod def _from_symbol(Symbol symbol): @@ -2283,8 +2284,8 @@ cdef class NaryReduce(ArraySymbol): if len(initial_values) != len(input_symbols): raise ValueError("must have same number of initial values as inputs") - cdef Model expression = input_symbols[0].model - cdef Model model = operands[0].model + cdef _Model expression = input_symbols[0].model + cdef _Model model = operands[0].model cdef cppArrayNode* output = output_symbol.array_ptr cdef vector[double] cppinitial_values cdef vector[cppInputNode*] cppinputs @@ -2307,17 +2308,17 @@ cdef class NaryReduce(ArraySymbol): array = node cppoperands.push_back(array.array_ptr) - with expression.lock(): - try: - self.ptr = model._graph.emplace_node[cppNaryReduceNode]( - move(expression._graph), cppinputs, output, cppinitial_values, cppoperands - ) - except ValueError as e: - raise self._handle_unsupported_expression_exception(expression, e) + expression.lock() + try: + self.ptr = model._graph.emplace_node[cppNaryReduceNode]( + move(expression._graph), cppinputs, output, cppinitial_values, cppoperands + ) + except ValueError as e: + raise self._handle_unsupported_expression_exception(expression, e) self.initialize_arraynode(model, self.ptr) - def _handle_unsupported_expression_exception(self, Model expression, exception: ValueError): + def _handle_unsupported_expression_exception(self, _Model expression, exception: ValueError): try: info = json.loads(str(exception)) except json.JSONDecodeError: diff --git a/meson.build b/meson.build index e0222bbe..5e3e8336 100644 --- a/meson.build +++ b/meson.build @@ -87,6 +87,17 @@ py.extension_module( subdir: 'dwave/optimization/', ) +py.extension_module( + 'expression', + 'dwave/optimization/expression/expression.pyx', + dependencies: libdwave_optimization, + gnu_symbol_visibility: 'default', + install: true, + install_rpath: '$ORIGIN', + override_options : ['cython_language=cpp'], + subdir: 'dwave/optimization/expression/', +) + install_subdir('dwave', install_dir: py.get_install_dir(pure: false)) # meson doesn't disable building tests by default, so we explicitly don't diff --git a/tests/test_symbols.py b/tests/test_symbols.py index 4a82c4ac..475f3a97 100644 --- a/tests/test_symbols.py +++ b/tests/test_symbols.py @@ -34,6 +34,7 @@ logical_xor, mod ) +from dwave.optimization.expression import Expression class utils: @@ -1102,9 +1103,9 @@ def test_state_size(self): class TestInput(utils.SymbolTests): def generate_symbols(self): - model = Model() - inp = model.input(-10, 10, False) - model.lock() + exp = Expression() + inp = exp.input(-10, 10, False) + exp.lock() yield inp # TODO: enable once implemented @@ -1767,7 +1768,7 @@ def generate_symbols(self): c0 = model.constant([0, 0]) c1 = model.constant([0, 1]) - exp = Model() + exp = Expression() inputs = [exp.input(-10, 10, False) for _ in range(3)] sum_ = inputs[0] + inputs[1] + inputs[2] @@ -1781,7 +1782,7 @@ def test_mismatched_inputs(self): c0 = model.constant([0, 0]) c1 = model.constant([0, 1]) - exp = Model() + exp = Expression() inputs = [exp.input(-10, 10, False) for _ in range(3)] sum_ = inputs[0] + inputs[1] + inputs[2] @@ -1798,32 +1799,41 @@ def test_invalid_expressions(self): model = Model() c0 = model.constant([0, 0]) - exp = Model() - inputs = [exp.input(-10, 10, False) for _ in range(2)] - i = exp.integer() - sum_ = inputs[0] + inputs[1] - - try: - dwave.optimization.symbols.NaryReduce(inputs, sum_, (c0,)) - self.assertTrue(False, "should have raise exception") - except Exception as e: - self.assertIsInstance(e, dwave.optimization.symbols.UnsupportedNaryReduceExpression) - self.assertRegex(str(e), "decision") - self.assertTrue(i.equals(e.symbol)) - - exp = Model() - inputs = [exp.input(-10, 10, False) for _ in range(2)] - reshape = inputs[0].reshape((1, 1, 1)) - + # exp = Expression() + # inputs = [exp.input(-10, 10, False) for _ in range(2)] + # i = exp.integer() + # sum_ = inputs[0] + inputs[1] + # + # try: + # dwave.optimization.symbols.NaryReduce(inputs, sum_, (c0,)) + # self.assertTrue(False, "should raise exception") + # except Exception as e: + # self.assertIsInstance(e, dwave.optimization.symbols.UnsupportedNaryReduceExpression) + # self.assertRegex(str(e), "decision") + # self.assertTrue(i.equals(e.symbol)) + # + # exp = Expression() + # inputs = [exp.input(-10, 10, False) for _ in range(2)] + # reshape = inputs[0].reshape((1, 1, 1)) + # + # try: + # dwave.optimization.symbols.NaryReduce(inputs, reshape, (c0,)) + # self.assertTrue(False, "should raise exception") + # except Exception as e: + # self.assertIsInstance(e, dwave.optimization.symbols.UnsupportedNaryReduceExpression) + # self.assertRegex(str(e), "unsupported node") + # self.assertTrue(reshape.equals(e.symbol)) + + exp = Expression() + inp1 = exp.input(-10, 10, False) + inp5 = exp.input(-10, 10, False, (5,)) try: - dwave.optimization.symbols.NaryReduce(inputs, reshape, (c0,)) - self.assertTrue(False, "should have raise exception") + dwave.optimization.symbols.NaryReduce((inp1, inp5), inp1, (c0,)) + self.assertTrue(False, "should raise exception") except Exception as e: self.assertIsInstance(e, dwave.optimization.symbols.UnsupportedNaryReduceExpression) - self.assertRegex(str(e), "unsupported node") - self.assertTrue(reshape.equals(e.symbol)) - - # TODO: craft example with non-scalar... not sure how to do that right now + self.assertRegex(str(e), "scalar") + self.assertTrue(inp5.equals(e.symbol)) # TODO: enable once implemented @unittest.skip("not yet implemented") From bc08b4b313a6316b215029bdff75a2370cc79130 Mon Sep 17 00:00:00 2001 From: William Bernoudy Date: Tue, 19 Nov 2024 21:37:46 -0800 Subject: [PATCH 2/2] Track inputs explicitly in Graph Allows NaryReduce to just take an Expression and operands --- dwave/optimization/expression/__init__.pxd | 2 +- dwave/optimization/expression/expression.pxd | 2 + dwave/optimization/expression/expression.pyi | 8 +- dwave/optimization/expression/expression.pyx | 42 ++++++++- .../include/dwave-optimization/graph.hpp | 10 +++ dwave/optimization/libcpp/graph.pxd | 14 ++- dwave/optimization/model.pyx | 18 +++- dwave/optimization/symbols.pyx | 34 +++---- tests/test_expression.py | 89 +++++++++++++++++++ tests/test_symbols.py | 44 ++------- 10 files changed, 204 insertions(+), 59 deletions(-) create mode 100644 tests/test_expression.py diff --git a/dwave/optimization/expression/__init__.pxd b/dwave/optimization/expression/__init__.pxd index a3a49812..baeb2740 100644 --- a/dwave/optimization/expression/__init__.pxd +++ b/dwave/optimization/expression/__init__.pxd @@ -1,4 +1,4 @@ -# Copyright 2023 D-Wave Systems Inc. +# Copyright 2024 D-Wave Systems Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/dwave/optimization/expression/expression.pxd b/dwave/optimization/expression/expression.pxd index 3b7d5df2..534437a5 100644 --- a/dwave/optimization/expression/expression.pxd +++ b/dwave/optimization/expression/expression.pxd @@ -22,3 +22,5 @@ __all__ = ["Expression"] cdef class Expression(_Model): cdef readonly ArraySymbol output + + cpdef Py_ssize_t num_inputs(self) noexcept diff --git a/dwave/optimization/expression/expression.pyi b/dwave/optimization/expression/expression.pyi index 32b262d4..5ad7a07e 100644 --- a/dwave/optimization/expression/expression.pyi +++ b/dwave/optimization/expression/expression.pyi @@ -21,7 +21,13 @@ _ShapeLike: typing.TypeAlias = typing.Union[int, collections.abc.Sequence[int]] class Expression(_Model): - def __init__(self): ... + def __init__( + self, + num_inputs: int = 0, + lower_bound: Optional[float] = None, + upper_bound: Optional[float] = None, + integral: Optional[bool] = None, + ): ... def input(self, lower_bound: float, upper_bound: float, integral: bool, shape: Optional[tuple] = None): diff --git a/dwave/optimization/expression/expression.pyx b/dwave/optimization/expression/expression.pyx index 07991335..0ebae6ab 100644 --- a/dwave/optimization/expression/expression.pyx +++ b/dwave/optimization/expression/expression.pyx @@ -12,26 +12,60 @@ # See the License for the specific language governing permissions and # limitations under the License. +import numbers from typing import Optional from libcpp cimport bool +from libcpp.cast cimport dynamic_cast from dwave.optimization.libcpp.array cimport Array as cppArray +from dwave.optimization.libcpp.graph cimport Node as cppNode +from dwave.optimization.libcpp.nodes cimport InputNode as cppInputNode +from dwave.optimization.model cimport ArraySymbol, _Model, States from dwave.optimization.symbols cimport symbol_from_ptr +ctypedef cppNode* cppNodePtr + __all__ = ["Expression"] cdef class Expression(_Model): - def __init__(self): - pass + def __init__( + self, + num_inputs: int = 0, + # necessary to prevent Cython from rejecting an int + lower_bound: Optional[numbers.Real] = None, + upper_bound: Optional[numbers.Real] = None, + integral: Optional[bool] = None, + ): + self.states = States(self) + + self._data_sources = [] + + if num_inputs > 0: + if any(arg is None for arg in (lower_bound, upper_bound, integral)): + raise ValueError( + "`lower_bound`, `upper_bound` and `integral` must be provided " + "explicitly when initializing inputs" + ) + for _ in range(num_inputs): + self.input(lower_bound, upper_bound, integral) - def input(self, lower_bound: float, upper_bound: float, bool integral, shape: Optional[tuple] = None): + def input(self, lower_bound: float, upper_bound: float, bool integral): """TODO""" # avoid circular import from dwave.optimization.symbols import Input - return Input(self, lower_bound, upper_bound, integral, shape=shape) + # Shape is always scalar for now + return Input(self, lower_bound, upper_bound, integral, shape=tuple()) def set_output(self, value: ArraySymbol): self.output = value + + cpdef Py_ssize_t num_inputs(self) noexcept: + return self._graph.num_inputs() + + def iter_inputs(self): + inputs = self._graph.inputs() + for i in range(self._graph.num_inputs()): + yield symbol_from_ptr(self, inputs[i]) diff --git a/dwave/optimization/include/dwave-optimization/graph.hpp b/dwave/optimization/include/dwave-optimization/graph.hpp index f9f33ce8..83ae1827 100644 --- a/dwave/optimization/include/dwave-optimization/graph.hpp +++ b/dwave/optimization/include/dwave-optimization/graph.hpp @@ -34,6 +34,7 @@ namespace dwave::optimization { class ArrayNode; class Node; class DecisionNode; +class InputNode; // We don't want this interface to be opinionated about what type of rng we're using. // So we create this class to do type erasure on RNGs. @@ -139,6 +140,9 @@ class Graph { // The number of constraints in the model. ssize_t num_constraints() const noexcept { return constraints_.size(); } + // The number of input nodes in the model. + ssize_t num_inputs() const noexcept { return inputs_.size(); } + // Specify the objective node. Must be an array with a single element. // To unset the objective provide nullptr. void set_objective(ArrayNode* objective_ptr); @@ -159,6 +163,9 @@ class Graph { std::span decisions() noexcept { return decisions_; } std::span decisions() const noexcept { return decisions_; } + std::span inputs() noexcept { return inputs_; } + std::span inputs() const noexcept { return inputs_; } + // Remove unused nodes from the graph. // // This method will reset the topological sort if there is one. @@ -182,6 +189,7 @@ class Graph { ArrayNode* objective_ptr_ = nullptr; std::vector constraints_; std::vector decisions_; + std::vector inputs_; // Track whether the model is currently topologically sorted bool topologically_sorted_ = false; @@ -332,6 +340,8 @@ NodeType* Graph::emplace_node(Args&&... args) { static_assert(std::is_base_of_v); ptr->topological_index_ = decisions_.size(); decisions_.emplace_back(ptr); + } else if constexpr (std::is_base_of_v) { + inputs_.emplace_back(ptr); } return ptr; // return the observing pointer diff --git a/dwave/optimization/libcpp/graph.pxd b/dwave/optimization/libcpp/graph.pxd index 251aaaff..505bff40 100644 --- a/dwave/optimization/libcpp/graph.pxd +++ b/dwave/optimization/libcpp/graph.pxd @@ -22,15 +22,23 @@ from libcpp.vector cimport vector from dwave.optimization.libcpp.array cimport Array, span from dwave.optimization.libcpp.state cimport State + +# This seems to be necessary to allow Cython to iterate over the returned +# span from `inputs()` directly. Otherwise it tries to cast it to a non-const +# version of span before iterating, which the C++ compiler will complain about. +ctypedef InputNode* const constInputNodePtr + + cdef extern from "dwave-optimization/graph.hpp" namespace "dwave::optimization" nogil: cdef cppclass Graph: T* emplace_node[T](...) except+ void initialize_state(State&) except+ span[const unique_ptr[Node]] nodes() const span[ArrayNode*] constraints() const + Py_ssize_t num_constraints() Py_ssize_t num_nodes() Py_ssize_t num_decisions() - Py_ssize_t num_constraints() + Py_ssize_t num_inputs() @staticmethod void recursive_initialize(State&, Node*) except+ @staticmethod @@ -41,6 +49,7 @@ cdef extern from "dwave-optimization/graph.hpp" namespace "dwave::optimization" void topological_sort() bool topologically_sorted() const Py_ssize_t remove_unused_nodes() + span[constInputNodePtr] inputs() cdef cppclass Node: struct SuccessorView: @@ -52,3 +61,6 @@ cdef extern from "dwave-optimization/graph.hpp" namespace "dwave::optimization" cdef cppclass ArrayNode(Node, Array): pass + + cdef cppclass InputNode(Node, Array): + pass diff --git a/dwave/optimization/model.pyx b/dwave/optimization/model.pyx index fb3d06a3..3da32307 100644 --- a/dwave/optimization/model.pyx +++ b/dwave/optimization/model.pyx @@ -46,11 +46,17 @@ from libcpp.vector cimport vector from dwave.optimization.libcpp.array cimport Array as cppArray from dwave.optimization.symbols cimport symbol_from_ptr +from dwave.optimization.expression cimport Expression __all__ = ["Model"] +ctypedef fused ExpressionOrModel: + Model + Expression + + @contextlib.contextmanager def locked(model): """Context manager that hold a locked model and unlocks it when the context is exited.""" @@ -1042,6 +1048,10 @@ cdef class Model(_Model): return SetVariable(self, n, min_size, n if max_size is None else max_size) +def _States_init(States self, ExpressionOrModel model): + self._model_ref = weakref.ref(model) + + cdef class States: r"""States of a symbol in a model. @@ -1094,8 +1104,12 @@ cdef class States: >>> model.states.size() 0 """ - def __init__(self, Model model): - self._model_ref = weakref.ref(model) + + # Cython doesn't seem to properly handle fused type arguments on __init__, + # so we have to use this awkward workaround + # See https://github.com/cython/cython/issues/3758 + def __init__(self, model): + _States_init(self, model) def __len__(self): """The number of model states.""" diff --git a/dwave/optimization/symbols.pyx b/dwave/optimization/symbols.pyx index 557613dc..301158c1 100644 --- a/dwave/optimization/symbols.pyx +++ b/dwave/optimization/symbols.pyx @@ -99,6 +99,11 @@ from dwave.optimization.expression cimport Expression ctypedef cppArrayNode* cppArrayNodePtr # Cython gets confused when templating pointers ctypedef cppNode* cppNodePtr +ctypedef fused ExpressionOrModel: + Model + Expression + + __all__ = [ "Absolute", "Add", @@ -175,6 +180,7 @@ cdef void _register(object cls, const type_info& typeinfo): _cpp_type_to_python[type_index(typeinfo)] = (cls) +# TODO: should this use ExpressionOrModel? cdef object symbol_from_ptr(_Model model, cppNode* node_ptr): """Create a Python/Cython symbol from a C++ Node*.""" @@ -2267,39 +2273,36 @@ cdef class NaryReduce(ArraySymbol): def __init__( self, - input_symbols: Collection[Input], - ArraySymbol output_symbol, + # input_symbols: Collection[Input], + # ArraySymbol output_symbol, + expression: Expression, operands: Collection[ArraySymbol], initial_values: Optional[tuple[float]] = None, ): if len(operands) == 0: raise ValueError("must have at least one operand") - if len(input_symbols) != len(operands) + 1: + if expression.num_inputs() != len(operands) + 1: raise ValueError("must have exactly one more input than number of operands") if initial_values is None: - initial_values = (0,) * len(input_symbols) + initial_values = (0,) * expression.num_inputs() - if len(initial_values) != len(input_symbols): + if len(initial_values) != expression.num_inputs(): raise ValueError("must have same number of initial values as inputs") - cdef _Model expression = input_symbols[0].model - cdef _Model model = operands[0].model - cdef cppArrayNode* output = output_symbol.array_ptr + cdef Model model = operands[0].model + cdef cppArrayNode* output = expression.output.array_ptr cdef vector[double] cppinitial_values + cdef cppInputNode* cppinput cdef vector[cppInputNode*] cppinputs cdef vector[cppArrayNode*] cppoperands for val in initial_values: cppinitial_values.push_back(val) - cdef Input inp - for node in input_symbols: - if node.model != expression: - raise ValueError("all inputs must belong to the expression model") - inp = node - cppinputs.push_back(inp.ptr) + for cppinput in expression._graph.inputs(): + cppinputs.push_back(cppinput) cdef ArraySymbol array for node in operands: @@ -2314,11 +2317,12 @@ cdef class NaryReduce(ArraySymbol): move(expression._graph), cppinputs, output, cppinitial_values, cppoperands ) except ValueError as e: + expression.unlock() raise self._handle_unsupported_expression_exception(expression, e) self.initialize_arraynode(model, self.ptr) - def _handle_unsupported_expression_exception(self, _Model expression, exception: ValueError): + def _handle_unsupported_expression_exception(self, Expression expression, exception): try: info = json.loads(str(exception)) except json.JSONDecodeError: diff --git a/tests/test_expression.py b/tests/test_expression.py new file mode 100644 index 00000000..8cb219ff --- /dev/null +++ b/tests/test_expression.py @@ -0,0 +1,89 @@ +# Copyright 2024 D-Wave Systems Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import dwave.optimization.symbols +from dwave.optimization.expression import Expression + + +class TestExpression(unittest.TestCase): + def test(self): + Expression() + + def test_initial_inputs(self): + exp = Expression(num_inputs=10, lower_bound=-5, upper_bound=3.7, integral=False) + self.assertEqual(exp.num_inputs(), 10) + + # Test that all arguments must be provided if starting with initial inputs + with self.assertRaises(ValueError): + Expression(num_inputs=10, upper_bound=3.7, integral=False) + with self.assertRaises(ValueError): + Expression(num_inputs=10, lower_bound=-5, integral=False) + with self.assertRaises(ValueError): + Expression(num_inputs=10, lower_bound=-5, upper_bound=3.7) + + def test_unsupported_symbols(self): + # Can't add decisions to an Expression, even manually + exp = Expression() + with self.assertRaises(TypeError): + dwave.optimization.symbols.IntegerVariable(exp) + + # Can't add other symbols, e.g. Reshape + exp = Expression() + inp = exp.input(0, 1, False) + with self.assertRaises(TypeError): + dwave.optimization.symbols.Reshape(inp, (1, 1, 1)) + + def test_num_inputs(self): + exp = Expression() + self.assertEqual(exp.num_inputs(), 0) + + inp0 = exp.input(-1, 1, True) + self.assertEqual(exp.num_inputs(), 1) + + inp1 = exp.input(-1, 1, True) + self.assertEqual(exp.num_inputs(), 2) + + inp0 + inp1 + self.assertEqual(exp.num_inputs(), 2) + self.assertEqual(exp.num_nodes(), 3) + + exp.input(-1, 1, True) + self.assertEqual(exp.num_inputs(), 3) + self.assertEqual(exp.num_nodes(), 4) + + def test_iter_inputs(self): + exp = Expression() + self.assertListEqual(list(exp.iter_inputs()), []) + + inp0 = exp.input(-1, 1, True) + symbols = list(exp.iter_inputs()) + self.assertEqual(len(symbols), 1) + self.assertTrue(inp0.equals(symbols[0])) + + inp1 = exp.input(-1, 1, True) + symbols = list(exp.iter_inputs()) + self.assertEqual(len(symbols), 2) + self.assertTrue(inp0.equals(symbols[0])) + self.assertTrue(inp1.equals(symbols[1])) + + inp0 + inp1 + symbols = list(exp.iter_inputs()) + self.assertEqual(len(symbols), 2) + + inp2 = exp.input(-1, 1, True) + symbols = list(exp.iter_inputs()) + self.assertEqual(len(symbols), 3) + self.assertTrue(inp2.equals(symbols[2])) diff --git a/tests/test_symbols.py b/tests/test_symbols.py index 475f3a97..919d3e4a 100644 --- a/tests/test_symbols.py +++ b/tests/test_symbols.py @@ -1770,9 +1770,9 @@ def generate_symbols(self): exp = Expression() inputs = [exp.input(-10, 10, False) for _ in range(3)] - sum_ = inputs[0] + inputs[1] + inputs[2] + exp.set_output(inputs[0] + inputs[1] + inputs[2]) - acc = dwave.optimization.symbols.NaryReduce(inputs, sum_, (c0, c1)) + acc = dwave.optimization.symbols.NaryReduce(exp, (c0, c1)) model.lock() yield acc @@ -1784,51 +1784,25 @@ def test_mismatched_inputs(self): exp = Expression() inputs = [exp.input(-10, 10, False) for _ in range(3)] - sum_ = inputs[0] + inputs[1] + inputs[2] + exp.set_output(inputs[0] + inputs[1] + inputs[2]) with self.assertRaises(ValueError): - dwave.optimization.symbols.NaryReduce(inputs, sum_, (c0,)) + dwave.optimization.symbols.NaryReduce(exp, (c0,)) with self.assertRaises(ValueError): - dwave.optimization.symbols.NaryReduce(inputs[:1], sum_, (c0, c1)) - - with self.assertRaises(ValueError): - dwave.optimization.symbols.NaryReduce(inputs, sum_, (c0, c1), initial_values=(0,)) + dwave.optimization.symbols.NaryReduce(exp, (c0, c1), initial_values=(0,)) def test_invalid_expressions(self): model = Model() c0 = model.constant([0, 0]) - # exp = Expression() - # inputs = [exp.input(-10, 10, False) for _ in range(2)] - # i = exp.integer() - # sum_ = inputs[0] + inputs[1] - # - # try: - # dwave.optimization.symbols.NaryReduce(inputs, sum_, (c0,)) - # self.assertTrue(False, "should raise exception") - # except Exception as e: - # self.assertIsInstance(e, dwave.optimization.symbols.UnsupportedNaryReduceExpression) - # self.assertRegex(str(e), "decision") - # self.assertTrue(i.equals(e.symbol)) - # - # exp = Expression() - # inputs = [exp.input(-10, 10, False) for _ in range(2)] - # reshape = inputs[0].reshape((1, 1, 1)) - # - # try: - # dwave.optimization.symbols.NaryReduce(inputs, reshape, (c0,)) - # self.assertTrue(False, "should raise exception") - # except Exception as e: - # self.assertIsInstance(e, dwave.optimization.symbols.UnsupportedNaryReduceExpression) - # self.assertRegex(str(e), "unsupported node") - # self.assertTrue(reshape.equals(e.symbol)) - + # Can't use an Expression that uses a non-scalar input exp = Expression() inp1 = exp.input(-10, 10, False) - inp5 = exp.input(-10, 10, False, (5,)) + inp5 = dwave.optimization.symbols.Input(exp, -10, 10, False, (5,)) + exp.set_output(inp1) try: - dwave.optimization.symbols.NaryReduce((inp1, inp5), inp1, (c0,)) + dwave.optimization.symbols.NaryReduce(exp, (c0,)) self.assertTrue(False, "should raise exception") except Exception as e: self.assertIsInstance(e, dwave.optimization.symbols.UnsupportedNaryReduceExpression)