Explainability of pymc predictions: which features and which direction

I’ve developed a pymc model that is fairly accurate on the probability predictions given by pm.sample_posterior_predictive(idata). However I need a hint on explainability: How can I return back to the business actionable suggestions based on the features input to the model?

To better communicate the question I have run the same process on titanic survivor data, and can see a few important features from az.plot_trace() which are centered to the left and right of 0. I conclude that these two features are important features to the predictions (shown here, it’s gender_classo and boato). How/what function can I use to say to the business in actual number ranges, “when boato is x value, that passenger is x% more likely to survive, all other features being equal?” I’ve tried finding the mean and std of these plots, and seems plausible - am I on the right track? Do I need the log of the mean?

1 Like

It sounds like you are going to want to generate some posterior predictive samples that are conditional on new (hand selected) values of your predictors (e.g., boato). To do so, it might be easiest to wrap your data in a pm.Data container and then pm.observe() new values (e.g., the particular values of boato that you want to present) and then generate some posterior predictive samples (that are now conditional on the newly observed data). Here is a notebook that might help.

2 Likes

You might also find arviz.plot_posterior which has a ref_val argument to generate a plot closer to what you want for this task, as opposed to plot_trace being more geared toward convergence diagnosis.

1 Like

Thanks for this idea - to clarify: is the suggestion to experiment here by testing various possible values of input features, then see how the outcome changes via posterior predictive samples? That seems like it would accommodate some specific ideas, but require a lot of trial and error. I just wonder if what I’m looking for is already in the data somewhere without the tests?

e.g. I try varying featureX by running with values 1,2,3…,10, and learn that values 7-9 are the most likely to cause my outcome. Is there something in the plot_trace or posterior predictive that already shows me that without hand-testing all values? Don’t be afraid to suggest something so basic that you’d think I’d already know it, this is my first go with pymc.

I think this image captures what you’re suggesting? If so, how can I use its results to answer my initial question? For instance, I see that the chart on the bottom right, n_boato, has a mean of 0.85 and 94% HDI between 0.68-1.0. (I happen to know from the dataset that knowing the boat is highly indicative of survival, since “boat0” means there was no rescue boat assigned to passenger). How would you phrase this posterior plot in a business-form sentence: “having a boat0 value between 0.68-1.0 lowers the likelihood of survival by x%”?

When you have a simple linear regression, you can look at these parameter values and make statements about the effect of X on y; specifically, “a unit change in X causes a \beta unit change in y”. This is all tied to the partial derivative \frac{\partial y}{\partial X}. Since you have a model of the form y = X\beta, clearly this derivative is just \beta.

In logistic regression (which I guess you’re doing?), you have to deal with the non-linear effect of the link function. The effect of X of y \frac{\partial y}{\partial X} under the model y = \text{Logit}^{-1}(X\beta) will not be just \beta. The inverse logit function is \text{Logit}^{-1}(x) = \frac{1}{1 + exp(-x)}. From the presence of the exponential you can guess that the effect of a unit shift in X is now going to depend on the level of X. The “traditional” way to talk about impacts here to talk about log-odds – see here for an explaination. The short version is the the logit function (not the inverse!) is equal to the log odds, so if you take logit on both sides of the model, e/g. \text{Logit(y)} = \text{Logit}(\text{Logit}^{-1}(X\beta)) \implies \text{Logit}(y) = X\beta \implies \ln \left ( \frac{y}{1 - y} \right ) = X \beta . So a unit change in X can be interpreted linearly with respect to the log-odds of survival. You could say in your example, that, holding all else equal, being assigned a life-boat increases the log-odds of survival by 0.85, or the odds of survival by exp(0.85) = 2.3. So a passenger assigned a lifeboat had a 130% better shot at making it than one without.

You will be forgiven for thinking this all sucks. For one thing, I discarded all the posterior uncertainty. For another, you will have to work out how to reason about these partial derivatives every time you work with a different model. Instead, you can run simulations using pm.set_data. To illustrate, I’ll make a similar logistic regression model for titanic survivors:

import pymc as pm
from sklearn import datasets
import numpy as np
import matplotlib.pyplot as plt
import arviz as az

data = datasets.fetch_openml(name='titanic', version=1)
X = data['data']
y = data['target']

X['last_name'] = X.name.str.split(',').str[0].str.strip()
X['first_name'] = X.name.str.split(',').str[1:].str.join(',').str.strip()
X['honorific'] = X.first_name.str.split('.').str[0].str.strip()

normal_people = ['Miss', 'Mr', 'Mrs', 'Mme', 'Ms']
X['has_title'] = ~X.honorific.isin(normal_people)

X['first_class'] = X.pclass == 1
X['second_class'] = X.pclass == 2
X['female'] = X['sex'] == 'female'
X['log_fare'] = X.fare.apply(np.log).replace(-np.inf, np.nan).fillna(0)
X['no_boat'] = X.boat.isna()

features = ['first_class', 'second_class', 'has_title', 'female', 'log_fare', 'no_boat']
coords = {'feature': features,
          'obs_idx': X.index.values}

with pm.Model(coords=coords) as model:
    X_pt = pm.Data('X', X[features].astype(float), dims=['obs_idx', 'feature'])
    y_pt = pm.Data('y', y.astype(int), dims=['obs_idx'])
    alpha = pm.Normal('alpha', 0, 1)
    beta = pm.Normal('beta', 0, 3, dims=['feature'])
    
    logit_p = alpha + X_pt @ beta
    y_hat = pm.Bernoulli('y_hat', logit_p=logit_p, observed=y_pt, dims=['obs_idx'])
    
    idata = pm.sample()
    idata = pm.sample_posterior_predictive(idata, extend_inferencedata=True)

My estimates are somewhat different than yours, which I guess reflects that I don’t know what your variables mean. But that’s not really the point of the exercise, so it doesn’t matter.

We can see what predictions our model would make if everyone had no_boat = 1.0 or everyone had no_boat = 0.0. Comparing these will give insight into the effect of the no_boat variable on the model. To do this, use pm.set_data with pm.posterior_predictive:

with model:
    pm.set_data({'X': X[features].assign(no_boat = 1.0)})
    idata_no_boat = pm.sample_posterior_predictive(idata, predictions=True, extend_inferencedata=False)
    pm.set_data({'X': X[features].assign(no_boat = 0.0)})
    idata_boat = pm.sample_posterior_predictive(idata, predictions=True, extend_inferencedata=False)

fig, ax = plt.subplots(1, 3, figsize=(14, 4))
az.plot_posterior(idata_no_boat.predictions, combine_dims={'obs_idx'}, ax=ax[0])
az.plot_posterior(idata_boat.predictions, combine_dims={'obs_idx'}, ax=ax[1])
az.plot_posterior(idata.posterior_predictive, combine_dims={'obs_idx'}, ax=ax[2])
ax[0].set_title('Nobody gets a boat')
ax[1].set_title('Everyone gets a boat')
ax[2].set_title('Observed')
plt.show()

It would be more direct to directly contrast the two outputs, by instead plotting the difference idata_boat - idata_no_boat:

az.plot_posterior(idata_boat.predictions - idata_no_boat.predictions, combine_dims={'obs_idx'})

So across the whole dataset, holding other factors equal, having a boat raises the chances of survival by about 88%.

2 Likes

Wow that is a very thorough and appreciated answer. by comparing two different outcomes with pm.set_data you’ve seen a clear difference, and without unwinding values through logit calculations. That will work well for my binary features, and I guess for my continuous ones I can set up a few bins, find the most impacting ones, and compare differences still.

For pymc’s power in prediction, I see there remains some work in interpretation. But this seems like a way forward. I’ll put some work into this can confirm back. Thank you!

For continuous variables, you can also directly compute the derivatives of the mean and look at how different features influence different rows of data:

import pytensor.tensor as pt
# run model as before, obtain idata

with pm.Model(coords=coords) as prediction_model:
    X_pt = pm.Data('X', X[features].astype(float), dims=['obs_idx', 'feature'])
    y_pt = pm.Data('y', y.astype(int), dims=['obs_idx'])
    alpha = pm.Flat('alpha')
    beta = pm.Flat('beta', dims=['feature'])
    
    logit_p = pm.Deterministic('logit_p', alpha + X_pt @ beta, dims=['obs_idx'])
    p = pm.Deterministic('p', pm.math.invlogit(logit_p), dims=['obs_idx'])
    dp_dX = pm.Deterministic('dp_dX', pt.grad(p.sum(), X_pt), dims=['obs_idx', 'feature'])

    feature_grads = pm.sample_posterior_predictive(idata, extend_inferencedata=False, var_names=['logit_p', 'p', 'dp_dX'])

Plot a few random rows:

az.plot_forest(feature_grads.posterior_predictive, 
               var_names=['dp_dX'],
               combined=True,
               oords={'feature':'log_fare', 'obs_idx':X.sample(10).sort_index().index})

The gradients are extremely small. This makes sense, because my estimated effect for log fare was close to zero. Nevertheless, we see what for some passengers, there is somewhat more uncertainty about it. This has to do with where they fall on the S-curve that defines the inverse logit link function. In this case, 286 and 853 both were assigned to boats, and have p \approx 1 to survive. Evidently this puts them in a region of the space where there is a bit more curvature with respect to log fare. But not by much. For those two passengers, we can read this plot as saying that given a 1% change in the fare, their probability of survival would shift by somewhere between -2.5% and 2.5%.

1 Like