Árboles de decisión y ensambles

Tema 11 · Fase 2, Aprender de ejemplos · Toda la ruta

Cuaderno del tema. La teoría y los ejercicios con todo su código, listos para ejecutar en Colab sin instalar nada.

Los 17 bloques de código de la teoría, en el mismo orden. Si prefieres armar tu propio cuaderno, cópialos y córrelos en orden; en la ficha Teoría está la explicación de cada uno.

Los datos: el Titanic

import numpy as np
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt

titanic = sns.load_dataset("titanic")

# Las columnas que vamos a usar, con nombres en español
columnas = {"pclass": "clase", "sex": "sexo", "age": "edad",
            "sibsp": "hermanos_pareja", "parch": "padres_hijos",
            "fare": "tarifa", "embarked": "puerto"}
X = titanic[list(columnas)].rename(columns=columnas)
X["sexo"] = X["sexo"].map({"male": "hombre", "female": "mujer"})
y = titanic["survived"]               # 1 = sobrevivió, 0 = murió

print(X.shape)
print(X.head(3))
print("Faltantes:", X.isna().sum()[X.isna().sum() > 0].to_dict())
print(f"Sobrevivió el {y.mean():.1%} de los pasajeros")
from sklearn.model_selection import train_test_split
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline, make_pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import OneHotEncoder
from sklearn.base import clone

X_ent, X_prueba, y_ent, y_prueba = train_test_split(
    X, y, test_size=0.25, stratify=y, random_state=0)

numericas = ["clase", "edad", "hermanos_pareja", "padres_hijos", "tarifa"]
categoricas = ["sexo", "puerto"]
categoricas_a_numeros = make_pipeline(
    SimpleImputer(strategy="most_frequent"),
    # drop="if_binary": «sexo» queda en una sola columna, sexo_mujer
    OneHotEncoder(drop="if_binary", handle_unknown="ignore",
                  sparse_output=False))
preparar = ColumnTransformer(
    [("num", SimpleImputer(strategy="median"), numericas),
     ("cat", categoricas_a_numeros, categoricas)],
    verbose_feature_names_out=False)

def con_preparacion(modelo):
    """Un Pipeline: preparar los datos y después el modelo."""
    return Pipeline([("preparar", clone(preparar)), ("modelo", modelo)])

A = preparar.fit_transform(X_ent)     # solo con entrenamiento
nombres = preparar.get_feature_names_out()
y_arr = y_ent.to_numpy()
print(len(X_ent), "pasajeros para entrenar,",
      len(X_prueba), "para la prueba final")
print("Columnas:", ", ".join(nombres[:5]))
print("         ", ", ".join(nombres[5:]))

¿Qué es un buen corte? La impureza de Gini

def gini(etiquetas):
    """Impureza de Gini de un grupo de etiquetas."""
    _, cuentas = np.unique(etiquetas, return_counts=True)
    p = cuentas / len(etiquetas)      # proporción de cada clase
    return 1 - np.sum(p ** 2)

print("Puro:          ", gini([1, 1, 1, 1]))
print("Mitad y mitad: ", gini([0, 1, 0, 1]))
print("Tres a uno:    ", gini([0, 0, 0, 1]))
print(f"Titanic entero: {gini(y_arr):.3f}")

El mejor corte, desde cero

def mejor_corte(x, etiquetas):
    """El umbral u de la pregunta «¿x <= u?» con mayor ganancia.

    Devuelve el mejor umbral, su ganancia, y todos los umbrales
    probados con su impureza ponderada (para graficarlos).
    """
    valores = np.unique(x)            # ordenados y sin repetir
    umbrales = (valores[:-1] + valores[1:]) / 2
    n = len(etiquetas)
    ponderadas = []
    for u in umbrales:
        izq = etiquetas[x <= u]
        der = etiquetas[x > u]
        ponderadas.append((len(izq) * gini(izq)
                           + len(der) * gini(der)) / n)
    ponderadas = np.array(ponderadas)
    k = np.argmin(ponderadas)
    ganancia = gini(etiquetas) - ponderadas[k]
    return umbrales[k], ganancia, umbrales, ponderadas

# El mejor corte de cada columna, para la raíz del árbol
for j, nombre in enumerate(nombres):
    u, ganancia, _, _ = mejor_corte(A[:, j], y_arr)
    print(f"{nombre:16s} ¿<= {u:6.2f}?   ganancia = {ganancia:.3f}")
hombres = A[:, list(nombres).index("sexo_mujer")] == 0
edad_h = A[hombres, list(nombres).index("edad")]
u, ganancia, umbrales, ponderadas = mejor_corte(edad_h, y_arr[hombres])
print(f"Entre los hombres: ¿edad <= {u:g}?   ganancia = {ganancia:.3f}")
menores = y_arr[hombres][edad_h <= u]
print(f"Hombres de {u:g} años o menos: {len(menores)},"
      f" sobrevivió el {menores.mean():.0%}")

plt.plot(umbrales, ponderadas, color="C0")
plt.scatter([u], [ponderadas.min()], color="C1", s=120, zorder=3)
# Un texto con flecha que señala el punto
plt.annotate(f"Mejor corte: {u:g} años", xy=(u, ponderadas.min()),
             xytext=(30, 0.284),
             arrowprops=dict(arrowstyle="->", color="C0"))
plt.xlabel("Umbral de edad (años)")
plt.ylabel("Gini ponderado")
plt.title("Hombres: Gini según el corte")
plt.show()

Un árbol con scikit-learn

from sklearn.tree import DecisionTreeClassifier, plot_tree

arbol2 = con_preparacion(DecisionTreeClassifier(max_depth=2,
                                                random_state=0))
arbol2.fit(X_ent, y_ent)

plt.figure(figsize=(4.8, 3.6))
cajas = plot_tree(arbol2["modelo"], feature_names=nombres,
                  class_names=["murió", "vivió"], label="none",
                  impurity=False, proportion=True, precision=2,
                  rounded=True, fontsize=12)
for caja in cajas:
    texto = caja.get_text().strip()
    if texto in ("True", "False"):
        caja.set_text("sí" if texto == "True" else "no")
    elif texto.endswith("vivió"):
        caja.get_bbox_patch().set(facecolor="C3", edgecolor="C3")
    else:
        caja.get_bbox_patch().set(facecolor="white", edgecolor="C2")
plt.show()

print(f"Exactitud en entrenamiento: {arbol2.score(X_ent, y_ent):.3f}")

Profundidad y sobreajuste

from sklearn.datasets import make_moons

X2, y2 = make_moons(n_samples=300, noise=0.3, random_state=0)
X2_ent, X2_val, y2_ent, y2_val = train_test_split(
    X2, y2, test_size=0.5, random_state=0)

# Una rejilla fina para pintar la región de cada clase
xx, yy = np.meshgrid(np.linspace(-2, 3, 300), np.linspace(-1.5, 2, 300))
rejilla = np.column_stack([xx.ravel(), yy.ravel()])

def dibujar_puntos(eje):
    """Clase 1: puntos llenos. Clase 0: puntos huecos."""
    # .T deja las dos columnas como filas, y * las pasa por separado
    # a scatter, como x e y
    eje.scatter(*X2_ent[y2_ent == 1].T, s=12, color="C0")
    eje.scatter(*X2_ent[y2_ent == 0].T, s=12, facecolors="white",
                edgecolors="C0", linewidths=0.8)
    eje.set_xticks([])
    eje.set_yticks([])

fig, ejes = plt.subplots(2, 2, figsize=(4.8, 4.8))
dibujar_puntos(ejes[0, 0])
ejes[0, 0].set_title("Los datos")
for eje, prof in zip([ejes[0, 1], ejes[1, 0], ejes[1, 1]], [1, 3, 10]):
    a = DecisionTreeClassifier(max_depth=prof, random_state=0)
    a.fit(X2_ent, y2_ent)
    z = a.predict(rejilla).reshape(xx.shape)
    # Se pinta solo la región donde el árbol predice la clase 1
    eje.contourf(xx, yy, z, levels=[0.5, 1.5], colors=["C0"], alpha=0.15)
    dibujar_puntos(eje)
    eje.set_title(f"Profundidad {prof}")
    print(f"Profundidad {prof:2d}: {a.get_n_leaves():2d} hojas,"
          f" entrenamiento {a.score(X2_ent, y2_ent):.2f},"
          f" validación {a.score(X2_val, y2_val):.2f}")
plt.show()
from sklearn.model_selection import cross_val_score

print("max_depth   entrenamiento   validación cruzada")
for prof in [1, 2, 3, 4, 6, 8, None]:
    modelo = con_preparacion(DecisionTreeClassifier(max_depth=prof,
                                                    random_state=0))
    vc = cross_val_score(modelo, X_ent, y_ent, cv=5).mean()
    ent = modelo.fit(X_ent, y_ent).score(X_ent, y_ent)
    print(f"{str(prof):>9s}   {ent:13.3f}   {vc:18.3f}")

print("\nmin_samples_leaf   entrenamiento   validación cruzada")
for hoja in [1, 5, 10, 20]:
    modelo = con_preparacion(DecisionTreeClassifier(
        min_samples_leaf=hoja, random_state=0))
    vc = cross_val_score(modelo, X_ent, y_ent, cv=5).mean()
    ent = modelo.fit(X_ent, y_ent).score(X_ent, y_ent)
    print(f"{hoja:16d}   {ent:13.3f}   {vc:18.3f}")

La varianza de un árbol

rng = np.random.default_rng(0)
# Un pliegue de validación dentro del entrenamiento
A_e, A_v, y_e, y_v = train_test_split(A, y_arr, test_size=0.25,
                                      stratify=y_arr, random_state=1)
predicciones = []
for i in range(5):
    muestra = rng.integers(0, len(A_e), size=len(A_e))  # bootstrap
    a = DecisionTreeClassifier(random_state=0)
    a.fit(A_e[muestra], y_e[muestra])
    predicciones.append(a.predict(A_v))
    acierto = np.mean(predicciones[-1] == y_v)
    print(f"Árbol {i + 1}: ve {len(np.unique(muestra))} de"
          f" {len(A_e)} pasajeros, validación {acierto:.3f}")

predicciones = np.array(predicciones)
# Un pasajero tiene desacuerdo si no todos los árboles dicen lo mismo
distintos = predicciones.min(axis=0) != predicciones.max(axis=0)
desacuerdo = np.mean(distintos)
print(f"Los 5 árboles no coinciden en el {desacuerdo:.0%}"
      " de los pasajeros de validación")
votos = (predicciones.mean(axis=0) > 0.5).astype(int)
print(f"Votación de los 5 árboles: validación {np.mean(votos == y_v):.3f}")

Bagging y random forest

from sklearn.ensemble import BaggingClassifier, RandomForestClassifier

bagging = con_preparacion(BaggingClassifier(
    DecisionTreeClassifier(), n_estimators=200, random_state=0))
bosque = con_preparacion(RandomForestClassifier(
    n_estimators=200, oob_score=True, random_state=0))

for nombre, modelo in [("Bagging", bagging), ("Random forest", bosque)]:
    vc = cross_val_score(modelo, X_ent, y_ent, cv=5).mean()
    print(f"{nombre:14s} validación cruzada = {vc:.3f}")

bosque.fit(X_ent, y_ent)
print(f"Random forest, exactitud OOB: {bosque['modelo'].oob_score_:.3f}")
bosque2 = RandomForestClassifier(n_estimators=200, min_samples_leaf=5,
                                 random_state=0)
bosque2.fit(X2_ent, y2_ent)
prob = bosque2.predict_proba(rejilla)[:, 1].reshape(xx.shape)

fig, eje = plt.subplots()
for desde in [0.5, 0.625, 0.75, 0.875]:
    eje.contourf(xx, yy, prob, levels=[desde, 1.01], colors=["C0"],
                 alpha=0.08)
eje.contour(xx, yy, prob, levels=[0.5], colors=["C1"], linewidths=2.5)
dibujar_puntos(eje)
eje.set_title("Bosque de 200 árboles: frontera y votos")
plt.show()

print(f"Bosque: entrenamiento {bosque2.score(X2_ent, y2_ent):.2f},"
      f" validación {bosque2.score(X2_val, y2_val):.2f}")

Boosting: árboles que corrigen errores

from sklearn.tree import DecisionTreeRegressor

rng = np.random.default_rng(0)
x = np.sort(rng.uniform(0, 6, 80))
onda = np.sin(x) + rng.normal(0, 0.25, 80)   # datos sintéticos
Xx = x.reshape(-1, 1)

tasa = 0.1
pred = np.full(len(x), onda.mean())   # paso 1: el promedio
historia = {}                         # predicción tras algunos árboles
errores = []
for m in range(1, 101):
    residuos = onda - pred            # paso 2: lo que falta
    a = DecisionTreeRegressor(max_depth=2, random_state=0)
    a.fit(Xx, residuos)               # paso 3: aprender los residuos
    pred = pred + tasa * a.predict(Xx)  # paso 4: corregir un poco
    errores.append(np.mean((onda - pred) ** 2))
    if m in (1, 5, 50):
        historia[m] = pred.copy()

for m in (1, 5, 50, 100):
    print(f"Después de {m:3d} árboles: MSE = {errores[m - 1]:.3f}")

fig, ejes = plt.subplots(2, 2, figsize=(4.8, 4.8))
for eje, m in zip([ejes[0, 0], ejes[0, 1], ejes[1, 0]], (1, 5, 50)):
    eje.vlines(x, historia[m], onda, color="C2", linewidth=0.8)
    eje.scatter(x, onda, s=8, color="C0")
    eje.plot(x, historia[m], color="C1" if m == 50 else "C0")
    eje.set_title(f"{m} {'árbol' if m == 1 else 'árboles'}")
    eje.set_xticks([])
    eje.set_yticks([])
ejes[1, 1].plot(range(1, 101), errores, color="C0")
ejes[1, 1].set_title("MSE")
ejes[1, 1].set_xlabel("Árboles")
plt.show()
from sklearn.ensemble import GradientBoostingRegressor

gb = GradientBoostingRegressor(n_estimators=100, learning_rate=0.1,
                               max_depth=2, random_state=0)
gb.fit(Xx, onda)
diferencia = np.abs(gb.predict(Xx) - pred).max()
print(f"MSE de scikit-learn: {np.mean((onda - gb.predict(Xx)) ** 2):.3f}")
print(f"Mayor diferencia con nuestro bucle: {diferencia:.1e}")

¿Cuántos árboles?

from sklearn.model_selection import StratifiedKFold
from sklearn.ensemble import GradientBoostingClassifier

cantidades = [1, 2, 5, 10, 20, 50, 100, 200, 400, 1000]
pliegues = StratifiedKFold(n_splits=5, shuffle=True, random_state=0)
acierto_bosque = np.zeros(len(cantidades))
acierto_boost = np.zeros(len(cantidades))
for ent, val in pliegues.split(A, y_arr):
    for i, n in enumerate(cantidades):
        rf = RandomForestClassifier(n_estimators=n, random_state=0)
        rf.fit(A[ent], y_arr[ent])
        acierto_bosque[i] += rf.score(A[val], y_arr[val]) / 5
    gbc = GradientBoostingClassifier(n_estimators=1000, learning_rate=0.1,
                                     max_depth=3, random_state=0)
    gbc.fit(A[ent], y_arr[ent])
    etapas = list(gbc.staged_predict(A[val]))  # tras cada árbol
    for i, n in enumerate(cantidades):
        acierto_boost[i] += np.mean(etapas[n - 1] == y_arr[val]) / 5

for n, b, g in zip(cantidades, acierto_bosque, acierto_boost):
    print(f"{n:4d} árboles:  bosque {b:.3f}   boosting {g:.3f}")

plt.plot(cantidades, acierto_bosque, marker="o", color="C1",
         label="Random forest")
plt.plot(cantidades, acierto_boost, marker="o", color="C2",
         label="Gradient boosting")
plt.xscale("log")
plt.xlabel("Número de árboles (escala logarítmica)")
plt.ylabel("Exactitud (validación)")
plt.title("Más árboles: el bosque se estabiliza")
plt.legend()
plt.show()

Comparar los modelos

from sklearn.ensemble import HistGradientBoostingClassifier

modelos = {
    "Árbol (profundidad 4)": DecisionTreeClassifier(max_depth=4,
                                                    random_state=0),
    "Bagging": BaggingClassifier(DecisionTreeClassifier(),
                                 n_estimators=200, random_state=0),
    "Random forest": RandomForestClassifier(n_estimators=200,
                                            min_samples_leaf=3,
                                            random_state=0),
    "HistGradientBoosting": HistGradientBoostingClassifier(
        max_depth=3, learning_rate=0.05, random_state=0),
}
for nombre, modelo in modelos.items():
    vc = cross_val_score(con_preparacion(modelo), X_ent, y_ent, cv=5)
    print(f"{nombre:22s} {vc.mean():.3f} ± {vc.std():.3f}")
final = con_preparacion(modelos["Random forest"]).fit(X_ent, y_ent)
print(f"Exactitud en la prueba: {final.score(X_prueba, y_prueba):.3f}")

¿Qué variables importan?

from sklearn.inspection import permutation_importance

imp = permutation_importance(final, X_prueba, y_prueba, n_repeats=20,
                             random_state=0)
orden = np.argsort(imp.importances_mean)
for j in orden[::-1]:
    print(f"{X.columns[j]:16s} {imp.importances_mean[j]:+.3f}")

colores = ["C1" if j == orden[-1] else "C0" for j in orden]
plt.barh(X.columns[orden], imp.importances_mean[orden], color=colores)
plt.xlabel("Exactitud que se pierde")
plt.title("Importancia")
plt.show()