5 - Clustering con K-Means: descubrir grupos de pacientes¶
En el capítulo 5, Elena le plantea al equipo un tipo de pregunta nuevo: ¿podemos identificar grupos de pacientes en riesgo antes de que enfermen? Esta vez no hay etiquetas. Nadie sabe qué grupos existen, así que no sirven los modelos de los capítulos anteriores, que aprenden de ejemplos con una respuesta conocida. Marta propone el aprendizaje no supervisado: dejar que el algoritmo descubra los grupos que existen de forma natural en los datos, según lo parecidos que son los pacientes. Como ella advierte, el algoritmo no entiende lo que es un paciente; solo compara números con una regla de similitud que elegimos nosotros.
En este cuaderno:
- Importar las bibliotecas.
- K-Means a mano, paso a paso (las notas de Marta, sección 5.4).
- Los óptimos locales y la inicialización: K-Means++.
- La población de pacientes de Minermont.
- Por qué hay que escalar las características.
- Elegir $K$: el método del codo.
- Los cinco grupos y su interpretación clínica.
- Las variables que incluimos deciden los grupos.
- Asignar un paciente nuevo a un grupo.
Paso 1: Importar las bibliotecas¶
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
from sklearn.metrics import adjusted_rand_score
Paso 2: K-Means a mano¶
K-Means divide $n$ puntos en $K$ grupos (clústeres), cada uno representado por su centroide $\mu_k$. Alterna dos pasos:
- Asignación: cada punto va al clúster cuyo centroide está más cerca (distancia euclídea al cuadrado): $$C(i) = \arg\min_k \|x_i - \mu_k\|^2$$
- Actualización: cada centroide se recalcula como la media de los puntos asignados a él: $$\mu_k = \frac{1}{|S_k|} \sum_{x_i \in S_k} x_i$$
y los repite hasta que los centroides dejan de moverse. Como señala Marta, los centroides "no se mueven como objetos": simplemente recalculamos su posición después de cada reasignación. La cantidad que reduce K-Means es la suma de errores al cuadrado (SSE), o inercia, la suma de las distancias al cuadrado de cada punto a su centroide:
$$\text{SSE} = \sum_{k=1}^{K} \sum_{x_i \in S_k} \|x_i - \mu_k\|^2$$
Lo probamos con 150 puntos en dos dimensiones, generados alrededor de tres centros, empezando desde tres centroides arbitrarios:
rng = np.random.default_rng(3)
X_toy = np.vstack([rng.normal(center, 0.8, (50, 2)) for center in [[2, 2], [7, 3], [4.5, 7]]])
def sse(X, labels, centroids):
return sum(np.sum((X[labels == k] - c) ** 2) for k, c in enumerate(centroids))
def kmeans(X, centroids, max_iter=50):
"""K-Means a mano. Devuelve los centroides finales, las etiquetas, la SSE de cada iteración y el historial de centroides."""
history, sses = [centroids.copy()], []
for _ in range(max_iter):
distances = np.sum((X[:, None, :] - centroids[None, :, :]) ** 2, axis=2) # puntos x centroides
labels = distances.argmin(axis=1) # 1. asignación
sses.append(sse(X, labels, centroids))
new_centroids = np.array([X[labels == k].mean(axis=0) for k in range(len(centroids))]) # 2. actualización
if np.allclose(new_centroids, centroids):
break
centroids = new_centroids
history.append(centroids.copy())
return centroids, labels, sses, history
start = np.array([[2.0, 7.0], [3.0, 6.0], [4.0, 5.0]])
centroids, labels, sses, history = kmeans(X_toy, start)
print(f"Iteraciones: {len(sses)}")
print("SSE en cada iteración:", [round(float(s), 1) for s in sses])
Iteraciones: 10 SSE en cada iteración: [1633.3, 909.3, 842.9, 827.8, 818.9, 806.2, 737.2, 396.2, 194.5, 194.0]
colors = np.array(['tab:blue', 'tab:orange', 'tab:green'])
steps = [0, 1, 3, len(history) - 1]
fig, axes = plt.subplots(1, 4, figsize=(18, 4.5))
for ax, t in zip(axes, steps):
c = history[t]
lab = np.sum((X_toy[:, None, :] - c[None]) ** 2, axis=2).argmin(axis=1)
ax.scatter(*X_toy.T, c=colors[lab], s=15, alpha=0.6)
path = np.array(history[:t + 1])
for k in range(3):
ax.plot(path[:, k, 0], path[:, k, 1], 'k--', linewidth=0.8)
ax.scatter(*c.T, c='k', marker='X', s=200)
ax.set_title(f'Iteración {t}: SSE {sse(X_toy, lab, c):.1f}')
plt.suptitle('K-Means: los centroides (X) se recalculan después de cada asignación', y=1.02)
plt.show()
La SSE nunca sube: baja de 1633,3 a 194,0 en 10 iteraciones, y el algoritmo se detiene cuando los centroides ya no se mueven. En los gráficos, los tres centroides empiezan en una diagonal y cada uno viaja, iteración tras iteración, hacia el centro de uno de los tres grupos.
Paso 3: Los óptimos locales y la inicialización¶
Las notas de Marta añaden una advertencia importante: K-Means siempre converge, pero puede converger a un óptimo local, una agrupación estable que no es la mejor posible. Depende de dónde empiecen los centroides. Empecemos desde tres centroides colocados uno al lado del otro en la parte de arriba del gráfico:
bad_start = np.array([[1.0, 7.0], [2.0, 7.0], [3.0, 7.0]])
bad_centroids, bad_labels, bad_sses, _ = kmeans(X_toy, bad_start)
print(f"Buen comienzo: SSE final {sses[-1]:.1f}")
print(f"Mal comienzo: SSE final {bad_sses[-1]:.1f}")
fig, axes = plt.subplots(1, 2, figsize=(11, 4.5))
for ax, lab, c, title in [(axes[0], labels, centroids, f'Mejor solución: SSE {sses[-1]:.1f}'),
(axes[1], bad_labels, bad_centroids, f'Óptimo local: SSE {bad_sses[-1]:.1f}')]:
ax.scatter(*X_toy.T, c=colors[lab], s=15, alpha=0.6)
ax.scatter(*c.T, c='k', marker='X', s=200)
ax.set_title(title)
plt.show()
Buen comienzo: SSE final 194.0 Mal comienzo: SSE final 680.1
Desde este comienzo el algoritmo también converge, pero a una agrupación con una SSE de 680,1, tres veces y media peor que la mejor: dos centroides se reparten el grupo de abajo a la izquierda y un solo centroide cubre los otros dos grupos. Desde ahí, ninguna reasignación baja la SSE, así que el algoritmo se detiene.
Hay dos remedios habituales. El primero es ejecutar el algoritmo varias veces desde comienzos aleatorios distintos y quedarse con la solución de menor SSE. El segundo es K-Means++, que elige mejores centroides iniciales: el primero es un punto al azar, y cada uno de los siguientes se elige al azar con probabilidad proporcional a $D(x)^2$, la distancia al cuadrado de cada punto al centroide ya elegido más cercano. Así, los puntos lejanos tienen más probabilidad de ser elegidos, y los centroides empiezan repartidos. Comparamos 200 comienzos aleatorios de cada tipo:
def random_start(X, k, rng):
return X[rng.choice(len(X), k, replace=False)]
def kmeans_plus_plus_start(X, k, rng):
chosen = [X[rng.integers(len(X))]]
for _ in range(1, k):
d2 = np.min(np.sum((X[:, None, :] - np.array(chosen)[None]) ** 2, axis=2), axis=1) # D(x)^2
chosen.append(X[rng.choice(len(X), p=d2 / d2.sum())])
return np.array(chosen)
best_sse = sses[-1]
for name, starter, offset in [('puntos al azar', random_start, 0), ('K-Means++', kmeans_plus_plus_start, 1000)]:
finals = [kmeans(X_toy, starter(X_toy, 3, np.random.default_rng(offset + s)))[2][-1] for s in range(200)]
stuck = np.sum(np.array(finals) > best_sse + 1)
print(f"Comienzo con {name:<14}: {stuck} de 200 ejecuciones terminan en un óptimo local")
sk = KMeans(n_clusters=3, init='k-means++', n_init=10, random_state=0).fit(X_toy)
print(f"\nscikit-learn (K-Means++, mejor de 10 comienzos): SSE {sk.inertia_:.1f}")
Comienzo con puntos al azar: 19 de 200 ejecuciones terminan en un óptimo local Comienzo con K-Means++ : 9 de 200 ejecuciones terminan en un óptimo local scikit-learn (K-Means++, mejor de 10 comienzos): SSE 194.0
Con puntos iniciales al azar, 19 de las 200 ejecuciones se quedan atascadas; con K-Means++, 9. No elimina el problema, pero lo reduce a la mitad. Por eso scikit-learn usa K-Means++ por defecto y, con n_init=10, se queda con la mejor de 10 ejecuciones; su resultado tiene la misma SSE que nuestra mejor solución.
Paso 4: La población de pacientes de Minermont¶
Luis prepara los datos anonimizados de los pacientes adultos: edad, visitas, analíticas básicas y antecedentes familiares. Los registros reales son confidenciales, así que generamos una población sintética de 2000 pacientes. El generador crea cinco perfiles ocultos; el algoritmo nunca verá de qué perfil viene cada paciente. Guardamos esa información aparte solo para comprobar, al final, algo que nunca podríamos comprobar con pacientes reales.
| Columna | Significado |
|---|---|
edad |
Edad en años |
visitas_anuales |
Visitas al hospital en el último año |
glucosa |
Glucosa en ayunas (mg/dL) |
presion_sistolica |
Presión arterial sistólica (mmHg) |
colesterol |
Colesterol total (mg/dL) |
enf_cronicas |
Número de enfermedades crónicas diagnosticadas |
antecedentes_familiares |
1 si hay antecedentes familiares de enfermedad cardiaca o diabetes tipo 2 |
def generate_population(n, seed):
"""Pacientes adultos sintéticos de Minermont. Devuelve los datos y, aparte, el perfil oculto de cada paciente."""
rng = np.random.default_rng(seed)
# cinco perfiles ocultos: (media, desviación) de edad, glucosa, presión arterial y colesterol; media de visitas y de
# enfermedades crónicas; probabilidad de antecedentes familiares; proporción de la población
profiles = [
dict(age=(30, 6), visits=1.0, glucose=(84, 6), sbp=(113, 7), chol=(170, 20), chronic=0.05, family=0.12, share=0.30),
dict(age=(72, 6), visits=7.0, glucose=(130, 15), sbp=(140, 10), chol=(205, 25), chronic=2.5, family=0.35, share=0.22),
dict(age=(45, 13), visits=8.0, glucose=(90, 8), sbp=(118, 9), chol=(185, 22), chronic=0.3, family=0.2, share=0.25),
dict(age=(68, 10), visits=20.0, glucose=(150, 25), sbp=(146, 15), chol=(215, 35), chronic=5.0, family=0.4, share=0.08),
dict(age=(52, 4), visits=1.5, glucose=(106, 4), sbp=(135, 4), chol=(225, 15), chronic=0.1, family=0.75, share=0.15),
]
hidden = rng.choice(len(profiles), n, p=[p['share'] for p in profiles])
cols = {c: np.empty(n) for c in ['age', 'visits', 'glucose', 'sbp', 'chol', 'chronic', 'family']}
for k, p in enumerate(profiles):
m = hidden == k
c = m.sum()
cols['age'][m] = rng.normal(*p['age'], c)
cols['visits'][m] = rng.poisson(p['visits'], c)
cols['glucose'][m] = rng.normal(*p['glucose'], c)
cols['sbp'][m] = rng.normal(*p['sbp'], c)
cols['chol'][m] = rng.normal(*p['chol'], c)
cols['chronic'][m] = rng.poisson(p['chronic'], c)
cols['family'][m] = rng.binomial(1, p['family'], c)
df = pd.DataFrame({
'edad': np.clip(np.round(cols['age']), 18, 95).astype(int), 'visitas_anuales': cols['visits'].astype(int),
'glucosa': np.round(cols['glucose']).astype(int), 'presion_sistolica': np.round(cols['sbp']).astype(int),
'colesterol': np.round(cols['chol']).astype(int), 'enf_cronicas': cols['chronic'].astype(int),
'antecedentes_familiares': cols['family'].astype(int),
})
return df, hidden
patients, hidden_profile = generate_population(2000, seed=42)
features = ['edad', 'visitas_anuales', 'glucosa', 'presion_sistolica', 'colesterol', 'enf_cronicas']
print(patients.describe().round(1).T[['mean', 'std', 'min', 'max']].to_string())
mean std min max edad 49.7 18.1 18.0 92.0 visitas_anuales 5.7 6.0 0.0 32.0 glucosa 105.2 25.5 68.0 219.0 presion_sistolica 126.1 15.1 85.0 175.0 colesterol 194.4 29.8 113.0 309.0 enf_cronicas 1.1 1.8 0.0 12.0 antecedentes_familiares 0.3 0.5 0.0 1.0
Hacemos el clustering con las seis medidas numéricas. Dejamos los antecedentes familiares (una columna de sí o no) fuera del clustering y los usamos después para describir los grupos; en el paso 8 veremos por qué.
Paso 5: Por qué hay que escalar las características¶
K-Means usa la distancia euclídea al cuadrado, así que, como advierte el libro, es sensible a la escala de las características. El colesterol varía en decenas de mg/dL, mientras que el número de enfermedades crónicas varía en una o dos unidades: sin escalar, el colesterol lo decidiría casi todo. Estandarizamos cada característica (media 0, desviación típica 1).
Como nuestros datos son sintéticos, podemos medir lo bien que cada agrupación recupera los perfiles ocultos con el índice de Rand ajustado (ARI): 1 significa la misma agrupación y 0 es lo que esperaríamos por azar. Con pacientes reales esta comprobación no existe, y precisamente por eso el aprendizaje no supervisado necesita la interpretación clínica.
X_raw = patients[features].to_numpy(dtype=float)
X = StandardScaler().fit_transform(X_raw)
for name, data in [('sin escalar', X_raw), ('estandarizado', X)]:
km = KMeans(n_clusters=5, n_init=10, random_state=0).fit(data)
print(f"K-Means con K = 5, {name:<14} coincidencia con los perfiles ocultos (ARI) = {adjusted_rand_score(hidden_profile, km.labels_):.3f}")
K-Means con K = 5, sin escalar coincidencia con los perfiles ocultos (ARI) = 0.408
K-Means con K = 5, estandarizado coincidencia con los perfiles ocultos (ARI) = 0.881
Sin escalar, la coincidencia es solo de 0,408: el colesterol y la glucosa, las características con los números más grandes, deciden los grupos casi por sí solas. Con las características estandarizadas, K-Means recupera los perfiles ocultos con un ARI de 0,881.
Paso 6: Elegir $K$, el método del codo¶
En la vida real no sabemos cuántos grupos hay. Como en el libro, ejecutamos K-Means con $K$ de 1 a 10 y representamos la SSE. Siempre baja a medida que crece $K$ (con $K = n$ sería cero), así que la pregunta es a partir de dónde deja de compensar añadir clústeres: el codo de la curva. Para que la decisión sea menos subjetiva, calculamos también una regla automática sencilla: el punto de la curva más alejado de la recta que une su primer y su último punto.
ks = list(range(1, 11))
sse_by_k = [KMeans(n_clusters=k, n_init=10, random_state=0).fit(X).inertia_ for k in ks]
rows = [{'K': k, 'SSE': s, 'reducción frente a K-1': (sse_by_k[i - 1] - s) / sse_by_k[i - 1] if i else np.nan}
for i, (k, s) in enumerate(zip(ks, sse_by_k))]
print(pd.DataFrame(rows).set_index('K').round({'SSE': 0, 'reducción frente a K-1': 3}).to_string())
# codo automático: distancia de cada punto a la recta que une el primer y el último punto
p1, p2 = np.array([ks[0], sse_by_k[0]]), np.array([ks[-1], sse_by_k[-1]])
d = p2 - p1
dist = [abs(d[0] * (p1[1] - s) - (p1[0] - k) * d[1]) / np.linalg.norm(d) for k, s in zip(ks, sse_by_k)]
elbow = ks[int(np.argmax(dist))]
print(f"\nCodo automático: K = {elbow}")
plt.figure(figsize=(8, 4))
plt.plot(ks, sse_by_k, 'o-')
plt.plot([ks[0], ks[-1]], [sse_by_k[0], sse_by_k[-1]], 'k:', linewidth=1)
plt.axvline(elbow, color='gray', linestyle='--')
plt.xlabel('Número de clústeres K')
plt.ylabel('SSE (inercia)')
plt.title('El método del codo')
plt.xticks(ks)
plt.grid(True)
plt.show()
SSE reducción frente a K-1 K 1 12000.0 NaN 2 6645.0 0.446 3 4968.0 0.252 4 4099.0 0.175 5 3504.0 0.145 6 3303.0 0.057 7 3134.0 0.051 8 2947.0 0.060 9 2808.0 0.047 10 2648.0 0.057 Codo automático: K = 3
La SSE baja deprisa al principio y después más despacio. Como en el libro, a partir de $K = 5$ las mejoras son mucho menores: cada clúster extra solo baja la SSE un 5 o un 6%, frente al 14,5% del quinto. La regla automática, en cambio, apunta a $K = 3$. El codo es una heurística, y dos formas razonables de leer la misma curva no coinciden. Como propone Marta, decidimos una $K$ razonable y después comprobamos si los grupos tienen sentido clínico. Comparemos los grupos para varios valores de $K$:
short = ['edad', 'visitas_anuales', 'glucosa', 'presion_sistolica', 'colesterol', 'enf_cronicas']
for k in [3, 4, 5, 6]:
km = KMeans(n_clusters=k, n_init=10, random_state=0).fit(X)
groups = patients.groupby(km.labels_)[short].mean().round(0).astype(int)
groups['pacientes'] = np.bincount(km.labels_)
print(f"K = {k}")
print(groups.sort_values('edad').to_string(index=False), end='\n\n')
K = 3 edad visitas_anuales glucosa presion_sistolica colesterol enf_cronicas pacientes 35 4 86 114 175 0 984 60 4 111 135 217 1 622 70 13 142 142 207 4 394 K = 4 edad visitas_anuales glucosa presion_sistolica colesterol enf_cronicas pacientes 35 4 86 114 174 0 955 54 3 103 132 220 0 450 67 20 152 145 216 5 169 72 7 132 139 204 2 426 K = 5 edad visitas_anuales glucosa presion_sistolica colesterol enf_cronicas pacientes 30 1 84 113 169 0 625 45 8 90 118 188 0 428 53 2 106 135 224 0 355 67 20 152 145 216 5 169 72 7 131 139 204 2 423
K = 6 edad visitas_anuales glucosa presion_sistolica colesterol enf_cronicas pacientes 30 1 84 113 169 0 625 45 8 90 118 188 0 428 53 2 106 135 224 0 344 67 21 152 146 219 5 155 72 8 133 137 188 3 208 73 7 132 141 218 2 240
Con $K = 3$ los pacientes de riesgo silencioso desaparecen dentro de un grupo grande de 622 pacientes de 60 años de media. Con $K = 4$ forman el núcleo de un grupo de 450, pero mezclados con otros pacientes, lo que diluye sus medias (3 visitas al año, glucosa de 103 mg/dL). Con $K = 5$ el grupo aparece por sí solo, con 355 pacientes. Con $K = 6$ no aparece nada nuevo: los pacientes crónicos estables simplemente se dividen en dos por su colesterol. Así que $K = 5$ es la elección que respalda la curva y que además tiene sentido clínico.
Paso 7: Los cinco grupos¶
Ejecutamos K-Means con $K = 5$. Los números de clúster que devuelve el algoritmo son arbitrarios, así que renumeramos los clústeres por edad media creciente. Después describimos cada grupo con la media de cada característica en las unidades originales, su tamaño y el porcentaje de pacientes con antecedentes familiares, la columna que dejamos fuera del clustering.
scaler = StandardScaler().fit(X_raw)
kmeans5 = KMeans(n_clusters=5, n_init=10, random_state=0).fit(scaler.transform(X_raw))
order = np.argsort(scaler.inverse_transform(kmeans5.cluster_centers_)[:, 0]) # por edad media
renumber = {old: new + 1 for new, old in enumerate(order)}
patients['cluster'] = [renumber[c] for c in kmeans5.labels_]
profile = patients.groupby('cluster')[features].mean().round(1)
profile['pacientes'] = patients['cluster'].value_counts().sort_index()
profile['% antecedentes'] = (100 * patients.groupby('cluster')['antecedentes_familiares'].mean()).round(0)
print(profile.to_string())
edad visitas_anuales glucosa presion_sistolica colesterol enf_cronicas pacientes % antecedentes cluster 1 30.3 1.2 84.2 112.7 169.3 0.1 625 12.0 2 45.4 8.2 90.3 118.1 188.3 0.3 428 21.0 3 53.3 1.8 106.4 135.0 224.3 0.1 355 67.0 4 67.3 20.4 152.0 145.4 216.0 5.0 169 39.0 5 72.4 7.3 131.4 139.0 203.7 2.5 423 40.0
Los cinco grupos coinciden con los que encuentra el equipo en el libro:
- Clúster 1 (625 pacientes): jóvenes (30 años de media), alrededor de una visita al año, analíticas en rangos óptimos. "Jóvenes sanos", como los etiqueta Javier.
- Clúster 2 (428): pacientes de edades variadas con visitas frecuentes (8 al año), pero con analíticas normales y casi sin enfermedades crónicas: los casos activos diversos.
- Clúster 3 (355): unos 53 años, menos de dos visitas al año, glucosa de 106 mg/dL y presión arterial de 135 mmHg, las dos en la parte alta de la normalidad, y un 67% con antecedentes familiares. Es el grupo de riesgo silencioso que sorprende al equipo.
- Clúster 4 (169): el grupo más pequeño, con 20 visitas al año y 5 enfermedades crónicas: pacientes de alta complejidad.
- Clúster 5 (423): los de más edad (72 años), con 2,5 enfermedades crónicas y visitas regulares: pacientes crónicos estables.
Ninguno de estos nombres viene del algoritmo, que solo ha producido cinco centroides: poner nombre a los grupos es trabajo del equipo clínico. Fíjate también en que los antecedentes familiares, que K-Means nunca vio, son más frecuentes en el clúster 3, como en el libro.
Representamos a los pacientes en dos de las dimensiones, coloreados por clúster. En cualquier pareja de características los grupos se solapan: K-Means los separa usando las seis características a la vez.
fig, axes = plt.subplots(1, 2, figsize=(13, 5))
for ax, (fx, fy) in zip(axes, [('edad', 'glucosa'), ('glucosa', 'presion_sistolica')]):
for c in range(1, 6):
sub = patients[patients['cluster'] == c]
ax.scatter(sub[fx], sub[fy], s=8, alpha=0.5, label=f'clúster {c}')
ax.set_xlabel(fx)
ax.set_ylabel(fy)
ax.grid(True)
axes[0].legend(markerscale=3)
plt.suptitle('Los cinco clústeres vistos en dos dimensiones')
plt.show()
print("Perfil oculto (filas) frente al clúster encontrado por K-Means (columnas):")
print(pd.crosstab(hidden_profile, patients['cluster'], rownames=['perfil oculto'], colnames=['clúster']).to_string())
print(f"ARI = {adjusted_rand_score(hidden_profile, patients['cluster']):.3f}")
Perfil oculto (filas) frente al clúster encontrado por K-Means (columnas): clúster 1 2 3 4 5 perfil oculto 0 591 7 2 0 0 1 0 1 23 5 414 2 34 419 17 0 1 3 0 1 0 164 8 4 0 0 313 0 0 ARI = 0.881
Cada perfil oculto termina sobre todo en un solo clúster, y los 313 pacientes del perfil de riesgo silencioso están en el clúster 3. Los errores están en las fronteras entre perfiles que se solapan, por ejemplo 34 casos activos más jóvenes de lo habitual que terminan con los jóvenes sanos. Con pacientes reales esta tabla no existiría: solo es posible porque hemos generado nosotros los datos.
Paso 8: Las variables que incluimos deciden los grupos¶
La advertencia de Marta, "solo compara números usando una regla de similitud que elegimos nosotros, en función de las variables que incluimos", se puede comprobar. Repetimos el clustering añadiendo los antecedentes familiares, la columna de sí o no que habíamos dejado fuera:
with_family = StandardScaler().fit_transform(patients[features + ['antecedentes_familiares']])
km_family = KMeans(n_clusters=5, n_init=10, random_state=0).fit(with_family)
print(f"ARI incluyendo los antecedentes familiares: {adjusted_rand_score(hidden_profile, km_family.labels_):.3f}\n")
print("Proporción de pacientes con antecedentes familiares en cada clúster nuevo:")
print(pd.Series(patients.groupby(km_family.labels_)['antecedentes_familiares'].mean().round(2).to_numpy(),
index=[f'clúster {c + 1}' for c in range(5)]).to_string())
ARI incluyendo los antecedentes familiares: 0.661 Proporción de pacientes con antecedentes familiares en cada clúster nuevo: clúster 1 0.12 clúster 2 1.00 clúster 3 0.40 clúster 4 0.34 clúster 5 0.00
La coincidencia baja de 0,881 a 0,661, y la tabla explica por qué: K-Means ha gastado dos de sus cinco clústeres en separar a los pacientes con antecedentes familiares (clúster 2, todos ellos) de los que no los tienen (clúster 5, ninguno). Una vez estandarizada, una columna de sí o no forma dos nubes de puntos alejadas, y la distancia al cuadrado premia separarlas. El algoritmo no se ha equivocado: ha hecho exactamente lo que le pide la distancia que le dimos. Elegir las variables, y por tanto qué significa "parecido", es una decisión clínica, no solo técnica.
Paso 9: Asignar un paciente nuevo¶
Una vez que existen los grupos, un paciente nuevo se asigna al clúster del centroide más cercano, después de aplicarle el mismo escalado. El ejemplo de Carlos en el libro es un paciente con una glucosa de 105 mg/dL y una presión arterial de 135 mmHg que se encuentra bien: en una consulta rutinaria, nada haría saltar la alarma.
new_patients = pd.DataFrame([
{'edad': 54, 'visitas_anuales': 1, 'glucosa': 105, 'presion_sistolica': 135, 'colesterol': 220, 'enf_cronicas': 0},
{'edad': 29, 'visitas_anuales': 1, 'glucosa': 88, 'presion_sistolica': 115, 'colesterol': 175, 'enf_cronicas': 0},
{'edad': 75, 'visitas_anuales': 9, 'glucosa': 135, 'presion_sistolica': 142, 'colesterol': 200, 'enf_cronicas': 3},
])
new_patients['cluster'] = [renumber[c] for c in kmeans5.predict(scaler.transform(new_patients[features].to_numpy(dtype=float)))]
print(new_patients.to_string(index=False))
edad visitas_anuales glucosa presion_sistolica colesterol enf_cronicas cluster 54 1 105 135 220 0 3 29 1 88 115 175 0 1 75 9 135 142 200 3 5
El primer paciente, con los valores del ejemplo de Carlos, cae en el clúster 3, el grupo de riesgo silencioso, y podría ser invitado al programa de prevención que pone en marcha Minermont en el libro. Los otros dos quedan asignados a los jóvenes sanos (clúster 1) y a los pacientes crónicos estables (clúster 5).
Conclusión¶
- El aprendizaje no supervisado busca estructura en datos sin etiquetas. K-Means agrupa los puntos alrededor de $K$ centroides alternando dos pasos, la asignación al centroide más cercano y el recálculo de cada centroide como una media, y cada iteración baja o mantiene la SSE.
- Siempre converge, pero puede hacerlo a un óptimo local: varios comienzos y K-Means++ lo hacen mucho menos probable.
- Las distancias dependen de las unidades, así que hay que escalar las características, y las variables que incluimos deciden qué grupos pueden aparecer.
- El método del codo ayuda a elegir $K$, pero es una heurística: la decisión final combina la curva con el significado clínico de los grupos.
- El algoritmo solo devuelve números y centroides. Poner nombre a los grupos, decidir si tienen sentido clínico y actuar en consecuencia (como el programa de prevención de Minermont para el grupo de riesgo silencioso) es trabajo del equipo médico.