Clasificadores Supervisados en la Práctica

Experimentos reales con múltiples modelos

Diego Villalba

2026-08-16

Objetivos de aprendizaje

¿Qué vamos a hacer hoy?

  • Diseñar y ejecutar un experimento de clasificación completo
  • Comparar múltiples clasificadores con la misma partición de datos
  • Identificar qué modelos requieren preprocesamiento específico
  • Interpretar métricas de forma comparativa, no aislada
  • Ajustar thresholds de decisión y observar su efecto
  • Reconocer errores comunes y retos reales del flujo de clasificación

Nota

Nota Esta clase asume que ya se exploraron árboles de decisión (CART, ID3), Random Forest, métricas básicas (accuracy, precision, recall, F1) y la matriz de confusión. El énfasis está en la experimentación práctica, no en la teoría.

El flujo de un experimento real

Hoy seguiremos este flujo en código, paso a paso.

El dataset

Breast Cancer Wisconsin

Código
from sklearn.datasets import load_breast_cancer

data = load_breast_cancer()
X = pd.DataFrame(data.data, columns=data.feature_names)
y = pd.Series(data.target, name="target")

print(f"Muestras: {X.shape[0]}  |  Variables: {X.shape[1]}")
print(f"\nClases: {data.target_names}")
print(f"Distribución:\n{y.value_counts().rename({0:'maligno', 1:'benigno'})}")
Muestras: 569  |  Variables: 30

Clases: ['malignant' 'benign']
Distribución:
target
benigno    357
maligno    212
Name: count, dtype: int64

Importante

Primer reto práctico:
¿Cuál es la variable objetivo? ¿Cuál es la clase positiva?
En scikit-learn, target=1 es benigno y target=0 es maligno.
¿Esto es lo que queremos? Si nos interesa detectar tumores malignos, quizá deberíamos invertir la convención.

Variables predictoras

Código
X.iloc[:, :6].describe().round(2)
mean radius mean texture mean perimeter mean area mean smoothness mean compactness
count 569.00 569.00 569.00 569.00 569.00 569.00
mean 14.13 19.29 91.97 654.89 0.10 0.10
std 3.52 4.30 24.30 351.91 0.01 0.05
min 6.98 9.71 43.79 143.50 0.05 0.02
25% 11.70 16.17 75.17 420.30 0.09 0.06
50% 13.37 18.84 86.24 551.10 0.10 0.09
75% 15.78 21.80 104.10 782.70 0.11 0.13
max 28.11 39.28 188.50 2501.00 0.16 0.35

Observación: Las escalas son muy distintas entre variables.
→ Modelos sensibles al escalamiento (MLP, Regresión Logística, KNN) lo notarán.
→ Árboles y Random Forest son invariantes al escalamiento.

Balance de clases

Código
fig, ax = plt.subplots(figsize=(5, 3))
counts = y.value_counts().rename({0: "Maligno (0)", 1: "Benigno (1)"})
counts.plot(kind="bar", ax=ax, color=["#e74c3c", "#2ecc71"], edgecolor="white")
ax.set_title("Distribución de clases")
ax.set_xlabel("")
ax.set_ylabel("Frecuencia")
ax.set_xticklabels(counts.index, rotation=0)
for i, v in enumerate(counts):
    ax.text(i, v + 5, f"{v} ({v/len(y)*100:.1f}%)", ha="center")
plt.tight_layout()
plt.show()

Preguntas para discusión

Discusión (2 minutos)

  1. ¿El dataset está balanceado?
  2. ¿Qué error sería más costoso en este problema: un falso positivo o un falso negativo?
  3. ¿Qué métrica principal elegiríamos para comparar modelos aquí?

Preparación de datos

Separación train/test

Código
from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(
    X, y,
    test_size=0.2,
    random_state=SEED,
    stratify=y      # mantiene proporciones de clase
)

print(f"Entrenamiento: {X_train.shape[0]} muestras")
print(f"Prueba:        {X_test.shape[0]} muestras")
print(f"\nClase maligno en train: {(y_train==0).mean():.2%}")
print(f"Clase maligno en test:  {(y_test==0).mean():.2%}")
Entrenamiento: 455 muestras
Prueba:        114 muestras

Clase maligno en train: 37.36%
Clase maligno en test:  36.84%

Advertencia

Regla de oro: El conjunto de prueba no se toca hasta la evaluación final.
Escalar, imputar o transformar usando información de test → data leakage.

Pipelines: la forma correcta

Código
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression

# Scaler ajustado SOLO sobre train, aplicado a test automáticamente
pipe_lr = Pipeline([
    ("scaler", StandardScaler()),
    ("clf",    LogisticRegression(max_iter=1000, random_state=SEED))
])

pipe_lr.fit(X_train, y_train)
print(f"Accuracy en test: {pipe_lr.score(X_test, y_test):.4f}")
Accuracy en test: 0.9825
Código
# Sin pipeline: error clásico (no hacer esto)
# scaler = StandardScaler()
# X_scaled = scaler.fit_transform(X)        # ← leakage
# X_train_s, X_test_s = train_test_split(X_scaled, ...)

Entrenamiento de modelos

Los modelos del experimento

Modelo Requiere escalamiento Nota
Regresión Logística ✓ Baseline lineal
CART (Gini) ✗ Árbol binario
ID3-proxy (Entropy) ✗ Mismo árbol, distinto criterio
Random Forest ✗ Ensamble por bagging
Gradient Boosting ✗ Ensamble secuencial
MLP (red neuronal) ✓ Modelo flexible

Definición de todos los pipelines

Código
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.neural_network import MLPClassifier

modelos = {
    "Logistic Regression": Pipeline([
        ("scaler", StandardScaler()),
        ("clf", LogisticRegression(max_iter=1000, random_state=SEED))
    ]),
    "CART (Gini)": Pipeline([
        ("clf", DecisionTreeClassifier(criterion="gini",
                                       max_depth=5, random_state=SEED))
    ]),
    "ID3-proxy (Entropy)": Pipeline([
        ("clf", DecisionTreeClassifier(criterion="entropy",
                                       max_depth=5, random_state=SEED))
    ]),
    "Random Forest": Pipeline([
        ("clf", RandomForestClassifier(n_estimators=100,
                                       max_depth=None, random_state=SEED))
    ]),
    "Gradient Boosting": Pipeline([
        ("clf", GradientBoostingClassifier(n_estimators=100,
                                            learning_rate=0.1,
                                            max_depth=3, random_state=SEED))
    ]),
    "MLP": Pipeline([
        ("scaler", StandardScaler()),
        ("clf", MLPClassifier(hidden_layer_sizes=(64, 32),
                              max_iter=300, random_state=SEED))
    ]),
}

Entrenamiento en un bucle

Código
resultados = {}

for nombre, pipe in modelos.items():
    pipe.fit(X_train, y_train)
    resultados[nombre] = pipe
    print(f"✓ {nombre}")

print("\nTodos los modelos entrenados.")
✓ Logistic Regression
✓ CART (Gini)
✓ ID3-proxy (Entropy)
✓ Random Forest
✓ Gradient Boosting
✓ MLP

Todos los modelos entrenados.

Evaluación: recordatorio operativo

Métricas: recordatorio compacto

\text{Accuracy} = \frac{TP+TN}{TP+TN+FP+FN}

\text{Precision} = \frac{TP}{TP+FP} \qquad \text{Recall} = \frac{TP}{TP+FN}

F_1 = \frac{2 \cdot \text{Precision} \cdot \text{Recall}}{\text{Precision} + \text{Recall}}

Nota

Ya los conocen. Hoy los usamos como herramienta de comparación, no como tema de exposición.

Evaluación con classification_report

Código
from sklearn.metrics import classification_report

y_pred = resultados["Random Forest"].predict(X_test)
print(classification_report(y_test, y_pred,
      target_names=["Maligno (0)", "Benigno (1)"]))
              precision    recall  f1-score   support

 Maligno (0)       0.95      0.93      0.94        42
 Benigno (1)       0.96      0.97      0.97        72

    accuracy                           0.96       114
   macro avg       0.96      0.95      0.95       114
weighted avg       0.96      0.96      0.96       114

Tabla comparativa de todos los modelos

Código
from sklearn.metrics import f1_score, roc_auc_score, accuracy_score, precision_score, recall_score

filas = []
for nombre, pipe in resultados.items():
    y_pred = pipe.predict(X_test)
    y_prob = pipe.predict_proba(X_test)[:, 1]
    filas.append({
        "Modelo": nombre,
        "Accuracy": accuracy_score(y_test, y_pred),
        "Precision": precision_score(y_test, y_pred),
        "Recall": recall_score(y_test, y_pred),
        "F1": f1_score(y_test, y_pred),
        "ROC-AUC": roc_auc_score(y_test, y_prob),
    })

df_resultados = pd.DataFrame(filas).set_index("Modelo")
print(df_resultados.round(4).to_string())
                     Accuracy  Precision  Recall      F1  ROC-AUC
Modelo                                                           
Logistic Regression    0.9825     0.9861  0.9861  0.9861   0.9954
CART (Gini)            0.9211     0.9565  0.9167  0.9362   0.9163
ID3-proxy (Entropy)    0.9298     0.9706  0.9167  0.9429   0.9435
Random Forest          0.9561     0.9589  0.9722  0.9655   0.9937
Gradient Boosting      0.9561     0.9467  0.9861  0.9660   0.9907
MLP                    0.9649     0.9857  0.9583  0.9718   0.9937

Visualización de métricas

Código
fig, ax = plt.subplots(figsize=(9, 4))
df_resultados[["F1", "ROC-AUC"]].plot(kind="bar", ax=ax,
    color=["#3498db", "#e67e22"], edgecolor="white")
ax.set_xticklabels(df_resultados.index, rotation=25, ha="right")
ax.set_ylim(0.85, 1.0)
ax.set_title("F1-score y ROC-AUC por modelo (conjunto de prueba)")
ax.legend(loc="lower right")
plt.tight_layout()
plt.show()

Matrices de confusión: comparación visual

Código
from sklearn.metrics import ConfusionMatrixDisplay
import matplotlib.gridspec as gridspec

fig = plt.figure(figsize=(14, 5))
nombres = list(resultados.keys())
for i, nombre in enumerate(nombres):
    ax = fig.add_subplot(2, 3, i+1)
    y_pred = resultados[nombre].predict(X_test)
    ConfusionMatrixDisplay.from_predictions(
        y_test, y_pred,
        display_labels=["Maligno", "Benigno"],
        ax=ax, colorbar=False, cmap="Blues"
    )
    ax.set_title(nombre, fontsize=9)
plt.suptitle("Matrices de confusión — todos los modelos", y=1.02)
plt.tight_layout()
plt.show()

Ajuste de thresholds

¿Por qué cambiar el threshold?

Por defecto, predict() usa threshold = 0.50.

Pero en diagnóstico médico: - Falso negativo = no detectar un tumor maligno → consecuencia grave - Falso positivo = alarma innecesaria → consecuencia menor

→ Queremos maximizar recall para la clase maligno
→ Eso significa bajar el threshold

predict_proba: el fundamento

Código
pipe_rf = resultados["Random Forest"]
probs = pipe_rf.predict_proba(X_test)

print("Primeras 5 filas de probabilidades:")
df_probs = pd.DataFrame(probs, columns=["P(maligno)", "P(benigno)"])
print(df_probs.head())
print(f"\nPredicción con threshold=0.5:")
print(pipe_rf.predict(X_test[:5]))
Primeras 5 filas de probabilidades:
   P(maligno)  P(benigno)
0        1.00        0.00
1        0.00        1.00
2        0.88        0.12
3        0.72        0.28
4        0.99        0.01

Predicción con threshold=0.5:
[0 1 0 0 0]

Efecto de distintos thresholds

Código
from sklearn.metrics import precision_score, recall_score, f1_score

probs_maligno = pipe_rf.predict_proba(X_test)[:, 0]  # P(maligno=clase 0)

filas_th = []
for th in [0.20, 0.30, 0.40, 0.50, 0.60, 0.70, 0.80]:
    y_pred_th = (probs_maligno >= th).astype(int)
    # recodificar: 1 si predicción es maligno (0 original)
    y_pred_bin = np.where(y_pred_th == 1, 0, 1)
    filas_th.append({
        "Threshold": th,
        "Precision (maligno)": precision_score(y_test, y_pred_bin, pos_label=0, zero_division=0),
        "Recall (maligno)":    recall_score(y_test, y_pred_bin, pos_label=0, zero_division=0),
        "F1 (maligno)":        f1_score(y_test, y_pred_bin, pos_label=0, zero_division=0),
    })

df_th = pd.DataFrame(filas_th).set_index("Threshold")
print(df_th.round(4).to_string())
           Precision (maligno)  Recall (maligno)  F1 (maligno)
Threshold                                                     
0.2                     0.8235            1.0000        0.9032
0.3                     0.8400            1.0000        0.9130
0.4                     0.9091            0.9524        0.9302
0.5                     0.9512            0.9286        0.9398
0.6                     0.9512            0.9286        0.9398
0.7                     0.9737            0.8810        0.9250
0.8                     1.0000            0.8810        0.9367

Visualización del efecto del threshold

Código
fig, ax = plt.subplots(figsize=(8, 3.8))
df_th.plot(ax=ax, marker="o")
ax.axvline(0.5, color="gray", linestyle="--", alpha=0.6, label="Default (0.5)")
ax.set_xlabel("Threshold de decisión")
ax.set_ylabel("Métrica")
ax.set_title("Efecto del threshold sobre Precision, Recall y F1 (clase maligno)")
ax.legend(fontsize=8)
plt.tight_layout()
plt.show()

Curva ROC

Código
from sklearn.metrics import roc_curve, auc

fig, ax = plt.subplots(figsize=(6, 4))

for nombre, pipe in resultados.items():
    y_prob = pipe.predict_proba(X_test)[:, 1]
    fpr, tpr, _ = roc_curve(y_test, y_prob)
    roc_auc = auc(fpr, tpr)
    ax.plot(fpr, tpr, label=f"{nombre} (AUC={roc_auc:.3f})", linewidth=1.5)

ax.plot([0,1],[0,1], "k--", alpha=0.4, label="Aleatorio")
ax.set_xlabel("Tasa de Falsos Positivos")
ax.set_ylabel("Tasa de Verdaderos Positivos (Recall)")
ax.set_title("Curvas ROC — todos los modelos")
ax.legend(fontsize=7.5, loc="lower right")
plt.tight_layout()
plt.show()

Curva Precision-Recall

Código
from sklearn.metrics import precision_recall_curve, average_precision_score

fig, ax = plt.subplots(figsize=(6, 4))

for nombre, pipe in resultados.items():
    y_prob = pipe.predict_proba(X_test)[:, 1]
    prec, rec, _ = precision_recall_curve(y_test, y_prob)
    ap = average_precision_score(y_test, y_prob)
    ax.plot(rec, prec, label=f"{nombre} (AP={ap:.3f})", linewidth=1.5)

ax.set_xlabel("Recall")
ax.set_ylabel("Precision")
ax.set_title("Curvas Precision-Recall — todos los modelos")
ax.legend(fontsize=7.5, loc="lower left")
plt.tight_layout()
plt.show()

Preguntas de discusión

Discusión (3 minutos)

  1. ¿Qué threshold elegiríamos para este problema médico?
  2. ¿Cómo cambia la matriz de confusión si bajamos el threshold de 0.5 a 0.3?
  3. ¿El “mejor” modelo cambia según la métrica que usemos?
  4. ¿Por qué la curva PR puede ser más informativa que ROC en problemas con desbalance?

Comparación final e interpretación

¿Qué modelo elegirías?

                     Accuracy  Precision  Recall      F1  ROC-AUC
Modelo                                                           
Logistic Regression    0.9825     0.9861  0.9861  0.9861   0.9954
Random Forest          0.9561     0.9589  0.9722  0.9655   0.9937
MLP                    0.9649     0.9857  0.9583  0.9718   0.9937
Gradient Boosting      0.9561     0.9467  0.9861  0.9660   0.9907
ID3-proxy (Entropy)    0.9298     0.9706  0.9167  0.9429   0.9435
CART (Gini)            0.9211     0.9565  0.9167  0.9362   0.9163

Depende del contexto:

  • Si priorizamos detectar malignos → Recall
  • Si priorizamos no alarmar innecesariamente → Precision
  • Si queremos balance → F1
  • Si queremos analizar todos los thresholds → ROC-AUC

CART vs ID3: ¿hace diferencia el criterio?

Código
for nombre in ["CART (Gini)", "ID3-proxy (Entropy)"]:
    y_pred = resultados[nombre].predict(X_test)
    f1 = f1_score(y_test, y_pred)
    auc_val = roc_auc_score(y_test, resultados[nombre].predict_proba(X_test)[:,1])
    print(f"{nombre:25s}  F1={f1:.4f}  AUC={auc_val:.4f}")
CART (Gini)                F1=0.9362  AUC=0.9163
ID3-proxy (Entropy)        F1=0.9429  AUC=0.9435

Nota

Observación esperada: En datasets bien limpios como este, la diferencia entre Gini y Entropy suele ser pequeña. La elección del criterio importa más en datasets con muchas categorías nominales.

Random Forest vs Gradient Boosting

Código
for nombre in ["Random Forest", "Gradient Boosting"]:
    y_pred = resultados[nombre].predict(X_test)
    f1 = f1_score(y_test, y_pred)
    auc_val = roc_auc_score(y_test, resultados[nombre].predict_proba(X_test)[:,1])
    n_mal = (y_pred[y_test == 0] == 1).sum()
    print(f"{nombre:22s}  F1={f1:.4f}  AUC={auc_val:.4f}  FN(maligno)={n_mal}")
Random Forest           F1=0.9655  AUC=0.9937  FN(maligno)=3
Gradient Boosting       F1=0.9660  AUC=0.9907  FN(maligno)=4

Nota

  • Random Forest: paralelizable, menos hiperparámetros que ajustar
  • Gradient Boosting: secuencial, más sensible a learning_rate y max_depth, pero suele ganar en rendimiento con ajuste fino

Retos comunes y buenas prácticas

Errores frecuentes en experimentos de clasificación

  • ✗ Escalar antes del train/test split → data leakage
  • ✗ Evaluar solo con accuracy cuando hay desbalance
  • ✗ Comparar modelos con particiones distintas
  • ✗ No fijar random_state → resultados no reproducibles
  • ✗ Ajustar hiperparámetros mirando repetidamente el test set
  • ✗ Entrenar una red neuronal sin escalar las entradas
  • ✗ Comparar modelos con preprocesamientos inconsistentes
  • ✗ Modificar thresholds usando el test set como validación

Buenas prácticas

  • ✓ Usar pipelines para garantizar preprocesamiento consistente
  • ✓ Usar stratify al dividir datos
  • ✓ Evaluar siempre en datos no vistos durante entrenamiento
  • ✓ Reportar varias métricas, no solo accuracy
  • ✓ Analizar la matriz de confusión junto con las métricas
  • ✓ Considerar el costo relativo de FP y FN según el contexto
  • ✓ Documentar semillas, versiones y parámetros usados
  • ✓ Usar validación cruzada cuando el dataset es pequeño

Ejercicio guiado

Ejercicio: modificar hiperparámetros

Código
# ¿Qué pasa si aumentamos la profundidad máxima de CART?
for depth in [2, 5, 10, None]:
    pipe = Pipeline([
        ("clf", DecisionTreeClassifier(criterion="gini",
                                       max_depth=depth, random_state=SEED))
    ])
    pipe.fit(X_train, y_train)
    train_f1 = f1_score(y_train, pipe.predict(X_train))
    test_f1  = f1_score(y_test,  pipe.predict(X_test))
    print(f"max_depth={str(depth):5s}  Train F1={train_f1:.4f}  Test F1={test_f1:.4f}")
max_depth=2      Train F1=0.9661  Test F1=0.9118
max_depth=5      Train F1=0.9947  Test F1=0.9362
max_depth=10     Train F1=1.0000  Test F1=0.9286
max_depth=None   Train F1=1.0000  Test F1=0.9286

Pregunta

¿Qué observas al pasar de max_depth=2 a None?
¿Qué significa cuando Train F1 >> Test F1?

Ejercicio para estudiantes

Tu turno

Ejercicios (15 minutos)

  1. Hiperparámetros RF: Entrena Random Forest con n_estimators ∈ {10, 50, 200}. ¿Cuándo se estabiliza el F1?

  2. Learning rate en GB: Prueba learning_rate ∈ {0.01, 0.1, 0.3} con n_estimators=100. ¿Cómo afecta al rendimiento?

  3. MLP sin escalamiento: Entrena un MLPClassifier sin StandardScaler. ¿Qué pasa con las métricas?

  4. Threshold óptimo: ¿Qué threshold maximiza el recall para la clase maligno manteniendo precision > 0.85?

  5. Bonus: Usa cross_val_score con cv=5 para comparar modelos. ¿Cambia el ranking?

Cierre

Lo que hicimos hoy

  1. Identificamos correctamente la variable objetivo y su convención
  2. Separamos datos con stratify para preservar proporciones
  3. Construimos pipelines para evitar leakage
  4. Entrenamos 6 clasificadores con la misma partición
  5. Comparamos con múltiples métricas, no solo accuracy
  6. Exploramos el efecto de cambiar thresholds de decisión
  7. Visualizamos curvas ROC y Precision-Recall
  8. Discutimos retos reales del flujo de clasificación

La idea más importante

Entrenar un clasificador es fácil.
Diseñar bien el experimento es lo difícil.

  • La elección de métricas depende del contexto del problema
  • El threshold por defecto (0.5) no siempre es el mejor
  • Los pipelines no son opcionales: son obligatorios para reproducibilidad
  • El conjunto de prueba es sagrado: no lo uses para tomar decisiones de diseño

Referencias