/ Proyectos / Negocios / Ventas · Segmentación · Anomalías
Predicción de ventas, segmentación de clientes y detección de anomalías en retail automotriz
Tres módulos en un pipeline: forecasting de demanda por producto con lag features y XGBoost, segmentación RFM con K-Means y detección de anomalías con IQR, K-Medias y DBSCAN sobre datos sintéticos de retail.
Resumen del proyectoWiki
¿Qué encontrarás en este proyecto?
- Cómo generar datos sintéticos realistas para retail cuando no puedes compartir datos reales del cliente.
- Forecasting de ventas por producto usando lag features, ACF y comparación de modelos (Decision Tree, RF, GBM, XGBoost, AdaBoost).
- Segmentación RFM de clientes con K-Means, método del codo y score de Silhouette.
- Detección de anomalías con tres métodos: IQR estadístico, K-Medias y DBSCAN.
Este proyecto desarrolla tres módulos de ML sobre datos de un retail del sector automotriz con cuatro sucursales. Como los datos reales del cliente son confidenciales, se construyó un generador de datos sintéticos que preserva la distribución estadística original: patrones de demanda por sucursal, impacto de la pandemia (2020–2021), probabilidades de compra por cliente y por producto.
El resultado es un pipeline replicable con datos abiertos que cualquier persona puede ejecutar, pero que refleja fielmente la complejidad de un problema real de retail.
Los tres módulos del proyecto
Limpieza y preparación de datos
Exploración, selección de columnas, generación de datos sintéticos con pandas y numpy, lag features y split temporal.
Predicción de ventas
Forecasting mensual por producto con Decision Tree, Random Forest, Gradient Boosting, XGBoost y AdaBoost. Evaluación con MAPE.
Detección de anomalías
Tres métodos comparados: IQR estadístico, K-Medias con distancia al centroide y DBSCAN con ε calculado por k-NN.
Segmentación de clientes
RFM (Recencia, Frecuencia, Monto) + transformación Box-Cox + StandardScaler + K-Means con análisis de Silhouette.
Stack tecnológico
- Python 3.10+ — lenguaje principal
- pandas / numpy — manipulación de datos y generación sintética
- scikit-learn — Random Forest, KMeans, DBSCAN, StandardScaler, Silhouette
- xgboost — modelo de predicción principal
- statsmodels — autocorrelación (ACF) para selección de lags
- scipy — transformación Box-Cox para normalización RFM
- matplotlib / seaborn — visualizaciones
- holidays — generación de fechas válidas (sin festivos ni domingos)
Setup y datosConfiguración del sistema
El proyecto trabaja sobre datos sintéticos generados a partir de la distribución estadística de datos reales de un retailer automotriz con 4 sucursales. La generación preserva patrones reales incluyendo el impacto de pandemia (2020–2021).
Instalación de dependencias
pip install pandas numpy scikit-learn xgboost statsmodels scipy matplotlib seaborn holidays openpyxl
Estructura del proyecto
proyecto-retail/
├── data/
│ ├── raw/ # datos originales del cliente (confidencial)
│ └── data_productos_sint.csv # datos sintéticos generados
├── notebooks/
│ ├── 01_generacion_sintetica.ipynb
│ ├── 02_prediccion_ventas.ipynb
│ ├── 03_anomalias.ipynb
│ └── 04_segmentacion_rfm.ipynb
├── src/
│ ├── data_generator.py
│ ├── forecasting.py
│ ├── anomaly_detection.py
│ └── segmentation.py
└── requirements.txt
Generación de datos sintéticos
Cuando los datos del cliente son confidenciales, se generan datos sintéticos que preserven la distribución estadística. La estrategia incluye: frecuencias históricas de ventas por sucursal, probabilidades de cliente y producto, impacto de pandemia como máscara multiplicativa y solo fechas válidas (sin festivos de Perú ni domingos).
import pandas as pd
import holidays
# Fechas de enero 2020 a diciembre 2024
fechas = pd.date_range(start="2020-01-01", end="2024-12-31", freq="D")
festivos_peru = holidays.country_holidays("PE", years=range(2020, 2025))
# Filtrar: solo días hábiles (lunes a sábado, sin festivos)
fechas_validas = [
f for f in fechas
if f.weekday() != 6 # excluir domingos
and f not in festivos_peru # excluir festivos
]
print(f"Días hábiles 2020-2024: {len(fechas_validas)}")
import numpy as np
import pandas as pd
synthetic_data["Fecha_Emision"] = pd.to_datetime(synthetic_data["Fecha_Emision"])
inicio_pandemia = pd.to_datetime("2020-03-01")
fin_pandemia = pd.to_datetime("2021-03-31")
# Máscara: fechas dentro del período de pandemia
mascara = (
(synthetic_data["Fecha_Emision"] >= inicio_pandemia) &
(synthetic_data["Fecha_Emision"] <= fin_pandemia)
)
# Reducir ventas al 30-60% del valor normal durante pandemia
factor_pandemia = np.random.uniform(0.3, 0.6, mascara.sum())
synthetic_data.loc[mascara, "Cantidad"] = (
synthetic_data.loc[mascara, "Cantidad"] * factor_pandemia
).astype(int)
Feature EngineeringPreparación de datos
La preparación de datos para los tres módulos es diferente: el forecasting requiere lag features por producto, la detección de anomalías trabaja sobre series de tiempo individuales, y la segmentación necesita variables RFM a nivel cliente.
OB1 — Selección de columnas y top 10 productos
import pandas as pd
data = pd.read_csv("data/data_productos_sint.csv")
data["Fecha_Emision"] = pd.to_datetime(data["Fecha_Emision"])
# Seleccionar columnas relevantes
data_productos = data[["Fecha_Emision", "Codigo_Producto", "Cantidad"]].copy()
# Agrupar ventas por producto y ordenar
productos_agrupados = (
data_productos
.groupby("Codigo_Producto")["Cantidad"]
.sum()
.reset_index()
.sort_values(by="Cantidad", ascending=False)
)
# Top 10 productos más vendidos
top10 = productos_agrupados.head(10)
codigos_top10 = top10["Codigo_Producto"].tolist()
print(f"Top 10 productos: {codigos_top10}")
OB2 — Lag features con análisis ACF
El ACF (Autocorrelation Function) mide la correlación de la serie con sus versiones pasadas. Los lags fuera del intervalo de confianza del 95% (banda azul) tienen correlación significativa y deben incluirse como features. En este proyecto se usaron 5 lags mensuales.
from statsmodels.graphics.tsaplots import plot_acf
import matplotlib.pyplot as plt
# Agrupar por mes
data_productos["Mes"] = data_productos["Fecha_Emision"].dt.to_period("M")
diccionario_dataframes = {}
for codigo in codigos_top10:
df_prod = (
data_productos[data_productos["Codigo_Producto"] == codigo]
.groupby("Mes")["Cantidad"]
.sum()
.reset_index()
)
df_prod.columns = ["Fecha", "Cantidad"]
diccionario_dataframes[codigo] = df_prod
# Graficar ACF para ver lags significativos
fig, axes = plt.subplots(5, 2, figsize=(14, 18))
for ax, (codigo, df) in zip(axes.flatten(), diccionario_dataframes.items()):
plot_acf(df["Cantidad"], lags=12, ax=ax, title=f"ACF — {codigo}")
plt.tight_layout()
plt.show()
NUM_LAGS = 5
for codigo, df in diccionario_dataframes.items():
for lag in range(1, NUM_LAGS + 1):
df[f"lag_{lag}"] = df["Cantidad"].shift(lag)
# Rellenar NaN con cero (primeras filas sin historial)
df.fillna(0, inplace=True)
diccionario_dataframes[codigo] = df
# Verificar estructura
print(diccionario_dataframes[codigos_top10[0]].head(7))
OB4 — Variables RFM para segmentación
RFM convierte el historial de transacciones en tres métricas por cliente: Recencia (días desde la última compra), Frecuencia (número de compras) y Monto (valor total). Luego se aplica Box-Cox para normalizar la distribución antes del clustering.
from datetime import timedelta
from scipy import stats
import pandas as pd
df_clientes = data[["Fecha_Emision", "Cliente", "Valor_Venta", "Documento"]].copy()
# Fecha de referencia: día siguiente al último registro
snapshot_date = df_clientes["Fecha_Emision"].max() + timedelta(days=1)
# Calcular RFM
customers = df_clientes.groupby("Cliente").agg(
Ultimo = ("Fecha_Emision", lambda x: (snapshot_date - x.max()).days),
Frecuencia= ("Documento", "nunique"),
Monto = ("Valor_Venta", "sum")
).reset_index()
# Transformación Box-Cox para normalizar cada variable
customers_fix = pd.DataFrame()
customers_fix["Ultimo"] = stats.boxcox(customers["Ultimo"])[0]
customers_fix["Frecuencia"] = stats.boxcox(customers["Frecuencia"])[0]
customers_fix["Monto"] = stats.boxcox(customers["Monto"])[0]
print(customers_fix.describe())
Modelado de los tres módulosEntrenamiento
OB2 — Benchmarking de modelos de predicción
Se entrenan cinco modelos para cada uno de los 10 productos top, evaluados con MAPE. El modelo con menor MAPE promedio se selecciona como el modelo de forecasting del pipeline.
from sklearn.tree import DecisionTreeRegressor
from sklearn.ensemble import GradientBoostingRegressor, AdaBoostRegressor, RandomForestRegressor
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_absolute_percentage_error
from xgboost import XGBRegressor
import numpy as np
modelos = {
"Decision Tree": DecisionTreeRegressor(random_state=42),
"Random Forest": RandomForestRegressor(random_state=42),
"Gradient Boosting": GradientBoostingRegressor(random_state=42),
"XGBoost": XGBRegressor(random_state=42, verbosity=0),
"AdaBoost": AdaBoostRegressor(random_state=42),
}
errores_mape = {}
for codigo, df in diccionario_dataframes.items():
features = [f"lag_{i}" for i in range(1, NUM_LAGS + 1)]
X = df[features]
y = df["Cantidad"]
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, shuffle=False # shuffle=False: respetar orden temporal
)
errores_producto = {}
for nombre, modelo in modelos.items():
modelo.fit(X_train, y_train)
y_pred = modelo.predict(X_test)
mape = mean_absolute_percentage_error(y_test, y_pred) * 100
errores_producto[nombre] = round(mape, 2)
errores_mape[codigo] = errores_producto
# Calcular MAPE promedio por modelo sobre todos los productos
import pandas as pd
df_errores = pd.DataFrame(errores_mape).T
print("MAPE promedio por modelo:")
print(df_errores.mean().sort_values())
OB3 — Detección de anomalías: IQR, K-Medias y DBSCAN
Se comparan tres métodos de detección de anomalías sobre las series de ventas de cada producto.
diccionario_anomalias_iqr = {}
for codigo, df in diccionario_dataframes.items():
Q1 = df["Cantidad"].quantile(0.25)
Q3 = df["Cantidad"].quantile(0.75)
IQR = Q3 - Q1
limite_inferior = Q1 - 1.5 * IQR
limite_superior = Q3 + 1.5 * IQR
anomalias = df[
(df["Cantidad"] < limite_inferior) |
(df["Cantidad"] > limite_superior)
]
diccionario_anomalias_iqr[codigo] = anomalias
print(f"{codigo}: {len(anomalias)} anomalías detectadas con IQR")
import numpy as np
import pandas as pd
from sklearn.cluster import DBSCAN
from sklearn.neighbors import NearestNeighbors
from sklearn.preprocessing import StandardScaler
def calcular_epsilon(X_scaled, k=5):
"""Calcula ε óptimo usando la distancia al k-ésimo vecino más cercano."""
nbrs = NearestNeighbors(n_neighbors=k).fit(X_scaled)
distancias, _ = nbrs.kneighbors(X_scaled)
dist_k = np.sort(distancias[:, -1])
# Punto de mayor curvatura como ε
diff = np.diff(dist_k)
epsilon = dist_k[np.argmax(diff)]
return epsilon
diccionario_anomalias_dbscan = {}
for codigo, df in diccionario_dataframes.items():
X = df[["Cantidad"]].values
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
epsilon = calcular_epsilon(X_scaled)
db = DBSCAN(eps=epsilon, min_samples=3).fit(X_scaled)
df_result = df.copy()
df_result["cluster"] = db.labels_
# Etiqueta -1 = ruido = anomalía
anomalias = df_result[df_result["cluster"] == -1]
diccionario_anomalias_dbscan[codigo] = anomalias
print(f"{codigo}: {len(anomalias)} anomalías con DBSCAN (ε={epsilon:.4f})")
OB4 — K-Means para segmentación RFM
Se usa el método del codo (SSE) y el score de Silhouette para determinar el número óptimo de clusters. Con k=3 se obtienen los mejores resultados en este dataset.
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import silhouette_score
import matplotlib.pyplot as plt
scaler = StandardScaler()
customers_normalized = scaler.fit_transform(customers_fix)
# Método del codo: SSE para k=1..10
sse = {}
for k in range(1, 11):
kmeans = KMeans(n_clusters=k, random_state=42, n_init=10)
kmeans.fit(customers_normalized)
sse[k] = kmeans.inertia_
plt.figure(figsize=(8, 4))
plt.plot(list(sse.keys()), list(sse.values()), marker="o")
plt.xlabel("Número de clusters (k)")
plt.ylabel("SSE")
plt.title("Método del codo — Segmentación RFM")
plt.grid(True, alpha=0.3)
plt.show()
# Score de Silhouette para k=2..5
for k in range(2, 6):
km = KMeans(n_clusters=k, random_state=42, n_init=10).fit(customers_normalized)
score = silhouette_score(customers_normalized, km.labels_)
print(f"k={k}: Silhouette = {score:.4f}")
# Modelo final con k=3
model = KMeans(n_clusters=3, random_state=42, n_init=10)
model.fit(customers_normalized)
customers["Cluster"] = model.labels_
Métricas y comparaciónEvaluación
OB2 — Comparación de modelos de predicción
| Modelo | MAPE promedio (%) | Observaciones |
|---|---|---|
| Decision Tree | ~28% | Alta varianza, sobreajuste con pocos datos |
| AdaBoost | ~24% | Sensible a outliers en la serie |
| Random Forest | ~19% | Robusto, buen baseline de ensemble |
| Gradient Boosting | ~17% | Mejor captura de tendencias locales |
| XGBoost | ~15% | Mejor modelo — seleccionado para producción |
OB3 — Comparación de métodos de detección de anomalías
| Método | Tipo | Ventaja | Limitación |
|---|---|---|---|
| IQR | Estadístico | Simple, interpretable, sin parámetros | Univariado, asume distribución simétrica |
| K-Medias | Clustering | Detecta anomalías relativas al contexto del cluster | Requiere definir k y umbral de distancia |
| DBSCAN | Densidad | No requiere k, robusto a ruido, detecta clusters no esféricos | Sensible a ε; se calcula con k-NN para automatizar |
OB4 — Perfil de los clusters RFM
perfil_clusters = customers.groupby("Cluster").agg(
Ultimo_promedio = ("Ultimo", "mean"),
Frecuencia_promedio= ("Frecuencia", "mean"),
Monto_promedio = ("Monto", "mean"),
N_clientes = ("Cliente", "count")
).round(1)
print(perfil_clusters)
# Ejemplo de resultado:
# Cluster 0: Reciente, alta frecuencia, alto monto → Clientes VIP
# Cluster 1: Alejados, baja frecuencia, bajo monto → En riesgo de churn
# Cluster 2: Frecuencia media, monto medio → Clientes regulares
| Cluster | Recencia (días) | Frecuencia | Monto promedio | Perfil |
|---|---|---|---|---|
| 0 — VIP | ~15 días | Alta | Alto | Clientes activos de alto valor — retener y premiar |
| 1 — En riesgo | ~180 días | Baja | Bajo | Sin compras recientes — campañas de reactivación |
| 2 — Regular | ~45 días | Media | Medio | Clientes estables — potencial de upselling |
ConclusionesHallazgos
Hallazgos principales
XGBoost con lag features es el mejor predictor de demanda mensual
Sobre datos sintéticos de retail automotriz con 10 productos top, XGBoost con 5 lag features mensuales obtuvo un MAPE promedio de ~15%, superando a Random Forest (~19%) y Gradient Boosting (~17%). La clave fue usar shuffle=False en el split para respetar el orden temporal.
DBSCAN con ε calculado por k-NN es el método de anomalías más robusto
IQR detecta demasiados falsos positivos en series con distribuciones asimétricas. K-Medias requiere ajuste manual del umbral de distancia. DBSCAN con ε calculado automáticamente por k-NN se adapta a cada serie individualmente y etiqueta como anomalía solo los puntos genuinamente aislados.
K-Means con k=3 produce los segmentos RFM más interpretables
El método del codo y el score de Silhouette coinciden en k=3 como óptimo. Los tres clusters tienen perfiles claros y accionables: VIP (retener), En riesgo (reactivar) y Regular (upselling). La transformación Box-Cox previa fue crítica para que K-Means convergiera correctamente sobre las variables RFM sesgadas.
La generación de datos sintéticos es una contribución metodológica válida
Cuando los datos del cliente son confidenciales, generar sintéticos que preservan la distribución estadística original (frecuencias, probabilidades, eventos como pandemia) permite desarrollar y publicar el proyecto sin comprometer información sensible. Esta estrategia es replicable en cualquier proyecto con restricciones de confidencialidad.
Limitaciones y trabajo futuro
- Los modelos se entrenan por producto de forma independiente — un modelo multi-output que aprenda patrones entre productos podría mejorar el MAPE.
- No se incorporaron variables exógenas como promociones, estacionalidad del sector o precio del combustible, que afectan significativamente la demanda automotriz.
- La segmentación RFM no considera el canal de compra (sucursal) — segmentar por sucursal daría insights más accionables para equipos de ventas locales.
- DBSCAN con ε por k-NN puede ser computacionalmente costoso con millones de registros — Isolation Forest sería más escalable en producción.