ML clásico · Fácil · 15 min

k-means a mano

Asignar, recalcular, repetir: k-means son dos funciones y un bucle. Agrupa 90 clientes por ticket y frecuencia y mira cómo el codo elige k.

POINTS son 90 clientes como [ticket medio en EUR, visitas al mes]. Escribe cinco funciones:

standardize(points): cada columna a (x - media) / desviación (desviación entre n); si la desviación es 0, ceros.

assign(points, centroids): para cada punto, el índice del centroide más cercano (distancia al cuadrado, dist2); en empate, el índice menor.

update(points, labels, k, prev): k centroides nuevos, cada uno la media de sus puntos; un grupo sin puntos conserva prev[c].

kmeans(points, k, seed, maxIters): centroides iniciales = k puntos distintos elegidos con mulberry32(seed) (Math.floor(rng() * n), repitiendo la tirada si el índice ya salió). Repite asignar y actualizar hasta maxIters veces o hasta que las etiquetas no cambien. Devuelve { centroids, labels, iterations, inertia }, con inertia la suma de dist2 de cada punto a su centroide.

elbow(points, ks, seed): la inercia de kmeans para cada k de ks (con maxIters 50). La vista previa pinta los grupos, los centroides y la curva del codo.

Retos 0/5

  • Estandarizar por columnas, con ceros si no hay dispersión
  • Asignar al centroide más cercano, con el índice menor en empate
  • Recalcular centroides, conservando el de un grupo vacío
  • k-means con k = 3 sobre los datos estandarizados separa los tres grupos
  • La curva del codo baja y se aplana en k = 3

function standardize(points) {
  // por columna: (x - media) / sd con sd entre n; ceros si sd es 0
  return points;
}

function assign(points, centroids) {
  // índice del centroide más cercano por dist2; el índice menor en empate
  return points.map(() => 0);
}

function update(points, labels, k, prev) {
  // media de los puntos de cada grupo; prev[c] si el grupo está vacío
  return prev;
}

function kmeans(points, k, seed, maxIters) {
  // inicio: k puntos distintos con mulberry32(seed); repite assign + update hasta que las etiquetas no cambien o maxIters
  // devuelve { centroids, labels, iterations, inertia }
  return { centroids: [], labels: points.map(() => 0), iterations: 0, inertia: 0 };
}

function elbow(points, ks, seed) {
  // kmeans(points, k, seed, 50).inertia para cada k
  return ks.map(() => 0);
}

console.log(kmeans(standardize(POINTS), 3, 7, 50));
La consola aparece aquí (console.log).

La idea

k-means no sabe nada de clientes. Parte de k centros al azar, asigna cada punto al centro más cercano, mueve cada centro a la media de sus puntos y repite hasta que nada cambia. Son dos funciones y un bucle, y con 90 puntos converge en menos de diez vueltas.

La trampa está en las unidades. El ticket medio va de 20 a 400 EUR y las visitas de 0,2 a 6: sin estandarizar, la distancia la decide el ticket y las visitas no cuentan. Prueba la vista previa con y sin estandarizar y verás que los grupos cambian. Esa decisión la tomas tú, no el algoritmo.

k tampoco lo decide el algoritmo. La curva del codo pinta la inercia (suma de distancias al cuadrado) para cada k: baja siempre, pero deja de bajar deprisa en el k «natural». Aquí el codo está en 3, que es como construimos los datos; con datos reales el codo suele ser menos claro y conviene mirar los grupos, no solo la curva.

La semilla fija el resultado. Con otra semilla, otro arranque y a veces otro mínimo local; scikit-learn lo resuelve arrancando diez veces y quedándose con la mejor inercia. Aquí lo haces una vez para ver qué hay debajo.

Hazlo en tu máquina

Con Python y scikit-learn, sobre el CSV de este ejercicio (CC BY 4.0):

curl -O https://vorluxai.com/data/labs/clientes-kmeans.csv
python3 -c "
import pandas as pd
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler
X = StandardScaler().fit_transform(pd.read_csv('clientes-kmeans.csv')[['gasto_medio_eur', 'visitas_mes']])
for k in range(1, 7):
    print(k, round(KMeans(n_clusters=k, n_init=10, random_state=0).fit(X).inertia_, 1))
"

Comprueba lo que has entendido

1. ¿Por qué estandarizar antes de k-means con ticket y visitas?
2. ¿Qué dice la curva del codo?
3. Dos semillas dan agrupaciones distintas. ¿Qué pasa?
4. ¿Qué hace `update` con un grupo que se queda sin puntos?

Para profundizar: la referencia de scikit-learn (en inglés) →

¿Esto en producción, con tus datos? Hablemos 15 minutos →