https://github.com/johnny-godoy/laboratorios-mds/blob/main/lab%206/laboratorio_6.ipynb
¶pandas
con respecto a trabajar en Python 'puro'.El laboratorio deberá ser desarrollado sin el uso indiscriminado de iteradores nativos de python (aka "for", "while"). La idea es que aprendan a exprimir al máximo las funciones optimizadas que nos entrega numpy
, las cuales vale mencionar, son bastante más eficientes que los iteradores nativos sobre arreglos (o tensores).
import datetime
import numpy as np
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.cluster import KMeans
from sklearn.compose import ColumnTransformer
from sklearn.exceptions import NotFittedError
from sklearn.impute import SimpleImputer
from sklearn.manifold import TSNE
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import FunctionTransformer
from IPython.display import HTML
Mr. Lepin, en una nueva reunión, le cuenta a ud y su equipo que los resultados derivados del análisis exploratorio de dato presentaron una gran utilidad para la empresa y que tiene un gran entusiasmo por continuar trabajando con ustedes. Es por esto, que Mr. Lepin les pide que cargue y visualicen algunas de las filas que componen el Dataset. A continuación un extracto de lo parlamentado en la reunión:
- Usted: Es un gran logro para nuestro equipo que usted haya encontrado excelente el EDA. ¿Qué tiene en mente ahora?
- Mr. Lepin: Resulta que hace algún tiempo, mientras tomaba un mojito en una reunión de gerentes en Panamá, oí a un *chato* acerca de **LRMFP**, que es un modelo que permite personificar a los clientes a través de la farbicación de distintos atributos que describen a los clientes. Lo encontré es-tu-pendo ñatito.
- Usted: Ehh bueno. Investigaremos acerca de este modelo y veremos lo que podemos hacer.
Por ende, su siguiente tarea es calcular LRMFP sobre cada cliente y luego hacer un análisis de las características generadas. Para esto, el área de ventas les entrega un nuevo archivo llamado online_retail_II_cleaned.pickle
, quien posee los datos del DataFrame original limpios y listos para obtener las características solicitadas por Mr. Lepin.
df_retail = pd.read_pickle("data/online_retail_II_cleaned.pickle")
df_retail = df_retail.astype(
{
"Invoice": "category",
"StockCode": "category",
"Description": "category",
"Description": str,
"Customer ID": "category",
"Country": "category"
}
)
df_retail.head()
Invoice | StockCode | Description | Quantity | InvoiceDate | Price | Customer ID | Country | |
---|---|---|---|---|---|---|---|---|
0 | 489434 | 85048 | 15CM CHRISTMAS GLASS BALL 20 LIGHTS | 12 | 2009-12-01 07:45:00 | 6.95 | 13085.0 | United Kingdom |
1 | 489434 | 79323P | PINK CHERRY LIGHTS | 12 | 2009-12-01 07:45:00 | 6.75 | 13085.0 | United Kingdom |
2 | 489434 | 79323W | WHITE CHERRY LIGHTS | 12 | 2009-12-01 07:45:00 | 6.75 | 13085.0 | United Kingdom |
3 | 489434 | 22041 | RECORD FRAME 7" SINGLE SIZE | 48 | 2009-12-01 07:45:00 | 2.10 | 13085.0 | United Kingdom |
4 | 489434 | 21232 | STRAWBERRY CERAMIC TRINKET BOX | 24 | 2009-12-01 07:45:00 | 1.25 | 13085.0 | United Kingdom |
Como ya se les comento, Mr. Lepin esta interesado en obtener las características LRMFP, para esto les señala que estas características se construyen en base a las siguientes definiciones:
Recency (R): Indica la actualidad de la interacción de un cliente con la empresa, y da información sobre la tendencia a repetir la compra. Se define como: $$Recency(n)=\dfrac{1}{n} \sum^n_{i=1} date\_diff(t_{fecha final}, t_{m-i+1})$$
Donde $date\_diff$ representa la diferencia en días entre la fecha de finalización del periodo de observación ($t_{fecha final}$), y la fecha de una visita del cliente cercana a $t_{fecha final}$, $t_{m-i+1}; t_{m}$ es la última visita del cliente; y n es el número de visitas recientes del cliente consideradas.
Monetary (M): El término "monetario" se refiere a la cantidad media de dinero gastada por cada visita del cliente durante el período de observación y refleja la contribución del cliente a los ingresos de la empresa.
Frequency (F): Se refiere al número total de visitas del cliente durante el periodo de observación. Cuanto mayor sea la frecuencia, mayor será la fidelidad del cliente.
Periodicity (P): Representa si los clientes visitan las tiendas con regularidad.
Donde $IVT$ denota el tiempo entre visitas y n representa el número de valores de tiempo entre visitas de un cliente.
$$IVT_i=date\_diff(t_{i+1},t)$$En base a las definiciones señaladas, diseñe una función que permita obtener las características LRMFP recibiendo un DataFrame como entrada. Para esto, no estará permitido el uso de iteradores, utilice todas las herramientas que les ofrece pandas
para realizar esto.
Una referencia que le puede ser útil es el documento original en donde se propone este método.
Nota: Para la $fechafinal$ utilice la fecha máxima del dataset más 1 día.
Ejemplo de Resultado Esperado:
Customer ID | Length | Recency | Frequency | Monetary | Periodicity |
---|---|---|---|---|---|
12346.0 | 294 | 67 | 46 | -64.68 | 37.0 |
12347.0 | 37 | 3 | 71 | 1323.32 | 0.0 |
12349.0 | 327 | 43 | 107 | 2646.99 | 78.0 |
12352.0 | 16 | 11 | 18 | 343.80 | 0.0 |
12356.0 | 44 | 16 | 84 | 3562.25 | 12.0 |
Respuesta:
def custom_features(dataframe_in: pd.DataFrame, m: int = None) -> pd.DataFrame:
"""Retorna un frame con las características del modelo LRMFP.
Parametros
----------
dataframe_in: pd.DataFrame
frame con los datos de retail.
m : int, optional
Número de visitas consideradas para calcular recencia.
Si es None, entonces se calcula con el máximo entre 1 y
la frecuencia mínima.
Retorna
-------
df_out: pd.DataFrame
frame con las características LRMFP."""
dataframe_copy = dataframe_in[["Customer ID", "InvoiceDate"]].copy()
dataframe_copy["prod"] = dataframe_in.Price*dataframe_in.Quantity
df_out = pd.DataFrame(index=dataframe_in["Customer ID"].unique().sort_values())
by_customer = dataframe_copy.sort_values(by="InvoiceDate").groupby("Customer ID")
dates = by_customer.InvoiceDate
df_out["Length"] = (dates.max() - dates.min()).dt.days
freq = dates.count()
if m is None:
m = max(1, freq.min())
last_date = df_retail.InvoiceDate.max() + datetime.timedelta(days=1)
df_out["Recency"] = (last_date - by_customer.tail(m).groupby("Customer ID").InvoiceDate.mean()).dt.days
df_out["Frecuency"] = freq
df_out["Monetary"] = by_customer["prod"].mean()
dataframe_copy["diff_time"] = dates.diff().dt.days
df_out["Periodicity"] = dataframe_copy.groupby("Customer ID").diff_time.std()
return df_out
custom_features(df_retail)
Length | Recency | Frecuency | Monetary | Periodicity | |
---|---|---|---|---|---|
12346.0 | 196 | 165 | 33 | 11.298788 | 21.724076 |
12347.0 | 37 | 3 | 71 | 18.638310 | 4.422346 |
12348.0 | 0 | 74 | 20 | 11.108000 | 0.000000 |
12349.0 | 181 | 43 | 102 | 26.187647 | 16.200990 |
12351.0 | 0 | 11 | 21 | 14.330000 | 0.000000 |
... | ... | ... | ... | ... | ... |
18283.0 | 275 | 18 | 217 | 2.854240 | 11.783701 |
18284.0 | 0 | 67 | 28 | 16.488571 | 0.000000 |
18285.0 | 0 | 296 | 12 | 35.583333 | 0.000000 |
18286.0 | 247 | 112 | 67 | 19.349701 | 30.403598 |
18287.0 | 188 | 18 | 85 | 27.596588 | 15.299909 |
4314 rows × 5 columns
Finalmente Don Mora le pregunta si seria posible realizar un pipeline para realizar una segmentación de los clientes con los nuevos datos generados, a lo que usted responde que sí y propone la utilización de k-means para la segmentación.
A continuación siga los pasos requeridos para obtener la segmentación de clientes.
Construya una clase llamada MinMax()
utilizando BaseEstimator
y TransformerMixin
para realizar una transformación de cada una de las columnas de un DataFrame utilizando ColumnTransformer()
más tarde (tome como referencia el siguiente enlace).
Para esto considere que Min-Max escaler queda dada por la ecuación:
$$MinMax = \dfrac{x-min(x)}{max(x) - min(x)}$$Con esto buscamos que los valores que componen a las columnas se muevan en el rango de valores $[0, 1]$.
Respuesta:
class MinMax(BaseEstimator, TransformerMixin):
"""Escala y transforma cada característica tal que quede en el rango [0, 1]
en el conjunto de entrenamiento. Si una feature es constante, se vuelve nula."""
def fit(self, X, y=None):
"""Calcula el mínimo y máximo para ser usado en escalamiento.
Parametros
----------
X: array-like de forma (n_muestras, n_características)
Los datos usados para calcular el mínimo y máximo por característica
que se usan para escalar.
y: None
Ignorado, por compatibilidad.
Retorna
-------
self: MinMax
El escalador entrenado."""
self.min_ = np.nanmin(X, axis=0)
self.denominator_ = np.nanmax(X, axis=0) - self.min_
self.denominator_[self.denominator_ == 0.] = 1. # Evitando dividir por 0
return self
def transform(self, X):
"""Escala las características de X según el mínimo y máximo encontrado.
Parametros
----------
X: array-like de forma (n_muestras, n_características)
Datos de entrada a ser transformados.
Retorna
-------
Xt: array-like de forma (n_muestras, n_características)
Datos transformados.
Levanta
-------
NotFittedError: Si es que el modelo no ha llamado al método fit antes."""
try:
Xt = (X - self.min_)/self.denominator_
return Xt
except AttributeError:
raise NotFittedError("Esta instancia de MinMax no está entrenada todavía. "
"Llame a `fit` con los argumentos apropiados antes de usar este transformador")
T-SNE
Pipeline [1.0 puntos]¶Para comenzar introduciéndose en el uso de pipeline, decide probar realizando un pipeline enfocado en la reducción de dimensionalidad y así hacer no decepcionar a Mr. Lepin con la clusterización del modelo.
Configure un pipeline utilizando el algoritmo T-SNE
sobre los datos LRMFP, donde, para la realización del pipeline considera los siguientes pasos:
df_retail_II_cleaned.pickle
utilizando la función custom_features
creada anteriormente, junto a FunctionTransformer()
. Considere esto como el primer paso de su pipeline.ColumnTransformer()
aplique el MinxMax scaler creado por usted sobre todas las columnas generadas en el paso anterior. Tras aplicar las transformaciones sobre el dataset LRMFP, gráfique las componentes obtenidas en la reducción de dimensionalidad.
Aplicando la transformaciones
preprocessing = Pipeline(steps=[("feature_transformer", FunctionTransformer(custom_features)),
("imputer", SimpleImputer(strategy="mean")),
("scaler", MinMax()), # No se necesita ColumnTransformer pues todo se escala
],
)
dimensionality_reduction = Pipeline(steps=[("preprocessing", preprocessing),
("reducer", TSNE(n_components=2, random_state=0,
learning_rate="auto", init="random",
)
),
],
)
X_reduced = pd.DataFrame(dimensionality_reduction.fit_transform(df_retail), columns=["x", "y"])
X_reduced
x | y | |
---|---|---|
0 | 8.378868 | -42.816170 |
1 | -1.798583 | 21.489256 |
2 | -71.841568 | 8.571382 |
3 | 21.666998 | -18.503113 |
4 | -9.306869 | 39.164833 |
... | ... | ... |
4309 | 53.665306 | -13.795135 |
4310 | -68.078026 | 20.554010 |
4311 | -33.021099 | 52.639996 |
4312 | 24.813828 | -38.750008 |
4313 | 21.537745 | -6.155716 |
4314 rows × 2 columns
Visualizando el espacio de dimensión reducida
fig = px.scatter(X_reduced, x="x", y="y")
fig.update_layout(title="Espacio de dimensión reducida con T-SNE")
Utilizando la clase creada para escalamiento, aplique el método del codo para visualizar cual es el número de clusters que mejor se ajustan a los datos. Realice esto utilizando el algoritmo K-means dentro de un pipeline para un $k \in [1,20]$, donde k representa el número de clusters del k-means. Para la realización de esta sección y la próxima (1.3.3.2), considere los mismos pasos utilizados para el t-sne, pero permutando el algoritmo de reducción de dimensionalidad por k-means.
A través del grafico obtenido, comente y justifique que valor de k escogería para realizar el k-means.
Creando el pipeline de KMeans
def process_k_means(n_clusters: int) -> Pipeline:
"""Crea un pipeline con el mismo preprocesamiento anterior,
agregando un paso de clustering KMeans
Parametros
----------
n_clusters: int
Cantidad de clusters que busca KMeans
Retorna
-------
clst: Pipeline
Pipeline de clustering."""
clst = Pipeline(steps=[("preprocessing", preprocessing),
("cluster", KMeans(n_clusters=n_clusters, random_state=0)),
],
).fit(df_retail)
return clst
Calculando las inercias
kvals = np.arange(1, 20)
clustering_models = [process_k_means(k) for k in kvals]
inertias = [clst.named_steps["cluster"].inertia_ for clst in clustering_models]
Visualizando el cambio de inercia.
fig = px.line(x=kvals, y=inertias)
fig.update_layout(title="Método del codo para determinar cantidad de clusters",
xaxis_title="Cantidad de clusters", yaxis_title="Inercia")
fig.show()
Se elige como valor óptimo $k=3$, pues a partir de este punto los beneficios ganados por aumentar los cluster disminuyen considerablemente (la inercia cae en 60, mientras que la reducción anterior fue de 150).
optimal_k = 3
En base a la elección de k realizada en la sección anterior, utilice este valor escogido y entrene un modelo de K-means utilizando el mismo pipeline de scikit-learn utilizado anteriormente.
Una vez ajustado los datos, genere una tabla con los promedios (o medianas) para cada uno de los atributos, agrupando estos por el clúster que pertenecen. ¿Es posible observar agrupaciones coherentes?, ¿Qué tipo de clientes posee el retail?, Justifique su respuesta y no decepcione a Mr. Lepin.
Respuesta:
Generando agrupaciones por cluster
optimal_model = clustering_models[optimal_k - 1]
df_clusters = custom_features(df_retail)
labels = optimal_model.named_steps["cluster"].labels_
df_clusters["Cluster"] = labels
by_cluster = df_clusters.groupby("Cluster")
Mostrando los estadísticos agrupados:
agrupaciones = by_cluster.agg(["mean", "median"])
agrupaciones["cluster size"] = by_cluster.size()
agrupaciones
Length | Recency | Frecuency | Monetary | Periodicity | cluster size | ||||||
---|---|---|---|---|---|---|---|---|---|---|---|
mean | median | mean | median | mean | median | mean | median | mean | median | ||
Cluster | |||||||||||
0 | 23.645464 | 0.0 | 251.627737 | 245.0 | 28.454640 | 19.0 | 53.258432 | 16.870000 | 3.940067 | 0.000000 | 959 |
1 | 277.280495 | 278.0 | 37.318156 | 25.0 | 167.035413 | 97.0 | 32.357707 | 18.077614 | 21.125143 | 15.854776 | 1779 |
2 | 39.241117 | 0.0 | 54.590736 | 46.0 | 48.542513 | 29.0 | 32.311214 | 16.788990 | 5.395593 | 0.397565 | 1576 |
Respuesta Esperada:
Length | Recency | Frequency | Monetary | Periodicity | ||
---|---|---|---|---|---|---|
Cluster | ||||||
0 | 258.8 | 45.2 | 76.1 | 1107.7 | 107.6 | 449 |
1 | 76.1 | 217.6 | 45.5 | 791.7 | 14.1 | 466 |
2 | 368.5 | 4.8 | 2715.0 | 226621.6 | 4.2 | 4 |
3 | 85.3 | 45.7 | 65.8 | 1047.0 | 10.5 | 987 |
4 | 347.2 | 15.9 | 1658.0 | 35829.3 | 8.0 | 25 |
5 | 298.0 | 29.8 | 183.8 | 3639.9 | 32.0 | 1188 |
Por último, Mr. Lepin, impaciente de no entender lo que usted intenta explicarle, le solicita que por favor muestre algún resultado "visual" de los grupos encontrados.
Para esto, grafique nuevamente las características encontradas usando T-SNE
(no calcule de nuevo, simplemente utilice las proyecciones encontradas) y agregue las labels calculadas con kmeans como el argumento color
.
Comente: ¿Se separan bien los distintos clusters en la visualización?
Respuesta:
X_reduced_with_cluster = X_reduced.copy()
X_reduced_with_cluster["Cluster"] = labels
X_reduced_with_cluster["Cluster"] = X_reduced_with_cluster.Cluster.astype("category")
fig = px.scatter(X_reduced_with_cluster, x="x", y="y", color="Cluster")
fig.update_layout(title="Espacio de dimensión reducida con T-SNE")
La separación es razonable en varias partes (secciones verdes aisladas), pero hay punto de muy baja separación. Esto puede ocurrir porque la reducción de dimensionalidad no capturó agrupaciones igual que KMeans.
Eso ha sido todo para el lab de hoy, recuerden que el laboratorio tiene un plazo de entrega de una semana y que los días de atraso no se pueden utilizar para entregas de lab solo para tareas. Cualquier duda del laboratorio, no duden en contactarnos por mail o U-cursos.