"Machine learning is a black box". I've heard that phrase a lot. In recent years, the machine learning community has brought forth many advancements for explaining models. In most cases, but not all, we can illuminate the decisions of our models. Model explainability also produces feature importance scores to demonstrate how our model operates on a global scale. Broadly, feature importance scores have two main benefits. For one, we data scientists can better understand our models and how they are working. More on this soon. Second, we can more confidently and clearly explain our predictions to stakeholders, thus (hopefully) engendering trust in our work.
Exploring feature importance scores can be an invaluable tool in helping us vet our models. If a feature we thought would be a sure-fire strong predictor doesn't render as important, we have maybe screwed up somewhere. Likewise, if some features we considered to be minor turn out to be predictive, that might be concerning. Again, we might have screwed up somewhere, or one of those features might allow our model to "cheat" by leaking information from the future. Feature importance scores can also give us ideas: perhaps we could engineer more features like the ones that are most predictive.
Thus far, we have discussed what we would call global interpretation of our model (i.e. what features most influence the predictions overall). However, we also want local explanations of our model. That is, we want to be able to decompose each prediction and show why the model predicted the probability it did. Local interpretations can help us gain an appreciation for how our model is working under the hood and either boost or decrease our confidence. In this chapter, we will explore both global and local model explanations.
Note that there is a difference between model evaluation and model explanation. A model can be terrible yet explainable. The two are not necessarily correlated.
Below is our code for modeling/explain.py. The docstrings explain all of the components. In the remaining sections of the chapter, we will review each function in turn along with some other knowledge nuggets.
import shap | |
import pandas as pd | |
import numpy as np | |
import os | |
import matplotlib.pyplot as plt | |
import multiprocessing as mp | |
from statistics import mean | |
from sklearn.inspection import permutation_importance | |
from copy import deepcopy | |
from functools import partial | |
from dtreeviz.trees import dtreeviz | |
from sklearn.inspection import plot_partial_dependence | |
from PyALE import ale | |
from helpers.model_helpers import make_directories_if_not_exists, find_non_dummied_columns, \ | |
transform_data_with_pipeline, determine_if_name_in_object | |
from data.db import log_feature_importance_to_mysql | |
plt.switch_backend('Agg') | |
def _run_shap_explainer(x_df, explainer, boosting_model, use_kernel, nsamples_kernel=500): | |
""" | |
Runs the SHAP explainer on a dataframe. | |
:param x_df: x dataframe | |
:param explainer: SHAP explainer object | |
:param boosting_model: Boolean of whether or not the explainer is for a boosting model | |
:param use_kernel: Boolean of whether or not to use Kernel SHAP, which mostly makes sense when we are using a | |
CalibratedClassifierCV | |
:param nsamples_kernel: number of samples to use when employing the kernel explainer | |
""" | |
if boosting_model: | |
if use_kernel: | |
return explainer.shap_values(x_df, nsamples=nsamples_kernel, check_additivity=False) | |
else: | |
return explainer.shap_values(x_df, check_additivity=False) | |
else: | |
if use_kernel: | |
return explainer.shap_values(x_df, nsamples=nsamples_kernel, check_additivity=False)[1] | |
else: | |
return explainer.shap_values(x_df, check_additivity=False)[1] | |
def _run_parallel_shap_explainer(x_df, explainer, boosting_model, use_kernel): | |
""" | |
Splits x_df into evenly-split partitions based on the number of CPU available on the machine. Then, the SHAP | |
explainer object is run in parallel on each subset of x_df. The results are then combined into a single object. | |
:param x_df: x dataframe | |
:param explainer: SHAP explainer object | |
:param boosting_model: Boolean of whether or not the explainer is for a boosting model | |
:param use_kernel: Boolean of whether or not to use Kernel SHAP, which mostly makes sense when we are using a | |
CalibratedClassifierCV | |
""" | |
array_split = np.array_split(x_df, mp.cpu_count()) | |
shap_fn = partial(_run_shap_explainer, explainer=explainer, boosting_model=boosting_model, use_kernel=use_kernel) | |
with mp.Pool(processes=mp.cpu_count()) as pool: | |
result = pool.map(shap_fn, array_split) | |
result = np.concatenate(result) | |
return result | |
def _get_shap_expected_value(explainer, boosting_model): | |
""" | |
Extracts a SHAP Explainer's expected value. | |
:param explainer: SHAP explainer object | |
:param boosting_model: Boolean of whether or not the explainer is for a boosting model | |
:returns: int | |
""" | |
if boosting_model: | |
expected_value = explainer.expected_value[0] | |
else: | |
try: | |
expected_value = explainer.expected_value[1] | |
except IndexError: | |
expected_value = explainer.expected_value[0] | |
return expected_value | |
def _produce_raw_shap_values(model, model_uid, x_df, calibrated, boosting_model, use_kernel): | |
""" | |
Produces the raw shap values for every observation in the test set. A dataframe of the shap values is saved locally | |
as a csv. The shap expected value is extracted and save locally in a csv. | |
:param model: fitted model | |
:param model_uid: model uid | |
:param x_df: x dataframe | |
:param calibrated: boolean of whether or not the model is a CalibratedClassifierCV; the default is False | |
:param boosting_model: Boolean of whether or not the explainer is for a boosting model | |
:param use_kernel: Boolean of whether or not to use Kernel SHAP, which mostly makes sense when we are using a | |
CalibratedClassifierCV | |
:returns: numpy array | |
""" | |
if calibrated: | |
if use_kernel: | |
explainer = shap.KernelExplainer(model.predict_proba, x_df.iloc[:50, :]) | |
shap_values = _run_parallel_shap_explainer(x_df, explainer, boosting_model, True) | |
shap_expected_value = _get_shap_expected_value(explainer, boosting_model) | |
else: | |
shap_values_list = [] | |
shap_expected_list = [] | |
for calibrated_classifier in model.calibrated_classifiers_: | |
explainer = shap.TreeExplainer(calibrated_classifier.base_estimator) | |
shap_values = _run_parallel_shap_explainer(x_df, explainer, boosting_model, False) | |
shap_expected_value = _get_shap_expected_value(explainer, boosting_model) | |
shap_values_list.append(shap_values) | |
shap_expected_list.append(shap_expected_value) | |
shap_values = np.array(shap_values_list).sum(axis=0) / len(shap_values_list) | |
shap_expected_value = mean(shap_expected_list) | |
shap_df = pd.DataFrame(shap_values, columns=list(x_df)) | |
shap_df.to_csv(os.path.join('modeling', model_uid, 'diagnostics', 'shap', 'shap_values.csv'), index=False) | |
shap_expected_value = pd.DataFrame({'expected_value': [shap_expected_value]}) | |
shap_expected_value.to_csv(os.path.join('modeling', model_uid, 'diagnostics', 'shap', 'shap_expected.csv'), | |
index=False) | |
else: | |
explainer = shap.TreeExplainer(model) | |
shap_values = _run_parallel_shap_explainer(x_df, explainer, boosting_model, False) | |
shap_df = pd.DataFrame(shap_values, columns=list(x_df)) | |
shap_df.to_csv(os.path.join('modeling', model_uid, 'diagnostics', 'shap', 'shap_values.csv'), index=False) | |
shap_expected_value = _get_shap_expected_value(explainer, boosting_model) | |
shap_expected_value = pd.DataFrame({'expected_value': [shap_expected_value]}) | |
shap_expected_value.to_csv(os.path.join('modeling', model_uid, 'diagnostics', 'shap', 'shap_expected.csv'), | |
index=False) | |
return shap_values | |
def _generate_shap_global_values(shap_values, x_df, model_uid, db_schema_name, db_conn, log_to_db): | |
""" | |
Extracts the global shape values for every feature ans saves the outcome as a dataframe locally. Amends the | |
dataframe so that it could be used in log_feature_importance_to_mysql(). | |
:param shap_values: numpy array of shap values | |
:param x_df: x_df dataframe | |
:param model_uid: model uid | |
:param db_schema_name: database schema to log metrics to | |
:param log_to_db: Boolean of whether to log scores to the database | |
:param db_conn: database connection | |
:returns: pandas dataframe | |
""" | |
shap_values = np.abs(shap_values).mean(0) | |
df = pd.DataFrame(list(zip(x_df.columns, shap_values)), columns=['feature', 'shap_value']) | |
df.sort_values(by=['shap_value'], ascending=False, inplace=True) | |
df.to_csv(os.path.join('modeling', model_uid, 'diagnostics', 'shap', 'shap_global.csv'), index=False) | |
df.rename(columns={'shap_value': 'importance_score'}, inplace=True) | |
df['model_uid'] = model_uid | |
df['importance_metric'] = 'shap' | |
df = df[['model_uid', 'feature', 'importance_score', 'importance_metric']] | |
if log_to_db: | |
log_feature_importance_to_mysql(df, db_schema_name, db_conn) | |
def _generate_shap_plot(shap_values, x_df, model_uid, plot_type): | |
""" | |
Generates a plot of shap values and saves it locally. | |
:param shap_values: numpy array of shap values produced for x_df | |
:param x_df: x dataframe | |
:param model_uid: model uid | |
:param plot_type: the type of plot we want to generate; generally, either dot or bar | |
""" | |
shap.summary_plot(shap_values, x_df, plot_type=plot_type, show=False) | |
plt.savefig(os.path.join('modeling', model_uid, 'diagnostics', 'shap', f'shap_values_{plot_type}.png'), | |
bbox_inches='tight') | |
plt.clf() | |
def produce_shap_values_and_plots(model, x_df, model_uid, boosting_model, use_kernel, calibrated, db_schema_name, | |
db_conn, log_to_db): | |
""" | |
Produces SHAP values for x_df and writes associated diagnostics locally. | |
:param model: model with predict method | |
:param x_df: x dataframe | |
:param model_uid: model uid | |
:param boosting_model: Boolean of whether or not the explainer is for a boosting model | |
:param use_kernel: Boolean of whether or not to use Kernel SHAP, which mostly makes sense when we are using a | |
CalibratedClassifierCV | |
:param calibrated: boolean of whether or not the model is a CalibratedClassifierCV | |
:param db_schema_name: database schema to log metrics to | |
:param db_conn: database connection | |
:param log_to_db: Boolean of whether to log scores to the database | |
""" | |
shap_values = _produce_raw_shap_values(model, model_uid, x_df, calibrated, boosting_model, use_kernel) | |
_generate_shap_global_values(shap_values, x_df, model_uid, db_schema_name, db_conn, log_to_db) | |
_generate_shap_plot(shap_values, x_df, model_uid, 'dot') | |
_generate_shap_plot(shap_values, x_df, model_uid, 'bar') | |
def pull_out_embedded_feature_scores(x_df, model, model_uid): | |
""" | |
Pulls out feature importance attributes from models and writes the results locally. The passed model must have | |
either a coef_ or feature_importances_ attribute. | |
:param x_df: cleaned x dataframe | |
:param model: model with either a coef_ or feature_importances_ attribute | |
:param model_uid: model uid | |
""" | |
if hasattr(model, 'coef_'): | |
df = pd.DataFrame({'feature': list(x_df), 'score': model.coef_.reshape(-1)}) | |
df['score'] = np.exp(df['score']) | |
df['score'] = df['score'] / (1 + df['score']) | |
df['score'] = (df['score'] - 0.5) / 0.5 | |
df.rename(columns={'score': 'probability_contribution'}, inplace=True) | |
elif hasattr(model, 'feature_importances_'): | |
df = pd.DataFrame({'feature': list(x_df), 'importance': model.feature_importances_}) | |
else: | |
raise Exception('model must have either coef_ or feature_importances_ attribute') | |
df.to_csv(os.path.join('modeling', model_uid, 'diagnostics', 'explanation_files', 'embedded_feature_scores.csv'), | |
index=False) | |
def run_permutation_importance(estimator, x_test, y_test, scoring_string, model_uid, db_schema_name, db_conn, | |
log_to_db): | |
""" | |
Produces feature permutation importance scores and saved the results locally. | |
:param estimator: estimator object | |
:param x_test: x_test | |
:param y_test: y_test | |
:param scoring_string: scoring metric in the form of a string (e.g. 'neg_log-loss') | |
:param model_uid: string name of the model | |
:param db_schema_name: database schema to log metrics to | |
:param db_conn: database connection | |
:param log_to_db: Boolean of whether to log scores to the database | |
""" | |
result = permutation_importance(estimator, x_test, y_test, n_repeats=10, random_state=0, scoring=scoring_string) | |
df = pd.DataFrame({ | |
'permutation_importance_mean': result.importances_mean, | |
'permutation_importance_std': result.importances_std, | |
'feature': list(x_test) | |
}) | |
df.sort_values(by=['permutation_importance_mean'], ascending=False, inplace=True) | |
df.to_csv(os.path.join('modeling', model_uid, 'diagnostics', 'permutation_importance', | |
'permutation_importance_scores.csv'), index=False) | |
df.rename(columns={'permutation_importance_mean': 'importance_score'}, inplace=True) | |
df['model_uid'] = model_uid | |
df['importance_metric'] = 'permutation_importance' | |
df = df[['model_uid', 'feature', 'importance_score', 'importance_metric']] | |
if log_to_db: | |
log_feature_importance_to_mysql(df, db_schema_name, db_conn) | |
def _score_drop_col_model(estimator, x_test, y_test, scoring_type, scorer): | |
""" | |
Scores a trained for drop-column feature importance. | |
:param estimator: estimator object | |
:param x_test: x_test | |
:param y_test: y_test | |
:param scoring_type: if we want to evaluation class or probability predictions | |
:param scorer: scikit-learn scoring callable | |
:returns: model's score on the test set | |
""" | |
if scoring_type == 'class': | |
predictions = estimator.predict(x_test) | |
score = scorer(y_test, predictions) | |
elif scoring_type == 'probability': | |
predictions = estimator.predict_proba(x_test) | |
score = scorer(y_test, predictions[:, 1]) | |
else: | |
raise Exception('scoring_type must either be class or probability') | |
return score | |
def _train_and_score_drop_col_model(feature, estimator, x_train, y_train, x_test, y_test, baseline_score, scoring_type, | |
scorer): | |
""" | |
Drops specified feature, refits the pipeline to the training data, and determines the differences from the baseline | |
model score. | |
:param feature: name of the feature to drop | |
:param estimator: estimator object | |
:param x_train: x_train | |
:param y_train: y_train | |
:param x_test: x_test | |
:param y_test: y_test | |
:param baseline_score: the score on the test set using all the columns for training | |
:param scoring_type: if we want to evaluation class or probability predictions | |
:param scorer: scikit-learn scoring callable | |
""" | |
try: | |
x = x_train.drop(feature, axis=1) | |
x_test = x_test.drop(feature, axis=1) | |
train_pipe = deepcopy(estimator) | |
train_pipe.fit(x, y_train) | |
feature_score = baseline_score - _score_drop_col_model(train_pipe, x_test, y_test, scoring_type, scorer) | |
except: | |
feature_score = np.nan | |
return {'feature': feature, 'importance': feature_score} | |
def run_drop_column_importance(pipeline, x_train, y_train, x_test, y_test, scorer, scoring_type, model_uid, | |
db_schema_name, db_conn, log_to_db, higher_is_better): | |
""" | |
Produces drop column feature importance scores and saves the results locally. | |
:param pipeline: fitted pipeline | |
:param x_train: x_train | |
:param y_train: y_train | |
:param x_test: x_test | |
:param y_test: y_test | |
:param scorer: scoring function | |
:param scoring_type: either class or probability | |
:param model_uid: model uid | |
:param db_schema_name: database schema to log metrics to | |
:param log_to_db: Boolean of whether to log scores to the database | |
:param db_conn: database connection | |
:param higher_is_better: whether or not a higher score is better | |
""" | |
pipeline_ = deepcopy(pipeline) | |
pipeline_.fit(x_train, y_train) | |
baseline_score = _score_drop_col_model(pipeline_, x_test, y_test, scoring_type, scorer) | |
drop_col_train_fn = partial(_train_and_score_drop_col_model, estimator=pipeline_, x_train=x_train, y_train=y_train, | |
x_test=x_test, y_test=y_test, baseline_score=baseline_score, scoring_type=scoring_type, | |
scorer=scorer) | |
columns = list(x_train) | |
with mp.Pool(processes=mp.cpu_count()) as pool: | |
result = pool.map(drop_col_train_fn, columns) | |
df = pd.DataFrame.from_records(result) | |
df.sort_values(by=['importance'], ascending=higher_is_better, inplace=True) | |
df.fillna(0, inplace=True) | |
df.to_csv(os.path.join('modeling', model_uid, 'diagnostics', 'drop_column_importance', | |
'drop_column_importance_scores.csv'), index=False) | |
df.rename(columns={'importance': 'importance_score'}, inplace=True) | |
df['model_uid'] = model_uid | |
df['importance_metric'] = 'drop_column_importance' | |
df = df[['model_uid', 'feature', 'importance_score', 'importance_metric']] | |
if log_to_db: | |
log_feature_importance_to_mysql(df, db_schema_name, db_conn) | |
def produce_tree_visualization(tree, tree_index, x, y, target_name, feature_names, class_names, model_uid): | |
""" | |
Produces visualization of a decision tree from an ensemble. | |
:param tree: tree model | |
:param tree_index: index of the tree in the ensemble | |
:param x: predictor matrix | |
:param y: target series | |
:param target_name: name of the target | |
:param feature_names: list of feature names | |
:param class_names: name of the target classes | |
:param model_uid: name of the model | |
""" | |
viz = dtreeviz(tree.estimators_[tree_index], | |
x, | |
y, | |
target_name=target_name, | |
feature_names=feature_names, | |
class_names=class_names | |
) | |
viz.save(os.path.join('modeling', model_uid, 'diagnostics', 'trees', f'decision_tree_{tree_index}.svg')) | |
def _plot_partial_dependence(feature, model, x_df, plot_kind, model_uid): | |
""" | |
Produces a PDP or ICE plot and saves it locally into the pdp directory. | |
:param feature: name of the feature | |
:param model: fitted model | |
:param x_df: x dataframe | |
:param plot_kind: "both" for ICE plot of "average" for PDP | |
:param model_uid: model uid | |
""" | |
_, ax = plt.subplots(ncols=1, figsize=(9, 4)) | |
display = plot_partial_dependence(model, x_df, [feature], kind=plot_kind) | |
plt.title(feature) | |
plt.xlabel(feature) | |
plt.savefig(os.path.join('modeling', model_uid, 'diagnostics', 'pdp', f'{feature}_{plot_kind}.png')) | |
plt.clf() | |
def produce_partial_dependence_plots(model, x_df, plot_kind, model_uid): | |
""" | |
Produces a PDP or ICE plot for every column in x_df. x_df is spread across all available CPUs on the machine, | |
allowing plots to be created and saved in parallel. | |
:param model: fitted model | |
:param x_df: x dataframe | |
:param plot_kind: "both" for ICE plot of "average" for PDP | |
:param model_uid: model uid | |
""" | |
model.fitted_ = True | |
pdp_plot_fn = partial(_plot_partial_dependence, model=model, x_df=x_df, plot_kind=plot_kind, model_uid=model_uid) | |
with mp.Pool(processes=mp.cpu_count()) as pool: | |
result = pool.map(pdp_plot_fn, list(x_df)) | |
def _produce_ale_plot(feature, x_df, model, model_uid): | |
""" | |
Produces an ALE plot and saves it locally. | |
:param feature: name of the feature | |
:param x_df: x dataframe | |
:param model: fitted model | |
:param model_uid: model uid | |
""" | |
ale_effect = ale(X=x_df, model=model, feature=[feature], include_CI=False) | |
plt.title(feature) | |
plt.xlabel(feature) | |
plt.savefig(os.path.join('modeling', model_uid, 'diagnostics', 'ale', f'{feature}_ale.png')) | |
plt.clf() | |
def produce_accumulated_local_effects_plots(x_df, model, model_uid): | |
""" | |
Produces an ALE plot for every column numereic column in x_df. x_df is spread across all available CPUs on the | |
machine, allowing plots to be created and saved in parallel. | |
:param x_df: x dataframe | |
:param model: fitted model | |
feature index as the second item | |
:param model_uid: model uid | |
""" | |
x_numeric_df = x_df[find_non_dummied_columns(x_df)] | |
ale_plot_fn = partial(_produce_ale_plot, model=model, x_df=x_df, model_uid=model_uid) | |
with mp.Pool(processes=mp.cpu_count()) as pool: | |
result = pool.map(ale_plot_fn, list(x_numeric_df)) | |
def run_omnibus_model_explanation(estimator, model_uid, x_test, y_test, x_train, y_train, drop_col_scorer, | |
drop_col_scorer_string, drop_col_scoring_type, drop_col_higher_is_better, | |
sample_n, use_shap_kernel, db_schema_name, db_conn, log_to_db): | |
""" | |
Runs a series of model explainability techniques on the model. | |
- PDP plots | |
- ICE plots | |
- ALE plots | |
- SHAP values | |
- permutation importance | |
- drop-column importance | |
:param estimator: fitted estimator | |
:param x_test: x_test | |
:param y_test: y_test | |
:param x_train: x_train | |
:param y_train: y_train | |
:param drop_col_scorer: scikit-learn scoring function | |
:param drop_col_scorer_string: scoring metric in the form of a string (e.g. 'neg_log-loss') | |
:param drop_col_scoring_type: either class or probability | |
:param model_uid: model uid | |
:param drop_col_higher_is_better: Boolean of whether or not a higher score is better (e.g. roc auc vs. log loss) | |
:param sample_n: number of samples to keep when running interpretability metrics | |
:param use_shap_kernel: Boolean of whether or not to use Kernel SHAP, which mostly makes sense when we are using a | |
CalibratedClassifierCV | |
:param db_schema_name: database schema to log metrics to | |
:param db_conn: database connection | |
:param log_to_db: Boolean of whether to log scores to the database | |
""" | |
print(f'explaining {model_uid}...') | |
make_directories_if_not_exists([ | |
os.path.join('modeling', model_uid, 'diagnostics', 'shap'), | |
os.path.join('modeling', model_uid, 'diagnostics', 'pdp'), | |
os.path.join('modeling', model_uid, 'diagnostics', 'ale'), | |
os.path.join('modeling', model_uid, 'diagnostics', 'permutation_importance'), | |
os.path.join('modeling', model_uid, 'diagnostics', 'drop_column_importance') | |
]) | |
pipeline_ = deepcopy(estimator) | |
model = estimator.named_steps['model'] | |
x_df = transform_data_with_pipeline(estimator, x_test) | |
if sample_n: | |
x_train = x_train.head(sample_n) | |
y_train = y_train.head(sample_n) | |
x_test = x_test.head(sample_n) | |
y_test = y_test.head(sample_n) | |
x_df = x_df.head(sample_n) | |
boosting_model = determine_if_name_in_object('boost', model) | |
calibrated_model = determine_if_name_in_object('calibrated', model) | |
produce_shap_values_and_plots(model, x_df, model_uid, boosting_model, use_shap_kernel, calibrated_model, | |
db_schema_name, db_conn, log_to_db) | |
produce_partial_dependence_plots(model, x_df, 'average', model_uid) | |
produce_partial_dependence_plots(model, x_df, 'both', model_uid) | |
produce_accumulated_local_effects_plots(x_df, model, model_uid) | |
run_permutation_importance(model, x_df, y_test, drop_col_scorer_string, model_uid, db_schema_name, db_conn, | |
log_to_db) | |
run_drop_column_importance(pipeline_, x_train, y_train, x_test, y_test, drop_col_scorer, drop_col_scoring_type, | |
model_uid, db_schema_name, db_conn, log_to_db, drop_col_higher_is_better) |
We can get out-of-the-box variable importance scores for logistic regression and tree-based models.
The logistic regression coefficients can be a bit tricky to interpret. Without transformation, they are the log odds of belonging to the positive class. Fortunately, we can exponentiate the log odds to simply get the odds. After exponentiating, the interpretation of the coefficient is such: for every unit increase in the feature, the odds of being in the positive class increase / decrease by the coefficient, all else held constant. We can also translate the odds into a probability with the following: odds / (1 + odds). The previous interpretation is valid, but instead of "odds", we can state the "probability" of being in the positive class. Any probability below 0.5 reduces the likelihood of being in the positive class, while any probability above 0.5 increases the likelihood of being in the positive class. To note, this approach is not how you would derive the predicted probabilities.
We can also pull out feature importance scores from tree-based models. These correspond to the mean impact on the purity of a split that a feature provides. For example, if a feature can be split so that it clearly delineates between two classes (e.g. churn vs. no churn), then it has increased the purity. That is, the nodes will be more "pure" in that they will have a greater imbalance of classes (that's a good thing). Though these scores are easy to access, they are not the most informative. First, they are known to struggle with high-cardinality categorical features. Second, they can sometimes even be fooled by random features (see https://explained.ai/rf-importance/). Third, we can't sum them to get anything meaningful. Fourth, the scores are not with respect to the impact on any given class (e.g. churn or not churn), which makes sense as the tree splits impact both classes. Fifth, the scores are really only meaningful in comparison to one another. An importance score of 0.9 means nothing to me on it's own. The main message: basically never use these scores.
import pandas as pd | |
import os | |
def pull_out_embedded_feature_scores(x_df, model, model_uid): | |
""" | |
Pulls out feature importance attributes from models and writes the results locally. The passed model must have | |
either a coef_ or feature_importances_ attribute. | |
:param x_df: cleaned x dataframe | |
:param model: model with either a coef_ or feature_importances_ attribute | |
:param model_uid: model uid | |
""" | |
if hasattr(model, 'coef_'): | |
df = pd.DataFrame({'feature': list(x_df), 'score': model.coef_.reshape(-1)}) | |
df['score'] = np.exp(df['score']) | |
df['score'] = df['score'] / (1 + df['score']) | |
df['score'] = (df['score'] - 0.5) / 0.5 | |
df.rename(columns={'score': 'probability_contribution'}, inplace=True) | |
elif hasattr(model, 'feature_importances_'): | |
df = pd.DataFrame({'feature': list(x_df), 'importance': model.feature_importances_}) | |
else: | |
raise Exception('model must have either coef_ or feature_importances_ attribute') | |
df.to_csv(os.path.join('modeling', model_uid, 'diagnostics', 'explanation_files', 'embedded_feature_scores.csv'), | |
index=False) |
A viable alternative is permutation importance. For every feature, we randomly scramble its values in the test set. We then make predictions on the test set and see how much our evaluation metric declines. The more the evaluation metric declines, the more important the feature.
import pandas as pd | |
import os | |
from sklearn.inspection import permutation_importance | |
from data.db import log_feature_importance_to_mysql | |
def run_permutation_importance(estimator, x_test, y_test, scoring_string, model_uid, db_schema_name, db_conn, | |
log_to_db): | |
""" | |
Produces feature permutation importance scores and saved the results locally. | |
:param estimator: estimator object | |
:param x_test: x_test | |
:param y_test: y_test | |
:param scoring_string: scoring metric in the form of a string (e.g. 'neg_log-loss') | |
:param model_uid: string name of the model | |
:param db_schema_name: database schema to log metrics to | |
:param db_conn: database connection | |
:param log_to_db: Boolean of whether to log scores to the database | |
""" | |
result = permutation_importance(estimator, x_test, y_test, n_repeats=10, random_state=0, scoring=scoring_string) | |
df = pd.DataFrame({ | |
'permutation_importance_mean': result.importances_mean, | |
'permutation_importance_std': result.importances_std, | |
'feature': list(x_test) | |
}) | |
df.sort_values(by=['permutation_importance_mean'], ascending=False, inplace=True) | |
df.to_csv(os.path.join('modeling', model_uid, 'diagnostics', 'permutation_importance', | |
'permutation_importance_scores.csv'), index=False) | |
df.rename(columns={'permutation_importance_mean': 'importance_score'}, inplace=True) | |
df['model_uid'] = model_uid | |
df['importance_metric'] = 'permutation_importance' | |
df = df[['model_uid', 'feature', 'importance_score', 'importance_metric']] | |
if log_to_db: | |
log_feature_importance_to_mysql(df, db_schema_name, db_conn) |
A comparatively drastic alternative to permutation importance is drop-column importance. We completely drop a feature from our data, retrain the model on the training set, and then evaluate the performance on the test set. The bigger the difference in the new model score compared to the original model score, the more important the feature. The results of drop-column importance can also help us understand how features, or feature combinations, can act as substitutes. For example, Feature A appears quite important at first blush. However, when we run drop-column importance, our evaluation metric does not change much. Why? This could be because the model was able to proxy or replace this feature with others.
import multiprocessing as mp | |
import pandas as pd | |
import os | |
from copy import deepcopy | |
from functools import partial | |
from data.db import log_feature_importance_to_mysql | |
def _score_drop_col_model(estimator, x_test, y_test, scoring_type, scorer): | |
""" | |
Scores a trained for drop-column feature importance. | |
:param estimator: estimator object | |
:param x_test: x_test | |
:param y_test: y_test | |
:param scoring_type: if we want to evaluation class or probability predictions | |
:param scorer: scikit-learn scoring callable | |
:returns: model's score on the test set | |
""" | |
if scoring_type == 'class': | |
predictions = estimator.predict(x_test) | |
score = scorer(y_test, predictions) | |
elif scoring_type == 'probability': | |
predictions = estimator.predict_proba(x_test) | |
score = scorer(y_test, predictions[:, 1]) | |
else: | |
raise Exception('scoring_type must either be class or probability') | |
return score | |
def _train_and_score_drop_col_model(feature, estimator, x_train, y_train, x_test, y_test, baseline_score, scoring_type, | |
scorer): | |
""" | |
Drops specified feature, refits the pipeline to the training data, and determines the differences from the baseline | |
model score. | |
:param feature: name of the feature to drop | |
:param estimator: estimator object | |
:param x_train: x_train | |
:param y_train: y_train | |
:param x_test: x_test | |
:param y_test: y_test | |
:param baseline_score: the score on the test set using all the columns for training | |
:param scoring_type: if we want to evaluation class or probability predictions | |
:param scorer: scikit-learn scoring callable | |
""" | |
try: | |
x = x_train.drop(feature, axis=1) | |
x_test = x_test.drop(feature, axis=1) | |
train_pipe = deepcopy(estimator) | |
train_pipe.fit(x, y_train) | |
feature_score = baseline_score - _score_drop_col_model(train_pipe, x_test, y_test, scoring_type, scorer) | |
except: | |
feature_score = np.nan | |
return {'feature': feature, 'importance': feature_score} | |
def run_drop_column_importance(pipeline, x_train, y_train, x_test, y_test, scorer, scoring_type, model_uid, | |
db_schema_name, db_conn, log_to_db, higher_is_better): | |
""" | |
Produces drop column feature importance scores and saves the results locally. | |
:param pipeline: fitted pipeline | |
:param x_train: x_train | |
:param y_train: y_train | |
:param x_test: x_test | |
:param y_test: y_test | |
:param scorer: scoring function | |
:param scoring_type: either class or probability | |
:param model_uid: model uid | |
:param db_schema_name: database schema to log metrics to | |
:param log_to_db: Boolean of whether to log scores to the database | |
:param db_conn: database connection | |
:param higher_is_better: whether or not a higher score is better | |
""" | |
pipeline_ = deepcopy(pipeline) | |
pipeline_.fit(x_train, y_train) | |
baseline_score = _score_drop_col_model(pipeline_, x_test, y_test, scoring_type, scorer) | |
drop_col_train_fn = partial(_train_and_score_drop_col_model, estimator=pipeline_, x_train=x_train, y_train=y_train, | |
x_test=x_test, y_test=y_test, baseline_score=baseline_score, scoring_type=scoring_type, | |
scorer=scorer) | |
columns = list(x_train) | |
with mp.Pool(processes=mp.cpu_count()) as pool: | |
result = pool.map(drop_col_train_fn, columns) | |
df = pd.DataFrame.from_records(result) | |
df.sort_values(by=['importance'], ascending=higher_is_better, inplace=True) | |
df.fillna(0, inplace=True) | |
df.to_csv(os.path.join('modeling', model_uid, 'diagnostics', 'drop_column_importance', | |
'drop_column_importance_scores.csv'), index=False) | |
df.rename(columns={'importance': 'importance_score'}, inplace=True) | |
df['model_uid'] = model_uid | |
df['importance_metric'] = 'drop_column_importance' | |
df = df[['model_uid', 'feature', 'importance_score', 'importance_metric']] | |
if log_to_db: | |
log_feature_importance_to_mysql(df, db_schema_name, db_conn) | |
Perhaps the best way to explain our model is via SHAP values. These values provide both local and global feature importance scores. Thus far, we have mostly discussed global importance, that is, how a feature impacts a model on the whole. Local importance explains how a feature influences a specific prediction. SHAP values do something quite powerful: for each prediction, we can see how each feature moved the prediction away from a baseline value. I frankly think SHAP values are the best way to explain models. Therefore, I do not present some of its "competitors", such as LIME. For a thorough explanation of SHAP values, please refer to https://christophm.github.io/interpretable-ml-book/shap.html. Here, we will demonstrate how SHAP values operate via a working example.
import shap | |
import pandas as pd | |
import numpy as np | |
import os | |
import matplotlib.pyplot as plt | |
import multiprocessing as mp | |
from functools import partial | |
from statistics import mean | |
from data.db import log_feature_importance_to_mysql | |
plt.switch_backend('Agg') | |
def _run_shap_explainer(x_df, explainer, boosting_model, use_kernel, nsamples_kernel=500): | |
""" | |
Runs the SHAP explainer on a dataframe. | |
:param x_df: x dataframe | |
:param explainer: SHAP explainer object | |
:param boosting_model: Boolean of whether or not the explainer is for a boosting model | |
:param use_kernel: Boolean of whether or not to use Kernel SHAP, which mostly makes sense when we are using a | |
CalibratedClassifierCV | |
:param nsamples_kernel: number of samples to use when employing the kernel explainer | |
""" | |
if boosting_model: | |
if use_kernel: | |
return explainer.shap_values(x_df, nsamples=nsamples_kernel, check_additivity=False) | |
else: | |
return explainer.shap_values(x_df, check_additivity=False) | |
else: | |
if use_kernel: | |
return explainer.shap_values(x_df, nsamples=nsamples_kernel, check_additivity=False)[1] | |
else: | |
return explainer.shap_values(x_df, check_additivity=False)[1] | |
def _run_parallel_shap_explainer(x_df, explainer, boosting_model, use_kernel): | |
""" | |
Splits x_df into evenly-split partitions based on the number of CPU available on the machine. Then, the SHAP | |
explainer object is run in parallel on each subset of x_df. The results are then combined into a single object. | |
:param x_df: x dataframe | |
:param explainer: SHAP explainer object | |
:param boosting_model: Boolean of whether or not the explainer is for a boosting model | |
:param use_kernel: Boolean of whether or not to use Kernel SHAP, which mostly makes sense when we are using a | |
CalibratedClassifierCV | |
""" | |
array_split = np.array_split(x_df, mp.cpu_count()) | |
shap_fn = partial(_run_shap_explainer, explainer=explainer, boosting_model=boosting_model, use_kernel=use_kernel) | |
with mp.Pool(processes=mp.cpu_count()) as pool: | |
result = pool.map(shap_fn, array_split) | |
result = np.concatenate(result) | |
return result | |
def _get_shap_expected_value(explainer, boosting_model): | |
""" | |
Extracts a SHAP Explainer's expected value. | |
:param explainer: SHAP explainer object | |
:param boosting_model: Boolean of whether or not the explainer is for a boosting model | |
:returns: int | |
""" | |
if boosting_model: | |
expected_value = explainer.expected_value[0] | |
else: | |
try: | |
expected_value = explainer.expected_value[1] | |
except IndexError: | |
expected_value = explainer.expected_value[0] | |
return expected_value | |
def _produce_raw_shap_values(model, model_uid, x_df, calibrated, boosting_model, use_kernel): | |
""" | |
Produces the raw shap values for every observation in the test set. A dataframe of the shap values is saved locally | |
as a csv. The shap expected value is extracted and save locally in a csv. | |
:param model: fitted model | |
:param model_uid: model uid | |
:param x_df: x dataframe | |
:param calibrated: boolean of whether or not the model is a CalibratedClassifierCV; the default is False | |
:param boosting_model: Boolean of whether or not the explainer is for a boosting model | |
:param use_kernel: Boolean of whether or not to use Kernel SHAP, which mostly makes sense when we are using a | |
CalibratedClassifierCV | |
:returns: numpy array | |
""" | |
if calibrated: | |
if use_kernel: | |
explainer = shap.KernelExplainer(model.predict_proba, x_df.iloc[:50, :]) | |
shap_values = _run_parallel_shap_explainer(x_df, explainer, boosting_model, True) | |
shap_expected_value = _get_shap_expected_value(explainer, boosting_model) | |
else: | |
shap_values_list = [] | |
shap_expected_list = [] | |
for calibrated_classifier in model.calibrated_classifiers_: | |
explainer = shap.TreeExplainer(calibrated_classifier.base_estimator) | |
shap_values = _run_parallel_shap_explainer(x_df, explainer, boosting_model, False) | |
shap_expected_value = _get_shap_expected_value(explainer, boosting_model) | |
shap_values_list.append(shap_values) | |
shap_expected_list.append(shap_expected_value) | |
shap_values = np.array(shap_values_list).sum(axis=0) / len(shap_values_list) | |
shap_expected_value = mean(shap_expected_list) | |
shap_df = pd.DataFrame(shap_values, columns=list(x_df)) | |
shap_df.to_csv(os.path.join('modeling', model_uid, 'diagnostics', 'shap', 'shap_values.csv'), index=False) | |
shap_expected_value = pd.DataFrame({'expected_value': [shap_expected_value]}) | |
shap_expected_value.to_csv(os.path.join('modeling', model_uid, 'diagnostics', 'shap', 'shap_expected.csv'), | |
index=False) | |
else: | |
explainer = shap.TreeExplainer(model) | |
shap_values = _run_parallel_shap_explainer(x_df, explainer, boosting_model, False) | |
shap_df = pd.DataFrame(shap_values, columns=list(x_df)) | |
shap_df.to_csv(os.path.join('modeling', model_uid, 'diagnostics', 'shap', 'shap_values.csv'), index=False) | |
shap_expected_value = _get_shap_expected_value(explainer, boosting_model) | |
shap_expected_value = pd.DataFrame({'expected_value': [shap_expected_value]}) | |
shap_expected_value.to_csv(os.path.join('modeling', model_uid, 'diagnostics', 'shap', 'shap_expected.csv'), | |
index=False) | |
return shap_values | |
def _generate_shap_global_values(shap_values, x_df, model_uid, db_schema_name, db_conn, log_to_db): | |
""" | |
Extracts the global shape values for every feature ans saves the outcome as a dataframe locally. Amends the | |
dataframe so that it could be used in log_feature_importance_to_mysql(). | |
:param shap_values: numpy array of shap values | |
:param x_df: x_df dataframe | |
:param model_uid: model uid | |
:param db_schema_name: database schema to log metrics to | |
:param log_to_db: Boolean of whether to log scores to the database | |
:param db_conn: database connection | |
:returns: pandas dataframe | |
""" | |
shap_values = np.abs(shap_values).mean(0) | |
df = pd.DataFrame(list(zip(x_df.columns, shap_values)), columns=['feature', 'shap_value']) | |
df.sort_values(by=['shap_value'], ascending=False, inplace=True) | |
df.to_csv(os.path.join('modeling', model_uid, 'diagnostics', 'shap', 'shap_global.csv'), index=False) | |
df.rename(columns={'shap_value': 'importance_score'}, inplace=True) | |
df['model_uid'] = model_uid | |
df['importance_metric'] = 'shap' | |
df = df[['model_uid', 'feature', 'importance_score', 'importance_metric']] | |
if log_to_db: | |
log_feature_importance_to_mysql(df, db_schema_name, db_conn) | |
def _generate_shap_plot(shap_values, x_df, model_uid, plot_type): | |
""" | |
Generates a plot of shap values and saves it locally. | |
:param shap_values: numpy array of shap values produced for x_df | |
:param x_df: x dataframe | |
:param model_uid: model uid | |
:param plot_type: the type of plot we want to generate; generally, either dot or bar | |
""" | |
shap.summary_plot(shap_values, x_df, plot_type=plot_type, show=False) | |
plt.savefig(os.path.join('modeling', model_uid, 'diagnostics', 'shap', f'shap_values_{plot_type}.png'), | |
bbox_inches='tight') | |
plt.clf() | |
def produce_shap_values_and_plots(model, x_df, model_uid, boosting_model, use_kernel, calibrated, db_schema_name, | |
db_conn, log_to_db): | |
""" | |
Produces SHAP values for x_df and writes associated diagnostics locally. | |
:param model: model with predict method | |
:param x_df: x dataframe | |
:param model_uid: model uid | |
:param boosting_model: Boolean of whether or not the explainer is for a boosting model | |
:param use_kernel: Boolean of whether or not to use Kernel SHAP, which mostly makes sense when we are using a | |
CalibratedClassifierCV | |
:param calibrated: boolean of whether or not the model is a CalibratedClassifierCV | |
:param db_schema_name: database schema to log metrics to | |
:param db_conn: database connection | |
:param log_to_db: Boolean of whether to log scores to the database | |
""" | |
shap_values = _produce_raw_shap_values(model, model_uid, x_df, calibrated, boosting_model, use_kernel) | |
_generate_shap_global_values(shap_values, x_df, model_uid, db_schema_name, db_conn, log_to_db) | |
_generate_shap_plot(shap_values, x_df, model_uid, 'dot') | |
_generate_shap_plot(shap_values, x_df, model_uid, 'bar') |
One of the "gotchas" about SHAP is that it expects a model, not a pipeline, and preprocessed data. However, we have all our preprocessing in our pipeline. Therefore, we need to perform two tasks.
import pandas as pd | |
from copy import deepcopy | |
def transform_data_with_pipeline(pipeline, x_df): | |
""" | |
Prepares the model and x_test dataframe for extracting feature importance values. This involves applying the | |
preprocessing stepsin the pipeline and converting the output into a dataframe with the appropriate columns. | |
Likewise, this process involves plucking out the model. | |
:param pipeline: scikit-learn pipeline with preprocessing steps and model | |
:param x_df: x_test dataframe | |
computationally expensive; default is 10,000. If n_obs is greater than the total number of observations, then | |
50% of the data will be sampled. | |
:returns: model with predict method, transformed x_test dataframe | |
""" | |
# make a copy of the pipeline to avoid implications of in-place operations | |
pipeline_ = deepcopy(pipeline) | |
# Extract the names of the features from the dict vectorizers | |
num_dict_vect = pipeline_.named_steps['preprocessor'].named_transformers_.get('numeric_transformer').named_steps[ | |
'dict_vectorizer'] | |
cat_dict_vect = pipeline_.named_steps['preprocessor'].named_transformers_.get('categorical_transformer').named_steps[ | |
'dict_vectorizer'] | |
num_features = num_dict_vect.feature_names_ | |
cat_features = cat_dict_vect.feature_names_ | |
# Get the boolean masks for the variance threshold and feature selector steps | |
num_feature_selector_support = pipeline_.named_steps['preprocessor'].named_transformers_.get( | |
'numeric_transformer').named_steps['feature_selector'].get_support() | |
cat_feature_selector_support = pipeline_.named_steps['preprocessor'].named_transformers_.get( | |
'categorical_transformer').named_steps['feature_selector'].get_support() | |
variance_threshold_support = pipeline_.named_steps['variance_thresholder'].get_support() | |
# Create a dataframe of column names | |
cols_df = pd.DataFrame({'cols': num_features + cat_features}) | |
# Remove columns based on the feature selectors | |
cols_df = pd.concat([ | |
cols_df, | |
pd.DataFrame({'selector_support': list(num_feature_selector_support) + list(cat_feature_selector_support)}) | |
], axis=1) | |
cols_df = cols_df.loc[cols_df['selector_support']] | |
cols_df = cols_df.reset_index() | |
# Remove columns based on the variance threshold | |
cols_df = pd.concat([ | |
cols_df, | |
pd.DataFrame({'threshold_support': variance_threshold_support}) | |
], axis=1) | |
cols_df = cols_df.loc[cols_df['threshold_support']] | |
# Make list of final column names | |
cols = cols_df['cols'].tolist() | |
# Remove the model | |
pipeline_.steps.pop(len(pipeline_) - 1) | |
# Transform the data using the remaining pipeline_ steps, cast to a dataframe, and assign the column names | |
x_df = pipeline_.transform(x_df) | |
x_df = pd.DataFrame(x_df) | |
x_df.columns = cols | |
return x_df |
Our SHAP code produces the following files.
The dot plot is shown below. The SHAP impact is shown on the x-axis, which is the influence of moving a prediction away from some base value (e.g. the overall positive class rate in this case). Each dot represents an observation in the test set. The colors represent relative high vs. low values for that feature.
The shap library provides some additional nifty visualization options. We shall show three kinds of them, which are produced with the below script.
import os | |
import shap | |
import joblib | |
import matplotlib.pyplot as plt | |
import random | |
from helpers.model_helpers import make_directories_if_not_exists | |
from modeling.explain import transform_data_with_pipeline | |
def make_waterfall_plots(indices, expected_value, shap_values, x_df): | |
""" | |
Makes a SHAP waterfall plot for each index passed through for x_df. Plots are saved to the shap_plots directory. | |
:param indices: the indices of x_df for which to make plots | |
:param expected_value: the expected value produced from the explainer | |
:param shap_values: numpy array of shap values | |
:param x_df: x dataframe | |
""" | |
for index in indices: | |
shap.waterfall_plot(expected_value, shap_values[index], x_df.iloc[index], show=False) | |
plt.savefig(os.path.join('shap_plots', f'shap_waterfall_{index}.png'), bbox_inches='tight') | |
plt.clf() | |
def make_decision_plot(expected_value, shap_values, x_df): | |
""" | |
Makes a SHAP decision plot and saved it ont the shap_plots directory. | |
:param expected_value: the expected value produced from the explainer | |
:param shap_values: numpy array of shap values | |
:param x_df: x dataframe | |
""" | |
shap.decision_plot(expected_value, shap_values, x_df, show=False, link='identity') | |
plt.savefig(os.path.join('shap_plots', 'shap_decision_plot.png'), bbox_inches='tight') | |
plt.clf() | |
def make_shap_interaction_plots(shap_values, x_df, n_rank): | |
""" | |
Plots a series pf SHAP interaction plots fo the top n_rank features. The interaction plot shows the most meaningful | |
interaction with feature. | |
:param shap_values: numpy array of shap values | |
:param x_df: x dataframe | |
:param n_rank: number of top ranking features for which to plot interactions | |
""" | |
for rank in range(n_rank): | |
shap.dependence_plot(f'rank({rank})', shap_values, x_df, show=False) | |
plt.savefig(os.path.join('shap_plots', f'shap_interaction_{rank}.png'), bbox_inches='tight') | |
plt.clf() | |
def main(): | |
make_directories_if_not_exists(['shap_plots']) | |
x_df = joblib.load('../modeling/extra_trees_uncalibrated_202101312152421899080600/data/x_test.pkl') | |
x_df = x_df.sample(n=500) | |
pipeline = joblib.load('../modeling/extra_trees_uncalibrated_202101312152421899080600/models/model.pkl') | |
model = pipeline.named_steps['model'] | |
x_df = transform_data_with_pipeline(pipeline, x_df) | |
x_df.reset_index(inplace=True, drop=True) | |
x_indices = x_df.index.to_list() | |
random_x_indices = random.sample(x_indices, 3) | |
explainer = shap.TreeExplainer(model) | |
shap_values = explainer.shap_values(x_df)[1] | |
expected_value = explainer.expected_value[1] | |
make_waterfall_plots(random_x_indices, expected_value, shap_values, x_df) | |
make_decision_plot(expected_value, shap_values, x_df) | |
make_shap_interaction_plots(shap_values, x_df, 10) | |
if __name__ == "__main__": | |
main() | |
The waterfall plot is pretty cool as it shows how the model reached an individual prediction. For this observation, our predicted probability is 0.137, above the expected value of 0.086. We learn that a few features, in conjunction, drive the probability above the baseline.
The decision plot is simply an alternative to the dot plot displayed earlier. Each line represents an observation from our x dataframe, which in this case is our test set. As we move from bottom to top, the SHAP values are added, which shows each features' impact on the final prediction. At the top of the plot, we end at each observations' predicted value.
The interaction plot shows SHAP values for two features with notable interplay. We can say that, when the marketing_campaign is level 9 and the all_star_group is level 1, we get a greater movement away from the expected value than if only the marketing campaign is level 9.
Lastly, a couple of important nuances with SHAP values exists. First, our SHAP expected value + sum of SHAP values should equal our predicted probability. When we're explaining a CalibratedClassifierCV, using the TreeExplainer requires us to feed in each base estimator from our ensemble, produce its SHAP values, and then average all the SHAP values. This is all well and good. However, remember that a CalibratedClassifierCV includes a calibrator model in addition to the base models. Therefore, the average prediction of our base models is not necessarily our final prediction. Related, the average of our SHAP values plus the expected value may not equal our predicted probability. If we want this calculation to back out, we need to instead use the KernelExplainer. From the SHAP documentation: "Kernel SHAP is a method that uses a special weighted linear regression to compute the importance of each feature. The computed importance values are Shapley values from game theory and also coefficents from a local linear regression." The benefit of using the TreeExplainer version is that algorithm is optimized for tree-based models and it's, therefore, comparatively fast. That downside is that the expected SHAP calculation may not exactly check out. However, the SHAP values will still be very useful and informative. The KernelExplainer is more generic and slower, but the SHAP calculation will add up. We have to assess the tradeoff and determine what is best for our circumstance.
In the code, you may see check_additivity=False. This is a check in the SHAP library code that our SHAP expected value + sum of SHAP values should equal our predicted probability for an individual model (e.g. standalone XGBoost, base estimator of a CalibratedClassifierCV). Some occasional weird behavior appears to exist with Random Forest and Extra Trees models. This appears to be a known issue, evidenced by several open GitHub issues. From my experience, the error is non-deterministic: given the same exact model and the same exact data, I sometimes get the error and I sometimes don't. My guess, though I don't know for sure, is the feature perturbation is non-deterministic, and this error will occur in trees with certain characteristics. When the error occurs, SHAP claims the model output is something crazy high. This seems to be something the shap library maintainers need to fix, so I just turn off the check to prevent non-deterministic errors.
One last note on SHAP values: many gradient boosting models don't output a probability. The output is a more generic numeric value, but the interpretation will be the same relatively.
Most of the models we trained involved an ensemble of trees. Individually, each of these trees are not great. Likewise, each model builds a slew of trees, and inspecting them all would likely be intractable. However, inspecting a sampling of the individual trees can help us better understand our model and, to a degree, how it's making decisions under the hood. Inspecting the trees can also help us see if our model is "cheating". Perhaps we repeatedly see splits that don't make sense and are simply the result of feature leakage. Even though each individual tree is comparatively weak, we can still get a feel for if the splits make sense or are nonsensical. We can use a package called dtreeviz to help with our worthy aim.
def produce_tree_visualization(tree, tree_index, x, y, target_name, feature_names, class_names, model_name): | |
""" | |
Produces visualization of a decision tree from an ensemble. | |
:param tree: tree model | |
:param tree_index: index of the tree in the ensemble | |
:param x: predictor matrix | |
:param y: target series | |
:param target_name: name of the target | |
:param feature_names: list of feature names | |
:param class_names: name of the target classes | |
:param model_name: name of the model | |
""" | |
viz = dtreeviz(tree.estimators_[tree_index], | |
x, | |
y, | |
target_name=target_name, | |
feature_names=feature_names, | |
class_names=class_names | |
) | |
viz.save(os.path.join(model_name, 'diagnostics', 'explanation_files', f'decision_tree_{tree_index}.svg')) |
Partial dependence is a concept that allows us to see how our target changes as we vary the value of a feature. Our partial dependence plot supplies the mean response. However, we are also likely interested in the range of responses. We can satiate this want with ICE plots. To create such plots, we take a feature, artificially decrease or increase it's value in our data, and then record the effect on the prediction. More or less, this allow us to show the general dependence between the feature and the target. The code below produces PDP and ICE plots.
import multiprocessing as mp | |
import matplotlib.pyplot as plt | |
import os | |
from functools import partial | |
from sklearn.inspection import plot_partial_dependence | |
def _plot_partial_dependence(feature, model, x_df, plot_kind, model_uid): | |
""" | |
Produces a PDP or ICE plot and saves it locally into the pdp directory. | |
:param feature: name of the feature | |
:param model: fitted model | |
:param x_df: x dataframe | |
:param plot_kind: "both" for ICE plot of "average" for PDP | |
:param model_uid: model uid | |
""" | |
_, ax = plt.subplots(ncols=1, figsize=(9, 4)) | |
display = plot_partial_dependence(model, x_df, [feature], kind=plot_kind) | |
plt.title(feature) | |
plt.xlabel(feature) | |
plt.savefig(os.path.join('modeling', model_uid, 'diagnostics', 'pdp', f'{feature}_{plot_kind}.png')) | |
plt.clf() | |
def produce_partial_dependence_plots(model, x_df, plot_kind, model_uid): | |
""" | |
Produces a PDP or ICE plot for every column in x_df. x_df is spread across all available CPUs on the machine, | |
allowing plots to be created and saved in parallel. | |
:param model: fitted model | |
:param x_df: x dataframe | |
:param plot_kind: "both" for ICE plot of "average" for PDP | |
:param model_uid: model uid | |
""" | |
model.fitted_ = True | |
pdp_plot_fn = partial(_plot_partial_dependence, model=model, x_df=x_df, plot_kind=plot_kind, model_uid=model_uid) | |
with mp.Pool(processes=mp.cpu_count()) as pool: | |
result = pool.map(pdp_plot_fn, list(x_df)) | |
Below are some of our PDPs. We can see that as average_stars decreases (these are the scaled values), the predicted probability decreases, though the overall effect is pretty small. We also notice that when the all_star_group is level 2 (dummy variable of 1), the predicted probability goes down.
For the profile_score, the ICE plot tells us that the effect is pretty similar across the feature, though we can observe a bit of a jump for certain values on the high end of the distribution.
Partial dependence plots are quite useful. However, they cannot be trusted when features are strongly correlated. An alternative is accumulated local effects (ALE). For a thorough discussion of ALE's, please refer to https://christophm.github.io/interpretable-ml-book/ale.html. Please see the code below for how to produce ALE charts. To note, ALE only works for numeric features.
import matplotlib.pyplot as plt | |
import multiprocessing as mp | |
from functools import partial | |
from PyALE import ale | |
from helpers.model_helpers import find_non_dummied_columns | |
def _produce_ale_plot(feature, x_df, model, model_uid): | |
""" | |
Produces an ALE plot and saves it locally. | |
:param feature: name of the feature | |
:param x_df: x dataframe | |
:param model: fitted model | |
:param model_uid: model uid | |
""" | |
ale_effect = ale(X=x_df, model=model, feature=[feature], include_CI=False) | |
plt.title(feature) | |
plt.xlabel(feature) | |
plt.savefig(os.path.join('modeling', model_uid, 'diagnostics', 'ale', f'{feature}_ale.png')) | |
plt.clf() | |
def produce_accumulated_local_effects_plots(x_df, model, model_uid): | |
""" | |
Produces an ALE plot for every column numereic column in x_df. x_df is spread across all available CPUs on the | |
machine, allowing plots to be created and saved in parallel. | |
:param x_df: x dataframe | |
:param model: fitted model | |
feature index as the second item | |
:param model_uid: model uid | |
""" | |
x_numeric_df = x_df[find_non_dummied_columns(x_df)] | |
ale_plot_fn = partial(_produce_ale_plot, model=model, x_df=x_df, model_uid=model_uid) | |
with mp.Pool(processes=mp.cpu_count()) as pool: | |
result = pool.map(ale_plot_fn, list(x_numeric_df)) |