Capítulo 23 de 27 8 secciones 11 min

Agrupar clientes sin decirle cómo

K-means sobre los clientes de verdad, la silueta que dice que no hay grupos naturales, y detección de anomalías.

Todo el libro hasta aquí necesitaba una respuesta correcta y aquí no hay ninguna. Yo uso esto para segmentar clientes, y lo primero que hago siempre es medir si los grupos existen: sobre estos 617 clientes la silueta sale 0,2950 y baja cuanto más grupos pido, o sea que no hay grupos naturales. Segmentar igual está bien, sabiendo que es una decisión y no un hallazgo 🧩

Veintiún capítulos prediciendo, y todos necesitaban lo mismo: una columna con la respuesta correcta. Este va de cuando no la hay 🧩

Y es lo que más me piden después de predecir, con una frase que ya conoces: "queremos segmentar a los clientes". Antes de empezar, la pregunta: ¿cómo sabrías si los grupos que encontraste existen de verdad o los inventó el algoritmo? De eso va casi todo el capítulo 🔍

Una fila por cliente, que es donde empieza todo

import numpy as np
import pandas as pd
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
from sklearn.preprocessing import StandardScaler

URL = 'https://missyera.com/static/datasets/ventas-miss-yera.csv'

v = pd.read_csv(URL).drop_duplicates()
v['monto'] = pd.to_numeric(v['monto'].str.replace(',', '.'))
v = v[v['monto'] > 0]
v['fecha'] = pd.to_datetime(v['fecha'], format='mixed', dayfirst=True, errors='coerce')

corte = v['fecha'].max()
c = v.groupby('cliente_id').agg(
    total=('monto', 'sum'), compras=('monto', 'count'),
    ticket=('monto', 'mean'), ultima=('fecha', 'max')).reset_index()
c['dias_sin_comprar'] = (corte - c['ultima']).dt.days
c = c.dropna(subset=['dias_sin_comprar'])

COLS = ['total', 'compras', 'ticket', 'dias_sin_comprar']
print('clientes:', len(c))
print(c[COLS].describe().round(1).loc[['mean', 'min', 'max']].to_string())
clientes: 617
        total  compras  ticket  dias_sin_comprar
mean   3922.6      4.8   814.3             245.7
min      36.5      1.0    36.5               0.0
max   12823.4     14.0  2781.1             687.0

Esas cuatro columnas son las de siempre en segmentación: cuánto compra, cuántas veces, de a cuánto, y hace cuánto que no vuelve. Si te suena a las tres letras de RFM, es exactamente eso 💡

Escalar no es opcional aquí

El total llega a 19.929 y las compras a 15. Si le das eso a k-means tal cual, la distancia entre dos clientes la decide el total y las otras tres columnas no existen.

X = c[COLS].to_numpy(float)
Xs = StandardScaler().fit_transform(X)

sin_escalar = KMeans(n_clusters=4, n_init=10, random_state=42).fit(X)
escalado = KMeans(n_clusters=4, n_init=10, random_state=42).fit(Xs)
print('tamanos sin escalar:', np.bincount(sin_escalar.labels_))
print('tamanos escalado   :', np.bincount(escalado.labels_))
print('silueta sin escalar:', round(silhouette_score(Xs, sin_escalar.labels_), 4))
print('silueta escalado   :', round(silhouette_score(Xs, escalado.labels_), 4))
tamanos sin escalar: [171 171 227  48]
tamanos escalado   : [133 141 198 145]
silueta sin escalar: 0.1592
silueta escalado   : 0.2798

Sin escalar sale un grupo de 48 y otro de 227, y la silueta es casi la mitad. Es el mismo escalado del capítulo 10, y aquí no es una buena práctica: es la diferencia entre agrupar por cuatro cosas o por una ⚖️

Sin etiquetas hay dos preguntas distintas: agrupar, donde a todos les toca un grupo y la silueta de 0,2950 dice que no hay grupos naturales, y detectar anomalías, donde salen 19 raros porque se pidió el 3 por ciento.
Las dos herramientas contestan preguntas distintas, y en las dos el número que sale lo decides tú: cuántos grupos y qué porcentaje es raro.

Cuántos grupos, y por qué el codo no contesta

Ahora la pregunta de siempre. El método que te van a enseñar se llama el codo: mira cómo baja la inercia (lo apretados que están los grupos) y quédate donde la curva hace una esquina.

for k in range(2, 9):
    m = KMeans(n_clusters=k, n_init=10, random_state=42).fit(Xs)
    print(f'k={k}  inercia {m.inertia_:9.1f}  silueta {silhouette_score(Xs, m.labels_):.4f}')
k=2  inercia    1647.3  silueta 0.2950
k=3  inercia    1291.0  silueta 0.2830
k=4  inercia    1013.2  silueta 0.2798
k=5  inercia     903.7  silueta 0.2465
k=6  inercia     805.7  silueta 0.2471
k=7  inercia     725.7  silueta 0.2494
k=8  inercia     664.5  silueta 0.2511

Mira la columna de la inercia: baja, baja y sigue bajando. No hay codo, hay una cuesta 🫠

Y no es mala suerte: la inercia siempre baja al subir k, por construcción. Con un grupo por cliente sería cero. El codo solo aparece cuando los datos de verdad vienen en montoncitos separados, que es lo que pasa en los ejemplos de los tutoriales y casi nunca en una cartera de clientes.

La otra columna es más honesta. La silueta mide, para cada punto, cuánto más cerca está de su grupo que del grupo vecino, en una escala de -1 a 1. Y aquí dice dos cosas incómodas:

  • 📉 El máximo está en k=2 y de ahí para abajo. Cuantos más grupos pides, peor separados están.
  • 🤏 Y el máximo es 0,2950, que es bajo. Por encima de 0,5 se habla de grupos razonables. Esto no llega ni a la mitad.

Estos clientes no vienen en grupos. Segmentarlos es una decisión de negocio, no un descubrimiento del algoritmo.

Y eso está bien, siempre que se diga. Cortar una cartera en cuatro sirve para organizar al equipo comercial aunque la naturaleza no la haya cortado. Lo que no se puede es presentarlo como "descubrimos cuatro tipos de cliente", porque no se descubrió nada: se decidió 🧭

Los grupos, que es donde está el trabajo de verdad

El algoritmo es una línea. Lo que cuesta es mirarlos y ponerles nombre.

c['grupo'] = escalado.labels_
print(c.groupby('grupo')[COLS].mean().round(1).to_string())
print()
print(c['grupo'].value_counts().sort_index().to_string())
        total  compras  ticket  dias_sin_comprar
grupo                                           
0      7274.1      7.7   975.0             179.7
1      1418.5      2.8   492.6             367.0
2      3152.0      5.2   614.3             162.8
3      4335.8      3.6  1253.0             301.3

grupo
0    133
1    141
2    198
3    145

Ahora sí se lee, y encima se lee bonito 💰

  • 👑 Grupo 0: compran mucho, seguido y caro, y volvieron hace poco. Son 133 y hay que cuidarlos.
  • 🥶 Grupo 1: poco, poquísimas veces y llevan 367 días sin aparecer. Están perdidos y hay que decidir si se recuperan o se sueltan.
  • 🌱 Grupo 2: el más numeroso, compra seguido pero barato, y está activo. Aquí es donde se sube el ticket.
  • 💎 Grupo 3: ticket de 1.253, el más alto de los cuatro, pero solo 3,6 compras y 301 días sin volver. Compran poco y grande.

Fíjate en el 3, que es el que justifica el capítulo entero: no lo habrías encontrado ordenando por total, porque en total queda en medio. Solo aparece cuando miras las cuatro columnas a la vez 🔍

Y cada uno de esos cuatro párrafos es una acción distinta del equipo comercial. Ese es el entregable, no la tabla.

Los raros, que es la otra mitad del capítulo

Segmentar parte a todos en grupos. Detectar anomalías busca los que no son de ninguno, que es una pregunta distinta y muy útil:

  • Fraude
  • Errores de carga
  • El cliente que se comporta raro antes de irse
from sklearn.ensemble import IsolationForest

iso = IsolationForest(contamination=0.03, random_state=42).fit(Xs)
c['raro'] = iso.predict(Xs) == -1
print('marcados como raros:', int(c['raro'].sum()))
print()
print('los raros :', c[c['raro']][COLS].mean().round(1).to_dict())
print('el resto  :', c[~c['raro']][COLS].mean().round(1).to_dict())
marcados como raros: 19

los raros : {'total': 6629.1, 'compras': 5.9, 'ticket': 1365.4, 'dias_sin_comprar': 280.9}
el resto  : {'total': 3836.6, 'compras': 4.8, 'ticket': 796.8, 'dias_sin_comprar': 244.5}

Diecinueve clientes con casi el doble de ticket que el resto. Y ojo con el contamination=0.03, que no es un detalle técnico: eres tú quien decide qué porcentaje va a salir raro. El algoritmo no lo descubre, lo obedece.

Compáralo con la herramienta más simple de todas, la regla de las tres desviaciones del libro de estadística:

z = np.abs((c['total'] - c['total'].mean()) / c['total'].std()) > 3
print('la regla de la z sobre el total marca:', int(z.sum()))
print('de esos, cuantos marca tambien el bosque:', int((z & c['raro']).sum()))
la regla de la z sobre el total marca: 4
de esos, cuantos marca tambien el bosque: 4

Los cuatro de la z están dentro de los diecinueve del bosque 🎯

Y eso dice exactamente para qué sirve cada uno. La z mira una columna y encuentra al que gastó una barbaridad. El bosque mira las cuatro a la vez y encuentra además al que tiene una combinación rara sin que ninguna cifra suelta destaque: mucho ticket con pocas compras, por ejemplo.

Si te sirve una regla para elegir: empieza siempre por la z, que es una línea y se explica en una reunión. Sube al bosque cuando lo raro sea la combinación y no el número.

El error que sale al pedir un solo grupo

silhouette_score(Xs, np.zeros(len(Xs), dtype=int))
ValueError: Number of labels is 1. Valid values are 2 to n_samples - 1 (inclusive)

Tiene sentido si piensas qué mide la silueta: cuánto más cerca está cada punto de su grupo que del vecino. Sin vecino no hay nada que comparar.

Y por eso no existe la silueta de k=1, que sería la forma natural de preguntar "¿de verdad hacen falta grupos?". Esa pregunta hay que contestarla mirando si el máximo es alto, y aquí no lo era 🧐

La trampa

Un equipo segmenta la cartera y presenta cuatro perfiles de cliente. Al mes siguiente vuelven a correrlo con los datos nuevos para actualizar los grupos y presentan el resultado igual.

# mes 1
grupos_enero = KMeans(n_clusters=4, random_state=42).fit_predict(escalar(enero))
# -> grupo 0 = "los premium", grupo 1 = "los perdidos", ...

# mes 2, con los datos nuevos
grupos_febrero = KMeans(n_clusters=4, random_state=42).fit_predict(escalar(febrero))

# y se comparan los dos meses grupo por grupo
crecimiento = grupos_febrero.mean() - grupos_enero.mean()
Qué está mal

El numero de grupo que devuelve k-means no significa nada y no es estable: sale del orden en que arrancaron los centros. El grupo 0 de enero y el grupo 0 de febrero pueden ser perfiles completamente distintos, asi que restarlos no mide crecimiento, mide nada. Y no da error: da un numero que alguien va a poner en una diapositiva. Se arregla de dos maneras y hay que elegir una: o se guarda el modelo de enero y en febrero solo se usa predict, sin volver a entrenar, o se vuelve a entrenar y despues se emparejan los grupos comparando sus centros. Lo que nunca se puede hacer es dar por hecho que el numero es el mismo perfil.

Ejercicios

Seis, y el 4 es el que separa un análisis de un adorno 💛

1. La semilla, y si los grupos aguantan

Corre k-means con cinco semillas distintas y mira si salen los mismos grupos.

for s in range(5):
    m = KMeans(n_clusters=4, n_init=10, random_state=s).fit(Xs)
    print(f'semilla {s}: tamanos {np.sort(np.bincount(m.labels_))}  '
          f'silueta {silhouette_score(Xs, m.labels_):.4f}')

Si los tamaños ordenados coinciden, la partición es la misma aunque cambien los números de grupo. Si no coinciden, tienes un problema que hay que contar antes de presentar nada.

2. Qué columna manda

Agrupa usando solo total y compras, y compara la silueta contra la de las cuatro columnas.

Menos columnas casi siempre da silueta más alta, y eso no significa que los grupos sean mejores: significa que en menos dimensiones es más fácil parecer separado. Es el mismo aviso de la maldición de la dimensión, al revés.

3. El logaritmo antes de agrupar

Aplica logaritmo a total y ticket antes de escalar, y vuelve a mirar la silueta.

c2 = c.copy()
c2['total'] = np.log1p(c2['total'].clip(lower=0))
c2['ticket'] = np.log1p(c2['ticket'].clip(lower=0))
Xs2 = StandardScaler().fit_transform(c2[COLS].to_numpy(float))
m = KMeans(n_clusters=4, n_init=10, random_state=42).fit(Xs2)
print('silueta con logaritmo:', round(silhouette_score(Xs2, m.labels_), 4))

El dinero casi siempre tiene cola larga, y el logaritmo la endereza. Fíjate en el clip: hay clientes con total negativo por las notas de crédito, y el logaritmo de un negativo no existe.

4. Ponles nombre y una acción

Sin código. Escribe para cada uno de los cuatro grupos una frase con el nombre y una acción concreta del equipo comercial.

Este es el ejercicio de verdad, y el que casi nadie hace. Una segmentación que no termina en cuatro acciones distintas es una tabla bonita que nadie va a usar. Si a algún grupo no le encuentras acción, esa es la señal de que sobran grupos 🧭

5. Sube la contaminación

Prueba contamination en 0,01, 0,05 y 0,10 y cuenta cuántos raros salen.

for cont in [0.01, 0.03, 0.05, 0.10]:
    iso = IsolationForest(contamination=cont, random_state=42).fit(Xs)
    print(f'contamination={cont}: {int((iso.predict(Xs) == -1).sum())} raros')

Salen exactamente los que pediste. Eso es lo que hay que entender: el número de anomalías es un presupuesto que tú fijas, normalmente por cuántos casos puede revisar el equipo a la semana, no por cuántos hay.

6. Agrupar y después predecir

Mete el grupo como columna en el modelo del capítulo 11 y mira si el AUC mejora.

A veces sube un poquito y casi nunca compensa. Y hay que tener cuidado con algo: si el grupo se calculó usando datos posteriores a la predicción, acabas de meter fuga de información, que es el capítulo 12. Los dias_sin_comprar son justo esa clase de columna 🚩

Comprueba que lo tienes

Corres k-means sobre tus clientes, la silueta sale 0,21 y el codo no aparece por ningún lado. ¿Qué haces?

  • Segmento igual si sirve al negocio, diciendo que es una decisión y no un hallazgo
  • Pruebo más valores de k hasta que la silueta suba
  • Concluyo que no se puede segmentar y paro
  • Cambio a otro algoritmo hasta que salgan grupos claros

Lo que te llevas

  • ⚖️ Sin escalar, k-means agrupa por la columna más grande y nada más.
  • 📉 El codo casi nunca aparece, porque la inercia siempre baja. La silueta sí contesta.
  • 🧭 Aquí la silueta máxima fue 0,2950 en k=2 y baja de ahí. No hay grupos naturales, y decirlo es parte del entregable.
  • 🔍 El trabajo no es correr el algoritmo: es mirar los grupos, ponerles nombre y sacar una acción de cada uno.
  • 🚨 En anomalías, tú fijas cuántas salen. La z mira una columna y el bosque mira la combinación.

Y si lo que quieres es medir cuánto pesa cada variable en vez de agrupar, eso es el capítulo 22, y con todos sus intervalos está en el libro de estadística desde cero 📐

Que tengas lindo día! 🌸

¿Tienes alguna duda o consulta?