diff --git a/docs/_images/to_networkx_example.svg b/docs/_images/to_networkx_example.svg new file mode 100644 index 00000000..a6ca0960 --- /dev/null +++ b/docs/_images/to_networkx_example.svg @@ -0,0 +1 @@ +<dwave.optimization.symbols.Constant at 0x559b56f604a0><dwave.optimization.symbols.Constant at 0x559b568bd0c0><dwave.optimization.symbols.IntegerVariable at 0x559b56d274d0><dwave.optimization.symbols.Multiply at 0x559b57314190><dwave.optimization.symbols.Subtract at 0x559b56ee36f0>minimize \ No newline at end of file diff --git a/dwave/optimization/model.pyx b/dwave/optimization/model.pyx index 2c951bf3..2a421166 100644 --- a/dwave/optimization/model.pyx +++ b/dwave/optimization/model.pyx @@ -40,6 +40,7 @@ import numpy as np from cpython cimport Py_buffer from cython.operator cimport dereference as deref, preincrement as inc from cython.operator cimport typeid +from libc.stdint cimport uintptr_t from libcpp cimport bool from libcpp.utility cimport move from libcpp.vector cimport vector @@ -845,53 +846,71 @@ cdef class Model: def to_networkx(self): """Convert the model to a NetworkX graph. - Note: - Currently requires the installation of a GNU compiler. - Returns: - A :obj:`NetworkX ` graph. + A :obj:`NetworkX ` graph. Examples: This example converts a model to a graph. >>> from dwave.optimization.model import Model >>> model = Model() - >>> c = model.constant(8) - >>> i = model.integer((20, 30)) - >>> g = model.to_networkx() # doctest: +SKIP - """ - # Todo: adapt to use iter_symbols() - # This whole method will need a re-write, it currently only works with gcc - # but it is useful for development + >>> one = model.constant(1) + >>> two = model.constant(2) + >>> i = model.integer() + >>> model.minimize(two * i - one) + >>> G = model.to_networkx() - import re - import networkx + One advantage of converting to NetworkX is the wide availability + of drawing tools. See NetworkX's + `drawing `_ + documentation. - G = networkx.DiGraph() + This example uses `DAGVIZ `_ to + draw the NetworkX graph created in the example above. - cdef cppNode* ptr - for i in range(self._graph.num_nodes()): - ptr = self._graph.nodes()[i].get() + >>> import dagviz # doctest: +SKIP + >>> r = dagviz.render_svg(G) # doctest: +SKIP + >>> with open("model.svg", "w") as f: # doctest: +SKIP + ... f.write(r) - # this regex is compiler specific! Don't do this for the general case - match = re.search("\d+([a-zA-z]+Node)", str(typeid(deref(ptr)).name())) - if not match: - raise ValueError + This creates the following image: - u = (match[1], (ptr)) + .. figure:: /_images/to_networkx_example.svg + :width: 500 px + :name: dwave-optimization-to-networkx-example + :alt: Image of NetworkX Directed Graph - G.add_node(u) + """ + import networkx - for j in range(ptr.predecessors().size()): - pptr = ptr.predecessors()[j] + G = networkx.MultiDiGraph() - match = re.search("\d+([a-zA-z]+Node)", str(typeid(deref(pptr)).name())) - if not match: - raise ValueError + # Add the symbols, in order if we happen to be topologically sorted + G.add_nodes_from(repr(symbol) for symbol in self.iter_symbols()) - v = (match[1], (pptr)) + # 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") - G.add_edge(v, u) + # 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 @@ -1193,6 +1212,20 @@ cdef class Symbol: # via their subclasses. raise ValueError("Symbols cannot be constructed directly") + def __repr__(self): + """Return a representation of the symbol. + + The representation refers to the identity of the underlying node, rather than + the identity of the Python symbol. + """ + cls = type(self) + # We refer to the node_ptr, which is not necessarily the address of the + # C++ node, as it sublasses Node. + # But this is unique to each node, and functions as an id rather than + # as a pointer, so that's OK. + # Otherwise we aim to match Python's default __repr__. + return f"<{cls.__module__}.{cls.__qualname__} at {self.node_ptr:#x}>" + cdef void initialize_node(self, Model model, cppNode* node_ptr) noexcept: self.model = model diff --git a/releasenotes/notes/fix-to_networkx-a78f0f669cc9638c.yaml b/releasenotes/notes/fix-to_networkx-a78f0f669cc9638c.yaml new file mode 100644 index 00000000..fe0a409e --- /dev/null +++ b/releasenotes/notes/fix-to_networkx-a78f0f669cc9638c.yaml @@ -0,0 +1,9 @@ +--- +features: + - | + Make ``repr()`` of symbols unique to the underlying node, rather than to the Python symbol. + See `#52 _. +fixes: + - | + Fix ``Symbol.to_networkx()`` to no longer be compiler-dependant. + See `#18 _. \ No newline at end of file diff --git a/tests/test_model.py b/tests/test_model.py index 232cdd4c..3565ed47 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -260,6 +260,76 @@ def test_state_size(self): model.constant(np.arange(25).reshape(5, 5)) self.assertEqual(model.state_size(), 25 * 8) + def test_to_networkx(self): + try: + import networkx as nx + except ImportError: + return self.skipTest("NetworkX is not installed") + + model = Model() + a = model.binary() + b = model.binary() + ab = a * b + + G = model.to_networkx() + + self.assertEqual(len(G.nodes), 3) + self.assertEqual(len(G.edges), 2) + + # the repr as labels is an implementation detail and subject to change + self.assertIn(repr(a), G.nodes) + self.assertIn(repr(b), G.nodes) + self.assertIn(repr(ab), G.nodes) + self.assertIn((repr(a), repr(ab)), G.edges) + self.assertIn((repr(b), repr(ab)), G.edges) + + # graph created is deterministic + self.assertTrue(nx.utils.graphs_equal(G, model.to_networkx())) + + def test_to_networkx_multigraph(self): + try: + import networkx as nx + except ImportError: + return self.skipTest("NetworkX is not installed") + + model = Model() + a = model.binary() + aa = a * a # two edges to the same node + + G = model.to_networkx() + + # the repr as labels is an implementation detail and subject to change + self.assertEqual(len(G.nodes), 2) + self.assertEqual(len(G.edges), 2) + self.assertIn(repr(a), G.nodes) + self.assertIn(repr(aa), G.nodes) + self.assertIn((repr(a), repr(aa)), G.edges) + + # graph created is deterministic + self.assertTrue(nx.utils.graphs_equal(G, model.to_networkx())) + + def test_to_networkx_objective_and_constraints(self): + try: + import networkx as nx + except ImportError: + return self.skipTest("NetworkX is not installed") + + model = Model() + a = model.binary() + b = model.binary() + model.minimize(a * b) + + G = model.to_networkx() + self.assertEqual(len(G.nodes), 4) # 3 symbols + "minimize" + self.assertEqual(len(G.edges), 3) + self.assertIn("minimize", G.nodes) + + model.add_constraint(a <= b) + G = model.to_networkx() + self.assertEqual(len(G.nodes), 6) + self.assertEqual(len(G.edges), 6) + self.assertIn("constraint(s)", G.nodes) + class TestStates(unittest.TestCase): def test_clear(self): @@ -424,3 +494,14 @@ def test_abstract(self): from dwave.optimization.model import Symbol with self.assertRaisesRegex(ValueError, "Symbols cannot be constructed directly"): Symbol() + + def test_repr(self): + model = Model() + c0 = model.constant(5) + c1, = model.iter_symbols() + c2 = model.constant(5) + + # the specific form is an implementation detail, but different symbols + # representing the same underlying node should have the same repr + self.assertEqual(repr(c0), repr(c1)) + self.assertNotEqual(repr(c0), repr(c2))