Repository: modin-project/modin

Issue:
BUG: `.loc` slicing a MultiIndex fails with KeyError: array(['1'], dtype='<U1')
### Modin version checks

- [X] I have checked that this issue has not already been reported.

- [X] I have confirmed this bug exists on the latest released version of Modin.

- [X] I have confirmed this bug exists on the main branch of Modin. (In order to do this you can follow [this guide](https://modin.readthedocs.io/en/stable/getting_started/installation.html#installing-from-the-github-master-branch).)


### Reproducible Example

Fails:

```python

import modin.pandas as pd
df = pd.DataFrame({'col1': {('A', '1'): 'val1', ('A', '2'): 'val2'}})
df.loc[('A', '1')]
```

Works:

```python
df.loc[('A', '1'),]
df.loc[('A', '1'),:]
```



### Issue Description

Slicing a multi-indexed DataFrame with a tuple fails with KeyError (but not using pandas). See traceback below.

### Expected Behavior

`df.loc[('A', '1')]` should return the same as `df.loc[('A', '1'),]` and not fail with an error.

### Error Logs

<details>

```python-traceback
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
Cell In[20], line 2
      1 df = pd.DataFrame({'col1': {('A', '1'): 'val1', ('A', '2'): 'val2'}})
----> 2 df.loc[('A', '1')]

File .../lib/python3.10/site-packages/modin/logging/logger_decorator.py:128, in enable_logging.<locals>.decorator.<locals>.run_and_log(*args, **kwargs)
    113 """
    114 Compute function with logging if Modin logging is enabled.
    115 
   (...)
    125 Any
    126 """
    127 if LogMode.get() == "disable":
--> 128     return obj(*args, **kwargs)
    130 logger = get_logger()
    131 logger_level = getattr(logger, log_level)

File .../lib/python3.10/site-packages/modin/pandas/indexing.py:663, in _LocIndexer.__getitem__(self, key)
    660 if isinstance(row_loc, Series) and is_boolean_array(row_loc):
    661     return self._handle_boolean_masking(row_loc, col_loc)
--> 663 qc_view = self.qc.take_2d_labels(row_loc, col_loc)
    664 result = self._get_pandas_object_from_qc_view(
    665     qc_view,
    666     row_multiindex_full_lookup,
   (...)
    670     ndim,
    671 )
    673 if isinstance(result, Series):

File .../lib/python3.10/site-packages/modin/logging/logger_decorator.py:128, in enable_logging.<locals>.decorator.<locals>.run_and_log(*args, **kwargs)
    113 """
    114 Compute function with logging if Modin logging is enabled.
    115 
   (...)
    125 Any
    126 """
    127 if LogMode.get() == "disable":
--> 128     return obj(*args, **kwargs)
    130 logger = get_logger()
    131 logger_level = getattr(logger, log_level)

File .../lib/python3.10/site-packages/modin/core/storage_formats/base/query_compiler.py:3117, in BaseQueryCompiler.take_2d_labels(self, index, columns)
   3097 def take_2d_labels(
   3098     self,
   3099     index,
   3100     columns,
   3101 ):
   3102     """
   3103     Take the given labels.
   3104 
   (...)
   3115         Subset of this QueryCompiler.
   3116     """
-> 3117     row_lookup, col_lookup = self.get_positions_from_labels(index, columns)
   3118     if isinstance(row_lookup, slice):
   3119         ErrorMessage.catch_bugs_and_request_email(
   3120             failure_condition=row_lookup != slice(None),
   3121             extra_log=f"Only None-slices are acceptable as a slice argument in masking, got: {row_lookup}",
   3122         )

File .../lib/python3.10/site-packages/modin/logging/logger_decorator.py:128, in enable_logging.<locals>.decorator.<locals>.run_and_log(*args, **kwargs)
    113 """
    114 Compute function with logging if Modin logging is enabled.
    115 
   (...)
    125 Any
    126 """
    127 if LogMode.get() == "disable":
--> 128     return obj(*args, **kwargs)
    130 logger = get_logger()
    131 logger_level = getattr(logger, log_level)

File .../lib/python3.10/site-packages/modin/core/storage_formats/base/query_compiler.py:3225, in BaseQueryCompiler.get_positions_from_labels(self, row_loc, col_loc)
   3217     if missing_mask.any():
   3218         missing_labels = (
   3219             axis_loc[missing_mask]
   3220             if is_list_like(axis_loc)
   (...)
   3223             else axis_loc
   3224         )
-> 3225         raise KeyError(missing_labels)
   3227 if isinstance(axis_lookup, pandas.Index) and not is_range_like(axis_lookup):
   3228     axis_lookup = axis_lookup.values

KeyError: array(['1'], dtype='<U1')

```

</details>


### Installed Versions

<details>

INSTALLED VERSIONS
------------------
commit           : 9068fbcfd7d4c169018ae706d6c96edea6db0cd1
python           : 3.10.9.final.0
python-bits      : 64
OS               : Linux
OS-release       : 5.15.91-1-MANJARO
Version          : #1 SMP PREEMPT Wed Feb 1 12:03:19 UTC 2023
machine          : x86_64
processor        : 
byteorder        : little
LC_ALL           : None
LANG             : en_US.utf8
LOCALE           : en_US.UTF-8

Modin dependencies
------------------
modin            : 0.18.1
ray              : 2.3.0
dask             : 2022.7.0
distributed      : 2022.7.0
hdk              : None

pandas dependencies
-------------------
pandas           : 1.5.3
numpy            : 1.23.5
pytz             : 2022.7
dateutil         : 2.8.2
setuptools       : 65.6.3
pip              : 22.3.1
Cython           : None
pytest           : 7.1.3
hypothesis       : None
sphinx           : 5.3.0
blosc            : None
feather          : None
xlsxwriter       : None
lxml.etree       : 4.9.2
html5lib         : None
pymysql          : None
psycopg2         : None
jinja2           : 3.1.2
IPython          : 8.9.0
pandas_datareader: None
bs4              : 4.11.2
bottleneck       : 1.3.5
brotli           : 
fastparquet      : None
fsspec           : 2022.11.0
gcsfs            : None
matplotlib       : 3.6.3
numba            : None
numexpr          : 2.8.4
odfpy            : None
openpyxl         : None
pandas_gbq       : None
pyarrow          : 11.0.0
pyreadstat       : None
pyxlsb           : None
s3fs             : None
scipy            : 1.10.0
snappy           : None
sqlalchemy       : 1.4.46
tables           : None
tabulate         : 0.9.0
xarray           : None
xlrd             : None
xlwt             : None
zstandard        : None
tzdata           : None

</details>



Discussion:
@johentsch thank you for the great bug report! I can verify the bug on Modin version bbd973462388e11d95fa04f6d5416cc3413de7cc. Let me see what I can do to fix this.

Resolving patch:
diff --git a/modin/pandas/indexing.py b/modin/pandas/indexing.py
--- a/modin/pandas/indexing.py
+++ b/modin/pandas/indexing.py
@@ -632,7 +632,47 @@ def __getitem__(self, key):
         """
         if self.df.empty:
             return self.df._default_to_pandas(lambda df: df.loc[key])
-        row_loc, col_loc, ndim = self._parse_row_and_column_locators(key)
+
+        if (
+            isinstance(key, tuple)
+            and len(key) == 2
+            and all((is_scalar(k) for k in key))
+            and self.qc.has_multiindex(axis=0)
+        ):
+            # __getitem__ has no way to distinguish between
+            # loc[('level_one_key', level_two_key')] and
+            # loc['level_one_key', 'column_name']. It's possible for both to be valid
+            # when we have a multiindex on axis=0, and it seems pandas uses
+            # interpretation 1 if that's possible. Do the same.
+            locators = self._parse_row_and_column_locators((key, slice(None)))
+            try:
+                return self._helper_for__getitem__(key, *locators)
+            except KeyError:
+                pass
+        return self._helper_for__getitem__(
+            key, *self._parse_row_and_column_locators(key)
+        )
+
+    def _helper_for__getitem__(self, key, row_loc, col_loc, ndim):
+        """
+        Retrieve dataset according to `key`, row_loc, and col_loc.
+
+        Parameters
+        ----------
+        key : callable, scalar, or tuple
+            The global row index to retrieve data from.
+        row_loc : callable, scalar, or slice
+            Row locator(s) as a scalar or List.
+        col_loc : callable, scalar, or slice
+            Row locator(s) as a scalar or List.
+        ndim : int
+            The number of dimensions of the returned object.
+
+        Returns
+        -------
+        modin.pandas.DataFrame or modin.pandas.Series
+            Located dataset.
+        """
         row_scalar = is_scalar(row_loc)
         col_scalar = is_scalar(col_loc)
 


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
@@ -608,6 +608,30 @@ def test_loc_multi_index_with_tuples():
     eval_general(modin_df, pandas_df, lambda df: df.loc[:, ("bar", "two")])
 
 
+def test_loc_multi_index_rows_with_tuples_5721():
+    arrays = [
+        ["bar", "bar", "baz", "baz"],
+        ["one", "two", "one", "two"],
+    ]
+    ncols = 5
+    index = pd.MultiIndex.from_tuples(zip(*arrays), names=["a", "b"])
+    data = np.arange(0, ncols * len(index)).reshape(len(index), ncols)
+    modin_df, pandas_df = create_test_dfs(data, index=index)
+    eval_general(modin_df, pandas_df, lambda df: df.loc[("bar",)])
+    eval_general(modin_df, pandas_df, lambda df: df.loc[("bar", "two")])
+
+
+def test_loc_multi_index_level_two_has_same_name_as_column():
+    eval_general(
+        *create_test_dfs(
+            pandas.DataFrame(
+                [[0]], index=[pd.Index(["foo"]), pd.Index(["bar"])], columns=["bar"]
+            )
+        ),
+        lambda df: df.loc[("foo", "bar")],
+    )
+
+
 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])