-
Notifications
You must be signed in to change notification settings - Fork 81
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feature/cut variables #548
Changes from all commits
ea09da6
8144431
1571e34
e138312
ea98785
e6d776a
5eb2329
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,281 @@ | ||
# Copyright 2019 D-Wave Systems Inc. | ||
# | ||
# Licensed under the Apache License, Version 2.0 (the "License"); | ||
# you may not use this file except in compliance with the License. | ||
# You may obtain a copy of the License at | ||
# | ||
# http://www.apache.org/licenses/LICENSE-2.0 | ||
# | ||
# Unless required by applicable law or agreed to in writing, software | ||
# distributed under the License is distributed on an "AS IS" BASIS, | ||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
# See the License for the specific language governing permissions and | ||
# limitations under the License. | ||
# | ||
# ============================================================================= | ||
""" | ||
A composite that breaks the problem into sub-problems corresponding to the | ||
biconnected components of the binary quadratic model graph before sending to its child sampler. | ||
""" | ||
|
||
from dimod.sampleset import as_samples | ||
from dimod.core.composite import ComposedSampler | ||
from dimod.sampleset import SampleSet | ||
import dimod | ||
import networkx as nx | ||
import itertools | ||
|
||
|
||
__all__ = ['CutVertexComposite'] | ||
|
||
|
||
class CutVertexComposite(ComposedSampler): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Alex made a good point that the original name CutVariableComposite is more consistent with dimod. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To make things easy to search for, would it be too weird to have an alias like |
||
"""Composite to decompose a problem into biconnected components | ||
and solve each. | ||
|
||
Biconnected components of a bqm graph are computed (if not provided), | ||
and each subproblem is passed to the child sampler. | ||
Returned samples from each child sampler are merged. Only the best solution | ||
of each response is pick and merged with others | ||
(i.e. this composite returns a single solution). | ||
|
||
Args: | ||
sampler (:obj:`dimod.Sampler`): | ||
A dimod sampler | ||
|
||
Examples: | ||
This example uses :class:`.CutVertexComposite` to instantiate a | ||
composed sampler that submits a simple Ising problem to a sampler. | ||
The composed sampler finds the cut vertex ("2"), breaks the problem into biconnected components ({0, 1, | ||
2} and {2, 3, 4}), solves each biconnected component and combines the results into a single solution. | ||
|
||
>>> h = {} | ||
>>> J = {e: -1 for e in [(0, 1), (0, 2), (1, 2), (2, 3), (2, 4), (3, 4)]} | ||
>>> sampler = dimod.CutVertexComposite(dimod.ExactSolver()) | ||
>>> sampleset = sampler.sample_ising(h, J) | ||
|
||
""" | ||
|
||
def __init__(self, child_sampler): | ||
self._children = [child_sampler] | ||
|
||
@property | ||
def children(self): | ||
return self._children | ||
|
||
@property | ||
def parameters(self): | ||
params = self.child.parameters.copy() | ||
return params | ||
|
||
@property | ||
def properties(self): | ||
return {'child_properties': self.child.properties.copy()} | ||
|
||
def sample(self, bqm, tree_decomp=None, **parameters): | ||
"""Sample from the provided binary quadratic model. | ||
|
||
Args: | ||
bqm (:obj:`dimod.BinaryQuadraticModel`): | ||
Connected binary quadratic model to be sampled from. | ||
|
||
tree_decomp: (:obj:`BiconnectedTreeDecomposition', default=None) | ||
Tree decomposition of the bqm. Computed if not provided. | ||
|
||
**parameters: | ||
Parameters for the sampling method, specified by the child sampler. | ||
|
||
Returns: | ||
:obj:`dimod.SampleSet` | ||
|
||
""" | ||
|
||
if not(len(bqm.variables)): | ||
return SampleSet.from_samples_bqm({}, bqm) | ||
|
||
if tree_decomp is None: | ||
tree_decomp = BiconnectedTreeDecomposition(bqm) | ||
|
||
return tree_decomp.sample(self.child, **parameters) | ||
|
||
|
||
def sub_bqm(bqm, variables): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not a comment on this PR per se, but linking to #538 for future reference |
||
# build bqm out of a subset of variables. Equivalent to fixing all other variables to 0, but faster when then | ||
# subset is small. | ||
linear = {v: bqm.linear[v] for v in variables} | ||
quadratic = {(u, v): bqm.quadratic[(u, v)] for (u, v) in itertools.combinations(variables, 2) | ||
if (u, v) in bqm.quadratic} | ||
return dimod.BinaryQuadraticModel(linear, quadratic, offset=0, vartype=bqm.vartype) | ||
|
||
|
||
def get_conditionals(bqm, x, sampler, **parameters): | ||
# Get a sample from bqm with x fixed to each of its two possible values. | ||
# Return the samples and the difference in energy. | ||
|
||
not_one = -1 if bqm.vartype == dimod.Vartype.SPIN else 0 | ||
conditionals = dict() | ||
for value in [1, not_one]: | ||
|
||
bqm2 = bqm.copy() | ||
bqm2.fix_variable(x, value) | ||
# here .truncate(1) is used to pick the best solution only. | ||
conditionals[value] = sampler.sample(bqm2, **parameters).truncate(1) | ||
|
||
delta = conditionals[1].record.energy[0] - conditionals[not_one].record.energy[0] | ||
return conditionals, delta | ||
|
||
|
||
class BiconnectedTreeDecomposition(object): | ||
"""Class for building and sampling from tree decompositions based on biconnected components. | ||
|
||
|
||
""" | ||
def __init__(self, bqm): | ||
self.bqm = bqm | ||
G = bqm.to_networkx_graph() | ||
|
||
if not(nx.is_connected(G)): | ||
raise ValueError("bqm is not connected. Use ConnectedComponentsComposite(CutVertexComposite(...)).") | ||
|
||
# build the tree decomposition: | ||
self.T, self.root = self.build_biconnected_tree_decomp(G) | ||
|
||
@staticmethod | ||
def build_biconnected_tree_decomp(G): | ||
""" | ||
Build a tree decomposition of a graph based on its biconnected components. | ||
|
||
Args: | ||
G: a networkx Graph. | ||
|
||
Returns: | ||
T: a networkx Digraph that is a tree representing the tree decomposition. | ||
|
||
root: the root vertex of T. | ||
|
||
Each vertex x of T is a tuple of vertices V_x in G that induces a biconnected component (i.e. a bag in | ||
the tree decomposition). Associated with each x is the data: | ||
"cuts": a list of vertices of G in V_x that are cut vertices. | ||
"parent_cut": the cut vertex in V_x connecting V_x to its parent in the tree. | ||
"child_nodes": the children of V_x in the tree. | ||
An arc (x, y) in T indicates that V_x and V_y share a cut vertex c, and V_x is the parent of V_y. The | ||
vertex c is in the data "cut" of arc (x, y). | ||
|
||
""" | ||
|
||
cut_vertices = list(nx.articulation_points(G)) | ||
biconnected_components = [tuple(c) for c in nx.biconnected_components(G)] | ||
|
||
# build components associated with each cut vertex and digraph nodes | ||
components = {v: [] for v in cut_vertices} | ||
T = nx.DiGraph() | ||
for c in biconnected_components: | ||
T.add_node(c, cuts=[v for v in c if v in cut_vertices]) | ||
for v in T.nodes[c]['cuts']: | ||
components[v].append(c) | ||
|
||
# bfs on components to find tree structure | ||
root = biconnected_components[0] | ||
queue = [root] | ||
for v in T.nodes(): | ||
T.nodes[v]['child_cuts'] = [] | ||
T.nodes[v]['child_nodes'] = [] | ||
visited = {c: False for c in biconnected_components} | ||
visited[root] = True | ||
while queue: | ||
c1 = queue.pop(0) | ||
for v in T.nodes[c1]['cuts']: | ||
for c2 in components[v]: | ||
if not (visited[c2]): | ||
T.add_edge(c1, c2, cut=v) | ||
T.nodes[c2]['parent_cut'] = v | ||
T.nodes[c2]['parent_node'] = c1 | ||
T.nodes[c1]['child_cuts'].append(v) | ||
T.nodes[c1]['child_nodes'].append(c2) | ||
queue.append(c2) | ||
visited[c2] = True | ||
|
||
return T, root | ||
|
||
def sample(self, sampler, **parameters): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can we use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Actually, you're already passing |
||
""" | ||
Sample from a bqm by sampling from its biconnected components. | ||
|
||
Args: | ||
sampler: dimod sampler used to sample from components. | ||
|
||
parameters: parameters passed to sampler. | ||
|
||
Returns: | ||
sampleset: a dimod sampleset. | ||
|
||
Method: dynamic programming on the tree decomposition from the biconnected components. | ||
|
||
Each node in the tree decomposition is a biconnected component in the bqm. | ||
Working up from the leaves of the tree to the root, sample each biconnected component. Record the energy and | ||
best state in the component, for each configuration of the cut vertex associated with the parent of that | ||
component in the tree. When sampling, the linear biases of the cut vertices associated with children in the | ||
tree are modified so that the energy difference between the best states from the remainder of the tree below | ||
the cut vertex are accounted for. | ||
Then, working down from the root of the tree to the leaves, sample from each biconnected component. Use the | ||
resulting value at a cut vertex to sample from the remainder of the tree below that cut vertex. | ||
""" | ||
|
||
T = self.T | ||
root = self.root | ||
bqm = self.bqm | ||
|
||
cut_vertex_conditionals = dict() | ||
delta_energies = dict() | ||
|
||
# build conditionals from leaves of tree up. | ||
for c in nx.dfs_postorder_nodes(T, source=root): | ||
|
||
# get component | ||
bqm_copy = sub_bqm(bqm, c) | ||
# adjust linear biases on child cut vertices | ||
child_cut_nodes = T.nodes[c]['child_nodes'] | ||
for cc in child_cut_nodes: | ||
linear_offset = delta_energies[cc] if bqm.vartype == dimod.BINARY else delta_energies[cc]/2. | ||
cv = T.nodes[cc]['parent_cut'] | ||
bqm_copy.add_variable(cv, linear_offset - bqm.linear[cv]) | ||
|
||
if c != root: | ||
# not at root yet. Unique parent cut vertex. | ||
parent_cut_vertex = T.nodes[c]['parent_cut'] | ||
# sample component at each value of the parent cut vertex. | ||
conditional, delta = get_conditionals(bqm_copy, parent_cut_vertex, sampler, **parameters) | ||
cut_vertex_conditionals[c] = conditional | ||
delta_energies[c] = delta | ||
else: | ||
# sample at the root node. | ||
# here .truncate(1) is used to pick the best solution only. | ||
sampleset = sampler.sample(bqm_copy, **parameters).truncate(1) | ||
|
||
# sample bqm from root of tree down. | ||
for c in nx.dfs_preorder_nodes(T, source=root): | ||
if c != root: | ||
# Unique parent cut vertex. | ||
parent_cut_vertex = T.nodes[c]['parent_cut'] | ||
|
||
# add component to solution according to value of parent cut vertex. | ||
samples, labels = as_samples(sampleset) | ||
if samples.shape[0] == 1: | ||
# Extract a single sample from the cut vertex conditionals. | ||
parent_cut_value = samples[0, labels.index(parent_cut_vertex)] | ||
sampleset = sampleset.append_variables(cut_vertex_conditionals[c][parent_cut_value]) | ||
else: | ||
# For now we're only producing a single sample. To produce multiple samples, resample from each | ||
# biconnected component with the parent cut vertices fixed to each of its two values. | ||
raise NotImplementedError | ||
|
||
# recompute energies (total energy was messed up by linear biases): | ||
sampleset = SampleSet.from_samples_bqm(sampleset, bqm) | ||
return sampleset | ||
|
||
|
||
|
||
|
||
|
||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,106 @@ | ||
# Copyright 2018 D-Wave Systems Inc. | ||
# | ||
# Licensed under the Apache License, Version 2.0 (the "License"); | ||
# you may not use this file except in compliance with the License. | ||
# You may obtain a copy of the License at | ||
# | ||
# http://www.apache.org/licenses/LICENSE-2.0 | ||
# | ||
# Unless required by applicable law or agreed to in writing, software | ||
# distributed under the License is distributed on an "AS IS" BASIS, | ||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
# See the License for the specific language governing permissions and | ||
# limitations under the License. | ||
# | ||
# ============================================================================= | ||
|
||
import unittest | ||
|
||
import dimod.testing as dtest | ||
from dimod.vartypes import Vartype | ||
|
||
from dimod import BinaryQuadraticModel | ||
from dimod import CutVertexComposite, ExactSolver, FixedVariableComposite, ConnectedComponentsComposite | ||
from dimod import SampleSet | ||
from dimod.reference.composites.cutvertex import BiconnectedTreeDecomposition | ||
import itertools | ||
|
||
|
||
class TestCutVerticesComposite(unittest.TestCase): | ||
|
||
def test_instantiation_smoketest(self): | ||
sampler = CutVertexComposite(ExactSolver()) | ||
|
||
dtest.assert_sampler_api(sampler) | ||
|
||
def test_sample(self): | ||
bqm = BinaryQuadraticModel(linear={0: 0.0, 1: 1.0, 2: -1.0, 3: 0.0, 4: -0.5}, | ||
quadratic={(0, 1): 1.5, (0, 2): 0.7, (1, 2): -0.3, (0, 3): 0.9, (0, 4): 1.6, | ||
(3, 4): -0.3}, | ||
offset=0.0, | ||
vartype=Vartype.SPIN) | ||
sampler = CutVertexComposite(ExactSolver()) | ||
response = sampler.sample(bqm) | ||
self.assertIsInstance(response, SampleSet) | ||
|
||
ground_response = ExactSolver().sample(bqm) | ||
self.assertEqual(response.first.sample, ground_response.first.sample) | ||
self.assertAlmostEqual(response.first.energy, ground_response.first.energy) | ||
|
||
def test_empty_bqm(self): | ||
bqm = BinaryQuadraticModel(linear={1: -1.3, 4: -0.5}, | ||
quadratic={(1, 4): -0.6}, | ||
offset=0, | ||
vartype=Vartype.SPIN) | ||
|
||
fixed_variables = {1: -1, 4: -1} | ||
sampler = FixedVariableComposite(CutVertexComposite(ExactSolver())) | ||
response = sampler.sample(bqm, fixed_variables=fixed_variables) | ||
self.assertIsInstance(response, SampleSet) | ||
|
||
def test_sample_two_components(self): | ||
bqm = BinaryQuadraticModel({0: 0.0, 1: 4.0, 2: -4.0, 3: 0.0}, {(0, 1): -4.0, (2, 3): 4.0}, 0.0, Vartype.BINARY) | ||
|
||
sampler = ConnectedComponentsComposite(CutVertexComposite(ExactSolver())) | ||
response = sampler.sample(bqm) | ||
self.assertIsInstance(response, SampleSet) | ||
self.assertEqual(response.first.sample, {0: 0, 1: 0, 2: 1, 3: 0}) | ||
self.assertAlmostEqual(response.first.energy, bqm.energy({0: 0, 1: 0, 2: 1, 3: 0})) | ||
|
||
def test_sample_pass_treedecomp(self): | ||
bqm = BinaryQuadraticModel(linear={0: 0.0, 1: 1.0, 2: -1.0, 3: 0.0, 4: -0.5}, | ||
quadratic={(0, 1): 1.5, (0, 2): 0.7, (1, 2): -0.3, (0, 3): 0.9, (0, 4): 1.6, | ||
(3, 4): -0.3}, | ||
offset=0.0, | ||
vartype=Vartype.SPIN) | ||
|
||
sampler = CutVertexComposite(ExactSolver()) | ||
tree_decomp = BiconnectedTreeDecomposition(bqm) | ||
response = sampler.sample(bqm, tree_decomp=tree_decomp) | ||
self.assertIsInstance(response, SampleSet) | ||
ground_response = ExactSolver().sample(bqm) | ||
self.assertEqual(response.first.sample, ground_response.first.sample) | ||
self.assertAlmostEqual(response.first.energy, ground_response.first.energy) | ||
|
||
def test_forked_tree_decomp(self): | ||
comps = [[0, 1, 2], [2, 3, 4], [3, 5, 6], [4, 7, 8]] | ||
J = {(u, v): -1 for c in comps for (u, v) in itertools.combinations(c, 2)} | ||
h = {0: 0.1} | ||
bqm = BinaryQuadraticModel.from_ising(h, J) | ||
sampler = CutVertexComposite(ExactSolver()) | ||
response = sampler.sample(bqm) | ||
|
||
ground_state = {i: -1 for i in range(9)} | ||
self.assertEqual(response.first.sample, ground_state) | ||
self.assertAlmostEqual(response.first.energy, bqm.energy(ground_state)) | ||
|
||
def test_simple_tree(self): | ||
J = {(u, v): -1 for (u, v) in [(0, 3), (1, 3), (2, 3)]} | ||
h = {3: 5} | ||
bqm = BinaryQuadraticModel.from_ising(h, J) | ||
sampler = CutVertexComposite(ExactSolver()) | ||
response = sampler.sample(bqm) | ||
|
||
ground_state = {i: -1 for i in range(4)} | ||
self.assertEqual(response.first.sample, ground_state) | ||
self.assertAlmostEqual(response.first.energy, bqm.energy(ground_state)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See discussion in #539 (comment). However, in this case the functions are potentially more involved, so rewriting might be more trouble than it's worth.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The nx.articulation_points and nx.biconnected_components aren't too difficult actually, and pure python. It's basically just DFS. So if you'd rather have dimod.articulation_points and dimod.biconnected_components we could do that (although it does seem like a lot of code replication).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah exactly, e5dd5f3 added bfs so dfs as well is not such a stretch, but at some point we end up just re-implementing NetworkX and there's no point in that. On the other hand, casting BQMs to NetworkX graphs has a non-trivial copy cost so working natively on BQMs can be nice 🤷♂️.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IMHO, we should try to use nx's function until it's clear there is a drastic slowdown because of copy costs or pure Python.
Glad to learn about
nx.articulation_points
. I somehow missed thearticulation_points
function last summer when I was working on this problem. That would have simplified things. 👍There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I tend to agree. It's just frustrating because the BQM and networkx graph are so close in API, for a lot of nx functions the only thing blocking them working on the BQM directly is a decorator checking whether it's a directed graph or not. We could potentially wrap the BQM with some attributes to get around that but I am sure there would be edge cases.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
BTW, I've had great results using Metis in the past to decompose graphs. IIRC, it has a Python interface, but would add another requirement (including C backend 👎 )
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
pymetis, I think. Yeah metis is great (although overkill if you just want cut vertices rather a partition of an arbitrary graph). In principle we could extend the functionality in CutVertexComposite to cut sets larger than a single vertex, but the run time grows exponentially with the size of the cut set.