Regresión logística para predicciones bancarias

Este estudio trata de construir un modelo predictivo que responda a siguiente pregunta:

Pregunta: ¿Se suscribirá el cliente a un depósito a largo plazo?

Para ello hemos utilizado un modelo de regresión logística. Tras el análisis exploratorio de las variables, hemos seleccionado las 12 siguientes, las más significativas, como variables predictoras para la construcción del modelo:

Variables predictoras:

  • previous: número de veces que hemos contactado con el cliente antes de esta campaña
  • euribor3m: tipos de interés Euribor con un plazo de vigencia de 3 meses

Las otras 10 son variables dummies categóricas creadas para este estudio. El valor 1 indica "sí", el valor 0 indica "no":

  • job_entrepreneur: si el cliente es emprendedor
  • job_management: si el cliente es administrador
  • job_self-employed: si el cliente es autónomo
  • month_apr: si el último contacto con el cliente fue en el mes de abril
  • month_dec: si el último contacto con el cliente fue en el mes de diciembre
  • month_mar: si el último contacto con el cliente fue en el mes de marzo
  • month_may: si el último contacto con el cliente fue en el mes de mayo
  • month_nov: si el último contacto con el cliente fue en el mes de noviembre
  • poutcome_failure: si no compró en la campaña de marketing previa (failure in previous outcome)
  • poutcome_success: si compró en la campaña de marketing previa (success in previous outcome)

Variable de salida:

  • y: si el cliente ha comprado o no (1: sí, 0: no)

1. Análisis exploratorio de los datos

In [1]:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
In [2]:
data = pd.read_csv("datasets/bank/bank.csv", sep=";")
In [3]:
data.head()
Out[3]:
age job marital education default housing loan contact month day_of_week ... campaign pdays previous poutcome emp.var.rate cons.price.idx cons.conf.idx euribor3m nr.employed y
0 30 blue-collar married basic.9y no yes no cellular may fri ... 2 999 0 nonexistent -1.8 92.893 -46.2 1.313 5099.1 no
1 39 services single high.school no no no telephone may fri ... 4 999 0 nonexistent 1.1 93.994 -36.4 4.855 5191.0 no
2 25 services married high.school no yes no telephone jun wed ... 1 999 0 nonexistent 1.4 94.465 -41.8 4.962 5228.1 no
3 38 services married basic.9y no unknown unknown telephone jun fri ... 3 999 0 nonexistent 1.4 94.465 -41.8 4.959 5228.1 no
4 47 admin. married university.degree no yes no cellular nov mon ... 1 999 0 nonexistent -0.1 93.200 -42.0 4.191 5195.8 no

5 rows × 21 columns

In [4]:
data.shape
Out[4]:
(4119, 21)
In [5]:
data.columns.values
Out[5]:
array(['age', 'job', 'marital', 'education', 'default', 'housing', 'loan',
       'contact', 'month', 'day_of_week', 'duration', 'campaign', 'pdays',
       'previous', 'poutcome', 'emp.var.rate', 'cons.price.idx',
       'cons.conf.idx', 'euribor3m', 'nr.employed', 'y'], dtype=object)

Limpieza de datos:

Vamos a poner los datos en un formato uniforme y a darle un valor numérico a la variable de salida

In [6]:
data["y"] = (data["y"]=="yes").astype(int)
In [7]:
data.tail()
Out[7]:
age job marital education default housing loan contact month day_of_week ... campaign pdays previous poutcome emp.var.rate cons.price.idx cons.conf.idx euribor3m nr.employed y
4114 30 admin. married basic.6y no yes yes cellular jul thu ... 1 999 0 nonexistent 1.4 93.918 -42.7 4.958 5228.1 0
4115 39 admin. married high.school no yes no telephone jul fri ... 1 999 0 nonexistent 1.4 93.918 -42.7 4.959 5228.1 0
4116 27 student single high.school no no no cellular may mon ... 2 999 1 failure -1.8 92.893 -46.2 1.354 5099.1 0
4117 58 admin. married high.school no no no cellular aug fri ... 1 999 0 nonexistent 1.4 93.444 -36.1 4.966 5228.1 0
4118 34 management single high.school no yes no cellular nov wed ... 1 999 0 nonexistent -0.1 93.200 -42.0 4.120 5195.8 0

5 rows × 21 columns

In [8]:
data["education"].unique()
Out[8]:
array(['basic.9y', 'high.school', 'university.degree',
       'professional.course', 'basic.6y', 'basic.4y', 'unknown',
       'illiterate'], dtype=object)
In [9]:
#where(haya_esto, "me_pones_esto", y en caso contrario este 3er argumento)

data["education"] = np.where(data["education"]=="basic.4y", "Basic", data["education"])
data["education"] = np.where(data["education"]=="basic.6y", "Basic", data["education"])
data["education"] = np.where(data["education"]=="basic.9y", "Basic", data["education"])

data["education"] = np.where(data["education"]=="high.school", "High School", data["education"])
data["education"] = np.where(data["education"]=="professional.course", "Professional Course", data["education"])
data["education"] = np.where(data["education"]=="university.degree", "University Degree", data["education"])

data["education"] = np.where(data["education"]=="illiterate", "Illiterate", data["education"])
data["education"] = np.where(data["education"]=="unknown", "Unknown", data["education"])
In [10]:
data["education"].unique()
Out[10]:
array(['Basic', 'High School', 'University Degree', 'Professional Course',
       'Unknown', 'Illiterate'], dtype=object)

1.1 Información estadística por categorías:

Comprobamos los valores medios de las medidas entre los clientes que compran (1) y los que no (0) y el número total de compras, así como la frecuencia de compras entre distintos grupos según su nivel educativo, estado civil, día de la semana del último contacto con el cliente y mes del último contacto con el cliente.

In [11]:
#Medias

data.groupby("y").mean()
Out[11]:
age duration campaign pdays previous emp.var.rate cons.price.idx cons.conf.idx euribor3m nr.employed
y
0 39.895311 219.40976 2.605780 982.763086 0.141767 0.240185 93.599677 -40.586723 3.802826 5175.502072
1 41.889135 560.78714 1.980044 778.722838 0.585366 -1.177384 93.417268 -39.786475 2.145448 5093.118625
In [12]:
#Total de compras (1) y no compras (0)

data["y"].value_counts()
Out[12]:
0    3668
1     451
Name: y, dtype: int64

Por nivel educativo:

In [13]:
#Medias por niveles educativos

data.groupby("education").mean()
Out[13]:
age duration campaign pdays previous emp.var.rate cons.price.idx cons.conf.idx euribor3m nr.employed y
education
Basic 42.337124 253.898457 2.429732 978.815597 0.149472 0.237368 93.658600 -41.120552 3.775701 5174.133144 0.079610
High School 38.097720 258.534202 2.630836 958.022801 0.206298 -0.002497 93.564314 -40.995765 3.511732 5163.212595 0.105320
Illiterate 42.000000 146.000000 4.000000 999.000000 0.000000 -2.900000 92.201000 -31.400000 0.834000 5076.200000 0.000000
Professional Course 40.207477 278.816822 2.512150 958.211215 0.194393 0.163925 93.599630 -40.127664 3.701426 5167.595140 0.121495
University Degree 39.017405 247.707278 2.583070 947.900316 0.207278 -0.009731 93.499109 -39.830063 3.547132 5163.023180 0.130538
Unknown 42.826347 267.281437 2.538922 939.700599 0.263473 -0.074251 93.637455 -39.487425 3.410174 5151.260479 0.155689
In [14]:
#Total de compras (1) y no compras (0) por grupos de nivel educativo

pd.crosstab(data["education"], data["y"])
Out[14]:
y 0 1
education
Basic 1133 98
High School 824 97
Illiterate 1 0
Professional Course 470 65
University Degree 1099 165
Unknown 141 26
In [15]:
%matplotlib inline
pd.crosstab(data.education, data.y).plot(kind="bar") 
plt.title("Frecuencia de compra en función del nivel de educación")
plt.xlabel("Nivel de educación")
plt.ylabel("Frecuencia de compra del producto")
Out[15]:
Text(0, 0.5, 'Frecuencia de compra del producto')

Por estado civil:

In [16]:
table=pd.crosstab(data.marital, data.y)
table.div(table.sum(1).astype(float), axis=0).plot(kind="bar", stacked=True) #stacked = True es para que salga apilado
#Con table.div(table.sum(1).astype(float), axis=0) es como arrastrar en excel la columna (axis = 0) 
#para que cada celda quede dividida por la suma de su fila 

plt.title("Diagrama apilado de estado civil contra el nivel de compras")
plt.xlabel("Estado civil")
plt.ylabel("Proporción de clientes")
Out[16]:
Text(0, 0.5, 'Proporción de clientes')

Por día de la semana del último contacto con el cliente:

In [17]:
%matplotlib inline
table= pd.crosstab(data.day_of_week, data.y)
table.div(table.sum(1).astype(float), axis=0).plot(kind="bar", stacked=True)
plt.title("Frecuencia de compra en función del día de la semana")
plt.xlabel("Día de la semana")
plt.ylabel("Frecuencia de compra del producto")
Out[17]:
Text(0, 0.5, 'Frecuencia de compra del producto')

Por mes del último contacto con el cliente:

In [18]:
%matplotlib inline
table= pd.crosstab(data.month, data.y)
table.div(table.sum(1).astype(float), axis=0).plot(kind="bar", stacked=True)
plt.title("Frecuencia de compra en función del mes")
plt.xlabel("Mes del año")
plt.ylabel("Frecuencia de compra del producto")
Out[18]:
Text(0, 0.5, 'Frecuencia de compra del producto')

Tal vez es que en marzo y diciembre haya menos datos y por eso la proporción de compra / no compra es mayor, sin que eso signifique realmente un volumen de compras mayor

In [19]:
%matplotlib inline
table.plot(kind="bar", stacked=False)
plt.title("Frecuencia de compra en función del mes")
plt.xlabel("Mes del año")
plt.ylabel("Frecuencia de compra del producto")
Out[19]:
Text(0, 0.5, 'Frecuencia de compra del producto')

Efectivamente vemos que en diciembre y marzo apenas hay datos, y las mayor proporción de compras frente a rechazos no significaba un mayor volumen de compras en términos absolutos. Vemos que los meses en los que se hizo campaña fueron en primavera y verano (mayo, junio, julio, agosto) y hubo mucho rechazos

Por edad del cliente:

In [20]:
%matplotlib inline
data.age.hist()
plt.title("Histograma de la Edad")
plt.xlabel("Edad")
plt.ylabel("Número de Clientes")
Out[20]:
Text(0, 0.5, 'Número de Clientes')

Los clientes tienen edades entre 25 y 60 años mayoritariamente.

In [21]:
pd.crosstab(data.age, data.y).plot(kind="bar", figsize=(16,6), stacked=True)
Out[21]:
<matplotlib.axes._subplots.AxesSubplot at 0x1bbba362308>
In [22]:
compras_abs = pd.crosstab(data.age, data.y)
frec_abs_edades = data.age.value_counts().sort_index()

compras_rel = compras_abs.div(frec_abs_edades, axis=0)
In [23]:
compras_rel.plot(kind="bar", stacked=True, figsize=(16,6))
Out[23]:
<matplotlib.axes._subplots.AxesSubplot at 0x1bbba5a3548>

Los mayores de 60, aunque son pocos, tienen en general mayor probabilidad de compra

Por frecuencia de compras anteriores:

In [24]:
#poutcome es la frecuencia de compras anteriores
pd.crosstab(data.poutcome, data.y).plot(kind="bar")
Out[24]:
<matplotlib.axes._subplots.AxesSubplot at 0x1bbbabf3a88>

poutcome es buena variable para predecir si alguien va a llevar a cabo una inversión. Vemos que la gente que ha tenido malas experiencias (failure) pasadas, no repite, pero los que tuvieron éxito sí que repiten. Los que no han invertido, por defecto no van a invertir, con algunas excepciones

2. Preprocesado de datos

2.1. Conversión de variables categóricas a variables dummies

In [25]:
categories = ["job", "marital", "education", "default", "housing", "loan", "contact", 
              "month", "day_of_week", "poutcome"]
for category in categories:
    cat_dummies = pd.get_dummies(data[category], prefix=category)
    data = data.join(cat_dummies)
In [26]:
data.columns.values
Out[26]:
array(['age', 'job', 'marital', 'education', 'default', 'housing', 'loan',
       'contact', 'month', 'day_of_week', 'duration', 'campaign', 'pdays',
       'previous', 'poutcome', 'emp.var.rate', 'cons.price.idx',
       'cons.conf.idx', 'euribor3m', 'nr.employed', 'y', 'job_admin.',
       'job_blue-collar', 'job_entrepreneur', 'job_housemaid',
       'job_management', 'job_retired', 'job_self-employed',
       'job_services', 'job_student', 'job_technician', 'job_unemployed',
       'job_unknown', 'marital_divorced', 'marital_married',
       'marital_single', 'marital_unknown', 'education_Basic',
       'education_High School', 'education_Illiterate',
       'education_Professional Course', 'education_University Degree',
       'education_Unknown', 'default_no', 'default_unknown',
       'default_yes', 'housing_no', 'housing_unknown', 'housing_yes',
       'loan_no', 'loan_unknown', 'loan_yes', 'contact_cellular',
       'contact_telephone', 'month_apr', 'month_aug', 'month_dec',
       'month_jul', 'month_jun', 'month_mar', 'month_may', 'month_nov',
       'month_oct', 'month_sep', 'day_of_week_fri', 'day_of_week_mon',
       'day_of_week_thu', 'day_of_week_tue', 'day_of_week_wed',
       'poutcome_failure', 'poutcome_nonexistent', 'poutcome_success'],
      dtype=object)

Eliminación de las categorías originales:

In [27]:
data_vars = data.columns.values.tolist()
to_keep = [v for v in data_vars if v not in categories] #Me quiero quedar con estos

bank_data = data[to_keep]

2.2. Selección de variables para la creación del modelo

¿Qué variables son las más significativas para la predicción del modelo?

In [28]:
n = 12
In [29]:
from sklearn import datasets
from sklearn.feature_selection import RFE #Recursive Feature Elimination
from sklearn.linear_model import LogisticRegression
In [30]:
lr = LogisticRegression(solver="lbfgs", max_iter = 10000) #Me daba warnings si no metía esos argumentos
In [31]:
bank_data_list = bank_data.columns.values.tolist()
Y_list = ['y']
X_list = [v for v in bank_data_list if v not in Y_list]

rfe = RFE(lr, n) #Con el método RFE elegimos, con el modelo logístico, el número de variables con el que queremos quedarnos
rfe = rfe.fit(bank_data[X_list], bank_data[Y_list].values.ravel())

rfe.support_ ¿Cuales son las 12 variables que se van a quedar en el modelo?

In [32]:
rfe.support_
Out[32]:
array([False, False, False, False,  True, False, False, False,  True,
       False, False, False,  True, False,  True, False,  True, False,
       False, False, False, False, False, False, False, False, False,
       False, False, False, False, False, False, False, False, False,
       False, False, False, False, False, False, False,  True, False,
        True, False, False,  True,  True,  True, False, False, False,
       False, False, False, False,  True, False,  True])
In [33]:
rfe.support_.sum() #Hay 12 Trues, ya que especifiqué que quería quedarme con 12 variables
Out[33]:
12

rfe.ranking_ ¿Cuales tengo que ir añadiendo si quiero sumar una 13ª, 14ª, etc.? Con rfe.ranking_ obtenemos las 12 que me entran con un 1, y las siguientes que me entrarían con un 2,3,... por orden de prioridad

In [34]:
print(rfe.ranking_) 
[42 47 23 49  1 20 27 26  1 46 25  4  1 43  1 44  1  6 40 34 19  9 21 41
 38 48 28 16  7 24 39 10 31 32 50 12 18 30 33  8 17 11  3  1  5  1 45  2
  1  1  1 14 22 36 29 37 35 13  1 15  1]

zip Para ver juntos los True o False junto con los nombres de las variables correspondientes que entran en el modelo o se quedan fuera (respectivamente):

In [35]:
z=zip(X_list,rfe.support_, rfe.ranking_)
In [36]:
z_list = list(z)
In [37]:
z_list
Out[37]:
[('age', False, 42),
 ('duration', False, 47),
 ('campaign', False, 23),
 ('pdays', False, 49),
 ('previous', True, 1),
 ('emp.var.rate', False, 20),
 ('cons.price.idx', False, 27),
 ('cons.conf.idx', False, 26),
 ('euribor3m', True, 1),
 ('nr.employed', False, 46),
 ('job_admin.', False, 25),
 ('job_blue-collar', False, 4),
 ('job_entrepreneur', True, 1),
 ('job_housemaid', False, 43),
 ('job_management', True, 1),
 ('job_retired', False, 44),
 ('job_self-employed', True, 1),
 ('job_services', False, 6),
 ('job_student', False, 40),
 ('job_technician', False, 34),
 ('job_unemployed', False, 19),
 ('job_unknown', False, 9),
 ('marital_divorced', False, 21),
 ('marital_married', False, 41),
 ('marital_single', False, 38),
 ('marital_unknown', False, 48),
 ('education_Basic', False, 28),
 ('education_High School', False, 16),
 ('education_Illiterate', False, 7),
 ('education_Professional Course', False, 24),
 ('education_University Degree', False, 39),
 ('education_Unknown', False, 10),
 ('default_no', False, 31),
 ('default_unknown', False, 32),
 ('default_yes', False, 50),
 ('housing_no', False, 12),
 ('housing_unknown', False, 18),
 ('housing_yes', False, 30),
 ('loan_no', False, 33),
 ('loan_unknown', False, 8),
 ('loan_yes', False, 17),
 ('contact_cellular', False, 11),
 ('contact_telephone', False, 3),
 ('month_apr', True, 1),
 ('month_aug', False, 5),
 ('month_dec', True, 1),
 ('month_jul', False, 45),
 ('month_jun', False, 2),
 ('month_mar', True, 1),
 ('month_may', True, 1),
 ('month_nov', True, 1),
 ('month_oct', False, 14),
 ('month_sep', False, 22),
 ('day_of_week_fri', False, 36),
 ('day_of_week_mon', False, 29),
 ('day_of_week_thu', False, 37),
 ('day_of_week_tue', False, 35),
 ('day_of_week_wed', False, 13),
 ('poutcome_failure', True, 1),
 ('poutcome_nonexistent', False, 15),
 ('poutcome_success', True, 1)]

Creo una lista con las 12 variables que han sido seleccionadas:

In [38]:
support = rfe.support_ #Array de True y False
#X_list era una lista con los nombres de las columnas de las variables predictoras (excluida la "y")

columnas = [nombre for nombre in X_list if support[X_list.index(nombre)]==np.bool_("True")]
In [39]:
columnas
Out[39]:
['previous',
 'euribor3m',
 'job_entrepreneur',
 'job_management',
 'job_self-employed',
 'month_apr',
 'month_dec',
 'month_mar',
 'month_may',
 'month_nov',
 'poutcome_failure',
 'poutcome_success']

Nota: Habíamos hecho una regresión logística con todas las variables, para después ver qué 12 son las mejores predictoras. Ahora finalmente construimos el modelo final sólo con estas 12

3. Implementación del modelo de regresión logística con statsmodel.api

In [40]:
import statsmodels.api as sm
In [41]:
X = bank_data[columnas]
Y = bank_data["y"]

logit_model = sm.Logit(Y, X)
In [42]:
result = logit_model.fit() #Método de Newton-Raphson
Optimization terminated successfully.
         Current function value: 0.276671
         Iterations 7
In [43]:
result.summary2()
Out[43]:
Model: Logit Pseudo R-squared: 0.199
Dependent Variable: y AIC: 2303.2120
Date: 2021-02-03 17:06 BIC: 2379.0924
No. Observations: 4119 Log-Likelihood: -1139.6
Df Model: 11 LL-Null: -1422.9
Df Residuals: 4107 LLR p-value: 1.9093e-114
Converged: 1.0000 Scale: 1.0000
No. Iterations: 7.0000
Coef. Std.Err. z P>|z| [0.025 0.975]
previous 0.3273 0.1410 2.3219 0.0202 0.0510 0.6036
euribor3m -0.5534 0.0204 -27.1941 0.0000 -0.5933 -0.5135
job_entrepreneur -0.3939 0.3812 -1.0335 0.3014 -1.1411 0.3532
job_management -0.3667 0.2249 -1.6305 0.1030 -0.8076 0.0741
job_self-employed -0.4280 0.3258 -1.3138 0.1889 -1.0665 0.2105
month_apr -0.7489 0.1932 -3.8769 0.0001 -1.1275 -0.3703
month_dec 0.6044 0.4654 1.2987 0.1940 -0.3077 1.5166
month_mar 0.9246 0.3154 2.9317 0.0034 0.3065 1.5427
month_may -1.2067 0.1241 -9.7217 0.0000 -1.4500 -0.9634
month_nov -0.4668 0.1935 -2.4122 0.0159 -0.8460 -0.0875
poutcome_failure -0.8157 0.2501 -3.2621 0.0011 -1.3059 -0.3256
poutcome_success 0.9954 0.2927 3.4006 0.0007 0.4217 1.5692

En regresiones logísticas, el estadístico z nos indica si los coeficientes para cada variable predictora son significativamente distintos de 0 y por tanto están haciendo una contribución significativa al modelo para predecir y. "Z" es el número de veces la desviación estándar que tenemos que movernos desde la media (el valor dado para "coef") hasta el 0. Esto coincide con el valor de z en la transformación de una distribución normal con media != 0 y std != 0 a una distribución normal, lo cual se hace, por ejemplo, en el contraste de hipótesis.

$$z = \frac{x - \mu}{\sigma} = \frac{0 - coef}{str.err} \leftarrow \rightarrow \sigma · z = -coef $$

El z en este caso, sin embargo, es simplemente esta transformación, pero no tiene nada que ver con un contraste de hipótesis que esté haciendo la librería ni nada por el estilo.
P>|z| es el área bajo la curva desde el 0 en adelante (si coef es negativo) o desde el 0 hacia atrás (si coef es positivo).
Cuanto mayor sea z (en valor absoluto), menos probable es que el coeficiente sea 0. A continuación comprobamos que dividiendo los coef entre las std.err obtenemos los valores de z:

In [44]:
std_err = np.array([0.1410, 0.0204, 0.3812, 0.2249, 0.3258, 0.1932, 0.4654, 0.3154, 0.1241, 0.1935, 0.2501, 0.2927])
result.params.div(std_err) #result.params da los coef.
Out[44]:
previous              2.321326
euribor3m           -27.128056
job_entrepreneur     -1.033442
job_management       -1.630698
job_self-employed    -1.313729
month_apr            -3.876368
month_dec             1.298764
month_mar             2.931491
month_may            -9.723889
month_nov            -2.412156
poutcome_failure     -3.261660
poutcome_success      3.400919
dtype: float64

Las pequeñas discrepancias se deben a los valores redondeados que hemos tomado de std_err, tal y como aparecen en la tabla. Por ejemplo, en el caso del euribor3m, ese str.err = 0.2024 es un redondeo del valor exacto 0.02035.... Vemos que dividiendo entre un valor así sí se obtiene el z de la tabla:

In [45]:
result.params['euribor3m']/0.02035045
Out[45]:
-27.194108169506162

4. Implementación del modelo de regresión logística con scikit-learn

In [46]:
from sklearn import linear_model
In [47]:
logit_model = linear_model.LogisticRegression(solver="lbfgs")
logit_model.fit(X,Y)
Out[47]:
LogisticRegression(C=1.0, class_weight=None, dual=False, fit_intercept=True,
                   intercept_scaling=1, l1_ratio=None, max_iter=100,
                   multi_class='warn', n_jobs=None, penalty='l2',
                   random_state=None, solver='lbfgs', tol=0.0001, verbose=0,
                   warm_start=False)

score: Factor $R^{2}$. Nos dice la calidad del ajuste:

In [48]:
logit_model.score(X,Y)
Out[48]:
0.9021607186210245
In [49]:
1-Y.mean()
Out[49]:
0.8905074047098811

$1-Y.mean()$ me da el tanto por 1 de gente que no compra (y=0). Si hicieramos las predicciones diciendo "no va a comprar", acierto en el 89,05% de los casos. Este sería el máximo % de valor que puedo acertar sin un modelo y nuestro valor de partida a mejorar.

Con el modelo que hemos creado acertamos el 90,22%

In [50]:
pd.DataFrame(list(zip(X.columns, np.transpose(logit_model.coef_))))
Out[50]:
0 1
0 previous [0.352964601543823]
1 euribor3m [-0.5064017912005329]
2 job_entrepreneur [-0.33610544989775376]
3 job_management [-0.31299186859645284]
4 job_self-employed [-0.34501965684175084]
5 month_apr [-0.5928525909101696]
6 month_dec [0.6091102600858387]
7 month_mar [0.9600680955735055]
8 month_may [-1.080023362500404]
9 month_nov [-0.39589215207397765]
10 poutcome_failure [-0.7293411496181734]
11 poutcome_success [1.0562846688350007]

Vemos que los coeficientes que obtenemos no son exactamente los mismos con los modulos de scikit learn que con los de statsmodel.api, aunque los resultados de la "y" que queremos inferir no diferirán demasiado. Pero tenemos que hacer un análisis exhaustivo de las variables, no podemos dejar que el modelo decida por nosotros mismos

5. Validación del modelo logístico

Vamos a hacer una division entre conjunto de entrenamiento y de testing y a validar el modelo con el conjunto de testing

In [51]:
from sklearn.model_selection import train_test_split
In [52]:
X_train, X_test, Y_train, Y_test = train_test_split(X,Y, test_size = 0.3, random_state=0) 
#random_state=0 es para fijar la semilla de la división aleatoria de los conjuntos
In [53]:
lm = linear_model.LogisticRegression(solver = "lbfgs")
lm.fit(X_train, Y_train)
Out[53]:
LogisticRegression(C=1.0, class_weight=None, dual=False, fit_intercept=True,
                   intercept_scaling=1, l1_ratio=None, max_iter=100,
                   multi_class='warn', n_jobs=None, penalty='l2',
                   random_state=None, solver='lbfgs', tol=0.0001, verbose=0,
                   warm_start=False)

El modelo predice probabilidades. Luego nosotros establecemos un umbral (por ejemplo, 0,5), a partir del cual se considerará como 1, y si la probabilidad es más pequeña que el umbral se considerará como 0

In [54]:
from IPython.display import display, Math, Latex
In [55]:
display(Math(r'Y_p=\begin{cases}0& si\ p\leq0.5\\1&si\ p >0.5\end{cases}'))
$\displaystyle Y_p=\begin{cases}0& si\ p\leq0.5\\1&si\ p >0.5\end{cases}$
In [56]:
probs = lm.predict_proba(X_test)

El módulo lm.predictproba devuelve las probabilidades de cada clase (en este caso hay 2 clases, y = 0, y = 1 (no compra, compra)), según el orden dado en lm.classes

In [57]:
probs #1ª col: probabilidades de que no compra. 2ª col: probabilidades de que sí compre
Out[57]:
array([[0.93981108, 0.06018892],
       [0.90265584, 0.09734416],
       [0.93694408, 0.06305592],
       ...,
       [0.65525362, 0.34474638],
       [0.9784045 , 0.0215955 ],
       [0.23925306, 0.76074694]])
In [58]:
prediction = lm.predict(X_test)

Porcentaje de datos capaz de predecir correctamente

In [59]:
from sklearn import metrics
In [60]:
metrics.accuracy_score(Y_test, prediction, normalize=False)
Out[60]:
1117

$Número\ de\ veces\ que\ y_{pred} = y_{i}$

In [61]:
metrics.accuracy_score(Y_test, prediction) #Por defecto normalize=True
Out[61]:
0.9037216828478964

$\large \frac{Número\ de\ veces\ que\ y_{pred} = y_{i}}{Total\ de\ puntos\ (len(Y_{test}))}$

Ha mejorado un poco respecto al anterior modelo en el que no dividimos los datos en los conjuntos de entrenamiento y testing. Con este modelo predecimos un 90,37% del valor

Compras con probabilidad p > 50%

El umbral que hemos establecido es el que python toma por defecto con el módulo predict: p = 0,5

In [62]:
prediction
Out[62]:
array([0, 0, 0, ..., 0, 0, 1])
In [63]:
np.sum(prediction)/prediction.shape[0]
Out[63]:
0.02669902912621359

Tenemos un 2,7% de compras considerando como "compra" una probabilidad superior al 50% de que el cliente compre

In [ ]:
 

Compras con probabilidad p > 10%

Podemos elegir nosotros otro umbral. Como tenemos sólo un 10% de clientes que compran, puede que un umbral de 0,10 sea un buen umbral de decisión. Así, si las probabilidades de que compre son superiores al 10%, lo catalogaremos como venta:

In [64]:
display(Math(r'\varepsilon\in (0,1), Y_p=\begin{cases}0& si\ p\leq \varepsilon\\1&si\ p >\varepsilon\end{cases}'))
$\displaystyle \varepsilon\in (0,1), Y_p=\begin{cases}0& si\ p\leq \varepsilon\\1&si\ p >\varepsilon\end{cases}$
In [65]:
prob_yes = probs[:,1] #Todas las filas, segunda columna (probabilidades de que sí compre, y = 1)
prob_df = pd.DataFrame(prob_yes)
threshold = 0.1
prob_df["prediction"] = np.where(prob_df[0]>threshold, 1, 0)
prob_df.tail()
Out[65]:
0 prediction
1231 0.060277 0
1232 0.060218 0
1233 0.344746 1
1234 0.021595 0
1235 0.760747 1

Si la probabilidad de que compre es superior al 10%, lo catalogaremos como "compra"

In [66]:
pd.crosstab(prob_df.prediction, columns="contador")
Out[66]:
col_0 contador
prediction
0 900
1 336
In [67]:
336/len(prob_df)*100
Out[67]:
27.184466019417474

27% de compras considerando "compra" una probabilidad de más del 10%

Compras con probabilidad p > 15%

In [68]:
threshold = 0.15
prob_df["prediction"] = np.where(prob_df[0]>threshold, 1, 0)
pd.crosstab(prob_df.prediction, columns="count")
Out[68]:
col_0 count
prediction
0 1037
1 199
In [69]:
199/len(prob_df)*100
Out[69]:
16.100323624595468

16% de compras considerando "compra" una probabilidad de más del 15%

Compras con probabilidad p > 5%

Nos puede interesar establecer un umbral más bajo y captar al mayor número de clientes posible

In [70]:
threshold = 0.05
prob_df["prediction"] = np.where(prob_df[0]>threshold, 1, 0)
pd.crosstab(prob_df.prediction, columns="count")
Out[70]:
col_0 count
prediction
0 316
1 920
In [71]:
920/len(prob_df)*100
Out[71]:
74.4336569579288

74% de compras considerando "compra" una probabilidad de más del 5%

5.1. Validación cruzada

In [72]:
from sklearn.model_selection import cross_val_score
In [73]:
scores = cross_val_score(linear_model.LogisticRegression(solver = "lbfgs"), X, Y, scoring="accuracy", cv=10)
  • scoring = "accuracy" --> el objetivo es calcular la mejor eficacia
  • cv: número de partes (k) en el que se divide el dataset para hacer la cross validation
In [74]:
scores
Out[74]:
array([0.92251816, 0.90048544, 0.90291262, 0.89320388, 0.90291262,
       0.90533981, 0.8907767 , 0.89563107, 0.89294404, 0.90024331])
In [75]:
scores.mean()
Out[75]:
0.9006967643660498

No es muy diferente al valor que obtuvimos, luego podemos decir que el modelo es bueno, no había problemas de overfitting ni nada

5.2. Matrices de confusión y curvas ROC

In [76]:
# probs = lm.predict_proba(X_test)

probs
Out[76]:
array([[0.93981108, 0.06018892],
       [0.90265584, 0.09734416],
       [0.93694408, 0.06305592],
       ...,
       [0.65525362, 0.34474638],
       [0.9784045 , 0.0215955 ],
       [0.23925306, 0.76074694]])
In [77]:
prob_yes=probs[:,1]
prob_df = pd.DataFrame(probs)
threshold = 0.1
prob_df["prediction"] = np.where(prob_df[1]>=threshold, 1, 0)
prob_df["actual"] = list(Y_test) 
prob_df.head()
Out[77]:
0 1 prediction actual
0 0.939811 0.060189 0 0
1 0.902656 0.097344 0 0
2 0.936944 0.063056 0 0
3 0.939723 0.060277 0 0
4 0.940004 0.059996 0 0
In [78]:
confusion_matrix = pd.crosstab(prob_df.prediction, prob_df.actual)
confusion_matrix
Out[78]:
actual 0 1
prediction
0 854 46
1 260 76
In [79]:
#OJO: matrix[columna][fila]
TN=confusion_matrix[0][0]
TP=confusion_matrix[1][1]
FP=confusion_matrix[0][1]
FN=confusion_matrix[1][0]
In [80]:
sens = TP/(TP+FN) #Sensibilidad (% de valores actuales positivos predichos como tal por el modelo)
sens
Out[80]:
0.6229508196721312
In [81]:
espc_1 = 1-TN/(TN+FP) #El % de todos los valores actuales negativos que son Falsos Positivos --> Also: FP/(TN+FP)
espc_1
Out[81]:
0.23339317773788149
In [82]:
thresholds = [0.04, 0.05, 0.07, 0.10, 0.12, 0.15, 0.18, 0.20, 0.25, 0.3, 0.4, 0.5]
sensitivities = [1]
especifities_1 = [1]

for t in thresholds:
    prob_df["prediction"] = np.where(prob_df[1]>=t, 1, 0)
    prob_df["actual"] = list(Y_test)
    prob_df.head()

    confusion_matrix = pd.crosstab(prob_df.prediction, prob_df.actual)
    TN=confusion_matrix[0][0]
    TP=confusion_matrix[1][1]
    FP=confusion_matrix[0][1]
    FN=confusion_matrix[1][0]
    
    sens = TP/(TP+FN)
    sensitivities.append(sens)
    espc_1 = 1-TN/(TN+FP)
    especifities_1.append(espc_1)

sensitivities.append(0)
especifities_1.append(0)

#Añadimos el 1 y el 0 para luego la representación. 
In [83]:
sensitivities #(True Positives)/(total valores positivos actuales)
Out[83]:
[1,
 0.9590163934426229,
 0.9426229508196722,
 0.6639344262295082,
 0.6229508196721312,
 0.6065573770491803,
 0.5245901639344263,
 0.45901639344262296,
 0.4426229508196721,
 0.4098360655737705,
 0.36885245901639346,
 0.18032786885245902,
 0.14754098360655737,
 0]
In [84]:
especifities_1 #(False Positives)/(total valores negativos actuales)
Out[84]:
[1,
 0.7621184919210053,
 0.7226211849192101,
 0.2719928186714542,
 0.23339317773788149,
 0.20377019748653502,
 0.12118491921005381,
 0.09335727109515257,
 0.08797127468581689,
 0.08168761220825849,
 0.06104129263913827,
 0.01795332136445238,
 0.013464991023339312,
 0]
In [85]:
import matplotlib.pyplot as plt
In [86]:
%matplotlib inline
plt.plot(especifities_1, sensitivities, marker="o", linestyle="--", color="r")
x=[i*0.01 for i in range(100)]
y=[i*0.01 for i in range(100)]
plt.plot(x,y)
plt.xlabel("1-Especifidad")
plt.ylabel("Sensibilidad")
plt.title("Curva ROC")
Out[86]:
Text(0.5, 1.0, 'Curva ROC')
  • Thresholds altos corresponden a puntos abajo a la izquierda, y threshold bajos corresponden a puntos arriba a la derecha. Puntos muy aglutinados indican probabilidades muy extremas (cercanas a 0/1), y puntos agrupados en 2 grupos muy separados corresponderían a probabilidades muy intermedias.
  • La diagonal corresponde a un modelo que, haya el número de predicciones iguales a 0/1 que haya (lo cual depende del threshold), estos no siguen a los datos actuales y se distribuyen aleatoriamente. Un threshold alto correspondería a puntos de la recta por abajo: (0.1, 0.1), (0.2, 0.2), etc., y viceversa. Que los puntos de la recta estuvieran distribuidos más uniformemente o menos (es decir, más equidistantes o más aglutinados), depende de lo extremas que sean las probabilidades (predict_proba).
  • El peor de los modelos (peor que el aleatorio, uno que yerre a drede) sería uno que fuera por y = 0 y luego subiera en vertical en x = 1. En general, por debajo de la diagonal el modelo "falla a propósito", siendo mejor cualquier modelo aleatorio.
  • En el mejor de los modelos, en x=0 línea vertical hasta y=1, y el último punto en (x,y)=(1,1)
  • El modelo será mejor cuanto mayor sea el área bajo la curva, que puede ir desde 0 en el peor de los casos hasta 1.

A la vista de la curva ROC, un buen threshold podría ser el 4º o hasta el 7º, es decir threshold = 0.07 / thresold = 0.15

No obstante, nos puede interesar acertar un gran número de compras a expensas de un gran porcentaje de falsos positivos, por lo que un threshold = 0.05 sería interesante

In [92]:
from sklearn import metrics
from ggplot import *
In [93]:
prob_yes
Out[93]:
array([0.06018892, 0.09734416, 0.06305592, ..., 0.34474638, 0.0215955 ,
       0.76074694])
In [94]:
espc_1, sensit, _ = metrics.roc_curve(Y_test, prob_yes)

metrics.roc_curve(Y_test, prob_yes) nos devuelve 3 parámetros:

  • Array con 1 - especifidad
  • Array con Sensibilidad
  • Otro que no vamos a utilizar, por lo que ponemos una "_"

La división de thresholds que hace es de 0,01 en 0,01

In [95]:
df = pd.DataFrame({
    "esp":espc_1,
    "sens":sensit
})
In [96]:
df.head()
Out[96]:
esp sens
0 0.000000 0.000000
1 0.000000 0.008197
2 0.000000 0.040984
3 0.000898 0.040984
4 0.000898 0.065574

ggplot:

Es una librería que se utiliza tanto en Python como en R y que vale la pena echarle un vistazo. Está diseñado para el lenguaje de programación R, que utiliza el signo "+" para añadir características a la gráfica

In [97]:
ggplot(df, aes(x="esp", y="sens")) + geom_line() + geom_abline(linetype="dashed")+xlim(-0.01,1.01)+ylim(-0.01,1.01)+xlab("1-Especifidad")+ylab("Sensibilidad")
Out[97]:
<ggplot: (-9223371917721899024)>

Área bajo la curva:

In [98]:
auc = metrics.auc(espc_1, sensit)
auc #Area Under the Curve
Out[98]:
0.7681740589222121
In [99]:
ggplot(df, aes(x="esp", y="sens")) + geom_area(alpha=0.25)+geom_line(aes(y="sens"))+ggtitle("Curva ROC y AUC=%s"%str(auc))
Out[99]:
<ggplot: (-9223371917721744376)>
Portafolio