Repository: pandas-dev/pandas

Issue:
API: add 'level' kwarg for Index.isin method
xref #7886

It would be nice to be able to write:

``` python
s[s.index.isin(['a', 'b', 'c'], level='foo')]
```

instead of

``` python
s[s.index.get_level_values('foo').isin(['a', 'b', 'c'])]
```

The former variant is not only shorter but is also potentially more performant: one only has to look up levels in given container and then operate on labels only avoiding instantiation of sub-index completely.

Default value should be `None`, plain `Index` objects should only accept `None`, `0`, and, `Index.name` for the sake of consistency (the last one is especially arguable).



Discussion:
ok, I think if we put this in we can then revert #7867 

@cpcloud @jorisvandenbossche thoughts?

:+1: here, also agree on @immerrr's choice of defaults.


Resolving patch:
diff --git a/pandas/core/index.py b/pandas/core/index.py
--- a/pandas/core/index.py
+++ b/pandas/core/index.py
@@ -12,7 +12,7 @@
 import pandas.index as _index
 from pandas.lib import Timestamp, is_datetime_array
 from pandas.core.base import FrozenList, FrozenNDArray, IndexOpsMixin
-from pandas.util.decorators import cache_readonly, deprecate
+from pandas.util.decorators import cache_readonly, deprecate, Appender
 from pandas.core.common import isnull, array_equivalent
 import pandas.core.common as com
 from pandas.core.common import (_values_from_object, is_float, is_integer,
@@ -687,13 +687,29 @@ def _engine(self):
         # property, for now, slow to look up
         return self._engine_type(lambda: self.values, len(self))
 
+    def _validate_index_level(self, level):
+        """
+        Validate index level.
+
+        For single-level Index getting level number is a no-op, but some
+        verification must be done like in MultiIndex.
+
+        """
+        if isinstance(level, int):
+            if level < 0 and level != -1:
+                raise IndexError("Too many levels: Index has only 1 level,"
+                                 " %d is not a valid level number" % (level,))
+            elif level > 0:
+                raise IndexError("Too many levels:"
+                                 " Index has only 1 level, not %d" %
+                                 (level + 1))
+        elif level != self.name:
+            raise KeyError('Level %s must be same as name (%s)'
+                           % (level, self.name))
+
     def _get_level_number(self, level):
-        if not isinstance(level, int):
-            if level != self.name:
-                raise AssertionError('Level %s must be same as name (%s)'
-                                     % (level, self.name))
-            level = 0
-        return level
+        self._validate_index_level(level)
+        return 0
 
     @cache_readonly
     def inferred_type(self):
@@ -1271,7 +1287,7 @@ def get_level_values(self, level):
         values : ndarray
         """
         # checks that level number is actually just 1
-        self._get_level_number(level)
+        self._validate_index_level(level)
         return self
 
     def get_indexer(self, target, method=None, limit=None):
@@ -1370,7 +1386,7 @@ def groupby(self, to_groupby):
     def map(self, mapper):
         return self._arrmap(self.values, mapper)
 
-    def isin(self, values):
+    def isin(self, values, level=None):
         """
         Compute boolean array of whether each index value is found in the
         passed set of values
@@ -1378,12 +1394,26 @@ def isin(self, values):
         Parameters
         ----------
         values : set or sequence of values
+            Sought values.
+        level : str or int, optional
+            Name or position of the index level to use (if the index is a
+            MultiIndex).
+
+        Notes
+        -----
+        If `level` is specified:
+
+        - if it is the name of one *and only one* index level, use that level;
+        - otherwise it should be a number indicating level position.
 
         Returns
         -------
         is_contained : ndarray (boolean dtype)
+
         """
         value_set = set(values)
+        if level is not None:
+            self._validate_index_level(level)
         return lib.ismember(self._array_values(), value_set)
 
     def _array_values(self):
@@ -2149,20 +2179,11 @@ def hasnans(self):
     def is_unique(self):
         return super(Float64Index, self).is_unique and self._nan_idxs.size < 2
 
-    def isin(self, values):
-        """
-        Compute boolean array of whether each index value is found in the
-        passed set of values
-
-        Parameters
-        ----------
-        values : set or sequence of values
-
-        Returns
-        -------
-        is_contained : ndarray (boolean dtype)
-        """
+    @Appender(Index.isin.__doc__)
+    def isin(self, values, level=None):
         value_set = set(values)
+        if level is not None:
+            self._validate_index_level(level)
         return lib.ismember_nans(self._array_values(), value_set,
                                  isnull(list(value_set)).any())
 
@@ -4052,6 +4073,21 @@ def _wrap_joined_index(self, joined, other):
         names = self.names if self.names == other.names else None
         return MultiIndex.from_tuples(joined, names=names)
 
+    @Appender(Index.isin.__doc__)
+    def isin(self, values, level=None):
+        if level is None:
+            return lib.ismember(self._array_values(), set(values))
+        else:
+            num = self._get_level_number(level)
+            levs = self.levels[num]
+            labs = self.labels[num]
+
+            sought_labels = levs.isin(values).nonzero()[0]
+            if levs.size == 0:
+                return np.zeros(len(labs), dtype=np.bool_)
+            else:
+                return np.lib.arraysetops.in1d(labs, sought_labels)
+
 
 # For utility purposes
 


Test patch:
diff --git a/pandas/tests/test_index.py b/pandas/tests/test_index.py
--- a/pandas/tests/test_index.py
+++ b/pandas/tests/test_index.py
@@ -840,7 +840,7 @@ def test_get_set_value(self):
         self.assertEqual(values[67], 10)
 
     def test_isin(self):
-        values = ['foo', 'bar']
+        values = ['foo', 'bar', 'quux']
 
         idx = Index(['qux', 'baz', 'foo', 'bar'])
         result = idx.isin(values)
@@ -853,6 +853,49 @@ def test_isin(self):
         self.assertEqual(len(result), 0)
         self.assertEqual(result.dtype, np.bool_)
 
+    def test_isin_nan(self):
+        self.assert_numpy_array_equal(
+            Index(['a', np.nan]).isin([np.nan]), [False, True])
+        self.assert_numpy_array_equal(
+            Index(['a', pd.NaT]).isin([pd.NaT]), [False, True])
+        self.assert_numpy_array_equal(
+            Index(['a', np.nan]).isin([float('nan')]), [False, False])
+        self.assert_numpy_array_equal(
+            Index(['a', np.nan]).isin([pd.NaT]), [False, False])
+        # Float64Index overrides isin, so must be checked separately
+        self.assert_numpy_array_equal(
+            Float64Index([1.0, np.nan]).isin([np.nan]), [False, True])
+        self.assert_numpy_array_equal(
+            Float64Index([1.0, np.nan]).isin([float('nan')]), [False, True])
+        self.assert_numpy_array_equal(
+            Float64Index([1.0, np.nan]).isin([pd.NaT]), [False, True])
+
+    def test_isin_level_kwarg(self):
+        def check_idx(idx):
+            values = idx.tolist()[-2:] + ['nonexisting']
+
+            expected = np.array([False, False, True, True])
+            self.assert_numpy_array_equal(expected, idx.isin(values, level=0))
+            self.assert_numpy_array_equal(expected, idx.isin(values, level=-1))
+
+            self.assertRaises(IndexError, idx.isin, values, level=1)
+            self.assertRaises(IndexError, idx.isin, values, level=10)
+            self.assertRaises(IndexError, idx.isin, values, level=-2)
+
+            self.assertRaises(KeyError, idx.isin, values, level=1.0)
+            self.assertRaises(KeyError, idx.isin, values, level='foobar')
+
+            idx.name = 'foobar'
+            self.assert_numpy_array_equal(expected,
+                                          idx.isin(values, level='foobar'))
+
+            self.assertRaises(KeyError, idx.isin, values, level='xyzzy')
+            self.assertRaises(KeyError, idx.isin, values, level=np.nan)
+
+        check_idx(Index(['qux', 'baz', 'foo', 'bar']))
+        # Float64Index overrides isin, so must be checked separately
+        check_idx(Float64Index([1.0, 2.0, 3.0, 4.0]))
+
     def test_boolean_cmp(self):
         values = [1, 2, 3, 4]
 
@@ -2948,6 +2991,55 @@ def test_level_setting_resets_attributes(self):
         # if this fails, probably didn't reset the cache correctly.
         assert not ind.is_monotonic
 
+    def test_isin(self):
+        values = [('foo', 2), ('bar', 3), ('quux', 4)]
+
+        idx = MultiIndex.from_arrays([['qux', 'baz', 'foo', 'bar'],
+                                      np.arange(4)])
+        result = idx.isin(values)
+        expected = np.array([False, False, True, True])
+        self.assert_numpy_array_equal(result, expected)
+
+        # empty, return dtype bool
+        idx = MultiIndex.from_arrays([[], []])
+        result = idx.isin(values)
+        self.assertEqual(len(result), 0)
+        self.assertEqual(result.dtype, np.bool_)
+
+    def test_isin_nan(self):
+        idx = MultiIndex.from_arrays([['foo', 'bar'], [1.0, np.nan]])
+        self.assert_numpy_array_equal(idx.isin([('bar', np.nan)]),
+                                      [False, False])
+        self.assert_numpy_array_equal(idx.isin([('bar', float('nan'))]),
+                                      [False, False])
+
+    def test_isin_level_kwarg(self):
+        idx = MultiIndex.from_arrays([['qux', 'baz', 'foo', 'bar'],
+                                      np.arange(4)])
+
+        vals_0 = ['foo', 'bar', 'quux']
+        vals_1 = [2, 3, 10]
+
+        expected = np.array([False, False, True, True])
+        self.assert_numpy_array_equal(expected, idx.isin(vals_0, level=0))
+        self.assert_numpy_array_equal(expected, idx.isin(vals_0, level=-2))
+
+        self.assert_numpy_array_equal(expected, idx.isin(vals_1, level=1))
+        self.assert_numpy_array_equal(expected, idx.isin(vals_1, level=-1))
+
+        self.assertRaises(IndexError, idx.isin, vals_0, level=5)
+        self.assertRaises(IndexError, idx.isin, vals_0, level=-5)
+
+        self.assertRaises(KeyError, idx.isin, vals_0, level=1.0)
+        self.assertRaises(KeyError, idx.isin, vals_1, level=-1.0)
+        self.assertRaises(KeyError, idx.isin, vals_1, level='A')
+
+        idx.names = ['A', 'B']
+        self.assert_numpy_array_equal(expected, idx.isin(vals_0, level='A'))
+        self.assert_numpy_array_equal(expected, idx.isin(vals_1, level='B'))
+
+        self.assertRaises(KeyError, idx.isin, vals_1, level='C')
+
 
 def test_get_combined_index():
     from pandas.core.index import _get_combined_index