Saltar al contenido
← Todos los proyectos

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.

📦 Datos: Online Retail (UCI) K-Means + siluetaEstabilidad (ARI)BG/NBD + Gamma-GammaCLV validado en el tiempo 🧬 Alternativa: Mapa de Kohonen (SOM) 🔬 Mejor algoritmo: Ensamble de rangos (BG/NBD + LightGBM + hurdle)

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.
Silueta k = 40.34
Estabilidad (ARI)0.93 ± 0.06
Mejor CLVLightGBM
MAE mejor CLV£663

Resultados

Perfil de cada segmento (z-score de log RFM)

Recencia alta = hace mucho que no compra

  • Campeones
  • Dormidos
  • Leales
  • Nuevos / prometedores
-1012RecenciaFrecuenciaMonetarioDimensión
Desviaciones estándar vs. promedio

% del ingreso por segmento

Campeones 64.7 %
Leales 24.1 %
Nuevos / prometedores 5 %
Dormidos 6.2 %

Elección de k: silueta (más alto es mejor)

0.30.350.40.452345678Número de clustersk elegido
Silueta

Método del codo

02 k4 k6 k2345678Número de clustersk elegido
Inercia

CLV: error absoluto medio por modelo (£, menor es mejor)

Regla: promedio histórico £693
BG/NBD + Gamma-Gamma £668
LightGBM (CV 5-fold) £663

CLV (LightGBM (CV 5-fold)): gasto predicho vs. real por decil

  • Predicho
  • Real
£0£2 k£4 kD1D3D5D7D9Decil de CLV predicho
£ en los 3 meses siguientes

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

12345678 1 1.6261.4121.3031.3471.191.6352.1682.8432 1.1541.1991.2011.1811.3871.4971.7712.0183 1.1091.1981.1271.0971.0871.3011.5491.2724 1.8381.1711.0330.9571.1511.4141.2995 1.2051.2090.9210.8450.8661.2011.1651.9356 1.1971.2060.9310.8220.9040.9691.0771.2077 1.8371.1450.8970.7910.7690.810.7490.7818 1.31.2110.9420.8640.7260.6080.670.458
0.458 2.843

Gasto futuro promedio por neurona (£, 3 meses)

Estimado con clientes de entrenamiento; así se predice el CLV de un cliente nuevo

12345678 1 £11.9 k£2 k£1.1 k£1.3 k£583£1.1 k£454£2.6 k2 £2.3 k£1.2 k£948£625£283£506£357£2763 £2.3 k£1.1 k£655£463£482£405£417£2544 £5.3 k£1.3 k£715£421£331£364£196£2005 £1.1 k£652£697£357£266£162£344£7.7 k6 £858£471£475£412£263£295£163£2197 £1.1 k£662£418£661£485£193£189£1748 £730£457£407£249£317£283£261£224
£162 £11.9 k

Plano de componente: gasto histórico (£)

12345678 1 £10.9 k£3.8 k£2.1 k£1.3 k£983£1.1 k£714£2842 £4.7 k£2.7 k£1.4 k£1.3 k£536£624£400£1733 £4.4 k£2.3 k£1.3 k£802£718£536£332£1654 £7.4 k£2.4 k£1.3 k£709£476£319£180£1065 £2.5 k£1.4 k£1.1 k£713£391£229£139£326 £2.3 k£1.2 k£1 k£601£415£235£103£577 £4.2 k£1.4 k£732£481£335£181£132£1008 £1.6 k£910£504£356£314£240£186£137
£32 £10.9 k

Plano de componente: recencia (días)

12345678 1 84.832.58.713163.62 21141112102638173 26403824295677404 26488382475591645 2344103117877281896 52541331381371401421647 981161471801521581772378 91161155243145179238231
2.5 243

Clientes por neurona

12345678 1 31332320362240262 28353338413655513 26372836355168394 24493443312354395 4158364032268186 34404857382239247 18206464440418 2247647479426028
4 79

Aprendizaje: error de cuantización durante el entrenamiento

00.5101,0002,0003,0004,0005,000Paso
Error

Silueta: SOM + jerárquico vs. K-Means

SOM → 4 grupos 0.224
K-Means k = 4 0.298

CLV a 3 meses en clientes de prueba

ModeloMAE £SpearmanTop 20 % concentra
Regla: promedio histórico7580.53974.9
SOM (gasto de la neurona)1,0490.50572.6

Segmentos del SOM

Segmento SOMClientesRecencia (mediana)Compras (mediana)Gasto histórico (mediana)Gasto futuro promedio £
S19952051,936.62,088.2
S2470311353.9507.8
S31,0801102600.9397.3
S47721611165371.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.
alt08_som_kohonen.py131 líneas
"""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.

Línea base0.5106Regla: promedio histórico
Campeón0.5414Ensamble de rangos (BG/NBD + LightGBM + hurdle)
Mejora en Spearman (validación)+0.0308más es mejor
  • 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
0.460.480.50.520.540246Iteración
Spearman

Ranking de candidatos (Spearman en validación)

Ensamble de rangos (BG/NBD + LightGBM + hurdle) 0.541
CatBoost · log + BG/NBD 0.534
Hurdle: P(compra) × gasto 0.527
LightGBM · objetivo log 0.518
LightGBM · log + BG/NBD 0.516
Regla: promedio histórico 0.511
BG/NBD + Gamma-Gamma 0.508
Regla: gasto últimos 3 meses 0.467

Concentración del gasto real en el top 20 % predicho

Regla: promedio histórico 62.3 %
BG/NBD + Gamma-Gamma 63.3 %
Regla: gasto últimos 3 meses 61.1 %
LightGBM · objetivo log 61.9 %
LightGBM · log + BG/NBD 61.8 %
Hurdle: P(compra) × gasto 63 %
CatBoost · log + BG/NBD 62.3 %
Ensamble de rangos (BG/NBD + LightGBM + hurdle) 63.3 %

Prueba final: campeón vs. línea base (una sola evaluación)

ModeloSpearman CVTop 20 % concentra
Línea base: Regla: promedio histórico0.51162.3%
Campeón: Ensamble de rangos (BG/NBD + LightGBM + hurdle)0.54163.3%

Bitácora de experimentos

IteraciónHipótesisCandidatoSpearmanDesv.DecisiónNotaΔ vs. campeónp
0Línea base: gasto histórico promedio proyectado.Regla: promedio histórico0.5110.036Línea baseTop 20 % concentra 62% del gasto——
1Modelo probabilístico clásico de CLV.BG/NBD + Gamma-Gamma0.5080.039Se descartaTop 20 % concentra 63% del gasto-0.0030.854
2Lo reciente predice mejor: solo el gasto de los últimos 3 meses.Regla: gasto últimos 3 meses0.4670.026Se descartaTop 20 % concentra 61% del gasto-0.0440.028
3LightGBM sobre log(gasto): el logaritmo doma la cola larga y mejora el orden.LightGBM · objetivo log0.5180.026Nuevo campeón (mejora no significativa)Top 20 % concentra 62% del gasto0.0080.657
4Stacking: la predicción BG/NBD como variable del LightGBM.LightGBM · log + BG/NBD0.5160.027Se descartaTop 20 % concentra 62% del gasto-0.0020.356
5Modelo de dos partes: ¿comprará? × ¿cuánto si compra?Hurdle: P(compra) × gasto0.5270.033Nuevo campeón (mejora no significativa)Top 20 % concentra 63% del gasto0.0090.419
6CatBoost con BG/NBD como variable.CatBoost · log + BG/NBD0.5340.028Nuevo campeón (mejora no significativa)Top 20 % concentra 62% del gasto0.0070.500
7Promedio de rangos de los mejores enfoques.Ensamble de rangos (BG/NBD + LightGBM + hurdle)0.5410.033Nuevo campeón (mejora no significativa)Top 20 % concentra 63% del gasto0.0080.430
exp08_clv.py119 líneas
"""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

SegmentoClientes% clientesRecencia (días, mediana)Compras (mediana)Gasto (mediana, £)% del ingreso
Campeones71316.48103,72964.7
Leales1,19327.55441,34224.1
Nuevos / prometedores81018.71624705
Dormidos1,62237.417312976.2

Comparación de modelos de CLV (validación temporal)

ModeloMAE £SpearmanTop 20 % concentraGasto total predicho vs. real
Regla: promedio histórico693 [570–827]0.51163%85%
BG/NBD + Gamma-Gamma668 [551–817]0.50864%59%
LightGBM (CV 5-fold)663 [523–842]0.52563%61%

Diagnóstico de número de clusters

kInerciaSilueta (↑)Davies-Bouldin (↓)
26,485.710.4350.892
34,877.2520.3371.047
43,962.430.3381.011
53,330.6750.3150.995
62,875.240.3111.022
72,573.2530.3060.977
82,359.8070.30.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.

demo.py · editable
La primera ejecución descarga Python + NumPy/pandas (~15 MB, una sola vez).

Código completo

El script exacto que generó los resultados de esta página (python run_all.py).

p08_segmentacion_clv.py224 líneas
"""#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()