Optimizando Donaciones con Machine Learning

Donaciones y aprendizaje au

¿Es posible predecir donaciones? La respuesta corta es “Sí! es posible predecir donaciones utilizando Machine Learning”. Machine Learning (ML) es una técnica que permite a las organizaciones analizar datos históricos y patrones para hacer predicciones sobre eventos futuros, como donaciones.

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í).

¿Para qué sirven estos tipos de análisis predictivos?

Partiendo de la base de que los recursos de las ONGs son escasos y debemos optimizarlos al máximo, se me ocurren dos escenarios:

  1. Remarketing: supongamos que tenemos una lista de contactos históricos, que no han donado. Una forma de hacer una campaña sobre esa base es utilizar “fuerza bruta”: le mandamos un mail masivo a la lista y esperamos a ver que pasa.
    Ahora bien, supongamos que esa lista tiene 5.000 contactos y que podemos identificar a 300 como “potenciales donantes” con algún grado de certeza. Podríamos hacer una campaña de email marketing más precisa y sin afectar la reputación de nuestra dirección de correo electrónico. Es más, con un conjunto reducido, podríamos intentar una comunicación directa por teléfono o mensajería.
  2. Scoring: ¿y si pudiéramos identificar, en tiempo real, si un nuevo contacto tiene altas posibilidades de ser donante? En tal caso podríamos separar flujos de trabajo entre los leads más calificados y los menos calificados: contactos personalizado para los primeros (llamadas), contacto masivos para los segundos (automatizaciones de email).

En ambos casos, la intención es dotar a nuestro equipo de recaudación de fondos de las mejores oportunidades para lograr los objetivos de fundraising.

Un poco de contexto

Para trabajar con ML necesitamos una receta e ingredientes.

Lo ingredientes: el activo más importante de nuestra organización es nuestra base de datos (los lectores frecuentes saben que es un mantra recurrente de esta web). Para hacer predicciones necesitamos basarnos en datos pasados y en sus características (cuanto más datos y más características, mejor).

Si nuestra organización aún no está recolectado datos de manera ordenada y/o solo tiene unos pocos no es motivo de desazón: solo tenemos que comenzar o seguir hasta generar los necesarios.

Si ya tenemos los datos, ahora vamos a las recetas: para eso vamos a usar una galería de Python llamada Sklearn que posee una serie de modelos de predicción.

Para procesar nuestra receta (la batidora y el horno) vamos a usar Jupiter Notebook o Colab (de acuerdo a si queremos correr nuestras tareas localmente o en la nube). Para aquellos familiarizados con Python sugerimos la primera, para los que no la segunda (ambas son gratuitas).

Por último: para este tutorial vamos a usar dos bases de datos. Una “fake” de 600 registros, que generamos especialmente para esta prueba y uno real, que se puede descargar de la web de Kaggle.

Los análisis que vamos a realizar se adaptarán a estos datasets, pero mostramos dos puntos de partida bien distintos con la idea de que luego ustedes puedan adaptar el proceso a las bases de datos de sus organizaciones.

Pasos para resolver problemas de clasificación con ML

Predecir donaciones es un problema de clasificación (¿será o no será donante?). Para resolver esta clase de problemas, debemos seguir los siguientes pasos generales:

Preparación de los Datos:

  1. Importar las bibliotecas necesarias, como pandas y scikit-learn.
  2. Cargar el conjunto de datos en un DataFrame de pandas.
  3. Realizar la codificación de variables categóricas utilizando técnicas como one-hot encoding o label encoding.
  4. Dividir el conjunto de datos en características (X) y la variable objetivo (y), donde X contiene todas las columnas excepto “donante” y y contiene la columna “donante”.
  5. Dividir el conjunto de datos en conjuntos de entrenamiento y prueba para evaluar el modelo.

Manejo de datos desbalanceados

Dado que el conjunto de datos no está balanceado (normalmente hay más “NO donantes” que “SI donantes”), se pueden considerar técnicas de remuestreo como oversampling (aumentar la cantidad de muestras de la clase minoritaria) o undersampling (reducir la cantidad de muestras de la clase mayoritaria) para abordar este desequilibrio. Volveremos sobre el tema.

Selección de Modelo:

Un buen punto de partida es utilizar algoritmos comunes como Random Forest, Gradient Boosting, o Support Vector Machines (SVM).
La idea es “jugar” con varios modelos y ajusta los parámetros hasta encontrar el mejor rendimiento.

Entrenamiento del Modelo:

Entrenar el modelo de clasificación en el conjunto de datos de entrenamiento utilizando las funciones de scikit-learn.

Evaluación del Modelo:

Evaluar el rendimiento del modelo en el conjunto de prueba utilizando métricas como accuracy, precision, recall, F1-score y confusion matrix.

Predicciones en Nuevos Registros:

Una vez que tengamos un modelo entrenado y evaluado, se puede utilizar para hacer predicciones sobre nuevos registros (importante: hay preprocesar los nuevos datos de la misma manera que se hizo con el conjunto de entrenamiento).

Manos a la obra

Empezamos con nuestro primer dataset “Data Donantes Fake Original” (pueden hacer una copia de la hoja de cálculos para utlizarla). Como una buena base de datos inventada, es casi ideal: tiene 600 registros, con 9 columnas, con todos sus valores llenos.

Para el análisis vamos a dividir ese base, de tal manera de trabajar con una parte para generar el modelo predictivo (500 registros) y otra que nos servirá para poner a prueba el modelo creado (100 registros). La diferencia con de estos con el archivo original es que sacamos la columna “monto de la donación”. Por un lado no tenemos suficientes datos como para predecirla. Y no la podemos dejar porque estamos tratando de predecir si alguien va a donar o no, sumándola al modelo agregamos una categoría que no vamos a tener en los datos que queramos predecir a futuro.

Comenzamos con la opción de Jupiter Notebook (para los que no estén familiarizados con la herramienta, al final del artículo dejaremos el link al Colab).

Como siempre que trabajamos con datos en Python, vamos a cargar las librerías que necesitamos, nuestra base de datos y hacer una revisión rápida. El código:

import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import OneHotEncoder
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, confusion_matrix

# Cargar el dataset con la ruta del archivo en su computadora
data = pd.read_csv("data_donantes_fake_para_entrenar.csv")
data.head()

Si todo sale bien, deberían ver esto:

¿Qué sabemos de estos datos? Este un dataset sobre donantes ficticios a una ONG. Tiene 500 registros, sin valores NULL (están todas las celdas completas con datos válidos). Las propiedades son:

  • Fuente de tráfico: Facebook, Google, Twitter, etc.
  • Medio de trafico: CPC, banner, orggánico, etc.
  • Sexo: Masculino o Femenino.
  • Edad: valor numérico del 18 al 99.
  • Provincia: nombre de la provincia argentina de residencia.
  • Estado Civil: Casado o Soltero.
  • Nivel de Estudios: Secundario, Terciario, Universitario.
  • Donante: SI, NO (de acuerdo si ha donado o no).
  • Monto Donación: valor numérico.

Primera cuestión a tener en cuenta. Los modelos de ML no se llevan bien con valores expresados en formato texto, con lo cual lo primero que debemos hacer codificar esas variables y llevarlas a números.

Por suerte el paquete de sklearn que cargamos previamente tiene esa función integrada con el “OneHotEncoder”:

# Codificar variables categóricas
encoder = OneHotEncoder()
categorical_cols = ["fuente de tráfico", "medio de tráfico", "sexo", "provincia", "estado civil", "nivel de estudios"]
encoded_data = encoder.fit_transform(data[categorical_cols])

# Convertir la matriz dispersa en un DataFrame
encoded_data_df = pd.DataFrame(encoded_data.toarray(), columns=encoder.get_feature_names_out(input_features=categorical_cols))

# Combinar datos codificados con las características numéricas (excluyendo "monto donación")
X = pd.concat([encoded_data_df, data[["edad"]]], axis=1)
y = data["donante"]

Lo que hicimos fue tomar todas los valores de las columnas de texto (“categorical_cols”), eliminar la columna “monto donación” y transformarlos hasta obtener una nueva base de datos codificada: “encoded_data_df”. Hay muchas maneras de codificar categorías, veremos otros ejemplos más adelante. Pero por ahora, si queremos ver como quedó nuestra data llamamos a la nueva base:

encoded_data_df.head()

Y deberíamos ver algo similar a esto:

Un punto a tener en cuenta es que el dataset no está balanceado (hay más personas que no donaron que las que si donaron, en proporción 1 a 4) lo cual puede afectar nuestra capacidad de predicción. Esto suele ser un problema común y existen diversas maneras de abordarlo. Pero por el momento, sigamos sin alterar la información que tenemos disponible.

Ahora sí, podemos cargar nuestras líneas de código para generar un primer modelo, en este caso usando la alternativa de “Random Forest” (un algoritmo clásico de para problemas de clasificación y regresión):

# Dividir el conjunto de datos en entrenamiento y prueba
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

# Crear y entrenar un modelo (por ejemplo, Random Forest)
model = RandomForestClassifier()
model.fit(X_train, y_train)

# Realizar predicciones en el conjunto de prueba
y_pred = model.predict(X_test)

# Evaluar el modelo
accuracy = accuracy_score(y_test, y_pred)
precision = precision_score(y_test, y_pred, pos_label="SI")
recall = recall_score(y_test, y_pred, pos_label="SI")
f1 = f1_score(y_test, y_pred, pos_label="SI")
confusion = confusion_matrix(y_test, y_pred)

print("Accuracy:", accuracy)
print("Precision:", precision)
print("Recall:", recall)
print("F1-score:", f1)
print("Matriz de Confusión:")
print(confusion)

Lo que estamos haciendo aquí es lo básico para generar un modelo: dividimos nuestros registros para entrenar y verificar el modelo, proponemos el modelo que vamos a usar, corremos el proceso y evaluamos los resultados del modelo con una serie de valores estándar.

Las métricas de nuestra primer prueba son:

  • Accuracy (Exactitud): 0.83
  • Precision (Precisión): 0.692
  • Recall (Recuperación o Sensibilidad): 0.409
  • F1-score: 0.514
  • Matriz de Confusión:
    Verdaderos Positivos (TP): 9
    Falsos Positivos (FP): 4
    Verdaderos Negativos (TN): 74
    Falsos Negativos (FN): 13

Antes que nada, repasamos estos conceptos porque son centrales en nuestro trabajo de generar buenos modelos predictivos (expoiler: no hay modelos perfectos).

Accuracy (Exactitud): Mide la proporción de predicciones correctas sobre el total de predicciones. Es una medida general de qué tan bien funciona un modelo. El problema de quedarnos solo con este valor, en los casos de datasets no balanceados (como el nuestro) es que puede tener una buena exactitud (más del 70%), diciendo que todos son casos negativos, por ejemplo.

Por eso necesitamos el resto de los valores:

Precision (Precisión): Indica la proporción de predicciones positivas que son verdaderamente positivas. Mide la capacidad de un modelo para evitar falsos positivos.

Recall (Recuperación o Sensibilidad): Mide la proporción de ejemplos positivos que fueron correctamente identificados por el modelo. Es útil para evaluar cuántos de los casos positivos se capturan realmente.

F1-score (Puntuación F1): Es una métrica que combina precision y recall en una sola puntuación. Es útil cuando se necesita equilibrar la importancia de ambas métricas (que es más grave, que se nos escapen verdaderos positivos o que nos sume falsos positivos?).

Matriz de Confusión: Una tabla que muestra cómo un modelo de Machine Learning clasifica las predicciones en función de si son verdaderas positivas, verdaderas negativas, falsas positivas o falsas negativas. Ayuda a evaluar el rendimiento del modelo de manera más detallada.

Ahora sí, a partir de estas definiciones y sus valores, cómo nos fue:

Lo bueno:

  • Con un valor de 0.83, la exactitud es bastante alta, lo que sugiere que el modelo tiene un buen rendimiento general en la clasificación.
  • Con un valor de 0.692, la precisión indica que alrededor del 69.2% de las predicciones positivas del modelo son correctas. Es un buen comienzo, especialmente si consideramos que minimizar los falsos positivos es importante.

Lo no tan bueno:

  • Con un valor de 0.409, el recall indica que el modelo identifica correctamente el 40.9% de todas las muestras positivas en el conjunto de datos. Este es el punto más flojo de nuestro modelo, ya que sugiere que estamos perdiendo algunos casos positivos.
  • Un F1-score de 0.514 indica un rendimiento moderado en general.

Por último, la “matriz de confusión” muestra la distribución de las predicciones del modelo. En este caso, hay 9 verdaderos positivos (casos positivos correctamente clasificados), 4 falsos positivos (casos negativos incorrectamente clasificados como positivos), 74 verdaderos negativos (casos negativos correctamente clasificados) y 13 falsos negativos (casos positivos incorrectamente clasificados como negativos).

Primeras conclusiones: en general, el modelo parece tener un buen rendimiento en términos de Precisión, pero hay margen para mejorar el Recall.

La elección entre precisión y recall depende de las implicaciones de los errores en la aplicación específica del modelo. Si es más crítico evitar falsos positivos, entonces la precisión actual es adecuada. Si queremos identificar la mayoría de los casos positivos, entonces el recall necesita mejorarse, posiblemente ajustando el umbral de decisión del modelo o considerando otros algoritmos o técnicas de ajuste.

Haciendo predicciones con nuevos datos

Aunque los resultados no son ideales, vamos a dar por válida nuestra primera prueba. No queda ahora entender cómo aplicar este modelo a “nuevos datos”, para que haga sus predicciones (la planilla “Data Donantes Fake Para Observar”).

Lo importante aquí a tener presente es que debemos correr de nuevo el proceso de codificación, para que el modelo pueda trabajar con los datos.

Para probar tu modelo en los “100 casos nuevos”, tenemos que seguir estos pasos:

Preparación de los Nuevos Datos:
Esto significa que debemos tener las mismas características (variables) y la misma estructura de datos.

Preprocesamiento de los Nuevos Datos:
Aplicar el mismo preprocesamiento que realizamos en los datos de entrenamiento y prueba al conjunto de datos de los 100 casos nuevos.

Predicciones con el Modelo:
Podemos hacerlo utilizando la función model.predict(nuevos_datos), donde “nuevos_datos” es el conjunto de datos preprocesados de los 100 casos nuevos.

En código:

import pandas as pd
from sklearn.preprocessing import OneHotEncoder

# Cargar los nuevos datos desde el archivo CSV
nuevos_datos = pd.read_csv("data_donantes_fake_para_observar.csv")

# Verificar que los datos tengan la misma estructura que el conjunto de entrenamiento y prueba

# Aplicar la codificación one-hot a las variables categóricas de los nuevos datos
encoded_data = encoder.transform(nuevos_datos[categorical_cols])

# Convertir la matriz dispersa en un DataFrame
encoded_data_df = pd.DataFrame(encoded_data.toarray(), columns=encoder.get_feature_names_out(input_features=categorical_cols))

# Combinar datos codificados con las características numéricas (excluyendo "monto donación")
nuevos_datos_preprocesados = pd.concat([encoded_data_df, nuevos_datos[["edad"]]], axis=1)

Ahora tenemos `nuevos_datos_preprocesados` listos para hacer predicciones con el modelo que entrenamos. Entonces agregamos la columna donde vamos a volcar las predicciones a nuestro dataset y las hacemos:

# Usamos la columna "donante" en nuevos_datos
etiquetas_verdaderas = nuevos_datos["donante"].values  # Obtener los valores de la columna "donante"
# Realizamos predicciones con el modelo
predicciones_nuevos = model.predict(nuevos_datos_preprocesados)

# Calculamos las métricas de rendimiento (por ejemplo, precisión) si tienes etiquetas verdaderas
etiquetas_verdaderas = etiquetas_verdaderas  # Reemplaza con las etiquetas reales
precision_nuevos = precision_score(etiquetas_verdaderas, predicciones_nuevos, pos_label="SI")

# Imprimimos las predicciones y métricas
print("Predicciones para los 100 casos nuevos:", predicciones_nuevos)
print("Precisión en los 100 casos nuevos:", precision_nuevos)

Si todo va de acuerdo a lo planeado, debemos ver lo siguiente:

Ahora podemos agregar la columna de predicciones a nuestra base original, para comparar:

# Agregar una nueva columna para los valores de 'DONANTE' previstos
nuevos_datos['PREDICCION'] = predicciones_nuevos

Si hacemos el cruce, vamos a ver que el modelo detectó, de los 11 donantes verdaderos… solo 2. Decepcionante.

De todas formas, es lo habitual. Muchas veces nos vamos a encontrar en esta situación y tendremos que probar distintos modelos y ajustes para lograr predicciones adecuadas.

Para ahorra tiempo, les comparto la mejor solución que encontré luego de varias pruebas.

Primero, vamos a probar otro tipo de modelo, el “GradientBoostingClassifier” . Segundo, vamos a sumar una función de “oversampling” para tratar de balancear un poco las clases (generar datos extras sobre la base que tenemos de tal manera que los casos de donantes “SI” aumenten ).

Como ya estamos familiarizados con el código, va todo junto:

import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import OneHotEncoder
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, confusion_matrix
from imblearn.over_sampling import RandomOverSampler  # Importa RandomOverSampler

# Cargar el dataset
data = pd.read_csv("data_donantes_fake_para_entrenar.csv")

# Codificar variables categóricas
encoder = OneHotEncoder()
categorical_cols = ["fuente de tráfico", "medio de tráfico", "sexo", "provincia", "estado civil", "nivel de estudios"]
encoded_data = encoder.fit_transform(data[categorical_cols])

# Convertir la matriz dispersa en un DataFrame
encoded_data_df = pd.DataFrame(encoded_data.toarray(), columns=encoder.get_feature_names_out(input_features=categorical_cols))

# Combinar datos codificados con las características numéricas (excluyendo "monto donación")
X = pd.concat([encoded_data_df, data[["edad"]]], axis=1)
y = data["donante"]

# Aplica Random Oversampling para abordar el desbalance
ros = RandomOverSampler(random_state=42)
X_resampled, y_resampled = ros.fit_resample(X, y)

# Dividir el conjunto de datos balanceado en 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)

# Crear y entrenar un modelo Gradient Boosting
model = GradientBoostingClassifier()
model.fit(X_train, y_train)

# Realizar predicciones en el conjunto de prueba
y_pred = model.predict(X_test)

# Evaluar el modelo
accuracy = accuracy_score(y_test, y_pred)
precision = precision_score(y_test, y_pred, pos_label="SI")
recall = recall_score(y_test, y_pred, pos_label="SI")
f1 = f1_score(y_test, y_pred, pos_label="SI")
confusion = confusion_matrix(y_test, y_pred)

print("Accuracy:", accuracy)
print("Precision:", precision)
print("Recall:", recall)
print("F1-score:", f1)
print("Matriz de Confusión:")
print(confusion)

Esta vez los resultados son:

En líneas generales nuestro modelo mejoró mucho en todas sus evaluaciones, pero ¿Qué pasa si lo cruzamos con la base de nuevos datos?

Usamos una nueva variable para el nuevo modelo “y_pred_nuevos”:

y_pred_nuevos = model.predict(nuevos_datos_preprocesados)

Y volvemos a hacer el cruce:

# Calcular métricas de rendimiento (por ejemplo, precisión) sobre las etiquetas verdaderas
etiquetas_verdaderas = etiquetas_verdaderas  # Reemplaza con las etiquetas reales
precision_nuevos = precision_score(etiquetas_verdaderas, y_pred_nuevos, pos_label="SI")

# Imprimir las predicciones y métricas
print("Predicciones para los 100 casos nuevos:", y_pred_nuevos)
print("Precisión en los 100 casos nuevos:", precision_nuevos)

from sklearn.metrics import confusion_matrix
confusion = confusion_matrix(etiquetas_verdaderas, y_pred_nuevos)
print("Matriz de Confusión:")
print(confusion)

Nuestra nueva “matriz de confusión” arroja estos resultados (recordemos que en este dataset tenemos 11 casos positivos a detectar):

  • 71 representa los verdaderos negativos (TN), que son las muestras que fueron correctamente clasificadas como negativas.
  • 20 representa los falsos positivos (FP), es decir, las muestras que fueron incorrectamente clasificadas como positivas.
  • 2 representa los falsos negativos (FN), que son las muestras que fueron incorrectamente clasificadas como negativas.
  • 9 representa los verdaderos positivos (TP), que son las muestras que fueron correctamente clasificadas como positivas.

Nuestro nuevo modelo muestra un buen rendimiento en términos de verdaderos negativos (TN) y verdaderos positivos (TP), lo que indica que está clasificando correctamente tanto las muestras negativas como las positivas. Sin embargo, tiene un número relativamente alto de falsos positivos (FP) en comparación con los falsos negativos (FN), lo que puede sugerir una tendencia a sobreestimar la clase positiva.

En resumen: hemos encontrado un algoritmo de predicción que nos puede resultar de utilidad. De un nuevo conjunto de 100 registros, hemos sido capaces de separar un conjunto de 29 registros (el 29% de lo originales) en dónde identificamos correctamente el 81% de los donantes potenciales (9 sobre 11). Mucho mejor!

Ahora sólo nos queda agregar la columna de la predicción al dataset de nuestros 100 casos nuevos:

# Agreguamos una nueva columna para los valores de 'DONANTE' previstos
nuevos_datos['PREDICCION'] = y_pred_nuevos

Y exportarlo a un csv para que lo pueda usar nuestro equipo de marketing:

nuevos_datos.to_csv('prediction-dataset.csv', encoding = 'utf-8-sig') 

Filtrando la los valores “SI” de la columna “PREDICCION”, tenemos que poder ver algo así:

Lo mismo pero en Google Colab

Si no están familiarizados con Jupiter y/o quieren hacer todo el proceso en la nube, aquí está el notebook en Colab para que puedan copiar.

Lo único a tener en cuenta es que deberán subir los archivos a su Drive, y reemplazar la ruta de los mismos en cada llamada.

Conclusión

Con cierta facilidad y cero costos hemos llegado a un modelo de predictivo que nos puede ayudar en nuestra gestión diaria de donantes. Con lo cual el primer aprendizaje es que hay un amplio campo de colaboración entre el aprendizaje automático y las organizaciones cuyo financiamiento depende (aunque sea en parte) de donaciones.

Una objeción posible es que este modelo funcionó pero con una base de datos inventada, en condiciones ideales. Es cierto.

Por eso este es el primero de tres artículos. El segundo es sobre un dataset real, enfrentando todos los problemas de los dataset reales.

La otra objeción posible es: todo muy lindo, pero se necesitan conocimientos de Python para poder hacer esto y el equipo que lo necesita es el de marketing…

Por eso el tercer artículo de la serie será sobre cómo implementar un modelo aprendizaje automático que hayamos probado efectivo, en un entorno web desde el cual un usuario sin conocimientos técnicos pueda generar una predicción en tiempo real (por ejemplo, el equipo de marketing!)

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