Repository: DistrictDataLabs/yellowbrick

Issue:
Fix ParallelCoordinates tests and add fast parameter
See #420 

- [x] fix image comparison tests for parallel coordinates 
- [x] add `fast=False` argument and docstring 
- [x] create fast and regular drawing methods based on parameter 
- [x] add section in documentation explaining fast vs. slow 
- [x] update #230 and #59 


Discussion:

Resolving patch:
diff --git a/docs/api/features/pcoords.py b/docs/api/features/pcoords.py
--- a/docs/api/features/pcoords.py
+++ b/docs/api/features/pcoords.py
@@ -1,7 +1,25 @@
+import time
+import numpy as np
 import pandas as pd
 import matplotlib.pyplot as plt
 
 from yellowbrick.features import ParallelCoordinates
+from sklearn.datasets import load_iris
+
+
+def load_occupancy_data():
+    # Load the classification data set
+    data = pd.read_csv("../../../examples/data/occupancy/occupancy.csv")
+
+    # Specify the features of interest and the classes of the target
+    features = ["temperature", "relative humidity", "light", "C02", "humidity"]
+    classes = ['unoccupied', 'occupied']
+
+    # Extract the numpy arrays from the data frame
+    X = data[features].as_matrix()
+    y = data.occupancy.as_matrix()
+
+    return X, y, features, classes
 
 
 def pcoords(X, y, outpath, **kwargs):
@@ -10,30 +28,102 @@ def pcoords(X, y, outpath, **kwargs):
 
     # Create the visualizer
     visualizer = ParallelCoordinates(ax=ax, **kwargs)
-    visualizer.fit(X, y)
-    visualizer.transform(X)
+    visualizer.fit_transform(X, y)
 
     # Save to disk
     visualizer.poof(outpath=outpath)
 
 
-if __name__ == '__main__':
-    # Load the classification data set
-    data = pd.read_csv("../../../examples/data/occupancy/occupancy.csv")
+def plot_fast_vs_slow():
+    data = load_iris()
 
-    # Specify the features of interest and the classes of the target
-    features = ["temperature", "relative humidity", "light", "C02", "humidity"]
-    classes = ['unoccupied', 'occupied']
+    _, axes = plt.subplots(nrows=2, figsize=(9,9))
+
+    for idx, fast in enumerate((False, True)):
+        title = "Fast Parallel Coordinates" if fast else "Standard Parallel Coordinates"
+        oz = ParallelCoordinates(ax=axes[idx], fast=fast, title=title)
+        oz.fit_transform(data.data, data.target)
+        oz.finalize()
+
+    plt.tight_layout()
+    plt.savefig("images/fast_vs_slow_parallel_coordinates.png")
 
-    # Extract the numpy arrays from the data frame
-    X = data[features].as_matrix()
-    y = data.occupancy.as_matrix()
+
+def plot_speedup(trials=5, factors=np.arange(1, 11)):
+
+    def pcoords_time(X, y, fast=True):
+        _, ax = plt.subplots()
+        oz = ParallelCoordinates(fast=fast, ax=ax)
+
+        start = time.time()
+        oz.fit_transform(X, y)
+        delta = time.time() - start
+
+        plt.cla()        # clear current axis
+        plt.clf()        # clear current figure
+        plt.close("all") # close all existing plots
+
+        return delta
+
+    def pcoords_speedup(X, y):
+        fast_time = pcoords_time(X, y, fast=True)
+        slow_time = pcoords_time(X, y, fast=False)
+
+        return slow_time / fast_time
+
+    data = load_iris()
+
+    speedups = []
+    variance = []
+
+    for factor in factors:
+        X = np.repeat(data.data, factor, axis=0)
+        y = np.repeat(data.target, factor, axis=0)
+
+        local_speedups = []
+        for trial in range(trials):
+            local_speedups.append(pcoords_speedup(X, y))
+
+        local_speedups = np.array(local_speedups)
+        speedups.append(local_speedups.mean())
+        variance.append(local_speedups.std())
+
+    speedups = np.array(speedups)
+    variance = np.array(variance)
+
+    series = pd.Series(speedups, index=factors)
+    _, ax = plt.subplots(figsize=(9,6))
+    series.plot(ax=ax, marker='o', label="speedup factor", color='b')
+
+    # Plot one standard deviation above and below the mean
+    ax.fill_between(
+        factors, speedups - variance, speedups + variance, alpha=0.25,
+        color='b',
+    )
+
+    ax.set_ylabel("speedup factor")
+    ax.set_xlabel("dataset size (number of repeats in Iris dataset)")
+    ax.set_title("Speed Improvement of Fast Parallel Coordinates")
+    plt.savefig("images/fast_parallel_coordinates_speedup.png")
+
+
+if __name__ == '__main__':
+    # plot_fast_vs_slow()
+    # plot_speedup()
+
+    # Occupancy data visualizations
+    X, y, features, classes = load_occupancy_data()
 
     # Draw the full, original parallel coordinates
-    pcoords(X, y, "images/parallel_coordinates.png", classes=classes, features=features)
+    pcoords(
+        X, y, "images/parallel_coordinates.png",
+        classes=classes, features=features,
+        sample=0.05, shuffle=True, random_state=19,
+    )
 
     # Draw the noramlized, sampled parallel coordinates
-    pcoords(X, y, "images/normalized_sampled_parallel_coordinates.png",
+    pcoords(
+        X, y, "images/normalized_sampled_parallel_coordinates.png",
         classes=classes, features=features,
-        normalize='standard', sample=0.1,
+        normalize='standard', sample=0.05, shuffle=True, random_state=19,
     )
diff --git a/yellowbrick/features/pcoords.py b/yellowbrick/features/pcoords.py
--- a/yellowbrick/features/pcoords.py
+++ b/yellowbrick/features/pcoords.py
@@ -1,8 +1,9 @@
 # yellowbrick.features.pcoords
 # Implementations of parallel coordinates for feature analysis.
 #
-# Author:   Benjamin Bengfort <bbengfort@districtdatalabs.com>
-# Created:  Mon Oct 03 21:46:06 2016 -0400
+# Author:  Benjamin Bengfort <bbengfort@districtdatalabs.com>
+# Author:  @thekylesaurus
+# Created: Mon Oct 03 21:46:06 2016 -0400
 #
 # Copyright (C) 2016 District Data Labs
 # For license information, see LICENSE.txt
@@ -10,19 +11,19 @@
 # ID: pcoords.py [0f4b236] benjamin@bengfort.com $
 
 """
-Implementations of parallel coordinates for multi-dimensional feature
-analysis. There are a variety of parallel coordinates from Andrews Curves to
-coordinates that optimize column order.
+Implementation of parallel coordinates for multi-dimensional feature analysis.
 """
 
 ##########################################################################
 ## Imports
 ##########################################################################
 
-from copy import copy
-from numpy import hstack, ones
-from numpy.random import RandomState
+import numpy as np
 
+from six import string_types
+from matplotlib import patches
+from operator import itemgetter
+from numpy.random import RandomState
 from sklearn.preprocessing import MinMaxScaler, MaxAbsScaler
 from sklearn.preprocessing import Normalizer, StandardScaler
 
@@ -38,7 +39,8 @@
 
 def parallel_coordinates(X, y, ax=None, features=None, classes=None,
                          normalize=None, sample=1.0, color=None, colormap=None,
-                         vlines=True, vlines_kwds=None, **kwargs):
+                         alpha=None, fast=False, vlines=True, vlines_kwds=None,
+                         **kwargs):
     """Displays each feature as a vertical axis and each instance as a line.
 
     This helper function is a quick wrapper to utilize the ParallelCoordinates
@@ -87,6 +89,17 @@ def parallel_coordinates(X, y, ax=None, features=None, classes=None,
         Use either color to colorize the lines on a per class basis or
         colormap to color them on a continuous scale.
 
+    alpha : float, default: None
+        Specify a transparency where 1 is completely opaque and 0 is completely
+        transparent. This property makes densely clustered lines more visible.
+        If None, the alpha is set to 0.5 in "fast" mode and 0.25 otherwise.
+
+    fast : bool, default: False
+        Fast mode improves the performance of the drawing time of parallel
+        coordinates but produces an image that does not show the overlap of
+        instances in the same class. Fast mode should be used when drawing all
+        instances is too burdensome and sampling is not an option.
+
     vlines : boolean, default: True
         flag to determine vertical line display
 
@@ -104,8 +117,8 @@ def parallel_coordinates(X, y, ax=None, features=None, classes=None,
     """
     # Instantiate the visualizer
     visualizer = ParallelCoordinates(
-        ax, features, classes, normalize, sample, color, colormap, vlines,
-        vlines_kwds, **kwargs
+        ax, features, classes, normalize, sample, color, colormap, alpha,
+        fast, vlines, vlines_kwds, **kwargs
     )
 
     # Fit and transform the visualizer (calls draw)
@@ -124,7 +137,8 @@ class ParallelCoordinates(DataVisualizer):
     """
     Parallel coordinates displays each feature as a vertical axis spaced
     evenly along the horizontal, and each instance as a line drawn between
-    each individual axis.
+    each individual axis. This allows you to detect braids of similar instances
+    and separability that suggests a good classification problem. 
 
     Parameters
     ----------
@@ -172,6 +186,17 @@ class ParallelCoordinates(DataVisualizer):
         Use either color to colorize the lines on a per class basis or
         colormap to color them on a continuous scale.
 
+    alpha : float, default: None
+        Specify a transparency where 1 is completely opaque and 0 is completely
+        transparent. This property makes densely clustered lines more visible.
+        If None, the alpha is set to 0.5 in "fast" mode and 0.25 otherwise.
+
+    fast : bool, default: False
+        Fast mode improves the performance of the drawing time of parallel
+        coordinates but produces an image that does not show the overlap of
+        instances in the same class. Fast mode should be used when drawing all
+        instances is too burdensome and sampling is not an option.
+
     vlines : boolean, default: True
         flag to determine vertical line display
 
@@ -184,7 +209,6 @@ class ParallelCoordinates(DataVisualizer):
 
     Attributes
     --------
-
     n_samples_ : int
         number of samples included in the visualization object
 
@@ -203,7 +227,7 @@ class ParallelCoordinates(DataVisualizer):
     process, but can and should be set as early as possible.
     """
 
-    normalizers = {
+    NORMALIZERS = {
         'minmax': MinMaxScaler(),
         'maxabs': MaxAbsScaler(),
         'standard': StandardScaler(),
@@ -211,15 +235,28 @@ class ParallelCoordinates(DataVisualizer):
         'l2': Normalizer('l2'),
     }
 
-    def __init__(self, ax=None, features=None, classes=None, normalize=None,
-                 sample=1.0, random_state=None, shuffle=False, color=None, colormap=None, vlines=True,
-                 vlines_kwds=None, **kwargs):
+    def __init__(self,
+                 ax=None,
+                 features=None,
+                 classes=None,
+                 normalize=None,
+                 sample=1.0,
+                 random_state=None,
+                 shuffle=False,
+                 color=None,
+                 colormap=None,
+                 alpha=None,
+                 fast=False,
+                 vlines=True,
+                 vlines_kwds=None,
+                 **kwargs):
+
         super(ParallelCoordinates, self).__init__(
             ax, features, classes, color, colormap, **kwargs
         )
 
         # Validate 'normalize' argument
-        if normalize in self.normalizers or normalize is None:
+        if normalize in self.NORMALIZERS or normalize is None:
             self.normalize = normalize
         else:
             raise YellowbrickValueError(
@@ -263,12 +300,18 @@ def __init__(self, ax=None, features=None, classes=None, normalize=None,
         else:
             self._rng = None
 
-        # Visual Parameters
+        # Visual and drawing parameters
+        self.fast = fast
+        self.alpha = alpha
         self.show_vlines = vlines
         self.vlines_kwds = vlines_kwds or {
             'linewidth': 1, 'color': 'black'
         }
 
+        # Internal properties
+        self._increments = None
+        self._colors = None
+
     def fit(self, X, y=None, **kwargs):
         """
         The fit method is the primary drawing input for the
@@ -292,24 +335,45 @@ def fit(self, X, y=None, **kwargs):
             Returns the instance of the transformer/visualizer
         """
 
-        # Convert from dataframe
+        # Convert from pandas data types
         if is_dataframe(X):
+            # Get column names before reverting to an np.ndarray
+            if self.features_ is None:
+                self.features_ = np.array(X.columns)
+
             X = X.as_matrix()
         if is_series(y):
             y = y.as_matrix()
 
-        # Subsample
+        # Assign integer labels to the feature columns from the input
+        if self.features_ is None:
+            self.features_ = np.arange(0, X.shape[1])
+
+        # Ensure that all classes are represented in the color mapping (before sample)
+        # NOTE: np.unique also specifies the ordering of the classes
+        if self.classes_ is None:
+            self.classes_ = [str(label) for label in np.unique(y)]
+
+        # Create the color mapping for each class
+        # TODO: Allow both colormap, listed colors, and palette definition
+        # TODO: Make this an independent function or property for override!
+        color_values = resolve_colors(
+            n_colors=len(self.classes_), colormap=self.colormap, colors=self.color
+        )
+        self._colors = dict(zip(self.classes_, color_values))
+
+        # Ticks for each feature specified
+        self._increments = np.arange(len(self.features_))
+
+        # Subsample instances
         X, y = self._subsample(X, y)
 
-        # Normalize
+        # Normalize instances
         if self.normalize is not None:
-            X = self.normalizers[self.normalize].fit_transform(X)
+            X = self.NORMALIZERS[self.normalize].fit_transform(X)
 
         # the super method calls draw and returns self
-        super(ParallelCoordinates, self).fit(X, y, **kwargs)
-
-        # Fit always returns self.
-        return self
+        return super(ParallelCoordinates, self).fit(X, y, **kwargs)
 
     def draw(self, X, y, **kwargs):
         """
@@ -328,51 +392,100 @@ def draw(self, X, y, **kwargs):
             Pass generic arguments to the drawing method
 
         """
+        if self.fast:
+            return self.draw_classes(X, y, **kwargs)
+        return self.draw_instances(X, y, **kwargs)
 
-        # Get shape of the sampled data
-        nrows, ncols = X.shape
+    def draw_instances(self, X, y, **kwargs):
+        """
+        Draw the instances colored by the target y such that each line is a
+        single instance. This is the "slow" mode of drawing, since each
+        instance has to be drawn individually. However, in so doing, the
+        density of instances in braids is more apparent since lines have an
+        independent alpha that is compounded in the figure.
 
-        # Create the xticks for each column
-        # TODO: Allow the user to specify this feature
-        increments = list(range(ncols))
+        This is the default method of drawing.
 
-        # Create the colors
-        # TODO: Allow both colormap, listed colors, and palette definition
-        # TODO: Make this an independent function or property for override!
-        color_values = resolve_colors(
-            n_colors=len(self.classes_), colormap=self.colormap, colors=self.color
-        )
-        colors = dict(zip(self.classes_, color_values))
+        Parameters
+        ----------
+        X : ndarray of shape n x m
+            A matrix of n instances with m features
+
+        y : ndarray of length n
+            An array or series of target or class values
+
+        Notes
+        -----
+        This method can be used to draw additional instances onto the parallel
+        coordinates before the figure is finalized.
+        """
+        # Get alpha from param or default
+        alpha = self.alpha or 0.25
 
-        # TODO: Make this function compatible with DataFrames!
-        # TODO: Make an independent function to allow addition of instances!
+        for idx in range(len(X)):
+            Xi = X[idx]
+            yi = y[idx]
 
-        # Prepare to flatten data within each class
-        #   introduce separation between individual data points using None in x-values and arbitrary value (one) in
-        #   y-values
-        X_separated = hstack([X, ones((nrows, 1))])
-        increments_separated = copy(increments)
+            # TODO: generalize this duplicated code into a single function
+            if isinstance(yi, string_types):
+                label = yi
+            else:
+                # TODO: what happens if yi is not in classes?!
+                label = self.classes_[yi]
+
+            self.ax.plot(
+                self._increments, Xi,
+                color=self._colors[label], alpha=alpha, **kwargs
+            )
+
+        return self.ax
+
+    def draw_classes(self, X, y, **kwargs):
+        """
+        Draw the instances colored by the target y such that each line is a
+        single class. This is the "fast" mode of drawing, since the number of
+        lines drawn equals the number of classes, rather than the number of
+        instances. However, this drawing method sacrifices inter-class density
+        of points using the alpha parameter.
+
+        Parameters
+        ----------
+        X : ndarray of shape n x m
+            A matrix of n instances with m features
+
+        y : ndarray of length n
+            An array or series of target or class values
+        """
+        # Get alpha from param or default
+        alpha = self.alpha or 0.5
+
+        # Prepare to flatten data within each class:
+        #   introduce separation between individual data points using None in
+        #   x-values and arbitrary value (one) in y-values
+        X_separated = np.hstack([X, np.ones((X.shape[0], 1))])
+        increments_separated = self._increments.tolist()
         increments_separated.append(None)
 
-        # Plot each class
-        for label, color in sorted(colors.items()):
-            y_as_str = y.astype('str')  # must be consistent with class conversion in DataVisualizer.fit()
-            X_in_class = X_separated[y_as_str == label, :]
+        # Get the classes that exist in the dataset, y
+        y_values = np.unique(y)
+
+        # Plot each class as a single line plot
+        for yi in y_values:
+            if isinstance(yi, string_types):
+                label = yi
+            else:
+                # TODO: what happens if yi is not in classes?!
+                label = self.classes_[yi]
+
+            X_in_class = X_separated[y == yi, :]
             increments_in_class = increments_separated * len(X_in_class)
             if len(X_in_class) > 0:
-                self.ax.plot(increments_in_class, X_in_class.flatten(),
-                             label=label, color=colors[label], alpha=0.25, linewidth=1, **kwargs)
-
-        # Add the vertical lines
-        # TODO: Make an independent function for override!
-        if self.show_vlines:
-            for idx in increments:
-                self.ax.axvline(idx, **self.vlines_kwds)
+                self.ax.plot(
+                    increments_in_class, X_in_class.flatten(), linewidth=1,
+                    color=self._colors[label], alpha=alpha, **kwargs
+                )
 
-        # Set the limits
-        self.ax.set_xticks(increments)
-        self.ax.set_xticklabels(self.features_)
-        self.ax.set_xlim(increments[0], increments[-1])
+        return self.ax
 
     def finalize(self, **kwargs):
         """
@@ -389,8 +502,25 @@ def finalize(self, **kwargs):
             'Parallel Coordinates for {} Features'.format(len(self.features_))
         )
 
-        # Set the legend and the grid
-        self.ax.legend(loc='best')
+        # Add the vertical lines
+        # TODO: Make an independent function for override!
+        if self.show_vlines:
+            for idx in self._increments:
+                self.ax.axvline(idx, **self.vlines_kwds)
+
+        # Set the limits
+        self.ax.set_xticks(self._increments)
+        self.ax.set_xticklabels(self.features_)
+        self.ax.set_xlim(self._increments[0], self._increments[-1])
+
+        # Add the legend
+        handles = [
+            patches.Patch(color=color, label=label)
+            for label, color in sorted(self._colors.items(), key=itemgetter(0))
+        ]
+        self.ax.legend(handles=handles, loc='best', frameon=True)
+
+        # Add the grid view
         self.ax.grid()
 
     def _subsample(self, X, y):


Test patch:
diff --git a/tests/baseline_images/test_features/test_pcoords/test_alpha.png b/tests/baseline_images/test_features/test_pcoords/test_alpha.png
new file mode 100644
Binary files /dev/null and b/tests/baseline_images/test_features/test_pcoords/test_alpha.png differ
diff --git a/tests/baseline_images/test_features/test_pcoords/test_alpha_fast.png b/tests/baseline_images/test_features/test_pcoords/test_alpha_fast.png
new file mode 100644
Binary files /dev/null and b/tests/baseline_images/test_features/test_pcoords/test_alpha_fast.png differ
diff --git a/tests/baseline_images/test_features/test_pcoords/test_integrated_pcoords.png b/tests/baseline_images/test_features/test_pcoords/test_integrated_pcoords.png
deleted file mode 100644
Binary files a/tests/baseline_images/test_features/test_pcoords/test_integrated_pcoords.png and /dev/null differ
diff --git a/tests/baseline_images/test_features/test_pcoords/test_labels.png b/tests/baseline_images/test_features/test_pcoords/test_labels.png
new file mode 100644
Binary files /dev/null and b/tests/baseline_images/test_features/test_pcoords/test_labels.png differ
diff --git a/tests/baseline_images/test_features/test_pcoords/test_labels_fast.png b/tests/baseline_images/test_features/test_pcoords/test_labels_fast.png
new file mode 100644
Binary files /dev/null and b/tests/baseline_images/test_features/test_pcoords/test_labels_fast.png differ
diff --git a/tests/baseline_images/test_features/test_pcoords/test_normalized_l2.png b/tests/baseline_images/test_features/test_pcoords/test_normalized_l2.png
new file mode 100644
Binary files /dev/null and b/tests/baseline_images/test_features/test_pcoords/test_normalized_l2.png differ
diff --git a/tests/baseline_images/test_features/test_pcoords/test_normalized_l2_fast.png b/tests/baseline_images/test_features/test_pcoords/test_normalized_l2_fast.png
new file mode 100644
Binary files /dev/null and b/tests/baseline_images/test_features/test_pcoords/test_normalized_l2_fast.png differ
diff --git a/tests/baseline_images/test_features/test_pcoords/test_normalized_minmax.png b/tests/baseline_images/test_features/test_pcoords/test_normalized_minmax.png
new file mode 100644
Binary files /dev/null and b/tests/baseline_images/test_features/test_pcoords/test_normalized_minmax.png differ
diff --git a/tests/baseline_images/test_features/test_pcoords/test_normalized_minmax_fast.png b/tests/baseline_images/test_features/test_pcoords/test_normalized_minmax_fast.png
new file mode 100644
Binary files /dev/null and b/tests/baseline_images/test_features/test_pcoords/test_normalized_minmax_fast.png differ
diff --git a/tests/baseline_images/test_features/test_pcoords/test_normalized_pcoords.png b/tests/baseline_images/test_features/test_pcoords/test_normalized_pcoords.png
deleted file mode 100644
Binary files a/tests/baseline_images/test_features/test_pcoords/test_normalized_pcoords.png and /dev/null differ
diff --git a/tests/baseline_images/test_features/test_pcoords/test_pandas_integration_fast.png b/tests/baseline_images/test_features/test_pcoords/test_pandas_integration_fast.png
new file mode 100644
Binary files /dev/null and b/tests/baseline_images/test_features/test_pcoords/test_pandas_integration_fast.png differ
diff --git a/tests/baseline_images/test_features/test_pcoords/test_pandas_integration_sampled.png b/tests/baseline_images/test_features/test_pcoords/test_pandas_integration_sampled.png
new file mode 100644
Binary files /dev/null and b/tests/baseline_images/test_features/test_pcoords/test_pandas_integration_sampled.png differ
diff --git a/tests/baseline_images/test_features/test_pcoords/test_parallel_coords.png b/tests/baseline_images/test_features/test_pcoords/test_parallel_coords.png
Binary files a/tests/baseline_images/test_features/test_pcoords/test_parallel_coords.png and b/tests/baseline_images/test_features/test_pcoords/test_parallel_coords.png differ
diff --git a/tests/baseline_images/test_features/test_pcoords/test_parallel_coords_fast.png b/tests/baseline_images/test_features/test_pcoords/test_parallel_coords_fast.png
new file mode 100644
Binary files /dev/null and b/tests/baseline_images/test_features/test_pcoords/test_parallel_coords_fast.png differ
diff --git a/tests/test_features/test_pcoords.py b/tests/test_features/test_pcoords.py
--- a/tests/test_features/test_pcoords.py
+++ b/tests/test_features/test_pcoords.py
@@ -1,8 +1,9 @@
 # tests.test_features.test_pcoords
 # Testing for the parallel coordinates feature visualizers
 #
-# Author:   Benjamin Bengfort <bbengfort@districtdatalabs.com>
-# Created:  Thu Oct 06 11:21:27 2016 -0400
+# Author:  Benjamin Bengfort <bbengfort@districtdatalabs.com>
+# Author:  @thekylesaurus
+# Created: Thu Oct 06 11:21:27 2016 -0400
 #
 # Copyright (C) 2016 District Data Labs
 # For license information, see LICENSE.txt
@@ -17,134 +18,279 @@
 ## Imports
 ##########################################################################
 
-import sys
 import pytest
 import numpy as np
 
-from tests.base import VisualTestCase
 from yellowbrick.features.pcoords import *
-from tests.dataset import DatasetMixin
+
+from tests.base import VisualTestCase
+from tests.dataset import DatasetMixin, Dataset
+from sklearn.datasets import make_classification
+
+
+try:
+    import pandas as pd
+except ImportError:
+    pd = None
+
 
 ##########################################################################
-## Parallel Coordinates Tests
+## Fixtures
 ##########################################################################
 
+@pytest.fixture(scope='class')
+def dataset(request):
+    """
+    Creates a random multiclass classification dataset fixture
+    """
+    X, y = make_classification(
+        n_samples=200, n_features=5, n_informative=4, n_redundant=0,
+        n_classes=3, n_clusters_per_class=1, random_state=451, flip_y=0,
+        class_sep=3, scale=np.array([1.0, 2.0, 100.0, 20.0, 1.0])
+    )
 
-class ParallelCoordinatesTests(VisualTestCase, DatasetMixin):
+    dataset = Dataset(X, y)
+    request.cls.dataset = dataset
 
-    X = np.array(
-            [[ 2.318, 2.727, 4.260, 7.212, 4.792],
-             [ 2.315, 2.726, 4.295, 7.140, 4.783,],
-             [ 2.315, 2.724, 4.260, 7.135, 4.779,],
-             [ 2.110, 3.609, 4.330, 7.985, 5.595,],
-             [ 2.110, 3.626, 4.330, 8.203, 5.621,],
-             [ 2.110, 3.620, 4.470, 8.210, 5.612,]]
-        )
 
-    y = np.array([1, 1, 0, 1, 0, 0])
+
+##########################################################################
+## Parallel Coordinates Tests
+##########################################################################
+
+@pytest.mark.usefixtures('dataset')
+class TestParallelCoordinates(VisualTestCase, DatasetMixin):
+    """
+    Test the ParallelCoordinates visualizer
+    """
 
     def test_parallel_coords(self):
         """
-        Assert no errors occur during parallel coordinates integration
+        Test images closeness on random 3 class dataset
         """
         visualizer = ParallelCoordinates()
-        visualizer.fit_transform(self.X, self.y)
+        visualizer.fit_transform(self.dataset.X, self.dataset.y)
         visualizer.poof()
         self.assert_images_similar(visualizer, tol=0.25)
 
-    @pytest.mark.xfail(
-        sys.platform == 'win32', reason="images not close on windows"
-    )
-    def test_normalized_pcoords(self):
+    def test_parallel_coords_fast(self):
         """
-        Assert no errors occur using 'normalize' argument
+        Test images closeness on random 3 class dataset in fast mode
         """
-        visualizer = ParallelCoordinates(normalize='l2')
-        visualizer.fit_transform(self.X, self.y)
+        visualizer = ParallelCoordinates(fast=True)
+        visualizer.fit_transform(self.dataset.X, self.dataset.y)
+        visualizer.poof()
+        self.assert_images_similar(visualizer, tol=0.25)
+
+    def test_alpha(self):
+        """
+        Test image closeness on opaque alpha for random 3 class dataset
+        """
+        visualizer = ParallelCoordinates(alpha=1.0)
+        visualizer.fit_transform(self.dataset.X, self.dataset.y)
+        visualizer.poof()
+        self.assert_images_similar(visualizer, tol=0.25)
+
+    def test_alpha_fast(self):
+        """
+        Test image closeness on opaque alpha for random 3 class dataset in fast mode
+        """
+        visualizer = ParallelCoordinates(alpha=1.0, fast=True)
+        visualizer.fit_transform(self.dataset.X, self.dataset.y)
+        visualizer.poof()
+        self.assert_images_similar(visualizer, tol=0.25)
+
+    def test_labels(self):
+        """
+        Test image closeness when class and feature labels are supplied
+        """
+        visualizer = ParallelCoordinates(
+            classes=['a', 'b', 'c'], features=['f1', 'f2', 'f3', 'f4', 'f5']
+        )
+        visualizer.fit_transform(self.dataset.X, self.dataset.y)
         visualizer.poof()
         self.assert_images_similar(visualizer)
 
-    def test_normalized_pcoords_invalid_arg(self):
+    def test_labels_fast(self):
+        """
+        Test image closeness when class and feature labels are supplied in fast mode
+        """
+        visualizer = ParallelCoordinates(
+            classes=['a', 'b', 'c'], features=['f1', 'f2', 'f3', 'f4', 'f5'], fast=True
+        )
+        visualizer.fit_transform(self.dataset.X, self.dataset.y)
+        visualizer.poof()
+        self.assert_images_similar(visualizer)
+
+    def test_normalized_l2(self):
+        """
+        Test image closeness on l2 normalized 3 class dataset
+        """
+        visualizer = ParallelCoordinates(normalize='l2')
+        visualizer.fit_transform(self.dataset.X, self.dataset.y)
+        visualizer.poof()
+        self.assert_images_similar(visualizer, tol=0.25)
+
+    def test_normalized_l2_fast(self):
+        """
+        Test image closeness on l2 normalized 3 class dataset in fast mode
+        """
+        visualizer = ParallelCoordinates(normalize='l2', fast=True)
+        visualizer.fit_transform(self.dataset.X, self.dataset.y)
+        visualizer.poof()
+        self.assert_images_similar(visualizer, tol=0.25)
+
+    def test_normalized_minmax(self):
+        """
+        Test image closeness on minmax normalized 3 class dataset
+        """
+        visualizer = ParallelCoordinates(normalize='minmax')
+        visualizer.fit_transform(self.dataset.X, self.dataset.y)
+        visualizer.poof()
+        self.assert_images_similar(visualizer, tol=0.25)
+
+    def test_normalized_minmax_fast(self):
+        """
+        Test image closeness on minmax normalized 3 class dataset in fast mode
+        """
+        visualizer = ParallelCoordinates(normalize='minmax', fast=True)
+        visualizer.fit_transform(self.dataset.X, self.dataset.y)
+        visualizer.poof()
+        self.assert_images_similar(visualizer, tol=0.25)
+
+    @pytest.mark.skipif(pd is None, reason="test requires pandas")
+    def test_pandas_integration_sampled(self):
+        """
+        Test on a real dataset with pandas DataFrame and Series sampled for speed
+        """
+        df = self.load_pandas("occupancy")
+
+        target = "occupancy"
+        features = [
+            'temperature', 'relative humidity', 'light', 'C02', 'humidity'
+        ]
+
+        X = df[features]
+        y = pd.Series([
+            'occupied' if yi == 1 else 'unoccupied' for yi in df[target]
+        ])
+
+        assert isinstance(X, pd.DataFrame)
+        assert isinstance(y, pd.Series)
+
+        oz = ParallelCoordinates(sample=0.05, shuffle=True, random_state=4291)
+        oz.fit_transform(X, y)
+        oz.poof()
+
+        self.assert_images_similar(oz, tol=0.1)
+
+    @pytest.mark.skipif(pd is None, reason="test requires pandas")
+    def test_pandas_integration_fast(self):
+        """
+        Test on a real dataset with pandas DataFrame and Series in fast mode
+        """
+        df = self.load_pandas("occupancy")
+
+        target = "occupancy"
+        features = [
+            'temperature', 'relative humidity', 'light', 'C02', 'humidity'
+        ]
+
+        X = df[features]
+        y = pd.Series([
+            'occupied' if yi == 1 else 'unoccupied' for yi in df[target]
+        ])
+
+        assert isinstance(X, pd.DataFrame)
+        assert isinstance(y, pd.Series)
+
+        oz = ParallelCoordinates(fast=True)
+        oz.fit_transform(X, y)
+        oz.poof()
+
+        self.assert_images_similar(oz, tol=0.1)
+
+    def test_normalized_invalid_arg(self):
         """
         Invalid argument to 'normalize' should raise
         """
         with self.assertRaises(YellowbrickValueError):
             ParallelCoordinates(normalize='foo')
 
-    def test_pcoords_sample_int(self):
+    def test_sample_int(self):
         """
         Assert no errors occur using integer 'sample' argument
         """
         visualizer = ParallelCoordinates(sample=10)
-        visualizer.fit_transform(self.X, self.y)
+        visualizer.fit_transform(self.dataset.X, self.dataset.y)
 
-    def test_pcoords_sample_int_shuffle(self):
+    def test_sample_int_shuffle(self):
         """
         Assert no errors occur using integer 'sample' argument and shuffle, with different random_state args
         """
         visualizer = ParallelCoordinates(sample=3, shuffle=True)
-        visualizer.fit_transform(self.X, self.y)
+        visualizer.fit_transform(self.dataset.X, self.dataset.y)
 
         visualizer = ParallelCoordinates(sample=3, shuffle=True, random_state=444)
-        visualizer.fit_transform(self.X, self.y)
+        visualizer.fit_transform(self.dataset.X, self.dataset.y)
 
         visualizer = ParallelCoordinates(sample=3, shuffle=True, random_state=np.random.RandomState())
-        visualizer.fit_transform(self.X, self.y)
+        visualizer.fit_transform(self.dataset.X, self.dataset.y)
 
-    def test_pcoords_sample_int_shuffle_false(self):
+    def test_sample_int_shuffle_false(self):
         """
         Assert no errors occur using integer 'sample' argument and shuffle, with different random_state args
         """
         visualizer = ParallelCoordinates(sample=3, shuffle=False)
-        visualizer.fit_transform(self.X, self.y)
+        visualizer.fit_transform(self.dataset.X, self.dataset.y)
 
         visualizer = ParallelCoordinates(sample=3, shuffle=False, random_state=444)
-        visualizer.fit_transform(self.X, self.y)
+        visualizer.fit_transform(self.dataset.X, self.dataset.y)
 
         visualizer = ParallelCoordinates(sample=3, shuffle=False, random_state=np.random.RandomState())
-        visualizer.fit_transform(self.X, self.y)
+        visualizer.fit_transform(self.dataset.X, self.dataset.y)
 
-    def test_pcoords_sample_int_invalid(self):
+    def test_sample_int_invalid(self):
         """
-        Negative int values should raise
+        Negative int values should raise exception
         """
         with self.assertRaises(YellowbrickValueError):
             ParallelCoordinates(sample=-1)
 
-    def test_pcoords_sample_float(self):
+    def test_sample_float(self):
         """
         Assert no errors occur using float 'sample' argument
         """
         visualizer = ParallelCoordinates(sample=0.5)
-        visualizer.fit_transform(self.X, self.y)
+        visualizer.fit_transform(self.dataset.X, self.dataset.y)
 
-    def test_pcoords_sample_float_shuffle(self):
+    def test_sample_float_shuffle(self):
         """
         Assert no errors occur using float 'sample' argument and shuffle, with different random_state args
         """
         visualizer = ParallelCoordinates(sample=0.5, shuffle=True)
-        visualizer.fit_transform(self.X, self.y)
+        visualizer.fit_transform(self.dataset.X, self.dataset.y)
 
         visualizer = ParallelCoordinates(sample=0.5, shuffle=True, random_state=444)
-        visualizer.fit_transform(self.X, self.y)
+        visualizer.fit_transform(self.dataset.X, self.dataset.y)
 
         visualizer = ParallelCoordinates(sample=0.5, shuffle=True, random_state=np.random.RandomState())
-        visualizer.fit_transform(self.X, self.y)
+        visualizer.fit_transform(self.dataset.X, self.dataset.y)
 
-    def test_pcoords_sample_float_shuffle_false(self):
+    def test_sample_float_shuffle_false(self):
         """
         Assert no errors occur using float 'sample' argument and shuffle, with different random_state args
         """
         visualizer = ParallelCoordinates(sample=0.5, shuffle=False)
-        visualizer.fit_transform(self.X, self.y)
+        visualizer.fit_transform(self.dataset.X, self.dataset.y)
 
         visualizer = ParallelCoordinates(sample=0.5, shuffle=False, random_state=444)
-        visualizer.fit_transform(self.X, self.y)
+        visualizer.fit_transform(self.dataset.X, self.dataset.y)
 
         visualizer = ParallelCoordinates(sample=0.5, shuffle=False, random_state=np.random.RandomState())
-        visualizer.fit_transform(self.X, self.y)
+        visualizer.fit_transform(self.dataset.X, self.dataset.y)
 
-    def test_pcoords_sample_float_invalid(self):
+    def test_sample_float_invalid(self):
         """
         Float values for 'sample' argument outside [0,1] should raise.
         """
@@ -153,37 +299,13 @@ def test_pcoords_sample_float_invalid(self):
         with self.assertRaises(YellowbrickValueError):
             ParallelCoordinates(sample=1.1)
 
-    def test_pcoords_sample_invalid_type(self):
+    def test_sample_invalid_type(self):
         """
         Non-numeric values for 'sample' argument should raise.
         """
         with self.assertRaises(YellowbrickTypeError):
             ParallelCoordinates(sample='foo')
 
-    @pytest.mark.xfail(
-        sys.platform == 'win32', reason="images not close on windows"
-    )
-    def test_integrated_pcoords(self):
-        """
-        Test parallel coordinates on a real data set (downsampled for speed)
-        """
-        occupancy = self.load_data('occupancy')
-
-        X = occupancy[[
-            "temperature", "relative_humidity", "light", "C02", "humidity"
-        ]]
-
-        y = occupancy['occupancy'].astype(int)
-
-        # Convert X to an ndarray
-        X = X.copy().view((float, len(X.dtype.names)))
-
-        # Test the visualizer
-        visualizer = ParallelCoordinates(sample=200)
-        visualizer.fit_transform(X, y)
-        visualizer.poof()
-        self.assert_images_similar(visualizer)
-
     @staticmethod
     def test_static_subsample():
         """
@@ -244,4 +366,3 @@ def test_static_subsample():
         assert np.array_equal(Xprime, X[yprime.flatten(), :])
         assert len(Xprime) == ntotal * sample
         assert len(yprime) == ntotal * sample
-