Files
2026-08-12 15:00:15 +00:00

304 KiB

Tarea: Selección de Modelos e Hyper-parameter Tuning

Universidad Galileo — IA para Aplicaciones del Mundo Real Tarea — Unidad de Buenas Prácticas (Cross-validation, Selección de Modelos y Tuning)

Contexto

Una empresa de servicios públicos digitaliza las lecturas de medidores que sus inspectores anotan a mano en boletas de papel. Hoy una persona teclea cada dígito, y el proceso es lento y caro. Quieren un modelo que lea esos dígitos automáticamente.

El área de tecnología te dio una instrucción clara:

"No queremos que nos traigas un modelo. Queremos que compares varias opciones de forma ordenada, que dejes registrado cada experimento que corriste —con su configuración y sus resultados— y que al final nos digas cuál elegiste y por qué, con un número honesto de qué tan bien va a funcionar en producción. Si dentro de seis meses alguien pregunta por qué se eligió ese modelo, la bitácora tiene que poder responderlo."

Tu tarea

Vas a comparar tres arquitecturas implementadas en tres frameworks distintos (scikit-learn, TensorFlow y PyTorch), ajustar sus hiper-parámetros con tres estrategias de búsqueda distintas, registrar todo en una bitácora .csv y entregar un reporte final del modelo elegido.

El método que vas a seguir

Usaremos el split de tres vías con un único conjunto de cross-validation (holdout validation), que es la forma tradicional:

Conjunto Para qué sirve Cuántas veces se usa
train entrenar los parámetros del modelo muchas
cross-validation comparar arquitecturas y elegir hiper-parámetros muchas
test estimar el desempeño real una sola vez, al final

Nota. En el notebook 09 vimos que un solo cv-set es frágil y que k-fold promedia varios para reducir esa varianza. Aquí trabajamos a propósito con el cv-set tradicional: es más simple, es lo que se usa cuando entrenar es caro (como con redes neuronales), y deja ver con claridad el flujo completo. En la conclusión te vamos a preguntar justamente por esa limitación.

Lo que ya está implementado para ti

  • Carga y exploración del dataset
  • El preprocesamiento (escalado), con el detalle de dónde se ajusta
  • Los constructores y entrenadores de los tres frameworks, con una interfaz común
  • La bitácora: una función que registra cada experimento en experimentos.csv
  • Todas las gráficas y el formato del reporte final

Lo que tienes que implementar tú

  1. El split de tres vías
  2. La función de métricas de evaluación
  3. Grid Search
  4. Random Search
  5. Optimización Bayesiana (Optuna)
  6. La selección del modelo final y su evaluación en el test-set
  7. Tu conclusión escrita

Las celdas que te tocan están marcadas con # TU CÓDIGO AQUÍ y lanzan un NotImplementedError hasta que las completes. Después de cada una hay una celda de verificación que revisa el formato de lo que produjiste: si pasa, puedes seguir con confianza.

0. Setup

optuna no viene preinstalado en Colab, así que lo instalamos solo si hace falta. TensorFlow y PyTorch sí vienen incluidos.

In [1]:
try:
    import optuna
except ImportError:
    %pip install -q optuna
    import optuna

import os, json, random, time, itertools, warnings
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.ensemble import RandomForestClassifier

import tensorflow as tf
import torch
import torch.nn as nn

warnings.filterwarnings("ignore")
optuna.logging.set_verbosity(optuna.logging.WARNING)
tf.get_logger().setLevel("ERROR")

# Semillas: que la tarea sea reproducible para ti y para quien la revise
SEED = 42
random.seed(SEED); np.random.seed(SEED)
tf.random.set_seed(SEED); torch.manual_seed(SEED)

# Paleta Okabe-Ito (segura para daltonismo), en el orden fijo del curso
OKABE = ["#0072B2", "#E69F00", "#009E73", "#CC79A7", "#D55E00", "#56B4E9", "#F0E442"]

print("numpy       ", np.__version__)
print("pandas      ", pd.__version__)
print("tensorflow  ", tf.__version__)
print("torch       ", torch.__version__)
print("optuna      ", optuna.__version__)
WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager, possibly rendering your system unusable. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv. Use the --root-user-action option if you know what you are doing and want to suppress this warning.
Note: you may need to restart the kernel to use updated packages.
/usr/local/lib/python3.12/dist-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html
  from .autonotebook import tqdm as notebook_tqdm
numpy        2.1.0
pandas       2.3.3
tensorflow   2.21.0
torch        2.10.0a0+b4e4ee81d3.nv25.12
optuna       4.9.0

1. Los datos (ya implementado)

load_digits de scikit-learn: 1,797 imágenes de dígitos escritos a mano, de 8×8 píxeles en escala de grises (valores de 0 a 16). Cada imagen se aplana en un vector de 64 características, y la etiqueta es el dígito que representa (0 a 9).

Es un dataset chico a propósito: permite correr decenas de experimentos en minutos, que es justo lo que necesita esta tarea.

In [2]:
digits = load_digits()
X, y = digits.data, digits.target

print(f"X: {X.shape}   (1,797 imágenes de 8x8 = 64 píxeles)")
print(f"y: {y.shape}   clases: {np.unique(y)}")
print(f"Rango de los píxeles: {X.min():.0f} a {X.max():.0f}")

fig, axes = plt.subplots(2, 8, figsize=(11, 3))
for ax, img, lab in zip(axes.ravel(), digits.images, y):
    ax.imshow(img, cmap="gray_r")
    ax.set_title(str(lab), fontsize=10)
    ax.axis("off")
fig.suptitle("Ejemplos del dataset — lecturas de medidor escritas a mano", y=1.04)
plt.tight_layout(); plt.show()

conteo = pd.Series(y).value_counts().sort_index()
fig, ax = plt.subplots(figsize=(7, 2.6))
ax.bar(conteo.index, conteo.values, color=OKABE[0])
ax.set_xlabel("dígito"); ax.set_ylabel("imágenes"); ax.set_xticks(range(10))
ax.set_title(f"Distribución de clases — entre {conteo.min()} y {conteo.max()} por dígito (balanceado)")
plt.tight_layout(); plt.show()
X: (1797, 64)   (1,797 imágenes de 8x8 = 64 píxeles)
y: (1797,)   clases: [0 1 2 3 4 5 6 7 8 9]
Rango de los píxeles: 0 a 16

2. El split de tres vías

# TU CÓDIGO AQUÍ

Divide X e y en tres conjuntos: entrenamiento, cross-validation y prueba.

train_test_split solo parte en dos, así que tendrás que llamarlo dos veces: primero separas el test, y luego partes lo que queda en train y cv.

Debes usar exactamente estas proporciones y parámetros, para que tus resultados sean comparables con los de tus compañeros:

  • 60% train · 20% cross-validation · 20% test
  • random_state=SEED en las dos llamadas
  • stratify= en las dos llamadas, para que los 10 dígitos queden repartidos en la misma proporción en los tres conjuntos

Cuidado con la segunda llamada. Si al primer split le pides test_size=0.2, te queda un 80% por repartir. Para que el cv-set sea el 20% del total, la fracción que le pides al segundo split no es 0.2. Haz la cuenta.

Formato esperado: seis variables con exactamente estos nombres:

X_train, X_cv, X_test, y_train, y_cv, y_test
In [3]:
# ============================================================
# TU CÓDIGO AQUÍ
# ============================================================

# Paso 1: separar el test-set del resto (20% del total)
X_temp, X_test, y_temp, y_test = train_test_split(
    X, y, test_size=0.2, random_state=SEED, stratify=y)

# Paso 2: partir lo que queda (80% del total) en train (60% del total)
# y cross-validation (20% del total) -> 0.20 / 0.80 = 0.25 de lo que queda
X_train, X_cv, y_train, y_cv = train_test_split(
    X_temp, y_temp, test_size=0.25, random_state=SEED, stratify=y_temp)
In [4]:
# --- Verificación (no modifiques esta celda) ---
for _v in ["X_train", "X_cv", "X_test", "y_train", "y_cv", "y_test"]:
    assert _v in dir(), f"No encuentro `{_v}` — revisa la celda anterior."

_n = len(X)
_frac = {"train": len(X_train) / _n, "cv": len(X_cv) / _n, "test": len(X_test) / _n}
assert abs(_frac["train"] - 0.60) < 0.02, f"train debería ser ~60% del total, es {_frac['train']:.1%}"
assert abs(_frac["cv"]    - 0.20) < 0.02, f"cv debería ser ~20% del total, es {_frac['cv']:.1%}"
assert abs(_frac["test"]  - 0.20) < 0.02, f"test debería ser ~20% del total, es {_frac['test']:.1%}"
assert len(X_train) + len(X_cv) + len(X_test) == _n, "Los tres conjuntos no suman el total"

# ¿estratificó? las proporciones por clase deben parecerse entre los tres
_p = [np.bincount(s, minlength=10) / len(s) for s in (y_train, y_cv, y_test)]
assert max(np.abs(_p[0] - _p[1]).max(), np.abs(_p[0] - _p[2]).max()) < 0.03, \
    "Las proporciones por dígito difieren mucho entre conjuntos — ¿usaste stratify en las dos llamadas?"

print("✅ Split de 3 vías verificado")
print(f"  train : {len(X_train):>5} imágenes  ({_frac['train']:.0%})")
print(f"  cv    : {len(X_cv):>5} imágenes  ({_frac['cv']:.0%})")
print(f"  test  : {len(X_test):>5} imágenes  ({_frac['test']:.0%})")
✅ Split de 3 vías verificado
  train :  1077 imágenes  (60%)
  cv    :   360 imágenes  (20%)
  test  :   360 imágenes  (20%)

3. Preprocesamiento (ya implementado)

Las redes neuronales convergen mucho mejor con las entradas escaladas. Usamos StandardScaler.

El detalle que importa: el scaler se ajusta (fit) solo con el train-set, y después se aplica (transform) a los tres conjuntos. Si lo ajustáramos con todos los datos, estaríamos filtrando información del cv y del test hacia el entrenamiento — el clásico data leakage que vimos en la unidad de train/test split, y que produce métricas optimistas que no se sostienen en producción.

In [5]:
scaler = StandardScaler().fit(X_train)        # <-- SOLO con train

X_train_s = scaler.transform(X_train)
X_cv_s    = scaler.transform(X_cv)
X_test_s  = scaler.transform(X_test)

print(f"Media del train escalado : {X_train_s.mean():+.4f}  (debe ser ~0)")
print(f"Desv. del train escalado : {X_train_s.std():.4f}  (debe ser ~1)")
print(f"Media del cv escalado    : {X_cv_s.mean():+.4f}  (NO es exactamente 0, y está bien:")
print(f"                            el cv no participó en el ajuste del scaler)")
Media del train escalado : -0.0000  (debe ser ~0)
Desv. del train escalado : 0.9682  (debe ser ~1)
Media del cv escalado    : +0.0001  (NO es exactamente 0, y está bien:
                            el cv no participó en el ajuste del scaler)

4. La bitácora de experimentos (ya implementado)

Esto es lo que pidió el área de tecnología: que cada experimento quede registrado. La función registrar_experimento agrega una fila a experimentos.csv con la estrategia de búsqueda, el framework, la arquitectura, los hiper-parámetros y las métricas.

Fíjate en el diseño: los hiper-parámetros se guardan también como una cadena JSON en la columna config. Así la bitácora sirve aunque cada framework tenga hiper-parámetros distintos, y siempre se puede reconstruir exactamente la configuración de cualquier fila.

In [6]:
BITACORA = "experimentos.csv"

def iniciar_bitacora(path=BITACORA):
    """Borra la bitácora anterior. Útil si quieres re-correr la tarea desde cero."""
    if os.path.exists(path):
        os.remove(path)
    print(f"Bitácora reiniciada: {path}")


def registrar_experimento(estrategia, framework, arquitectura, config, metricas,
                          segundos=None, path=BITACORA):
    """Agrega UNA fila a la bitácora de experimentos.

    estrategia   : 'grid' | 'random' | 'bayesiana'  (cómo se eligió esta config)
    framework    : 'sklearn' | 'tensorflow' | 'pytorch'
    arquitectura : nombre legible del tipo de modelo
    config       : dict de hiper-parámetros
    metricas     : dict con las métricas medidas en el CV-SET
    segundos     : cuánto tardó en entrenar (opcional)
    """
    fila = {
        "experimento": _siguiente_id(path),
        "estrategia": estrategia,
        "framework": framework,
        "arquitectura": arquitectura,
        "config": json.dumps(config, sort_keys=True),   # reconstruible después
        **{f"cv_{k}": v for k, v in metricas.items()},
        "segundos": round(segundos, 2) if segundos is not None else None,
    }
    # también en columnas propias, para poder filtrar y graficar cómodamente
    for k, v in config.items():
        fila[f"hp_{k}"] = v

    # Cada framework aporta columnas hp_* distintas, asi que NO se puede
    # hacer append directo: pandas escribiria los valores por posicion y las
    # filas quedarian desalineadas. Se reescribe el archivo completo, que con
    # decenas de experimentos es instantaneo y siempre queda consistente.
    previo = pd.read_csv(path) if os.path.exists(path) else pd.DataFrame()
    completo = pd.concat([previo, pd.DataFrame([fila])], ignore_index=True)
    completo.to_csv(path, index=False)
    return fila


def _siguiente_id(path=BITACORA):
    if not os.path.exists(path):
        return 1
    return len(pd.read_csv(path)) + 1


def leer_bitacora(path=BITACORA):
    """Devuelve la bitácora como DataFrame."""
    if not os.path.exists(path):
        return pd.DataFrame()
    return pd.read_csv(path)


iniciar_bitacora()
print("Listo. Usa registrar_experimento(...) después de evaluar cada configuración.")
Bitácora reiniciada: experimentos.csv
Listo. Usa registrar_experimento(...) después de evaluar cada configuración.

5. La función de métricas

# TU CÓDIGO AQUÍ

Escribe una función que reciba las etiquetas verdaderas y las predichas, y devuelva un diccionario con cuatro métricas. La vas a llamar decenas de veces durante las búsquedas, así que conviene tenerla en un solo lugar.

Como el problema es multiclase (10 dígitos), precision, recall y F1 necesitan un promedio. Usa average="macro", que promedia la métrica de cada clase dándoles el mismo peso — así un dígito que el modelo lee mal no queda escondido detrás de los otros nueve.

Formato esperado: una función con esta firma exacta, que devuelva un dict con exactamente estas cuatro llaves:

def evaluar(y_true, y_pred):
    return {"accuracy": ..., "precision": ..., "recall": ..., "f1": ...}

Importa lo que necesites de sklearn.metrics.

In [7]:
# ============================================================
# TU CÓDIGO AQUÍ
# ============================================================
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score

def evaluar(y_true, y_pred):
    return {
        "accuracy":  accuracy_score(y_true, y_pred),
        "precision": precision_score(y_true, y_pred, average="macro", zero_division=0),
        "recall":    recall_score(y_true, y_pred, average="macro", zero_division=0),
        "f1":        f1_score(y_true, y_pred, average="macro", zero_division=0),
    }
In [8]:
# --- Verificación (no modifiques esta celda) ---
assert "evaluar" in dir() and callable(evaluar), "No encuentro la función `evaluar`."

_yt = np.array([0, 1, 2, 3, 0, 1, 2, 3])
_yp = np.array([0, 1, 2, 3, 0, 1, 3, 3])      # 1 error de 8
_m = evaluar(_yt, _yp)

assert isinstance(_m, dict), "`evaluar` debe devolver un diccionario"
_esperadas = {"accuracy", "precision", "recall", "f1"}
assert set(_m.keys()) == _esperadas, f"Las llaves deben ser exactamente {_esperadas}, tienes {set(_m.keys())}"
assert abs(_m["accuracy"] - 0.875) < 1e-6, f"accuracy esperada 0.875, obtuve {_m['accuracy']}"
assert abs(_m["f1"] - 0.8666667) < 1e-3, \
    f"f1 macro esperada ≈0.867, obtuve {_m['f1']:.4f} — ¿usaste average='macro'?"

print("✅ Función evaluar() verificada")
print("   sobre el ejemplo de prueba:", {k: round(v, 4) for k, v in _m.items()})
✅ Función evaluar() verificada
   sobre el ejemplo de prueba: {'accuracy': 0.875, 'precision': 0.9167, 'recall': 0.875, 'f1': 0.8667}

6. Los tres frameworks (ya implementado)

Aquí está lo que compite en esta tarea:

Framework Arquitectura Hiper-parámetros a ajustar
scikit-learn Random Forest n_estimators, max_depth, min_samples_leaf
TensorFlow / Keras MLP densa capas, unidades, dropout, lr
PyTorch MLP densa capas, unidades, dropout, lr

Los dos MLP usan el mismo espacio de hiper-parámetros a propósito: así puedes comparar si dos implementaciones de la misma arquitectura llegan a resultados parecidos. (Spoiler: parecidos, no idénticos — cada framework inicializa los pesos y baraja los lotes a su manera.)

La función entrenar te devuelve un objeto con un método .predict(X), sin importar el framework. Eso es lo que te permite escribir un solo bucle de búsqueda que funcione para los tres.

In [9]:
class ModeloEntrenado:
    """Envoltura con interfaz común para los tres frameworks."""

    def __init__(self, framework, obj, arquitectura):
        self.framework = framework
        self.obj = obj
        self.arquitectura = arquitectura

    def predict(self, X):
        if self.framework == "sklearn":
            return self.obj.predict(X)
        if self.framework == "tensorflow":
            return self.obj.predict(X, verbose=0).argmax(axis=1)
        # pytorch
        self.obj.eval()
        with torch.no_grad():
            logits = self.obj(torch.tensor(X, dtype=torch.float32))
        return logits.argmax(dim=1).numpy()


def _entrenar_sklearn(hp, X_tr, y_tr):
    modelo = RandomForestClassifier(
        n_estimators=hp["n_estimators"],
        max_depth=hp["max_depth"],
        min_samples_leaf=hp["min_samples_leaf"],
        random_state=SEED, n_jobs=-1,
    ).fit(X_tr, y_tr)
    return ModeloEntrenado("sklearn", modelo, "RandomForest")


def _entrenar_tensorflow(hp, X_tr, y_tr):
    tf.keras.backend.clear_session()
    tf.random.set_seed(SEED)
    capas = [tf.keras.layers.Input(shape=(X_tr.shape[1],))]
    for _ in range(hp["capas"]):
        capas.append(tf.keras.layers.Dense(hp["unidades"], activation="relu"))
        capas.append(tf.keras.layers.Dropout(hp["dropout"]))
    capas.append(tf.keras.layers.Dense(10, activation="softmax"))
    modelo = tf.keras.Sequential(capas)
    modelo.compile(optimizer=tf.keras.optimizers.Adam(learning_rate=hp["lr"]),
                   loss="sparse_categorical_crossentropy", metrics=["accuracy"])
    modelo.fit(X_tr, y_tr, epochs=EPOCAS, batch_size=64, verbose=0)
    return ModeloEntrenado("tensorflow", modelo, "MLP-Keras")


class _MLPTorch(nn.Module):
    def __init__(self, n_entradas, capas, unidades, dropout):
        super().__init__()
        bloques, dim = [], n_entradas
        for _ in range(capas):
            bloques += [nn.Linear(dim, unidades), nn.ReLU(), nn.Dropout(dropout)]
            dim = unidades
        bloques.append(nn.Linear(dim, 10))
        self.red = nn.Sequential(*bloques)

    def forward(self, x):
        return self.red(x)


def _entrenar_pytorch(hp, X_tr, y_tr):
    torch.manual_seed(SEED)
    modelo = _MLPTorch(X_tr.shape[1], hp["capas"], hp["unidades"], hp["dropout"])
    opt = torch.optim.Adam(modelo.parameters(), lr=hp["lr"])
    lossf = nn.CrossEntropyLoss()
    Xt = torch.tensor(X_tr, dtype=torch.float32)
    yt = torch.tensor(y_tr, dtype=torch.long)
    ds = torch.utils.data.TensorDataset(Xt, yt)
    dl = torch.utils.data.DataLoader(ds, batch_size=64, shuffle=True)
    modelo.train()
    for _ in range(EPOCAS):
        for xb, yb in dl:
            opt.zero_grad()
            loss = lossf(modelo(xb), yb)
            loss.backward()
            opt.step()
    return ModeloEntrenado("pytorch", modelo, "MLP-PyTorch")


EPOCAS = 30      # igual para los dos frameworks de redes, para que compitan parejo

def entrenar(framework, hp, X_tr=None, y_tr=None):
    """Entrena UNA configuración y devuelve un ModeloEntrenado con .predict(X).

    framework: 'sklearn' | 'tensorflow' | 'pytorch'
    hp       : dict de hiper-parámetros de ese framework
    """
    X_tr = X_train_s if X_tr is None else X_tr
    y_tr = y_train   if y_tr is None else y_tr
    return {"sklearn": _entrenar_sklearn,
            "tensorflow": _entrenar_tensorflow,
            "pytorch": _entrenar_pytorch}[framework](hp, X_tr, y_tr)


# Prueba rápida de que los tres funcionan (y de cuánto tarda cada uno)
for _fw, _hp in [("sklearn",    {"n_estimators": 50, "max_depth": 10, "min_samples_leaf": 1}),
                 ("tensorflow", {"capas": 1, "unidades": 64, "dropout": 0.2, "lr": 1e-3}),
                 ("pytorch",    {"capas": 1, "unidades": 64, "dropout": 0.2, "lr": 1e-3})]:
    _t0 = time.time()
    _m = entrenar(_fw, _hp)
    _seg = time.time() - _t0
    _acc = (_m.predict(X_cv_s) == y_cv).mean()
    print(f"{_fw:<12} arquitectura={_m.arquitectura:<14} accuracy_cv={_acc:.4f}  ({_seg:.1f} s)")
sklearn      arquitectura=RandomForest   accuracy_cv=0.9694  (0.1 s)
tensorflow   arquitectura=MLP-Keras      accuracy_cv=0.9778  (1.1 s)
pytorch      arquitectura=MLP-PyTorch    accuracy_cv=0.9639  (0.6 s)

# TU CÓDIGO AQUÍ

Prueba todas las combinaciones de una rejilla de hiper-parámetros, para los tres frameworks.

Usa exactamente estas rejillas (ya definidas en la celda de abajo, en REJILLAS):

REJILLAS = {
    "sklearn":    {"n_estimators": [50, 150], "max_depth": [5, 15], "min_samples_leaf": [1, 4]},
    "tensorflow": {"capas": [1, 2], "unidades": [32, 128], "dropout": [0.0, 0.3]},
    "pytorch":    {"capas": [1, 2], "unidades": [32, 128], "dropout": [0.0, 0.3]},
}

Los MLP necesitan además un lr; para grid search déjalo fijo en 1e-3 (si no, la rejilla crece de más). Lo vas a ajustar en las otras dos estrategias.

Para cada combinación debes:

  1. Entrenar con entrenar(framework, hp) — usa el train-set
  2. Predecir sobre el cv-set (X_cv_s) y medir con tu función evaluar
  3. Registrar el experimento con registrar_experimento(...), pasando estrategia="grid", el framework, modelo.arquitectura, la config, las métricas y los segundos

Pista. itertools.product(*rejilla.values()) te da todas las combinaciones, y dict(zip(rejilla.keys(), combo)) las vuelve un dict de hiper-parámetros.

Son 8 combinaciones por framework, 24 en total. Debería tomar 2 o 3 minutos.

In [10]:
REJILLAS = {
    "sklearn":    {"n_estimators": [50, 150], "max_depth": [5, 15], "min_samples_leaf": [1, 4]},
    "tensorflow": {"capas": [1, 2], "unidades": [32, 128], "dropout": [0.0, 0.3]},
    "pytorch":    {"capas": [1, 2], "unidades": [32, 128], "dropout": [0.0, 0.3]},
}
LR_FIJO = 1e-3     # para los MLP en grid search

# ============================================================
# TU CÓDIGO AQUÍ
# ============================================================
for framework, rejilla in REJILLAS.items():
    for combo in itertools.product(*rejilla.values()):
        hp = dict(zip(rejilla.keys(), combo))
        if framework in ("tensorflow", "pytorch"):
            hp["lr"] = LR_FIJO

        t0 = time.time()
        modelo = entrenar(framework, hp)
        segundos = time.time() - t0

        y_pred = modelo.predict(X_cv_s)          # sobre el CV-SET
        metricas = evaluar(y_cv, y_pred)

        registrar_experimento(estrategia="grid", framework=framework,
                              arquitectura=modelo.arquitectura, config=hp,
                              metricas=metricas, segundos=segundos)

print("Grid search terminado")
Grid search terminado
In [11]:
# --- Verificación (no modifiques esta celda) ---
_b = leer_bitacora()
assert len(_b) > 0, "La bitácora está vacía — ¿llamaste a registrar_experimento?"
_g = _b[_b["estrategia"] == "grid"]
assert len(_g) == 24, f"Se esperaban 24 experimentos de grid (8 por framework), hay {len(_g)}"
assert set(_g["framework"].unique()) == {"sklearn", "tensorflow", "pytorch"}, \
    f"Faltan frameworks en el grid: {set(_g['framework'].unique())}"
for _fw in ("sklearn", "tensorflow", "pytorch"):
    assert len(_g[_g["framework"] == _fw]) == 8, f"{_fw} debería tener 8 combinaciones"
assert _g["cv_f1"].notna().all(), "Hay filas sin métrica cv_f1 — ¿pasaste bien el dict de métricas?"
assert _g["cv_f1"].max() > 0.80, \
    f"El mejor f1 del grid es {_g['cv_f1'].max():.3f}, sospechosamente bajo — ¿evaluaste sobre el CV-set?"

print(f"✅ Grid search verificado: {len(_g)} experimentos registrados")
print(_g.groupby("framework")["cv_f1"].agg(["count", "mean", "max"]).round(4))
✅ Grid search verificado: 24 experimentos registrados
            count    mean     max
framework                        
pytorch         8  0.9701  0.9834
sklearn         8  0.9532  0.9752
tensorflow      8  0.9629  0.9777

# TU CÓDIGO AQUÍ

En vez de recorrer una rejilla, muestrea configuraciones al azar de un espacio continuo. Como vimos en la unidad, esto suele encontrar mejores configuraciones con el mismo presupuesto: la rejilla desperdicia pruebas en hiper-parámetros que no importan, el muestreo aleatorio no.

Corre 8 configuraciones por framework (24 en total, el mismo presupuesto que el grid, para que la comparación sea justa). Usa el generador rng ya creado, para que sea reproducible.

Espacios a muestrear:

Framework Hiper-parámetro Cómo muestrearlo
sklearn n_estimators entero entre 30 y 300
sklearn max_depth entero entre 3 y 25
sklearn min_samples_leaf entero entre 1 y 8
tensorflow / pytorch capas entero entre 1 y 3
tensorflow / pytorch unidades entero entre 16 y 256
tensorflow / pytorch dropout real entre 0.0 y 0.5
tensorflow / pytorch lr log-uniforme entre 1e-4 y 1e-2

Por qué el lr es log-uniforme. Un learning rate de 0.0001 y uno de 0.001 son tan distintos entre sí como 0.001 y 0.01, aunque en la recta real disten muy poco. Muestrear uniforme te daría casi siempre valores grandes. Pista: 10 ** rng.uniform(-4, -2).

Registra cada experimento igual que antes, pero con estrategia="random".

In [12]:
rng = np.random.default_rng(SEED)
N_RANDOM = 8      # configuraciones por framework

# ============================================================
# TU CÓDIGO AQUÍ
# ============================================================
for framework in ("sklearn", "tensorflow", "pytorch"):
    for _ in range(N_RANDOM):
        if framework == "sklearn":
            hp = {"n_estimators": int(rng.integers(30, 301)),
                  "max_depth": int(rng.integers(3, 26)),
                  "min_samples_leaf": int(rng.integers(1, 9))}
        else:
            hp = {"capas": int(rng.integers(1, 4)),
                  "unidades": int(rng.integers(16, 257)),
                  "dropout": float(rng.uniform(0.0, 0.5)),
                  "lr": float(10 ** rng.uniform(-4, -2))}

        t0 = time.time()
        modelo = entrenar(framework, hp)
        segundos = time.time() - t0

        y_pred = modelo.predict(X_cv_s)          # sobre el CV-SET
        metricas = evaluar(y_cv, y_pred)

        registrar_experimento(estrategia="random", framework=framework,
                              arquitectura=modelo.arquitectura, config=hp,
                              metricas=metricas, segundos=segundos)

print("Random search terminado")
Random search terminado
In [13]:
# --- Verificación (no modifiques esta celda) ---
_b = leer_bitacora()
_r = _b[_b["estrategia"] == "random"]
assert len(_r) == 24, f"Se esperaban 24 experimentos de random search, hay {len(_r)}"
for _fw in ("sklearn", "tensorflow", "pytorch"):
    assert len(_r[_r["framework"] == _fw]) == N_RANDOM, f"{_fw} debería tener {N_RANDOM} configuraciones"

_nn = _r[_r["framework"].isin(["tensorflow", "pytorch"])]
assert _nn["hp_lr"].nunique() > 8, \
    "Los learning rates se repiten demasiado — ¿los estás muestreando al azar?"
assert _nn["hp_lr"].min() < 1e-3 < _nn["hp_lr"].max(), \
    "Los learning rates no cubren el rango 1e-4 a 1e-2 — revisa el muestreo log-uniforme"
assert _r["hp_n_estimators"].dropna().nunique() > 4, "n_estimators se repite demasiado"

print(f"✅ Random search verificado: {len(_r)} experimentos registrados")
print(f"   learning rates muestreados: de {_nn['hp_lr'].min():.5f} a {_nn['hp_lr'].max():.5f}")
print(_r.groupby("framework")["cv_f1"].agg(["count", "mean", "max"]).round(4))
✅ Random search verificado: 24 experimentos registrados
   learning rates muestreados: de 0.00013 a 0.00611
            count    mean     max
framework                        
pytorch         8  0.9560  0.9835
sklearn         8  0.9633  0.9780
tensorflow      8  0.9588  0.9834

9. Estrategia 3: Optimización Bayesiana (Optuna)

# TU CÓDIGO AQUÍ

Grid y random search son ciegos: cada prueba ignora lo que aprendieron las anteriores. La optimización bayesiana construye un modelo de qué zonas del espacio son prometedoras y decide dónde probar con base en los resultados que ya vio.

Con Optuna esto se escribe como una función objetivo(trial) que:

  1. Pide los hiper-parámetros a trial (en vez de elegirlos tú)
  2. Entrena y evalúa en el cv-set
  3. Registra el experimento
  4. Devuelve la métrica a maximizar (usa f1)

Aquí trial también elige el framework: así Optuna hace selección de arquitectura y tuning al mismo tiempo, que es lo que se hace en la práctica.

Métodos de trial que vas a necesitar:

trial.suggest_categorical("framework", ["sklearn", "tensorflow", "pytorch"])
trial.suggest_int("n_estimators", 30, 300)
trial.suggest_float("dropout", 0.0, 0.5)
trial.suggest_float("lr", 1e-4, 1e-2, log=True)     # log=True hace el muestreo log-uniforme

Corre 24 trials, el mismo presupuesto que las otras dos estrategias. Registra cada uno con estrategia="bayesiana".

Ojo: dentro del objetivo, el dict hp que le pasas a entrenar y a registrar_experimento no debe incluir la llave "framework" (ese va como argumento aparte).

In [14]:
N_TRIALS = 24

# ============================================================
# TU CÓDIGO AQUÍ
# ============================================================
def objetivo(trial):
    framework = trial.suggest_categorical("framework", ["sklearn", "tensorflow", "pytorch"])

    if framework == "sklearn":
        hp = {"n_estimators": trial.suggest_int("n_estimators", 30, 300),
              "max_depth": trial.suggest_int("max_depth", 3, 25),
              "min_samples_leaf": trial.suggest_int("min_samples_leaf", 1, 8)}
    else:
        hp = {"capas": trial.suggest_int("capas", 1, 3),
              "unidades": trial.suggest_int("unidades", 16, 256),
              "dropout": trial.suggest_float("dropout", 0.0, 0.5),
              "lr": trial.suggest_float("lr", 1e-4, 1e-2, log=True)}

    t0 = time.time()
    modelo = entrenar(framework, hp)
    segundos = time.time() - t0
    metricas = evaluar(y_cv, modelo.predict(X_cv_s))

    registrar_experimento(estrategia="bayesiana", framework=framework,
                          arquitectura=modelo.arquitectura, config=hp,
                          metricas=metricas, segundos=segundos)
    return metricas["f1"]

estudio = optuna.create_study(direction="maximize",
                              sampler=optuna.samplers.TPESampler(seed=SEED))
estudio.optimize(objetivo, n_trials=N_TRIALS)
print("Mejor f1 en cv:", estudio.best_value)
print("Mejores parámetros:", estudio.best_params)
Mejor f1 en cv: 0.9833983001975856
Mejores parámetros: {'framework': 'tensorflow', 'capas': 1, 'unidades': 146, 'dropout': 0.07046211248738132, 'lr': 0.0040215545266902904}
In [15]:
# --- Verificación (no modifiques esta celda) ---
_b = leer_bitacora()
_o = _b[_b["estrategia"] == "bayesiana"]
assert len(_o) == N_TRIALS, f"Se esperaban {N_TRIALS} trials bayesianos, hay {len(_o)}"
assert "estudio" in dir(), "No encuentro el objeto `estudio` de Optuna."
assert len(_o["framework"].unique()) >= 2, \
    "Optuna probó un solo framework — ¿incluiste 'framework' como suggest_categorical?"
assert _o["cv_f1"].max() > 0.80, f"El mejor f1 bayesiano es {_o['cv_f1'].max():.3f}, revisa la evaluación"

print(f"✅ Optimización bayesiana verificada: {len(_o)} trials registrados")
print(f"   frameworks explorados: {dict(_o['framework'].value_counts())}")
print(f"   mejor f1 en cv: {_o['cv_f1'].max():.4f}")
✅ Optimización bayesiana verificada: 24 trials registrados
   frameworks explorados: {'pytorch': np.int64(12), 'tensorflow': np.int64(8), 'sklearn': np.int64(4)}
   mejor f1 en cv: 0.9834

10. Comparación de las tres estrategias (ya implementado)

Con la bitácora completa (72 experimentos) podemos comparar. Fíjate en tres cosas al mirar las gráficas:

  1. ¿Qué estrategia encontró la mejor configuración? No siempre es la bayesiana con presupuestos chicos: TPE necesita algunos trials para "calentar".
  2. ¿Qué framework domina? Y ojo con el costo: mira también los segundos.
  3. La convergencia: grid y random son planos por construcción (no aprenden); la bayesiana debería mejorar su mejor-hasta-ahora más rápido.
In [16]:
bit = leer_bitacora()
print(f"Bitácora: {len(bit)} experimentos\n")
print(bit.groupby(["estrategia", "framework"])["cv_f1"].agg(["count", "mean", "max"]).round(4))
Bitácora: 72 experimentos

                       count    mean     max
estrategia framework                        
bayesiana  pytorch        12  0.9708  0.9806
           sklearn         4  0.9096  0.9613
           tensorflow      8  0.9422  0.9834
grid       pytorch         8  0.9701  0.9834
           sklearn         8  0.9532  0.9752
           tensorflow      8  0.9629  0.9777
random     pytorch         8  0.9560  0.9835
           sklearn         8  0.9633  0.9780
           tensorflow      8  0.9588  0.9834
In [17]:
fig, axes = plt.subplots(1, 3, figsize=(14, 3.8))

# (a) distribución del f1 por estrategia
ests = ["grid", "random", "bayesiana"]
datos = [bit[bit["estrategia"] == e]["cv_f1"].values for e in ests]
bp = axes[0].boxplot(datos, labels=ests, patch_artist=True)
for caja in bp["boxes"]:
    caja.set_facecolor(OKABE[0]); caja.set_alpha(0.55)
axes[0].set_ylabel("f1 en cross-validation")
axes[0].set_title("(a) Distribución por estrategia")
axes[0].grid(alpha=0.3, axis="y")

# (b) f1 por framework
fws = ["sklearn", "tensorflow", "pytorch"]
datos2 = [bit[bit["framework"] == f]["cv_f1"].values for f in fws]
bp2 = axes[1].boxplot(datos2, labels=fws, patch_artist=True)
for caja in bp2["boxes"]:
    caja.set_facecolor(OKABE[2]); caja.set_alpha(0.55)
axes[1].set_ylabel("f1 en cross-validation")
axes[1].set_title("(b) Distribución por framework")
axes[1].grid(alpha=0.3, axis="y")

# (c) mejor-hasta-ahora, por estrategia
for i, e in enumerate(ests):
    sub = bit[bit["estrategia"] == e].reset_index(drop=True)
    axes[2].plot(range(1, len(sub) + 1), sub["cv_f1"].cummax(),
                 marker="o", ms=3, color=OKABE[i], label=e)
axes[2].set_xlabel("experimento dentro de la estrategia")
axes[2].set_ylabel("mejor f1 hasta ahora")
axes[2].set_title("(c) Convergencia")
axes[2].legend(); axes[2].grid(alpha=0.3)

plt.tight_layout(); plt.show()
In [18]:
# Costo vs beneficio: ¿los modelos más lentos son mejores?
fig, ax = plt.subplots(figsize=(7, 4))
for i, fw in enumerate(fws):
    sub = bit[bit["framework"] == fw]
    ax.scatter(sub["segundos"], sub["cv_f1"], s=42, alpha=0.75,
               color=OKABE[i], label=fw, edgecolors="white", linewidths=0.6)
ax.set_xlabel("segundos de entrenamiento")
ax.set_ylabel("f1 en cross-validation")
ax.set_title("Costo de entrenamiento vs desempeño")
ax.legend(); ax.grid(alpha=0.3)
plt.tight_layout(); plt.show()

print("Top 5 configuraciones de toda la bitácora:")
cols = ["experimento", "estrategia", "framework", "arquitectura", "cv_f1", "cv_accuracy", "segundos"]
print(bit.nlargest(5, "cv_f1")[cols].to_string(index=False))
Top 5 configuraciones de toda la bitácora:
 experimento estrategia  framework arquitectura    cv_f1  cv_accuracy  segundos
          47     random    pytorch  MLP-PyTorch 0.983540     0.983333      0.54
          58  bayesiana tensorflow    MLP-Keras 0.983398     0.983333      1.26
          24       grid    pytorch  MLP-PyTorch 0.983362     0.983333      0.40
          36     random tensorflow    MLP-Keras 0.983360     0.983333      1.50
          37     random tensorflow    MLP-Keras 0.980661     0.980556      2.00

11. El modelo final y el test-set

# TU CÓDIGO AQUÍ

Llegó el momento de usar el test-set — por primera y única vez.

Lo que tienes que hacer:

  1. Encontrar en la bitácora la fila con el mejor cv_f1 y guardarla en mejor_fila
  2. Reconstruir su configuración desde la columna config (es una cadena JSON: json.loads) y guardarla en mejor_config
  3. Re-entrenar esa configuración usando train + cv juntos (np.vstack / np.concatenate) — ahora que ya elegiste, no tiene sentido desperdiciar el cv
  4. Evaluar ese modelo en el test-set y guardar las métricas en metricas_test

Formato esperado: tres variables con estos nombres:

mejor_fila       # una fila (Series) de la bitácora
mejor_config     # dict de hiper-parámetros
metricas_test    # dict con las 4 métricas, medidas en test

Por qué re-entrenar con train + cv. El cv-set ya cumplió su función (elegir). Volver a entrenar con más datos casi siempre mejora el modelo que sale a producción. Lo que no puedes hacer es tocar el test-set antes de este punto.

In [19]:
# ============================================================
# TU CÓDIGO AQUÍ
# ============================================================
bit = leer_bitacora()
mejor_fila = bit.loc[bit["cv_f1"].idxmax()]
mejor_config = json.loads(mejor_fila["config"])

X_full = np.vstack([X_train_s, X_cv_s])
y_full = np.concatenate([y_train, y_cv])

modelo_final = entrenar(mejor_fila["framework"], mejor_config, X_full, y_full)
metricas_test = evaluar(y_test, modelo_final.predict(X_test_s))

print(metricas_test)
{'accuracy': 0.975, 'precision': 0.9757523257523258, 'recall': 0.9748369798369797, 'f1': 0.9749683984469085}
In [20]:
# --- Verificación (no modifiques esta celda) ---
for _v in ["mejor_fila", "mejor_config", "metricas_test", "modelo_final"]:
    assert _v in dir(), f"No encuentro `{_v}` — revisa la celda anterior."
assert isinstance(mejor_config, dict), "`mejor_config` debe ser un dict (usa json.loads)"
assert set(metricas_test.keys()) == {"accuracy", "precision", "recall", "f1"}, \
    "`metricas_test` debe tener las mismas 4 llaves que devuelve evaluar()"

_bit = leer_bitacora()
assert abs(mejor_fila["cv_f1"] - _bit["cv_f1"].max()) < 1e-9, \
    "`mejor_fila` no es la de mayor cv_f1 de la bitácora"
assert metricas_test["f1"] > 0.85, \
    f"f1 en test es {metricas_test['f1']:.3f}, muy bajo — ¿evaluaste con X_test_s (escalado)?"

print("✅ Selección final verificada")
print(f"   modelo elegido : {mejor_fila['arquitectura']} ({mejor_fila['framework']}, "
      f"estrategia {mejor_fila['estrategia']})")
print(f"   f1 en cv       : {mejor_fila['cv_f1']:.4f}")
print(f"   f1 en test     : {metricas_test['f1']:.4f}")
✅ Selección final verificada
   modelo elegido : MLP-PyTorch (pytorch, estrategia random)
   f1 en cv       : 0.9835
   f1 en test     : 0.9750

12. Reporte final del modelo elegido (ya implementado)

Esto es lo que le entregas al área de tecnología.

In [21]:
from sklearn.metrics import confusion_matrix, classification_report

bit = leer_bitacora()

print("=" * 68)
print("REPORTE FINAL — Lector automático de medidores")
print("=" * 68)
print(f"\nExperimentos corridos      : {len(bit)}")
print(f"  por grid search          : {(bit['estrategia'] == 'grid').sum()}")
print(f"  por random search        : {(bit['estrategia'] == 'random').sum()}")
print(f"  por optimización bayesiana: {(bit['estrategia'] == 'bayesiana').sum()}")
print(f"Tiempo total de entrenamiento: {bit['segundos'].sum():.0f} s")

print(f"\nMODELO ELEGIDO")
print(f"  arquitectura   : {mejor_fila['arquitectura']}")
print(f"  framework      : {mejor_fila['framework']}")
print(f"  hallado por    : {mejor_fila['estrategia']} (experimento #{mejor_fila['experimento']})")
print(f"  hiper-parámetros:")
for k, v in mejor_config.items():
    print(f"      {k:<18} = {v}")

print(f"\nDESEMPEÑO")
print(f"  {'métrica':<12} {'cross-validation':>18} {'test':>10}   {'diferencia':>12}")
for m in ["accuracy", "precision", "recall", "f1"]:
    cv_v, te_v = mejor_fila[f"cv_{m}"], metricas_test[m]
    print(f"  {m:<12} {cv_v:>18.4f} {te_v:>10.4f}   {te_v - cv_v:>+12.4f}")

print(f"\n  El número que va al informe es el de TEST: f1 = {metricas_test['f1']:.4f}")
print("  (el de cv está sesgado al alza: esa configuración se eligió justamente por ser la mejor ahí)")
print("=" * 68)
====================================================================
REPORTE FINAL — Lector automático de medidores
====================================================================

Experimentos corridos      : 72
  por grid search          : 24
  por random search        : 24
  por optimización bayesiana: 24
Tiempo total de entrenamiento: 49 s

MODELO ELEGIDO
  arquitectura   : MLP-PyTorch
  framework      : pytorch
  hallado por    : random (experimento #47)
  hiper-parámetros:
      capas              = 2
      dropout            = 0.39036451551098394
      lr                 = 0.0008276210937283387
      unidades           = 185

DESEMPEÑO
  métrica        cross-validation       test     diferencia
  accuracy                 0.9833     0.9750        -0.0083
  precision                0.9842     0.9758        -0.0084
  recall                   0.9832     0.9748        -0.0084
  f1                       0.9835     0.9750        -0.0086

  El número que va al informe es el de TEST: f1 = 0.9750
  (el de cv está sesgado al alza: esa configuración se eligió justamente por ser la mejor ahí)
====================================================================
In [22]:
fig, axes = plt.subplots(1, 2, figsize=(13, 4.4))

# matriz de confusión en test
cm = confusion_matrix(y_test, modelo_final.predict(X_test_s))
im = axes[0].imshow(cm, cmap="Blues")
axes[0].set_xticks(range(10)); axes[0].set_yticks(range(10))
axes[0].set_xlabel("dígito predicho"); axes[0].set_ylabel("dígito real")
axes[0].set_title(f"Matriz de confusión en TEST — {mejor_fila['arquitectura']}")
for i in range(10):
    for j in range(10):
        if cm[i, j]:
            axes[0].text(j, i, cm[i, j], ha="center", va="center", fontsize=8,
                         color="white" if cm[i, j] > cm.max() / 2 else "black")
fig.colorbar(im, ax=axes[0], fraction=0.046)

# cv vs test del modelo elegido
ms = ["accuracy", "precision", "recall", "f1"]
xs = np.arange(len(ms)); w = 0.36
axes[1].bar(xs - w/2, [mejor_fila[f"cv_{m}"] for m in ms], w, label="cross-validation", color=OKABE[0])
axes[1].bar(xs + w/2, [metricas_test[m] for m in ms], w, label="test", color=OKABE[1])
axes[1].set_xticks(xs); axes[1].set_xticklabels(ms)
axes[1].set_ylim(0, 1.05); axes[1].legend(); axes[1].grid(alpha=0.3, axis="y")
axes[1].set_title("Modelo elegido: cross-validation vs test")
plt.tight_layout(); plt.show()

print("\nReporte por dígito (test-set):\n")
print(classification_report(y_test, modelo_final.predict(X_test_s), digits=3))
Reporte por dígito (test-set):

              precision    recall  f1-score   support

           0      1.000     0.972     0.986        36
           1      0.897     0.972     0.933        36
           2      0.972     1.000     0.986        35
           3      1.000     1.000     1.000        37
           4      0.972     0.972     0.972        36
           5      1.000     0.973     0.986        37
           6      1.000     0.972     0.986        36
           7      1.000     1.000     1.000        36
           8      0.970     0.914     0.941        35
           9      0.946     0.972     0.959        36

    accuracy                          0.975       360
   macro avg      0.976     0.975     0.975       360
weighted avg      0.976     0.975     0.975       360

In [23]:
# Los dígitos que el modelo confunde: útil para el informe
y_pred_test = modelo_final.predict(X_test_s)
errores = np.where(y_pred_test != y_test)[0]
print(f"El modelo se equivocó en {len(errores)} de {len(y_test)} imágenes de test "
      f"({100 * len(errores) / len(y_test):.1f}%)\n")

if len(errores):
    n = min(10, len(errores))
    fig, axes = plt.subplots(1, n, figsize=(1.35 * n, 2.1))
    axes = np.atleast_1d(axes)
    for ax, idx in zip(axes, errores[:n]):
        ax.imshow(X_test[idx].reshape(8, 8), cmap="gray_r")
        ax.set_title(f"real {y_test[idx]}\npred {y_pred_test[idx]}", fontsize=8)
        ax.axis("off")
    fig.suptitle("Errores del modelo final en el test-set", y=1.12)
    plt.tight_layout(); plt.show()
El modelo se equivocó en 9 de 360 imágenes de test (2.5%)

13. Tu Análisis y Conclusión

# TU CÓDIGO AQUÍ (en Markdown)

Con la bitácora, las gráficas y el reporte final en mano, escribe tu recomendación en la celda de abajo. Guíate con estas preguntas — no hace falta responderlas una por una, pero tu conclusión debe reflejar que las pensaste:

Sobre las estrategias de búsqueda

  • Con el mismo presupuesto (24 experimentos cada una), ¿cuál encontró la mejor configuración?
  • Mira la gráfica de convergencia: ¿se nota que la bayesiana "aprende" de sus pruebas anteriores? ¿Le alcanzaron 24 trials para despegarse de las otras dos?
  • ¿En qué situación recomendarías grid search a pesar de sus desventajas?

Sobre los frameworks

  • ¿Qué arquitectura ganó? Mira también la gráfica de costo vs desempeño: ¿la diferencia en f1 justifica la diferencia en tiempo de entrenamiento?
  • TensorFlow y PyTorch entrenaron la misma arquitectura con el mismo espacio de hiper-parámetros. ¿Dieron resultados parecidos? Si difieren, ¿a qué lo atribuyes?

Sobre la honestidad de los números

  • Compara el f1 de cross-validation del modelo elegido contra su f1 de test. ¿Cuál es mayor? ¿Por qué era esperable, aunque no hubiera nada mal hecho? (piensa en el winner's curse del notebook 09)
  • ¿Qué número le reportarías al área de tecnología como estimación del desempeño en producción?

Sobre el método

  • Usamos un solo cv-set en vez de k-fold. ¿Qué riesgo trae eso al comparar dos configuraciones cuyos f1 difieren, digamos, en 0.005? ¿Qué harías distinto si el entrenamiento fuera barato?

Tu conclusión debe incluir, como mínimo:

  1. El modelo que recomiendas, con su framework, arquitectura e hiper-parámetros.
  2. El número que reportas como desempeño esperado, y de qué conjunto salió.
  3. Una comparación de las tres estrategias con datos de tu bitácora.
  4. Una limitación honesta de este estudio y qué harías para resolverla.

Mi conclusión

  • Modelo recomendado (framework, arquitectura e hiper-parámetros): El modelo elegido es MLP-PyTorch (framework pytorch), hallado por random search (experimento #47), con hiper-parámetros: capas=2, unidades=185, dropout=0.390, lr=0.000828. Fue la configuración con mayor cv_f1 de las 72 probadas.

  • Desempeño esperado en producción, y de qué conjunto sale ese número: El número que reporto es el f1 en el test-set: 0.9750 (accuracy 0.975, precision 0.976, recall 0.975), no el de cross-validation (0.9835). El cv se usó para elegir la configuración entre 72 candidatas, así que su métrica está sesgada al alza (winner's curse): la que salió mejor ahí tuvo, además de ser buena, algo de suerte con ese cv-set particular de 360 imágenes. El test-set no participó en ninguna decisión, por eso 0.975 es el número honesto para producción.

  • Comparación de las tres estrategias, con números de mi bitácora: Con el mismo presupuesto (24 experimentos cada una), las tres llegaron prácticamente al mismo techo:

    • Grid search: mejor f1 = 0.9834 (pytorch)
    • Random search: mejor f1 = 0.9835 (pytorch) — la ganadora, por un margen mínimo
    • Bayesiana (Optuna): mejor f1 = 0.9834 (tensorflow)

    De hecho, el "top 5" de toda la bitácora está comprimido en un rango de solo 0.0029 de f1 (0.9835 a 0.9807), y los primeros cuatro difieren entre sí por menos de 0.0002 — una diferencia muchísimo menor a los 0.005 que se mencionan como umbral de riesgo, y que cae dentro del ruido esperable de un solo cv-set de 360 imágenes. Con este presupuesto, ninguna estrategia le sacó ventaja clara a las otras: eligiendo cualquiera de las tres me habría llevado a una configuración prácticamente equivalente.

    Sí hay evidencia de que la bayesiana "aprendió": de sus 24 trials, dedicó 12 a pytorch y 8 a tensorflow, pero solo 4 a sklearn — precisamente el framework con peor techo dentro de sus propios trials (f1 máximo 0.9613 en bayesiana, contra ~0.978 en grid/random). Optuna detectó rápido que sklearn rendía menos y reasignó su presupuesto a los MLP. Además, su mejor resultado (experimento #58) apareció en el décimo trial de sus 24 (no en los primeros), lo que sugiere que sí necesitó ese "calentamiento" antes de converger — con más presupuesto (por ejemplo 100 trials) probablemente sí se habría despegado de grid y random. Recomendaría grid search cuando el espacio de hiper-parámetros es pequeño y discreto de antemano y se necesita cobertura exhaustiva y reproducible, sin depender de un sampler — por ejemplo, para un reporte donde hay que justificar que se probó todo el espacio declarado.

  • Sobre los frameworks (arquitectura ganadora y costo vs desempeño): Los dos MLP (TensorFlow y PyTorch) superaron consistentemente a Random Forest: el mejor f1 de sklearn en toda la bitácora fue 0.9780, contra 0.9835 (pytorch) y 0.9834 (tensorflow). Con el mismo espacio de hiper-parámetros, TensorFlow y PyTorch llegaron a resultados muy parecidos (0.9834 vs 0.9835, prácticamente empatados), lo que confirma que la arquitectura importa más que el framework — las pequeñas diferencias que sí aparecen se explican por inicialización de pesos y orden de los lotes distintos entre ambos. Random Forest, sin embargo, entrena en fracciones de segundo (0.1 s en la prueba inicial) contra ~0.5-2 s de los MLP: para este problema, la ganancia de ~0.005-0.006 de f1 de los MLP probablemente sí justifica el costo extra (el tiempo total de las 72 corridas fue de solo 49 s), pero en un dataset mucho más grande valdría la pena reconsiderar si esa diferencia sigue siendo rentable.

  • Por qué el f1 de cross-validation y el de test difieren: El f1 de cv (0.9835) es mayor que el de test (0.9750), una diferencia de -0.0086. Era esperable aunque no hubiera nada mal hecho: la configuración se seleccionó por ser la mejor entre 72 precisamente en el cv-set, así que cualquier ruido favorable de esas 360 imágenes quedó "capturado" en la elección (winner's curse, visto en el notebook 09). El test-set, al no haber influido en ninguna decisión, da una estimación sin ese sesgo — por eso el número que reporto a producción es 0.975 y no 0.9835.

  • Una limitación de este estudio y cómo la resolvería: Usamos un único cv-set (holdout) en vez de k-fold, y los datos lo confirman: el top-4 de la bitácora difiere en menos de 0.0002 de f1, un margen totalmente dominado por qué imágenes cayeron en ese cv-set de 360 en particular, no por diferencias reales de calidad entre esas configuraciones. Si tuviera que elegir entre el experimento #47 (random) y el #58 (bayesiana) con esta metodología, en realidad estaría tirando una moneda. Si el entrenamiento fuera barato (como con Random Forest, que tarda decimas de segundo) usaría k-fold cross-validation para promediar varios splits y reducir esa varianza antes de decidir; para los MLP, donde entrenar es más caro, una alternativa intermedia sería repetir el holdout con 2-3 semillas distintas y comparar el promedio de f1 en vez de un solo número, al menos para las configuraciones que queden en el top-5.