Implementación de Redes Residuales (ResNet) desde Cero con PyTorch

El concepto de aprendizaje residual

El propósito fundamental de las arquitecturas ResNet es mitigar el problema de la degradación en redes neuronales profundas. En un modelo convencional, el sistema intenta aprender una transformación directa $H(x)$. ResNet propone un cambio de paradigma: en lugar de buscar $H(x)$, las capas aprenden una función de residuo $F(x) = H(x) - x$.

A través de las conexiones de salto (skip connections), la entrada original $x$ se suma al resultado de las transformaciones no lineales, resultando en $H(x) = F(x) + x$. Esta estructura facilita que la red aprenda la función identidad si las capas adicionales no aportan información relevante, evitando que el rendimiento disminuya al aumentar la profundidad.

Construcción del Bloque Residual

El componente básico de esta arquitectura es el bloque residual. Este módulo procesa la entrada a través de dos capas convolucionales y aplica una transformación de ajuste si las dimensiones espaciales o el número de canales cambian.

import torch
import torch.nn as nn

class BloqueResidual(nn.Module):
    def __init__(self, entrada_ch, salida_ch, paso=1):
        super(BloqueResidual, self).__init__()
        
        # Primera convolución: puede reducir el tamaño del mapa de características
        self.conv_a = nn.Conv2d(entrada_ch, salida_ch, kernel_size=3, stride=paso, padding=1, bias=False)
        self.bn_a = nn.BatchNorm2d(salida_ch)
        
        # Segunda convolución: mantiene dimensiones
        self.conv_b = nn.Conv2d(salida_ch, salida_ch, kernel_size=3, stride=1, padding=1, bias=False)
        self.bn_b = nn.BatchNorm2d(salida_ch)
        
        self.relu = nn.ReLU(inplace=True)
        
        # Ajuste de identidad: se usa cuando el input no coincide con el output
        self.ajuste_identidad = None
        if paso != 1 or entrada_ch != salida_ch:
            self.ajuste_identidad = nn.Sequential(
                nn.Conv2d(entrada_ch, salida_ch, kernel_size=1, stride=paso, bias=False),
                nn.BatchNorm2d(salida_ch)
            )

    def forward(self, x):
        identidad = x
        
        # Flujo principal
        out = self.conv_a(x)
        out = self.bn_a(out)
        out = self.relu(out)
        
        out = self.conv_b(out)
        out = self.bn_b(out)
        
        # Aplicar proyección si es necesario
        if self.ajuste_identidad is not None:
            identidad = self.ajuste_identidad(x)
            
        # Conexión de salto
        out += identidad
        return self.relu(out)

En esta implementación, el parámetro paso (stride) controla la reducción de resolución espacial. Cuando paso=2, el tamaño de la imagen se reduce a la mitad. La capa de 1x1 en ajuste_identidad garantiza que las dimensiones matemáticas coincidan antes de la suma final.

Arquitectura ResNet-18 Personalizada

Utilizando el BloqueResidual, podemos ensamblar una versión de ResNet-18. Esta red comienza con una etapa de preprocesamiento (convolución de gran tamaño y pooling), seguida de cuatro etapas de bloques residuales acumualtivos.

class ArquitecturaResNet18(nn.Module):
    def __init__(self, num_clases=1000):
        super(ArquitecturaResNet18, self).__init__()
        
        # Capas iniciales de extracción
        self.pre_procesado = nn.Sequential(
            nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False),
            nn.BatchNorm2d(64),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
        )
        
        # Etapas de bloques residuales
        self.etapa1 = self._crear_capa(64, 64, bloques=2, paso=1)
        self.etapa2 = self._crear_capa(64, 128, bloques=2, paso=2)
        self.etapa3 = self._crear_capa(128, 256, bloques=2, paso=2)
        self.etapa4 = self._crear_capa(256, 512, bloques=2, paso=2)
        
        # Clasificador final
        self.global_pool = nn.AdaptiveAvgPool2d((1, 1))
        self.fc = nn.Linear(512, num_clases)

    def _crear_capa(self, in_ch, out_ch, bloques, paso):
        capas = []
        # El primer bloque de cada etapa puede realizar submuestreo
        capas.append(BloqueResidual(in_ch, out_ch, paso))
        for _ in range(1, bloques):
            capas.append(BloqueResidual(out_ch, out_ch, 1))
        return nn.Sequential(*capas)

    def forward(self, x):
        x = self.pre_procesado(x)
        
        x = self.etapa1(x)
        x = self.etapa2(x)
        x = self.etapa3(x)
        x = self.etapa4(x)
        
        x = self.global_pool(x)
        x = torch.flatten(x, 1)
        x = self.fc(x)
        return x

Análisis de la Estructura de Bloques

La razón por la que cada etapa contiene múltiples bloques es estratégica:

  • Extracción jerárquica: El primer bloque de una etapa (donde paso=2) se encarga de reducir la resolución y aumentar la profundidad de los canales (la "extracción gruesa").
  • Refinamiento: Los bloques subsigueintes operan sobre la misma resolución, permitiendo que la red refine las características aprendidas sin alterar la geometría de los tensores.
  • Flujo de Gradiente: Al repetir la estructura residual, creamos múltiples caminos por los cuales el gradiante puede fluir hacia las capas inferiores durante el backpropagation, reduciendo drásticamente el desvanecimiento del gradiente.

Finalmente, para verificar la complejidad del modelo, podemos inspeccionar el número de parámetros en módulos específicos:

def contar_parametros(modulo):
    return sum(p.numel() for p in modulo.parameters() if p.requires_grad)

modelo_custom = ArquitecturaResNet18()
tensor_prueba = torch.randn(1, 3, 224, 224)
resultado = modelo_custom(tensor_prueba)

print(f"Parámetros en etapa 1: {contar_parametros(modelo_custom.etapa1)}")
print(f"Forma del output: {resultado.shape}")

Etiquetas: PyTorch Deep Learning Computer Vision ResNet Neural Networks

Publicado el 7-21 07:53