Skip to content
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

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions dimod/reference/composites/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from dimod.reference.composites.fixedvariable import FixedVariableComposite
from dimod.reference.composites.connectedcomponent import ConnectedComponentsComposite
from dimod.reference.composites.cutvertex import CutVertexComposite
from dimod.reference.composites.higherordercomposites import *
from dimod.reference.composites.roofduality import RoofDualityComposite
from dimod.reference.composites.clipcomposite import ClipComposite
Expand Down
281 changes: 281 additions & 0 deletions dimod/reference/composites/cutvertex.py
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
Copy link
Member

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.

Copy link
Contributor Author

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).

Copy link
Member

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 🤷‍♂️.

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 the articulation_points function last summer when I was working on this problem. That would have simplified things. 👍

Copy link
Member

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.

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 👎 )

Copy link
Contributor Author

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.

import itertools


__all__ = ['CutVertexComposite']


class CutVertexComposite(ComposedSampler):
Copy link
Contributor

Choose a reason for hiding this comment

The 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.

Copy link

@jberwald jberwald Oct 21, 2019

Choose a reason for hiding this comment

The 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 CutVertexComposite = CutVariableComposite? Just because "cut vertex" is a more likely search term.

"""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):
Copy link
Member

@arcondello arcondello Oct 10, 2019

Choose a reason for hiding this comment

The 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):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we use self.child as a sampler? This requires that BiconnectedTreeDecomposition inherent from ComposedSampler

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, you're already passing self.child of CutVertexComposite to this. Nevermind

"""
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







106 changes: 106 additions & 0 deletions tests/test_cutvertexcomposite.py
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))