Repository: pandas-dev/pandas

Issue:
Grouping by index and column fails on DataFrame with single index
Referenced in #5677
#### Example

The following snippet shows how a MultiIndex DataFrame (`df`) may be grouped by a combination of a column (`B`) and a named index level (`inner`) using a `Grouper` object.

``` python
import pandas as pd
import numpy as np

idx = pd.MultiIndex.from_tuples([('a', 1), ('a', 2), ('a', 3), ('b', 1), ('b', 2), ('b', 3)])
idx.names = ['outer', 'inner']
df = pd.DataFrame({"A": np.arange(6), 'B': ['one', 'one', 'two', 'two', 'one', 'one']}, index=idx)

In [1]: df
Out[1]: 
             A    B
outer inner        
a     1      0  one
      2      1  one
      3      2  two
b     1      3  two
      2      4  one
      3      5  one

In [2]: df.groupby(['B', pd.Grouper(level='inner')]).mean()
Out [2]: 
             A
B   inner     
one 1      0.0
    2      2.5
    3      5.0
two 1      3.0
    3      2.0
```

However, when the DataFrame (`df2`) has only a single index level an `AttributeError` is thrown

``` python
In [3]: df2 = df.reset_index('outer')

In [4]: df2
Out [4]: 
      outer  A    B
inner              
1         a  0  one
2         a  1  one
3         a  2  two
1         b  3  two
2         b  4  one
3         b  5  one

In [5]: df2.groupby(['B', pd.Grouper(level='inner')]).mean()  

...
AttributeError: 'Int64Index' object has no attribute 'labels'
```
#### Expected Output

``` python
In [2]: df2.groupby(['B', pd.Grouper(level='inner')]).mean()
Out [2]: 
             A
B   inner     
one 1      0.0
    2      2.5
    3      5.0
two 1      3.0
    3      2.0
```
#### Output of `pd.show_versions()`

<details>

```
INSTALLED VERSIONS
------------------
commit: None
python: 3.5.2.final.0
python-bits: 64
OS: Darwin
OS-release: 15.6.0
machine: x86_64
processor: i386
byteorder: little
LC_ALL: None
LANG: en_US.UTF-8

pandas: 0.18.1
nose: 1.3.7
pip: 8.1.2
setuptools: 23.0.0
Cython: 0.24
numpy: 1.11.1
scipy: 0.17.1
statsmodels: 0.6.1
xarray: None
IPython: 4.2.0
sphinx: 1.4.1
patsy: 0.4.1
dateutil: 2.5.3
pytz: 2016.4
blosc: 1.4.1
bottleneck: 1.1.0
tables: 3.2.2
numexpr: 2.6.0
matplotlib: 1.5.1
openpyxl: 2.3.2
xlrd: 1.0.0
xlwt: 1.1.2
xlsxwriter: 0.9.2
lxml: 3.6.0
bs4: 4.4.1
html5lib: 0.999
httplib2: None
apiclient: None
sqlalchemy: 1.0.13
pymysql: None
psycopg2: None
jinja2: 2.8
boto: 2.40.0
pandas_datareader: None
```

</details>



Discussion:

Resolving patch:
diff --git a/pandas/core/groupby.py b/pandas/core/groupby.py
--- a/pandas/core/groupby.py
+++ b/pandas/core/groupby.py
@@ -2201,36 +2201,12 @@ def __init__(self, index, grouper=None, obj=None, name=None, level=None,
                     raise AssertionError('Level %s not in index' % str(level))
                 level = index.names.index(level)
 
-            inds = index.labels[level]
-            level_index = index.levels[level]
-
             if self.name is None:
                 self.name = index.names[level]
 
-            # XXX complete hack
-
-            if grouper is not None:
-                level_values = index.levels[level].take(inds)
-                self.grouper = level_values.map(self.grouper)
-            else:
-                # all levels may not be observed
-                labels, uniques = algos.factorize(inds, sort=True)
-
-                if len(uniques) > 0 and uniques[0] == -1:
-                    # handle NAs
-                    mask = inds != -1
-                    ok_labels, uniques = algos.factorize(inds[mask], sort=True)
-
-                    labels = np.empty(len(inds), dtype=inds.dtype)
-                    labels[mask] = ok_labels
-                    labels[~mask] = -1
-
-                if len(uniques) < len(level_index):
-                    level_index = level_index.take(uniques)
+            self.grouper, self._labels, self._group_index = \
+                index._get_grouper_for_level(self.grouper, level)
 
-                self._labels = labels
-                self._group_index = level_index
-                self.grouper = level_index.take(labels)
         else:
             if isinstance(self.grouper, (list, tuple)):
                 self.grouper = com._asarray_tuplesafe(self.grouper)
diff --git a/pandas/indexes/base.py b/pandas/indexes/base.py
--- a/pandas/indexes/base.py
+++ b/pandas/indexes/base.py
@@ -432,6 +432,36 @@ def _update_inplace(self, result, **kwargs):
         # guard when called from IndexOpsMixin
         raise TypeError("Index can't be updated inplace")
 
+    _index_shared_docs['_get_grouper_for_level'] = """
+        Get index grouper corresponding to an index level
+
+        Parameters
+        ----------
+        mapper: Group mapping function or None
+            Function mapping index values to groups
+        level : int or None
+            Index level
+
+        Returns
+        -------
+        grouper : Index
+            Index of values to group on
+        labels : ndarray of int or None
+            Array of locations in level_index
+        uniques : Index or None
+            Index of unique values for level
+        """
+
+    @Appender(_index_shared_docs['_get_grouper_for_level'])
+    def _get_grouper_for_level(self, mapper, level=None):
+        assert level is None or level == 0
+        if mapper is None:
+            grouper = self
+        else:
+            grouper = self.map(mapper)
+
+        return grouper, None, None
+
     def is_(self, other):
         """
         More flexible, faster check like ``is`` but that works through views
diff --git a/pandas/indexes/multi.py b/pandas/indexes/multi.py
--- a/pandas/indexes/multi.py
+++ b/pandas/indexes/multi.py
@@ -539,6 +539,37 @@ def _format_native_types(self, na_rep='nan', **kwargs):
 
         return mi.values
 
+    @Appender(_index_shared_docs['_get_grouper_for_level'])
+    def _get_grouper_for_level(self, mapper, level):
+        indexer = self.labels[level]
+        level_index = self.levels[level]
+
+        if mapper is not None:
+            # Handle group mapping function and return
+            level_values = self.levels[level].take(indexer)
+            grouper = level_values.map(mapper)
+            return grouper, None, None
+
+        labels, uniques = algos.factorize(indexer, sort=True)
+
+        if len(uniques) > 0 and uniques[0] == -1:
+            # Handle NAs
+            mask = indexer != -1
+            ok_labels, uniques = algos.factorize(indexer[mask],
+                                                 sort=True)
+
+            labels = np.empty(len(indexer), dtype=indexer.dtype)
+            labels[mask] = ok_labels
+            labels[~mask] = -1
+
+        if len(uniques) < len(level_index):
+            # Remove unobserved levels from level_index
+            level_index = level_index.take(uniques)
+
+        grouper = level_index.take(labels)
+
+        return grouper, labels, level_index
+
     @property
     def _constructor(self):
         return MultiIndex.from_tuples


Test patch:
diff --git a/pandas/tests/test_groupby.py b/pandas/tests/test_groupby.py
--- a/pandas/tests/test_groupby.py
+++ b/pandas/tests/test_groupby.py
@@ -458,6 +458,39 @@ def test_grouper_creation_bug(self):
         expected = s.groupby(level='one').sum()
         assert_series_equal(result, expected)
 
+    def test_grouper_column_and_index(self):
+        # GH 14327
+
+        # Grouping a multi-index frame by a column and an index level should
+        # be equivalent to resetting the index and grouping by two columns
+        idx = pd.MultiIndex.from_tuples([('a', 1), ('a', 2), ('a', 3),
+                                         ('b', 1), ('b', 2), ('b', 3)])
+        idx.names = ['outer', 'inner']
+        df_multi = pd.DataFrame({"A": np.arange(6),
+                                 'B': ['one', 'one', 'two',
+                                       'two', 'one', 'one']},
+                                index=idx)
+        result = df_multi.groupby(['B', pd.Grouper(level='inner')]).mean()
+        expected = df_multi.reset_index().groupby(['B', 'inner']).mean()
+        assert_frame_equal(result, expected)
+
+        # Test the reverse grouping order
+        result = df_multi.groupby([pd.Grouper(level='inner'), 'B']).mean()
+        expected = df_multi.reset_index().groupby(['inner', 'B']).mean()
+        assert_frame_equal(result, expected)
+
+        # Grouping a single-index frame by a column and the index should
+        # be equivalent to resetting the index and grouping by two columns
+        df_single = df_multi.reset_index('outer')
+        result = df_single.groupby(['B', pd.Grouper(level='inner')]).mean()
+        expected = df_single.reset_index().groupby(['B', 'inner']).mean()
+        assert_frame_equal(result, expected)
+
+        # Test the reverse grouping order
+        result = df_single.groupby([pd.Grouper(level='inner'), 'B']).mean()
+        expected = df_single.reset_index().groupby(['inner', 'B']).mean()
+        assert_frame_equal(result, expected)
+
     def test_grouper_getting_correct_binner(self):
 
         # GH 10063