Repository: pyjanitor-devs/pyjanitor

Issue:
[ENH] Allow a dictionary in `names_to`
# Brief Description

An alternative to flipping data to long form for regex - inspired by tidyr's `separate_wider_regex` and this [publication](https://journal.r-project.org/archive/2021/RJ-2021-029/RJ-2021-029.pdf)

# Example API

```py

iris
Out[180]:
   sepal.length  sepal.width  petal.length  petal.width variety
0           5.1          3.5           1.4          0.2  Setosa
1           4.9          3.0           1.4          0.2  Setosa

# current method
iris.pivot_longer(index='variety', names_to = ['feature','description'], names_pattern=r'(.+)\.(.+)')
Out[182]:
  variety feature description  value
0  Setosa   sepal      length    5.1
1  Setosa   sepal      length    4.9
2  Setosa   sepal       width    3.5
3  Setosa   sepal       width    3.0
4  Setosa   petal      length    1.4
5  Setosa   petal      length    1.4
6  Setosa   petal       width    0.2
7  Setosa   petal       width    0.2

#proposed implementation:
iris.pivot_longer(index='variety', names_to = {'feature': r'(.+)', _:'\.', 'description':r'(.+)'})
```



Discussion:

Resolving patch:
diff --git a/janitor/functions/pivot.py b/janitor/functions/pivot.py
--- a/janitor/functions/pivot.py
+++ b/janitor/functions/pivot.py
@@ -132,6 +132,16 @@ def pivot_longer(
         0   1        sp      m  5564      2
         1   1       rel      f    65      3
 
+    Split the column labels for the above dataframe using named groups in `names_pattern`:
+
+        >>> df.pivot_longer(
+        ...     index = 'id',
+        ...     names_pattern = r"new_?(?P<diagnosis>.+)_(?P<gender>.)(?P<age>\\d+)",
+        ... )
+            id diagnosis gender   age  value
+        0   1        sp      m  5564      2
+        1   1       rel      f    65      3
+
     Convert the dtypes of specific columns with `names_transform`:
 
         >>> result = (df
@@ -173,6 +183,50 @@ def pivot_longer(
         0    50    1      10      30
         1    50    2      20      40
 
+    Replicate the above with named groups in `names_pattern` - use `_` instead of `.value`:
+
+        >>> df.pivot_longer(
+        ...     index="unit",
+        ...     names_pattern=r"(?P<_>x|y)_(?P<time>[0-9])(?P<__>_mean)",
+        ... )
+           unit time  x_mean  y_mean
+        0    50    1      10      30
+        1    50    2      20      40
+
+    Reshape dataframe by passing a sequence to `names_pattern`:
+
+        >>> df = pd.DataFrame({'hr1': [514, 573],
+        ...                    'hr2': [545, 526],
+        ...                    'team': ['Red Sox', 'Yankees'],
+        ...                    'year1': [2007, 2007],
+        ...                    'year2': [2008, 2008]})
+        >>> df
+           hr1  hr2     team  year1  year2
+        0  514  545  Red Sox   2007   2008
+        1  573  526  Yankees   2007   2008
+        >>> df.pivot_longer(
+        ...     index = 'team',
+        ...     names_to = ['year', 'hr'],
+        ...     names_pattern = ['year', 'hr']
+        ... )
+              team   hr  year
+        0  Red Sox  514  2007
+        1  Yankees  573  2007
+        2  Red Sox  545  2008
+        3  Yankees  526  2008
+
+    Reshape above dataframe by passing a dictionary to `names_pattern`:
+
+        >>> df.pivot_longer(
+        ...     index = 'team',
+        ...     names_pattern = {"year":"year", "hr":"hr"}
+        ... )
+              team   hr  year
+        0  Red Sox  514  2007
+        1  Yankees  573  2007
+        2  Red Sox  545  2008
+        3  Yankees  526  2008
+
     Multiple values_to:
 
         >>> df = pd.DataFrame(
@@ -207,7 +261,7 @@ def pivot_longer(
         ...         column_names=slice("Mango", "Vodka"),
         ...         names_to=("Fruit", "Drink"),
         ...         values_to=("Pounds", "Ounces"),
-        ...         names_pattern=[r"M|O|W", r"G|V"],
+        ...         names_pattern=["M|O|W", "G|V"],
         ...     )
               City    State       Fruit  Pounds  Drink  Ounces
         0  Houston    Texas       Mango       4    Gin    16.0
@@ -220,11 +274,34 @@ def pivot_longer(
         7   Austin    Texas  Watermelon      99   None     NaN
         8   Hoover  Alabama  Watermelon      43   None     NaN
 
+    Replicate the above transformation with a nested dictionary passed to `names_pattern`:
+
+        >>> df.pivot_longer(
+        ...     index=["City", "State"],
+        ...     column_names=slice("Mango", "Vodka"),
+        ...     names_pattern={
+        ...         "Fruit": {"Pounds": "M|O|W"},
+        ...         "Drink": {"Ounces": "G|V"},
+        ...     },
+        ... )
+              City    State       Fruit  Pounds  Drink  Ounces
+        0  Houston    Texas       Mango       4    Gin    16.0
+        1   Austin    Texas       Mango      10    Gin   200.0
+        2   Hoover  Alabama       Mango      90    Gin    34.0
+        3  Houston    Texas      Orange      10  Vodka    20.0
+        4   Austin    Texas      Orange       8  Vodka    33.0
+        5   Hoover  Alabama      Orange      14  Vodka    18.0
+        6  Houston    Texas  Watermelon      40   None     NaN
+        7   Austin    Texas  Watermelon      99   None     NaN
+        8   Hoover  Alabama  Watermelon      43   None     NaN
 
     !!! abstract "Version Changed"
 
         - 0.24.0
             - Added `dropna` parameter.
+        - 0.24.1
+            - `names_pattern` can accept a dictionary.
+            - named groups supported in `names_pattern`.
 
 
     :param df: A pandas DataFrame.
@@ -257,13 +334,27 @@ def pivot_longer(
         or regular expression. `names_sep` does not work with MultiIndex
         columns.
     :param names_pattern: Determines how the column name is broken up.
-        It can be a regular expression containing matching groups (it takes
-        the same specification as pandas' `str.extract` method), or a
-        list/tuple of regular expressions. If it is a single regex, the
-        number of groups must match the length of `names_to`.
+        It can be a regular expression containing matching groups.
+        Under the hood it is processed with pandas' `str.extract` function.
+        If it is a single regex, the number of groups must match
+        the length of `names_to`.
+        Named groups are supported, if `names_to` is none. `_` is used
+        instead of `.value` as a placeholder in named groups.
+        `_` can be overloaded for multiple `.value`
+        calls - `_`, `__`, `___`, ...
+        `names_pattern` can also be a list/tuple of regular expressions
+        It can also be a list/tuple of strings;
+        the strings will be treated as regular expressions.
+        Under the hood it is processed with pandas' `str.contains` function.
         For a list/tuple of regular expressions,
         `names_to` must also be a list/tuple and the lengths of both
         arguments must match.
+        `names_pattern` can also be a dictionary, where the keys are
+        the new column names, while the values can be a regular expression
+        or a string which will be evaluated as a regular expression.
+        Alternatively, a nested dictionary can be used, where the sub
+        key(s) are associated with `values_to`. Please have a look
+        at the examples for usage.
         `names_pattern` does not work with MultiIndex columns.
     :param names_transform: Use this option to change the types of columns that
         have been transformed to rows. This does not applies to the values' columns.
@@ -428,34 +519,40 @@ def _data_checks_pivot_longer(
         if column_names is None:
             column_names = df.columns.difference(index, sort=False).tolist()
 
-    len_names_to = 0
     if names_to is not None:
         if isinstance(names_to, str):
             names_to = [names_to]
         elif isinstance(names_to, tuple):
             names_to = [*names_to]
+
         check("names_to", names_to, [list, str, tuple])
 
         uniques = set()
         for word in names_to:
-            check(f"{word} in names_to", word, [str])
+            check(f"'{word}' in names_to", word, [str])
             if (word in uniques) and (word != ".value"):
-                raise ValueError(f"{word} is duplicated in names_to.")
+                raise ValueError(f"'{word}' is duplicated in names_to.")
             uniques.add(word)
 
-        len_names_to = len(names_to)
     else:
         if not any((names_sep, names_pattern)):
             names_to = ["variable"]
 
     check("values_to", values_to, [str, list, tuple])
-    if isinstance(values_to, (list, tuple)) and (
-        not isinstance(names_pattern, (list, tuple))
-    ):
-        raise TypeError(
-            "values_to can be a list/tuple only "
-            "if names_pattern is a list/tuple."
-        )
+    if isinstance(values_to, (list, tuple)):
+        if not isinstance(names_pattern, (list, tuple)):
+            raise TypeError(
+                "values_to can be a list/tuple only "
+                "if names_pattern is a list/tuple."
+            )
+        if index:
+            exclude = set(values_to).intersection(index)
+            if exclude:
+                raise ValueError(
+                    f"Labels {*exclude, } in values_to already exist as "
+                    "column labels assigned to the dataframe's "
+                    "index parameter. Kindly use unique labels."
+                )
     if (
         (names_sep is None)
         and (names_pattern is None)
@@ -475,25 +572,40 @@ def _data_checks_pivot_longer(
         )
 
     if names_pattern is not None:
-        check("names_pattern", names_pattern, [str, Pattern, list, tuple])
-        if names_to is None:
-            raise ValueError("Kindly provide values for names_to.")
+        check(
+            "names_pattern", names_pattern, [str, Pattern, list, tuple, dict]
+        )
         if isinstance(names_pattern, (str, Pattern)):
-            num_regex_grps = re.compile(names_pattern).groups
-
-            if len_names_to != num_regex_grps:
+            regex = re.compile(names_pattern)
+            if names_to is None:
+                if regex.groupindex:
+                    names_to = regex.groupindex.keys()
+                    names_to = [
+                        ".value"
+                        if ("_" in name) and (len(set(name)) == 1)
+                        else name
+                        for name in names_to
+                    ]
+                    len_names_to = len(names_to)
+                else:
+                    raise ValueError("Kindly provide values for names_to.")
+            else:
+                len_names_to = len(names_to)
+            if len_names_to != regex.groups:
                 raise ValueError(
                     f"The length of names_to does not match "
                     "the number of groups in names_pattern. "
                     f"The length of names_to is {len_names_to} "
                     "while the number of groups in the regex "
-                    f"is {num_regex_grps}."
+                    f"is {regex.groups}."
                 )
 
         elif isinstance(names_pattern, (list, tuple)):
+            if names_to is None:
+                raise ValueError("Kindly provide values for names_to.")
             for word in names_pattern:
-                check(f"{word} in names_pattern", word, [str, Pattern])
-
+                check(f"'{word}' in names_pattern", word, [str, Pattern])
+            len_names_to = len(names_to)
             if len(names_pattern) != len_names_to:
                 raise ValueError(
                     f"The length of names_to does not match "
@@ -521,12 +633,91 @@ def _data_checks_pivot_longer(
                     check(f"{word} in values_to", word, [str])
                     if word in names_to:
                         raise ValueError(
-                            f"{word} in values_to already exists in names_to."
+                            f"'{word}' in values_to "
+                            "already exists in names_to."
                         )
 
                     if word in uniques:
-                        raise ValueError(f"{word} is duplicated in values_to.")
+                        raise ValueError(
+                            f"'{word}' is duplicated in values_to."
+                        )
                     uniques.add(word)
+        # outer keys belong to names_to
+        # if the values are dicts,
+        # then the inner key belongs to values_to
+        # inner keys should not exist in the outer keys
+        # non keys belong to names_pattern
+        elif isinstance(names_pattern, dict):
+            if names_to is not None:
+                raise ValueError(
+                    "names_to should be None "
+                    "when names_pattern is a dictionary"
+                )
+            for key, value in names_pattern.items():
+                check(f"'{key}' in names_pattern", key, [str])
+                if index and (key in index):
+                    raise ValueError(
+                        f"'{key}' in the names_pattern dictionary "
+                        "already exists as a column label "
+                        "assigned to the index parameter. "
+                        "Kindly use a unique name"
+                    )
+            names_to = list(names_pattern)
+            is_dict = (
+                isinstance(arg, dict) for _, arg in names_pattern.items()
+            )
+            if all(is_dict):
+                values_to = []
+                patterns = []
+                for key, value in names_pattern.items():
+                    if len(value) != 1:
+                        raise ValueError(
+                            "The length of the dictionary paired "
+                            f"with '{key}' in names_pattern "
+                            "should be length 1, instead got "
+                            f"{len(value)}"
+                        )
+                    for k, v in value.items():
+                        if not isinstance(k, str):
+                            raise TypeError(
+                                "The key in the nested dictionary "
+                                f"for '{key}' in names_pattern "
+                                "should be a string, instead got {type(k)}"
+                            )
+                        if k in names_pattern:
+                            raise ValueError(
+                                f"'{k}' in the nested dictionary "
+                                "already exists as one of the main "
+                                "keys in names_pattern"
+                            )
+                        if index and (k in index):
+                            raise ValueError(
+                                f"'{k}' in the nested dictionary "
+                                "already exists as a column label "
+                                "assigned to the index parameter. "
+                                "Kindly use a unique name"
+                            )
+                        check(
+                            f"The value paired with '{k}' "
+                            "in the nested dictionary in names_pattern",
+                            v,
+                            [str, Pattern],
+                        )
+                        patterns.append(v)
+                        values_to.append(k)
+            else:
+                patterns = []
+                for key, value in names_pattern.items():
+                    check(
+                        f"The value paired with '{key}' "
+                        "in the names_pattern dictionary",
+                        value,
+                        [str, Pattern],
+                    )
+
+                    patterns.append(value)
+            names_pattern = patterns
+            patterns = None
 
     if names_sep is not None:
         check("names_sep", names_sep, [str, Pattern])
@@ -707,14 +898,6 @@ def _pivot_longer_names_pattern_sequence(
     names_pattern is provided, and is a list/tuple.
     """
     values_to_is_a_sequence = isinstance(values_to, (list, tuple))
-    if values_to_is_a_sequence and index:
-        exclude = set(values_to).intersection(index)
-        if exclude:
-            raise ValueError(
-                f"Labels {*exclude, } in values_to already exist as "
-                "column labels assigned to the dataframe's index parameter. "
-                "Kindly use unique labels."
-            )
     values = df.columns
 
     mapping = [


Test patch:
diff --git a/tests/functions/test_pivot_longer.py b/tests/functions/test_pivot_longer.py
--- a/tests/functions/test_pivot_longer.py
+++ b/tests/functions/test_pivot_longer.py
@@ -6,6 +6,9 @@
 from pandas import NA
 
 
+df = [1, 2, 3]
+
+
 @pytest.fixture
 def df_checks():
     """fixture dataframe"""
@@ -69,13 +72,13 @@ def test_subtype_names_to(df_checks):
     and the wrong type is provided for entries
     in names_to.
     """
-    with pytest.raises(TypeError, match="1 in names_to.+"):
+    with pytest.raises(TypeError, match="'1' in names_to.+"):
         df_checks.pivot_longer(names_to=[1])
 
 
 def test_duplicate_names_to(df_checks):
     """Raise error if names_to contains duplicates."""
-    with pytest.raises(ValueError, match="y is duplicated in names_to."):
+    with pytest.raises(ValueError, match="'y' is duplicated in names_to."):
         df_checks.pivot_longer(names_to=["y", "y"], names_pattern="(.+)(.)")
 
 
@@ -101,10 +104,20 @@ def test_name_pattern_wrong_type(df_checks):
 
 def test_name_pattern_no_names_to(df_checks):
     """Raise ValueError if names_pattern and names_to is None."""
-    with pytest.raises(ValueError):
+    with pytest.raises(
+        ValueError, match="Kindly provide values for names_to."
+    ):
         df_checks.pivot_longer(names_to=None, names_pattern="(.+)(.)")
 
 
+def test_name_pattern_seq_no_names_to(df_checks):
+    """Raise ValueError if names_pattern is a sequence and names_to is None."""
+    with pytest.raises(
+        ValueError, match="Kindly provide values for names_to."
+    ):
+        df_checks.pivot_longer(names_to=None, names_pattern=[".{2}", "\\d"])
+
+
 def test_name_pattern_groups_len(df_checks):
     """
     Raise ValueError if names_pattern
@@ -124,7 +137,7 @@ def test_names_pattern_wrong_subtype(df_checks):
     Raise TypeError if names_pattern is a list/tuple
     and wrong subtype is supplied.
     """
-    with pytest.raises(TypeError, match="1 in names_pattern.+"):
+    with pytest.raises(TypeError, match="'1' in names_pattern.+"):
         df_checks.pivot_longer(
             names_to=["ht", "num"], names_pattern=[1, "\\d"]
         )
@@ -215,7 +228,7 @@ def test_values_to_names_seq_names_to(df_checks):
     and intersects with names_to.
     """
     with pytest.raises(
-        ValueError, match="salvo in values_to already exists in names_to."
+        ValueError, match="'salvo' in values_to already exists in names_to."
     ):
         df_checks.pivot_longer(
             values_to=["salvo"], names_pattern=["ht"], names_to="salvo"
@@ -234,7 +247,9 @@ def test_sub_values_to(df_checks):
 
 def test_duplicate_values_to(df_checks):
     """Raise error if values_to is a sequence, and contains duplicates."""
-    with pytest.raises(ValueError, match="salvo is duplicated in values_to."):
+    with pytest.raises(
+        ValueError, match="'salvo' is duplicated in values_to."
+    ):
         df_checks.pivot_longer(
             names_to=["x", "y"],
             names_pattern=[r"ht", r"\d"],
@@ -790,6 +805,40 @@ def test_names_pattern_list():
     assert_frame_equal(result, actual)
 
 
+def test_names_pattern_dict():
+    """Test output for names_pattern if dict."""
+
+    df = pd.DataFrame(
+        {
+            "Activity": ["P1", "P2"],
+            "General": ["AA", "BB"],
+            "m1": ["A1", "B1"],
+            "t1": ["TA1", "TB1"],
+            "m2": ["A2", "B2"],
+            "t2": ["TA2", "TB2"],
+            "m3": ["A3", "B3"],
+            "t3": ["TA3", "TB3"],
+        }
+    )
+
+    result = df.pivot_longer(
+        index=["Activity", "General"],
+        names_pattern={"M": "^m", "Task": "^t"},
+        sort_by_appearance=True,
+    ).loc[:, ["Activity", "General", "Task", "M"]]
+
+    actual = (
+        pd.wide_to_long(
+            df, i=["Activity", "General"], stubnames=["t", "m"], j="number"
+        )
+        .set_axis(["Task", "M"], axis="columns")
+        .droplevel(-1)
+        .reset_index()
+    )
+
+    assert_frame_equal(result, actual)
+
+
 @pytest.fixture
 def not_dot_value():
     """Fixture DataFrame"""
@@ -873,6 +922,33 @@ def test_not_dot_value_pattern(not_dot_value):
     assert_frame_equal(result, actual)
 
 
+def test_not_dot_value_pattern_named_groups(not_dot_value):
+    """
+    Test output when names_pattern has named groups
+    """
+
+    result = not_dot_value.pivot_longer(
+        "country",
+        names_pattern=r"(?P<event>.+)_(?P<year>.+)",
+        values_to="score",
+        sort_by_appearance=True,
+    )
+    result = result.sort_values(
+        ["country", "event", "year"], ignore_index=True
+    )
+    actual = not_dot_value.set_index("country")
+    actual.columns = actual.columns.str.split("_", expand=True)
+    actual.columns.names = ["event", "year"]
+    actual = (
+        actual.stack(["event", "year"])
+        .rename("score")
+        .sort_index()
+        .reset_index()
+    )
+
+    assert_frame_equal(result, actual)
+
+
 def test_not_dot_value_sep_single_column(not_dot_value):
     """
     Test output when names_sep and no dot_value
@@ -941,6 +1017,44 @@ def test_multiple_dot_value():
     assert_frame_equal(result, actual, check_dtype=False)
 
 
+def test_multiple_dot_value_named_group():
+    """Test output for multiple .value."""
+    df = pd.DataFrame(
+        {
+            "x_1_mean": [1, 2, 3, 4],
+            "x_2_mean": [1, 1, 0, 0],
+            "x_1_sd": [0, 1, 1, 1],
+            "x_2_sd": [0.739, 0.219, 1.46, 0.918],
+            "y_1_mean": [1, 2, 3, 4],
+            "y_2_mean": [1, 1, 0, 0],
+            "y_1_sd": [0, 1, 1, 1],
+            "y_2_sd": [-0.525, 0.623, -0.705, 0.662],
+            "unit": [1, 2, 3, 4],
+        }
+    )
+
+    result = df.pivot_longer(
+        index="unit",
+        names_pattern=r"(?P<_>x|y)_(?P<time>[0-9])(?P<__>_mean|_sd)",
+    ).astype({"time": int})
+
+    actual = df.set_index("unit")
+    cols = [ent.split("_") for ent in actual.columns]
+    actual.columns = [f"{start}_{end}{middle}" for start, middle, end in cols]
+    actual = (
+        pd.wide_to_long(
+            actual.reset_index(),
+            stubnames=["x_mean", "y_mean", "x_sd", "y_sd"],
+            i="unit",
+            j="time",
+        )
+        .sort_index(axis=1)
+        .reset_index()
+    )
+
+    assert_frame_equal(result, actual, check_dtype=False)
+
+
 @pytest.fixture
 def single_val():
     """fixture dataframe"""
@@ -963,6 +1077,16 @@ def test_multiple_dot_value2(single_val):
     assert_frame_equal(result, single_val)
 
 
+def test_multiple_dot_value2_named_group(single_val):
+    """Test output for multiple .value."""
+
+    result = single_val.pivot_longer(
+        index="id", names_pattern="(?P<_>.)(?P<_____________>.)"
+    )
+
+    assert_frame_equal(result, single_val)
+
+
 def test_names_pattern_sequence_single_unique_column(single_val):
     """
     Test output if names_pattern is a sequence of length 1.
@@ -1142,6 +1266,176 @@ def multiple_values_to():
     )
 
 
+def test_names_pattern_dict_names_to(multiple_values_to):
+    """
+    Raise Error if names_pattern is a dict
+    and one of the keys exists in the index
+    """
+    with pytest.raises(
+        ValueError, match="'City' in the names_pattern dictionary.+"
+    ):
+        multiple_values_to.pivot_longer(
+            index=["City", "State"],
+            column_names=slice("Mango", "Vodka"),
+            names_pattern={
+                "City": {"Pounds": r"M|O|W"},
+                "Drink": {"Ounces": r"G|V"},
+            },
+        )
+
+
+def test_names_pattern_dict_index(multiple_values_to):
+    """
+    Raise Error if names_pattern is a dict
+    and the key already exists in
+    """
+    with pytest.raises(
+        ValueError,
+        match="names_to should be None when names_pattern is a dictionary",
+    ):
+        multiple_values_to.pivot_longer(
+            index=["City", "State"],
+            column_names=slice("Mango", "Vodka"),
+            names_to=("Fruit", "Drink"),
+            names_pattern={
+                "Fruit": {"Pounds": r"M|O|W"},
+                "Drink": {"Ounces": r"G|V"},
+            },
+        )
+
+
+def test_names_pattern_dict_outer_key(multiple_values_to):
+    """
+    Raise Error if names_pattern is a dict
+    and the outer key is not a string
+    """
+    with pytest.raises(TypeError, match="'1' in names_pattern.+"):
+        multiple_values_to.pivot_longer(
+            index=["City", "State"],
+            column_names=slice("Mango", "Vodka"),
+            names_pattern={
+                1: {"Pounds": r"M|O|W"},
+                "Drink": {"Ounces": r"G|V"},
+            },
+        )
+
+
+def test_names_pattern_nested_dict_length(multiple_values_to):
+    """
+    Raise Error if names_pattern is a nested dict
+    and the inner dictionary is not of length 1
+    """
+    with pytest.raises(
+        ValueError,
+        match="The length of the dictionary paired with 'Fruit' "
+        "in names_pattern should be length 1, instead got 2",
+    ):
+        multiple_values_to.pivot_longer(
+            index=["City", "State"],
+            column_names=slice("Mango", "Vodka"),
+            names_pattern={"Fruit": {"Pounds": r"M|O|W", "Drink": r"G|V"}},
+        )
+
+
+def test_names_pattern_nested_dict_inner_key(multiple_values_to):
+    """
+    Raise Error if names_pattern is a nested dict
+    and the inner key is not a string
+    """
+    with pytest.raises(
+        TypeError,
+        match="The key in the nested dictionary for 'Fruit' "
+        "in names_pattern should be a string.+",
+    ):
+        multiple_values_to.pivot_longer(
+            index=["City", "State"],
+            column_names=slice("Mango", "Vodka"),
+            names_pattern={
+                "Fruit": {1: r"M|O|W"},
+                "Drink": {"Ounces": r"G|V"},
+            },
+        )
+
+
+def test_names_pattern_nested_dict_inner_key_outer_key_dupe(
+    multiple_values_to,
+):
+    """
+    Raise Error if names_pattern is a nested dict
+    and the inner key is a dupe of the outer key
+    """
+    with pytest.raises(
+        ValueError,
+        match="'Fruit' in the nested dictionary already exists "
+        "as one of the main keys in names_pattern",
+    ):
+        multiple_values_to.pivot_longer(
+            index=["City", "State"],
+            column_names=slice("Mango", "Vodka"),
+            names_pattern={
+                "Fruit": {"Pounds": r"M|O|W"},
+                "Drink": {"Fruit": r"G|V"},
+            },
+        )
+
+
+def test_names_pattern_nested_dict_inner_key_index(multiple_values_to):
+    """
+    Raise Error if names_pattern is a nested dict
+    and the inner key already exists in the index
+    """
+    with pytest.raises(
+        ValueError,
+        match="'State' in the nested dictionary already exists "
+        "as a column label assigned to the index parameter.+",
+    ):
+        multiple_values_to.pivot_longer(
+            index=["City", "State"],
+            column_names=slice("Mango", "Vodka"),
+            names_pattern={
+                "Fruit": {"Pounds": r"M|O|W"},
+                "Drink": {"State": r"G|V"},
+            },
+        )
+
+
+def test_names_pattern_nested_dict_inner_value(multiple_values_to):
+    """
+    Raise Error if names_pattern is a nested dict
+    and the inner value is not a string/regex
+    """
+    with pytest.raises(
+        TypeError,
+        match="The value paired with 'Pounds' in the nested dictionary "
+        "in names_pattern.+",
+    ):
+        multiple_values_to.pivot_longer(
+            index=["City", "State"],
+            column_names=slice("Mango", "Vodka"),
+            names_pattern={
+                "Fruit": {"Pounds": [r"M|O|W"]},
+                "Drink": {"Ounces": r"G|V"},
+            },
+        )
+
+
+def test_names_pattern_dict_value(multiple_values_to):
+    """
+    Raise Error if names_pattern is a dict
+    and the value is not a string/regex
+    """
+    with pytest.raises(
+        TypeError,
+        match="The value paired with 'Fruit' "
+        "in the names_pattern dictionary.+",
+    ):
+        multiple_values_to.pivot_longer(
+            index=["City", "State"],
+            column_names=slice("Mango", "Vodka"),
+            names_pattern={"Fruit": [r"M|O|W"], "Drink": r"G|V"},
+        )
+
+
 def test_output_values_to_seq(multiple_values_to):
     """Test output when values_to is a list/tuple."""
 
@@ -1206,6 +1500,50 @@ def test_output_values_to_seq1(multiple_values_to):
     assert_frame_equal(expected, actual)
 
 
+def test_output_names_pattern_nested_dictionary(multiple_values_to):
+    """Test output when names_pattern is a nested dictionary."""
+    # https://stackoverflow.com/a/51520155/7175713
+    df1 = multiple_values_to.melt(
+        id_vars=["City", "State"],
+        value_vars=["Mango", "Orange", "Watermelon"],
+        var_name="Fruit",
+        value_name="Pounds",
+    )
+    df2 = multiple_values_to.melt(
+        id_vars=["City", "State"],
+        value_vars=["Gin", "Vodka"],
+        var_name="Drink",
+        value_name="Ounces",
+    )
+
+    df1 = df1.set_index(
+        ["City", "State", df1.groupby(["City", "State"]).cumcount()]
+    )
+    df2 = df2.set_index(
+        ["City", "State", df2.groupby(["City", "State"]).cumcount()]
+    )
+
+    actual = (
+        pd.concat([df1, df2], axis=1)
+        .sort_index(level=2)
+        .reset_index(level=2, drop=True)
+        .reset_index()
+        .astype({"Fruit": "category", "Drink": "category"})
+    )
+
+    expected = multiple_values_to.pivot_longer(
+        index=["City", "State"],
+        column_names=slice("Mango", "Vodka"),
+        names_pattern={
+            "Fruit": {"Pounds": r"M|O|W"},
+            "Drink": {"Ounces": r"G|V"},
+        },
+        names_transform={"Fruit": "category", "Drink": "category"},
+    ).sort_values(["Fruit", "City", "State"], ignore_index=True)
+
+    assert_frame_equal(expected, actual)
+
+
 def test_categorical(df_checks):
     """Test category output for names_to."""