Files
2026-08-25 12:19:45 -06:00

116 KiB
Executable File

Laboratorio de clasificación tabular

Propósito

Construya, compare y seleccione clasificadores binarios para un problema no lineal y desbalanceado. El único archivo de datos permitido durante desarrollo es datos_publicos/train_1000_desbalanceado.csv.

El conjunto privado de 200 ejemplos no se entrega al estudiante. No lo use para ajustar la arquitectura, hiperparámetros, normalización ni umbral.

Reglas de calidad

  • Use una división estratificada fija de 800 ejemplos para entrenamiento y 200 para desarrollo.
  • Ajuste cualquier transformación, incluidas media y desviación estándar, únicamente con entrenamiento.
  • El entrenamiento tiene 75% de clase 0 y 25% de clase 1. Justifique cómo manejará el desbalance: peso de clase, métrica de selección y umbral.
  • Compare exactamente estas familias: un MLP convencional y un MLP residual con Batch Normalization.
  • Seleccione configuración y umbral usando exclusivamente desarrollo. Declare explícitamente los costos de falso negativo y falso positivo.
In [1]:
from pathlib import Path
import sys
import numpy as np
import matplotlib.pyplot as plt

candidatos = [Path.cwd().resolve(), Path.cwd().resolve() / 'dist' / 'laboratorio_clasificacion', Path.cwd().resolve().parent]
RAIZ = next((ruta for ruta in candidatos if (ruta / 'lib_modelos.py').exists()), None)
if RAIZ is None:
    raise FileNotFoundError('No se encontró la carpeta laboratorio_clasificacion.')
sys.path.insert(0, str(RAIZ))
print('Raíz del laboratorio:', RAIZ)
Raíz del laboratorio: /home/aleleba/projects/cursos/Universidad/Procesamiento de Imagenes y Vision por Computadora/Labs/Lab2

1. Carga, auditoría y división 800/200

Complete la siguiente celda. Verifique los tamaños y las proporciones de clase. La función division_estratificada preserva el 75/25 en ambas particiones.

In [2]:
from lib_modelos import (
    cargar_csv,
    division_estratificada,
    ajustar_estandarizador,
    transformar,
)

# Único archivo de datos permitido durante el desarrollo.
x, y, ids = cargar_csv(RAIZ / 'datos_publicos' / 'train_1000_desbalanceado.csv')

# División estratificada fija: 800 entrenamiento / 200 desarrollo,
# preservando la proporción 75 % clase 0 / 25 % clase 1 en ambas partes.
SEMILLA_DIVISION = 31
indice_train, indice_dev = division_estratificada(y, proporcion_dev=0.20, semilla=SEMILLA_DIVISION)

# La media y la desviación estándar se ajustan SOLO con entrenamiento,
# para no filtrar información de desarrollo hacia el preprocesamiento.
media_entrenamiento, desviacion_entrenamiento = ajustar_estandarizador(x[indice_train])

x_train = transformar(x[indice_train], media_entrenamiento, desviacion_entrenamiento)
x_dev = transformar(x[indice_dev], media_entrenamiento, desviacion_entrenamiento)
y_train, y_dev = y[indice_train], y[indice_dev]

# Comprobaciones requeridas.
print(len(y_train), len(y_dev))
print(y_train.mean(), y_dev.mean())
800 200
0.25 0.25

2. Estrategia ante el desbalance

Responda en esta celda:

  1. ¿Por qué accuracy sola no es suficiente?
  2. ¿Qué peso positivo usará y cómo se calcula?
  3. ¿Qué métrica y qué costo operacional usaría para elegir modelo y umbral?

Luego calcule el peso positivo y escriba su valor.

Respuesta.

  1. Accuracy sola no basta porque el conjunto está desbalanceado (75 % clase 0 / 25 % clase 1): un modelo que siempre prediga la clase 0 obtiene 75 % de accuracy con recall 0, es decir, nunca detecta la clase minoritaria.
  2. Se usa peso_positivo = negativos_train / positivos_train (ver celda de código), que multiplica el costo de los errores sobre la clase 1 dentro de la BCE ponderada, compensando la proporción 3:1 de entrenamiento.
  3. La selección de modelo y umbral usa el costo esperado en desarrollo costo = costo_fn * FN + costo_fp * FP, con costo_fn = 5 y costo_fp = 1 (un falso negativo cuesta cinco veces más que un falso positivo). Se reportan además precision, recall, F1, especificidad y accuracy para ver el desempeño por clase, no solo el global.
In [3]:
# El entrenamiento tiene 75 % clase 0 / 25 % clase 1. Para que la pérdida
# BCE ponderada no ignore la clase minoritaria, el peso positivo se calcula
# como la razón negativos/positivos en entrenamiento (compensa 3:1).
peso_positivo = (y_train == 0).sum() / (y_train == 1).sum()
print('Peso positivo:', peso_positivo)
Peso positivo: 3.0

3. Dos arquitecturas

La biblioteca incluye:

  • MLP: capas densas ReLU y salida logística.
  • MLP residual con Batch Normalization: proyección de entrada, bloque residual y salida logística.

Defina una configuración inicial para cada familia. Mantenga fija la dimensión de entrada en 4. Utilice por lo menos dos configuraciones para cada caso con 4 o 10 capas. Explore la configuración optima en la seleción de hiperparametros.

In [4]:
configuraciones = [
    {  # MLP angosto y poco profundo: referencia base.
        'arquitectura': {'tipo': 'mlp', 'entrada': 4, 'ocultas': [24, 16]},
        'entrenamiento': {'epocas': 100, 'batch_size': 64, 'learning_rate': 0.003},
        'costo_fn': 5, 'costo_fp': 1,
    },
    {  # MLP más ancho: más capacidad, learning rate algo menor.
        'arquitectura': {'tipo': 'mlp', 'entrada': 4, 'ocultas': [48, 24]},
        'entrenamiento': {'epocas': 100, 'batch_size': 64, 'learning_rate': 0.002},
        'costo_fn': 5, 'costo_fp': 1,
    },
    {  # Residual + BatchNorm angosto.
        'arquitectura': {'tipo': 'mlp_residual_bn', 'entrada': 4, 'ancho': 24},
        'entrenamiento': {'epocas': 100, 'batch_size': 64, 'learning_rate': 0.002},
        'costo_fn': 5, 'costo_fp': 1,
    },
    {  # Residual + BatchNorm más ancho, learning rate menor para más estabilidad.
        'arquitectura': {'tipo': 'mlp_residual_bn', 'entrada': 4, 'ancho': 40},
        'entrenamiento': {'epocas': 100, 'batch_size': 64, 'learning_rate': 0.0015},
        'costo_fn': 5, 'costo_fp': 1,
    },
]

4. Búsqueda de hiperparámetros

Use buscar_hiperparametros. Pruebe al menos cuatro configuraciones en total: dos MLP y dos residuales. Registre en una tabla arquitectura, ancho/capas, learning rate, épocas, costo en desarrollo, F1, recall, precision y umbral.

No modifique el conjunto de desarrollo después de mirar los resultados; elija una regla de selección antes de comparar.

In [5]:
from lib_modelos import buscar_hiperparametros

mejor, resultados = buscar_hiperparametros(
    configuraciones, x_train, y_train, x_dev, y_dev, semilla=41,
)

# Tabla compacta: una fila por configuración probada, ordenadas como se declararon.
encabezado = (
    f"{'arquitectura':<16}{'capas/ancho':<14}{'lr':>8}{'epocas':>8}"
    f"{'costo':>8}{'f1':>7}{'recall':>8}{'precision':>10}{'umbral':>8}"
)
print(encabezado)
for fila in resultados:
    arquitectura = fila['configuracion']['arquitectura']
    entrenamiento = fila['configuracion']['entrenamiento']
    capas_o_ancho = arquitectura.get('ocultas', arquitectura.get('ancho'))
    print(
        f"{arquitectura['tipo']:<16}{str(capas_o_ancho):<14}"
        f"{entrenamiento['learning_rate']:>8}{entrenamiento['epocas']:>8}"
        f"{fila['costo']:>8}{fila['f1']:>7.3f}{fila['recall']:>8.3f}"
        f"{fila['precision']:>10.3f}{fila['umbral']:>8.3f}"
    )
print('Configuración seleccionada:', mejor['configuracion']['arquitectura'])
arquitectura    capas/ancho         lr  epocas   costo     f1  recall precision  umbral
mlp             [24, 16]         0.003     100      43  0.715   0.980     0.563   0.050
mlp             [48, 24]         0.002     100      43  0.796   0.900     0.714   0.315
mlp_residual_bn 24               0.002     100      55  0.686   0.940     0.540   0.060
mlp_residual_bn 40              0.0015     100      56  0.804   0.820     0.788   0.400
Configuración seleccionada: {'tipo': 'mlp', 'entrada': 4, 'ocultas': [48, 24]}

5. Curvas y decisión

Grafique pérdida de train/dev y al menos una métrica de desarrollo. Seleccione el modelo con su criterio declarado. Después genere la curva de costo contra umbral, fije el mejor umbral y reporte la matriz de confusión y las métricas en desarrollo.

In [6]:
from lib_modelos import buscar_umbral, metricas

historia = mejor['historia']
epocas_transcurridas = range(1, len(historia['loss_train']) + 1)

figura, ejes = plt.subplots(1, 2, figsize=(10, 3.5))
ejes[0].plot(epocas_transcurridas, historia['loss_train'], label='train')
ejes[0].plot(epocas_transcurridas, historia['loss_dev'], label='dev')
ejes[0].set(xlabel='Época', ylabel='Pérdida BCE ponderada', title='Curvas de pérdida')
ejes[0].legend()
ejes[1].plot(epocas_transcurridas, historia['accuracy_dev'], label='accuracy dev')
ejes[1].plot(epocas_transcurridas, historia['f1_dev'], label='F1 dev')
ejes[1].set(xlabel='Época', ylabel='Métrica', title='Desarrollo')
ejes[1].legend()
plt.show()

# El umbral de "mejor" ya minimiza el costo en desarrollo; recalculamos el
# recorrido completo únicamente para graficar costo vs. umbral.
probabilidades_dev = mejor['modelo'].probabilidad(x_dev)
umbral_elegido, recorrido_umbrales = buscar_umbral(y_dev, probabilidades_dev, costo_fn=5, costo_fp=1)

plt.figure(figsize=(6, 3.5))
plt.plot([f['umbral'] for f in recorrido_umbrales], [f['costo'] for f in recorrido_umbrales])
plt.axvline(umbral_elegido['umbral'], color='crimson', linestyle='--',
            label=f"umbral={umbral_elegido['umbral']:.3f}")
plt.xlabel('Umbral'); plt.ylabel('Costo esperado en desarrollo'); plt.legend()
plt.show()

metricas_dev = metricas(y_dev, probabilidades_dev, umbral_elegido['umbral'])
print('Umbral elegido:', umbral_elegido['umbral'])
print('Métricas en desarrollo (incluye matriz de confusión tp/fp/fn/tn):', metricas_dev)
Umbral elegido: 0.31499999999999995
Métricas en desarrollo (incluye matriz de confusión tp/fp/fn/tn): {'tp': 45, 'fp': 18, 'fn': 5, 'tn': 132, 'accuracy': 0.885, 'precision': 0.7142857142857143, 'recall': 0.9, 'especificidad': 0.88, 'f1': 0.7964601769911505}

Justificación de la decisión final.

Se elige la configuración con menor costo esperado en desarrollo (costo_fn=5, costo_fp=1); si dos configuraciones quedan con costo igual, se desempata por mayor F1. El umbral se fija con el mismo criterio de costo mínimo, calculado exclusivamente sobre desarrollo, nunca sobre el test privado. Las curvas de pérdida permiten revisar sobreajuste: si loss_dev empieza a subir mientras loss_train sigue bajando, convendría un checkpoint de una época anterior en vez de la última.

6. Exportación

Guarde los pesos y el estandarizador del modelo seleccionado. La inferencia debe poder reconstruir la arquitectura desde un diccionario de configuración. Guarde en una carpeta entrega que no sustituya los artefactos del instructor.

In [7]:
from lib_modelos import guardar_modelo

# Carpeta propia del estudiante: "entrega", distinta de "entrega_solucion"
# (que es la referencia del instructor y no debe sobrescribirse).
ruta_modelo_elegido = RAIZ / 'entrega' / 'modelo_elegido.npz'
guardar_modelo(
    ruta_modelo_elegido,
    mejor['modelo'],
    mejor['configuracion']['arquitectura'],
    media_entrenamiento,
    desviacion_entrenamiento,
)

# Este diccionario es el que se copia a inferencia_configurable.ipynb para
# reconstruir exactamente este modelo (arquitectura, pesos y umbral).
configuracion_inferencia = {
    'ruta_pesos': ruta_modelo_elegido,
    'arquitectura': mejor['configuracion']['arquitectura'],
    'umbral': umbral_elegido['umbral'],
}
configuracion_inferencia
Out [7]:
{'ruta_pesos': PosixPath('/home/aleleba/projects/cursos/Universidad/Procesamiento de Imagenes y Vision por Computadora/Labs/Lab2/entrega/modelo_elegido.npz'),
 'arquitectura': {'tipo': 'mlp', 'entrada': 4, 'ocultas': [48, 24]},
 'umbral': 0.31499999999999995}

Entrega

Entregue este notebook completado, su tabla de búsqueda, las curvas, las métricas en desarrollo, la justificación del manejo del desbalance y la configuración exportada. No incluya resultados del test privado.