{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "VVluUyWzCFLy",
   "metadata": {
    "id": "VVluUyWzCFLy"
   },
   "source": [
    "# Laboratorio: MLPs, verosimilitud, entrenamiento y evaluación\n",
    "\n",
    "**Nombre:** Michaelle Perez  \n",
    "**Institución:** BioMedLab Galileo - deeplearning.ai  \n",
    "\n",
    "Este laboratorio acompaña la sesión sobre perceptrones multicapa. El objetivo es practicar el flujo completo:\n",
    "\n",
    "1. creación y carga de datasets;\n",
    "2. visualización;\n",
    "3. normalización y estandarización;\n",
    "4. construcción de redes MLP;\n",
    "5. entrenamiento;\n",
    "6. evaluación con métricas apropiadas;\n",
    "7. pruebas públicas y privadas.\n",
    "\n",
    "Las notas de clase proveen el desarrollo teórico completo. Este notebook se enfoca en ejemplos computacionales y tareas implementables.\n",
    "\n",
    "> Importante: en las celdas Markdown se usa `$$...$$` para bloques de LaTeX.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "F3Ru2r0xCFLz",
   "metadata": {
    "id": "F3Ru2r0xCFLz"
   },
   "source": [
    "## Identificación del estudiante\n",
    "\n",
    "Antes de ejecutar pruebas privadas, cambie los valores de `ID` y `NOMBRE`.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "id": "-S0jst8YCFLz",
   "metadata": {
    "id": "-S0jst8YCFLz"
   },
   "outputs": [],
   "source": [
    "ID = \"12002840\"\n",
    "NOMBRE = \"Alejandro Lembke Barrientos\"\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "vXNfE06SCFLz",
   "metadata": {
    "id": "vXNfE06SCFLz"
   },
   "source": [
    "## Imports\n",
    "\n",
    "Este laboratorio usa `numpy`, `pandas`, `matplotlib`, `scikit-learn` y `torch`.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "id": "ki35HkfFCFLz",
   "metadata": {
    "id": "ki35HkfFCFLz"
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "PyTorch: 2.10.0a0+b4e4ee81d3.nv25.12\n",
      "Data directory: /workspace/data\n"
     ]
    }
   ],
   "source": [
    "from pathlib import Path\n",
    "\n",
    "import numpy as np\n",
    "import pandas as pd\n",
    "import matplotlib.pyplot as plt\n",
    "\n",
    "from sklearn.datasets import make_classification, make_multilabel_classification\n",
    "from sklearn.model_selection import train_test_split, KFold\n",
    "from sklearn.preprocessing import MinMaxScaler, StandardScaler\n",
    "from sklearn.metrics import (\n",
    "    accuracy_score,\n",
    "    precision_score,\n",
    "    recall_score,\n",
    "    f1_score,\n",
    "    confusion_matrix,\n",
    "    mean_squared_error,\n",
    "    mean_absolute_error,\n",
    "    r2_score,\n",
    ")\n",
    "\n",
    "import torch\n",
    "import torch.nn as nn\n",
    "import torch.nn.functional as F\n",
    "from torch.utils.data import TensorDataset, DataLoader\n",
    "\n",
    "torch.manual_seed(42)\n",
    "np.random.seed(42)\n",
    "\n",
    "DATA_DIR = Path(\"data\")\n",
    "DATA_DIR.mkdir(exist_ok=True)\n",
    "\n",
    "print(\"PyTorch:\", torch.__version__)\n",
    "print(\"Data directory:\", DATA_DIR.resolve())\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "33ee73f5",
   "metadata": {},
   "source": [
    "## Disponibilidad de `public_tests` en el kernel\n",
    "\n",
    "Este notebook se ejecuta contra un kernel de Jupyter que corre en **otra máquina**, así que el kernel no ve los archivos locales del laboratorio. Solo trabaja con su propio directorio de trabajo.\n",
    "\n",
    "Eso no afecta a `data/`: la sección 1 regenera los cuatro datasets con semillas fijas, de modo que los CSV se crean en el directorio de trabajo del kernel.\n",
    "\n",
    "Sí afecta a `public_tests.py`, que no existe en el kernel remoto. La siguiente celda usa la magia `%%writefile` para materializarlo ahí con el mismo contenido que entregó el instructor, y la celda posterior lo importa. Si el notebook se corriera en local, simplemente reescribe el archivo con contenido idéntico.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "id": "f0a930c9",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Writing public_tests.py\n"
     ]
    }
   ],
   "source": [
    "%%writefile public_tests.py\n",
    "\"\"\"\n",
    "Public tests for the MLP lab.\n",
    "\n",
    "Usage inside the notebook:\n",
    "    import public_tests as public_tests\n",
    "    public_tests.test_tarea1(globals())\n",
    "    public_tests.test_tarea2(globals())\n",
    "    public_tests.test_tarea3(globals())\n",
    "\n",
    "These tests are intentionally lightweight. Passing public tests does not guarantee\n",
    "full credit in the private grader.\n",
    "\"\"\"\n",
    "\n",
    "from pathlib import Path\n",
    "import numpy as np\n",
    "import pandas as pd\n",
    "import torch\n",
    "import torch.nn as nn\n",
    "from torch.utils.data import TensorDataset, DataLoader\n",
    "from sklearn.preprocessing import MinMaxScaler, StandardScaler\n",
    "\n",
    "DATA_DIR = Path(\"data\")\n",
    "\n",
    "\n",
    "def _require(namespace, name):\n",
    "    assert name in namespace, f\"Missing required object: {name}\"\n",
    "    return namespace[name]\n",
    "\n",
    "\n",
    "def _preprocess_features(train_df, dev_df, feature_cols=(\"x1\", \"x2\")):\n",
    "    X_train = train_df[list(feature_cols)].to_numpy(dtype=np.float32)\n",
    "    X_dev = dev_df[list(feature_cols)].to_numpy(dtype=np.float32)\n",
    "    all_X = np.vstack([X_train, X_dev])\n",
    "    normalizer = MinMaxScaler()\n",
    "    normalizer.fit(all_X)\n",
    "    X_train_norm = normalizer.transform(X_train)\n",
    "    X_dev_norm = normalizer.transform(X_dev)\n",
    "    standardizer = StandardScaler()\n",
    "    standardizer.fit(X_train_norm)\n",
    "    X_train_std = standardizer.transform(X_train_norm).astype(np.float32)\n",
    "    X_dev_std = standardizer.transform(X_dev_norm).astype(np.float32)\n",
    "    return X_train_std, X_dev_std\n",
    "\n",
    "\n",
    "def _loader_regression(batch_size=64):\n",
    "    train_df = pd.read_csv(DATA_DIR / \"dataset_B_train.csv\")\n",
    "    dev_df = pd.read_csv(DATA_DIR / \"dataset_B_dev.csv\")\n",
    "    X_train, X_dev = _preprocess_features(train_df, dev_df)\n",
    "    y_train = train_df[\"y\"].to_numpy(dtype=np.float32).reshape(-1, 1)\n",
    "    y_dev = dev_df[\"y\"].to_numpy(dtype=np.float32).reshape(-1, 1)\n",
    "    train_ds = TensorDataset(torch.tensor(X_train), torch.tensor(y_train))\n",
    "    dev_ds = TensorDataset(torch.tensor(X_dev), torch.tensor(y_dev))\n",
    "    return DataLoader(train_ds, batch_size=batch_size, shuffle=True), DataLoader(dev_ds, batch_size=256)\n",
    "\n",
    "\n",
    "def _loader_multiclass(batch_size=64):\n",
    "    train_df = pd.read_csv(DATA_DIR / \"dataset_C_train.csv\")\n",
    "    dev_df = pd.read_csv(DATA_DIR / \"dataset_C_dev.csv\")\n",
    "    X_train, X_dev = _preprocess_features(train_df, dev_df)\n",
    "    y_train = train_df[\"y\"].to_numpy(dtype=np.int64)\n",
    "    y_dev = dev_df[\"y\"].to_numpy(dtype=np.int64)\n",
    "    train_ds = TensorDataset(torch.tensor(X_train), torch.tensor(y_train))\n",
    "    dev_ds = TensorDataset(torch.tensor(X_dev), torch.tensor(y_dev))\n",
    "    return DataLoader(train_ds, batch_size=batch_size, shuffle=True), DataLoader(dev_ds, batch_size=256)\n",
    "\n",
    "\n",
    "def _loader_multilabel(batch_size=64):\n",
    "    train_df = pd.read_csv(DATA_DIR / \"dataset_D_train.csv\")\n",
    "    dev_df = pd.read_csv(DATA_DIR / \"dataset_D_dev.csv\")\n",
    "    X_train, X_dev = _preprocess_features(train_df, dev_df)\n",
    "    y_cols = [\"y0\", \"y1\", \"y2\", \"y3\"]\n",
    "    y_train = train_df[y_cols].to_numpy(dtype=np.float32)\n",
    "    y_dev = dev_df[y_cols].to_numpy(dtype=np.float32)\n",
    "    train_ds = TensorDataset(torch.tensor(X_train), torch.tensor(y_train))\n",
    "    dev_ds = TensorDataset(torch.tensor(X_dev), torch.tensor(y_dev))\n",
    "    return DataLoader(train_ds, batch_size=batch_size, shuffle=True), DataLoader(dev_ds, batch_size=256)\n",
    "\n",
    "\n",
    "def _count_linears(model):\n",
    "    return [m for m in model.modules() if isinstance(m, nn.Linear)]\n",
    "\n",
    "\n",
    "def _has_relu(model):\n",
    "    return any(isinstance(m, nn.ReLU) for m in model.modules())\n",
    "\n",
    "\n",
    "def _assert_three_layer_mlp(model, output_dim):\n",
    "    linears = _count_linears(model)\n",
    "    assert len(linears) == 3, \"The model must contain exactly 3 Linear layers.\"\n",
    "    assert linears[0].in_features == 2, \"The first Linear layer must receive 2 input features.\"\n",
    "    assert linears[0].out_features == 8, \"The first hidden layer must have 8 units.\"\n",
    "    assert linears[1].in_features == 8 and linears[1].out_features == 8, \"The second hidden layer must be 8 -> 8.\"\n",
    "    assert linears[2].in_features == 8 and linears[2].out_features == output_dim, f\"The output layer must have {output_dim} units.\"\n",
    "    assert _has_relu(model), \"The model should include ReLU activations.\"\n",
    "\n",
    "\n",
    "def test_tarea1(namespace):\n",
    "    torch.manual_seed(123)\n",
    "    RegressionMLP = _require(namespace, \"RegressionMLP\")\n",
    "    train_fn = _require(namespace, \"train_regression_model\")\n",
    "    eval_fn = _require(namespace, \"evaluate_regression_model\")\n",
    "    model = RegressionMLP()\n",
    "    assert isinstance(model, nn.Module), \"RegressionMLP must be a torch.nn.Module.\"\n",
    "    _assert_three_layer_mlp(model, output_dim=1)\n",
    "    x = torch.randn(5, 2)\n",
    "    out = model(x)\n",
    "    assert tuple(out.shape) == (5, 1), \"RegressionMLP forward output must have shape (batch, 1).\"\n",
    "    train_loader, dev_loader = _loader_regression()\n",
    "    _ = train_fn(model, train_loader, epochs=5, lr=1e-2)\n",
    "    metrics = eval_fn(model, dev_loader)\n",
    "    assert isinstance(metrics, dict), \"evaluate_regression_model must return a dictionary.\"\n",
    "    for key in [\"mse\", \"rmse\", \"mae\", \"r2\"]:\n",
    "        assert key in metrics, f\"Missing regression metric: {key}\"\n",
    "        assert np.isfinite(metrics[key]), f\"Metric {key} must be finite.\"\n",
    "    print(\"Tarea 1 public tests passed.\")\n",
    "\n",
    "\n",
    "def test_tarea2(namespace):\n",
    "    torch.manual_seed(123)\n",
    "    MulticlassMLP = _require(namespace, \"MulticlassMLP\")\n",
    "    train_fn = _require(namespace, \"train_multiclass_model\")\n",
    "    eval_fn = _require(namespace, \"evaluate_multiclass_model\")\n",
    "    model = MulticlassMLP()\n",
    "    assert isinstance(model, nn.Module), \"MulticlassMLP must be a torch.nn.Module.\"\n",
    "    _assert_three_layer_mlp(model, output_dim=4)\n",
    "    x = torch.randn(5, 2)\n",
    "    out = model(x)\n",
    "    assert tuple(out.shape) == (5, 4), \"MulticlassMLP forward output must have shape (batch, 4).\"\n",
    "    train_loader, dev_loader = _loader_multiclass()\n",
    "    _ = train_fn(model, train_loader, epochs=5, lr=1e-2)\n",
    "    metrics = eval_fn(model, dev_loader)\n",
    "    assert isinstance(metrics, dict), \"evaluate_multiclass_model must return a dictionary.\"\n",
    "    for key in [\"accuracy\", \"macro_f1\", \"confusion_matrix\"]:\n",
    "        assert key in metrics, f\"Missing multiclass metric: {key}\"\n",
    "    assert np.isfinite(metrics[\"accuracy\"]), \"accuracy must be finite.\"\n",
    "    assert np.isfinite(metrics[\"macro_f1\"]), \"macro_f1 must be finite.\"\n",
    "    print(\"Tarea 2 public tests passed.\")\n",
    "\n",
    "\n",
    "def test_tarea3(namespace):\n",
    "    torch.manual_seed(123)\n",
    "    MultilabelMLP = _require(namespace, \"MultilabelMLP\")\n",
    "    train_fn = _require(namespace, \"train_multilabel_model\")\n",
    "    eval_fn = _require(namespace, \"evaluate_multilabel_model\")\n",
    "    model = MultilabelMLP()\n",
    "    assert isinstance(model, nn.Module), \"MultilabelMLP must be a torch.nn.Module.\"\n",
    "    _assert_three_layer_mlp(model, output_dim=4)\n",
    "    x = torch.randn(5, 2)\n",
    "    out = model(x)\n",
    "    assert tuple(out.shape) == (5, 4), \"MultilabelMLP forward output must have shape (batch, 4).\"\n",
    "    train_loader, dev_loader = _loader_multilabel()\n",
    "    _ = train_fn(model, train_loader, epochs=5, lr=1e-2)\n",
    "    metrics = eval_fn(model, dev_loader)\n",
    "    assert isinstance(metrics, dict), \"evaluate_multilabel_model must return a dictionary.\"\n",
    "    for key in [\"subset_accuracy\", \"micro_f1\", \"macro_f1\"]:\n",
    "        assert key in metrics, f\"Missing multilabel metric: {key}\"\n",
    "        assert np.isfinite(metrics[key]), f\"Metric {key} must be finite.\"\n",
    "    print(\"Tarea 3 public tests passed.\")\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "id": "33d0908e",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "public_tests listo en: /workspace/public_tests.py\n"
     ]
    }
   ],
   "source": [
    "import importlib\n",
    "\n",
    "import public_tests\n",
    "importlib.reload(public_tests)\n",
    "\n",
    "print(\"public_tests listo en:\", Path(\"public_tests.py\").resolve())\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "EX1hFdMuCFL0",
   "metadata": {
    "id": "EX1hFdMuCFL0"
   },
   "source": [
    "# 1. Creación de datasets\n",
    "\n",
    "Se crearán cuatro datasets con 1,000 ejemplos para entrenamiento y 200 para desarrollo.\n",
    "\n",
    "| Dataset | Tipo | Entradas | Salida |\n",
    "|---|---|---:|---|\n",
    "| A | Clasificación binaria | 2 features | 1 etiqueta binaria |\n",
    "| B | Regresión no lineal de orden 5 | 2 features | 1 valor continuo |\n",
    "| C | Clasificación multiclase | 2 features | 4 clases |\n",
    "| D | Clasificación multilabel | 2 features | 4 etiquetas binarias |\n",
    "\n",
    "Los datasets se guardan como CSV para luego cargarlos desde archivo, simulando un flujo real de trabajo.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "id": "WLJPSxh2CFL0",
   "metadata": {
    "id": "WLJPSxh2CFL0"
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "data/dataset_A_dev.csv (200, 3)\n",
      "data/dataset_A_train.csv (1000, 3)\n",
      "data/dataset_B_dev.csv (200, 3)\n",
      "data/dataset_B_train.csv (1000, 3)\n",
      "data/dataset_C_dev.csv (200, 3)\n",
      "data/dataset_C_train.csv (1000, 3)\n",
      "data/dataset_D_dev.csv (200, 6)\n",
      "data/dataset_D_train.csv (1000, 6)\n"
     ]
    }
   ],
   "source": [
    "rng = np.random.default_rng(42)\n",
    "\n",
    "# Dataset A: clasificación binaria, 2 features\n",
    "XA, yA = make_classification(\n",
    "    n_samples=1200,\n",
    "    n_features=2,\n",
    "    n_informative=2,\n",
    "    n_redundant=0,\n",
    "    n_repeated=0,\n",
    "    n_classes=2,\n",
    "    n_clusters_per_class=1,\n",
    "    class_sep=1.35,\n",
    "    flip_y=0.03,\n",
    "    random_state=11,\n",
    ")\n",
    "\n",
    "XA_train, XA_dev, yA_train, yA_dev = train_test_split(\n",
    "    XA, yA,\n",
    "    train_size=1000,\n",
    "    test_size=200,\n",
    "    stratify=yA,\n",
    "    random_state=101,\n",
    ")\n",
    "\n",
    "# Dataset B: regresión no lineal de orden 5, 2 features\n",
    "XB = rng.uniform(-1.6, 1.6, size=(1200, 2))\n",
    "x1, x2 = XB[:, 0], XB[:, 1]\n",
    "noise = rng.normal(0, 0.35, size=1200)\n",
    "\n",
    "yB = (\n",
    "    0.55 * x1**5\n",
    "    - 0.45 * x2**5\n",
    "    + 0.80 * x1**3\n",
    "    - 0.65 * x2**2\n",
    "    + 0.50 * x1**2 * x2\n",
    "    - 0.35 * x1 * x2**3\n",
    "    + 0.25 * x1\n",
    "    + noise\n",
    ")\n",
    "\n",
    "XB_train, XB_dev, yB_train, yB_dev = train_test_split(\n",
    "    XB, yB,\n",
    "    train_size=1000,\n",
    "    test_size=200,\n",
    "    random_state=202,\n",
    ")\n",
    "\n",
    "# Dataset C: clasificación multiclase con 4 clases, 2 features\n",
    "XC, yC = make_classification(\n",
    "    n_samples=1200,\n",
    "    n_features=2,\n",
    "    n_informative=2,\n",
    "    n_redundant=0,\n",
    "    n_repeated=0,\n",
    "    n_classes=4,\n",
    "    n_clusters_per_class=1,\n",
    "    class_sep=1.45,\n",
    "    flip_y=0.04,\n",
    "    random_state=33,\n",
    ")\n",
    "\n",
    "XC_train, XC_dev, yC_train, yC_dev = train_test_split(\n",
    "    XC, yC,\n",
    "    train_size=1000,\n",
    "    test_size=200,\n",
    "    stratify=yC,\n",
    "    random_state=303,\n",
    ")\n",
    "\n",
    "# Dataset D: clasificación multilabel con 4 clases, 2 features\n",
    "XD, yD = make_multilabel_classification(\n",
    "    n_samples=1200,\n",
    "    n_features=2,\n",
    "    n_classes=4,\n",
    "    n_labels=2,\n",
    "    allow_unlabeled=False,\n",
    "    random_state=44,\n",
    ")\n",
    "\n",
    "XD = XD.astype(np.float64) + rng.normal(0, 0.05, size=XD.shape)\n",
    "strat_D = yD.sum(axis=1)\n",
    "\n",
    "XD_train, XD_dev, yD_train, yD_dev = train_test_split(\n",
    "    XD, yD,\n",
    "    train_size=1000,\n",
    "    test_size=200,\n",
    "    stratify=strat_D,\n",
    "    random_state=404,\n",
    ")\n",
    "\n",
    "def save_csv(prefix, X_train, X_dev, y_train, y_dev, multilabel=False):\n",
    "    if multilabel:\n",
    "        train_df = pd.DataFrame(X_train, columns=[\"x1\", \"x2\"])\n",
    "        dev_df = pd.DataFrame(X_dev, columns=[\"x1\", \"x2\"])\n",
    "        for k in range(y_train.shape[1]):\n",
    "            train_df[f\"y{k}\"] = y_train[:, k].astype(int)\n",
    "            dev_df[f\"y{k}\"] = y_dev[:, k].astype(int)\n",
    "    else:\n",
    "        train_df = pd.DataFrame(X_train, columns=[\"x1\", \"x2\"])\n",
    "        dev_df = pd.DataFrame(X_dev, columns=[\"x1\", \"x2\"])\n",
    "        train_df[\"y\"] = y_train\n",
    "        dev_df[\"y\"] = y_dev\n",
    "\n",
    "    train_df.to_csv(DATA_DIR / f\"dataset_{prefix}_train.csv\", index=False)\n",
    "    dev_df.to_csv(DATA_DIR / f\"dataset_{prefix}_dev.csv\", index=False)\n",
    "\n",
    "save_csv(\"A\", XA_train, XA_dev, yA_train, yA_dev)\n",
    "save_csv(\"B\", XB_train, XB_dev, yB_train, yB_dev)\n",
    "save_csv(\"C\", XC_train, XC_dev, yC_train, yC_dev)\n",
    "save_csv(\"D\", XD_train, XD_dev, yD_train, yD_dev, multilabel=True)\n",
    "\n",
    "for path in sorted(DATA_DIR.glob(\"*.csv\")):\n",
    "    print(path, pd.read_csv(path).shape)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "ya7JlUdwCFL0",
   "metadata": {
    "id": "ya7JlUdwCFL0"
   },
   "source": [
    "# 2. Verosimilitud, máxima verosimilitud y KL divergence\n",
    "\n",
    "La verosimilitud interpreta la probabilidad de los datos observados como una función de los parámetros.\n",
    "\n",
    "Si tenemos datos:\n",
    "\n",
    "$$\n",
    "\\mathcal{D}=\\{x_i\\}_{i=1}^{N}\n",
    "$$\n",
    "\n",
    "y un modelo paramétrico:\n",
    "\n",
    "$$\n",
    "p(x;\\theta)\n",
    "$$\n",
    "\n",
    "la verosimilitud es:\n",
    "\n",
    "$$\n",
    "L(\\theta)=p(\\mathcal{D};\\theta)\n",
    "$$\n",
    "\n",
    "Si los datos son independientes:\n",
    "\n",
    "$$\n",
    "L(\\theta)=\\prod_{i=1}^{N}p(x_i;\\theta)\n",
    "$$\n",
    "\n",
    "La log-verosimilitud es:\n",
    "\n",
    "$$\n",
    "\\ell(\\theta)=\\sum_{i=1}^{N}\\log p(x_i;\\theta)\n",
    "$$\n",
    "\n",
    "El estimador de máxima verosimilitud busca:\n",
    "\n",
    "$$\n",
    "\\hat{\\theta}_{MLE}=\\arg\\max_{\\theta}\\ell(\\theta)\n",
    "$$\n",
    "\n",
    "En el ejemplo siguiente se genera una muestra desde una distribución Bernoulli con parámetro verdadero \\(p^\\star\\). Luego se evalúan varios valores candidatos de \\(p\\). La log-verosimilitud se maximiza cerca del parámetro empírico de la muestra. La divergencia KL entre la distribución empírica y la distribución candidata se minimiza en el mismo punto.\n",
    "\n",
    "La KL divergence entre dos Bernoulli \\(q\\) y \\(p\\) es:\n",
    "\n",
    "$$\n",
    "D_{KL}(q||p)=q\\log\\frac{q}{p}+(1-q)\\log\\frac{1-q}{1-p}\n",
    "$$\n",
    "\n",
    "Importante: la KL no se maximiza en los parámetros verdaderos. La KL se minimiza cuando ambas distribuciones coinciden.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "id": "qZQ2TeIICFL0",
   "metadata": {
    "id": "qZQ2TeIICFL0"
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "p verdadero: 0.72\n",
      "p empírico: 0.722\n",
      "p que maximiza log-verosimilitud en la grilla: 0.724070351758794\n",
      "p que minimiza KL en la grilla: 0.724070351758794\n"
     ]
    },
    {
     "data": {
      "image/png": "[encoded data, 42024 characters]",
      "text/plain": [
       "<Figure size 700x400 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "data": {
      "image/png": "[encoded data, 44124 characters]",
      "text/plain": [
       "<Figure size 700x400 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "rng = np.random.default_rng(123)\n",
    "\n",
    "p_true = 0.72\n",
    "n = 1000\n",
    "sample = rng.binomial(n=1, p=p_true, size=n)\n",
    "\n",
    "p_empirical = sample.mean()\n",
    "grid = np.linspace(0.01, 0.99, 200)\n",
    "\n",
    "def bernoulli_log_likelihood(sample, p):\n",
    "    return np.sum(sample * np.log(p) + (1 - sample) * np.log(1 - p))\n",
    "\n",
    "def kl_bernoulli(q, p):\n",
    "    return q * np.log(q / p) + (1 - q) * np.log((1 - q) / (1 - p))\n",
    "\n",
    "log_likelihoods = np.array([bernoulli_log_likelihood(sample, p) for p in grid])\n",
    "kl_values = np.array([kl_bernoulli(p_empirical, p) for p in grid])\n",
    "\n",
    "p_mle_grid = grid[np.argmax(log_likelihoods)]\n",
    "p_kl_min_grid = grid[np.argmin(kl_values)]\n",
    "\n",
    "print(\"p verdadero:\", p_true)\n",
    "print(\"p empírico:\", p_empirical)\n",
    "print(\"p que maximiza log-verosimilitud en la grilla:\", p_mle_grid)\n",
    "print(\"p que minimiza KL en la grilla:\", p_kl_min_grid)\n",
    "\n",
    "plt.figure(figsize=(7, 4))\n",
    "plt.plot(grid, log_likelihoods)\n",
    "plt.axvline(p_true, linestyle=\"--\", label=\"p verdadero\")\n",
    "plt.axvline(p_empirical, linestyle=\":\", label=\"p empírico\")\n",
    "plt.title(\"Log-verosimilitud Bernoulli\")\n",
    "plt.xlabel(\"p candidato\")\n",
    "plt.ylabel(\"log L(p)\")\n",
    "plt.legend()\n",
    "plt.grid(True)\n",
    "plt.show()\n",
    "\n",
    "plt.figure(figsize=(7, 4))\n",
    "plt.plot(grid, kl_values)\n",
    "plt.axvline(p_true, linestyle=\"--\", label=\"p verdadero\")\n",
    "plt.axvline(p_empirical, linestyle=\":\", label=\"p empírico\")\n",
    "plt.title(\"KL entre distribución empírica y Bernoulli candidata\")\n",
    "plt.xlabel(\"p candidato\")\n",
    "plt.ylabel(\"D_KL(q || p)\")\n",
    "plt.legend()\n",
    "plt.grid(True)\n",
    "plt.show()\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "iF4AWjZpCFL0",
   "metadata": {
    "id": "iF4AWjZpCFL0"
   },
   "source": [
    "# 3. Funciones auxiliares de preprocesamiento\n",
    "\n",
    "En este laboratorio se pide practicar dos transformaciones:\n",
    "\n",
    "1. **Normalización min-max sobre todo el dataset**: se ajusta con train + dev.\n",
    "2. **Estandarización sobre train normalizado**: la media y desviación se ajustan solo sobre train normalizado y luego se aplican a dev.\n",
    "\n",
    "La normalización min-max es:\n",
    "\n",
    "$$\n",
    "x'=\\frac{x-x_{min}}{x_{max}-x_{min}}\n",
    "$$\n",
    "\n",
    "La estandarización es:\n",
    "\n",
    "$$\n",
    "z=\\frac{x'-\\mu_{train}}{\\sigma_{train}}\n",
    "$$\n",
    "\n",
    "En proyectos reales, usualmente se evita ajustar cualquier transformación con dev o test. Aquí se pide explícitamente normalizar sobre todo el dataset para fines didácticos y luego estandarizar usando solo train.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 9,
   "id": "aaKMn64hCFL0",
   "metadata": {
    "id": "aaKMn64hCFL0"
   },
   "outputs": [],
   "source": [
    "def normalize_all_then_standardize_train(train_df, dev_df, feature_cols=(\"x1\", \"x2\")):\n",
    "    X_train = train_df[list(feature_cols)].to_numpy(dtype=np.float32)\n",
    "    X_dev = dev_df[list(feature_cols)].to_numpy(dtype=np.float32)\n",
    "\n",
    "    all_X = np.vstack([X_train, X_dev])\n",
    "    normalizer = MinMaxScaler()\n",
    "    normalizer.fit(all_X)\n",
    "\n",
    "    X_train_norm = normalizer.transform(X_train)\n",
    "    X_dev_norm = normalizer.transform(X_dev)\n",
    "\n",
    "    standardizer = StandardScaler()\n",
    "    standardizer.fit(X_train_norm)\n",
    "\n",
    "    X_train_std = standardizer.transform(X_train_norm).astype(np.float32)\n",
    "    X_dev_std = standardizer.transform(X_dev_norm).astype(np.float32)\n",
    "\n",
    "    return X_train_std, X_dev_std, normalizer, standardizer\n",
    "\n",
    "\n",
    "def make_loader(X, y, batch_size=64, shuffle=True):\n",
    "    X_tensor = torch.tensor(X, dtype=torch.float32)\n",
    "\n",
    "    if y.dtype.kind in {\"i\", \"u\"}:\n",
    "        y_tensor = torch.tensor(y, dtype=torch.long)\n",
    "    else:\n",
    "        y_tensor = torch.tensor(y, dtype=torch.float32)\n",
    "\n",
    "    ds = TensorDataset(X_tensor, y_tensor)\n",
    "    return DataLoader(ds, batch_size=batch_size, shuffle=shuffle)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "yVkFgG64CFL0",
   "metadata": {
    "id": "yVkFgG64CFL0"
   },
   "source": [
    "# 4. Ejemplo completo: Dataset A, clasificación binaria\n",
    "\n",
    "En este ejemplo se implementa el flujo completo para clasificación binaria:\n",
    "\n",
    "1. visualizar el dataset;\n",
    "2. preprocesar las entradas;\n",
    "3. crear una red neuronal de 3 capas lineales: \\(2 \\rightarrow 8 \\rightarrow 8 \\rightarrow 1\\);\n",
    "4. entrenar la red;\n",
    "5. medir métricas apropiadas en dev.\n",
    "\n",
    "Para clasificación binaria usamos un logit de salida y `BCEWithLogitsLoss`.\n",
    "\n",
    "La probabilidad se obtiene después con:\n",
    "\n",
    "$$\n",
    "\\hat{p}=\\sigma(z)\n",
    "$$\n",
    "\n",
    "donde \\(z\\) es el logit.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 10,
   "id": "Naq5i_fDCFL0",
   "metadata": {
    "id": "Naq5i_fDCFL0"
   },
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 77020 characters]",
      "text/plain": [
       "<Figure size 600x500 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "A_train = pd.read_csv(DATA_DIR / \"dataset_A_train.csv\")\n",
    "A_dev = pd.read_csv(DATA_DIR / \"dataset_A_dev.csv\")\n",
    "\n",
    "plt.figure(figsize=(6, 5))\n",
    "plt.scatter(A_train[\"x1\"], A_train[\"x2\"], c=A_train[\"y\"], s=16, alpha=0.75)\n",
    "plt.title(\"Dataset A: clasificación binaria\")\n",
    "plt.xlabel(\"x1\")\n",
    "plt.ylabel(\"x2\")\n",
    "plt.grid(True)\n",
    "plt.show()\n",
    "\n",
    "X_A_train, X_A_dev, norm_A, std_A = normalize_all_then_standardize_train(A_train, A_dev)\n",
    "y_A_train = A_train[\"y\"].to_numpy(dtype=np.float32).reshape(-1, 1)\n",
    "y_A_dev = A_dev[\"y\"].to_numpy(dtype=np.float32).reshape(-1, 1)\n",
    "\n",
    "train_A_loader = DataLoader(\n",
    "    TensorDataset(torch.tensor(X_A_train), torch.tensor(y_A_train)),\n",
    "    batch_size=64,\n",
    "    shuffle=True,\n",
    ")\n",
    "dev_A_loader = DataLoader(\n",
    "    TensorDataset(torch.tensor(X_A_dev), torch.tensor(y_A_dev)),\n",
    "    batch_size=256,\n",
    "    shuffle=False,\n",
    ")\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 11,
   "id": "2BKTHnbuCFL1",
   "metadata": {
    "id": "2BKTHnbuCFL1"
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "{'accuracy': 0.98, 'precision': 0.9702970297029703, 'recall': 0.98989898989899, 'f1': 0.98, 'confusion_matrix': array([[98,  3],\n",
      "       [ 1, 98]])}\n"
     ]
    },
    {
     "data": {
      "image/png": "[encoded data, 31044 characters]",
      "text/plain": [
       "<Figure size 600x400 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "class BinaryMLP(nn.Module):\n",
    "    def __init__(self, input_dim=2, hidden_dim=8, output_dim=1):\n",
    "        super().__init__()\n",
    "        self.net = nn.Sequential(\n",
    "            nn.Linear(input_dim, hidden_dim),\n",
    "            nn.ReLU(),\n",
    "            nn.Linear(hidden_dim, hidden_dim),\n",
    "            nn.ReLU(),\n",
    "            nn.Linear(hidden_dim, output_dim),\n",
    "        )\n",
    "\n",
    "    def forward(self, x):\n",
    "        return self.net(x)\n",
    "\n",
    "\n",
    "def train_binary_model(model, train_loader, epochs=100, lr=1e-2):\n",
    "    criterion = nn.BCEWithLogitsLoss()\n",
    "    optimizer = torch.optim.Adam(model.parameters(), lr=lr)\n",
    "    history = []\n",
    "\n",
    "    for epoch in range(epochs):\n",
    "        model.train()\n",
    "        total_loss = 0.0\n",
    "        total_n = 0\n",
    "\n",
    "        for xb, yb in train_loader:\n",
    "            logits = model(xb)\n",
    "            loss = criterion(logits, yb)\n",
    "\n",
    "            optimizer.zero_grad()\n",
    "            loss.backward()\n",
    "            optimizer.step()\n",
    "\n",
    "            total_loss += loss.item() * xb.size(0)\n",
    "            total_n += xb.size(0)\n",
    "\n",
    "        history.append(total_loss / total_n)\n",
    "\n",
    "    return history\n",
    "\n",
    "\n",
    "def evaluate_binary_model(model, dev_loader, threshold=0.5):\n",
    "    model.eval()\n",
    "    y_true = []\n",
    "    y_pred = []\n",
    "\n",
    "    with torch.no_grad():\n",
    "        for xb, yb in dev_loader:\n",
    "            logits = model(xb)\n",
    "            probs = torch.sigmoid(logits)\n",
    "            preds = (probs >= threshold).int()\n",
    "\n",
    "            y_true.append(yb.cpu().numpy())\n",
    "            y_pred.append(preds.cpu().numpy())\n",
    "\n",
    "    y_true = np.vstack(y_true).reshape(-1)\n",
    "    y_pred = np.vstack(y_pred).reshape(-1)\n",
    "\n",
    "    return {\n",
    "        \"accuracy\": accuracy_score(y_true, y_pred),\n",
    "        \"precision\": precision_score(y_true, y_pred, zero_division=0),\n",
    "        \"recall\": recall_score(y_true, y_pred, zero_division=0),\n",
    "        \"f1\": f1_score(y_true, y_pred, zero_division=0),\n",
    "        \"confusion_matrix\": confusion_matrix(y_true, y_pred),\n",
    "    }\n",
    "\n",
    "\n",
    "binary_model = BinaryMLP()\n",
    "history_A = train_binary_model(binary_model, train_A_loader, epochs=100, lr=1e-2)\n",
    "metrics_A = evaluate_binary_model(binary_model, dev_A_loader)\n",
    "\n",
    "print(metrics_A)\n",
    "\n",
    "plt.figure(figsize=(6, 4))\n",
    "plt.plot(history_A)\n",
    "plt.title(\"Dataset A: pérdida de entrenamiento\")\n",
    "plt.xlabel(\"epoch\")\n",
    "plt.ylabel(\"BCEWithLogitsLoss\")\n",
    "plt.grid(True)\n",
    "plt.show()\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "Xkv_9YLTCFL1",
   "metadata": {
    "id": "Xkv_9YLTCFL1"
   },
   "source": [
    "# 5. Pruebas públicas y privadas\n",
    "\n",
    "Las pruebas públicas se ejecutan dentro del notebook para dar retroalimentación al estudiante.\n",
    "\n",
    "La calificación final se distribuye así:\n",
    "\n",
    "| Tarea | Dataset | Peso |\n",
    "|---|---|---:|\n",
    "| Tarea 1 | Dataset B, regresión | 30% |\n",
    "| Tarea 2 | Dataset C, multiclase | 30% |\n",
    "| Tarea 3 | Dataset D, multilabel | 40% |\n",
    "\n",
    "Dentro de cada tarea:\n",
    "\n",
    "| Subtarea | Peso interno |\n",
    "|---|---:|\n",
    "| Modelo MLP | 40% |\n",
    "| Función de entrenamiento | 30% |\n",
    "| Función de evaluación | 30% |\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "25jL4cTRCFL1",
   "metadata": {
    "id": "25jL4cTRCFL1"
   },
   "source": [
    "# Tarea 1 — Dataset B: regresión no lineal\n",
    "\n",
    "Esta tarea utiliza el dataset B.\n",
    "\n",
    "## Actividades no calificadas\n",
    "\n",
    "1. Visualizar el dataset B.\n",
    "2. Normalizar las entradas sobre todo el dataset.\n",
    "3. Estandarizar las entradas usando las estadísticas de train normalizado.\n",
    "\n",
    "## Actividades calificadas\n",
    "\n",
    "Debe implementar:\n",
    "\n",
    "1. `RegressionMLP`: red MLP para regresión con arquitectura \\(2 \\rightarrow 8 \\rightarrow 8 \\rightarrow 1\\), ReLU en las capas ocultas y salida lineal.\n",
    "2. `train_regression_model`: función que entrena la red durante 100 epochs por defecto.\n",
    "3. `evaluate_regression_model`: función que retorna métricas apropiadas.\n",
    "\n",
    "Métricas esperadas:\n",
    "\n",
    "- `mse`;\n",
    "- `rmse`;\n",
    "- `mae`;\n",
    "- `r2`.\n",
    "\n",
    "Para regresión con error normal, MSE corresponde a la log-verosimilitud negativa, salvo constantes y escala:\n",
    "\n",
    "$$\n",
    "\\mathcal{L}_{MSE}\n",
    "=\n",
    "\\frac{1}{N}\n",
    "\\sum_{i=1}^{N}\n",
    "(y_i-\\hat{y}_i)^2\n",
    "$$\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 12,
   "id": "Vxb9qmH7CFL1",
   "metadata": {
    "id": "Vxb9qmH7CFL1"
   },
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 223600 characters]",
      "text/plain": [
       "<Figure size 600x500 with 2 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "X_B_train: (1000, 2)\n",
      "y_B_train: (1000, 1)\n"
     ]
    }
   ],
   "source": [
    "B_train = pd.read_csv(DATA_DIR / \"dataset_B_train.csv\")\n",
    "B_dev = pd.read_csv(DATA_DIR / \"dataset_B_dev.csv\")\n",
    "\n",
    "# Non-graded: visualización\n",
    "plt.figure(figsize=(6, 5))\n",
    "plt.scatter(B_train[\"x1\"], B_train[\"x2\"], c=B_train[\"y\"], s=16, alpha=0.75)\n",
    "plt.title(\"Dataset B: regresión no lineal\")\n",
    "plt.xlabel(\"x1\")\n",
    "plt.ylabel(\"x2\")\n",
    "plt.colorbar(label=\"y\")\n",
    "plt.grid(True)\n",
    "plt.show()\n",
    "\n",
    "# Non-graded: normalización y estandarización\n",
    "X_B_train, X_B_dev, norm_B, std_B = normalize_all_then_standardize_train(B_train, B_dev)\n",
    "y_B_train = B_train[\"y\"].to_numpy(dtype=np.float32).reshape(-1, 1)\n",
    "y_B_dev = B_dev[\"y\"].to_numpy(dtype=np.float32).reshape(-1, 1)\n",
    "\n",
    "train_B_loader = DataLoader(\n",
    "    TensorDataset(torch.tensor(X_B_train), torch.tensor(y_B_train)),\n",
    "    batch_size=64,\n",
    "    shuffle=True,\n",
    ")\n",
    "dev_B_loader = DataLoader(\n",
    "    TensorDataset(torch.tensor(X_B_dev), torch.tensor(y_B_dev)),\n",
    "    batch_size=256,\n",
    "    shuffle=False,\n",
    ")\n",
    "\n",
    "print(\"X_B_train:\", X_B_train.shape)\n",
    "print(\"y_B_train:\", y_B_train.shape)\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 13,
   "id": "8cclzgJpCFL1",
   "metadata": {
    "id": "8cclzgJpCFL1"
   },
   "outputs": [],
   "source": [
    "# GRADED: Tarea 1\n",
    "\n",
    "class RegressionMLP(nn.Module):\n",
    "    def __init__(self, input_dim=2, hidden_dim=8, output_dim=1):\n",
    "        super().__init__()\n",
    "\n",
    "        # START CODE HERE\n",
    "        # Arquitectura 2 -> 8 -> 8 -> 1.\n",
    "        # Las activaciones se registran como módulos nn.ReLU (no F.relu) para que\n",
    "        # aparezcan en model.modules(). La capa de salida queda lineal: en regresión\n",
    "        # el modelo predice directamente la media condicional de y.\n",
    "        self.net = nn.Sequential(\n",
    "            nn.Linear(input_dim, hidden_dim),\n",
    "            nn.ReLU(),\n",
    "            nn.Linear(hidden_dim, hidden_dim),\n",
    "            nn.ReLU(),\n",
    "            nn.Linear(hidden_dim, output_dim),\n",
    "        )\n",
    "        # END CODE HERE\n",
    "\n",
    "    def forward(self, x):\n",
    "        # START CODE HERE\n",
    "        return self.net(x)\n",
    "        # END CODE HERE\n",
    "\n",
    "\n",
    "def train_regression_model(model, train_loader, epochs=100, lr=1e-2):\n",
    "    \"\"\"\n",
    "    Entrena un modelo de regresión usando MSELoss.\n",
    "\n",
    "    Debe retornar una lista con la pérdida promedio por epoch.\n",
    "    \"\"\"\n",
    "    # START CODE HERE\n",
    "    # MSE es la log-verosimilitud negativa bajo error gaussiano homocedástico,\n",
    "    # salvo constantes y escala.\n",
    "    criterion = nn.MSELoss()\n",
    "    optimizer = torch.optim.Adam(model.parameters(), lr=lr)\n",
    "    history = []\n",
    "\n",
    "    for epoch in range(epochs):\n",
    "        model.train()\n",
    "        total_loss = 0.0\n",
    "        total_n = 0\n",
    "\n",
    "        for xb, yb in train_loader:\n",
    "            preds = model(xb)\n",
    "            loss = criterion(preds, yb)\n",
    "\n",
    "            optimizer.zero_grad()\n",
    "            loss.backward()\n",
    "            optimizer.step()\n",
    "\n",
    "            # Se pondera por el tamaño del batch para que el último batch,\n",
    "            # que puede ser más pequeño, no distorsione el promedio.\n",
    "            total_loss += loss.item() * xb.size(0)\n",
    "            total_n += xb.size(0)\n",
    "\n",
    "        history.append(total_loss / total_n)\n",
    "\n",
    "    return history\n",
    "    # END CODE HERE\n",
    "\n",
    "\n",
    "def evaluate_regression_model(model, dev_loader):\n",
    "    \"\"\"\n",
    "    Evalúa el modelo de regresión.\n",
    "\n",
    "    Debe retornar un diccionario con:\n",
    "        mse, rmse, mae, r2\n",
    "    \"\"\"\n",
    "    # START CODE HERE\n",
    "    model.eval()\n",
    "    y_true = []\n",
    "    y_pred = []\n",
    "\n",
    "    with torch.no_grad():\n",
    "        for xb, yb in dev_loader:\n",
    "            preds = model(xb)\n",
    "\n",
    "            y_true.append(yb.cpu().numpy())\n",
    "            y_pred.append(preds.cpu().numpy())\n",
    "\n",
    "    y_true = np.vstack(y_true).reshape(-1)\n",
    "    y_pred = np.vstack(y_pred).reshape(-1)\n",
    "\n",
    "    # El RMSE se calcula con np.sqrt: el argumento squared=False de\n",
    "    # mean_squared_error fue eliminado en scikit-learn 1.6.\n",
    "    mse = mean_squared_error(y_true, y_pred)\n",
    "\n",
    "    return {\n",
    "        \"mse\": float(mse),\n",
    "        \"rmse\": float(np.sqrt(mse)),\n",
    "        \"mae\": float(mean_absolute_error(y_true, y_pred)),\n",
    "        \"r2\": float(r2_score(y_true, y_pred)),\n",
    "    }\n",
    "    # END CODE HERE\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 14,
   "id": "xsjmG3LGCFL1",
   "metadata": {
    "id": "xsjmG3LGCFL1"
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Tarea 1 public tests passed.\n"
     ]
    }
   ],
   "source": [
    "# Public test: ejecutar después de implementar Tarea 1.\n",
    "public_tests.test_tarea1(globals())\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "Jb5zKzbwCFL1",
   "metadata": {
    "id": "Jb5zKzbwCFL1"
   },
   "source": [
    "# Tarea 2 — Dataset C: clasificación multiclase\n",
    "\n",
    "Esta tarea utiliza el dataset C.\n",
    "\n",
    "## Actividades no calificadas\n",
    "\n",
    "1. Visualizar el dataset C.\n",
    "2. Normalizar las entradas sobre todo el dataset.\n",
    "3. Estandarizar las entradas usando las estadísticas de train normalizado.\n",
    "\n",
    "## Actividades calificadas\n",
    "\n",
    "Debe implementar:\n",
    "\n",
    "1. `MulticlassMLP`: red MLP para clasificación multiclase con arquitectura \\(2 \\rightarrow 8 \\rightarrow 8 \\rightarrow 4\\), ReLU en las capas ocultas y salida de 4 logits.\n",
    "2. `train_multiclass_model`: función que entrena la red durante 100 epochs por defecto.\n",
    "3. `evaluate_multiclass_model`: función que retorna métricas apropiadas.\n",
    "\n",
    "Métricas esperadas:\n",
    "\n",
    "- `accuracy`;\n",
    "- `macro_f1`;\n",
    "- `confusion_matrix`.\n",
    "\n",
    "Para clasificación multiclase se usa cross-entropy categórica:\n",
    "\n",
    "$$\n",
    "\\mathcal{L}_{CE}\n",
    "=\n",
    "-\\frac{1}{N}\n",
    "\\sum_{i=1}^{N}\n",
    "\\log p(y_i|\\mathbf{x}_i)\n",
    "$$\n",
    "\n",
    "En PyTorch, `CrossEntropyLoss` recibe logits directamente.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 15,
   "id": "Mi-vC9ETCFL1",
   "metadata": {
    "id": "Mi-vC9ETCFL1"
   },
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 109688 characters]",
      "text/plain": [
       "<Figure size 600x500 with 2 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "X_C_train: (1000, 2)\n",
      "y_C_train: (1000,)\n"
     ]
    }
   ],
   "source": [
    "C_train = pd.read_csv(DATA_DIR / \"dataset_C_train.csv\")\n",
    "C_dev = pd.read_csv(DATA_DIR / \"dataset_C_dev.csv\")\n",
    "\n",
    "# Non-graded: visualización\n",
    "plt.figure(figsize=(6, 5))\n",
    "plt.scatter(C_train[\"x1\"], C_train[\"x2\"], c=C_train[\"y\"], s=16, alpha=0.75)\n",
    "plt.title(\"Dataset C: clasificación multiclase\")\n",
    "plt.xlabel(\"x1\")\n",
    "plt.ylabel(\"x2\")\n",
    "plt.colorbar(label=\"clase\")\n",
    "plt.grid(True)\n",
    "plt.show()\n",
    "\n",
    "# Non-graded: normalización y estandarización\n",
    "X_C_train, X_C_dev, norm_C, std_C = normalize_all_then_standardize_train(C_train, C_dev)\n",
    "y_C_train = C_train[\"y\"].to_numpy(dtype=np.int64)\n",
    "y_C_dev = C_dev[\"y\"].to_numpy(dtype=np.int64)\n",
    "\n",
    "train_C_loader = DataLoader(\n",
    "    TensorDataset(torch.tensor(X_C_train), torch.tensor(y_C_train)),\n",
    "    batch_size=64,\n",
    "    shuffle=True,\n",
    ")\n",
    "dev_C_loader = DataLoader(\n",
    "    TensorDataset(torch.tensor(X_C_dev), torch.tensor(y_C_dev)),\n",
    "    batch_size=256,\n",
    "    shuffle=False,\n",
    ")\n",
    "\n",
    "print(\"X_C_train:\", X_C_train.shape)\n",
    "print(\"y_C_train:\", y_C_train.shape)\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 16,
   "id": "ieesh8NxCFL1",
   "metadata": {
    "id": "ieesh8NxCFL1"
   },
   "outputs": [],
   "source": [
    "# GRADED: Tarea 2\n",
    "\n",
    "class MulticlassMLP(nn.Module):\n",
    "    def __init__(self, input_dim=2, hidden_dim=8, output_dim=4):\n",
    "        super().__init__()\n",
    "\n",
    "        # START CODE HERE\n",
    "        # Arquitectura 2 -> 8 -> 8 -> 4. La salida son logits crudos, sin softmax:\n",
    "        # CrossEntropyLoss ya aplica log_softmax internamente, y aplicarlo dos veces\n",
    "        # aplanaría los gradientes.\n",
    "        self.net = nn.Sequential(\n",
    "            nn.Linear(input_dim, hidden_dim),\n",
    "            nn.ReLU(),\n",
    "            nn.Linear(hidden_dim, hidden_dim),\n",
    "            nn.ReLU(),\n",
    "            nn.Linear(hidden_dim, output_dim),\n",
    "        )\n",
    "        # END CODE HERE\n",
    "\n",
    "    def forward(self, x):\n",
    "        # START CODE HERE\n",
    "        return self.net(x)\n",
    "        # END CODE HERE\n",
    "\n",
    "\n",
    "def train_multiclass_model(model, train_loader, epochs=100, lr=1e-2):\n",
    "    \"\"\"\n",
    "    Entrena un modelo de clasificación multiclase usando CrossEntropyLoss.\n",
    "\n",
    "    Debe retornar una lista con la pérdida promedio por epoch.\n",
    "    \"\"\"\n",
    "    # START CODE HERE\n",
    "    # CrossEntropyLoss espera logits (N, C) y etiquetas enteras (N,) de tipo long.\n",
    "    criterion = nn.CrossEntropyLoss()\n",
    "    optimizer = torch.optim.Adam(model.parameters(), lr=lr)\n",
    "    history = []\n",
    "\n",
    "    for epoch in range(epochs):\n",
    "        model.train()\n",
    "        total_loss = 0.0\n",
    "        total_n = 0\n",
    "\n",
    "        for xb, yb in train_loader:\n",
    "            logits = model(xb)\n",
    "            loss = criterion(logits, yb)\n",
    "\n",
    "            optimizer.zero_grad()\n",
    "            loss.backward()\n",
    "            optimizer.step()\n",
    "\n",
    "            total_loss += loss.item() * xb.size(0)\n",
    "            total_n += xb.size(0)\n",
    "\n",
    "        history.append(total_loss / total_n)\n",
    "\n",
    "    return history\n",
    "    # END CODE HERE\n",
    "\n",
    "\n",
    "def evaluate_multiclass_model(model, dev_loader):\n",
    "    \"\"\"\n",
    "    Evalúa el modelo multiclase.\n",
    "\n",
    "    Debe retornar un diccionario con:\n",
    "        accuracy, macro_f1, confusion_matrix\n",
    "    \"\"\"\n",
    "    # START CODE HERE\n",
    "    model.eval()\n",
    "    y_true = []\n",
    "    y_pred = []\n",
    "\n",
    "    with torch.no_grad():\n",
    "        for xb, yb in dev_loader:\n",
    "            logits = model(xb)\n",
    "            # argmax sobre los logits equivale a argmax sobre las probabilidades,\n",
    "            # porque softmax es monótona: no hace falta calcularla.\n",
    "            preds = logits.argmax(dim=1)\n",
    "\n",
    "            y_true.append(yb.cpu().numpy())\n",
    "            y_pred.append(preds.cpu().numpy())\n",
    "\n",
    "    y_true = np.concatenate(y_true)\n",
    "    y_pred = np.concatenate(y_pred)\n",
    "\n",
    "    return {\n",
    "        \"accuracy\": float(accuracy_score(y_true, y_pred)),\n",
    "        # macro promedia el F1 por clase sin ponderar por soporte, así que\n",
    "        # penaliza el desempeño pobre en las clases minoritarias.\n",
    "        \"macro_f1\": float(f1_score(y_true, y_pred, average=\"macro\", zero_division=0)),\n",
    "        \"confusion_matrix\": confusion_matrix(y_true, y_pred),\n",
    "    }\n",
    "    # END CODE HERE\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 17,
   "id": "rWCbdcYZCFL1",
   "metadata": {
    "id": "rWCbdcYZCFL1"
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Tarea 2 public tests passed.\n"
     ]
    }
   ],
   "source": [
    "# Public test: ejecutar después de implementar Tarea 2.\n",
    "public_tests.test_tarea2(globals())\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "DSM5iYJeCFL1",
   "metadata": {
    "id": "DSM5iYJeCFL1"
   },
   "source": [
    "# Tarea 3 — Dataset D: clasificación multilabel\n",
    "\n",
    "Esta tarea utiliza el dataset D.\n",
    "\n",
    "## Actividades no calificadas\n",
    "\n",
    "1. Visualizar el dataset D.\n",
    "2. Normalizar las entradas sobre todo el dataset.\n",
    "3. Estandarizar las entradas usando las estadísticas de train normalizado.\n",
    "\n",
    "## Actividades calificadas\n",
    "\n",
    "Debe implementar:\n",
    "\n",
    "1. `MultilabelMLP`: red MLP para clasificación multilabel con arquitectura \\(2 \\rightarrow 8 \\rightarrow 8 \\rightarrow 4\\), ReLU en las capas ocultas y salida de 4 logits.\n",
    "2. `train_multilabel_model`: función que entrena la red durante 100 epochs por defecto.\n",
    "3. `evaluate_multilabel_model`: función que retorna métricas apropiadas.\n",
    "\n",
    "Métricas esperadas:\n",
    "\n",
    "- `subset_accuracy`;\n",
    "- `micro_f1`;\n",
    "- `macro_f1`.\n",
    "\n",
    "En clasificación multilabel, cada etiqueta se modela como una Bernoulli independiente. Por eso se usa `BCEWithLogitsLoss` con 4 logits de salida.\n",
    "\n",
    "La pérdida por etiqueta es:\n",
    "\n",
    "$$\n",
    "\\mathcal{L}_{BCE}\n",
    "=\n",
    "-\\frac{1}{N}\n",
    "\\sum_{i=1}^{N}\n",
    "\\left[\n",
    "y_i\\log(\\hat{y}_i)\n",
    "+\n",
    "(1-y_i)\\log(1-\\hat{y}_i)\n",
    "\\right]\n",
    "$$\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 18,
   "id": "vLhapKefCFL2",
   "metadata": {
    "id": "vLhapKefCFL2"
   },
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 138156 characters]",
      "text/plain": [
       "<Figure size 600x500 with 2 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "X_D_train: (1000, 2)\n",
      "y_D_train: (1000, 4)\n"
     ]
    }
   ],
   "source": [
    "D_train = pd.read_csv(DATA_DIR / \"dataset_D_train.csv\")\n",
    "D_dev = pd.read_csv(DATA_DIR / \"dataset_D_dev.csv\")\n",
    "\n",
    "# Non-graded: visualización.\n",
    "# Para visualizar multilabel en 2D, coloreamos por el número de etiquetas activas.\n",
    "label_cols = [\"y0\", \"y1\", \"y2\", \"y3\"]\n",
    "D_train[\"label_count\"] = D_train[label_cols].sum(axis=1)\n",
    "\n",
    "plt.figure(figsize=(6, 5))\n",
    "plt.scatter(D_train[\"x1\"], D_train[\"x2\"], c=D_train[\"label_count\"], s=16, alpha=0.75)\n",
    "plt.title(\"Dataset D: clasificación multilabel\")\n",
    "plt.xlabel(\"x1\")\n",
    "plt.ylabel(\"x2\")\n",
    "plt.colorbar(label=\"número de etiquetas activas\")\n",
    "plt.grid(True)\n",
    "plt.show()\n",
    "\n",
    "# Non-graded: normalización y estandarización\n",
    "X_D_train, X_D_dev, norm_D, std_D = normalize_all_then_standardize_train(D_train, D_dev)\n",
    "y_D_train = D_train[label_cols].to_numpy(dtype=np.float32)\n",
    "y_D_dev = D_dev[label_cols].to_numpy(dtype=np.float32)\n",
    "\n",
    "train_D_loader = DataLoader(\n",
    "    TensorDataset(torch.tensor(X_D_train), torch.tensor(y_D_train)),\n",
    "    batch_size=64,\n",
    "    shuffle=True,\n",
    ")\n",
    "dev_D_loader = DataLoader(\n",
    "    TensorDataset(torch.tensor(X_D_dev), torch.tensor(y_D_dev)),\n",
    "    batch_size=256,\n",
    "    shuffle=False,\n",
    ")\n",
    "\n",
    "print(\"X_D_train:\", X_D_train.shape)\n",
    "print(\"y_D_train:\", y_D_train.shape)\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 19,
   "id": "LNB27jPLCFL2",
   "metadata": {
    "id": "LNB27jPLCFL2"
   },
   "outputs": [],
   "source": [
    "# GRADED: Tarea 3\n",
    "\n",
    "class MultilabelMLP(nn.Module):\n",
    "    def __init__(self, input_dim=2, hidden_dim=8, output_dim=4):\n",
    "        super().__init__()\n",
    "\n",
    "        # START CODE HERE\n",
    "        # Arquitectura 2 -> 8 -> 8 -> 4. Cada una de las 4 salidas es el logit de una\n",
    "        # Bernoulli independiente, así que la sigmoide se aplica después (dentro de\n",
    "        # BCEWithLogitsLoss al entrenar, y de forma explícita al evaluar).\n",
    "        self.net = nn.Sequential(\n",
    "            nn.Linear(input_dim, hidden_dim),\n",
    "            nn.ReLU(),\n",
    "            nn.Linear(hidden_dim, hidden_dim),\n",
    "            nn.ReLU(),\n",
    "            nn.Linear(hidden_dim, output_dim),\n",
    "        )\n",
    "        # END CODE HERE\n",
    "\n",
    "    def forward(self, x):\n",
    "        # START CODE HERE\n",
    "        return self.net(x)\n",
    "        # END CODE HERE\n",
    "\n",
    "\n",
    "def train_multilabel_model(model, train_loader, epochs=100, lr=1e-2):\n",
    "    \"\"\"\n",
    "    Entrena un modelo de clasificación multilabel usando BCEWithLogitsLoss.\n",
    "\n",
    "    Debe retornar una lista con la pérdida promedio por epoch.\n",
    "    \"\"\"\n",
    "    # START CODE HERE\n",
    "    # BCEWithLogitsLoss combina sigmoide y BCE en una sola operación estable\n",
    "    # numéricamente (log-sum-exp), y promedia sobre las 4 etiquetas.\n",
    "    criterion = nn.BCEWithLogitsLoss()\n",
    "    optimizer = torch.optim.Adam(model.parameters(), lr=lr)\n",
    "    history = []\n",
    "\n",
    "    for epoch in range(epochs):\n",
    "        model.train()\n",
    "        total_loss = 0.0\n",
    "        total_n = 0\n",
    "\n",
    "        for xb, yb in train_loader:\n",
    "            logits = model(xb)\n",
    "            loss = criterion(logits, yb)\n",
    "\n",
    "            optimizer.zero_grad()\n",
    "            loss.backward()\n",
    "            optimizer.step()\n",
    "\n",
    "            total_loss += loss.item() * xb.size(0)\n",
    "            total_n += xb.size(0)\n",
    "\n",
    "        history.append(total_loss / total_n)\n",
    "\n",
    "    return history\n",
    "    # END CODE HERE\n",
    "\n",
    "\n",
    "def evaluate_multilabel_model(model, dev_loader, threshold=0.5):\n",
    "    \"\"\"\n",
    "    Evalúa el modelo multilabel.\n",
    "\n",
    "    Debe retornar un diccionario con:\n",
    "        subset_accuracy, micro_f1, macro_f1\n",
    "    \"\"\"\n",
    "    # START CODE HERE\n",
    "    model.eval()\n",
    "    y_true = []\n",
    "    y_pred = []\n",
    "\n",
    "    with torch.no_grad():\n",
    "        for xb, yb in dev_loader:\n",
    "            logits = model(xb)\n",
    "            probs = torch.sigmoid(logits)\n",
    "            # Cada etiqueta se decide por separado contra el umbral.\n",
    "            preds = (probs >= threshold).int()\n",
    "\n",
    "            y_true.append(yb.cpu().numpy())\n",
    "            y_pred.append(preds.cpu().numpy())\n",
    "\n",
    "    # Ambas matrices se castean a entero: las etiquetas llegan como float32 y\n",
    "    # sklearn interpretaría una mezcla float/bool como continuous-multioutput.\n",
    "    y_true = np.vstack(y_true).astype(int)\n",
    "    y_pred = np.vstack(y_pred).astype(int)\n",
    "\n",
    "    return {\n",
    "        # Con matrices 2D, accuracy_score exige que las 4 etiquetas de la fila\n",
    "        # coincidan: es exactamente el subset accuracy (exact match ratio).\n",
    "        \"subset_accuracy\": float(accuracy_score(y_true, y_pred)),\n",
    "        # micro agrega TP/FP/FN de todas las etiquetas antes de calcular el F1;\n",
    "        # macro promedia el F1 de cada etiqueta por separado.\n",
    "        \"micro_f1\": float(f1_score(y_true, y_pred, average=\"micro\", zero_division=0)),\n",
    "        \"macro_f1\": float(f1_score(y_true, y_pred, average=\"macro\", zero_division=0)),\n",
    "    }\n",
    "    # END CODE HERE\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 20,
   "id": "XiGclmWpCFL2",
   "metadata": {
    "id": "XiGclmWpCFL2"
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Tarea 3 public tests passed.\n"
     ]
    }
   ],
   "source": [
    "# Public test: ejecutar después de implementar Tarea 3.\n",
    "public_tests.test_tarea3(globals())\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "lERAz931CFL2",
   "metadata": {
    "id": "lERAz931CFL2"
   },
   "source": [
    "# Ejecución de pruebas privadas\n",
    "\n",
    "En un ambiente real de calificación, el archivo `private.py` no se entrega al estudiante.\n",
    "\n",
    "Las siguientes líneas deben permanecer comentadas en la versión pública. Cuando el instructor ejecute las pruebas privadas, estas líneas pueden activarse o ejecutarse desde otro script. El archivo privado escribirá un CSV llamado `notas.csv` con columnas:\n",
    "\n",
    "- `tarea1`;\n",
    "- `tarea2`;\n",
    "- `tarea3`;\n",
    "- `nota final`.\n",
    "\n",
    "La nota final se calcula como:\n",
    "\n",
    "$$\n",
    "\\text{nota final}\n",
    "=\n",
    "0.30(tarea1)\n",
    "+\n",
    "0.30(tarea2)\n",
    "+\n",
    "0.40(tarea3)\n",
    "$$\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "ZjJFB_KQCFL2",
   "metadata": {
    "id": "ZjJFB_KQCFL2"
   },
   "source": [
    "# Apéndice: ejemplos computables a mano\n",
    "\n",
    "## Forward pass de una neurona\n",
    "\n",
    "Suponga:\n",
    "\n",
    "$$\n",
    "\\mathbf{x}=\n",
    "\\begin{bmatrix}\n",
    "2\\\\\n",
    "-1\n",
    "\\end{bmatrix},\n",
    "\\quad\n",
    "\\mathbf{w}=\n",
    "\\begin{bmatrix}\n",
    "0.5\\\\\n",
    "-2\n",
    "\\end{bmatrix},\n",
    "\\quad\n",
    "b=1\n",
    "$$\n",
    "\n",
    "Entonces:\n",
    "\n",
    "$$\n",
    "z=\\mathbf{w}^T\\mathbf{x}+b\n",
    "$$\n",
    "\n",
    "$$\n",
    "z=(0.5)(2)+(-2)(-1)+1=4\n",
    "$$\n",
    "\n",
    "Si usamos ReLU:\n",
    "\n",
    "$$\n",
    "a=\\max(0,4)=4\n",
    "$$\n",
    "\n",
    "## MSE\n",
    "\n",
    "Si:\n",
    "\n",
    "$$\n",
    "\\mathbf{y}=[3,0,1],\n",
    "\\quad\n",
    "\\hat{\\mathbf{y}}=[2.5,0.7,1.2]\n",
    "$$\n",
    "\n",
    "entonces:\n",
    "\n",
    "$$\n",
    "MSE=\\frac{(3-2.5)^2+(0-0.7)^2+(1-1.2)^2}{3}\n",
    "$$\n",
    "\n",
    "## BCE\n",
    "\n",
    "Si \\(y=1\\) y \\(\\hat{p}=0.8\\):\n",
    "\n",
    "$$\n",
    "BCE=-\\log(0.8)\n",
    "$$\n",
    "\n",
    "Si \\(y=0\\) y \\(\\hat{p}=0.8\\):\n",
    "\n",
    "$$\n",
    "BCE=-\\log(1-0.8)\n",
    "$$\n",
    "\n",
    "## Categorical cross-entropy\n",
    "\n",
    "Si la clase correcta es \\(2\\) y:\n",
    "\n",
    "$$\n",
    "\\mathbf{p}=[0.1,0.7,0.2]\n",
    "$$\n",
    "\n",
    "entonces:\n",
    "\n",
    "$$\n",
    "CE=-\\log(0.2)\n",
    "$$\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 21,
   "id": "OQyqSG09CFL2",
   "metadata": {
    "id": "OQyqSG09CFL2"
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Forward neurona: 4.0 4.0\n",
      "MSE: 0.26\n",
      "BCE y=1, p=0.8: 0.2231435513142097\n",
      "BCE y=0, p=0.8: 1.6094379124341005\n",
      "CE: 1.6094379124341003\n"
     ]
    }
   ],
   "source": [
    "# Cálculos rápidos para verificar los ejemplos a mano.\n",
    "\n",
    "z = 0.5 * 2 + (-2) * (-1) + 1\n",
    "relu_z = max(0, z)\n",
    "print(\"Forward neurona:\", z, relu_z)\n",
    "\n",
    "y = np.array([3.0, 0.0, 1.0])\n",
    "y_hat = np.array([2.5, 0.7, 1.2])\n",
    "mse = np.mean((y - y_hat) ** 2)\n",
    "print(\"MSE:\", mse)\n",
    "\n",
    "print(\"BCE y=1, p=0.8:\", -np.log(0.8))\n",
    "print(\"BCE y=0, p=0.8:\", -np.log(1 - 0.8))\n",
    "\n",
    "p = np.array([0.1, 0.7, 0.2])\n",
    "correct_class = 2\n",
    "print(\"CE:\", -np.log(p[correct_class]))\n"
   ]
  }
 ],
 "metadata": {
  "colab": {
   "provenance": []
  },
  "kernelspec": {
   "display_name": "Python 3 (ipykernel)",
   "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.12.3"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
