Repository: dwavesystems/dimod

Issue:
Inconsistent behavior in `BQM.relabel_variables` and `SampleSet.relabel_variables`
**Description**
If variable labels that were not present in the original BQM are passed into `BQM.relabel_variables`, then they are ignored (which seems reasonable to me). However, `SampleSet.relabel_variables` will raise an error in the same situation.

**Steps To Reproduce**
```python
In [25]: bqm = dimod.BinaryQuadraticModel.from_qubo({(0, 1): 1})                                                                                                                         

In [26]: bqm.relabel_variables({0: "a", 1: "b", 2: "c"}, inplace=False)                                                                                                                  
Out[26]: BinaryQuadraticModel({'a': 0, 'b': 0}, {('a', 'b'): 1}, 0.0, Vartype.BINARY)

In [27]: ss = dimod.ExactSolver().sample(bqm)                                                                                                                                            

In [28]: ss.relabel_variables({0: "a", 1: "b", 2: "c"})                                                                                                                                  
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
<ipython-input-28-a428957af354> in <module>
----> 1 ss.relabel_variables({0: "a", 1: "b", 2: "c"})

/opt/miniconda3/envs/three/lib/python3.7/site-packages/dimod/sampleset.py in relabel_variables(self, mapping, inplace)
    974             return self.copy().relabel_variables(mapping, inplace=True)
    975 
--> 976         self.variables.relabel(mapping)
    977         return self
    978 

/opt/miniconda3/envs/three/lib/python3.7/site-packages/dimod/variables.py in relabel(self, mapping)
    123 
    124         for old, new in mapping.items():
--> 125             label[index[old]] = new
    126 
    127             index[new] = index[old]

KeyError: 2
```

**Expected Behavior**
I think these should certainly be consistent. Personally, I like the `BQM.relabel_variables` behavior of ignoring unused labels more.

Discussion:

Patch:
diff --git a/dimod/sampleset.py b/dimod/sampleset.py
index 48f0ba39..29e3ebba 100644
--- a/dimod/sampleset.py
+++ b/dimod/sampleset.py
@@ -1154,8 +1154,8 @@ class SampleSet(abc.Iterable, abc.Sized):
 
             sorted_by (str/None, optional, default='energy'):
                 Selects the record field used to sort the samples before
-                truncating. Note that sample order is maintained in the
-                underlying array.
+                truncating. Note that this sort order is maintained in the
+                returned SampleSet.
 
         Returns:
             :obj:`.SampleSet`
@@ -1179,14 +1179,95 @@ class SampleSet(abc.Iterable, abc.Sized):
             1 +1 +1 +1 +1 +1      5       1
             ['SPIN', 2 rows, 2 samples, 5 variables]
 
+        See:
+            :meth:`SampleSet.slice`
+
         """
-        record = self.record
+        return self.slice(n, sorted_by=sorted_by)
+
+    def slice(self, *slice_args, **kwargs):
+        """Create a new SampleSet with rows sliced according to standard Python
+        slicing syntax.
+
+        Args:
+            start (int, optional, default=None):
+                Start index for `slice`.
+
+            stop (int):
+                Stop index for `slice`.
+
+            step (int, optional, default=None):
+                Step value for `slice`.
+
+            sorted_by (str/None, optional, default='energy'):
+                Selects the record field used to sort the samples before
+                slicing. Note that `sorted_by` determines the sample order in
+                the returned SampleSet.
+
+        Returns:
+            :obj:`.SampleSet`
+
+        Examples:
+
+            >>> import numpy as np
+            ...
+            >>> sampleset = dimod.SampleSet.from_samples(np.diag(range(1, 11)), dimod.BINARY, energy=range(10))
+            >>> print(sampleset)
+               0  1  2  3  4  5  6  7  8  9 energy num_oc.
+            0  1  0  0  0  0  0  0  0  0  0      0       1
+            1  0  1  0  0  0  0  0  0  0  0      1       1
+            2  0  0  1  0  0  0  0  0  0  0      2       1
+            3  0  0  0  1  0  0  0  0  0  0      3       1
+            4  0  0  0  0  1  0  0  0  0  0      4       1
+            5  0  0  0  0  0  1  0  0  0  0      5       1
+            6  0  0  0  0  0  0  1  0  0  0      6       1
+            7  0  0  0  0  0  0  0  1  0  0      7       1
+            8  0  0  0  0  0  0  0  0  1  0      8       1
+            9  0  0  0  0  0  0  0  0  0  1      9       1
+            ['BINARY', 10 rows, 10 samples, 10 variables]
+
+            >>> # the first 3 samples by energy == truncate(3)
+            >>> print(sampleset.slice(3))
+               0  1  2  3  4  5  6  7  8  9 energy num_oc.
+            0  1  0  0  0  0  0  0  0  0  0      0       1
+            1  0  1  0  0  0  0  0  0  0  0      1       1
+            2  0  0  1  0  0  0  0  0  0  0      2       1
+            ['BINARY', 3 rows, 3 samples, 10 variables]
+
+            >>> # the last 3 samples by energy
+            >>> print(sampleset.slice(-3, None))
+               0  1  2  3  4  5  6  7  8  9 energy num_oc.
+            0  0  0  0  0  0  0  0  1  0  0      7       1
+            1  0  0  0  0  0  0  0  0  1  0      8       1
+            2  0  0  0  0  0  0  0  0  0  1      9       1
+            ['BINARY', 3 rows, 3 samples, 10 variables]
+
+            >>> # every second sample in between (skip the top and the bottom 3)
+            >>> print(sampleset.slice(3, -3, 2))
+               0  1  2  3  4  5  6  7  8  9 energy num_oc.
+            0  0  0  0  1  0  0  0  0  0  0      3       1
+            1  0  0  0  0  0  1  0  0  0  0      5       1
+            ['BINARY', 2 rows, 2 samples, 10 variables]
+
+        """
+        # handle `sorted_by` kwarg with a default value in a python2-compatible way
+        sorted_by = kwargs.pop('sorted_by', 'energy')
+        if kwargs:
+            # be strict about allowed kwargs: throw the same error as python3 would
+            raise TypeError('slice got an unexpected '
+                            'keyword argument {!r}'.format(kwargs.popitem()[0]))
+
+        # follow Python's slice syntax
+        if slice_args:
+            selector = slice(*slice_args)
+        else:
+            selector = slice(None)
 
         if sorted_by is None:
-            record = record[:n]
+            record = self.record[selector]
         else:
-            sort_indices = np.argsort(record[sorted_by])
-            record = record[sort_indices[:n]]
+            sort_indices = np.argsort(self.record[sorted_by])
+            record = self.record[sort_indices[selector]]
 
         return type(self)(record, self.variables, copy.deepcopy(self.info),
                           self.vartype)
diff --git a/dimod/variables.py b/dimod/variables.py
index 11a65ecf..810b09a4 100644
--- a/dimod/variables.py
+++ b/dimod/variables.py
@@ -133,6 +133,9 @@ class Variables(abc.Sequence, abc.Set):
         index = self.index
 
         for old, new in mapping.items():
+            if old not in self:
+                continue
+
             label[index[old]] = new
             index[new] = index[old]
             del index[old]


Test patch:
diff --git a/tests/test_sampleset.py b/tests/test_sampleset.py
index 176627ee..d4379a4b 100644
--- a/tests/test_sampleset.py
+++ b/tests/test_sampleset.py
@@ -412,6 +412,65 @@ class TestTruncate(unittest.TestCase):
                     self.assertEqual(val, 1)
 
 
+class TestSlice(unittest.TestCase):
+    def test_typical(self):
+        bqm = dimod.BinaryQuadraticModel.from_ising({v: -1 for v in range(100)}, {})
+        sampleset = dimod.SampleSet.from_samples_bqm(np.tril(np.ones(100)), bqm.binary)
+
+        # `:10` is equal to `truncate(10)`
+        self.assertEqual(sampleset.slice(10), sampleset.truncate(10))
+
+    def test_unordered(self):
+        bqm = dimod.BinaryQuadraticModel.from_ising({v: -1 for v in range(100)}, {})
+        sampleset = dimod.SampleSet.from_samples_bqm(np.triu(np.ones(100)), bqm.binary)
+
+        # `:10` but for the unordered case
+        self.assertEqual(sampleset.slice(10, sorted_by=None), sampleset.truncate(10, sorted_by=None))
+
+    def test_null_slice(self):
+        energies = list(range(10))
+        sampleset = dimod.SampleSet.from_samples(np.ones((10, 1)), dimod.SPIN, energy=energies)
+
+        self.assertTrue((sampleset.slice().record.energy == energies).all())
+
+    def test_slice_stop_only(self):
+        energies = list(range(10))
+        sampleset = dimod.SampleSet.from_samples(np.ones((10, 1)), dimod.SPIN, energy=energies)
+
+        self.assertTrue((sampleset.slice(3).record.energy == energies[:3]).all())
+        self.assertTrue((sampleset.slice(-3).record.energy == energies[:-3]).all())
+
+    def test_slice_range(self):
+        energies = list(range(10))
+        sampleset = dimod.SampleSet.from_samples(np.ones((10, 1)), dimod.SPIN, energy=energies)
+
+        self.assertTrue((sampleset.slice(3, 5).record.energy == energies[3:5]).all())
+        self.assertTrue((sampleset.slice(3, -3).record.energy == energies[3:-3]).all())
+        self.assertTrue((sampleset.slice(-3, None).record.energy == energies[-3:]).all())
+
+    def test_slice_stride(self):
+        energies = list(range(10))
+        sampleset = dimod.SampleSet.from_samples(np.ones((10, 1)), dimod.SPIN, energy=energies)
+
+        self.assertTrue((sampleset.slice(3, -3, 2).record.energy == energies[3:-3:2]).all())
+        self.assertTrue((sampleset.slice(None, None, 2).record.energy == energies[::2]).all())
+        self.assertTrue((sampleset.slice(None, None, -1).record.energy == energies[::-1]).all())
+
+    def test_custom_ordering(self):
+        custom = list(range(10))
+        sampleset = dimod.SampleSet.from_samples(np.ones((10, 1)), dimod.SPIN, energy=None, custom=custom)
+
+        self.assertTrue((sampleset.slice(3, sorted_by='custom').record.custom == custom[:3]).all())
+        self.assertTrue((sampleset.slice(3, -3, sorted_by='custom').record.custom == custom[3:-3]).all())
+        self.assertTrue((sampleset.slice(None, None, -1, sorted_by='custom').record.custom == custom[::-1]).all())
+
+    def test_kwargs(self):
+        sampleset = dimod.SampleSet.from_samples(np.ones((10, 1)), dimod.SPIN, energy=None)
+
+        with self.assertRaises(TypeError):
+            sampleset.slice(1, sortedby='invalid-kwarg')
+
+
 class TestIteration(unittest.TestCase):
     def test_data_reverse(self):
         bqm = dimod.BinaryQuadraticModel.from_ising({}, {'ab': -1})
diff --git a/tests/test_variables.py b/tests/test_variables.py
index 532c99ed..6e5f3370 100644
--- a/tests/test_variables.py
+++ b/tests/test_variables.py
@@ -43,6 +43,16 @@ class TestDuplicates(unittest.TestCase):
         self.assertEqual(len(variables), 1)
 
 
+class TestRelabel(unittest.TestCase):
+    def test_permissive(self):
+        variables = Variables([0, 1])
+
+        # relabels a non-existant variable 2
+        variables.relabel({0: 'a', 1: 'b', 2: 'c'})
+
+        self.assertEqual(variables, Variables('ab'))
+
+
 class TestList(unittest.TestCase):
     iterable = list(range(5))