This notebook intends to provide an overview of SHAP, a framework to improve model explainability, by focusing on the following four topics:
Many frameworks have been proposed to help with improving the explainability and transparence of machine learning models. SHAP (SHapley Additive exPlanations) is one of the most popular frameworks that aims at providing explainability of machine learning algorithms. SHAP takes a game-theory-inspired approach to explain the prediction of a machine learning model. The SHAP framework is now available in the open-source python library, SHAP, for everyone wants to understand how their models make prediction (“uncovering the blackbox”).
SHAP explains the output of a machine learning model by using Shapley values, a method from cooperative game theory. Shapley values is a solution to fairly distributing payoff to participating players based on the contributions by each player as they work in cooperation with each other to obtain the grand payoff.
The main idea behind SHAP framework is to explain Machine Learning models by measuring how much each feature contributes to the model prediction using Shapley values. The SHAP framework considers making a prediction for an instance in the dataset as a game, the gain (can be positive of negative) from playing the game is the difference between the actual prediction on this particular instance and the average prediction for all instances (base value). SHAP treats each feature value of the instance as a "player", who works with each other feature value to receive the gain (= the difference between predicted value and the base value). As different player (feature value) contributes to the game differently, Shapley values is the average marginal contribution by each player (feature value) across all possible coalitions. In short, Shapley values is calculated at instance level, and with the current set of feature values for a given instance, the marginal contribution of a feature value to the difference between the actual prediction on this particular instance and the base value is the estimated Shapley value for that feature value.
For detailed explanation of how SHAP values are calculated: https://vknight.org/Year_3_game_theory_course/Content/Chapter_16_Cooperative_games/ https://christophm.github.io/interpretable-ml-book/shapley.html#general-idea
SHAP explains the output of machine learning models of all kinds.
• Computes SHAP Values for model features at instance level • Computes SHAP Interaction Values including the interaction terms of features (only support SHAP TreeExplainer for now) • Visualize feature importance through plotting SHAP values: o shap.summary_plot o shap.dependence_plot o shap.force_plot o shap.decision_plot o shap.waterfall_plot o shap.image_plot
Note: The Shap values computed by SHAP library is in the same unit of the model output, which means it varies by model. It could be “raw”, “probability”, “log-odds” or etc. You have the option to specify it when initiating a SHAP Explainer by setting parameter model_output.
To create example SHAP plots (see belows), I am using the California Housing Prices dataset from Kaggle and built a binary classification model(GradientBoostingClassifier from scikit-learn). The original target variable median_house_price (continuous) is converted to a categorical variable price_high_low (label 0 or 1), indicating the median_house_price is above 50 percentile or below 50 percentiles. The model is trained to classifier whether a house is at the higher price range or lower price range.
import xgboost as xgb
import numpy as np
import pandas as pd
import random
from itertools import combinations
import sklearn
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from pandas.plotting import scatter_matrix
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.multioutput import MultiOutputClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn import utils
import os
import tarfile
import urllib
# libraries for plotting
import matplotlib.pyplot as plt
import seaborn as sn
import matplotlib.colors as mcolors
from matplotlib import rcParams
# import
import random
from itertools import combinations
import shap
shap.initjs()
import warnings
warnings.filterwarnings('ignore')
# Load data
# Data source: https://www.kaggle.com/camnugent/california-housing-prices
csv_path= "/Users/yuanhongzhang/Folders/xpresso/xgb_component/datasets/housing/housing.csv"
housing = pd.read_csv(csv_path)
Create two new variables:
# Create a new target variable price_high_low (binary, 1 for median house value above 50 percentile)
housing["price_high_low"]= pd.qcut(housing.median_house_value, q=[0,0.5, 1])
housing["price_high_low"]= housing["price_high_low"].astype('category').cat.codes
housing["price_high_low_m"]= pd.qcut(housing.median_house_value, q=[0,0.25, 0.5, 0.75, 1])
housing["price_high_low_m"]= housing["price_high_low_m"].astype('category').cat.codes
y = housing.loc[:,["median_house_value", "price_high_low", "price_high_low_m"]].copy()
X= housing.drop(labels=["median_house_value", "price_high_low","price_high_low_m"] , axis= 1)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.33, random_state=42)
#identify and separate numerical and categorical columns
num_attribs = X_train.select_dtypes(include=[np.number]).columns
cat_attribs = [col for col in X_train.columns if col not in num_attribs]
# onehotencoder for categorical variables
enc = OneHotEncoder()
cat_transformed = enc.fit_transform(X_train[cat_attribs]).toarray()
cat_attribs_enc = [str(col) for col in enc.categories_[0]]
# create a pipeline for feature transformation
imputer = SimpleImputer(strategy="median")
#feature engineering pipeline for numerical features
num_pipeline = Pipeline([
('imputer', SimpleImputer(strategy="median")),
('std_scaler', StandardScaler()),
])
#build full pipelines for both numerical and categorical features
full_pipeline = ColumnTransformer([
("num", num_pipeline, num_attribs),
("cat", OneHotEncoder(), cat_attribs),
])
# transform training set
train_x_trans = full_pipeline.fit_transform(X_train)
# transform test set
test_x_trans = full_pipeline.transform(X_test)
# get the list of new column names
new_col_list = num_attribs.values.tolist() + cat_attribs_enc
# adding column names back to train x
train_x_trans_df = pd.DataFrame(train_x_trans, columns =new_col_list )
# adding column names back to test x
test_x_trans_df = pd.DataFrame(test_x_trans, columns =new_col_list )
train_x_trans_df.head()
# columns available
new_col_list
Train a gradient boosting model to classify whether the house value is high or low
from sklearn.ensemble import GradientBoostingClassifier
gb_clf = GradientBoostingClassifier(random_state=0).fit(train_x_trans,
y_train["price_high_low"])
# select number of data samples to be explained
select = range(len(test_x_trans))
# create background data
background_data = shap.sample(test_x_trans, nsamples=100)
# create SHAP KernelExplainer
explainer = shap.TreeExplainer(gb_clf, background_data)
# Get SHAP values for the samples to be explain
shap_values = explainer.shap_values(test_x_trans[select])
# Expected value
expected_value = explainer.expected_value
print(f"Explainer expected value: {expected_value}")
# SHAP value
shap_values.shape
# select number of data samples to be explained
select = range(len(test_x_trans))
# You can provide a background dataset to the Explainer
#background_data = shap.sample(test_x_trans, nsamples=100)
# create SHAP KernelExplainer
explainer = shap.TreeExplainer(gb_clf)
# Get SHAP values for the samples to be explain
shap_values = explainer.shap_values(test_x_trans[select])
# Get SHAP Interaction values
shap_interaction_values = explainer.shap_interaction_values(test_x_trans[select])
# The first argument is the matrix of SHAP values (it is the same shape as the data matrix)
# The second argument is the data matrix to explain (a pandas dataframe or numpy array)
# The third argument is provide feature names (optional)
# The forth argument is to deferred the showing function if you want to customize the plot
shap.summary_plot(shap_values, test_x_trans[select], feature_names= new_col_list,
show=False)
plt.title("Feature Importance by SHAP Summary Plot")
plt.ylabel("Features")
plt.savefig("shap_values_summary_plot.png")
plt.show()
The summary plot (dot type) displays the SHAP values for model features at the individual samples/instances level.
The summary plot (bar type) displays the SHAP values for model features at aggregate feature level (by averaging).
# Plot SHAP summary plot with bar type
shap.summary_plot(shap_values, test_x_trans[select], feature_names= new_col_list,
plot_type="bar", show= False )
plt.title("Feature Importance by SHAP Values(mean absolute value)")
plt.show()
SHAP offers the option to take into account the effect of interaction terms on model prediction. The interpretation of this plot is the same as the regular summary plot above, with the additional interaction terms.
shap.summary_plot(shap_interaction_values, test_x_trans[select],
plot_type="compact_dot",
feature_names= new_col_list, show =False)
plt.title("Features by SHAP Values including Interaction Values")
plt.show()
Here I am going to rank features based on SHAP Values, the mean absolute value of shap values.
# Rank features based on SHAP Values (mean of the absolute value of shap values)
shap_values_abs = (np.abs(shap_values)).mean(axis = 0)
sorted_features = [f for _,f in sorted(zip(shap_values_abs, new_col_list), reverse=True)]
sorted_shap_values = [round(v,4) for v,_ in sorted(zip(shap_values_abs, new_col_list),
reverse=True)]
#present features with their SHAP values in a sorted dataframe
pd.DataFrame({"features":sorted_features,
"SHAP_values" : sorted_shap_values})
A dependence plot is a scatter plot that shows the effect a single feature on the model prediction. Here are some great examples from SHAP github pages
# The first argument is the feature name or index of the feature we want to plot
# The second argument is the matrix of SHAP values (it is the same shape as the data matrix)
# The third argument is the data matrix (a pandas dataframe or numpy array)
for i in range(3):
shap.dependence_plot(sorted_features[i], shap_values, test_x_trans[select],
feature_names= new_col_list,
show=False)
plt.title(f"Dependence Plot : {sorted_features[i]}")
plt.show()
For the first dependence plot - median_income:
# random sample 1 observations
ob = random.sample(range(len(test_x_trans[select])), 1)
#plot the SHAP values for the random sampled observations
print(f"Force Plot of observation {ob}")
p = shap.force_plot(np.around(expected_value, decimals=4),
np.around(shap_values[ob],decimals=4),
test_x_trans[ob],
feature_names= new_col_list)
p
SHAP Force Plot shows how each feature contributes to the model prediction for a particular instance
The force plot offers an option to plot the predicted probability instead of the raw prediction by setting parameter link=”logit”. The axis shows the predicted probability (range between 0 and 1)
#plot the SHAP values for the random sampled observations with predicted probability
print(f"Force Plot of observation {ob}")
p = shap.force_plot(np.around(expected_value, decimals=4),
np.around(shap_values[ob],decimals=4),
test_x_trans[ob],
feature_names= new_col_list,
link="logit")
p
# The collective force plot
p=shap.force_plot(expected_value, shap_values, test_x_trans[select],
feature_names= new_col_list)
# save to html file
shap.save_html('SHAP_force_plot(collective).html', p)
p
The SHAP Force Plot is able to show how each feature contributes to the model prediction on a collection of instances (examples in the explaining set)
# random sample 3 observations
random_obs = random.sample(range(len(test_x_trans[select])), 3)
for ob in random_obs:
#plot the SHAP values for the random sampled observations
print(f"Force Plot of observation {ob}")
shap.force_plot(np.around(expected_value, decimals=4),
np.around(shap_values[ob],decimals=4),
test_x_trans[ob],
feature_names= new_col_list,
show=False,matplotlib =True )
plt.show()
# Plot collective decision plot for 50 data points
rcParams['axes.titlepad'] = 24
r = shap.decision_plot(expected_value, shap_values[:50], test_x_trans[select][:50],
feature_names= new_col_list, show= False,return_objects=True)
plt.title("SHAP Decision Plot (collective)")
plt.savefig("SHAP_decision_plot_collective.png")
plt.show()
Like the force plot, the decision plot supports link='logit' to transform log odds to probabilities. The interpretation is the same as the collective plot, except for now the x-axes are in the unit of prediction probabilities.
#Plot decision plot by assigning link='logit' to transform log odds to probabilities.
shap.decision_plot(expected_value,
shap_values[:50],
test_x_trans[select][:50],
feature_names= new_col_list, link='logit',
show= False)
plt.title("SHAP Decision Plot (collective) with prediction probability")
plt.savefig("SHAP_decision_plot_collective.png")
plt.show()
SHAP provides the option to create decision plot for a single instance. The interpretation is the same as the collective plot, except for now we only one instance of choice.
# create decision plot for 3 randomized selected samples
rcParams['axes.titlepad'] = 24
for ob in random_obs:
#plot the SHAP values for the random sampled observations
shap.decision_plot(expected_value,
shap_values[ob],
test_x_trans[ob],
feature_names= new_col_list,
feature_order=r.feature_idx,
xlim=r.xlim,
show=False)
plt.title(f"Decision Plot of observation {ob}")
plt.savefig(f"decision_plot_ob_{ob}.png")
plt.show()
SHAP provides the option to create decision plot that includes interaction terms
shap.decision_plot(expected_value, shap_interaction_values[0:100],
test_x_trans[0:100], link='logit',
feature_names= new_col_list, show=False)
plt.title("SHAP Decision Plots including Interaction Values (predicted probabilities)")
plt.show()
The waterfall plot is another option to understand how features effect the model prediction at a single observation level.
# plot waterfall plot
for ob in random_obs:
#plot the SHAP values for the random sampled observations
shap.waterfall_plot(expected_value,
shap_values[ob],
test_x_trans[ob],
feature_names= new_col_list,
show=False)
plt.title(f"SHAP Waterfall Plot of observation {ob}")
plt.savefig(f"shap_waterfall_plot_ob_{ob}.png")
plt.show()
# get predicted probability for instance 0
gb_clf.predict_proba(test_x_trans[0].reshape(1, -1))
# compute shap values for instance 0
shap_values_0 = explainer.shap_values(test_x_trans[0].reshape(1, -1))
# the shap values for instance 0 plus expected values is the log-odds for instance 0
log_odd_0 = np.exp(shap_values_0.sum() + explainer.expected_value)
#convert log-odds to probability, which equals the probability predicted by the model
log_odd_0/(1+log_odd_0)
explainer (shap.explainer_type(params))
background dataset
explainer.expected_value
explaining set
Shap values
output value (for an instance)