Repository: pyjanitor-devs/pyjanitor

Issue:
[ENH] find_replace on multiple columns 
modify find_replace to be applicable to a list of columns 


Discussion:
I don't know a lot about decorators, but it seems like we've got a similar pattern at multiple points in the codebase:

- [1](https://github.com/ericmjl/pyjanitor/blob/ceca343d4dad07aa7602ff7f6742af96c560e872/janitor/functions.py#L249)
- [2](https://github.com/ericmjl/pyjanitor/blob/ceca343d4dad07aa7602ff7f6742af96c560e872/janitor/functions.py#L302)
- [3](https://github.com/ericmjl/pyjanitor/blob/ceca343d4dad07aa7602ff7f6742af96c560e872/janitor/functions.py#L361)
- etc

Is this an appropriate use for them?
Perhaps so! I have always been a bit lazy and just provided `X_column(s)` as corresponding pairs as the need arose. I'd like to see a bit more standardization of my laziness into something more formal here. @NapsterInBlue, do you have any ideas to propose? If you do, please post them here, so we can flesh out the implementation in the open first.
Could this also support destination column(s) so as to not replace the values in the original column(s)? Also, some sort of `check_exists` (default could be `False`) mode or whatever which ensures that the keys in your dict are present could be handy when people want such a sanity check.
> Could this also support destination column(s) so as to not replace the values in the original column(s)? 

`find_replace`, semantically, I think means replacing the existing values. We can debate this though.

In terms of creating a new column, I think `join_apply` would be the right way to do this. 
`transform_column` would then follow the same logic, no?
That's true too. We need a vote.
The way to then do this would then be:

`df.join_apply(lambda df: df.copy().[function]('column_name', ...)['column_name'], 'new_column_name')`

where `[function]` is a function that operates in place on column[s], right? (or something like that). ~~In place functions also conflict with the mission to make pyjanitor functions not modify the original dataframe, as well, as far as I can see.~~

I think the added cognitive load of having a destination column name that is by default in place is much less than having to go through an extra `join_apply` step
Cognitive load wins the argument for me. I'm out right now, do you have time to do the PR?
Not at the moment; I'm on a deadline to get some data to a collaborator
Btw:

> In place functions also conflict with the mission to make pyjanitor functions not modify the original dataframe, as well, as far as I can see.

There's a difference between in-place in memory and in-place assigned to the original. IIRC, `find_replace` should be the latter, i.e. the original dataframe is copied (I think).
~~hence the strikethrough :thinking:~~ 


I would like to propose adding both multi-column, single-mapping support and multi-column, multi-mapping support to the existing find_replace() functionality.  To support both cases, I propose splitting find_replace() into an additional function, find_replace_all():

# Example API
```python
def find_replace_all(mapping: Dict, column_names: Union[List, str]): pass
def find_replace(**mappings: Dict[str, Dict]): pass
```

## Example Usages:

```python
df.find_replace_all({-9999: np.nan}, column_names=['column1', 'column2'])
```
```python
df.find_replace(column1={-9999: np.nan}, column2={'none', np.nan})
```
An interesting benefit is that find_replace() can also handle multi-column, single-mapping if pre-assignment or the new walrus is used:

```python
m = {-9999: np.nan}
df.find_replace(column1=m, column2=m)
```

```python
df.find_replace(column1=m:={-9999: np.nan}, column2=m)
```
@ericmjl 
I’m in favour of the propose API, @nickdelgrosso!
5 months later (with many apologies to all who have been watching this issue), I think @nickdelgrosso's proposed API is better than the current API. Reasons are mainly for readability: "`find` and `replace` in `columnA` this `mapping`, in `columnB` this `other_mapping`, etc."

Resolving patch:
diff --git a/janitor/functions.py b/janitor/functions.py
--- a/janitor/functions.py
+++ b/janitor/functions.py
@@ -2980,20 +2980,14 @@ def dropnotnull(df: pd.DataFrame, column_name: Hashable) -> pd.DataFrame:
 
 
 @pf.register_dataframe_method
-@deprecated_alias(column="column_name")
-def find_replace(
-    df: pd.DataFrame, column_name: str, mapper: Dict, match: str = "exact"
-) -> pd.DataFrame:
+def find_replace(df, match: str = "exact", **mappings):
     """
-    Perform a find-and-replace action on a column of data.
+    Perform a find-and-replace action on provided columns.
 
-    This method mutates the original DataFrame.
-
-    Depending on use cases, users can choose either exact, full value matching
-    or regular-expression-based fuzzy matching (substring matching is allowed
-    in the latter case). For strings, the matching is always case sensitive.
-
-    Note that default value for keyword argument `match` is "exact".
+    Depending on use case, users can choose either exact, full-value matching,
+    or regular-expression-based fuzzy matching
+    (hence allowing substring matching in the latter case).
+    For strings, the matching is always case sensitive.
 
     For instance, given a dataframe containing orders at a coffee shop:
 
@@ -3013,27 +3007,77 @@ def find_replace(
 
         # Functional usage
         df = find_replace(
-            df, 'order', {'ice coffee': 'latte', 'regular coffee': 'latte'},
-            match='exact'
+            df,
+            match='exact',
+            order={'ice coffee': 'latte', 'regular coffee': 'latte'},
         )
 
         # Method chaining usage
         df = df.find_replace(
-            'order', {'ice coffee': 'latte', 'regular coffee': 'latte'},
             match='exact'
+            order={'ice coffee': 'latte', 'regular coffee': 'latte'},
         )
 
-
     Example 2: Regular-expression-based matching
 
     .. code-block:: python
 
         # Functional usage
-        df = find_replace(df, 'order', {'coffee$': 'latte'}, match='regex')
+        df = find_replace(
+            df,
+            match='regex',
+            order={'coffee$': 'latte'},
+        )
 
         # Method chaining usage
-        df = df.find_replace('order', {'coffee$': 'latte'}, match='regex')
+        df = df.find_replace(
+            match='regex',
+            order={'coffee$': 'latte'},
+        )
+
+    To perform a find and replace on the entire dataframe,
+    pandas' ``df.replace()`` function provides the appropriate functionality.
+    You can find more detail on the replace_ docs.
+
+    This function only works with column names that have no spaces
+    or punctuation in them.
+    For example, a column name ``item_name`` would work with ``find_replace``,
+    because it is a contiguous string that can be parsed correctly,
+    but ``item name`` would not be parsed correctly by the Python interpreter.
+
+    If you have column names that might not be compatible,
+    we recommend calling on ``clean_names()`` as the first method call.
+    If, for whatever reason, that is not possible,
+    then ``_find_replace()`` is available as a function
+    that you can do a pandas pipe_ call on.
+
+    .. _replace: https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.replace.html
+    .. _pipe: https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.pipe.html
+
+    :param df: A pandas DataFrame.
+    :param match: Whether or not to perform an exact match or not.
+        Valid values are "exact" or "regex".
+    :param mappings: keyword arguments corresponding to column names
+        that have dictionaries passed in indicating what to find (keys)
+        and what to replace with (values).
+    """  # noqa: E501
+    for column_name, mapper in mappings.items():
+        df = _find_replace(df, column_name, mapper, match=match)
+    return df
+
+
+def _find_replace(
+    df: pd.DataFrame, column_name: str, mapper: Dict, match: str = "exact"
+) -> pd.DataFrame:
+    """
+    Utility function for ``find_replace``.
 
+    The code in here was the original implementation of ``find_replace``,
+    but we decided to change out the front-facing API to accept
+    kwargs + dictionaries for readability,
+    and instead dispatch underneath to this function.
+    This implementation was kept
+    because it has a number of validations that are quite useful.
 
     :param df: A pandas DataFrame.
     :param column_name: The column on which the find/replace action is to be


Test patch:
diff --git a/tests/functions/test_find_replace.py b/tests/functions/test_find_replace.py
--- a/tests/functions/test_find_replace.py
+++ b/tests/functions/test_find_replace.py
@@ -13,12 +13,12 @@ def df():
 @pytest.mark.functions
 def test_find_replace_single(df):
     assert df["a"].iloc[2] == 3
-    df.find_replace("a", {3: 5})
+    df.find_replace(a={3: 5})
     assert df["a"].iloc[2] == 5
 
     assert sum(df["c"] == 2) == 2
     assert sum(df["c"] == 5) == 0
-    df.find_replace("c", {2: 5})
+    df.find_replace(c={2: 5})
     assert sum(df["c"] == 2) == 0
     assert sum(df["c"] == 5) == 2
 
@@ -26,7 +26,7 @@ def test_find_replace_single(df):
 @pytest.mark.functions
 def test_find_replace_null_raises_error(df):
     with pytest.raises(ValueError):
-        df.find_replace("a", {np.nan: 5})
+        df.find_replace(a={np.nan: 5})
 
 
 @pytest.fixture
@@ -41,15 +41,13 @@ def df_orders():
 
 @pytest.mark.functions
 def test_find_replace_regex(df_orders):
-    df_orders.find_replace("order", {"coffee$": "latte"}, match="regex")
+    df_orders.find_replace(order={"coffee$": "latte"}, match="regex")
     assert df_orders["order"].iloc[0] == "latte"
     assert df_orders["order"].iloc[1] == "lemonade"
     assert df_orders["order"].iloc[-1] == "latte"
 
 
 @pytest.mark.functions
-def test_find_replace_regex_match_rases_error(df_orders):
+def test_find_replace_regex_match_raises_error(df_orders):
     with pytest.raises(ValueError):
-        df_orders.find_replace(
-            "order", {"lemonade": "orange juice"}, match="bla"
-        )
+        df_orders.find_replace(order={"lemonade": "orange juice"}, match="bla")