diff --git a/dimod/generators/__init__.py b/dimod/generators/__init__.py index d6e9ac06a..bf258a029 100644 --- a/dimod/generators/__init__.py +++ b/dimod/generators/__init__.py @@ -16,6 +16,7 @@ from dimod.generators.binpacking import * from dimod.generators.bpsp import * from dimod.generators.chimera import * +from dimod.generators.coloring import * from dimod.generators.constraints import * from dimod.generators.fcl import * from dimod.generators.gates import * @@ -23,8 +24,13 @@ from dimod.generators.integer import * from dimod.generators.knapsack import * from dimod.generators.magic_square import * +from dimod.generators.markov import * +from dimod.generators.matching import * from dimod.generators.multi_knapsack import * from dimod.generators.quadratic_assignment import * +from dimod.generators.partition import * from dimod.generators.random import * from dimod.generators.satisfiability import * +from dimod.generators.social import * +from dimod.generators.tsp import * from dimod.generators.wireless import * \ No newline at end of file diff --git a/dimod/generators/coloring.py b/dimod/generators/coloring.py new file mode 100644 index 000000000..93245bbab --- /dev/null +++ b/dimod/generators/coloring.py @@ -0,0 +1,249 @@ +# Copyright 2026 D-Wave +# +# 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 itertools +import math +from collections.abc import Sequence + +from dimod.binary_quadratic_model import BinaryQuadraticModel +from dimod.decorators import graph_argument +from dimod.typing import GraphLike +from dimod.vartypes import Vartype + +__all__ = ["min_vertex_coloring", + "vertex_coloring", + ] + + +@graph_argument('graph') +def vertex_coloring(graph: GraphLike, + colors: Sequence, + ) -> BinaryQuadraticModel: + """Generate a binary quadratic model (BQM) with ground states corresponding + to a vertex coloring. + + If :math:`V` is the set of nodes, :math:`E` is the set of edges and + :math:`C` is the set of colors the resulting BQM will have: + + * :math:`|V|*|C|` variables/nodes + * :math:`|V|*|C|*(|C| - 1) / 2 + |E|*|C|` interactions/edges + + The BQM has ground energy :math:`-|V|` and an infeasible gap of 1. + + Args: + graph: + The graph on which to find a vertex coloring. Either an integer + ``n``, interpreted as a complete graph of size ``n``, a nodes/edges + pair, a list of edges or a NetworkX graph. + + colors: + Color labels to use. The number of colors must be greater or equal + to the chromatic number of the graph. + + Returns: + A binary quadratic model with ground states corresponding to valid + colorings of the graph. The BQM variables are labelled ``(v, c)`` where + ``v`` is a node in ``graph`` and ``c`` is a color. In the ground state + of the BQM, a variable ``(v, c)`` has value 1 if ``v`` should be colored + ``c`` in a valid coloring. + + """ + variables, edges = graph + + bqm = BinaryQuadraticModel(Vartype.BINARY) + + # enforce that each variable in G has at most one color + for v in variables: + # 1 in k constraint + for c in colors: + bqm.add_linear((v, c), -1) + + for c0, c1 in itertools.combinations(colors, 2): + bqm.add_quadratic((v, c0), (v, c1), 2) + + # enforce that adjacent nodes do not have the same color + for u, v in edges: + # NAND constraint + for c in colors: + bqm.add_quadratic((u, c), (v, c), 1) + + return bqm + + +@graph_argument('G', as_networkx=True) +def _chromatic_number_upper_bound(G: 'nx.Graph') -> int: + # tries to determine an upper bound on the chromatic number of G + # Assumes G is not complete + + # nx is an optional dependency, so we defer import + # it's safe to import here, as `@graph_argument` would fail otherwise + import networkx as nx + + if not nx.is_connected(G): + return max((_chromatic_number_upper_bound(G.subgraph(c)) + for c in nx.connected_components(G))) + + n_nodes = len(G.nodes) + n_edges = len(G.edges) + + # chi * (chi - 1) <= 2 * |E| + quad_bound = math.ceil((1 + math.sqrt(1 + 8 * n_edges)) / 2) + + if n_nodes % 2 == 1 and is_cycle(G): + # odd cycle graphs need three colors + bound = 3 + elif n_nodes > 2: + try: + import numpy as np + except ImportError: + # chi <= max degree, unless it is complete or a cycle graph of odd length, + # in which case chi <= max degree + 1 (Brook's Theorem) + bound = max(G.degree(node) for node in G) + else: + # Let A be the adj matrix of G (symmetric, 0 on diag). Let theta_1 + # be the largest eigenvalue of A. Then chi <= theta_1 + 1 with + # equality iff G is complete or an odd cycle. + # this is strictly better than brooks theorem + # G is real symmetric, use eigvalsh for real valued output. + bound = math.ceil(max(np.linalg.eigvalsh(nx.to_numpy_array(G)))) + else: + # we know it's connected + bound = n_nodes + + return min(quad_bound, bound) + + +def _chromatic_number_lower_bound(G: 'nx.Graph') -> int: + # find a random maximal clique and use that to determine a lower bound + v = max(G, key=G.degree) + + clique = {v} + for u in G[v]: + if all(w in G[u] for w in clique): + clique.add(u) + + return len(clique) + + +@graph_argument('graph', as_networkx=True) +def min_vertex_coloring(graph: GraphLike, + chromatic_lb: int | None = None, + chromatic_ub: int | None = None, + ) -> BinaryQuadraticModel: + """Generate a binary quadratic model (BQM) with ground states corresponding + to a minimum vertex coloring. + + Vertex coloring is the problem of assigning a color to the + vertices of a graph in a way that no adjacent vertices have the + same color. A minimum vertex coloring is the problem of solving + the vertex coloring problem using the smallest number of colors. + + After defining a BQM/QUBO [Dah2013]_ with ground states corresponding to + minimum vertex colorings, use a sampler to sample from it. + + Args: + graph: + The graph on which to find a vertex coloring. Either an integer + ``n``, interpreted as a complete graph of size ``n``, a nodes/edges + pair, a list of edges or a NetworkX graph. + + chromatic_lb: + A lower bound on the chromatic number. If one is not provided, a + bound is calculated. + + chromatic_ub: + An upper bound on the chromatic number. If one is not provided, a + bound is calculated. + + Returns: + A binary quadratic model with ground states corresponding to minimum + colorings of the graph. The BQM variables are labelled ``(v, c)`` where + ``v`` is a node in ``graph`` and ``c`` is a color. In the ground state + of the BQM, a variable ``(v, c)`` has value 1 if ``v`` should be colored + ``c`` in a valid coloring. + + """ + + chi_ub = _chromatic_number_upper_bound(graph) + chromatic_ub = chi_ub if chromatic_ub is None else min(chi_ub, chromatic_ub) + + chib_lb = _chromatic_number_lower_bound(graph) + chromatic_lb = chib_lb if chromatic_lb is None else max(chib_lb, chromatic_lb) + + if chromatic_lb > chromatic_ub: + raise RuntimeError("something went wrong when calculating the " + "chromatic number bounds") + + # our base BQM is one with as many colors as we might need, so we use the + # upper bound + bqm = vertex_coloring(graph, colors=range(int(chromatic_ub))) + + if chromatic_lb != chromatic_ub: + # we want to penalize the colors that we aren't sure that we need + # we might need to use some of the colors, so we want to penalize + # them in increasing amounts, linearly. + + num_penalized = chromatic_ub - chromatic_lb + + # we want evenly spaced penalties in (0, 1) without the endpoints + weights = [p / (num_penalized + 1) for p in range(1, num_penalized + 1)] + + for p, c in zip(weights, range(chromatic_lb, chromatic_ub)): + for v in graph.nodes: + bqm.linear[(v, c)] += p + + return bqm + + +@graph_argument('G', as_networkx=True) +def is_cycle(G: GraphLike) -> bool: + """Determines whether the given graph is a cycle or circle graph. + + A cycle graph or circular graph is a graph that consists of a single cycle. + + https://en.wikipedia.org/wiki/Cycle_graph + + Args: + G: A NetworkX graph. + + Returns: + True if the graph consists of a single cycle. + + """ + if len(G) <= 2: + return False + + trailing, leading = next(iter(G.edges)) + start_node = trailing + + # travel around the graph, checking that each node has degree exactly two + # also track how many nodes were visited + n_visited = 1 + while leading != start_node: + neighbors = G[leading] + + if len(neighbors) != 2: + return False + + node1, node2 = neighbors + + if node1 == trailing: + trailing, leading = leading, node2 + else: + trailing, leading = leading, node1 + + n_visited += 1 + + # if we haven't visited all of the nodes, then it is not a connected cycle + return n_visited == len(G) diff --git a/dimod/generators/markov.py b/dimod/generators/markov.py new file mode 100644 index 000000000..fe057eeb1 --- /dev/null +++ b/dimod/generators/markov.py @@ -0,0 +1,107 @@ +# Copyright 2026 D-Wave +# +# 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 dimod + +__all__ = ['markov_network', + ] + + +############################################################################### +# The following code is partially based on https://github.com/tbabej/gibbs +# +# MIT License +# =========== +# +# Copyright 2017 Tomas Babej +# https://github.com/tbabej/gibbs +# +# This software is released under MIT licence. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# + + +def markov_network(graph: 'nx.Graph') -> dimod.BinaryQuadraticModel: + """Construct a binary quadratic model for a Markov network. + + Args: + graph: + A Markov network (in a NetworkX graph) as returned by + :func:`dwave.graphs.generators.markov.markov_network`. + + Returns: + A binary quadratic model. + """ + + if not hasattr(graph, 'nodes') or not hasattr(graph, 'edges'): + raise ValueError("A NetworkX graph with potentials data required") + + bqm = dimod.BinaryQuadraticModel.empty(dimod.BINARY) + + # the variable potentials + for v, ddict in graph.nodes(data=True, default=None): + potential = ddict.get('potential', None) + + if potential is None: + continue + + # for single nodes we don't need to worry about order + + phi0 = potential[(0,)] + phi1 = potential[(1,)] + + bqm.add_variable(v, phi1 - phi0) + bqm.offset += phi0 + + # the interaction potentials + for u, v, ddict in graph.edges(data=True, default=None): + potential = ddict.get('potential', None) + + if potential is None: + continue + + # in python<=3.5 the edge order might not be consistent so we use the + # one that was stored + order = ddict['order'] + u, v = order + + phi00 = potential[(0, 0)] + phi01 = potential[(0, 1)] + phi10 = potential[(1, 0)] + phi11 = potential[(1, 1)] + + bqm.add_variable(u, phi10 - phi00) + bqm.add_variable(v, phi01 - phi00) + bqm.add_interaction(u, v, phi11 - phi10 - phi01 + phi00) + bqm.offset += phi00 + + return bqm diff --git a/dimod/generators/matching.py b/dimod/generators/matching.py new file mode 100644 index 000000000..145a1cb59 --- /dev/null +++ b/dimod/generators/matching.py @@ -0,0 +1,195 @@ +# Copyright 2026 D-Wave +# +# 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 collections +import itertools +from typing import Any + +import dimod +from dimod.decorators import graph_argument +from dimod.typing import GraphLike + +__all__ = ['matching', + 'maximal_matching', + 'min_maximal_matching', + ] + + +@graph_argument('graph') +def matching(graph: GraphLike) -> dimod.BinaryQuadraticModel: + """Find a binary quadratic model for the graph's matchings. + + A matching is a subset of edges in which no node occurs more than + once. This function returns a binary quadratic model (BQM) with ground + states corresponding to the possible matchings of ``graph``. + + Finding valid matchings can be done in polynomial time, so finding matching + with BQMs is generally inefficient. + This BQM may be useful when combined with other constraints and objectives. + + Args: + graph: + The graph on which to find a matching. Either an integer + ``n``, interpreted as a complete graph of size ``n``, a nodes/edges + pair, a list of edges or a NetworkX graph. + + Returns: + A binary quadratic model with ground states corresponding to a + matching. The variables of the BQM are the edges of ``graph`` as frozensets. + The BQM's ground state energy is 0 by construction. + The energy of the first excited state is 1. + + """ + nodes, edges = graph + hood = _neighborhood(edges) + + bqm = dimod.BinaryQuadraticModel.empty('BINARY') + + # add the edges of `graph` as variables + for edge in edges: + bqm.add_variable(frozenset(edge), 0) + + for node in nodes: + for edge0, edge1 in itertools.combinations(hood[node], 2): + u = frozenset(edge0) + v = frozenset(edge1) + bqm.add_interaction(u, v, 1) + + return bqm + + +def _neighborhood(edges: list[tuple[Any, Any]]) -> dict[Any, list[tuple[Any, Any]]]: + # return neighborhood dict from the list of edges + hood = collections.defaultdict(list) + for edge in edges: + for node in edge: + hood[node].append(edge) + return hood + + +@graph_argument('graph') +def maximal_matching(graph: GraphLike, + lagrange: float | None = None, + ) -> dimod.BinaryQuadraticModel: + """Find a binary quadratic model for the graph's maximal matchings. + + A matching is a subset of edges in which no node occurs more than + once. A maximal matching is one in which no edges from ``graph`` can be + added without violating the matching rule. + This function returns a binary quadratic model (BQM) with ground + states corresponding to the possible maximal matchings of ``graph`. + + Finding maximal matchings can be done in polynomial time, so finding + maximal matching with BQMs is generally inefficient. + This BQM may be useful when combined with other constraints and objectives. + + Args: + graph: + The graph on which to find a maximal matching. Either an integer + ``n``, interpreted as a complete graph of size ``n``, a nodes/edges + pair, a list of edges or a NetworkX graph. + + lagrange: + The Lagrange multiplier for the matching constraint. Should be + positive and greater than ``max_degree - 2``. + Defaults to :math:`1.25 * (max_degree - 2)`. + + Returns: + A binary quadratic model with ground states corresponding to a maximal + matching. The variables of the BQM are the edges of ``graph`` as + frozensets. The BQM's ground state energy is 0 by construction. + + """ + nodes, edges = graph + hood = _neighborhood(edges) + + bqm = matching(graph) + + if lagrange is None: + max_degree = len(max(hood.values(), key=len, default=[])) + lagrange = max(1.25 * (max_degree - 2), 1) + + bqm.scale(lagrange) + + for node0, node1 in edges: + # (1 - y_v - y_u + y_v*y_u) <- see paper + + bqm.offset += 1 + + for edge in hood[node0]: + bqm.linear[frozenset(edge)] -= 1 + + for edge in hood[node1]: + bqm.linear[frozenset(edge)] -= 1 + + for edge0 in hood[node0]: + u = frozenset(edge0) + for edge1 in hood[node1]: + v = frozenset(edge1) + if u == v: + bqm.linear[u] += 1 + else: + bqm.add_interaction(u, v, 1) + + return bqm + + +@graph_argument('graph') +def min_maximal_matching(graph: GraphLike, + maximal_lagrange: float = 2, + matching_lagrange: float | None = None + ) -> dimod.BinaryQuadraticModel: + """Find a binary quadratic model for the graph's minimum maximal matchings. + + A matching is a subset of edges in which no node occurs more than + once. A maximal matching is one in which no edges from ``graph`` can be + added without violating the matching rule. A minimum maximal matching + is a maximal matching that contains the smallest possible number of edges. + This function returns a binary quadratic model (BQM) with ground + states corresponding to the possible maximal matchings of ``graph``. + + Args: + graph: + The graph on which to find a minimum maximal matching. Either an + integer ``n``, interpreted as a complete graph of size ``n``, a + nodes/edges pair, a list of edges or a NetworkX graph. + + maximal_lagrange: + The Lagrange multiplier for the maximal constraint. Should be + greater than 1. Defaults to 2. + + matching_lagrange: + The Lagrange multiplier for the matching constraint. Should be + positive and greater than ``maximal_lagrange * max_degree - 2``. + Defaults to ``1.25 * (maximal_lagrange * max_degree - 2)``. + + Returns: + A binary quadratic model with ground states corresponding to a + minimum maximal matching. The variables of the BQM are the edges + of ``graph`` as frozensets. + + """ + + if matching_lagrange is not None: + # we're going to scale the bqm by maximal_matching so undo that + # for maximal_lagrange + matching_lagrange /= maximal_lagrange + + bqm = maximal_matching(graph, lagrange=matching_lagrange) + bqm.scale(maximal_lagrange) + + for v in bqm.variables: + bqm.linear[v] += 1 + + return bqm diff --git a/dimod/generators/partition.py b/dimod/generators/partition.py new file mode 100644 index 000000000..3f74c7b9c --- /dev/null +++ b/dimod/generators/partition.py @@ -0,0 +1,94 @@ +# Copyright 2026 D-Wave +# +# 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 itertools +import math + +import dimod +from dimod.typing import GraphLike +from dimod.decorators import graph_argument + +__all__ = ["graph_partition", + ] + + +@graph_argument('graph', as_networkx=True) +def graph_partition(graph: GraphLike, + num_partitions: int, + ) -> dimod.ConstrainedQuadraticModel: + """Find a constrained quadratic model for the graph's partitions. + + Defines a CQM with ground states corresponding to a balanced k-partition + of ``graph``. A k-partition is a collection of k subsets of the vertices + of ``graph`` such that each vertex is in exactly one subset, and the number + of edges between vertices in different subsets is as small as possible. + If ``graph`` is a weighted graph, the sum of weights over those edges are + minimized. + + Args: + graph: + The graph to partition. Either an integer ``n``, interpreted as a + complete graph of size ``n``, a nodes/edges pair, a list of edges or + a NetworkX graph. When NetworkX graph is provided, optional edge + weights can be provided in the ``weight`` attribute. + + num_partitions: + The number of subsets in the desired partition. + + Returns: + A constrained quadratic model with ground states corresponding to a + partition problem. The nodes of ``graph`` are discrete logical variables + of the CQM, where the cases are the different partitions the node + can be assigned to. The objective is given as the number of edges + connecting nodes in different partitions. + + """ + partition_size = graph.number_of_nodes() / num_partitions + partitions = range(num_partitions) + cqm = dimod.ConstrainedQuadraticModel() + + # Variables will be added using the discrete method in CQM + x = {vk: dimod.Binary(vk) for vk in itertools.product(graph.nodes, partitions)} + + for v in graph.nodes: + cqm.add_discrete(((v, k) for k in partitions), label=v) + + if not math.isclose(partition_size, int(partition_size)): + # if number of nodes don't divide into num_partitions, + # accept partitions of size ceil() or floor() + floor, ceil = int(partition_size), int(partition_size+1) + for k in partitions: + cqm.add_constraint( + dimod.quicksum((x[u, k] for u in graph.nodes)) >= floor, + label=f'equal_partition_low_{k}') + cqm.add_constraint( + dimod.quicksum((x[u, k] for u in graph.nodes)) <= ceil, + label=f'equal_partition_high_{k}') + else: + # each partition must have partition_size elements + for k in partitions: + cqm.add_constraint( + dimod.quicksum((x[u, k] for u in graph.nodes)) == int(partition_size), + label=f'equal_partition_{k}') + + cuts = 0 + for (u, v, d) in graph.edges(data=True): + for k in partitions: + w = d.get('weight',1) + cuts += w * x[u,k] * x[v,k] + + if cuts: + cqm.set_objective(-cuts) + + return cqm diff --git a/dimod/generators/social.py b/dimod/generators/social.py new file mode 100644 index 000000000..9865b9db4 --- /dev/null +++ b/dimod/generators/social.py @@ -0,0 +1,77 @@ +# Copyright 2026 D-Wave +# +# 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 dimod + +__all__ = ["structural_imbalance", + ] + + +def structural_imbalance(graph: 'nx.Graph') -> dimod.BinaryQuadraticModel: + """Construct a binary quadratic model (BQM) to calculate the structural + imbalance of a signed social network. + + A signed social network graph is a graph whose signed edges represent + friendly/hostile interactions between nodes. A signed social network is + considered balanced if it can be cleanly divided into two factions, where + all relations within a faction are friendly, and all relations between + factions are hostile. The measure of imbalance or frustration is the minimum + number of edges that violate this rule. + + Args: + graph: + A social graph (in a NetworkX graph) on which each edge has a 'sign' + attribute with a numeric value. + + Returns: + A binary quadratic model. Each variable in the model represents a node + in the signed social network. The solution that minimized the BQM will + assign each variable a value, either -1 or 1. This bi-coloring defines + the factions. + + Raises: + ValueError: If any edge does not have a 'sign' attribute. + + Examples: + >>> import dimod + >>> import networkx as nx + ... + >>> S = nx.Graph() + >>> S.add_edge('Alice', 'Bob', sign=1) # Alice and Bob are friendly + >>> S.add_edge('Alice', 'Eve', sign=-1) # Alice and Eve are hostile + >>> S.add_edge('Bob', 'Eve', sign=-1) # Bob and Eve are hostile + ... + >>> bqm = dimod.generators.structural_imbalance(S) + >>> bqm.linear # doctest: +SKIP + {'Alice': 0.0, 'Bob': 0.0, 'Eve': 0.0} + >>> bqm.quadratic # doctest: +SKIP + {('Alice', 'Bob'): -1.0, ('Alice', 'Eve'): 1.0, ('Bob', 'Eve'): 1.0} + + """ + if not hasattr(graph, 'nodes') or not hasattr(graph, 'edges'): + raise ValueError("Signed social network graph in NetworkX format required") + + bqm = dimod.BinaryQuadraticModel.empty('SPIN') + + for v in graph: + bqm.add_linear(v, 0.0) + + for u, v, data in graph.edges(data=True): + try: + bqm.add_quadratic(u, v, -1. * data['sign']) + except KeyError: + raise ValueError("graph should be a signed social graph, " + "each edge should have a 'sign' attr") + + return bqm diff --git a/dimod/generators/tsp.py b/dimod/generators/tsp.py new file mode 100644 index 000000000..fabe853ed --- /dev/null +++ b/dimod/generators/tsp.py @@ -0,0 +1,134 @@ +# Copyright 2026 D-Wave +# +# 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 itertools + +import dimod + +__all__ = ["traveling_salesperson", + "traveling_salesman", + ] + + +def traveling_salesperson(graph: 'nx.Graph', + lagrange: float | None = None, + weight: str = 'weight', + missing_edge_weight: float | None = None + ) -> dimod.BinaryQuadraticModel: + """Return a binary quadratic model (BQM) with ground states corresponding + to a minimum TSP route. + + If :math:`|graph|` is the number of nodes in the graph, the resulting qubo + will have: + + * :math:`|graph|^2` variables/nodes + * :math:`2 |graph|^2 (|graph| - 1)` interactions/edges + + Args: + graph: + A complete graph in which each edge has an attribute giving its + weight, given as a NetworkX graph. + + lagrange: + Lagrange parameter to weight constraints (no edges within set) + versus objective (largest set possible). + + weight: + The name of the edge attribute containing the weight, defaults to + "weight". + + missing_edge_weight: + For bi-directional graphs, the weight given to missing edges. + If None is given (the default), missing edges will be set to + the sum of all weights. + + Returns: + A binary quadratic model (BQM) with ground states corresponding to a + minimum traveling salesperson route. The BQM variables are labelled + ``(c, t)`` where ``c`` is a node in ``graph`` and ``t`` is the time + index. For instance, if ``('a', 0)`` is 1 in the ground state, that means + the node 'a' is visited first. + + """ + if not hasattr(graph, 'nodes') or not hasattr(graph, 'edges'): + raise ValueError("A NetworkX graph with weights data required") + + N = graph.number_of_nodes() + + if lagrange is None: + # If no lagrange parameter provided, set to 'average' tour length. + # Usually a good estimate for a lagrange parameter is between 75-150% + # of the objective function value, so we come up with an estimate for + # tour length and use that. + if graph.number_of_edges() > 0: + lagrange = (graph.size(weight=weight) + * graph.number_of_nodes() + / graph.number_of_edges()) + else: + lagrange = 2 + + # calculate default missing_edge_weight if required + if missing_edge_weight is None: + # networkx method to calculate sum of all weights + missing_edge_weight = graph.size(weight=weight) + + # some input checking + if N in (1, 2): + msg = "graph must have at least 3 nodes or be empty" + raise ValueError(msg) + + # Creating the BQM + bqm = dimod.BinaryQuadraticModel.empty(dimod.BINARY) + + # Constraint that each row has exactly one 1 + for node in graph: + for pos_1 in range(N): + bqm.add_linear((node, pos_1), -lagrange) + for pos_2 in range(pos_1+1, N): + bqm.add_quadratic((node, pos_1), (node, pos_2), 2.0*lagrange) + + # Constraint that each col has exactly one 1 + for pos in range(N): + for node_1 in graph: + bqm.add_linear((node_1, pos), -lagrange) + for node_2 in set(graph)-{node_1}: + # quadratic coefficient is 2*lagrange, but we are placing this value + # above *and* below the diagonal, so we put half in each position. + bqm.add_quadratic((node_1, pos), (node_2, pos), lagrange) + + # Objective that minimizes distance + for u, v in itertools.combinations(graph.nodes, 2): + for pos in range(N): + nextpos = (pos + 1) % N + + # going from u -> v + try: + value = graph[u][v][weight] + except KeyError: + value = missing_edge_weight + + bqm.add_quadratic((u, pos), (v, nextpos), value) + + # going from v -> u + try: + value = graph[v][u][weight] + except KeyError: + value = missing_edge_weight + + bqm.add_quadratic((v, pos), (u, nextpos), value) + + return bqm + + +traveling_salesman = traveling_salesperson diff --git a/dimod/typing.py b/dimod/typing.py index 28f4d73ab..70bd715ed 100644 --- a/dimod/typing.py +++ b/dimod/typing.py @@ -26,14 +26,14 @@ from numpy.typing import ArrayLike, DTypeLike except ImportError: # support numpy < 1.20 - ArrayLike = collections.abc.Sequence - DTypeLike = typing.Any + ArrayLike: typing.TypeAlias = collections.abc.Sequence + DTypeLike: typing.TypeAlias = typing.Any try: from numpy.typing import NDArray except ImportError: # support numpy < 1.21 - NDArray = collections.abc.Sequence + NDArray: typing.TypeAlias = collections.abc.Sequence __all__ = ['Bias', @@ -47,7 +47,7 @@ # use float for python types, https://www.python.org/dev/peps/pep-0484/#the-numeric-tower # exclude np.complexfloating from numpy types -Bias = typing.Union[float, np.floating, np.integer] +Bias: typing.TypeAlias = typing.Union[float, np.floating, np.integer] """A :obj:`~typing.Union` representing objects that can be used as biases. This includes: @@ -62,7 +62,7 @@ # a typing class is a pain accross our supported Python versions. In the future # we should handle it -Variable = collections.abc.Hashable +Variable: typing.TypeAlias = collections.abc.Hashable """Objects that can be used as variable labels. .. note:: @@ -74,7 +74,7 @@ """ -GraphLike = typing.Union[ +GraphLike: typing.TypeAlias = typing.Union[ int, # number of nodes tuple[ collections.abc.Collection[Variable], @@ -98,9 +98,9 @@ except ImportError: pass else: - GraphLike = typing.Union[GraphLike, nx.Graph] + GraphLike: typing.TypeAlias = typing.Union[GraphLike, nx.Graph] -Polynomial = collections.abc.Mapping[collections.abc.Sequence[Variable], Bias] +Polynomial: typing.TypeAlias = collections.abc.Mapping[collections.abc.Sequence[Variable], Bias] """A polynomial represented by a mapping.""" @@ -131,7 +131,7 @@ class DQMVectors(typing.NamedTuple): offset: Bias -SampleLike = typing.Union[ +SampleLike: typing.TypeAlias = typing.Union[ collections.abc.Sequence[Bias], collections.abc.Mapping[Variable, Bias], tuple[collections.abc.Sequence[Bias], collections.abc.Sequence[Variable]], @@ -155,7 +155,7 @@ class DQMVectors(typing.NamedTuple): """ -SamplesLike = typing.Union[ +SamplesLike: typing.TypeAlias = typing.Union[ SampleLike, collections.abc.Sequence[collections.abc.Sequence[Bias]], # 2d array tuple[collections.abc.Sequence[collections.abc.Sequence[Bias]], collections.abc.Sequence[Variable]], diff --git a/dimod/vartypes.py b/dimod/vartypes.py index cfddc3aa5..bb8d27e8c 100644 --- a/dimod/vartypes.py +++ b/dimod/vartypes.py @@ -146,7 +146,7 @@ def __call__(self, v: typing.Optional[dimod.typing.Variable] = None) -> Vartype: # There are other types we allow (e.g. frozenset((-1, +1)))) but they are very # rarely used over the years so IMO more succinct typing is more useful. # Also, Literal doesn't accept frozensets in its list so we'd have to do a Union. -VartypeLike = typing.Literal[ +VartypeLike: typing.TypeAlias = typing.Literal[ Vartype.SPIN, "SPIN", Vartype.BINARY, "BINARY", Vartype.INTEGER, "INTEGER", diff --git a/docs/generators.rst b/docs/generators.rst index 621d84e70..751995b45 100644 --- a/docs/generators.rst +++ b/docs/generators.rst @@ -32,6 +32,7 @@ Constraints binary_encoding combinations fulladder_gate + graph_partition halfadder_gate multiplication_circuit or_gate @@ -47,9 +48,13 @@ Optimization coordinated_multipoint independent_set knapsack + matching + maximal_matching maximum_independent_set maximum_weight_independent_set mimo + min_maximal_matching + min_vertex_coloring multi_knapsack quadratic_assignment quadratic_knapsack @@ -57,6 +62,9 @@ Optimization random_bin_packing random_knapsack random_multi_knapsack + structural_imbalance + traveling_salesperson + vertex_coloring Random ====== @@ -67,6 +75,7 @@ Random doped gnm_random_bqm gnp_random_bqm + markov_network randint random_2in4sat random_bin_packing diff --git a/docs/requirements.txt b/docs/requirements.txt index fd436e07c..e31439b7c 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -4,4 +4,4 @@ sphinx==7.3.7 breathe notebook==6.4.11 -networkx==2.5 \ No newline at end of file +networkx diff --git a/pyproject.toml b/pyproject.toml index e053eeb69..2092c0004 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,11 @@ exclude_lines = [ [tool.cibuildwheel] build-verbosity = "1" skip = "pp* *musllinux*" -before-test = "pip install -r {project}/tests/requirements.txt" +before-test = [ + # Install local dimod first to make sure test dependencies use the version under test + "pip install {package}", + "pip install -r {project}/tests/requirements.txt", +] test-command = "python -m unittest discover {project}/tests/" [tool.cibuildwheel.linux] diff --git a/releasenotes/notes/add-generators-from-dnx-db2b3873ef912599.yaml b/releasenotes/notes/add-generators-from-dnx-db2b3873ef912599.yaml new file mode 100644 index 000000000..f5e974371 --- /dev/null +++ b/releasenotes/notes/add-generators-from-dnx-db2b3873ef912599.yaml @@ -0,0 +1,28 @@ +--- +prelude: > + Binary and constrained quadratic model generators that used to reside in + ``dwave-networkx`` (now ``dwave-graphs``) have been moved to + ``dimod.generators``. +features: + - | + Add generators for binary quadratic models with ground states corresponding + to a vertex coloring (``dimod.generators.vertex_coloring``) and a minimum + vertex coloring (``dimod.generators.min_vertex_coloring``). + - | + Add a binary quadratic model generator for a Markov network + (``dimod.generators.markov_network``). + - | + Add generators binary quadratic models with ground states corresponding to + a graph matching (``dimod.generators.matching``), graphs's maximal matching + (``dimod.generators.maximal_matching``) and graph's minimum maximal matching + (``dimod.generators.min_maximal_matching``). + - | + Add a generator for a constrained quadratic model with ground states + corresponding to a balanced k-partition of graph + (``dimod.generators.graph_partition``). + - | + Add a binary quadratic model generator to calculate the structural imbalance + of a signed social network (``dimod.generators.structural_imbalance``). + - | + Add a generator for a binary quadratic model with ground states corresponding + to a minimum TSP route (``dimod.generators.traveling_salesperson``). diff --git a/tests/requirements.txt b/tests/requirements.txt index 5505b0629..fd0528377 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -4,3 +4,6 @@ pandas~=2.3.0; python_version < '3.14' pandas~=3.0.0; python_version >= '3.14' networkx==2.6.3 pymongo~=4.17.0 + +# TODO: update to dwave-graphs once released +dwave_networkx diff --git a/tests/test_decorators.py b/tests/test_decorators.py index 9d649e5f1..745552fc9 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -16,19 +16,12 @@ import typing import unittest import itertools - from collections.abc import Sequence from operator import itemgetter -try: - import networkx as nx -except ImportError: - _networkx = False -else: - _networkx = True +import networkx as nx import dimod - from dimod.vartypes import SPIN, BINARY from dimod.decorators import vartype_argument, graph_argument @@ -183,7 +176,6 @@ def f(vartype='SPIN'): class TestGraphArgument(unittest.TestCase): - @unittest.skipUnless(_networkx, "no networkx installed") def test_networkx_graph(self): @graph_argument('G') def f(G): @@ -311,7 +303,6 @@ def test_other_kwarg(self): def f(G): pass - @unittest.skipUnless(_networkx, "no networkx installed") def test_as_networkx_graph(self): from networkx.utils import graphs_equal diff --git a/tests/test_generators.py b/tests/test_generators.py index 94f5be40e..128eef428 100644 --- a/tests/test_generators.py +++ b/tests/test_generators.py @@ -12,20 +12,25 @@ # See the License for the specific language governing permissions and # limitations under the License. -import unittest -import unittest.mock - -import dimod import itertools +import math +import operator +import unittest +import networkx as nx import numpy as np +import parameterized + +import dimod +# TODO: update to dwave-graphs once released try: - import networkx as nx + import dwave_networkx as dnx except ImportError: - _networkx = False + _dnx = False else: - _networkx = True + _dnx = True + class TestRandomGNMRandomBQM(unittest.TestCase): def test_bias_generator(self): @@ -238,7 +243,6 @@ def test_singletile(self): self.assertIn(j, bqm.adj[i]) self.assertIn(bqm.adj[i][j], (-1, 1)) - @unittest.skipUnless(_networkx, "no networkx installed") def test_multitile(self): bqm = dimod.generators.chimera_anticluster(2, multiplier=4) @@ -312,7 +316,6 @@ def test_deprecation(self): bqm = dimod.generators.chimera_anticluster(0, cls=6) -@unittest.skipUnless(_networkx, "no networkx installed") class TestFCL(unittest.TestCase): def setUp(self): @@ -855,7 +858,6 @@ def test_edges(self): self.assertEqual(bqm.offset, 0) self.assertIs(bqm.vartype, dimod.BINARY) - @unittest.skipUnless(_networkx, "no networkx installed") def test_edges_networkx(self): G = nx.complete_graph(3) bqm = dimod.generators.independent_set(G.edges) @@ -871,7 +873,6 @@ def test_edges_and_nodes(self): self.assertEqual(bqm.offset, 0) self.assertIs(bqm.vartype, dimod.BINARY) - @unittest.skipUnless(_networkx, "no networkx installed") def test_edges_and_nodes_networkx(self): G = nx.complete_graph(3) G.add_node(3) @@ -885,7 +886,6 @@ def test_empty(self): self.assertEqual(dimod.generators.independent_set([]).shape, (0, 0)) self.assertEqual(dimod.generators.independent_set([], []).shape, (0, 0)) - @unittest.skipUnless(_networkx, "no networkx installed") def test_empty_networkx(self): G = nx.Graph() self.assertEqual(dimod.generators.independent_set(G.edges).shape, (0, 0)) @@ -920,7 +920,6 @@ def test_edges(self): self.assertEqual(bqm.offset, 0) self.assertIs(bqm.vartype, dimod.BINARY) - @unittest.skipUnless(_networkx, "no networkx installed") def test_edges_networkx(self): G = nx.complete_graph(3) bqm = dimod.generators.maximum_independent_set(G.edges) @@ -936,7 +935,6 @@ def test_edges_and_nodes(self): self.assertEqual(bqm.offset, 0) self.assertIs(bqm.vartype, dimod.BINARY) - @unittest.skipUnless(_networkx, "no networkx installed") def test_edges_and_nodes_networkx(self): G = nx.complete_graph(3) G.add_node(3) @@ -950,7 +948,6 @@ def test_empty(self): self.assertEqual(dimod.generators.maximum_independent_set([]).shape, (0, 0)) self.assertEqual(dimod.generators.maximum_independent_set([], []).shape, (0, 0)) - @unittest.skipUnless(_networkx, "no networkx installed") def test_empty_networkx(self): G = nx.Graph() self.assertEqual(dimod.generators.maximum_independent_set(G.edges).shape, (0, 0)) @@ -1002,7 +999,6 @@ def test_functional(self): configs = {tuple(sample[v] for v in range(3)) for sample in sampleset.lowest().samples()} self.assertEqual(configs, {(0, 1, 0), (1, 0, 1)}) - @unittest.skipUnless(_networkx, "no networkx installed") def test_functional_networkx(self): G = nx.complete_graph(3) G.add_nodes_from([0, 2], weight=.5) @@ -1019,7 +1015,6 @@ def test_empty(self): self.assertEqual(dimod.generators.maximum_weight_independent_set([]).shape, (0, 0)) self.assertEqual(dimod.generators.maximum_weight_independent_set([], []).shape, (0, 0)) - @unittest.skipUnless(_networkx, "no networkx installed") def test_empty_networkx(self): G = nx.Graph() self.assertEqual(dimod.generators.maximum_weight_independent_set(G.edges).shape, (0, 0)) @@ -1198,7 +1193,7 @@ def test_constraints_squares(self): else: self.assertEqual(term, 24) -@unittest.skipUnless(_networkx, "no networkx installed") + class TestMIMO(unittest.TestCase): def setUp(self): @@ -1875,3 +1870,527 @@ def test_feasible(self): x = {f'x_{i}_{j}': 1 if i==j else 0 for i in range(num_locations)} lhs = cqm.constraints[f'facility_constraint_{j}'].lhs.energy(x) self.assertEqual(lhs, 0) + + +class TestMinVertexColoring(unittest.TestCase): + def test_chromatic_number(self): + G = nx.cycle_graph('abcd') + + # when the chromatic number is fixed this is exactly vertex_color + self.assertEqual( + dimod.generators.coloring.min_vertex_coloring(G, chromatic_lb=2, chromatic_ub=2), + dimod.generators.coloring.vertex_coloring(G, range(2)) + ) + + @parameterized.parameterized.expand([ + (nx.path_graph(5), ), + (nx.cycle_graph(5), ), + (nx.complete_graph(3), ), + ]) + def test_smoke(self, G): + dimod.generators.min_vertex_coloring(G) + + +class TestVertexColoring(unittest.TestCase): + def test_single_node(self): + G = nx.Graph() + G.add_node('a') + + # a single color + bqm = dimod.generators.coloring.vertex_coloring(G, ['red']) + + self.assertEqual(bqm, dimod.BQM.from_qubo({(('a', 'red'), ('a', 'red')): -1})) + + def test_4cycle(self): + G = nx.cycle_graph('abcd') + + bqm = dimod.generators.coloring.vertex_coloring(G, range(2)) + + sampleset = dimod.ExactSolver().sample(bqm) + + # check that the ground state is a valid coloring + ground_energy = sampleset.first.energy + + colorings = [] + for sample, en in sampleset.data(['sample', 'energy']): + if en > ground_energy: + break + + coloring = {} + for (v, c), val in sample.items(): + if val: + coloring[v] = c + + is_vertex_coloring = all(coloring[u] != coloring[v] for u, v in G.edges) + self.assertTrue(is_vertex_coloring) + + colorings.append(coloring) + + # there are two valid colorings + self.assertEqual(len(colorings), 2) + + self.assertEqual(ground_energy, -len(G)) + + def test_num_variables(self): + G = nx.Graph() + G.add_nodes_from(range(15)) + + bqm = dimod.generators.coloring.vertex_coloring(G, range(7)) + self.assertEqual(len(bqm.quadratic), len(G)*7*(7-1)/2) + + # add one edge + G.add_edge(0, 1) + bqm = dimod.generators.coloring.vertex_coloring(G, range(7)) + self.assertEqual(len(bqm.quadratic), len(G)*7*(7-1)/2 + 7) + + def test_docstring_stats(self): + # get a complex-ish graph + G = nx.karate_club_graph() + + colors = range(10) + + bqm = dimod.generators.coloring.vertex_coloring(G, colors) + + self.assertEqual(len(bqm), len(G)*len(colors)) + self.assertEqual(len(bqm.quadratic), len(G)*len(colors)*(len(colors)-1)/2 + + len(G.edges)*len(colors)) + + +@unittest.skipUnless(_dnx, "no dwave-graphs installed") +class TestMarkovNetwork(unittest.TestCase): + def test_input_validation(self): + with self.assertRaises(ValueError): + dimod.generators.markov.markov_network(dict()) + + def test_one_node(self): + potentials = {'a': {(0,): 1.2, (1,): .4}} + + bqm = dimod.generators.markov.markov_network(dnx.markov_network(potentials)) + + for edge, potential in potentials.items(): + for config, energy in potential.items(): + sample = dict(zip(edge, config)) + self.assertAlmostEqual(bqm.energy(sample), energy) + + def test_one_edge(self): + potentials = {'ab': {(0, 0): 1.2, (1, 0): .4, + (0, 1): 1.3, (1, 1): -4}} + + bqm = dimod.generators.markov.markov_network(dnx.markov_network(potentials)) + + for edge, potential in potentials.items(): + for config, energy in potential.items(): + sample = dict(zip(edge, config)) + self.assertAlmostEqual(bqm.energy(sample), energy) + + def test_typical(self): + potentials = {'a': {(0,): 1.5, (1,): -.5}, + 'ab': {(0, 0): 1.2, (1, 0): .4, + (0, 1): 1.3, (1, 1): -4}, + 'bc': {(0, 0): 1.7, (1, 0): .4, + (0, 1): -1, (1, 1): -4}, + 'd': {(0,): -.5, (1,): 1.6}} + + bqm = dimod.generators.markov.markov_network(dnx.markov_network(potentials)) + + samples = dimod.ExactSolver().sample(bqm) + + for sample, energy in samples.data(['sample', 'energy']): + + en = 0 + for interaction, potential in potentials.items(): + config = tuple(sample[v] for v in interaction) + en += potential[config] + + self.assertAlmostEqual(en, energy) + + +@parameterized.parameterized_class( + 'graph', + [[nx.Graph()], + [nx.path_graph(10)], + [nx.complete_graph(4)], + [nx.Graph([(0, 1), (0, 2), (1, 2), (1, 3), (2, 4), (3, 4), (3, 5)])], + [nx.Graph([(0, 1), (0, 2), (1, 2), (1, 3), (1, 4), (1, 5)])], + [nx.Graph([(0, 1), (0, 2), (1, 2), (1, 3), (2, 4), (3, 4)])], + [nx.Graph({0: [], 1: [6], 2: [5], 3: [4], 4: [3], 2: [5], 6: [1]})], + ] + ) +class TestMatching(unittest.TestCase): + def test_matching(self): + bqm = dimod.generators.matching(self.graph) + + # the ground states should be exactly the matchings of G + sampleset = dimod.ExactSolver().sample(bqm) + + for sample, energy in sampleset.data(['sample', 'energy']): + edges = [v for v, val in sample.items() if val > 0] + self.assertEqual(nx.is_matching(self.graph, edges), energy == 0) + self.assertTrue(energy == 0 or energy >= 1) + + def test_maximal_matching(self): + bqm = dimod.generators.maximal_matching(self.graph) + + # the ground states should be exactly the maximal matchings of G + sampleset = dimod.ExactSolver().sample(bqm) + + for sample, energy in sampleset.data(['sample', 'energy']): + edges = set(v for v, val in sample.items() if val > 0) + self.assertEqual(nx.is_maximal_matching(self.graph, edges), + energy == 0) + self.assertGreaterEqual(energy, 0) + + def test_min_maximal_matching_bqm(self): + bqm = dimod.generators.min_maximal_matching(self.graph) + + if len(self.graph) == 0: + self.assertEqual(len(bqm.linear), 0) + return + + # the ground states should be exactly the minimum maximal matchings of + # G + sampleset = dimod.ExactSolver().sample(bqm) + + # we'd like to use sampleset.lowest() but it didn't exist in dimod + # 0.8.0 + ground_energy = sampleset.first.energy + cardinalities = set() + for sample, energy in sampleset.data(['sample', 'energy']): + if energy > ground_energy: + continue + edges = set(v for v, val in sample.items() if val > 0) + self.assertTrue(nx.is_maximal_matching(self.graph, edges)) + cardinalities.add(len(edges)) + + # all ground have the same cardinality (or it's empty) + self.assertEqual(len(cardinalities), 1) + cardinality, = cardinalities + + # everything that's not ground has a higher energy + for sample, energy in sampleset.data(['sample', 'energy']): + edges = set(v for v, val in sample.items() if val > 0) + if energy != sampleset.first.energy: + if nx.is_maximal_matching(self.graph, edges): + self.assertGreater(len(edges), cardinality) + + +class TestPartitioning(unittest.TestCase): + def get_partitions(self, cqm): + # copied from: :meth:`dwave.graphs.algorithms.partition.partition`. + + sampler = dimod.ExactCQMSolver() + response = sampler.sample_cqm(cqm) + possible_partitions = response.filter(lambda d: d.is_feasible) + + if not possible_partitions: + return {} + + indicators = (key for key, value in possible_partitions.first.sample.items() if math.isclose(value, 1.)) + node_partition = {key[0]: key[1] for key in indicators} + return node_partition + + def test_edge_cases(self): + G = nx.Graph() + cqm = dimod.generators.partition.graph_partition(G, num_partitions=2) + node_partitions = self.get_partitions(cqm) + self.assertTrue(node_partitions == {}) + + def test_typical_cases(self): + G = nx.complete_graph(8) + cqm = dimod.generators.partition.graph_partition(G, num_partitions=4) + node_partitions = self.get_partitions(cqm) + for i in range(4): + self.assertEqual(sum(x == i for x in node_partitions.values()), 2) # 4 equally sized subsets + + cqm = dimod.generators.partition.graph_partition(8, num_partitions=4) + node_partitions2 = self.get_partitions(cqm) + self.assertEqual(node_partitions, node_partitions2) + + G = nx.complete_graph(10) + cqm = dimod.generators.partition.graph_partition(G, num_partitions=2) + node_partitions = self.get_partitions(cqm) + self.assertEqual(sum(x == 0 for x in node_partitions.values()), 5) # half of the nodes in subset '0' + + nx.set_edge_attributes(G, 1, 'weight') + cqm = dimod.generators.partition.graph_partition(G, num_partitions=2) + node_partitions = self.get_partitions(cqm) + self.assertEqual(sum(x == 0 for x in node_partitions.values()), 5) # half of the nodes in subset '0' + + G = nx.Graph() + G.add_edges_from([(0, 1), (0, 2), (1, 2), (1, 3), (3, 4), (2, 4)]) + cqm = dimod.generators.partition.graph_partition(G, num_partitions=2) + node_partitions = self.get_partitions(cqm) + self.assertIn(sum(x == 0 for x in node_partitions.values()), (2, 3)) # either 2 or 3 nodes in subset '0' (ditto '1') + + G = nx.Graph() + G.add_edges_from([(0, 1), (0, 2), (1, 2), (2, 3), (3, 4), (3, 5), (4, 5)]) + cqm = dimod.generators.partition.graph_partition(G, num_partitions=2) + node_partitions = self.get_partitions(cqm) + self.assertTrue(node_partitions[0] == node_partitions[1] == node_partitions[2]) + self.assertTrue(node_partitions[3] == node_partitions[4] == node_partitions[5]) + + nx.set_edge_attributes(G, values=1, name='weight') + nx.set_edge_attributes(G, values={(2, 3): 100}, name='weight') + cqm = dimod.generators.partition.graph_partition(G, num_partitions=2) + node_partitions = self.get_partitions(cqm) + self.assertEqual(node_partitions[2], node_partitions[3]) # weight edges are respected + + +class TestSocial(unittest.TestCase): + def structural_imbalance(self, graph): + # see: :meth:`dwave.graphs.social.structural_imbalance`. + + bqm = dimod.generators.social.structural_imbalance(graph) + + # use the exact solver to find low energy states + sampler = dimod.ExactSolver() + response = sampler.sample(bqm) + + # we want the lowest energy sample + sample = response.first.sample + + # spins determine the color + colors = {v: (spin + 1) // 2 for v, spin in sample.items()} + return colors + + def check_bicolor(self, colors): + # colors should be ints and either 0 or 1 + for c in colors.values(): + self.assertIn(c, (0, 1)) + + def all_eq(self, l): + return all(itertools.starmap(operator.eq, zip(l, l[1:]))) + + def test_structural_imbalance_basic(self): + blueteam = ['Alice', 'Bob', 'Carol'] + redteam0 = ['Eve'] + redteam1 = ['Mallory', 'Trudy'] + + S = nx.Graph() + for p0, p1 in itertools.combinations(blueteam, 2): + S.add_edge(p0, p1, sign=1) + + S.add_edge(*redteam1, sign=1) + for p0 in blueteam: + for p1 in redteam0: + S.add_edge(p0, p1, sign=-1) + for p1 in redteam1: + S.add_edge(p0, p1, sign=-1) + + colors = self.structural_imbalance(S) + self.check_bicolor(colors) + + # blue team is one color, and red team another + self.assertTrue(self.all_eq([colors[n] for n in blueteam])) + self.assertTrue(self.all_eq([colors[n] for n in (redteam0 + redteam1)])) + self.assertNotEqual(colors[blueteam[0]], colors[redteam0[0]]) + + greenteam = ['Ted'] + for p0 in set(S.nodes): + for p1 in greenteam: + S.add_edge(p0, p1, sign=1) + + colors = self.structural_imbalance(S) + self.check_bicolor(colors) + + def test_non_nx_graph(self): + with self.assertRaises(ValueError): + dimod.generators.social.structural_imbalance({'a': 1}) + + def test_invalid_graph(self): + S = nx.Graph() + S.add_edge('Alice', 'Bob') + + with self.assertRaises(ValueError): + dimod.generators.social.structural_imbalance(S) + + def test_frustrated_hostile_edge(self): + S = nx.florentine_families_graph() + nx.set_edge_attributes(S, -1, 'sign') + + colors = self.structural_imbalance(S) + self.check_bicolor(colors) + + +class TestTSP(unittest.TestCase): + def check_routes(self, bqm, min_routes, sampleset): + ground_energy = sampleset.first.energy + + # all possible routes are equally good + for route in min_routes: + sample = {v: 0 for v in bqm.variables} + for idx, city in enumerate(route): + sample[(city, idx)] = 1 + self.assertAlmostEqual(bqm.energy(sample), ground_energy) + + # all min-energy solutions are valid routes + ground_count = 0 + for sample, energy in sampleset.data(['sample', 'energy']): + if abs(energy - ground_energy) > .001: + break + ground_count += 1 + + self.assertEqual(ground_count, len(min_routes)) + + def test_empty(self): + bqm = dimod.generators.tsp.traveling_salesperson(nx.Graph()) + self.assertEqual(bqm.to_qubo(), ({}, 0)) + + def test_k3(self): + # 3cycle so all paths are equally good + G = nx.Graph() + G.add_weighted_edges_from([('a', 'b', 0.5), + ('b', 'c', 1.0), + ('a', 'c', 2.0)]) + + bqm = dimod.generators.tsp.traveling_salesperson(G, lagrange=10) + + # all routes are min weight + min_routes = list(itertools.permutations(G.nodes)) + + # get the min energy of the qubo + sampleset = dimod.ExactSolver().sample(bqm) + + self.check_routes(bqm, min_routes, sampleset) + + def test_k3_bidirectional(self): + G = nx.DiGraph() + G.add_weighted_edges_from([('a', 'b', 0.5), + ('b', 'a', 0.5), + ('b', 'c', 1.0), + ('c', 'b', 1.0), + ('a', 'c', 2.0), + ('c', 'a', 2.0)]) + + bqm = dimod.generators.tsp.traveling_salesperson(G, lagrange=10) + + # all routes are min weight + min_routes = list(itertools.permutations(G.nodes)) + + # get the min energy of the qubo + sampleset = dimod.ExactSolver().sample(bqm) + + self.check_routes(bqm, min_routes, sampleset) + + def test_graph_missing_edges(self): + G1 = nx.Graph() + G1.add_weighted_edges_from([ + ('a', 'b', 0.5), + ('b', 'c', 1.0), + ('a', 'c', 2.0), + ]) + bqm1 = dimod.generators.tsp.traveling_salesperson(G1, lagrange=10) + + G2 = nx.Graph() + G2.add_weighted_edges_from([ + ('a', 'b', 0.5), + ('a', 'c', 2.0), + ]) + # make sure that missing_edge_weight gets applied correctly + bqm2 = dimod.generators.tsp.traveling_salesperson(G2, lagrange=10, missing_edge_weight=1.0) + + self.assertEqual(bqm1, bqm2) + + def test_digraph_missing_edges(self): + G1 = nx.DiGraph() + G1.add_weighted_edges_from([ + ('a', 'b', 0.5), + ('b', 'a', 0.8), + ('b', 'c', 1.0), + ('c', 'b', 0.7), + ('a', 'c', 2.0), + ('c', 'a', 2.0), + ]) + bqm1 = dimod.generators.tsp.traveling_salesperson(G1, lagrange=10) + + G2 = nx.DiGraph() + G2.add_weighted_edges_from([ + ('a', 'b', 0.5), + ('b', 'a', 0.8), + ('c', 'b', 0.7), + ('a', 'c', 2.0), + ('c', 'a', 2.0), + ]) + + # make sure that missing_edge_weight gets applied correctly + bqm2 = dimod.generators.tsp.traveling_salesperson(G2, lagrange=10, missing_edge_weight=1.0) + + self.assertEqual(bqm1, bqm2) + + def test_k4_equal_weights(self): + # k5 with all equal weights so all paths are equally good + G = nx.Graph() + G.add_weighted_edges_from((u, v, .5) + for u, v in itertools.combinations(range(4), 2)) + + bqm = dimod.generators.tsp.traveling_salesperson(G, lagrange=10) + + # all routes are min weight + min_routes = list(itertools.permutations(G.nodes)) + + # get the min energy of the qubo + sampleset = dimod.ExactSolver().sample(bqm) + + self.check_routes(bqm, min_routes, sampleset) + + def test_k4(self): + # good routes are 0,1,2,3 or 3,2,1,0 (and their rotations) + G = nx.Graph() + G.add_weighted_edges_from([(0, 1, 1), + (1, 2, 1), + (2, 3, 1), + (3, 0, 1), + (0, 2, 2), + (1, 3, 2)]) + + bqm = dimod.generators.tsp.traveling_salesperson(G, lagrange=10) + + # good routes won't have 0<->2 or 1<->3 + min_routes = [(0, 1, 2, 3), + (1, 2, 3, 0), + (2, 3, 0, 1), + (1, 2, 3, 0), + (3, 2, 1, 0), + (2, 1, 0, 3), + (1, 0, 3, 2), + (0, 3, 2, 1)] + + # get the min energy of the qubo + sampleset = dimod.ExactSolver().sample(bqm) + + self.check_routes(bqm, min_routes, sampleset) + + def test_weighted_complete_graph(self): + G = nx.Graph() + G.add_weighted_edges_from({(0, 1, 1), (0, 2, 100), + (0, 3, 1), (1, 2, 1), + (1, 3, 100), (2, 3, 1)}) + + lagrange = 5.0 + + bqm = dimod.generators.tsp.traveling_salesperson(G, lagrange, 'weight') + + N = G.number_of_nodes() + correct_sum = G.size('weight')*2*N-2*N*N*lagrange+2*N*N*(N-1)*lagrange + + actual_sum = sum(bqm.linear.values()) + sum(bqm.quadratic.values()) + + self.assertEqual(correct_sum, actual_sum) + + def test_exceptions(self): + G = nx.Graph([(0, 1)]) + with self.assertRaises(ValueError): + dimod.generators.tsp.traveling_salesperson(G) + + def test_docstring_size(self): + # in the docstring we state the size of the resulting BQM, this checks + # that + for n in range(3, 20): + G = nx.Graph() + G.add_weighted_edges_from((u, v, .5) + for u, v + in itertools.combinations(range(n), 2)) + bqm = dimod.generators.tsp.traveling_salesperson(G) + + self.assertEqual(len(bqm), n**2) + self.assertEqual(len(bqm.quadratic), 2*n*n*(n - 1))