Explain multioutput predictive models with dalex

This notebook provides examples of working with multiclass classification and other multioutput algorithms, e.g. multioutput regression.

A natural example of such an algorithm is a multilayer perceptron neural network.

For a broad overview of the topic, see a comprehensive introduction in the scikit-learn package's documentation: 1.12. Multiclass and multioutput algorithms.

https://dalex.drwhy.ai/python

Imports

In [1]:
import dalex as dx

import numpy as np
import pandas as pd

from sklearn import datasets
from sklearn.ensemble import RandomForestRegressor
from sklearn.multioutput import MultiOutputRegressor
from lightgbm import LGBMClassifier, LGBMRegressor

import plotly
plotly.offline.init_notebook_mode()

import warnings
warnings.filterwarnings('ignore')
In [2]:
dx.__version__
Out[2]:
'1.7.0'

Part 1: treating a multioutput model as multiple singleoutput models

One approach is to use each model's output separately, e.g. the predicted probability for a given class in multiclass classification problem.

Part 1A: Multiclass classification

We will use the iris dataset and the LGBMClassifier model for this example.

In [3]:
# data
X, y = datasets.load_iris(return_X_y=True, as_frame=True)

# model 
model = LGBMClassifier(n_estimators=25, verbose=-1)
model.fit(X, y)

# model has 3 outputs
model.predict_proba(X).shape
Out[3]:
(150, 3)

Let's explain the classification for the first class 0. For that, we need to create a custom predict_function.

In [4]:
# custom (binary) predict function
pf_0 = lambda m, d: m.predict_proba(d)[:, 0]

# custom (binary) target values
y_0 = y == 0

# explainer
exp_0 = dx.Explainer(model, X, y_0, predict_function=pf_0, label="LGBMClassifier: class 0")
Preparation of a new explainer is initiated

  -> data              : 150 rows 4 cols
  -> target variable   : Parameter 'y' was a pandas.Series. Converted to a numpy.ndarray.
  -> target variable   : 150 values
  -> model_class       : lightgbm.sklearn.LGBMClassifier (default)
  -> label             : LGBMClassifier: class 0
  -> predict function  : <function <lambda> at 0x2b8457ce0> will be used
  -> predict function  : Accepts pandas.DataFrame and numpy.ndarray.
  -> predicted values  : min = 0.0126, mean = 0.337, max = 0.974
  -> model type        : classification will be used (default)
  -> residual function : difference between y and yhat (default)
  -> residuals         : min = -0.242, mean = -0.00336, max = 0.129
  -> model_info        : package lightgbm

A new explainer has been created!
In [5]:
exp_0.model_performance()
Out[5]:
recall precision f1 accuracy auc
LGBMClassifier: class 0 1.0 1.0 1.0 1.0 1.0
In [6]:
exp_0.model_parts().plot()
exp_0.model_profile().plot()
Calculating ceteris paribus: 100%|██████████| 4/4 [00:00<00:00, 245.38it/s]

Now, let's explain the classification for all the classes. For that, we can loop over multiple explainers.

In [7]:
exp_list = []

for i in range(len(np.unique(y))):
    # add i parameter to `predict_function` just to do it in a loop
    pf = lambda m, d, i=i: m.predict_proba(d)[:, i]
    e = dx.Explainer(
        model, X, 
        y == i, 
        predict_function=pf,
        label=f'LGBMClassifier: class {i}',
        verbose=False
    )
    exp_list += [e]

exp_list
Out[7]:
[<dalex._explainer.object.Explainer at 0x2bb619cd0>,
 <dalex._explainer.object.Explainer at 0x177404b90>,
 <dalex._explainer.object.Explainer at 0x2bb6ddd60>]
In [8]:
# create multiple explanations
m_profile_list = [e.model_profile() for e in exp_list]
Calculating ceteris paribus: 100%|██████████| 4/4 [00:00<00:00, 270.11it/s]
Calculating ceteris paribus: 100%|██████████| 4/4 [00:00<00:00, 233.53it/s]
Calculating ceteris paribus: 100%|██████████| 4/4 [00:00<00:00, 326.06it/s]
In [9]:
# plot multiple explanations
m_profile_list[0].plot(m_profile_list[1:])
In [10]:
m_parts_list = [e.model_parts() for e in exp_list]
m_parts_list[0].plot(m_parts_list[1:])

We explain predictions in a same way

In [11]:
# choose a data point to explain
observation = X.iloc[[0]]

p_parts_list = [e.predict_parts(observation) for e in exp_list]
p_parts_list[0].plot(p_parts_list[1:], min_max=[-0.1, 1.1])
In [12]:
p_profile_list = [e.predict_profile(observation) for e in exp_list]
p_profile_list[0].plot(p_profile_list[1:])
Calculating ceteris paribus: 100%|██████████| 4/4 [00:00<00:00, 826.83it/s]
Calculating ceteris paribus: 100%|██████████| 4/4 [00:00<00:00, 933.88it/s]
Calculating ceteris paribus: 100%|██████████| 4/4 [00:00<00:00, 1025.13it/s]

Part 1B: Multioutput regression

We will use a custom dataset and two models for this example:

  1. RandomForestRegressor, which supports multioutput directly,
  2. LGBMRegressor with MultiOutputRegressor, which involves creating one model for each output independently.

For details, see 1.10.3. Multi-output problems and scikit-learn: Comparing random forests and the multi-output meta estimator.

In [13]:
# create a toy multiregression example
n_outputs = 4
X, y = datasets.make_regression(n_samples=2000, n_features=7, n_informative=5, n_targets=n_outputs, effective_rank=1, noise=0.5, random_state=1)

# summarize the dataset
print(X.shape, y.shape)
(2000, 7) (2000, 4)
In [14]:
# model
model_rf = RandomForestRegressor()
model_rf.fit(X, y)
model_rf.predict(X).shape
Out[14]:
(2000, 4)

The following code returns an error because the LGBMRegressor model does not support multioutput directly.

In [15]:
try:
    model_gbm = LGBMRegressor()
    model_gbm.fit(X, y)
except Exception as e:
    print(f"Error: {e}")
Error: y should be a 1d array, got an array of shape (2000, 4) instead.
In [16]:
# wrap the second model
model_gbm = MultiOutputRegressor(LGBMRegressor(verbose=-1))
model_gbm.fit(X, y)
model_gbm.predict(X).shape
Out[16]:
(2000, 4)

Like in Part 1A, we explain the regression for all the outputs by looping over multiple explainers.

In [17]:
exp_rf_list, exp_gbm_list = [], []

for i in range(n_outputs):
    # add i parameter to `predict_function` just to do it in a loop
    pf = lambda m, d, i=i: m.predict(d)[:, i]

    e_rf = dx.Explainer(
        model_rf, X, 
        y[:, i], 
        predict_function=pf,
        label=f'RF: output {i}',
        verbose=False
    )
    e_gbm = dx.Explainer(
        model_gbm, X, 
        y[:, i], 
        predict_function=pf,
        label=f'GBM: output {i}',
        verbose=False
    )

    exp_rf_list += [e_rf]
    exp_gbm_list += [e_gbm]

exp_rf_list + exp_gbm_list
Out[17]:
[<dalex._explainer.object.Explainer at 0x2c2b82330>,
 <dalex._explainer.object.Explainer at 0x2c306b680>,
 <dalex._explainer.object.Explainer at 0x2c30a8d10>,
 <dalex._explainer.object.Explainer at 0x2c30a8fb0>,
 <dalex._explainer.object.Explainer at 0x2c30680e0>,
 <dalex._explainer.object.Explainer at 0x2c30a8350>,
 <dalex._explainer.object.Explainer at 0x2c306b5f0>,
 <dalex._explainer.object.Explainer at 0x2c2c98260>]

Let's check the models' performance.

In [18]:
m_performance_list = [e.model_performance() for e in exp_rf_list + exp_gbm_list]
pd.concat([mp.result for mp in m_performance_list], axis=0)
Out[18]:
mse rmse r2 mae mad
RF: output 0 0.066228 0.257348 0.979263 0.197098 0.150733
RF: output 1 0.051802 0.227601 0.979225 0.179050 0.147194
RF: output 2 0.052453 0.229026 0.961594 0.180068 0.151624
RF: output 3 0.064954 0.254861 0.980359 0.199861 0.165121
GBM: output 0 0.083923 0.289694 0.973723 0.226224 0.182196
GBM: output 1 0.081460 0.285411 0.967332 0.225156 0.190575
GBM: output 2 0.084242 0.290245 0.938318 0.230180 0.196593
GBM: output 3 0.080827 0.284301 0.975559 0.224692 0.184345
In [19]:
m_performance_list[0].plot(m_performance_list[4:8])

Explain!

In [20]:
m_parts_gbm_list = [e.model_parts(verbose=False) for e in exp_gbm_list]
m_parts_gbm_list[0].plot(m_parts_gbm_list[1::])

If the targets have related values and domains, we could compare their profiles.

In [21]:
m_profile_gbm_list = [e.model_profile(verbose=False) for e in exp_gbm_list]
m_profile_gbm_list[0].plot(m_profile_gbm_list[1::])

Let's compare local explanations.

In [22]:
# choose a data point to explain
observation = X[0, :]

exp_rf_list[1].predict_parts(observation, type="shap").plot(
    exp_gbm_list[1].predict_parts(observation, type="shap")
)

Part 2: adapting explanations for multioutput models

Another approach is to customize explanations specifically for multioutput models.

For example, there are loss_functions specific to multiclass classification, which we can substitute when calculating feature importance. We will use the iris dataset and the LGBMClassifier model for this example.

In [23]:
# data
X, y = datasets.load_iris(return_X_y=True, as_frame=True)

# model 
model = LGBMClassifier(n_estimators=25, verbose=-1)
model.fit(X, y)

# model has 3 outputs
model.predict_proba(X).shape
Out[23]:
(150, 3)
In [24]:
# predict function
pf = lambda m, d: m.predict_proba(d)

# explainer
exp = dx.Explainer(model, X, y, predict_function=pf, label="LGBMClassifier: multioutput")
Preparation of a new explainer is initiated

  -> data              : 150 rows 4 cols
  -> target variable   : Parameter 'y' was a pandas.Series. Converted to a numpy.ndarray.
  -> target variable   : 150 values
  -> model_class       : lightgbm.sklearn.LGBMClassifier (default)
  -> label             : LGBMClassifier: multioutput
  -> predict function  : <function <lambda> at 0x2c307a980> will be used
  -> predict function  : Accepts pandas.DataFrame and numpy.ndarray.
  -> predicted values  : min = 0.0123, mean = 0.333, max = 0.975
  -> model type        : classification will be used (default)
  -> residual function : difference between y and yhat (default)
  -> residuals         :  'residual_function' returns an Error when executed:
operands could not be broadcast together with shapes (150,) (150,3) 
  -> model_info        : package lightgbm

A new explainer has been created!

Please note the above warning:

-> residuals : 'residual_function' returns an Error when executed: operands could not be broadcast together with shapes (150,) (150,3)

It is due to the default residual_function expecting predict_function to return a numpy.ndarray (1d). With a wrong predict_function and others, we obtain the following Errors:

In [25]:
try:
    exp.model_performance()
except Exception as e:
    print(f"Error: {e}")

try:
    exp.model_parts()
except Exception as e:
    print(f"Error: {e}")

try:
    exp.model_profile(verbose=False)
except Exception as e:
    print(f"Error: {e}")
Error: operands could not be broadcast together with shapes (150,) (150,3) 
Error: Per-column arrays must each be 1-dimensional
Error: Expected a 1D array, got an array with shape (15150, 3)

Let's define a custom loss_function to work with model_parts, e.g. we can use Cross-entropy loss.

In [26]:
def loss_cross_entropy(y_true, y_pred):  
    ## for loop for code clarity - could be optimized
    probs = [0]*len(y_true)
    for i, val in enumerate(y_true):
        probs[i] = y_pred[i, val]
    return np.sum(-np.log(np.maximum(probs, 0.0000001)))
In [27]:
# test if it works
loss_cross_entropy(exp.y, exp.predict(exp.data))
Out[27]:
13.969795200086196

Explain! We obtain one global explanation for the multioutput model.

In [28]:
mp = exp.model_parts(loss_function=loss_cross_entropy)
In [29]:
mp.result
Out[29]:
variable dropout_loss label
0 _full_model_ 13.969795 LGBMClassifier: multioutput
1 sepal length (cm) 14.101999 LGBMClassifier: multioutput
2 sepal width (cm) 15.454057 LGBMClassifier: multioutput
3 petal width (cm) 57.806928 LGBMClassifier: multioutput
4 petal length (cm) 147.965945 LGBMClassifier: multioutput
5 _baseline_ 381.194627 LGBMClassifier: multioutput
In [30]:
mp.plot()

Let's define a custom residual_function to work with model_diagnostics.

In [31]:
# residual function
def residual_multiclass(model, data, y_true):  
    # convert target to one-hot encoding
    y_true_ohe = np.zeros((len(y_true), max(y_true)+1))
    y_true_ohe[np.arange(len(y_true)), y_true] = 1
    
    y_pred = model.predict_proba(data)

    # examplary definition of a residual in multiclass
    residuals = (y_true_ohe - y_pred).max(axis=1)
    
    return residuals

residual_multiclass(model, X, y)
Out[31]:
array([0.02774252, 0.04163863, 0.03701258, 0.03785492, 0.02686358,
       0.02830568, 0.0279677 , 0.02623561, 0.04165905, 0.03785492,
       0.02709457, 0.02684038, 0.04163863, 0.04163863, 0.02774252,
       0.02758579, 0.02824271, 0.02824271, 0.02830568, 0.02758579,
       0.02780508, 0.02758579, 0.02746971, 0.12876921, 0.12071159,
       0.03979186, 0.02671168, 0.02709457, 0.02774252, 0.03614604,
       0.03785492, 0.02758579, 0.02709457, 0.02774252, 0.03785492,
       0.03623249, 0.02774252, 0.02746971, 0.04163863, 0.02709457,
       0.02734837, 0.04272371, 0.03701258, 0.10575102, 0.12611292,
       0.0423885 , 0.02709457, 0.03701258, 0.02709457, 0.03020258,
       0.052583  , 0.05505766, 0.18704562, 0.04279669, 0.0719759 ,
       0.05189848, 0.06675326, 0.06222834, 0.04060428, 0.04556485,
       0.06116572, 0.04505639, 0.05693209, 0.05318432, 0.0362354 ,
       0.0337152 , 0.05977671, 0.05718467, 0.0657071 , 0.04248536,
       0.64022528, 0.04175985, 0.23349021, 0.06174934, 0.03200454,
       0.0337152 , 0.09935829, 0.812102  , 0.05565151, 0.05817762,
       0.04249668, 0.06115695, 0.03985367, 0.65337007, 0.05977671,
       0.05609032, 0.06341944, 0.04017693, 0.03611429, 0.04279669,
       0.04304149, 0.04566882, 0.03968361, 0.06116572, 0.04291728,
       0.03368476, 0.03396602, 0.03395432, 0.26016319, 0.04230437,
       0.03379132, 0.06308577, 0.03231422, 0.03244895, 0.03182727,
       0.03231422, 0.35802604, 0.03295214, 0.03262869, 0.03430726,
       0.08522448, 0.04110757, 0.0366989 , 0.09332252, 0.05392016,
       0.04755372, 0.04755951, 0.03430726, 0.02517083, 0.51712228,
       0.03430726, 0.12141293, 0.02560554, 0.16120138, 0.03430726,
       0.04456931, 0.23973531, 0.25454487, 0.02491712, 0.17252202,
       0.02930592, 0.03471619, 0.02491712, 0.37260292, 0.18299951,
       0.03231422, 0.03379132, 0.04755951, 0.36096157, 0.0366989 ,
       0.03231422, 0.08231583, 0.06308577, 0.03430726, 0.03430726,
       0.0460674 , 0.10842656, 0.0459176 , 0.03809492, 0.0953771 ])
In [32]:
# explainer
exp_res = dx.Explainer(
    model, X, y, 
    # by default, predict_function needs to return a (1d) numpy.ndarray
    predict_function=lambda m, d: m.predict(d), 
    residual_function=residual_multiclass, 
    label="LGBMClassifier: multioutput"
)
Preparation of a new explainer is initiated

  -> data              : 150 rows 4 cols
  -> target variable   : Parameter 'y' was a pandas.Series. Converted to a numpy.ndarray.
  -> target variable   : 150 values
  -> model_class       : lightgbm.sklearn.LGBMClassifier (default)
  -> label             : LGBMClassifier: multioutput
  -> predict function  : <function <lambda> at 0x2c32ff880> will be used
  -> predict function  : Accepts pandas.DataFrame and numpy.ndarray.
  -> predicted values  : min = 0.0, mean = 1.01, max = 2.0
  -> model type        : classification will be used (default)
  -> residual function : <function residual_multiclass at 0x2c32fc680>
  -> residuals         : min = 0.0249, mean = 0.0763, max = 0.812
  -> model_info        : package lightgbm

A new explainer has been created!
In [33]:
exp_res.model_diagnostics().plot(variable="petal length (cm)")

Plots

This package uses plotly to render the plots:

Resources - https://dalex.drwhy.ai/python