Modelo predictivo para la detección temprana del abandono de clientes

Introducción - Resumen ejecutivo

El presente trabajo tiene como objetivo el desarrollo de un modelo predictivo que permita anticipar el comportamiento futuro de los clientes de un banco en relación con su permanencia o abandono del servicio de Paquete Premium. En particular, se busca predecir la variable CLASECORR, que indica el estado de cada cliente en un horizonte temporal de dos meses, tomando las siguientes categorías:

  • BAJA+1: el cliente se da de baja durante el próximo mes,

  • BAJA+2: el cliente se da de baja durante el segundo mes,

  • CONTINUA: el cliente continúa siendo parte de la cartera de Paquete Premium luego de dos meses.

Para ello, se emplearán distintos algoritmos de aprendizaje supervisado con el objetivo de identificar patrones de deserción (churn) y contribuir a la detección temprana de clientes con alto riesgo de baja. La posibilidad de anticipar estos eventos es de gran relevancia para la gestión comercial y estratégica de la entidad, dado que permite diseñar acciones proactivas de retención, optimizando los recursos destinados a tales fines y mejorando la relación con los clientes.

En las secciones siguientes se detallarán las etapas de preprocesamiento de los datos, la selección de variables predictoras, la construcción y evaluación comparativa de los modelos, así como las principales conclusiones obtenidas.

Preprocesamiento

Código
reticulate::py_config()

reticulate::use_python("C:/Users/esteb/OneDrive/Documentos/.virtualenvs/r-reticulate/Scripts/python.exe")

#reticulate::py_install("pyreadstat")

#reticulate::py_install("polars")
#reticulate::py_install("seaborn")
#reticulate::py_install("matplotlib")
#reticulate::py_install("kneed")
#reticulate::py_install("xgboost")

Apertura de la base y revisión de valores missing

En primer lugar, debemos observar si la variable a predecir cuenta con valores missing, que para esta primera prueba en donde a posteriori separaremos entre datos de testeo y entrenamiento, solo nos quedaremos con las observaciones reales.

Código
# Para usar un lenguaje python más puro, las librerias que se listan abajo se instalan desde terminal (no desde la consola) una vez que se haya activado el virtual enviroment
#pip install polars
#pip install pyreadstat
#pip install panda
#pip install numpy
#pip install scikit-learn
import pyreadstat
import pandas as pd
import numpy as np
import polars
import seaborn as sns
from sklearn.preprocessing import StandardScaler
import seaborn as sns
import matplotlib.pyplot as plt


tabla_modelo = pd.read_csv("Base_Modelo.csv", sep=",")

## Realizamos un conteo de la variable a predecir y observamos que tiene valores missing por lo que deberá recategorizar como tales.

tabla_modelo["CLASECORR"].value_counts()
Código
## Recategorizamos y a veriguamos qué porcentaje representan estos missing para decir si eliminarlos o no de a momento

tabla_modelo["CLASECORR"] = tabla_modelo["CLASECORR"].replace(" ", np.nan)

tabla_modelo = tabla_modelo[tabla_modelo["CLASECORR"].notna()].reset_index(drop=True)


tabla_modelo["CLASECORR"].value_counts()

Al ser solo el 2.3%, en un primer momento decidiremos omitirlos para el armado del modelo.

Primero observaremos cuántas variables contienen al menos un registro de tipo ” “, para asignarle el correspondiente NaN

Aquellas columnas con más del 20% no serán tenidas en cuenta para el análisis.

Código

porcentaje_na = tabla_modelo.isna().mean() * 100


columnas_con_espacio = tabla_modelo.columns[(tabla_modelo == " ").any()].tolist()


tabla_modelo = tabla_modelo.replace(" ", np.nan)


mayor_a_20 = porcentaje_na[porcentaje_na > 20].index.tolist()

## Eliminamos las columnas que tienen más del 20% de NA

tabla_modelo = tabla_modelo.drop(columns=mayor_a_20)

Revisión y manejo de outliers

Una vez sopesada la primera revisión de los valores NaN, nos abocamos al armado de un ranking basado en el conteo de valores atípicos eliminando algunas variables numéricas (de al menos 4 valores numéricos distintivos) que estén marcadas por la alta presencia de los mismos, y que puedan generar ruido en el modelo.

Primero, convertimos todas las variables que contienen dígitos en integer, debido a que algunas de ellas están seteadas como object.

Código

for col in tabla_modelo.select_dtypes(include='object').columns:
    try:
        tabla_modelo[col] = pd.to_numeric(tabla_modelo[col], downcast='integer')
    except Exception:
        pass

Generamos un dataframe con la cantidad y porcentaje de atípicos para vairables con al menos un outlier.

Código
num_cols = tabla_modelo.select_dtypes(include=[np.number]).columns

# Filtrar solo variables con al menos 4 valores únicos distintos (sin considerar NaN)
cols_4plus_unique = [col for col in num_cols if tabla_modelo[col].nunique(dropna=True) >= 4]

# Lista para guardar resultados
outlier_counts = []

# Iterar sobre cada columna numérica seleccionada
for col in cols_4plus_unique:
    Q1 = tabla_modelo[col].quantile(0.25)
    Q3 = tabla_modelo[col].quantile(0.75)
    IQR = Q3 - Q1
    limite_inferior = Q1 - 1.5 * IQR
    limite_superior = Q3 + 1.5 * IQR

    # Conteo de outliers
    
    n_outliers = tabla_modelo[(tabla_modelo[col] < limite_inferior) | (tabla_modelo[col] > limite_superior)][col].count()
    
    # Calculo de proporción
    
    n_total = tabla_modelo[col].count()
    porcentaje = n_outliers / n_total * 100 if n_total != 0 else np.nan

    outlier_counts.append({
        'Variable': col,
        'Cantidad_outliers': n_outliers,
        'Total_observados': n_total,
        'Porcentaje_outliers': porcentaje
    })

# Convierto a dataframe

df_outliers = pd.DataFrame(outlier_counts)

# Ordenar de mayor a menor cantidad de outliers
df_outliers = df_outliers.sort_values(by='Cantidad_outliers', ascending=False).reset_index(drop=True)

## Histograma

sns.set(style="whitegrid")


plt.figure(figsize=(10,6))
sns.histplot(df_outliers['Porcentaje_outliers'], bins=10, color='skyblue')


plt.title('Distribución del porcentaje de outliers por variable')
plt.xlabel('Porcentaje de outliers')
plt.ylabel('Cantidad de variables')
plt.show()

Por lo que se ve, las 77 variables numéricas tienen al menos un valor atípico, con lo que haremos una selección equilibrada entre mantensión de información y estabilidad frente a distorsiones. Esto se debe a la alta tolerancia de algoritmos como XGBoost y Random Forest frente a los outliers ya que dividen el espacio de las variables por rangos. Pero la regresión logística, al modelar de forma lineal, se puede ver afectada para la estimación de los coeficientes, aumentando la varianza del error estándar. Por esto, nos quedaremos con aquellas variables con un peso relativo de outliers menor al 10% de la distribución.

Código

variables_menor_10 = df_outliers[df_outliers['Porcentaje_outliers'] <= 15]['Variable'].tolist()

# 2. Seleccionar las variables no numéricas en tabla_modelo
variables_no_num = tabla_modelo.select_dtypes(exclude=[np.number]).columns.tolist()


variables_extra = ['ClaseNum', 'ClaseBinaria']


variables_finales = variables_no_num + variables_menor_10 + variables_extra


tabla_modelo = tabla_modelo[variables_finales]

Imputación simple y múltiple para variables discretas y categóricas.

En este sector, dividimos el dataframe en dos partes. Una, para las variables con hasta 5% de NaN que serán imputadas de forma simple. Con la media redondeada para las variables discretas, y la moda para las categóricas. Mientras que aquellas variables numéricas con más de 5% y hasta 20% de missing, se imputarán de forma múltiple utilizando el algoritmo por default, esto es, la regresión de ridge.

Primero, identificamos las variables con hasta 5% de NaN, y mayor a 5% y hasta 20%

Código
porcentaje_na = tabla_modelo.isna().mean() * 100

# Filtrar columnas con hasta 5% de NA
columnas_hasta_5_na = porcentaje_na[porcentaje_na <= 5].index.tolist()

columnas_hasta_5_na
['MASTER_TADELANTOSEFECTIVO', 'MASTER_TCONSUMOS', 'TAUTOSERVICIO', 'TCAJA_AHORRO', 'TCAJA_SEGURIDAD', 'TCAJAS', 'TCAJAS_CONSULTAS', 'TCAJAS_DEPOSITOS', 'TCAJAS_EXTRACCIONES', 'TCAJAS_OTRAS', 'TCALLCENTER', 'TCAMBIO_MONEDAS', 'TCUENTA_CORRIENTE', 'TCUENTA_DEBITOS_AUTOMATICOS', 'TCUENTAS', 'TFONDOS_COMUNES_INVERSION', 'THOMEBANKING', 'TMOVIMIENTOS_ULTIMOS90DIAS', 'TPAGODESERVICIOS', 'TPAGOMISCUENTAS', 'TPAQUETE_PREMIUM', 'TPLAN_SUELDO', 'TPLAZO_FIJO', 'TSEGURO_ACCIDENTES_PERSONALES', 'TSEGURO_AUTO', 'TSEGURO_VIDA_MERCADO_ABIERTO', 'TSEGURO_VIVIENDA', 'TTARJETA_DEBITO', 'TTARJETA_MASTER', 'TTARJETA_MASTER_DEBITOS_AUTOMA', 'TTARJETA_VISA', 'TTARJETA_VISA_DEBITOS_AUTOMATI', 'TTITULOS', 'VISA_TADELANTOSEFECTIVO', 'VISA_TCONSUMOS', 'CLASECORR', 'MCUENTA_DEBITOS_AUTOMATICOS', 'CCALLCENTER_TRANSACCIONES', 'CTARJETA_MASTER_DESCUENTOS', 'MCAJA_AHORRO_PAQUETE', 'MCUENTAS_SALDO', 'MTARJETA_MASTER_CONSUMO', 'MPASIVOS_MARGEN', 'MCAJEROS_PROPIO', 'CTARJETA_MASTER_TRANSACCIONES', 'MPRESTAMOS_PERSONALES', 'CTARJETA_VISA_DESCUENTOS', 'MTTARJETA_VISA_DEBITOS_AUTOMAT', 'CCAMBIO_MONEDAS_VENTA', 'MACTIVOS_MARGEN', 'MCAMBIO_MONEDAS_VENTA', 'CAUTOSERVICIO_TRANSACCIONES', 'CHOMEBANKING_TRANSACCIONES', 'MPAGODESERVICIOS', 'CCAMBIO_MONEDAS_COMPRA', 'MCAMBIO_MONEDAS_COMPRA', 'MAUTOSERVICIO', 'MEXTRACCION_AUTOSERVICIO', 'CPRESTAMOS_PERSONALES', 'CCAJEROS_PROPIOS_DESCUENTOS', 'MTITULOS', 'CCAJEROS_PROPIO_TRANSACCIONES', 'MRENTABILIDAD', 'CTARJETA_DEBITO_TRANSACCIONES', 'MPLAN_SUELDO', 'MTARJETA_VISA_CONSUMO', 'MTARJETA_MASTER_DESCUENTOS', 'CPRESTAMOS_HIPOTECARIOS', 'MPRESTAMOS_HIPOTECARIOS', 'MRENTABILIDAD_ANNUAL', 'CTARJETA_VISA_TRANSACCIONES', 'CEXTRACCION_AUTOSERVICIO', 'CLIENTE_EDAD', 'MPLAZO_FIJO_DOLARES', 'MFONDOS_COMUNES_INVERSION_PESO', 'CTRANSFERENCIAS_EMITIDAS', 'MTRANSFERENCIAS_EMITIDAS', 'MPRESTAMOS_PRENDARIOS', 'CPRESTAMOS_PRENDARIOS', 'MCAJA_AHORRO_NOPAQUETE', 'MARKETING_COSS_SELLING', 'MPLAZO_FIJO_PESOS', 'MCHEQUES_DEPOSITADOS_RECHAZADO', 'CCHEQUES_DEPOSITADOS_RECHAZADO', 'CCOMISIONES_OTRAS', 'CLIENTE_SUCURSAL', 'MFONDOS_COMUNES_INVERSION_DOLA', 'MCHEQUES_EMITIDOS_RECHAZADOS', 'CCHEQUES_EMITIDOS_RECHAZADOS', 'CTRANSFERENCIAS_RECIBIDAS', 'MTRANSFERENCIAS_RECIBIDAS', 'MCUENTA_CORRIENTE_NOPAQUETE', 'CPLAN_SUELDO_TRANSACCION', 'MPLAN_SUELDO_MANUAL', 'CLIENTE_ANTIGUEDAD', 'ClaseNum', 'ClaseBinaria']

Cómo se ve, esta clasificación alcanza solo a tres variables

Código
columnas_entre_5_20_na = porcentaje_na[(porcentaje_na > 5) & (porcentaje_na <= 20)].index.tolist()

columnas_entre_5_20_na
['VISA_MSALDODOLARES', 'VISA_MSALDOTOTAL', 'VISA_MSALDOPESOS', 'VISA_CUENTA_ESTADO', 'VISA_FECHAALTA']

Solo 4 columnas presentan más de 5% y hasta 20% de valores NA.

Seleccionamos las variables numéricas con hasta 5% de sus valores con NaN e imputamos la media

Código

from sklearn.impute import SimpleImputer

columnas_num_hasta_5_na = tabla_modelo[columnas_hasta_5_na].select_dtypes(include=['number']).columns.tolist()

# Filtro

tabla_imput_num = tabla_modelo[columnas_num_hasta_5_na]

## Imputo

imputer = SimpleImputer(strategy='mean')

tabla_num_imputada = pd.DataFrame(imputer.fit_transform(tabla_imput_num), columns=tabla_imput_num.columns)

## Redondeo ya que mis variables numéricas tiene valores discretos

tabla_num_imputada = tabla_num_imputada.round(0).astype('int')

Vamos por las categóricas y numéricas de mayor a 5% y hasta 20% de missing

Código


## No hay así que procederemos a borrar este objeto

columnas_cat_entre_5_20_na = tabla_modelo[columnas_entre_5_20_na].select_dtypes(include='object').columns.tolist()

del columnas_cat_entre_5_20_na


columnas_num_entre_5_20_na = tabla_modelo[columnas_entre_5_20_na].select_dtypes(include='number').columns.tolist()

from sklearn.experimental import enable_iterative_imputer  

from sklearn.impute import IterativeImputer

imputador = IterativeImputer(random_state=123)

## Agrego las variables numéricas ya imputadas para otorgarle robustes al modelo

columnas_para_imput = columnas_num_entre_5_20_na

tabla_imput = tabla_modelo[columnas_para_imput]

tabla_imput1 = pd.DataFrame(imputador.fit_transform(tabla_imput), columns=tabla_imput.columns)

tabla_imput1 = tabla_imput1.round(0).astype('int')

Por último, concatenamos las dos tablas finales, ,borramos algunos objetos para ir liberando memoria, como también eliinaremos algunas columnas no presentes en la tabla utilizada para la validación

Código

tabla_final = pd.concat([tabla_num_imputada, tabla_imput1], axis=1)


del tabla_imput_num

del col 

del columnas_entre_5_20_na

del columnas_hasta_5_na

del columnas_num_entre_5_20_na

del columnas_num_hasta_5_na

del columnas_para_imput

del imputador

del imputer

del tabla_imput

del tabla_imput1

del tabla_num_imputada

Procesamiento y uso de las variables tipo fecha

En primer lugar, luego de la eliminación de algunas de nuestras variables según el peso de los valores missing, confirmamos cuáles de las variables de tipo fecha quedaron presentes en nuestro dataframe.

Código
# Lista de las columnas a verificar
columnas_buscar = ['MASTER_FINICIOMORA', 'VISA_FECHAALTA', 'VISA_FINICIOMORA']


columnas_existentes = [col for col in columnas_buscar if col in tabla_final.columns]

columnas_existentes
['VISA_FECHAALTA']

Como vemos, solo tenemos presente nuestra fecha de alta, por lo que solo nos valdremos del año para el modelo bajo el supuesto de que clientes cuya alta es más reciente tienen más riesgo de abandonar el paquete premium.

Código
## Para extraer los 4 primeros dígitos, a pesar de ser de tipo int, convierto en un primer momento a str y luego a int

tabla_final['VISA_FECHAALTA'] = tabla_final['VISA_FECHAALTA'].astype(str).str[:4].astype(int)

columnas_tres_valores = []


for col in tabla_final.select_dtypes(include=['number']).columns:
    if tabla_final[col].nunique() == 3:
        columnas_tres_valores.append(col)

Reducción de dimesionalidad - Análisis de Componentes Principales

En esta sección, nos abocaremos a los pasos previos para identificar los componentes que utilizaremos frente a nuestro modelo, esto es, la estandarización de nuestras variables discretas, previsualización de posibles componentes a partir de una matriz de correlación, y extracción de componentes.

Matriz de correlación

Para este primer paso, nos quedaremos con las variables numéricas con al menos tres valores numéricos distintivos, que como vimos, corresponde a todos nuestros predictores de tipo numérico, y escalamos el conjunto de las variables

Código
tabla_num = tabla_final.select_dtypes(include=['number']).drop(columns=['ClaseNum', 'ClaseBinaria'])


scaler = StandardScaler()

## Estandarizamos

tabla_num = pd.DataFrame(scaler.fit_transform(tabla_num), columns=tabla_num.columns)


tabla_num.head()
   MCUENTA_DEBITOS_AUTOMATICOS  ...  VISA_FECHAALTA
0                     0.083031  ...       -1.046642
1                     0.046703  ...       -1.046642
2                    -0.048476  ...        0.185622
3                    -0.013359  ...        0.185622
4                     0.071406  ...       -2.032454

[5 rows x 64 columns]

Ahora, generamos la matriz de correlación y visualizamos en un heatmap las variables con un R de Pearson mayor de +/- 0.70

Código
matriz_corr = tabla_num.corr()

matriz_corr_filtrada = matriz_corr.copy()


matriz_corr_filtrada = matriz_corr_filtrada.where((matriz_corr >= 0.70) | (matriz_corr <= -0.70), other=0)

plt.figure(figsize=(20,20))
sns.heatmap(matriz_corr_filtrada, cmap='coolwarm', center=0, annot=False, fmt=".2f")
plt.title("Matriz de correlaciones")
plt.show()

Como vemos, resulta muy difícil visualizar ante la presencia de tantas variables, por eso, seleccionaremos solo algunas de ellas.

Código
corr_pairs = matriz_corr_filtrada.stack().reset_index()


corr_pairs.columns = ['Variable1', 'Variable2', 'Correlacion']

# Filtro valores absolutos >= 0.70 y elimino duplicados

corr_pairs_filtrados = corr_pairs[(abs(corr_pairs['Correlacion']) >= 0.70) & (corr_pairs['Variable1'] != corr_pairs['Variable2'])]


corr_pairs_filtrados['Sorted'] = corr_pairs_filtrados.apply(lambda x: '-'.join(sorted([x['Variable1'], x['Variable2']])), axis=1)

corr_pairs_filtrados = corr_pairs_filtrados.drop_duplicates(subset=['Sorted']).drop(columns=['Sorted'])

corr_pairs_filtrados.head()
                    Variable1                      Variable2  Correlacion
198      MCAJA_AHORRO_PAQUETE                MPASIVOS_MARGEN     0.738718
328   MTARJETA_MASTER_CONSUMO  CTARJETA_MASTER_TRANSACCIONES     0.710531
469           MCAJEROS_PROPIO       MEXTRACCION_AUTOSERVICIO     0.843483
473           MCAJEROS_PROPIO  CCAJEROS_PROPIO_TRANSACCIONES     0.712522
1307            MAUTOSERVICIO  CTARJETA_DEBITO_TRANSACCIONES     0.788233

Aquí podemos ver por ejemplo que la cantidad de transacciones realizadas en cajeros del banco (CCAJEROS_PROPIO_TRANSACCIONES) con la cantidad de extracciones en cajeros, ajenos o no al banco, (CEXTRACCION_AUTOSERVICIO) presentan fuerte correlación, con lo cual es probable que se agrupen en un mismo componente.

Lo mismo con el monto total gastado con tarjeta de crédito (MTARJETA_VISA_CONSUMO) y el saldo disponible en la tarjeta para ese mes (VISA_MSALDOTOTAL), o también el saldo total disponible (VISA_MSALDOTOTAL) y el saldo total en pesos (VISA_MSALDOPESOS).

Extracción de componentes

En primer lugar, visualizamos la cantidad de varianza total explicitada por cada a componente a extraer para fijar un número óptimo.

Código
from sklearn.decomposition import PCA

# Inicializar PCA sin limitar el número de componentes
pca = PCA()

# Ajustar PCA a los datos

pca.fit(tabla_num)
PCA()
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Código
# Varianza explicada por cada componente
varianza_explicada = pca.explained_variance_ratio_

# Varianza acumulada
varianza_acumulada = np.cumsum(varianza_explicada)

# Eigenvalues (autovalores)
eigenvalues = pca.explained_variance_

componentes_kaiser = np.sum(eigenvalues > 1)

componentes_kaiser
np.int64(22)
Código
## Genero mi scree plot

plt.figure(figsize=(10,6))
plt.plot(range(1, len(varianza_explicada)+1), varianza_explicada, 'o-', label='Varianza explicada por componente')
plt.plot(range(1, len(varianza_acumulada)+1), varianza_acumulada, 's-', label='Varianza acumulada')
plt.axhline(y=0.8, color='r', linestyle='--', label='80% varianza explicada')
plt.xlabel('Número de componentes principales')
plt.ylabel('Proporción de varianza explicada')
plt.title('Scree Plot (PCA)')
plt.legend()
plt.grid(True)
plt.show()

Código
varianza_acumulada = np.cumsum(pca.explained_variance_ratio_)

varianza_total_componentes = varianza_acumulada[19]

varianza_total_componentes
np.float64(0.6364986618234436)

Si nos fueramos a guiar por los resultados que otorga el estadístico de Kaiser, tenemos los primeros 20 componentes que explican un monto de la varianza superior a la variable original, sin embargo, estos 20 componentes solo explican el 68,3% de la varianza total, y para un modelo de churn, se recomienda tener un piso del 80% de la varianza total explicitada, algo que encontramos a partir de los 27 componentes.

En nuestro caso, no interesa facilitar la interpretación, sinom ejorar la capacidad predictiva del modelo, por lo tanto se procederá extrayendo 31 componentes que explican 85% de la varianza total como se indica en el objeto varianza_total_componentes.

Código
from sklearn.decomposition import PCA

pca_45 = PCA(n_components=31)

componentes_45 = pca_45.fit_transform(tabla_num)

componentes = pd.DataFrame(componentes_45, 
                                 columns=[f"PC{i+1}" for i in range(31)],index=tabla_num.index)  


componentes.head()
        PC1       PC2       PC3  ...      PC29      PC30      PC31
0  1.906774 -2.557916  0.820223  ...  0.057668  0.027219 -0.012240
1 -0.187907 -1.238462  1.024351  ...  0.095380 -0.417140  0.297726
2  0.796363 -1.759107 -1.366031  ...  0.081739  0.260569 -0.101302
3  2.973275 -1.363259 -3.146002  ... -0.355420  1.149454 -0.644411
4 -1.159707 -1.153317  1.685796  ...  0.063955  0.060214  0.105454

[5 rows x 31 columns]

Pero también quisiera saber qué variable tiene mayor correlación con cada componente, por lo tanto identificamos las cargas factoriales.

Código
cargas = pd.DataFrame(pca_45.components_.T, 
                        index=tabla_num.columns, 
                        columns=[f"PC{i+1}" for i in range(31)])
                        
mayor_peso = {}

for pc in cargas.columns:
    var_max = cargas[pc].abs().idxmax()
    valor = cargas.loc[var_max, pc]
    mayor_peso[pc] = (var_max, valor)

# Convertir a dataframe para visualizar
df_mayor_peso = pd.DataFrame.from_dict(mayor_peso, orient='index', columns=['Variable', 'Peso'])

df_mayor_peso.head()                        
                     Variable      Peso
PC1     MTARJETA_VISA_CONSUMO  0.330142
PC2  MEXTRACCION_AUTOSERVICIO  0.342039
PC3           MPASIVOS_MARGEN  0.318981
PC4             MRENTABILIDAD  0.311980
PC5  MTRANSFERENCIAS_EMITIDAS  0.553885

Como aquí vemos, las cargas resultan bajas, siendo la máxima de 0.74 en el componente 23 para la variable de la pertenencia o no del cliente a la sucursal (CLIENTE_SUCURSAL). Sin embargo, como nuestro objetivo es predecir, las cargas factoriales aquí no resultan un problema puesto que nos interesa maximizar la varianza y mitiga la multicolinealidad a partir del PCA, que resulta ideal para modelos lineales aunque dificulta la interpretación de los componentes.

Código
del corr_pairs

del matriz_corr

del matriz_corr_filtrada

#del tabla_num

tabla_final["ClaseBinaria"].dtype
dtype('int64')

Variables categóricas - Selección de variables relevantes para el modelo

Al tener 35 variables categóricas dentro de nuestro dataset, probaremos un método de selección simple de aquellas que estarán incluidas dentro de nuestro modelo evaluando si existe asociación significativa de las mismas con la variable objetivo a partir de un test de chi cuadrado.

Código
tabla_object = tabla_modelo.select_dtypes(include=['object'])

## Elimino una de mis variables target de este data set de predictores categóricos

tabla_object = tabla_object.drop("CLASECORR", axis=1)

from scipy.stats import chi2_contingency

resultados_chi2 = []

for col in tabla_object.columns:
    tabla_contingencia = pd.crosstab(tabla_modelo[col], tabla_modelo['ClaseBinaria'])
    chi2, p, dof, ex = chi2_contingency(tabla_contingencia)
    resultados_chi2.append({'Variable': col, 'p-value': p})

# Convierto a dataframe ordenado por p-value
df_resultados_chi2 = pd.DataFrame(resultados_chi2).sort_values(by='p-value')

df_resultados_chi2.head(10)
                          Variable        p-value
0        MASTER_TADELANTOSEFECTIVO   0.000000e+00
33         VISA_TADELANTOSEFECTIVO   0.000000e+00
34                  VISA_TCONSUMOS   0.000000e+00
1                 MASTER_TCONSUMOS  1.607556e-275
30                   TTARJETA_VISA   1.044980e-17
10                     TCALLCENTER   6.832264e-15
29  TTARJETA_MASTER_DEBITOS_AUTOMA   2.114574e-13
22                     TPLAZO_FIJO   3.112675e-06
16                    THOMEBANKING   6.688418e-06
31  TTARJETA_VISA_DEBITOS_AUTOMATI   9.406627e-06
Código

## Filtro las que tienen asociación significativa con la variable dicotómica

df_significativas = df_resultados_chi2[df_resultados_chi2['p-value'] < 0.05]

Solo 16 variable presentan asociación significativa con la variable a predecir binaria, veremos cuantas de ellas presentan asociación con la variable a predecir de tres clases, y optaremos por seleccionar aquellas que tienen asociación con ambas variables target.

Código
resultados_chi2 = []

for col in tabla_object.columns:
    tabla_contingencia = pd.crosstab(tabla_modelo[col], tabla_modelo['ClaseNum'])
    chi2, p, dof, ex = chi2_contingency(tabla_contingencia)
    resultados_chi2.append({'Variable': col, 'p-value': p})

# Convierto a dataframe ordenado por p-value
df_resultados_chi2 = pd.DataFrame(resultados_chi2).sort_values(by='p-value')

df_resultados_chi2.head(10)
                          Variable       p-value
0        MASTER_TADELANTOSEFECTIVO  0.000000e+00
1                 MASTER_TCONSUMOS  0.000000e+00
34                  VISA_TCONSUMOS  0.000000e+00
33         VISA_TADELANTOSEFECTIVO  0.000000e+00
30                   TTARJETA_VISA  8.940367e-18
10                     TCALLCENTER  5.849405e-14
29  TTARJETA_MASTER_DEBITOS_AUTOMA  1.441994e-12
22                     TPLAZO_FIJO  1.132301e-06
12               TCUENTA_CORRIENTE  6.248528e-06
3                     TCAJA_AHORRO  6.413757e-06
Código

## Filtro las que tienen asociación significativa con la variable dicotómica

df_significativas1 = df_resultados_chi2[df_resultados_chi2['p-value'] < 0.05]

Y en este caso, solo 17, procederemos entonces a seleccionar aquellas variables que tienen asociación significativa con ambas variables target para concatenarlas con los componentes, y con las variables a predecir para pasar hacia nuestra tabla final que utilizaremos para modelar.

Código


variables_significativas1 = set(df_significativas1['Variable'])
variables_significativas2 = set(df_significativas['Variable'])

# Intersección
variables_interseccion = list(variables_significativas1.intersection(variables_significativas2))



categoricas_signif = tabla_modelo[variables_interseccion]

targets = tabla_modelo[['CLASECORR', 'ClaseNum', 'ClaseBinaria']]

## Tabla final (Para las variables a utilizar a futuro en la tabla de validación tomar objeto categoricas_signif y tabla_num)

tabla_modelo = pd.concat([categoricas_signif, componentes, targets], axis=1)

## Liberamos memoria

del tabla_final

del tabla_object

del categoricas_signif

del targets

Entrenando el modelo

Código
## Recategorizo porque al ser tres clases me solicita 0, 1 y 2

tabla_modelo['ClaseNum'] = tabla_modelo['ClaseNum'] - 1


tabla_modelo['ClaseNum'] = tabla_modelo['ClaseNum'].astype('category')

categoricas = tabla_modelo.iloc[:, :16]

# Genero dummies (el drop_first=True es para evitar multicolinealidad en regresión logística)

categoricas_dummies = pd.get_dummies(categoricas, drop_first=True)

#categoricas_dummies2 = pd.get_dummies(tabla_object, drop_first=True)


# Selecciono las variables numéricas continuas (columnas 17 a 47)

#numericas = tabla_modelo.iloc[:, 16:47]

# Concateno predictores finales y genero dos alternativas, con y sin reducción de dimensionalidad

#X = pd.concat([categoricas_dummies, numericas], axis=1)

## Finalmente utilizamos las numéricas estandarizadas y descaratamos los componentes

X = pd.concat([categoricas_dummies, tabla_num], axis=1)

#X = pd.concat([categoricas_dummies2, tabla_num], axis=1)

#X = tabla_num

# Variable target
y = tabla_modelo['ClaseNum']

XGBoost para predecir 3 clases

XGBoost es un algoritmo que funciona sobre la base del modelado de árboles de decisión, en donde cada árbol intenta corregir los residuos de los árboles anteriores minimizando el error de la predicción. Este algoritmo resulta de utilidad al lidiar con grandes volúmenes de datos en tanto permite manejar multicolinealidad, presencia de outliers, detección de relaciones no lineales, robustez frente a violación de la normalidad, etc

Fijamos los argumentos multi:softprob que indica que estamos queriendo predecir una variable de más de dos categorías, como así también dejamos los que están establecidos por default, centralmente el número de árboles o iteraciones realizada (n_estimators=100, tasa de aprendizaje de 0.3, y máxima profundidad de cada árbol igual a 6), y luego subimos el aprendizaje a 0.8 (aún a riesgo de sobreajuste) para mejorar la detección de los abandonos luego de un mes

El algoritmo resulta potente según una tasa de eficacia del 92%. A su vez esto se refleja mayormente en la correcta detección de aquellos que continúan siendo clientes, como aquellos que abandonan a dos meses desde el mes vigente.

Código
from sklearn.model_selection import train_test_split
import xgboost as xgb
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
from sklearn.metrics import recall_score

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42, stratify=y)

modelo_xgb = xgb.XGBClassifier(objective='multi:softprob', num_class=3, random_state=42, learning_rate=0.8)

modelo_xgb.fit(X_train, y_train)
XGBClassifier(base_score=None, booster=None, callbacks=None,
              colsample_bylevel=None, colsample_bynode=None,
              colsample_bytree=None, device=None, early_stopping_rounds=None,
              enable_categorical=False, eval_metric=None, feature_types=None,
              feature_weights=None, gamma=None, grow_policy=None,
              importance_type=None, interaction_constraints=None,
              learning_rate=0.8, max_bin=None, max_cat_threshold=None,
              max_cat_to_onehot=None, max_delta_step=None, max_depth=None,
              max_leaves=None, min_child_weight=None, missing=nan,
              monotone_constraints=None, multi_strategy=None, n_estimators=None,
              n_jobs=None, num_class=3, ...)
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Código
tabla_modelo["ClaseNum"].value_counts()
ClaseNum
0    106662
2     15409
1     15314
Name: count, dtype: int64
Código
# Predicciones
y_pred = modelo_xgb.predict(X_test)

accuracy_score(y_test, y_pred)
0.9409452639751553
Código
## Matriz de confusión

from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay

recall_1 = recall_score(y_test == 1, y_pred == 1)

recall_1
0.6649978232477144
Código
recall_2 = recall_score(y_test == 2, y_pred == 2)

recall_2
0.9589011464417045
Código
# Generar matriz de confusión
cm = confusion_matrix(y_test, y_pred)

# Visualizar matriz de confusión
disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=modelo_xgb.classes_)
disp.plot(cmap='Blues')
<sklearn.metrics._plot.confusion_matrix.ConfusionMatrixDisplay object at 0x000001E47C3F7050>
Código
plt.title("Matriz de confusión")
plt.show()

Árboles de decisión

En este caso, por default, tenemos un único árbol de decisión. A partir de este algoritmo se crean splits (que no suponen proceso de aprendizaje secuencial) sobre variables para minimizar impureza (Gini o Entropía).

Con el parámetro max_depth buscamos aumentar la profundidad del árbol para bajar el sesgo y aumentar levemente la variabilidad para aumentar la detección de clientes que abandonan luego de un mes.

Código
from sklearn.tree import DecisionTreeClassifier
from sklearn.metrics import accuracy_score, confusion_matrix, ConfusionMatrixDisplay, classification_report

# División en train y test
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42, stratify=y)

# Modelo Decision Tree
modelo_tree = DecisionTreeClassifier(random_state=42, max_depth=50)

# Ajustar modelo
modelo_tree.fit(X_train, y_train)
DecisionTreeClassifier(max_depth=50, random_state=42)
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Código
# Predicciones
y_pred = modelo_tree.predict(X_test)

accuracy_score(y_test, y_pred)
0.8932938664596274
Código
recall_1 = recall_score(y_test == 1, y_pred == 1)

recall_1
0.5803221593382673
Código
recall_2 = recall_score(y_test == 2, y_pred == 2)

recall_2
0.9584685269305646
Código
cm = confusion_matrix(y_test, y_pred)
disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=modelo_tree.classes_)
disp.plot(cmap='Blues')
<sklearn.metrics._plot.confusion_matrix.ConfusionMatrixDisplay object at 0x000001E4763D0590>
Código
plt.title("Matriz de confusión (Decision Tree)")
plt.show()

Random Forest

Este algoritmo combina múltiples árboles de decisión para mejorar la precisión y robustez del modelo, y crea muestras aleatorias con reemplazo del dataset original para cada árbol. En cada muestra se entrena un árbol y se realiza un filtro de variables para reducir la correlación entre árboles.

Sin embargo, resulta muy baja la tasa de acierto para la clase 1

Código
from sklearn.ensemble import RandomForestClassifier

# Modelo Random Forest
modelo_rf = RandomForestClassifier(n_estimators=100, random_state=42, max_depth=50)

# Ajustar modelo
modelo_rf.fit(X_train, y_train)
RandomForestClassifier(max_depth=50, random_state=42)
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Código
# Predicciones
y_pred = modelo_rf.predict(X_test)

accuracy_score(y_test, y_pred)
0.914547748447205
Código
# Classification report
print(classification_report(y_test, y_pred))
              precision    recall  f1-score   support

           0       0.90      1.00      0.95     31999
           1       0.97      0.29      0.45      4594
           2       1.00      0.95      0.97      4623

    accuracy                           0.91     41216
   macro avg       0.95      0.75      0.79     41216
weighted avg       0.92      0.91      0.90     41216
Código
# Matriz de confusión
cm = confusion_matrix(y_test, y_pred)
disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=modelo_rf.classes_)
disp.plot(cmap='Blues')
<sklearn.metrics._plot.confusion_matrix.ConfusionMatrixDisplay object at 0x000001E47DC0AB90>
Código
plt.title("Matriz de confusión (Random Forest)")
plt.show()

Análisis discriminante

El Análisis Discriminante Lineal es un método supervisado de clasificación y reducción de dimensionalidad. Este calcula la media de cada predictor por clase, la matriz de covarianza común, y las funciones discriminantes lineales que son combinaciones lineales de las variables predictoras, generadas para maximizar la separación entre clases (maximizando la varianza explicada entre clases relativa a la varianza dentro de clases).

En este caso el análisis discriminante resulta una buena opción en caso de que se busque interpretabilidad de los factores que determinan el abandono, permitiendo observar la combinación lineal de variables que mejor separa las clases.

Para este tipo de modelos donde lo que interesa es mejorar la capacidad predictiva y no la interpretación del modelo, vemos a partir de los resultados de la matriz que presenta limitaciones

Código
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis

lda = LinearDiscriminantAnalysis(solver='lsqr', shrinkage='auto')

# Ajustar modelo
lda.fit(X_train, y_train)
LinearDiscriminantAnalysis(shrinkage='auto', solver='lsqr')
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Código
# Predecir en test
y_pred = lda.predict(X_test)

# Evaluar accuracy
acc = accuracy_score(y_test, y_pred)

cm = confusion_matrix(y_test, y_pred)
disp = ConfusionMatrixDisplay(confusion_matrix=cm)
disp.plot(cmap='Blues')
<sklearn.metrics._plot.confusion_matrix.ConfusionMatrixDisplay object at 0x000001E4766D7310>
Código
plt.title("Matriz de confusión (LDA)")
plt.show()

XGBoost y Regresión Logística para predecir 2 clases.

Código
from sklearn.linear_model import LogisticRegression

y = tabla_modelo['ClaseBinaria']

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42, stratify=y)

logreg = LogisticRegression(max_iter=1000)
logreg.fit(X_train, y_train)
LogisticRegression(max_iter=1000)
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Código
# Predecir
y_pred_logreg = logreg.predict(X_test)

cm = confusion_matrix(y_test, y_pred_logreg)
disp = ConfusionMatrixDisplay(confusion_matrix=cm)
disp.plot(cmap='Blues')
<sklearn.metrics._plot.confusion_matrix.ConfusionMatrixDisplay object at 0x000001E47917F990>
Código
plt.title("Matriz de confusión - Regresión logística")
plt.show()

Código
xgb_model = xgb.XGBClassifier(objective='binary:logistic', eval_metric='logloss',learning_rate=0.8)

# Entrenar
xgb_model.fit(X_train, y_train)
XGBClassifier(base_score=None, booster=None, callbacks=None,
              colsample_bylevel=None, colsample_bynode=None,
              colsample_bytree=None, device=None, early_stopping_rounds=None,
              enable_categorical=False, eval_metric='logloss',
              feature_types=None, feature_weights=None, gamma=None,
              grow_policy=None, importance_type=None,
              interaction_constraints=None, learning_rate=0.8, max_bin=None,
              max_cat_threshold=None, max_cat_to_onehot=None,
              max_delta_step=None, max_depth=None, max_leaves=None,
              min_child_weight=None, missing=nan, monotone_constraints=None,
              multi_strategy=None, n_estimators=None, n_jobs=None,
              num_parallel_tree=None, ...)
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Código
# Predecir
y_pred_xgb = xgb_model.predict(X_test)

cm = confusion_matrix(y_test, y_pred_xgb)
disp = ConfusionMatrixDisplay(confusion_matrix=cm)
disp.plot(cmap='Blues')
<sklearn.metrics._plot.confusion_matrix.ConfusionMatrixDisplay object at 0x000001E47C6FF910>
Código
plt.title("Matriz de confusión - XGBoost")
plt.show()

Código
7488/(7488+1729)
0.8124118476727785

Arboles de decisión para clase binaria.

Código
X_train, X_test, y_train, y_test = train_test_split(
    X, y,  # tu variable target binaria aquí
    test_size=0.3,
    random_state=42,
    stratify=y
)

# Modelo
modelo_dt = DecisionTreeClassifier(
    max_depth=None,  
    criterion='entropy',
    random_state=42
)

# Ajustar modelo
modelo_dt.fit(X_train, y_train)
DecisionTreeClassifier(criterion='entropy', random_state=42)
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Código
# Predicciones
y_pred = modelo_dt.predict(X_test)

# Accuracy
accuracy_score(y_test, y_pred)
0.8951620729813664
Código
# Matriz de confusión
cm = confusion_matrix(y_test, y_pred)
disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=modelo_dt.classes_)
disp.plot(cmap='Blues')
<sklearn.metrics._plot.confusion_matrix.ConfusionMatrixDisplay object at 0x000001E47A981190>
Código
plt.title("Matriz de confusión (Decision Tree Binario)")
plt.show()

Random Forest para target binaria

Código
from sklearn.ensemble import RandomForestClassifier

X_train, X_test, y_train, y_test = train_test_split(
    X, y,  # usando 'y' como target
    test_size=0.3,
    random_state=42,
    stratify=y
)

# Modelo: Random Forest
modelo_rf = RandomForestClassifier(
    n_estimators=100, 
    max_depth=None,
    criterion='entropy', 
    random_state=42,
    n_jobs=-1         
)

# Ajustar modelo
modelo_rf.fit(X_train, y_train)
RandomForestClassifier(criterion='entropy', n_jobs=-1, random_state=42)
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Código
# Predicciones
y_pred = modelo_rf.predict(X_test)

accuracy_score(y_test, y_pred)
0.9187451475155279
Código
classification_report(y_test, y_pred)
'              precision    recall  f1-score   support\n\n           0       0.91      1.00      0.95     31999\n           1       0.99      0.65      0.78      9217\n\n    accuracy                           0.92     41216\n   macro avg       0.95      0.82      0.87     41216\nweighted avg       0.92      0.92      0.91     41216\n'
Código
# Matriz de confusión
cm = confusion_matrix(y_test, y_pred)
disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=modelo_rf.classes_)
disp.plot(cmap='Blues')
<sklearn.metrics._plot.confusion_matrix.ConfusionMatrixDisplay object at 0x000001E47D76B0D0>
Código
plt.title("Matriz de confusión (Random Forest Binario)")
plt.show()

Conclusiones sobre el armado del modelo

En lo que respecta a la predicción de tres clases encontramos que el algoritmo XGBoost (aunque no si ncierto sobreajuste) resulta el más pertinente para nuestro objetivo de interés, esto es, la predicción de los clientes potencialmente desertores luego de uno y dos meses, centralmente en esta última clase, alcanzando un acierto de 96%, mientras que para aquellos que dejan de ser usuarios del banco luego de un mes, esta alcanza un 66.5%. Podría decirse entonces que este modelo resulta de mayor certeza para predecir aquellos casos de abandono luego de los dos meses partiendo del mes vigente

En lo que respecta a la clase binaria, abandono o no, el algoritmo de mayor utilidad para la detección de la categoria de interés fue, nuevamente, XGBoost (que mejoró en tanto se agregaron más predictores numéricos continuos) que detecta 81% de coincidencia de los valores predichos de abandono con los respectivos observables.

Se barajaron una serie de alternativas para el procesamiento previo a la incorporación de las variables al modelo. Se redujo dimensionalidad con PCA, se eliminaron y se agregaron variables según el peso de los outliers en la distribución, se imputo de formas particulares según el peso de los missing, se seleccionaron variables categóricas según la relación de significancia con los target, y si pudieran destacarse dos pasos que deben quedar estables para el armado de un modelo con un dataset de tal tamaño, es el hecho de n oreducir dimensionalidad si de predecir se trata, como así también descartar aquellas variables con más de 20% de missing en la distribución.

Procesamiento de dataset para validación

En este apartado se reiterarán los pasos ejecutados en materia de selección de variables e imputación de valores missing según su peso en la distribución, como la estandarización para luego imputar la clase correspondiente tanto de la variable dicotómica, como la de tres clases.

Selección de variables e imputación de missing

Código


tabla_validacion = pd.read_csv("Base_Validacion.csv", sep=",")


columnas_num = set(tabla_num.columns)
columnas_cat = set(categoricas.columns)


columnas_finales = columnas_num.union(columnas_cat)

# Filtrar tabla_validacion dejando solo esas columnas si existen
tabla_validacion = tabla_validacion[list(columnas_finales.intersection(tabla_validacion.columns))]

del tabla_modelo

del tabla_num

del categoricas

Imputación simple y múltiple

Código
## Reemplazamos espacios vacíos por NA

tabla_validacion = tabla_validacion.replace(" ", np.nan)

## Identificamos NA en todo el dataframe

porcentaje_na = tabla_validacion.isna().mean() * 100

## Aquellas columnas numéricas que figuran como object las convertimos en numéricas

for col in tabla_validacion.select_dtypes(include='object').columns:
    try:
        tabla_validacion[col] = pd.to_numeric(tabla_validacion[col], downcast='integer')
    except Exception:
        pass


## Identificó columas con hasta 5% y sin NA

columnas_hasta_5_na = porcentaje_na[porcentaje_na <= 5].index.tolist()

columnas_hasta_5_na
['MPLAZO_FIJO_DOLARES', 'TCUENTA_CORRIENTE', 'CEXTRACCION_AUTOSERVICIO', 'MCHEQUES_DEPOSITADOS_RECHAZADO', 'MPRESTAMOS_PRENDARIOS', 'CCOMISIONES_OTRAS', 'CTARJETA_VISA_DESCUENTOS', 'MASTER_TADELANTOSEFECTIVO', 'CCHEQUES_DEPOSITADOS_RECHAZADO', 'MCUENTAS_SALDO', 'CTRANSFERENCIAS_RECIBIDAS', 'MRENTABILIDAD_ANNUAL', 'MFONDOS_COMUNES_INVERSION_DOLA', 'CLIENTE_EDAD', 'CTARJETA_DEBITO_TRANSACCIONES', 'TTARJETA_VISA', 'MAUTOSERVICIO', 'TTARJETA_VISA_DEBITOS_AUTOMATI', 'CTARJETA_MASTER_TRANSACCIONES', 'CTARJETA_MASTER_DESCUENTOS', 'MCUENTA_DEBITOS_AUTOMATICOS', 'MPAGODESERVICIOS', 'TSEGURO_ACCIDENTES_PERSONALES', 'MCAMBIO_MONEDAS_COMPRA', 'CCAJEROS_PROPIO_TRANSACCIONES', 'MTARJETA_MASTER_DESCUENTOS', 'CLIENTE_SUCURSAL', 'MTRANSFERENCIAS_RECIBIDAS', 'VISA_TCONSUMOS', 'MPLAN_SUELDO', 'MCAMBIO_MONEDAS_VENTA', 'CPRESTAMOS_PRENDARIOS', 'CTRANSFERENCIAS_EMITIDAS', 'CHOMEBANKING_TRANSACCIONES', 'CCHEQUES_EMITIDOS_RECHAZADOS', 'CCALLCENTER_TRANSACCIONES', 'MTARJETA_VISA_CONSUMO', 'MASTER_TCONSUMOS', 'CCAMBIO_MONEDAS_COMPRA', 'CPRESTAMOS_HIPOTECARIOS', 'THOMEBANKING', 'MTTARJETA_VISA_DEBITOS_AUTOMAT', 'MTRANSFERENCIAS_EMITIDAS', 'TPAGOMISCUENTAS', 'MPRESTAMOS_HIPOTECARIOS', 'TTARJETA_MASTER', 'VISA_TADELANTOSEFECTIVO', 'MTITULOS', 'CAUTOSERVICIO_TRANSACCIONES', 'MTARJETA_MASTER_CONSUMO', 'MPASIVOS_MARGEN', 'TPAGODESERVICIOS', 'CPLAN_SUELDO_TRANSACCION', 'MPRESTAMOS_PERSONALES', 'MPLAZO_FIJO_PESOS', 'MEXTRACCION_AUTOSERVICIO', 'MCHEQUES_EMITIDOS_RECHAZADOS', 'TCAJA_AHORRO', 'TCALLCENTER', 'MPLAN_SUELDO_MANUAL', 'MRENTABILIDAD', 'CLIENTE_ANTIGUEDAD', 'MCAJA_AHORRO_NOPAQUETE', 'MCAJA_AHORRO_PAQUETE', 'CCAMBIO_MONEDAS_VENTA', 'CCAJEROS_PROPIOS_DESCUENTOS', 'MACTIVOS_MARGEN', 'TPLAZO_FIJO', 'CTARJETA_VISA_TRANSACCIONES', 'MCUENTA_CORRIENTE_NOPAQUETE', 'MARKETING_COSS_SELLING', 'TTARJETA_MASTER_DEBITOS_AUTOMA', 'MCAJEROS_PROPIO', 'CPRESTAMOS_PERSONALES', 'MFONDOS_COMUNES_INVERSION_PESO']
Código
## Identificamos columnas con más de 5% y hasta 20% de NaN

columnas_entre_5_20_na = porcentaje_na[(porcentaje_na > 5) & (porcentaje_na <= 20)].index.tolist()

columnas_entre_5_20_na
['VISA_MSALDODOLARES', 'VISA_FECHAALTA', 'VISA_CUENTA_ESTADO', 'VISA_MSALDOTOTAL', 'VISA_MSALDOPESOS']
Código
## Imputamos con media aquellas variables numéricas con hasta 5% de NaN

columnas_num_hasta_5_na = tabla_validacion[columnas_hasta_5_na].select_dtypes(include=['number']).columns.tolist()


tabla_imput_num = tabla_validacion[columnas_num_hasta_5_na]


imputer = SimpleImputer(strategy='mean')

tabla_num_imputada = pd.DataFrame(imputer.fit_transform(tabla_imput_num), columns=tabla_imput_num.columns)


tabla_num_imputada = tabla_num_imputada.round(0).astype('int')

#Imputamos categóricas (no hay) y numéricas de mayor a 5% y hasta 20% de missing


columnas_cat_entre_5_20_na = tabla_validacion[columnas_entre_5_20_na].select_dtypes(include='object').columns.tolist()


del columnas_cat_entre_5_20_na

columnas_num_entre_5_20_na = tabla_validacion[columnas_entre_5_20_na].select_dtypes(include='number').columns.tolist()

imputador = IterativeImputer(random_state=123)


columnas_para_imput = columnas_num_entre_5_20_na

tabla_imput = tabla_validacion[columnas_para_imput]

tabla_imput1 = pd.DataFrame(imputador.fit_transform(tabla_imput), columns=tabla_imput.columns)

tabla_imput1 = tabla_imput1.round(0).astype('int')

tabla_num_imputada
       MPLAZO_FIJO_DOLARES  ...  MFONDOS_COMUNES_INVERSION_PESO
0                        0  ...                               0
1                        0  ...                               0
2                        0  ...                               0
3                        0  ...                               0
4                        0  ...                               0
...                    ...  ...                             ...
15655                    0  ...                               0
15656                    0  ...                               0
15657                    0  ...                               0
15658                    0  ...                               0
15659                    0  ...                               0

[15660 rows x 59 columns]
Código
tabla_final = pd.concat([tabla_num_imputada, tabla_imput1], axis=1)

## Concateno las columnas imputadas con aquellas no imputadas de mi base madre

columnas_faltantes = tabla_validacion.columns.difference(tabla_final.columns)


tabla_faltante = tabla_validacion[columnas_faltantes]


tabla_validacion = pd.concat([tabla_final, tabla_faltante], axis=1)


## Chequeamos si quedó alguna variable con NaN

tabla_validacion.isnull().any()
MPLAZO_FIJO_DOLARES               False
CEXTRACCION_AUTOSERVICIO          False
MCHEQUES_DEPOSITADOS_RECHAZADO    False
MPRESTAMOS_PRENDARIOS             False
CCOMISIONES_OTRAS                 False
                                  ...  
TTARJETA_MASTER_DEBITOS_AUTOMA    False
TTARJETA_VISA                     False
TTARJETA_VISA_DEBITOS_AUTOMATI    False
VISA_TADELANTOSEFECTIVO           False
VISA_TCONSUMOS                    False
Length: 80, dtype: bool
Código

del tabla_imput

del tabla_imput1

del tabla_num_imputada

del tabla_imput_num

del tabla_final

del tabla_faltante

## Extraemos los 4 primeros dígitos de la variable fecha

tabla_validacion['VISA_FECHAALTA'] = tabla_validacion['VISA_FECHAALTA'].astype(str).str[:4].astype(int)

columnas_tres_valores = []


for col in tabla_validacion.select_dtypes(include=['number']).columns:
    if tabla_validacion[col].nunique() == 3:
        columnas_tres_valores.append(col)

## Conversión de categóricas a dummy y estandarización de las numéricas

categoricas = tabla_validacion.select_dtypes(include=['object', 'category'])

numericas = tabla_validacion.select_dtypes(exclude=['object', 'category'])

# Convertir variables categóricas en dummies
categoricas_dummies = pd.get_dummies(categoricas, drop_first=True)

# Estandarizo

scaler = StandardScaler()

numericas_scaled = pd.DataFrame(
    scaler.fit_transform(numericas),
    columns=numericas.columns,
    index=numericas.index
)

# Concateno

tabla_validacion = pd.concat([numericas_scaled, categoricas_dummies], axis=1)

Predicciones en dataset de validación - tres clases

Código
tabla_validacion_X = tabla_validacion[X.columns]


predicciones = modelo_xgb.predict(tabla_validacion_X)

tabla_validacion["ClaseNum_predicha"] = predicciones

## Chequeo

tabla_validacion["ClaseNum_predicha"].value_counts()
ClaseNum_predicha
0    13105
2     1630
1      925
Name: count, dtype: int64
Código

## Calculo probabilidades

prob = modelo_xgb.predict_proba(tabla_validacion_X)

prob_df = pd.DataFrame(prob, columns=["prob_clase0", "prob_clase1", "prob_clase2"], index=tabla_validacion.index)

# Concateno

tabla_validacion = pd.concat([tabla_validacion, prob_df], axis=1)

En nuestro dataframe original almacenado en tabla_modelo teníamos 22.36% de individuos físicos que abandonaban el banco ya sea uno o dos meses después del vigente, mientras que en en este dataset a partir de nuestro modelo se detectan 16.31%

Predicciones en dataset de validación - dos clases

Código
pred_binaria = xgb_model.predict(tabla_validacion_X)

tabla_validacion["ClaseBinaria_predicha"] = pred_binaria

## Chequeo

tabla_validacion["ClaseBinaria_predicha"].value_counts()
ClaseBinaria_predicha
0    12838
1     2822
Name: count, dtype: int64
Código

## Calculo probabilidades

prob_bin = xgb_model.predict_proba(tabla_validacion_X)

prob_df_2 = pd.DataFrame(prob_bin, columns=["prob_bin_clase0", "prob_bin_clase1"], index=tabla_validacion.index)

# Concateno

tabla_validacion = pd.concat([tabla_validacion, prob_df_2], axis=1)

#tabla_validacion.to_csv("TablaValidacionPredicho.csv")

Para la clase binaria nuestro modelo detecta 18% de desertores, mientras que, como ya se mencionó, nuestro dataset original identificaba 22.36%.

Resultaría más certero guiarse por las predicciones de la variable de tres clases, específicamente por aquellos idetficados como desertores luego de dos meses, en tanto el recall resultó de un 95%.