dwavesystems / dimod

A shared API for QUBO/Ising samplers.
https://docs.ocean.dwavesys.com/en/stable/docs_dimod/
Apache License 2.0
121 stars 80 forks source link

`DQM.from_bqm` or similar #945

Open arcondello opened 3 years ago

arcondello commented 3 years ago

Something like

import warnings

from typing import Collection, Dict, Hashable, Mapping, Optional, Tuple

import dimod

try:
    from dimod.typing import Variable
except ImportError:
    # dimod < 0.10
    Variable = Hashable

def bqm_to_dqm(bqm: dimod.BinaryQuadraticModel,
               one_hots: Optional[Collection[Collection[Variable]]] = None,
               ) -> Tuple[dimod.DiscreteQuadraticModel, Dict[Variable, Tuple[Variable, int]]]:
    """Convert a BQM into a DQM.

    Args:
        bqm: A binary quadratic model.
        one_hots: a collection of one-hot constraints. Each one-hot constraint
            should cover a set of BQM variables. The one-hot constraints
            cannot overlap

    Returns:
        A 2-tuple:

            dqm: A discrete quadratic model
            mapping: A mapping from the variable of the bqm to the `(variable, case)`
                 in the dqm.

    """
    if bqm.vartype is dimod.SPIN:
        raise ValueError("given bqm must be binary")

    dqm = dimod.DiscreteQuadraticModel()
    mapping: Dict[Variable, Tuple[Variable, int]] = dict()

    if one_hots is not None:
        for c, constraint in enumerate(one_hots):
            if len(constraint) == 1:
                # this is just binary
                continue

            dqm.add_variable(num_cases=len(constraint), label=c)

            for case, v in enumerate(constraint):
                if v in mapping:
                    raise ValueError("one-hot constraints must be disjoint")
                mapping[v] = (c, case)

    for v in bqm.variables:
        if v not in mapping:
            # variables that are not included in a one-hot constraint are
            # added as two-case variables
            dqm.add_variable(2, label=v)
            mapping[v] = (v, 1)  # track the case that indicates v is true

        dqm.set_linear_case(*mapping[v], bqm.get_linear(v))

    for u, v, bias in bqm.iter_quadratic():
        if mapping[u][0] == mapping[v][0]:
            # we can ignore interactions within a one-hot constraint
            continue
        dqm.set_quadratic_case(*mapping[u], *mapping[v], bias)

    try:
        # dimod 0.10 supports offsets, just ignore it for older versions
        dqm.offset += bqm.offset
    except AttributeError:
        if bqm.offset:
            warnings.warn('bqm has an offset that is ignored', stacklevel=2)

    return dqm, mapping

def dqm_sample_to_bqm_sample(sample: Mapping[Variable, int],
                             mapping: Mapping[Variable, Tuple[Variable, int]],
                             ) -> Dict[Variable, int]:
    """Convert a DQM sample into a BQM sample according to the given mapping
    from BQM variables to DQM variables."""
    return dict((bqm_v, int(sample[dqm_v] == case)) for bqm_v, (dqm_v, case) in mapping.items())

some basic unittests

import unittest

class Test(unittest.TestCase):
    def test_no_onehot(self):
        bqm = dimod.generators.gnp_random_bqm('abcdefghijkl', .25, 'SPIN')
        bqm.change_vartype('BINARY', inplace=True)
        bqm.offset = 0  # zero the offset out since not all dimod versions support it in DQM

        dqm, mapping = bqm_to_dqm(bqm)

        bqm_sampleset = dimod.ExactSolver().sample(bqm)
        dqm_sampleset = dimod.ExactDQMSolver().sample_dqm(dqm)

        # this assumes that the lowest is unique. todo: fix
        self.assertEqual(dqm_sampleset.first.sample,
                         dqm_sample_to_bqm_sample(dqm_sampleset.first.sample, mapping))

    def test_with_onehot(self):
        bqm = dimod.generators.gnp_random_bqm('abcdefghijkl', .25, 'SPIN')
        bqm.change_vartype('BINARY', inplace=True)
        bqm.offset = 0  # zero the offset out since not all dimod versions support it in DQM

        one_hots = [['a', 'b', 'c', 'd'], ['k', 'f']]

        dqm, mapping = bqm_to_dqm(bqm, one_hots)

        bqm_sampleset = dimod.ExactSolver().sample(bqm)
        dqm_sampleset = dimod.ExactDQMSolver().sample_dqm(dqm)

        # find the lowest-energy bqm sample that satisfies the one-hot
        for sample in bqm_sampleset.samples():
            if all(sum(sample[v] for v in const) == 1 for const in one_hots):
                # this assumes that the lowest is unique. todo: fix
                self.assertEqual(sample,
                                 dqm_sample_to_bqm_sample(dqm_sampleset.first.sample, mapping))
                break

the first snippet should work with dimod 0.9 but the tests require 0.10.

Additional Context For CaseLabelDQM, we would not need to return the mapping.

arcondello commented 3 years ago

Example usage (tested with dwave-ocean-sdk==3.4.1):

import dimod
import numpy as np

from dwave.system import LeapHybridDQMSampler

# make simple tsp
num_cities = 5
distances = np.triu(np.random.uniform(size=(num_cities, num_cities)), 1)
penalty_strength = 2

bqm = dimod.BQM('BINARY')  # variables are (city, time)

# enforce the "visit each city once" constraint with one-hots
one_hots = [[(c, t) for t in range(num_cities)] for c in range(num_cities)]

# bqm_to_dqm does not allow one-hots to overlap, so we enforce "one city at a
# time" with penalty models
# in dimod 0.10 we could use:
#     bqm.add_linear_equality_constraint([((c, t), 1) for c in range(num_cities)], penalty_strength, 1)
for t in range(num_cities):
    bqm.update(dimod.generators.combinations([(c, t) for c in range(num_cities)], 1, penalty_strength))

# costs of travelling between cities
for c0 in range(num_cities):
    for c1 in range(c0+1, num_cities):
        cost = distances[c0, c1]

        for t0 in range(num_cities):
            t1 = (t0 + 1) % num_cities

            bqm.set_quadratic((c0, t0), (c1, t1), cost)
            bqm.set_quadratic((c1, t0), (c0, t1), cost)

# construct the DQM, using the one-hots from above
dqm, mapping = bqm_to_dqm(bqm, one_hots)

# solve on LeapHybridDQMSampler
sampleset = LeapHybridDQMSampler().sample_dqm(dqm)

# convert the lowest-energy returned solution back to bqm
print(dqm_sample_to_bqm_sample(sampleset.first.sample, mapping))