Repository: modin-project/modin

Issue:
MultiIndex: DataFrame.loc with a tuple on columns defined as MultiIndex returns an error
### System information
- **OS Platform and Distribution**: MacOS Monterey 12.2.1
- **Modin version** 0.14
- **Python version**: 3.8.12
- **Code we can use to reproduce**:

### Using modin: 
```python
import ray
ray.init()
import modin.pandas as pd
import numpy as np

arrays = [
    ["bar", "bar", "baz", "baz", "foo", "foo", "qux", "qux"],
    ["one", "two", "one", "two", "one", "two", "one", "two"],
]
columns =  pd.MultiIndex.from_tuples(zip(*arrays), names=['a', 'b'])
data = [np.random.randint(10, 50, len(columns)) for _ in range(30)]
df = pd.DataFrame(data, columns=columns)

# bug happens here
df.loc[:, ("bar", "two")]
```

### Same operation in vanilla pandas 
```python
import pandas as pd
import numpy as np

arrays = [
    ["bar", "bar", "baz", "baz", "foo", "foo", "qux", "qux"],
    ["one", "two", "one", "two", "one", "two", "one", "two"],
]
columns =  pd.MultiIndex.from_tuples(zip(*arrays), names=['a', 'b'])
data = [np.random.randint(10, 50, len(columns)) for _ in range(30)]
df = pd.DataFrame(data, columns=columns)

df.loc[:, ("bar", "two")]
```

### Describe the problem
When using .loc with tuples on a multiIndex, a ValueError appears

### Source code / logs
**traceback:**
```shell
Traceback (most recent call last):
  File "/VIRTUALENV/lib/python3.8/site-packages/ipython-7.32.0-py3.8.egg/IPython/core/interactiveshell.py", line 3524, in run_code
    exec(code_obj, self.user_global_ns, self.user_ns)
  File "<ipython-input-4-59ea95655168>", line 1, in <cell line: 1>
    df.loc[:, ("bar", "two")]
  File "/VIRTUALENV/lib/python3.8/site-packages/modin/pandas/indexing.py", line 674, in __getitem__
    result.columns = result.columns.droplevel(list(range(len(col_loc_as_list))))
  File "/VIRTUALENV/lib/python3.8/site-packages/pandas/core/indexes/base.py", line 2063, in droplevel
    return self._drop_level_numbers(levnums)
  File "/VIRTUALENV/lib/python3.8/site-packages/pandas/core/indexes/base.py", line 2074, in _drop_level_numbers
    raise ValueError(
ValueError: Cannot remove 2 levels from an index with 2 levels: at least one level must be left.
```


Discussion:
It will however work when explicitely converting the tuple to a MultiIndex: 

```python
df.loc[:, pd.MultiIndex.from_tuples([ ("bar", "two") ]) ]
```
@neltacigreb thank you for the detailed bug report. I reproduced the issue with your code on Modin commit 6d05c31a24da9776723a179c74b70338b9302a82.

The bug happens when the column selectors in the `loc` select one column at each level of the column MultiIndex. Modin needs to stop trying to drop all levels of the result `DataFrame` [here](https://github.com/modin-project/modin/blob/e5e9634357e60925a5a70e56a1d4882d269f533a/modin/pandas/indexing.py#L674). Instead, Modin should make a `Series` whose name comes from the columns selected, e.g. in the example pandas snippet above, the Series name is `('bar', 'bars')`. I guess we should make `_LocationIndexerBase. __getitem__` return a `Series` with the correct column name in this case.

Resolving patch:
diff --git a/modin/pandas/indexing.py b/modin/pandas/indexing.py
--- a/modin/pandas/indexing.py
+++ b/modin/pandas/indexing.py
@@ -283,6 +283,8 @@ def __init__(self, modin_df):
         self.qc = modin_df._query_compiler
         self.row_scalar = False
         self.col_scalar = False
+        self.row_multiindex_full_lookup = False
+        self.col_multiindex_full_lookup = False
 
     def __getitem__(self, row_lookup, col_lookup, ndim):
         """
@@ -331,11 +333,17 @@ def __getitem__(self, row_lookup, col_lookup, ndim):
         elif ndim == 0:
             axis = None
         else:
+            # We are in the case where ndim == 1
+            # The axis we squeeze on depends on whether we are looking for an exact
+            # value or a subset of rows and columns. Knowing if we have a full MultiIndex
+            # lookup or scalar lookup can help us figure out whether we need to squeeze
+            # on the row or column index.
             axis = (
                 None
-                if self.col_scalar and self.row_scalar
+                if (self.col_scalar and self.row_scalar)
+                or (self.row_multiindex_full_lookup and self.col_multiindex_full_lookup)
                 else 1
-                if self.col_scalar
+                if self.col_scalar or self.col_multiindex_full_lookup
                 else 0
             )
         return self.df.__constructor__(query_compiler=qc_view).squeeze(axis=axis)
@@ -528,6 +536,39 @@ def _handle_boolean_masking(self, row_loc, col_loc):
         # Passing `slice(None)` as a row indexer since we've just applied it
         return type(self)(masked_df)[(slice(None), col_loc)]
 
+    def _multiindex_possibly_contains_key(self, axis, key):
+        """
+        Determine if a MultiIndex row/column possibly contains a key.
+
+        Check to see if the current DataFrame has a MultiIndex row/column and if it does,
+        check to see if the key is potentially a full key-lookup such that the number of
+        levels match up with the length of the tuple key.
+
+        Parameters
+        ----------
+        axis : {0, 1}
+            0 for row, 1 for column.
+        key : Any
+            Lookup key for MultiIndex row/column.
+
+        Returns
+        -------
+        bool
+            If the MultiIndex possibly contains the given key.
+
+        Notes
+        -----
+        This function only returns False if we have a partial key lookup. It's
+        possible that this function returns True for a key that does NOT exist
+        since we only check the length of the `key` tuple to match the number
+        of levels in the MultiIndex row/colunmn.
+        """
+        if not self.qc.has_multiindex(axis=axis):
+            return False
+
+        multiindex = self.df.index if axis == 0 else self.df.columns
+        return isinstance(key, tuple) and len(key) == len(multiindex.levels)
+
 
 class _LocIndexer(_LocationIndexerBase):
     """
@@ -563,6 +604,22 @@ def __getitem__(self, key):
         self.row_scalar = is_scalar(row_loc)
         self.col_scalar = is_scalar(col_loc)
 
+        # The thought process here is that we should check to see that we have a full key lookup
+        # for a MultiIndex DataFrame. If that's the case, then we should not drop any levels
+        # since our resulting intermediate dataframe will have dropped these for us already.
+        # Thus, we need to make sure we don't try to drop these levels again. The logic here is
+        # kind of hacked together. Ideally, we should handle this properly in the lower-level
+        # implementations, but this will have to be engineered properly later.
+        self.row_multiindex_full_lookup = self._multiindex_possibly_contains_key(
+            axis=0, key=row_loc
+        )
+        self.col_multiindex_full_lookup = self._multiindex_possibly_contains_key(
+            axis=1, key=col_loc
+        )
+        levels_already_dropped = (
+            self.row_multiindex_full_lookup or self.col_multiindex_full_lookup
+        )
+
         if isinstance(row_loc, Series) and is_boolean_array(row_loc):
             return self._handle_boolean_masking(row_loc, col_loc)
 
@@ -577,6 +634,7 @@ def __getitem__(self, key):
         if (
             isinstance(result, (Series, DataFrame))
             and result._query_compiler.has_multiindex()
+            and not levels_already_dropped
         ):
             if (
                 isinstance(result, Series)
@@ -596,6 +654,7 @@ def __getitem__(self, key):
         if (
             hasattr(result, "columns")
             and not isinstance(col_loc_as_list, slice)
+            and not levels_already_dropped
             and result._query_compiler.has_multiindex(axis=1)
             and all(
                 col_loc_as_list[i] in result.columns.levels[i]


Test patch:
diff --git a/modin/pandas/test/dataframe/test_indexing.py b/modin/pandas/test/dataframe/test_indexing.py
--- a/modin/pandas/test/dataframe/test_indexing.py
+++ b/modin/pandas/test/dataframe/test_indexing.py
@@ -562,6 +562,42 @@ def test_loc_multi_index():
     df_equals(modin_df.loc[modin_df.index[:7]], pandas_df.loc[pandas_df.index[:7]])
 
 
+def test_loc_multi_index_with_tuples():
+    arrays = [
+        ["bar", "bar", "baz", "baz"],
+        ["one", "two", "one", "two"],
+    ]
+    nrows = 5
+    columns = pd.MultiIndex.from_tuples(zip(*arrays), names=["a", "b"])
+    data = np.arange(0, nrows * len(columns)).reshape(nrows, len(columns))
+    modin_df, pandas_df = create_test_dfs(data, columns=columns)
+    eval_general(modin_df, pandas_df, lambda df: df.loc[:, ("bar", "two")])
+
+
+def test_loc_multi_index_duplicate_keys():
+    modin_df, pandas_df = create_test_dfs([1, 2], index=[["a", "a"], ["b", "b"]])
+    eval_general(modin_df, pandas_df, lambda df: df.loc[("a", "b"), 0])
+    eval_general(modin_df, pandas_df, lambda df: df.loc[("a", "b"), :])
+
+
+def test_loc_multi_index_both_axes():
+    multi_index = pd.MultiIndex.from_tuples(
+        [("r0", "rA"), ("r1", "rB")], names=["Courses", "Fee"]
+    )
+    cols = pd.MultiIndex.from_tuples(
+        [
+            ("Gasoline", "Toyota"),
+            ("Gasoline", "Ford"),
+            ("Electric", "Tesla"),
+            ("Electric", "Nio"),
+        ]
+    )
+    data = [[100, 300, 900, 400], [200, 500, 300, 600]]
+    modin_df, pandas_df = create_test_dfs(data, columns=cols, index=multi_index)
+    eval_general(modin_df, pandas_df, lambda df: df.loc[("r0", "rA"), :])
+    eval_general(modin_df, pandas_df, lambda df: df.loc[:, ("Gasoline", "Toyota")])
+
+
 def test_loc_empty():
     pandas_df = pandas.DataFrame(index=range(5))
     modin_df = pd.DataFrame(index=range(5))