Repository: dask/dask

Issue:
SeriesGroupBy.apply raises AttributeError for '_contains_index_name'
```
import dask.dataframe as dd
import pandas as pd
testdf = pd.DataFrame([[1, 2]], columns=['a', 'b'])
testdd = dd.from_pandas(testdf, npartitions=1).set_index('a')
testdd.b.groupby('a').apply(lambda d: d)
```

Results in the error:

```
... python3.6/site-packages/dask/dataframe/groupby.py in apply(self, func, *args, **kwargs)
   1112         df = self.obj
   1113         should_shuffle = not (df.known_divisions and
-> 1114                               df._contains_index_name(self.index))
   1115 
   1116         if should_shuffle:

AttributeError: 'Series' object has no attribute '_contains_index_name'
```

The error also occurs if I provide the meta object: `testdd.b.groupby('a').apply(lambda d: d, meta=('b', 'f8'))`.



Discussion:
https://github.com/dask/dask/blob/4bcd0fb6cc0add749d6f37b0bd86ef7dea1dab9d/dask/dataframe/groupby.py#L1079 seems to think that `self.obj` is always a dataframe, which may not always be correct.

If you use `testdd.groupby('a').b.apply(lambda d: d)`, then things work because it is a DataFrame. We'll need to update `.apply` to gracefully handle Series objects.


Resolving patch:
diff --git a/dask/dataframe/core.py b/dask/dataframe/core.py
--- a/dask/dataframe/core.py
+++ b/dask/dataframe/core.py
@@ -1845,6 +1845,28 @@ def values(self):
         """
         return self.map_partitions(methods.values)
 
+    def _is_index_level_reference(self, key):
+        """
+        Test whether a key is an index level reference
+
+        To be considered an index level reference, `key` must match the index name
+        and must NOT match the name of any column (if a dataframe).
+        """
+        return (self.index.name is not None and
+                not is_dask_collection(key) and
+                (np.isscalar(key) or isinstance(key, tuple)) and
+                key == self.index.name and
+                key not in getattr(self, 'columns', ()))
+
+    def _contains_index_name(self, columns_or_index):
+        """
+        Test whether the input contains a reference to the index of the DataFrame/Series
+        """
+        if isinstance(columns_or_index, list):
+            return any(self._is_index_level_reference(n) for n in columns_or_index)
+        else:
+            return self._is_index_level_reference(columns_or_index)
+
 
 def _raise_if_object_series(x, funcname):
     """
@@ -3334,28 +3356,6 @@ def _is_column_label_reference(self, key):
                 (np.isscalar(key) or isinstance(key, tuple)) and
                 key in self.columns)
 
-    def _is_index_level_reference(self, key):
-        """
-        Test whether a key is an index level reference
-
-        To be considered an index level reference, `key` must match the index name
-        and must NOT match the name of any column.
-        """
-        return (self.index.name is not None and
-                not is_dask_collection(key) and
-                (np.isscalar(key) or isinstance(key, tuple)) and
-                key == self.index.name and
-                key not in self.columns)
-
-    def _contains_index_name(self, columns_or_index):
-        """
-        Test whether the input contains a reference to the index of the DataFrame
-        """
-        if isinstance(columns_or_index, list):
-            return any(self._is_index_level_reference(n) for n in columns_or_index)
-        else:
-            return self._is_index_level_reference(columns_or_index)
-
 
 # bind operators
 for op in [operator.abs, operator.add, operator.and_, operator_div,
diff --git a/dask/dataframe/groupby.py b/dask/dataframe/groupby.py
--- a/dask/dataframe/groupby.py
+++ b/dask/dataframe/groupby.py
@@ -1141,6 +1141,13 @@ def apply(self, func, *args, **kwargs):
                               df._contains_index_name(self.index))
 
         if should_shuffle:
+            if isinstance(self.obj, Series):
+                # Temporarily convert series to dataframe for shuffle
+                df = df.to_frame('__series__')
+                convert_back_to_series = True
+            else:
+                convert_back_to_series = False
+
             if isinstance(self.index, DataFrame):  # add index columns to dataframe
                 df2 = df.assign(**{'_index_' + c: self.index[c]
                                    for c in self.index.columns})
@@ -1153,27 +1160,32 @@ def apply(self, func, *args, **kwargs):
                 index = df._select_columns_or_index(self.index)
 
             df3 = shuffle(df2, index)  # shuffle dataframe and index
-        else:
-            df3 = df
-
-        if should_shuffle and isinstance(self.index, DataFrame):
-            # extract index from dataframe
-            cols = ['_index_' + c for c in self.index.columns]
-            index2 = df3[cols]
-            if is_dataframe_like(meta):
-                df4 = df3.map_partitions(drop_columns, cols, meta.columns.dtype)
-            else:
-                df4 = df3.drop(cols, axis=1)
-        elif should_shuffle and isinstance(self.index, Series):
-            index2 = df3['_index']
-            index2.name = self.index.name
-            if is_dataframe_like(meta):
-                df4 = df3.map_partitions(drop_columns, '_index',
-                                         meta.columns.dtype)
+
+            if isinstance(self.index, DataFrame):
+                # extract index from dataframe
+                cols = ['_index_' + c for c in self.index.columns]
+                index2 = df3[cols]
+                if is_dataframe_like(meta):
+                    df4 = df3.map_partitions(drop_columns, cols, meta.columns.dtype)
+                else:
+                    df4 = df3.drop(cols, axis=1)
+            elif isinstance(self.index, Series):
+                index2 = df3['_index']
+                index2.name = self.index.name
+                if is_dataframe_like(meta):
+                    df4 = df3.map_partitions(drop_columns, '_index',
+                                             meta.columns.dtype)
+                else:
+                    df4 = df3.drop('_index', axis=1)
             else:
-                df4 = df3.drop('_index', axis=1)
+                df4 = df3
+                index2 = self.index
+
+            if convert_back_to_series:
+                df4 = df4['__series__'].rename(self.obj.name)
+
         else:
-            df4 = df3
+            df4 = df
             index2 = self.index
 
         # Perform embarrassingly parallel groupby-apply


Test patch:
diff --git a/dask/dataframe/tests/test_groupby.py b/dask/dataframe/tests/test_groupby.py
--- a/dask/dataframe/tests/test_groupby.py
+++ b/dask/dataframe/tests/test_groupby.py
@@ -239,6 +239,9 @@ def func(df):
     def func2(df):
         return df[['b']] - df[['b']].mean()
 
+    def func3(df):
+        return df.mean()
+
     with dask.config.set(scheduler=scheduler):
         with pytest.warns(None):
             assert_eq(ddf.groupby('a').apply(func),
@@ -250,6 +253,12 @@ def func2(df):
             assert_eq(pdf2.groupby(pdf2.index).apply(func2),
                       ddf2.groupby(ddf2.index).apply(func2))
 
+            assert_eq(ddf2.b.groupby('a').apply(func3),
+                      pdf2.b.groupby('a').apply(func3))
+
+            assert_eq(ddf2.b.groupby(ddf2.index).apply(func3),
+                      pdf2.b.groupby(pdf2.index).apply(func3))
+
 
 @pytest.mark.parametrize('grouper',
                          [lambda df: df.groupby('a')['b'],
@@ -673,6 +682,13 @@ def test_apply_shuffle():
         assert_eq(ddf.groupby(ddf['A'] + 1)['B'].apply(lambda x: x.sum()),
                   pdf.groupby(pdf['A'] + 1)['B'].apply(lambda x: x.sum()))
 
+        # Series.groupby
+        assert_eq(ddf.B.groupby(ddf['A']).apply(lambda x: x.sum()),
+                  pdf.B.groupby(pdf['A']).apply(lambda x: x.sum()))
+
+        assert_eq(ddf.B.groupby(ddf['A'] + 1).apply(lambda x: x.sum()),
+                  pdf.B.groupby(pdf['A'] + 1).apply(lambda x: x.sum()))
+
         # DataFrameGroupBy with column slice
         assert_eq(ddf.groupby('A')[['B', 'C']].apply(lambda x: x.sum()),
                   pdf.groupby('A')[['B', 'C']].apply(lambda x: x.sum()))