Deep Dive into Catboost Functionalities for Model Interpretation | by Alvira Swalin | Towards Data Science

In my previous blog, we saw a comparative study of XGBoost, LightGBM & Catboost. With that analysis, we were able to conclude that catboost outperformed the other two in terms of both speed and accuracy. In this part, we will dig further into the catboost, exploring the new features that catboost provides for efficient modeling and understanding the hyperparameters.
For new readers, catboost is an open-source gradient boosting algorithm developed by Yandex team in 2017. It is a machine learning algorithm which allows users to quickly handle categorical features for a large data set and this differentiates it from XGBoost & LightGBM. Catboost can be used to solve regression, classification and ranking problems.
As Data Scientists, we can easily train models and make predictions, but, we often fail to understand what’s happening inside those fancy algorithms. This is one of the reasons why we see a huge difference in model performance between offline evaluation and final production. It is high time we stop treating ML as a “black box” and give importance to model interpretation while improving model accuracy. This will also help us in identifying data biases. In this part, we will see how catboost helps us to analyze models and improve visibility with the following functionalities:
Why should you know it?- Remove unnecessary features to simplify the model and reduce training/prediction time- Get the most influential feature for your target value and manipulate them for business gains (eg: healthcare providers want to identify what factors are driving each patient’s risk of some disease so they can directly address those risk factors with targeted medicines)
Apart from choosing the type of feature importance, we should also know which data we want to use for finding feature importance — train or test or complete dataset. There are pros and cons of choosing one over the other but in the end, you need to decide whether you want to know how much the model relies on each feature for making predictions (use training data) or how much the feature contributes to the performance of the model on unseen data (use test data). We will see later in this blog that only some methods can be used to find feature importance on data not used for training model.
If you care for the second and assuming you have all the time and resources, the crudest and most reliable way to find feature importance is to train multiple models leaving one feature at a time and compare the performance on the test set. If the performance changes a lot with respect to the baseline (performance when we use all the features) that means that the feature was important. But since we live in a practical world where we need to optimize both accuracy and computation time, this method is unnecessary. Here are a few smart ways in which catboost lets you find the best feature for your model:
For each feature, PredictionValuesChange shows how much on average the prediction changes if the feature value changes. The bigger the value of the importance the bigger on average is the change to the prediction value if this feature is changed.
Pros: It is cheap to compute as you don’t have to do multiple training or testing and you will not be storing any extra information. You will get normalized values as the ouput (all the importances will add up to 100).Cons: It may give misleading results for ranking objectives, it might put groupwise features into the top, even though they have a little influence on the resulting loss value.
To get this feature importance, catboost simply takes the difference between the metric (Loss function) obtained using the model in normal scenario (when we include the feature) and model without this feature (model is built approximately using the original model with this feature removed from all the trees in the ensemble). Higher the difference, the more important the feature is. It is not clearly mentioned in catboost docs how we find the model without feature.
Pros & Cons: This works well for most type of problems unlike predictionvalueschange where you can get misleading results for ranking problems, at the same time, it is computationally heavy .
SHAP value breaks a prediction value into contributions from each feature. It measures the impact of a feature on a single prediction value in comparison to the baseline prediction (mean of the target value for the training dataset).
Two major use cases for shap values:
object-level contributions of features
object-level contributions of features
2. Summary for the whole dataset (overall feature importance)
shap.summary_plot(shap_values, X_test)
Though we can get accurate feature importances through shap, they are computationally more expensive than catboost inbuilt feature importance. For more details on SHAP values, please read this kernel .
BonusAnother feature importance based on the same concept but different implementation is— Permutation based feature importance. Though catboost does not use this, this is purely model-agnostic and easy to calculate.
Though bothPredictionValuesChange & LossFunctionChange can be used for all types of metrics, it is recommended to use LossFunctionChangefor ranking metrics. Except for PredictionValuesChange , all other methods can use test data to find feature importance using models trained on train data.
To understand the differences better, here are the results for all the methods we discussed:
From the above plots, we can see that most of the methods are agreeing on top features. it looks like LossFunctionChange is closest to shap(which is more reliable). However, it is not fair to compare these methods directly because predictionvalueschange is based on train data whereas all others are based on test data.
We should also see the time it takes to run all of them:
With this parameter, you can find the strength of a pair of features (importance of two features together).
In the output, you will get a list for each pair of features. The list will have 3 values, the first value is the index of the first feature in the pair, the second value is the index of the second feature in the pair and the third value is the feature importance score for that pair. For implementation details, please check the embedded notebook.
It is interesting to note that the top two features in the single feature importance don’t necessarily make the strongest pair.
Dataset used in the notebook
import pandas as pd, numpy as np
import warnings
warnings.filterwarnings('ignore')
%matplotlib inline 
from catboost import *
import matplotlib.pyplot as plt
from sklearn import metrics
from sklearn.model_selection import train_test_split
import shap
from time import time

# ! pip install plotly 

header = ["age",
"workclass",
"fnlwgt",
"education",
"education-num",
"marital-status","occupation","relationship","race",
"sex","capital-gain","capital-loss","hours-per-week",
"native-country",
"income"]

train = pd.read_csv("adult_data", names = header, index_col=False)

print(train.shape)

(32561, 15)

train.head(3)

train.shape

(32561, 15)
train["income"] = train["income"].apply(lambda x: 0 if x == " <=50K" else 1)

def log_loss(m, X, y): 
    return metrics.log_loss(y,m.predict_proba(X)[:,1])
    
def permutation_importances(model, X, y, metric):
    baseline = metric(model, X, y)
    imp = []
    for col in X.columns:
        save = X[col].copy()
        X[col] = np.random.permutation(X[col])
        m = metric(model, X, y)
        X[col] = save
        imp.append(m-baseline)
    return np.array(imp)

def baseline_importance(model, X, y, X_test, y_test, metric):
    
    model = CatBoostClassifier(one_hot_max_size = 10, iterations = 500)
    model.fit(X, y, cat_features = categorical_features_indices, verbose = False)
    baseline = metric(model, X_test, y_test)
    
    imp = []
    for col in X.columns:
        
        save = X[col].copy()
        X[col] = np.random.permutation(X[col])
        
        model.fit(X, y, cat_features = categorical_features_indices, verbose = False)
        m = metric(model, X_test, y_test)
        X[col] = save
        imp.append(m-baseline)
    return np.array(imp)
    

X_train, X_test, y_train, y_test = train_test_split(train.drop("income", axis = 1), train["income"], \
                                                    train_size=0.7, random_state=1)

categorical_features_indices = [1, 3, 5, 6, 7, 8, 9, 13]

for col in X_train.columns:
    print(col, X_train[col].nunique())

age 73
workclass 9
fnlwgt 16640
education 16
education-num 16
marital-status 7
occupation 15
relationship 6
race 5
sex 2
capital-gain 116
capital-loss 88
hours-per-week 93
native-country 41

iteration = [50, 100, 150, 200, 250, 300, 350, 400, 450, 500, 550, 600, 650, 700, 750, 800]
loss = []
for i in iteration:
    model = CatBoostClassifier(one_hot_max_size = 10, iterations = i)
    model.fit(
    X_train, y_train,
    cat_features = categorical_features_indices,
    verbose = False
    )
    loss.append((i, log_loss(model, X_test, y_test), log_loss(model, X_train, y_train)))
    print(i)

50
100
150
200
250
300
350
400
450
500
550
600
650
700
750
800

plt.plot([i[0] for i in loss],[i[1] for i in loss]) 
plt.plot([i[0] for i in loss],[i[2] for i in loss]) 
plt.show

<function matplotlib.pyplot.show>
model = CatBoostClassifier(one_hot_max_size = 10, iterations = 500)
model.fit(
    X_train, y_train,
    cat_features = categorical_features_indices,
    verbose = False
)

<catboost.core.CatBoostClassifier at 0x1c283ca0b8>
# model.get_feature_importance??

shap_values = model.get_feature_importance(Pool(X_test, label=y_test,cat_features=categorical_features_indices), 
                                                                     type="ShapValues")
expected_value = shap_values[0,-1]
shap_values = shap_values[:,:-1]

shap.initjs()
shap.force_plot(expected_value, shap_values[3,:], X_test.iloc[3,:])

start_time = time.time()
shap_values = model.get_feature_importance(Pool(X_test, label=y_test,cat_features=categorical_features_indices), 
                                                                     type="ShapValues")
shap_values = shap_values[:,:-1]

shap.summary_plot(shap_values, X_test) 
elapsed = time.time() - start_time

elapsed

2.246151924133301
timeit.default_timer()

770830.962590046
def get_feature_imp_plot(method):
    
    if method == "Permutation":
        fi =  permutation_importances(model, X_test, y_test, log_loss)
    
    elif method == "Baseline":
        fi = baseline_importance(model, X_train, y_train, X_test, y_test, log_loss)
    
    elif method == "ShapeValues":
        shap_values = model.get_feature_importance(Pool(X_test, label=y_test,cat_features=categorical_features_indices), 
                                                                     type="ShapValues")
        shap_values = shap_values[:,:-1]
        shap.summary_plot(shap_values, X_test) 
        
    else:
        fi = model.get_feature_importance(Pool(X_test, label=y_test,cat_features=categorical_features_indices), 
                                                                     type=method)
        
    if method != "ShapeValues":
        feature_score = pd.DataFrame(list(zip(X_test.dtypes.index, fi )),
                                        columns=['Feature','Score'])

        feature_score = feature_score.sort_values(by='Score', ascending=False, inplace=False, kind='quicksort', na_position='last')

        plt.rcParams["figure.figsize"] = (12,7)
        ax = feature_score.plot('Feature', 'Score', kind='bar', color='c')
        ax.set_title("Feature Importance using {}".format(method), fontsize = 14)
        ax.set_xlabel("features")
        plt.show()

%time get_feature_imp_plot(method="PredictionValuesChange")

CPU times: user 281 ms, sys: 27.2 ms, total: 308 ms
Wall time: 313 ms

%time get_feature_imp_plot(method="LossFunctionChange")

CPU times: user 2.03 s, sys: 31.3 ms, total: 2.06 s
Wall time: 514 ms

%time get_feature_imp_plot(method="Permutation")

CPU times: user 1.27 s, sys: 137 ms, total: 1.4 s
Wall time: 1.08 s

%time get_feature_imp_plot(method="ShapeValues")

CPU times: user 3.86 s, sys: 50.1 ms, total: 3.91 s
Wall time: 2.29 s

%time get_feature_imp_plot(method="Baseline")

CPU times: user 25min 19s, sys: 6min 25s, total: 31min 45s
Wall time: 5min 32s

fi = model.get_feature_importance(Pool(X_test, label=y_test,cat_features=categorical_features_indices), 
                                                                     type="Interaction")

Dataset is provided, but Interaction feature importance don't use it.

fi[:5]

[[5, 10, 4.879359239300689],
 [7, 11, 4.211308589799566],
 [1, 7, 3.596972913690984],
 [6, 10, 3.56524055924632],
 [5, 6, 3.4918909052689524]]
fi_new = []
for k,item in enumerate(fi):  
    first = X_test.dtypes.index[fi[k][0]]
    second = X_test.dtypes.index[fi[k][1]]
    if first != second:
        fi_new.append([first + "_" + second, fi[k][2]])

fi_new[:5]

[['marital-status_capital-gain', 4.879359239300689],
 ['relationship_capital-loss', 4.211308589799566],
 ['workclass_relationship', 3.596972913690984],
 ['occupation_capital-gain', 3.56524055924632],
 ['marital-status_occupation', 3.4918909052689524]]
feature_score = pd.DataFrame(fi_new,columns=['Feature-Pair','Score'])

feature_score = feature_score.sort_values(by='Score', ascending=False, inplace=False, kind='quicksort', na_position='last')
plt.rcParams["figure.figsize"] = (16,7)
ax = feature_score.plot('Feature-Pair', 'Score', kind='bar', color='c')
ax.set_title("Pairwise Feature Importance", fontsize = 14)
ax.set_xlabel("features Pair")
plt.show()

feature = 'hours-per-week'
res = model.get_feature_statistics(X_train, y_train, feature, plot=True)

feature = 'workclass'
model.get_feature_statistics(X_train, y_train, feature, plot=True)

{'binarized_feature': array([8, 0, 1, ..., 0, 6, 0]),
 'mean_target': array([0.22366841, 0.1033411 , 0.2920592 , 0.        , 0.3948949 ,
        0.        , 0.29469123, 0.28923768, 0.57639754], dtype=float32),
 'mean_prediction': array([0.22334838, 0.10167585, 0.3016555 , 0.05339337, 0.37704837,
        0.01450176, 0.30424172, 0.2946237 , 0.56375426], dtype=float32),
 'objects_per_bin': array([15827,  1287,  1486,     9,   666,     5,   923,  1784,   805],
       dtype=uint32),
 'predictions_on_varying_feature': array([0.25023816, 0.24915191, 0.23332911, 0.18895106, 0.28084712,
        0.22534325, 0.24529697, 0.21784215, 0.2707088 , 0.        ]),
 'cat_values': array([' Private', ' ?', ' Local-gov', ' Without-pay', ' Federal-gov',
        ' Never-worked', ' State-gov', ' Self-emp-not-inc',
        ' Self-emp-inc'], dtype='<U17')}
feature = 'sex'
res = titanic_model.calc_feature_statistics(titanic_train, titanic_train_target, feature, plot=True)

model.get_feature_statistics?

 

Remove the most useless training objects from the training data
Prioritize a batch of new objects for labeling based on which ones are expected to be the most “helpful”, akin to active learning
With this functionality, you can calculate the impact of each object on the optimized metric for test data. Positive values reflect that the optimized metric increases and negative values reflect that the optimized metric decreases. This method is an implementation of the approach described in this paper. Details of these algorithms are beyond the scope of this blog.
github.com
There are three types of update_method available in cb.get_object_importance :
SinglePoint: The fastest and least accurate method
TopKLeaves: Specify the number of leaves. The higher the value, the more accurate and the slower the calculation
AllPoints: The slowest and most accurate method
For example, the following value sets the method to TopKLeaves and limits the number of leaves to 3:
TopKLeaves:top=3
Catboost has recently launched this functionality in its latest update. With this feature, we will be able to visualize how the algorithm is splitting the data for each feature and look at feature-specific statistics. More specifically we will be able to see:
Mean target value for each bin (bin is used for continuous feature) or category (currently only OHE features are supported)
Mean prediction value for each bin/category
Number of objects in each bin
Predictions for different feature values: For every object, feature value is varied so that it falls into some bin. The model then predicts the target according to the new values of that feature and takes the mean of predictions in a bin (given by red points).
This plot will give us information like how uniform our splitting is (we don’t want all the objects to go in one bin), whether our predictions are close to target (blue and orange line), the red line will tell us how sensitive our predictions are wrt a feature.
Thank you for reading this. Hopefully, next time you will be able to make use of these tools to understand your models better.
After the positive reception from the machine learning community to my previous blog on CatBoost vs. Light GBM vs. XGBoost, the CatBoost team reached out to see if I was interested in covering more in-depth topics on the library. Thanks to the CatBoost team for helping answer my questions for this post!
About Me: I am currently working as a Data Scientist in Maps team at Uber. If you are interested in solving challenging problems at Uber, please reach out to me at LinkedIn. You can read my other blogs here.
Catboost Documentation
SHAP Values
Catboost official website
CatBoost Paper
Notebook
Paper on Finding Influential Training Samples