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 ⚖️
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! 🌸