Repository: pyjanitor-devs/pyjanitor

Issue:
Add the ability to test whether data is changing/jumping beyond a tolerance 
# Brief Description

Following up on #703, this issue seeks to introduce the ability to test whether in each column in a data frame the change between every two rows is within a user-defined range of values.

I would like to propose..
# Example API
```python
import pandas as pd
import numpy as np

# Create a random data frame
df = pd.DataFrame(data=[["2015-01-01 00:00:00", -0.76, 2, 2, 1.2],
                        ["2015-01-01 01:00:00", -0.73, 2, 4, 1.1],
                        ["2015-01-01 02:00:00", -0.71, 2, 4, 1.1],
                        ["2015-01-01 03:00:00", -0.68, 2, 32, 1.1],
                        ["2015-01-01 04:00:00", -0.65, 2, 2, 1.0],
                        ["2015-01-01 05:00:00", -0.76, 2, 2, 1.2],
                        ["2015-01-01 06:00:00", -0.73, 2, 4, 1.1],
                        ["2015-01-01 07:00:00", -0.71, 2, 4, 1.1],
                        ["2015-01-01 08:00:00", -0.68, 2, 32, 1.1],
                        ["2015-01-01 09:00:00", -0.65, 2, 2, 1.0],
                        ["2015-01-01 10:00:00", -0.76, 2, 2, 1.2],
                        ["2015-01-01 11:00:00", -0.73, 2, 4, 1.1],
                        ["2015-01-01 12:00:00", -0.71, 2, 4, 1.1],
                        ["2015-01-01 13:00:00", -0.68, 2, 32, 1.1],
                        ["2015-01-01 14:00:00", -0.65, 2, 2, 1.0],
                        ["2015-01-01 15:00:00", -0.76, 2, 2, 1.2],
                        ["2015-01-01 16:00:00", -0.73, 2, 4, 1.1],
                        ["2015-01-01 17:00:00", -0.71, 2, 4, 1.1],
                        ["2015-01-01 18:00:00", -0.68, 2, 32, 1.1],
                        ["2015-01-01 19:00:00", -0.65, 2, 2, 1.0],
                        ["2015-01-01 20:00:00", -0.76, 2, 2, 1.2],
                        ["2015-01-01 21:00:00", -0.73, 2, 4, 1.1],
                        ["2015-01-01 22:00:00", -0.71, 2, 4, 1.1],
                        ["2015-01-01 23:00:00", -0.68, 2, 32, 1.1],
                        ["2015-01-02 00:00:00", -0.65, 2, 2, 1.0]],
                  columns=['DateTime', 'column1', 'column2', 'column3', 'column4'])

# Set the index
df["DateTime"] = pd.to_datetime(df["DateTime"])
df.set_index("DateTime", inplace=True)

# Flag functionality 
df1 = df.copy(deep=True)

def flag_jumps(
        df_input: pd.DataFrame,
        tolerance: float = 10**-2
) -> pd.DataFrame:
    """
    Returns a data frame the size of the input

    Flags rows in each column where tolerance is violated
    :param df_input: input data frame to test for jumps
    :param tolerance: acceptable value of tolerance
    :return: data frame with flags indicating whether tolerance has been violated or not
    """
    # Calculate the difference between every two rows
    df2 = df_input.diff()

    # Check for tolerance violation
    df3 = df2.gt(tolerance)

    return df3

jump_flags = flag_jumps(df1, tolerance=2)
print(jump_flags)
```



Discussion:
I'd like to claim this issue. Planning my Hacktoberfest early 🎃 
Assigned!
@ericmjl 

I've mulled this over in my head today and have a few open-ended points to bring up as well as a few clarifying questions

## Open-Ended Points (need some guidance here)

1. I originally thought that using "percent change" rather than "difference" for thresholding would be a better implementation.
    - Using a percent change paradigm for flagging allows users to define a universal unit that applies in context to each column. Take the below example:

    ```python
    In [1]: import pandas as pd

    In [2]: df = pd.DataFrame(
       ...:     {'a': [0.75, 2.25, 2.5], 'b': [2, 5, 8], 'c': [300, 303, 306]}
       ...: )

    In [3]: df
    Out[3]:
          a  b    c
    0  0.75  2  300
    1  2.25  5  303
    2  2.50  8  306

    In [4]: df.diff()
    Out[4]:
          a    b    c
    0   NaN  NaN  NaN
    1  1.50  3.0  3.0
    2  0.25  3.0  3.0

    In [5]: df.diff().gt(2)
    Out[5]:
           a      b      c
    0  False  False  False
    1  False   True   True
    2  False   True   True
    ```
    - In the above example, an arbitrary threshold (`2`) is used that applies to all columns in the dataframe. This may be appropriate for the first and second columns (`'a'` and `'b'`) but may not be applicable to the third column `'c'`. A difference threshold of `2` in the context of numbers that range from `[0.75, 2.5]` or `[2, 8]` may capture a large change, while a threshold of `2` in the context of numbers that range from `[300, 306]` may not hold a lot of weight.
    - By using a percent change threshold, we may be able to capture large variations **in the context** of each column:

    ```python
    In [6]: df
    Out[6]:
          a  b    c
    0  0.75  2  300
    1  2.25  5  303
    2  2.50  8  306

    In [7]: df.pct_change()
    Out[7]:
              a    b         c
    0       NaN  NaN       NaN
    1  2.000000  1.5  0.010000
    2  0.111111  0.6  0.009901

    In [8]: df.pct_change().gt(1)
    Out[8]:
           a      b      c
    0  False  False  False
    1   True   True  False
    2  False  False  False
    ```
    - By using a percent change threshold, we can see that column `'a'` doubled in size from row `0` to row `1` and that column `'b'` increases by a unit of `1.5`. We also see that column `'c'` isn't changing very much in the context of its original values.
    - I am thinking that setting a threshold of `2` and applying it with a difference method may *incorrectly* flag column `'c'` as changing or jumping beyond a tolerance (i.e. column `'c'` changing `0.1%` or `0.99%` is much smaller than changing `200%` or `150%` in the cases of columns `'a'` and `'b'`).

    - **Where this breaksdown** is when negative values are introduced:

    ```python
    In [1]: df = pd.DataFrame(
        ...:     {'a': [1, 2, 4], 'b': [-1, -2, -4], 'c': [-1, 2, 0]}
        ...: )

    In [2]: df
    Out[2]:
       a  b  c
    0  1 -1 -1
    1  2 -2  2
    2  4 -4  0

    In [3]: df.pct_change()
    Out[3]:
         a    b    c
    0  NaN  NaN  NaN
    1  1.0  1.0 -3.0
    2  1.0  1.0 -1.0
   ```
    - In the above, notice that column `'b'` and column `'c'` percentage change are a bit counter-intuitive.
    - Column `'b'` is *decreasing* from `-1` to `-2` to `-4`, but has a percentage change of `1.0` and `1.0`.
    - Similarly, `'c'` is *increasing* from `-1` to `2` and then *decreasing* from `2` to `0`. However, the percentage of change is `-3.0` and `-1.0` respectively.
    - This doesn't present an issue if we treat the percentage change as an absolute value (i.e. flag where the values are *either* increasing or decreasing beyond a certain percentage), but it leads me to where I was *eventually going to go* before encountering this hurdle (# 2 below is where I was heading).


2. Do we want to allow for multiple "condition methods" when applying this function:
    - Suppose one user wants to see the flags when anything in a dataframe is beyond a certain threshold **in either direction**.
    - Suppose another user wants to see flags when anything in a dataframe is **strictly above** a certain threshold.
    - Suppose another user wants to see flags when anything in a dataframe is **strictly below** a certain threshold.
    - I think the above scenarios pertain to (1) seeing any type of variation (in the positive *or* negative direction) (2) seeing only *increasing* variations (strictly positive and over a threshold) (3) seeing only *decreasing* variations (strictly negative and over a threshold)
    - This presents an interesting problem if we go with the percentage change route. Consider this example:

    ```python
    In [1]: df = pd.DataFrame(
        ...:     {'a': [1, 2, 4], 'b': [-1, -2, -4], 'c': [-1, 2, 0]}
        ...: )

    In [2]: df
    Out[2]:
       a  b  c
    0  1 -1 -1
    1  2 -2  2
    2  4 -4  0

    In [3]: df.pct_change()
    Out[3]:
         a    b    c
    0  NaN  NaN  NaN
    1  1.0  1.0 -3.0
    2  1.0  1.0 -1.0
   ```

    - If we wanted to flag all values where a percentage of change is greater than `50%` *in either direction*, we can use `.abs()`:

    ```python
    In [38]: df.pct_change().abs().gt(0.5)
    Out[38]:
           a      b      c
    0  False  False  False
    1   True   True   True
    2   True   True   True
    ```
    - If we wanted to flag all values that were **strictly increasing**, we wouldn't be able to accuratley represent this with a `.gt()`:

    ```python
    In [1]: df = pd.DataFrame(
        ...:     {'a': [1, 2, 4], 'b': [-1, -2, -4], 'c': [-1, 2, 0]}
        ...: )

    In [2]: df
    Out[2]:
       a  b  c
    0  1 -1 -1
    1  2 -2  2
    2  4 -4  0

    In [3]: df.pct_change().gt(0.5)
    Out[3]:
           a      b      c
    0  False  False  False
    1   True   True  False
    2   True   True  False
    ```
    - The above shows that column `'b'` would be flagged as increasing when in reality it is increasing in the negative direction (i.e. truly decreasing).

    - This leads me to think, hmmm maybe an arbitrary threshold would work then... In the scenario above, a `.diff()` method would yield the difference in the true positive and negative direction:

    ```python
    In [1]: df
    Out[1]:
       a  b  c
    0  1 -1 -1
    1  2 -2  2
    2  4 -4  0

    In [2]: df.diff()
    Out[2]:
         a    b    c
    0  NaN  NaN  NaN
    1  1.0 -1.0  3.0
    2  2.0 -2.0 -2.0
    ```
    - Here, we can capture increases and decreases and flag accordingly:

    ```python
    In [3]: df
    Out[3]:
       a  b  c
    0  1 -1 -1
    1  2 -2  2
    2  4 -4  0

    In [4]: df.diff()
    Out[4]:
         a    b    c
    0  NaN  NaN  NaN
    1  1.0 -1.0  3.0
    2  2.0 -2.0 -2.0

    In [5]: # Either increasing or decreasing beyond a threshold
        ...: df.diff().abs().gt(1.5)
    Out[5]:
           a      b      c
    0  False  False  False
    1  False  False   True
    2   True   True   True

    In [6]: # Strictly increasing beyond a threshold
        ...: df.diff().gt(1.5)
    Out[6]:
           a      b      c
    0  False  False  False
    1  False  False   True
    2   True  False  False

    In [7]: # Strictly decreasing beyond a threshold
        ...: df.diff().lt(0) & df.diff().abs().gt(1.5)
    Out[7]:
           a      b      c
    0  False  False  False
    1  False  False  False
    2  False   True   True
    ```
    - If we do stay with the arbitrary threshold approach (not the percentage change), I'd suggest a way to allow the user to declare a *universal* threshold as well as a way to declare a threshold per column. This kind of solves the idea of having a dataframe that spans multiple decades across columns (the `2` vs `[300,306]` discussion above).
    - At this point, I'm at a loss. I like the idea of the percentage change, but can see how trying to flag for specific scenarios would present issues. I like the ease of filtering with the `.diff()` approach, but think we need a way for uses to declare thresholds per column (if they so desire).

## Clarifying Questions
- What is the ideal output for this function?
    - Are we wanting to return a boolean dataframe?
    - Are we wanting to return a *filtered* dataframe based on a condition?
- I'm trying to understand how this would be used:

    ```python
    In [1]: df = pd.DataFrame(
        ...:     {'a': [0.75, 2.25, 2.5], 'b': [2, 5, 8], 'c': [300, 303, 306]}
        ...: )

    In [2]: df
    Out[3]:
          a  b    c
    0  0.75  2  300
    1  2.25  5  303
    2  2.50  8  306

    In [3]: df.diff()
    Out[3]:
          a    b    c
    0   NaN  NaN  NaN
    1  1.50  3.0  3.0
    2  0.25  3.0  3.0

    In [4]: flags = df.diff().gt(2)

    In [5]: flags
    Out[5]:
           a      b      c
    0  False  False  False
    1  False   True   True
    2  False   True   True
   ```

    - Is the intended use here to show the entire dataframe with the flags filter?
    
    ```python
    In [6]: df[flags]
    Out[6]:
        a    b      c
    0 NaN  NaN    NaN
    1 NaN  5.0  303.0
    2 NaN  8.0  306.0
    ```

    - Or, to use a specific column within flags to filter the original dataframe?

    ```python
    In [7]: df[flags['b']]
    Out[7]:
          a  b    c
    1  2.25  5  303
    2  2.50  8  306
   ```

    - I guess my question here is what "cleaning" process will this do? If we return the entire boolean **dataframe** the user can manipulate it at will and can do the filtering based on the column within `flags` that they choose.
    - If this is really to be applied for a single column, we could handle that as a columnar function. This would filter the dataframe based on a *single* column condition provided by the user.


Sorry for the long comment 😊 , but any input you have would be really helpful!

Thanks!


Resolving patch:
diff --git a/janitor/functions.py b/janitor/functions.py
--- a/janitor/functions.py
+++ b/janitor/functions.py
@@ -3183,7 +3183,9 @@ def _find_replace(
         regular-expression-based fuzzy match will be used for finding patterns.
         Default to "exact". Can only be "exact" or "regex".
     :returns: A pandas DataFrame.
-    :raises: ValueError
+    :raises ValueError: is trying to use null replacement. Kindly use
+        ``.fillna()`` instead.
+    :raises ValueError: if ``match`` is not one of 'exact' or 'regex'.
     """
     if any(map(pd.isna, mapper.keys())):
         raise ValueError(
@@ -3247,10 +3249,12 @@ def update_where(
         not get set in the new column will be null.
     :param target_val: Value to be updated
     :returns: An updated pandas DataFrame.
-    :raises: IndexError if ``conditions`` does not have the same length as
+    :raises IndexError: if ``conditions`` does not have the same length as
         ``df``.
-    :raises: TypeError if ``conditions`` is not a pandas-compatible string
+    :raises TypeError: if ``conditions`` is not a pandas-compatible string
         query.
+
+    .. # noqa: DAR402
     """
 
     # use query mode if a string expression is passed
@@ -3657,29 +3661,29 @@ def flag_nulls(
         import pandas as pd
         import janitor as jn
 
-        data = pd.DataFrame(
+        df = pd.DataFrame(
             {'a': [1, 2, None, 4],
              'b': [5.0, None, 7.0, 8.0]})
 
         df.flag_nulls()
         #  'a' | 'b'  | 'null_flag'
         #   1  | 5.0  |   0
-        #   2  | None |   1
-        # None | 7.0  |   1
+        #   2  | NaN  |   1
+        #  NaN | 7.0  |   1
         #   4  | 8.0  |   0
 
-        jn.functions.flag_nulls(data)
+        jn.functions.flag_nulls(df)
         #  'a' | 'b'  | 'null_flag'
         #   1  | 5.0  |   0
-        #   2  | None |   1
-        # None | 7.0  |   1
+        #   2  | NaN  |   1
+        #  NaN | 7.0  |   1
         #   4  | 8.0  |   0
 
         df.flag_nulls(columns=['b'])
         #  'a' | 'b'  | 'null_flag'
         #   1  | 5.0  |   0
-        #   2  | None |   1
-        # None | 7.0  |   0
+        #   2  | NaN  |   1
+        #  NaN | 7.0  |   0
         #   4  | 8.0  |   0
 
 
@@ -3689,7 +3693,12 @@ def flag_nulls(
         only want to look at one column, you can simply give its name. If set
         to None (default), all DataFrame columns are used.
     :returns: Input dataframe with the null flag column.
-    :raises: ValueError
+    :raises ValueError: if ``column_name`` is already present in the
+        DataFrame.
+    :raises ValueError: if a column within ``columns`` is no present in
+        the DataFrame.
+
+    .. # noqa: DAR402
     """
     # Sort out columns input
     if isinstance(columns, str):
@@ -4902,20 +4911,22 @@ def pivot_longer(
         were previously the values of the columns in `column_names`.
     :returns: A pandas DataFrame that has been unpivoted from wide to long
         format.
-    :raises: TypeError if `index` or `column_names` is not a string, or a
+    :raises TypeError: if `index` or `column_names` is not a string, or a
         list/tuple of strings, or a `janitor.patterns` function.
-    :raises: TypeError if `names_to` or `column_names` is not a string, or a
+    :raises TypeError: if `names_to` or `column_names` is not a string, or a
         list/tuple of strings.
-    :raises: TypeError if `values_to` is not a string.
-    :raises: ValueError if `names_to` is a list/tuple, and both `names_sep` and
+    :raises TypeError: if `values_to` is not a string.
+    :raises ValueError: if `names_to` is a list/tuple, and both `names_sep` and
         `names_pattern` are provided.
-    :raises: ValueError if `names_to` is a string or a list/tuple of length 1,
+    :raises ValueError: if `names_to` is a string or a list/tuple of length 1,
         and `names_sep` is provided.
-    :raises: TypeError if `names_sep` or `names_pattern` is not a string or
+    :raises TypeError: if `names_sep` or `names_pattern` is not a string or
         regular expression.
-    :raises: ValueError if `names_to` is a list/tuple, and its length does not
+    :raises ValueError: if `names_to` is a list/tuple, and its length does not
         match the number of extracted columns.
-    :raises: Warning if `df` is a MultiIndex dataframe.
+    :raises Warning: if `df` is a MultiIndex dataframe.
+
+    .. # noqa: DAR402
     """
 
     # this code builds on the wonderful work of @benjaminjack’s PR
diff --git a/janitor/timeseries.py b/janitor/timeseries.py
--- a/janitor/timeseries.py
+++ b/janitor/timeseries.py
@@ -1,9 +1,13 @@
 """ Time series-specific data cleaning functions. """
 
+import itertools
+from typing import Union, Dict
+
 import pandas as pd
 import pandas_flavor as pf
 
 from janitor import check
+from .errors import JanitorError
 
 
 @pf.register_dataframe_method
@@ -176,3 +180,273 @@ def sort_timestamps_monotonically(
 
     # Return the dataframe
     return df
+
+
+def _flag_jumps_single_col(
+    df: pd.DataFrame,
+    col: str,
+    scale: str,
+    direction: str,
+    threshold: Union[int, float],
+) -> pd.Series:
+    """
+    Creates a boolean column that flags whether or not the change
+    between consecutive rows in the provided dataframe column exceeds a
+    provided threshold.
+
+    Comparisons are always performed utilizing a GREATER THAN
+    threshold check. Thus, flags correspond to values that EXCEED
+    the provided threshold.
+
+    The method used to create consecutive row comparisons is set by the
+    `scale` argument. A `scale=absolute` corresponds to a difference
+    method (`.diff()`) and a `scale=percentage` corresponds to a
+    percentage change methods (`pct_change()`).
+
+    A `direction` argument is used to determine how to handle the sign
+    of the difference or percentage change methods.
+    A `direction=increasing` will only consider consecutive rows that
+    are increasing in value and exceeding the provided threshold.
+    A `direction=decreasing` will only consider consecutive rows that
+    are decreasing in value and exceeding the provided threshold.
+    If `direction=any`, the absolute value is taken for both the
+    difference method and the percentage change methods and the sign
+    between consecutive rows is ignored.
+    """
+    check("scale", scale, [str])
+    check("direction", direction, [str])
+    check("threshold", threshold, [int, float])
+
+    scale_types = ["absolute", "percentage"]
+    if scale not in scale_types:
+        raise JanitorError(
+            f"Unrecognized scale: '{scale}'. Must be one of: {scale_types}."
+        )
+
+    direction_types = ["increasing", "decreasing", "any"]
+    if direction not in direction_types:
+        raise JanitorError(
+            f"Unrecognized direction: '{direction}'. "
+            + f"Must be one of: {direction_types}."
+        )
+
+    if threshold < 0:
+        raise JanitorError(
+            f"Unrecognized threshold: {threshold}. "
+            + "This value must be >= 0.0. "
+            + "Use 'direction' to specify positive or negative intent."
+        )
+
+    single_col = df[col]
+    single_col_diffs = single_col.diff()
+
+    if scale == "percentage":
+        single_col_pcts = single_col.pct_change()
+
+        if direction == "increasing":
+            # Using diffs ensures correct sign is used for incr/decr
+            # (see issue #711)
+            out = (single_col_diffs > 0) & (single_col_pcts.abs() > threshold)
+
+        elif direction == "decreasing":
+            # Using diffs ensures correct sign is used for incr/decr
+            # (see issue #711)
+            out = (single_col_diffs < 0) & (single_col_pcts.abs() > threshold)
+
+        else:
+            out = single_col_pcts.abs() > threshold
+
+    else:
+        if direction == "increasing":
+            out = single_col_diffs > threshold
+
+        elif direction == "decreasing":
+            out = (single_col_diffs < 0) & (single_col_diffs.abs() > threshold)
+
+        else:
+            out = single_col_diffs.abs() > threshold
+
+    out = out.astype(int)
+
+    return out
+
+
+@pf.register_dataframe_method
+def flag_jumps(
+    df: pd.DataFrame,
+    scale: Union[str, Dict[str, str]] = "percentage",
+    direction: Union[str, Dict[str, str]] = "any",
+    threshold: Union[int, float, Dict[str, Union[int, float]]] = 0.0,
+    strict: bool = False,
+) -> pd.DataFrame:
+    """
+    Create boolean column(s) that flag whether or not the change
+    between consecutive rows exceeds a provided threshold.
+
+    Functional usage example:
+
+    .. code-block:: python
+
+        import pandas as pd
+        import janitor.timeseries
+
+        df = pd.DataFrame(...)
+
+        df = flag_jumps(
+            df=df,
+            scale="absolute",
+            direction="any",
+            threshold=2,
+        )
+
+    Method chaining example:
+
+    .. code-block:: python
+
+        import pandas as pd
+        import janitor.timeseries
+
+        df = (
+            pd.DatFrame(...)
+            .flag_jumps(
+                scale="absolute",
+                direction="any",
+                threshold=2,
+            )
+        )
+
+    Detailed chaining examples:
+
+    .. code-block:: python
+
+        # Applies specified criteria across all columns of the dataframe
+        # Appends a flag column for each column in the dataframe
+        df = (
+            pd.DataFrame(...)
+            .flag_jumps(
+                scale="absolute",
+                direction="any",
+                threshold=2
+            )
+        )
+
+        # Applies specific criteria to certain dataframe columns
+        # Applies default criteria to columns not specifically listed
+        # Appends a flag column for each column in the dataframe
+        df = (
+            pd.DataFrame(...)
+            .flag_jumps(
+                scale=dict(col1="absolute", col2="percentage"),
+                direction=dict(col1="increasing", col2="any"),
+                threshold=dict(col1=1, col2=0.5),
+            )
+        )
+
+        # Applies specific criteria to certain dataframe columns
+        # Applies default criteria to columns not specifically listed
+        # Appends a flag column for each column in the dataframe
+        df = (
+            pd.DataFrame(...)
+            .flag_jumps(
+                scale=dict(col1="absolute"),
+                direction=dict(col2="increasing"),
+            )
+        )
+
+        # Applies specific criteria to certain dataframe columns
+        # Applies default criteria to columns not specifically listed
+        # Appends a flag column for only those columns found in
+        #   specified criteria
+        df = (
+            pd.DataFrame(...)
+            .flag_jumps(
+                scale=dict(col1="absolute"),
+                threshold=dict(col2=1),
+                strict=True,
+            )
+        )
+
+    :param df: Dataframe which needs to be flagged for changes between
+        consecutive rows above a certain threshold.
+    :param scale: Type of scaling approach to use.
+        Acceptable arguments are:
+            1. absolute (consider the difference between rows).
+            2. percentage (consider the percentage change between rows).
+        Defaults to percentage.
+    :param direction: Type of method used to handle the sign change when
+        comparing consecutive rows.
+        Acceptable arguments are:
+            1. increasing (only consider rows that are increasing in value).
+            2. decreasing (only consider rows that are decreasing in value).
+            3. any (consider rows that are either increasing or decreasing;
+                sign is ignored).
+        Defaults to any.
+    :param threshold: The value to check if consecutive row comparisons
+        exceed. Always uses a greater than comparison. Must be >= 0.0.
+        Defaults to 0.0
+    :param strict: flag to enable/disable appending of a flag column for
+        each column in the provided dataframe.
+        If set to True, will only append a flag column for those columns
+            found in at least one of the input dictionaries.
+        If set to False, will append a flag column for each column found
+             in the provided dataframe. If criteria is not specified,
+             the defaults for each criteria is used.
+        Defaults to False.
+    :returns: Dataframe that has flag jump columns.
+    :raises JanitorError: if ``strict=True`` and at least one of
+        ``scale``, ``direction``, or ``threshold`` inputs is not a
+        dictionary.
+    :raises JanitorError: if ``scale`` is not one of
+        ``["absolute", "percentage"]``.
+    :raises JanitorError: if ``direction`` is not one of
+        ``["increasing", "decreasing", "any"]``.
+    :raises JanitorError: if ``threshold`` is less than 0.0.
+    """
+    df = df.copy()
+
+    if strict:
+        if (
+            any(isinstance(arg, dict) for arg in (scale, direction, threshold))
+            is False
+        ):
+            raise JanitorError(
+                "When enacting 'strict=True', 'scale', 'direction', or "
+                + "'threshold' must be a dictionary."
+            )
+
+        # Only append a flag col for the cols that appear
+        # in at least one of the input dicts
+        arg_keys = [
+            arg.keys()
+            for arg in (scale, direction, threshold)
+            if isinstance(arg, dict)
+        ]
+        cols = set(itertools.chain.from_iterable(arg_keys))
+
+    else:
+        # Append a flag col for each col in the dataframe
+        cols = df.columns
+
+    columns_to_add = {}
+    for col in sorted(cols):
+
+        # Allow arguments to be a mix of dict and single instances
+        s = scale.get(col, "percentage") if isinstance(scale, dict) else scale
+        d = (
+            direction.get(col, "any")
+            if isinstance(direction, dict)
+            else direction
+        )
+        t = (
+            threshold.get(col, 0.0)
+            if isinstance(threshold, dict)
+            else threshold
+        )
+
+        columns_to_add[f"{col}_jump_flag"] = _flag_jumps_single_col(
+            df, col, scale=s, direction=d, threshold=t
+        )
+
+    df = df.assign(**columns_to_add)
+
+    return df


Test patch:
diff --git a/tests/timeseries/test_flag_jumps.py b/tests/timeseries/test_flag_jumps.py
new file mode 100644
--- /dev/null
+++ b/tests/timeseries/test_flag_jumps.py
@@ -0,0 +1,628 @@
+"""
+Unit tests for `.flag_jumps()`.
+"""
+
+import pandas as pd
+import numpy as np
+import pytest
+
+import janitor.timeseries  # noqa: F401
+from janitor.timeseries import _flag_jumps_single_col
+from janitor.errors import JanitorError
+
+
+@pytest.fixture
+def timeseries_dataframe() -> pd.DataFrame:
+    """Returns a time series dataframe."""
+    ts_index = pd.date_range("1/1/2019", periods=10, freq="1H")
+    c1 = [*range(10)]
+    c2 = [*range(100, 110)]
+    c3 = c1[::-1]
+    c4 = c2[::-1]
+    c5 = [-2.0, -1.0, 0, 1.0, 2.0, 1.0, 0, -1.0, -2.0, -7.5]
+    test_df = pd.DataFrame(
+        {"col1": c1, "col2": c2, "col3": c3, "col4": c4, "col5": c5},
+        index=ts_index,
+    )
+    return test_df
+
+
+@pytest.mark.timeseries
+def test__flag_jumps_single_col_raises_error_for_bad_scale_type(
+    timeseries_dataframe
+):
+    """Test that invalid scale argument raises a JanitorError."""
+    # Setup
+    df = timeseries_dataframe
+    expected_error_msg = (
+        "Unrecognized scale: 'bad_scale'. "
+        + "Must be one of: ['absolute', 'percentage']."
+    )
+
+    # Exercise
+    with pytest.raises(JanitorError) as error_info:
+        _flag_jumps_single_col(
+            df, col="col1", scale="bad_scale", direction="any", threshold=1
+        )
+
+    # Verify
+    assert str(error_info.value) == expected_error_msg
+
+    # Cleanup - none necessary
+
+
+@pytest.mark.timeseries
+def test__flag_jumps_single_col_raises_error_for_bad_direction_type(
+    timeseries_dataframe
+):
+    """Test that invalid direction argument raises a JanitorError."""
+    # Setup
+    df = timeseries_dataframe
+    expected_error_msg = (
+        "Unrecognized direction: 'bad_direction'. "
+        + "Must be one of: ['increasing', 'decreasing', 'any']."
+    )
+
+    # Exercise
+    with pytest.raises(JanitorError) as error_info:
+        _flag_jumps_single_col(
+            df,
+            col="col1",
+            scale="absolute",
+            direction="bad_direction",
+            threshold=1,
+        )
+
+    # Verify
+    assert str(error_info.value) == expected_error_msg
+
+    # Cleanup - none necessary
+
+
+@pytest.mark.timeseries
+def test__flag_jumps_single_col_raises_error_for_bad_threshold_value(
+    timeseries_dataframe
+):
+    """Test that invalid threshold argument raises a JanitorError."""
+    # Setup
+    df = timeseries_dataframe
+    expected_error_msg = (
+        "Unrecognized threshold: -1. This value must be >= 0.0. "
+        + "Use 'direction' to specify positive or negative intent."
+    )
+
+    # Exercise
+    with pytest.raises(JanitorError) as error_info:
+        _flag_jumps_single_col(
+            df, col="col1", scale="absolute", direction="any", threshold=-1
+        )
+
+    # Verify
+    assert str(error_info.value) == expected_error_msg
+
+    # Cleanup - none necessary
+
+
+@pytest.mark.timeseries
+@pytest.mark.parametrize(
+    "col, direction, expected",
+    [
+        ("col1", "increasing", [0, 1, 1, 1, 1, 1, 1, 1, 1, 1]),
+        ("col2", "increasing", [0, 1, 1, 1, 1, 1, 1, 1, 1, 1]),
+        ("col3", "decreasing", [0, 1, 1, 1, 1, 1, 1, 1, 1, 1]),
+        ("col4", "decreasing", [0, 1, 1, 1, 1, 1, 1, 1, 1, 1]),
+        ("col5", "increasing", [0, 1, 1, 1, 1, 0, 0, 0, 0, 0]),
+        ("col5", "decreasing", [0, 0, 0, 0, 0, 1, 1, 1, 1, 1]),
+    ],
+)
+def test__flag_jumps_single_col_absolute_scale_correct_direction(
+    timeseries_dataframe, col, direction, expected
+):
+    """
+    Test utility function for flagging jumps with absolute scale.
+    Here, the correct/anticipated `direction` is provided
+        (i.e. increasing when the df column is truly increasing
+        and decreasing when the df column is truly decreasing)
+    """
+    # Setup
+    df = timeseries_dataframe
+
+    # Exercise
+    result = _flag_jumps_single_col(
+        df, col, scale="absolute", direction=direction, threshold=0.5
+    )
+
+    # Verify
+    np.testing.assert_array_equal(result.array, expected)
+
+    # Cleanup - none necessary
+
+
+@pytest.mark.timeseries
+@pytest.mark.parametrize(
+    "col, direction, expected",
+    [
+        ("col1", "decreasing", [0] * 10),
+        ("col2", "decreasing", [0] * 10),
+        ("col3", "increasing", [0] * 10),
+        ("col4", "increasing", [0] * 10),
+        ("col5", "decreasing", [0, 0, 0, 0, 0, 1, 1, 1, 1, 1]),
+        ("col5", "increasing", [0, 1, 1, 1, 1, 0, 0, 0, 0, 0]),
+    ],
+)
+def test__flag_jumps_single_col_absolute_scale_inverse_direction(
+    timeseries_dataframe, col, direction, expected
+):
+    """
+    Test utility function for flagging jumps with absolute scale.
+    Here, the inverse `direction` is provided so should not flag
+    anything (i.e. increasing when the df column is truly decreasing
+    and increasing when the df column is truly increasing)
+    """
+    # Setup
+    df = timeseries_dataframe
+
+    # Exercise
+    result = _flag_jumps_single_col(
+        df, col, scale="absolute", direction=direction, threshold=0.5
+    )
+
+    # Verify
+    np.testing.assert_array_equal(result.array, expected)
+
+    # Cleanup - none necessary
+
+
+@pytest.mark.timeseries
+@pytest.mark.parametrize("col", ("col1", "col2", "col3", "col4", "col5"))
+def test__flag_jumps_single_col_absolute_scale_any_direction(
+    timeseries_dataframe, col
+):
+    """
+    Test utility function for flagging jumps with absolute scale.
+    Here, the any `direction` is provided so should flag everything.
+    """
+    # Setup
+    df = timeseries_dataframe
+
+    # Exercise
+    result = _flag_jumps_single_col(
+        df, col, scale="absolute", direction="any", threshold=0.5
+    )
+
+    # Verify
+    np.testing.assert_array_equal(result.array, [0, 1, 1, 1, 1, 1, 1, 1, 1, 1])
+
+    # Cleanup - none necessary
+
+
+@pytest.mark.timeseries
+def test__flag_jumps_single_col_absolute_scale_flags_large_jump(
+    timeseries_dataframe
+):
+    """
+    Test utility function for flagging jumps with absolute scale.
+    Here, a large threshold is used to verify only one row is flagged.
+    """
+
+    # Setup
+    df = timeseries_dataframe
+
+    # Exercise
+    result_incr = _flag_jumps_single_col(
+        df, "col5", scale="absolute", direction="increasing", threshold=5
+    )
+    result_decr = _flag_jumps_single_col(
+        df, "col5", scale="absolute", direction="decreasing", threshold=5
+    )
+    result_any = _flag_jumps_single_col(
+        df, "col5", scale="absolute", direction="any", threshold=5
+    )
+
+    # Verify
+    np.testing.assert_array_equal(result_incr.array, [0] * 10)
+    np.testing.assert_array_equal(
+        result_decr.array, [0, 0, 0, 0, 0, 0, 0, 0, 0, 1]
+    )
+    np.testing.assert_array_equal(
+        result_any.array, [0, 0, 0, 0, 0, 0, 0, 0, 0, 1]
+    )
+
+    # Cleanup - none necessary
+
+
+@pytest.mark.timeseries
+@pytest.mark.parametrize(
+    "col, direction, expected",
+    [
+        ("col1", "increasing", [0, 1, 1, 1, 1, 0, 0, 0, 0, 0]),
+        ("col2", "increasing", [0] * 10),
+        ("col3", "decreasing", [0, 0, 0, 0, 0, 0, 0, 1, 1, 1]),
+        ("col4", "decreasing", [0] * 10),
+        ("col5", "increasing", [0, 1, 1, 1, 1, 0, 0, 0, 0, 0]),
+        ("col5", "decreasing", [0, 0, 0, 0, 0, 1, 1, 1, 1, 1]),
+    ],
+)
+def test__flag_jumps_single_col_percentage_scale_correct_direction(
+    timeseries_dataframe, col, direction, expected
+):
+    """
+    Test utility function for flagging jumps with percentage scale and
+    a 25% jump. Here, the correct/anticipated `direction` is provided
+    (i.e. increasing when the df column is truly increasing and
+    decreasing when the df column is truly decreasing).
+    """
+    # Setup
+    df = timeseries_dataframe
+
+    # Exercise
+    result = _flag_jumps_single_col(
+        df, col, scale="percentage", direction=direction, threshold=0.25
+    )
+
+    # Verify
+    np.testing.assert_array_equal(result.array, expected)
+
+    # Cleanup - none necessary
+
+
+@pytest.mark.timeseries
+@pytest.mark.parametrize(
+    "col, direction, expected",
+    [
+        ("col1", "decreasing", [0] * 10),
+        ("col2", "decreasing", [0] * 10),
+        ("col3", "increasing", [0] * 10),
+        ("col4", "increasing", [0] * 10),
+        ("col5", "decreasing", [0, 0, 0, 0, 0, 1, 1, 1, 1, 1]),
+        ("col5", "increasing", [0, 1, 1, 1, 1, 0, 0, 0, 0, 0]),
+    ],
+)
+def test__flag_jumps_single_col_percentage_scale_inverse_direction(
+    timeseries_dataframe, col, direction, expected
+):
+    """
+    Test utility function for flagging jumps with percentage scale and
+    a 25% jump. Here, the inverse `direction` is provided so should not
+    flag anything (i.e. increasing when the df column is truly
+    decreasing and increasing when the df column is truly increasing).
+    """
+    # Setup
+    df = timeseries_dataframe
+
+    # Exercise
+    result = _flag_jumps_single_col(
+        df, col, scale="percentage", direction=direction, threshold=0.25
+    )
+
+    # Verify
+    np.testing.assert_array_equal(result.array, expected)
+
+    # Cleanup - none necessary
+
+
+@pytest.mark.timeseries
+@pytest.mark.parametrize(
+    "col, expected",
+    [
+        ("col1", [0, 1, 1, 1, 1, 1, 1, 1, 1, 1]),
+        ("col2", [0] * 10),
+        ("col3", [0, 1, 1, 1, 1, 1, 1, 1, 1, 1]),
+        ("col4", [0] * 10),
+        ("col5", [0, 1, 1, 1, 1, 1, 1, 1, 1, 1]),
+    ],
+)
+def test__flag_jumps_single_col_percentage_scale_any_direction(
+    timeseries_dataframe, col, expected
+):
+    """
+    Test utility function for flagging jumps with percentage scale and
+    a 10% jump. Here, the any direction is provided so should flag
+    everything.
+    """
+    # Setup
+    df = timeseries_dataframe
+
+    # Exercise
+    result = _flag_jumps_single_col(
+        df, col, scale="percentage", direction="any", threshold=0.10
+    )
+
+    # Verify
+    np.testing.assert_array_equal(result.array, expected)
+
+    # Cleanup - none necessary
+
+
+@pytest.mark.timeseries
+def test__flag_jumps_single_col_percentage_scale_flags_large_jump(
+    timeseries_dataframe
+):
+    """
+    Test utility function for flagging jumps with percentage scale and
+    a 100% jump. Here, a large threshold is used to verify only
+    drastically changed rows are flagged.
+    """
+    # Setup
+    df = timeseries_dataframe
+
+    # Exercise
+    result_incr = _flag_jumps_single_col(
+        df, "col5", scale="percentage", direction="increasing", threshold=1.0
+    )
+    result_decr = _flag_jumps_single_col(
+        df, "col5", scale="percentage", direction="decreasing", threshold=1.0
+    )
+    result_any = _flag_jumps_single_col(
+        df, "col5", scale="percentage", direction="any", threshold=1.0
+    )
+
+    # Verify
+    np.testing.assert_array_equal(
+        result_incr.array, [0, 0, 0, 1, 0, 0, 0, 0, 0, 0]
+    )
+    np.testing.assert_array_equal(
+        result_decr.array, [0, 0, 0, 0, 0, 0, 0, 1, 0, 1]
+    )
+    np.testing.assert_array_equal(
+        result_any.array, [0, 0, 0, 1, 0, 0, 0, 1, 0, 1]
+    )
+
+    # Cleanup - none necessary
+
+
+@pytest.mark.timeseries
+def test_flag_jumps_raises_error_for_strict_no_arg_dicts(timeseries_dataframe):
+    """
+    Test that an error is raised when `strict=True`
+    and no input arguments are of type dict.
+    """
+    # Setup
+    df = timeseries_dataframe
+
+    # Exercise
+    expected_error_msg = (
+        "When enacting 'strict=True', 'scale', 'direction', "
+        + "or 'threshold' must be a dictionary."
+    )
+
+    # Exercise
+    with pytest.raises(JanitorError) as error_info:
+        df.flag_jumps(
+            scale="absolute", direction="any", threshold=0, strict=True
+        )
+
+    # Verify
+    assert str(error_info.value) == expected_error_msg
+
+    # Cleanup - none necessary
+
+
+@pytest.mark.timeseries
+def test_flag_jumps_default_args(timeseries_dataframe):
+    """
+    Test the default values behave as expected.
+    Namely, `scale=percentage`, `direction=any`, `threshold=0.0` and
+    `strict=False`.
+    """
+    # Setup
+    df = timeseries_dataframe
+    orig_cols = df.columns
+
+    expected = np.ones((10, 5), dtype=int)
+    expected[0, :] = 0
+    expected_cols = [f"{c}_jump_flag" for c in orig_cols]
+    expected_df = pd.DataFrame(expected, columns=expected_cols, index=df.index)
+
+    # Exercise
+    df = df.flag_jumps()
+
+    # Verify
+    assert list(df.columns) == list(orig_cols) + expected_cols
+    assert df.filter(regex="flag").equals(expected_df)
+
+    # Cleanup - none necessary
+
+
+@pytest.mark.timeseries
+def test_flag_jumps_all_args_specifed_as_non_dict(timeseries_dataframe):
+    """Test provided kwargs (not of type dict) behave as expected."""
+    # Setup
+    df = timeseries_dataframe
+    orig_cols = df.columns
+
+    expected = np.ones((10, 5), dtype=int)
+    expected[0, :] = 0
+    expected[:, 2:4] = 0
+    expected[5:10, 4] = 0
+    expected_cols = [f"{c}_jump_flag" for c in orig_cols]
+    expected_df = pd.DataFrame(expected, columns=expected_cols, index=df.index)
+
+    # Exercise
+    df = df.flag_jumps(
+        scale="absolute", direction="increasing", threshold=0, strict=False
+    )
+
+    # Verify
+    assert list(df.columns) == list(orig_cols) + expected_cols
+    assert df.filter(regex="flag").equals(expected_df)
+
+    # Cleanup - none necessary
+
+
+@pytest.mark.timeseries
+def test_flag_jumps_all_args_specified_as_dict(timeseries_dataframe):
+    """
+    Test provided kwargs (of type dict) behaves as expected.
+    Since strict defaults to `False`, col3, col4, and col5 will be
+    flagged and will use default args (`scale=percentage`,
+    `direction=any`, and `threshold=0.0`).
+    """
+    df = timeseries_dataframe
+    orig_cols = df.columns
+
+    expected = np.ones((10, 5), dtype=int)
+    expected[0, :] = 0
+    expected[:, 0:2] = 0
+    expected_cols = [f"{c}_jump_flag" for c in orig_cols]
+    expected_df = pd.DataFrame(expected, columns=expected_cols, index=df.index)
+
+    # Exercise
+    df = df.flag_jumps(
+        scale=dict(col1="absolute", col2="percentage"),
+        direction=dict(col1="increasing", col2="any"),
+        threshold=dict(col1=1, col2=2),
+    )
+
+    # Verify
+    assert list(df.columns) == list(orig_cols) + expected_cols
+    assert df.filter(regex="flag").equals(expected_df)
+
+    # Cleanup - none necessary
+
+
+@pytest.mark.timeseries
+def test_flag_jumps_strict_with_both_cols_in_all_args(timeseries_dataframe):
+    """
+    Test provided strict behaves as expected
+    (only col1 and col2 flagged).
+    """
+    df = timeseries_dataframe
+    orig_cols = df.columns
+
+    expected = np.zeros((10, 2), dtype=int)
+    expected_cols = ["col1_jump_flag", "col2_jump_flag"]
+    expected_df = pd.DataFrame(expected, columns=expected_cols, index=df.index)
+
+    # Exercise
+    df = df.flag_jumps(
+        scale=dict(col1="absolute", col2="percentage"),
+        direction=dict(col1="increasing", col2="any"),
+        threshold=dict(col1=1, col2=2),
+        strict=True,
+    )
+
+    # Verify
+    assert list(df.columns) == list(orig_cols) + expected_cols
+    assert df.filter(regex="flag").equals(expected_df)
+
+    # Cleanup - none necessary
+
+
+@pytest.mark.timeseries
+def test_flag_jumps_strict_with_both_cols_in_at_least_one_args(
+    timeseries_dataframe
+):
+    """
+    Test provided strict behaves as expected
+    (col4 not provided in any input arg dict thus not flagged)
+    When left unspecified, a column will be flagged based on defaults
+    (`scale=percentage`, `direction=any`, `threshold=0.0`).
+    """
+    df = timeseries_dataframe
+    orig_cols = df.columns
+
+    expected = np.ones((10, 4), dtype=int)
+    expected[0, :] = 0
+    expected[:, 3] = 0
+    expected[3, 3] = 1
+    expected[7, 3] = 1
+    expected[9, 3] = 1
+    expected_cols = [f"col{i}_jump_flag" for i in [1, 2, 3, 5]]
+    expected_df = pd.DataFrame(expected, columns=expected_cols, index=df.index)
+
+    # Exercise
+    df = df.flag_jumps(
+        scale=dict(col1="absolute", col3="absolute"),
+        direction=dict(col2="increasing"),
+        threshold=dict(col5=2),
+        strict=True,
+    )
+
+    # Verify
+    assert list(df.columns) == list(orig_cols) + expected_cols
+    assert df.filter(regex="flag").equals(expected_df)
+
+    # Cleanup - none necessary
+
+
+@pytest.mark.timeseries
+def test_flag_jumps_for_one_column(timeseries_dataframe):
+    """
+    Test provided strict behaves as expected for a single column.
+    """
+    df = timeseries_dataframe
+    orig_cols = df.columns
+    expected = [0, 1, 1, 1, 1, 1, 1, 1, 1, 1]
+
+    # Exercise
+    df = df.flag_jumps(scale=dict(col1="absolute"), strict=True)
+
+    # Verify
+    assert list(df.columns) == list(orig_cols) + ["col1_jump_flag"]
+    np.testing.assert_array_equal(df["col1_jump_flag"].array, expected)
+
+    # Cleanup - none necessary
+
+
+@pytest.mark.timeseries
+def test_flag_jumps_on_issue_provided_use_case():
+    """
+    Test example provided in issue is solved with `flag_jumps()`
+    See issue # 711
+    """
+    # Setup
+    df = pd.DataFrame(
+        data=[
+            ["2015-01-01 00:00:00", -0.76, 2, 2, 1.2],
+            ["2015-01-01 01:00:00", -0.73, 2, 4, 1.1],
+            ["2015-01-01 02:00:00", -0.71, 2, 4, 1.1],
+            ["2015-01-01 03:00:00", -0.68, 2, 32, 1.1],
+            ["2015-01-01 04:00:00", -0.65, 2, 2, 1.0],
+            ["2015-01-01 05:00:00", -0.76, 2, 2, 1.2],
+            ["2015-01-01 06:00:00", -0.73, 2, 4, 1.1],
+            ["2015-01-01 07:00:00", -0.71, 2, 4, 1.1],
+            ["2015-01-01 08:00:00", -0.68, 2, 32, 1.1],
+            ["2015-01-01 09:00:00", -0.65, 2, 2, 1.0],
+            ["2015-01-01 10:00:00", -0.76, 2, 2, 1.2],
+            ["2015-01-01 11:00:00", -0.73, 2, 4, 1.1],
+            ["2015-01-01 12:00:00", -0.71, 2, 4, 1.1],
+            ["2015-01-01 13:00:00", -0.68, 2, 32, 1.1],
+            ["2015-01-01 14:00:00", -0.65, 2, 2, 1.0],
+            ["2015-01-01 15:00:00", -0.76, 2, 2, 1.2],
+            ["2015-01-01 16:00:00", -0.73, 2, 4, 1.1],
+            ["2015-01-01 17:00:00", -0.71, 2, 4, 1.1],
+            ["2015-01-01 18:00:00", -0.68, 2, 32, 1.1],
+            ["2015-01-01 19:00:00", -0.65, 2, 2, 1.0],
+            ["2015-01-01 20:00:00", -0.76, 2, 2, 1.2],
+            ["2015-01-01 21:00:00", -0.73, 2, 4, 1.1],
+            ["2015-01-01 22:00:00", -0.71, 2, 4, 1.1],
+            ["2015-01-01 23:00:00", -0.68, 2, 32, 1.1],
+            ["2015-01-02 00:00:00", -0.65, 2, 2, 1.0],
+        ],
+        columns=["DateTime", "column1", "column2", "column3", "column4"],
+    )
+
+    df["DateTime"] = pd.to_datetime(df["DateTime"])
+    df = df.set_index("DateTime")
+    orig_cols = df.columns
+
+    expected = np.zeros((25, 4), dtype=int)
+    expected[3, 2] = 1
+    expected[8, 2] = 1
+    expected[13, 2] = 1
+    expected[18, 2] = 1
+    expected[23, 2] = 1
+    expected_cols = [f"{c}_jump_flag" for c in orig_cols]
+    expected_df = pd.DataFrame(expected, columns=expected_cols, index=df.index)
+
+    # Exercise
+    result = df.flag_jumps(
+        scale="absolute", direction="increasing", threshold=2
+    )
+
+    # Verify
+    assert list(result.columns) == list(orig_cols) + expected_cols
+    assert result.filter(regex="flag").equals(expected_df)
+
+    # Cleanup - none necessary