Repository: pyjanitor-devs/pyjanitor

Issue:
"as_categorical"
Another idea!

```python
@pf.register_dataframe_method
def as_categorical(df, column_name, categories=None, ordered=True):
    """
    Convert one column in the dataframe to a categorical one.
    """
    if categories is None:
        categories = sorted(df[column_name].unique())
    df[column_name] = pd.Categorical(
        df[column_name], categories=categories, ordered=ordered,
    )
    return df
```

It replaces repeatedly doing this piece:

```python
df[column_name] = pd.Categorical(
    df[column_name], categories=categories, ordered=ordered,
)
```

Leaving this here for now.


Discussion:

Resolving patch:
diff --git a/janitor/functions.py b/janitor/functions.py
--- a/janitor/functions.py
+++ b/janitor/functions.py
@@ -1,5 +1,6 @@
 """ General purpose data cleaning functions. """
 
+import collections
 import datetime as dt
 import inspect
 import itertools
@@ -15,6 +16,7 @@
     Hashable,
     Iterable,
     List,
+    NamedTuple,
     Optional,
     Pattern,
     Set,
@@ -36,6 +38,7 @@
     _check_instance,
     _clean_accounting_column,
     _complete_groupings,
+    _computations_as_categorical,
     _computations_pivot_longer,
     _computations_pivot_wider,
     _currency_column_to_numeric,
@@ -465,56 +468,279 @@ def get_dupes(
     return df[dupes == True]  # noqa: E712
 
 
+def As_Categorical(
+    categories: Optional[
+        Union[List, Set, Tuple, pd.Series, np.ndarray]
+    ] = None,
+    order: Optional[str] = None,
+) -> NamedTuple:
+    """
+    Helper function for `encode_categorical`. It makes creating the
+    `categories` and `order` more explicit. Inspired by pd.NamedAgg.
+
+    :param categories: list-like object to create new categorical column.
+    :param order: string object that can be either "sort" or "appearance".
+        If "sort", the `categories` argument will be sorted with np.sort;
+        if "apperance", the `categories` argument will be used as is.
+    :returns: A namedtuple of (`categories`, `order`).
+    """
+    AsCategorical = collections.namedtuple(
+        "AsCategorical", ["categories", "order"], defaults=(None, None)
+    )
+
+    return AsCategorical._make((categories, order))
+
+
 @pf.register_dataframe_method
 @deprecated_alias(columns="column_names")
 def encode_categorical(
-    df: pd.DataFrame, column_names: Union[str, Iterable[str], Hashable]
+    df: pd.DataFrame,
+    column_names: Union[str, Iterable[str], Hashable] = None,
+    **kwargs,
 ) -> pd.DataFrame:
     """Encode the specified columns with Pandas'
     `category dtype <http://pandas.pydata.org/pandas-docs/stable/user_guide/categorical.html>`_.
 
-    This method mutates the original DataFrame.
+    Categories and order can be explicitly specified via the `kwargs` option, which is a
+    pairing of column name and a tuple of (categories, order).
+
+    The `janitor.As_Categorical` function is provided to make it clearer what the arguments
+    to the function are.
+
+    It is syntactic sugar around `pd.Categorical`.
+
+    This method does not mutate the original DataFrame.
+
+    .. note:: In versions < 0.20.11, this method mutates the original DataFrame.
+
+    **Examples:**
+
+
+    .. code-block:: python
+
+               col1	col2	col3
+        0	2.0	a	2020-01-01
+        1	1.0	b	2020-01-02
+        2	3.0	c	2020-01-03
+        3	1.0	d	2020-01-04
+        4	NaN	a	2020-01-05
+
+        df.dtypes
+
+        col1           float64
+        col2            object
+        col3    datetime64[ns]
+        dtype: object
+
+    Specific columns can be converted to category type:
+
+    .. code-block:: python
+
+        df = (pd.DataFrame(...)
+                .encode_categorical(
+                    column_names=['col1', 'col2', 'col3']
+                    )
+            )
+
+        df.dtypes
+
+        col1    category
+        col2    category
+        col3    category
+        dtype: object
+
+    Note that for the code above, the categories were inferred from
+    the columns, and is unordered::
+
+        df['col3']
+        0   2020-01-01
+        1   2020-01-02
+        2   2020-01-03
+        3   2020-01-04
+        4   2020-01-05
+        Name: col3, dtype: category
+        Categories (5, datetime64[ns]):
+        [2020-01-01, 2020-01-02, 2020-01-03, 2020-01-04, 2020-01-05]
+
+
+    Explicit categories can be provided, and ordered via the ``kwargs``
+    parameter::
+
+        df = (pd.DataFrame(...)
+                .as_categorical(
+                    col1 = ([3, 2, 1, 4], "appearance"),
+                    col2 = (['a','d','c','b'], "sort")
+                    )
+            )
+
+        df['col1']
+        0      2
+        1      1
+        2      3
+        3      1
+        4    NaN
+        Name: col1, dtype: category
+        Categories (4, int64): [3 < 2 < 1 < 4]
+
+        df['col2']
+        0    a
+        1    b
+        2    c
+        3    d
+        4    a
+        Name: col2, dtype: category
+        Categories (4, object): [a < b < c < d]
+
+    When the `order` parameter is "appearance", the categories argument is used as-is;
+    if the `order` is "sort", the categories argument is sorted in ascending order;
+    if `order` is ``None``, then the categories argument is applied unordered.
+
+    The ``janitor.As_Categorical`` function can also be used to make clearer
+    what the arguments to the function are::
+
+        df = (pd.DataFrame(...)
+                .as_categorical(
+                    col1 = As_Categorical(
+                                categories = [3, 2, 1, 4],
+                                order = "appearance"
+                                ),
+                    col2 = As_Categorical(
+                                categories = ['a','d','c','b'],
+                                order = "sort"
+                                )
+                    )
+            )
+
+    A User Warning will be generated if some or all of the unique values
+    in the column are not present in the provided `categories` argument.
+
+    .. code-block:: python
+
+        df = (pd.DataFrame(...)
+                .encode_categorical(
+                    col1 = As_Categorical(
+                            categories = [4, 5, 6],
+                            order = "appearance"
+                            )
+            )
+
+        UserWarning: None of the values in col1 are in [4, 5, 6];
+                     this might create nulls for all your values
+                     in the new categorical column.
+
+        df['col1']
+        0    NaN
+        1    NaN
+        2    NaN
+        3    NaN
+        4    NaN
+        Name: col1, dtype: category
+        Categories (3, int64): [4 < 5 < 6]
+
+
+    .. note:: if ``categories`` is None in the ``kwargs`` tuple, then the
+        values for `categories` are inferred from the column; if `order`
+        is None, then the values for categories are applied unordered.
+
+    .. note:: ``column_names`` and ``kwargs`` parameters cannot be used at
+        the same time.
 
     Functional usage syntax:
 
     .. code-block:: python
 
+        import pandas as pd
+        import janitor as jn
+
+    - With ``column_names``::
+
         categorical_cols = ['col1', 'col2', 'col4']
-        df = encode_categorical(df, columns=categorical_cols)  # one way
+        df = jn.encode_categorical(
+                    df,
+                    columns = categorical_cols)  # one way
+
+    - With ``kwargs``::
+
+        df = jn.encode_categorical(
+                    df,
+                    col1 = (categories, order),
+                    col2 = jn.As_Categorical(
+                                categories = [values],
+                                order="sort"/"appearance"/None
+                                )
+                )
 
     Method chaining syntax:
 
-    .. code-block:: python
+    - With ``column_names``::
 
-        import pandas as pd
-        import janitor
         categorical_cols = ['col1', 'col2', 'col4']
-        df = pd.DataFrame(...).encode_categorical(columns=categorical_cols)
+        df = (pd.DataFrame(...)
+                .encode_categorical(columns=categorical_cols)
+            )
+
+    - With ``kwargs``::
+
+        df = (
+            pd.DataFrame(...)
+            .encode_categorical(
+                col1 = (categories, order),
+                col2 = jn.As_Categorical(
+                            categories = [values],
+                            order="sort"/"appearance"/None
+                            )
+        )
+
 
     :param df: The pandas DataFrame object.
     :param column_names: A column name or an iterable (list or
         tuple) of column names.
+    :param kwargs: A pairing of column name to a tuple of (`categories`, `order`).
+        There is also the `janitor.As_Categorical` function, which creates a
+        namedtuple of (`categories`, `order`) to make it clearer what the arguments
+        are. This is useful in creating categorical columns that are ordered, or
+        if the user needs to explicitly specify the categories.
     :returns: A pandas DataFrame.
     :raises JanitorError: if a column specified within ``column_names``
         is not found in the DataFrame.
     :raises JanitorError: if ``column_names`` is not hashable
         nor iterable.
+    :raises ValueError: if both ``column_names`` and ``kwargs`` are provided.
     """  # noqa: E501
-    if isinstance(column_names, list) or isinstance(column_names, tuple):
-        for col in column_names:
-            if col not in df.columns:
-                raise JanitorError(f"{col} missing from DataFrame columns!")
-            df[col] = pd.Categorical(df[col])
-    elif isinstance(column_names, Hashable):
-        if column_names not in df.columns:
+
+    df = df.copy()
+
+    if all((column_names, kwargs)):
+        raise ValueError(
+            """
+            Only one of `column_names` or `kwargs`
+            can be provided.
+            """
+        )
+    # column_names deal with only category dtype (unordered)
+    # kwargs takes care of scenarios where user wants an ordered category
+    # or user supplies specific categories to create the categorical
+    if column_names:
+        if isinstance(column_names, (list, Tuple)):
+            for col in column_names:
+                if col not in df.columns:
+                    raise JanitorError(
+                        f"{col} missing from DataFrame columns!"
+                    )
+                df[col] = pd.Categorical(df[col])
+        elif isinstance(column_names, Hashable):
+            if column_names not in df.columns:
+                raise JanitorError(
+                    f"{column_names} missing from DataFrame columns!"
+                )
+            df[column_names] = pd.Categorical(df[column_names])
+        else:
             raise JanitorError(
-                f"{column_names} missing from DataFrame columns!"
+                "kwarg `column_names` must be hashable or iterable!"
             )
-        df[column_names] = pd.Categorical(df[column_names])
-    else:
-        raise JanitorError(
-            "kwarg `column_names` must be hashable or iterable!"
-        )
+        return df
+
+    df = _computations_as_categorical(df, **kwargs)
     return df
 
 
@@ -5262,6 +5488,7 @@ def pivot_wider(
     Pivoting on multiple columns is possible :
 
     .. code-block:: python
+
             name    n  pct
         0     1  10.0  0.1
         1     2  20.0  0.2
diff --git a/janitor/utils.py b/janitor/utils.py
--- a/janitor/utils.py
+++ b/janitor/utils.py
@@ -4,6 +4,7 @@
 import os
 import sys
 import warnings
+from collections import namedtuple
 from itertools import chain, product
 from typing import Callable, Dict, List, Optional, Pattern, Tuple, Union
 
@@ -1432,3 +1433,136 @@ def _computations_pivot_wider(
         return df
 
     return df
+
+
+def _computations_as_categorical(df: pd.DataFrame, **kwargs) -> pd.DataFrame:
+    """
+    This function handles cases where categorical columns are created with
+    an order, or specific values supplied for the categories. It uses a kwarg,
+    with a namedtuple - `column_name: (categories, order)`, with the idea
+    inspired by Pandas' NamedAggregation. The defaults for the namedtuple are
+    (None, None) and will return a categorical dtype with no order and
+    categories inferred from the column.
+    """
+
+    df = df.copy()
+
+    AsCategorical = namedtuple(
+        "AsCategorical", ["categories", "order"], defaults=(None, None)
+    )
+
+    categories_dict = {}
+
+    # type and column presence checks
+    check_column(df, kwargs)
+
+    for column_name, value in kwargs.items():
+        check("AsCategorical", value, [tuple])
+        if len(value) != 2:
+            raise ValueError("Must provide tuples of (categories, order).")
+
+        value = AsCategorical._make(value)
+
+        if value.categories is not None:
+            check(
+                "categories",
+                value.categories,
+                [list, tuple, set, np.ndarray, pd.Series],
+            )
+
+        if value.order is not None:
+            check("order", value.order, [str])
+            if value.order not in ("appearance", "sort"):
+                raise ValueError(
+                    """
+                    `order` argument should be one of
+                    "appearance", "sort" or `None`.
+                    """
+                )
+
+        categories_dict[column_name] = value
+
+    categories_dtypes = {}
+    unique_values_in_column = None
+    missing_values = None
+    for column_name, categories_order_tuple in categories_dict.items():
+        if categories_order_tuple.categories is None:
+            if categories_order_tuple.order is None:
+                categories_dtypes[column_name] = "category"
+
+            elif categories_order_tuple.order == "sort":
+                if df[column_name].hasnans:
+                    unique_values_in_column = np.unique(
+                        df[column_name].dropna()
+                    )
+                else:
+                    unique_values_in_column = np.unique(df[column_name])
+                categories_dtypes[column_name] = CategoricalDtype(
+                    categories=unique_values_in_column, ordered=True
+                )
+
+            else:  # appearance
+                if df[column_name].hasnans:
+                    unique_values_in_column = df[column_name].dropna().unique()
+                else:
+                    unique_values_in_column = df[column_name].unique()
+                categories_dtypes[column_name] = CategoricalDtype(
+                    categories=unique_values_in_column, ordered=True
+                )
+        # categories supplied
+        else:
+            if df[column_name].hasnans:
+                unique_values_in_column = df[column_name].dropna().unique()
+            else:
+                unique_values_in_column = df[column_name].unique()
+            missing_values = np.setdiff1d(
+                unique_values_in_column,
+                categories_order_tuple.categories,
+                assume_unique=False,
+            )
+            # check if categories supplied does not match
+            # with the values in the column
+            # either there are no matches
+            # or an incomplete number of matches
+            if np.any(missing_values):
+                if len(missing_values) == len(unique_values_in_column):
+                    warnings.warn(
+                        f"""
+                        None of the values in {column_name} are in
+                        {categories_order_tuple.categories};
+                        this might create nulls for all your values
+                        in the new categorical column.
+                        """,
+                        UserWarning,
+                        stacklevel=2,
+                    )
+                else:
+                    warnings.warn(
+                        f"""
+                        Values {tuple(missing_values)} are missing from
+                        categories {categories_order_tuple.categories}
+                        for {column_name}; this may create nulls
+                        the new categorical column.
+                        """,
+                        UserWarning,
+                        stacklevel=2,
+                    )
+
+            if categories_order_tuple.order is None:
+                categories_dtypes[column_name] = CategoricalDtype(
+                    categories=categories_order_tuple.categories,
+                    ordered=False,
+                )
+            elif categories_order_tuple.order == "sort":
+                categories_dtypes[column_name] = CategoricalDtype(
+                    categories=np.sort(categories_order_tuple.categories),
+                    ordered=True,
+                )
+            else:  # appearance
+                categories_dtypes[column_name] = CategoricalDtype(
+                    categories=categories_order_tuple.categories, ordered=True,
+                )
+
+    df = df.astype(categories_dtypes)
+
+    return df


Test patch:
diff --git a/tests/functions/test_convert_excel_date.py b/tests/functions/test_convert_excel_date.py
--- a/tests/functions/test_convert_excel_date.py
+++ b/tests/functions/test_convert_excel_date.py
@@ -4,11 +4,14 @@
 import pytest
 
 
-@pytest.mark.skip(reason="xlrd issues")
+@pytest.mark.functions
 def test_convert_excel_date():
+    # using openpyxl as the engine staves off an error that crops up
+    # during the CI build up with xlrd
     df = (
         pd.read_excel(
-            Path(pytest.EXAMPLES_DIR) / "notebooks" / "dirty_data.xlsx"
+            Path(pytest.EXAMPLES_DIR) / "notebooks" / "dirty_data.xlsx",
+            engine="openpyxl",
         )
         .clean_names()
         .convert_excel_date("hire_date")
diff --git a/tests/functions/test_encode_categorical.py b/tests/functions/test_encode_categorical.py
--- a/tests/functions/test_encode_categorical.py
+++ b/tests/functions/test_encode_categorical.py
@@ -1,6 +1,11 @@
+import numpy as np
+import pandas as pd
 import pytest
 from hypothesis import given
+from pandas.api.types import CategoricalDtype
+from pandas.testing import assert_frame_equal
 
+from janitor import As_Categorical
 from janitor.errors import JanitorError
 from janitor.testing_utils.strategies import (
     categoricaldf_strategy,
@@ -18,6 +23,11 @@ def test_encode_categorical(df):
 @pytest.mark.functions
 @given(df=df_strategy())
 def test_encode_categorical_missing_column(df):
+    """
+    Raise JanitorError for missing columns
+    when only one arguments is provided to
+    `column_names`.
+    """
     with pytest.raises(JanitorError):
         df.encode_categorical("aloha")
 
@@ -25,6 +35,11 @@ def test_encode_categorical_missing_column(df):
 @pytest.mark.functions
 @given(df=df_strategy())
 def test_encode_categorical_missing_columns(df):
+    """
+    Raise JanitorError for missing columns
+    when the number of arguments to `column_names`
+    is more than one.
+    """
     with pytest.raises(JanitorError):
         df.encode_categorical(["animals@#$%^", "cities", "aloha"])
 
@@ -32,5 +47,383 @@ def test_encode_categorical_missing_columns(df):
 @pytest.mark.functions
 @given(df=df_strategy())
 def test_encode_categorical_invalid_input(df):
+    """
+    Raise JanitorError for wrong input type
+    for `column_names`.
+    """
     with pytest.raises(JanitorError):
         df.encode_categorical(1)
+
+
+@pytest.fixture
+def df_categorical():
+    return pd.DataFrame(
+        {
+            "col1": [2, 1, 3, 1],
+            "col2": ["a", "b", "c", "d"],
+            "col3": pd.date_range("1/1/2020", periods=4),
+        }
+    )
+
+
+def test_encode_categorical_multiple_column_names(df_categorical):
+    """
+    Test output when more than one column is provided
+    to `column_names`.
+    """
+    result = df_categorical.astype({"col1": "category", "col2": "category"})
+    assert_frame_equal(
+        df_categorical.encode_categorical(column_names=["col1", "col2"]),
+        result,
+    )
+
+
+def test_both_column_names_kwargs(df_categorical):
+    """
+    Raise Error if both `column_names`
+    and kwargs are provided.
+    """
+    with pytest.raises(ValueError):
+        df_categorical.encode_categorical(
+            column_names=["col1", "col2"], col1=(None, "sort")
+        )
+
+
+def test_check_presence_column_names_in_kwargs(df_categorical):
+    """
+    Raise ValueError if column names in `kwargs`
+    do not exist in the dataframe.
+    """
+    with pytest.raises(ValueError):
+        df_categorical.encode_categorical(col_1=(None, "sort"))
+
+
+def test_check_type_tuple_in_kwargs(df_categorical):
+    """
+    Raise TypeError if the categories, order pairing
+    in `kwargs` is not a tuple.
+    """
+    with pytest.raises(TypeError):
+        df_categorical.encode_categorical(col1=[None, "sort"])
+
+
+def test_categories_type_in_kwargs(df_categorical):
+    """
+    Raise TypeError if the wrong argument is supplied to
+    the `categories` parameter in kwargs.
+    """
+    with pytest.raises(TypeError):
+        df_categorical.encode_categorical(col1=({1: 2, 3: 3}, None))
+
+
+def test_order_type_in_kwargs(df_categorical):
+    """
+    Raise TypeError if the wrong argument is supplied to
+    the `order` parameter in kwargs.
+    """
+    with pytest.raises(TypeError):
+        df_categorical.encode_categorical(col1=({1, 2, 3, 3}, {"sort"}))
+
+
+def test_order_wrong_option_in_kwargs(df_categorical):
+    """
+    Raise ValueError if the value supplied to the `order`
+    parameter in kwargs is not one of None, 'sort', or 'appearance'.
+    """
+    with pytest.raises(ValueError):
+        df_categorical.encode_categorical(col1=({1, 2, 3, 3}, "sorted"))
+
+
+as_categorical_tuple_length = [
+    (
+        pd.DataFrame(
+            {
+                "col1": [2, 1, 3, 1],
+                "col2": ["a", "b", "c", "d"],
+                "col3": pd.date_range("1/1/2020", periods=4),
+            }
+        ),
+        (None,),
+    ),
+    (
+        pd.DataFrame(
+            {
+                "col1": [2, 1, 3, 1],
+                "col2": ["a", "b", "c", "d"],
+                "col3": pd.date_range("1/1/2020", periods=4),
+            }
+        ),
+        (None, "sort", "appearance"),
+    ),
+]
+
+
+@pytest.mark.parametrize(
+    "df,as_categorical_tuple", as_categorical_tuple_length
+)
+def test_tuple_length_in_kwargs(df, as_categorical_tuple):
+    """
+    Raise ValueError if the length of the tuple
+    in kwargs is not equal to 2.
+    """
+    with pytest.raises(ValueError):
+        df.encode_categorical(col1=as_categorical_tuple)
+
+
+test_various_df = [
+    (
+        pd.DataFrame(
+            {
+                "col1": [2, 1, 3, 1],
+                "col2": ["a", "b", "c", "d"],
+                "col3": pd.date_range("1/1/2020", periods=4),
+            }
+        ),
+        pd.DataFrame(
+            {
+                "col1": [2, 1, 3, 1],
+                "col2": ["a", "b", "c", "d"],
+                "col3": pd.date_range("1/1/2020", periods=4),
+            }
+        ).astype({"col1": "category", "col2": "category"}),
+        {
+            "col1": As_Categorical(categories=None, order=None),
+            "col2": As_Categorical(order=None, categories=None),
+        },
+    ),
+    (
+        pd.DataFrame(
+            {
+                "col1": [2, 1, 3, 1],
+                "col2": ["a", "b", "c", "d"],
+                "col3": pd.date_range("1/1/2020", periods=4),
+            }
+        ),
+        pd.DataFrame(
+            {
+                "col1": [2, 1, 3, 1],
+                "col2": ["a", "b", "c", "d"],
+                "col3": pd.date_range("1/1/2020", periods=4),
+            }
+        ).astype({"col1": "category", "col2": "category"}),
+        {"col1": (None, None), "col2": (None, None)},
+    ),
+    (
+        pd.DataFrame(
+            {
+                "col1": [2, 1, 3, 1],
+                "col2": ["a", "b", "c", "d"],
+                "col3": pd.date_range("1/1/2020", periods=4),
+            }
+        ),
+        pd.DataFrame(
+            {
+                "col1": [2, 1, 3, 1],
+                "col2": ["a", "b", "c", "d"],
+                "col3": pd.date_range("1/1/2020", periods=4),
+            }
+        ).astype(
+            {
+                "col3": "category",
+                "col1": CategoricalDtype(categories=[1, 2, 3], ordered=True),
+            }
+        ),
+        {"col3": (None, None), "col1": (None, "sort")},
+    ),
+    (
+        pd.DataFrame(
+            {
+                "col1": [2, 1, 3, np.nan],
+                "col2": ["a", "b", "c", "d"],
+                "col3": pd.date_range("1/1/2020", periods=4),
+            }
+        ),
+        pd.DataFrame(
+            {
+                "col1": [2.0, 1, 3, np.nan],
+                "col2": ["a", "b", "c", "d"],
+                "col3": pd.date_range("1/1/2020", periods=4),
+            }
+        ).astype(
+            {
+                "col3": "category",
+                "col1": CategoricalDtype(categories=[1, 2.0, 3], ordered=True),
+            }
+        ),
+        {"col3": (None, None), "col1": (None, "sort")},
+    ),
+    (
+        pd.DataFrame(
+            {
+                "col1": [2, 1, 3, np.nan],
+                "col2": ["b", "c", "a", "d"],
+                "col3": pd.date_range("1/1/2020", periods=4),
+            }
+        ),
+        pd.DataFrame(
+            {
+                "col1": [2.0, 1, 3, np.nan],
+                "col2": ["b", "c", "a", "d"],
+                "col3": pd.date_range("1/1/2020", periods=4),
+            }
+        ).astype(
+            {
+                "col2": CategoricalDtype(
+                    categories=["b", "c", "a", "d"], ordered=True
+                ),
+                "col1": CategoricalDtype(categories=[2.0, 1, 3], ordered=True),
+            }
+        ),
+        {"col2": (None, "appearance"), "col1": (None, "appearance")},
+    ),
+    (
+        pd.DataFrame(
+            {
+                "col1": [2, 1, 3, 1, np.nan],
+                "col2": ["a", "b", "c", "d", "a"],
+                "col3": pd.date_range("1/1/2020", periods=5),
+            }
+        ),
+        pd.DataFrame(
+            {
+                "col1": [2, 1, 3, 1, np.nan],
+                "col2": ["a", "b", "c", "d", "a"],
+                "col3": pd.date_range("1/1/2020", periods=5),
+            }
+        ).astype(
+            {
+                "col1": CategoricalDtype(
+                    categories=[2.0, 1.0, 3.0], ordered=True
+                ),
+                "col2": CategoricalDtype(
+                    categories=["a", "b", "c", "d"], ordered=False
+                ),
+            }
+        ),
+        {
+            "col2": (["a", "b", "c", "d"], None),
+            "col1": ([2.0, 1.0, 3.0], "appearance"),
+        },
+    ),
+    (
+        pd.DataFrame(
+            {
+                "col1": [2, 1, 3, 1, np.nan],
+                "col2": ["a", "b", "c", "d", "a"],
+                "col3": pd.date_range("1/1/2020", periods=5),
+            }
+        ),
+        pd.DataFrame(
+            {
+                "col1": [2, 1, 3, 1, np.nan],
+                "col2": ["a", "b", "c", "d", "a"],
+                "col3": pd.date_range("1/1/2020", periods=5),
+            }
+        ).astype(
+            {
+                "col1": CategoricalDtype(
+                    categories=[2.0, 1.0, 3.0], ordered=True
+                ),
+                "col2": CategoricalDtype(
+                    categories=["a", "b", "c", "d"], ordered=True
+                ),
+            }
+        ),
+        {
+            "col2": (["a", "b", "c", "d"], "sort"),
+            "col1": ([2.0, 1.0, 3.0], "appearance"),
+        },
+    ),
+    (
+        pd.DataFrame(
+            {
+                "col1": [2, 1, 3, 1, np.nan],
+                "col2": ["a", "b", "c", "d", "a"],
+                "col3": pd.date_range("1/1/2020", periods=5),
+            }
+        ),
+        pd.DataFrame(
+            {
+                "col1": [2, 1, 3, 1, np.nan],
+                "col2": ["a", "b", "c", "d", "a"],
+                "col3": pd.date_range("1/1/2020", periods=5),
+            }
+        ).astype(
+            {
+                "col1": CategoricalDtype(
+                    categories=[1.0, 2.0, 3.0], ordered=True
+                ),
+                "col2": CategoricalDtype(
+                    categories=["a", "b", "c", "d"], ordered=True
+                ),
+                "col3": CategoricalDtype(
+                    categories=pd.to_datetime(
+                        [
+                            "2020-01-01",
+                            "2020-01-02",
+                            "2020-01-03",
+                            "2020-01-04",
+                            "2020-01-05",
+                        ]
+                    ),
+                    ordered=True,
+                ),
+            }
+        ),
+        {
+            "col3": (None, "sort"),
+            "col1": (None, "sort"),
+            "col2": (None, "appearance"),
+        },
+    ),
+]
+
+
+@pytest.mark.parametrize("df_in, df_out, kwargs", test_various_df)
+def test_various(df_in, df_out, kwargs):
+    """
+    Test output for various combinations.
+    """
+    result = df_in.encode_categorical(**kwargs,)
+    assert_frame_equal(result, df_out)
+
+
+df_warnings = [
+    (
+        pd.DataFrame(
+            {
+                "col1": [2, 1, 3, 1, np.nan],
+                "col2": ["a", "b", "c", "d", "a"],
+                "col3": pd.date_range("1/1/2020", periods=5),
+            }
+        ),
+        {
+            "col1": ([4, 5, 6], "appearance"),
+            "col2": (["a", "b", "c", "d"], "sort"),
+        },
+    ),
+    (
+        pd.DataFrame(
+            {
+                "col1": [2, 1, 3, 1, np.nan],
+                "col2": ["a", "b", "c", "d", "a"],
+                "col3": pd.date_range("1/1/2020", periods=5),
+            }
+        ),
+        {
+            "col1": ([2.0, 1.0, 3.0], "appearance"),
+            "col2": (["a", "b", "c"], "sort"),
+        },
+    ),
+]
+
+
+@pytest.mark.parametrize("df_in,kwargs", df_warnings)
+def test_warnings(df_in, kwargs):
+    """
+    Test that warnings are raised if `categories` is provided, and
+    the categories do not match the unique values in the column, or
+    some values in the column are missing in `categories`.
+    """
+    with pytest.warns(UserWarning):
+        df_in.encode_categorical(**kwargs)