06 noviembre, 2023 #Analítica #Herramientas

Mejorar la gestión de las donaciones con la ayuda de IA

Marketing Funnel segun DALLE-3

En nuestra entrada anterior demostramos que es posible “predecir” donaciones con el uso de Machine Learning (ML). En esta, la segunda parte de la serie, vamos a trabajar con un dataset de la vida real, generaremos un nuevo modelo predictivo y por último analizaremos su eficacia a la hora de mejorar nuestros procesos de generación de donantes.

NOTA IMPORTANTE: no hacen falta conocimientos avanzados porque esta será una guía paso a paso, pero siempre es bueno saber algo de Python y de Estadística a la hora de generar modelos predictivos. Esta web tiene muy buenos cursos teórico-prácticos introductorios que se pueden hacer de manera gratuita (en inglés, eso sí).

Un poco de contexto

Como mencionamos en la nota anterior, para trabajar con ML necesitamos ingredientes (la base de datos), recetas (el modelo predictivo) y el horno para procesar la receta (Jupiter Notebook o Colab).

Esta vez vamos a usar el “Fundraising Data” de Kaggle. La base tiene 34.508 registros y 23 columnas, con nuestra variable target (la que queremos predecir) con los datos “Y” o “N”. Si quieren descargar directamente el archivo con el que vamos a trabajar pueden hacer una copia del misma aquí.

Como a todo dataset real, le faltan datos (tiene celdas vacías), tiene datos que nos van a servir para trabajar y otros que no. Parte de nuestra receta, será como lidiar con estas particularidades.

En esta oportunidad vamos a dar por entendidas algunas cuestiones prácticas que sí vimos en profundidad en la nota anterior (cualquier duda al respecto remitirse a “Optimizando Donaciones con Machine Learning“).

Manos a la obra

Recordemos que existen una serie de pasos para resolver problemas de clasificación con ML: Importar las bibliotecas necesarias, cargar los datos, hacer una análisis de las variables, codificar las variables categóricas, identificar la variable target y dividir el conjunto de datos en conjuntos de entrenamiento y prueba.

Comenzamos con el código de Jupyter:

#Importamos galerías, cargamos nuestro archivo, vemos "de qué" está hecho
import pandas as pd
fundraising_df = pd.read_csv("data_science_for_fundraising_donor_data.csv")
fundraising_df.dtypes

Si todo va bien, deberían ver lo siguiente:

Mientras que si queremos saber las cantidad de valores válidos de cada columna:

fundraising_df.count()

Y obtener lo siguiente:

Primer tema que debemos abordar: ¿qué hacemos con las columnas que no tienen todos sus datos? (para ptrabajar con modelos, no podemos tener celdas vacías).

Lidiando con valores “null” (limpieza de datos)

Los valores nulos pueden tratarse de diferentes maneras:

  • Eliminación: se pueden eliminar las filas que contienen valores nulos si no representan una parte significativa de tus datos.
  • Imputación: para columnas específicas, se pueden reemplazar los valores nulos con estimaciones como la media o la mediana para datos numéricos o el valor más frecuente para datos categóricos.
  • Columnas No Relevantes: Si una columna tiene muchos valores nulos y no aporta información valiosa, lo mejor es eliminarla.

Empecemos por elegir las columnas. Si analizamos los valores, vamos a ver que las siguiente podemos borrar, porque son valores relacionados a los que efectivamente donaron (y ya sabemos que son datos con los que no contaremos en las futuras bases a las que le aplicaremos el modelo).

También vamos a eliminar columnas que no consideramos relevantes a la hora de realizar predicciones (o de las que tengamos muy pocos datos). O dicho de otro modo, vamos a dejar solo los datos socio-demográficos que puedan ser útiles para el modelo, con el siguiente código:

# Lista de columnas a eliminar
columnas_a_eliminar = [
    'MEMBERSHIP_IND',
    'ALUMNUS_IND',
    'PARENT_IND',
    'HAS_INVOLVEMENT_IND',
    'WEALTH_RATING',
    'PREF_ADDRESS_TYPE',
    'CON_YEARS',
    'PrevFYGiving',
    'PrevFY1Giving',
    'PrevFY2Giving',
    'PrevFY3Giving',
    'PrevFY4Giving',
    'CurrFYGiving',
    'TotalGiving'
]

# Eliminar las columnas
fundraising_df = fundraising_df.drop(columnas_a_eliminar, axis=1)

Ahora ya tenemos un dataset más “limpio” que debería verse así despues de una llamada de head y de count:

Aquí vamos a tomar nuestra primera decisión importante.

De los treinta y cuatro mil registros, tenemos casi todos con el código postal y casi todos con el sexo.

El siguiente escalón es el de “Edad”, donde solo tenemos un tercio de los registros. Una opción posible es tratar de “imputar” los valores que faltan con sus promedios. En general es una idea siempre y cuando los registros que tenemos que “re llenar” no sean demasiados. Y a mi criterio entre 13 mil y 34 mil hay demasiados faltantes.

Así que en este caso vamos a aprovechar que tenemos muchos registros con casi todos los datos (aunque sean un tercio del total) y trabajaremos solo con esos (para no contaminar demasiado la muestra) con el siguiente código:

# Crea un nuevo DataFrame con solo los registros donde "AGE" no es nulo
nuevo_df = fundraising_df[fundraising_df["AGE"].notnull()]

También vamos a eliminar “BIRTH_DATE”, ya que tenemos la edad (y no creo que un cambio de signo te haga más o menos donante):

# Elimina la columna "BIRTH_DATE" del DataFrame
fundraising_df = fundraising_df.drop("BIRTH_DATE", axis=1)

Repasamos como quedó el dataset en cantidad de datos por cateogorías:

nuevo_df.count()

Y vemos el siguiente resultado:

ID 13318
ZIPCODE 13283
AGE 13318
MARITAL_STATUS 1069
GENDER 13137
DEGREE_LEVEL 4979
EMAIL_PRESENT_IND 13318
DONOR_IND 13318

¡Mucho mejor! Segundo momento de tomar decisiones. Ahora que tenemos un dataset más balanceado, ¿qué hacemos con el resto de los valores (ejemplo “DEGREE_LEVEL”) que no están completos?

En mi caso, me incliné por completar los que estaban más cerca del valor total:

#"inplace=True" hace que los nuevos datos se apliquen directamente al dataset

mode_gender = nuevo_df['GENDER'].mode()[0]
nuevo_df['GENDER'].fillna(mode_gender, inplace=True)

mode_degree_level = nuevo_df['DEGREE_LEVEL'].mode()[0]
nuevo_df['DEGREE_LEVEL'].fillna(mode_degree_level, inplace=True)

mode_degree_level = nuevo_df['ZIPCODE'].mode()[0]
nuevo_df['ZIPCODE'].fillna(mode_degree_level, inplace=True)

mode_degree_level = nuevo_df['ZIPCODE'].mode()[0]
nuevo_df['ZIPCODE'].fillna(mode_degree_level, inplace=True)

Y eliminar la categoría que menos datos tenía disponibles:

nuevo_df = nuevo_df.drop("MARITAL_STATUS", axis=1)

Para que la base de datos nos quede así:

Finalmente tenemos un dataset con 13.318 valores en todas sus columnas y una serie de “propiedades” que nos pueden servir para generar un modelo predictivo (zona donde viven, edad, sexo, nivel de estudios y dato de contacto).

Transformación de características

Ahora que ya tenemos los valores del dataset que queremos, debemos realizar codificación de características categóricas o no numéricas (como “GENDER,” “DEGREE_LEVEL,” “EMAIL_PRESENT_IND,” y “DONOR_IND”) para que puedan ser utilizadas en el modelo que vamos a construir.

Pero antes, por las dudas, guardemos lo ya trabajado y carguemos un nuevo dataset:

nuevo_df.to_csv('datos_limpios-preprocesados-set-fundrasing.csv', index=False)
fundraising_df2 = pd.read_csv("datos_limpios-preprocesados-set-fundrasing.csv")

Ahora que tenemos nuestro dataset preprocesado cargado, vamos a aplicar la codificación. Hay varios métodos para hacerlo, en este caso vamos a usar “get_dummies” (en la entrada anterior usamos el “hot-encoder”):

# Aplica codificación one-hot a las variables categóricas
fundraising_df2 = pd.get_dummies(fundraising_df2, columns=['GENDER', 'DEGREE_LEVEL', 'EMAIL_PRESENT_IND'], drop_first=True)

# La opción 'drop_first=True' elimina una de las categorías para evitar multicolinealidad

Con este método se crea una nueva columna por cada posible “respuesta” en cada categoría. Nuestro base debe quedar ahora así:

Datos de entrenamiento y datos de validación

La idea ahora es aprovechar la gran cantidad de registros que tenemos y separar nuestro dataset de tal forma de entrenar el modelo con 10.000 datos y dejar el resto sin tocar para evaluar luego cómo se comparta nuestras predicciones con datos “no vistos” (en la entrada anterior hicimos esto al principio, y luego procesamos los datos “nuevos”, como para mostrar diferentes formas de trabajo):

# Divide el dataset en entrenamiento (10,000 datos) y validación (3,318 datos)
entrenamiento_df = fundraising_df2.sample(n=10000, random_state=42)
validacion_df = fundraising_df2.drop(entrenamiento_df.index)

# Guarda los datasets en archivos CSV por separado
entrenamiento_df.to_csv('fundraising_df2_entrenamiento.csv', index=False)
validacion_df.to_csv('fundraising_df2_validacion.csv', index=False)

Ahora que tenemos una base de “entrenamiento”, podemos ver cómo quedó distribuida nuestra variable target (cuántos donantes “Y” tenemos):

# Contar la distribución de la variable "DONOR_IND"
distribucion_donor = entrenamiento_df['DONOR_IND'].value_counts()

# Muestra la distribución
print(distribucion_donor)

Como resultado vamos a ver que en este caso tenemos más donantes “sí” que donantes “no” (también a diferencia de nuestro artículo anterior):

Y: 6.022
N: 3.978

A diferencia de nuestro dataset anterior, aquí sí vamos a abordar el tema de las clases desbalanceadas usando una librería (imbalanced-learn) y una técnica llamada SMOTE (Synthetic Minority Over-sampling Technique). Esta técnica crea nuevas instancias (o ejemplos) sintéticos para la clase minoritaria. Tiene el efecto de aumentar la densidad de la clase minoritaria en el espacio de características, ya que introduce ejemplos adicionales que comparten características similares a los ejemplos originales (en este caso, sumando casos negativos).

¿Y qué modelos de clasificación podemos usar?

  • Regresión Logística: es un modelo de clasificación binaria ampliamente utilizado que es adecuado para problemas de este tipo. Es un buen punto de partida para entender las relaciones entre las características y la probabilidad de pertenecer a una de las clases.
  • Random Forest: es un modelo de ensamble que combina múltiples árboles de decisión para mejorar la precisión de la clasificación. Es robusto y funciona bien en una variedad de conjuntos de datos.
  • Support Vector Machine (SVM): otro modelo de clasificación que busca encontrar un hiperplano de separación óptimo entre las clases. Puede ser eficaz en conjuntos de datos con características no lineales.
  • Naive Bayes: es una opción simple y efectiva para clasificación de texto y problemas de clasificación binaria.

Creando nuestro Modelo Predictivo

En mi experiencia, el modelo que mejor funciona es para este tipo de datasets es el de Random Forest. De hecho hice algunas pruebas con modelos de Regresión Logística y de SVM, sin buenos resultados. Así que les voy a ahorrar las pruebas que no funcionaron y les presentaré con la que sí tuve buenos resultados.

Sin más preámbulos, el código de nuestro es:

# Importa las bibliotecas necesarias
import pandas as pd
from imblearn.over_sampling import SMOTE
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score, confusion_matrix, classification_report

# Carga tu conjunto de datos
data = entrenamiento_df

# Preprocesamiento de Datos
X = data.drop('DONOR_IND', axis=1)
y = data['DONOR_IND']
# Codifica 'DONOR_IND' como 0 y 1
y = y.map({'N': 0, 'Y': 1})

# Aplica SMOTE para abordar el desbalance de clases
smote = SMOTE()
X_resampled, y_resampled = smote.fit_resample(X, y)

# Divide los datos en conjuntos de entrenamiento y prueba
X_train, X_test, y_train, y_test = train_test_split(X_resampled, y_resampled, test_size=0.2, random_state=42)

# Entrenamiento del Modelo de Random Forest
random_forest = RandomForestClassifier(random_state=42)
random_forest.fit(X_train, y_train)

# Evaluación del Modelo
y_pred = random_forest.predict(X_test)
accuracy = accuracy_score(y_test, y_pred)
conf_matrix = confusion_matrix(y_test, y_pred)
report = classification_report(y_test, y_pred)

# Imprime las métricas de evaluación
print("Exactitud:", accuracy)
print("Matriz de Confusión:\n", conf_matrix)
print("Informe de Clasificación:\n", report)

Nuestro primeros resultados:

Traducido:

– Exactitud (Accuracy): la proporción de predicciones correctas en el conjunto de prueba. En este caso, el modelo tiene una exactitud del 58.3%, lo que significa que acierta en aproximadamente el 58.3% de las predicciones.

– Matriz de Confusión: la distribución de predicciones correctas e incorrectas para cada clase. En lo personal, la “métrica” que mejor nos ayuda a entender si estamos por buen camino (o no).

Verdaderos Positivos (TP): 730
Verdaderos Negativos (TN): 675
Falsos Positivos (FP): 498
Falsos Negativos (FN): 506

En este caso el modelo tiene un número equilibrado de TP y TN, lo que indica que es capaz de predecir tanto “Y” como “N” de manera efectiva. Sin embargo, también tiene un número significativo de FP y FN, lo lo que significa que comete errores de predicción.

– Precisión (Precision): Para la clase 0 es 0.57 y para la clase 1 es 0.59. La precisión mide la proporción de predicciones positivas que son correctas. En este caso, para ambas clases, la precisión es cercana al 0.5, lo que indica que el modelo comete errores en la predicción de ambas clases.

– Recall: Para la clase 0 es 0.58 y para la clase 1 es 0.59. El recall (también conocido como sensibilidad) mide la proporción de casos positivos reales que el modelo ha predicho correctamente. En este caso, el modelo tiene un recall equilibrado para ambas clases, lo que significa que es capaz de identificar correctamente casos “N” y “Y”.

-F1-score: Para la clase 0 es 0.57 y para la clase 1 es 0.59. El F1-score es la puntuación que combina precisión y recall en una métrica única. En este caso, el F1-score es equilibrado para ambas clases.

En resumen, nuestro primer modelo muestra un rendimiento razonablemente equilibrado en la predicción de ambas clases (DONOR_IND “Y” y “N”), con una exactitud del 58.3%. Sin embargo, existen margen de mejora en términos de precisión y F1-score.

No está mal para el primer intento, pero no sirve para nuestro cometido. Tenemos ahora dos caminos posibles: la afinación de “hiperparámetros” (o fine tuning) o probar otros algoritmos. En este caso, vamos a tratar de “tunear” el nuestro Random forest.

Mejorando nuestro modelo

Aquí también hay varios caminos que tomar: podemos ir haciendo pruebas con diferentes parámetros como los n_estimators (representa el número de “árboles en el bosque”) o el min_samples_split (el número mínimo de muestras requeridas para dividir un nodo interno) de forma manual o bien le podemos pedir al modelo que lo haga automáticamente en forma aleatoria.

Esta técnica es útil porque permite explorar una variedad de configuraciones de “hiperparámetros” de manera rápida y confiable.

Siendo esta opción además más sencilla que la manual , empezamos por ahí:

from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import RandomizedSearchCV
import numpy as np

# Define el modelo de Random Forest
random_forest = RandomForestClassifier(random_state=42)

# Define una cuadrícula de hiperparámetros para buscar de manera aleatoria
param_dist = {
    'n_estimators': np.arange(100, 1000, 100),  # Número de árboles en el bosque
    'max_depth': [None] + list(np.arange(10, 50, 10)),  # Profundidad máxima de los árboles
    'min_samples_split': np.arange(2, 11),  # Número mínimo de muestras requeridas para dividir un nodo
    'min_samples_leaf': np.arange(1, 5)  # Número mínimo de muestras requeridas en una hoja
}

# Configura la búsqueda aleatoria con validación cruzada (CV)
random_search = RandomizedSearchCV(random_forest, param_distributions=param_dist, n_iter=10, cv=5, scoring='accuracy', n_jobs=-1, random_state=42)

# Realiza la búsqueda aleatoria en los datos de entrenamiento
random_search.fit(X_train, y_train)

# Obtiene los mejores hiperparámetros encontrados
best_params = random_search.best_params_

# Entrena un modelo con los mejores hiperparámetros en todos los datos de entrenamiento
best_random_forest = RandomForestClassifier(random_state=42, **best_params)
best_random_forest.fit(X_train, y_train)

# Evalúa el modelo ajustado en los datos de prueba
y_pred = best_random_forest.predict(X_test)
accuracy = accuracy_score(y_test, y_pred)

#Imprime los mejores hiperparámetros
print("Mejores hiperparámetros encontrados:")
print(best_params)

El resultado:

Mejores hiperparámetros encontrados:
{‘n_estimators’: 800, ‘min_samples_split’: 8, ‘min_samples_leaf’: 2, ‘max_depth’: 10}

Aplicamos entonces los mejores parámetros a nuestro modelo:

from sklearn.ensemble import RandomForestClassifier

# Define un nuevo modelo de Random Forest con los mejores hiperparámetros
best_random_forest = RandomForestClassifier(
    n_estimators=800,
    min_samples_split=8,
    min_samples_leaf=2,
    max_depth=10,
    random_state=42
)

# Entrena el modelo con tus datos de entrenamiento
best_random_forest.fit(X_train, y_train)

Probando nuestro modelo tuneado

Bien, llegó la hora de probar nuestro con casos que no haya “visto” aún. Para eso tenemos los 3.318 casos que separamos en su momento, en el dataset “validacion_df”. Si vemos la composición del mismo, se distribuye así:

Y 1.978
N 1.340

¿Cómo nos irá para encontrar esos casi dos mil donantes? Veamos:

# Carga el nuevo conjunto de datos
data_validacion = pd.read_csv("fundraising_df2_validacion.csv")
# Preprocesamiento de datos
# Similar al preprocesamiento que hicimos para el conjunto de entrenamiento.
X_validacion = data_validacion.drop('DONOR_IND', axis=1)
y_validacion = data_validacion['DONOR_IND']
y_validacion = y_validacion.map({'N': 0, 'Y': 1})

# Realiza predicciones en el conjunto de validación
y_pred_validacion = best_random_forest.predict(X_validacion)

# Agrega las predicciones como una columna "DONOR_IND_PRED" al conjunto de datos
data_validacion['DONOR_IND_PRED'] = y_pred_validacion

# Calcula la exactitud en el conjunto de validación
accuracy_validacion = accuracy_score(y_validacion, y_pred_validacion)

# Calcula el informe de clasificación en el conjunto de validación
report_validacion = classification_report(y_validacion, y_pred_validacion)


# Imprime la exactitud en el conjunto de validación
print("Exactitud en el conjunto de validación:", accuracy_validacion)

# Imprime el informe de clasificación en el conjunto de validación
print("Informe de Clasificación en el conjunto de validación:")
print(report_validacion)

# Calcula la matriz de confusión
conf_matrix = confusion_matrix(y_validacion, y_pred_validacion)
print("Matriz de Confusión:")
print(conf_matrix)

# Guarda el conjunto de datos con las predicciones
data_validacion.to_csv('fundraising_df2_validacion_con_predicciones.csv', index=False)

Si vamos al valor de la matriz, podemos ver lo siguiente:

Verdaderos Negativos (TN): 465
Falsos Positivos (FP): 875
Falsos Negativos (FN): 652
Verdaderos Positivos (TP): 1.326

La interpretación de la matriz depende del contexto del problema abordado. Seguimos teniendo un número grande de falsos positivos, pero estamos en el 67% de detección de los verdaderos positivos.

Es cierto, en general la idea es siempre minimizar los falsos positivos y falsos negativos para tener una clasificación más precisa. Pero recordemos que el ejercicio lo estamos haciendo en función de “escorear” nuevos contactos y/o de trabajar bases antiguas para encontrar “posibles” donantes (que aún no donaron).

Desde esta perspectiva, en lugar de hacer 3.318 llamados o contactos personalizados, nuestro equipo de donaciones o de marketing ahora está en capacidad de hacer 2.000 contactos directos (un 40% menos) y “concretar” más de 1.300 donaciones. No es un mundo perfecto, pero es mejor que antes.

Lo mismo pero en Google Colab

Si quieren trabajar el mismo set de datos, pero en el entorno de Google sin necesidad de instalar nada, aquí el enlace al proyecto.

Conclusión

En ambos ejercicios logramos un modelo predictivo que funciona bien en alrededor del 70% de los casos. ¿Se puede mejorar? Seguro. Pero dependerá mucho del tipo de datos que estemos manejando y el ajuste de parámetros finos.

A los efectos de este artículo, logramos nuestro cometido: enfrentarnos a un dataset con datos reales y encontrar una modelo predictivo que nos ayude a mejorar nuestra gestión de donantes. Lo que nos da una buena perspectiva para el uso de ML para fundraising.

En nuestro próximo artículo (el último de esta serie) veremos como crear una aplicación web en la cual nuestro equipo de gestión de donantes pueda, con los datos de un nuevo contacto y en tiempo real, obtener una predicción (sin necesidad de generar ningún código!).

Postdata: si tu organización recibe donaciones y necesita ayuda con Machine Learning hablemos y los oriento (el único requisito previo es tener una base de datos sobre la cual trabajar con un mínimo de 500 registros y una serie de variables socio-demográficas que nos puedan servir de predictores).

Te puede interesar