Modelos interpretables con Python SHAP

Enrique Blanco    27 abril, 2021

Recientemente se publicó en este blog un interesante artículo sobre la interpretabilidad de modelos de Machine Learning. Los modelos de inteligencia artificial se han vuelto complejos, en especial con el auge del Deep Learning. Este es un problema grave, especialmente cuando los humanos son responsables de las decisiones basadas en soluciones digitales. El objetivo de hacer que los algoritmos sean accesibles, entendible e interpretables es fundamental para lo que se conoce como «Inteligencia Artificial Explicable (XAI)«, y en los últimos tres años se ha convertido en un área de investigación muy activa.

En este post vamos a realizar un ejemplo simple de cómo dotar de explicabilidad e interpretabilidad a un modelo de análisis de sentimiento de regresión logística lineal usando la librería de Python Shap. Tomando como ejemplo un análisis típico de sentimiento sobre un dataset de críticas de películas, veremos cómo los valores SHAP de cada palabra nos permitirán entender cuáles son las más importantes a la hora de tomar la decisión de si esa crítica es buena o mala.
Con un modelo lineal el valor SHAP para la característica i para la predicción f(x) (asumiendo que las características son independientes) es simplemente:

Dado que estamos explicando un modelo de regresión logística, las unidades de los valores SHAP estarán en el espacio log-odds.
No nos vamos a complicar para realizar esta prueba de usabilidad; el conjunto de datos que utilizamos es el conjunto de datos clásico de IMDB de este enlace.

Para empezar a usar la librería, dentro de nuestro entorno virtual de Python, simplemente hay que ejecutar el siguiente comando en un terminal:

pip install shap

Importando librerías

Para esta rápida prueba, vamos a usar scikit-learn como librería desde la que haremos uso de las funciones necesarias.

import sklearn
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report
import numpy as np
import shap
from gensim.parsing.preprocessing import remove_stopwords

shap.initjs()

Cargando y procesando en IMDB dataset

corpus, y = shap.datasets.imdb()

Como ejemplo, tenemos la siguiente reseña:

corpus[10]

This film is one giant pant load. Paul Schrader is utterly lost in his own bad screenplay. And his directing is about as comatose as it can be without his actually having been sleepwalking during the process. <br /><br />The worst though is Woody Harrelson, whom I ordinarily like when he's properly cast...

Estamos manejando un dataset perfectamente balanceado, con 12500 reseñas positivas y el mismo número de reseñas negativas.

print('Posibles etiquetas para el dataset: ', np.unique(y, return_counts=True))
Posibles etiquetas para el dataset:  (array([False,  True]), array([12500, 12500]))

Antes de empezar con la tokenización de las palabras que componen las reseñas de las películas, debemos eliminar las stopwords de nuestro dataset, sobre lo que ya hablamos con anterioridad en este blog. Estas palabras no añaden información válida a la reseña y son muy frecuentes (conjunciones, preposiciones, pronombres, verbos auxiliares, etc.) Este paso se puede abordar con un simple bucle para todas las reseñas:

corpus_cleaned = list()

for review in corpus:
  corpus_cleaned.append(remove_stopwords(review).replace('</br>', ''))

Por ejemplo, la reseña corpus[10] queda de la siguiente manera:

corpus_cleaned[10]

This film giant pant load. Paul Schrader utterly lost bad screenplay. And directing comatose actually having sleepwalking process. </></>The worst Woody Harrelson, I ordinarily like he\'s properly cast...

Partición train-test del dataset

Una vez nuestro dataset está limpio de stopwords, se debe realizar la partición entrenamiento-testeo (vamos a elegir una razón 80%-20%) y se procede a su transformación TF-IDF con la TfidfVectorizer de sklearn.

corpus_train, corpus_test, y_train, y_test = train_test_split(corpus_cleaned, 
                                                              y, 
                                                              test_size=0.2, 
                                                              random_state=42)

vectorizer = TfidfVectorizer(min_df=10)
X_train = vectorizer.fit_transform(corpus_train).toarray() # sparse also works but Explanation slicing is not yet supported
X_test = vectorizer.transform(corpus_test).toarray()

Con este paso ya tenemos la partición train-test realizada con 20,000 muestras de entrenamiento y 5,000 muestras de testeo. Cada una de esas muestras o arrays tiene 16,364 elementos.

print(X_train.shape)
print(X_test.shape)

(20000, 16364)
(5000, 16364)

Entrenando un algoritmo de Regresión Logística

model = sklearn.linear_model.LogisticRegression(penalty="l2", C=0.1)
model.fit(X_train, y_train)

LogisticRegression(C=0.1, class_weight=None, dual=False, fit_intercept=True,
                   intercept_scaling=1, l1_ratio=None, max_iter=100,
                   multi_class='auto', n_jobs=None, penalty='l2',
                   random_state=None, solver='lbfgs', tol=0.0001, verbose=0,
                   warm_start=False)

Tras este rápido entrenamiento, el comportamiento de nuestro modelo es aceptable. Se consiguen unas precisiones y recalls en el intervalo 0.82-0.88 para los datos de validación. Para esta partición, el comportamiento es muy similar al obtenido en el train dataset.

print(classification_report(y_test, model.predict(X_test)))

              precision    recall  f1-score   support

       False       0.88      0.82      0.85      2515
        True       0.83      0.88      0.85      2485

    accuracy                           0.85      5000
   macro avg       0.85      0.85      0.85      5000
weighted avg       0.85      0.85      0.85      5000

Ahora bien, ¿en qué palabras se está fijando nuestro sencillo modelo de regresión logística para determinar si una secuencia de palabras pertenece a una reseña buena o mala? Con SHAP, visualizar esta información es muy sencillo.

Explicación de nuestro modelo lineal

explainer = shap.Explainer(model, X_train, feature_names=vectorizer.get_feature_names())
shap_values = explainer(X_test)

Resumen de la contribución de todas las características

Para obtener una descripción general de qué características son más importantes para un modelo, se pueden trazar los valores SHAP de cada característica para cada muestra. El gráfico siguiente clasifica las características por la suma de las magnitudes de los valores SHAP en todas las muestras y usa los valores SHAP para mostrar la distribución de los impactos que cada característica tiene en la salida del modelo. El color representa el valor de la característica (rojo alto, azul bajo).

shap.plots.beeswarm(shap_values)
Figura 1. Beeswarm summary plot de la contribución de las características más relevantes para el modelo.

Podemos observar cómo altos valores de frecuencias en palabras como bad o worst tienen un impacto negativo importante en la toma de decisiones del modelo. Si esas palabras aparecen en una reseña, es bastante probable que la misma sea mala. Por el contrario, palabras como great, best o love son indicativos de que la reseña tiene altas probabilidades de ser positivas. Al final, el criterio de nuestro modelo de clasificación termina siendo bastante simple y fácilmente interpretable.

Explicación de la predicción del sentimiento de la segunda reseña

ind = 1
print("Reseña Positiva" if y_test[ind] else "Reseña Negativa")
print(corpus_test[ind])

Reseña Positiva
The idea ia short film lot information. Interesting, entertaining leaves viewer wanting more. The producer produced short film excellent quality compared short film I seen. I rated film highest possible rating. I recommend shown office managers ...

Otra forma de visualizar la contribución de cada componente a la decisión final del modelo sobre una secuencia de muestra es el force plot (descrito en el paper Nature BME paper):

shap.plots.force(shap_values[ind])
Figura 2. Force plot de la contribución de las palabras más relevantes a la toma de decisión.

Otra librería similar es LIME, la cual es capaz de explicar cualquier clasificador, con dos o más clases. Todo lo que necesitamos es que el clasificador implemente una función que tome texto sin formato o una matriz numérica y genere una probabilidad para cada clase, también integrado para clasificadores de scikit-learn. Jugaremos con esta librería en próximos posts.

Para mantenerte al día con el área de Internet of Things de Telefónica visita nuestra página web o síguenos en TwitterLinkedIn YouTube

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *