Repository: DistrictDataLabs/yellowbrick

Issue:
tSNE Value Error when no classes are specified
See [yellowbrick t-SNE fit raises ValueError](https://stackoverflow.com/questions/48950135/yellowbrick-t-sne-fit-raises-valueerror):

The issue has to do with the way numpy arrays are evaluated in 1.13 - we need to change [yellowbrick.text.tsne Line 256](https://github.com/DistrictDataLabs/yellowbrick/blob/develop/yellowbrick/text/tsne.py#L256) to `if y is not None and self.classes_ is None`

code to produce:

```python
import pandas as pd
from yellowbrick.text import TSNEVisualizer
from sklearn.datasets import make_classification

## produce random data
X, y = make_classification(n_samples=200, n_features=100,
                       n_informative=20, n_redundant=10,
                       n_classes=3, random_state=42)

## visualize data with t-SNE
tsne = TSNEVisualizer()
tsne.fit(X, y)
tsne.poof()
```

error raised:

```
Traceback (most recent call last):
  File "t.py", line 12, in <module>
    tsne.fit(X, y)
  File "/Users/benjamin/Workspace/ddl/yellowbrick/yellowbrick/text/tsne.py", line 256, in fit
    if y and self.classes_ is None:
ValueError: The truth value of an array with more than one element is ambiguous. Use a.any() or a.all()
```


Discussion:

Resolving patch:
diff --git a/docs/api/text/tsne.py b/docs/api/text/tsne.py
--- a/docs/api/text/tsne.py
+++ b/docs/api/text/tsne.py
@@ -1,3 +1,13 @@
+# ID: tsne.py [] benjamin@bengfort.com $
+
+"""
+Generate figures for TSNE documentation.
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
 import matplotlib.pyplot as plt
 
 from corpus import load_corpus
@@ -7,17 +17,25 @@
 from sklearn.feature_extraction.text import TfidfVectorizer
 
 
-def tsne(docs, labels, outpath, **kwargs):
+##########################################################################
+## Generate
+##########################################################################
+
+def tsne(docs, target, outpath, **kwargs):
     # Create a new figure and axes
     fig = plt.figure()
     ax = fig.add_subplot(111)
 
     # Visualize the frequency distribution
     visualizer = TSNEVisualizer(ax=ax, **kwargs)
-    visualizer.fit(docs, labels)
+    visualizer.fit(docs, target)
     visualizer.poof(outpath=outpath)
 
 
+##########################################################################
+## Main Method
+##########################################################################
+
 if __name__ == '__main__':
 
     # Load and vectorize the corpus
@@ -25,17 +43,13 @@ def tsne(docs, labels, outpath, **kwargs):
     tfidf = TfidfVectorizer()
 
     docs   = tfidf.fit_transform(corpus.data)
-    labels = corpus.target
+    target = corpus.target
 
     # Whole corpus visualization
-    tsne(docs, labels, "images/tsne_all_docs.png")
-
-    # Partial corpus visualization
-    # Only visualize the sports, cinema, and gaming classes
-    tsne(docs, labels, "images/tsne_limit_classes.png", classes=['sports', 'cinema', 'gaming'])
+    tsne(docs, target, "images/tsne_all_docs.png")
 
     # No labels
-    tsne(docs, None, "images/tsne_no_labels.png")
+    tsne(docs, None, "images/tsne_no_labels.png", labels=["documents"])
 
     # Apply clustering instead of class names.
     clusters = KMeans(n_clusters=5)
diff --git a/yellowbrick/text/tsne.py b/yellowbrick/text/tsne.py
--- a/yellowbrick/text/tsne.py
+++ b/yellowbrick/text/tsne.py
@@ -17,10 +17,12 @@
 ## Imports
 ##########################################################################
 
+import numpy as np
+
 from collections import defaultdict
 
 from yellowbrick.text.base import TextVisualizer
-from yellowbrick.style.colors import get_color_cycle
+from yellowbrick.style.colors import resolve_colors
 from yellowbrick.exceptions import YellowbrickValueError
 
 from sklearn.manifold import TSNE
@@ -130,17 +132,18 @@ class TSNEVisualizer(TextVisualizer):
     ax : matplotlib axes
         The axes to plot the figure on.
 
-    decompose : string or None
+    decompose : string or None, default: ``'svd'``
         A preliminary decomposition is often used prior to TSNE to make the
-        projection faster. Specify `"svd"` for sparse data or `"pca"` for
-        dense data. If decompose is None, the original data set will be used.
+        projection faster. Specify ``"svd"`` for sparse data or ``"pca"`` for
+        dense data. If None, the original data set will be used.
 
-    decompose_by : int
+    decompose_by : int, default: 50
         Specify the number of components for preliminary decomposition, by
         default this is 50; the more components, the slower TSNE will be.
 
-    classes : list of strings
+    labels : list of strings
         The names of the classes in the target, used to create a legend.
+        Labels must match names of classes in sorted order.
 
     colors : list or tuple of colors
         Specify the colors for each individual class
@@ -148,25 +151,32 @@ class TSNEVisualizer(TextVisualizer):
     colormap : string or matplotlib cmap
         Sequential colormap for continuous target
 
+    random_state : int, RandomState instance or None, optional, default: None
+        If int, random_state is the seed used by the random number generator;
+        If RandomState instance, random_state is the random number generator;
+        If None, the random number generator is the RandomState instance used
+        by np.random. The random state is applied to the preliminary
+        decomposition as well as tSNE.
+
     kwargs : dict
         Pass any additional keyword arguments to the TSNE transformer.
     """
 
-    def __init__(self, ax=None, decompose='svd', decompose_by=50, classes=None,
-               colors=None, colormap=None, **kwargs):
+    # NOTE: cannot be np.nan
+    NULL_CLASS = None
+
+    def __init__(self, ax=None, decompose='svd', decompose_by=50, labels=None,
+               classes=None, colors=None, colormap=None, random_state=None, **kwargs):
         """
         Initialize the TSNE visualizer with visual hyperparameters.
         """
         super(TSNEVisualizer, self).__init__(ax=ax, **kwargs)
 
-        # Visualizer parameters
-        self.classes_ = classes
-        self.n_instances_ = 0
-
         # Visual Parameters
-        # TODO: Only colors currently works to select the colors of classes.
+        self.labels = labels
         self.colors = colors
         self.colormap = colormap
+        self.random_state = random_state
 
         # TSNE Parameters
         self.transformer_ = self.make_transformer(decompose, decompose_by, kwargs)
@@ -181,12 +191,13 @@ def make_transformer(self, decompose='svd', decompose_by=50, tsne_kwargs={}):
         Parameters
         ----------
 
-        decompose : string or None
-            A preliminary decomposition is often used prior to TSNE to make the
-            projection faster. Specify `"svd"` for sparse data or `"pca"` for
-            dense data. If decompose is None, the original data set will be used.
+        decompose : string or None, default: ``'svd'``
+            A preliminary decomposition is often used prior to TSNE to make
+            the projection faster. Specify ``"svd"`` for sparse data or ``"pca"``
+            for dense data. If decompose is None, the original data set will
+            be used.
 
-        decompose_by : int
+        decompose_by : int, default: 50
             Specify the number of components for preliminary decomposition, by
             default this is 50; the more components, the slower TSNE will be.
 
@@ -197,6 +208,8 @@ def make_transformer(self, decompose='svd', decompose_by=50, tsne_kwargs={}):
             Pipelined transformer for TSNE projections
         """
 
+        # TODO: detect decompose by inferring from sparse matrix or dense or
+        # If number of features > 50 etc.
         decompositions = {
             'svd': TruncatedSVD,
             'pca': PCA,
@@ -215,10 +228,12 @@ def make_transformer(self, decompose='svd', decompose_by=50, tsne_kwargs={}):
         # Add the pre-decomposition
         if decompose:
             klass = decompositions[decompose]
-            steps.append((decompose, klass(n_components=decompose_by)))
+            steps.append((decompose, klass(
+                n_components=decompose_by, random_state=self.random_state)))
 
         # Add the TSNE manifold
-        steps.append(('tsne', TSNE(n_components=2, **tsne_kwargs)))
+        steps.append(('tsne', TSNE(
+            n_components=2, random_state=self.random_state, **tsne_kwargs)))
 
         # return the pipeline
         return Pipeline(steps)
@@ -252,13 +267,17 @@ def fit(self, X, y=None, **kwargs):
             Returns the instance of the transformer/visualizer
         """
 
-        # If we don't have classes already stored, store them.
-        if y and self.classes_ is None:
-            self.classes_ = [str(label) for label in set(y)]
+        # Store the classes we observed in y
+        if y is not None:
+            self.classes_ = np.unique(y)
+        elif y is None and self.labels is not None:
+            self.classes_ = np.array([self.labels[0]])
+        else:
+            self.classes_ = np.array([self.NULL_CLASS])
 
         # Fit our internal transformer and transform the data.
         vecs = self.transformer_.fit_transform(X)
-        self.n_instances_ += vecs.shape[0]
+        self.n_instances_ = vecs.shape[0]
 
         # Draw the vectors
         self.draw(vecs, y, **kwargs)
@@ -274,34 +293,45 @@ def draw(self, points, target=None, **kwargs):
         of each of the points. If the target is not specified, then the points
         are plotted as a single cloud to show similar documents.
         """
-        # Create the color mapping for the classes.
-        # TODO: Allow both colormap, listed colors, and palette definition
-        # See the FeatureVisualizer for more on this.
-        color_values = get_color_cycle()
-        classes = self.classes_ or [None]
-        colors = dict(zip(classes, color_values))
+        # Resolve the labels with the classes
+        labels = self.labels if self.labels is not None else self.classes_
+        if len(labels) != len(self.classes_):
+            raise YellowbrickValueError((
+                "number of supplied labels ({}) does not "
+                "match the number of classes ({})"
+            ).format(len(labels), len(self.classes_)))
+
+
+        # Create the color mapping for the labels.
+        color_values = resolve_colors(
+            n_colors=len(labels), colormap=self.colormap, colors=self.color)
+        colors = dict(zip(labels, color_values))
+
+        # Transform labels into a map of class to label
+        labels = dict(zip(self.classes_, labels))
 
         # Expand the points into vectors of x and y for scatter plotting,
         # assigning them to their label if the label has been passed in.
         # Additionally, filter classes not specified directly by the user.
         series = defaultdict(lambda: {'x':[], 'y':[]})
-        if self.classes_: classes = frozenset(self.classes_)
-
-        if target:
-            for label, point in zip(target, points):
-                if self.classes_ and label not in classes:
-                    continue
 
+        if target is not None:
+            for t, point in zip(target, points):
+                label = labels[t]
                 series[label]['x'].append(point[0])
                 series[label]['y'].append(point[1])
         else:
+            label = self.classes_[0]
             for x,y in points:
-                series[None]['x'].append(x)
-                series[None]['y'].append(y)
+                series[label]['x'].append(x)
+                series[label]['y'].append(y)
 
         # Plot the points
         for label, points in series.items():
-            self.ax.scatter(points['x'], points['y'], c=colors[label], alpha=0.7, label=label)
+            self.ax.scatter(
+                points['x'], points['y'], c=colors[label],
+                alpha=0.7, label=label
+            )
 
     def finalize(self, **kwargs):
         """
@@ -319,7 +349,7 @@ def finalize(self, **kwargs):
         self.ax.set_xticks([])
 
         # Add the legend outside of the figure box.
-        if self.classes_:
+        if not all(self.classes_ == np.array([self.NULL_CLASS])):
             box = self.ax.get_position()
             self.ax.set_position([box.x0, box.y0, box.width * 0.8, box.height])
             self.ax.legend(loc='center left', bbox_to_anchor=(1, 0.5))


Test patch:
diff --git a/tests/baseline_images/test_text/test_tsne/test_integrated_tsne.png b/tests/baseline_images/test_text/test_tsne/test_integrated_tsne.png
new file mode 100644
Binary files /dev/null and b/tests/baseline_images/test_text/test_tsne/test_integrated_tsne.png differ
diff --git a/tests/baseline_images/test_text/test_tsne/test_make_classification_tsne.png b/tests/baseline_images/test_text/test_tsne/test_make_classification_tsne.png
new file mode 100644
Binary files /dev/null and b/tests/baseline_images/test_text/test_tsne/test_make_classification_tsne.png differ
diff --git a/tests/baseline_images/test_text/test_tsne/test_make_classification_tsne_class_labels.png b/tests/baseline_images/test_text/test_tsne/test_make_classification_tsne_class_labels.png
new file mode 100644
Binary files /dev/null and b/tests/baseline_images/test_text/test_tsne/test_make_classification_tsne_class_labels.png differ
diff --git a/tests/baseline_images/test_text/test_tsne/test_no_target_tsne.png b/tests/baseline_images/test_text/test_tsne/test_no_target_tsne.png
new file mode 100644
Binary files /dev/null and b/tests/baseline_images/test_text/test_tsne/test_no_target_tsne.png differ
diff --git a/tests/baseline_images/test_text/test_tsne/test_visualizer_with_pandas.png b/tests/baseline_images/test_text/test_tsne/test_visualizer_with_pandas.png
new file mode 100644
Binary files /dev/null and b/tests/baseline_images/test_text/test_tsne/test_visualizer_with_pandas.png differ
diff --git a/tests/requirements.txt b/tests/requirements.txt
--- a/tests/requirements.txt
+++ b/tests/requirements.txt
@@ -12,6 +12,7 @@ pytest-flakes>=2.0.0
 pytest-spec>=1.1.0
 coverage>=4.4.1
 requests>=2.18.3
+six==1.11.0
 
 # Python 2 Testing Requirements
 mock>=2.0.0
diff --git a/tests/test_text/test_tsne.py b/tests/test_text/test_tsne.py
--- a/tests/test_text/test_tsne.py
+++ b/tests/test_text/test_tsne.py
@@ -17,26 +17,37 @@
 ## Imports
 ##########################################################################
 
-
-import unittest
+import six
+import pytest
 
 from yellowbrick.text.tsne import *
+from tests.base import VisualTestCase
 from tests.dataset import DatasetMixin
 from yellowbrick.exceptions import YellowbrickValueError
+
+from sklearn.datasets import make_classification
 from sklearn.feature_extraction.text import TfidfVectorizer
 
+try:
+    import pandas
+except ImportError:
+    pandas = None
+
 
 ##########################################################################
 ## TSNE Tests
 ##########################################################################
 
-class TSNETests(unittest.TestCase, DatasetMixin):
+class TestTSNE(VisualTestCase, DatasetMixin):
+    """
+    TSNEVisualizer tests
+    """
 
     def test_bad_decomposition(self):
         """
         Ensure an error is raised when a bad decompose argument is specified
         """
-        with self.assertRaises(YellowbrickValueError):
+        with pytest.raises(YellowbrickValueError):
             TSNEVisualizer(decompose='bob')
 
     def test_make_pipeline(self):
@@ -45,20 +56,20 @@ def test_make_pipeline(self):
         """
 
         tsne = TSNEVisualizer() # Should not cause an exception.
-        self.assertIsNotNone(tsne.transformer_)
+        assert tsne.transformer_ is not None
 
         svdp = tsne.make_transformer('svd', 90)
-        self.assertEqual(len(svdp.steps), 2)
+        assert len(svdp.steps) == 2
 
         pcap = tsne.make_transformer('pca')
-        self.assertEqual(len(pcap.steps), 2)
+        assert len(pcap.steps) == 2
 
         none = tsne.make_transformer(None)
-        self.assertEqual(len(none.steps), 1)
+        assert len(none.steps) == 1
 
     def test_integrated_tsne(self):
         """
-        Assert no errors occur during tsne integration
+        Check tSNE integrated visualization on the hobbies corpus
         """
         corpus = self.load_data('hobbies')
         tfidf  = TfidfVectorizer()
@@ -66,5 +77,96 @@ def test_integrated_tsne(self):
         docs   = tfidf.fit_transform(corpus.data)
         labels = corpus.target
 
-        tsne = TSNEVisualizer()
+        tsne = TSNEVisualizer(random_state=8392, colormap='Set1')
         tsne.fit_transform(docs, labels)
+
+        tol = 40 if six.PY3 else 55
+        self.assert_images_similar(tsne, tol=tol)
+
+    def test_make_classification_tsne(self):
+        """
+        Test tSNE integrated visualization on a sklearn classifier dataset
+        """
+
+        ## produce random data
+        X, y = make_classification(n_samples=200, n_features=100,
+                               n_informative=20, n_redundant=10,
+                               n_classes=3, random_state=42)
+
+        ## visualize data with t-SNE
+        tsne = TSNEVisualizer(random_state=87)
+        tsne.fit(X, y)
+
+        tol = 0.1 if six.PY3 else 40
+        self.assert_images_similar(tsne, tol=tol)
+
+    def test_make_classification_tsne_class_labels(self):
+        """
+        Test tSNE integrated visualization with class labels specified
+        """
+
+        ## produce random data
+        X, y = make_classification(n_samples=200, n_features=100,
+                               n_informative=20, n_redundant=10,
+                               n_classes=3, random_state=42)
+
+        ## visualize data with t-SNE
+        tsne = TSNEVisualizer(random_state=87, labels=['a', 'b', 'c'])
+        tsne.fit(X, y)
+
+        tol = 0.1 if six.PY3 else 40
+        self.assert_images_similar(tsne, tol=tol)
+
+    def test_tsne_mismtached_labels(self):
+        """
+        Assert exception is raised when number of labels doesn't match
+        """
+        ## produce random data
+        X, y = make_classification(n_samples=200, n_features=100,
+                               n_informative=20, n_redundant=10,
+                               n_classes=3, random_state=42)
+
+        ## fewer labels than classes
+        tsne = TSNEVisualizer(random_state=87, labels=['a', 'b'])
+        with pytest.raises(YellowbrickValueError):
+            tsne.fit(X,y)
+
+        ## more labels than classes
+        tsne = TSNEVisualizer(random_state=87, labels=['a', 'b', 'c', 'd'])
+        with pytest.raises(YellowbrickValueError):
+            tsne.fit(X,y)
+
+
+    def test_no_target_tsne(self):
+        """
+        Test tSNE when no target or classes are specified
+        """
+        ## produce random data
+        X, y = make_classification(n_samples=200, n_features=100,
+                               n_informative=20, n_redundant=10,
+                               n_classes=3, random_state=6897)
+
+        ## visualize data with t-SNE
+        tsne = TSNEVisualizer(random_state=64)
+        tsne.fit(X)
+
+        self.assert_images_similar(tsne, tol=0.1)
+
+    @pytest.mark.skipif(pandas is None, reason="test requires pandas")
+    def test_visualizer_with_pandas(self):
+        """
+        Test tSNE when passed a pandas DataFrame and series
+        """
+        X, y = make_classification(
+            n_samples=200, n_features=100, n_informative=20, n_redundant=10,
+            n_classes=3, random_state=3020
+        )
+
+        X = pandas.DataFrame(X)
+        y = pandas.Series(y)
+
+        tsne = TSNEVisualizer(random_state=64)
+        tsne.fit(X, y)
+
+        tol = 0.1 if six.PY3 else 40
+        self.assert_images_similar(tsne, tol=tol)