Repository: DistrictDataLabs/yellowbrick

Issue:
Enhanced FeatureImportances for Multidimensional Coefficients
**Describe the solution you'd like**
Create an enhanced version of the Feature Importances visualizer to allow the user to see the coefficients on a per-class basis.

**Is your feature request related to a problem? Please describe.**
Some estimators returns a multidimensional array for `coef_` , like scikit-learn's [`LogisticRegression`](http://scikit-learn.org/stable/modules/generated/sklearn.linear_model.LogisticRegression.html), in anticipation that there will be multiple classes and decision function coefficients for each class. In the case of a binary classification, the shape of `coef_` is (1, n_features), for multiclass it will be (n_classes, n_features). In #490, we addressed the bug raised in #485 by simply averaging the per-class feature importances for each feature in the case of multidimensional coefficients (as with LogisticRegression). However, we could create an enhanced version of this visualizer that would allow the user to see the coefficients on a per-class basis, depicted as a stacked histogram.

**Examples**
The `fit` method now checks to see if the `feature_importances_` are a multidimensional array. We could add a param such as `hist=True` that instead of averaging would stack the importances per class using something like [this](https://stackoverflow.com/questions/16653815/horizontal-stacked-bar-chart-in-matplotlib/16654564#16654564) in our `draw` method. If we go this route, we may want to consider adding a target labels attribute to the class à la the [`ClassificationScoreVisualizer`](https://github.com/DistrictDataLabs/yellowbrick/blob/develop/yellowbrick/classifier/base.py#L67).  



Discussion:
@rebeccabilbro If nobody is working on this, I would like to give it a try~
@zjpoh That would be great! Go ahead and open up a PR that references this issue when you're ready.

Resolving patch:
diff --git a/yellowbrick/features/importances.py b/yellowbrick/features/importances.py
--- a/yellowbrick/features/importances.py
+++ b/yellowbrick/features/importances.py
@@ -24,9 +24,10 @@
 import numpy as np
 import matplotlib.pyplot as plt
 
-from yellowbrick.utils import is_dataframe
+from yellowbrick.utils import is_dataframe, is_classifier
 from yellowbrick.base import ModelVisualizer
 from yellowbrick.exceptions import YellowbrickTypeError, NotFitted
+from ..style.palettes import color_palette
 
 
 ##########################################################################
@@ -72,6 +73,11 @@ class FeatureImportances(ModelVisualizer):
         The label for the X-axis. If None is automatically determined by the
         underlying model and options provided.
 
+    stack : bool, default: False
+        If true and the classifier returns multi-class feature importance,
+        then a stacked bar plot is plotted; otherwise the mean of the
+        feature importance across classes are plotted.
+
     kwargs : dict
         Keyword arguments that are passed to the base class and may influence
         the visualization as defined in other Visualizers.
@@ -84,6 +90,9 @@ class FeatureImportances(ModelVisualizer):
     feature_importances_ : np.array
         The numeric value of the feature importance computed by the model
 
+    classes_ : np.array
+        The classees labeled. Is not None only for classifier.
+
     Examples
     --------
 
@@ -94,13 +103,13 @@ class FeatureImportances(ModelVisualizer):
     """
 
     def __init__(self, model, ax=None, labels=None, relative=True,
-                 absolute=False, xlabel=None, **kwargs):
+                 absolute=False, xlabel=None, stack=False, **kwargs):
         super(FeatureImportances, self).__init__(model, ax, **kwargs)
 
         # Data Parameters
         self.set_params(
             labels=labels, relative=relative, absolute=absolute,
-            xlabel=xlabel,
+            xlabel=xlabel, stack=stack
         )
 
     def fit(self, X, y=None, **kwargs):
@@ -129,15 +138,20 @@ def fit(self, X, y=None, **kwargs):
         # Get the feature importances from the model
         self.feature_importances_ = self._find_importances_param()
 
-        # If feature importances is a multidim array, we're expecting a shape of
-        # (n_classes, n_features) therefore we flatten by taking the average by
-        # column to get shape (n_features,)  (see LogisticRegression)
-        if self.feature_importances_.ndim > 1:
-            self.feature_importances_ = np.mean(self.feature_importances_, axis=0)
+        # Get the classes from the model
+        if is_classifier(self):
+            self.classes_ = self._find_classes_param()
+        else:
+            self.classes_ = None
+            self.stack = False
 
-        # TODO - as an alternative to the above flattening approach, explore an
-        # alternative visualize that uses the array shape to create a stacked bar chart
-        # of feature importances for each class/feature combination
+        # If self.stack = True and feature importances is a multidim array,
+        # we're expecting a shape of (n_classes, n_features)
+        # therefore we flatten by taking the average by
+        # column to get shape (n_features,)  (see LogisticRegression)
+        if not self.stack and self.feature_importances_.ndim > 1:
+            self.feature_importances_ = np.mean(self.feature_importances_,
+                                                axis=0)
 
         # Apply absolute value filter before normalization
         if self.absolute:
@@ -145,7 +159,7 @@ def fit(self, X, y=None, **kwargs):
 
         # Normalize features relative to the maximum
         if self.relative:
-            maxv = self.feature_importances_.max()
+            maxv = np.abs(self.feature_importances_).max()
             self.feature_importances_ /= maxv
             self.feature_importances_ *= 100.0
 
@@ -164,9 +178,14 @@ def fit(self, X, y=None, **kwargs):
             self.features_ = np.array(self.labels)
 
         # Sort the features and their importances
-        sort_idx = np.argsort(self.feature_importances_)
-        self.features_ = self.features_[sort_idx]
-        self.feature_importances_ = self.feature_importances_[sort_idx]
+        if self.stack:
+            sort_idx = np.argsort(np.mean(self.feature_importances_, 0))
+            self.features_ = self.features_[sort_idx]
+            self.feature_importances_ = self.feature_importances_[:, sort_idx]
+        else:
+            sort_idx = np.argsort(self.feature_importances_)
+            self.features_ = self.features_[sort_idx]
+            self.feature_importances_ = self.feature_importances_[sort_idx]
 
         # Draw the feature importances
         self.draw()
@@ -185,7 +204,27 @@ def draw(self, **kwargs):
         pos = np.arange(self.features_.shape[0]) + 0.5
 
         # Plot the bar chart
-        self.ax.barh(pos, self.feature_importances_, align='center')
+        if self.stack:
+            colors = color_palette(kwargs.pop('colors', None),
+                                   len(self.classes_))
+            zeros = np.zeros(self.feature_importances_.shape[1])
+            left_arr = np.zeros((self.feature_importances_.shape[1], 2))
+
+            for idx in range(len(self.feature_importances_)):
+                left = [
+                    left_arr[j, int(self.feature_importances_[idx][j] > 0)]
+                    for j in range(len(self.feature_importances_[idx]))
+                ]
+
+                self.ax.barh(pos, self.feature_importances_[idx], left=left,
+                             color=colors[idx], label=self.classes_[idx])
+
+                left_arr[:, 0] += np.minimum(self.feature_importances_[idx],
+                                             zeros)
+                left_arr[:, 1] += np.maximum(self.feature_importances_[idx],
+                                             zeros)
+        else:
+            self.ax.barh(pos, self.feature_importances_, align='center')
 
         # Set the labels for the bars
         self.ax.set_yticks(pos)
@@ -207,9 +246,27 @@ def finalize(self, **kwargs):
         # Remove the ygrid
         self.ax.grid(False, axis='y')
 
+        if self.stack:
+            plt.legend(bbox_to_anchor=(1.04, 0.5), loc="center left")
         # Ensure we have a tight fit
         plt.tight_layout()
 
+    def _find_classes_param(self):
+        """
+        Searches the wrapped model for the classes_ parameter.
+        """
+        for attr in ["classes_"]:
+            try:
+                return getattr(self.estimator, attr)
+            except AttributeError:
+                continue
+
+        raise YellowbrickTypeError(
+            "could not find classes_ param on {}".format(
+                self.estimator.__class__.__name__
+            )
+        )
+
     def _find_importances_param(self):
         """
         Searches the wrapped model for the feature importances parameter.
@@ -257,7 +314,8 @@ def _is_fitted(self):
 ##########################################################################
 
 def feature_importances(model, X, y=None, ax=None, labels=None,
-                        relative=True, absolute=False, xlabel=None, **kwargs):
+                        relative=True, absolute=False, xlabel=None,
+                        stack=False, **kwargs):
     """
     Displays the most informative features in a model by showing a bar chart
     of features ranked by their importances. Although primarily a feature
@@ -297,6 +355,11 @@ def feature_importances(model, X, y=None, ax=None, labels=None,
         The label for the X-axis. If None is automatically determined by the
         underlying model and options provided.
 
+    stack : bool, default: False
+        If true and the classifier returns multi-class feature importance,
+        then a stacked bar plot is plotted; otherwise the mean of the
+        feature importance across classes are plotted.
+
     kwargs : dict
         Keyword arguments that are passed to the base class and may influence
         the visualization as defined in other Visualizers.
@@ -308,7 +371,7 @@ def feature_importances(model, X, y=None, ax=None, labels=None,
     """
     # Instantiate the visualizer
     visualizer = FeatureImportances(
-        model, ax, labels, relative, absolute, xlabel, **kwargs)
+        model, ax, labels, relative, absolute, xlabel, stack, **kwargs)
 
     # Fit and transform the visualizer (calls draw)
     visualizer.fit(X, y)


Test patch:
diff --git a/tests/baseline_images/test_features/test_importances/test_multi_coefs_stacked.png b/tests/baseline_images/test_features/test_importances/test_multi_coefs_stacked.png
new file mode 100644
Binary files /dev/null and b/tests/baseline_images/test_features/test_importances/test_multi_coefs_stacked.png differ
diff --git a/tests/test_features/test_importances.py b/tests/test_features/test_importances.py
--- a/tests/test_features/test_importances.py
+++ b/tests/test_features/test_importances.py
@@ -28,8 +28,9 @@
 from yellowbrick.exceptions import NotFitted
 from yellowbrick.features.importances import *
 
-from sklearn.base import BaseEstimator
-from sklearn.linear_model import Lasso
+from sklearn.datasets import load_iris
+from sklearn.base import BaseEstimator, ClassifierMixin
+from sklearn.linear_model import LogisticRegression, Lasso
 from sklearn.ensemble import RandomForestClassifier
 from sklearn.ensemble import GradientBoostingClassifier
 
@@ -248,6 +249,24 @@ def test_multi_coefs(self):
 
         npt.assert_equal(visualizer.feature_importances_.ndim, 1)
 
+    @pytest.mark.xfail(
+        sys.platform == 'win32', reason="images not close on windows"
+    )
+    def test_multi_coefs_stacked(self):
+        """
+        Test stack plot with multidimensional coefficients
+        """
+        X_iris, y_iris = load_iris(True)
+        X_iris_pd = pd.DataFrame(X_iris, columns=['f1', 'f2', 'f3', 'f4'])
+
+        viz = FeatureImportances(LogisticRegression(), stack=True)
+        viz.fit(X_iris_pd, y_iris)
+        viz.poof()
+
+        npt.assert_equal(viz.feature_importances_.shape, (3, 4))
+        self.assert_images_similar(viz)
+
+
     @pytest.mark.skipif(pd is None, reason="pandas is required for this test")
     def test_fit_dataframe(self):
         """
@@ -350,6 +369,21 @@ def test_find_importances_param_not_found(self):
         with pytest.raises(YellowbrickTypeError):
             visualizer._find_importances_param()
 
+    def test_find_classes_param_not_found(self):
+        """
+        Raises an exception when classes param not found
+        """
+        model = MockClassifier()
+        visualizer = FeatureImportances(model)
+
+        assert not hasattr(model, 'classes_')
+
+        e = 'could not find classes_ param on {}'.format(
+            visualizer.estimator.__class__.__name__
+        )
+        with pytest.raises(YellowbrickTypeError, match=e):
+            visualizer._find_classes_param()
+
     def test_xlabel(self):
         """
         Check the various xlabels are sensical
@@ -421,3 +455,10 @@ def make_importance_param(self, name='feature_importances_', value=None):
 
     def fit(self, X, y=None, **kwargs):
         return self
+
+
+class MockClassifier(BaseEstimator, ClassifierMixin):
+    """
+    Creates empty classifier.
+    """
+    pass