Antes de empezar
Varios ejercicios usan las variables y funciones de la teoría: X, y, X_ent, X_prueba, y_ent, y_prueba, preparar, con_preparacion, A (el entrenamiento ya preparado), nombres, y_arr, gini, mejor_corte, arbol2 (el árbol de profundidad 2) y los datos de la onda (x, onda, Xx). Abre un cuaderno nuevo en Colab, pega y ejecuta en orden los bloques de la ficha Código y después trabaja debajo. Intenta cada ejercicio antes de abrir la respuesta.
Ejercicios
1. El Gini de dos cortes, a mano
Diez ejemplos con una sola variable x y su clase:
| x | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|
| clase | 0 | 0 | 0 | 1 | 0 | 1 | 1 | 1 | 1 | 0 |
a) Calcula el Gini del grupo completo.
b) Compara dos cortes: «¿x ≤ 3,5?» y «¿x ≤ 5,5?». Para cada uno, calcula el Gini de cada lado, el Gini ponderado y la ganancia. ¿Cuál elegiría un árbol?
c) Comprueba tus cuentas con gini y busca con mejor_corte si hay un corte todavía mejor.
Respuesta
a) Hay 5 ejemplos de cada clase: Gini = 1 − 0,5² − 0,5² = 0,5.
b) Corte en 3,5. A la izquierda quedan 1, 2 y 3, todos de clase 0: Gini = 0. A la derecha quedan 7 ejemplos, 5 de clase 1 y 2 de clase 0: Gini = 1 − (5/7)² − (2/7)² = 1 − 25/49 − 4/49 = 20/49 ≈ 0,408. Ponderado: (3 · 0 + 7 · 0,408) / 10 ≈ 0,286. Ganancia: 0,5 − 0,286 ≈ 0,214.
Corte en 5,5. A la izquierda, 1 a 5: cuatro de clase 0 y uno de clase 1, Gini = 1 − 0,8² − 0,2² = 0,32. A la derecha, 6 a 10: cuatro de clase 1 y uno de clase 0, también 0,32. Ponderado: 0,32. Ganancia: 0,18.
El árbol elegiría el corte en 3,5: deja un lado completamente puro, aunque el otro quede algo mezclado.
c)
xs = np.arange(1, 11)
clases = np.array([0, 0, 0, 1, 0, 1, 1, 1, 1, 0])
print(f"Gini total: {gini(clases):.3f}")
for u in [3.5, 5.5]:
izq, der = clases[xs <= u], clases[xs > u]
ponderado = (len(izq) * gini(izq) + len(der) * gini(der)) / 10
print(f"Corte en {u}: izq {gini(izq):.3f}, der {gini(der):.3f},"
f" ponderado {ponderado:.3f}, ganancia {0.5 - ponderado:.3f}")
u, ganancia, _, _ = mejor_corte(xs, clases)
print(f"Mejor corte: x <= {u}, ganancia {ganancia:.3f}")
Gini total: 0.500
Corte en 3.5: izq 0.000, der 0.408, ponderado 0.286, ganancia 0.214
Corte en 5.5: izq 0.320, der 0.320, ponderado 0.320, ganancia 0.180
Mejor corte: x <= 3.5, ganancia 0.214
El mejor corte es el de 3,5. Fíjate que la función solo prueba los puntos medios entre valores vecinos: 1,5, 2,5, 3,5…
2. Entropía y ganancia de información
a) Escribe una función entropia(etiquetas) que calcule H = −Σ pₖ · log₂ pₖ. Comprueba que da 0 en un grupo puro y 1 en uno mitad y mitad.
b) Cambia mejor_corte para que reciba la medida de impureza como parámetro (impureza=gini por defecto) y busca, con la entropía, el mejor corte de la edad entre los hombres del Titanic. ¿Coincide con el de Gini?
Respuesta
def entropia(etiquetas):
"""Entropía (en bits) de un grupo de etiquetas."""
_, cuentas = np.unique(etiquetas, return_counts=True)
p = cuentas / len(etiquetas)
# 0.0 - ... evita que un grupo puro imprima -0.0
return 0.0 - np.sum(p * np.log2(p))
def mejor_corte_con(x, etiquetas, impureza=gini):
"""Como mejor_corte, pero con la medida de impureza que se elija."""
valores = np.unique(x)
umbrales = (valores[:-1] + valores[1:]) / 2
n = len(etiquetas)
ponderadas = np.array([
(np.sum(x <= u) * impureza(etiquetas[x <= u])
+ np.sum(x > u) * impureza(etiquetas[x > u])) / n
for u in umbrales])
k = np.argmin(ponderadas)
return umbrales[k], impureza(etiquetas) - ponderadas[k]
print("Puro:", entropia([1, 1, 1, 1]), " mitad:", entropia([0, 1, 0, 1]))
hombres = A[:, list(nombres).index("sexo_mujer")] == 0
edad_h = A[hombres, list(nombres).index("edad")]
for impureza in [gini, entropia]:
u, g = mejor_corte_con(edad_h, y_arr[hombres], impureza)
print(f"{impureza.__name__:8s}: edad <= {u:g}, ganancia {g:.3f}")
Puro: 0.0 mitad: 1.0
gini : edad <= 13, ganancia 0.022
entropia: edad <= 13, ganancia 0.040
Las dos medidas eligen el mismo corte. La ganancia de información es un número más grande porque la entropía va de 0 a 1 con dos clases, y el Gini de 0 a 0,5: solo se comparan cortes con la misma medida.
3. Bajar por el árbol
Usa el árbol de profundidad 2 de la teoría (la figura del árbol). Sin computadora, predice si sobrevive cada pasajero y con qué probabilidad:
- Un niño de 8 años que viajaba en tercera clase.
- Una mujer de 40 años que viajaba en tercera clase.
- Un hombre de 30 años que viajaba en primera clase.
Después compruébalo con arbol2.predict_proba.
Respuesta
- No es mujer: va por el sí. Tiene 13 años o menos: va otra vez por el sí y cae en la hoja [0,43, 0,57]. Predicción: vivió, con 57 %.
- Es mujer: va por el no. Su clase es 3, que no es ≤ 2,5: cae en la hoja [0,51, 0,49]. Predicción: murió, con 49 % de sobrevivir. Es casi una moneda al aire.
- No es mujer y tiene más de 13 años: hoja [0,84, 0,16]. Predicción: murió, con 16 % de sobrevivir. El árbol de profundidad 2 no mira la clase de los hombres, aunque viajar en primera sí les ayudaba: para eso haría falta un árbol más profundo.
pasajeros = pd.DataFrame({
"clase": [3, 3, 1], "sexo": ["hombre", "mujer", "hombre"],
"edad": [8, 40, 30], "hermanos_pareja": [0, 0, 0],
"padres_hijos": [1, 0, 0], "tarifa": [10.0, 10.0, 80.0],
"puerto": ["S", "S", "S"]})
print(arbol2.predict(pasajeros))
print(arbol2.predict_proba(pasajeros)[:, 1].round(2))
[1 0 0]
[0.57 0.49 0.16]
4. Un árbol profundo frente a un bosque
La varianza de un modelo se puede medir directamente: entrena el mismo modelo con dos mitades distintas del entrenamiento y cuenta en cuántos pasajeros de validación no coinciden sus predicciones.
a) Separa, dentro de A y y_arr, un 25 % para validación. Divide el resto en dos mitades al azar.
b) Entrena un árbol sin límite de profundidad con cada mitad. ¿En qué porcentaje de los pasajeros de validación discrepan? ¿Qué exactitud tiene cada uno?
c) Repite con dos random forest de 200 árboles. ¿Qué cambia?
Respuesta
from sklearn.ensemble import RandomForestClassifier
A_resto, A_v, y_resto, y_v = train_test_split(
A, y_arr, test_size=0.25, stratify=y_arr, random_state=2)
mezcla = np.random.default_rng(3).permutation(len(A_resto))
mitades = [mezcla[: len(mezcla) // 2], mezcla[len(mezcla) // 2:]]
for nombre, crear in [
("Árbol profundo", lambda: DecisionTreeClassifier(random_state=0)),
("Random forest", lambda: RandomForestClassifier(
n_estimators=200, random_state=0))]:
preds = []
for m in mitades:
modelo = crear().fit(A_resto[m], y_resto[m])
preds.append(modelo.predict(A_v))
aciertos = [np.mean(p == y_v) for p in preds]
print(f"{nombre:15s} exactitudes {aciertos[0]:.3f} y "
f"{aciertos[1]:.3f}, discrepan en el "
f"{np.mean(preds[0] != preds[1]):.0%}")
Árbol profundo exactitudes 0.802 y 0.778, discrepan en el 22%
Random forest exactitudes 0.802 y 0.820, discrepan en el 16%
Con mitades distintas de los datos, los dos árboles profundos no coinciden en más de uno de cada cinco pasajeros de validación: eso es la varianza. Los dos bosques también cambian, pero bastante menos (16 %), y aciertan lo mismo o más. Promediar 200 árboles no elimina la dependencia de los datos, pero la achica.
5. Elegir la profundidad con una búsqueda en rejilla
Con GridSearchCV (tema 10) y el pipeline con_preparacion(DecisionTreeClassifier(random_state=0)), busca la mejor combinación de max_depth (2, 3, 4, 5, 6, 8 y sin límite) y min_samples_leaf (1, 3, 5, 10 y 20) con validación cruzada de 5 pliegues. Recuerda que, dentro de un pipeline, los nombres de los hiperparámetros llevan el nombre del paso: modelo__max_depth. ¿Cuál es la mejor combinación? ¿Cuántas hojas tiene ese árbol?
Respuesta
from sklearn.model_selection import GridSearchCV
rejilla = {"modelo__max_depth": [2, 3, 4, 5, 6, 8, None],
"modelo__min_samples_leaf": [1, 3, 5, 10, 20]}
busqueda = GridSearchCV(
con_preparacion(DecisionTreeClassifier(random_state=0)),
rejilla, cv=5)
busqueda.fit(X_ent, y_ent)
print("Mejor combinación:", busqueda.best_params_)
print(f"Validación cruzada: {busqueda.best_score_:.3f}")
mejor_arbol = busqueda.best_estimator_["modelo"]
print("Hojas:", mejor_arbol.get_n_leaves(),
" profundidad:", mejor_arbol.get_depth())
Mejor combinación: {'modelo__max_depth': 8, 'modelo__min_samples_leaf': 5}
Validación cruzada: 0.829
Hojas: 52 profundidad: 8
La mejor combinación usa los dos frenos a la vez: hasta 8 preguntas, pero con al menos 5 pasajeros por hoja. Con eso llega a 0,829 en validación cruzada, un poco más que con cualquiera de los dos frenos por separado en la teoría. El árbol tiene 52 hojas: ya no se puede leer de un vistazo, que es el precio de dejarlo crecer.
6. La tasa de aprendizaje del boosting
Con los datos de la onda de la teoría, entrena GradientBoostingRegressor (100 árboles de profundidad 2) con tasas de aprendizaje 1, 0,1 y 0,01. Para medir cada uno con datos que no vio, genera una segunda onda con otra semilla: x_nueva = np.sort(np.random.default_rng(1).uniform(0, 6, 80)) y onda_nueva = np.sin(x_nueva) + ... con el mismo ruido. Compara el MSE en los datos de entrenamiento y en los nuevos. ¿Qué tasa elegirías? ¿Qué harías con la tasa de 0,01?
Respuesta
rng_nueva = np.random.default_rng(1)
x_nueva = np.sort(rng_nueva.uniform(0, 6, 80))
onda_nueva = np.sin(x_nueva) + rng_nueva.normal(0, 0.25, 80)
for tasa in [1, 0.1, 0.01]:
gb = GradientBoostingRegressor(n_estimators=100, learning_rate=tasa,
max_depth=2, random_state=0)
gb.fit(Xx, onda)
mse_ent = np.mean((onda - gb.predict(Xx)) ** 2)
mse_nueva = np.mean((onda_nueva
- gb.predict(x_nueva.reshape(-1, 1))) ** 2)
print(f"tasa {tasa:<4}: entrenamiento {mse_ent:.3f}"
f" datos nuevos {mse_nueva:.3f}")
tasa 1 : entrenamiento 0.000 datos nuevos 0.087
tasa 0.1 : entrenamiento 0.017 datos nuevos 0.064
tasa 0.01: entrenamiento 0.124 datos nuevos 0.180
- Con tasa 1, el error de entrenamiento llega a cero: cada árbol corrige todo de golpe, incluido el ruido, y en los datos nuevos el error es mayor que con 0,1. Es sobreajuste.
- Con tasa 0,1, el error en los datos nuevos es el más bajo. Es la que elegiríamos.
- Con tasa 0,01, 100 árboles no alcanzan: el modelo todavía está lejos de la onda en los dos conjuntos (subajuste). Habría que darle muchos más árboles, unas diez veces más. Por eso la tasa y el número de árboles se eligen juntos.
7. Una variable que no sirve para nada
Agrega a los datos una columna de puro azar, entrena un random forest y compara las dos importancias: feature_importances_ (basada en cuánto bajó el Gini) y la importancia por permutación medida en el conjunto de prueba. ¿Qué lugar le da cada una a la columna de azar?
Respuesta
rng_azar = np.random.default_rng(0)
A_azar = np.column_stack([A, rng_azar.normal(size=len(A))])
A_prueba = preparar.transform(X_prueba)
A_prueba_azar = np.column_stack([A_prueba,
rng_azar.normal(size=len(A_prueba))])
columnas_azar = list(nombres) + ["azar"]
rf = RandomForestClassifier(n_estimators=200, random_state=0)
rf.fit(A_azar, y_arr)
perm = permutation_importance(rf, A_prueba_azar, y_prueba,
n_repeats=20, random_state=0)
tabla = pd.DataFrame({"Gini": rf.feature_importances_,
"permutación": perm.importances_mean},
index=columnas_azar)
print(tabla.sort_values("Gini", ascending=False).round(3))
Gini permutación
sexo_mujer 0.261 0.160
tarifa 0.194 0.028
azar 0.183 -0.003
edad 0.165 0.051
clase 0.088 0.076
hermanos_pareja 0.043 0.011
padres_hijos 0.033 0.002
puerto_C 0.013 0.011
puerto_S 0.012 -0.003
puerto_Q 0.009 -0.003
Para feature_importances_, la columna de azar es la tercera más importante, por encima de la edad y de la clase. No es que el bosque la use para predecir bien: como tiene 668 valores distintos, ofrece muchísimos umbrales, y en las ramas profundas siempre aparece alguno que «limpia» un poco un grupo chico por pura casualidad. La importancia por permutación, medida en la prueba, le da prácticamente cero (un poco negativa, por el azar del desorden): desordenarla no le quita nada al modelo. La tarifa tiene el mismo problema en menor medida: muchos valores distintos, y una importancia de Gini bastante mayor que la de permutación.
Mini-proyecto: el mejor modelo para el Titanic
Busca el mejor modelo de árboles que puedas para predecir quién sobrevivió, y escribe un informe corto de qué variables importan. Usa siempre la misma división de la teoría (X_ent, X_prueba) y toca el conjunto de prueba una sola vez, al final.
Pasos.
- Nuevas variables. Agrega a
Xal menos dos:familia(hermanos_pareja + padres_hijos + 1) ycubierta(la columnadeckdetitanic, con los faltantes como la categoría"desconocida"). Vuelve a dividir con la misma semilla y la misma estratificación para que los pasajeros de prueba sean los mismos. - Un pipeline nuevo. Arma otro
ColumnTransformerque incluya las nuevas columnas. - Busca hiperparámetros con
GridSearchCVpara un random forest (min_samples_leaf,max_features) y unHistGradientBoostingClassifier(learning_rate,max_depth). Compara sus puntajes de validación cruzada con el árbol podado del ejercicio 5. - Elige un modelo según la validación cruzada (no según la prueba) y mídelo una vez en la prueba.
- Importancia. Calcula la importancia por permutación en la prueba y haz el gráfico de barras.
- Informe. Escribe cinco o seis frases: qué modelo elegiste y por qué, cuánto acierta, si las variables nuevas ayudaron y qué variables importan. Agrega una advertencia: ¿qué no dice la importancia?
Resultados de referencia, para comprobar los tuyos
Este código hace los pasos 1 a 5 (sin el gráfico); tus números deberían coincidir si usas las mismas rejillas.
from sklearn.ensemble import HistGradientBoostingClassifier
# Paso 1: nuevas variables
X_mas = X.copy()
X_mas["familia"] = X["hermanos_pareja"] + X["padres_hijos"] + 1
cubierta = titanic["deck"].astype(str)
X_mas["cubierta"] = cubierta.where(titanic["deck"].notna(), "desconocida")
Xm_ent, Xm_prueba, _, _ = train_test_split(
X_mas, y, test_size=0.25, stratify=y, random_state=0)
print("Mismos pasajeros de prueba:",
(Xm_prueba.index == X_prueba.index).all())
# Paso 2: el pipeline con las columnas nuevas
preparar_mas = ColumnTransformer(
[("num", SimpleImputer(strategy="median"), numericas + ["familia"]),
("cat", categoricas_a_numeros, categoricas + ["cubierta"])],
verbose_feature_names_out=False)
# Paso 3: búsqueda para cada modelo
candidatos = {
"Random forest": (
RandomForestClassifier(n_estimators=300, random_state=0),
{"modelo__min_samples_leaf": [1, 3, 5],
"modelo__max_features": ["sqrt", 0.5]}),
"HistGradientBoosting": (
HistGradientBoostingClassifier(random_state=0),
{"modelo__learning_rate": [0.03, 0.1],
"modelo__max_depth": [2, 3, None]}),
}
mejores = {}
for nombre, (modelo, rejilla_m) in candidatos.items():
tubo = Pipeline([("preparar", preparar_mas), ("modelo", modelo)])
b = GridSearchCV(tubo, rejilla_m, cv=5).fit(Xm_ent, y_ent)
mejores[nombre] = b
elegidos = {k.split("__")[1]: v for k, v in b.best_params_.items()}
print(f"{nombre:21s} {b.best_score_:.3f} {elegidos}")
# Paso 4: el mejor según la validación cruzada, medido una vez
elegido = max(mejores, key=lambda k: mejores[k].best_score_)
modelo_final = mejores[elegido].best_estimator_
print(f"Elegido: {elegido}. Exactitud en la prueba: "
f"{modelo_final.score(Xm_prueba, y_prueba):.3f}")
# Paso 5: importancia por permutación en la prueba
imp_mas = permutation_importance(modelo_final, Xm_prueba, y_prueba,
n_repeats=20, random_state=0)
print(pd.Series(imp_mas.importances_mean, index=X_mas.columns)
.sort_values(ascending=False).round(3).to_string())
Mismos pasajeros de prueba: True
Random forest 0.840 {'max_features': 0.5, 'min_samples_leaf': 3}
HistGradientBoosting 0.832 {'learning_rate': 0.03, 'max_depth': None}
Elegido: Random forest. Exactitud en la prueba: 0.825
sexo 0.173
edad 0.054
clase 0.049
tarifa 0.030
puerto 0.021
cubierta 0.004
padres_hijos -0.007
hermanos_pareja -0.009
familia -0.012
Un informe posible:
- El random forest (300 árboles, al menos 3 pasajeros por hoja y la mitad de las variables en cada corte) tuvo la mejor validación cruzada, 0,840, apenas por encima del HistGradientBoosting (0,832) y del árbol podado (0,829). Con tan pocos pasajeros, esas diferencias están dentro del ruido.
- En la prueba acertó 0,825, en línea con la validación cruzada. Es el número que se reporta.
- Las variables nuevas no ayudaron: la cubierta casi no importa, y
familiasale un poco negativa: desordenarla no empeora el modelo, probablemente porque repite lo que ya dicenhermanos_parejaypadres_hijos. La mejora de la validación cruzada respecto de la teoría viene sobre todo de afinar los hiperparámetros, no de las columnas nuevas. Una variable nueva no ayuda solo por existir: hay que medirla. - El sexo es, de lejos, la variable más importante; después vienen la edad y la clase.
- La importancia dice de qué depende este modelo para predecir, no por qué sobrevivió cada persona. Además, entre variables que dicen casi lo mismo (como
familiay sus dos componentes), la importancia se reparte y cada una parece menos importante de lo que es.
Errores comunes
- Dejar crecer el árbol sin límites. Acierta casi el 100 % en entrenamiento y menos que un árbol podado en validación. Elige
max_depthomin_samples_leafcon validación cruzada. - Escalar o estandarizar «por costumbre» antes de un árbol. No hace daño, pero no cambia nada: los cortes dependen solo del orden de los valores.
- Codificar una categoría nominal con números (Cherbourg = 1, Queenstown = 2, Southampton = 3). El árbol puede preguntar «¿puerto ≤ 1,5?» como si hubiera un orden que no existe. Usa one-hot, como en el tema 10.
- Afinar el número de árboles de un random forest como si fuera a sobreajustar. En el bosque, más árboles solo cuesta tiempo; en el boosting sí hay que elegirlo, junto con la tasa de aprendizaje.
- Subir la tasa de aprendizaje del boosting para «ir más rápido». Con tasa 1, cada árbol corrige todo de golpe, incluido el ruido.
- Leer
feature_importances_como la verdad. Favorece a las variables con muchos valores distintos, aunque sean ruido. Usa la importancia por permutación en datos que el modelo no vio. - Creer que una variable importante es una causa. El modelo encontró que ayuda a predecir en estos datos; eso no dice por qué.
- Comparar modelos con el conjunto de prueba. Se comparan con validación cruzada sobre el entrenamiento; la prueba se usa una vez, con el modelo ya elegido.
- Confiar en un árbol leído de memoria. Un árbol profundo cambia mucho si cambian un poco los datos: sus preguntas de abajo no son reglas generales.
¿Lo lograste?
Al terminar este tema deberías poder hacer lo siguiente. Al lado, dónde lo practicaste.
- Explicar cómo un árbol elige un corte con la impureza de Gini o la entropía (ejercicios 1 y 2, y la actividad).
- Controlar el sobreajuste de un árbol con
max_depthymin_samples_leaf(ejercicio 5). - Explicar el bagging y el random forest como el promedio de árboles distintos (ejercicio 4).
- Explicar el gradient boosting como una corrección sucesiva de errores (ejercicio 6).
- Comparar los modelos e interpretar la importancia de las variables (ejercicio 7 y el mini-proyecto).