From 039abef4b61e51f49b1a28028b9c66a3892aa465 Mon Sep 17 00:00:00 2001 From: Alexander Condello Date: Tue, 3 Dec 2024 12:42:10 -0800 Subject: [PATCH] Add _Graph base class for Model Thereby making Model a Python class rather than a Cython one. Co-authored-by: William Bernoudy --- dwave/optimization/_graph.pxd | 120 ++++++ dwave/optimization/{model.pyi => _graph.pyi} | 57 +-- dwave/optimization/{model.pyx => _graph.pyx} | 382 +---------------- dwave/optimization/mathematical.py | 2 +- dwave/optimization/model.pxd | 116 +----- dwave/optimization/model.py | 416 +++++++++++++++++++ dwave/optimization/states.pxd | 4 +- dwave/optimization/states.pyx | 16 +- meson.build | 4 +- tests/test_model.py | 2 +- 10 files changed, 580 insertions(+), 539 deletions(-) create mode 100644 dwave/optimization/_graph.pxd rename dwave/optimization/{model.pyi => _graph.pyi} (72%) rename dwave/optimization/{model.pyx => _graph.pyx} (80%) create mode 100644 dwave/optimization/model.py diff --git a/dwave/optimization/_graph.pxd b/dwave/optimization/_graph.pxd new file mode 100644 index 00000000..1a753ab5 --- /dev/null +++ b/dwave/optimization/_graph.pxd @@ -0,0 +1,120 @@ +# Copyright 2024 D-Wave 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 libc.stdint cimport uintptr_t +from libcpp cimport bool +from libcpp.memory cimport shared_ptr +from libcpp.vector cimport vector + +from dwave.optimization.libcpp.graph cimport ArrayNode as cppArrayNode, Node as cppNode +from dwave.optimization.libcpp.graph cimport Graph as cppGraph +from dwave.optimization.libcpp.state cimport State as cppState + +__all__ = [] + + +cdef class _Graph: + cpdef bool is_locked(self) noexcept + cpdef Py_ssize_t num_constraints(self) noexcept + cpdef Py_ssize_t num_decisions(self) noexcept + cpdef Py_ssize_t num_nodes(self) noexcept + cpdef Py_ssize_t num_symbols(self) noexcept + + # Make the _Graph class weak referenceable + cdef object __weakref__ + + cdef cppGraph _graph + + cdef readonly object objective # todo: cdef ArraySymbol? + """Objective to be minimized. + + Examples: + This example prints the value of the objective of a model representing + the simple polynomial, :math:`y = i^2 - 4i`, for a state with value + :math:`i=2.0`. + + >>> from dwave.optimization import Model + ... + >>> model = Model() + >>> i = model.integer(lower_bound=-5, upper_bound=5) + >>> c = model.constant(4) + >>> y = i**2 - c*i + >>> model.minimize(y) + >>> with model.lock(): + ... model.states.resize(1) + ... i.set_state(0, 2.0) + ... print(f"Objective = {model.objective.state(0)}") + Objective = -4.0 + """ + + cdef readonly object 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 Symbol: + # Inheriting nodes must call this method from their __init__() + cdef void initialize_node(self, _Graph model, cppNode* node_ptr) noexcept + + cpdef uintptr_t id(self) noexcept + + # Exactly deref(self.expired_ptr) + cpdef bool expired(self) noexcept + + @staticmethod + cdef Symbol from_ptr(_Graph model, cppNode* ptr) + + # Hold on to a reference to the _Graph, 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 _Graph 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 + # of a redundant pointer is quite small for these Python objects and it + # simplifies things quite a bit. + cdef cppNode* node_ptr + + # The node's expired flag. If the node is destructed, the boolean value + # pointed to by the expired_ptr will be set to True + cdef shared_ptr[bool] expired_ptr + + +# Ideally this wouldn't subclass Symbol, but Cython only allows a single +# extension base class, so to support that we assume all ArraySymbols are +# also Symbols (probably a fair assumption) +cdef class ArraySymbol(Symbol): + # Inheriting symbols must call this method from their __init__() + cdef void initialize_arraynode(self, _Graph 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. + # But again, it's cheap and it simplifies things. + cdef cppArrayNode* array_ptr diff --git a/dwave/optimization/model.pyi b/dwave/optimization/_graph.pyi similarity index 72% rename from dwave/optimization/model.pyi rename to dwave/optimization/_graph.pyi index 1b840692..952e0e5a 100644 --- a/dwave/optimization/model.pyi +++ b/dwave/optimization/_graph.pyi @@ -20,14 +20,17 @@ import typing import numpy import numpy.typing +from dwave.optimization.states import States from dwave.optimization.symbols import * _ShapeLike: typing.TypeAlias = typing.Union[int, collections.abc.Sequence[int]] +_GraphSubclass = typing.TypeVar("_GraphSubclass", bound="_Graph") -class Model: - def __init__(self): ... + +class _Graph: + def __init__(self, *args, **kwargs) -> typing.NoReturn: ... @property def objective(self) -> ArraySymbol: ... @@ -35,34 +38,15 @@ class Model: 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( - self, primary_set_size: int, num_disjoint_sets: int, - ) -> tuple[DisjointBitSets, tuple[DisjointBitSet, ...]]: ... - - def disjoint_lists( - self, primary_set_size: int, num_disjoint_lists: int, - ) -> tuple[DisjointLists, tuple[DisjointList, ...]]: ... - - def feasible(self, index: int = 0) -> bool: ... - @classmethod def from_file( - cls, + cls: typing.Type[_GraphSubclass], file: typing.Union[typing.BinaryIO, collections.abc.ByteString, str], *, check_header: bool = True, - ) -> Model: ... - - def integer( - self, - shape: typing.Optional[_ShapeLike] = None, - lower_bound: typing.Optional[int] = None, - upper_bound: typing.Optional[int] = None, - ) -> IntegerVariable: ... + ) -> _GraphSubclass: ... def into_file( self, @@ -76,39 +60,14 @@ class Model: 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 lock(self): ... 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, - min_size: int = 0, - 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): ... diff --git a/dwave/optimization/model.pyx b/dwave/optimization/_graph.pyx similarity index 80% rename from dwave/optimization/model.pyx rename to dwave/optimization/_graph.pyx index c220f455..000c2437 100644 --- a/dwave/optimization/model.pyx +++ b/dwave/optimization/_graph.pyx @@ -1,4 +1,4 @@ -# Copyright 2024 D-Wave Systems Inc. +# Copyright 2024 D-Wave Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -12,18 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Nonlinear models are especially suited for use with decision variables that -represent a common logic, such as subsets of choices or permutations of ordering. -For example, in a -`traveling salesperson problem `_ -permutations of the variables representing cities can signify the order of the -route being optimized and in a -`knapsack problem `_ the -variables representing items can be divided into subsets of packed and not -packed. -""" - -import contextlib import collections.abc import functools import itertools @@ -31,7 +19,6 @@ import json import numbers import operator import struct -import tempfile import zipfile import numpy as np @@ -49,44 +36,19 @@ from dwave.optimization.states cimport States from dwave.optimization.states import StateView from dwave.optimization.symbols cimport symbol_from_ptr +__all__ = [] -__all__ = ["Model"] - -@contextlib.contextmanager -def locked(model): - """Context manager that hold a locked model and unlocks it when the context is exited.""" - try: - yield - finally: - 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): +cdef class _Graph: + def __cinit__(self): self.states = States(self) - self._data_sources = [] + def __init__(self, *args, **kwargs): + # disallow direct construction of _Graphs, they should be constructed + # via their subclasses. + raise ValueError("_Graphs cannot be constructed directly") + def add_constraint(self, ArraySymbol value): """Add a constraint to the model. @@ -125,47 +87,6 @@ cdef class Model: 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 constant(self, array_like): - r"""Create a constant symbol. - - Args: - array_like: An |array-like|_ representing a constant. Can be a scalar - or a NumPy array. If the array's ``dtype`` is ``np.double``, the - array is not copied. - - Returns: - A constant symbol. - - Examples: - This example creates a :math:`1 \times 4`-sized constant symbol - with the specified values. - - >>> from dwave.optimization.model import Model - >>> model = Model() - >>> time_limits = model.constant([10, 15, 5, 8.5]) - """ - 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. @@ -181,102 +102,6 @@ cdef class Model: """ 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, @@ -315,7 +140,7 @@ cdef class Model: header_len = struct.unpack('>> 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() @@ -613,25 +412,6 @@ cdef class Model: for i in range(nodes.size()): yield symbol_from_ptr(self, 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. @@ -674,8 +454,6 @@ cdef class Model: # note that we do not initialize the nodes or resize the states! # We do it lazily for performance - return locked(self) - def minimize(self, ArraySymbol value): """Set the objective value to minimize. @@ -788,7 +566,7 @@ cdef class Model: """ return self._graph.num_nodes() - def num_symbols(self): + cpdef Py_ssize_t num_symbols(self) noexcept: """Number of symbols tracked by the model. Equivalent to the number of nodes in the directed acyclic @@ -811,32 +589,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. @@ -890,28 +642,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. @@ -929,88 +659,6 @@ cdef class Model: """ return sum(sym.state_size() for sym in self.iter_symbols()) - def to_file(self, **kwargs): - """Serialize the model to a new file-like object. - - See also: - :meth:`.into_file`, :meth:`.from_file` - """ - file = tempfile.TemporaryFile(mode="w+b") - self.into_file(file, **kwargs) - file.seek(0) - return file - - def to_networkx(self): - """Convert the model to a NetworkX graph. - - Returns: - A :obj:`NetworkX ` graph. - - Examples: - This example converts a model to a graph. - - >>> from dwave.optimization.model import Model - >>> model = Model() - >>> one = model.constant(1) - >>> two = model.constant(2) - >>> i = model.integer() - >>> model.minimize(two * i - one) - >>> G = model.to_networkx() # doctest: +SKIP - - One advantage of converting to NetworkX is the wide availability - of drawing tools. See NetworkX's - `drawing `_ - documentation. - - This example uses `DAGVIZ `_ to - draw the NetworkX graph created in the example above. - - >>> import dagviz # doctest: +SKIP - >>> r = dagviz.render_svg(G) # doctest: +SKIP - >>> with open("model.svg", "w") as f: # doctest: +SKIP - ... f.write(r) - - This creates the following image: - - .. figure:: /_images/to_networkx_example.svg - :width: 500 px - :name: dwave-optimization-to-networkx-example - :alt: Image of NetworkX Directed Graph - - """ - import networkx - - G = networkx.MultiDiGraph() - - # Add the symbols, in order if we happen to be topologically sorted - G.add_nodes_from(repr(symbol) for symbol in self.iter_symbols()) - - # Sanity check. If several nodes map to the same symbol repr we'll see - # too few nodes in the graph - if len(G) != self.num_symbols(): - raise RuntimeError("symbol repr() is not unique to the underlying node") - - # Now add the edges - for symbol in self.iter_symbols(): - for successor in symbol.iter_successors(): - G.add_edge(repr(symbol), repr(successor)) - - # Sanity check again. If the repr of symbols isn't unique to the underlying - # node then we'll see too many nodes in the graph here - if len(G) != self.num_symbols(): - raise RuntimeError("symbol repr() is not unique to the underlying node") - - # Add the objective if it's present. We call it "minimize" to be - # consistent with the minimize() function - if self.objective is not None: - G.add_edge(repr(self.objective), "minimize") - - # Likewise if we have constraints, add a special node for them - for symbol in self.iter_constraints(): - G.add_edge(repr(symbol), "constraint(s)") - - return G - def unlock(self): """Release a lock, decrementing the lock count. @@ -1056,7 +704,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, _Graph model, cppNode* node_ptr) noexcept: self.model = model self.node_ptr = node_ptr @@ -1087,7 +735,7 @@ cdef class Symbol: return deref(self.expired_ptr) @staticmethod - cdef Symbol from_ptr(Model model, cppNode* ptr): + cdef Symbol from_ptr(_Graph model, cppNode* ptr): """Construct a Symbol from a C++ Node pointer. There are times when a Node* needs to be passed through the Python layer @@ -1108,7 +756,7 @@ cdef class Symbol: raise ValueError("Symbols cannot be constructed directly") @classmethod - def _from_zipfile(cls, zf, directory, Model model, predecessors): + def _from_zipfile(cls, zf, directory, _Graph model, predecessors): """Construct a symbol from a compressed file. Args: @@ -1492,7 +1140,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, _Graph model, cppArrayNode* array_ptr) noexcept: self.array_ptr = array_ptr self.initialize_node(model, array_ptr) diff --git a/dwave/optimization/mathematical.py b/dwave/optimization/mathematical.py index 77ef652e..649a6d0d 100644 --- a/dwave/optimization/mathematical.py +++ b/dwave/optimization/mathematical.py @@ -16,7 +16,7 @@ import functools import typing -from dwave.optimization.model import ArraySymbol +from dwave.optimization._graph import ArraySymbol from dwave.optimization.symbols import ( Add, And, diff --git a/dwave/optimization/model.pxd b/dwave/optimization/model.pxd index fd501502..9beeb841 100644 --- a/dwave/optimization/model.pxd +++ b/dwave/optimization/model.pxd @@ -1,6 +1,4 @@ -# distutils: language = c++ - -# Copyright 2024 D-Wave Systems Inc. +# Copyright 2024 D-Wave Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -14,111 +12,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -from libc.stdint cimport uintptr_t -from libcpp cimport bool -from libcpp.memory cimport shared_ptr -from libcpp.vector cimport vector - -from dwave.optimization.libcpp.graph cimport ArrayNode as cppArrayNode, Node as cppNode -from dwave.optimization.libcpp.graph cimport Graph as cppGraph -from dwave.optimization.libcpp.state cimport State as cppState - -__all__ = ["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__ - - # Make the Model class weak referenceable - cdef object __weakref__ - - cdef cppGraph _graph - - cdef readonly object objective # todo: cdef ArraySymbol? - """Objective to be minimized. - - Examples: - This example prints the value of the objective of a model representing - the simple polynomial, :math:`y = i^2 - 4i`, for a state with value - :math:`i=2.0`. - - >>> from dwave.optimization import Model - ... - >>> model = Model() - >>> i = model.integer(lower_bound=-5, upper_bound=5) - >>> c = model.constant(4) - >>> y = i**2 - c*i - >>> model.minimize(y) - >>> with model.lock(): - ... model.states.resize(1) - ... i.set_state(0, 2.0) - ... print(f"Objective = {model.objective.state(0)}") - Objective = -4.0 - """ - - cdef readonly object 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 Symbol: - # Inheriting nodes must call this method from their __init__() - cdef void initialize_node(self, Model model, cppNode* node_ptr) noexcept - - cpdef uintptr_t id(self) noexcept - - # Exactly deref(self.expired_ptr) - cpdef bool expired(self) noexcept - - @staticmethod - 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 - - # 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 - # of a redundant pointer is quite small for these Python objects and it - # simplifies things quite a bit. - cdef cppNode* node_ptr - - # The node's expired flag. If the node is destructed, the boolean value - # pointed to by the expired_ptr will be set to True - cdef shared_ptr[bool] expired_ptr - - -# Ideally this wouldn't subclass Symbol, but Cython only allows a single -# extension base class, so to support that we assume all ArraySymbols are -# 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 +# Pull ArraySymbol and Symbol into this namespace for convenience +from dwave.optimization._graph cimport ArraySymbol, Symbol - # Hold ArrayNode* pointer. Again this is redundant, because we're also holding - # a pointer to Node* and we can theoretically dynamic cast each time. - # But again, it's cheap and it simplifies things. - cdef cppArrayNode* array_ptr +# Alias _Graph as Model, this allows other modules to cimport Model if they +# wish which is sometimes convenient. +from dwave.optimization._graph cimport _Graph as Model diff --git a/dwave/optimization/model.py b/dwave/optimization/model.py new file mode 100644 index 00000000..9dd7b062 --- /dev/null +++ b/dwave/optimization/model.py @@ -0,0 +1,416 @@ +# Copyright 2024 D-Wave 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. + +"""Nonlinear models are especially suited for use with decision variables that +represent a common logic, such as subsets of choices or permutations of ordering. +For example, in a +`traveling salesperson problem `_ +permutations of the variables representing cities can signify the order of the +route being optimized and in a +`knapsack problem `_ the +variables representing items can be divided into subsets of packed and not +packed. +""" + +import contextlib +import tempfile + +from dwave.optimization._graph import ArraySymbol, _Graph, Symbol + +__all__ = ["Model"] + + +@contextlib.contextmanager +def locked(model): + """Context manager that hold a locked model and unlocks it when the context is exited.""" + try: + yield + finally: + model.unlock() + + +class Model(_Graph): + """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): + pass + + 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 constant(self, array_like): + r"""Create a constant symbol. + + Args: + array_like: An |array-like|_ representing a constant. Can be a scalar + or a NumPy array. If the array's ``dtype`` is ``np.double``, the + array is not copied. + + Returns: + A constant symbol. + + Examples: + This example creates a :math:`1 \times 4`-sized constant symbol + with the specified values. + + >>> from dwave.optimization.model import Model + >>> model = Model() + >>> time_limits = model.constant([10, 15, 5, 8.5]) + """ + from dwave.optimization.symbols import Constant # avoid circular import + return Constant(self, array_like) + + def disjoint_bit_sets(self, primary_set_size, 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, primary_set_size, 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, index: int = 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 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. + + No new symbols can be added to a locked model. + + Returns: + A context manager. If the context is subsequently exited then the + :meth:`.unlock` will be called. + + See also: + :meth:`.is_locked`, :meth:`.unlock` + + Examples: + This example checks the status of a model after locking it and + subsequently unlocking it. + + >>> from dwave.optimization.model import Model + >>> model = Model() + >>> i = model.integer(20, upper_bound=100) + >>> cntx = model.lock() + >>> model.is_locked() + True + >>> model.unlock() + >>> model.is_locked() + False + + This example locks a model temporarily with a context manager. + + >>> model = Model() + >>> with model.lock(): + ... # no nodes can be added within the context + ... print(model.is_locked()) + True + >>> model.is_locked() + False + """ + super().lock() + return locked(self) + + def quadratic_model(self, 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, n, 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 to_file(self, **kwargs): + """Serialize the model to a new file-like object. + + See also: + :meth:`.into_file`, :meth:`.from_file` + """ + file = tempfile.TemporaryFile(mode="w+b") + self.into_file(file, **kwargs) + file.seek(0) + return file + + def to_networkx(self): + """Convert the model to a NetworkX graph. + + Returns: + A :obj:`NetworkX ` graph. + + Examples: + This example converts a model to a graph. + + >>> from dwave.optimization.model import Model + >>> model = Model() + >>> one = model.constant(1) + >>> two = model.constant(2) + >>> i = model.integer() + >>> model.minimize(two * i - one) + >>> G = model.to_networkx() # doctest: +SKIP + + One advantage of converting to NetworkX is the wide availability + of drawing tools. See NetworkX's + `drawing `_ + documentation. + + This example uses `DAGVIZ `_ to + draw the NetworkX graph created in the example above. + + >>> import dagviz # doctest: +SKIP + >>> r = dagviz.render_svg(G) # doctest: +SKIP + >>> with open("model.svg", "w") as f: # doctest: +SKIP + ... f.write(r) + + This creates the following image: + + .. figure:: /_images/to_networkx_example.svg + :width: 500 px + :name: dwave-optimization-to-networkx-example + :alt: Image of NetworkX Directed Graph + + """ + import networkx + + G = networkx.MultiDiGraph() + + # Add the symbols, in order if we happen to be topologically sorted + G.add_nodes_from(repr(symbol) for symbol in self.iter_symbols()) + + # Sanity check. If several nodes map to the same symbol repr we'll see + # too few nodes in the graph + if len(G) != self.num_symbols(): + raise RuntimeError("symbol repr() is not unique to the underlying node") + + # Now add the edges + for symbol in self.iter_symbols(): + for successor in symbol.iter_successors(): + G.add_edge(repr(symbol), repr(successor)) + + # Sanity check again. If the repr of symbols isn't unique to the underlying + # node then we'll see too many nodes in the graph here + if len(G) != self.num_symbols(): + raise RuntimeError("symbol repr() is not unique to the underlying node") + + # Add the objective if it's present. We call it "minimize" to be + # consistent with the minimize() function + if self.objective is not None: + G.add_edge(repr(self.objective), "minimize") + + # Likewise if we have constraints, add a special node for them + for symbol in self.iter_constraints(): + G.add_edge(repr(symbol), "constraint(s)") + + return G diff --git a/dwave/optimization/states.pxd b/dwave/optimization/states.pxd index 7fe5b5bc..ac271957 100644 --- a/dwave/optimization/states.pxd +++ b/dwave/optimization/states.pxd @@ -15,7 +15,7 @@ from libcpp.vector cimport vector from dwave.optimization.libcpp.state cimport State as cppState -from dwave.optimization.model cimport Model +from dwave.optimization._graph cimport _Graph cdef class States: @@ -24,7 +24,7 @@ cdef class States: cpdef resolve(self) cpdef Py_ssize_t size(self) except -1 - cdef Model _model(self) + cdef _Graph _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 diff --git a/dwave/optimization/states.pyx b/dwave/optimization/states.pyx index 5ca0f6f1..3ba07bc6 100644 --- a/dwave/optimization/states.pyx +++ b/dwave/optimization/states.pyx @@ -17,8 +17,8 @@ import weakref from libcpp.utility cimport move from dwave.optimization.libcpp.array cimport Array as cppArray -from dwave.optimization.model cimport ArraySymbol - +from dwave.optimization._graph cimport ArraySymbol +from dwave.optimization.model import Model __all__ = ["States"] @@ -75,7 +75,9 @@ cdef class States: >>> model.states.size() 0 """ - def __init__(self, Model model): + def __init__(self, model): + if not isinstance(model, Model): + raise TypeError("model must be an instance of Model") self._model_ref = weakref.ref(model) def __len__(self): @@ -158,7 +160,7 @@ cdef class States: # todo: we don't need to actually construct a model, but this is nice and # abstract. We should performance test and then potentially re-implement - cdef Model model = Model.from_file(file, check_header=check_header) + cdef _Graph model = Model.from_file(file, check_header=check_header) cdef States states = model.states # Check that the model is compatible @@ -190,7 +192,7 @@ cdef class States: """Initialize any uninitialized states.""" self.resolve() - cdef Model model = self._model() + cdef _Graph model = self._model() if not model.is_locked(): raise ValueError("Cannot initialize states of an unlocked model") @@ -213,9 +215,9 @@ cdef class States: return self._model().into_file(file, only_decision=True, max_num_states=self.size()) - cdef Model _model(self): + cdef _Graph _model(self): """Get a ref-counted Model object.""" - cdef Model m = self._model_ref() + cdef _Graph m = self._model_ref() if m is None: raise ReferenceError("accessing the states of a garbage collected model") return m diff --git a/meson.build b/meson.build index 8037082b..42c87ab7 100644 --- a/meson.build +++ b/meson.build @@ -65,8 +65,8 @@ else endif py.extension_module( - 'model', - 'dwave/optimization/model.pyx', + '_graph', + 'dwave/optimization/_graph.pyx', dependencies: libdwave_optimization, gnu_symbol_visibility: 'default', install: true, diff --git a/tests/test_model.py b/tests/test_model.py index 9a8df008..303be044 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -118,7 +118,7 @@ def __init__(self, array): def __getitem__(self, index): if not isinstance(index, tuple): return self[(index,)] - i0, i1 = dwave.optimization.model._split_indices(index) + i0, i1 = dwave.optimization._graph._split_indices(index) np.testing.assert_array_equal(self.array[index], self.array[i0][i1]) def test_split_indices(self):