Repository: pandas-dev/pandas
Pull request: ENH: MultiIndex.from_frame
- [x] closes #22420
- [x] tests added / passed
- [ ] passes `git diff upstream/master -u -- "*.py" | flake8 --diff`
- [x] whatsnew entry

This pull request is to add the from_frame method of creating multiindexes. Along with this feature the helper method "squeeze" has been added for squeezing single level multiindexes to a standard index (analogous to df.squeeze).

Additionally the to_frame method was updated to guarantee that the order of the labels is preserved when converting a multiindex to a dataframe. Currently this cannot be guaranteed in Python 2.7 due to the use of a dictionary for creating the frame. With this change from_frame and to_frame are perfectly complementary.


Problem statement:
Feature Request: pd.MultiIndex.from_frame(). Complement to pd.MultiIndex.to_frame().
The MultiIndex.to_frame function is great for working with multi-indexes as a meta-dataframe. I find myself using this paradigm very often.

    df = create_multiindex_df()
    meta = df.columns.to_frame(index=False)
    meta = filter_multiindex()
    df.reindex(columns=convert_df_to_multiindex(df))

Having the `convert_df_to_multiindex` as a pd.MultiIndex method would be extremely complimentary to `pd.MultiIndex.to_frame`.

A simplified one-line implementation provided below, not including accounting for some corner case behaviors and discerning between series/frames vs Index/Multiindex.

    pd.MultiIndex.from_tuples(list(df.values), names=df.columns)

Hints from issue discussion:
can u show a complete example
Here is a more complete implementation:


    # pd.Index

    @classmethod
    def from_frame(cls, df):
        if not isinstance(df.squeeze(), pd.Series):
            raise ValueError('DataFrame must be be single column')
        return cls.from_series(df.squeeze())


    @classmethod
    def from_series(cls, s):
        return cls(s, name=s.name)



    # pd.MultiIndex

    @classmethod
    def from_frame(cls, df, squeeze=True):
        """
        :param df
        :param squeeze
            Squeeze single level multiindex to be a regular index
        """
        # just let column level names be the tuple of the meta df columns since they're not required to be strings
        # columns = ['.'.join(col) for col in list(df)]  
        columns = list(df)
        mi = cls.from_tuples(list(df.values), names=columns)
        if squeeze:
            if len(mi.levels) == 1:
                return mi.levels[0][mi.labels[0]]
            else:
                return mi
        else:
            return mi

Commit message: DOC - update MultiIndex.from_frame and squeeze doctests to comply with the Pandas docstring guide

Patch to review:
diff --git a/pandas/core/indexes/multi.py b/pandas/core/indexes/multi.py
index 3cccb65503378..3068aa87d9887 100644
--- a/pandas/core/indexes/multi.py
+++ b/pandas/core/indexes/multi.py
@@ -1189,11 +1189,11 @@ def to_frame(self, index=True, name=None):
         else:
             idx_names = self.names
 
-        result = DataFrame({(name or level):
-                            self._get_level_values(level)
-                            for name, level in
-                            zip(idx_names, range(len(self.levels)))},
-                           copy=False)
+        result = DataFrame(
+            list(self),
+            columns=[n or i for i, n in enumerate(idx_names)]
+        )
+
         if index:
             result.index = self
         return result
@@ -1294,6 +1294,7 @@ def from_arrays(cls, arrays, sortorder=None, names=None):
         MultiIndex.from_tuples : Convert list of tuples to MultiIndex
         MultiIndex.from_product : Make a MultiIndex from cartesian product
                                   of iterables
+        MultiIndex.from_frame : Make a MultiIndex from a DataFrame.
         """
         if not is_list_like(arrays):
             raise TypeError("Input must be a list / sequence of array-likes.")
@@ -1343,6 +1344,7 @@ def from_tuples(cls, tuples, sortorder=None, names=None):
         MultiIndex.from_arrays : Convert list of arrays to MultiIndex
         MultiIndex.from_product : Make a MultiIndex from cartesian product
                                   of iterables
+        MultiIndex.from_frame : Make a MultiIndex from a DataFrame.
         """
         if not is_list_like(tuples):
             raise TypeError('Input must be a list / sequence of tuple-likes.')
@@ -1399,6 +1401,7 @@ def from_product(cls, iterables, sortorder=None, names=None):
         --------
         MultiIndex.from_arrays : Convert list of arrays to MultiIndex
         MultiIndex.from_tuples : Convert list of tuples to MultiIndex
+        MultiIndex.from_frame : Make a MultiIndex from a DataFrame.
         """
         from pandas.core.arrays.categorical import _factorize_from_iterables
         from pandas.core.reshape.util import cartesian_product
@@ -1412,6 +1415,61 @@ def from_product(cls, iterables, sortorder=None, names=None):
         labels = cartesian_product(labels)
         return MultiIndex(levels, labels, sortorder=sortorder, names=names)
 
+    @classmethod
+    def from_frame(cls, df, squeeze=True):
+        """
+        Make a MultiIndex from a DataFrame.
+
+        Parameters
+        ----------
+        df : pd.DataFrame
+            DataFrame to be converted to MultiIndex.
+        squeeze : bool, default True
+            If df is a single column, squeeze multiindex to be a regular index.
+
+        Returns
+        -------
+        MultiIndex or Index
+            The MultiIndex representation of the given DataFrame. Returns an
+            Index if the DataFrame is single column and squeeze is True.
+
+        Examples
+        --------
+        >>> df = pd.DataFrame([[0, 'green'], [0, 'purple'], [1, 'green'],
+        ...                    [1, 'purple'], [2, 'green'], [2, 'purple']],
+        ...                   columns=['number', 'color'])
+        >>> df
+           number   color
+        0       0   green
+        1       0  purple
+        2       1   green
+        3       1  purple
+        4       2   green
+        5       2  purple
+        >>> pd.MultiIndex.from_frame(df)
+        MultiIndex(levels=[[0, 1, 2], ['green', 'purple']],
+                   labels=[[0, 0, 1, 1, 2, 2], [0, 1, 0, 1, 0, 1]],
+                   names=['number', 'color'])
+
+        See Also
+        --------
+        MultiIndex.from_arrays : Convert list of arrays to MultiIndex
+        MultiIndex.from_tuples : Convert list of tuples to MultiIndex
+        MultiIndex.from_product : Make a MultiIndex from cartesian product
+                                  of iterables
+        """
+        from pandas import DataFrame
+        # just let column level names be the tuple of the meta df columns
+        # since they're not required to be strings
+        if not isinstance(df, DataFrame):
+            raise TypeError("Input must be a DataFrame")
+        columns = list(df)
+        mi = cls.from_tuples(list(df.values), names=columns)
+        if squeeze:
+            return mi.squeeze()
+        else:
+            return mi
+
     def _sort_levels_monotonic(self):
         """
         .. versionadded:: 0.20.0
@@ -1474,6 +1532,27 @@ def _sort_levels_monotonic(self):
                           names=self.names, sortorder=self.sortorder,
                           verify_integrity=False)
 
+    def squeeze(self):
+        """
+        Squeeze a single level MultiIndex to be a regular Index instane.
+
+        Returns
+        -------
+        Index or MultiIndex
+            Returns Index equivalent of single level MultiIndex. Returns
+            copy of MultiIndex if multilevel.
+
+        Examples
+        --------
+        >>> mi = pd.MultiIndex.from_tuples([('a',), ('b',), ('c',)])
+        >>> mi.squeeze()
+        Index(['a', 'b', 'c'], dtype='object')
+        """
+        if len(self.levels) == 1:
+            return self.levels[0][self.labels[0]]
+        else:
+            return self.copy()
+
     def remove_unused_levels(self):
         """
         Create a new MultiIndex from the current that removes
diff --git a/pandas/tests/indexes/multi/test_constructor.py b/pandas/tests/indexes/multi/test_constructor.py
index ab2e4c1d863a7..abe412317d920 100644
--- a/pandas/tests/indexes/multi/test_constructor.py
+++ b/pandas/tests/indexes/multi/test_constructor.py
@@ -472,3 +472,45 @@ def test_from_tuples_with_tuple_label():
     idx = pd.MultiIndex.from_tuples([(2, 1), (4, (1, 2))], names=('a', 'b'))
     result = pd.DataFrame([2, 3], columns=['c'], index=idx)
     tm.assert_frame_equal(expected, result)
+
+
+def test_from_frame():
+    expected = pd.MultiIndex.from_tuples([('a', 'a'), ('a', 'b'), ('a', 'c'),
+                                          ('b', 'a'), ('b', 'b'), ('c', 'a'),
+                                          ('c', 'b')],
+                                         names=['L1', 'L2'])
+    df = pd.DataFrame([['a', 'a'], ['a', 'b'], ['a', 'c'], ['b', 'a'],
+                       ['b', 'b'], ['c', 'a'], ['c', 'b']],
+                      columns=['L1', 'L2'])
+    result = pd.MultiIndex.from_frame(df)
+    tm.assert_index_equal(expected, result)
+
+
+def test_from_frame():
+    df = pd.DataFrame([['a', 'a'], ['a', 'b'], ['b', 'a'], ['b', 'b']],
+                      columns=['L1', 'L2'])
+    expected = pd.MultiIndex.from_tuples([('a', 'a'), ('a', 'b'),
+                                          ('b', 'a'), ('b', 'b')],
+                                         names=['L1', 'L2'])
+    result = pd.MultiIndex.from_frame(df)
+    tm.assert_index_equal(expected, result)
+
+
+def test_from_frame_with_squeeze():
+    df = pd.DataFrame([['a'], ['a'], ['b'], ['b']], columns=['L1'])
+    expected = pd.Index(['a', 'a', 'b', 'b'], name='L1')
+    result = pd.MultiIndex.from_frame(df)
+    tm.assert_index_equal(expected, result)
+
+
+def test_from_frame_with_no_squeeze():
+    df = pd.DataFrame([['a'], ['a'], ['b'], ['b']], columns=['L1'])
+    expected = pd.MultiIndex.from_tuples([('a',), ('a',), ('b',), ('b',)],
+                                         names=['L1'])
+    result = pd.MultiIndex.from_frame(df, squeeze=False)
+    tm.assert_index_equal(expected, result)
+
+
+def test_from_frame_non_frame():
+    with pytest.raises(TypeError):
+        pd.MultiIndex.from_frame([1, 2, 3, 4])
diff --git a/pandas/tests/indexes/multi/test_conversion.py b/pandas/tests/indexes/multi/test_conversion.py
index 8c9566b7e651f..7696f89f73f0e 100644
--- a/pandas/tests/indexes/multi/test_conversion.py
+++ b/pandas/tests/indexes/multi/test_conversion.py
@@ -169,3 +169,18 @@ def test_to_series_with_arguments(idx):
     assert s.values is not idx.values
     assert s.index is not idx
     assert s.name != idx.name
+
+
+def test_squeeze():
+    mi = pd.MultiIndex.from_tuples([('a',), ('a',), ('b',), ('b',)],
+                                   names=['L1'])
+    expected = pd.Index(['a', 'a', 'b', 'b'], name='L1')
+    result = mi.squeeze()
+    tm.assert_index_equal(expected, result)
+
+    mi = pd.MultiIndex.from_tuples([('a', 'a'), ('a', 'b'), ('b', 'a'),
+                                   ('b', 'b')],
+                                   names=['L1', 'L2'])
+    expected = mi.copy()
+    result = mi.squeeze()
+    tm.assert_index_equal(expected, result)


Code review comments:
diff_hunk: @@ -1474,6 +1532,27 @@ def _sort_levels_monotonic(self):
                           names=self.names, sortorder=self.sortorder,
                           verify_integrity=False)
 
+    def squeeze(self):
+        """
+        Squeeze a single level MultiIndex to be a regular Index instane.
path: pandas/core/indexes/multi.py
text: @user1:
instane --> instance

diff_hunk: @@ -1412,6 +1415,61 @@ def from_product(cls, iterables, sortorder=None, names=None):
         labels = cartesian_product(labels)
         return MultiIndex(levels, labels, sortorder=sortorder, names=names)
 
+    @classmethod
+    def from_frame(cls, df, squeeze=True):
+        """
+        Make a MultiIndex from a DataFrame.
+
+        Parameters
+        ----------
+        df : pd.DataFrame
+            DataFrame to be converted to MultiIndex.
+        squeeze : bool, default True
+            If df is a single column, squeeze multiindex to be a regular index.
path: pandas/core/indexes/multi.py
text: @user1:
multiiindex --> MultiIndex

diff_hunk: @@ -472,3 +472,45 @@ def test_from_tuples_with_tuple_label():
     idx = pd.MultiIndex.from_tuples([(2, 1), (4, (1, 2))], names=('a', 'b'))
     result = pd.DataFrame([2, 3], columns=['c'], index=idx)
     tm.assert_frame_equal(expected, result)
+
+
+def test_from_frame():
+    expected = pd.MultiIndex.from_tuples([('a', 'a'), ('a', 'b'), ('a', 'c'),
+                                          ('b', 'a'), ('b', 'b'), ('c', 'a'),
+                                          ('c', 'b')],
+                                         names=['L1', 'L2'])
+    df = pd.DataFrame([['a', 'a'], ['a', 'b'], ['a', 'c'], ['b', 'a'],
+                       ['b', 'b'], ['c', 'a'], ['c', 'b']],
+                      columns=['L1', 'L2'])
+    result = pd.MultiIndex.from_frame(df)
+    tm.assert_index_equal(expected, result)
+
+
+def test_from_frame():
+    df = pd.DataFrame([['a', 'a'], ['a', 'b'], ['b', 'a'], ['b', 'b']],
+                      columns=['L1', 'L2'])
+    expected = pd.MultiIndex.from_tuples([('a', 'a'), ('a', 'b'),
+                                          ('b', 'a'), ('b', 'b')],
+                                         names=['L1', 'L2'])
+    result = pd.MultiIndex.from_frame(df)
+    tm.assert_index_equal(expected, result)
+
+
+def test_from_frame_with_squeeze():
+    df = pd.DataFrame([['a'], ['a'], ['b'], ['b']], columns=['L1'])
+    expected = pd.Index(['a', 'a', 'b', 'b'], name='L1')
+    result = pd.MultiIndex.from_frame(df)
+    tm.assert_index_equal(expected, result)
+
+
+def test_from_frame_with_no_squeeze():
+    df = pd.DataFrame([['a'], ['a'], ['b'], ['b']], columns=['L1'])
+    expected = pd.MultiIndex.from_tuples([('a',), ('a',), ('b',), ('b',)],
+                                         names=['L1'])
+    result = pd.MultiIndex.from_frame(df, squeeze=False)
+    tm.assert_index_equal(expected, result)
path: pandas/tests/indexes/multi/test_constructor.py
text: @user2:
I think we can squash this test and the one right above it by parametrizing on `squeeze`.  You can include a second parameter for the expected result i.e.

~~~python
@user1.mark.parametrize("squeeze,expected", [(True, <expected>, (False, <expected>)]
def test_from_frame_squeeze_param(squeeze, expected):
    df = pd.DataFrame([['a'], ['a'], ['b'], ['b']], columns=['L1'])
    result = pd.MultiIndex.from_frame(df, squeeze=squeeze)
    tm.assert_index_equal(expected, result)
~~~

diff_hunk: @@ -1412,6 +1415,61 @@ def from_product(cls, iterables, sortorder=None, names=None):
         labels = cartesian_product(labels)
         return MultiIndex(levels, labels, sortorder=sortorder, names=names)
 
+    @classmethod
+    def from_frame(cls, df, squeeze=True):
+        """
+        Make a MultiIndex from a DataFrame.
+
+        Parameters
+        ----------
+        df : pd.DataFrame
+            DataFrame to be converted to MultiIndex.
+        squeeze : bool, default True
+            If df is a single column, squeeze multiindex to be a regular index.
+
+        Returns
+        -------
+        MultiIndex or Index
+            The MultiIndex representation of the given DataFrame. Returns an
+            Index if the DataFrame is single column and squeeze is True.
+
+        Examples
+        --------
+        >>> df = pd.DataFrame([[0, 'green'], [0, 'purple'], [1, 'green'],
+        ...                    [1, 'purple'], [2, 'green'], [2, 'purple']],
+        ...                   columns=['number', 'color'])
+        >>> df
+           number   color
+        0       0   green
+        1       0  purple
+        2       1   green
+        3       1  purple
+        4       2   green
+        5       2  purple
+        >>> pd.MultiIndex.from_frame(df)
+        MultiIndex(levels=[[0, 1, 2], ['green', 'purple']],
+                   labels=[[0, 0, 1, 1, 2, 2], [0, 1, 0, 1, 0, 1]],
+                   names=['number', 'color'])
+
+        See Also
+        --------
+        MultiIndex.from_arrays : Convert list of arrays to MultiIndex
+        MultiIndex.from_tuples : Convert list of tuples to MultiIndex
+        MultiIndex.from_product : Make a MultiIndex from cartesian product
+                                  of iterables
+        """
+        from pandas import DataFrame
+        # just let column level names be the tuple of the meta df columns
+        # since they're not required to be strings
+        if not isinstance(df, DataFrame):
+            raise TypeError("Input must be a DataFrame")
+        columns = list(df)
+        mi = cls.from_tuples(list(df.values), names=columns)
+        if squeeze:
+            return mi.squeeze()
+        else:
+            return mi
path: pandas/core/indexes/multi.py
text: @user1:
Personal nit: change the four lines of `squeeze` logic to one:

~~~python
return mi.squeeze() if squeeze else mi
~~~

diff_hunk: @@ -472,3 +472,45 @@ def test_from_tuples_with_tuple_label():
     idx = pd.MultiIndex.from_tuples([(2, 1), (4, (1, 2))], names=('a', 'b'))
     result = pd.DataFrame([2, 3], columns=['c'], index=idx)
     tm.assert_frame_equal(expected, result)
+
+
+def test_from_frame():
+    expected = pd.MultiIndex.from_tuples([('a', 'a'), ('a', 'b'), ('a', 'c'),
+                                          ('b', 'a'), ('b', 'b'), ('c', 'a'),
+                                          ('c', 'b')],
+                                         names=['L1', 'L2'])
+    df = pd.DataFrame([['a', 'a'], ['a', 'b'], ['a', 'c'], ['b', 'a'],
+                       ['b', 'b'], ['c', 'a'], ['c', 'b']],
+                      columns=['L1', 'L2'])
+    result = pd.MultiIndex.from_frame(df)
+    tm.assert_index_equal(expected, result)
+
+
+def test_from_frame():
+    df = pd.DataFrame([['a', 'a'], ['a', 'b'], ['b', 'a'], ['b', 'b']],
+                      columns=['L1', 'L2'])
+    expected = pd.MultiIndex.from_tuples([('a', 'a'), ('a', 'b'),
+                                          ('b', 'a'), ('b', 'b')],
+                                         names=['L1', 'L2'])
+    result = pd.MultiIndex.from_frame(df)
+    tm.assert_index_equal(expected, result)
+
+
+def test_from_frame_with_squeeze():
+    df = pd.DataFrame([['a'], ['a'], ['b'], ['b']], columns=['L1'])
+    expected = pd.Index(['a', 'a', 'b', 'b'], name='L1')
+    result = pd.MultiIndex.from_frame(df)
+    tm.assert_index_equal(expected, result)
+
+
+def test_from_frame_with_no_squeeze():
+    df = pd.DataFrame([['a'], ['a'], ['b'], ['b']], columns=['L1'])
+    expected = pd.MultiIndex.from_tuples([('a',), ('a',), ('b',), ('b',)],
+                                         names=['L1'])
+    result = pd.MultiIndex.from_frame(df, squeeze=False)
+    tm.assert_index_equal(expected, result)
+
+
+def test_from_frame_non_frame():
+    with pytest.raises(TypeError):
+        pd.MultiIndex.from_frame([1, 2, 3, 4])
path: pandas/tests/indexes/multi/test_constructor.py
text: @user1:
Let's the check the error message via `pandas.util.testing.assert_raises_regex`.