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.
De segmentar a recomendar, que es el mismo truco
Antes de seguir, una parada corta, porque de acá sale una familia entera de sistemas que seguro usas todos los días.
Todo lo de arriba se apoya en una sola idea: poner cada cliente como una fila de números y medir qué tan cerca están. Segmentar es agrupar a los que están cerca. Y si en vez de agruparlos preguntas quién está más cerca de uno en concreto, ya tienes un recomendador.
Eso es lo que hay detrás de "clientes como tú también compraron". No hay magia: se busca a los vecinos y se mira qué compraron ellos que este todavía no. En los libros le dicen filtrado colaborativo, y la versión que acabas de aprender a montar es la mitad del trabajo.
Las dos advertencias, que son las que hunden estos proyectos:
- El cliente nuevo no tiene vecinos. Si acaba de llegar y no compró nada, no hay con qué medir su distancia. Es el problema del arranque en frío y se resuelve por fuera: lo más vendido, lo de su zona, preguntarle.
- Recomendar lo parecido encierra. Si a quien compra arroz siempre le ofreces arroz, nunca descubre el aceite. Un recomendador que solo acierta es un recomendador que no vende nada nuevo, y eso no lo mide el acierto.
Y el que no te pregunta cuántos grupos quieres
Todo lo anterior lo hizo k-means, y k-means tiene una manía:
- Le pides tres grupos y te da tres
- Haya tres
- No haya ninguno
Es obediente, no sincero.
DBSCAN funciona al revés. En vez del número de grupos le dices qué tan cerca tienen que estar los clientes para considerarse vecinos, y él decide cuántos grupos salen. Y tiene un premio: lo que no entra en ninguno lo deja suelto, o sea que agrupa y detecta raros de una vez.
from sklearn.cluster import DBSCAN d = DBSCAN(eps=0.9, min_samples=8).fit(Xs) c['suelto'] = d.labels_ == -1 print('grupos que encuentra:', len(set(d.labels_)) - (1 if -1 in d.labels_ else 0)) print('sueltos:', int(c['suelto'].sum())) print('coinciden con los raros del IsolationForest:', int((c['suelto'] & c['raro']).sum()), 'de', int(c['raro'].sum()))
grupos que encuentra: 1 sueltos: 23 coinciden con los raros del IsolationForest: 14 de 19
Un grupo. Uno solo, con 23 clientes colgando por fuera.
Y eso no es que DBSCAN haya fallado: es la respuesta honesta a la pregunta que k-means no contesta. Estos clientes no forman islas separadas, forman una nube continua con una cola de gente que gasta mucho más. Cuando le pediste cuatro grupos a k-means, cortó esa nube en cuatro pedazos porque se lo pediste, no porque estuvieran ahí 🔀
Lo cual no invalida la segmentación de arriba. Cortar una nube continua en cuatro tramos sirve igual para trabajar: son cuatro cajones útiles para decidir a quién llamas. Lo que cambia es cómo lo cuentas. "Descubrimos cuatro tipos de cliente" no es lo que pasó. "Partimos a los clientes en cuatro tramos según cuánto y cada cuánto compran" sí, y encima se defiende mejor en una reunión.
Y mira la última línea: de los 19 raros que marcó el IsolationForest, DBSCAN coincide en 14. Dos métodos distintos señalando a la misma gente es la mejor señal que vas a tener de que esos clientes son de verdad distintos. Los cinco en los que no coinciden son los que vale la pena mirar a mano 🔍
Lo que se paga: el eps. Ese 0,9 lo elegiste tú igual que elegiste
el número de grupos en k-means, así que la decisión no desapareció, se mudó de
sitio. La diferencia es que ahora estás decidiendo qué tan cerca es cerca, que se
puede razonar mirando las distancias, en vez de cuántas islas hay, que era
adivinar.
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! 🌸
Practica este capítulo 📓
Todo el código de arriba en un cuaderno que corre de principio a fin, y los ejercicios con una celda vacía para que los hagas tú. Se abre en Google Colab de un clic y no hay que instalar nada. Donde veas %%revisa, escribe tu respuesta y el cuaderno te dice si te salió.
¿Prefieres trabajar en tu máquina? Bájate el cuaderno de práctica o el de soluciones. Todos están también en github.com/soymissyera/MissYeraEjercicios.
Los términos que salen en este capítulo
Están todos en el glosario de IA, con su definición corta.