Explicando la explicabilidad

Por Manuel Garrido

Explicando la explicabilidad

Un aspecto importante en cualquier aplicación de Machine Learning que suele pasar desapercibida para mucha gente es la habilidad de los modelos predictivos para explicar porqué devuelven ciertas predicciones en lugar de otras. Llamamos explicabilidad o interpretabilidad a dicha habilidad.

Por ejemplo, supongamos que trabajamos como consultores externos para una empresa elaborando modelos. Seguramente a dicha empresa no le va a gustar que le digamos que el modelo que forma parte vital de su negocio es una "caja negra" que funciona mágicamente (de forma inexplicable) y que seguramente no podrán mantener una vez hayamos pasado a otro proyecto.

En otros casos, la explicabilidad de un modelo no solo es importante, sino obligatoria. En industrias altamente reguladas (por ejemplo en el sector financiero) el explicar porqué un modelo funciona de la manera que funciona es obligatorio. En la Unión Europea, la reciente implementación de la nueva directiva de datos GDPR, en concreto su artículo 22 indica que:

El sujeto de los datos tendrá el derecho a no ser sujeto a una decisión basada únicamente en un proceso automático...

según la interpretación (dado que la ley es muy muy nueva), esto puede llegar a obligar a que los usuarios tengan derecho a saber porqué un sistema automático ha tomado una decisión que les afecta (y digo puede porque la regulación no es muy concreta al respecto, pero hay debate sobre cómo esto puede cargarse modelos de caja negra tipo deep learning).

Vamos a comentar aquí algunas medidas que nos permiten proporcionar cierta explicabilidad a nuestros modelos:

En primer lugar, pongo las características de mi ordenador y de los paquetes que vamos a usar. En concreto, vamos a usar lo siguiente:

In [1]:
%load_ext watermark
%watermark -v -m -p sklearn,pdpbox,lime,eli5
CPython 3.6.4
IPython 6.3.1

sklearn 0.19.1
pdpbox 0.2.0
lime n
eli5 0.8

compiler   : GCC 7.2.0
system     : Linux
release    : 4.15.0-36-generic
machine    : x86_64
processor  : x86_64
CPU cores  : 8
interpreter: 64bit

Imports y eso

In [2]:
from IPython.display import Image
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from matplotlib import cm
import warnings
warnings.simplefilter("ignore")
%matplotlib inline
In [3]:
# figuritas grandes
plt.rcParams['figure.figsize'] = [10, 10]

Cargamos los datos

Para empezar con algo sencillito, vamos a usar el dataset de casas de Boston, Boston Housing Dataset que contiene información sobre los precios y características de bloques de casas en la ciudad de Boston. La variable objetivo de este dataset PREDV es el precio medio de las casas en un bloque en función de las características de dicha área residencial.

Está disponible en scikit-learn por lo tanto no tenemos que descargarnos nada:

In [4]:
from sklearn.datasets import load_boston
from sklearn.model_selection import train_test_split

boston = load_boston()
X = pd.DataFrame(boston.data, columns=boston.feature_names)
y = boston.target
X.head()
Out[4]:
CRIM ZN INDUS CHAS NOX RM AGE DIS RAD TAX PTRATIO B LSTAT
0 0.00632 18.0 2.31 0.0 0.538 6.575 65.2 4.0900 1.0 296.0 15.3 396.90 4.98
1 0.02731 0.0 7.07 0.0 0.469 6.421 78.9 4.9671 2.0 242.0 17.8 396.90 9.14
2 0.02729 0.0 7.07 0.0 0.469 7.185 61.1 4.9671 2.0 242.0 17.8 392.83 4.03
3 0.03237 0.0 2.18 0.0 0.458 6.998 45.8 6.0622 3.0 222.0 18.7 394.63 2.94
4 0.06905 0.0 2.18 0.0 0.458 7.147 54.2 6.0622 3.0 222.0 18.7 396.90 5.33
In [5]:
y[:10]
Out[5]:
array([24. , 21.6, 34.7, 33.4, 36.2, 28.7, 22.9, 27.1, 16.5, 18.9])

Nota: Los precios están en miles de dólares, y el dataset es de 1978. Hoy en dia no hay casas que valgan 24,000$ desgraciadamente ;)

El diccionario de datos lo podéis consultar aquí. De todas formas todos los datasets de scikit-learn tienen el atributo DESCR que nos proporciona su descripción:

In [6]:
print(boston.DESCR)
Boston House Prices dataset
===========================

Notes
------
Data Set Characteristics:  

    :Number of Instances: 506 

    :Number of Attributes: 13 numeric/categorical predictive
    
    :Median Value (attribute 14) is usually the target

    :Attribute Information (in order):
        - CRIM     per capita crime rate by town
        - ZN       proportion of residential land zoned for lots over 25,000 sq.ft.
        - INDUS    proportion of non-retail business acres per town
        - CHAS     Charles River dummy variable (= 1 if tract bounds river; 0 otherwise)
        - NOX      nitric oxides concentration (parts per 10 million)
        - RM       average number of rooms per dwelling
        - AGE      proportion of owner-occupied units built prior to 1940
        - DIS      weighted distances to five Boston employment centres
        - RAD      index of accessibility to radial highways
        - TAX      full-value property-tax rate per $10,000
        - PTRATIO  pupil-teacher ratio by town
        - B        1000(Bk - 0.63)^2 where Bk is the proportion of blacks by town
        - LSTAT    % lower status of the population
        - MEDV     Median value of owner-occupied homes in $1000's

    :Missing Attribute Values: None

    :Creator: Harrison, D. and Rubinfeld, D.L.

This is a copy of UCI ML housing dataset.
http://archive.ics.uci.edu/ml/datasets/Housing


This dataset was taken from the StatLib library which is maintained at Carnegie Mellon University.

The Boston house-price data of Harrison, D. and Rubinfeld, D.L. 'Hedonic
prices and the demand for clean air', J. Environ. Economics & Management,
vol.5, 81-102, 1978.   Used in Belsley, Kuh & Welsch, 'Regression diagnostics
...', Wiley, 1980.   N.B. Various transformations are used in the table on
pages 244-261 of the latter.

The Boston house-price data has been used in many machine learning papers that address regression
problems.   
     
**References**

   - Belsley, Kuh & Welsch, 'Regression diagnostics: Identifying Influential Data and Sources of Collinearity', Wiley, 1980. 244-261.
   - Quinlan,R. (1993). Combining Instance-Based and Model-Based Learning. In Proceedings on the Tenth International Conference of Machine Learning, 236-243, University of Massachusetts, Amherst. Morgan Kaufmann.
   - many more! (see http://archive.ics.uci.edu/ml/datasets/Housing)

Ahora generamos el dataset de entrenamiento:

In [7]:
variables_independientes = boston.feature_names
X_train, X_test, y_train, y_test = train_test_split(boston.data, boston.target, test_size=0.2)

Ya tenemos un dataset de entrenamiento y otro de validación, tenemos varias opciones a la hora de proporcionar cierta explicabilidad a nuestras predicciones:

Opción 1. Usar modelos explicables.

Hay ciertos modelos que por su definición son intrínsecamente explicables.

Por ejemplo, para modelos de regresión lineal, podemos crear una fórmula y explicar que el modelo simplemente es un producto lineal de coeficientes y las variables independientes.

In [8]:
from sklearn.linear_model import LinearRegression

model_ols = LinearRegression()
model_ols.fit(X_train, y_train)
Out[8]:
LinearRegression(copy_X=True, fit_intercept=True, n_jobs=1, normalize=False)
In [9]:
model_ols.intercept_
Out[9]:
42.75925047586816

Por ejemplo, podemos ver los coeficientes que se multiplican a cada variable de forma sencilla mediante el atributo _coef del modelo.

In [10]:
dict(zip(boston.feature_names, model_ols.coef_))
Out[10]:
{'CRIM': -0.10628408093803422,
 'ZN': 0.05719302123216638,
 'INDUS': 0.01858562853463657,
 'CHAS': 3.03591544145478,
 'NOX': -20.30932114037633,
 'RM': 3.18058175303253,
 'AGE': 0.007219326713202369,
 'DIS': -1.7383059571474184,
 'RAD': 0.3322161390394403,
 'TAX': -0.013897273320144073,
 'PTRATIO': -0.9461324334577969,
 'B': 0.009246372584185768,
 'LSTAT': -0.5571793936674317}

Para modelos basados en árboles, podemos usar el atributo feature_importances para conocer la importancia de cada variable a la hora de hacer una partición (partir un nodo en subnodos).

In [11]:
from sklearn.tree import DecisionTreeRegressor

model_tree = DecisionTreeRegressor(max_depth=5)
model_tree.fit(X_train, y_train)
Out[11]:
DecisionTreeRegressor(criterion='mse', max_depth=5, max_features=None,
           max_leaf_nodes=None, min_impurity_decrease=0.0,
           min_impurity_split=None, min_samples_leaf=1,
           min_samples_split=2, min_weight_fraction_leaf=0.0,
           presort=False, random_state=None, splitter='best')

Ahora que tenemos el modelo ajustado podemos usar feature_importances_

In [12]:
dict(zip(boston.feature_names, model_tree.feature_importances_))
Out[12]:
{'CRIM': 0.00891653770841052,
 'ZN': 0.0,
 'INDUS': 0.0,
 'CHAS': 0.0,
 'NOX': 0.030684728646413053,
 'RM': 0.27879476963541194,
 'AGE': 0.0028730119491539702,
 'DIS': 0.09008882718769619,
 'RAD': 0.0012602535791255542,
 'TAX': 0.01299990801227938,
 'PTRATIO': 0.028939765371919998,
 'B': 1.4288589332512238e-06,
 'LSTAT': 0.5454407690506561}

Así que en el caso concreto de este modelo, la variable que más importancia tiene a la hora de realizar una predicción es la variable RM (lo cual tiene sentido, el precio de una casa está muy relacionado con su número de habitaciones) y la variable LSTAT (barrios con mayor gente de clase alta tendrán casas más caras y al revés).

Si el modelo que elegimos no es uno de los de arriba, tenemos que usar otras formas de proporcionar explicabilidad al modelo. Vamos a ver un par de métodos.

Permutation importances

Permutation importances (importancia de permutaciones) es un método para proporcionar explicabilidad a un estimador de caja negra.

En este método, la importancia de cada variable independiente a la hora de predecir se estima mirando cómo varía la puntuación o el error (precisión, $F1$, $R^2$, etc.) del modelo cuando dicha variable no está disponible

Podemos usar el paquete eli5 para importancia de cada variable mediante Permutation Importances.

Por ejemplo, vamos a usar ahora un modelo SVR (Máquina de Vectores Soporte para problemas de regresión) para entrenar en el mismo dataset y usaremos eli5 para calcular la importancia de cada variable independiente mediante Permutation Importance.

In [13]:
from sklearn.svm import SVR
from eli5.sklearn import PermutationImportance
from eli5 import explain_weights

Ajustamos el modelo a los datos

In [14]:
svr = SVR().fit(X_train, y_train)

Ahora entrenamos el the PermutationImportance explainer en el dataset de test. Debemos pasarle el argumento scoring que indica la función de evaluación que vamos a usar. Podemos usar cualquiera de las que proporciona scikit-learn por defecto o crear la nuestra propia.

Por ejemplo, nosotros seleccionamos el error absoluto medio (neg_mean_absolute_error en scikit-learn). Es importante mencionar que en sklearn los errores se proporcionan en negativo.

In [15]:
explainer = PermutationImportance(svr, scoring="neg_mean_absolute_error").fit(X_test, y_test)

Ahora podemos usar la función explain_weights para que eli5 nos indique la importancia de cada variable independiente a la hora de tomar una decisión.

In [16]:
explain_weights(explainer, feature_names=boston.feature_names)
Out[16]:
Weight Feature
0.0927 ± 0.0260 TAX
0.0750 ± 0.0193 INDUS
0.0649 ± 0.0398 AGE
0.0638 ± 0.0419 LSTAT
0.0546 ± 0.0160 RAD
0.0414 ± 0.0384 ZN
0.0382 ± 0.0219 CRIM
0.0379 ± 0.0165 PTRATIO
0.0352 ± 0.0183 DIS
0.0030 ± 0.0040 RM
0.0007 ± 0.0016 CHAS
0.0003 ± 0.0001 NOX
-0.0032 ± 0.0357 B

Vemos, que para el modelo SVM, la variable más importante a la hora de predecir el precio de una casa es la cantidad de impuestos que se pagan en la zona y la edad de las casas.

Hay que tener en cuenta que diferentes algoritmos darán diferentes importancias a cada variable!. Por ejemplo si ahora usamos un Perceptrón Multicapa (MLP):

In [17]:
from sklearn.neural_network import MLPRegressor
np.random.seed(42)
mlp = MLPRegressor().fit(X_train, y_train)
explainer = PermutationImportance(mlp, scoring="neg_mean_absolute_error").fit(X_test, y_test)
explain_weights(explainer, feature_names=boston.feature_names)
Out[17]:
Weight Feature
1.0067 ± 0.1530 TAX
0.8525 ± 0.3151 B
0.8334 ± 0.3482 ZN
0.6072 ± 0.1576 LSTAT
0.1445 ± 0.0718 CRIM
0.0991 ± 0.0849 PTRATIO
0.0216 ± 0.0207 DIS
0.0003 ± 0.0049 CHAS
-0.0000 ± 0.0008 NOX
-0.0034 ± 0.0320 RAD
-0.0333 ± 0.0204 RM
-0.0471 ± 0.2234 AGE
-0.1557 ± 0.1739 INDUS

Vemos que dicho algoritmo predice que a la hora de predecir el precio de las viviendas las variables más importantes son el nivel fiscal y el porcentaje de la población de raza negra (algoritmos racistas, anyone?).

LIME

LIME (Local Interpretable Model-Agnostic Explanations) es un método que se puede usar para explicar el output de cualquier clasificador. Es una técnica bastante reciente (aquí está el paper, y la explicación del método por parte del autor)

A diferencia de Permutacion Importances, LIME se aplica a una observación cada vez, y funciona haciendo perturbaciones aleatorias de dicha observación (es decir, cambiando un poquito la observación cada vez) y viendo cómo varían las prediciones del modelo (es decir, la clase predicha). Una vez hecho esto se entrena un modelo de regresión lineal usando como variables independientes las permutaciones y como variable dependiente (u objetivo) la predicción del modelo para cada perturbación.

lime Traducido del artículo del autor:

La función de decisión original del modelo se representa mediante el fondo de color azul/rosa y es claramente no lineal. La equis de color rojo brillante representa la observación que está siendo explicada (llamémosla X). Tomamos un muestreo de observaciones alrededor de X, y les asignamos pesos según su proximidad a X (aquí representamos el peso mediante el tamaño). Calculamos la predicción del modelo original en cada una de estas observaciones perturbadas y entonces aprendemos un modelo lineal (línea intermitente) que aproxima el modelo bien en la vecindad de X. Nótese que la explicación de dicho modelo no es fiable a nivel global, pero es fiable a nivel local alrededor de X.

Podemos usar el paquete lime para usar LIME en python.

In [18]:
import lime.lime_tabular

Para este ejemplo vamos a usar un dataset clásico de clasificación, el dataset de Cáncer de Pecho (Breast Cancer Dataset), que contiene mediciones sobre observaciones de células potencialmente cancerosas, y donde la variable objetivo es si la paciente tiene cáncer maligno o benigno.

breast cancer

In [19]:
from sklearn.datasets import load_breast_cancer
cancer = load_breast_cancer()
print(cancer.DESCR)
Breast Cancer Wisconsin (Diagnostic) Database
=============================================

Notes
-----
Data Set Characteristics:
    :Number of Instances: 569

    :Number of Attributes: 30 numeric, predictive attributes and the class

    :Attribute Information:
        - radius (mean of distances from center to points on the perimeter)
        - texture (standard deviation of gray-scale values)
        - perimeter
        - area
        - smoothness (local variation in radius lengths)
        - compactness (perimeter^2 / area - 1.0)
        - concavity (severity of concave portions of the contour)
        - concave points (number of concave portions of the contour)
        - symmetry 
        - fractal dimension ("coastline approximation" - 1)

        The mean, standard error, and "worst" or largest (mean of the three
        largest values) of these features were computed for each image,
        resulting in 30 features.  For instance, field 3 is Mean Radius, field
        13 is Radius SE, field 23 is Worst Radius.

        - class:
                - WDBC-Malignant
                - WDBC-Benign

    :Summary Statistics:

    ===================================== ====== ======
                                           Min    Max
    ===================================== ====== ======
    radius (mean):                        6.981  28.11
    texture (mean):                       9.71   39.28
    perimeter (mean):                     43.79  188.5
    area (mean):                          143.5  2501.0
    smoothness (mean):                    0.053  0.163
    compactness (mean):                   0.019  0.345
    concavity (mean):                     0.0    0.427
    concave points (mean):                0.0    0.201
    symmetry (mean):                      0.106  0.304
    fractal dimension (mean):             0.05   0.097
    radius (standard error):              0.112  2.873
    texture (standard error):             0.36   4.885
    perimeter (standard error):           0.757  21.98
    area (standard error):                6.802  542.2
    smoothness (standard error):          0.002  0.031
    compactness (standard error):         0.002  0.135
    concavity (standard error):           0.0    0.396
    concave points (standard error):      0.0    0.053
    symmetry (standard error):            0.008  0.079
    fractal dimension (standard error):   0.001  0.03
    radius (worst):                       7.93   36.04
    texture (worst):                      12.02  49.54
    perimeter (worst):                    50.41  251.2
    area (worst):                         185.2  4254.0
    smoothness (worst):                   0.071  0.223
    compactness (worst):                  0.027  1.058
    concavity (worst):                    0.0    1.252
    concave points (worst):               0.0    0.291
    symmetry (worst):                     0.156  0.664
    fractal dimension (worst):            0.055  0.208
    ===================================== ====== ======

    :Missing Attribute Values: None

    :Class Distribution: 212 - Malignant, 357 - Benign

    :Creator:  Dr. William H. Wolberg, W. Nick Street, Olvi L. Mangasarian

    :Donor: Nick Street

    :Date: November, 1995

This is a copy of UCI ML Breast Cancer Wisconsin (Diagnostic) datasets.
https://goo.gl/U2Uwz2

Features are computed from a digitized image of a fine needle
aspirate (FNA) of a breast mass.  They describe
characteristics of the cell nuclei present in the image.

Separating plane described above was obtained using
Multisurface Method-Tree (MSM-T) [K. P. Bennett, "Decision Tree
Construction Via Linear Programming." Proceedings of the 4th
Midwest Artificial Intelligence and Cognitive Science Society,
pp. 97-101, 1992], a classification method which uses linear
programming to construct a decision tree.  Relevant features
were selected using an exhaustive search in the space of 1-4
features and 1-3 separating planes.

The actual linear program used to obtain the separating plane
in the 3-dimensional space is that described in:
[K. P. Bennett and O. L. Mangasarian: "Robust Linear
Programming Discrimination of Two Linearly Inseparable Sets",
Optimization Methods and Software 1, 1992, 23-34].

This database is also available through the UW CS ftp server:

ftp ftp.cs.wisc.edu
cd math-prog/cpo-dataset/machine-learn/WDBC/

References
----------
   - W.N. Street, W.H. Wolberg and O.L. Mangasarian. Nuclear feature extraction 
     for breast tumor diagnosis. IS&T/SPIE 1993 International Symposium on 
     Electronic Imaging: Science and Technology, volume 1905, pages 861-870,
     San Jose, CA, 1993.
   - O.L. Mangasarian, W.N. Street and W.H. Wolberg. Breast cancer diagnosis and 
     prognosis via linear programming. Operations Research, 43(4), pages 570-577, 
     July-August 1995.
   - W.H. Wolberg, W.N. Street, and O.L. Mangasarian. Machine learning techniques
     to diagnose breast cancer from fine-needle aspirates. Cancer Letters 77 (1994) 
     163-171.

Generamos dataset de entrenamiento y test de nuevo.

In [20]:
X_train, X_test, y_train, y_test = train_test_split(cancer.data,
                                                    cancer.target,
                                                    test_size=0.2,
                                                   random_state=42)

Ahora utilizamos un modelo SVM para clasificación para entrenar un modelo que clasifique correctamente los cánceres malignos

In [21]:
from sklearn.svm import SVC
svc = SVC( probability=True).fit(X_train, y_train)

Como este dataset es un dataset tabular estructurado (o sea, una tabla/dataframe), podemos usar LimeTabularExplainer para usar LIME. Simplemente tenemos que usar como argumentos los datos de entrenamiento (X_train, o sea las variables independientes), los nombres de las variables independientes, los nombres de las clases de la variable objetivo. También es conveniente usar discretize_continuous que agrupa las variables continuas y hace que sea más fácil interpretar los resultados.

In [22]:
explainer = lime.lime_tabular.LimeTabularExplainer(X_train, 
                                                   feature_names=cancer.feature_names,
                                                   class_names=cancer.target_names,
                                                   discretize_continuous=True)

Ahora podemos tomar una observación (una medición de una paciente) y explicar porqué nuestro modelo SVM tomó la decisión de si es cáncer maligno o no. Dado que este artículo está escrito en un jupyter notebook podemos usar la función show_in_notebook para que nos muestre una bonita tabla.

In [23]:
observation = X_test[1]
exp = explainer.explain_instance(observation, svc.predict_proba, num_features=10, top_labels=1)
exp.show_in_notebook(show_table=True, show_all=False)