Repository: dwavesystems/dimod

Issue:
Include option to compress samples into column of lists in `to_pandas_dataframe`
By default, `to_pandas_dataframe` returns something like 

```python
samples.to_pandas_dataframe()
               a  b  c  energy  num_occurrences
            0 -1  1 -1    -0.5                1
            1 -1 -1  1    -0.5                1
```

It would be nice for downstream applications using DataFrames if all variables were in a list or tuple in a single column. I propose that a flag, say `use_sample_column`, causes the method to collapse all of the variable columns into a single column, such as

```python
samples.to_pandas_dataframe(use_sample_column=True)
                    sample  energy  num_occurrences
            0 [-1,  1, -1]    -0.5                1
            1 [-1 ,-1,  1]    -0.5                1 
```

Something like this should do it:

```python
        samples = list(map(dict, sampleset.samples()))
        df = pd.DataFrame(list(zip(samples,
                                   bqm.energies(samples),
                                   sampleset.record['num_occurrences'])),
                          columns=['sample', 'energy', 'num_occurrences'])
```



Discussion:
codecov-io: # [Codecov](https://codecov.io/gh/dwavesystems/dimod/pull/383?src=pr&el=h1) Report
> Merging [#383](https://codecov.io/gh/dwavesystems/dimod/pull/383?src=pr&el=desc) into [master](https://codecov.io/gh/dwavesystems/dimod/commit/72188236007a1225294a994f9684560de657d865?src=pr&el=desc) will **decrease** coverage by `0.02%`.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/dwavesystems/dimod/pull/383/graphs/tree.svg?width=650&token=KyxipO4fvT&height=150&src=pr)](https://codecov.io/gh/dwavesystems/dimod/pull/383?src=pr&el=tree)

```diff
@@            Coverage Diff            @@
##           master    #383      +/-   ##
=========================================
- Coverage   91.63%   91.6%   -0.03%     
=========================================
  Files          40      40              
  Lines        2450    2455       +5     
=========================================
+ Hits         2245    2249       +4     
- Misses        205     206       +1
```


| [Impacted Files](https://codecov.io/gh/dwavesystems/dimod/pull/383?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [dimod/sampleset.py](https://codecov.io/gh/dwavesystems/dimod/pull/383/diff?src=pr&el=tree#diff-ZGltb2Qvc2FtcGxlc2V0LnB5) | `94.77% <100%> (+0.47%)` | :arrow_up: |
| [dimod/views.py](https://codecov.io/gh/dwavesystems/dimod/pull/383/diff?src=pr&el=tree#diff-ZGltb2Qvdmlld3MucHk=) | `88.23% <0%> (-1.31%)` | :arrow_down: |
| [dimod/binary\_quadratic\_model.py](https://codecov.io/gh/dwavesystems/dimod/pull/383/diff?src=pr&el=tree#diff-ZGltb2QvYmluYXJ5X3F1YWRyYXRpY19tb2RlbC5weQ==) | `95.18% <0%> (-0.18%)` | :arrow_down: |
| [dimod/serialization/json.py](https://codecov.io/gh/dwavesystems/dimod/pull/383/diff?src=pr&el=tree#diff-ZGltb2Qvc2VyaWFsaXphdGlvbi9qc29uLnB5) | `95.32% <0%> (+0.93%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/dwavesystems/dimod/pull/383?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/dwavesystems/dimod/pull/383?src=pr&el=footer). Last update [7218823...a34c3f7](https://codecov.io/gh/dwavesystems/dimod/pull/383?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/dimod/binary_quadratic_model.py b/dimod/binary_quadratic_model.py
index 77f60957..86003d9a 100644
--- a/dimod/binary_quadratic_model.py
+++ b/dimod/binary_quadratic_model.py
@@ -946,6 +946,16 @@ class BinaryQuadraticModel(abc.Sized, abc.Container, abc.Iterable):
                 return 0, 0
             return min(iterable), max(iterable)
 
+        if ignored_variables is None:
+            ignored_variables = set()
+        elif not isinstance(ignored_variables, abc.Container):
+            ignored_variables = set(ignored_variables)
+
+        if ignored_interactions is None:
+            ignored_interactions = set()
+        elif not isinstance(ignored_interactions, abc.Container):
+            ignored_interactions = set(ignored_interactions)
+
         if quadratic_range is None:
             linear_range, quadratic_range = bias_range, bias_range
         else:
@@ -954,8 +964,13 @@ class BinaryQuadraticModel(abc.Sized, abc.Container, abc.Iterable):
         lin_range, quad_range = map(parse_range, (linear_range,
                                                   quadratic_range))
 
-        lin_min, lin_max = min_and_max(self.linear.values())
-        quad_min, quad_max = min_and_max(self.quadratic.values())
+        lin_min, lin_max = min_and_max([v for k, v in self.linear.items()
+                                    if k not in ignored_variables])
+        quad_min, quad_max = min_and_max([v for (a,b),
+                                                v in self.quadratic.items()
+                                          if ((a, b) not in ignored_interactions
+                                              and (b, a) not in
+                                              ignored_interactions)])
 
         inv_scalar = max(lin_min / lin_range[0], lin_max / lin_range[1],
                          quad_min / quad_range[0], quad_max / quad_range[1])
diff --git a/dimod/reference/composites/scalecomposite.py b/dimod/reference/composites/scalecomposite.py
index 10fcc4a5..a2f49e28 100644
--- a/dimod/reference/composites/scalecomposite.py
+++ b/dimod/reference/composites/scalecomposite.py
@@ -1,4 +1,4 @@
-# Copyright 2018 D-Wave Systems Inc.
+# 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.
@@ -14,8 +14,8 @@
 #
 # ================================================================================================
 """
-A composite that fixes the variables provided and removes them from the
-bqm object before sending to its child sampler.
+A composite that scales problem variables as directed. if scalar is not given
+calculates it based on quadratic and bias ranges.
 
 See `Ocean Glossary <https://docs.ocean.dwavesys.com/en/latest/glossary.html>`_ for explanations
 of technical terms in descriptions of Ocean tools.
@@ -72,12 +72,12 @@ class ScaleComposite(ComposedSampler):
     @property
     def parameters(self):
         param = self.child.parameters.copy()
-        param['scalar'] = []
-        param['bias_range'] = []
-        param['quadratic_range'] = []
-        param['ignored_variables'] = []
-        param['ignored_interactions'] = []
-        param['ignore_offset'] = []
+        param.update({'scalar': [],
+                      'bias_range': [],
+                      'quadratic_range': [],
+                      'ignored_variables': [],
+                      'ignored_interactions': [],
+                      'ignore_offset': []})
         return param
 
     @property
@@ -124,22 +124,17 @@ class ScaleComposite(ComposedSampler):
 
         """
         ignored_variables, ignored_interactions = _check_params(
-            ignored_interactions=ignored_interactions,
-            ignored_variables=ignored_variables)
+            ignored_variables, ignored_interactions)
 
         child = self.child
-        bqm_copy = _scaled_bqm(bqm, scalar=scalar,
-                               bias_range=bias_range,
-                               quadratic_range=quadratic_range,
-                               ignored_variables=ignored_variables,
-                               ignored_interactions=ignored_interactions,
-                               ignore_offset=ignore_offset)
+        bqm_copy = _scaled_bqm(bqm, scalar, bias_range, quadratic_range,
+                               ignored_variables, ignored_interactions,
+                               ignore_offset)
         response = child.sample(bqm_copy, **parameters)
 
         return _scale_back_response(bqm, response, bqm_copy.info['scalar'],
-                                    ignored_variables=ignored_variables,
-                                    ignored_interactions=ignored_interactions,
-                                    ignore_offset=ignore_offset)
+                                    ignored_variables, ignored_interactions,
+                                    ignore_offset)
 
     def sample_ising(self, h, J, offset=0, scalar=None,
                      bias_range=1, quadratic_range=None,
@@ -198,17 +193,14 @@ class ScaleComposite(ComposedSampler):
         # handle HUBO
 
         ignored_variables, ignored_interactions = _check_params(
-            ignored_interactions=ignored_interactions,
-            ignored_variables=ignored_variables)
+            ignored_variables,
+            ignored_interactions)
 
         child = self.child
-        h_sc, J_sc, offset_sc = _scaled_hubo(h, J, offset=offset,
-                                             scalar=scalar,
-                                             bias_range=bias_range,
-                                             quadratic_range=quadratic_range,
-                                             ignored_variables=ignored_variables,
-                                             ignored_interactions=ignored_interactions,
-                                             ignore_offset=ignore_offset)
+        h_sc, J_sc, offset_sc = _scaled_hubo(h, J, offset, scalar, bias_range,
+                                             quadratic_range, ignored_variables,
+                                             ignored_interactions,
+                                             ignore_offset)
         response = child.sample_ising(h_sc, J_sc, offset=offset_sc,
                                       **parameters)
 
@@ -218,8 +210,8 @@ class ScaleComposite(ComposedSampler):
         return response
 
 
-def _scale_back_response(bqm, response, scalar, ignored_interactions=None,
-                         ignored_variables=None, ignore_offset=None):
+def _scale_back_response(bqm, response, scalar, ignored_interactions,
+                         ignored_variables, ignore_offset):
     """Helper function to scale back the response of sample method"""
     if len(ignored_interactions) + len(
             ignored_variables) + ignore_offset == 0:
@@ -230,7 +222,7 @@ def _scale_back_response(bqm, response, scalar, ignored_interactions=None,
     return response
 
 
-def _check_params(ignored_interactions=None, ignored_variables=None):
+def _check_params(ignored_variables, ignored_interactions):
     """Helper for sample methods"""
 
     if ignored_variables is None:
@@ -246,8 +238,8 @@ def _check_params(ignored_interactions=None, ignored_variables=None):
     return ignored_variables, ignored_interactions
 
 
-def _calc_norm_coeff(h, J, bias_range, quadratic_range, ignored_variables=None,
-                     ignored_interactions=None):
+def _calc_norm_coeff(h, J, bias_range, quadratic_range, ignored_variables,
+                     ignored_interactions):
     """Helper function to calculate normalization coefficient"""
 
     if ignored_variables is None or ignored_interactions is None:
@@ -286,17 +278,16 @@ def _calc_norm_coeff(h, J, bias_range, quadratic_range, ignored_variables=None,
         return 1.
 
 
-def _scaled_bqm(bqm, scalar=None, bias_range=1, quadratic_range=None,
-                ignored_variables=None, ignored_interactions=None,
-                ignore_offset=False):
+def _scaled_bqm(bqm, scalar, bias_range, quadratic_range,
+                ignored_variables, ignored_interactions,
+                ignore_offset):
     """Helper function of sample for scaling"""
 
     bqm_copy = bqm.copy()
     if scalar is None:
         scalar = _calc_norm_coeff(bqm_copy.linear, bqm_copy.quadratic,
                                   bias_range, quadratic_range,
-                                  ignored_variables=ignored_variables,
-                                  ignored_interactions=ignored_interactions)
+                                  ignored_variables, ignored_interactions)
 
     bqm_copy.scale(scalar, ignored_variables=ignored_variables,
                    ignored_interactions=ignored_interactions,
@@ -305,18 +296,16 @@ def _scaled_bqm(bqm, scalar=None, bias_range=1, quadratic_range=None,
     return bqm_copy
 
 
-def _scaled_hubo(h, j, offset=0, scalar=None, bias_range=1,
-                 quadratic_range=None,
-                 ignored_variables=None,
-                 ignored_interactions=None,
-                 ignore_offset=False):
+def _scaled_hubo(h, j, offset, scalar, bias_range,
+                 quadratic_range,
+                 ignored_variables,
+                 ignored_interactions,
+                 ignore_offset):
     """Helper function of sample_ising for scaling"""
 
     if scalar is None:
         scalar = _calc_norm_coeff(h, j, bias_range, quadratic_range,
-                                  ignored_variables=ignored_variables,
-                                  ignored_interactions=ignored_interactions
-                                  )
+                                  ignored_variables, ignored_interactions)
     h_sc = dict(h)
     j_sc = dict(j)
     offset_sc = offset
diff --git a/dimod/sampleset.py b/dimod/sampleset.py
index 77919a63..f93633af 100644
--- a/dimod/sampleset.py
+++ b/dimod/sampleset.py
@@ -695,7 +695,8 @@ class SampleSet(abc.Iterable, abc.Sized):
             for sample in itertools.islice(self.samples(n=None, sorted_by=sorted_by), n):
                 yield sample
 
-    def data(self, fields=None, sorted_by='energy', name='Sample', reverse=False):
+    def data(self, fields=None, sorted_by='energy', name='Sample', reverse=False,
+             sample_dict_cast=True):
         """Iterate over the data in the :class:`SampleSet`.
 
         Args:
@@ -713,6 +714,11 @@ class SampleSet(abc.Iterable, abc.Sized):
             reverse (bool, optional, default=False):
                 If True, yield in reverse order.
 
+            sample_dict_cast (bool, optional, default=False):
+                If True, samples are returned as dicts rather than
+                `.SampleView`s. Note that this can lead to very heavy memory
+                usage.
+
         Yields:
             namedtuple/tuple: The data in the :class:`SampleSet`, in the order specified by the input
             `fields`.
@@ -768,7 +774,10 @@ class SampleSet(abc.Iterable, abc.Sized):
         def _values(idx):
             for field in fields:
                 if field == 'sample':
-                    yield SampleView(self.variables, record.sample[idx, :])
+                    sample = SampleView(self.variables, record.sample[idx, :])
+                    if sample_dict_cast:
+                        sample = dict(sample)
+                    yield sample
                 else:
                     yield record[field][idx]
 
@@ -963,7 +972,7 @@ class SampleSet(abc.Iterable, abc.Sized):
     # Export to dataframe
     ###############################################################################################
 
-    def to_pandas_dataframe(self):
+    def to_pandas_dataframe(self, sample_column=False):
         """Convert a SampleSet to a Pandas DataFrame
 
         Returns:
@@ -977,15 +986,25 @@ class SampleSet(abc.Iterable, abc.Sized):
                a  b  c  energy  num_occurrences
             0 -1  1 -1    -0.5                1
             1 -1 -1  1    -0.5                1
+            >>> samples.to_pandas_dataframe(sample_column=True)
+                                   sample  energy  num_occurrences
+            0  {'a': -1, 'b': 1, 'c': -1}    -0.5                1
+            1  {'a': -1, 'b': -1, 'c': 1}    -0.5                1
 
         """
         import pandas as pd
 
-        df = pd.DataFrame(self.record.sample, columns=self.variables)
-        for field in sorted(self.record.dtype.fields):  # sort for consistency
-            if field == 'sample':
-                continue
+        if sample_column:
+            df = pd.DataFrame(self.data(sorted_by=None, sample_dict_cast=True))
+
+        else:
+            # work directly with the record, it's much faster
+            df = pd.DataFrame(self.record.sample, columns=self.variables)
+
+            for field in sorted(self.record.dtype.fields):  # sort for consistency
+                if field == 'sample':
+                    continue
 
-            df.loc[:, field] = self.record[field]
+                df.loc[:, field] = self.record[field]
 
         return df


Test patch:
diff --git a/dimod/testing/asserts.py b/dimod/testing/asserts.py
index b28d929f..7a723c11 100644
--- a/dimod/testing/asserts.py
+++ b/dimod/testing/asserts.py
@@ -194,3 +194,61 @@ def assert_response_energies(response, bqm, precision=7):
                 assert v in sample, "bqm contains a variable not in sample"
 
         assert round(bqm.energy(sample) - energy, precision) == 0
+
+
+def assert_bqm_almost_equal(actual, desired, places=7,
+                            ignore_zero_interactions=False):
+    """Test if two bqm have almost equal biases.
+
+    Args:
+        actual (:obj:`.BinaryQuadraticModel`)
+
+        desired (:obj:`.BinaryQuadraticModel`)
+
+        places (int, optional, default=7):
+            Bias equality is computed as :code:`round(b0 - b1, places) == 0`
+
+        ignore_zero_interactions (bool, optional, default=False):
+            If true, interactions with 0 bias are ignored.
+
+    """
+    assert isinstance(actual, dimod.BinaryQuadraticModel), "not a binary quadratic model"
+    assert isinstance(desired, dimod.BinaryQuadraticModel), "not a binary quadratic model"
+
+    # vartype should match
+    assert actual.vartype is desired.vartype, "unlike vartype"
+
+    # variables should match
+    variables_diff = set(actual).symmetric_difference(desired)
+    if variables_diff:
+        v = variables_diff.pop()
+        msg = "{!r} is not a shared variable".format(v)
+        raise AssertionError(msg)
+
+    # offset
+    if round(actual.offset - desired.offset, places):
+        msg = 'offsets {} != {}'.format(desired.offset, actual.offset)
+        raise AssertionError(msg)
+
+    # linear biases - we already checked variables
+    for v, bias in desired.linear.items():
+        if round(bias - actual.linear[v], places):
+            msg = 'linear bias associated with {!r} does not match, {!r} != {!r}'
+            raise AssertionError(msg.format(v, bias, actual.linear[v]))
+
+    default = 0 if ignore_zero_interactions else None
+
+    for inter, bias in actual.quadratic.items():
+        other_bias = desired.quadratic.get(inter, default)
+        if other_bias is None:
+            raise AssertionError('{!r} is not a shared interaction'.format(inter))
+        if round(bias - other_bias, places):
+            msg = 'quadratic bias associated with {!r} does not match, {!r} != {!r}'
+            raise AssertionError(msg.format(inter, bias, other_bias))
+    for inter, bias in desired.quadratic.items():
+        other_bias = actual.quadratic.get(inter, default)
+        if other_bias is None:
+            raise AssertionError('{!r} is not a shared interaction'.format(inter))
+        if round(bias - other_bias, places):
+            msg = 'quadratic bias associated with {!r} does not match, {!r} != {!r}'
+            raise AssertionError(msg.format(inter, bias, other_bias))
diff --git a/docs/reference/testing.rst b/docs/reference/testing.rst
index bd6f5185..1f58ccae 100644
--- a/docs/reference/testing.rst
+++ b/docs/reference/testing.rst
@@ -12,6 +12,7 @@ API Asserts
 .. autosummary::
    :toctree: generated/
 
+   assert_bqm_almost_equal
    assert_composite_api
    assert_sampler_api
    assert_structured_api
diff --git a/tests/test_binary_quadratic_model.py b/tests/test_binary_quadratic_model.py
index 3def6ab9..1d65fbc2 100644
--- a/tests/test_binary_quadratic_model.py
+++ b/tests/test_binary_quadratic_model.py
@@ -771,6 +771,13 @@ class TestBinaryQuadraticModel(unittest.TestCase):
         self.assertAlmostEqual(bqm.offset, 1.)
         self.assertConsistentBQM(bqm)
 
+        bqm = dimod.BinaryQuadraticModel({0: -2, 1: 2}, {(0, 1): -5}, 1.,
+                                         dimod.SPIN)
+        bqm.normalize(0.5, ignored_interactions=[(0, 1)])
+        self.assertAlmostEqual(bqm.linear, {0: -.5, 1: .5})
+        self.assertAlmostEqual(bqm.quadratic, {(0, 1): -5})
+        self.assertAlmostEqual(bqm.offset, 0.25)
+        self.assertConsistentBQM(bqm)
 
     def test_scale_exclusions(self):
         bqm = dimod.BinaryQuadraticModel({0: -2, 1: 2}, {(0, 1): -1}, 1., dimod.SPIN)
diff --git a/tests/test_sampleset.py b/tests/test_sampleset.py
index a2b34bc7..9eb9a300 100644
--- a/tests/test_sampleset.py
+++ b/tests/test_sampleset.py
@@ -307,6 +307,17 @@ class TestSampleSet_to_pandas_dataframe(unittest.TestCase):
 
         pd.testing.assert_frame_equal(df, other, check_dtype=False)
 
+    def test_sample_column(self):
+        samples = dimod.SampleSet.from_samples(([[-1, 1, -1], [-1, -1, 1]], 'abc'),
+                                               dimod.SPIN, energy=[-.5, .5])
+        df = samples.to_pandas_dataframe(sample_column=True)
+
+        other = pd.DataFrame([[{'a': -1, 'b': 1, 'c': -1}, -0.5, 1],
+                              [{'a': -1, 'b': -1, 'c': 1}, 0.5, 1]],
+                             columns=['sample', 'energy', 'num_occurrences'])
+
+        pd.testing.assert_frame_equal(df, other, check_dtype=False)
+
 
 class TestSampleSet_first(unittest.TestCase):
     def test_empty(self):
diff --git a/tests/test_scalecomposite.py b/tests/test_scalecomposite.py
index 4921ef2e..754a6ae2 100644
--- a/tests/test_scalecomposite.py
+++ b/tests/test_scalecomposite.py
@@ -1,4 +1,4 @@
-# Copyright 2018 D-Wave Systems Inc.
+# 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.
@@ -14,15 +14,6 @@
 #
 # ================================================================================================
 
-# class ScalingChecker(dimod.Sampler):
-#     def __init__(self, originalbqm, scalar):
-#         self.bqm = originalbqm.copy()
-#         self.bqm.scale(scalar)
-#     def sample(self, bqm):
-#         assert self.bqm == bqm
-#         return ExactSolver().sample(bqm)
-#     def sample_ising(self,h, J):
-
 import unittest
 
 import dimod.testing as dtest
@@ -87,12 +78,14 @@ class TestScaleComposite(unittest.TestCase):
         linear = {'a': -4.0, 'b': -4.0}
         quadratic = {('a', 'b'): 3.2}
 
-        ignored_variables, ignored_interactions = _check_params()
+        ignored_variables, ignored_interactions = _check_params(None, None)
         bqm = BinaryQuadraticModel.from_ising(linear, quadratic, offset=5.0)
-        bqm_new = _scaled_bqm(bqm,
-                              bias_range=2,
-                              ignored_interactions=ignored_interactions,
-                              ignored_variables=ignored_variables)
+        scalar = None
+        quadratic_range = None
+        ignore_offset = False
+        bqm_new = _scaled_bqm(bqm, scalar, 2, quadratic_range,
+                              ignored_variables, ignored_interactions,
+                              ignore_offset)
 
         sc = 2.
         hsc = {k: v / sc if k not in ignored_variables else v for
@@ -102,13 +95,11 @@ class TestScaleComposite(unittest.TestCase):
         bqm_scaled = BinaryQuadraticModel.from_ising(hsc, Jsc, offset=5.0 / 2.)
         self.assertEqual(bqm_scaled, bqm_new)
 
-        ignored_variables, ignored_interactions = _check_params()
+        ignored_variables, ignored_interactions = _check_params(None, None)
         bqm = BinaryQuadraticModel.from_ising(linear, quadratic, offset=5.0)
-        bqm_new = _scaled_bqm(bqm,
-                              bias_range=2,
-                              ignored_interactions=ignored_interactions,
-                              ignored_variables=ignored_variables,
-                              ignore_offset=True)
+        bqm_new = _scaled_bqm(bqm, scalar, 2, quadratic_range,
+                              ignored_variables, ignored_interactions,
+                              True)
 
         sc = 2.
         hsc = {k: v / sc if k not in ignored_variables else v for
@@ -118,10 +109,9 @@ class TestScaleComposite(unittest.TestCase):
         bqm_scaled = BinaryQuadraticModel.from_ising(hsc, Jsc, offset=5.0)
         self.assertEqual(bqm_scaled, bqm_new)
 
-        bqm_new = _scaled_bqm(bqm,
-                              quadratic_range=(-1, 0.4),
-                              ignored_interactions=ignored_interactions,
-                              ignored_variables=ignored_variables)
+        bqm_new = _scaled_bqm(bqm, scalar, 1, (-1, 0.4),
+                              ignored_variables, ignored_interactions,
+                              ignore_offset)
 
         sc = 3.2 / 0.4
         hsc = {k: v / sc if k not in ignored_variables else v for
@@ -135,13 +125,12 @@ class TestScaleComposite(unittest.TestCase):
         ignored_variables = ['a', 'b']
         ignored_interactions = None
         ignored_variables, ignored_interactions = _check_params(
-            ignored_interactions=ignored_interactions,
-            ignored_variables=ignored_variables)
+            ignored_variables, ignored_interactions)
         bqm = BinaryQuadraticModel.from_ising(linear, quadratic)
-        bqm_new = _scaled_bqm(bqm,
-                              bias_range=(-2, 2),
-                              ignored_interactions=ignored_interactions,
-                              ignored_variables=ignored_variables)
+
+        bqm_new = _scaled_bqm(bqm, scalar, (2, 2), quadratic_range,
+                              ignored_variables, ignored_interactions,
+                              ignore_offset)
 
         sc = 3.2 / 2.
         hsc = {k: v / sc if k not in ignored_variables else v for
@@ -155,13 +144,12 @@ class TestScaleComposite(unittest.TestCase):
         ignored_variables = None
         ignored_interactions = [('a', 'b')]
         ignored_variables, ignored_interactions = _check_params(
-            ignored_interactions=ignored_interactions,
-            ignored_variables=ignored_variables)
+            ignored_variables, ignored_interactions)
+
         bqm = BinaryQuadraticModel.from_ising(linear, quadratic)
-        bqm_new = _scaled_bqm(bqm,
-                              quadratic_range=0.5,
-                              ignored_interactions=ignored_interactions,
-                              ignored_variables=ignored_variables)
+        bqm_new = _scaled_bqm(bqm, scalar, 1, 0.5,
+                              ignored_variables, ignored_interactions,
+                              ignore_offset)
 
         sc = 4.
         hsc = {k: v / sc if k not in ignored_variables else v for
@@ -179,13 +167,11 @@ class TestScaleComposite(unittest.TestCase):
         ignored_variables = None
         ignored_interactions = None
         ignored_variables, ignored_interactions = _check_params(
-            ignored_interactions=ignored_interactions,
-            ignored_variables=ignored_variables)
+            ignored_variables, ignored_interactions)
 
-        hnew, jnew, offsetnew = _scaled_hubo(linear, quadratic, offset=offset,
-                                             bias_range=2,
-                                             ignored_interactions=ignored_interactions,
-                                             ignored_variables=ignored_variables)
+        hnew, jnew, offsetnew = _scaled_hubo(linear, quadratic, offset,
+                                             None, 2, None, ignored_variables,
+                                             ignored_interactions, False)
 
         sc = 2.
         hsc = {k: v / sc if k not in ignored_variables else v for
@@ -196,11 +182,9 @@ class TestScaleComposite(unittest.TestCase):
         self.assertEqual(Jsc, jnew)
         self.assertEqual(offsetnew, offset / sc)
 
-        hnew, jnew, offsetnew = _scaled_hubo(linear, quadratic, offset=offset,
-                                             bias_range=2,
-                                             ignored_interactions=ignored_interactions,
-                                             ignored_variables=ignored_variables,
-                                             ignore_offset=True)
+        hnew, jnew, offsetnew = _scaled_hubo(linear, quadratic, offset,
+                                             None, 2, None, ignored_variables,
+                                             ignored_interactions, True)
 
         sc = 2.
         hsc = {k: v / sc if k not in ignored_variables else v for
@@ -211,10 +195,10 @@ class TestScaleComposite(unittest.TestCase):
         self.assertEqual(Jsc, jnew)
         self.assertEqual(offsetnew, offset)
 
-        hnew, jnew, offsetnew = _scaled_hubo(linear, quadratic, offset=offset,
-                                             quadratic_range=(-1, 0.4),
-                                             ignored_interactions=ignored_interactions,
-                                             ignored_variables=ignored_variables)
+        hnew, jnew, offsetnew = _scaled_hubo(linear, quadratic, offset,
+                                             None, 1, (-1, 0.4),
+                                             ignored_variables,
+                                             ignored_interactions, False)
 
         sc = 3.2 / 0.4
         hsc = {k: v / sc if k not in ignored_variables else v for
@@ -228,13 +212,12 @@ class TestScaleComposite(unittest.TestCase):
         ignored_variables = ['a', 'b']
         ignored_interactions = None
         ignored_variables, ignored_interactions = _check_params(
-            ignored_interactions=ignored_interactions,
-            ignored_variables=ignored_variables)
+            ignored_variables, ignored_interactions)
 
-        hnew, jnew, offsetnew = _scaled_hubo(linear, quadratic, offset=offset,
-                                             bias_range=(-2, 2),
-                                             ignored_interactions=ignored_interactions,
-                                             ignored_variables=ignored_variables)
+        hnew, jnew, offsetnew = _scaled_hubo(linear, quadratic, offset,
+                                             None, (-2, 2), None,
+                                             ignored_variables,
+                                             ignored_interactions, False)
         sc = 3.2 / 2.
         hsc = {k: v / sc if k not in ignored_variables else v for
                k, v in linear.items()}
@@ -247,13 +230,11 @@ class TestScaleComposite(unittest.TestCase):
         ignored_variables = None
         ignored_interactions = [('a', 'b', 'c')]
         ignored_variables, ignored_interactions = _check_params(
-            ignored_interactions=ignored_interactions,
-            ignored_variables=ignored_variables)
+            ignored_variables, ignored_interactions)
 
-        hnew, jnew, offsetnew = _scaled_hubo(linear, quadratic, offset=offset,
-                                             quadratic_range=0.5,
-                                             ignored_interactions=ignored_interactions,
-                                             ignored_variables=ignored_variables)
+        hnew, jnew, offsetnew = _scaled_hubo(linear, quadratic, offset,
+                                             None, 1, 0.5, ignored_variables,
+                                             ignored_interactions, False)
 
         sc = 4.
         hsc = {k: v / sc if k not in ignored_variables else v for
@@ -269,7 +250,7 @@ class TestScaleComposite(unittest.TestCase):
         quadratic = {('a', 'b', 'c'): 3.2}
         offset = 5
 
-        ignored_variables, ignored_interactions = _check_params()
+        ignored_variables, ignored_interactions = _check_params(None, None)
 
         comp_parameters = dict(ignored_interactions=ignored_interactions,
                                ignored_variables=ignored_variables,
@@ -290,7 +271,7 @@ class TestScaleComposite(unittest.TestCase):
         linear = {'a': -4.0, 'b': -4.0, 'c': -4.0}
         quadratic = {('a', 'b', 'c'): 3.2}
         offset = 5
-        ignored_variables, ignored_interactions = _check_params()
+        ignored_variables, ignored_interactions = _check_params(None, None)
         comp_parameters = dict(ignored_interactions=ignored_interactions,
                                ignored_variables=ignored_variables,
                                penalty_strength=5.,
@@ -309,7 +290,7 @@ class TestScaleComposite(unittest.TestCase):
         linear = {'a': -4.0, 'b': -4.0, 'c': -4.0}
         quadratic = {('a', 'b', 'c'): 3.2}
         offset = 5
-        ignored_variables, ignored_interactions = _check_params()
+        ignored_variables, ignored_interactions = _check_params(None, None)
 
         comp_parameters = dict(ignored_interactions=ignored_interactions,
                                ignored_variables=ignored_variables,
@@ -345,7 +326,7 @@ class TestScaleComposite(unittest.TestCase):
         linear = {'a': -4.0, 'b': -4.0, 'c': -4.0}
         quadratic = {('a', 'b', 'c'): 3.2}
         offset = 5
-        ignored_variables, ignored_interactions = _check_params()
+        ignored_variables, ignored_interactions = _check_params(None, None)
         comp_parameters = dict(ignored_interactions=ignored_interactions,
                                ignored_variables=ignored_variables,
                                penalty_strength=5.,
@@ -366,7 +347,7 @@ class TestScaleComposite(unittest.TestCase):
         linear = {'a': -4.0, 'b': -4.0}
         quadratic = {('a', 'b'): 3.2}
         offset = 5
-        ignored_variables, ignored_interactions = _check_params()
+        ignored_variables, ignored_interactions = _check_params(None, None)
         bqm = BinaryQuadraticModel.from_ising(linear, quadratic, offset=offset)
         comp_parameters = dict(ignored_interactions=ignored_interactions,
                                ignored_variables=ignored_variables)
@@ -379,8 +360,7 @@ class TestScaleComposite(unittest.TestCase):
     def test_sample_bias_range(self):
         linear = {'a': -4.0, 'b': -4.0}
         quadratic = {('a', 'b'): 3.2}
-
-        ignored_variables, ignored_interactions = _check_params()
+        ignored_variables, ignored_interactions = _check_params(None, None)
         bqm = BinaryQuadraticModel.from_ising(linear, quadratic)
         comp_parameters = dict(ignored_interactions=ignored_interactions,
                                ignored_variables=ignored_variables,
@@ -395,7 +375,7 @@ class TestScaleComposite(unittest.TestCase):
         linear = {'a': -4.0, 'b': -4.0}
         quadratic = {('a', 'b'): 3.2}
         offset = 5
-        ignored_variables, ignored_interactions = _check_params()
+        ignored_variables, ignored_interactions = _check_params(None, None)
         bqm = BinaryQuadraticModel.from_ising(linear, quadratic, offset=offset)
         comp_parameters = dict(ignored_interactions=ignored_interactions,
                                ignored_variables=ignored_variables,
@@ -419,7 +399,7 @@ class TestScaleComposite(unittest.TestCase):
         linear = {'a': -4.0, 'b': -4.0}
         quadratic = {('a', 'b'): 3.2}
         offset = 5
-        ignored_variables, ignored_interactions = _check_params()
+        ignored_variables, ignored_interactions = _check_params(None, None)
         bqm = BinaryQuadraticModel.from_ising(linear, quadratic, offset=offset)
         comp_parameters = dict(ignored_interactions=ignored_interactions,
                                ignored_variables=ignored_variables,
@@ -435,7 +415,7 @@ class TestScaleComposite(unittest.TestCase):
         linear = {'a': -4.0, 'b': -4.0}
         quadratic = {('a', 'b'): 3.2}
         offset = 5
-        ignored_variables, ignored_interactions = _check_params()
+        ignored_variables, ignored_interactions = _check_params(None, None)
         comp_parameters = dict(ignored_interactions=ignored_interactions,
                                ignored_variables=ignored_variables)
         sampler = ScaleComposite(ScalingChecker(ExactSolver(), h=linear,
@@ -450,7 +430,7 @@ class TestScaleComposite(unittest.TestCase):
         quadratic = {('a', 'b'): 3.2}
         offset = 5
         ignored_variables, ignored_interactions = _check_params(
-            ignored_interactions=[('a', 'b')])
+            None, [('a', 'b')])
         comp_parameters = dict(ignored_interactions=ignored_interactions,
                                ignored_variables=ignored_variables,
                                scalar=0.5
@@ -469,7 +449,7 @@ class TestScaleComposite(unittest.TestCase):
         quadratic = {('a', 'b'): 3.2}
         offset = 5
 
-        ignored_variables, ignored_interactions = _check_params()
+        ignored_variables, ignored_interactions = _check_params(None, None)
         comp_parameters = dict(ignored_interactions=ignored_interactions,
                                ignored_variables=ignored_variables,
                                ignore_offset=True,
@@ -488,7 +468,7 @@ class TestScaleComposite(unittest.TestCase):
         offset = 5
         bqm = BinaryQuadraticModel.from_ising(linear, quadratic, offset=offset)
 
-        ignored_variables, ignored_interactions = _check_params()
+        ignored_variables, ignored_interactions = _check_params(None, None)
         comp_parameters = dict(ignored_interactions=ignored_interactions,
                                ignored_variables=ignored_variables,
                                ignore_offset=True,
@@ -505,7 +485,7 @@ class TestScaleComposite(unittest.TestCase):
         quadratic = {('a', 'b', 'c'): 3.2}
         offset = 5
 
-        ignored_variables, ignored_interactions = _check_params()
+        ignored_variables, ignored_interactions = _check_params(None, None)
         comp_parameters = dict(ignored_interactions=ignored_interactions,
                                ignored_variables=ignored_variables,
                                ignore_offset=True,
@@ -524,7 +504,7 @@ class TestScaleComposite(unittest.TestCase):
         quadratic = {('a', 'b'): 3.2}
         offset = 5
         ignored_variables, ignored_interactions = _check_params(
-            ignored_interactions=[('a', 'b')])
+            None, [('a', 'b')])
 
         comp_parameters = dict(ignored_interactions=ignored_interactions,
                                ignored_variables=ignored_variables,
@@ -545,7 +525,7 @@ class TestScaleComposite(unittest.TestCase):
         bqm = BinaryQuadraticModel.from_ising(linear, quadratic, offset=offset)
 
         ignored_variables, ignored_interactions = _check_params(
-            ignored_interactions=[('a', 'b')])
+            None, [('a', 'b')])
         comp_parameters = dict(ignored_interactions=ignored_interactions,
                                ignored_variables=ignored_variables,
                                ignore_offset=True,
@@ -562,7 +542,7 @@ class TestScaleComposite(unittest.TestCase):
         quadratic = {('a', 'b', 'c'): 3.2}
         offset = 5
         ignored_variables, ignored_interactions = _check_params(
-            ignored_interactions=[('a', 'b', 'c')])
+            None, [('a', 'b', 'c')])
         comp_parameters = dict(ignored_interactions=ignored_interactions,
                                ignored_variables=ignored_variables,
                                ignore_offset=True,
@@ -581,7 +561,7 @@ class TestScaleComposite(unittest.TestCase):
         quadratic = {('a', 'b'): 3.2}
         offset = 5
         ignored_variables, ignored_interactions = _check_params(
-            ignored_variables=['a'])
+            ['a'], None)
 
         comp_parameters = dict(ignored_interactions=ignored_interactions,
                                ignored_variables=ignored_variables,
@@ -601,7 +581,7 @@ class TestScaleComposite(unittest.TestCase):
         offset = 5
         bqm = BinaryQuadraticModel.from_ising(linear, quadratic, offset=offset)
         ignored_variables, ignored_interactions = _check_params(
-            ignored_variables=['a'])
+            ['a'], None)
         comp_parameters = dict(ignored_interactions=ignored_interactions,
                                ignored_variables=ignored_variables,
                                ignore_offset=True,
@@ -618,7 +598,7 @@ class TestScaleComposite(unittest.TestCase):
         quadratic = {('a', 'b', 'c'): 3.2}
         offset = 5
         ignored_variables, ignored_interactions = _check_params(
-            ignored_variables=['a'])
+            ['a'], None)
         comp_parameters = dict(ignored_interactions=ignored_interactions,
                                ignored_variables=ignored_variables,
                                ignore_offset=True,
diff --git a/tests/test_testing.py b/tests/test_testing.py
new file mode 100644
index 00000000..2947944f
--- /dev/null
+++ b/tests/test_testing.py
@@ -0,0 +1,70 @@
+# 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.
+#
+# =============================================================================
+import unittest
+
+import dimod
+
+
+class Test_assert_almost_equal_bqm(unittest.TestCase):
+    def test_empty(self):
+        bqm0 = dimod.BinaryQuadraticModel.empty(dimod.SPIN)
+        bqm1 = dimod.BinaryQuadraticModel.empty(dimod.SPIN)
+        dimod.testing.assert_bqm_almost_equal(bqm0, bqm1)
+
+    def test_self_empty(self):
+        bqm = dimod.BinaryQuadraticModel.empty(dimod.SPIN)
+        dimod.testing.assert_bqm_almost_equal(bqm, bqm)
+
+    def test_unlike_variables(self):
+        bqm0 = dimod.BinaryQuadraticModel.from_ising({'a': -1}, {})
+        bqm1 = dimod.BinaryQuadraticModel.from_ising({'b': -1}, {})
+        with self.assertRaises(AssertionError):
+            dimod.testing.assert_bqm_almost_equal(bqm0, bqm1)
+
+    def test_unlike_offset(self):
+        bqm0 = dimod.BinaryQuadraticModel.from_ising({'a': -1}, {}, 1.1)
+        bqm1 = dimod.BinaryQuadraticModel.from_ising({'a': -1}, {}, 1.2)
+        with self.assertRaises(AssertionError):
+            dimod.testing.assert_bqm_almost_equal(bqm0, bqm1)
+        dimod.testing.assert_bqm_almost_equal(bqm0, bqm1, places=0)
+
+    def test_unlike_linear(self):
+        bqm0 = dimod.BinaryQuadraticModel.from_ising({'a': -1}, {})
+        bqm1 = dimod.BinaryQuadraticModel.from_ising({'a': -1.1}, {})
+        with self.assertRaises(AssertionError):
+            dimod.testing.assert_bqm_almost_equal(bqm0, bqm1)
+        dimod.testing.assert_bqm_almost_equal(bqm0, bqm1, places=0)
+
+    def test_unlike_interactions(self):
+        bqm0 = dimod.BinaryQuadraticModel.from_ising({}, {'ab': -1})
+        bqm1 = dimod.BinaryQuadraticModel.from_ising({}, {'ab': -1.1})
+        with self.assertRaises(AssertionError):
+            dimod.testing.assert_bqm_almost_equal(bqm0, bqm1)
+        dimod.testing.assert_bqm_almost_equal(bqm0, bqm1, places=0)
+
+    def test_ignore_zero_interactions(self):
+        h = {'a': 0, 'b': 0, 'c': 0, 'd': 0}
+        J0 = {'ab': 0, 'bc': -1}
+        J1 = {'cb': -1, 'cd': 0}
+
+        bqm0 = dimod.BinaryQuadraticModel.from_ising(h, J0)
+        bqm1 = dimod.BinaryQuadraticModel.from_ising(h, J1)
+        with self.assertRaises(AssertionError):
+            dimod.testing.assert_bqm_almost_equal(bqm0, bqm1)
+        dimod.testing.assert_bqm_almost_equal(bqm0, bqm1, ignore_zero_interactions=True)
+        with self.assertRaises(AssertionError):
+            dimod.testing.assert_bqm_almost_equal(bqm1, bqm0)
+        dimod.testing.assert_bqm_almost_equal(bqm1, bqm0, ignore_zero_interactions=True)