Repository: pandas-dev/pandas

Issue:
API: support "unique=True" in MultiIndex.get_level_values()
#### Code Sample, a copy-pastable example if possible

I often find my self doing
```python
In [2]: df = pd.Series(index=pd.MultiIndex.from_product([['A', 'B'], ['a', 'b']]))

In [3]: df.index.get_level_values(0).unique()
Out[3]: Index(['A', 'B'], dtype='object')
```

#### Problem description

The above is very inefficient, because first a ``Series`` is built which includes a copy of the entire level (possibly using way more memory than the index itself), and only then duplicates are stripped. [Other people on SO](https://stackoverflow.com/questions/24495695/pandas-get-unique-multiindex-level-values-by-label#comment44836216_24496435) have faced the same problem, and this is also blocking a fix I wrote for #17845.

I'm pushing a simple PR in seconds.

#### Expected Output

Same as above, but in an efficient way.

#### Output of ``pd.show_versions()``

<details>

INSTALLED VERSIONS
------------------
commit: None
python: 3.5.3.final.0
python-bits: 64
OS: Linux
OS-release: 4.9.0-3-amd64
machine: x86_64
processor: 
byteorder: little
LC_ALL: None
LANG: it_IT.UTF-8
LOCALE: it_IT.UTF-8

pandas: 0.21.0rc1+19.gb15d92d14
pytest: 3.0.6
pip: 9.0.1
setuptools: None
Cython: 0.25.2
numpy: 1.12.1
scipy: 0.19.0
pyarrow: None
xarray: None
IPython: 5.1.0.dev
sphinx: 1.5.6
patsy: 0.4.1
dateutil: 2.6.0
pytz: 2017.2
blosc: None
bottleneck: 1.2.1
tables: 3.3.0
numexpr: 2.6.1
feather: 0.3.1
matplotlib: 2.0.0
openpyxl: None
xlrd: 1.0.0
xlwt: 1.1.2
xlsxwriter: 0.9.6
lxml: None
bs4: 4.5.3
html5lib: 0.999999999
sqlalchemy: 1.0.15
pymysql: None
psycopg2: None
jinja2: 2.9.6
s3fs: None
fastparquet: None
pandas_gbq: None
pandas_datareader: 0.2.1

</details>



Discussion:

Resolving patch:
diff --git a/pandas/core/indexes/base.py b/pandas/core/indexes/base.py
--- a/pandas/core/indexes/base.py
+++ b/pandas/core/indexes/base.py
@@ -3757,8 +3757,32 @@ def drop(self, labels, errors='raise'):
             indexer = indexer[~mask]
         return self.delete(indexer)
 
-    @Appender(base._shared_docs['unique'] % _index_doc_kwargs)
-    def unique(self):
+    _index_shared_docs['index_unique'] = (
+        """
+        Return unique values in the index. Uniques are returned in order
+        of appearance, this does NOT sort.
+
+        Parameters
+        ----------
+        level : int or str, optional, default None
+            Only return values from specified level (for MultiIndex)
+
+            .. versionadded:: 0.22.0
+
+        Returns
+        -------
+        Index without duplicates
+
+        See Also
+        --------
+        unique
+        Series.unique
+        """)
+
+    @Appender(_index_shared_docs['index_unique'] % _index_doc_kwargs)
+    def unique(self, level=None):
+        if level is not None:
+            self._validate_index_level(level)
         result = super(Index, self).unique()
         return self._shallow_copy(result)
 
diff --git a/pandas/core/indexes/category.py b/pandas/core/indexes/category.py
--- a/pandas/core/indexes/category.py
+++ b/pandas/core/indexes/category.py
@@ -378,8 +378,10 @@ def is_monotonic_increasing(self):
     def is_monotonic_decreasing(self):
         return Index(self.codes).is_monotonic_decreasing
 
-    @Appender(base._shared_docs['unique'] % _index_doc_kwargs)
-    def unique(self):
+    @Appender(_index_shared_docs['index_unique'] % _index_doc_kwargs)
+    def unique(self, level=None):
+        if level is not None:
+            self._validate_index_level(level)
         result = base.IndexOpsMixin.unique(self)
         # CategoricalIndex._shallow_copy uses keeps original categories
         # and ordered if not otherwise specified
diff --git a/pandas/core/indexes/multi.py b/pandas/core/indexes/multi.py
--- a/pandas/core/indexes/multi.py
+++ b/pandas/core/indexes/multi.py
@@ -908,7 +908,7 @@ def _try_mi(k):
 
             raise InvalidIndexError(key)
 
-    def _get_level_values(self, level):
+    def _get_level_values(self, level, unique=False):
         """
         Return vector of label values for requested level,
         equal to the length of the index
@@ -918,17 +918,21 @@ def _get_level_values(self, level):
         Parameters
         ----------
         level : int level
+        unique : bool, default False
+            if True, drop duplicated values
 
         Returns
         -------
         values : ndarray
         """
 
-        unique = self.levels[level]
+        values = self.levels[level]
         labels = self.labels[level]
-        filled = algos.take_1d(unique._values, labels,
-                               fill_value=unique._na_value)
-        values = unique._shallow_copy(filled)
+        if unique:
+            labels = algos.unique(labels)
+        filled = algos.take_1d(values._values, labels,
+                               fill_value=values._na_value)
+        values = values._shallow_copy(filled)
         return values
 
     def get_level_values(self, level):
@@ -967,6 +971,15 @@ def get_level_values(self, level):
         values = self._get_level_values(level)
         return values
 
+    @Appender(_index_shared_docs['index_unique'] % _index_doc_kwargs)
+    def unique(self, level=None):
+
+        if level is None:
+            return super(MultiIndex, self).unique()
+        else:
+            level = self._get_level_number(level)
+            return self._get_level_values(level=level, unique=True)
+
     def format(self, space=2, sparsify=None, adjoin=True, names=False,
                na_rep=None, formatter=None):
         if len(self) == 0:


Test patch:
diff --git a/pandas/tests/indexes/common.py b/pandas/tests/indexes/common.py
--- a/pandas/tests/indexes/common.py
+++ b/pandas/tests/indexes/common.py
@@ -329,6 +329,27 @@ def test_duplicates(self, indices):
         assert not idx.is_unique
         assert idx.has_duplicates
 
+    def test_unique(self, indices):
+        # don't test a MultiIndex here (as its tested separated)
+        # don't test a CategoricalIndex because categories change (GH 18291)
+        if isinstance(indices, (MultiIndex, CategoricalIndex)):
+            return
+
+        # GH 17896
+        expected = indices.drop_duplicates()
+        for level in 0, indices.name, None:
+            result = indices.unique(level=level)
+            tm.assert_index_equal(result, expected)
+
+        for level in 3, 'wrong':
+            pytest.raises((IndexError, KeyError), indices.unique, level=level)
+
+    def test_unique_na(self):
+        idx = pd.Index([2, np.nan, 2, 1], name='my_index')
+        expected = pd.Index([2, np.nan, 1], name='my_index')
+        result = idx.unique()
+        tm.assert_index_equal(result, expected)
+
     def test_get_unique_index(self, indices):
         # MultiIndex tested separately
         if not len(indices) or isinstance(indices, MultiIndex):
diff --git a/pandas/tests/indexes/test_multi.py b/pandas/tests/indexes/test_multi.py
--- a/pandas/tests/indexes/test_multi.py
+++ b/pandas/tests/indexes/test_multi.py
@@ -963,19 +963,21 @@ def test_get_level_values(self):
         exp = CategoricalIndex([1, 2, 3, 1, 2, 3])
         tm.assert_index_equal(index.get_level_values(1), exp)
 
-    def test_get_level_values_na(self):
+    @pytest.mark.xfail(reason='GH 17924 (returns Int64Index with float data)')
+    def test_get_level_values_int_with_na(self):
         arrays = [['a', 'b', 'b'], [1, np.nan, 2]]
         index = pd.MultiIndex.from_arrays(arrays)
-        values = index.get_level_values(1)
-        expected = np.array([1, np.nan, 2])
-        tm.assert_numpy_array_equal(values.values.astype(float), expected)
+        result = index.get_level_values(1)
+        expected = Index([1, np.nan, 2])
+        tm.assert_index_equal(result, expected)
 
         arrays = [['a', 'b', 'b'], [np.nan, np.nan, 2]]
         index = pd.MultiIndex.from_arrays(arrays)
-        values = index.get_level_values(1)
-        expected = np.array([np.nan, np.nan, 2])
-        tm.assert_numpy_array_equal(values.values.astype(float), expected)
+        result = index.get_level_values(1)
+        expected = Index([np.nan, np.nan, 2])
+        tm.assert_index_equal(result, expected)
 
+    def test_get_level_values_na(self):
         arrays = [[np.nan, np.nan, np.nan], ['a', np.nan, 1]]
         index = pd.MultiIndex.from_arrays(arrays)
         result = index.get_level_values(0)
@@ -990,7 +992,7 @@ def test_get_level_values_na(self):
         index = pd.MultiIndex.from_arrays(arrays)
         values = index.get_level_values(1)
         expected = pd.DatetimeIndex([0, 1, pd.NaT])
-        tm.assert_numpy_array_equal(values.values, expected.values)
+        tm.assert_index_equal(values, expected)
 
         arrays = [[], []]
         index = pd.MultiIndex.from_arrays(arrays)
@@ -2277,6 +2279,20 @@ def test_unique(self):
         exp = pd.MultiIndex.from_arrays([['a'], ['a']])
         tm.assert_index_equal(res, exp)
 
+    @pytest.mark.parametrize('level', [0, 'first', 1, 'second'])
+    def test_unique_level(self, level):
+        # GH #17896 - with level= argument
+        result = self.index.unique(level=level)
+        expected = self.index.get_level_values(level).unique()
+        tm.assert_index_equal(result, expected)
+
+        # With already unique level
+        mi = pd.MultiIndex.from_arrays([[1, 3, 2, 4], [1, 3, 2, 5]],
+                                       names=['first', 'second'])
+        result = mi.unique(level=level)
+        expected = mi.get_level_values(level)
+        tm.assert_index_equal(result, expected)
+
     def test_unique_datetimelike(self):
         idx1 = pd.DatetimeIndex(['2015-01-01', '2015-01-01', '2015-01-01',
                                  '2015-01-01', 'NaT', 'NaT'])