Proyecto #8 · Customer Analytics
Segmentación RFM y CLV predictivo
Segmentos validados por estabilidad y CLV probabilístico (BG/NBD) vs. ML vs. regla simple.
Resumen ejecutivo
- 4,338 clientes en 4 segmentos. Validación: silueta 0.34 (la máxima es k = 2, pero k = 4 es más accionable), estabilidad bootstrap ARI 0.93 ± 0.06 y acuerdo con un modelo de mezclas gaussianas ARI 0.44.
- Los "Campeones" son 16 % de los clientes y generan 65 % del ingreso. Acuerdo con la regla clásica de quintiles RFM: ARI 0.40.
- CLV a 3 meses fuera de muestra — MAE: Regla: promedio histórico £693, BG/NBD + Gamma-Gamma £668, LightGBM (CV 5-fold) £663. Los IC bootstrap se traslapan (empate práctico) y todos subestiman el gasto total (temporada navideña fuera de la historia): Regla: promedio histórico 85%, BG/NBD + Gamma-Gamma 59%, LightGBM 61%.
- Cambio de enfoque: además de ML, un modelo probabilístico (BG/NBD + Gamma-Gamma) que se explica con dos ideas de negocio —qué tan seguido compra y cuánto gasta por compra— y que no necesita etiquetas.
Resultados
Perfil de cada segmento (z-score de log RFM)
Recencia alta = hace mucho que no compra
- Campeones
- Dormidos
- Leales
- Nuevos / prometedores
% del ingreso por segmento
Elección de k: silueta (más alto es mejor)
Método del codo
CLV: error absoluto medio por modelo (£, menor es mejor)
CLV (LightGBM (CV 5-fold)): gasto predicho vs. real por decil
- Predicho
- Real
Metodología y validación
K-Means sobre log-RFM estandarizado; CLV con regla simple, BG/NBD + Gamma-Gamma y LightGBM.
✔ Cómo se validó
- Clustering: silueta, Davies-Bouldin, codo, estabilidad bootstrap (ARI) y acuerdo con GMM y regla RFM.
- CLV: validación temporal (historia hasta ago-2011, objetivo sep–dic 2011); LightGBM con predicción fuera de fold; MAE con IC bootstrap, Spearman y concentración del top 20 %.
⚙ Supuestos
- Las devoluciones y facturas sin cliente se excluyen.
- BG/NBD supone que un cliente "muere" una sola vez y compra como proceso de Poisson mientras vive.
⚠ Limitaciones
- Un solo año de historia: el trimestre objetivo incluye la temporada navideña.
- Clientes mayoristas extremos dominan el error absoluto; para decisiones conviene ver el orden (Spearman).
🧬 Enfoque alternativo · Red neuronal SOM 8×8 + clustering jerárquico de prototipos
Mapa autoorganizado de Kohonen (SOM)
¿Por qué otro enfoque? K-Means daba segmentos estables pero poco separados y el CLV empataba entre modelos. El SOM es una red neuronal bioinspirada que dibuja un mapa de la cartera: se ve dónde está el valor y por qué.
- Mapa de 8×8 neuronas entrenado con 6,000 pasos: error de cuantización 0.63 (desv. estándar) y error topográfico 15.3% en clientes no vistos — el mapa preserva la vecindad de los perfiles.
- CLV por neurona en clientes de prueba: Spearman 0.505 vs. 0.539 de la regla; el top 20 % concentra 73 % vs. 75 % del gasto real. La regla sigue siendo igual de buena para ordenar.
- MAE £1,049 (SOM) vs. £758 (regla): al promediar vecinos el SOM pierde la información individual del cliente (su propio historial), que en este negocio es la mejor señal. Resultado negativo honesto: el SOM no mejora el CLV.
- Segmentar agrupando neuronas da silueta 0.22 vs. 0.30 de K-Means: K-Means separa mejor; el valor del SOM está en la visualización y el CLV.
U-matrix: fronteras entre grupos de clientes
Celdas claras = perfiles parecidos (valles); oscuras = fronteras
Gasto futuro promedio por neurona (£, 3 meses)
Estimado con clientes de entrenamiento; así se predice el CLV de un cliente nuevo
Plano de componente: gasto histórico (£)
Plano de componente: recencia (días)
Clientes por neurona
Aprendizaje: error de cuantización durante el entrenamiento
Silueta: SOM + jerárquico vs. K-Means
CLV a 3 meses en clientes de prueba
| Modelo | MAE £ | Spearman | Top 20 % concentra |
|---|---|---|---|
| Regla: promedio histórico | 758 | 0.539 | 74.9 |
| SOM (gasto de la neurona) | 1,049 | 0.505 | 72.6 |
Segmentos del SOM
| Segmento SOM | Clientes | Recencia (mediana) | Compras (mediana) | Gasto histórico (mediana) | Gasto futuro promedio £ |
|---|---|---|---|---|---|
| S1 | 995 | 20 | 5 | 1,936.6 | 2,088.2 |
| S2 | 470 | 31 | 1 | 353.9 | 507.8 |
| S3 | 1,080 | 110 | 2 | 600.9 | 397.3 |
| S4 | 772 | 161 | 1 | 165 | 371.8 |
✔ Cómo se validó
- Mapa entrenado con 70 % de los clientes; CLV y errores medidos en el 30 % restante.
- Errores de cuantización y topográfico como calidad del mapa; silueta para comparar con K-Means.
⚙ Supuestos
- Clientes con perfil parecido (vecinos en el mapa) tienen gasto futuro parecido.
⚠ Limitaciones
- Un solo año de historia; el CLV por neurona hereda la estacionalidad del trimestre objetivo.
"""Alternativa #8 — Mapa autoorganizado de Kohonen (SOM) para segmentar y estimar CLV.
Problema que resuelve: en el proyecto #8 K-Means daba segmentos estables pero con silueta baja, y los tres
modelos de CLV empataron. El SOM es una red neuronal bioinspirada (organización topográfica de la corteza):
aprende un "mapa" 2D de los clientes donde neuronas vecinas representan perfiles parecidos. Sirve para:
1) ver la forma de la cartera (U-matrix y planos por variable), 2) segmentar agrupando neuronas y
3) estimar CLV como el gasto futuro promedio de los clientes que caen en la misma neurona.
"""
import numpy as np
import pandas as pd
from scipy.cluster.hierarchy import fcluster, linkage
from scipy.stats import spearmanr
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
from sklearn.preprocessing import StandardScaler
from bioinspirados import SOM
from common import SEED, bars, export_alternativa, heatmap, line, table
from p08_segmentacion_clv import CORTE, load, rfm
from validacion import model_card
FILAS = COLS = 8
VARS = ["recencia", "frecuencia", "monetario", "ticket", "antiguedad", "meses_activos"]
SUAVIZADO = 5 # encogimiento del promedio por neurona hacia la media global (neuronas con pocos clientes)
def run():
tx = load()
cal, val = tx[tx.InvoiceDate < CORTE], tx[tx.InvoiceDate >= CORTE]
X = rfm(cal, CORTE)
X["meses_activos"] = cal.groupby("CustomerID").InvoiceDate.apply(lambda s: s.dt.to_period("M").nunique())
futuro = val.groupby("CustomerID").monto.sum().reindex(X.index, fill_value=0).values
esc = StandardScaler().fit(np.log1p(X[VARS]))
Z = esc.transform(np.log1p(X[VARS]))
rng = np.random.default_rng(SEED)
idx = rng.permutation(len(Z))
tr, te = idx[: int(len(idx) * 0.7)], idx[int(len(idx) * 0.7):]
# --- Entrenamiento del mapa (solo clientes de entrenamiento) ---
som = SOM(FILAS, COLS, iteraciones=6000).fit(Z[tr])
eq, et = som.error_cuantizacion(Z[te]), som.error_topografico(Z[te])
b_tr, b_te = som.bmu(Z[tr]), som.bmu(Z[te])
hits = np.bincount(b_tr, minlength=FILAS * COLS)
# --- CLV por neurona: gasto futuro promedio de los vecinos de mapa (con encogimiento) ---
media = futuro[tr].mean()
suma = np.bincount(b_tr, weights=futuro[tr], minlength=FILAS * COLS)
clv_neurona = (suma + SUAVIZADO * media) / (hits + SUAVIZADO)
pred_som = clv_neurona[b_te]
real = futuro[te]
regla = (X.monetario / np.maximum(X.antiguedad, 90) * 99).values[te]
def metricas(p):
top = np.argsort(-p)[: int(len(p) * 0.2)]
return np.mean(np.abs(real - p)), spearmanr(real, p)[0], real[top].sum() / real.sum()
clv_tbl = pd.DataFrame([["Regla: promedio histórico", *metricas(regla)], ["SOM (gasto de la neurona)", *metricas(pred_som)]],
columns=["Modelo", "MAE £", "Spearman", "Top 20 % concentra"])
clv_tbl["MAE £"] = clv_tbl["MAE £"].round(0)
clv_tbl["Spearman"] = clv_tbl["Spearman"].round(3)
clv_tbl["Top 20 % concentra"] = (clv_tbl["Top 20 % concentra"] * 100).round(1)
# --- Segmentación en dos niveles: neuronas → 4 grupos (Ward sobre los prototipos) vs. K-Means directo ---
grupos_neurona = fcluster(linkage(som.W, "ward"), 4, "maxclust") - 1
seg_som = grupos_neurona[som.bmu(Z)]
seg_km = KMeans(4, n_init=10, random_state=SEED).fit_predict(Z)
muestra = rng.choice(len(Z), 3000, replace=False)
sil_som = silhouette_score(Z[muestra], seg_som[muestra])
sil_km = silhouette_score(Z[muestra], seg_km[muestra])
perfil = X.assign(seg=seg_som, futuro=futuro).groupby("seg").agg(
clientes=("recencia", "size"), recencia=("recencia", "median"), frecuencia=("frecuencia", "median"),
monetario=("monetario", "median"), gasto_futuro=("futuro", "mean")).round(1)
perfil = perfil.sort_values("gasto_futuro", ascending=False).reset_index(drop=True)
perfil.insert(0, "Segmento SOM", [f"S{i + 1}" for i in range(len(perfil))])
perfil.columns = ["Segmento SOM", "Clientes", "Recencia (mediana)", "Compras (mediana)", "Gasto histórico (mediana)",
"Gasto futuro promedio £"]
# Planos de componentes en unidades originales (aprox.: se invierte log y escala)
W_orig = np.expm1(esc.inverse_transform(som.W))
plano = lambda v: W_orig[:, VARS.index(v)].reshape(FILAS, COLS).round(1)
ejes = [str(i + 1) for i in range(COLS)]
r = clv_tbl.set_index("Modelo")
result = {
"titulo": "Mapa autoorganizado de Kohonen (SOM)",
"algoritmo": "Red neuronal SOM 8×8 + clustering jerárquico de prototipos",
"veredicto": "Mapa visual de la cartera (U-matrix y planos); para CLV y segmentación no supera a la regla ni a K-Means.",
"por_que": "K-Means daba segmentos estables pero poco separados y el CLV empataba entre modelos. El SOM es una "
"red neuronal bioinspirada que dibuja un mapa de la cartera: se ve dónde está el valor y por qué.",
"summary": [
f"Mapa de {FILAS}×{COLS} neuronas entrenado con 6,000 pasos: error de cuantización {eq:.2f} (desv. estándar) y "
f"error topográfico {et:.1%} en clientes no vistos — el mapa preserva la vecindad de los perfiles.",
f"CLV por neurona en clientes de prueba: Spearman {r.loc['SOM (gasto de la neurona)', 'Spearman']:.3f} vs. "
f"{r.loc['Regla: promedio histórico', 'Spearman']:.3f} de la regla; el top 20 % concentra "
f"{r.loc['SOM (gasto de la neurona)', 'Top 20 % concentra']:.0f} % vs. {r.loc['Regla: promedio histórico', 'Top 20 % concentra']:.0f} % del gasto real. "
+ ("El SOM ordena mejor a los clientes." if r.loc['SOM (gasto de la neurona)', 'Spearman'] > r.loc['Regla: promedio histórico', 'Spearman']
else "La regla sigue siendo igual de buena para ordenar."),
f"MAE £{r.loc['SOM (gasto de la neurona)', 'MAE £']:,.0f} (SOM) vs. £{r.loc['Regla: promedio histórico', 'MAE £']:,.0f} (regla): "
+ ("al promediar vecinos el SOM pierde la información individual del cliente (su propio historial), que en "
"este negocio es la mejor señal. Resultado negativo honesto: el SOM no mejora el CLV."
if r.loc['SOM (gasto de la neurona)', 'MAE £'] > r.loc['Regla: promedio histórico', 'MAE £']
else "promediar vecinos reduce el error absoluto."),
f"Segmentar agrupando neuronas da silueta {sil_som:.2f} vs. {sil_km:.2f} de K-Means: "
+ ("mejor separación." if sil_som > sil_km else "K-Means separa mejor; el valor del SOM está en la visualización y el CLV."),
],
"charts": [
heatmap("umatrix", "U-matrix: fronteras entre grupos de clientes", som.u_matrix().round(3), ejes, ejes, fmt="dec",
subtitle="Celdas claras = perfiles parecidos (valles); oscuras = fronteras"),
heatmap("clv", "Gasto futuro promedio por neurona (£, 3 meses)", clv_neurona.reshape(FILAS, COLS).round(0), ejes, ejes,
fmt="gbp", subtitle="Estimado con clientes de entrenamiento; así se predice el CLV de un cliente nuevo"),
heatmap("mon", "Plano de componente: gasto histórico (£)", plano("monetario"), ejes, ejes, fmt="gbp"),
heatmap("rec", "Plano de componente: recencia (días)", plano("recencia"), ejes, ejes, fmt="num"),
heatmap("hits", "Clientes por neurona", hits.reshape(FILAS, COLS), ejes, ejes, fmt="int"),
line("qe", "Aprendizaje: error de cuantización durante el entrenamiento",
{"Error": [[a, round(b, 4)] for a, b in som.error]}, x_label="Paso", y_label="Error", fmt="dec"),
bars("sil", "Silueta: SOM + jerárquico vs. K-Means", ["SOM → 4 grupos", "K-Means k = 4"],
[round(sil_som, 3), round(sil_km, 3)], fmt="dec"),
],
"tables": [table("CLV a 3 meses en clientes de prueba", clv_tbl), table("Segmentos del SOM", perfil)],
"card": model_card(
"SOM de Kohonen implementado desde cero (vecindario gaussiano decreciente) sobre 6 variables RFM en logaritmo.",
["Mapa entrenado con 70 % de los clientes; CLV y errores medidos en el 30 % restante.",
"Errores de cuantización y topográfico como calidad del mapa; silueta para comparar con K-Means."],
["Clientes con perfil parecido (vecinos en el mapa) tienen gasto futuro parecido."],
["Un solo año de historia; el CLV por neurona hereda la estacionalidad del trimestre objetivo."]),
}
export_alternativa("p08-segmentacion-clv", result)
return result
if __name__ == "__main__":
run()
🔬 Búsqueda del mejor algoritmo · 8 iteraciones
Búsqueda del mejor modelo de CLV
Objetivo: Ordenar mejor a los clientes por su gasto futuro para priorizar retención y marketing.
- 8 enfoques con los mismos 10 folds. Campeón: Ensamble de rangos (BG/NBD + LightGBM + hurdle) — Spearman 0.541 vs. 0.511 de la regla, y su top 20 % concentra 63% vs. 62% del gasto real.
- Cambio de métrica: el error absoluto lo dominan unos cuantos mayoristas; para priorizar clientes importa el orden. Contra la regla, la mejora del campeón tiene p = 0.013 (t corregido): es real.
- Lo que funcionó: separar ¿comprará? de ¿cuánto? (hurdle) y combinar enfoques distintos por rangos. Usar BG/NBD como variable de LightGBM no ayudó; sí ayudó dentro de CatBoost y en el ensamble.
- Nota: aquí la métrica de CV ya es fuera de muestra para cada cliente (predicción fuera de fold); el objetivo es un periodo futuro, por lo que también es validación temporal.
💡 Para CLV la métrica correcta es de orden; combinar modelos distintos ordena mejor que cualquiera solo.
Progreso de la búsqueda (Spearman, validación)
- Cada experimento
- Mejor hasta el momento
Ranking de candidatos (Spearman en validación)
Concentración del gasto real en el top 20 % predicho
Prueba final: campeón vs. línea base (una sola evaluación)
| Modelo | Spearman CV | Top 20 % concentra |
|---|---|---|
| Línea base: Regla: promedio histórico | 0.511 | 62.3% |
| Campeón: Ensamble de rangos (BG/NBD + LightGBM + hurdle) | 0.541 | 63.3% |
Bitácora de experimentos
| Iteración | Hipótesis | Candidato | Spearman | Desv. | Decisión | Nota | Δ vs. campeón | p |
|---|---|---|---|---|---|---|---|---|
| 0 | Línea base: gasto histórico promedio proyectado. | Regla: promedio histórico | 0.511 | 0.036 | Línea base | Top 20 % concentra 62% del gasto | — | — |
| 1 | Modelo probabilístico clásico de CLV. | BG/NBD + Gamma-Gamma | 0.508 | 0.039 | Se descarta | Top 20 % concentra 63% del gasto | -0.003 | 0.854 |
| 2 | Lo reciente predice mejor: solo el gasto de los últimos 3 meses. | Regla: gasto últimos 3 meses | 0.467 | 0.026 | Se descarta | Top 20 % concentra 61% del gasto | -0.044 | 0.028 |
| 3 | LightGBM sobre log(gasto): el logaritmo doma la cola larga y mejora el orden. | LightGBM · objetivo log | 0.518 | 0.026 | Nuevo campeón (mejora no significativa) | Top 20 % concentra 62% del gasto | 0.008 | 0.657 |
| 4 | Stacking: la predicción BG/NBD como variable del LightGBM. | LightGBM · log + BG/NBD | 0.516 | 0.027 | Se descarta | Top 20 % concentra 62% del gasto | -0.002 | 0.356 |
| 5 | Modelo de dos partes: ¿comprará? × ¿cuánto si compra? | Hurdle: P(compra) × gasto | 0.527 | 0.033 | Nuevo campeón (mejora no significativa) | Top 20 % concentra 63% del gasto | 0.009 | 0.419 |
| 6 | CatBoost con BG/NBD como variable. | CatBoost · log + BG/NBD | 0.534 | 0.028 | Nuevo campeón (mejora no significativa) | Top 20 % concentra 62% del gasto | 0.007 | 0.500 |
| 7 | Promedio de rangos de los mejores enfoques. | Ensamble de rangos (BG/NBD + LightGBM + hurdle) | 0.541 | 0.033 | Nuevo campeón (mejora no significativa) | Top 20 % concentra 63% del gasto | 0.008 | 0.430 |
"""Búsqueda del mejor algoritmo — #8 CLV (gasto de los próximos 3 meses).
Punto de partida: regla de promedio histórico, BG/NBD y LightGBM empatados. Como el CLV se usa para PRIORIZAR
clientes, la métrica de selección es el orden: correlación de Spearman entre predicho y real (además se reporta
la concentración del gasto en el top 20 %). Mismos 10 folds (5×2) para todos; el objetivo es el gasto real de
sep–dic 2011 con historia hasta agosto.
"""
import numpy as np
import pandas as pd
from catboost import CatBoostRegressor
from lightgbm import LGBMClassifier, LGBMRegressor
from scipy.stats import spearmanr
from sklearn.model_selection import RepeatedKFold
from common import SEED, bars, export_experimento
from experimentos import Bitacora
from validacion import t_test_corregido
from p08_segmentacion_clv import CORTE, clv_bgnbd, load, rfm
def run():
tx = load()
cal, val = tx[tx.InvoiceDate < CORTE], tx[tx.InvoiceDate >= CORTE]
dias = (tx.InvoiceDate.max().normalize() - CORTE).days + 1
X = rfm(cal, CORTE)
X["meses_activos"] = cal.groupby("CustomerID").InvoiceDate.apply(lambda s: s.dt.to_period("M").nunique())
X["reino_unido"] = (cal.groupby("CustomerID").Country.first() == "United Kingdom").astype(int)
ult = cal[cal.InvoiceDate >= CORTE - pd.DateOffset(months=3)].groupby("CustomerID").monto
X["gasto_ult_3m"] = ult.sum().reindex(X.index, fill_value=0)
X["compras_ult_3m"] = cal[cal.InvoiceDate >= CORTE - pd.DateOffset(months=3)].groupby("CustomerID").InvoiceNo.nunique().reindex(X.index, fill_value=0)
real = val.groupby("CustomerID").monto.sum().reindex(X.index, fill_value=0).values
bg = clv_bgnbd(cal, dias).reindex(X.index).fillna(0).values
regla = (X.monetario / np.maximum(X.antiguedad, 90) * dias).values
Xb = X.assign(bgnbd=bg) # BG/NBD como variable de un modelo ML (stacking)
part = list(RepeatedKFold(n_splits=5, n_repeats=2, random_state=SEED).split(X))
def puntuar(pred_fn):
s, top = [], []
for tr, te in part:
p = pred_fn(tr, te)
s.append(spearmanr(real[te], p)[0])
k = np.argsort(-p)[: int(len(te) * 0.2)]
top.append(real[te][k].sum() / real[te].sum())
return np.array(s), float(np.mean(top))
lgb_log = lambda Xm: (lambda tr, te: np.expm1(LGBMRegressor(n_estimators=400, learning_rate=0.03, num_leaves=15,
min_child_samples=30, random_state=SEED, verbose=-1)
.fit(Xm.iloc[tr], np.log1p(real[tr])).predict(Xm.iloc[te])))
def hurdle(tr, te):
compra = (real > 0).astype(int)
pc = LGBMClassifier(n_estimators=300, learning_rate=0.03, num_leaves=15, random_state=SEED, verbose=-1) \
.fit(Xb.iloc[tr], compra[tr]).predict_proba(Xb.iloc[te])[:, 1]
pos = tr[real[tr] > 0]
monto = np.expm1(LGBMRegressor(n_estimators=300, learning_rate=0.03, num_leaves=15, random_state=SEED, verbose=-1)
.fit(Xb.iloc[pos], np.log1p(real[pos])).predict(Xb.iloc[te]))
return pc * monto
def cat_log(tr, te):
m = CatBoostRegressor(iterations=600, depth=5, learning_rate=0.03, verbose=0, random_seed=SEED,
allow_writing_files=False, loss_function="RMSE")
return np.expm1(m.fit(Xb.iloc[tr], np.log1p(real[tr])).predict(Xb.iloc[te]))
def rango_promedio(*fns):
def f(tr, te):
return np.mean([pd.Series(fn(tr, te)).rank().values for fn in fns], axis=0)
return f
candidatos = [
("Línea base: gasto histórico promedio proyectado.", "Regla: promedio histórico", lambda tr, te: regla[te]),
("Modelo probabilístico clásico de CLV.", "BG/NBD + Gamma-Gamma", lambda tr, te: bg[te]),
("Lo reciente predice mejor: solo el gasto de los últimos 3 meses.", "Regla: gasto últimos 3 meses",
lambda tr, te: X.gasto_ult_3m.values[te]),
("LightGBM sobre log(gasto): el logaritmo doma la cola larga y mejora el orden.", "LightGBM · objetivo log",
lgb_log(X)),
("Stacking: la predicción BG/NBD como variable del LightGBM.", "LightGBM · log + BG/NBD", lgb_log(Xb)),
("Modelo de dos partes: ¿comprará? × ¿cuánto si compra?", "Hurdle: P(compra) × gasto", hurdle),
("CatBoost con BG/NBD como variable.", "CatBoost · log + BG/NBD", cat_log),
("Promedio de rangos de los mejores enfoques.", "Ensamble de rangos (BG/NBD + LightGBM + hurdle)",
rango_promedio(lambda tr, te: bg[te], lgb_log(Xb), hurdle)),
]
bit = Bitacora("Spearman", n_train=int(len(X) * 0.8), n_val=int(len(X) * 0.2))
tops = {}
for hip, nombre, fn in candidatos:
s, top = puntuar(fn)
tops[nombre] = top
bit.probar(hip, nombre, s, nota=f"Top 20 % concentra {top:.0%} del gasto")
campeon = bit.campeon["Candidato"]
base = bit.filas[0]
_, p_base = t_test_corregido(bit.campeon["_scores"], base["_scores"], int(len(X) * 0.8), int(len(X) * 0.2))
tabla_final = pd.DataFrame([
{"Modelo": f"Línea base: {base['Candidato']}", "Spearman CV": round(base["Spearman"], 4),
"Top 20 % concentra": f"{tops[base['Candidato']]:.1%}"},
{"Modelo": f"Campeón: {campeon}", "Spearman CV": round(bit.campeon["Spearman"], 4),
"Top 20 % concentra": f"{tops[campeon]:.1%}"}])
result = bit.resultado(
"Búsqueda del mejor modelo de CLV",
"Ordenar mejor a los clientes por su gasto futuro para priorizar retención y marketing.",
[f"{len(bit.filas)} enfoques con los mismos 10 folds. Campeón: {campeon} — Spearman {bit.campeon['Spearman']:.3f} vs. "
f"{base['Spearman']:.3f} de la regla, y su top 20 % concentra {tops[campeon]:.0%} vs. {tops[base['Candidato']]:.0%} del gasto real.",
"Cambio de métrica: el error absoluto lo dominan unos cuantos mayoristas; para priorizar clientes importa el "
f"orden. Contra la regla, la mejora del campeón tiene p = {p_base:.3f} (t corregido)"
+ (": es real." if p_base < 0.05 else ": es consistente pero no concluyente."),
"Lo que funcionó: separar ¿comprará? de ¿cuánto? (hurdle) y combinar enfoques distintos por rangos. Usar BG/NBD "
"como variable de LightGBM no ayudó; sí ayudó dentro de CatBoost y en el ensamble.",
"Nota: aquí la métrica de CV ya es fuera de muestra para cada cliente (predicción fuera de fold); el objetivo es "
"un periodo futuro, por lo que también es validación temporal."],
tabla_final,
graficas_extra=[bars("top20", "Concentración del gasto real en el top 20 % predicho", list(tops),
[round(v * 100, 1) for v in tops.values()], fmt="pct")],
leccion="Para CLV la métrica correcta es de orden; combinar modelos distintos ordena mejor que cualquiera solo.",
)
export_experimento("p08-segmentacion-clv", result)
return result
if __name__ == "__main__":
run()
Perfil de segmentos
| Segmento | Clientes | % clientes | Recencia (días, mediana) | Compras (mediana) | Gasto (mediana, £) | % del ingreso |
|---|---|---|---|---|---|---|
| Campeones | 713 | 16.4 | 8 | 10 | 3,729 | 64.7 |
| Leales | 1,193 | 27.5 | 54 | 4 | 1,342 | 24.1 |
| Nuevos / prometedores | 810 | 18.7 | 16 | 2 | 470 | 5 |
| Dormidos | 1,622 | 37.4 | 173 | 1 | 297 | 6.2 |
Comparación de modelos de CLV (validación temporal)
| Modelo | MAE £ | Spearman | Top 20 % concentra | Gasto total predicho vs. real |
|---|---|---|---|---|
| Regla: promedio histórico | 693 [570–827] | 0.511 | 63% | 85% |
| BG/NBD + Gamma-Gamma | 668 [551–817] | 0.508 | 64% | 59% |
| LightGBM (CV 5-fold) | 663 [523–842] | 0.525 | 63% | 61% |
Diagnóstico de número de clusters
| k | Inercia | Silueta (↑) | Davies-Bouldin (↓) |
|---|---|---|---|
| 2 | 6,485.71 | 0.435 | 0.892 |
| 3 | 4,877.252 | 0.337 | 1.047 |
| 4 | 3,962.43 | 0.338 | 1.011 |
| 5 | 3,330.675 | 0.315 | 0.995 |
| 6 | 2,875.24 | 0.311 | 1.022 |
| 7 | 2,573.253 | 0.306 | 0.977 |
| 8 | 2,359.807 | 0.3 | 0.993 |
Pruébalo: Python en tu navegador
Una versión compacta del análisis, ejecutada con Pyodide (CPython en WebAssembly). Edita el código y vuelve a ejecutarlo; si defines la variable grafica, se dibuja abajo.
Código completo
El script exacto que generó los resultados de esta página (python run_all.py).
"""#8 Segmentación RFM y valor futuro del cliente (CLV predictivo).
Datos: Online Retail (UCI), 541,909 transacciones de un minorista en línea (dic-2010 a dic-2011).
1) Segmentación RFM con K-Means. 2) CLV: con 9 meses de historia se predice el gasto de los
3 meses siguientes y se valida contra lo que realmente ocurrió.
"""
import io
import numpy as np
import pandas as pd
from lightgbm import LGBMRegressor
from lifetimes import BetaGeoFitter, GammaGammaFitter
from lifetimes.utils import ConvergenceError, summary_data_from_transaction_data
from scipy.stats import spearmanr
from sklearn.cluster import KMeans
from sklearn.metrics import adjusted_rand_score, davies_bouldin_score, silhouette_score
from sklearn.mixture import GaussianMixture
from sklearn.model_selection import KFold
from sklearn.preprocessing import StandardScaler
from common import SEED, bars, download, export, line, read_from_zip, table
from validacion import model_card
URL = "https://archive.ics.uci.edu/static/public/352/online+retail.zip"
CORTE = pd.Timestamp("2011-09-01") # calibración: antes del corte · validación: 3 meses después
def load() -> pd.DataFrame:
cache = download(URL, "online_retail.zip").with_suffix(".parquet")
if cache.exists():
return pd.read_parquet(cache)
df = pd.read_excel(io.BytesIO(read_from_zip(cache.with_suffix(".zip"), ".xlsx")))
df = df.dropna(subset=["CustomerID"])
df = df[(df.Quantity > 0) & (df.UnitPrice > 0)]
df["monto"] = df.Quantity * df.UnitPrice
df = df[["InvoiceNo", "InvoiceDate", "CustomerID", "Country", "monto"]]
df.to_parquet(cache)
return df
def rfm(tx: pd.DataFrame, fecha_ref: pd.Timestamp) -> pd.DataFrame:
f = tx.groupby("CustomerID").agg(ultima=("InvoiceDate", "max"), primera=("InvoiceDate", "min"),
frecuencia=("InvoiceNo", "nunique"), monetario=("monto", "sum"))
f["recencia"] = (fecha_ref - f.ultima).dt.days
f["antiguedad"] = (fecha_ref - f.primera).dt.days
f["ticket"] = f.monetario / f.frecuencia
return f.drop(columns=["ultima", "primera"])
def estabilidad(Z: np.ndarray, k: int, n=20) -> tuple[float, float]:
"""ARI medio entre la segmentación completa y la de submuestras del 80 % (sobre las filas comunes)."""
rng = np.random.default_rng(SEED)
base = KMeans(k, n_init=10, random_state=SEED).fit_predict(Z)
aris = []
for i in range(n):
idx = rng.choice(len(Z), int(len(Z) * 0.8), replace=False)
lab = KMeans(k, n_init=5, random_state=i).fit_predict(Z[idx])
aris.append(adjusted_rand_score(base[idx], lab))
return float(np.mean(aris)), float(np.std(aris))
def clv_bgnbd(cal: pd.DataFrame, dias: int) -> pd.Series:
"""BG/NBD (frecuencia de compra) × Gamma-Gamma (valor por compra): el estándar probabilístico de CLV."""
d = cal.assign(dia=cal.InvoiceDate.dt.normalize()).groupby(["CustomerID", "dia"]).monto.sum().reset_index()
# Unidad semanal: con días el optimizador no converge (muchas compras el mismo día de la semana)
s = summary_data_from_transaction_data(d, "CustomerID", "dia", monetary_value_col="monto",
observation_period_end=CORTE - pd.Timedelta(days=1), freq="W")
try:
bgf = BetaGeoFitter(penalizer_coef=0.0).fit(s.frequency, s.recency, s["T"])
except ConvergenceError:
bgf = BetaGeoFitter(penalizer_coef=0.1).fit(s.frequency, s.recency, s["T"])
compras = bgf.conditional_expected_number_of_purchases_up_to_time(dias / 7, s.frequency, s.recency, s["T"])
rep = s[s.frequency > 0]
ggf = GammaGammaFitter(penalizer_coef=0.01).fit(rep.frequency, rep.monetary_value)
valor = ggf.conditional_expected_average_profit(s.frequency, s.monetary_value)
valor[s.frequency == 0] = rep.monetary_value.mean()
return (compras * valor).rename("bgnbd")
def run():
tx = load()
ref = tx.InvoiceDate.max().normalize() + pd.Timedelta(days=1)
r = rfm(tx, ref)
# ---------- 1) Segmentación ----------
L = np.log1p(r[["recencia", "frecuencia", "monetario"]])
Z = StandardScaler().fit_transform(L)
rng = np.random.default_rng(SEED)
muestra = rng.choice(len(Z), min(3000, len(Z)), replace=False)
diag = []
for k in range(2, 9):
km_k = KMeans(k, n_init=10, random_state=SEED).fit(Z)
diag.append({"k": k, "inercia": km_k.inertia_,
"silueta": silhouette_score(Z[muestra], km_k.labels_[muestra]),
"davies_bouldin": davies_bouldin_score(Z, km_k.labels_)})
diag = pd.DataFrame(diag)
K = 4 # elección de negocio (4 segmentos accionables) validada abajo con silueta y estabilidad
km = KMeans(K, n_init=10, random_state=SEED).fit(Z)
r["cluster"] = km.labels_
ari_m, ari_s = estabilidad(Z, K)
gmm = GaussianMixture(K, random_state=SEED, n_init=3).fit(Z)
ari_gmm = adjusted_rand_score(km.labels_, gmm.predict(Z))
# Regla de negocio clásica: quintiles RFM → 4 grupos por puntaje total
score_rfm = (pd.qcut(-r.recencia, 5, labels=False, duplicates="drop") + pd.qcut(r.frecuencia.rank(method="first"), 5, labels=False)
+ pd.qcut(r.monetario, 5, labels=False))
ari_regla = adjusted_rand_score(km.labels_, pd.qcut(score_rfm.rank(method="first"), K, labels=False))
perfil = r.groupby("cluster").agg(clientes=("recencia", "size"), recencia=("recencia", "median"),
frecuencia=("frecuencia", "median"), monetario=("monetario", "median"),
ingreso=("monetario", "sum"))
nombres = {perfil.monetario.idxmax(): "Campeones", perfil.recencia.idxmax(): "Dormidos"}
resto = perfil.drop(index=list(nombres)).sort_values("monetario", ascending=False).index
nombres.update(dict(zip(resto, ["Leales", "Nuevos / prometedores"])))
perfil.index = perfil.index.map(nombres)
perfil["% ingreso"] = (perfil.ingreso / perfil.ingreso.sum() * 100).round(1)
perfil["% clientes"] = (perfil.clientes / perfil.clientes.sum() * 100).round(1)
perfil = perfil.sort_values("monetario", ascending=False)
# Perfil estandarizado (z) de cada segmento para la gráfica de coordenadas paralelas
zc = pd.DataFrame(Z, columns=["Recencia", "Frecuencia", "Monetario"]).groupby(r["cluster"].map(nombres).values).mean()
# ---------- 2) CLV: 9 meses de historia → gasto de los 3 meses siguientes ----------
cal, val = tx[tx.InvoiceDate < CORTE], tx[tx.InvoiceDate >= CORTE]
dias = (tx.InvoiceDate.max().normalize() - CORTE).days + 1
Xc = rfm(cal, CORTE)
Xc["meses_activos"] = cal.groupby("CustomerID").InvoiceDate.apply(lambda s: s.dt.to_period("M").nunique())
Xc["reino_unido"] = (cal.groupby("CustomerID").Country.first() == "United Kingdom").astype(int)
real = val.groupby("CustomerID").monto.sum().reindex(Xc.index, fill_value=0).values
# (a) regla simple, (b) BG/NBD + Gamma-Gamma, (c) LightGBM con predicción fuera de fold
pred = {"Regla: promedio histórico": (Xc.monetario / np.maximum(Xc.antiguedad, 90) * dias).values,
"BG/NBD + Gamma-Gamma": clv_bgnbd(cal, dias).reindex(Xc.index).fillna(0).values}
oof = np.zeros(len(Xc))
for tr, te in KFold(5, shuffle=True, random_state=SEED).split(Xc):
m = LGBMRegressor(n_estimators=400, learning_rate=0.03, num_leaves=15, min_child_samples=30,
objective="regression_l1", random_state=SEED, verbose=-1)
oof[te] = m.fit(Xc.iloc[tr], real[tr]).predict(Xc.iloc[te])
pred["LightGBM (CV 5-fold)"] = oof
def concentracion(score):
top = np.argsort(-score)[: int(len(score) * 0.2)]
return real[top].sum() / real.sum()
def mae_ci(e):
b = [np.mean(np.abs(e[i])) for i in (rng.integers(0, len(e), len(e)) for _ in range(300))]
return np.mean(np.abs(e)), *np.percentile(b, [2.5, 97.5])
evals = []
for nombre, pr in pred.items():
mae, lo, hi = mae_ci(real - pr)
evals.append({"Modelo": nombre, "MAE £": f"{mae:,.0f} [{lo:,.0f}–{hi:,.0f}]", "mae": mae,
"Spearman": round(spearmanr(real, pr)[0], 3), "Top 20 % concentra": f"{concentracion(pr):.0%}",
"Gasto total predicho vs. real": f"{pr.sum() / real.sum():.0%}"})
evals = pd.DataFrame(evals)
mejor = evals.loc[evals.mae.idxmin(), "Modelo"]
dec = pd.DataFrame({"pred": pred[mejor], "real": real})
dec["decil"] = pd.qcut(-dec.pred.rank(method="first"), 10, labels=[f"D{i}" for i in range(1, 11)])
dec = dec.groupby("decil", observed=True).agg(pred=("pred", "mean"), real=("real", "mean")).reset_index()
perfil_tbl = perfil.reset_index().rename(columns={"cluster": "Segmento"})[
["Segmento", "clientes", "% clientes", "recencia", "frecuencia", "monetario", "% ingreso"]]
perfil_tbl.columns = ["Segmento", "Clientes", "% clientes", "Recencia (días, mediana)",
"Compras (mediana)", "Gasto (mediana, £)", "% del ingreso"]
perfil_tbl["Gasto (mediana, £)"] = perfil_tbl["Gasto (mediana, £)"].round(0)
diag_tbl = diag.round(3).rename(columns={"inercia": "Inercia", "silueta": "Silueta (↑)",
"davies_bouldin": "Davies-Bouldin (↓)"})
camp = perfil.iloc[0]
sil_k = float(diag.set_index("k").loc[K, "silueta"])
k_sil = int(diag.loc[diag.silueta.idxmax(), "k"])
e = evals.set_index("Modelo")
result = {
"title": "Segmentación RFM y CLV predictivo",
"summary": [
f"{len(r):,} clientes en {K} segmentos. Validación: silueta {sil_k:.2f} (la máxima es k = {k_sil}, pero k = {K} "
f"es más accionable), estabilidad bootstrap ARI {ari_m:.2f} ± {ari_s:.2f} y acuerdo con un modelo de mezclas "
f"gaussianas ARI {ari_gmm:.2f}.",
f"Los \"{perfil.index[0]}\" son {camp['% clientes']:.0f} % de los clientes y generan {camp['% ingreso']:.0f} % del "
f"ingreso. Acuerdo con la regla clásica de quintiles RFM: ARI {ari_regla:.2f}.",
"CLV a 3 meses fuera de muestra — MAE: " + ", ".join(f"{k} £{v:,.0f}" for k, v in e.mae.items()) +
f". Los IC bootstrap se traslapan (empate práctico) y todos subestiman el gasto total "
f"(temporada navideña fuera de la historia): {', '.join(f'{k.split(' (')[0]} {v}' for k, v in e['Gasto total predicho vs. real'].items())}.",
"Cambio de enfoque: además de ML, un modelo probabilístico (BG/NBD + Gamma-Gamma) que se explica con dos "
"ideas de negocio —qué tan seguido compra y cuánto gasta por compra— y que no necesita etiquetas.",
],
"metrics": [
{"label": "Silueta k = 4", "value": f"{sil_k:.2f}"},
{"label": "Estabilidad (ARI)", "value": f"{ari_m:.2f} ± {ari_s:.2f}"},
{"label": "Mejor CLV", "value": mejor.split(" (")[0]},
{"label": "MAE mejor CLV", "value": f"£{e.loc[mejor, 'mae']:,.0f}"},
],
"charts": [
line("perfil", "Perfil de cada segmento (z-score de log RFM)",
{seg: [[c, round(float(v), 3)] for c, v in fila.items()] for seg, fila in zc.iterrows()},
x_label="Dimensión", y_label="Desviaciones estándar vs. promedio", fmt="dec",
subtitle="Recencia alta = hace mucho que no compra"),
bars("ingreso", "% del ingreso por segmento", perfil.index, perfil["% ingreso"], fmt="pct"),
line("sil", "Elección de k: silueta (más alto es mejor)", {"Silueta": diag[["k", "silueta"]].round(3).values.tolist()},
x_label="Número de clusters", y_label="Silueta", fmt="dec", markers=[[K, "k elegido"]]),
line("codo", "Método del codo", {"Inercia": diag[["k", "inercia"]].round(1).values.tolist()},
x_label="Número de clusters", y_label="Inercia", markers=[[K, "k elegido"]]),
bars("clv", "CLV: error absoluto medio por modelo (£, menor es mejor)", evals.Modelo, evals.mae.round(0).values,
fmt="gbp"),
line("dec", f"CLV ({mejor}): gasto predicho vs. real por decil",
{"Predicho": [[d, round(float(v), 1)] for d, v in zip(dec.decil.astype(str), dec.pred)],
"Real": [[d, round(float(v), 1)] for d, v in zip(dec.decil.astype(str), dec.real)]},
x_label="Decil de CLV predicho", y_label="£ en los 3 meses siguientes", fmt="gbp"),
],
"tables": [table("Perfil de segmentos", perfil_tbl),
table("Comparación de modelos de CLV (validación temporal)", evals.drop(columns="mae")),
table("Diagnóstico de número de clusters", diag_tbl)],
"card": model_card(
"K-Means sobre log-RFM estandarizado; CLV con regla simple, BG/NBD + Gamma-Gamma y LightGBM.",
["Clustering: silueta, Davies-Bouldin, codo, estabilidad bootstrap (ARI) y acuerdo con GMM y regla RFM.",
"CLV: validación temporal (historia hasta ago-2011, objetivo sep–dic 2011); LightGBM con predicción "
"fuera de fold; MAE con IC bootstrap, Spearman y concentración del top 20 %."],
["Las devoluciones y facturas sin cliente se excluyen.",
"BG/NBD supone que un cliente \"muere\" una sola vez y compra como proceso de Poisson mientras vive."],
["Un solo año de historia: el trimestre objetivo incluye la temporada navideña.",
"Clientes mayoristas extremos dominan el error absoluto; para decisiones conviene ver el orden (Spearman)."]),
}
sample = r[["recencia", "frecuencia", "monetario"]].round(2).sample(1500, random_state=1)
export("p08-segmentacion-clv", result, sample)
return result
if __name__ == "__main__":
run()