Repository: pyjanitor-devs/pyjanitor (python)

Issue:
[EHN] `min_max_scale` transforms each column values not entire values
# Brief Description

<!-- Please provide a brief description of what you'd like to propose. -->

I thought this method should transform each column values sperately not all values when `column_name` was set to `None`.
From `DataFrame` view, each column represtent differnt data.

```python
def min_max_scale(
    df: pd.DataFrame,
    old_min=None,
    old_max=None,
    column_name=None,
    new_min=0,
    new_max=1,
) -> pd.DataFrame:
    ...
```

# Example

<!-- One of the selling points of pyjanitor is the API. Hence, we guard the API very carefully, and want to
make sure that it is accessible and understandable to many people. Please provide a few examples of what the API
of the new function you're proposing will look like. We have provided an example that you should modify. -->

```python
import pandas as pd
import janitor  #noqa

df = pd.DataFrame({'a':[1, 2], 'b':[0, 1]})
df.min_max_scale(new_max=10)
# not this one
#       a    b
# 0   5.0  0.0
# 1  10.0  5.0

# excepted result
#       a     b
# 0   0.0   0.0
# 1  10.0  10.0

# To get excepted result I have to add `column_name=df.columns.tolist()`
df.min_max_scale(new_max=10, column_name=df.columns.tolist())
#       a     b
# 0   0.0   0.0
# 1  10.0  10.0

# bad for chaining
(
    df.eval("c = a+b")
    .min_max_scale(new_max=10, column_name=df.columns.tolist())  # 'c' column was missed.
)
#       a     b  c
# 0   0.0   0.0  1
# 1  10.0  10.0  3
```


Pull request:
[EHN] Add `jointly` option for `min_max_scale`
<!-- Thank you for your PR!

BEFORE YOU CONTINUE! Please add the appropriate three-letter abbreviation to your title.

The abbreviations can be:
- [DOC]: Documentation fixes.
- [ENH]: Code contributions and new features.
- [TST]: Test-related contributions.
- [INF]: Infrastructure-related contributions.

Also, do not forget to tag the relevant issue here as well.

Finally, as commits come in, don't forget to regularly rebase!
-->

# PR Description

Please describe the changes proposed in the pull request:

- Add an option for `min_max_scale` support to transform each column values or entire values
- Default transform each column, similar behavior to [sklearn.preprocessing.MinMaxScaler](http://scikit-learn.org/stable/modules/generated/sklearn.preprocessing.MinMaxScaler.html)

<!-- Doing so provides maintainers with context on what the PR is, and can help us more effectively review your PR. -->

<!-- Please also identify below which issue that has been raised that you are going to close. -->

**This PR resolves #1067.**

<!-- As you go down the PR template, please feel free to delete sections that are irrelevant. -->

# PR Checklist

<!-- This checklist exists for newcomers who are not yet familiar with our requirements. If you are experienced with
the project, please feel free to delete this section. -->

Please ensure that you have done the following:

1. [x] PR in from a fork off your branch. Do not PR from `<your_username>`:`dev`, but rather from `<your_username>`:`<feature-branch_name>`.
<!-- Doing this helps us keep the commit history much cleaner than it would otherwise be. -->
2. [x] If you're not on the contributors list, add yourself to `AUTHORS.md`.
<!-- We'd like to acknowledge your contributions! -->
3. [x] Add a line to `CHANGELOG.md` under the latest version header (i.e. the one that is "on deck") describing the contribution.
    - Do use some discretion here; if there are multiple PRs that are related, keep them in a single line.

# Automatic checks

There will be automatic checks run on the PR. These include:

- Building a preview of the docs on Netlify
- Automatically linting the code
- Making sure the code is documented
- Making sure that all tests are passed
- Making sure that code coverage doesn't go down.

# Relevant Reviewers

<!-- Finally, please tag relevant maintainers to review. -->

Please tag maintainers to review.

- @ericmjl


Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index bbd9cf2..9aef068 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -2,7 +2,7 @@
 # See https://pre-commit.com/hooks.html for more hooks
 repos:
 - repo: https://github.com/pre-commit/pre-commit-hooks
-  rev: v4.2.0
+  rev: v4.3.0
   hooks:
   - id: trailing-whitespace
   - id: end-of-file-fixer
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a014cf9..efde50c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,9 +5,10 @@
 -   [DOC] Updated developer guide docs.
 -   [ENH] Allow column selection/renaming within conditional_join. #1102 @samukweku.
 -   [ENH] New decorator `deprecated_kwargs` for breaking API. #1103 @Zeroto521
--   [ENH] Extend select_columns to support non-string columns. #1105 @samukweku
+-   [ENH] Extend select_columns to support non-string columns. Also allow selection on MultiIndex columns via level parameter. #1105 @samukweku
 -   [ENH] Performance improvement for groupby_topk. #1093 @samukweku
 -   [EHN] `min_max_scale` drop `old_min` and `old_max` to fit sklearn's method API. Issue #1068 @Zeroto521
+-   [EHN] Add `jointly` option for `min_max_scale` support to transform each column values or entire values. Default transform each column, similar behavior to `sklearn.preprocessing.MinMaxScaler`. Issue #1067 @Zeroto521
 
 ## [v0.23.1] - 2022-05-03
 
diff --git a/janitor/functions/min_max_scale.py b/janitor/functions/min_max_scale.py
index 016a4f6..90ccf90 100644
--- a/janitor/functions/min_max_scale.py
+++ b/janitor/functions/min_max_scale.py
@@ -23,23 +23,19 @@ def min_max_scale(
     df: pd.DataFrame,
     feature_range: tuple[int | float, int | float] = (0, 1),
     column_name: str | int | list[str | int] | pd.Index = None,
+    jointly: bool = False,
 ) -> pd.DataFrame:
     """
-    Scales data to between a minimum and maximum value.
+    Scales DataFrame to between a minimum and maximum value.
 
-    This method mutates the original DataFrame.
+    One can optionally set a new target **minimum** and **maximum** value
+    using the `feature_range` keyword argument.
 
-    If `minimum` and `maximum` are provided, the true min/max of the
-    `DataFrame` or column is ignored in the scaling process and replaced with
-    these values, instead.
-
-    One can optionally set a new target minimum and maximum value using the
-    `feature_range[0]` and `feature_range[1]` keyword arguments.
-    This will result in the transformed data being bounded between
-    `feature_range[0]` and `feature_range[1]`.
-
-    If a particular column name is specified, then only that column of data
-    are scaled. Otherwise, the entire dataframe is scaled.
+    If `column_name` is specified, then only that column(s) of data is scaled.
+    Otherwise, the entire dataframe is scaled.
+    If `jointly` is `True`, the `column_names` provided entire dataframe will
+    be regnozied as the one to jointly scale. Otherwise, each column of data
+    will be scaled separately.
 
     Example: Basic usage.
 
@@ -48,6 +44,10 @@ def min_max_scale(
         >>> df = pd.DataFrame({'a':[1, 2], 'b':[0, 1]})
         >>> df.min_max_scale()
              a    b
+        0  0.0  0.0
+        1  1.0  1.0
+        >>> df.min_max_scale(jointly=True)
+             a    b
         0  0.5  0.0
         1  1.0  0.5
 
@@ -57,6 +57,10 @@ def min_max_scale(
         >>> import janitor
         >>> df = pd.DataFrame({'a':[1, 2], 'b':[0, 1]})
         >>> df.min_max_scale(feature_range=(0, 100))
+               a      b
+        0    0.0    0.0
+        1  100.0  100.0
+        >>> df.min_max_scale(feature_range=(0, 100), jointly=True)
                a     b
         0   50.0   0.0
         1  100.0  50.0
@@ -65,15 +69,26 @@ def min_max_scale(
 
         >>> import pandas as pd
         >>> import janitor
-        >>> df = pd.DataFrame({'a':[1, 2], 'b':[0, 1]})
-        >>> df.min_max_scale(feature_range=(0, 100), column_name=['a', 'b'])
-               a      b
-        0    0.0    0.0
-        1  100.0  100.0
+        >>> df = pd.DataFrame({'a':[1, 2], 'b':[0, 1], 'c': [1, 0]})
+        >>> df.min_max_scale(
+        ...     feature_range=(0, 100),
+        ...     column_name=["a", "c"],
+        ... )
+               a  b      c
+        0    0.0  0  100.0
+        1  100.0  1    0.0
+        >>> df.min_max_scale(
+        ...     feature_range=(0, 100),
+        ...     column_name=["a", "c"],
+        ...     jointly=True,
+        ... )
+               a  b     c
+        0   50.0  0  50.0
+        1  100.0  1   0.0
         >>> df.min_max_scale(feature_range=(0, 100), column_name='a')
-               a  b
-        0    0.0  0
-        1  100.0  1
+               a  b  c
+        0    0.0  0  1
+        1  100.0  1  0
 
     The aforementioned example might be applied to something like scaling the
     isoelectric points of amino acids. While technically they range from
@@ -84,11 +99,16 @@ def min_max_scale(
     :param df: A pandas DataFrame.
     :param feature_range: (optional) Desired range of transformed data.
     :param column_name: (optional) The column on which to perform scaling.
+    :param jointly: (bool) Scale the entire data if Ture.
     :returns: A pandas DataFrame with scaled data.
     :raises ValueError: if `feature_range` isn't tuple type.
     :raises ValueError: if the length of `feature_range` isn't equal to two.
     :raises ValueError: if the element of `feature_range` isn't number type.
     :raises ValueError: if `feature_range[1]` <= `feature_range[0]`.
+
+    Changed in version 0.24.0: Deleted "old_min", "old_max", "new_min", and
+    "new_max" options.
+    Changed in version 0.24.0: Added "feature_range", and "jointly" options.
     """
 
     if not (
@@ -102,23 +122,67 @@ def min_max_scale(
             "the first element must be greater than the second one"
         )
 
-    new_min, new_max = feature_range
-    new_range = new_max - new_min
-
     if column_name is not None:
-        old_min = df[column_name].min()
-        old_max = df[column_name].max()
-        old_range = old_max - old_min
-
-        df = df.copy()
-        df[column_name] = (
-            df[column_name] - old_min
-        ) * new_range / old_range + new_min
-    else:
-        old_min = df.min().min()
-        old_max = df.max().max()
-        old_range = old_max - old_min
+        df = df.copy()  # Avoid to change the original DataFrame.
 
-        df = (df - old_min) * new_range / old_range + new_min
+        old_feature_range = df[column_name].pipe(min_max_value, jointly)
+        df[column_name] = df[column_name].pipe(
+            apply_min_max,
+            *old_feature_range,
+            *feature_range,
+        )
+    else:
+        old_feature_range = df.pipe(min_max_value, jointly)
+        df = df.pipe(
+            apply_min_max,
+            *old_feature_range,
+            *feature_range,
+        )
 
     return df
+
+
+def min_max_value(df: pd.DataFrame, jointly: bool) -> tuple:
+    """
+    Return the minimum and maximum of DataFrame.
+
+    Use the `jointly` flag to control returning entire data or each column.
+
+    .. # noqa: DAR101
+    .. # noqa: DAR201
+    """
+
+    if jointly:
+        mmin = df.min().min()
+        mmax = df.max().max()
+    else:
+        mmin = df.min()
+        mmax = df.max()
+
+    return mmin, mmax
+
+
+def apply_min_max(
+    df: pd.DataFrame,
+    old_min: int | float | pd.Series,
+    old_max: int | float | pd.Series,
+    new_min: int | float | pd.Series,
+    new_max: int | float | pd.Series,
+) -> pd.DataFrame:
+    """
+    Apply minimax scaler to DataFrame.
+
+    Notes
+    -----
+    - Inputting minimum and maximum type
+        - int or float : It will apply minimax to the entire DataFrame.
+        - Series : It will apply minimax to each column.
+
+    .. # noqa: DAR101
+    .. # noqa: DAR201
+    """
+
+    old_range = old_max - old_min
+    new_range = new_max - new_min
+
+    return (df - old_min) * new_range / old_range + new_min
diff --git a/janitor/functions/select_columns.py b/janitor/functions/select_columns.py
index 9ddc1e2..5b5cde7 100644
--- a/janitor/functions/select_columns.py
+++ b/janitor/functions/select_columns.py
@@ -1,11 +1,11 @@
 """Implementation of select_columns"""
+from typing import Optional, Union
 import pandas_flavor as pf
 import pandas as pd
-
-from janitor.utils import deprecated_alias
+from pandas.api.types import is_list_like
+from janitor.utils import deprecated_alias, check
 
 from janitor.functions.utils import _select_column_names
-from pandas.api.types import is_list_like
 
 
 @pf.register_dataframe_method
@@ -13,6 +13,7 @@ from pandas.api.types import is_list_like
 def select_columns(
     df: pd.DataFrame,
     *args,
+    level: Optional[Union[int, str]] = None,
     invert: bool = False,
 ) -> pd.DataFrame:
     """
@@ -48,6 +49,8 @@ def select_columns(
         a callable which is applicable to each Series in the DataFrame,
         or variable arguments of all the aforementioned.
         A sequence of booleans is also acceptable.
+    :param level: Determines which level in the columns should be used for the
+        column selection.
     :param invert: Whether or not to invert the selection.
         This will result in the selection of the complement of the columns
         provided.
@@ -62,8 +65,26 @@ def select_columns(
             search_column_names.extend(arg)
         else:
             search_column_names.append(arg)
-    full_column_list = _select_column_names(search_column_names, df)
-
+    if level is not None:
+        # goal here is to capture the original columns
+        # trim the df.columns to the specified level only,
+        # and apply the selection (_select_column_names)
+        # to get the relevant column labels.
+        # note that no level is dropped; if there are three levels,
+        # then three levels are returned, with the specified labels
+        # selected/deselected.
+        # A copy of the dataframe is made via set_axis,
+        # to avoid mutating the original dataframe.
+        df_columns = df.columns
+        check("level", level, [int, str])
+        full_column_list = df_columns.get_level_values(level)
+        full_column_list = _select_column_names(
+            search_column_names, df.set_axis(full_column_list, axis=1)
+        )
+        full_column_list = df_columns.isin(full_column_list, level=level)
+        full_column_list = df_columns[full_column_list]
+    else:
+        full_column_list = _select_column_names(search_column_names, df)
     if invert:
         return df.drop(columns=full_column_list)
     return df.loc[:, full_column_list]
diff --git a/janitor/functions/utils.py b/janitor/functions/utils.py
index 7f0b832..99dbfff 100644
--- a/janitor/functions/utils.py
+++ b/janitor/functions/utils.py
@@ -12,6 +12,9 @@ from pandas.api.types import (
     union_categoricals,
     is_scalar,
     is_list_like,
+    is_datetime64_dtype,
+    is_string_dtype,
+    is_categorical_dtype,
 )
 import numpy as np
 from multipledispatch import dispatch
@@ -212,7 +215,16 @@ def _select_column_names(columns_to_select, df):
     """
     if columns_to_select in df.columns:
         return [columns_to_select]
-    raise KeyError(f"No match was returned for '{columns_to_select}'.")
+    raise KeyError(f"No match was returned for {columns_to_select}.")
+
+
+def _is_str_or_cat(df_columns):
+    """Check if the column is a string or categorical with strings."""
+    if is_string_dtype(df_columns):
+        return True
+    if is_categorical_dtype(df_columns):
+        return is_string_dtype(df_columns.categories)
+    return False
 
 
 @_select_column_names.register(str)  # noqa: F811
@@ -221,32 +233,26 @@ def _column_sel_dispatch(columns_to_select, df):  # noqa: F811
     Base function for column selection.
     Applies only to strings.
     It is also applicable to shell-like glob strings,
-    specifically, the `*`.
+    which are supported by `fnmatch`.
     A list/pandas Index of matching column names is returned.
     """
     df_columns = df.columns
-    if pd.api.types.is_string_dtype(df_columns):
-        if (
-            "*" in columns_to_select
-        ):  # shell-style glob string (e.g., `*_thing_*`)
-            return fnmatch.filter(df_columns, columns_to_select)
+
+    if _is_str_or_cat(df_columns):
         if columns_to_select in df_columns:
             return [columns_to_select]
-        raise KeyError(f"No match was returned for '{columns_to_select}'.")
-    if pd.api.types.is_datetime64_any_dtype(df_columns):
-        if not df_columns.is_monotonic_increasing:
-            raise ValueError(
-                "The column is a DatetimeIndex and should be "
-                "monotonic increasing."
-            )
+        outcome = fnmatch.filter(df_columns, columns_to_select)
+        if not outcome:
+            raise KeyError(f"No match was returned for '{columns_to_select}'.")
+        return outcome
+
+    if is_datetime64_dtype(df_columns):
         timestamp = df_columns.get_loc(columns_to_select)
-        if isinstance(timestamp, slice):
+        if not isinstance(timestamp, int):
             return df_columns[timestamp]
         return [df_columns[timestamp]]
-    raise KeyError(
-        f"String('{columns_to_select}') can be applied "
-        "only to string/datetime columns."
-    )
+
+    raise KeyError(f"No match was returned for '{columns_to_select}'.")
 
 
 @_select_column_names.register(re.Pattern)  # noqa: F811
@@ -257,15 +263,16 @@ def _column_sel_dispatch(columns_to_select, df):  # noqa: F811
     `re.compile` is required for the regular expression.
     A pandas Index of matching column names is returned.
     """
-    if pd.api.types.is_string_dtype(df.columns):
-        bools = df.columns.str.contains(
+    df_columns = df.columns
+
+    if _is_str_or_cat(df_columns):
+        bools = df_columns.str.contains(
             columns_to_select, na=False, regex=True
         )
-        return df.columns[bools]
-    raise KeyError(
-        f"Regular expressions('{columns_to_select}') "
-        "can be applied only to string columns."
-    )
+        if not bools.any():
+            raise KeyError(f"No match was returned for {columns_to_select}.")
+        return df_columns[bools]
+    raise KeyError(f"No match was returned for {columns_to_select}.")
 
 
 @_select_column_names.register(slice)  # noqa: F811
@@ -291,13 +298,12 @@ def _column_sel_dispatch(columns_to_select, df):  # noqa: F811
     step_check = None
     method = None
 
-    if not df_columns.is_unique:
+    if not df_columns.is_unique and not df_columns.is_monotonic_increasing:
         raise ValueError(
-            "The column labels are not unique. "
-            "Kindly ensure the labels are unique "
-            "to ensure the correct output."
+            "Non-unique column labels should be monotonic increasing."
         )
-    is_date_column = pd.api.types.is_datetime64_any_dtype(df_columns)
+
+    is_date_column = is_datetime64_dtype(df_columns)
     if is_date_column:
         if not df_columns.is_monotonic_increasing:
             raise ValueError(
@@ -377,6 +383,8 @@ def _column_sel_dispatch(columns_to_select, df):  # noqa: F811
         raise TypeError(
             "The output of the applied callable should be a boolean array."
         )
+    if not filtered_columns.any():
+        raise KeyError(f"No match was returned for {columns_to_select}.")
 
     return df.columns[filtered_columns]
 


Test patch:
diff --git a/tests/functions/test_min_max_scale.py b/tests/functions/test_min_max_scale.py
index 8468bc0..9b94bf1 100644
--- a/tests/functions/test_min_max_scale.py
+++ b/tests/functions/test_min_max_scale.py
@@ -4,42 +4,81 @@ import pytest
 
 @pytest.mark.functions
 @pytest.mark.parametrize(
-    "df, column_name, excepted",
+    "df, column_name, jointly, excepted",
     [
         # test default parameter
         (
             pd.DataFrame({"a": [5, 10], "b": [0, 5]}),
             None,
+            True,
             pd.DataFrame({"a": [0.5, 1], "b": [0, 0.5]}),
         ),
+        # test default parameter
+        (
+            pd.DataFrame({"a": [5, 10], "b": [0, 5]}),
+            None,
+            False,
+            pd.DataFrame({"a": [0, 1.0], "b": [0, 1.0]}),
+        ),
+        # test list condition
+        (
+            pd.DataFrame({"a": [5, 10], "b": [0, 5]}),
+            ["a", "b"],
+            True,
+            pd.DataFrame({"a": [0.5, 1.0], "b": [0, 0.5]}),
+        ),
         # test list condition
         (
             pd.DataFrame({"a": [5, 10], "b": [0, 5]}),
             ["a", "b"],
+            False,
             pd.DataFrame({"a": [0, 1.0], "b": [0, 1.0]}),
         ),
         # test Index condition
         (
             pd.DataFrame({"a": [5, 10], "b": [0, 5]}),
             pd.Index(["a", "b"]),
+            False,
             pd.DataFrame({"a": [0, 1.0], "b": [0, 1.0]}),
         ),
+        # test Index condition
+        (
+            pd.DataFrame({"a": [5, 10], "b": [0, 5]}),
+            pd.Index(["a", "b"]),
+            True,
+            pd.DataFrame({"a": [0.5, 1], "b": [0, 0.5]}),
+        ),
         # test str condition
         (
             pd.DataFrame({"a": [5, 10], "b": [0, 5]}),
             "a",
+            True,
+            pd.DataFrame({"a": [0, 1.0], "b": [0, 5]}),
+        ),
+        (
+            pd.DataFrame({"a": [5, 10], "b": [0, 5]}),
+            "a",
+            False,
             pd.DataFrame({"a": [0, 1.0], "b": [0, 5]}),
         ),
         # test int condition
         (
             pd.DataFrame({1: [5, 10], "b": [0, 5]}),
             1,
+            True,
+            pd.DataFrame({1: [0, 1.0], "b": [0, 5]}),
+        ),
+        # test int condition
+        (
+            pd.DataFrame({1: [5, 10], "b": [0, 5]}),
+            1,
+            False,
             pd.DataFrame({1: [0, 1.0], "b": [0, 5]}),
         ),
     ],
 )
-def test_min_max_scale_column_name(df, column_name, excepted):
-    result = df.min_max_scale(column_name=column_name)
+def test_min_max_scale_column_name_type(df, column_name, jointly, excepted):
+    result = df.min_max_scale(column_name=column_name, jointly=jointly)
 
     assert result.equals(excepted)
 
diff --git a/tests/functions/test_select_columns.py b/tests/functions/test_select_columns.py
index 195f7c8..137170d 100644
--- a/tests/functions/test_select_columns.py
+++ b/tests/functions/test_select_columns.py
@@ -1,5 +1,6 @@
 import pandas as pd
 import pytest
+import re
 from pandas.testing import assert_frame_equal
 
 
@@ -51,36 +52,6 @@ def test_select_column_names_missing_columns(dataframe, columns):
         dataframe.select_columns(columns)
 
 
-@pytest.mark.functions
-@pytest.mark.parametrize(
-    "columns",
-    [
-        pytest.param(
-            "a",
-            marks=pytest.mark.xfail(
-                reason="`select_columns` now accepts strings"
-            ),
-        ),
-        pytest.param(
-            ("a", "Bell__Chart"),
-            marks=pytest.mark.xfail(
-                reason="`select_columns` converts list-like into lists"
-            ),
-        ),
-        pytest.param(
-            {"a", "Bell__Chart"},
-            marks=pytest.mark.xfail(
-                reason="`select_columns` converts list-like into lists"
-            ),
-        ),
-    ],
-)
-def test_select_column_names_input(dataframe, columns):
-    """Check that passing an iterable that is not a list raises TypeError."""
-    with pytest.raises(TypeError):
-        dataframe.select_columns(columns)
-
-
 @pytest.mark.functions
 @pytest.mark.parametrize(
     "invert,expected",
@@ -116,20 +87,60 @@ def test_select_callable_columns(dataframe, invert, expected):
     assert_frame_equal(df, dataframe[expected])
 
 
-@pytest.mark.xfail(reason="Allow tuples which are acceptable in MultiIndex.")
-def test_MultiIndex():
-    """
-    Raise ValueError if columns is a MultiIndex.
-    """
-    df = pd.DataFrame(
+@pytest.fixture
+def df_tuple():
+    "pytest fixture."
+    frame = pd.DataFrame(
         {
             "A": {0: "a", 1: "b", 2: "c"},
             "B": {0: 1, 1: 3, 2: 5},
             "C": {0: 2, 1: 4, 2: 6},
         }
     )
+    frame.columns = [list("ABC"), list("DEF")]
+    return frame
+
+
+def test_multiindex(df_tuple):
+    """
+    Test output for a MultiIndex and tuple passed.
+    """
+    assert_frame_equal(
+        df_tuple.select_columns(("A", "D")), df_tuple.loc[:, [("A", "D")]]
+    )
 
-    df.columns = [list("ABC"), list("DEF")]
 
-    with pytest.raises(ValueError):
-        df.select_columns("A")
+def test_level_callable(df_tuple):
+    """
+    Test output if level is supplied for a callable.
+    """
+    expected = df_tuple.select_columns(
+        lambda df: df.name.startswith("A"), level=0
+    )
+    actual = df_tuple.xs("A", axis=1, drop_level=False, level=0)
+    assert_frame_equal(actual, expected)
+
+
+def test_level_regex(df_tuple):
+    """
+    Test output if level is supplied for a regex
+    """
+    expected = df_tuple.select_columns(re.compile("D"), level=1)
+    actual = df_tuple.xs("D", axis=1, drop_level=False, level=1)
+    assert_frame_equal(actual, expected)
+
+
+def test_level_slice(df_tuple):
+    """
+    Test output if level is supplied for a slice
+    """
+    expected = df_tuple.select_columns(slice("F", "D"), level=1)
+    assert_frame_equal(df_tuple, expected)
+
+
+def test_level_str(df_tuple):
+    """
+    Test output if level is supplied for a string.
+    """
+    expected = df_tuple.select_columns("A", level=0, invert=True)
+    assert_frame_equal(df_tuple.drop(columns="A", axis=1, level=0), expected)
diff --git a/tests/utils/test__select_column.py b/tests/utils/test__select_column.py
index 70e8304..a6099d0 100644
--- a/tests/utils/test__select_column.py
+++ b/tests/utils/test__select_column.py
@@ -103,7 +103,7 @@ def test_col_not_found3(df_dates):
 
 def test_col_not_found4(df_numbers):
     """Raise KeyError if `columns_to_select` is not in df.columns."""
-    with pytest.raises(KeyError, match=r"String\(.+\) can be applied.+"):
+    with pytest.raises(KeyError, match=r"No match was returned.+"):
         _select_column_names("id", df_numbers)
 
 
@@ -123,6 +123,18 @@ def test_strings(df1):
     ]
 
 
+def test_strings_cat(df1):
+    """Test output on categorical columns"""
+    df1.columns = df1.columns.astype("category")
+    assert _select_column_names("id", df1) == ["id"]
+    assert _select_column_names("*type*", df1) == [
+        "type",
+        "type1",
+        "type2",
+        "type3",
+    ]
+
+
 def test_strings_do_not_exist(df):
     """
     Raise KeyError if `columns_to_select` is a string
@@ -151,14 +163,12 @@ def test_strings_dates_range(df_dates):
 
 
 def test_unsorted_dates(df_dates):
-    """Raise Error if the dates are unsorted."""
+    """Test output if the dates are unsorted, and a string is passed."""
     df_dates = df_dates.iloc[:, [10, 4, 7, 2, 1, 3, 5, 6, 8, 9, 11, 0]]
-    with pytest.raises(
-        ValueError,
-        match="The column is a DatetimeIndex and should be "
-        "monotonic increasing.",
-    ):
-        _select_column_names("2011-01-31", df_dates)
+    assert_index_equal(
+        df_dates.loc[:, ["2011-01-31"]].columns,
+        _select_column_names("2011-01-31", df_dates),
+    )
 
 
 def test_regex(df1):
@@ -169,6 +179,15 @@ def test_regex(df1):
     )
 
 
+def test_regex_cat(df1):
+    """Test output on categorical columns"""
+    df1.columns = df1.columns.astype("category")
+    assert_index_equal(
+        _select_column_names(re.compile(r"\d$"), df1),
+        df1.filter(regex=r"\d$").columns,
+    )
+
+
 def test_patterns_warning(df1):
     """
     Check that warning is raised if `janitor.patterns` is used.
@@ -180,14 +199,21 @@ def test_patterns_warning(df1):
         )
 
 
+def test_regex_presence_string_column(df):
+    """
+    Raise KeyError if `columns_to_select` is a regex
+    and does not exist in the dataframe's columns.
+    """
+    with pytest.raises(KeyError, match="No match was returned for.+"):
+        _select_column_names(re.compile("word"), df)
+
+
 def test_regex_presence(df_dates):
     """
     Raise KeyError if `columns_to_select` is a regex
     and the columns is not a string column.
     """
-    with pytest.raises(
-        KeyError, match=r"Regular expressions\(.+\) can be applied.+"
-    ):
+    with pytest.raises(KeyError, match=r"No match was returned.+"):
         _select_column_names(re.compile(r"^\d+"), df_dates)
 
 
@@ -195,8 +221,11 @@ def test_slice_unique():
     """
     Raise ValueError if the columns are not unique.
     """
-    not_unique = pd.DataFrame([], columns=["code", "code", "code1", "code2"])
-    with pytest.raises(ValueError):
+    not_unique = pd.DataFrame([], columns=["code", "code2", "code1", "code"])
+    with pytest.raises(
+        ValueError,
+        match="Non-unique column labels should be monotonic increasing.",
+    ):
         _select_column_names(slice("code", "code2"), not_unique)
 
 
@@ -218,11 +247,17 @@ def test_slice_dtypes(df):
     and either the start value or the stop value is not a string,
     or the step value is not an integer.
     """
-    with pytest.raises(ValueError):
+    with pytest.raises(
+        ValueError,
+        match="The start value for the slice must either be `None`.+",
+    ):
         _select_column_names(slice(1, "M_end_date_2"), df)
-    with pytest.raises(ValueError):
+    with pytest.raises(
+        ValueError,
+        match="The stop value for the slice must either be `None`.+",
+    ):
         _select_column_names(slice("id", 2), df)
-    with pytest.raises(ValueError):
+    with pytest.raises(ValueError, match="The step value for the slice.+"):
         _select_column_names(slice("id", "M_end_date_2", "3"), df)
 
 
@@ -289,52 +324,19 @@ def test_slice_dates_inexact(df_dates):
     )
 
 
-@pytest.mark.xfail(reason="level parameter removed.")
-def test_level_type(df_tuple):
-    """Raise TypeError if `level` is the wrong type."""
-    with pytest.raises(TypeError):
-        _select_column_names("A", df_tuple)
-
-
-@pytest.mark.xfail(reason="level parameter removed.")
-def test_level_nonexistent(df_tuple):
-    """
-    Raise ValueError if column is a MultiIndex
-    and level is `None`.
-    """
-    with pytest.raises(ValueError):
-        _select_column_names("A", df_tuple)
-
-
-@pytest.mark.xfail(reason="level parameter removed.")
-def test_tuple_callable(df_tuple):
-    """
-    Raise ValueError if dataframe has MultiIndex columns
-    and a callable is provided.
-    """
-    with pytest.raises(ValueError):
-        _select_column_names(lambda df: df.name.startswith("A"), df_tuple)
-
-
-@pytest.mark.xfail(reason="level parameter removed.")
-def test_tuple_regex(df_tuple):
-    """
-    Raise ValueError if dataframe has MultiIndex columns'
-    a regex is provided and level is None.
-    """
-    with pytest.raises(ValueError):
-        _select_column_names(re.compile("A"), df_tuple)
-
-
 def test_boolean_list_dtypes(df):
     """
     Raise ValueError if `columns_to_select` is a list of booleans
     and the length is unequal to the number of columns
     in the dataframe.
     """
-    with pytest.raises(ValueError):
+    with pytest.raises(
+        ValueError, match="The length of the list of booleans.+"
+    ):
         _select_column_names([True, False], df)
-    with pytest.raises(ValueError):
+    with pytest.raises(
+        ValueError, match="The length of the list of booleans.+"
+    ):
         _select_column_names(
             [True, True, True, False, False, False, True, True, True, False],
             df,
@@ -355,7 +357,9 @@ def test_callable(df_numbers):
         _select_column_names(lambda df: df + 3, df_numbers)
 
 
-@pytest.mark.xfail(reason="Indexing in Pandas is possible with a Series.")
+@pytest.mark.xfail(
+    reason="Indexing in Pandas is possible with a boolean Series."
+)
 def test_callable_returns_series(df):
     """
     Check that error is raised if `columns_to_select` is a
@@ -367,12 +371,11 @@ def test_callable_returns_series(df):
 
 def test_callable_no_match(df):
     """
-    Test if `columns_to_select` is a callable,
-    and no value is returned.
+     Raise KeyError if `columns_to_select` is a callable,
+    and no match is returned.
     """
-    assert _select_column_names(pd.api.types.is_float_dtype, df).empty
-
-    assert _select_column_names(lambda x: "Date" in x.name, df).empty
+    with pytest.raises(KeyError, match="No match was returned.+"):
+        _select_column_names(pd.api.types.is_float_dtype, df)
 
 
 def test_tuple_presence(df_tuple):