/ 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.
Punto de partidaBrief del proyecto
Eres analista de datos en un retailer automotriz con cuatro sucursales. El gerente comercial te pide tres cosas: saber cuánto va a vender cada producto el próximo mes, identificar qué clientes están en riesgo de perderse y detectar meses de ventas anómalas antes de que lleguen al reporte financiero.
Los datos reales del cliente son confidenciales. Tu primera decisión es cómo desarrollar y publicar el proyecto sin comprometer información sensible.
Qué está en juego
Un modelo de forecasting que sobreestima la demanda genera sobrestock. Uno que subestima genera roturas de inventario. La segmentación incorrecta dirige campañas de reactivación al segmento equivocado. Y una detección de anomalías con demasiados falsos positivos hace que el equipo deje de confiar en el sistema.
Mapa del proyecto
Resultados de aprendizajeLo que aprendes aplicando este proyecto
- Cómo generar datos sintéticos que preservan la distribución estadística real cuando los datos del cliente son confidenciales — una contribución metodológica válida para una tesis.
- Por qué usar
shuffle=Falseen el split de series temporales y cómo el ACF determina cuántos lags incluir como features. - La diferencia real entre IQR, K-Medias y DBSCAN para detectar anomalías, y cuándo cada uno falla — no solo cómo implementarlos.
- Cómo transformar RFM con Box-Cox antes de K-Means y por qué sin esa transformación el clustering produce segmentos sin sentido.
Al terminar este proyecto sabrás hacer esto
- Construir un pipeline completo de ML con tres módulos distintos integrados en un mismo dataset transaccional.
- Seleccionar el modelo de forecasting correcto comparando cinco algoritmos con MAPE sobre datos reales.
- Segmentar clientes en perfiles accionables (VIP, en riesgo, regular) con RFM y K-Means.
- Detectar y comparar anomalías en series de ventas con tres métodos distintos, justificando cuál usar según el contexto.
- Justificar cada decisión metodológica ante un comité de tesis o un equipo de negocios.
Resumen del proyecto¿Qué construyes?
¿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.
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)
Fase 1Entendimiento del problema
Antes de abrir un notebook, necesitas definir exactamente qué problema de ML estás resolviendo en cada módulo. Este proyecto tiene tres — y cada uno tiene su propio tipo de variable objetivo, su propia métrica y sus propios riesgos.
Decisión clave — tipo de problema
Forecasting de ventas: regresión. El target es una cantidad continua (unidades vendidas por mes por producto).
Segmentación de clientes: clustering no supervisado. No hay etiquetas — el modelo encuentra los grupos por similitud en RFM.
Detección de anomalías: también no supervisado. No hay anomalías etiquetadas — los métodos definen qué es anómalo según criterios estadísticos o de densidad.
Decisión clave — datos confidenciales
¿Usar datos reales o generar sintéticos?
Datos reales: ideal, pero implica acuerdo de confidencialidad y no puedes publicarlos.
Datos sintéticos: permiten publicar y replicar el proyecto. Son válidos si preservan la distribución estadística real.
Se eligió: generar datos sintéticos que replican las frecuencias de ventas por sucursal, probabilidades de producto y el impacto de pandemia 2020–2021. La metodología de generación es en sí una contribución del proyecto.
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 = pd.date_range(start="2020-01-01", end="2024-12-31", freq="D")
festivos_peru = holidays.country_holidays("PE", years=range(2020, 2025))
fechas_validas = [
f for f in fechas
if f.weekday() != 6
and f not in festivos_peru
]
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")
mascara = (
(synthetic_data["Fecha_Emision"] >= inicio_pandemia) &
(synthetic_data["Fecha_Emision"] <= fin_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)
Fase 2Datos
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.
Decisión clave — lag features
¿Cuántos lags incluir como features del modelo de forecasting?
El ACF (Autocorrelation Function) mide la correlación de la serie con sus versiones pasadas. Los lags fuera del intervalo de confianza del 95% tienen correlación significativa. En este proyecto el análisis mostró correlación significativa hasta el lag 5.
Se eligió: 5 lags mensuales. Incluir más no mejora el modelo y añade ruido; incluir menos pierde patrones de demanda estacional.
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"])
data_productos = data[["Fecha_Emision", "Codigo_Producto", "Cantidad"]].copy()
productos_agrupados = (
data_productos
.groupby("Codigo_Producto")["Cantidad"]
.sum()
.reset_index()
.sort_values(by="Cantidad", ascending=False)
)
top10 = productos_agrupados.head(10)
codigos_top10 = top10["Codigo_Producto"].tolist()
print(f"Top 10 productos: {codigos_top10}")
Lag features con análisis ACF
from statsmodels.graphics.tsaplots import plot_acf
import matplotlib.pyplot as plt
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
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)
df.fillna(0, inplace=True)
diccionario_dataframes[codigo] = df
print(diccionario_dataframes[codigos_top10[0]].head(7))
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).
Error común
Aplicar K-Means directamente sobre variables RFM sin transformar. RFM tiene distribuciones sesgadas — pocos clientes con monto muy alto distorsionan los centroides. Sin transformación Box-Cox y normalización, K-Means produce clusters dominados por los outliers de monto, perdiendo la segmentación real.
from datetime import timedelta
from scipy import stats
import pandas as pd
df_clientes = data[["Fecha_Emision", "Cliente", "Valor_Venta", "Documento"]].copy()
snapshot_date = df_clientes["Fecha_Emision"].max() + timedelta(days=1)
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()
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())
Fase 3Modelado
Decisión clave — split temporal
¿Por qué usar shuffle=False en el split de series temporales?
En series de tiempo, el orden importa. Si mezclas los datos antes del split, el modelo puede ver ventas del mes 10 para predecir el mes 5 — filtrando información del futuro al pasado (data leakage). shuffle=False garantiza que el test siempre es el período más reciente, nunca algo que ocurrió antes del entrenamiento.
Benchmarking de modelos de predicción
Se entrenan cinco modelos para cada uno de los 10 productos top, evaluados con MAPE.
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
)
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
import pandas as pd
df_errores = pd.DataFrame(errores_mape).T
print("MAPE promedio por modelo:")
print(df_errores.mean().sort_values())
Detección de anomalías: IQR, K-Medias y DBSCAN
Decisión clave — qué método de anomalías usar
IQR es simple y sin parámetros, pero asume distribución simétrica — falla con series asimétricas. K-Medias detecta anomalías relativas al contexto del cluster, pero requiere definir k y un umbral de distancia manual. DBSCAN con ε calculado por k-NN se adapta a cada serie individualmente y no requiere definir el número de clusters.
Conclusión: en este proyecto DBSCAN fue el más robusto. Pero conocer los tres es lo que permite justificar esa elección.
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
from sklearn.cluster import DBSCAN
from sklearn.neighbors import NearestNeighbors
from sklearn.preprocessing import StandardScaler
def calcular_epsilon(X_scaled, k=5):
nbrs = NearestNeighbors(n_neighbors=k).fit(X_scaled)
distancias, _ = nbrs.kneighbors(X_scaled)
dist_k = np.sort(distancias[:, -1])
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_
anomalias = df_result[df_result["cluster"] == -1]
diccionario_anomalias_dbscan[codigo] = anomalias
print(f"{codigo}: {len(anomalias)} anomalías con DBSCAN (ε={epsilon:.4f})")
K-Means para segmentación RFM
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)
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()
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}")
model = KMeans(n_clusters=3, random_state=42, n_init=10)
model.fit(customers_normalized)
customers["Cluster"] = model.labels_
Fase 4Evaluación
Decisión clave — qué métrica reportar
¿MAPE, RMSE o MAE para el forecasting?
RMSE penaliza errores grandes de forma cuadrática — sensible a outliers. MAE es más robusto pero no da error porcentual. MAPE expresa el error en porcentaje, lo que permite comparar productos con escalas de ventas muy diferentes (un producto que vende 5 unidades y otro que vende 500 son comparables con MAPE, no con RMSE).
Se eligió MAPE porque el cliente quiere saber "¿cuánto me equivoco en porcentaje?", no "¿cuántas unidades me equivoco?".
Error común
Usar Silhouette como único criterio para k en K-Means. El Silhouette score maximiza la separación entre clusters, pero no garantiza que los clusters sean interpretables. Siempre combínalo con el método del codo (SSE) y con el perfil de los clusters resultantes. Un k=4 con Silhouette ligeramente mejor pero clusters sin sentido de negocio es peor que un k=3 claro.
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 |
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 |
Fase 5Aplicación real
El modelo entrenado no es el producto final. El producto es la decisión que habilita. Aquí defines cómo los resultados de cada módulo se conectan con decisiones concretas de negocio.
Insight de experto
Un MAPE del 15% puede ser aceptable o inaceptable según el margen del producto. Si el margen es 10%, un error del 15% en forecasting puede hacer que el modelo cueste más de lo que aporta. Siempre convierte el error del modelo a impacto financiero antes de presentarlo al cliente o comité.
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)
# 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 |
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 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.
GitHubReplica este proyecto
El código completo está disponible en GitHub: notebooks organizados por módulo, datos sintéticos listos para usar y requirements.txt para reproducir el entorno exacto.
FuzzyFrogAI / ml-sales-forecasting-xgboost-rfm-dbscan
Forecasting de ventas con XGBoost, segmentación RFM con K-Means y detección de anomalías con DBSCAN sobre retail automotriz.
github.com/FuzzyFrogAI/ml-sales-forecasting-xgboost-rfm-dbscan