Repository: DistrictDataLabs/yellowbrick

Issue:
KElbowVisualizer Crashes on Null Cluster
**Description**
I am using the Spherical K-Means outlined in [Learning Feature Representation with K-Means](https://www-cs.stanford.edu/~acoates/papers/coatesng_nntot2012.pdf), and my implementation is in [busFred/sklearn_plugins](https://github.com/busFred/sklearn_plugins).
In my implementation, it is possible that some cluster centroids has no points get assigned to it. Hereafter, such centroids will be referred to as "null cluster". However, due to the incorrect usage of `sklearn.preprocessing.LabelEncoder` in [function `distortion_score` of yellowbrick/cluster/elbow.py](https://github.com/DistrictDataLabs/yellowbrick/blob/783069e082569463faa3a192d1884de627a13370/yellowbrick/cluster/elbow.py#L90), the program could fail with `ValueError: Found array with 0 sample(s) (shape=(0, 640)) while a minimum of 1 is required by check_pairwise_arrays.`
I don't think the problem is not specific to my SphericalKMeans implementation. Any KMeans that produces centroids that no data point get assigned to could cause the program to fail.

**To Reproduce**
```python
import numpy as np
from yellowbrick.cluster import distortion_score
X = np.array([[1,2], [3,4], [5,6]])
labels = np.array([1,3,5])
distortion_score(X, labels)
```

**Dataset**
For my research project, I am using UrbanSoundDataset. But I have backtracked the problem and pinned down the cause of the problem and can be reproduced with the code provided above.

**Expected behavior**
The program should not crash when null clusters exist.

**Traceback**
The traceback for the code in the reproduce section:
```
>>> distortion_score(X, labels)
/home/fred/Documents/research/yellowbrick/yellowbrick/cluster/elbow.py:103: RuntimeWarning: Mean of empty slice.
  center = instances.mean(axis=0)
/home/fred/anaconda3/envs/yellowbrick/lib/python3.8/site-packages/numpy/core/_methods.py:162: RuntimeWarning: invalid value encountered in true_divide
  ret = um.true_divide(
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/fred/Documents/research/yellowbrick/yellowbrick/cluster/elbow.py", line 114, in distortion_score
    distances = pairwise_distances(instances, center, metric=metric)
  File "/home/fred/.local/lib/python3.8/site-packages/sklearn/utils/validation.py", line 63, in inner_f
    return f(*args, **kwargs)
  File "/home/fred/.local/lib/python3.8/site-packages/sklearn/metrics/pairwise.py", line 1790, in pairwise_distances
    return _parallel_pairwise(X, Y, func, n_jobs, **kwds)
  File "/home/fred/.local/lib/python3.8/site-packages/sklearn/metrics/pairwise.py", line 1359, in _parallel_pairwise
    return func(X, Y, **kwds)
  File "/home/fred/.local/lib/python3.8/site-packages/sklearn/utils/validation.py", line 63, in inner_f
    return f(*args, **kwargs)
  File "/home/fred/.local/lib/python3.8/site-packages/sklearn/metrics/pairwise.py", line 272, in euclidean_distances
    X, Y = check_pairwise_arrays(X, Y)
  File "/home/fred/.local/lib/python3.8/site-packages/sklearn/utils/validation.py", line 63, in inner_f
    return f(*args, **kwargs)
  File "/home/fred/.local/lib/python3.8/site-packages/sklearn/metrics/pairwise.py", line 146, in check_pairwise_arrays
    X = check_array(X, accept_sparse=accept_sparse, dtype=dtype,
  File "/home/fred/.local/lib/python3.8/site-packages/sklearn/utils/validation.py", line 63, in inner_f
    return f(*args, **kwargs)
  File "/home/fred/.local/lib/python3.8/site-packages/sklearn/utils/validation.py", line 726, in check_array
    raise ValueError("Found array with %d sample(s) (shape=%s) while a"
ValueError: Found array with 0 sample(s) (shape=(0, 2)) while a minimum of 1 is required by check_pairwise_arrays.
```
The traceback I actually encounter in my real research project:
```
/home/fred/anaconda3/envs/sound_detection/lib/python3.8/site-packages/yellowbrick/cluster/elbow.py:93: RuntimeWarning: Mean of empty slice.
  center = instances.mean(axis=0)
/home/fred/anaconda3/envs/sound_detection/lib/python3.8/site-packages/numpy/core/_methods.py:162: RuntimeWarning: invalid value encountered in true_divide
  ret = um.true_divide(
Traceback (most recent call last):
  File "/home/fred/Documents/research/sound_detection/segmented_audio_classifier/script/local/feature/../../../src/executable/training/kmeans/try_k_urban_sound_dataset.py", line 423, in <module>
    main(len(args), args)
  File "/home/fred/Documents/research/sound_detection/segmented_audio_classifier/script/local/feature/../../../src/executable/training/kmeans/try_k_urban_sound_dataset.py", line 389, in main
    min_optimal_k, max_optimal_k = try_k_elbow(
  File "/home/fred/Documents/research/sound_detection/segmented_audio_classifier/script/local/feature/../../../src/executable/training/kmeans/try_k_urban_sound_dataset.py", line 264, in try_k_elbow
    visualizer.fit(curr_class_feature_vectors)
  File "/home/fred/anaconda3/envs/sound_detection/lib/python3.8/site-packages/yellowbrick/cluster/elbow.py", line 316, in fit
    self.k_scores_.append(self.scoring_metric(X, self.estimator.labels_))
  File "/home/fred/anaconda3/envs/sound_detection/lib/python3.8/site-packages/yellowbrick/cluster/elbow.py", line 104, in distortion_score
    distances = pairwise_distances(instances, center, metric=metric)
  File "/home/fred/anaconda3/envs/sound_detection/lib/python3.8/site-packages/sklearn/utils/validation.py", line 63, in inner_f
    return f(*args, **kwargs)
  File "/home/fred/anaconda3/envs/sound_detection/lib/python3.8/site-packages/sklearn/metrics/pairwise.py", line 1790, in pairwise_distances
    return _parallel_pairwise(X, Y, func, n_jobs, **kwds)
  File "/home/fred/anaconda3/envs/sound_detection/lib/python3.8/site-packages/sklearn/metrics/pairwise.py", line 1359, in _parallel_pairwise
    return func(X, Y, **kwds)
  File "/home/fred/anaconda3/envs/sound_detection/lib/python3.8/site-packages/sklearn/utils/validation.py", line 63, in inner_f
    return f(*args, **kwargs)
  File "/home/fred/anaconda3/envs/sound_detection/lib/python3.8/site-packages/sklearn/metrics/pairwise.py", line 272, in euclidean_distances
    X, Y = check_pairwise_arrays(X, Y)
  File "/home/fred/anaconda3/envs/sound_detection/lib/python3.8/site-packages/sklearn/utils/validation.py", line 63, in inner_f
    return f(*args, **kwargs)
  File "/home/fred/anaconda3/envs/sound_detection/lib/python3.8/site-packages/sklearn/metrics/pairwise.py", line 146, in check_pairwise_arrays
    X = check_array(X, accept_sparse=accept_sparse, dtype=dtype,
  File "/home/fred/anaconda3/envs/sound_detection/lib/python3.8/site-packages/sklearn/utils/validation.py", line 63, in inner_f
    return f(*args, **kwargs)
  File "/home/fred/anaconda3/envs/sound_detection/lib/python3.8/site-packages/sklearn/utils/validation.py", line 726, in check_array
    raise ValueError("Found array with %d sample(s) (shape=%s) while a"
ValueError: Found array with 0 sample(s) (shape=(0, 640)) while a minimum of 1 is required by check_pairwise_arrays.
```

**Desktop (please complete the following information):**
 - OS: Ubuntu MATE 20.04
 - Python Version: 3.8.5
 - Yellowbrick Version: 1.3.post1

**Cause:**
In [`distortion_score`](https://github.com/DistrictDataLabs/yellowbrick/blob/783069e082569463faa3a192d1884de627a13370/yellowbrick/cluster/elbow.py#L90), variable `labels` are reassigned to `LabelEncoder().fit_transform(labels)`. After this line, labels are represented in range 0 and n_unique_classes-1. However, the for loop on line 97, it is looping through `unique_labels`, which is assigned to `LabelEncoder().classes_`. However, `LabelEncoder().classes_` are the unique labels prior to transform, which are no necessarily in the range 0 and n_unique_classes-1.
In the example above, the passed in labels is [1,3,5]. After line 90, labels is reassigned to [0,1,2] and `le.classes_` is [1,3,5]. While looping through it on line 97, when `current_label` is 0, `mask` is a boolean array that is `[false, false, false]` and `instances = X[mask]` is a length 0 array.
The official sklearn.preprocessing.LabelEncoder documentation is attached below:
    `LabelEncoder` can be used to normalize labels.

    >>> from sklearn import preprocessing
    >>> le = preprocessing.LabelEncoder()
    >>> le.fit([1, 2, 2, 6])
    LabelEncoder()
    >>> le.classes_
    array([1, 2, 6])
    >>> le.transform([1, 1, 2, 6])
    array([0, 0, 1, 2]...)
    >>> le.inverse_transform([0, 0, 1, 2])
    array([1, 1, 2, 6])



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
@@ -87,7 +87,7 @@ def distortion_score(X, labels, metric="euclidean"):
     """
     # Encode labels to get unique centers and groups
     le = LabelEncoder()
-    labels = le.fit_transform(labels)
+    le.fit(labels)
     unique_labels = le.classes_
 
     # Sum of the distortions
@@ -432,15 +432,15 @@ def metric_color(self):
     @metric_color.setter
     def metric_color(self, val):
         self.colors[CMETRIC] = val
-    
+
     @property
     def timing_color(self):
         return self.colors[CTIMING]
-    
+
     @timing_color.setter
     def timing_color(self, val):
         self.colors[CTIMING] = val
-    
+
     @property
     def vline_color(self):
         return self.colors[CVLINE]


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
@@ -116,6 +116,15 @@ def test_distortion_score_pandas_input(self):
         score = distortion_score(df, s)
         assert score == pytest.approx(69.10006514142941)
 
+    def test_distortion_score_empty_clusters(self):
+        """
+        Ensure no ValueError is thrown when there are empty clusters #1185
+        """
+        X = np.array([[1,2],[3,4],[5,6]])
+        valuea = distortion_score(X, np.array([1,3,3]))
+        valueb = distortion_score(X, np.array([0,1,1]))
+        assert valuea == valueb
+
 
 ##########################################################################
 ## KElbowVisualizer Test Cases
@@ -439,14 +448,14 @@ def test_set_colors_manually(self):
         oz.metric_color = "r"
         oz.timing_color = "y"
         oz.vline_color = "c"
-        
+
         # Create artificial "fit" data for testing purposes
         oz.k_values_ = [1, 2, 3, 4, 5, 6, 7, 8]
         oz.k_timers_ = [6.2, 8.3, 10.1, 15.8, 21.2, 27.9, 38.2, 44.9]
         oz.k_scores_ = [.8, .7, .55, .48, .40, .38, .35, .30]
         oz.elbow_value_ = 5
         oz.elbow_score_ = 0.40
-        
+
         # Execute drawing
         oz.draw()
         oz.finalize()