Repository: modin-project/modin

Issue:
[FEAT] Support categorical methods + target dependent cat.codes
Currently, Series.cat method is defaulting to pandas, which means modifying cat operations don't affect the original Series.

Also, we need cat.codes operation to be backend specific to support it in OmniSci engine for Mortgage benchmark.


Discussion:

Resolving patch:
diff --git a/modin/backends/pandas/query_compiler.py b/modin/backends/pandas/query_compiler.py
--- a/modin/backends/pandas/query_compiler.py
+++ b/modin/backends/pandas/query_compiler.py
@@ -179,6 +179,8 @@ def default_to_pandas(self, pandas_op, *args, **kwargs):
 
         result = pandas_op(self.to_pandas(), *args, **kwargs)
         if isinstance(result, pandas.Series):
+            if result.name is None:
+                result.name = "__reduced__"
             result = result.to_frame()
         if isinstance(result, pandas.DataFrame):
             return self.from_pandas(result, type(self._modin_frame))
@@ -2240,3 +2242,9 @@ def sort_columns_by_row_values(self, rows, ascending=True, **kwargs):
             by=rows, axis=1, ascending=ascending, kind=kind, na_position=na_position,
         ).columns
         return self.reindex(1, new_columns)
+
+    # Cat operations
+    def cat_codes(self):
+        return self.default_to_pandas(lambda df: df[df.columns[0]].cat.codes)
+
+    # END Cat operations
diff --git a/modin/pandas/series.py b/modin/pandas/series.py
--- a/modin/pandas/series.py
+++ b/modin/pandas/series.py
@@ -1627,7 +1627,7 @@ def axes(self):
 
     @property
     def cat(self):
-        return self._default_to_pandas(pandas.Series.cat)
+        return CategoryMethods(self)
 
     @property
     def dt(self):
@@ -2240,3 +2240,76 @@ def _default_to_pandas(self, op, *args, **kwargs):
         return self._series._default_to_pandas(
             lambda series: op(series.str, *args, **kwargs)
         )
+
+
+class CategoryMethods(object):
+    def __init__(self, series):
+        self._series = series
+        self._query_compiler = series._query_compiler
+
+    @property
+    def categories(self):
+        return self._series._default_to_pandas(pandas.Series.cat).categories
+
+    @categories.setter
+    def categories(self, categories):
+        def set_categories(series, categories):
+            series.cat.categories = categories
+
+        self._series._default_to_pandas(set_categories, categories=categories)
+
+    @property
+    def ordered(self):
+        return self._series._default_to_pandas(pandas.Series.cat).ordered
+
+    @property
+    def codes(self):
+        return Series(query_compiler=self._query_compiler.cat_codes())
+
+    def rename_categories(self, new_categories, inplace=False):
+        return self._default_to_pandas(
+            pandas.Series.cat.rename_categories, new_categories, inplace=inplace
+        )
+
+    def reorder_categories(self, new_categories, ordered=None, inplace=False):
+        return self._default_to_pandas(
+            pandas.Series.cat.reorder_categories,
+            new_categories,
+            ordered=ordered,
+            inplace=inplace,
+        )
+
+    def add_categories(self, new_categories, inplace=False):
+        return self._default_to_pandas(
+            pandas.Series.cat.add_categories, new_categories, inplace=inplace
+        )
+
+    def remove_categories(self, removals, inplace=False):
+        return self._default_to_pandas(
+            pandas.Series.cat.remove_categories, removals, inplace=inplace
+        )
+
+    def remove_unused_categories(self, inplace=False):
+        return self._default_to_pandas(
+            pandas.Series.cat.remove_unused_categories, inplace=inplace
+        )
+
+    def set_categories(self, new_categories, ordered=None, rename=False, inplace=False):
+        return self._default_to_pandas(
+            pandas.Series.cat.set_categories,
+            new_categories,
+            ordered=ordered,
+            rename=rename,
+            inplace=inplace,
+        )
+
+    def as_ordered(self, inplace=False):
+        return self._default_to_pandas(pandas.Series.cat.as_ordered, inplace=inplace)
+
+    def as_unordered(self, inplace=False):
+        return self._default_to_pandas(pandas.Series.cat.as_unordered, inplace=inplace)
+
+    def _default_to_pandas(self, op, *args, **kwargs):
+        return self._series._default_to_pandas(
+            lambda series: op(series.cat, *args, **kwargs)
+        )


Test patch:
diff --git a/modin/pandas/test/test_series.py b/modin/pandas/test/test_series.py
--- a/modin/pandas/test/test_series.py
+++ b/modin/pandas/test/test_series.py
@@ -57,6 +57,8 @@
     eval_general,
     test_data_small_values,
     test_data_small_keys,
+    test_data_categorical_values,
+    test_data_categorical_keys,
 )
 
 pd.DEFAULT_NPARTITIONS = 4
@@ -4100,3 +4102,141 @@ def test_hasattr_sparse(data):
     else:
         modin_result = hasattr(modin_series, "sparse")
         assert modin_result == pandas_result
+
+
+@pytest.mark.parametrize(
+    "data", test_data_categorical_values, ids=test_data_categorical_keys
+)
+def test_cat_categories(data):
+    modin_series, pandas_series = create_test_series(data.copy())
+    df_equals(modin_series.cat.categories, pandas_series.cat.categories)
+    pandas_series.cat.categories = list("qwert")
+    modin_series.cat.categories = list("qwert")
+    df_equals(modin_series, pandas_series)
+
+
+@pytest.mark.parametrize(
+    "data", test_data_categorical_values, ids=test_data_categorical_keys
+)
+def test_cat_ordered(data):
+    modin_series, pandas_series = create_test_series(data.copy())
+    assert modin_series.cat.ordered == pandas_series.cat.ordered
+
+
+@pytest.mark.parametrize(
+    "data", test_data_categorical_values, ids=test_data_categorical_keys
+)
+def test_cat_codes(data):
+    modin_series, pandas_series = create_test_series(data.copy())
+    pandas_result = pandas_series.cat.codes
+    modin_result = modin_series.cat.codes
+    df_equals(modin_result, pandas_result)
+
+
+@pytest.mark.parametrize(
+    "data", test_data_categorical_values, ids=test_data_categorical_keys
+)
+@pytest.mark.parametrize("inplace", [True, False])
+def test_cat_rename_categories(data, inplace):
+    modin_series, pandas_series = create_test_series(data.copy())
+    pandas_result = pandas_series.cat.rename_categories(list("qwert"), inplace=inplace)
+    modin_result = modin_series.cat.rename_categories(list("qwert"), inplace=inplace)
+    df_equals(modin_series, pandas_series)
+    df_equals(modin_result, pandas_result)
+
+
+@pytest.mark.parametrize(
+    "data", test_data_categorical_values, ids=test_data_categorical_keys
+)
+@pytest.mark.parametrize("ordered", bool_arg_values, ids=bool_arg_keys)
+@pytest.mark.parametrize("inplace", [True, False])
+def test_cat_reorder_categories(data, ordered, inplace):
+    modin_series, pandas_series = create_test_series(data.copy())
+    pandas_result = pandas_series.cat.reorder_categories(
+        list("tades"), ordered=ordered, inplace=inplace
+    )
+    modin_result = modin_series.cat.reorder_categories(
+        list("tades"), ordered=ordered, inplace=inplace
+    )
+    df_equals(modin_series, pandas_series)
+    df_equals(modin_result, pandas_result)
+
+
+@pytest.mark.parametrize(
+    "data", test_data_categorical_values, ids=test_data_categorical_keys
+)
+@pytest.mark.parametrize("inplace", [True, False])
+def test_cat_add_categories(data, inplace):
+    modin_series, pandas_series = create_test_series(data.copy())
+    pandas_result = pandas_series.cat.add_categories(list("qw"), inplace=inplace)
+    modin_result = modin_series.cat.add_categories(list("qw"), inplace=inplace)
+    df_equals(modin_series, pandas_series)
+    df_equals(modin_result, pandas_result)
+
+
+@pytest.mark.parametrize(
+    "data", test_data_categorical_values, ids=test_data_categorical_keys
+)
+@pytest.mark.parametrize("inplace", [True, False])
+def test_cat_remove_categories(data, inplace):
+    modin_series, pandas_series = create_test_series(data.copy())
+    pandas_result = pandas_series.cat.remove_categories(list("at"), inplace=inplace)
+    modin_result = modin_series.cat.remove_categories(list("at"), inplace=inplace)
+    df_equals(modin_series, pandas_series)
+    df_equals(modin_result, pandas_result)
+
+
+@pytest.mark.parametrize(
+    "data", test_data_categorical_values, ids=test_data_categorical_keys
+)
+@pytest.mark.parametrize("inplace", [True, False])
+def test_cat_remove_unused_categories(data, inplace):
+    modin_series, pandas_series = create_test_series(data.copy())
+    pandas_series[1] = np.nan
+    pandas_result = pandas_series.cat.remove_unused_categories(inplace=inplace)
+    modin_series[1] = np.nan
+    modin_result = modin_series.cat.remove_unused_categories(inplace=inplace)
+    df_equals(modin_series, pandas_series)
+    df_equals(modin_result, pandas_result)
+
+
+@pytest.mark.parametrize(
+    "data", test_data_categorical_values, ids=test_data_categorical_keys
+)
+@pytest.mark.parametrize("ordered", bool_arg_values, ids=bool_arg_keys)
+@pytest.mark.parametrize("rename", [True, False])
+@pytest.mark.parametrize("inplace", [True, False])
+def test_cat_set_categories(data, ordered, rename, inplace):
+    modin_series, pandas_series = create_test_series(data.copy())
+    pandas_result = pandas_series.cat.set_categories(
+        list("qwert"), ordered=ordered, rename=rename, inplace=inplace
+    )
+    modin_result = modin_series.cat.set_categories(
+        list("qwert"), ordered=ordered, rename=rename, inplace=inplace
+    )
+    df_equals(modin_series, pandas_series)
+    df_equals(modin_result, pandas_result)
+
+
+@pytest.mark.parametrize(
+    "data", test_data_categorical_values, ids=test_data_categorical_keys
+)
+@pytest.mark.parametrize("inplace", [True, False])
+def test_cat_as_ordered(data, inplace):
+    modin_series, pandas_series = create_test_series(data.copy())
+    pandas_result = pandas_series.cat.as_ordered(inplace=inplace)
+    modin_result = modin_series.cat.as_ordered(inplace=inplace)
+    df_equals(modin_series, pandas_series)
+    df_equals(modin_result, pandas_result)
+
+
+@pytest.mark.parametrize(
+    "data", test_data_categorical_values, ids=test_data_categorical_keys
+)
+@pytest.mark.parametrize("inplace", [True, False])
+def test_cat_as_unordered(data, inplace):
+    modin_series, pandas_series = create_test_series(data.copy())
+    pandas_result = pandas_series.cat.as_unordered(inplace=inplace)
+    modin_result = modin_series.cat.as_unordered(inplace=inplace)
+    df_equals(modin_series, pandas_series)
+    df_equals(modin_result, pandas_result)
diff --git a/modin/pandas/test/utils.py b/modin/pandas/test/utils.py
--- a/modin/pandas/test/utils.py
+++ b/modin/pandas/test/utils.py
@@ -173,6 +173,14 @@
 test_data_with_duplicates_values = list(test_data_with_duplicates.values())
 test_data_with_duplicates_keys = list(test_data_with_duplicates.keys())
 
+test_data_categorical = {
+    "ordered": pandas.Categorical(list("testdata"), ordered=True),
+    "unordered": pandas.Categorical(list("testdata"), ordered=False),
+}
+
+test_data_categorical_values = list(test_data_categorical.values())
+test_data_categorical_keys = list(test_data_categorical.keys())
+
 numeric_dfs = [
     "empty_data",
     "columns_only",