{
  "cells": [
    {
      "cell_type": "markdown",
      "id": "nm9eJW4JtsGr",
      "metadata": {
        "id": "nm9eJW4JtsGr"
      },
      "source": [
        "# Laboratorio de clasificación tabular\n",
        "\n",
        "## Propósito\n",
        "\n",
        "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.\n",
        "\n",
        "El conjunto privado de 200 ejemplos no se entrega al estudiante. No lo use para ajustar la arquitectura, hiperparámetros, normalización ni umbral.\n",
        "\n",
        "## Reglas de calidad\n",
        "\n",
        "- Use una división estratificada fija de 800 ejemplos para entrenamiento y 200 para desarrollo.\n",
        "- Ajuste cualquier transformación, incluidas media y desviación estándar, únicamente con entrenamiento.\n",
        "- 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.\n",
        "- Compare exactamente estas familias: un MLP convencional y un MLP residual con Batch Normalization.\n",
        "- Seleccione configuración y umbral usando exclusivamente desarrollo. Declare explícitamente los costos de falso negativo y falso positivo.\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 1,
      "id": "luvf3DLZtsGs",
      "metadata": {
        "id": "luvf3DLZtsGs"
      },
      "outputs": [
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "Raíz del laboratorio: /home/aleleba/projects/cursos/Universidad/Procesamiento de Imagenes y Vision por Computadora/Labs/Lab2\n"
          ]
        }
      ],
      "source": [
        "from pathlib import Path\n",
        "import sys\n",
        "import numpy as np\n",
        "import matplotlib.pyplot as plt\n",
        "\n",
        "candidatos = [Path.cwd().resolve(), Path.cwd().resolve() / 'dist' / 'laboratorio_clasificacion', Path.cwd().resolve().parent]\n",
        "RAIZ = next((ruta for ruta in candidatos if (ruta / 'lib_modelos.py').exists()), None)\n",
        "if RAIZ is None:\n",
        "    raise FileNotFoundError('No se encontró la carpeta laboratorio_clasificacion.')\n",
        "sys.path.insert(0, str(RAIZ))\n",
        "print('Raíz del laboratorio:', RAIZ)\n"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "EH5B5dnztsGs",
      "metadata": {
        "id": "EH5B5dnztsGs"
      },
      "source": [
        "## 1. Carga, auditoría y división 800/200\n",
        "\n",
        "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.\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 2,
      "id": "4iZH4V99tsGs",
      "metadata": {
        "id": "4iZH4V99tsGs"
      },
      "outputs": [
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "800 200\n",
            "0.25 0.25\n"
          ]
        }
      ],
      "source": [
        "from lib_modelos import (\n",
        "    cargar_csv,\n",
        "    division_estratificada,\n",
        "    ajustar_estandarizador,\n",
        "    transformar,\n",
        ")\n",
        "\n",
        "# Único archivo de datos permitido durante el desarrollo.\n",
        "x, y, ids = cargar_csv(RAIZ / 'datos_publicos' / 'train_1000_desbalanceado.csv')\n",
        "\n",
        "# División estratificada fija: 800 entrenamiento / 200 desarrollo,\n",
        "# preservando la proporción 75 % clase 0 / 25 % clase 1 en ambas partes.\n",
        "SEMILLA_DIVISION = 31\n",
        "indice_train, indice_dev = division_estratificada(y, proporcion_dev=0.20, semilla=SEMILLA_DIVISION)\n",
        "\n",
        "# La media y la desviación estándar se ajustan SOLO con entrenamiento,\n",
        "# para no filtrar información de desarrollo hacia el preprocesamiento.\n",
        "media_entrenamiento, desviacion_entrenamiento = ajustar_estandarizador(x[indice_train])\n",
        "\n",
        "x_train = transformar(x[indice_train], media_entrenamiento, desviacion_entrenamiento)\n",
        "x_dev = transformar(x[indice_dev], media_entrenamiento, desviacion_entrenamiento)\n",
        "y_train, y_dev = y[indice_train], y[indice_dev]\n",
        "\n",
        "# Comprobaciones requeridas.\n",
        "print(len(y_train), len(y_dev))\n",
        "print(y_train.mean(), y_dev.mean())\n"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "PIGc4mqFtsGs",
      "metadata": {
        "id": "PIGc4mqFtsGs"
      },
      "source": [
        "## 2. Estrategia ante el desbalance\n",
        "\n",
        "Responda en esta celda:\n",
        "\n",
        "1. ¿Por qué accuracy sola no es suficiente?\n",
        "2. ¿Qué peso positivo usará y cómo se calcula?\n",
        "3. ¿Qué métrica y qué costo operacional usaría para elegir modelo y umbral?\n",
        "\n",
        "Luego calcule el peso positivo y escriba su valor.\n",
        "\n",
        "**Respuesta.**\n",
        "\n",
        "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.\n",
        "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.\n",
        "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.\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 3,
      "id": "9vNYL7qItsGs",
      "metadata": {
        "id": "9vNYL7qItsGs"
      },
      "outputs": [
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "Peso positivo: 3.0\n"
          ]
        }
      ],
      "source": [
        "# El entrenamiento tiene 75 % clase 0 / 25 % clase 1. Para que la pérdida\n",
        "# BCE ponderada no ignore la clase minoritaria, el peso positivo se calcula\n",
        "# como la razón negativos/positivos en entrenamiento (compensa 3:1).\n",
        "peso_positivo = (y_train == 0).sum() / (y_train == 1).sum()\n",
        "print('Peso positivo:', peso_positivo)\n"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "Zr2LjQa9tsGt",
      "metadata": {
        "id": "Zr2LjQa9tsGt"
      },
      "source": [
        "## 3. Dos arquitecturas\n",
        "\n",
        "La biblioteca incluye:\n",
        "\n",
        "- MLP: capas densas ReLU y salida logística.\n",
        "- MLP residual con Batch Normalization: proyección de entrada, bloque residual y salida logística.\n",
        "\n",
        "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.\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 4,
      "id": "TcI-ymgAtsGt",
      "metadata": {
        "id": "TcI-ymgAtsGt"
      },
      "outputs": [],
      "source": [
        "configuraciones = [\n",
        "    {  # MLP angosto y poco profundo: referencia base.\n",
        "        'arquitectura': {'tipo': 'mlp', 'entrada': 4, 'ocultas': [24, 16]},\n",
        "        'entrenamiento': {'epocas': 100, 'batch_size': 64, 'learning_rate': 0.003},\n",
        "        'costo_fn': 5, 'costo_fp': 1,\n",
        "    },\n",
        "    {  # MLP más ancho: más capacidad, learning rate algo menor.\n",
        "        'arquitectura': {'tipo': 'mlp', 'entrada': 4, 'ocultas': [48, 24]},\n",
        "        'entrenamiento': {'epocas': 100, 'batch_size': 64, 'learning_rate': 0.002},\n",
        "        'costo_fn': 5, 'costo_fp': 1,\n",
        "    },\n",
        "    {  # Residual + BatchNorm angosto.\n",
        "        'arquitectura': {'tipo': 'mlp_residual_bn', 'entrada': 4, 'ancho': 24},\n",
        "        'entrenamiento': {'epocas': 100, 'batch_size': 64, 'learning_rate': 0.002},\n",
        "        'costo_fn': 5, 'costo_fp': 1,\n",
        "    },\n",
        "    {  # Residual + BatchNorm más ancho, learning rate menor para más estabilidad.\n",
        "        'arquitectura': {'tipo': 'mlp_residual_bn', 'entrada': 4, 'ancho': 40},\n",
        "        'entrenamiento': {'epocas': 100, 'batch_size': 64, 'learning_rate': 0.0015},\n",
        "        'costo_fn': 5, 'costo_fp': 1,\n",
        "    },\n",
        "]\n"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "xmbqjNl5tsGt",
      "metadata": {
        "id": "xmbqjNl5tsGt"
      },
      "source": [
        "## 4. Búsqueda de hiperparámetros\n",
        "\n",
        "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.\n",
        "\n",
        "No modifique el conjunto de desarrollo después de mirar los resultados; elija una regla de selección antes de comparar.\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 5,
      "id": "eTgo4ia-tsGt",
      "metadata": {
        "id": "eTgo4ia-tsGt"
      },
      "outputs": [
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "arquitectura    capas/ancho         lr  epocas   costo     f1  recall precision  umbral\n",
            "mlp             [24, 16]         0.003     100      43  0.715   0.980     0.563   0.050\n",
            "mlp             [48, 24]         0.002     100      43  0.796   0.900     0.714   0.315\n",
            "mlp_residual_bn 24               0.002     100      55  0.686   0.940     0.540   0.060\n",
            "mlp_residual_bn 40              0.0015     100      56  0.804   0.820     0.788   0.400\n",
            "Configuración seleccionada: {'tipo': 'mlp', 'entrada': 4, 'ocultas': [48, 24]}\n"
          ]
        }
      ],
      "source": [
        "from lib_modelos import buscar_hiperparametros\n",
        "\n",
        "mejor, resultados = buscar_hiperparametros(\n",
        "    configuraciones, x_train, y_train, x_dev, y_dev, semilla=41,\n",
        ")\n",
        "\n",
        "# Tabla compacta: una fila por configuración probada, ordenadas como se declararon.\n",
        "encabezado = (\n",
        "    f\"{'arquitectura':<16}{'capas/ancho':<14}{'lr':>8}{'epocas':>8}\"\n",
        "    f\"{'costo':>8}{'f1':>7}{'recall':>8}{'precision':>10}{'umbral':>8}\"\n",
        ")\n",
        "print(encabezado)\n",
        "for fila in resultados:\n",
        "    arquitectura = fila['configuracion']['arquitectura']\n",
        "    entrenamiento = fila['configuracion']['entrenamiento']\n",
        "    capas_o_ancho = arquitectura.get('ocultas', arquitectura.get('ancho'))\n",
        "    print(\n",
        "        f\"{arquitectura['tipo']:<16}{str(capas_o_ancho):<14}\"\n",
        "        f\"{entrenamiento['learning_rate']:>8}{entrenamiento['epocas']:>8}\"\n",
        "        f\"{fila['costo']:>8}{fila['f1']:>7.3f}{fila['recall']:>8.3f}\"\n",
        "        f\"{fila['precision']:>10.3f}{fila['umbral']:>8.3f}\"\n",
        "    )\n",
        "print('Configuración seleccionada:', mejor['configuracion']['arquitectura'])\n"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "_nMKEVfHtsGt",
      "metadata": {
        "id": "_nMKEVfHtsGt"
      },
      "source": [
        "## 5. Curvas y decisión\n",
        "\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.\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 6,
      "id": "vX0KAMj4tsGt",
      "metadata": {
        "id": "vX0KAMj4tsGt"
      },
      "outputs": [
        {
          "data": {
            "image/png": "[encoded data, 69636 characters]",
            "text/plain": [
              "<Figure size 1000x350 with 2 Axes>"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        },
        {
          "data": {
            "image/png": "[encoded data, 30036 characters]",
            "text/plain": [
              "<Figure size 600x350 with 1 Axes>"
            ]
          },
          "metadata": {},
          "output_type": "display_data"
        },
        {
          "name": "stdout",
          "output_type": "stream",
          "text": [
            "Umbral elegido: 0.31499999999999995\n",
            "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}\n"
          ]
        }
      ],
      "source": [
        "from lib_modelos import buscar_umbral, metricas\n",
        "\n",
        "historia = mejor['historia']\n",
        "epocas_transcurridas = range(1, len(historia['loss_train']) + 1)\n",
        "\n",
        "figura, ejes = plt.subplots(1, 2, figsize=(10, 3.5))\n",
        "ejes[0].plot(epocas_transcurridas, historia['loss_train'], label='train')\n",
        "ejes[0].plot(epocas_transcurridas, historia['loss_dev'], label='dev')\n",
        "ejes[0].set(xlabel='Época', ylabel='Pérdida BCE ponderada', title='Curvas de pérdida')\n",
        "ejes[0].legend()\n",
        "ejes[1].plot(epocas_transcurridas, historia['accuracy_dev'], label='accuracy dev')\n",
        "ejes[1].plot(epocas_transcurridas, historia['f1_dev'], label='F1 dev')\n",
        "ejes[1].set(xlabel='Época', ylabel='Métrica', title='Desarrollo')\n",
        "ejes[1].legend()\n",
        "plt.show()\n",
        "\n",
        "# El umbral de \"mejor\" ya minimiza el costo en desarrollo; recalculamos el\n",
        "# recorrido completo únicamente para graficar costo vs. umbral.\n",
        "probabilidades_dev = mejor['modelo'].probabilidad(x_dev)\n",
        "umbral_elegido, recorrido_umbrales = buscar_umbral(y_dev, probabilidades_dev, costo_fn=5, costo_fp=1)\n",
        "\n",
        "plt.figure(figsize=(6, 3.5))\n",
        "plt.plot([f['umbral'] for f in recorrido_umbrales], [f['costo'] for f in recorrido_umbrales])\n",
        "plt.axvline(umbral_elegido['umbral'], color='crimson', linestyle='--',\n",
        "            label=f\"umbral={umbral_elegido['umbral']:.3f}\")\n",
        "plt.xlabel('Umbral'); plt.ylabel('Costo esperado en desarrollo'); plt.legend()\n",
        "plt.show()\n",
        "\n",
        "metricas_dev = metricas(y_dev, probabilidades_dev, umbral_elegido['umbral'])\n",
        "print('Umbral elegido:', umbral_elegido['umbral'])\n",
        "print('Métricas en desarrollo (incluye matriz de confusión tp/fp/fn/tn):', metricas_dev)\n"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "justificacionDecisionFinal1",
      "metadata": {
        "id": "justificacionDecisionFinal1"
      },
      "source": [
        "**Justificación de la decisión final.**\n",
        "\n",
        "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.\n"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "oSs_-a31tsGt",
      "metadata": {
        "id": "oSs_-a31tsGt"
      },
      "source": [
        "## 6. Exportación\n",
        "\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.\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 7,
      "id": "A1BlaJqNtsGt",
      "metadata": {
        "id": "A1BlaJqNtsGt"
      },
      "outputs": [
        {
          "data": {
            "text/plain": [
              "{'ruta_pesos': PosixPath('/home/aleleba/projects/cursos/Universidad/Procesamiento de Imagenes y Vision por Computadora/Labs/Lab2/entrega/modelo_elegido.npz'),\n",
              " 'arquitectura': {'tipo': 'mlp', 'entrada': 4, 'ocultas': [48, 24]},\n",
              " 'umbral': 0.31499999999999995}"
            ]
          },
          "execution_count": 7,
          "metadata": {},
          "output_type": "execute_result"
        }
      ],
      "source": [
        "from lib_modelos import guardar_modelo\n",
        "\n",
        "# Carpeta propia del estudiante: \"entrega\", distinta de \"entrega_solucion\"\n",
        "# (que es la referencia del instructor y no debe sobrescribirse).\n",
        "ruta_modelo_elegido = RAIZ / 'entrega' / 'modelo_elegido.npz'\n",
        "guardar_modelo(\n",
        "    ruta_modelo_elegido,\n",
        "    mejor['modelo'],\n",
        "    mejor['configuracion']['arquitectura'],\n",
        "    media_entrenamiento,\n",
        "    desviacion_entrenamiento,\n",
        ")\n",
        "\n",
        "# Este diccionario es el que se copia a inferencia_configurable.ipynb para\n",
        "# reconstruir exactamente este modelo (arquitectura, pesos y umbral).\n",
        "configuracion_inferencia = {\n",
        "    'ruta_pesos': ruta_modelo_elegido,\n",
        "    'arquitectura': mejor['configuracion']['arquitectura'],\n",
        "    'umbral': umbral_elegido['umbral'],\n",
        "}\n",
        "configuracion_inferencia\n"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "5Ci3fWhHtsGt",
      "metadata": {
        "id": "5Ci3fWhHtsGt"
      },
      "source": [
        "## Entrega\n",
        "\n",
        "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.\n"
      ]
    }
  ],
  "metadata": {
    "colab": {
      "provenance": []
    },
    "kernelspec": {
      "display_name": "Python 3",
      "language": "python",
      "name": "python3"
    },
    "language_info": {
      "codemirror_mode": {
        "name": "ipython",
        "version": 3
      },
      "file_extension": ".py",
      "mimetype": "text/x-python",
      "name": "python",
      "nbconvert_exporter": "python",
      "pygments_lexer": "ipython3",
      "version": "3.10.12"
    }
  },
  "nbformat": 4,
  "nbformat_minor": 5
}
