Las Redes Neuronales Convolucionales (CNN) constituyen una categoría de arquitecturas de aprendizaje profundo diseñadas específicamente para el procesamiento de datos con estructura de cuadrícula, como las imágenes. A diferencia de las redes densamente conectadas (MLP), las CNN aprovechan las propiedades espaciales de los datos, lo que permite reducir drásticamente el número de parámetros y facilitar el cómputo paralelo mediante GPUs.
De las Capas Densas a la Convolución
Los Perceptrones Multicapa (MLP) presentan limitaciones significativas al tratar con imágenes de alta resolución. Al aplanar una imagen en un vector unidimensional, se pierde la estructura espacial y el número de conexiones crece exponencialmente. Para resolver esto, las CNN se basan en dos principios fundamentales:
- Invarianza a la traslación: El sistema debe ser capaz de reconocer un patrón independientemente de su ubicación en la imagen. Las primeras capas de la red deben reaccionar de manera similar a parches idénticos en distintas posiciones.
- Localidad: Las capas iniciales deben enfocarse en regiones pequeñas y locales de la entrada, sin considerar inicialmente las relacionse con píxeles muy distantes.
La Operación de Convolución
Matemáticamente, la convolución entre dos funciones discretas en dos dimensiones se define como el sumatorio del producto de sus elementos tras un desplazamiento:
(f * g)(i, j) = ∑∑ f(a, b) g(i - a, j - b)
En el contexto del aprendizaje profundo, solemos utilizar la operación de correlación cruzada, donde un núcleo (kernel) se desliza sobre la entrada para generar un mapa de características.
import torch
from torch import nn
def correlacion_2d(entrada, nucleo):
"""Calcula una operación de correlación cruzada bidimensional básica."""
h_n, w_n = nucleo.shape
salida = torch.zeros((entrada.shape[0] - h_n + 1, entrada.shape[1] - w_n + 1))
for fila in range(salida.shape[0]):
for col in range(salida.shape[1]):
ventana = entrada[fila:fila + h_n, col:col + w_n]
salida[fila, col] = (ventana * nucleo).sum()
return salida
# Ejemplo de uso
matriz_X = torch.tensor([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0], [6.0, 7.0, 8.0]])
filtro_K = torch.tensor([[0.0, 1.0], [2.0, 3.0]])
print(correlacion_2d(matriz_X, filtro_K))
Capas Convolucionales y Aprendizaje de Filtros
Una capa convolucional optimiza los pesos del núcleo y un sesgo (bias) durante el entrenamienot. A continuación se muestra una implementaicón simplificada de una capa personalizada:
class CapaConvSimple(nn.Module):
def __init__(self, tamano_kernel):
super().__init__()
self.pesos = nn.Parameter(torch.rand(tamano_kernel))
self.sesgo = nn.Parameter(torch.zeros(1))
def forward(self, x):
return correlacion_2d(x, self.pesos) + self.sesgo
Para aprender un filtro específico a partir de datos (por ejemplo, un detector de bordes), se utiliza el descenso de gradiente para minimizar el error entre la salida de la red y el objetivo esperado:
# Configuración de una capa convolucional estándar de PyTorch
modelo_conv = nn.Conv2d(1, 1, kernel_size=(1, 2), bias=False)
X_input = torch.ones((1, 1, 6, 8)) # Lote, Canal, Alto, Ancho
Y_target = torch.ones((1, 1, 6, 7))
tasa_aprendizaje = 0.02
for epoc in range(15):
y_pred = modelo_conv(X_input)
error = (y_pred - Y_target) ** 2
modelo_conv.zero_grad()
error.sum().backward()
# Actualización manual de pesos
modelo_conv.weight.data -= tasa_aprendizaje * modelo_conv.weight.grad
if (epoc + 1) % 5 == 0:
print(f'Época {epoc+1}, Error: {error.sum():.4f}')
Relleno (Padding) y Zancada (Stride)
Para controlar el tamaño de la salida y evitar la pérdida de información en los bordes, se utilizan dos hiperparámetros:
- Padding: Añade filas y columnas adicionales (generalmente ceros) alrededor de la entrada. Si la entrada es
n x ny el kernelk x k, con un paddingp, la salida es(n - k + p + 1). - Stride: Determina el número de píxeles que el kernel se desplaza en cada paso. Un stride mayor reduce drásticamente la resolución espacial.
Capas de Agregación (Pooling)
El pooling se utiliza para reducir la sensibilidad de la red a la ubicación exacta de las características y para disminuir la carga computacional (submuestreo). No posee parámetros entrenables; simplemente aplica una función fija (Máximo o Promedio) sobre una ventana local.
def aplicar_pooling(matriz, tam_pool, tipo='max'):
p_h, p_w = tam_pool
res = torch.zeros((matriz.shape[0] - p_h + 1, matriz.shape[1] - p_w + 1))
for i in range(res.shape[0]):
for j in range(res.shape[1]):
bloque = matriz[i:i + p_h, j:j + p_w]
if tipo == 'max':
res[i, j] = bloque.max()
elif tipo == 'avg':
res[i, j] = bloque.mean()
return res
Arquitectura Clásica: LeNet-5
LeNet-5 fue una de las primeras redes convolucionales exitosas, diseñada para el reconocimiento de dígitos escritos a mano. Su estructura alterna capas convolucionales con capas de pooling, terminando con capas densas para la clasificación.
le_net = nn.Sequential(
nn.Conv2d(1, 6, kernel_size=5, padding=2), nn.Sigmoid(),
nn.AvgPool2d(kernel_size=2, stride=2),
nn.Conv2d(6, 16, kernel_size=5), nn.Sigmoid(),
nn.AvgPool2d(kernel_size=2, stride=2),
nn.Flatten(),
nn.Linear(16 * 5 * 5, 120), nn.Sigmoid(),
nn.Linear(120, 84), nn.Sigmoid(),
nn.Linear(84, 10)
)
# Prueba de flujo de datos
datos_prueba = torch.randn(size=(1, 1, 28, 28))
for capa in le_net:
datos_prueba = capa(datos_prueba)
print(f"Capa: {capa.__class__.__name__} | Forma de salida: {datos_prueba.shape}")
En el diseño moderno, las funciones de activación como ReLU y el Max-Pooling han sustituido en gran medida a la Sigmoide y al Average-Pooling utilizados en el LeNet original, debido a su mayor eficiencia en el entrenamiento de redes más profundas.