Repository: DistrictDataLabs/yellowbrick

Issue:
KElbow raises confusing `ValueError` when optimal k is outside provided k-range
**Describe the bug**
We seem to have a bug in our updated `KElbow` visualizer following the updates in #813 and #935. When the `locate_elbow` param is set to `True` (which it is by default), we get a `ValueError` when calling `fit` on the visualizer.

**To Reproduce**

```python
from sklearn.cluster import KMeans
from sklearn.datasets import make_blobs
from yellowbrick.cluster import KElbowVisualizer
X, y = make_blobs(centers=12, n_samples=1000, n_features=16, shuffle=True)
viz = KElbowVisualizer(KMeans(), k=(4,12))
viz.fit(X)
```
```bash
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/rbilbro/pyjects/my_yb/yellowbrick/cluster/elbow.py", line 334, in fit
    self.k_values_, self.k_scores_, **locator_kwargs
  File "/Users/rbilbro/pyjects/my_yb/yellowbrick/utils/kneed.py", line 126, in __init__
    self.knee, self.norm_knee = min(self.all_knees), min(self.all_norm_knees)
ValueError: min() arg is an empty sequence
```

**Dataset**
`make_blobs`

**Expected behavior**
`fit` should fit the `KElbow` visualizer without raising an error.

**Desktop (please complete the following information):**
 - OS: macOS
 - Python Version 3.7
 - Yellowbrick Version v1.0 dev


KElbow raises confusing `ValueError` when optimal k is outside provided k-range
**Describe the bug**
We seem to have a bug in our updated `KElbow` visualizer following the updates in #813 and #935. When the `locate_elbow` param is set to `True` (which it is by default), we get a `ValueError` when calling `fit` on the visualizer.

**To Reproduce**

```python
from sklearn.cluster import KMeans
from sklearn.datasets import make_blobs
from yellowbrick.cluster import KElbowVisualizer
X, y = make_blobs(centers=12, n_samples=1000, n_features=16, shuffle=True)
viz = KElbowVisualizer(KMeans(), k=(4,12))
viz.fit(X)
```
```bash
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/rbilbro/pyjects/my_yb/yellowbrick/cluster/elbow.py", line 334, in fit
    self.k_values_, self.k_scores_, **locator_kwargs
  File "/Users/rbilbro/pyjects/my_yb/yellowbrick/utils/kneed.py", line 126, in __init__
    self.knee, self.norm_knee = min(self.all_knees), min(self.all_norm_knees)
ValueError: min() arg is an empty sequence
```

**Dataset**
`make_blobs`

**Expected behavior**
`fit` should fit the `KElbow` visualizer without raising an error.

**Desktop (please complete the following information):**
 - OS: macOS
 - Python Version 3.7
 - Yellowbrick Version v1.0 dev




Discussion:

Resolving patch:
diff --git a/yellowbrick/cluster/elbow.py b/yellowbrick/cluster/elbow.py
--- a/yellowbrick/cluster/elbow.py
+++ b/yellowbrick/cluster/elbow.py
@@ -333,8 +333,8 @@ def fit(self, X, y=None, **kwargs):
             elbow_locator = KneeLocator(
                 self.k_values_, self.k_scores_, **locator_kwargs
             )
-            self.elbow_value_ = elbow_locator.knee
-            if self.elbow_value_ is not None:
+            if elbow_locator.knee is None:
+                self.elbow_value_ = None
                 self.elbow_score_ = 0
                 warning_message = (
                     "No 'knee' or 'elbow' point detected, "
@@ -342,6 +342,7 @@ def fit(self, X, y=None, **kwargs):
                 )
                 warnings.warn(warning_message, YellowbrickWarning)
             else:
+                self.elbow_value_ = elbow_locator.knee
                 self.elbow_score_ = self.k_scores_[
                     self.k_values_.index(self.elbow_value_)
                 ]
@@ -420,8 +421,8 @@ def kelbow_visualizer(
     X,
     y=None,
     k=10,
-    ax=None, 
-    timings=True, 
+    ax=None,
+    timings=True,
     locate_elbow=True,
     metric="distortion",
     **kwargs
diff --git a/yellowbrick/utils/kneed.py b/yellowbrick/utils/kneed.py
--- a/yellowbrick/utils/kneed.py
+++ b/yellowbrick/utils/kneed.py
@@ -2,22 +2,22 @@
 # A port of the knee-point detection package, kneed.
 #
 # Author:   Kevin Arvai
-# Author:   Pradeep Singh 
+# Author:   Pradeep Singh
 # Created:  Mon Apr 15 09:43:18 2019 -0400
 #
 # Copyright (C) 2017 Kevin Arvai
 # All rights reserved.
-# Redistribution and use in source and binary forms, with or without modification, 
+# Redistribution and use in source and binary forms, with or without modification,
 # are permitted provided that the following conditions are met:
-# 
+#
 # 1. Redistributions of source code must retain the above copyright notice, this list
 # of conditions and the following disclaimer.
 #
-# 2. Redistributions in binary form must reproduce the above copyright notice, this 
-# list of conditions and the following disclaimer in the documentation and/or other 
+# 2. Redistributions in binary form must reproduce the above copyright notice, this
+# list of conditions and the following disclaimer in the documentation and/or other
 # materials provided with the distribution.
 #
-# 3. Neither the name of the copyright holder nor the names of its contributors may 
+# 3. Neither the name of the copyright holder nor the names of its contributors may
 # be used to endorse or promote products derived from this software without specific
 # prior written permission.
 #
@@ -26,7 +26,7 @@
 # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
 # ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
-# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS 
+# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS
 # OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
 # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN
@@ -78,7 +78,10 @@ class KneeLocator(object):
     The KneeLocator is implemented using the "knee point detection algorithm" which can be read at
     `<https://www1.icsi.berkeley.edu/~barath/papers/kneedle-simplex11.pdf>`
     """
-    def __init__(self, x, y, S=1.0, curve_nature='concave', curve_direction='increasing'):
+
+    def __init__(
+        self, x, y, S=1.0, curve_nature="concave", curve_direction="increasing"
+    ):
 
         # Raw Input
         self.x = x
@@ -101,7 +104,10 @@ def __init__(self, x, y, S=1.0, curve_nature='concave', curve_direction='increas
 
         # Step 3: Calculate the Difference curve
         self.x_normalized, self.y_normalized = self.transform_xy(
-            self.x_normalized, self.y_normalized, self.curve_direction, self.curve_nature
+            self.x_normalized,
+            self.y_normalized,
+            self.curve_direction,
+            self.curve_nature,
         )
         # normalized difference curve
         self.y_distance = self.y_normalized - self.x_normalized
@@ -119,11 +125,23 @@ def __init__(self, x, y, S=1.0, curve_nature='concave', curve_direction='increas
         self.y_distance_minima = self.y_distance[self.minima_indices]
 
         # Step 5: Calculate thresholds
-        self.Tmx = self.y_distance_maxima - (self.S * np.abs(np.diff(self.x_normalized).mean()))
+        self.Tmx = self.y_distance_maxima - (
+            self.S * np.abs(np.diff(self.x_normalized).mean())
+        )
 
         # Step 6: find knee
         self.find_knee()
-        self.knee, self.norm_knee = min(self.all_knees), min(self.all_norm_knees)
+        if (self.all_knees or self.all_norm_knees) == set():
+            warning_message = (
+                "No 'knee' or 'elbow point' detected "
+                "This could be due to bad clustering, no "
+                "actual clusters being formed etc."
+            )
+            warnings.warn(warning_message, YellowbrickWarning)
+            self.knee = None
+            self.norm_knee = None
+        else:
+            self.knee, self.norm_knee = min(self.all_knees), min(self.all_norm_knees)
 
     @staticmethod
     def __normalize(a):
@@ -140,26 +158,27 @@ def __normalize(a):
     def transform_xy(x, y, direction, curve):
         """transform x and y to concave, increasing based on curve_direction and curve_nature"""
         # convert elbows to knees
-        if curve == 'convex':
+        if curve == "convex":
             x = x.max() - x
             y = y.max() - y
         # flip decreasing functions to increasing
-        if direction == 'decreasing':
+        if direction == "decreasing":
             y = np.flip(y)
 
-        if curve == 'convex':
+        if curve == "convex":
             x = np.flip(x)
             y = np.flip(y)
 
         return x, y
 
-    def find_knee(self, ):
+    def find_knee(self,):
         """This function finds and sets the knee value and the normalized knee value. """
         if not self.maxima_inidices.size:
-            warning_message = \
-                'No "knee" or "elbow point" detected ' \
-                'This could be due to bad clustering, no '\
-                'actual clusters being formed etc.'
+            warning_message = (
+                'No "knee" or "elbow point" detected '
+                "This could be due to bad clustering, no "
+                "actual clusters being formed etc."
+            )
             warnings.warn(warning_message, YellowbrickWarning)
             return None, None
 
@@ -190,8 +209,8 @@ def find_knee(self, ):
 
             # evaluate the threshold
             if self.y_distance[idx] < threshold:
-                if self.curve_nature == 'convex':
-                    if self.curve_direction == 'decreasing':
+                if self.curve_nature == "convex":
+                    if self.curve_direction == "decreasing":
                         knee = self.x[threshold_index]
                         self.all_knees.add(knee)
                         norm_knee = self.x_normalized[threshold_index]
@@ -202,8 +221,8 @@ def find_knee(self, ):
                         norm_knee = self.x_normalized[-(threshold_index + 1)]
                         self.all_norm_knees.add(norm_knee)
 
-                elif self.curve_nature == 'concave':
-                    if self.curve_direction == 'decreasing':
+                elif self.curve_nature == "concave":
+                    if self.curve_direction == "decreasing":
                         knee = self.x[-(threshold_index + 1)]
                         self.all_knees.add(knee)
                         norm_knee = self.x_normalized[-(threshold_index + 1)]
@@ -214,7 +233,7 @@ def find_knee(self, ):
                         norm_knee = self.x_normalized[threshold_index]
                         self.all_norm_knees.add(norm_knee)
 
-    def plot_knee_normalized(self, ):
+    def plot_knee_normalized(self,):
         """
         Plots the normalized curve, the distance curve (x_distance, y_normalized) and the
         knee, if it exists.
@@ -222,14 +241,16 @@ def plot_knee_normalized(self, ):
         import matplotlib.pyplot as plt
 
         plt.figure(figsize=(8, 8))
-        plt.plot(self.x_normalized, self.y_normalized,)
-        plt.plot(self.x_distance, self.y_distance, 'r')
-        plt.xticks(np.arange(self.x_normalized.min(), self.x_normalized.max() + 0.1, 0.1))
+        plt.plot(self.x_normalized, self.y_normalized)
+        plt.plot(self.x_distance, self.y_distance, "r")
+        plt.xticks(
+            np.arange(self.x_normalized.min(), self.x_normalized.max() + 0.1, 0.1)
+        )
         plt.yticks(np.arange(self.y_distance.min(), self.y_normalized.max() + 0.1, 0.1))
 
         plt.vlines(self.norm_knee, plt.ylim()[0], plt.ylim()[1])
 
-    def plot_knee(self, ):
+    def plot_knee(self,):
         """
         Plot the curve and the knee, if it exists
         


Test patch:
diff --git a/tests/test_cluster/test_elbow.py b/tests/test_cluster/test_elbow.py
--- a/tests/test_cluster/test_elbow.py
+++ b/tests/test_cluster/test_elbow.py
@@ -33,8 +33,8 @@
 from tests.base import VisualTestCase
 from yellowbrick.datasets import load_hobbies
 from yellowbrick.cluster.elbow import distortion_score
-from yellowbrick.exceptions import YellowbrickValueError
 from yellowbrick.cluster.elbow import KElbowVisualizer, kelbow_visualizer
+from yellowbrick.exceptions import YellowbrickValueError, YellowbrickWarning
 
 from tests.base import IS_WINDOWS_OR_CONDA
 
@@ -313,6 +313,22 @@ def test_locate_elbow(self):
         self.assert_images_similar(visualizer, windows_tol=2.2)
         assert_array_almost_equal(visualizer.k_scores_, expected)
 
+    def test_no_knee(self):
+        """
+        Assert that a warning is issued if there is no knee detected
+        """
+        X, y = make_blobs(n_samples=1000, centers=3, n_features=12, random_state=12)
+        message = (
+            "No 'knee' or 'elbow point' detected "
+            "This could be due to bad clustering, no "
+            "actual clusters being formed etc."
+        )
+        with pytest.warns(YellowbrickWarning, match=message):
+            visualizer = KElbowVisualizer(
+                KMeans(random_state=12), k=(4, 12), locate_elbow=True
+            )
+            visualizer.fit(X)
+
     def test_bad_metric(self):
         """
         Assert KElbow raises an exception when a bad metric is supplied