4.2 - K vecinos más cercanos (KNN) para el triaje de urgencias¶
En el capítulo 4, el equipo de Minermont quiere un sistema que sugiera el nivel de urgencia de cada paciente que llega a urgencias, del 1 (rojo, inmediato) al 5 (azul, no urgente). Es un problema de clasificación multiclase: el modelo no responde sí o no, sino que elige una de cinco clases.
El primer algoritmo que estudian en AIA es K vecinos más cercanos (KNN, K-Nearest Neighbors). No construye ningún modelo explícito: memoriza los casos de entrenamiento y, para clasificar a un paciente nuevo, busca los $K$ más parecidos y le asigna el nivel más frecuente entre ellos. En este cuaderno:
- Importar las bibliotecas.
- KNN a mano, con un ejemplo pequeño.
- Métricas de distancia: euclídea, Manhattan y Chebyshev.
- Los datos de triaje.
- El escalado de características.
- Elegir $K$ y la métrica con validación cruzada.
- La maldición de la dimensionalidad.
- Evaluación final en el conjunto de prueba.
Paso 1: Importar las bibliotecas¶
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split, StratifiedKFold, cross_val_score
from sklearn.preprocessing import StandardScaler, MinMaxScaler, RobustScaler
from sklearn.pipeline import make_pipeline
from sklearn.neighbors import KNeighborsClassifier
from sklearn.metrics import classification_report, ConfusionMatrixDisplay
Empezamos, como el libro, con dos grupos de puntos descritos por dos características y un punto nuevo que clasificar. El algoritmo tiene cuatro pasos:
- Elegir $K$, el número de vecinos.
- Calcular la distancia del punto nuevo a todos los puntos de entrenamiento.
- Quedarse con los $K$ puntos de menor distancia.
- Asignar la clase más frecuente entre ellos (voto por mayoría).
X_toy = np.array([[3.0, 1.8], [2.05, 2.05], [1.7, 3.0], [1.0, 1.0], [1.5, 0.8], [0.8, 1.8], [1.8, 1.2], [1.2, 2.5],
[3.9, 3.9], [4.25, 3.0], [5.0, 5.0], [4.6, 5.4], [5.5, 4.4], [5.2, 3.8], [4.4, 4.8], [5.8, 5.2]])
y_toy = np.array(['A'] * 8 + ['B'] * 8)
new_point = np.array([3.0, 3.0])
def euclidean(a, b):
return np.sqrt(np.sum((a - b) ** 2, axis=-1))
def knn_predict(X_train, y_train, x_new, k=3, distance=euclidean):
distances = distance(X_train, x_new) # paso 2
nearest = np.argsort(distances)[:k] # paso 3
classes, votes = np.unique(y_train[nearest], return_counts=True)
return classes[np.argmax(votes)], nearest # paso 4
prediction, nearest = knn_predict(X_toy, y_toy, new_point, k=3)
for i in nearest:
print(f"Vecino {X_toy[i]} clase {y_toy[i]} distancia {euclidean(X_toy[i], new_point):.3f}")
print(f"\nPredicción para {new_point}: clase {prediction}")
plt.figure(figsize=(5.5, 5))
for label, color, marker in [('A', 'tab:blue', 'o'), ('B', 'tab:red', 's')]:
plt.scatter(*X_toy[y_toy == label].T, color=color, marker=marker, s=60, label=f'clase {label}')
plt.scatter(*X_toy[nearest].T, s=260, facecolors='none', edgecolors='k', label='3 vecinos más cercanos')
plt.scatter(*new_point, marker='*', s=350, color='gold', edgecolors='k', zorder=3, label='punto nuevo')
plt.xlabel('Característica 1')
plt.ylabel('Característica 2')
plt.legend(loc='upper left')
plt.grid(True)
plt.show()
Vecino [3. 1.8] clase A distancia 1.200 Vecino [4.25 3. ] clase B distancia 1.250 Vecino [3.9 3.9] clase B distancia 1.273 Predicción para [3. 3.]: clase B
Dos de los tres vecinos más cercanos son de la clase B, así que el punto nuevo se clasifica como B, aunque el punto más cercano de todos es de la clase A. Con $K = 1$ la respuesta habría sido A: el valor de $K$ importa.
Paso 3: Métricas de distancia¶
¿Qué significa "cerca"? El libro presenta tres métricas habituales. Si $x_q$ es el punto nuevo y $x_i$ un punto de entrenamiento, ambos con $d$ características:
- Euclídea, la distancia en línea recta: $d(x_q, x_i) = \sqrt{\sum_{j=1}^{d} (x_{q,j} - x_{i,j})^2}$
- Manhattan, moviéndose solo a lo largo de los ejes, como en una cuadrícula de calles: $d(x_q, x_i) = \sum_{j=1}^{d} |x_{q,j} - x_{i,j}|$
- Chebyshev, solo la mayor diferencia en una sola característica: $d(x_q, x_i) = \max_{j} |x_{q,j} - x_{i,j}|$
Marta las visualiza con la bola unidad: todos los puntos que están a distancia 1 del origen según cada métrica.
def manhattan(a, b):
return np.sum(np.abs(a - b), axis=-1)
def chebyshev(a, b):
return np.max(np.abs(a - b), axis=-1)
t = np.linspace(0, 2 * np.pi, 400)
circle = np.c_[np.cos(t), np.sin(t)]
plt.figure(figsize=(5, 5))
for metric, color, name in [(euclidean, 'tab:blue', 'euclídea'), (manhattan, 'tab:green', 'Manhattan'), (chebyshev, 'tab:red', 'Chebyshev')]:
ball = circle / metric(circle, np.zeros(2))[:, None] # reescalamos cada dirección a distancia 1
plt.plot(*ball.T, color=color, label=name)
plt.gca().set_aspect('equal')
plt.axhline(0, color='gray', linewidth=0.5)
plt.axvline(0, color='gray', linewidth=0.5)
plt.title('Bolas unidad: puntos a distancia 1 del origen')
plt.legend()
plt.show()
El círculo, el rombo y el cuadrado contienen puntos distintos, así que cada métrica puede elegir vecinos distintos. Clasifiquemos el mismo punto nuevo con cada una:
fig, axes = plt.subplots(1, 3, figsize=(15, 4.8))
for metric, ax, name in zip([euclidean, manhattan, chebyshev], axes, ['Euclídea', 'Manhattan', 'Chebyshev']):
prediction, nearest = knn_predict(X_toy, y_toy, new_point, k=3, distance=metric)
print(f"{name:<10} vecinos {[tuple(p) for p in X_toy[nearest].tolist()]} -> clase {prediction}")
for label, color, marker in [('A', 'tab:blue', 'o'), ('B', 'tab:red', 's')]:
ax.scatter(*X_toy[y_toy == label].T, color=color, marker=marker, s=60)
ax.scatter(*X_toy[nearest].T, s=260, facecolors='none', edgecolors='k')
ax.scatter(*new_point, marker='*', s=350, color='gold', edgecolors='k', zorder=3)
radius = metric(X_toy[nearest[-1]], new_point) # distancia al tercer vecino
ball = new_point + radius * circle / metric(circle, np.zeros(2))[:, None]
ax.plot(*ball.T, 'k--', linewidth=1)
ax.set_aspect('equal')
ax.set_title(f'{name}: clase {prediction}')
plt.show()
Euclídea vecinos [(3.0, 1.8), (4.25, 3.0), (3.9, 3.9)] -> clase B Manhattan vecinos [(3.0, 1.8), (4.25, 3.0), (1.7, 3.0)] -> clase A Chebyshev vecinos [(3.9, 3.9), (2.05, 2.05), (3.0, 1.8)] -> clase A
Con la distancia euclídea el punto nuevo es B; con las distancias Manhattan y Chebyshev es A, y cada métrica ha elegido un conjunto de vecinos distinto. El rombo de Manhattan llega más lejos a lo largo de los ejes y recoge el punto (1.7, 3.0); el cuadrado de Chebyshev llega más lejos en las diagonales y recoge el (2.05, 2.05). Como dice Marta en el libro, elegir la métrica es elegir qué significa "parecido".
Paso 4: Los datos de triaje¶
Los registros de urgencias de un hospital real son confidenciales, así que generamos pacientes sintéticos que se comportan como los del libro. La siguiente celda contiene el generador. No hace falta seguirlo línea a línea: lo importante es que el nivel de urgencia sale de un protocolo oculto (una puntuación de alerta temprana basada en los signos vitales, más algunas señales de alarma clínicas) y del criterio de la propia enfermera, que a veces mueve el nivel un escalón. Los modelos nunca ven esa regla: tienen que descubrirla a partir de los ejemplos.
| Columna | Significado |
|---|---|
edad |
Edad en años (1–97; aproximadamente uno de cada ocho pacientes es un niño) |
hombre |
1 si el paciente es hombre, 0 si es mujer |
presion_sistolica |
Presión arterial sistólica (mmHg) |
frec_cardiaca |
Frecuencia cardiaca (latidos por minuto) |
frec_respiratoria |
Frecuencia respiratoria (respiraciones por minuto) |
spo2 |
Saturación de oxígeno (%) |
temperatura |
Temperatura (°C) |
consciencia |
Nivel de consciencia: 0 alerta, 1 responde a la voz, 2 responde solo al dolor, 3 no responde |
dolor |
Dolor referido por el paciente (0–10) |
dolor_toracico, disnea, traumatismo, vomitos |
Síntomas de consulta: dolor torácico, dificultad para respirar, traumatismo, vómitos (1 = sí) |
nivel |
Objetivo: nivel de urgencia, de 1 (rojo, inmediato) a 5 (azul, no urgente) |
def generate_patients(n, seed):
"""Pacientes sintéticos de urgencias de Minermont: una fila por paciente, con el nivel de urgencia asignado en el triaje."""
rng = np.random.default_rng(seed)
age = np.clip(np.round(np.where(rng.random(n) < 0.12, rng.uniform(1, 17, n), rng.normal(52, 20, n))), 1, 97)
male = rng.binomial(1, 0.5, n)
s = rng.gamma(0.9, 0.55, n) # gravedad oculta del episodio (el modelo nunca la ve)
infection = rng.binomial(1, 0.12 + 0.1 * (s > 1), n)
trauma = rng.binomial(1, 0.2, n)
chest_pain = rng.binomial(1, np.clip(0.04 + 0.0025 * age, 0, 0.35), n)
dyspnea = rng.binomial(1, np.clip(0.06 + 0.25 * s, 0, 0.9), n)
vomiting = rng.binomial(1, 0.12, n)
shock = (rng.random(n) < 0.35) & (s > 1.2)
hr = 76 + 16 * s + 12 * infection + rng.normal(0, 10, n)
brady = (rng.random(n) < 0.08) & (s > 1.3) # algunos pacientes graves tienen el pulso muy lento
hr = np.where(brady, rng.normal(42, 6, n), hr)
rr = 15 + 4.5 * s + 3 * dyspnea + rng.normal(0, 2.2, n)
spo2 = np.clip(98.2 - 2.2 * s * (0.5 + dyspnea) + rng.normal(0, 1.1, n), 70, 100)
sbp = np.clip(128 + 0.25 * (age - 50) + rng.normal(0, 16, n) - 28 * s * shock, 60, 230)
temp = 36.8 + rng.normal(0, 0.35, n) + infection * rng.normal(1.6, 0.6, n)
p_consc = np.clip(0.02 * s ** 2.2, 0, 0.9)
consc = np.where(rng.random(n) < p_consc, rng.choice([1, 2, 3], n, p=[0.55, 0.3, 0.15]), 0)
pain = np.clip(np.round(rng.normal(2.5 + 3.5 * trauma + 3 * chest_pain + 0.8 * s, 1.8, n)), 0, 10).astype(int)
hr, rr, sbp = np.round(hr).astype(int), np.round(rr).astype(int), np.round(sbp).astype(int)
temp, spo2 = np.round(temp, 1), np.round(spo2).astype(int)
# El protocolo oculto detrás de las etiquetas, que solo usamos para generar los datos:
# una puntuación de alerta temprana en la que un signo vital suma puntos si es demasiado bajo O demasiado alto...
score = (np.select([rr <= 8, rr <= 11, rr <= 20, rr <= 24], [3, 1, 0, 2], 3)
+ np.select([spo2 <= 91, spo2 <= 93, spo2 <= 95], [3, 2, 1], 0)
+ np.select([sbp <= 90, sbp <= 100, sbp <= 110, sbp <= 219], [3, 2, 1, 0], 3)
+ np.select([hr <= 40, hr <= 50, hr <= 90, hr <= 110, hr <= 130], [3, 1, 0, 1, 2], 3)
+ np.where(consc > 0, 3, 0)
+ np.select([temp <= 35, temp <= 36, temp <= 38, temp <= 39], [3, 1, 0, 1], 2))
# ...más discriminadores clínicos, del nivel menos urgente al más urgente
level = np.full(n, 5)
level = np.where((score >= 1) | (pain >= 4) | (trauma == 1), 4, level)
level = np.where((score >= 4) | (pain >= 7) | (temp >= 39) | ((chest_pain == 1) & (age < 45)), 3, level)
level = np.where((score >= 6) | ((chest_pain == 1) & (age >= 45)) | (spo2 <= 91), 2, level)
level = np.where((score >= 9) | (consc >= 2) | (sbp <= 80), 1, level)
# y el criterio de la propia enfermera, que a veces mueve el nivel un escalón
level = np.clip(level + rng.choice([-1, 0, 1], n, p=[0.05, 0.9, 0.05]), 1, 5)
return pd.DataFrame({
'edad': age.astype(int), 'hombre': male, 'presion_sistolica': sbp, 'frec_cardiaca': hr, 'frec_respiratoria': rr,
'spo2': spo2, 'temperatura': temp, 'consciencia': consc, 'dolor': pain, 'dolor_toracico': chest_pain,
'disnea': dyspnea, 'traumatismo': trauma, 'vomitos': vomiting, 'nivel': level,
})
df = generate_patients(4000, seed=42)
feature_names = [c for c in df.columns if c != 'nivel']
level_names = ['1 Rojo', '2 Naranja', '3 Amarillo', '4 Verde', '5 Azul']
X = df[feature_names]
y = df['nivel']
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y)
print(f"Entrenamiento: {len(y_train)} pacientes, prueba: {len(y_test)} pacientes\n")
print("Pacientes por nivel de urgencia:")
print(pd.DataFrame({'pacientes': y.value_counts().sort_index(),
'proporción': y.value_counts(normalize=True).sort_index().round(3)},).set_axis(level_names).to_string())
Entrenamiento: 3000 pacientes, prueba: 1000 pacientes
Pacientes por nivel de urgencia:
pacientes proporción
1 Rojo 158 0.040
2 Naranja 530 0.132
3 Amarillo 780 0.195
4 Verde 1671 0.418
5 Azul 861 0.215
Como en el libro, hay muchos pacientes de nivel 4 y 5 y muy pocos de nivel 1. Reservamos el 25% de los pacientes como conjunto de prueba (estratificado, para que cada nivel conserve su proporción) y solo lo usaremos al final.
Paso 5: El escalado de características¶
KNN compara distancias, así que las unidades importan. Mira los rangos de las características:
print(X_train.describe().loc[['min', 'max', 'std']].round(1).T.to_string())
min max std edad 1.0 97.0 23.1 hombre 0.0 1.0 0.5 presion_sistolica 60.0 195.0 19.1 frec_cardiaca 32.0 142.0 14.0 frec_respiratoria 8.0 35.0 3.7 spo2 85.0 100.0 1.7 temperatura 35.6 40.5 0.7 consciencia 0.0 3.0 0.2 dolor 0.0 10.0 2.5 dolor_toracico 0.0 1.0 0.4 disnea 0.0 1.0 0.4 traumatismo 0.0 1.0 0.4 vomitos 0.0 1.0 0.3
Una diferencia de 20 mmHg en la presión arterial es normal, mientras que una diferencia de 2 °C en la temperatura es enorme, pero sin escalar KNN trata la primera como diez veces mayor. El libro describe tres formas de poner todas las características en una escala comparable:
- Escalado min–max: $x' = \frac{x - \min(x)}{\max(x) - \min(x)}$, al rango [0, 1].
- Estandarización (z-score): $z = \frac{x - \mu}{\sigma}$, media 0 y desviación típica 1.
- Escalado robusto: $x' = \frac{x - \text{mediana}}{\text{IQR}}$, menos sensible a los valores extremos.
Las comparamos con una validación cruzada estratificada de 5 grupos sobre el conjunto de entrenamiento. Cada escalador está dentro de un pipeline, así que solo aprende sus parámetros de los grupos de entrenamiento.
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scalers = {'sin escalar': None, 'min-max': MinMaxScaler(), 'estandarización': StandardScaler(), 'robusto': RobustScaler()}
for name, scaler in scalers.items():
model = make_pipeline(scaler, KNeighborsClassifier(n_neighbors=5)) if scaler else KNeighborsClassifier(n_neighbors=5)
print(f"{name:<16} exactitud media de validación = {cross_val_score(model, X_train, y_train, cv=cv).mean():.3f}")
sin escalar exactitud media de validación = 0.485
min-max exactitud media de validación = 0.684 estandarización exactitud media de validación = 0.692
robusto exactitud media de validación = 0.663
Sin escalar, la exactitud cae a 0,485: la distancia la dominan la edad, la presión arterial y la frecuencia cardiaca, las características con los números más grandes, y la temperatura, los síntomas o el nivel de consciencia apenas cuentan. Cualquiera de los tres escaladores arregla la mayor parte del problema. La estandarización da el mejor resultado aquí, así que la usamos a partir de ahora.
Paso 6: Elegir $K$ y la métrica¶
Una $K$ pequeña es sensible al ruido (decide un solo vecino atípico); una $K$ grande suaviza demasiado e ignora los patrones locales. Como dice el libro, $K$ suele elegirse con validación cruzada:
ks = [1, 3, 5, 7, 9, 11, 15, 21, 31, 51, 101]
k_scores = [cross_val_score(make_pipeline(StandardScaler(), KNeighborsClassifier(n_neighbors=k)),
X_train, y_train, cv=cv).mean() for k in ks]
best_k = ks[int(np.argmax(k_scores))]
plt.figure(figsize=(8, 4))
plt.semilogx(ks, k_scores, 'o-')
plt.axvline(best_k, color='gray', linestyle='--')
plt.xticks(ks, ks)
plt.xlabel('K (número de vecinos)')
plt.ylabel('Exactitud media de validación')
plt.title('Elección de K con validación cruzada de 5 grupos')
plt.grid(True)
plt.show()
print(" ".join(f"K={k}: {score:.3f}" for k, score in zip(ks, k_scores)))
print(f"Mejor K: {best_k} (exactitud {max(k_scores):.3f})\n")
metric_scores = {}
for metric in ['euclidean', 'manhattan', 'chebyshev']:
model = make_pipeline(StandardScaler(), KNeighborsClassifier(n_neighbors=best_k, metric=metric))
metric_scores[metric] = cross_val_score(model, X_train, y_train, cv=cv).mean()
print(f"K = {best_k}, {metric:<10} exactitud media de validación = {metric_scores[metric]:.3f}")
best_metric = max(metric_scores, key=metric_scores.get)
print(f"Mejor métrica: {best_metric}")
K=1: 0.645 K=3: 0.658 K=5: 0.692 K=7: 0.700 K=9: 0.708 K=11: 0.695 K=15: 0.697 K=21: 0.691 K=31: 0.681 K=51: 0.670 K=101: 0.648 Mejor K: 9 (exactitud 0.708) K = 9, euclidean exactitud media de validación = 0.708 K = 9, manhattan exactitud media de validación = 0.719
K = 9, chebyshev exactitud media de validación = 0.667 Mejor métrica: manhattan
La curva tiene la forma que describe el libro. Con $K = 1$ el modelo es sensible al ruido (0,645); la exactitud mejora hasta $K = 9$ y después vuelve a caer a medida que los vecindarios se hacen demasiado grandes. Con $K = 9$, la distancia Manhattan funciona algo mejor que la euclídea, y Chebyshev, que solo mira la mayor diferencia, es la peor de las tres.
Paso 7: La maldición de la dimensionalidad¶
Carlos advierte en el libro de que, con muchas características, la noción de cercanía deja de ser fiable. Podemos comprobarlo: añadimos columnas de puro ruido, que no tienen nada que ver con la urgencia, y repetimos la validación cruzada.
rng = np.random.default_rng(0)
for n_noise in [0, 5, 20, 50]:
X_noisy = np.c_[X_train.to_numpy(dtype=float), rng.normal(size=(len(X_train), n_noise))]
model = make_pipeline(StandardScaler(), KNeighborsClassifier(n_neighbors=best_k, metric=best_metric))
score = cross_val_score(model, X_noisy, y_train, cv=cv).mean()
print(f"{len(feature_names)} características reales + {n_noise:>2} columnas de ruido: exactitud media de validación = {score:.3f}")
13 características reales + 0 columnas de ruido: exactitud media de validación = 0.719 13 características reales + 5 columnas de ruido: exactitud media de validación = 0.671
13 características reales + 20 columnas de ruido: exactitud media de validación = 0.604
13 características reales + 50 columnas de ruido: exactitud media de validación = 0.511
Cada bloque de columnas de ruido baja la exactitud, de 0,719 con las 13 características reales a 0,511 con 50 columnas extra. Tras el escalado, una columna de ruido pesa en la distancia tanto como una real, así que los vecinos se eligen cada vez más al azar. Con datos reales, esto es un motivo para seleccionar las características con cuidado antes de usar KNN.
Paso 8: Evaluación final¶
Entrenamos el modelo elegido (estandarización, la mejor $K$ y la mejor métrica) con todo el conjunto de entrenamiento y lo evaluamos una sola vez en el conjunto de prueba. En un problema multiclase la matriz de confusión es una tabla de $5 \times 5$: las filas son los niveles reales y las columnas los predichos, así que todo lo que queda fuera de la diagonal es un error.
knn = make_pipeline(StandardScaler(), KNeighborsClassifier(n_neighbors=best_k, metric=best_metric))
knn.fit(X_train, y_train)
y_pred = knn.predict(X_test)
print(classification_report(y_test, y_pred, target_names=level_names, digits=3))
errors = y_pred != y_test
print(f"Errores: {errors.sum()}, de los que un {np.mean(np.abs(y_pred - y_test)[errors] == 1):.0%} están a un nivel del real")
ConfusionMatrixDisplay.from_predictions(y_test, y_pred, display_labels=level_names, cmap='Blues', xticks_rotation=30)
plt.xlabel('Nivel predicho')
plt.ylabel('Nivel real')
plt.title(f'KNN (K = {best_k}, {best_metric}) en el conjunto de prueba')
plt.show()
precision recall f1-score support
1 Rojo 0.958 0.575 0.719 40
2 Naranja 0.773 0.750 0.762 132
3 Amarillo 0.688 0.508 0.584 195
4 Verde 0.728 0.840 0.780 418
5 Azul 0.752 0.777 0.764 215
accuracy 0.739 1000
macro avg 0.780 0.690 0.722 1000
weighted avg 0.741 0.739 0.734 1000
Errores: 261, de los que un 90% están a un nivel del real
En el conjunto de prueba KNN clasifica bien al 73,9% de los pacientes. Fíjate en el nivel rojo: cuando el modelo dice nivel 1 casi siempre acierta (precisión de 0,958), pero solo encuentra a 23 de los 40 pacientes de nivel 1 (recall de 0,575). En triaje ese es el error peligroso, un paciente crítico clasificado como menos urgente. Casi todos los errores (el 90%) están a un nivel del real, que es también donde varía el propio criterio de las enfermeras.
Por último, la función knn_predict que escribimos al principio hace exactamente lo mismo que scikit-learn. La aplicamos a los pacientes de prueba, con el mismo escalado, la misma $K$ y la misma métrica:
scaler = knn[0]
Xs_train, Xs_test = scaler.transform(X_train), scaler.transform(X_test)
distance = {'euclidean': euclidean, 'manhattan': manhattan, 'chebyshev': chebyshev}[best_metric]
ours = np.array([knn_predict(Xs_train, y_train.to_numpy(), x, k=best_k, distance=distance)[0] for x in Xs_test])
print(f"Misma predicción que scikit-learn en {np.sum(ours == y_pred)} de {len(y_pred)} pacientes de prueba")
Misma predicción que scikit-learn en 1000 de 1000 pacientes de prueba
Nuestras veinte líneas de NumPy dan la misma respuesta que scikit-learn para todos los pacientes de prueba. scikit-learn solo añade estructuras de datos que encuentran los vecinos más deprisa.
Conclusión¶
- KNN clasifica un caso nuevo con la clase mayoritaria de sus $K$ vecinos más cercanos. No necesita entrenamiento, pero tiene que guardar todos los datos y calcular todas las distancias en cada predicción.
- Qué significa "cerca" es una decisión: la métrica cambia los vecinos y, sin escalado, las características con las unidades más grandes dominan la distancia.
- $K$ y la métrica son hiperparámetros, que se eligen con validación cruzada.
- Con muchas características irrelevantes la noción de cercanía se diluye: la maldición de la dimensionalidad.
- El modelo maneja las cinco clases de forma natural, pero no da ninguna explicación de sus decisiones y, con los datos de triaje, todavía comete muchos errores. Como en el libro, los siguientes candidatos son Naive Bayes, los árboles de decisión y los bosques aleatorios.