Repository: feature-engine/feature_engine (python)

Issue:
Would be useful to have a simple categorical levels encoder
It would be useful if there could be a class that would encode `object` / `category` and similar dtypes into `pd.Categorical`, while remembering the categories / encodings / levels that were used when calling `transform` later, but without applying any further transformation like one-hot (akin to R's `factor(data, levels)`).

Pull request:
Add new transformer `MatchCategories` to preprocessing module
Fixes #418

This PR introduces a transformer `CategoryEncoder` which simply encodes columns as categorical dtype remembering the encodings / levels that were used for each variable.

As per the issue comments, I put it under `preprocessing`, but it is based on the base categorical encoder that the transformers in `encoding` use.

There's a few things that I was not sure about:
* I see that some modules use absolute imports (`from feature_engine... import ...`). I am not sure if that was intentional or not. This PR uses relative imports for internal objects.
* I see some transformers modify the data directly by using `df[column]`, creating an extra copy of the data beforehand which is inefficient (opened an issue about it: https://github.com/feature-engine/feature_engine/issues/457). For this PR instead I used `df.assign`, but it's still incurring an extra copy due to calling the input-checking methods.

Patch:
diff --git a/README.md b/README.md
index 83348fc..49e7491 100644
--- a/README.md
+++ b/README.md
@@ -134,6 +134,7 @@ transforming parameters from the data and then transform it.
  * ExpandingWindowFeatures
  
 ### Preprocessing
+ * MatchCategories
  * MatchVariables
  
 ### Wrappers:
diff --git a/docs/api_doc/preprocessing/MatchCategories.rst b/docs/api_doc/preprocessing/MatchCategories.rst
new file mode 100644
index 0000000..8f199b6
--- /dev/null
+++ b/docs/api_doc/preprocessing/MatchCategories.rst
@@ -0,0 +1,6 @@
+MatchCategories
+===============
+
+.. autoclass:: feature_engine.preprocessing.MatchCategories
+    :members:
+
diff --git a/docs/api_doc/preprocessing/index.rst b/docs/api_doc/preprocessing/index.rst
index b5bd923..dc9f549 100644
--- a/docs/api_doc/preprocessing/index.rst
+++ b/docs/api_doc/preprocessing/index.rst
@@ -9,4 +9,5 @@ and transformation procedures.
 .. toctree::
    :maxdepth: 2
 
+   MatchCategories
    MatchVariables
diff --git a/docs/index.rst b/docs/index.rst
index 624f5eb..1e4ad97 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -194,6 +194,7 @@ Forecasting:
 Preprocessing:
 --------------
 
+- :doc:`api_doc/preprocessing/MatchCategories`: ensures categorical variables are of type 'category'
 - :doc:`api_doc/preprocessing/MatchVariables`: ensures that columns in test set match those in train set
 
 Scikit-learn Wrapper:
diff --git a/docs/user_guide/preprocessing/MatchCategories.rst b/docs/user_guide/preprocessing/MatchCategories.rst
new file mode 100644
index 0000000..3c810b8
--- /dev/null
+++ b/docs/user_guide/preprocessing/MatchCategories.rst
@@ -0,0 +1,169 @@
+.. _match_categories:
+
+.. currentmodule:: feature_engine.preprocessing
+
+MatchCategories
+===============
+
+:class:`MatchCategories()` ensures that categorical variables are encoded as pandas
+'categorical' dtype instead of generic python 'object' or other dtypes.
+
+Under the hood, 'categorical' dtype is a representation that maps each
+category to an integer, thus providing a more memory-efficient object
+structure than, for example, 'str', and allowing faster grouping, mapping, and similar
+operations on the resulting object.
+
+:class:`MatchCategories()` remembers the encodings or levels that represent each
+category, and can thus can be used to ensure that the correct encoding gets
+applied when passing categorical data to modeling packages that support this
+dtype, or to prevent unseen categories from reaching a further transformer
+or estimator in a pipeline, for example.
+
+Let's explore this with an example. First we load the Titanic dataset and split it into
+a train and a test sets:
+
+.. code:: python
+
+    import numpy as np
+    import pandas as pd
+
+    from feature_engine.preprocessing import MatchCategories
+
+
+    # Load dataset
+    def load_titanic():
+        data = pd.read_csv('https://www.openml.org/data/get_csv/16826755/phpMYEkMl')
+        data = data.replace('?', np.nan)
+        data['cabin'] = data['cabin'].astype(str).str[0]
+        data['pclass'] = data['pclass'].astype('O')
+        data['age'] = data['age'].astype('float')
+        data['fare'] = data['fare'].astype('float')
+        data['embarked'].fillna('C', inplace=True)
+        data.drop(
+            labels=['name', 'ticket', 'boat', 'body', 'home.dest'],
+            axis=1, inplace=True,
+        )
+        return data
+
+    # load data as pandas dataframe
+    data = load_titanic()
+
+    # Split test and train
+    train = data.iloc[0:1000, :]
+    test = data.iloc[1000:, :]
+
+Now, we set up :class:`MatchCategories()` and fit it to the train set.
+
+.. code:: python
+
+    # set up the transformer
+    match_categories = MatchCategories(errors="ignore")
+
+    # learn the mapping of categories to integers in the train set
+    match_categories.fit(train)
+
+:class:`MatchCategories()` stores the mappings from the train set in its attribute:
+
+.. code:: python
+
+    # the transformer stores the mappings for categorical variables
+    match_categories.category_dict_
+
+.. code:: python
+
+    {'pclass': Int64Index([1, 2, 3], dtype='int64'),
+     'sex': Index(['female', 'male'], dtype='object'),
+     'cabin': Index(['A', 'B', 'C', 'D', 'E', 'F', 'T', 'n'], dtype='object'),
+     'embarked': Index(['C', 'Q', 'S'], dtype='object')}
+
+
+If we transform the test dataframe using the same `match_categories` object,
+categorical variables will be converted to a 'category' dtype with the same
+numeration (mapping from categories to integers) that was applied to the train
+dataset:
+
+.. code:: python
+
+    # encoding that would be gotten from the train set
+    train.embarked.unique()
+
+.. code:: python
+
+    array(['S', 'C', 'Q'], dtype=object)
+
+.. code:: python
+    
+    # encoding that would be gotten from the test set
+    test.embarked.unique()
+
+.. code:: python
+
+    array(['Q', 'S', 'C'], dtype=object)
+
+.. code:: python
+    
+    # with 'match_categories', the encoding remains the same
+    match_categories.transform(train).embarked.cat.categories
+
+.. code:: python
+
+    Index(['C', 'Q', 'S'], dtype='object')
+
+.. code:: python
+
+    # this will have the same encoding as the train set
+    match_categories.transform(test).embarked.cat.categories
+
+.. code:: python
+
+    Index(['C', 'Q', 'S'], dtype='object')
+
+
+If some category was not present in the training data, it will not mapped
+to any integer and will thus not get encoded. This behavior can be modified through the
+parameter `errors`:
+
+.. code:: python
+
+    # categories present in the train data
+    train.cabin.unique()
+
+.. code:: python
+
+    array(['B', 'C', 'E', 'D', 'A', 'n', 'T', 'F'], dtype=object)
+
+.. code:: python
+    
+    # categories present in the test data - 'G' is new
+    test.cabin.unique()
+
+.. code:: python
+
+    array(['n', 'F', 'E', 'G'], dtype=object)
+
+.. code:: python
+
+    match_categories.transform(train).cabin.unique()
+
+.. code:: python
+
+    ['B', 'C', 'E', 'D', 'A', 'n', 'T', 'F']
+    Categories (8, object): ['A', 'B', 'C', 'D', 'E', 'F', 'T', 'n']
+
+.. code:: python
+    
+    # unseen category 'G' will not get mapped to any integer
+    match_categories.transform(test).cabin.unique()
+
+.. code:: python
+
+    ['n', 'F', 'E', NaN]
+    Categories (8, object): ['A', 'B', 'C', 'D', 'E', 'F', 'T', 'n']
+
+
+When to use the transformer
+^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+This transformer is useful when creating custom transformers for categorical columns,
+as well as when passing categorical columns to modeling packages which support them
+natively but leave the variable casting to the user, such as ``lightgbm`` or ``glum``.
diff --git a/docs/user_guide/preprocessing/index.rst b/docs/user_guide/preprocessing/index.rst
index 874eeac..cca521d 100644
--- a/docs/user_guide/preprocessing/index.rst
+++ b/docs/user_guide/preprocessing/index.rst
@@ -9,4 +9,5 @@ and transformation procedures.
 .. toctree::
    :maxdepth: 1
 
+   MatchCategories
    MatchVariables
diff --git a/feature_engine/encoding/base_encoder.py b/feature_engine/encoding/base_encoder.py
index 5290d84..376b699 100644
--- a/feature_engine/encoding/base_encoder.py
+++ b/feature_engine/encoding/base_encoder.py
@@ -96,6 +96,10 @@ class CategoricalMethodsMixin(BaseEstimator, TransformerMixin):
     TransformerMixin brings method fit_transform()
     """
 
+    def _fit(self, X: pd.DataFrame):
+        self._check_or_select_variables(X)
+        _check_contains_na(X, self.variables_)
+
     def _check_or_select_variables(self, X: pd.DataFrame):
         """
         Finds categorical variables, or alternatively checks that the variables
@@ -123,9 +127,6 @@ class CategoricalMethodsMixin(BaseEstimator, TransformerMixin):
             # select all variables or check variables entered by the user
             self.variables_ = _find_all_variables(X, self.variables)
 
-        # check if dataset contains na
-        _check_contains_na(X, self.variables_)
-
     def _get_feature_names_in(self, X: pd.DataFrame):
 
         # save input features
@@ -166,9 +167,6 @@ class CategoricalMethodsMixin(BaseEstimator, TransformerMixin):
         # Check input data contains same number of columns as df used to fit
         _check_X_matches_training_df(X, self.n_features_in_)
 
-        # check if dataset contains na
-        _check_contains_na(X, self.variables_)
-
         # reorder df to match train set
         X = X[self.feature_names_in_]
 
@@ -203,6 +201,9 @@ class CategoricalMethodsMixin(BaseEstimator, TransformerMixin):
 
         X = self._check_transform_input_and_state(X)
 
+        # check if dataset contains na
+        _check_contains_na(X, self.variables_)
+
         # replace categories by the learned parameters
         for feature in self.encoder_dict_.keys():
             X[feature] = X[feature].map(self.encoder_dict_[feature])
diff --git a/feature_engine/encoding/count_frequency.py b/feature_engine/encoding/count_frequency.py
index 6243a58..6dfe41b 100644
--- a/feature_engine/encoding/count_frequency.py
+++ b/feature_engine/encoding/count_frequency.py
@@ -155,7 +155,7 @@ class CountFrequencyEncoder(CategoricalInitMixin, CategoricalMethodsMixin):
             y is not needed in this encoder. You can pass y or None.
         """
         X = check_X(X)
-        self._check_or_select_variables(X)
+        self._fit(X)
         self._get_feature_names_in(X)
 
         self.encoder_dict_ = {}
diff --git a/feature_engine/encoding/decision_tree.py b/feature_engine/encoding/decision_tree.py
index b3cfab9..e9a957f 100644
--- a/feature_engine/encoding/decision_tree.py
+++ b/feature_engine/encoding/decision_tree.py
@@ -206,7 +206,7 @@ class DecisionTreeEncoder(CategoricalInitMixin, CategoricalMethodsMixin):
         else:
             check_classification_targets(y)
 
-        self._check_or_select_variables(X)
+        self._fit(X)
         self._get_feature_names_in(X)
 
         if self.param_grid:
diff --git a/feature_engine/encoding/mean_encoding.py b/feature_engine/encoding/mean_encoding.py
index 6ca3885..0816dee 100644
--- a/feature_engine/encoding/mean_encoding.py
+++ b/feature_engine/encoding/mean_encoding.py
@@ -138,7 +138,7 @@ class MeanEncoder(CategoricalInitExpandedMixin, CategoricalMethodsMixin):
         """
 
         X, y = check_X_y(X, y)
-        self._check_or_select_variables(X)
+        self._fit(X)
         self._get_feature_names_in(X)
 
         temp = pd.concat([X, y], axis=1)
diff --git a/feature_engine/encoding/one_hot.py b/feature_engine/encoding/one_hot.py
index 190ee39..163aa66 100644
--- a/feature_engine/encoding/one_hot.py
+++ b/feature_engine/encoding/one_hot.py
@@ -14,7 +14,7 @@ from feature_engine._docstrings.fit_attributes import (
 )
 from feature_engine._docstrings.methods import _fit_transform_docstring
 from feature_engine._docstrings.substitute import Substitution
-from feature_engine.dataframe_checks import check_X
+from feature_engine.dataframe_checks import check_X, _check_contains_na
 from feature_engine.encoding._docstrings import (
     _ignore_format_docstring,
     _variables_docstring,
@@ -185,7 +185,7 @@ class OneHotEncoder(CategoricalInitMixin, CategoricalMethodsMixin):
         """
 
         X = check_X(X)
-        self._check_or_select_variables(X)
+        self._fit(X)
         self._get_feature_names_in(X)
 
         self.encoder_dict_ = {}
@@ -247,6 +247,9 @@ class OneHotEncoder(CategoricalInitMixin, CategoricalMethodsMixin):
 
         X = self._check_transform_input_and_state(X)
 
+        # check if dataset contains na
+        _check_contains_na(X, self.variables_)
+
         for feature in self.variables_:
             for category in self.encoder_dict_[feature]:
                 X[str(feature) + "_" + str(category)] = np.where(
diff --git a/feature_engine/encoding/ordinal.py b/feature_engine/encoding/ordinal.py
index ef05f48..4d6f6d3 100644
--- a/feature_engine/encoding/ordinal.py
+++ b/feature_engine/encoding/ordinal.py
@@ -157,7 +157,7 @@ class OrdinalEncoder(CategoricalInitExpandedMixin, CategoricalMethodsMixin):
         else:
             X = check_X(X)
 
-        self._check_or_select_variables(X)
+        self._fit(X)
         self._get_feature_names_in(X)
 
         if self.encoding_method == "ordered":
diff --git a/feature_engine/encoding/probability_ratio.py b/feature_engine/encoding/probability_ratio.py
index fd3c9da..6e7a4db 100644
--- a/feature_engine/encoding/probability_ratio.py
+++ b/feature_engine/encoding/probability_ratio.py
@@ -169,7 +169,7 @@ class PRatioEncoder(CategoricalInitExpandedMixin, CategoricalMethodsMixin):
                 "used has more than 2 unique values."
             )
 
-        self._check_or_select_variables(X)
+        self._fit(X)
         self._get_feature_names_in(X)
 
         temp = pd.concat([X, y], axis=1)
diff --git a/feature_engine/encoding/rare_label.py b/feature_engine/encoding/rare_label.py
index 85cc4ae..0c349c4 100644
--- a/feature_engine/encoding/rare_label.py
+++ b/feature_engine/encoding/rare_label.py
@@ -19,6 +19,7 @@ from feature_engine.encoding._docstrings import (
     _ignore_format_docstring,
     _variables_docstring,
 )
+from feature_engine.dataframe_checks import _check_contains_na
 from feature_engine.encoding.base_encoder import (
     CategoricalInitMixin,
     CategoricalMethodsMixin,
@@ -152,7 +153,7 @@ class RareLabelEncoder(CategoricalInitMixin, CategoricalMethodsMixin):
         """
 
         X = check_X(X)
-        self._check_or_select_variables(X)
+        self._fit(X)
         self._get_feature_names_in(X)
 
         self.encoder_dict_ = {}
@@ -204,6 +205,9 @@ class RareLabelEncoder(CategoricalInitMixin, CategoricalMethodsMixin):
 
         X = self._check_transform_input_and_state(X)
 
+        # check if dataset contains na
+        _check_contains_na(X, self.variables_)
+
         for feature in self.variables_:
             X[feature] = np.where(
                 X[feature].isin(self.encoder_dict_[feature]),
diff --git a/feature_engine/encoding/woe.py b/feature_engine/encoding/woe.py
index 8797d25..f151f50 100644
--- a/feature_engine/encoding/woe.py
+++ b/feature_engine/encoding/woe.py
@@ -151,7 +151,7 @@ class WoEEncoder(CategoricalInitExpandedMixin, CategoricalMethodsMixin):
                 "used has more than 2 unique values."
             )
 
-        self._check_or_select_variables(X)
+        self._fit(X)
         self._get_feature_names_in(X)
 
         temp = pd.concat([X, y], axis=1)
diff --git a/feature_engine/preprocessing/__init__.py b/feature_engine/preprocessing/__init__.py
index c477ab2..25ba600 100644
--- a/feature_engine/preprocessing/__init__.py
+++ b/feature_engine/preprocessing/__init__.py
@@ -3,8 +3,10 @@ The module preprocessing includes classes and functions for general data pre-pro
 and transformation.
 """
 
+from .match_categories import MatchCategories
 from .match_columns import MatchVariables
 
 __all__ = [
+    "MatchCategories",
     "MatchVariables",
 ]
diff --git a/feature_engine/preprocessing/match_categories.py b/feature_engine/preprocessing/match_categories.py
new file mode 100644
index 0000000..5e61607
--- /dev/null
+++ b/feature_engine/preprocessing/match_categories.py
@@ -0,0 +1,170 @@
+import warnings
+from typing import List, Optional, Union
+
+import pandas as pd
+from feature_engine._docstrings.class_inputs import _missing_values_docstring
+from feature_engine._docstrings.fit_attributes import (
+    _feature_names_in_docstring,
+    _n_features_in_docstring,
+    _variables_attribute_docstring,
+)
+from feature_engine._docstrings.substitute import Substitution
+from feature_engine.dataframe_checks import _check_contains_na, check_X
+from feature_engine.encoding._docstrings import (
+    _ignore_format_docstring,
+    _variables_docstring,
+)
+from feature_engine.encoding.base_encoder import (
+    CategoricalInitMixin,
+    CategoricalMethodsMixin,
+)
+
+
+@Substitution(
+    ignore_format=_ignore_format_docstring,
+    missing_values=_missing_values_docstring,
+    variables=_variables_docstring,
+    variables_=_variables_attribute_docstring,
+    feature_names_in_=_feature_names_in_docstring,
+    n_features_in_=_n_features_in_docstring,
+)
+class MatchCategories(CategoricalInitMixin, CategoricalMethodsMixin):
+    """
+    MatchCategories() ensures that categorical variables are encoded as pandas
+    `'categorical'` dtype, instead of generic python `'object'` or other dtypes.
+
+    Under the hood, `'categorical'` dtype is a representation that maps each
+    category to an integer, thus providing a more memory-efficient object
+    structure than, e.g., 'str', and allowing faster grouping, mapping, and similar
+    operations on the resulting object.
+
+    MatchCategories() remembers the encodings or levels that represent each
+    category, and can thus can be used to ensure that the correct encoding gets
+    applied when passing categorical data to modeling packages that support this
+    dtype, or to prevent unseen categories from reaching a further transformer
+    or estimator in a pipeline, for example.
+
+    More details in the :ref:`User Guide <match_categories>`.
+
+    Parameters
+    ----------
+    {variables}
+
+    {ignore_format}
+
+    {missing_values}
+
+    Attributes
+    ----------
+    category_dict_:
+        Dictionary with the category encodings assigned to each variable.
+
+    {variables_}
+
+    {feature_names_in_}
+
+    {n_features_in_}
+
+    Methods
+    -------
+    fit:
+        Learn the encodings or levels to use for each variable.
+
+    transform:
+        Enforce the type of categorical variables as dtype `categorical`.
+    """
+
+    def __init__(
+        self,
+        variables: Union[None, int, str, List[Union[str, int]]] = None,
+        ignore_format: bool = False,
+        missing_values: str = "raise",
+    ) -> None:
+
+        if missing_values not in ["raise", "ignore"]:
+            raise ValueError(
+                "missing_values takes only values 'raise' or 'ignore'. "
+                f"Got {missing_values} instead."
+            )
+
+        super().__init__(variables, ignore_format)
+        self.missing_values = missing_values
+
+    def fit(self, X: pd.DataFrame, y: Optional[pd.Series] = None):
+        """
+        Learn the encodings or levels to use for representing categorical variables.
+
+        Parameters
+        ----------
+        X: pandas dataframe of shape = [n_samples, n_features]
+            The training dataset. Can be the entire dataframe, not just the
+            variables to be transformed.
+
+        y: pandas Series, default = None
+            y is not needed in this encoder. You can pass y or None.
+        """
+        X = check_X(X)
+        self._check_or_select_variables(X)
+
+        if self.missing_values == "raise":
+            _check_contains_na(X, self.variables_)
+
+        self._get_feature_names_in(X)
+
+        self.category_dict_ = dict()
+        for var in self.variables_:
+            self.category_dict_[var] = pd.Categorical(X[var]).categories
+
+        return self
+
+    def transform(self, X: pd.DataFrame) -> pd.DataFrame:
+        """
+        Encode categorical variables as pandas categorical dtype.
+
+        Parameters
+        ----------
+        X: pandas dataframe of shape = [n_samples, n_features].
+            The dataset to encode.
+
+        Returns
+        -------
+        X_new: pandas dataframe of shape = [n_samples, n_features].
+            The dataframe with the variables encoded as pandas categorical dtype.
+        """
+        X = self._check_transform_input_and_state(X)
+
+        if self.missing_values == "raise":
+            _check_contains_na(X, self.variables_)
+
+        for feature, levels in self.category_dict_.items():
+            X[feature] = pd.Categorical(X[feature], levels)
+
+        self._check_nas_in_result(X)
+        return X
+
+    def _check_nas_in_result(self, X: pd.DataFrame):
+        # check if NaN values were introduced by the encoding
+        if X[self.category_dict_.keys()].isnull().sum().sum() > 0:
+
+            # obtain the name(s) of the columns that have null values
+            nan_columns = (
+                X[self.category_dict_.keys()]
+                .columns[X[self.category_dict_.keys()].isnull().any()]
+                .tolist()
+            )
+
+            if len(nan_columns) > 1:
+                nan_columns_str = ", ".join(nan_columns)
+            else:
+                nan_columns_str = nan_columns[0]
+
+            if self.missing_values == "ignore":
+                warnings.warn(
+                    "During the encoding, NaN values were introduced in the feature(s) "
+                    f"{nan_columns_str}."
+                )
+            elif self.missing_values == "raise":
+                raise ValueError(
+                    "During the encoding, NaN values were introduced in the feature(s) "
+                    f"{nan_columns_str}."
+                )


Test patch:
diff --git a/tests/test_preprocessing/test_check_estimator_preprocessing.py b/tests/test_preprocessing/test_check_estimator_preprocessing.py
index 439e8cb..70c6801 100644
--- a/tests/test_preprocessing/test_check_estimator_preprocessing.py
+++ b/tests/test_preprocessing/test_check_estimator_preprocessing.py
@@ -2,9 +2,9 @@ import pytest
 from sklearn.utils.estimator_checks import check_estimator
 
 from tests.estimator_checks.estimator_checks import check_feature_engine_estimator
-from feature_engine.preprocessing import MatchVariables
+from feature_engine.preprocessing import MatchCategories, MatchVariables
 
-_estimators = [MatchVariables()]
+_estimators = [MatchCategories(ignore_format=True), MatchVariables()]
 
 
 @pytest.mark.parametrize("estimator", _estimators)
@@ -12,6 +12,6 @@ def test_check_estimator_from_sklearn(estimator):
     return check_estimator(estimator)
 
 
-@pytest.mark.parametrize("estimator", _estimators)
+@pytest.mark.parametrize("estimator", [MatchCategories(), MatchVariables()])
 def test_check_estimator_from_feature_engine(estimator):
     return check_feature_engine_estimator(estimator)
diff --git a/tests/test_preprocessing/test_match_categories.py b/tests/test_preprocessing/test_match_categories.py
new file mode 100644
index 0000000..de7a4ea
--- /dev/null
+++ b/tests/test_preprocessing/test_match_categories.py
@@ -0,0 +1,67 @@
+import warnings
+
+import numpy as np
+import pandas as pd
+import pytest
+
+from feature_engine.preprocessing import MatchCategories
+
+
+def test_category_encoder_outputs_correct_dtype():
+    df_str = pd.DataFrame({"col1": ["a", "b", "c"]})
+    res_str = MatchCategories().fit(df_str).transform(df_str)
+    assert res_str.dtypes["col1"] == "category"
+
+    df_float = pd.DataFrame({"col1": [1.0, 2.0, 3.0]})
+    tr = MatchCategories(variables=["col1"], ignore_format=True)
+    res_float = tr.fit(df_float).transform(df_float)
+    assert res_float.dtypes["col1"] == "category"
+
+    df_obj = pd.DataFrame({"col1": ["a", None, -1.0]})
+    with warnings.catch_warnings():
+        warnings.simplefilter("ignore")
+        res_obj = MatchCategories(missing_values="ignore").fit(df_obj).transform(df_obj)
+    assert res_obj.dtypes["col1"] == "category"
+
+    df_categ = pd.DataFrame({"col1": pd.Categorical(pd.Series(["a", "b", "c"]))})
+    res_categ = MatchCategories().fit(df_categ).transform(df_categ)
+    assert res_categ.dtypes["col1"] == "category"
+
+
+def test_category_encoder_handles_missing():
+    df_no_nas = pd.DataFrame({"col1": ["a", "b", "c"]})
+    df_nas = pd.DataFrame({"col1": ["a", "b", None]})
+    df_new = pd.DataFrame({"col1": ["a", "b", "d"]})
+
+    # check that it fails for missing values when using 'raise'
+    tr = MatchCategories(missing_values="raise")
+    with pytest.raises(ValueError):
+        tr.fit(df_nas)
+
+    tr.fit(df_no_nas)
+    with pytest.raises(ValueError):
+        tr.transform(df_nas)
+
+    # check that it doens't fail for missing values when using 'ignore'
+    tr = MatchCategories(missing_values="ignore").fit(df_nas)
+    with pytest.warns(UserWarning):
+        tr.transform(df_nas)
+
+    # check that it doesn't fail at transforming new values when using 'ignore'
+    tr = MatchCategories(missing_values="ignore").fit(df_no_nas)
+    with pytest.warns(UserWarning):
+        tr.transform(df_new)
+
+
+def test_category_outputs_correct_results():
+    df = pd.DataFrame({"col1": ["a", "b", "c"], "col2": [1.0, 2.0, 3.0]})
+    res = MatchCategories(variables=["col1", "col2"], ignore_format=True).fit_transform(
+        df
+    )
+    pd.testing.assert_frame_equal(df, res, check_dtype=False, check_categorical=False)
+
+    df = pd.DataFrame({"col1": ["a", "b", None], "col2": [1.0, 2.0, np.nan]})
+    res = MatchCategories(
+        variables=["col1", "col2"], ignore_format=True, missing_values="ignore"
+    ).fit_transform(df)
+    pd.testing.assert_frame_equal(df, res, check_dtype=False, check_categorical=False)