Repository: mozilla/bugbug

Issue:
Extract feature importances for a classification
When making a classification, we should also return the features that were most important for the decision.


Discussion:

Resolving patch:
diff --git a/bugbug/model.py b/bugbug/model.py
--- a/bugbug/model.py
+++ b/bugbug/model.py
@@ -4,6 +4,7 @@
 # You can obtain one at http://mozilla.org/MPL/2.0/.
 
 import numpy as np
+import shap
 from imblearn.metrics import classification_report_imbalanced
 from imblearn.under_sampling import RandomUnderSampler
 from sklearn import metrics
@@ -69,10 +70,17 @@ def bugs():
 
         feature_names = self.get_feature_names()
         if len(feature_names):
+            explainer = shap.TreeExplainer(self.clf)
+            shap_values = explainer.shap_values(X_train)
+
             print('Feature ranking (top 20 features):')
-            indices = np.argsort(self.clf.feature_importances_)[::-1][:20]
-            for i in range(len(indices)):
-                print('{}. \'{}\' ({})'.format(i + 1, feature_names[indices[i]], self.clf.feature_importances_[indices[i]]))
+            # Calculate the values that represent the fraction of the model output variability attributable
+            # to each feature across the whole dataset.
+            shap_sums = np.abs(shap_values).sum(0)
+            rel_shap_sums = shap_sums / shap_sums.sum()
+            indices = np.argsort(rel_shap_sums)[::-1][:20]
+            for i, index in enumerate(indices):
+                print('{}. \'{}\' ({})'.format(i + 1, feature_names[index], rel_shap_sums[index]))
 
         y_pred = self.clf.predict(X_test)
 
@@ -109,7 +117,7 @@ def load(model_file_name):
     def overwrite_classes(self, bugs, classes, probabilities):
         return classes
 
-    def classify(self, bugs, probabilities=False):
+    def classify(self, bugs, probabilities=False, importances=False):
         assert bugs is not None
         assert self.extraction_pipeline is not None and self.clf is not None, 'The module needs to be initialized first'
 
@@ -124,4 +132,18 @@ def classify(self, bugs, probabilities=False):
         else:
             classes = self.clf.predict(X)
 
-        return self.overwrite_classes(bugs, classes, probabilities)
+        classes = self.overwrite_classes(bugs, classes, probabilities)
+
+        if importances:
+            explainer = shap.TreeExplainer(self.clf)
+            shap_values = explainer.shap_values(X)
+
+            shap_sums = shap_values.sum(0)
+            abs_shap_sums = np.abs(shap_sums)
+            rel_shap_sums = abs_shap_sums / abs_shap_sums.sum()
+            indices = np.argsort(abs_shap_sums)[::-1]
+            importances = [(index, shap_sums[index] > 0, rel_shap_sums[index]) for index in indices]
+
+            return classes, importances
+
+        return classes
diff --git a/bugbug/models/bug.py b/bugbug/models/bug.py
--- a/bugbug/models/bug.py
+++ b/bugbug/models/bug.py
@@ -135,8 +135,8 @@ def get_feature_names(self):
     def overwrite_classes(self, bugs, classes, probabilities):
         for i, bug in enumerate(bugs):
             if any(keyword in bug['keywords'] for keyword in ['regression', 'talos-regression']) or ('cf_has_regression_range' in bug and bug['cf_has_regression_range'] == 'yes'):
-                classes[i] = 1 if not probabilities else [1., 0.]
+                classes[i] = 1 if not probabilities else [0., 1.]
             elif 'feature' in bug['keywords']:
-                classes[i] = 0 if not probabilities else [0., 1.]
+                classes[i] = 0 if not probabilities else [1., 0.]
 
         return classes
diff --git a/run.py b/run.py
--- a/run.py
+++ b/run.py
@@ -5,6 +5,8 @@
 
 import argparse
 
+import numpy as np
+
 from bugbug import bugzilla
 from bugbug import db
 from bugbug import repository  # noqa
@@ -49,9 +51,14 @@
     if args.classify:
         for bug in bugzilla.get_bugs():
             print('https://bugzilla.mozilla.org/show_bug.cgi?id={} - {}'.format(bug['id'], bug['summary']))
-            c = model.classify(bug)
-            if c == 1:
-                print('Positive!')
+            probas, importances = model.classify(bug, probabilities=True, importances=True)
+
+            feature_names = model.get_feature_names()
+            for i, (index, is_positive, contrib) in enumerate(importances[:20]):
+                print('{}. \'{}\' ({}{})'.format(i + 1, feature_names[index], '+' if is_positive else '-', contrib))
+
+            if np.argmax(probas) == 1:
+                print('Positive! {}'.format(probas))
             else:
-                print('Negative!')
+                print('Negative! {}'.format(probas))
             input()


Test patch: