Módulo 3 · Aprendizaje supervisado
Árboles, vecinos y ensembles
En esta páginaEl problema
El problema
load_wine contiene 178 vinos de tres cultivares. Cada vino tiene 13 mediciones químicas. Estas tres filas muestran cuatro de ellas:
| fila | alcohol | malic_acid | magnesium | proline | clase |
|---|---|---|---|---|---|
| 135 | 12.60 | 2.46 | 94 | 695 | 2 |
| 170 | 12.20 | 3.03 | 96 | 510 | 2 |
| 18 | 14.19 | 1.59 | 108 | 1680 | 0 |
Los datos se dividen en entrenamiento, validación y prueba. Con las 13 columnas tal como vienen, un clasificador de K vecinos más cercanos (K-NN) acierta 27 de 36 vinos de validación. Un árbol de decisión, con los mismos datos, acierta 32. La prueba queda apartada: todas las comparaciones usan validación.
Vecinos más cercanos
K-NN no aprende parámetros. Guarda las filas de entrenamiento. Para clasificar un vino nuevo, calcula su distancia euclidiana a cada vino guardado, toma los k más cercanos y predice la clase que más se repite entre ellos.
El baseline DummyClassifier(strategy="most_frequent") predice siempre la clase más frecuente del entrenamiento. En el dataset completo, la clase 1 tiene 71 de los 178 vinos.
Pregunta. Escribe cuántas filas quedan en entrenamiento y en prueba. Calcula cuántos de los 36 vinos de validación acertará el baseline.
import numpy as np
import pandas as pd
from sklearn.datasets import load_wine
from sklearn.dummy import DummyClassifier
from sklearn.model_selection import train_test_split
from sklearn.neighbors import KNeighborsClassifier
X, y = load_wine(return_X_y=True, as_frame=True)
X_resto, X_prueba, y_resto, y_prueba = train_test_split(
X, y, test_size=0.2, stratify=y, random_state=0
)
X_ent, X_val, y_ent, y_val = train_test_split(
X_resto, y_resto, test_size=0.25, stratify=y_resto, random_state=0
)
baseline = DummyClassifier(strategy="most_frequent").fit(X_ent, y_ent)
knn_crudo = KNeighborsClassifier(n_neighbors=5).fit(X_ent, y_ent)
print(len(X_ent), len(X_val), len(X_prueba))
print(int((baseline.predict(X_val) == y_val).sum()))
print(int((knn_crudo.predict(X_val) == y_val).sum()))
La salida esperada está oculta.
Las filas 135 y 170 de la tabla son los dos primeros vinos de X_ent. La distancia suma las diferencias al cuadrado de las 13 columnas y saca la raíz.
Pregunta. Usa la tabla de El problema para estimar la distancia entre esos dos vinos. ¿Qué fracción de la suma de cuadrados aporta proline?
Ver resultado
La tabla muestra las tres columnas que más aportan a la suma de cuadrados entre las filas 135 y 170. La primera columna nombra la medición y la segunda da la diferencia entre los dos vinos en esa medición, elevada al cuadrado. La fila de proline con 34225.0 dice que 695 y 510 difieren en 185, y 185 al cuadrado es 34225.
| columna | diferencia al cuadrado |
|---|---|
proline | 34225.0 |
magnesium | 4.0 |
color_intensity | 2.6 |
La distancia entre los dos vinos es 185.0.
Las otras 12 columnas suman 7.6.
proline aporta una fracción de 1.0 de la suma de cuadrados.
proline: aporta 34225.0 y las otras 12 columnas juntas, 7.6. La fracción no es exactamente 1; el 1.0 sale de redondear a tres decimales. Para K-NN, dos vinos son cercanos si tienen proline parecida; las demás mediciones apenas cuentan. La causa es la unidad: proline varía en cientos y alcohol en décimas.StandardScaler resta la media y divide entre la desviación estándar de cada columna. Así cada columna queda con media 0 y desviación 1 en entrenamiento. Dentro de make_pipeline, el escalador se ajusta solo con las filas que recibe fit.
Pregunta. Después de escalar, ¿qué fracción de la suma de cuadrados entre los mismos dos vinos aporta proline? Para k igual a 1, 5 y 15, ¿cuál acierta todos los vinos de entrenamiento?
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
escalador = StandardScaler().fit(X_ent)
Z_ent = pd.DataFrame(escalador.transform(X_ent), columns=X.columns)
dif2_z = (Z_ent.iloc[0] - Z_ent.iloc[1]) ** 2
print(round(dif2_z["proline"] / dif2_z.sum(), 3))
for k in (1, 5, 15):
knn = make_pipeline(StandardScaler(), KNeighborsClassifier(n_neighbors=k))
knn.fit(X_ent, y_ent)
print(k, round(knn.score(X_ent, y_ent), 3), round(knn.score(X_val, y_val), 3))
La salida esperada está oculta.
Distancias en muchas dimensiones
En d dimensiones, la distancia euclidiana es la raíz de una suma de d diferencias al cuadrado. K-NN funciona si los vecinos más cercanos están claramente más cerca que el resto de los puntos. La razón entre la distancia al punto más lejano y al más cercano mide ese contraste.
La simulación genera 1000 puntos aleatorios uniformes en el cubo unitario de 2, 10, 100 y 1000 dimensiones. Después mide las distancias del primer punto a los otros 999.
Pregunta. Al subir la dimensión, ¿la razón entre la distancia máxima y la mínima crece, se mantiene o se acerca a 1?
Ver resultado
Cada fila corresponde a un número de dimensiones del cubo. Las columnas dan la distancia del primer punto a su vecino más cercano y a su vecino más lejano entre los otros 999, y la razón entre ambas. La fila con 10 dice que, en 10 dimensiones, el más cercano está a 0.567, el más lejano a 1.974 y la razón es 3.48.
| dimensiones | distancia mínima | distancia máxima | máxima / mínima |
|---|---|---|---|
| 2 | 0.006 | 0.948 | 165.17 |
| 10 | 0.567 | 1.974 | 3.48 |
| 100 | 3.405 | 4.87 | 1.43 |
| 1000 | 12.182 | 13.535 | 1.11 |
En el vino, las 13 columnas son mediciones químicas. Una columna irrelevante no tiene relación con la clase. El experimento agrega a X columnas de ruido uniforme y conserva las mismas filas de entrenamiento y validación. Después de StandardScaler, cada columna de ruido tiene la misma escala que una columna química.
Pregunta. Sin ruido, K-NN escalado con k=5 acierta 35 de 36 vinos de validación. ¿Cuántos acertará con 10, 100 y 1000 columnas de ruido? ¿Con cuántas se acerca a los 14 del baseline?
Ver resultado
Cada fila es una versión del vino con un número distinto de columnas de ruido. Las columnas dan cuántas columnas de ruido se agregaron, cuántas columnas recibe K-NN en total y cuántos de los 36 vinos de validación acierta K-NN escalado con k=5. La fila con 100 dice que, con 100 columnas de ruido y 113 en total, K-NN acierta 27 vinos.
| columnas de ruido | columnas en total | aciertos de 36 |
|---|---|---|
| 0 | 13 | 35 |
| 10 | 23 | 35 |
| 100 | 113 | 27 |
| 1000 | 1013 | 17 |
StandardScaler iguala las unidades, no distingue una columna útil de una de ruido.Árbol de decisión
Un árbol de decisión hace preguntas de sí o no sobre una columna: «¿alcohol ≤ 13?». En cada nodo elige la columna y el umbral que mejor separan las clases de las filas que llegan ahí. max_depth limita cuántas preguntas seguidas puede hacer.
Pregunta. Con max_depth=2, ¿cuántas hojas tiene el árbol como máximo? ¿Necesita escalar las columnas para comparar proline con un umbral?
from sklearn.tree import DecisionTreeClassifier, export_text
arbol_corto = DecisionTreeClassifier(max_depth=2, random_state=0).fit(X_ent, y_ent)
print(export_text(arbol_corto, feature_names=list(X.columns)))
La salida esperada está oculta.
DecisionTreeClassifier() sin max_depth no limita la profundidad.
Pregunta. ¿Qué exactitud tendrá el árbol sin límite en entrenamiento? ¿Cuántos niveles y hojas necesitará para lograrla?
arbol = DecisionTreeClassifier(random_state=0).fit(X_ent, y_ent)
print(arbol.get_depth(), arbol.get_n_leaves())
print(round(arbol.score(X_ent, y_ent), 3), round(arbol.score(X_val, y_val), 3))
La salida esperada está oculta.
Frontera de decisión
Mueve k o la profundidad y compara la exactitud de entrenamiento con la de validación.
Qué probar
Pasa el cursor sobre un punto o enfócalo para ver sus k vecinos.
Exactitud según k
Haz clic en un espacio vacío para agregar un punto de la clase elegida. Arrastra un punto o muévelo con las flechas. Los puntos de validación no cambian.
Bosques y boosting
Un ensemble combina muchos modelos. RandomForestClassifier entrena cada árbol con una muestra con reemplazo de las filas y, en cada división, con un subconjunto aleatorio de columnas. Después, promedia las probabilidades de clase de los árboles y predice la clase con el promedio más alto. Si cada hoja contiene una sola clase, cada árbol da probabilidad 1 a una clase y el promedio equivale a un voto. Los árboles individuales se entrenaron sin nombres de columna, por eso se evalúan con X_val.to_numpy().
Pregunta. Entre los 100 árboles del bosque, ¿la exactitud de validación del bosque será menor, igual o mayor que la media de sus árboles?
from sklearn.ensemble import RandomForestClassifier
bosque = RandomForestClassifier(n_estimators=100, random_state=0).fit(X_ent, y_ent)
exactitudes = [t.score(X_val.to_numpy(), y_val) for t in bosque.estimators_]
media = float(np.mean(exactitudes))
print(len(bosque.estimators_))
print(round(min(exactitudes), 3), round(media, 3), round(max(exactitudes), 3))
print(round(bosque.score(X_val, y_val), 3))
La salida esperada está oculta.
HistGradientBoostingClassifier también usa árboles, pero en secuencia. Cada ronda agrega árboles pequeños que corrigen los errores que dejaron las rondas anteriores. «Hist» indica que agrupa los valores de cada columna en a lo más 255 intervalos y busca umbrales solo entre intervalos. staged_predict devuelve la predicción después de cada ronda.
Pregunta. ¿Cómo cambia la exactitud de validación entre la ronda 1 y la ronda 100?
from sklearn.ensemble import HistGradientBoostingClassifier
boosting = HistGradientBoostingClassifier(max_iter=100, random_state=0).fit(X_ent, y_ent)
for ronda, pred in enumerate(boosting.staged_predict(X_val), start=1):
if ronda in (1, 5, 20, 100):
print(ronda, round(float((pred == y_val).mean()), 3))
La salida esperada está oculta.
Margen y kernel
Una máquina de vectores de soporte (SVM) busca la frontera que deja el mayor margen, es decir, la mayor distancia hasta los puntos más cercanos de cada clase. Un kernel mide la similitud entre puntos de modo que la frontera puede curvarse; kernel="linear" solo permite una recta y kernel="rbf" permite curvas.
make_moons genera dos medias lunas entrelazadas en dos columnas. Con noise=0.3, los bordes se mezclan.
Pregunta. Escribe la exactitud del baseline en validación. ¿Cuál kernel acertará más?
from sklearn.datasets import make_moons
from sklearn.svm import SVC
X_lunas, y_lunas = make_moons(n_samples=300, noise=0.3, random_state=0)
Xl_ent, Xl_val, yl_ent, yl_val = train_test_split(
X_lunas, y_lunas, test_size=0.3, stratify=y_lunas, random_state=0
)
print(round(DummyClassifier().fit(Xl_ent, yl_ent).score(Xl_val, yl_val), 3))
for kernel in ("linear", "rbf"):
svm = make_pipeline(StandardScaler(), SVC(kernel=kernel)).fit(Xl_ent, yl_ent)
print(kernel, round(svm.score(Xl_ent, yl_ent), 3), round(svm.score(Xl_val, yl_val), 3))
La salida esperada está oculta.
Comparación con el baseline
Una comparación es justa si todos los modelos usan la misma partición, la misma métrica y el mismo baseline, y si el preprocesamiento se ajusta solo con entrenamiento.
Pregunta. Seis de los siete modelos ya aparecieron con esta misma configuración. ¿Cuántos de los 36 vinos de validación acertará el SVM RBF y con qué modelos empatará o a cuáles superará?
modelos = {
"baseline": DummyClassifier(strategy="most_frequent"),
"vecinos sin escalar": KNeighborsClassifier(n_neighbors=5),
"vecinos escalados": make_pipeline(StandardScaler(), KNeighborsClassifier(n_neighbors=5)),
"árbol profundo": DecisionTreeClassifier(random_state=0),
"bosque aleatorio": RandomForestClassifier(random_state=0),
"boosting": HistGradientBoostingClassifier(random_state=0),
"SVM RBF": make_pipeline(StandardScaler(), SVC(kernel="rbf")),
}
for nombre, modelo in modelos.items():
modelo.fit(X_ent, y_ent)
aciertos = int((modelo.predict(X_val) == y_val).sum())
print(f"{nombre:<20} {aciertos:>2}/{len(y_val)} {aciertos / len(y_val):.3f}")
La salida esperada está oculta.
Práctica
Traza guiada
Calcula a mano las cinco distancias euclidianas de consulta a puntos. Predice la clase con k=1 y con k=3.
puntos = np.array([[1.0, 1.0], [1.0, 2.0], [2.0, 1.0], [3.0, 2.0], [5.0, 5.0]])
etiquetas = np.array([0, 0, 0, 1, 1])
consulta = np.array([[2.5, 1.8]])
Comprueba con KNeighborsClassifier(n_neighbors=k).fit(puntos, etiquetas), predict(consulta) y kneighbors(consulta). Las dos predicciones deben ser distintas. Si no coinciden con tu traza, revisa qué punto queda más cerca.
Árbol con escalado
Coloca el árbol sin límite dentro de make_pipeline(StandardScaler(), DecisionTreeClassifier(random_state=0)). Antes de ejecutarlo, predice su exactitud de validación. Compara con 0.889 y explica el resultado con el umbral de cada división. Después, compara el efecto del escalado en el árbol y en K-NN.
Comparación en cáncer de mama
Carga load_breast_cancer(return_X_y=True, as_frame=True). Repite las dos particiones con stratify y random_state=0. Construye la tabla de los siete modelos sobre validación. El baseline debe acertar 71 de 114 casos. Reporta cuántos casos separan a cada modelo del baseline y cuántos separan a los dos mejores. Compara el cambio de K-NN al escalar con el del vino y busca con std() qué columnas dominan la distancia sin escalar.
Reto opcional
Entrena el bosque del vino con n_estimators igual a 1, 5 y 50. Predice la exactitud de validación de cada uno y compárala con la media de los árboles individuales.
Errores que revisar
- Si K-NN rinde mucho peor que un árbol con los mismos datos, compara
X_ent.std(): una columna con escala mucho mayor decide la distancia. - Si un modelo acierta 1.0 en entrenamiento, mide también validación antes de reportarlo: el árbol sin límite y
k=1aciertan las filas que memorizaron. - Si ajustas
StandardScalercon todas las filas antes de dividir, el escalado ya usó la media de validación y prueba; ponlo dentro demake_pipeline. - Si dos modelos difieren en uno o dos aciertos de 36, reporta la diferencia en aciertos y no declares un ganador.
- Si
SVC(kernel="linear")no pasa de 0.77 enmake_moons, el código no está mal: ninguna recta separa las dos lunas.
Cierre
Con la tabla del vino, escribe qué modelos reportarías, cuántos vinos más que el baseline acierta cada uno y qué evidencia te falta para elegir uno solo.
Lista de comprobación
- ¿Por qué la escala de
prolinecambia las predicciones de K-NN y no las del árbol? - ¿Qué indica una exactitud de 1.0 en entrenamiento con 0.889 en validación?
- ¿En qué se diferencian un bosque aleatorio y el boosting al combinar árboles?
- ¿Por qué el kernel RBF separa
make_moonsmejor que el kernel lineal? - ¿Qué condiciones hacen justa la comparación contra el baseline?
Documentación oficial
- Vecinos más cercanos: K-NN, métricas de distancia y elección de
k. - Árboles de decisión: criterios de división, profundidad y
export_text. - Ensembles: bosques aleatorios y
HistGradientBoostingClassifier. - Máquinas de vectores de soporte: margen, vectores de soporte y kernels.