Repository: alteryx/woodwork (python)

Issue:
Box plot dictionary has misleading values for high and low bounds in some cases. 
As a Woodwork user, I want to be able to use the output of `series.ww.box_plot_dict` to build a box plot that makes sense. 

Currently, Woodwork's use of IQR to determine the high and low outlier bounds can end up in situations where the high or low bound can be above or below the max or min values of the series. If a box plot is then built using these bounds, it's misleading, as it points to the range of the data being wider than it actually is. 

An example using the following quantiles: `{0.0: 1 , 0.25: 42, 0.5: 145, 0.75: 284, 1.0: 977}` and our equation for calulating the bounds
```
IQR = q_3 - q_1
low_bound = q_1 - 1.5IQR
high_bound = q_3 + 1.5IQR
```
leaves us with a high bound of 647, which makes sense, but a low bound of -321, which is way below the min value of 1.

We should update our high and low bounds to be

```python
"low_bound": max(low_bound, quantiles['0.0'])
"high_bound": min(high_bound, quantiles['0.75'])
```

Pull request description:
Update box plot bounds to min and max if necessary
- Requires min and max values in quantiles so they can be used to set the high and low bounds when no outliers are present
- Closes #1267 


Patch:
diff --git a/docs/source/release_notes.rst b/docs/source/release_notes.rst
index 4bfcb8c..a33b9f7 100644
--- a/docs/source/release_notes.rst
+++ b/docs/source/release_notes.rst
@@ -9,6 +9,7 @@ Future Release
         * Add Slack link to GitHub issue creation templates (:pr:`1242`)
     * Fixes
         * Fixed issue with tuples being incorrectly inferred as EmailAddress (:pr:`1253`)
+        * Set high and low bounds to the max and min values if no outliers are present in ``box_plot_dict`` (:pr:`1269`)
     * Changes
         * Prevent setting index that contains null values (:pr:`1239`)
         * Allow tuple NaN LatLong values (:pr:`1255`)
@@ -22,7 +23,7 @@ Future Release
         * Fix permissions issue with S3 deserialization test (:pr:`1238`)
 
     Thanks to the following people for contributing to this release:
-    :user:`gsheni`, :user:`jeff-hernandez`, :user:`rwedge`, :user:`dvreed77`, :user:`thehomebrewnerd`
+    :user:`gsheni`, :user:`jeff-hernandez`, :user:`rwedge`, :user:`dvreed77`,:user:`tamargrey`, :user:`thehomebrewnerd`
 
 v0.11.1 Jan 4, 2022
 ===================
diff --git a/woodwork/column_accessor.py b/woodwork/column_accessor.py
index f341bf0..c99184e 100644
--- a/woodwork/column_accessor.py
+++ b/woodwork/column_accessor.py
@@ -406,17 +406,17 @@ class WoodworkColumnAccessor:
                 Defaults to True.
 
         Note:
-            The minimum quantiles necessary for outlier detection using the IQR method are the
-            first quartile (0.25) and third quartile (0.75). If these keys are missing from the quantiles dictionary,
-            the following quantiles will be calculated: {0.0, 0.25, 0.5, 0.75, 1.0}, which correspond to
-            {min, first quantile, median, third quantile, max}.
+            The minimum quantiles necessary for building a box plot using the IQR method are the
+            minimum value (0.0 in the quantiles dict), first quartile (0.25), third quartile (0.75), and maximum value (1.0).
+            If no quantiles are provided, the following quantiles will be calculated:
+            {0.0, 0.25, 0.5, 0.75, 1.0}, which correspond to {min, first quantile, median, third quantile, max}.
 
         Returns:
             (dict[str -> float,list[number]]): Returns a dictionary containing box plot information for the Series.
                 The following elements will be found in the dictionary:
 
-                - low_bound (float): the lower bound below which outliers lay - to be used as a whisker
-                - high_bound (float): the high bound above which outliers lay - to be used as a whisker
+                - low_bound (float): the lowest data point in the dataset excluding any outliers - to be used as a whisker
+                - high_bound (float): the highest point in the dataset excluding any outliers - to be used as a whisker
                 - quantiles (list[float]): the quantiles used to determine the bounds.
                     If quantiles were passed in, will contain all quantiles passed in. Otherwise, contains the five
                     quantiles {0.0, 0.25, 0.5, 0.75, 1.0}.
diff --git a/woodwork/statistics_utils.py b/woodwork/statistics_utils.py
index be2edca..0301cc9 100644
--- a/woodwork/statistics_utils.py
+++ b/woodwork/statistics_utils.py
@@ -463,17 +463,17 @@ def _get_box_plot_info_for_column(
             Defaults to True.
 
     Note:
-        The minimum quantiles necessary for outlier detection using the IQR method are the
-        first quartile (0.25) and third quartile (0.75). If these keys are missing from the quantiles dictionary,
-        the following quantiles will be calculated: {0.0, 0.25, 0.5, 0.75, 1.0}, which correspond to
-        {min, first quantile, median, third quantile, max}.
+        The minimum quantiles necessary for building a box plot using the IQR method are the
+        minimum value (0.0 in the quantiles dict), first quartile (0.25), third quartile (0.75), and maximum value (1.0).
+        If no quantiles are provided, the following quantiles will be calculated:
+        {0.0, 0.25, 0.5, 0.75, 1.0}, which correspond to {min, first quantile, median, third quantile, max}.
 
     Returns:
         (dict[str -> float,list[number]]): Returns a dictionary containing box plot information for the Series.
             The following elements will be found in the dictionary:
 
-            - low_bound (float): the lower bound below which outliers lay - to be used as a whisker
-            - high_bound (float): the high bound above which outliers lay - to be used as a whisker
+            - low_bound (float): the lowest data point in the dataset excluding any outliers - to be used as a whisker
+            - high_bound (float): the highest point in the dataset excluding any outliers - to be used as a whisker
             - quantiles (list[float]): the quantiles used to determine the bounds.
                 If quantiles were passed in, will contain all quantiles passed in. Otherwise, contains the five
                 quantiles {0.0, 0.25, 0.5, 0.75, 1.0}.
@@ -526,46 +526,36 @@ def _get_box_plot_info_for_column(
     # calculate the outlier bounds using IQR
     if quantiles is None:
         quantiles = series.quantile([0.0, 0.25, 0.5, 0.75, 1.0]).to_dict()
-    elif 0.25 not in quantiles or 0.75 not in quantiles:
+    elif len(set(quantiles.keys()) & {0.0, 0.25, 0.75, 1.0}) != 4:
         raise ValueError(
-            "Input quantiles do not contain the minimum necessary quantiles for outlier calculation: "
-            "0.25 (the first quartile) and 0.75 (the third quartile)."
+            "Input quantiles do not contain the minimum necessary quantiles for box plot calculation: "
+            "0.0 (the minimum value), 0.25 (the first quartile), 0.75 (the third quartile), and 1.0 (the maximum value)."
         )
-
+    min_value = quantiles[0.0]
     q1 = quantiles[0.25]
     q3 = quantiles[0.75]
+    max_value = quantiles[1.0]
 
+    # Box plot bounds calculation - the bounds should never be beyond the min and max values
     iqr = q3 - q1
-
-    low_bound = q1 - (iqr * 1.5)
-    high_bound = q3 + (iqr * 1.5)
+    low_bound = max(q1 - (iqr * 1.5), min_value)
+    high_bound = min(q3 + (iqr * 1.5), max_value)
 
     if include_indices_and_values:
         # identify outliers in the series
-        min = quantiles.get(0.0)
-        max = quantiles.get(1.0)
-        if (
-            min is not None
-            and max is not None
-            and low_bound <= min
-            and high_bound >= max
-        ):
-            outliers_dict = {
-                "low_values": [],
-                "high_values": [],
-                "low_indices": [],
-                "high_indices": [],
-            }
-        else:
-            low_series = series[series < low_bound]
-            high_series = series[series > high_bound]
+        low_series = (
+            series[series < low_bound] if low_bound > min_value else pd.Series()
+        )
+        high_series = (
+            series[series > high_bound] if high_bound < max_value else pd.Series()
+        )
 
-            outliers_dict = {
-                "low_values": low_series.tolist(),
-                "high_values": high_series.tolist(),
-                "low_indices": low_series.index.tolist(),
-                "high_indices": high_series.index.tolist(),
-            }
+        outliers_dict = {
+            "low_values": low_series.tolist(),
+            "high_values": high_series.tolist(),
+            "low_indices": low_series.index.tolist(),
+            "high_indices": high_series.index.tolist(),
+        }
 
     return {
         "low_bound": low_bound,


Test patch:
diff --git a/woodwork/tests/accessor/test_statistics.py b/woodwork/tests/accessor/test_statistics.py
index 106b03a..752bf08 100644
--- a/woodwork/tests/accessor/test_statistics.py
+++ b/woodwork/tests/accessor/test_statistics.py
@@ -1074,8 +1074,10 @@ def test_box_plot_outliers(outliers_df):
 
     no_outliers_dict = no_outliers_series.ww.box_plot_dict()
 
-    assert no_outliers_dict["low_bound"] == 8.125
-    assert no_outliers_dict["high_bound"] == 83.125
+    # Since there are no outliers, the the low bound is the min value
+    # and the high bound is the max value
+    assert no_outliers_dict["low_bound"] == 23.0
+    assert no_outliers_dict["high_bound"] == 60.0
     assert no_outliers_dict["quantiles"] == {
         0.0: 23.0,
         0.25: 36.25,
@@ -1117,8 +1119,8 @@ def test_box_plot_outliers_with_quantiles(outliers_df):
         quantiles={0.0: 23.0, 0.25: 36.25, 0.5: 42.0, 0.75: 55.0, 1.0: 60.0}
     )
 
-    assert no_outliers_dict["low_bound"] == 8.125
-    assert no_outliers_dict["high_bound"] == 83.125
+    assert no_outliers_dict["low_bound"] == 23.0
+    assert no_outliers_dict["high_bound"] == 60.0
     assert no_outliers_dict["quantiles"] == {
         0.0: 23.0,
         0.25: 36.25,
@@ -1221,6 +1223,7 @@ def test_box_plot_different_quantiles(outliers_df):
     has_outliers_series = outliers_df["has_outliers"]
     has_outliers_series.ww.init()
 
+    # No quantiles passed in - they all get calculated
     box_plot_info = has_outliers_series.ww.box_plot_dict()
 
     assert set(box_plot_info.keys()) == {
@@ -1238,9 +1241,9 @@ def test_box_plot_different_quantiles(outliers_df):
     assert len(box_plot_info["high_values"]) == 1
     assert len(box_plot_info["low_values"]) == 1
 
-    box_plot_info = has_outliers_series.ww.box_plot_dict(
-        quantiles={0.25: 36.25, 0.75: 55.0}
-    )
+    # The minimum required quantiles passed in with outliers present
+    partial_quantiles = {0.0: -16, 0.25: 36.25, 0.75: 55.0, 1.0: 93}
+    box_plot_info = has_outliers_series.ww.box_plot_dict(quantiles=partial_quantiles)
 
     assert set(box_plot_info.keys()) == {
         "low_bound",
@@ -1253,12 +1256,15 @@ def test_box_plot_different_quantiles(outliers_df):
     }
     assert box_plot_info["low_bound"] == 8.125
     assert box_plot_info["high_bound"] == 83.125
-    assert len(box_plot_info["quantiles"]) == 2
+    assert len(box_plot_info["quantiles"]) == 4
     assert len(box_plot_info["high_values"]) == 1
     assert len(box_plot_info["low_values"]) == 1
 
-    partial_quantiles = {0.0: -16, 0.25: 36.25, 0.75: 55.0, 1.0: 93}
-    box_plot_info = has_outliers_series.ww.box_plot_dict(quantiles=partial_quantiles)
+    # The minimum required quantiles passed in without outliers present
+    no_outliers_series = outliers_df["no_outliers"]
+    no_outliers_series.ww.init()
+    partial_quantiles = {0.0: 23.0, 0.25: 36.25, 0.75: 55.0, 1.0: 60.0}
+    box_plot_info = no_outliers_series.ww.box_plot_dict(quantiles=partial_quantiles)
 
     assert set(box_plot_info.keys()) == {
         "low_bound",
@@ -1269,11 +1275,11 @@ def test_box_plot_different_quantiles(outliers_df):
         "low_indices",
         "high_indices",
     }
-    assert box_plot_info["low_bound"] == 8.125
-    assert box_plot_info["high_bound"] == 83.125
+    assert box_plot_info["low_bound"] == 23.0
+    assert box_plot_info["high_bound"] == 60.0
     assert len(box_plot_info["quantiles"]) == 4
-    assert len(box_plot_info["high_values"]) == 1
-    assert len(box_plot_info["low_values"]) == 1
+    assert len(box_plot_info["high_values"]) == 0
+    assert len(box_plot_info["low_values"]) == 0
 
 
 def test_box_plot_quantiles_errors(outliers_df):
@@ -1281,11 +1287,11 @@ def test_box_plot_quantiles_errors(outliers_df):
     series.ww.init()
 
     error = re.escape(
-        "Input quantiles do not contain the minimum necessary quantiles for outlier calculation: "
-        "0.25 (the first quartile) and 0.75 (the third quartile)."
+        "Input quantiles do not contain the minimum necessary quantiles for box plot calculation: "
+        "0.0 (the minimum value), 0.25 (the first quartile), 0.75 (the third quartile), and 1.0 (the maximum value)."
     )
 
-    partial_quantiles = {0.25: 36.25}
+    partial_quantiles = {0.25: 36.25, 0.75: 20, 1.0: 90}
     with pytest.raises(ValueError, match=error):
         series.ww.box_plot_dict(quantiles=partial_quantiles)