Repository: modin-project/modin

Issue:
BUG: AttributeError: 'Categorical' object has no attribute 'cat'
### 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

```python
import pandas
import modin.pandas as pd


pandas_series = pandas.Series(range(10))
modin_series = pd.Series(range(10))

pandas_result = pandas.qcut(pandas_series, 4)
modin_result = pd.qcut(modin_series, 4)

print(pandas_result)
# 0    (-0.001, 2.25]
# 1    (-0.001, 2.25]
# 2    (-0.001, 2.25]
# 3       (2.25, 4.5]
# 4       (2.25, 4.5]
# 5       (4.5, 6.75]
# 6       (4.5, 6.75]
# 7       (6.75, 9.0]
# 8       (6.75, 9.0]
# 9       (6.75, 9.0]
# dtype: category
# Categories (4, interval[float64, right]): [(-0.001, 2.25] < (2.25, 4.5] < (4.5, 6.75] < (6.75, 9.0]]
print(pandas_result.cat.categories)
# IntervalIndex([(-0.001, 2.25], (2.25, 4.5], (4.5, 6.75], (6.75, 9.0]], dtype='interval[float64, right]')

print(modin_result)
# [(-0.001, 2.25], (-0.001, 2.25], (-0.001, 2.25], (2.25, 4.5], (2.25, 4.5], (4.5, 6.75], (4.5, 6.75], (6.75, 9.0], (6.75, 9.0], (6.75, 9.0]]
# Categories (4, interval[float64, right]): [(-0.001, 2.25] < (2.25, 4.5] < (4.5, 6.75] < (6.75, 9.0]]
print(modin_result.cat.categories)
AttributeError: 'Categorical' object has no attribute 'cat'
```


### Issue Description

We just import `qcut` from pandas and provides the interface as is to users. `qcut` can take a Series as the argument and if it is a Modin Series, then we can get undefined behavior as pandas does know nothing how to handle Modin object. As the first step we should add a default to pandas implementation for `qcut` and think of a distributed version as part of https://github.com/modin-project/modin/issues/1336.

### Expected Behavior

Should match pandas.

### Error Logs

<details>

```python-traceback

Replace this line with the error backtrace (if applicable).

```

</details>


### Installed Versions

<details>

Replace this line with the output of pd.show_versions()

</details>



Discussion:

Resolving patch:
diff --git a/modin/pandas/__init__.py b/modin/pandas/__init__.py
--- a/modin/pandas/__init__.py
+++ b/modin/pandas/__init__.py
@@ -29,7 +29,6 @@
         cut,
         factorize,
         test,
-        qcut,
         date_range,
         period_range,
         Index,
@@ -233,6 +232,7 @@ def init_remote_ray(partition):
     notna,
     pivot,
     to_numeric,
+    qcut,
     to_datetime,
     unique,
     value_counts,
diff --git a/modin/pandas/general.py b/modin/pandas/general.py
--- a/modin/pandas/general.py
+++ b/modin/pandas/general.py
@@ -271,6 +271,25 @@ def to_numeric(arg, errors="raise", downcast=None):  # noqa: PR01, RT01, D200
     return arg._to_numeric(errors=errors, downcast=downcast)
 
 
+@_inherit_docstrings(pandas.qcut, apilink="pandas.qcut")
+@enable_logging
+def qcut(
+    x, q, labels=None, retbins=False, precision=3, duplicates="raise"
+):  # noqa: PR01, RT01, D200
+    """
+    Quantile-based discretization function.
+    """
+    kwargs = {
+        "labels": labels,
+        "retbins": retbins,
+        "precision": precision,
+        "duplicates": duplicates,
+    }
+    if not isinstance(x, Series):
+        return pandas.qcut(x, q, **kwargs)
+    return x._qcut(q, **kwargs)
+
+
 @_inherit_docstrings(pandas.unique, apilink="pandas.unique")
 @enable_logging
 def unique(values):  # noqa: PR01, RT01, D200
diff --git a/modin/pandas/series.py b/modin/pandas/series.py
--- a/modin/pandas/series.py
+++ b/modin/pandas/series.py
@@ -2289,6 +2289,12 @@ def _to_numeric(self, **kwargs):
             query_compiler=self._query_compiler.to_numeric(**kwargs)
         )
 
+    def _qcut(self, q, **kwargs):  # noqa: PR01, RT01, D200
+        """
+        Quantile-based discretization function.
+        """
+        return self._default_to_pandas(pandas.qcut, q, **kwargs)
+
     def _reduce_dimension(self, query_compiler):
         """
         Try to reduce the dimension of data from the `query_compiler`.


Test patch:
diff --git a/modin/pandas/test/test_general.py b/modin/pandas/test/test_general.py
--- a/modin/pandas/test/test_general.py
+++ b/modin/pandas/test/test_general.py
@@ -29,6 +29,8 @@
     df_equals,
     sort_index_for_equal_values,
     eval_general,
+    bool_arg_values,
+    bool_arg_keys,
 )
 
 
@@ -725,6 +727,28 @@ def test_to_numeric(data, errors, downcast):
     df_equals(modin_result, pandas_result)
 
 
+@pytest.mark.parametrize("retbins", bool_arg_values, ids=bool_arg_keys)
+def test_qcut(retbins):
+    # test case from https://github.com/modin-project/modin/issues/5610
+    pandas_series = pandas.Series(range(10))
+    modin_series = pd.Series(range(10))
+    pandas_result = pandas.qcut(pandas_series, 4, retbins=retbins)
+    with warns_that_defaulting_to_pandas():
+        modin_result = pd.qcut(modin_series, 4, retbins=retbins)
+    if retbins:
+        df_equals(modin_result[0], pandas_result[0])
+        df_equals(modin_result[0].cat.categories, pandas_result[0].cat.categories)
+        assert_array_equal(modin_result[1], pandas_result[1])
+    else:
+        df_equals(modin_result, pandas_result)
+        df_equals(modin_result.cat.categories, pandas_result.cat.categories)
+
+    # test case for fallback to pandas, taken from pandas docs
+    pandas_result = pandas.qcut(range(5), 4)
+    modin_result = pd.qcut(range(5), 4)
+    df_equals(modin_result, pandas_result)
+
+
 @pytest.mark.parametrize(
     "data", [test_data_values[0], []], ids=["test_data_values[0]", "[]"]
 )