Los árboles de decisión son uno de los métodos más intuitivos y ampliamente utilizados en el aprendizaje supervisado. A diferencia de los métodos lineales como la regresión logística, los árboles pueden capturar relaciones no lineales complejas e interacciones entre variables de forma natural, produciendo modelos que son fáciles de interpretar y visualizar.
Motivación: Limitaciones de los Métodos Lineales
Consideremos un problema donde queremos predecir si un cliente comprará un producto basándonos en su edad y su ingreso. Los métodos lineales (como regresión logística) asumirían que existe una frontera de decisión lineal:
Sin embargo, la realidad puede ser más compleja: tal vez los clientes jóvenes con ingresos altos compran, los clientes mayores con cualquier ingreso compran, pero los clientes jóvenes con ingresos bajos no compran. Esta regla no es lineal y involucra interacciones entre variables.
Los árboles de decisión resuelven este problema al particionar el espacio de características en regiones rectangulares, donde cada región tiene su propia predicción.
Estructura de un Árbol de Decisión
Un árbol de decisión es una estructura jerárquica compuesta por:
Nodo raíz (root node): Contiene todos los datos de entrenamiento
Nodos internos (internal nodes): Representan decisiones basadas en características
Ramas (branches): Representan el resultado de una decisión
Nodos hoja o terminales (leaf nodes): Contienen las predicciones finales
Cada nodo interno realiza una pregunta binaria sobre una característica:
“¿Edad ≤ 30?”
“¿Ingreso > $50,000?”
“¿Categoría = A o B?”
Ejemplo Visual Simple
[Edad ≤ 30?]
/ \
Sí No
/ \
[Ingreso ≤ 40K?] Compra = Sí
/ \
Sí No
/ \
Compra = No Compra = Sí
Este árbol representa las siguientes reglas:
Si edad > 30 → Compra = Sí
Si edad ≤ 30 y ingreso > 40K → Compra = Sí
Si edad ≤ 30 y ingreso ≤ 40K → Compra = No
Construcción de Árboles de Decisión
Particionamiento Recursivo del Espacio
Los árboles de decisión construyen su estructura mediante particionamiento recursivo binario (recursive binary splitting). Este proceso:
Comienza con todos los datos en el nodo raíz
Encuentra la mejor división (variable y punto de corte)
Divide los datos en dos nodos hijos
Repite el proceso recursivamente para cada nodo hijo
Se detiene cuando se cumple un criterio de parada
Matemáticamente, el espacio de características \(\mathbb{R}^p\) se divide en \(M\) regiones disjuntas \(R_1, R_2, ..., R_M\) tales que:
\[\bigcup_{m=1}^{M} R_m = \mathbb{R}^p, \quad R_i \cap R_j = \emptyset \text{ para } i \neq j\]
Cada región \(R_m\) es un hiperrectángulo paralelo a los ejes de coordenadas.
Criterios de Impureza
Para decidir cómo dividir un nodo, necesitamos medir la impureza o heterogeneidad de un nodo. Un nodo es “puro” si contiene mayormente ejemplos de una sola clase.
La pregunta central es: ¿qué tan mezcladas están las clases en este nodo? Si un nodo tiene 99 perros y 1 gato, es casi puro — podemos predecir con alta confianza. Si tiene 50 perros y 50 gatos, estamos completamente perdidos. Las métricas de impureza cuantifican esto.
A continuación se muestra cómo varía la impureza según la proporción de la clase positiva, antes de entrar en las fórmulas:
Proporción clase 1 (\(p\))
Gini
Entropía (bits)
Interpretación
0.0
0.00
0.00
Nodo puro — predice clase 0 con certeza
0.1
0.18
0.47
Mayoría clase 0, poca mezcla
0.3
0.42
0.88
Mezcla moderada
0.5
0.50
1.00
Máxima impureza — no sé qué predecir
0.7
0.42
0.88
Mezcla moderada (simétrico)
0.9
0.18
0.47
Mayoría clase 1
1.0
0.00
0.00
Nodo puro — predice clase 1 con certeza
Nótese que ambas métricas son simétricas: un nodo con 90% clase 0 es igual de “puro” que uno con 90% clase 1. El máximo siempre ocurre en \(p = 0.5\).
1. Índice de Gini
Intuición — la analogía de la urna: Imagina una urna con bolas de colores, donde cada color es una clase. Si sacas dos bolas al azar (con reemplazo), ¿cuál es la probabilidad de que sean de colores distintos? Eso es exactamente lo que mide Gini.
Urna con solo bolas rojas → probabilidad de sacar dos distintas = 0 → nodo perfectamente puro
Urna con mitad rojas y mitad azules → probabilidad de sacar dos distintas = 0.5 → máxima impureza
Esta es la razón por la que queremos minimizar Gini al dividir: un nodo puro nos permite predecir con total confianza.
Ejemplo numérico: Supón un nodo con 7 perros y 3 gatos (10 total):
Interpretación: si etiquetamos aleatoriamente según estas proporciones, el 42% de las veces estaríamos equivocados. Queremos encontrar una pregunta que divida este nodo en dos grupos más puros.
Para 2 clases con \(p_1 = p_2 = 0.5\) → \(I_G = 1 - (0.5^2 + 0.5^2) = 0.5\)
Para \(K\) clases con \(p_k = 1/K\) → \(I_G = 1 - K(1/K)^2 = (K-1)/K\)
2. Entropía
Intuición — la sorpresa como información: Piensa en cuánto te sorprendería saber el resultado de un nodo. Si todos los ejemplos son de la misma clase, el resultado no te sorprende nada — ya lo sabías (entropía = 0 bits). Si las clases están perfectamente balanceadas, cualquier resultado es igualmente probable — máxima sorpresa, máxima incertidumbre (entropía máxima).
Ejemplo cotidiano:
Lanzas una moneda cargada que siempre cae en cara → ya sabes el resultado → 0 bits de información
Lanzas una moneda justa → resultado completamente incierto → 1 bit de información
Aplicado a los árboles: cuando la entropía de un nodo es 0, ya sabemos exactamente qué predecir. Cuando es máxima, necesitamos seguir dividiendo para reducir esa incertidumbre.
El algoritmo elegirá el Split B porque gana 0.88 bits vs 0.19 bits — separa perfectamente las clases.
3. Error de Clasificación
El error de clasificación es la tasa de ejemplos que no pertenecen a la clase mayoritaria:
\[E(t) = 1 - \max_k p_k(t)\]
Este criterio es menos sensible a cambios en la distribución de clases y se usa menos en la práctica.
Comparación Visual de Criterios de Impureza
Las tres métricas (Gini, entropía, error de clasificación) cuentan esencialmente la misma historia: impureza máxima cuando \(p = 0.5\) e impureza cero en los extremos. La gráfica siguiente confirma visualmente lo que ya intuimos con los ejemplos numéricos. Presta atención a:
Por qué las curvas son simétricas alrededor de \(p = 0.5\): un nodo con 90% clase 0 es igual de “puro” que uno con 90% clase 1
Por qué Gini y entropía son casi idénticas en forma: en la práctica producen árboles muy similares
Por qué el error de clasificación es menos sensible: es plano cerca de los extremos, lo que lo hace menos útil para guiar las divisiones
Comparación de criterios de impureza para clasificación binaria
Gini y Entropía son muy similares en comportamiento y suelen dar resultados comparables
Error de clasificación es menos sensible a cambios en las probabilidades
En la práctica, Gini es más común por ser más eficiente computacionalmente
Todas alcanzan su máximo cuando las clases están balanceadas (\(p = 0.5\))
Algoritmo de Construcción CART
El algoritmo CART (Classification And Regression Trees) es el método más común para construir árboles de decisión:
Algoritmo: Construcción Greedy de Árbol de Decisión
función CONSTRUIR_ARBOL(datos, profundidad_actual, max_profundidad):
// Criterios de parada
si profundidad_actual >= max_profundidad O
nodo es puro O
número de muestras < min_muestras:
crear nodo hoja con predicción mayoritaria
retornar
// Encontrar mejor división
mejor_ganancia = -infinito
para cada característica j en {1, ..., p}:
para cada posible punto de corte c:
dividir datos en: {x_j ≤ c} y {x_j > c}
calcular impureza ponderada de los nodos hijos
calcular ganancia = impureza_padre - impureza_hijos
si ganancia > mejor_ganancia:
mejor_ganancia = ganancia
mejor_característica = j
mejor_corte = c
// Crear división
crear nodo interno con pregunta: "x[mejor_característica] ≤ mejor_corte?"
datos_izq = datos donde x[mejor_característica] ≤ mejor_corte
datos_der = datos donde x[mejor_característica] > mejor_corte
// Recursión
hijo_izquierdo = CONSTRUIR_ARBOL(datos_izq, profundidad_actual + 1, max_profundidad)
hijo_derecho = CONSTRUIR_ARBOL(datos_der, profundidad_actual + 1, max_profundidad)
retornar nodo_actual
Características clave del algoritmo:
Greedy (Voraz): En cada paso, elige la mejor división local sin considerar divisiones futuras
Top-down: Construye desde la raíz hacia las hojas
Recursivo: Aplica el mismo proceso a cada subárbol
Binario: Cada división genera exactamente dos nodos hijos
¿Por qué greedy y no la solución óptima global?
Una pregunta natural es: ¿por qué no explorar todas las combinaciones posibles de divisiones para encontrar el árbol óptimo?
La respuesta es computacional: encontrar el árbol de decisión óptimo es un problema NP-difícil. Con \(p\) características y \(n\) datos, el número de árboles posibles crece exponencialmente. Para un árbol de profundidad 10 con 20 características, habría del orden de \(20^{10} \approx 10^{13}\) combinaciones a evaluar — completamente inviable.
El enfoque greedy es un compromiso razonable: en cada nodo, la mejor división local suele aproximar bien la solución global, especialmente cuando los datos tienen estructura clara. Sus limitaciones (puede quedar atrapado en óptimos locales) se mitigan con técnicas como la poda posterior o los métodos ensemble.
Ejemplo: Construcción Paso a Paso
import numpy as npimport pandas as pdfrom sklearn.tree import DecisionTreeClassifierfrom sklearn.datasets import make_classificationimport matplotlib.pyplot as plt# Generar datos sintéticos simples (2D para visualización)np.random.seed(42)X, y = make_classification( n_samples=200, n_features=2, n_informative=2, n_redundant=0, n_clusters_per_class=1, flip_y=0.1, class_sep=1.5, random_state=42)# Crear DataFrame para mejor visualizacióndf = pd.DataFrame(X, columns=['X1', 'X2'])df['Clase'] = yprint("Datos de ejemplo:")print("="*60)print(df.head(10))print(f"\nTotal de muestras: {len(df)}")print(f"Clases: {df['Clase'].value_counts().to_dict()}")
from sklearn.tree import plot_tree# Entrenar árboles con diferentes profundidadesprofundidades = [1, 2, 3, 5]fig, axes = plt.subplots(2, 2, figsize=(14, 10))axes = axes.ravel()for idx, depth inenumerate(profundidades):# Entrenar árbol tree = DecisionTreeClassifier( max_depth=depth, criterion='gini', random_state=42 ) tree.fit(X, y)# Visualizar árbol plot_tree( tree, ax=axes[idx], feature_names=['X1', 'X2'], class_names=['Clase 0', 'Clase 1'], filled=True, rounded=True, fontsize=9 )# Calcular accuracy en entrenamiento train_accuracy = tree.score(X, y) axes[idx].set_title(f'Profundidad = {depth} | Accuracy = {train_accuracy:.3f}', fontsize=12, pad=10 )plt.tight_layout()plt.show()# Mostrar información detallada del árbol más complejoprint("\n"+"="*60)print("INFORMACIÓN DEL ÁRBOL (Profundidad = 5)")print("="*60)tree_detailed = DecisionTreeClassifier(max_depth=5, random_state=42)tree_detailed.fit(X, y)print(f"Número de nodos: {tree_detailed.tree_.node_count}")print(f"Número de hojas: {tree_detailed.get_n_leaves()}")print(f"Profundidad real: {tree_detailed.get_depth()}")print(f"Accuracy en entrenamiento: {tree_detailed.score(X, y):.3f}")
Comparación de árboles con diferentes profundidades
============================================================
INFORMACIÓN DEL ÁRBOL (Profundidad = 5)
============================================================
Número de nodos: 41
Número de hojas: 21
Profundidad real: 5
Accuracy en entrenamiento: 0.950
Fronteras de decisión para diferentes profundidades de árbol
Observaciones importantes:
Profundidad = 1 (stump): Una sola división, frontera muy simple
Profundidad = 2-3: Capturas las principales regiones de decisión
Profundidad = 5: Frontera muy compleja, posible sobreajuste
Las fronteras son siempre paralelas a los ejes (particiones rectangulares)
Sobreajuste y Control de Complejidad
El Problema del Sobreajuste
Los árboles de decisión tienen una tendencia natural al sobreajuste (overfitting). Sin restricciones, un árbol puede crecer hasta que cada nodo hoja contenga un solo ejemplo, logrando 100% de accuracy en entrenamiento pero generalizando muy mal.
¿Qué significa “memorizar” los datos? Imagina que entrenas a alguien para reconocer gatos mostrándole 100 fotos. Si esa persona memoriza cada foto en lugar de aprender qué es un gato en general, fallará ante cualquier foto nueva. Un árbol sin restricciones hace exactamente eso: crea una regla específica para cada ejemplo de entrenamiento, hasta llegar a hojas con un solo ejemplo. Ese nodo sabe perfectamente cómo clasificar ese ejemplo, pero no ha aprendido ningún patrón generalizable.
El síntoma es una brecha grande entre accuracy de entrenamiento y de prueba:
Árbol sin restricciones:
Accuracy entrenamiento: 100% ← memorizó todo
Accuracy prueba: 72% ← no generaliza
Causas del sobreajuste:
Alta varianza: Pequeños cambios en los datos pueden producir árboles muy diferentes
Falta de regularización inherente: Sin restricciones, el árbol memoriza los datos
Captura de ruido: El árbol aprende patrones específicos del conjunto de entrenamiento
Estrategias de Control de Complejidad
1. Pre-Poda (Pre-Pruning)
La pre-poda detiene el crecimiento del árbol durante su construcción mediante criterios:
Hiperparámetros comunes:
max_depth: Profundidad máxima del árbol
Valores típicos: 3-10
Menor → Más sesgo, menos varianza
min_samples_split: Mínimo de muestras para dividir un nodo
Valores típicos: 2-20
Mayor → Árbol más pequeño
min_samples_leaf: Mínimo de muestras en una hoja
Valores típicos: 1-10
Mayor → Hojas más confiables
max_features: Número máximo de características a considerar por división
'sqrt': √p características (usado en Random Forest)
'log2': log₂(p) características
None: Todas las características
max_leaf_nodes: Número máximo de nodos hoja
Controla directamente el tamaño del árbol
2. Post-Poda (Post-Pruning)
La post-poda construye un árbol completo y luego lo reduce eliminando nodos que no aportan suficiente mejora.
Cost-Complexity Pruning (Poda por Costo-Complejidad):
Define una función de costo que balancea error y complejidad:
\(\hat{y}_m\) es la predicción en el nodo hoja \(m\)
Efecto de \(\alpha\):
\(\alpha = 0\): Árbol completo (sin poda)
\(\alpha\) grande: Árbol muy pequeño (mayor regularización)
from sklearn.model_selection import train_test_splitfrom sklearn.tree import DecisionTreeClassifier# Dividir datosX_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42)# Entrenar árbol completotree_full = DecisionTreeClassifier(random_state=42)tree_full.fit(X_train, y_train)# Obtener camino de cost-complexity pruningpath = tree_full.cost_complexity_pruning_path(X_train, y_train)ccp_alphas = path.ccp_alphasimpurities = path.impuritiesprint("Cost-Complexity Pruning Path:")print("="*60)print(f"Número de valores de alpha: {len(ccp_alphas)}")print(f"Rango de alpha: [{ccp_alphas[0]:.6f}, {ccp_alphas[-1]:.6f}]")# Entrenar árboles para TODOS los valores de alphatrain_scores = []test_scores = []n_leaves = []depths = []for alpha in ccp_alphas: tree = DecisionTreeClassifier(random_state=42, ccp_alpha=alpha) tree.fit(X_train, y_train) train_scores.append(tree.score(X_train, y_train)) test_scores.append(tree.score(X_test, y_test)) n_leaves.append(tree.get_n_leaves()) depths.append(tree.get_depth())best_idx = np.argmax(test_scores)best_alpha = ccp_alphas[best_idx]def plot_pruning(alphas, train_sc, test_sc, n_lv, dps, best_a, xscale='linear', title_suffix=''): fig, axes = plt.subplots(1, 3, figsize=(15, 4)) axes[0].plot(alphas, train_sc, label='Entrenamiento', marker='o', linewidth=2) axes[0].plot(alphas, test_sc, label='Prueba', marker='s', linewidth=2) axes[0].axvline(x=best_a, color='red', linestyle='--', label=f'Mejor α = {best_a:.4f}') axes[0].set_xlabel(f'Alpha{title_suffix}', fontsize=11) axes[0].set_ylabel('Accuracy', fontsize=11) axes[0].set_title('Accuracy vs Alpha', fontsize=12) axes[0].set_xscale(xscale) axes[0].legend(fontsize=9) axes[0].grid(True, alpha=0.3) axes[1].plot(alphas, n_lv, marker='o', linewidth=2, color='green') axes[1].axvline(x=best_a, color='red', linestyle='--') axes[1].set_xlabel(f'Alpha{title_suffix}', fontsize=11) axes[1].set_ylabel('Número de Hojas', fontsize=11) axes[1].set_title('Complejidad del Árbol vs Alpha', fontsize=12) axes[1].set_xscale(xscale) axes[1].grid(True, alpha=0.3) axes[2].plot(alphas, dps, marker='o', linewidth=2, color='purple') axes[2].axvline(x=best_a, color='red', linestyle='--') axes[2].set_xlabel(f'Alpha{title_suffix}', fontsize=11) axes[2].set_ylabel('Profundidad del Árbol', fontsize=11) axes[2].set_title('Profundidad vs Alpha', fontsize=12) axes[2].set_xscale(xscale) axes[2].grid(True, alpha=0.3) plt.tight_layout() plt.show()# Gráfica 1: todos los alphas, escala lineal# El último alpha (árbol de 1 hoja) distorsiona el eje xprint("\nGráfica con todos los alphas (escala lineal):")plot_pruning(ccp_alphas, train_scores, test_scores, n_leaves, depths, best_alpha)# Gráfica 2: sin el último alpha + escala logarítmica# El último alpha es el caso degenerado (1 hoja); quitarlo y usar# escala log revela la estructura de la región de interés.print("\nGráfica sin el último alpha (escala logarítmica):")plot_pruning(ccp_alphas[:-1], train_scores[:-1], test_scores[:-1], n_leaves[:-1], depths[:-1], best_alpha, xscale='log', title_suffix=' (escala log)')print(f"\n{'='*60}")print("COMPARACIÓN: Árbol sin poda vs Árbol podado")print("="*60)print(f"\nÁrbol sin poda (α = 0):")print(f" Hojas: {n_leaves[0]}")print(f" Profundidad: {depths[0]}")print(f" Accuracy entrenamiento: {train_scores[0]:.3f}")print(f" Accuracy prueba: {test_scores[0]:.3f}")print(f"\nÁrbol podado óptimo (α = {best_alpha:.4f}):")print(f" Hojas: {n_leaves[best_idx]}")print(f" Profundidad: {depths[best_idx]}")print(f" Accuracy entrenamiento: {train_scores[best_idx]:.3f}")print(f" Accuracy prueba: {test_scores[best_idx]:.3f}")
Cost-Complexity Pruning Path:
============================================================
Número de valores de alpha: 13
Rango de alpha: [0.000000, 0.309700]
Gráfica con todos los alphas (escala lineal):
Antes de ver la fórmula, construyamos la intuición:
Imagina que el árbol es un equipo de trabajo. Las variables que hacen preguntas cerca de la raíz son las que más contribuyen, porque sus decisiones afectan a todas las observaciones. Las que aparecen en los niveles más bajos solo afectan a subconjuntos pequeños. La importancia mide exactamente esto: cuánta reducción de impureza acumuló cada variable, ponderada por el tamaño de los grupos que dividió.
Formalmente, la importancia de la variable \(X_j\) es:
\(\Delta I(t)\) es la reducción en impureza por la división en el nodo \(t\)
Las importancias están normalizadas para que sumen 1, por lo que una variable con importancia 0.40 explica el 40% de la reducción total de impureza en el árbol.
Limitación: sesgo hacia variables de alta cardinalidad
La importancia por reducción de impureza (MDI) tiende a sobrestimar variables con muchos valores únicos o variables continuas, porque ofrecen más puntos de corte posibles. Esto puede inflar artificialmente su importancia frente a variables categóricas con pocos niveles.
from sklearn.datasets import make_classificationfrom sklearn.model_selection import train_test_split# Ejemplo ilustrativo: clasificar si un cliente comprará o no# Variables con nombres interpretables para facilitar la lecturafeat_names = ['ingreso', 'edad', 'historial_credito', 'deuda_actual', 'region']np.random.seed(42)X_ej, y_ej = make_classification( n_samples=600, n_features=5, n_informative=3, n_redundant=1, n_repeated=0, random_state=42)X_tr, X_te, y_tr, y_te = train_test_split(X_ej, y_ej, test_size=0.3, random_state=42)tree_ej = DecisionTreeClassifier(max_depth=4, random_state=42)tree_ej.fit(X_tr, y_tr)importances = tree_ej.feature_importances_idx_ord = np.argsort(importances)[::-1]n_feat =len(feat_names)fig, axes = plt.subplots(1, 2, figsize=(12, 5))# Panel 1: Barras de importancia MDIaxes[0].barh(range(n_feat), importances[idx_ord], color='steelblue', alpha=0.8)axes[0].set_yticks(range(n_feat))axes[0].set_yticklabels([feat_names[i] for i in idx_ord], fontsize=10)axes[0].set_xlabel('Importancia MDI (fracción de impureza reducida)', fontsize=10)axes[0].set_title('Importancia de Variables\n(Reducción de Impureza — MDI)', fontsize=11)axes[0].grid(True, alpha=0.3, axis='x')for i, imp inenumerate(importances[idx_ord]): axes[0].text(imp +0.005, i, f'{imp:.3f}', va='center', fontsize=9)# Panel 2: Curva de importancia acumuladacum_imp = np.cumsum(importances[idx_ord])axes[1].plot(range(1, n_feat +1), cum_imp, marker='o', linewidth=2.5, markersize=8, color='darkgreen')axes[1].fill_between(range(1, n_feat +1), cum_imp, alpha=0.2, color='green')axes[1].axhline(y=0.8, color='red', linestyle='--', linewidth=1.5, label='80% acumulado')axes[1].axhline(y=0.95, color='orange', linestyle='--', linewidth=1.5, label='95% acumulado')axes[1].set_xlabel('Número de variables (orden descendente de importancia)', fontsize=10)axes[1].set_ylabel('Importancia acumulada', fontsize=10)axes[1].set_title('¿Cuántas variables\nexplican el 80 / 95%?', fontsize=11)axes[1].set_xticks(range(1, n_feat +1))axes[1].legend()axes[1].grid(True, alpha=0.3)plt.tight_layout()plt.show()
Importancia MDI en un ejemplo ilustrativo de clasificación de clientes
En este ejemplo, ingreso e historial_credito aparecen al tope porque el árbol las usó en los nodos más altos, donde cada división afecta a cientos de observaciones. region, en cambio, solo aparece en ramas profundas y acumula poca importancia.
Permutation Importance: una alternativa más robusta
La importancia MDI se calcula durante el entrenamiento a partir de los nodos del árbol. Tiene una limitación importante: no mide directamente qué tan útil es la variable para generalizar a datos nuevos.
La permutation importance toma un camino diferente:
Toma el modelo ya entrenado y evalúalo en el conjunto de prueba. Luego baraja aleatoriamente los valores de una variable y vuelve a evaluar. Si el desempeño cae mucho, esa variable era crítica. Si apenas cambia, el modelo no la necesitaba para generalizar.
Este enfoque es más costoso (requiere re-evaluar \(k\) veces por variable) pero responde una pregunta más útil en la práctica.
from sklearn.inspection import permutation_importanceperm_res = permutation_importance(tree_ej, X_te, y_te, n_repeats=30, random_state=42)perm_mean = perm_res.importances_meanperm_std = perm_res.importances_stdperm_ord = np.argsort(perm_mean)[::-1]fig, axes = plt.subplots(1, 2, figsize=(12, 5))# MDIaxes[0].barh(range(n_feat), importances[idx_ord], color='steelblue', alpha=0.8)axes[0].set_yticks(range(n_feat))axes[0].set_yticklabels([feat_names[i] for i in idx_ord], fontsize=10)axes[0].set_xlabel('Importancia MDI', fontsize=10)axes[0].set_title('MDI\n(calculada en entrenamiento)', fontsize=11)axes[0].grid(True, alpha=0.3, axis='x')# Permutation importance con barras de error (incertidumbre entre repeticiones)axes[1].barh(range(n_feat), perm_mean[perm_ord], xerr=perm_std[perm_ord], color='tomato', alpha=0.8, capsize=4)axes[1].set_yticks(range(n_feat))axes[1].set_yticklabels([feat_names[i] for i in perm_ord], fontsize=10)axes[1].set_xlabel('Caída en accuracy al permutar', fontsize=10)axes[1].set_title('Permutation Importance\n(calculada en test)', fontsize=11)axes[1].axvline(x=0, color='black', linewidth=0.8)axes[1].grid(True, alpha=0.3, axis='x')plt.suptitle('MDI vs. Permutation Importance', fontsize=12)plt.tight_layout()plt.show()# Cambios de ranking entre ambas métricasprint("Cambios de ranking entre MDI y Permutation Importance:")print("-"*50)mdi_rk = {feat_names[idx_ord[i]]: i +1for i inrange(n_feat)}perm_rk = {feat_names[perm_ord[i]]: i +1for i inrange(n_feat)}for feat in feat_names: delta =abs(mdi_rk[feat] - perm_rk[feat]) signo =" <-- discrepancia"if delta >=2else""print(f" {feat:<20} MDI: #{mdi_rk[feat]} | Perm: #{perm_rk[feat]}{signo}")
Comparación entre importancia MDI y permutation importance
Cambios de ranking entre MDI y Permutation Importance:
--------------------------------------------------
ingreso MDI: #4 | Perm: #5
edad MDI: #2 | Perm: #2
historial_credito MDI: #1 | Perm: #1
deuda_actual MDI: #5 | Perm: #4
region MDI: #3 | Perm: #3
Las barras de error en la permutation importance reflejan la variabilidad entre las 30 repeticiones de la permutación. Una barra larga indica que la importancia de esa variable es inestable — señal de posible correlación con otra variable.
¿Cuándo usar cada métrica?
Métrica
Ventaja
Limitación
MDI
Rápida, no requiere datos de test
Sesgada hacia variables de alta cardinalidad
Permutation
Mide capacidad de generalización
Subestima variables correlacionadas entre sí
Si ambas coinciden en el ranking, el resultado es confiable. Si difieren notablemente, la causa más común es multicolinealidad entre variables.
Multicolinealidad: la importancia se fragmenta
Cuando dos variables contienen información redundante (están correlacionadas), el árbol puede usar cualquiera de las dos de forma casi intercambiable. Consecuencia: la importancia se reparte entre ambas y ambas parecen menos relevantes de lo que son en realidad.
Esto afecta especialmente a la Permutation Importance: si X_a y X_b son casi idénticas, barajar X_a no daña al modelo porque X_b sigue aportando la misma información.
Antes de interpretar importancias, vale la pena revisar la matriz de correlación entre variables.
Análisis profundo: dataset de cáncer de seno
Los conceptos anteriores — MDI, permutation importance, multicolinealidad — cobran más sentido con un caso de uso real donde las variables tienen significado concreto y las decisiones tienen consecuencias interpretables.
En el notebook notebooks/arboles_cancer_seno.ipynb se aplica todo lo visto en este capítulo al dataset de diagnóstico de cáncer de seno de Wisconsin: desde el EDA hasta la comparación de métricas de importancia, pasando por la interpretación clínica de qué características del tumor son más relevantes para distinguir tumores benignos de malignos.
Extracción de Reglas
Los árboles pueden convertirse en reglas IF-THEN interpretables:
from sklearn.tree import export_text# Entrenar árbol simple para mejor interpretabilidadtree_simple = DecisionTreeClassifier(max_depth=3, min_samples_leaf=10, random_state=42)tree_simple.fit(X[:, :2], y)# Exportar reglas como textotree_rules = export_text(tree_simple, feature_names=['X1', 'X2'])print("REGLAS DE DECISIÓN DEL ÁRBOL:")print("="*60)print(tree_rules)# Función para extraer rutas de decisióndef get_decision_path(tree, feature_names, sample):"""Extrae la ruta de decisión para una muestra""" node =0 path = []while tree.tree_.feature[node] !=-2: # -2 indica nodo hoja feature_idx = tree.tree_.feature[node] threshold = tree.tree_.threshold[node]if sample[feature_idx] <= threshold: direction ="<=" node = tree.tree_.children_left[node]else: direction =">" node = tree.tree_.children_right[node] path.append(f"{feature_names[feature_idx]}{direction}{threshold:.3f}")# Obtener predicción class_probs = tree.tree_.value[node][0] predicted_class = np.argmax(class_probs)return path, predicted_class, class_probs# Ejemplo: explicar predicción para algunas muestrasprint("\n"+"="*60)print("EXPLICACIÓN DE PREDICCIONES")print("="*60)for i inrange(3): sample = X[i, :2] path, pred_class, probs = get_decision_path(tree_simple, ['X1', 'X2'], sample)print(f"\nMuestra {i+1}: X1={sample[0]:.3f}, X2={sample[1]:.3f}")print(f"Clase real: {y[i]}")print(f"Predicción: {pred_class}")print(f"Probabilidades: Clase 0 = {probs[0]:.3f}, Clase 1 = {probs[1]:.3f}")print("Ruta de decisión:")for step in path:print(f" → {step}")
Dificultad con relaciones lineales: Necesitan muchas divisiones para aproximar funciones lineales
Fronteras de decisión restrictivas: Solo particiones rectangulares paralelas a los ejes
Sesgo hacia variables con muchos valores: Tienden a seleccionar variables con más opciones de corte
Inestabilidad: Pequeñas variaciones pueden cambiar completamente la estructura
Sobreajuste natural: Sin restricciones, memorizan los datos de entrenamiento
Comparación Visual: Árbol vs Regresión Logística
Comparación de fronteras de decisión: Árbol vs Regresión Logística
Observaciones:
Regresión logística captura mejor la relación lineal subyacente
Árbol de decisión crea fronteras rectangulares que aproximan la línea
Para relaciones lineales, la regresión logística es más eficiente
Para relaciones no lineales, los árboles son más flexibles
Optimización de Hiperparámetros con Optuna
Hasta ahora hemos elegido los hiperparámetros del árbol (como max_depth o min_samples_leaf) de forma manual o por intuición. Pero, ¿cómo saber cuál combinación es realmente la mejor? Necesitamos un proceso sistemático.
El problema: el espacio de búsqueda crece exponencialmente
Un árbol de decisión tiene varios hiperparámetros que interactúan entre sí. Si intentamos probar todas las combinaciones posibles, el número de experimentos se dispara rápidamente:
Hiperparámetro
Valores a probar
Opciones
criterion
gini, entropy
2
max_depth
1, 2, 3, …, 15
15
min_samples_split
2, 5, 10, 20, 50
5
min_samples_leaf
1, 2, 5, 10
4
max_features
sqrt, log2, None
3
Total de combinaciones:\(2 \times 15 \times 5 \times 4 \times 3 = \mathbf{1{,}800}\) experimentos.
Si cada experimento tarda 1 segundo, son 30 minutos solo para un árbol simple. Para modelos más complejos explorar todo el espacio se vuelve inviable.
Grid Search: la búsqueda exhaustiva
El enfoque tradicional es el Grid Search: definir una cuadrícula de valores y probar todas las combinaciones. Es garantizado pero ciego — prueba combinaciones malas con la misma dedicación que las buenas.
from sklearn.model_selection import GridSearchCV, cross_val_scoreimport numpy as npimport warningswarnings.filterwarnings('ignore')# Grid pequeño para ilustrar el costoparam_grid = {'max_depth': [2, 4, 6, 8, 10],'min_samples_split': [2, 10, 20],'min_samples_leaf': [1, 5, 10],'criterion': ['gini', 'entropy'],}from sklearn.tree import DecisionTreeClassifierfrom sklearn.model_selection import train_test_splitfrom sklearn.datasets import make_classificationnp.random.seed(42)X, y = make_classification( n_samples=500, n_features=10, n_informative=5, n_redundant=2, random_state=42)X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42)grid_search = GridSearchCV( DecisionTreeClassifier(random_state=42), param_grid, cv=5, scoring='accuracy', n_jobs=-1)grid_search.fit(X_train, y_train)print(f"Combinaciones evaluadas: {len(grid_search.cv_results_['mean_test_score'])}")print(f"Mejor score CV: {grid_search.best_score_:.4f}")print(f"Score en test: {grid_search.score(X_test, y_test):.4f}")print(f"\nMejores hiperparámetros:")for k, v in grid_search.best_params_.items():print(f" {k}: {v}")
Con solo 5 valores por hiperparámetro ya evaluamos 90 combinaciones. El problema real es que Grid Search no aprende: si las primeras 10 pruebas muestran que max_depth > 8 siempre da resultados malos, el Grid Search sigue probando esos valores igual. Optuna no.
Optuna: búsqueda inteligente
Optuna es una librería de optimización de hiperparámetros que aprende de los intentos anteriores para decidir qué combinación probar a continuación. En lugar de una cuadrícula fija, usa un algoritmo llamado TPE (Tree-structured Parzen Estimator) que modela qué regiones del espacio de búsqueda son prometedoras. La idea central: después de cada trial, Optuna actualiza su modelo interno de “qué funciona” y propone el siguiente trial en zonas del espacio que parecen más prometedoras. Esto se llama optimización bayesiana.
Cómo funciona TPE
TPE (Tree-structured Parzen Estimator) es el algoritmo que Optuna usa por defecto para decidir qué hiperparámetros probar a continuación. El nombre suena intimidante, pero la idea es sorprendentemente intuitiva.
La pregunta que TPE responde en cada paso:
“¿En qué parte del espacio de hiperparámetros han vivido los buenos resultados, y en qué parte los malos?”
Paso 1: los primeros trials son aleatorios
Al principio no hay información, así que Optuna muestrea aleatoriamente. Estos primeros trials son la “exploración inicial” — sirven para mapear el territorio.
Después de acumular suficientes trials, TPE los divide en dos grupos usando un umbral \(\gamma\) (típicamente el top 25%):
\(l(x)\) — distribución de los hiperparámetros donde el score fue bueno (top 25%)
\(g(x)\) — distribución de los hiperparámetros donde el score fue malo (el resto)
Siguiendo el ejemplo: si \(\gamma = 0.25\) y tenemos 5 trials, el “bueno” es el Trial 5 (accuracy = 0.86) y los “malos” son los otros cuatro.
TPE ajusta una distribución de probabilidad sobre cada grupo. Para variables continuas usa una mezcla de distribuciones gaussianas (Parzen window); para variables categóricas usa distribuciones discretas — de ahí el “Parzen Estimator” en el nombre.
Paso 3: proponer el siguiente punto
Para el siguiente trial, TPE busca el punto \(x^*\) que maximiza la razón:
\[x^* = \arg\max_x \frac{l(x)}{g(x)}\]
Esto es elegante: queremos valores de hiperparámetros que sean muy probables bajo los buenos resultados y poco probables bajo los malos. Un \(x\) con \(l(x)/g(x)\) alto es prometedor porque vive en la zona de los ganadores y lejos de la zona de los perdedores.
La intuición completa en una imagen mental
Imagina que estás buscando petróleo en un mapa. Después de perforar en varios lugares:
Marcas en verde los pozos que dieron petróleo (los buenos)
Marcas en rojo los que no dieron nada (los malos)
Grid Search perforaría en una cuadrícula fija, ignorando tus resultados previos. TPE, en cambio, mira el mapa y dice: “los pozos buenos se concentran en el noroeste, los malos en el sureste — el próximo pozo va al noroeste, pero un poco más al norte de lo que ya probé”.
Con cada nuevo trial, el mapa se actualiza y la búsqueda se vuelve más precisa.
¿Por qué “Tree-structured”?
El “Tree-structured” no se refiere a árboles de decisión. Se refiere a que cuando hay múltiples hiperparámetros, TPE los modela con una estructura de árbol de dependencias: algunos hiperparámetros solo son relevantes cuando otro toma cierto valor (por ejemplo, min_impurity_decrease solo importa si el árbol tiene cierta profundidad). TPE respeta estas dependencias condicionales.
Implementación
Los tres conceptos clave:
Concepto
Significado
Study
El experimento completo — el proceso de optimización
Trial
Un intento individual — una combinación de hiperparámetros
Objective
La función que queremos maximizar (o minimizar)
import optunaoptuna.logging.set_verbosity(optuna.logging.WARNING) # suprimir logs intermediosfrom sklearn.model_selection import cross_val_scoredef objective(trial):""" Esta función define UN trial: Optuna elige los hiperparámetros, nosotros entrenamos el modelo y regresamos el score a optimizar. """# Optuna sugiere valores dentro de los rangos que le indicamos params = {'criterion': trial.suggest_categorical('criterion', ['gini', 'entropy']),'max_depth': trial.suggest_int('max_depth', 1, 20),'min_samples_split': trial.suggest_int('min_samples_split', 2, 50),'min_samples_leaf': trial.suggest_int('min_samples_leaf', 1, 20),'max_features': trial.suggest_categorical('max_features', ['sqrt', 'log2', None]),'ccp_alpha': trial.suggest_float('ccp_alpha', 0.0, 0.05), } model = DecisionTreeClassifier(random_state=42, **params)# Usamos cross-validation para una estimación robusta del desempeño scores = cross_val_score(model, X_train, y_train, cv=5, scoring='accuracy')return scores.mean()# Crear el estudio y optimizarstudy = optuna.create_study(direction='maximize') # queremos maximizar accuracystudy.optimize(objective, n_trials=100)print(f"Trials completados: {len(study.trials)}")print(f"Mejor score CV: {study.best_value:.4f}")print(f"\nMejores hiperparámetros encontrados:")for k, v in study.best_params.items():print(f" {k}: {v}")
La gráfica de importancia de hiperparámetros (panel derecho) revela algo valioso: no todos los hiperparámetros importan igual. En los árboles, max_depth y min_samples_leaf suelen dominar porque controlan directamente la complejidad del modelo. criterion (gini vs entropy) rara vez hace diferencia notable.
Este diagnóstico es útil para todos los modelos que veremos después: cuando llegues a XGBoost con decenas de hiperparámetros, Optuna te dirá en cuáles vale la pena concentrarte.
Reglas prácticas para usar Optuna
Empieza con rangos amplios — deja que Optuna explore; puedes afinar después
Usa cross_val_score en la objective, no train/test directo — evita sobreajustar al conjunto de validación
50-200 trials suelen ser suficientes para modelos simples; para XGBoost o redes neuronales considera más
Lee la importancia de hiperparámetros antes de hacer una segunda ronda de búsqueda más fina
direction='maximize' para métricas como accuracy, AUC, F1; direction='minimize' para errores como MSE