14  Arquitecturas de redes convolucionales

deep-learning
pytorch
cnn
arquitecturas
Autor/a

Wilder Ramírez Delgado

Fecha de publicación

23 de octubre de 2026

14.1 El problema que vas a resolver

Ya puedes entrenar una CNN para reconocer una imagen. Ahora quieres cambiar su arquitectura con una razón concreta: ¿conviene apilar operaciones pequeñas, mirar varias escalas en paralelo o dejar una ruta que conserve la entrada? Compararás esas ideas reconociendo las mismas diez categorías de prendas de Fashion-MNIST.

  • Tiempo estimado de lectura: 65 minutos, más cerca de 10 minutos de ejecución en CPU.
  • Nivel: Básico. Tipo de capítulo: teórico-aplicado.
  • Conocimientos previos: fundamentos CNN, clasificación con CNN y el ciclo de aprendizaje del Apéndice A.
  • Herramientas: PyTorch, torchvision, NumPy, pandas, matplotlib y scikit-learn.

Al terminar podrás reconocer las ideas de VGG, Inception y ResNet, calcular el alcance de filtros apilados y distinguir concatenar de sumar. Podrás comparar redes llanas y residuales con la misma profundidad, los mismos parámetros y pesos iniciales idénticos, además de interpretar sus curvas y su coste.

Primero ubicarás las familias en una línea histórica. Después comprobarás sus operaciones con números pequeños y construirás versiones reducidas. Entrenarás seis configuraciones, elegirás con validación y abrirás prueba para informar sus resultados. La última lectura conectará esas piezas con las redes preentrenadas del capítulo siguiente.

TipLo que necesitas recordar
Concepto En una línea Dónde se explica
Tensor, forma, nn.Module y forward Números organizados y definición del recorrido por la red Apéndice A
Pérdida, gradiente, tasa, lote y época Medir y corregir por grupos de ejemplos Apéndice A
Semilla, CPU y state_dict Control del azar, procesador y estado del modelo Apéndice A
Logits, softmax y CE Salidas de clasificación y pérdida para elegir MLP
Adam e inicialización Cómo se ajustan los pesos y cómo comienzan Optimización
Entrenamiento, validación, prueba y métricas Ajustar, elegir y medir el resultado final Evaluación
Sobreajuste, checkpoint, paciencia y parada temprana Distinguir ajuste excesivo y conservar la mejor versión Regularización
Convolución, ReLU, pooling, canal y Flatten Las operaciones espaciales y su organización Fundamentos CNN

14.2 Arquitectura: decidir por dónde viaja la información

NotaArquitectura, profundidad y anchura

La arquitectura define las operaciones de una red y cómo se conectan. La profundidad cuenta etapas sucesivas; aquí indicaremos el número de convoluciones. La anchura expresa cuántos canales o unidades hay en una etapa.

Piensa en un taller que recibe una pieza y la transforma. Puedes añadir estaciones en serie, poner estaciones en paralelo o conservar una copia de la pieza mientras calculas una modificación. Las tres decisiones cambian el recorrido, aunque cada estación use herramientas conocidas.

La tabla ubica algunas familias y el problema de diseño al que respondieron. Los enlaces llevan a los trabajos originales. Las fechas corresponden a sus primeras publicaciones; VGG e Inception circularon en 2014 y ResNet en 2015 antes de sus publicaciones en conferencias.

Año Familia Idea que conviene reconocer
1998 LeNet-5 Combinar convoluciones, reducción espacial y capas finales para reconocer documentos
2012 AlexNet Entrenar una CNN mayor con GPU, ReLU y regularización sobre muchas imágenes
2014 VGG Apilar de forma regular filtros pequeños de 3 × 3
2014 GoogLeNet / Inception Combinar operaciones de distintos tamaños en paralelo
2015 ResNet Sumar a la transformación una ruta que conserva su entrada
2017 MobileNet Procesar cada canal por separado y después mezclarlos para reducir cálculo
2019 EfficientNet Equilibrar profundidad, anchura y resolución al ampliar la red
2022 ConvNeXt Modernizar los bloques convolucionales y su entrenamiento

En el experimento usarás versiones pequeñas de tres ideas: bloques tipo VGG, ramas tipo Inception y conexiones residuales. La historia describe familias completas; las redes que vas a construir permiten observar sus mecanismos con una CPU.

14.3 Un conjunto de prendas para comparar las conexiones

Trabajarás con Fashion-MNIST, de Xiao, Rasul y Vollgraf (2017), licencia MIT. Sus imágenes tienen resolución nativa de 28 × 28 y diez categorías. Se cargan desde su distribución oficial con torchvision. Una prenda tiene contornos y regiones interiores que las redes tendrán que combinar para distinguirla.

Seleccionamos de forma estratificada 8000 de las 60000 imágenes de desarrollo: 6400 para entrenamiento y 1600 para validación. Las 10000 oficiales de prueba se reservan completas. Todas las redes reciben los mismos índices y píxeles en la escala de 0 a 1.

La primera celda fija una sola semilla y registra el entorno. El archivo documentado apoyo_cnn.py reúne entrenamiento, evaluación y gráficas; puedes desplegar su código al final.

from pathlib import Path
import copy
import json
import platform
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import torch
import torchvision
from torch import nn
from torch.nn import functional as F
from IPython.display import display
import apoyo_cnn as apoyo

SEMILLA = 42
np.random.seed(SEMILLA)
torch.manual_seed(SEMILLA)
torch.set_num_threads(2)
dispositivo = torch.device("cpu")
artefactos = Path("artefactos_pytorch")
artefactos.mkdir(exist_ok=True)
versiones = {
    "python": platform.python_version(), "torch": str(torch.__version__),
    "torchvision": torchvision.__version__, "numpy": np.__version__,
    "pandas": pd.__version__, "dispositivo": str(dispositivo), "hilos": 2,
}
print(versiones)
{'python': '3.13.14', 'torch': '2.14.1+cpu', 'torchvision': '0.29.1+cpu', 'numpy': '2.5.1', 'pandas': '3.0.5', 'dispositivo': 'cpu', 'hilos': 2}

La salida python, torch, torchvision y dispositivo identifica las bibliotecas y la CPU de esta comparación. El resto del experimento comparte esa configuración y la semilla 42.

Carga las prendas y comprueba sus formas y cantidades. La galería muestra una imagen de entrenamiento por categoría.

datos = apoyo.datos("fashion", 8000, SEMILLA)
ajuste = datos["entrenamiento"]
validacion = datos["validacion"]
prueba = datos["prueba"]
nombres = ["Camiseta", "Pantalón", "Suéter", "Vestido", "Abrigo",
           "Sandalia", "Camisa", "Zapatilla", "Bolso", "Botín"]
print("Ajuste, validación y prueba:", len(ajuste), len(validacion), len(prueba))
print("Forma:", tuple(ajuste.tensors[0].shape))
figura, ejes = plt.subplots(2, 5, figsize=(10, 4))
for numero, eje in enumerate(ejes.flat):
    indice = np.flatnonzero(ajuste.tensors[1].numpy() == numero)[0]
    eje.imshow(ajuste.tensors[0][indice, 0], cmap="gray")
    eje.set_title(nombres[numero], fontsize=9)
    eje.axis("off")
figura.tight_layout()
plt.show()
np.savez_compressed(artefactos / "particiones.npz",
                    ajuste=datos["indices_ajuste"], validacion=datos["indices_validacion"])
Ajuste, validación y prueba: 6400 1600 10000
Forma: (6400, 1, 28, 28)
Una prenda por categoría de entrenamiento muestra las diez siluetas de entrada, sin ampliar píxeles de otro conjunto.
Figura 14.1: Fashion-MNIST a su resolución nativa

La entrada de ajuste tiene forma (6400, 1, 28, 28). La salida confirma 6400 imágenes para aprender, 1600 para elegir y 10000 para informar el resultado final. La galería permite comparar contornos de prendas antes de estudiar las distintas conexiones que procesarán esos mismos píxeles.

14.4 VGG: mirar más lejos con filtros pequeños

NotaBloque y campo receptivo

Un bloque agrupa operaciones que se repiten en una arquitectura. El campo receptivo teórico de una salida es la región de la entrada que puede influir en ella a través de esas operaciones.

Con una convolución de 3 × 3, una salida combina tres posiciones por eje. Si una segunda convolución de 3 × 3 combina esas salidas, alcanza cinco posiciones de la imagen original. Es como revisar primero tres casillas vecinas y luego combinar tres revisiones que se superponen.

Dos etapas de convolución con kernel de tres, paso uno y sin dilatación: la primera alcanza tres posiciones originales y la segunda cinco. La igualdad se refiere al alcance espacial; la ReLU intermedia cambia la transformación calculada.

Dos filtros de tres posiciones alcanzan cinco posiciones de la entrada.

Con 64 canales tanto de entrada como de salida, una convolución de 5 × 5 con sesgo tiene (25 × 64 + 1) × 64 parámetros. Dos de 3 × 3 tienen 2 × (9 × 64 + 1) × 64. Comprueba las cantidades antes de decidir qué bloque usar.

una_grande = (5 * 5 * 64 + 1) * 64
dos_pequenas = 2 * (3 * 3 * 64 + 1) * 64
display(pd.DataFrame({"Configuración": ["Una 5 × 5", "Dos 3 × 3"],
                      "Parámetros": [una_grande, dos_pequenas]}))
Configuración Parámetros
0 Una 5 × 5 102464
1 Dos 3 × 3 73856

Una convolución de 5 × 5 tiene 102464 parámetros; las dos de 3 × 3 suman 73856, incluidos los sesgos. Su alcance espacial teórico coincide en 5 × 5 bajo estas condiciones. Con una ReLU intermedia, las dos operaciones pequeñas introducen una transformación adicional además de ese cambio en cantidad de pesos.

Nuestra VGG pequeña repetirá dos convoluciones de 3 × 3 con ReLU y un pooling. Sus canales crecen de 8 a 16 y a 32, mientras los tamaños bajan de 28 a 14, a 7 y a 3. Flatten reúne 32 × 3 × 3 = 288 valores para una capa densa de 64 y la salida de diez logits. El pooling sobre siete posiciones conserva tres regiones completas de dos.

Recorrido tipo VGG con dos convoluciones por bloque: ocho mapas a 14 por 14, dieciséis a siete por siete y treinta y dos a tres por tres después de pooling. Se aplanan 288 valores y se producen diez logits.

Tres bloques sucesivos de convoluciones pequeñas antes de la cabeza.

La celda define esa red y muestra sus parámetros. Los pesos se inicializan para capas con ReLU; la salida lineal usa Xavier, como se explica en optimización.

def inicializar(modelo):
    """Recibe una red y devuelve el mismo modelo con He y salida Xavier."""
    for capa in modelo.modules():
        if isinstance(capa, (nn.Conv2d, nn.Linear)):
            nn.init.kaiming_normal_(capa.weight, nonlinearity="relu")
            nn.init.zeros_(capa.bias)
    lineales = [capa for capa in modelo.modules() if isinstance(capa, nn.Linear)]
    nn.init.xavier_uniform_(lineales[-1].weight)
    return modelo

def crear_vgg():
    """Devuelve tres bloques pequeños tipo VGG y su cabeza de clasificación."""
    capas = []
    entrada = 1
    for salida in [8, 16, 32]:
        capas.extend([
            nn.Conv2d(entrada, salida, 3, padding=1), nn.ReLU(),
            nn.Conv2d(salida, salida, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
        ])
        entrada = salida
    return nn.Sequential(*capas, nn.Flatten(), nn.Linear(288, 64), nn.ReLU(),
                         nn.Linear(64, 10))

vgg = inicializar(crear_vgg())
print("Parámetros VGG pequeña:", sum(p.numel() for p in vgg.parameters()))
print("Forma de salida:", tuple(vgg(torch.zeros(2, 1, 28, 28)).shape))
Parámetros VGG pequeña: 37186
Forma de salida: (2, 10)

La VGG pequeña suma 37186 parámetros y convierte dos imágenes en una salida (2, 10). Las convoluciones y los pooling llevan las prendas hasta 32 mapas de 3 × 3; los 288 valores resultantes alimentan la capa densa. Puedes relacionar así el tamaño de la entrada de esa capa con el último bloque del diagrama.

14.5 Inception: trabajar con varias ventanas en paralelo

NotaRama y concatenación

Una rama recibe la misma entrada que otras rutas paralelas y aplica sus propias operaciones. Concatenar reúne sus salidas agregando canales. Conserva por separado los valores de cada rama.

Piensa en tres personas que observan la misma tela con ventanas de distinto tamaño. Una revisa cada posición y mezcla sus canales; otra mira una región de 3 × 3 y otra de 5 × 5. Reunir sus observaciones produce una pila de mapas más ancha para la siguiente etapa.

La convolución de 1 × 1 combina canales en cada posición. Si recibe dieciséis canales, cada salida calcula una suma de dieciséis valores ponderados y un sesgo, sin incluir posiciones vecinas. En diseños completos puede reducir canales antes de filtros de mayor tamaño.

Nuestro bloque tendrá tres ramas de 1 × 1, 3 × 3 y 5 × 5, con padding 0, 1 y 2. Cada una conserva alto y ancho. Si cada rama entrega ocho canales, concatenarlas produce veinticuatro.

Una misma entrada se dirige a tres ramas con kernels uno, tres y cinco. Cada rama produce ocho mapas con igual alto y ancho. Las tres rutas llegan a una concatenación que produce veinticuatro mapas; se apilan canales en lugar de sumarlos.

Tres ramas paralelas se reúnen por el eje de canales.

La red pequeña utiliza dos bloques de estas ramas.

NotaPromedio global

Un promedio global reduce cada mapa a la media de sus posiciones: de cuarenta y ocho mapas obtienes cuarenta y ocho números. Es un resumen espacial que alimenta la cabeza sin aplanar todas las posiciones.

La celda comprueba las formas de las ramas y define la red que entrenaremos.

class Ramas(nn.Module):
    """Combina tres convoluciones paralelas conservando sus canales separados."""
    def __init__(self, entrada, salida):
        super().__init__()
        self.ramas = nn.ModuleList([
            nn.Conv2d(entrada, salida, kernel, padding=kernel // 2)
            for kernel in [1, 3, 5]
        ])

    def forward(self, entrada):
        resultados = [F.relu(rama(entrada)) for rama in self.ramas]
        return torch.cat(resultados, dim=1)

def crear_inception():
    """Devuelve una red de dos bloques de ramas con promedio global."""
    return nn.Sequential(
        nn.Conv2d(1, 8, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
        Ramas(8, 8), nn.MaxPool2d(2), Ramas(24, 16),
        nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(48, 64),
        nn.ReLU(), nn.Linear(64, 10),
    )

ramas_control = Ramas(16, 8)
print("Tres ramas de ocho canales:", tuple(ramas_control(torch.zeros(2, 16, 14, 14)).shape))
inception = inicializar(crear_inception())
print("Parámetros de la red de ramas:", sum(p.numel() for p in inception.parameters()))
Tres ramas de ocho canales: (2, 24, 14, 14)
Parámetros de la red de ramas: 19618

Las tres ramas producen la forma (2, 24, 14, 14): dos imágenes, veinticuatro canales y catorce posiciones por eje. El 24 sale de reunir tres grupos de ocho mapas. La red completa de ramas tiene 19618 parámetros, que se contrastarán con sus resultados después del entrenamiento.

14.6 ResNet: aprender una modificación de la entrada

NotaConexión residual e identidad

Una conexión residual suma la entrada x a una transformación F(x). La identidad es la ruta que conserva x sin cambiarla. En este capítulo el bloque residual calcula ReLU(F(x) + x) y el llano calcula ReLU(F(x)).

Si ya tienes un borrador útil, puedes aprender una corrección en lugar de escribirlo entero otra vez. Esa es la intuición del atajo: conservar una ruta para los valores recibidos y combinarla con una modificación aprendida. F(x) tendrá dos convoluciones de 3 × 3 con una ReLU entre ellas.

Para x = [1, 2] y F(x) = [0.5, -0.5], la ruta llana entrega [0.5, 0] después de ReLU. La residual suma primero y entrega [1.5, 1.5]. Conserva la entrada y añade su corrección por posición.

La entrada se divide en una ruta con dos convoluciones y una ruta de identidad. Ambas llegan a una suma elemento a elemento antes de ReLU. La alternativa llana usa las mismas convoluciones, pero no añade la identidad.

La ruta de identidad se suma a la transformación y después se aplica ReLU.
NotaProyección y suma compatible

Una proyección transforma la ruta de entrada para hacer compatibles sus dimensiones con F(x), por ejemplo con una convolución de 1 × 1. Para sumar posición a posición necesitas el mismo número de canales y el mismo tamaño espacial.

En las parejas del experimento los bloques conservarán ocho canales y el tamaño espacial. Así la identidad puede pasar directamente, sin parámetros nuevos. El pooling se aplicará después de los bloques tercero y sexto cuando existan, fuera de la suma.

Comprueba la suma numérica y una proyección de ocho a dieciséis canales. Después define el bloque llano y el residual usando las mismas dos convoluciones.

entrada_control = torch.tensor([1., 2.])
transformacion = torch.tensor([0.5, -0.5])
print("Llana:", F.relu(transformacion).tolist())
print("Residual:", F.relu(transformacion + entrada_control).tolist())
proyeccion = nn.Conv2d(8, 16, 1)
print("Proyección:", tuple(proyeccion(torch.zeros(2, 8, 14, 14)).shape))

class Bloque(nn.Module):
    """Aplica dos convoluciones; suma identidad cuando residual es verdadero."""
    def __init__(self, canales, residual):
        super().__init__()
        self.primera = nn.Conv2d(canales, canales, 3, padding=1)
        self.segunda = nn.Conv2d(canales, canales, 3, padding=1)
        self.residual = residual

    def forward(self, entrada):
        transformada = self.segunda(F.relu(self.primera(entrada)))
        if self.residual:
            transformada = transformada + entrada
        return F.relu(transformada)

class Profunda(nn.Module):
    """Recibe imágenes NCHW y devuelve diez logits con cuatro u ocho bloques."""
    def __init__(self, cantidad, residual):
        super().__init__()
        self.entrada = nn.Conv2d(1, 8, 3, padding=1)
        self.bloques = nn.ModuleList([Bloque(8, residual) for _ in range(cantidad)])
        self.salida = nn.Linear(8, 10)

    def forward(self, entrada):
        salida = F.relu(self.entrada(entrada))
        for indice, bloque in enumerate(self.bloques):
            salida = bloque(salida)
            if indice in [2, 5]:
                salida = F.max_pool2d(salida, 2)
        return self.salida(salida.mean((2, 3)))
Llana: [0.5, 0.0]
Residual: [1.5, 1.5]
Proyección: (2, 16, 14, 14)

La salida llana es [0.5, 0.0], mientras la residual conserva y modifica la entrada para producir [1.5, 1.5]. La proyección cambia la forma a (2, 16, 14, 14): duplica los canales y mantiene el tamaño espacial. En las parejas siguientes los canales permanecen en ocho, por lo que la ruta de identidad puede sumarse directamente.

14.7 Preparar una comparación controlada

NotaAblación, degradación y dificultad de ajuste

Una ablación cambia una pieza para estudiar su efecto, manteniendo otras condiciones fijas. Aquí la pieza es sumar o no la identidad.

La degradación por profundidad describe un ajuste de entrenamiento peor al añadir capas en determinadas condiciones. Se distingue del sobreajuste, donde las imágenes conocidas mejoran mientras la generalización empeora.

Construirás una pareja de cuatro bloques y otra de ocho. Cada bloque tiene dos convoluciones y hay una convolución de entrada: son nueve y diecisiete convoluciones, respectivamente. Dentro de cada pareja, ambas redes tendrán el mismo número de parámetros y copias exactas de sus pesos iniciales.

Esas copias evitan comparar un atajo con una inicialización afortunada diferente. Los lotes también compartirán la semilla de su generador. Entre cuatro y ocho bloques sí cambia la cantidad de parámetros: la comparación que aísla la conexión se hace dentro de cada pareja.

La siguiente celda crea las redes y comprueba igualdad de pesos y parámetros antes de entrenar. La VGG y la red de ramas completan las seis configuraciones.

modelos = {"VGG pequeña": vgg, "Ramas Inception": inception}
comprobaciones = []
for cantidad in [4, 8]:
    llana = inicializar(Profunda(cantidad, False))
    residual = copy.deepcopy(llana)
    for bloque in residual.bloques:
        bloque.residual = True
    cantidad_llana = sum(p.numel() for p in llana.parameters())
    cantidad_residual = sum(p.numel() for p in residual.parameters())
    iguales = all(torch.equal(llana.state_dict()[nombre], residual.state_dict()[nombre])
                  for nombre in llana.state_dict())
    assert cantidad_llana == cantidad_residual
    assert iguales
    comprobaciones.append({"Bloques": cantidad, "Convoluciones": 1 + 2 * cantidad,
                           "Parámetros por red": cantidad_llana, "Pesos iguales": iguales})
    modelos[f"Llana {cantidad}"] = llana
    modelos[f"Residual {cantidad}"] = residual
display(pd.DataFrame(comprobaciones))
Bloques Convoluciones Parámetros por red Pesos iguales
0 4 9 4842 True
1 8 17 9514 True

La pareja de cuatro bloques tiene nueve convoluciones y 4842 parámetros por red. La de ocho tiene diecisiete y 9514. En ambas filas, Pesos iguales es True: copiar la red llana y habilitar el atajo conserva exactamente sus pesos iniciales. Esa comprobación hace visible qué condición se mantiene y cuál cambia.

14.8 Entrenar con una regla compartida

Las seis redes usarán las mismas 6400 prendas de entrenamiento, las mismas 1600 de validación, CE, Adam con tasa 0.001 y lotes de 128. El máximo inicial será 60 épocas y la paciencia 5. Cada modelo recuperará la versión de menor CE de validación y comprobará que su mejor época quede antes del máximo; el apoyo amplía ese máximo si hace falta.

Primero entrena las dos redes pequeñas de patrones VGG e Inception, y después las parejas de cuatro y ocho bloques. La tabla inicial resume ajuste y selección; aún no consulta prueba.

historias = {}
resumenes = {}
for nombre, modelo in modelos.items():
    print("Modelo:", nombre)
    historial, resumen = apoyo.entrenar(
        modelo, ajuste, validacion, dispositivo, SEMILLA,
        maximo=60, paciencia=5, tasa=0.001,
    )
    historias[nombre] = historial
    resumenes[nombre] = resumen
    historial.to_csv(artefactos / (nombre.replace(" ", "-") + "-historial.csv"), index=False)
display(pd.DataFrame(resumenes).T)
Modelo: VGG pequeña
Mejor época < máximo: 15 < 60 = True
Modelo: Ramas Inception
Mejor época < máximo: 45 < 60 = True
Modelo: Llana 4
Mejor época < máximo: 39 < 60 = True
Modelo: Residual 4
Mejor época < máximo: 33 < 60 = True
Modelo: Llana 8
Mejor época < máximo: 28 < 60 = True
Modelo: Residual 8
Mejor época < máximo: 33 < 60 = True
mejor_epoca epocas_ejecutadas maximo_final perdida_validacion segundos parametros
VGG pequeña 15.0 20.0 60.0 0.359052 29.558750 37186.0
Ramas Inception 45.0 50.0 60.0 0.467256 60.518111 19618.0
Llana 4 39.0 44.0 60.0 0.496204 115.020774 4842.0
Residual 4 33.0 38.0 60.0 0.536384 110.868787 4842.0
Llana 8 28.0 33.0 60.0 0.402676 140.859458 9514.0
Residual 8 33.0 38.0 60.0 0.379235 138.075200 9514.0

Las seis comprobaciones quedaron antes del máximo 60: VGG eligió la época 15, las ramas Inception la 45, la llana de cuatro la 39, la residual de cuatro la 33, la llana de ocho la 28 y la residual de ocho la 33. Sus pérdidas mínimas de validación fueron 0.359052, 0.467256, 0.496204, 0.536384, 0.402676 y 0.379235, respectivamente. La tabla muestra que cada modelo agotó cinco épocas adicionales sin un nuevo mínimo.

14.9 Curvas: qué aprendió cada pareja

Las pérdidas de entrenamiento se miden sobre las imágenes utilizadas para ajustar pesos. Las de validación usan las prendas reservadas para elegir. Se calculan al terminar cada época, en modo evaluación y con los mismos pesos.

Las curvas siguientes colocan cada pareja en una fila. La izquierda muestra ajuste y la derecha validación. Para estudiar una dificultad de entrenamiento, comienza por el panel de ajuste; después mira qué ocurre al pasar a las imágenes reservadas.

figura, ejes = plt.subplots(2, 2, figsize=(11, 7))
for fila, cantidad in enumerate([4, 8]):
    for prefijo, estilo, color in [("Llana", "-o", "#1786B3"),
                                    ("Residual", "--s", "#EE5400")]:
        historial = historias[f"{prefijo} {cantidad}"]
        for columna, medida in enumerate(["perdida_ajuste", "perdida_validacion"]):
            ejes[fila, columna].plot(historial.epoca, historial[medida], estilo,
                                    color=color, label=prefijo, markersize=3)
    for columna, titulo in enumerate(["Entrenamiento (evaluación)", "Validación"]):
        ejes[fila, columna].set(xlabel="Época", ylabel="Entropía cruzada",
                                title=f"{cantidad} bloques · {titulo}")
        ejes[fila, columna].legend()
figura.tight_layout()
plt.show()
for nombre in ["Llana 4", "Residual 4", "Llana 8", "Residual 8"]:
    ultima = historias[nombre].iloc[-1]
    print(nombre, "CE ajuste final:", round(ultima.perdida_ajuste, 6),
          "CE validación final:", round(ultima.perdida_validacion, 6))
Dos filas comparan cuatro y ocho bloques, con pérdida de ajuste a la izquierda y validación a la derecha. La red llana usa círculos continuos y la residual cuadrados discontinuos.
Figura 14.2: Con y sin atajos a cuatro y ocho bloques
Llana 4 CE ajuste final: 0.501618 CE validación final: 0.50843
Residual 4 CE ajuste final: 0.541578 CE validación final: 0.540028
Llana 8 CE ajuste final: 0.340506 CE validación final: 0.444138
Residual 8 CE ajuste final: 0.303702 CE validación final: 0.38392

Con cuatro bloques, la pérdida de entrenamiento al cierre fue 0.501618 para la llana y 0.541578 para la residual. Sus mínimos de validación también favorecieron a la llana: 0.496204 frente a 0.536384. Con ocho bloques se invirtió ese orden de validación, con 0.402676 para la llana y 0.379235 para la residual. Las curvas permiten seguir cómo se acercan después de un inicio diferente.

La llana de ocho terminó con pérdida de ajuste 0.340506, por debajo del 0.501618 de la llana de cuatro. En este ensayo, la mayor profundidad vino acompañada de mejor ajuste. Los valores al cierre corresponden a cantidades distintas de épocas; compara también puntos de una misma época si quieres igualar el número de recorridos por los datos. Las versiones que se informarán en prueba se eligen por sus mínimos de validación.

14.10 Elegir y abrir prueba

Antes de consultar prueba, selecciona la red con menor CE de validación entre las seis configuraciones. Después calcula una vez por modelo sus predicciones sobre las 10000 prendas oficiales. Así puedes informar todos los resultados sin cambiar la elección.

La tabla muestra desempeño, parámetros y segundos por separado. Los tiempos incluyen las evaluaciones al cerrar épocas; miden el ajuste de este experimento, no el tiempo que tardaría atender una solicitud con una sola imagen.

elegido = min(resumenes, key=lambda nombre: resumenes[nombre]["perdida_validacion"])
print("Elegido antes de abrir prueba:", elegido)
resultados = {}
filas = []
for nombre, modelo in modelos.items():
    resultado = apoyo.evaluar(modelo, prueba, dispositivo, SEMILLA)
    resultados[nombre] = resultado
    filas.append({"Modelo": nombre, **resumenes[nombre],
                  "CE prueba": resultado["perdida"], "Exactitud prueba": resultado["exactitud"]})
tabla = pd.DataFrame(filas)
display(tabla)
tabla.to_csv(artefactos / "comparacion.csv", index=False)
Elegido antes de abrir prueba: VGG pequeña
Modelo mejor_epoca epocas_ejecutadas maximo_final perdida_validacion segundos parametros CE prueba Exactitud prueba
0 VGG pequeña 15 20 60 0.359052 29.558750 37186 0.402293 0.8597
1 Ramas Inception 45 50 60 0.467256 60.518111 19618 0.504286 0.8205
2 Llana 4 39 44 60 0.496204 115.020774 4842 0.548929 0.8038
3 Residual 4 33 38 60 0.536384 110.868787 4842 0.574156 0.7990
4 Llana 8 28 33 60 0.402676 140.859458 9514 0.461069 0.8341
5 Residual 8 33 38 60 0.379235 138.075200 9514 0.455051 0.8441

La selección previa eligió VGG pequeña, con CE de validación 0.359052. En prueba obtuvo 85.97 %, frente a 82.05 % de las ramas Inception. La pareja de cuatro bloques dio 80.38 % para la llana y 79.90 % para la residual; la de ocho dio 83.41 % y 84.41 %. El sentido de la diferencia entre los atajos cambió según la profundidad en este conjunto y presupuesto.

Tip¿Entonces los atajos no ayudan?

Ayudan cuando la red es profunda. ResNet los propuso para redes de decenas y cientos de capas, en las que una red llana empieza a entrenar peor a medida que crece. Con 9 convoluciones, la red llana de cuatro bloques aprende sin dificultad y el atajo no aporta: su pérdida de entrenamiento al cierre es 0.501618 frente a 0.541578 de la residual. Con 17 convoluciones aparece la ventaja: la residual de ocho bloques cierra con 0.303702 frente a 0.340506 y gana en prueba, 84.41 % frente a 83.41 %. Lo esperable, según He y colaboradores (2015), es que la diferencia crezca con más bloques; repetir la pareja con 30 o 50 bloques es un buen ejercicio para tu computador.

La siguiente figura relaciona tamaño y exactitud de prueba. Cada símbolo tiene una etiqueta de modelo, por lo que puedes leer los puntos sin distinguir colores. Las configuraciones VGG e Inception tienen parámetros y operaciones distintos; utiliza esta tabla para describir compromisos de esas redes concretas.

figura, eje = plt.subplots(figsize=(9, 5))
for indice, fila in tabla.iterrows():
    marca = ["o", "s", "^", "D", "v", "P"][indice]
    eje.scatter(fila["parametros"], fila["Exactitud prueba"], marker=marca, s=65)
    eje.annotate(fila["Modelo"], (fila["parametros"], fila["Exactitud prueba"]),
                 xytext=(5, 5), textcoords="offset points", fontsize=9)
eje.set(xlabel="Parámetros totales", ylabel="Exactitud en prueba",
        title="Tamaño y resultado de las seis configuraciones")
figura.tight_layout()
plt.show()
Cada punto tiene símbolo y etiqueta de modelo. Se comparan parámetros totales y exactitud de prueba, después de fijar la selección por validación.
Figura 14.3: Tamaño y exactitud de las configuraciones

VGG combina 37186 parámetros y 85.97 % de exactitud; las ramas Inception tienen 19618 y alcanzan 82.05 %. La residual de ocho utiliza 9514 parámetros y llega a 84.41 %, pero su ajuste tomó 138.08 segundos frente a 29.56 de VGG. Esos tiempos también incluyen distintas cantidades de épocas: 38 para esa residual y 20 para VGG. Tamaño, tiempo y aciertos describen aspectos diferentes de la configuración.

La matriz y la galería del modelo elegido reutilizan sus predicciones ya calculadas. El promedio macro del informe da igual peso a cada categoría.

matriz, por_categoria = apoyo.diagnostico(resultados[elegido], nombres, prueba.tensors[0])
display(por_categoria.round(4))
Matriz de confusión con números de casos y ocho fotografías de errores del modelo elegido antes de abrir prueba. Las etiquetas muestran la categoría real y la predicha.
(a) Diagnóstico del modelo elegido
Matriz de confusión con números de casos y ocho fotografías de errores del modelo elegido antes de abrir prueba. Las etiquetas muestran la categoría real y la predicha.
(b)
precision recall f1-score support
Camiseta 0.8084 0.7930 0.8006 1000.0
Pantalón 0.9876 0.9590 0.9731 1000.0
Suéter 0.8155 0.7600 0.7867 1000.0
Vestido 0.8543 0.8740 0.8641 1000.0
Abrigo 0.7027 0.8510 0.7698 1000.0
Sandalia 0.9728 0.9310 0.9515 1000.0
Camisa 0.6523 0.5740 0.6106 1000.0
Zapatilla 0.9175 0.9450 0.9310 1000.0
Bolso 0.9665 0.9530 0.9597 1000.0
Botín 0.9300 0.9570 0.9433 1000.0
macro avg 0.8608 0.8597 0.8590 10000.0
(c)
Figura 14.4

La categoría Camisa tiene la menor sensibilidad, 0.5740: el modelo elegido reconoce 574 de sus 1000 ejemplos. La mayor confusión individual es camisa como abrigo, con 152 casos. La precisión de Abrigo es 0.7027 frente a su sensibilidad 0.8510, consistente con recibir predicciones de otras categorías. Examina los contornos de las prendas que aparecen en la galería al proponer una revisión posterior.

14.11 Un error al reunir ramas

Una convolución de 3 × 3 sin padding produce un mapa más pequeño que otra de 1 × 1. Vamos a intentar concatenarlas a propósito: el mensaje permite reconocer qué dimensión falta igualar.

entrada = torch.zeros(2, 8, 28, 28)
rama_uno = nn.Conv2d(8, 8, 1)
rama_tres = nn.Conv2d(8, 8, 3)
try:
    _ = torch.cat([rama_uno(entrada), rama_tres(entrada)], dim=1)
except RuntimeError as error:
    print(type(error).__name__ + ":", str(error))
rama_corregida = nn.Conv2d(8, 8, 3, padding=1)
salida_corregida = torch.cat([rama_uno(entrada), rama_corregida(entrada)], dim=1)
print("Concatenación corregida:", tuple(salida_corregida.shape))
RuntimeError: Sizes of tensors must match except in dimension 1. Expected size 28 but got size 26 for tensor number 1 in the list.
Concatenación corregida: (2, 16, 28, 28)

El mensaje dice Expected size 28 but got size 26: la rama de 1 × 1 conserva el alto y ancho, mientras la de 3 × 3 sin borde los reduce. Al añadir padding 1, la concatenación corregida produce (2, 16, 28, 28).

Lo que ves Qué pasó Cómo se arregla
Sizes of tensors must match Las ramas conservan tamaños espaciales distintos Usa padding que conserve alto y ancho antes de concatenar
Un canal final distinto del esperado Se confundió sumar con concatenar La suma conserva canales; la concatenación agrega los de cada rama
Error al sumar el atajo x y F(x) tienen formas incompatibles Conserva las dimensiones o proyecta la ruta de entrada

14.12 Las piezas que continúan en arquitecturas modernas

NotaConvolución separable y escalado compuesto

Una convolución separable en profundidad divide el trabajo en filtrar espacialmente cada canal y después mezclar los canales con una operación de 1 × 1. MobileNet utiliza esa separación para reducir cómputo.

El escalado compuesto coordina cambios de profundidad, anchura y resolución. EfficientNet estudia ese equilibrio en lugar de ampliar solo una dimensión.

Estos diseños combinan y reorganizan piezas. MobileNetV2 agrega una organización particular de bloques y atajos; ConvNeXt revisa el diseño de bloques convolucionales y su entrenamiento con criterios más recientes. Reconocer cada pieza permite leer una arquitectura sin aprender todos sus nombres de memoria.

14.13 Alcance de las conclusiones

Las redes pequeñas de este capítulo representan ideas; sus resultados no reproducen los modelos de los trabajos originales. Fashion-MNIST mantiene una resolución pequeña y un dominio de prendas en gris. Las diferencias medidas corresponden a este subconjunto, inicialización, tasa y presupuesto; una comparación para otra aplicación necesita imágenes representativas y repeticiones dentro del desarrollo.

El número de parámetros representa almacenamiento de pesos, mientras el coste de ejecutar depende también del número de posiciones, ramas y operaciones. Las curvas permiten observar dificultad de ajuste y sobreajuste, pero para explicar gradientes o afirmar un efecto estable conviene medirlos y repetir las parejas con otras semillas. El criterio usado aquí queda fijado por validación.

14.14 Ejercicios

  1. Calcula el campo receptivo de tres convoluciones de 3 × 3 con paso uno, sin pooling entre ellas.
  2. Si una rama entrega 8 canales y otra 16, ¿cuántos deja una concatenación? ¿Se pueden sumar directamente?
  3. Para x = [2, -1] y F(x) = [-0.5, 3], calcula las salidas llana y residual después de ReLU.
  4. Explica por qué copiar pesos iniciales mejora la comparación de cada pareja.
  5. Propón una repetición que estudie la variabilidad de la conexión residual sin usar prueba para elegir la semilla.
  6. Identifica en la tabla histórica una familia centrada en cómputo y otra en facilitar el ajuste de redes profundas.
  1. El alcance teórico es 7 × 7: cada convolución añade dos posiciones por eje bajo esas condiciones.
  2. La concatenación entrega 24 canales si coinciden lote, alto y ancho. Para sumar posición a posición debes igualar los canales, por ejemplo mediante proyección.
  3. La llana produce [0, 3]; la residual suma [1.5, 2], que ReLU conserva.
  4. Ambas redes parten de los mismos números y reciben lotes en el mismo orden; la pieza modificada es el atajo.
  5. Declara varias semillas antes de ejecutar, repite dentro del desarrollo e informa su dispersión; reserva una evaluación final independiente.
  6. MobileNet separa operaciones para reducir cálculo; ResNet incorpora rutas de identidad para facilitar el aprendizaje de modificaciones.

14.15 Glosario de este capítulo

Término Significado
Arquitectura, profundidad y anchura Organización de conexiones, etapas sucesivas y canales de cada etapa
Bloque Grupo de operaciones repetido en una red
Campo receptivo teórico Región de entrada que puede influir en una salida
Rama y concatenación Ruta paralela y unión de sus salidas por canales
Promedio global Un promedio de las posiciones espaciales por canal
Conexión residual e identidad Sumar una transformación a una ruta que conserva la entrada
Proyección Transformación que hace compatibles las dimensiones del atajo
Ablación Comparación que modifica una pieza manteniendo otras condiciones
Degradación por profundidad Peor ajuste de entrenamiento al añadir capas en ciertas condiciones
Convolución separable Separar filtrado espacial por canal y mezcla de canales
Escalado compuesto Coordinar profundidad, anchura y resolución al ampliar una red

Para entrenamiento y tensores consulta el glosario del Apéndice A.

14.16 Qué aprendiste

Apilar filtros pequeños, reunir ramas y sumar una identidad modifican el recorrido de una misma entrada. La comparación de cuatro bloques favoreció a la red llana por validación; con ocho favoreció a la residual. Entre las seis configuraciones, VGG pequeña fue elegida con CE 0.359052 y alcanzó 85.97 % en prueba. Puedes leer estas cifras junto con la comprobación de pesos iniciales y la tabla de coste para argumentar una decisión concreta.

En transferencia de aprendizaje y ajuste fino aprovecharás pesos que otra red ya aprendió. La pregunta cambiará de cómo conectar bloques a qué conservar y qué adaptar de esa red para reconocer flores.

14.17 Referencias

"""Apoyo para los experimentos CNN de la Biblioteca BIT.

Reúne carga oficial de datos, entrenamiento con validación y gráficos.
La semilla y el dispositivo los decide el notebook. Ninguna función usa prueba
para ajustar pesos ni elegir una época. Los valores devueltos permiten escribir
las interpretaciones en el texto del capítulo, después de ejecutar.
"""

from pathlib import Path
import contextlib
import copy
import io
import time

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import torch
from sklearn.metrics import classification_report, confusion_matrix
from sklearn.model_selection import train_test_split
from torch.nn import functional as F
from torch.utils.data import DataLoader, TensorDataset
from torchvision.datasets import FashionMNIST, MNIST


def datos(nombre, cantidad, semilla):
    """Carga MNIST o Fashion-MNIST y devuelve tensores, etiquetas e índices.

    Recibe el nombre, el tamaño del subconjunto de desarrollo y la semilla.
    Descarga los archivos oficiales con torchvision la primera vez y después
    los reutiliza desde la caché. Devuelve un diccionario con entrenamiento,
    validación (20 % del subconjunto), prueba oficial e índices de desarrollo.
    """
    clase = FashionMNIST if nombre == "fashion" else MNIST
    raiz = Path.home() / ".cache" / "bit-datasets"
    with contextlib.redirect_stdout(io.StringIO()):
        with contextlib.redirect_stderr(io.StringIO()):
            desarrollo = clase(raiz, train=True, download=True)
            prueba = clase(raiz, train=False, download=True)
    etiquetas = desarrollo.targets.numpy()
    indices = np.arange(len(etiquetas))
    elegidos, _ = train_test_split(
        indices, train_size=cantidad, stratify=etiquetas, random_state=semilla
    )
    ajuste, validacion = train_test_split(
        elegidos, test_size=0.2, stratify=etiquetas[elegidos], random_state=semilla
    )
    assert not set(ajuste).intersection(validacion)
    imagenes = desarrollo.data.unsqueeze(1).float() / 255
    entrada_prueba = prueba.data.unsqueeze(1).float() / 255
    return {
        "entrenamiento": TensorDataset(imagenes[ajuste], desarrollo.targets[ajuste]),
        "validacion": TensorDataset(imagenes[validacion], desarrollo.targets[validacion]),
        "prueba": TensorDataset(entrada_prueba, prueba.targets),
        "indices_ajuste": ajuste,
        "indices_validacion": validacion,
    }


def cargador(conjunto, semilla, mezclar=False, lote=128):
    """Recibe datos, semilla y opciones; devuelve lotes con generador explícito."""
    generador = torch.Generator().manual_seed(semilla)
    return DataLoader(
        conjunto, batch_size=lote, shuffle=mezclar, generator=generador, num_workers=0
    )


@torch.inference_mode()
def evaluar(modelo, conjunto, dispositivo, semilla):
    """Recibe modelo y datos; devuelve CE media, exactitud, etiquetas y probabilidades."""
    modelo.eval()
    total = 0.0
    salidas = []
    etiquetas = []
    for entrada, respuesta in cargador(conjunto, semilla):
        logits = modelo(entrada.to(dispositivo))
        total += F.cross_entropy(
            logits, respuesta.to(dispositivo), reduction="sum"
        ).item()
        salidas.append(logits.cpu())
        etiquetas.append(respuesta)
    logits = torch.cat(salidas)
    reales = torch.cat(etiquetas)
    assert torch.isfinite(logits).all()
    return {
        "perdida": total / len(conjunto),
        "exactitud": (logits.argmax(1) == reales).float().mean().item(),
        "reales": reales.numpy(),
        "probabilidades": logits.softmax(1).numpy(),
    }


def entrenar(modelo, ajuste, validacion, dispositivo, semilla,
             maximo=60, paciencia=5, tasa=0.001):
    """Ajusta con Adam y restaura el mejor estado según CE de validación.

    Recibe modelo, dos conjuntos, dispositivo, semilla y presupuesto. Devuelve
    historial por época y resumen. Las curvas de ajuste también se miden en
    modo evaluación al cerrar la época, sobre datos usados para aprender.
    Si el mínimo queda en el borde se amplía el máximo, conservando el modelo
    y el optimizador. Si exige más de 1100 segundos o 240 épocas, se detiene
    con un error para revisar el diseño antes de abrir prueba.
    """
    modelo.to(dispositivo)
    optimizador = torch.optim.Adam(modelo.parameters(), lr=tasa)
    lotes = cargador(ajuste, semilla, mezclar=True)
    historial = []
    mejor = float("inf")
    estado = None
    espera = 0
    limite = maximo
    inicio = time.perf_counter()
    for epoca in range(1, 241):
        modelo.train()
        for entrada, respuesta in lotes:
            entrada = entrada.to(dispositivo)
            respuesta = respuesta.to(dispositivo)
            optimizador.zero_grad(set_to_none=True)
            perdida = F.cross_entropy(modelo(entrada), respuesta)
            assert torch.isfinite(perdida)
            perdida.backward()
            optimizador.step()
        medidas_ajuste = evaluar(modelo, ajuste, dispositivo, semilla)
        medidas_val = evaluar(modelo, validacion, dispositivo, semilla)
        historial.append({
            "epoca": epoca, "perdida_ajuste": medidas_ajuste["perdida"],
            "perdida_validacion": medidas_val["perdida"],
            "exactitud_ajuste": medidas_ajuste["exactitud"],
            "exactitud_validacion": medidas_val["exactitud"],
        })
        if medidas_val["perdida"] < mejor:
            mejor = medidas_val["perdida"]
            mejor_epoca = epoca
            estado = copy.deepcopy(modelo.state_dict())
            espera = 0
        else:
            espera += 1
        if time.perf_counter() - inicio > 1100:
            raise RuntimeError("Revisar presupuesto CPU antes de abrir prueba")
        if espera >= paciencia:
            break
        if epoca == limite:
            if mejor_epoca < limite:
                break
            limite *= 2
            print("Máximo ampliado a", limite)
    assert mejor_epoca < limite, "Ampliar presupuesto antes de evaluar prueba"
    modelo.load_state_dict(estado)
    modelo.eval()
    resumen = {
        "mejor_epoca": mejor_epoca, "epocas_ejecutadas": epoca,
        "maximo_final": limite, "perdida_validacion": mejor,
        "segundos": time.perf_counter() - inicio,
        "parametros": sum(p.numel() for p in modelo.parameters()),
    }
    print("Mejor época < máximo:", mejor_epoca, "<", limite, "=", mejor_epoca < limite)
    return pd.DataFrame(historial), resumen


def curvas(historial, titulo, mejor_epoca):
    """Recibe historial, título y época elegida; dibuja CE y exactitud comparables."""
    figura, ejes = plt.subplots(1, 2, figsize=(10, 3.6))
    for eje, medida, nombre in zip(
        ejes, ["perdida", "exactitud"], ["Entropía cruzada", "Exactitud"]
    ):
        eje.plot(historial.epoca, historial[medida + "_ajuste"], "-o",
                 color="#1786B3", label="Entrenamiento (evaluación)", markersize=3)
        eje.plot(historial.epoca, historial[medida + "_validacion"], "--s",
                 color="#EE5400", label="Validación", markersize=3)
        eje.axvline(mejor_epoca, color="#89878C", linestyle=":", label="Mejor época")
        eje.set(xlabel="Época", ylabel=nombre)
        eje.legend(fontsize=7)
    figura.suptitle(titulo)
    figura.tight_layout()
    plt.show()


def diagnostico(resultado, nombres, imagenes):
    """Recibe predicciones e imágenes; devuelve matriz y tabla de métricas por clase.

    Dibuja matriz con conteos y hasta ocho errores. Reutiliza las predicciones
    calculadas al abrir prueba, sin volver a evaluar el modelo.
    """
    reales = resultado["reales"]
    probabilidades = resultado["probabilidades"]
    predichos = probabilidades.argmax(1)
    matriz = confusion_matrix(reales, predichos, labels=np.arange(len(nombres)))
    reporte = classification_report(
        reales, predichos, target_names=nombres, output_dict=True, zero_division=0
    )
    figura, eje = plt.subplots(figsize=(7, 6))
    eje.imshow(matriz, cmap="Blues")
    eje.set_xticks(range(len(nombres)), nombres, rotation=65, ha="right", fontsize=8)
    eje.set_yticks(range(len(nombres)), nombres, fontsize=8)
    eje.set(xlabel="Categoría predicha", ylabel="Categoría real", title="Conteos en prueba")
    for i in range(len(nombres)):
        for j in range(len(nombres)):
            eje.text(j, i, str(matriz[i, j]), ha="center", va="center", fontsize=7,
                     color="white" if matriz[i, j] > matriz.max() / 2 else "black")
    eje.grid(False)
    figura.tight_layout()
    plt.show()
    errores = np.flatnonzero(reales != predichos)[:8]
    figura, ejes = plt.subplots(2, 4, figsize=(10, 5))
    for eje, indice in zip(ejes.flat, errores):
        eje.imshow(imagenes[indice, 0], cmap="gray")
        eje.set_title(
            f"Real: {nombres[reales[indice]]}\nPredicción: {nombres[predichos[indice]]}",
            fontsize=8,
        )
    for eje in ejes.flat:
        eje.axis("off")
    figura.tight_layout()
    plt.show()
    return matriz, pd.DataFrame(reporte).T.loc[nombres + ["macro avg"]]