{"metadata":{"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.12.13"},"kaggle":{"accelerator":"gpu","dataSources":[{"sourceId":20561,"sourceType":"competition"}],"dockerImageVersionId":31429,"isGpuEnabled":true,"isInternetEnabled":true,"language":"python","sourceType":"notebook"}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# De una tomografía a una máscara: segmentación de lesiones de COVID con U-Net\n\n## Equipo de trabajo\n\n- Ximena Perez Escalante\n- Jacob Espejel Guiza\n- Sebastian Osorio Arteaga\n\n**Notebook de referencia:** [PyTorch Baseline for Semantic Segmentation](https://www.kaggle.com/code/maedemaftouni/pytorch-baseline-for-semantic-segmentation).\n\n## El problema que vamos a resolver\n\nUna etiqueta para toda la imagen no nos dice dónde está una lesión. En este proyecto queremos **asignar una clase a cada píxel** de una tomografía y obtener una máscara que delimite las regiones de interés. Esta tarea se llama segmentación semántica.\n\nSeguiremos el recorrido completo: entender las imágenes y sus etiquetas, preparar ejemplos comparables, configurar una U-Net, entrenarla y revisar qué tan bien coinciden sus predicciones con las máscaras reales. Al final usaremos lo aprendido para generar máscaras de prueba y preparar el archivo de envío a Kaggle.\n\n## Qué recibe el modelo y qué esperamos de él\n\nCada imagen es un corte 2D de una tomografía de tórax, de 512 × 512 píxeles y un canal de intensidad. Trabajamos con cortes individuales, aunque varios pueden pertenecer al mismo estudio. Las intensidades originales están expresadas en unidades Hounsfield (HU); antes de usarlas las pondremos en una escala común.\n\nEn entrenamiento y validación, cada corte viene acompañado de una máscara del mismo tamaño. La imagen es la entrada y la máscara es la respuesta esperada. Mantenemos este orden de clases durante todo el notebook:\n\n| Clase | Nombre | Qué identifica en las etiquetas |\n|---|---|---|\n| 0 | Ground glass | Regiones anotadas como opacidad en vidrio esmerilado. |\n| 1 | Consolidation | Regiones anotadas como consolidación. |\n| 2 | Lungs other | Resto del pulmón, sin etiqueta de las dos lesiones anteriores; no equivale necesariamente a tejido sano. |\n| 3 | Background | Fondo y estructuras fuera de la región pulmonar etiquetada. |\n\nLas dos primeras clases son las lesiones de interés. Las otras dos permiten distinguirlas del pulmón restante y del fondo.\n\n## De dónde vienen los ejemplos\n\n| Fuente | Cortes | Información disponible |\n|---|---:|---|\n| MedSeg | 100 | Imágenes y máscaras. |\n| Radiopaedia | 829 | Imágenes y máscaras. |\n| Prueba de MedSeg | 10 | Solo imágenes; sus máscaras no están disponibles aquí. |\n\nCombinar las dos fuentes nos da más ejemplos, pero también introduce diferencias de procedencia. Por eso primero revisamos y normalizamos los datos y después reservamos una parte para validación.","metadata":{}},{"cell_type":"markdown","source":"---\n# 1. Preparar el entorno y verificar los archivos\n\nAntes de transformar imágenes necesitamos confirmar que tenemos todos los datos. Importamos NumPy para trabajar con arreglos y Matplotlib para visualizarlos; después recorremos los archivos de entrada de Kaggle.\n\nLa salida debe incluir `images_medseg.npy`, `masks_medseg.npy`, `images_radiopedia.npy`, `masks_radiopedia.npy` y `test_images_medseg.npy`. Las parejas imagen–máscara permiten aprender y evaluar; el archivo de prueba contiene las imágenes sobre las que predeciremos al final.\n\nEsta primera revisión evita avanzar con una fuente incompleta o una ruta equivocada. Una vez localizados los archivos, podemos cargarlos.","metadata":{}},{"cell_type":"code","source":"import numpy as np\nimport matplotlib.pyplot as plt\nimport os\n\nfor dirname, _, filenames in os.walk('/kaggle/input'):\n    for filename in filenames:\n        print(os.path.join(dirname, filename))\n","metadata":{"execution":{"iopub.status.busy":"2026-09-15T02:21:49.689896Z","iopub.execute_input":"2026-09-15T02:21:49.690203Z","iopub.status.idle":"2026-09-15T02:21:49.699379Z","shell.execute_reply.started":"2026-09-15T02:21:49.690179Z","shell.execute_reply":"2026-09-15T02:21:49.698367Z"},"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"---\n# 2. Cargar las imágenes y las máscaras\n\nAhora convertimos los archivos en arreglos de NumPy. Usamos `float32` para las intensidades y `int8` para las etiquetas binarias de las máscaras: las imágenes necesitan decimales y las etiquetas pueden representarse con enteros pequeños. Así reducimos el consumo de memoria.\n\nLas dimensiones nos ayudan a comprobar qué contiene cada arreglo:\n\n| Variable | Forma | Interpretación |\n|---|---|---|\n| `images_radiopedia` | `(829, 512, 512, 1)` | 829 cortes de un canal. |\n| `masks_radiopedia` | `(829, 512, 512, 4)` | Cuatro capas de etiquetas por corte. |\n| `images_medseg` | `(100, 512, 512, 1)` | 100 cortes de un canal. |\n| `masks_medseg` | `(100, 512, 512, 4)` | Cuatro capas de etiquetas por corte. |\n| `test_images_medseg` | `(10, 512, 512, 1)` | Diez cortes sin máscara disponible. |\n\nCargamos ambas fuentes para aplicarles el mismo procedimiento. Antes de cambiar su formato, vamos a mirar ejemplos: conocer las dimensiones no basta para confirmar que una máscara corresponde a su imagen.","metadata":{}},{"cell_type":"code","source":"prefix = '/kaggle/input/competitions/covid-segmentation/'\n\nimages_radiopedia = np.load(os.path.join(prefix, 'images_radiopedia.npy')).astype(np.float32)\nmasks_radiopedia = np.load(os.path.join(prefix, 'masks_radiopedia.npy')).astype(np.int8)\nimages_medseg = np.load(os.path.join(prefix, 'images_medseg.npy')).astype(np.float32)\nmasks_medseg = np.load(os.path.join(prefix, 'masks_medseg.npy')).astype(np.int8)\n\ntest_images_medseg = np.load(os.path.join(prefix, 'test_images_medseg.npy')).astype(np.float32)","metadata":{"execution":{"iopub.status.busy":"2026-09-15T02:21:49.700465Z","iopub.execute_input":"2026-09-15T02:21:49.700800Z","iopub.status.idle":"2026-09-15T02:22:11.227150Z","shell.execute_reply.started":"2026-09-15T02:21:49.700770Z","shell.execute_reply":"2026-09-15T02:22:11.226522Z"},"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"---\n# 3. Preparar una vista de las imágenes y sus etiquetas\n\nPara inspeccionar los datos necesitamos ver la tomografía junto con su respuesta esperada. La función `visualize` coloca varios cortes en columnas y las clases de sus máscaras en filas.\n\nLa reutilizaremos durante el tutorial: primero para entender las etiquetas, después para revisar los aumentos y finalmente para comparar predicciones. En este momento nos interesa comprobar que imagen y máscara estén alineadas y que las clases aparezcan en el orden acordado.","metadata":{}},{"cell_type":"code","source":"def visualize(image_batch, mask_batch=None, pred_batch=None, num_samples=8, hot_encode=True):\n    num_classes = mask_batch.shape[-1] if mask_batch is not None else 0\n    fix, ax = plt.subplots(num_classes + 1, num_samples, figsize=(num_samples * 2, (num_classes + 1) * 2))\n\n    for i in range(num_samples):\n        ax_image = ax[0, i] if num_classes > 0 else ax[i]\n        if hot_encode:\n            ax_image.imshow(image_batch[i,:,:,0], cmap='Greys')\n        else:\n            ax_image.imshow(image_batch[i,:,:])\n        ax_image.set_xticks([])\n        ax_image.set_yticks([])\n\n        if mask_batch is not None:\n            for j in range(num_classes):\n                if pred_batch is None:\n                    mask_to_show = mask_batch[i,:,:,j]\n                else:\n                    mask_to_show = np.zeros(shape=(*mask_batch.shape[1:-1], 3))\n                    mask_to_show[..., 0] = pred_batch[i,:,:,j] > 0.5\n                    mask_to_show[..., 1] = mask_batch[i,:,:,j]\n                ax[j + 1, i].imshow(mask_to_show, vmin=0, vmax=1)\n                ax[j + 1, i].set_xticks([])\n                ax[j + 1, i].set_yticks([])\n\n    plt.tight_layout()\n    plt.show()","metadata":{"execution":{"iopub.status.busy":"2026-09-15T02:22:11.228755Z","iopub.execute_input":"2026-09-15T02:22:11.228955Z","iopub.status.idle":"2026-09-15T02:22:11.236160Z","shell.execute_reply.started":"2026-09-15T02:22:11.228937Z","shell.execute_reply":"2026-09-15T02:22:11.235592Z"},"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"---\n# 4. Reconocer las clases en ejemplos reales\n\nAplicamos la visualización a los cortes 30 a 37 de Radiopaedia. La primera fila muestra las tomografías; debajo aparecen ground glass, consolidation, lungs other y background, en ese orden.\n\nObserva cuánto espacio ocupa cada clase y cómo cambian las regiones entre cortes cercanos. Las lesiones pueden ocupar una fracción pequeña de la imagen, mientras que el fondo ocupa una región grande. Más adelante esto explicará por qué no basta con contar el porcentaje total de píxeles correctos.\n\nLos cortes consecutivos pueden ser muy parecidos y no deben tratarse como pacientes independientes. Esta relación entre imágenes también importa al elegir la validación. Primero vamos a simplificar las etiquetas sin cambiar su ubicación.","metadata":{}},{"cell_type":"code","source":"visualize(images_radiopedia[30:], masks_radiopedia[30:])","metadata":{"execution":{"iopub.status.busy":"2026-09-15T02:22:11.237042Z","iopub.execute_input":"2026-09-15T02:22:11.237359Z","iopub.status.idle":"2026-09-15T02:22:13.293277Z","shell.execute_reply.started":"2026-09-15T02:22:11.237324Z","shell.execute_reply":"2026-09-15T02:22:13.292540Z"},"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"---\n# 5. Representar una sola clase por píxel\n\nLas máscaras originales tienen cuatro capas, una por clase. Para entrenar queremos una matriz en la que cada píxel contenga directamente su etiqueta: 0, 1, 2 o 3. `onehot_to_mask` usa `argmax` sobre las capas y asigna el código correspondiente.\n\nPor ejemplo, un píxel con etiqueta one-hot `[0, 1, 0, 0]` se convierte en `1`, consolidation. Conservamos la misma respuesta en un formato más compacto y compatible con la pérdida que usaremos después.\n\nEsta conversión supone que las capas representan clases mutuamente excluyentes. La visualización posterior reúne sus colores en una sola máscara para revisar que la anatomía y la ubicación de las regiones se conserven.","metadata":{}},{"cell_type":"code","source":"def onehot_to_mask(mask, palette):\n    \"\"\"\n    Converts a mask (H, W, K) to (H, W, C)\n    \"\"\"\n    x = np.argmax(mask, axis=-1)\n    colour_codes = np.array(palette)\n    x = np.uint8(colour_codes[x.astype(np.uint8)])\n    return x\n\npalette = [[0], [1], [2],[3]]\nmasks_radiopedia_recover = onehot_to_mask(masks_radiopedia, palette).squeeze()  # shape = (H, W)\n\nmasks_medseg_recover = onehot_to_mask(masks_medseg, palette).squeeze()  # shape = (H, W)\n\nprint('Hot encoded mask size: ',masks_radiopedia.shape)\nprint('Paletted mask size:',masks_medseg_recover.shape)\n\nvisualize(masks_medseg_recover[30:],hot_encode=False)","metadata":{"execution":{"iopub.status.busy":"2026-09-15T02:22:13.294436Z","iopub.execute_input":"2026-09-15T02:22:13.294742Z","iopub.status.idle":"2026-09-15T02:22:18.768157Z","shell.execute_reply.started":"2026-09-15T02:22:13.294712Z","shell.execute_reply":"2026-09-15T02:22:18.767419Z"},"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"---\n## Una distinción que usaremos durante todo el recorrido\n\nLa conversión anterior afecta únicamente a las etiquetas. La **imagen** sigue siendo una matriz de intensidades con forma `(512, 512, 1)`; la **máscara** pasa de `(512, 512, 4)` a `(512, 512)`.\n\nPara un mismo píxel podemos tener una intensidad de `-720.4` HU en la imagen y una etiqueta `1` en la máscara. El primer número describe la entrada; el segundo indica la clase esperada. Convertir el one-hot no une ambos arreglos ni añade las etiquetas a la imagen.\n\nEsta separación explica cómo funcionará el modelo: recibe la imagen para predecir y utilizamos la máscara para evaluar su respuesta durante entrenamiento y validación. Con las etiquetas listas, ahora preparamos las intensidades de entrada.","metadata":{}},{"cell_type":"markdown","source":"---\n# 6. Poner las intensidades en una escala común\n\nYa unificamos la representación de las etiquetas. Las imágenes también necesitan un tratamiento consistente: los valores extremos pueden dominar su escala y las dos fuentes pueden presentar distribuciones diferentes.\n\n`preprocess_images` primero recorta las intensidades al intervalo **[-1500, 500] HU**. Es una decisión heredada del baseline para limitar los extremos; los valores fuera del intervalo se sustituyen por sus límites, por lo que ese detalle se pierde.\n\nDespués calculamos la media y la desviación estándar usando los valores de Radiopaedia situados entre los percentiles 5 y 95. Usamos esa referencia para transformar las imágenes con `(imagen - media) / desviación`. Los percentiles seleccionan los valores que intervienen en el cálculo estadístico, no los píxeles que permanecen en la imagen.\n\nAplicamos la misma media y desviación a MedSeg y a prueba. Así evitamos que cada conjunto use una escala distinta; además, la referencia procede de Radiopaedia, que reservaremos para entrenamiento. Esta transformación homogeneiza la escala, pero no elimina todas las diferencias entre fuentes. El siguiente histograma permite revisarlas.","metadata":{}},{"cell_type":"code","source":"def preprocess_images(images_arr, mean_std=None):\n    images_arr[images_arr > 500] = 500\n    images_arr[images_arr < -1500] = -1500\n    min_perc, max_perc = np.percentile(images_arr, 5), np.percentile(images_arr, 95)\n    images_arr_valid = images_arr[(images_arr > min_perc) & (images_arr < max_perc)]\n    mean, std = (images_arr_valid.mean(), images_arr_valid.std()) if mean_std is None else mean_std\n    images_arr = (images_arr - mean) / std\n    print(f'mean {mean}, std {std}')\n    return images_arr, (mean, std)\n\nimages_radiopedia, mean_std = preprocess_images(images_radiopedia)\nimages_medseg, _ = preprocess_images(images_medseg, mean_std)\ntest_images_medseg, _ = preprocess_images(test_images_medseg, mean_std)","metadata":{"execution":{"iopub.status.busy":"2026-09-15T02:22:18.769053Z","iopub.execute_input":"2026-09-15T02:22:18.769340Z","iopub.status.idle":"2026-09-15T02:22:25.374341Z","shell.execute_reply.started":"2026-09-15T02:22:18.769316Z","shell.execute_reply":"2026-09-15T02:22:25.373578Z"},"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"---\n# 7. Comparar las dos fuentes antes de combinarlas\n\nEl histograma muestra cómo se distribuyen las intensidades después del preprocesamiento. Si las curvas de MedSeg y Radiopaedia difieren, la escala común no ha hecho idénticas sus distribuciones. El histograma permite observar la diferencia, aunque no identificar por sí solo su causa.\n\nCombinamos las fuentes para disponer de 929 cortes etiquetados y aprovechar más ejemplos. El costo de esa decisión es que el modelo debe aprender con imágenes de distinta procedencia. Por eso reservamos validación de MedSeg, que también es la fuente de las imágenes de prueba, y explicamos la separación a continuación.","metadata":{}},{"cell_type":"code","source":"def plot_hists(images1, images2=None):\n    plt.hist(images1.ravel(), bins=100, density=True, color='b', alpha=1 if images2 is None else 0.5)\n    if images2 is not None:\n        plt.hist(images2.ravel(), bins=100, density=True, alpha=0.5, color='orange')\n    plt.show();\n\nplot_hists(test_images_medseg, images_radiopedia)","metadata":{"execution":{"iopub.status.busy":"2026-09-15T02:22:25.375256Z","iopub.execute_input":"2026-09-15T02:22:25.375565Z","iopub.status.idle":"2026-09-15T02:22:28.098428Z","shell.execute_reply.started":"2026-09-15T02:22:25.375541Z","shell.execute_reply":"2026-09-15T02:22:28.097720Z"},"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"---\n# 8. Separar los ejemplos según su función\n\nAntes de entrenar debemos decidir qué imágenes utilizará el modelo para aprender y cuáles servirán para revisar su comportamiento. Conservamos la división del baseline: **los primeros 24 cortes de MedSeg van a validación**; los otros 76 se unen a los 829 de Radiopaedia y forman **905 cortes de entrenamiento**. Las diez imágenes de prueba permanecen separadas.\n\nEsta elección aprovecha todos los ejemplos de Radiopaedia y deja una validación de la misma fuente que prueba. Los índices son fijos: no barajamos los datos para decidir a qué conjunto pertenecen. Más adelante sí cambiaremos el orden en que se presentan los ejemplos de entrenamiento, sin moverlos de conjunto.\n\nLa división tiene una limitación: no separa explícitamente por paciente o volumen. Los cortes consecutivos pueden ser parecidos, incluso entre entrenamiento y validación, y no permiten medir de forma sólida la generalización a pacientes nuevos. Una comparación por paciente sería preferible si contamos con esos identificadores.\n\nAl terminar liberamos los arreglos originales que ya no necesitamos. La información para continuar queda en `train_images`, `train_masks`, `val_images` y `val_masks`, además de las imágenes de prueba.","metadata":{}},{"cell_type":"code","source":"val_indexes = list(range(24))\ntrain_indexes = list(range(24, 100))\n\ntrain_images = np.concatenate((images_medseg[train_indexes], images_radiopedia))\ntrain_masks = np.concatenate((masks_medseg_recover[train_indexes], masks_radiopedia_recover))\nval_images = images_medseg[val_indexes]\nval_masks = masks_medseg_recover[val_indexes]\n\ndel masks_medseg_recover\ndel masks_radiopedia_recover\ndel images_radiopedia\ndel masks_radiopedia\ndel images_medseg\ndel masks_medseg\n","metadata":{"execution":{"iopub.status.busy":"2026-09-15T02:22:28.099323Z","iopub.execute_input":"2026-09-15T02:22:28.099648Z","iopub.status.idle":"2026-09-15T02:22:28.551721Z","shell.execute_reply.started":"2026-09-15T02:22:28.099600Z","shell.execute_reply":"2026-09-15T02:22:28.551026Z"},"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"---\n# 9. De los datos preparados al aprendizaje\n\nHasta aquí hemos conservado una pareja imagen–máscara por ejemplo, reducido las etiquetas a una clase por píxel y aplicado una referencia común de intensidad. Tenemos **905 cortes para aprender, 24 para validar y 10 para predecir al final**.\n\nAhora cambia la pregunta: ya sabemos qué contienen los arreglos, pero necesitamos convertirlos en entradas que una red pueda procesar. Prepararemos el entorno, las transformaciones y los lotes; después definiremos cómo medir una predicción y construiremos U-Net.\n\nLa separación del paso anterior se mantiene durante todo el recorrido. Validación nos ayudará a interpretar lo aprendido, con la limitación de que todavía no representa una evaluación por paciente.","metadata":{}},{"cell_type":"markdown","source":"---\n# 10. Preparar las herramientas para construir U-Net\n\nPara pasar de arreglos a un modelo utilizaremos PyTorch y `segmentation_models_pytorch` (SMP). Esta biblioteca proporciona la U-Net y el encoder preentrenado que configuraremos en el paso 16, por lo que no necesitamos implementar cada bloque desde cero.\n\nLa siguiente celda instala SMP en Kaggle. Este flujo requiere GPU para el entrenamiento e Internet para instalar la biblioteca y descargar los pesos. La instalación no fija una versión concreta; si cambia el entorno, conviene registrar las versiones y ejecutar desde un kernel nuevo antes de comparar resultados.\n\nUna vez instalada la biblioteca, comprobaremos que PyTorch puede trabajar con la GPU asignada.","metadata":{}},{"cell_type":"code","source":"!pip install -q segmentation-models-pytorch","metadata":{"execution":{"iopub.status.busy":"2026-09-15T02:22:28.553980Z","iopub.execute_input":"2026-09-15T02:22:28.554536Z","iopub.status.idle":"2026-09-15T02:22:33.589700Z","shell.execute_reply.started":"2026-09-15T02:22:28.554514Z","shell.execute_reply":"2026-09-15T02:22:33.588725Z"},"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"---\n# 11. Importar PyTorch y comprobar la GPU\n\nPyTorch administra tensores y entrenamiento. Se fija `batch_size = 8`: cada actualización usa ocho cortes de 256 x 256 píxeles, un compromiso razonable entre memoria y velocidad en una GPU común de Kaggle. Lotes mayores pueden agotar la memoria.\n\nLa comprobación de CUDA evita entrenar accidentalmente en CPU, donde este experimento sería mucho más lento.\n\nSe fija una semilla para reducir variaciones entre ejecuciones. La libreta de mejoras restablece esta semilla antes de cada modelo para que el contraste dependa principalmente de la configuración evaluada.\n\n## Si Kaggle asigna una Tesla P100\n\nLa P100 tiene capacidad CUDA `sm_60` (arquitectura Pascal). El PyTorch actual de Kaggle solo incluye kernels desde `sm_70`, por lo que `torch.cuda.is_available()` puede responder `True` pero la primera operación del modelo falla con `CUDA error: no kernel image is available`.\n\nEsto no se corrige cambiando U-Net, la máscara o la celda de entrenamiento. En **Settings > Accelerator**, selecciona **T4 x2 GPU**; después pulsa **Restart session** y ejecuta todo desde el inicio. La comprobación siguiente detiene el notebook antes de entrenar si vuelve a asignarse una GPU incompatible.","metadata":{}},{"cell_type":"code","source":"import albumentations\nimport cv2\nfrom PIL import Image\nimport segmentation_models_pytorch as smp\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom torch.utils.data import DataLoader\nfrom torchvision import transforms as T\nfrom tqdm.notebook import tqdm\nimport time\n\nbatch_size = 8\nSEED = 42\n\ndef set_seed(seed=SEED):\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed_all(seed)\n    torch.backends.cudnn.deterministic = True\n    torch.backends.cudnn.benchmark = False\n\nset_seed()\ndevice = torch.device('cuda:0' if torch.cuda.is_available() else 'cpu')\n\nif device.type != 'cuda':\n    raise RuntimeError('Activa GPU en Kaggle antes de entrenar este notebook.')\n\ngpu_name = torch.cuda.get_device_name(0)\ncapability = torch.cuda.get_device_capability(0)\ngpu_architecture = f'sm_{capability[0]}{capability[1]}'\nsupported_architectures = torch.cuda.get_arch_list()\n\nif gpu_architecture not in supported_architectures:\n    raise RuntimeError(\n        f'La GPU asignada ({gpu_name}, {gpu_architecture}) no es compatible con '\n        f'este PyTorch. En Kaggle selecciona T4 x2 GPU y reinicia la sesión. '\n        f'Arquitecturas disponibles: {supported_architectures}'\n    )\n\nprint(f'Dispositivo: {gpu_name} ({gpu_architecture})')\nprint(f'Batch size: {batch_size}')","metadata":{"execution":{"iopub.status.busy":"2026-09-15T02:22:33.591275Z","iopub.execute_input":"2026-09-15T02:22:33.591750Z","iopub.status.idle":"2026-09-15T02:22:47.884734Z","shell.execute_reply.started":"2026-09-15T02:22:33.591719Z","shell.execute_reply":"2026-09-15T02:22:47.883999Z"},"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"---\n# 12. Aumentos de datos y cambio de tamaño\n\nLos **aumentos de datos** crean una versión distinta de una imagen cada vez que se usa en entrenamiento. No agregan pacientes nuevos: cambian la presentación de los 905 cortes ya disponibles para que el modelo aprenda patrones de lesión y no una posición fija dentro de la imagen.\n\n`albumentations.Compose([...])` reúne transformaciones y las aplica en secuencia. Cuando se le pasan `image=image` y `mask=mask`, Albumentations usa la **misma transformación geométrica** para ambos. Esto es esencial: si se rotara solo la imagen, la máscara dejaría de señalar la lesión correcta.\n\n## Aumentos de entrenamiento\n\n1. **`Rotate(limit=360, p=0.9, border_mode=cv2.BORDER_REPLICATE)`**\n   - `limit=360` no significa “girar siempre 360 grados”. Indica que el ángulo se elige aleatoriamente entre -360° y 360°. Girar 360° equivale a no girar, por lo que en la práctica permite cualquier orientación.\n   - `p=0.9` es la probabilidad de aplicar esta transformación: aproximadamente 90 de cada 100 imágenes se rotan; las demás se conservan en su orientación.\n   - Al girar un cuadrado aparecen huecos en las esquinas. `BORDER_REPLICATE` los rellena repitiendo el píxel más cercano del borde. Así no aparecen áreas negras artificiales, aunque esos bordes repetidos tampoco son anatomía real.\n\n2. **`RandomSizedCrop(...)`**\n   - `min_max_height=(384, 512)` indica que el recorte toma una altura aleatoria entre 384 y 512 píxeles. Como la imagen original mide 512, algunos ejemplos usan casi todo el corte y otros miran una región más cercana.\n   - `w2h_ratio=1.0` significa que la relación ancho/alto del recorte es 1:1; por eso el recorte es cuadrado. Si fuera 2.0, el ancho sería aproximadamente el doble de la altura.\n   - `size=(256, 256)` es el tamaño **final**. Sin importar si se recortaron 384 o 512 píxeles, el resultado se redimensiona a 256 × 256 para que todas las entradas del modelo tengan la misma forma.\n   - `interpolation=cv2.INTER_LINEAR` decide cómo calcular píxeles nuevos en la **imagen** al redimensionar. La interpolación lineal mezcla los vecinos y da transiciones suaves, adecuadas para intensidades continuas de una tomografía.\n   - `mask_interpolation=cv2.INTER_NEAREST` decide cómo redimensionar la **máscara**. Vecino más cercano copia la etiqueta del píxel más próximo. No mezcla las clases 0, 1, 2 y 3, algo indispensable porque una clase como 1.5 no tiene significado.\n   - El riesgo de este aumento es que un recorte pequeño puede excluir parte de una lesión. Por eso es una decisión que después puede compararse en el notebook de mejoras.\n\n3. **`HorizontalFlip(p=0.5)`**\n   - Refleja la imagen de izquierda a derecha con probabilidad de 50%. Sirve para reducir la dependencia de que una lesión aparezca siempre de un lado.\n   - También se refleja la máscara. No se usan reflejos verticales porque cambiarían de forma más fuerte la orientación anatómica superior-inferior.\n\n## Validación\n\nEn `val_augs` solo se usa `Resize(256, 256)`. No hay rotaciones ni recortes aleatorios: queremos que la métrica mida siempre las mismas imágenes reservadas. La imagen usa interpolación lineal y la máscara vecino más cercano por las mismas razones anteriores.\n\nReducir de 512 × 512 a 256 × 256 reduce memoria y tiempo de GPU, pero también puede borrar bordes pequeños. Es un compromiso del baseline, no una verdad clínica.","metadata":{}},{"cell_type":"code","source":"SOURCE_SIZE = 512\nTARGET_SIZE = 256\n\ntrain_augs = albumentations.Compose([\n    albumentations.Rotate(limit=360, p=0.9, border_mode=cv2.BORDER_REPLICATE),\n    albumentations.RandomSizedCrop(\n        min_max_height=(int(SOURCE_SIZE * 0.75), SOURCE_SIZE),\n        size=(TARGET_SIZE, TARGET_SIZE),\n        w2h_ratio=1.0,\n        interpolation=cv2.INTER_LINEAR,\n        mask_interpolation=cv2.INTER_NEAREST,\n    ),\n    albumentations.HorizontalFlip(p=0.5),\n])\n\nval_augs = albumentations.Compose([\n    albumentations.Resize(\n        TARGET_SIZE,\n        TARGET_SIZE,\n        interpolation=cv2.INTER_LINEAR,\n        mask_interpolation=cv2.INTER_NEAREST,\n    )\n])","metadata":{"execution":{"iopub.status.busy":"2026-09-15T02:22:47.885859Z","iopub.execute_input":"2026-09-15T02:22:47.886318Z","iopub.status.idle":"2026-09-15T02:22:47.895440Z","shell.execute_reply.started":"2026-09-15T02:22:47.886285Z","shell.execute_reply":"2026-09-15T02:22:47.894609Z"},"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"---\n# 13. Construir el conjunto de datos y los cargadores\n\nEn PyTorch, un `Dataset` no es una copia nueva de todos los datos. Es un objeto que sabe responder: “dame la imagen y la máscara con índice `i`”. Esto permite cargar, transformar y agrupar ejemplos solo cuando se necesitan.\n\n## Qué guarda `Dataset`\n\nEn `__init__`, el objeto conserva referencias a `images`, `masks` y a las transformaciones. También guarda `mean=[0.485]` y `std=[0.229]`, un valor por canal porque las tomografías tienen un solo canal. La normalización final aplica `(x - mean) / std` a cada píxel. Es una decisión heredada del baseline: se suma a la normalización previa de unidades Hounsfield y debe revisarse si se cambia el modelo.\n\n## Qué ocurre en `__getitem__(i)`\n\nCuando el cargador pide el ejemplo `i`:\n\n1. `image = self.images[i]` y `mask = self.masks[i]` recuperan un corte y su máscara correspondiente.\n2. Si hay aumentos, `self.augmentations(image=image, mask=mask)` transforma los dos con los mismos parámetros aleatorios.\n3. `np.squeeze(..., axis=2)` quita la dimensión final de tamaño uno. La imagen pasa de `(alto, ancho, 1)` a `(alto, ancho)`, una forma que PIL y TorchVision manejan fácilmente.\n4. `T.ToTensor()` convierte la matriz de NumPy en un tensor de PyTorch y reorganiza la imagen a `(canales, alto, ancho)`: `(1, 256, 256)`.\n5. `T.Normalize(...)` aplica la normalización de un canal.\n6. `torch.from_numpy(mask).long()` convierte la máscara a tensor entero de tipo `long`. Es importante: `CrossEntropyLoss` espera que cada píxel de la máscara sea el índice entero de la clase correcta, por ejemplo 0 o 1, no una probabilidad.\n\nLa función devuelve una pareja: `(imagen_tensor, mascara_tensor)`. La imagen tiene forma aproximada `(1, 256, 256)` y la máscara `(256, 256)`.\n\n## Por qué existen `__len__` y `DataLoader`\n\n- `__len__` devuelve cuántos ejemplos hay. PyTorch lo usa para saber cuándo termina una época.\n- `DataLoader` junta varias parejas en un lote. Con `batch_size=8`, un lote de imágenes tiene forma `(8, 1, 256, 256)` y un lote de máscaras `(8, 256, 256)`.\n- `shuffle=True` cambia el orden de los ejemplos en cada época de entrenamiento. Esto evita que el optimizador vea bloques con el mismo orden original y ayuda a que cada lote sea más variado.\n- `shuffle=False` en validación conserva un orden estable. Así se conservan los mismos lotes y las inspecciones son reproducibles; en este notebook, la agrupación importa porque mIoU se promedia por lote.\n- `pin_memory=True` reserva la memoria de los lotes de una manera que facilita transferirlos a GPU. Se aprovecha después con `non_blocking=True` en `.to(device)`.","metadata":{}},{"cell_type":"code","source":"class Dataset:\n    def __init__(self, images, masks, augmentations=None):\n        self.images = images\n        self.masks = masks\n        self.augmentations = augmentations\n        self.mean = [0.485]\n        self.std = [0.229]\n\n    def __getitem__(self, i):\n        image = self.images[i]\n        mask = self.masks[i]\n\n        if self.augmentations is not None:\n            sample = self.augmentations(image=image, mask=mask)\n            image = Image.fromarray(np.squeeze(sample['image'], axis=2))\n            mask = sample['mask']\n        else:\n            image = Image.fromarray(image)\n\n        transform = T.Compose([T.ToTensor(), T.Normalize(self.mean, self.std)])\n        return transform(image), torch.from_numpy(mask).long()\n\n    def __len__(self):\n        return len(self.images)\n\n\ntrain_dataset = Dataset(train_images, train_masks, train_augs)\nval_dataset = Dataset(val_images, val_masks, val_augs)\n\ntrain_dataloader = DataLoader(\n    train_dataset, batch_size=batch_size, shuffle=True, pin_memory=True\n)\nval_dataloader = DataLoader(\n    val_dataset, batch_size=batch_size, shuffle=False, pin_memory=True\n)","metadata":{"execution":{"iopub.status.busy":"2026-09-15T02:22:47.896429Z","iopub.execute_input":"2026-09-15T02:22:47.896753Z","iopub.status.idle":"2026-09-15T02:22:47.914282Z","shell.execute_reply.started":"2026-09-15T02:22:47.896720Z","shell.execute_reply":"2026-09-15T02:22:47.913701Z"},"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"---\n# 14. Inspeccionar un lote aumentado\n\nAntes de gastar tiempo de GPU, se revisa que aumentos y máscaras sigan alineados. La función convierte temporalmente la máscara de clase única a cuatro canales para reutilizar la visualización anterior. Si las regiones aparecen desplazadas o con etiquetas inesperadas, el entrenamiento no debe continuar.","metadata":{}},{"cell_type":"code","source":"def mask_to_onehot(mask, palette):\n    semantic_map = []\n    for colour in palette:\n        equality = np.equal(mask, colour)   \n        semantic_map.append(np.all(equality, axis=-1))\n    return torch.from_numpy(np.stack(semantic_map, axis=-1).astype(np.float32))\n\n\n_, train_data = next(enumerate(train_dataloader))\nmask_hot_encoded = mask_to_onehot(torch.unsqueeze(train_data[1], -1).numpy(), palette)\nvisualize(train_data[0].permute(0, 2, 3, 1), mask_hot_encoded)","metadata":{"execution":{"iopub.status.busy":"2026-09-15T02:22:47.916055Z","iopub.execute_input":"2026-09-15T02:22:47.916516Z","iopub.status.idle":"2026-09-15T02:22:49.653712Z","shell.execute_reply.started":"2026-09-15T02:22:47.916486Z","shell.execute_reply":"2026-09-15T02:22:49.652836Z"},"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"---\n# 15. Métricas del modelo: IoU y mIoU\n\nLa red produce cuatro **logits** por píxel. Las funciones de evaluación aplican `softmax` para convertirlos en probabilidades y `argmax` para elegir una clase de 0 a 3. Esa máscara predicha se compara con la máscara real.\n\nLa **Intersection over Union (IoU)** mide cuánto coincide la región predicha de una clase con su región real. La intersección contiene los píxeles de esa clase en ambas máscaras; la unión contiene los que aparecen en cualquiera de las dos. Se calcula como **`IoU = intersección / unión = TP / (TP + FP + FN)`**, donde TP son aciertos de esa clase, FP son píxeles marcados de más y FN son píxeles que faltaron. Por ejemplo, una intersección de 60 píxeles y una unión de 100 dan un IoU de 0.60. Un valor de 1 representa coincidencia perfecta y 0 indica que no hay solapamiento cuando la unión no está vacía.\n\nLa **mean Intersection over Union (mIoU)** resume los IoU de varias clases mediante su promedio. Aquí `mIoU` reúne los píxeles de cada lote, calcula el IoU de las clases presentes en su máscara real y promedia sus resultados. Después, el entrenamiento reporta la media de esos valores por lote. Las clases posibles son ground glass, consolidation, lungs other y background; no se evalúan únicamente las lesiones.\n\nElegimos IoU y mIoU porque el objetivo es **delimitar regiones**, y ambas reflejan tanto los píxeles añadidos por error como los omitidos. Promediar por clase evita que todos los aciertos del fondo dominen directamente el resultado como ocurre con la **exactitud por píxel**, que cuenta aciertos sobre el total de píxeles. Por eso una exactitud alta puede coexistir con máscaras de lesiones incompletas. [Referencia sobre métricas de segmentación](https://arxiv.org/abs/2202.05273).\n\nHay una particularidad de esta implementación: si una clase no aparece en la máscara real del lote, su IoU se omite mediante `np.nanmean`, incluso si el modelo la predijo. Sus falsos positivos no reciben una penalización propia para esa clase, aunque afectan a otras clases. Además, el promedio por lotes no equivale necesariamente a acumular la intersección y unión de todo el conjunto. Para comparar con el baseline se debe conservar la misma definición, agrupación y selección de clases.","metadata":{}},{"cell_type":"code","source":"def pixel_accuracy(output, mask):\n    with torch.no_grad():\n        output = torch.argmax(F.softmax(output, dim=1), dim=1)\n        return float(torch.eq(output, mask).sum()) / float(mask.numel())\n\n\ndef mIoU(pred_mask, mask, smooth=1e-10, n_classes=4):\n    with torch.no_grad():\n        pred_mask = torch.argmax(F.softmax(pred_mask, dim=1), dim=1).reshape(-1)\n        mask = mask.reshape(-1)\n        iou_per_class = []\n\n        for class_index in range(n_classes):\n            prediction = pred_mask == class_index\n            target = mask == class_index\n            if target.sum().item() == 0:\n                iou_per_class.append(np.nan)\n                continue\n            intersection = torch.logical_and(prediction, target).sum().float().item()\n            union = torch.logical_or(prediction, target).sum().float().item()\n            iou_per_class.append((intersection + smooth) / (union + smooth))\n\n        return np.nanmean(iou_per_class)","metadata":{"execution":{"iopub.status.busy":"2026-09-15T02:22:49.655062Z","iopub.execute_input":"2026-09-15T02:22:49.655418Z","iopub.status.idle":"2026-09-15T02:22:49.662610Z","shell.execute_reply.started":"2026-09-15T02:22:49.655378Z","shell.execute_reply":"2026-09-15T02:22:49.661927Z"},"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"---\n# 16. Seleccionar la arquitectura U-Net\n\nSe usa una **U-Net** con codificador EfficientNet-B2 y una entrada de un canal, porque las tomografías son escala de grises. El codificador reúne contexto y el decodificador recupera resolución; las conexiones de salto conservan detalles espaciales necesarios para delimitar lesiones.\n\nEl modelo devuelve cuatro logits por píxel. No se aplica `softmax` dentro del modelo porque `CrossEntropyLoss` lo incorpora de forma estable. Los pesos de ImageNet ayudan a iniciar el aprendizaje, pero provienen de imágenes naturales y existe una diferencia de dominio con las tomografías.\n\n## ¿Por qué elegimos EfficientNet-B2?\n\n**U-Net define cómo combinamos el encoder y el decoder; EfficientNet-B2 es la red que usamos como encoder**, es decir, la parte que extrae características de la tomografía. Sus filtros transforman intensidades en representaciones de bordes, texturas y patrones cada vez más complejos que después utiliza el decoder.\n\nEfficientNet es una familia de redes diseñada para equilibrar capacidad y costo de cómputo. **B2 es una variante de tamaño relativamente contenido**: ofrece más capacidad que B0, sin llegar al tamaño de variantes como B6 o B7. Para este baseline, la elección se justifica como un compromiso práctico entre capacidad, memoria y tiempo de entrenamiento en Kaggle, además de disponer de pesos preentrenados. Esto no demuestra que B2 sea la mejor para nuestros datos: habría que comparar encoders bajo la misma validación para afirmarlo. [Referencia: EfficientNet](https://arxiv.org/abs/1905.11946).\n\n## ¿Qué significa `in_channels=1`?\n\n`in_channels` indica **cuántos canales tiene cada imagen que entra al modelo**. Una imagen RGB tiene tres: rojo, verde y azul. Aquí cada corte de tomografía contiene un solo valor de intensidad por píxel, por eso usamos **un canal**.\n\nPor ejemplo, un lote de ocho cortes tiene forma `(8, 1, 256, 256)`: ocho imágenes, un canal, alto y ancho. Ese `1` no es el número de clases ni el número de filtros internos. Las cuatro clases de salida se indican aparte con `classes=4`.\n\n## ¿Qué son `encoder_weights` y los pesos de ImageNet?\n\nLos **pesos** son los valores numéricos que la red aprende durante el entrenamiento; por ejemplo, los coeficientes de sus filtros. `encoder_weights` elige con qué valores se inicializa el encoder:\n\n- **`encoder_weights=None`**: comienza con una inicialización aleatoria y aprende a partir de nuestros datos.\n- **`encoder_weights='imagenet'`**: carga pesos aprendidos previamente al clasificar imágenes naturales de ImageNet, un conjunto grande de fotografías de objetos, animales y otras categorías.\n\nElegimos ImageNet para aprovechar ese aprendizaje previo, llamado **aprendizaje por transferencia**. Con nuestros 905 cortes de entrenamiento, partir de filtros que ya responden a estructuras visuales puede facilitar el aprendizaje y reducir el entrenamiento necesario. Es una motivación para usar estos pesos; la mejora concreta se tendría que medir frente a una inicialización aleatoria.\n\n**¿Cómo sirven pesos de fotos RGB si tenemos un solo canal?** Para esta configuración, `segmentation_models_pytorch` adapta la primera convolución sumando sus pesos de los tres canales RGB, de modo que acepte una entrada de un canal. [Documentación de SMP: canales de entrada](https://github.com/qubvel-org/segmentation_models.pytorch#input-channels).\n\nEl preentrenamiento inicializa el **encoder**; el decoder y la salida de segmentación se inicializan por separado. En nuestro entrenamiento se ajustan también los pesos del encoder, porque el optimizador recibe `model.parameters()` y no lo congelamos. Así adaptamos las características a las tomografías y sus máscaras. ImageNet no enseña a reconocer lesiones de COVID: esa tarea se aprende con nuestros datos, y la diferencia entre fotografías y tomografías limita cuánto se puede transferir.","metadata":{}},{"cell_type":"code","source":"model = smp.Unet(\n    'efficientnet-b2',\n    in_channels=1,\n    encoder_weights='imagenet',\n    classes=4,\n    activation=None,\n    encoder_depth=5,\n    decoder_channels=[256, 128, 64, 32, 16],\n)\n\nprint(model.__class__.__name__)","metadata":{"execution":{"iopub.status.busy":"2026-09-15T02:22:49.663525Z","iopub.execute_input":"2026-09-15T02:22:49.663903Z","iopub.status.idle":"2026-09-15T02:22:51.409386Z","shell.execute_reply.started":"2026-09-15T02:22:49.663879Z","shell.execute_reply":"2026-09-15T02:22:51.408787Z"},"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"---\n## Cómo U-Net aprende a crear una máscara\n\nImagina que le mostramos al modelo una tomografía y preguntamos, para cada píxel: “¿esto parece ground glass, consolidation, pulmón o background?”. Al principio el encoder ya dispone de características aprendidas en ImageNet, pero el decoder y la salida aún deben aprender a convertirlas en máscaras de nuestras cuatro clases.\n\nDurante **entrenamiento** sí tenemos la máscara real. U-Net recibe **solo la imagen** y produce cuatro puntajes por píxel. `CrossEntropyLoss` compara esos puntajes con la máscara real; después `backward()` y AdamW ajustan los pesos para que la próxima respuesta se parezca más a la verdad.\n\nDurante **prueba** no existe máscara real. Eso no impide predecir: U-Net ya conserva en sus pesos lo aprendido con ejemplos anteriores. Recibe la tomografía nueva, genera cuatro puntajes por píxel y elige la clase con el puntaje mayor. La máscara predicha es, por tanto, una estimación basada en patrones aprendidos; no “recupera” una máscara escondida dentro de la imagen.\n\nEl diagrama de la siguiente celda resume el recorrido. Los números entre paréntesis son formas de tensor: primero tamaño del lote, después canales, alto y ancho.","metadata":{}},{"cell_type":"code","source":"from matplotlib.patches import FancyBboxPatch, FancyArrowPatch\n\ndef draw_segmentation_pipeline():\n    fig, axes = plt.subplots(1, 2, figsize=(18, 6))\n    fig.suptitle('De una tomografía a una máscara predicha', fontsize=18, fontweight='bold')\n\n    def box(ax, xy, text, color, width=2.4, height=0.95):\n        x, y = xy\n        patch = FancyBboxPatch(\n            (x, y), width, height,\n            boxstyle='round,pad=0.03',\n            facecolor=color, edgecolor='#1f2937', linewidth=1.5\n        )\n        ax.add_patch(patch)\n        ax.text(x + width / 2, y + height / 2, text, ha='center', va='center', fontsize=10)\n\n    def arrow(ax, start, end, color='#374151', style='-|>'):\n        ax.add_patch(FancyArrowPatch(start, end, arrowstyle=style, mutation_scale=18,\n                                     linewidth=2, color=color))\n\n    # Panel izquierdo: entrenamiento\n    ax = axes[0]\n    ax.set_title('Entrenamiento: conocemos la respuesta', fontsize=14, fontweight='bold')\n    box(ax, (0.3, 3.0), 'Imagen CT\\n(1, 256, 256)', '#bfdbfe')\n    box(ax, (3.4, 3.0), 'U-Net\\nEncoder + decoder', '#c7d2fe')\n    box(ax, (6.5, 3.0), 'Logits\\n(4, 256, 256)', '#ddd6fe')\n    box(ax, (9.6, 3.0), 'Softmax + argmax\\nMáscara predicha', '#bbf7d0')\n    box(ax, (6.5, 1.0), 'Máscara real\\n(256, 256)', '#fde68a')\n    box(ax, (9.6, 1.0), 'CrossEntropyLoss\\n¿qué tan distinta es?', '#fecaca')\n    arrow(ax, (2.7, 3.48), (3.4, 3.48))\n    arrow(ax, (5.8, 3.48), (6.5, 3.48))\n    arrow(ax, (8.9, 3.48), (9.6, 3.48))\n    arrow(ax, (7.7, 1.95), (7.7, 3.0))\n    arrow(ax, (10.8, 3.0), (10.8, 1.95))\n    arrow(ax, (9.6, 1.48), (8.9, 1.48), color='#dc2626')\n    ax.text(6.9, 0.35, 'La pérdida envía información hacia atrás\\npara ajustar los pesos de U-Net.', color='#b91c1c', fontsize=10)\n    ax.set_xlim(0, 12.3); ax.set_ylim(0, 4.5); ax.axis('off')\n\n    plt.tight_layout()\n    plt.show()\n\n\ndraw_segmentation_pipeline()","metadata":{"execution":{"iopub.status.busy":"2026-09-15T02:22:51.410346Z","iopub.execute_input":"2026-09-15T02:22:51.410765Z","iopub.status.idle":"2026-09-15T02:22:51.694791Z","shell.execute_reply.started":"2026-09-15T02:22:51.410741Z","shell.execute_reply":"2026-09-15T02:22:51.694037Z"},"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"---\n## La “U” de esta U-Net y cómo cambian las representaciones\n\nEl dibujo clásico de U-Net tiene una bajada y una subida:\n\n- El **encoder** es la bajada. Reduce alto y ancho gradualmente, pero aumenta el número de canales internos. Los canales no son máscaras clínicas: son detectores que el modelo aprende, por ejemplo bordes, texturas o patrones de opacidad.\n- El **cuello de botella** es la parte más compacta. Ve una zona grande de la imagen con poco detalle espacial y aporta contexto.\n- El **decoder** es la subida. Recupera resolución para decidir una clase por píxel.\n- Las **skip connections** llevan detalles del encoder al decoder en la misma escala. Sin ellas, al ampliar la imagen se perderían bordes finos de los pulmones y lesiones.\n\nNuestro modelo conserva esta idea, pero no es exactamente el dibujo original: `smp.Unet` usa **EfficientNet-B2** como encoder. La siguiente celda consulta el modelo real y muestra las formas de sus mapas de características para un ejemplo. Así se puede observar qué tan pequeña se vuelve la imagen dentro de la red y cómo el decoder vuelve a 256 × 256.\n\nLa función mueve explícitamente tanto el ejemplo como los pesos de U-Net a `device`. Esto evita el error “Input type CUDA y weight type CPU”: una operación de convolución solo puede multiplicar una imagen y unos pesos si ambos están en la misma GPU o ambos están en CPU.\n\nLa versión actual de `segmentation_models_pytorch` recibe la colección completa de mapas del encoder como una sola lista en `model.decoder(encoder_features)`. No se usa `*` porque ese operador separaría la lista en varios argumentos, una interfaz usada por versiones anteriores de la biblioteca.\n\n## ¿Qué “ve” cada canal?\n\nSí: las convoluciones permiten que la red encuentre patrones, pero conviene imaginarlo como un proceso de aprendizaje y combinación, no como si cada canal tuviera un nombre fijo desde el inicio.\n\nUna convolución usa un filtro pequeño, por ejemplo una ventana de 3 × 3 píxeles. Ese filtro se mueve por toda la tomografía y produce una nueva imagen 2D llamada **mapa de características**. Un canal equivale a uno de esos mapas.\n\nEn nuestro encoder, los filtros parten de los pesos de ImageNet; el decoder se inicializa por separado. Durante el entrenamiento, ambas partes se adaptan a las tomografías. Al calcular la pérdida entre los logits y las etiquetas reales y aplicar `backward()`, se obtienen los gradientes con los que el optimizador ajusta los filtros. Con muchas imágenes, algunos terminan respondiendo más a patrones útiles, por ejemplo:\n\n- transiciones entre aire y tejido;\n- bordes del pulmón;\n- texturas de opacidades;\n- combinaciones de textura y ubicación que son compatibles con una lesión.\n\nNo se puede afirmar que “el canal 7 siempre detecta consolidation”. Los canales se aprenden juntos y su significado puede cambiar entre ejecuciones. Lo importante es que la red dispone de muchos mapas para representar distintos aspectos de la misma imagen.\n\n## Cómo evoluciona el patrón a través de la U\n\nLa entrada es una capa gris: `(1, 256, 256)`. En el encoder, la red reduce alto y ancho y crea más canales internos. Por ejemplo, pasar de una representación grande con pocos mapas a una más pequeña con muchos mapas permite guardar información diferente:\n\n```text\nImagen:       1 mapa  de 256 × 256  → intensidad original\nEncoder:     muchos mapas de menor tamaño → bordes, texturas y combinaciones\nCuello:      mapas pequeños con mucho contexto → “qué región parece ser esto”\nDecoder:     mapas grandes otra vez → “dónde debe ir exactamente cada clase”\n```\n\nLa red no sigue un único patrón como una pelota que baja y sube por la U. La información se transforma y se mezcla entre muchos mapas en cada capa. Al bajar, cada píxel interno representa una zona cada vez mayor de la tomografía; esto se llama **campo receptivo**. Así, una capa profunda puede usar el contexto de una parte amplia del pulmón y no solo el valor de un píxel aislado.\n\nAl subir, el decoder amplía esos mapas y usa las conexiones de salto para recuperar detalles de ubicación. El decoder combina “esto parece una lesión” con “estos son los píxeles exactos donde estaba ese borde”.\n\n## Por qué el resultado tiene cuatro logits\n\nAl final, U-Net no produce directamente una máscara con colores. Produce cuatro números por cada píxel, uno por clase:\n\n```text\nPara un píxel:\n[logit ground glass, logit consolidation, logit lungs other, logit background]\n```\n\nPor eso la salida cruda tiene forma `(1, 4, 256, 256)` para un solo corte: un lote, cuatro canales de clases, alto y ancho. Los **logits** son puntajes; todavía no son probabilidades.\n\n`softmax` transforma los cuatro puntajes de cada píxel en cuatro probabilidades que suman 1. Después, `argmax` escoge la probabilidad mayor. Así la salida termina como una sola máscara `(1, 256, 256)` con valores 0, 1, 2 o 3.\n\nEn entrenamiento, la pérdida compara los logits con las etiquetas reales; la máscara obtenida con argmax se usa para las métricas. En prueba no hay máscara real: se aplica exactamente el mismo recorrido imagen → encoder → decoder → cuatro logits → argmax, pero ya no se calcula pérdida ni se cambian pesos.","metadata":{}},{"cell_type":"code","source":"def inspect_unet_feature_shapes(model, image_batch):\n    # La imagen y los pesos deben vivir en el mismo dispositivo.\n    model = model.to(device)\n    model.eval()\n    sample = image_batch[:1].to(device)\n\n    with torch.no_grad():\n        encoder_features = model.encoder(sample)\n        decoder_feature = model.decoder(encoder_features)\n        logits = model.segmentation_head(decoder_feature)\n\n    print('Entrada al modelo:', tuple(sample.shape))\n    print('\\nSalida de cada nivel del encoder:')\n    for level, feature in enumerate(encoder_features):\n        print(f'  Nivel {level}: {tuple(feature.shape)}')\n\n    print('\\nSalida del decoder:', tuple(decoder_feature.shape))\n    print('Logits finales:', tuple(logits.shape))\n    print('Máscara después de argmax:', tuple(torch.argmax(logits, dim=1).shape))\n\n    encoder_labels = [f'E{index}\\n{shape[1]} canales\\n{shape[2]}×{shape[3]}'\n                      for index, shape in enumerate([tuple(item.shape) for item in encoder_features])]\n    fig, ax = plt.subplots(figsize=(15, 4))\n    xs = np.linspace(0.5, 5.5, len(encoder_labels))\n    for x, label in zip(xs, encoder_labels):\n        ax.text(x, 0.7, label, ha='center', va='center',\n                bbox=dict(boxstyle='round,pad=0.5', facecolor='#bfdbfe', edgecolor='#1d4ed8'))\n    ax.text(7.4, 0.7, 'Decoder\\nrecupera 256×256', ha='center', va='center',\n            bbox=dict(boxstyle='round,pad=0.5', facecolor='#c7d2fe', edgecolor='#4338ca'))\n    ax.text(10.0, 0.7, '4 logits por píxel\\n→ argmax\\n1 clase por píxel', ha='center', va='center',\n            bbox=dict(boxstyle='round,pad=0.5', facecolor='#bbf7d0', edgecolor='#15803d'))\n    for left, right in zip(list(xs[:-1]) + [xs[-1], 7.4], list(xs[1:]) + [7.4, 10.0]):\n        ax.annotate('', xy=(right - 0.45, 0.7), xytext=(left + 0.45, 0.7),\n                    arrowprops=dict(arrowstyle='->', lw=2, color='#374151'))\n    ax.set_xlim(0, 11); ax.set_ylim(0, 1.4); ax.axis('off')\n    plt.title('Representaciones internas del modelo real')\n    plt.show()\n\n\nexample_images, _ = next(iter(val_dataloader))\ninspect_unet_feature_shapes(model, example_images)","metadata":{"execution":{"iopub.status.busy":"2026-09-15T02:22:51.695729Z","iopub.execute_input":"2026-09-15T02:22:51.696275Z","iopub.status.idle":"2026-09-15T02:22:52.911125Z","shell.execute_reply.started":"2026-09-15T02:22:51.696249Z","shell.execute_reply":"2026-09-15T02:22:52.910386Z"},"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"---\n## Configuración e hiperparámetros del modelo y del entrenamiento\n\nLa configuración es una **U-Net con encoder `efficientnet-b2`**, inicializado con `encoder_weights='imagenet'`. Recibe un canal de intensidad (`in_channels=1`) y devuelve cuatro puntajes por píxel (`classes=4`), en el orden ground glass, consolidation, lungs other y background. `activation=None` conserva los logits para que `CrossEntropyLoss` trabaje directamente con ellos.\n\nLa profundidad del encoder es `encoder_depth=5`: reduce la resolución en cinco etapas, de 256 × 256 a 8 × 8. El decoder recupera la resolución con `decoder_channels=[256, 128, 64, 32, 16]`, que indica cuántos mapas de características utiliza cada bloque de la subida de la U, del más profundo al más cercano a la salida.\n\nEntrenamos durante **10 épocas**, con **lotes de 8 imágenes**. Cada época recorre el conjunto de entrenamiento en 114 lotes, incluido el último lote de una imagen, por lo que se realizan **1140 actualizaciones**. La pérdida es `nn.CrossEntropyLoss()` sin ponderación por clase: compara los cuatro logits de cada píxel con su etiqueta real y proporciona la señal para corregir los pesos. mIoU sirve para evaluar el solapamiento de las máscaras; no es la función que se deriva durante este entrenamiento.\n\nUsamos **AdamW** para actualizar todos los parámetros del modelo, incluido el encoder preentrenado. Su configuración incluye `weight_decay=1e-4` y un learning rate controlado por `OneCycleLR`, con `max_lr=1e-3` y `pct_start=0.3`. Las siguientes secciones explican cómo actúan la regularización y el calentamiento. Al terminar cada época se evalúa el modelo y se guarda un checkpoint cuando mejora la pérdida de validación.\n\n## Regularización con `weight_decay`\n\nLa regularización busca que el modelo aprenda patrones que también funcionen en imágenes nuevas y reduzca el sobreajuste al entrenamiento. Aquí usamos **`weight_decay=1e-4` en AdamW**: en cada actualización, el optimizador aplica una pequeña reducción de la magnitud de los pesos, además del ajuste que indican los gradientes. Esto desincentiva que el modelo dependa de valores excesivamente grandes para ajustarse a los ejemplos de entrenamiento.\n\nPara un peso `w`, la parte de la actualización correspondiente al decaimiento puede expresarse como `w ← (1 - lr × weight_decay) × w`; AdamW también aplica por separado su actualización adaptativa basada en los gradientes. El valor `1e-4` controla la intensidad de esa reducción. Un valor mayor regulariza con más fuerza, pero si es excesivo puede dificultar el aprendizaje. El efecto de cada paso depende también del LR, por lo que varía durante el ciclo del scheduler.\n\nEscogemos este valor como parte de la configuración del baseline. Su utilidad se revisa comparando entrenamiento y validación: si el entrenamiento sigue mejorando mientras validación empeora de forma sostenida, puede existir sobreajuste. Usar weight decay ayuda a controlarlo, pero no garantiza eliminarlo. [Referencia: AdamW en PyTorch](https://docs.pytorch.org/docs/stable/generated/torch.optim.AdamW.html).\n\n## Learning rate variable: calentamiento con `OneCycleLR`\n\nEl **learning rate (LR)** controla el tamaño base de los ajustes de los pesos. Un LR grande permite cambios más amplios, pero puede volver inestable el aprendizaje; uno pequeño permite ajustes finos, aunque puede avanzar lentamente. Usamos un LR **variable**: empieza pequeño, sube hasta un máximo y luego baja. El calentamiento o *warm-up* es esa primera subida gradual; no se refiere a la temperatura de la GPU.\n\nEn `OneCycleLR(optimizer, max_lr=max_lr, epochs=epochs, steps_per_epoch=len(train_dataloader), pct_start=pct_start)`, `optimizer` indica a qué optimizador se cambia el LR; `max_lr=1e-3` fija el pico; `epochs=10` y los 114 pasos por época definen las 1140 actualizaciones del ciclo. **`pct_start=0.3` reserva el primer 30 % del ciclo para subir el LR** y el 70 % restante para reducirlo. El pico queda cerca del paso 342, al final de la tercera época.\n\nCon los valores por defecto `div_factor=25` y `final_div_factor=10000`, el LR inicial es `1e-3 / 25 = 4e-5` y el mínimo previsto es `4e-5 / 10000 = 4e-9`. La subida gradual busca evitar ajustes bruscos al inicio; después del pico, el descenso coseno permite afinar con pasos cada vez menores. `pct_start` cambia cuánto dura la subida, no el LR máximo ni la cantidad de épocas. Por ejemplo, `0.1` daría una subida más corta y `0.5` una más larga; son ejemplos, no experimentos ejecutados aquí.\n\nEl scheduler se actualiza **después de `optimizer.step()` en cada lote**, no una vez por época. `get_lr` solo consulta el valor actual; no busca el mejor LR. Con la configuración por defecto también varía `beta1` de AdamW de manera inversa al LR, entre 0.85 y 0.95. [Referencia: OneCycleLR en PyTorch](https://docs.pytorch.org/docs/stable/generated/torch.optim.lr_scheduler.OneCycleLR.html).","metadata":{}},{"cell_type":"markdown","source":"---\n# 17. Entrenar y validar\n\nCon los hiperparámetros anteriores, la función `fit` repite el aprendizaje durante diez épocas. En cada época, `model.train()` activa el modo entrenamiento y el cargador entrega imágenes junto con sus máscaras. Ambas se trasladan a `device` para que la predicción y el cálculo de pérdida ocurran en el mismo dispositivo.\n\n## Cómo aprende un lote\n\nPrimero, `optimizer.zero_grad()` borra los gradientes del lote anterior. Después, `output = model(image)` produce cuatro logits por píxel y `loss = criterion(output, mask)` los compara con las etiquetas reales mediante `CrossEntropyLoss`. La pérdida recibe los logits directamente, antes de convertirlos en una máscara con `argmax`.\n\n`loss.backward()` calcula cómo influye cada parámetro en el error. A continuación, `optimizer.step()` actualiza los parámetros con AdamW y aplica el weight decay explicado arriba. Finalmente, `scheduler.step()` prepara el learning rate del siguiente lote. Este proceso se repite 114 veces por época; la validación no actualiza los pesos.\n\n## Qué se evalúa en validación y prueba\n\nDespués de cada época, `model.eval()` activa el modo evaluación y `torch.no_grad()` permite predecir sin construir el grafo de gradientes. En los 24 cortes de validación se comparan predicciones con máscaras reales y se promedian pérdida, mIoU y exactitud por lote. No se ejecutan `backward()`, `optimizer.step()` ni `scheduler.step()` en esta fase. Así observamos cómo funciona el modelo en ejemplos que no se usan para ajustar sus pesos.\n\nCuando disminuye `val_loss`, se guardan los parámetros en `unet_efficientnet_b2_baseline.pt`. Guardar el checkpoint no detiene el entrenamiento: se completan las diez épocas. Además, guardarlo no lo carga de nuevo automáticamente; las evaluaciones posteriores de este notebook usan el modelo que queda en memoria al finalizar la última época.\n\nLas diez imágenes de prueba se usan para producir máscaras con los pesos aprendidos. Sus etiquetas no están disponibles en el notebook, por lo que aquí no se puede medir su IoU. La validación sirve para comparar configuraciones; el conjunto de prueba se reserva para la evaluación final externa.","metadata":{}},{"cell_type":"code","source":"def get_lr(optimizer):\n    return optimizer.param_groups[0]['lr']\n\n\ndef fit(epochs, model, train_loader, val_loader, criterion, optimizer, scheduler):\n    history = {\n        'train_loss': [], 'val_loss': [],\n        'train_miou': [], 'val_miou': [],\n        'train_acc': [], 'val_acc': [], 'lrs': [],\n    }\n    best_val_loss = np.inf\n    model.to(device)\n    fit_start = time.time()\n\n    for epoch_index in range(epochs):\n        epoch_start = time.time()\n        model.train()\n        train_loss = train_iou = train_acc = 0.0\n        # LR con el que empieza la época (el scheduler lo cambia en cada lote).\n        lr_inicio = get_lr(optimizer)\n        progress = tqdm(train_loader, desc=f'Época {epoch_index + 1}/{epochs}')\n\n        for image, mask in progress:\n            image = image.to(device, non_blocking=True)\n            mask = mask.to(device, non_blocking=True)\n            optimizer.zero_grad()\n            output = model(image)\n            loss = criterion(output, mask)\n            loss.backward()\n            optimizer.step()\n            scheduler.step()\n\n            train_loss += loss.item()\n            train_iou += mIoU(output, mask)\n            train_acc += pixel_accuracy(output, mask)\n            history['lrs'].append(get_lr(optimizer))\n            # Muestra en la barra de progreso la pérdida del lote y el LR que usará el siguiente lote.\n            progress.set_postfix(loss=f'{loss.item():.3f}', lr=f'{get_lr(optimizer):.2e}')\n\n        model.eval()\n        val_loss = val_iou = val_acc = 0.0\n        with torch.no_grad():\n            for image, mask in tqdm(val_loader):\n                image = image.to(device, non_blocking=True)\n                mask = mask.to(device, non_blocking=True)\n                output = model(image)\n                val_loss += criterion(output, mask).item()\n                val_iou += mIoU(output, mask)\n                val_acc += pixel_accuracy(output, mask)\n\n        values = {\n            'train_loss': train_loss / len(train_loader),\n            'val_loss': val_loss / len(val_loader),\n            'train_miou': train_iou / len(train_loader),\n            'val_miou': val_iou / len(val_loader),\n            'train_acc': train_acc / len(train_loader),\n            'val_acc': val_acc / len(val_loader),\n        }\n        for key, value in values.items():\n            history[key].append(value)\n\n        if values['val_loss'] < best_val_loss:\n            best_val_loss = values['val_loss']\n            torch.save(model.state_dict(), 'unet_efficientnet_b2_baseline.pt')\n\n        print(\n            f\"Epoca {epoch_index + 1}/{epochs} | \"\n            f\"perdida entrenamiento: {values['train_loss']:.3f} | \"\n            f\"perdida validacion: {values['val_loss']:.3f} | \"\n            f\"mIoU validacion: {values['val_miou']:.3f} | \"\n            f\"exactitud validacion: {values['val_acc']:.3f} | \"\n            f\"lr: {lr_inicio:.2e} -> {get_lr(optimizer):.2e} | \"\n            f\"tiempo: {(time.time() - epoch_start) / 60:.2f} min\"\n        )\n\n    print(f'Tiempo total: {(time.time() - fit_start) / 60:.2f} min')\n    return history\n\n\nmax_lr = 1e-3\nepochs = 10\nweight_decay = 1e-4\npct_start = 0.3  # fracción del ciclo dedicada a subir el LR (valor por defecto de OneCycleLR)\ncriterion = nn.CrossEntropyLoss()\noptimizer = torch.optim.AdamW(model.parameters(), lr=max_lr, weight_decay=weight_decay)\nscheduler = torch.optim.lr_scheduler.OneCycleLR(\n    optimizer, max_lr=max_lr, epochs=epochs, steps_per_epoch=len(train_dataloader), pct_start=pct_start\n)\n\n# OneCycleLR guarda en el optimizador el LR inicial, el máximo y el mínimo del ciclo.\nlr_group = optimizer.param_groups[0]\nprint('Plan de OneCycleLR:')\nprint(f'  lotes por época: {len(train_dataloader)} | pasos totales: {scheduler.total_steps}')\nprint(f\"  LR inicial: {lr_group['initial_lr']:.2e}\")\nprint(f\"  LR máximo: {lr_group['max_lr']:.2e} alrededor del lote {int(pct_start * scheduler.total_steps)}\")\nprint(f\"  LR final: {lr_group['min_lr']:.2e}\")\n\nhistory = fit(epochs, model, train_dataloader, val_dataloader, criterion, optimizer, scheduler)","metadata":{"execution":{"iopub.status.busy":"2026-09-15T02:22:52.912254Z","iopub.execute_input":"2026-09-15T02:22:52.912627Z","iopub.status.idle":"2026-09-15T02:26:13.634267Z","shell.execute_reply.started":"2026-09-15T02:22:52.912601Z","shell.execute_reply":"2026-09-15T02:26:13.633528Z"},"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"---\n# 18. Revisar las curvas de entrenamiento\n\nLas curvas comparan aprendizaje y validación. Si la pérdida de entrenamiento baja mientras la de validación sube de forma sostenida, puede haber sobreajuste. Si ambas permanecen altas, puede faltar aprendizaje. La pérdida debe interpretarse junto con mIoU: una pérdida menor no garantiza un mayor solapamiento después de elegir las clases con `argmax`.\n\n## Cómo leer la curva del learning rate\n\nLa última gráfica muestra el LR por lote y las líneas punteadas separan las épocas. Con `pct_start=0.3`, el calentamiento ocupa aproximadamente los primeros 342 pasos de los 1140: sube desde `4e-5` hasta `1e-3`, cerca del final de la época 3. Después desciende suavemente hacia el mínimo previsto de `4e-9`.\n\n`history['lrs']` se guarda después de `scheduler.step()`, así que cada punto representa el LR preparado para el siguiente lote. Por la discretización de los pasos, el último valor registrado puede diferir ligeramente del mínimo previsto. Las oscilaciones de validación alrededor del pico se deben interpretar con las demás curvas; el scheduler no garantiza por sí solo una mejora.\n\n## Interpretación de la ejecución guardada\n\nEn las salidas guardadas, la pérdida de entrenamiento baja de **1.069 a 0.044** y la de validación de **0.672 a 0.179**. El mIoU de validación sube de **0.403 a 0.633**. Esto indica que el modelo aprende y mejora el solapamiento en validación, aunque el resultado todavía está lejos de la coincidencia perfecta.\n\nLa exactitud final es **0.944**, pero el mIoU es **0.633**: acertar muchos píxeles no significa delimitar todas las clases igual de bien. Hay que revisar visualmente qué lesiones quedan fuera de la predicción y qué regiones se marcan de más. La diferencia entre las pérdidas finales de entrenamiento y validación merece atención, pero una sola ejecución no permite atribuirla únicamente a sobreajuste; también influyen las diferencias entre los conjuntos.\n\nLa menor pérdida de validación guardada es **0.179 en la época 10**. Por eso ese es el checkpoint seleccionado y, en esta ejecución, coincide con los pesos de la última época que se usan en las predicciones posteriores. El criterio de selección es pérdida mínima, no mIoU máximo.","metadata":{}},{"cell_type":"code","source":"def plot_history(history):\n    figures = [\n        ('train_loss', 'val_loss', 'Perdida por epoca', 'Cross-entropy'),\n        ('train_miou', 'val_miou', 'mIoU por epoca', 'mIoU'),\n        ('train_acc', 'val_acc', 'Exactitud por pixel por epoca', 'Exactitud'),\n    ]\n    for train_key, val_key, title, ylabel in figures:\n        plt.plot(history[train_key], label='entrenamiento', marker='o')\n        plt.plot(history[val_key], label='validacion', marker='o')\n        plt.title(title)\n        plt.xlabel('Epoca')\n        plt.ylabel(ylabel)\n        plt.legend()\n        plt.grid()\n        plt.show()\n\n\n\ndef plot_lr(history, steps_per_epoch):\n    lrs = np.array(history['lrs'])\n    peak = int(np.argmax(lrs))\n\n    plt.figure(figsize=(10, 4))\n    plt.plot(lrs, label='learning rate')\n    # Una línea vertical en cada cambio de época.\n    for epoch_end in range(steps_per_epoch, len(lrs), steps_per_epoch):\n        plt.axvline(epoch_end, color='gray', linestyle='--', linewidth=0.8)\n    plt.scatter([peak], [lrs[peak]], color='red', zorder=3,\n                label=f'máximo {lrs[peak]:.2e} (lote {peak})')\n    plt.title('Evolución del learning rate (OneCycleLR)')\n    plt.xlabel('Lote de entrenamiento')\n    plt.ylabel('Learning rate')\n    plt.legend()\n    plt.grid()\n    plt.show()\n\n    print('LR al terminar cada época:')\n    for epoch_index in range(len(lrs) // steps_per_epoch):\n        print(f'  Época {epoch_index + 1}: {lrs[(epoch_index + 1) * steps_per_epoch - 1]:.2e}')\n\n\nplot_history(history)\nplot_lr(history, len(train_dataloader))","metadata":{"execution":{"iopub.status.busy":"2026-09-15T02:26:13.635126Z","iopub.execute_input":"2026-09-15T02:26:13.635482Z","iopub.status.idle":"2026-09-15T02:26:14.196054Z","shell.execute_reply.started":"2026-09-15T02:26:13.635445Z","shell.execute_reply":"2026-09-15T02:26:14.195276Z"},"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"---\n# 19. Evaluar y visualizar una predicción de validación\n\nEsta etapa no entrena nada nuevo. Su propósito es convertir las salidas del modelo ya entrenado en una inspección visual que permita responder: “¿en qué lugares acierta o falla el modelo?”.\n\n## Qué hace `predict_image_mask_miou`\n\n1. `model.eval()` coloca a U-Net en modo evaluación, igual que durante validación.\n2. `image.to(device, non_blocking=True)` y `mask.to(device, non_blocking=True)` envían el lote a GPU. La máscara se mueve porque se necesita para calcular mIoU.\n3. `with torch.no_grad():` evita almacenar gradientes. En esta fase solo queremos predecir, no modificar pesos.\n4. `output = model(image)` genera logits con forma `(lote, 4, 256, 256)`.\n5. `score = mIoU(output, mask)` aplica softmax y argmax dentro de la función mIoU, luego compara las clases predichas contra la verdad.\n6. `torch.argmax(output, dim=1)` produce el mapa de clase final: para cada píxel elige la clase cuyo logit fue mayor. Es la predicción categórica del modelo.\n7. `F.softmax(output, dim=1).permute(0, 2, 3, 1).cpu().numpy()` transforma los logits en probabilidades, cambia el orden a `(lote, alto, ancho, clases)` y mueve el resultado de GPU a un arreglo NumPy en CPU. `visualize` usa NumPy y Matplotlib, por lo que no puede recibir directamente un tensor CUDA.\n\n## De dónde sale el lote de evaluación\n\n`image, mask = next(iter(val_dataloader))` crea un iterador del cargador de validación y toma su primer lote. Como `val_dataloader` usa `shuffle=False`, normalmente son los mismos ocho cortes al volver a ejecutar el notebook. No son las predicciones de todo el conjunto: son solo un ejemplo visual.\n\n`semantic_map = mask_to_onehot(...).numpy()` convierte las etiquetas reales de forma `(lote, alto, ancho)` a cuatro canales y las deja como arreglo NumPy. Por ejemplo, el canal 0 marca dónde está ground glass y el canal 1 dónde está consolidation. Esto permite que `visualize` muestre la verdad por clase.\n\n## Cómo leer la visualización\n\nPara cada imagen del lote se muestra la tomografía y una fila por clase:\n\n- **Rojo:** el modelo asignó una probabilidad mayor que 0.5 a esa clase.\n- **Verde:** la máscara real contiene esa clase.\n- **Amarillo:** coincidencia entre predicción y máscara real.\n\nEl valor impreso `mIoU del lote mostrado` resume esos ocho cortes. Las zonas rojas sin verde son falsos positivos; las verdes sin rojo son falsos negativos.\n\nEsta gráfica es diagnóstica, no una métrica final. El umbral de 0.5 ayuda a ver superposiciones, pero la decisión formal de clase sigue siendo `argmax`, que siempre elige exactamente una de las cuatro clases por píxel. Por eso el siguiente paso calcula mIoU para todos los lotes de validación y no basa la conclusión en esta sola imagen.","metadata":{}},{"cell_type":"code","source":"def predict_image_mask_miou(model, image, mask):\n    model.eval()\n    image = image.to(device, non_blocking=True)\n    mask = mask.to(device, non_blocking=True)\n\n    with torch.no_grad():\n        output = model(image)\n        score = mIoU(output, mask)\n        predicted_mask = torch.argmax(output, dim=1).cpu().squeeze(0)\n    return predicted_mask, score, F.softmax(output, dim=1).permute(0, 2, 3, 1).cpu().numpy()\n\n\nimage, mask = next(iter(val_dataloader))\npredicted_mask, score, output = predict_image_mask_miou(model, image, mask)\nsemantic_map = mask_to_onehot(torch.unsqueeze(mask, -1).numpy(), palette).numpy()\nprint(f'mIoU del lote mostrado: {score:.3f}')\nvisualize(image.permute(0, 2, 3, 1), semantic_map, pred_batch=output)","metadata":{"execution":{"iopub.status.busy":"2026-09-15T02:26:14.197072Z","iopub.execute_input":"2026-09-15T02:26:14.197438Z","iopub.status.idle":"2026-09-15T02:26:15.447233Z","shell.execute_reply.started":"2026-09-15T02:26:14.197414Z","shell.execute_reply":"2026-09-15T02:26:15.446376Z"},"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"---\n# 20. Resumir mIoU en validación\n\nSe obtiene un mIoU por lote y se reporta su media sobre los 24 cortes reservados. En la ejecución guardada, los tres lotes dan aproximadamente **0.596, 0.633 y 0.670**, con una media de **0.633**. La diferencia muestra que el rendimiento no es uniforme entre los grupos de imágenes. El resultado describe esta validación; no es una medición de las diez imágenes de prueba ni una puntuación exclusiva de lesiones.\n\nLa separación no está hecha por paciente y el conjunto de validación es pequeño. Por eso, el promedio debe acompañarse de esta limitación y de la inspección visual de las máscaras.\n\n## Comparación con el baseline: qué cambió realmente\n\nEl baseline guardado reporta **0.6125842828** y nuestra media de los tres lotes es **0.6329114957**. La diferencia numérica es **+0.0203272129**, aproximadamente **2.03 puntos porcentuales absolutos**. Sin embargo, la agrupación de la métrica es distinta, por lo que esta diferencia todavía no demuestra una mejora equivalente en la calidad del modelo.\n\nEl cambio principal de configuración es **`batch_size`: de 24 a 8**. El baseline usa `batch_size=len(val_masks)`, es decir, 24, tanto para entrenamiento como para validación. Con 905 imágenes y diez épocas realiza 38 actualizaciones por época, **380 en total**. Nuestro modelo realiza 114 por época, **1140 en total**. Ambos recorren los mismos ejemplos durante diez épocas, pero el nuestro ajusta los pesos tres veces más a menudo, usando menos imágenes en cada actualización. Esto cambia la trayectoria del aprendizaje y puede influir en el resultado; por sí solo no demuestra que un lote menor sea mejor.\n\nTambién cambió la **interpolación de las imágenes**: el baseline usa `INTER_NEAREST` en el recorte con redimensionamiento de entrenamiento y en el redimensionamiento de validación; nuestro notebook usa `INTER_LINEAR`. Vecino más cercano toma el valor de un píxel próximo y la interpolación bilineal combina valores vecinos. Esto cambia las intensidades que ve el modelo. Para las máscaras, nuestro código conserva explícitamente vecino más cercano para no mezclar etiquetas.\n\nLa **agrupación de mIoU** requiere especial atención. El baseline reúne los 24 cortes en un único lote y calcula la intersección y unión de cada clase sobre todos ellos. Nuestro notebook calcula tres mIoU, uno por lote de ocho, y luego los promedia. Promediar razones no equivale a dividir las sumas: por ejemplo, el promedio de `1/2` y `9/10` es `0.70`, mientras que `(1+9)/(2+10)` es aproximadamente `0.833`. Por eso los resultados pueden cambiar incluso sin cambiar las predicciones. Con los tres valores resumidos no se puede reconstruir el mIoU global: hacen falta las intersecciones y uniones por clase o las predicciones.\n\n**`pct_start` no cambió su valor efectivo**: en ambos casos es 0.3, implícito en el baseline y explícito en nuestro código. También se conservan U-Net con EfficientNet-B2, los pesos de ImageNet, AdamW, `weight_decay=1e-4`, `max_lr=1e-3` y las diez épocas. El ciclo sí tiene más pasos por el cambio de lote: el calentamiento ocupa aproximadamente 114 pasos de 380 en el baseline y 342 de 1140 en nuestro caso, cerca del final de la tercera época en ambos.\n\nNuestro notebook además fija `SEED=42` para NumPy y PyTorch y configura cuDNN determinista; el baseline no incluye esa configuración. Esto modifica las condiciones aleatorias de la ejecución, pero no garantiza mejorar el resultado ni controlar todas las fuentes de variación. Los archivos tampoco fijan todas las versiones de las bibliotecas. El baseline contiene una condición de parada anticipada, pero sus salidas confirman que completó las diez épocas, igual que nuestro entrenamiento.\n\nLa conclusión es que **el resultado reportado aumentó aproximadamente 0.0203, con cambios de tamaño de lote, interpolación y condiciones de ejecución, además de una agregación distinta de mIoU**. Para atribuir la diferencia a un hiperparámetro, primero hay que evaluar ambos modelos con la misma agregación sobre los 24 cortes y después comparar entrenamientos cambiando una sola variable. Una opción es acumular intersecciones y uniones por clase en todo el conjunto, independientemente del tamaño de lote. No se puede asegurar con estas dos ejecuciones cuál de los cambios produjo la diferencia.","metadata":{}},{"cell_type":"code","source":"def miou_score(model, data_loader):\n    scores = []\n    for image, mask in tqdm(data_loader):\n        _, score, _ = predict_image_mask_miou(model, image, mask)\n        scores.append(score)\n    return scores\n\n\nvalidation_miou = miou_score(model, val_dataloader)\nprint(f'mIoU medio de validacion: {np.mean(validation_miou):.3f}')\nprint(f'mIoU por lote: {validation_miou}')","metadata":{"execution":{"iopub.status.busy":"2026-09-15T02:26:15.448241Z","iopub.execute_input":"2026-09-15T02:26:15.448552Z","iopub.status.idle":"2026-09-15T02:26:15.702522Z","shell.execute_reply.started":"2026-09-15T02:26:15.448528Z","shell.execute_reply":"2026-09-15T02:26:15.701791Z"},"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"---\n# 21. Predecir máscaras para imágenes de prueba sin etiqueta\n\nLas diez imágenes de `test_images_medseg` pasan por el mismo preprocesamiento de intensidades y por el mismo redimensionamiento que validación. No se les proporciona máscara al modelo.\n\nPara cada imagen, U-Net devuelve cuatro probabilidades por píxel: una por clase. `argmax` elige la clase más probable y produce una máscara de una sola capa. Como no existe máscara real, aquí no podemos calcular pérdida, mIoU, Dice ni exactitud; solo podemos visualizar la predicción.\n\nEsta es la respuesta directa a “¿cómo crea la máscara?”. El modelo no necesita una máscara de prueba: usa los pesos que ajustó antes al comparar muchas imágenes de entrenamiento contra sus máscaras reales.","metadata":{}},{"cell_type":"code","source":"def prepare_test_image(image):\n    resized = val_augs(image=image)['image']\n    resized = Image.fromarray(np.squeeze(resized, axis=2))\n    transform = T.Compose([T.ToTensor(), T.Normalize([0.485], [0.229])])\n    return transform(resized)\n\n\ndef predict_test_images(model, images):\n    model.eval()\n    image_batch = torch.stack([prepare_test_image(image) for image in images])\n\n    with torch.no_grad():\n        logits = model(image_batch.to(device, non_blocking=True))\n        probabilities = F.softmax(logits, dim=1).cpu().numpy()\n        predicted_masks = torch.argmax(logits, dim=1).cpu().numpy()\n\n    return image_batch.numpy(), probabilities, predicted_masks\n\n\ndef plot_test_predictions(images, predicted_masks, num_samples=8):\n    class_names = ['Ground glass', 'Consolidation', 'Lungs other', 'Background']\n    cmap = plt.get_cmap('viridis', 4)\n    samples = min(num_samples, len(images))\n    fig, axes = plt.subplots(2, samples, figsize=(2.5 * samples, 5))\n\n    for index in range(samples):\n        axes[0, index].imshow(images[index, 0], cmap='gray')\n        axes[0, index].set_title(f'Test {index}')\n        axes[0, index].axis('off')\n\n        axes[1, index].imshow(predicted_masks[index], cmap=cmap, vmin=0, vmax=3)\n        axes[1, index].axis('off')\n\n    handles = [\n        plt.Line2D([0], [0], marker='s', color='w', markerfacecolor=cmap(i),\n                   markersize=10, label=name)\n        for i, name in enumerate(class_names)\n    ]\n    fig.legend(handles=handles, loc='lower center', ncol=4)\n    fig.suptitle('Arriba: tomografías de prueba. Abajo: máscaras creadas por U-Net.')\n    plt.tight_layout(rect=(0, 0.12, 1, 0.92))\n    plt.show()\n\n\ntest_batch, test_probabilities, test_predicted_masks = predict_test_images(model, test_images_medseg)\nplot_test_predictions(test_batch, test_predicted_masks, num_samples=len(test_images_medseg))","metadata":{"execution":{"iopub.status.busy":"2026-09-15T02:26:15.703512Z","iopub.execute_input":"2026-09-15T02:26:15.703843Z","iopub.status.idle":"2026-09-15T02:26:16.612346Z","shell.execute_reply.started":"2026-09-15T02:26:15.703809Z","shell.execute_reply":"2026-09-15T02:26:16.611519Z"},"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"---\n# 22. Conclusión, limitaciones y referencias\n\nEl baseline combina dos fuentes de tomografías, estandariza intensidades, aumenta los datos y entrena una U-Net para asignar una de cuatro clases a cada píxel. mIoU permite revisar el solapamiento de regiones; la exactitud por píxel debe leerse con precaución por el predominio del fondo.\n\nLas limitaciones principales son la validación con cortes consecutivos, la diferencia entre MedSeg y Radiopaedia, la pérdida de detalle al reducir resolución y la agregación de métricas por lote. La mejora reportada frente al baseline necesita acompañarse de las configuraciones y puntuaciones de ambas ejecuciones para identificar qué cambió.\n\n## Uso de modelos de lenguaje\n\nSe utilizaron modelos de lenguaje como apoyo para comprender el notebook de referencia, organizar explicaciones y revisar la claridad del texto. El equipo revisa el código, ejecuta los experimentos y es responsable de interpretar los resultados.\n\n## Referencias\n\n1. M. A. Maftouni, [PyTorch Baseline for Semantic Segmentation](https://www.kaggle.com/code/maedemaftouni/pytorch-baseline-for-semantic-segmentation), Kaggle.\n2. O. Ronneberger, P. Fischer y T. Brox, [U-Net: Convolutional Networks for Biomedical Image Segmentation](https://arxiv.org/abs/1505.04597), 2015.\n3. Competencia [COVID-19 Segmentation](https://www.kaggle.com/competitions/covid-segmentation), Kaggle.","metadata":{}},{"cell_type":"markdown","source":"---\n# 23. Generar el archivo de envío para Kaggle\n\nLa competencia evalúa las máscaras de las 10 imágenes de prueba en su **tamaño original, 512×512**, y solo para\nlas dos clases de lesión: ground glass (canal 0) y consolidation (canal 1). Este es el mismo formato que genera\nel notebook de referencia.\n\n1. La máscara predicha de 256×256 se amplía a 512×512 con **vecino más cercano**. Así cada píxel conserva una\n   clase entera y no aparecen clases intermedias.\n2. La máscara de clase única se convierte en dos canales binarios: `1` donde se predijo ground glass o\n   consolidation y `0` en el resto.\n3. El arreglo `(10, 512, 512, 2)` se aplana en el orden imagen → fila → columna → canal. Cada posición es una\n   fila del CSV con columnas `Id` y `Predicted`.\n\nEl archivo `sub.csv` queda en `/kaggle/working`. Al guardar la versión del notebook con \"Save & Run All\",\nKaggle lo muestra en la sección Output y permite enviarlo a la competencia.","metadata":{}},{"cell_type":"code","source":"import pandas as pd\n\nLESION_CLASSES = [0, 1]  # ground glass y consolidation\nORIGINAL_SIZE = 512\n\n\ndef build_submission(predicted_masks, path='sub.csv'):\n    # 1. Ampliar cada máscara a 512x512 sin mezclar clases.\n    masks_512 = np.stack([\n        cv2.resize(mask.astype(np.uint8), (ORIGINAL_SIZE, ORIGINAL_SIZE), interpolation=cv2.INTER_NEAREST)\n        for mask in predicted_masks\n    ])\n    # 2. Un canal binario por clase de lesión: forma (N, 512, 512, 2).\n    lesion_channels = np.stack([masks_512 == class_index for class_index in LESION_CLASSES], axis=-1)\n    # 3. Aplanar y guardar con el formato Id,Predicted.\n    predicted = lesion_channels.ravel().astype(np.uint8)\n    submission = pd.DataFrame({'Id': np.arange(predicted.size), 'Predicted': predicted})\n    submission.to_csv(path, index=False)\n\n    print(f'Forma antes de aplanar: {lesion_channels.shape}')\n    print(f'Filas en {path}: {len(submission):,}')\n    print(f'Píxeles marcados como lesión: {predicted.mean():.2%}')\n    return submission\n\n\nsubmission = build_submission(test_predicted_masks)\nsubmission.head()","metadata":{"execution":{"iopub.status.busy":"2026-09-15T02:26:16.613535Z","iopub.execute_input":"2026-09-15T02:26:16.613875Z","iopub.status.idle":"2026-09-15T02:26:20.712121Z","shell.execute_reply.started":"2026-09-15T02:26:16.613851Z","shell.execute_reply":"2026-09-15T02:26:20.711425Z"},"trusted":true},"outputs":[],"execution_count":null}]}