📖 Teoría

Convolución y Fundamentos de CNNs

Desde las limitaciones de las redes densas hasta la interpretabilidad de las CNNs: la operación de convolución, sus hiperparámetros, la arquitectura convolucional, la extracción jerárquica de características y las técnicas para entender qué ha aprendido la red.

🚧 Limitaciones de las redes densas (MLP)

Hasta ahora, las redes que hemos estudiado —el perceptrón multicapa (MLP)— utilizan capas densas (fully-connected): cada neurona de una capa está conectada a todas las neuronas de la capa anterior. Esto funciona razonablemente bien para datos tabulares de pocas dimensiones, pero se vuelve insostenible cuando trabajamos con imágenes.

Veamos exactamente por qué. Una imagen en escala de grises de tan solo 28 \times 28 píxeles (como las del dataset MNIST) tiene 784 valores de entrada. Una imagen a color de 224 \times 224 píxeles (tamaño estándar en visión por computador) tiene:

Entradas de una imagen RGB 224×224 224 \times 224 \times 3 = 150{,}528 \text{ valores de entrada}

Si conectamos esa entrada a una primera capa oculta de tan solo 1024 neuronas, necesitamos:

Parámetros de una capa densa 150{,}528 \times 1{,}024 + 1{,}024 = \textbf{154{,}141{,}696} \text{ parámetros}

¡Más de 154 millones de parámetros solo en la primera capa! Y esto para una resolución modesta. Imágenes de alta resolución harían el problema completamente inviable.

⚠️

Explosión de parámetros: una red densa con 3 capas ocultas de 1024 neuronas para imágenes de 224×224 tendría más de 310 millones de parámetros. Esto implica enormes requisitos de memoria, tiempos de entrenamiento prohibitivos, y un riesgo altísimo de overfitting.

Problema 1: no hay noción de estructura espacial

Para alimentar una imagen a un MLP, debemos aplanarla en un vector unidimensional (flatten). Un píxel en la esquina superior izquierda y su vecino inmediato quedan en posiciones consecutivas del vector, pero un píxel de la esquina superior derecha —que está justo al lado si la imagen tiene pocas columnas— puede quedar a cientos de posiciones de distancia.

Al aplanar la imagen, destruimos la información espacial: las relaciones de vecindad, los patrones locales, las texturas y los bordes. El MLP recibe un mar de números sin saber cuáles son vecinos entre sí.

Imagen 4×4 0.9 0.8 0.1 0.0 0.7 1.0 0.2 0.0 0.1 0.3 0.5 0.2 0.0 0.0 0.2 0.1 flatten Vector 1×16 0.9 0.8 0.1 0.0 0.7 1.0 0.2 0.0 0.1 0.3 0.5 0.2 0.0 ← vecinos ← vecino real (estaba justo debajo) ❌ Se pierde: • Vecindad espacial • Patrones 2D (bordes, texturas) • Relaciones locales entre píxeles

Aplanar una imagen destruye la estructura espacial 2D.

Problema 2: sin invarianza a la traslación

Si un gato aparece en la esquina superior izquierda de una imagen, y luego lo movemos a la esquina inferior derecha, un MLP necesita aprender a reconocerlo en cada posición por separado. Las neuronas de la capa densa que detectaban el gato arriba-izquierda son completamente distintas a las que lo detectarían abajo-derecha.

Esto no solo es ineficiente: en la práctica, requiere muchísimos más datos de entrenamiento para que la red "vea" cada patrón en todas las posiciones posibles.

💡

Invarianza a la traslación: un buen sistema de visión debería detectar un patrón (un borde, una textura, un objeto) independientemente de dónde aparezca en la imagen. Los MLPs no tienen esta propiedad de forma inherente.

Problema 3: no se comparten parámetros

En un MLP, cada conexión tiene su propio peso. Si la red aprende a detectar un borde vertical en una zona de la imagen, ese conocimiento no se transfiere a otras zonas. Debe aprender el mismo patrón múltiples veces para distintas posiciones.

Esto resulta en un uso extremadamente ineficiente de los parámetros: la gran mayoría de los pesos acaban codificando patrones redundantes.

Inspiración biológica: el córtex visual

La solución a estos problemas no surgió del vacío. En 1962, los neurocientíficos David Hubel y Torsten Wiesel descubrieron que las neuronas del córtex visual de los gatos responden a patrones locales específicos (bordes con una orientación particular) en regiones limitadas del campo visual, llamadas campos receptivos. Además, distintas neuronas detectan el mismo tipo de patrón en distintas posiciones del campo visual.

Este hallazgo —que les valió el Premio Nobel de Medicina en 1981— inspiró directamente el diseño de las redes convolucionales: filtros locales (campos receptivos pequeños) que comparten pesos (el mismo detector en todas las posiciones). El primer modelo computacional basado en estas ideas fue el Neocognitron de Kunihiko Fukushima (1980), precursor directo de las CNNs modernas.

📚

Referencias: Hubel & Wiesel, "Receptive fields, binocular interaction and functional architecture in the cat's visual cortex" (1962). Fukushima, "Neocognitron: A self-organizing neural network model" (1980). Para una introducción más detallada a los fundamentos de las redes neuronales, consulta el submódulo de Perceptrón y MLP.

💡 ¿Qué necesitamos?

Los tres problemas anteriores apuntan a la misma solución: necesitamos una arquitectura que:

🔍

Conectividad local

Cada neurona mira solo una región pequeña de la entrada (campo receptivo), en lugar de conectarse a todos los píxeles.

🔗

Parámetros compartidos

Los mismos pesos (el mismo filtro) se aplican en todas las posiciones de la imagen, reduciendo drásticamente los parámetros.

🌍

Invarianza espacial

Detecta patrones sin importar su posición en la imagen: un borde es un borde, esté donde esté.

Estos tres principios son exactamente los que implementa la operación de convolución, y las redes que la utilizan: las redes neuronales convolucionales (CNN).

🧪 Comparador: parámetros MLP vs CNN

Compara cuántos parámetros necesita un MLP frente a una capa convolucional para procesar la misma imagen. Ajusta el tamaño de imagen y la arquitectura.

Reducción drástica: una capa convolucional con 256 filtros de 3×3 sobre una imagen de 224×224×3 usa solo 7.168 parámetros, frente a los más de 154 millones de una capa densa equivalente. Es una reducción de más de 20.000×.

🔬 ¿Qué es la convolución?

La convolución es una operación matemática fundamental que, en esencia, mide cuánto se parece una parte de la señal a un patrón dado. En el contexto de redes neuronales, la señal es la imagen (o el mapa de activación de una capa previa) y el patrón es un filtro o kernel: una matriz pequeña de pesos.

Para entender la convolución de forma intuitiva, imagina que deslizas una lupa muy pequeña sobre una imagen. En cada posición, la lupa compara la zona que ve con un patrón de referencia (el kernel). Si la zona se parece mucho al patrón, devuelve un valor alto; si no se parece, devuelve un valor cercano a cero. El resultado es un nuevo mapa que indica dónde y cuánto aparece ese patrón en la imagen. Lo más poderoso: en una CNN, la red aprende automáticamente qué patrones (kernels) buscar durante el entrenamiento.

Convolución continua

En matemáticas, la convolución de dos funciones continuas f y g se define como:

Convolución continua 1D (f * g)(t) = \int_{-\infty}^{\infty} f(\tau) \cdot g(t - \tau) \, d\tau

La idea: deslizamos la función g sobre f, la invertimos (flip), y en cada posición calculamos la integral del producto. El resultado nos dice cuánto "se superponen" ambas funciones en cada punto.

Convolución discreta 1D

En la práctica, trabajamos con señales discretas (secuencias de valores). La convolución discreta es:

Convolución discreta 1D (f * g)[n] = \sum_{k=-\infty}^{\infty} f[k] \cdot g[n - k]

Aquí, g es nuestro kernel (un vector de pocos valores) que se desliza sobre la señal f.

📝

Convolución vs. correlación cruzada: en la convolución "pura", el kernel se invierte (flip) antes de deslizarse. En deep learning, no hacemos el flip porque los pesos del kernel se aprenden y pueden adoptar cualquier configuración. Lo que realmente computamos es una cross-correlation, pero por convención se sigue llamando "convolución". Esto no afecta al resultado porque el entrenamiento ajusta los pesos de todas formas.

Convolución 2D (para imágenes)

Para imágenes, usamos la convolución 2D. Dado un input \mathbf{I} (la imagen) y un kernel \mathbf{K} de tamaño k_h \times k_w, el valor de salida en la posición (i, j) es:

Convolución 2D (cross-correlation) S(i, j) = (\mathbf{I} * \mathbf{K})(i, j) = \sum_{m=0}^{k_h-1} \sum_{n=0}^{k_w-1} \mathbf{I}(i+m, \, j+n) \cdot \mathbf{K}(m, n)

El kernel se coloca sobre una región de la imagen, se multiplican los valores elemento a elemento, y se suman todos los productos. El resultado es un solo número que indica cuánto se activa el filtro en esa posición. Luego, el kernel se desplaza a la siguiente posición y se repite.

Ejemplo paso a paso

Consideremos un input de 5 \times 5 y un kernel de 3 \times 3:

Input 5×5 1 0 1 0 0 0 1 0 1 0 1 0 1 0 1 0 0 1 1 0 0 1 0 1 0 Kernel 3×3 1 0 1 0 1 0 1 0 1 1·1 + 0·0 + 1·1 + 0·0 + 1·1 + 0·0 + 1·1 + 0·0 + 1·1 = 4 Output 3×3 4 ? ? ? ? ? ? ? ?

Un kernel 3×3 deslizándose sobre un input 5×5 produce un output 3×3. El primer valor (4) es la suma de los productos elemento a elemento.

💡

Intuición: el kernel es un detector de patrones. El kernel del ejemplo detecta píxeles activos en una disposición diagonal. En las posiciones de la imagen donde haya un patrón similar, el valor de salida será alto. Donde no lo haya, será bajo. Los valores del kernel se aprenden durante el entrenamiento, así que la red descubre automáticamente qué patrones son útiles.

Es importante recalcar que la convolución resuelve de un plumazo los tres problemas del MLP que vimos antes. Al ser una operación local (el kernel solo mira unos pocos píxeles a la vez), no necesitamos conectar cada neurona con toda la imagen. Los pesos del kernel se comparten en todas las posiciones, reduciendo los parámetros de millones a solo k \times k por filtro. Y como el mismo filtro se aplica en todas partes, la red detecta un patrón da igual dónde aparezca en la imagen (invarianza a la traslación).

📚

Para profundizar: el capítulo 9 de Deep Learning (Goodfellow et al., 2016) ofrece un tratamiento formal y completo de la convolución y las CNNs. Para una introducción visual excelente, véase la serie CS231n: Convolutional Neural Networks for Visual Recognition de Stanford.

🧪 Explorador interactivo de convolución 2D

Visualiza paso a paso cómo un kernel se desliza sobre una imagen, multiplicando y sumando valores. Puedes elegir entre varios filtros predefinidos (detección de bordes, desenfoque, realce) o editar los valores del kernel manualmente.

🔍 Convolución 2D paso a paso

0 / 9
Observa cómo el kernel se superpone al input, multiplica elemento a elemento, y suma para obtener cada valor del output.
🧪

Herramienta completa: para una experiencia más profunda, visita el Explorador de Convoluciones. Incluye ajuste de dilation, aplicación de filtros sobre imágenes reales (con subida de imágenes propias), y una animación paso a paso con cálculos detallados.

📐 Tamaño del kernel

El tamaño del kernel (o filtro) determina cuántos píxeles vecinos considera la convolución en cada paso. Es el hiperparámetro más básico y uno de los más importantes.

Kernel Campo receptivo Parámetros Uso típico
1 × 1 1 píxel 1 × Cin Combinar canales, reducir dimensionalidad (Network-in-Network)
3 × 3 9 píxeles 9 × Cin El más común. Usado en VGG, ResNet y la mayoría de arquitecturas modernas
5 × 5 25 píxeles 25 × Cin Primeras capas de AlexNet, GoogLeNet. Más contexto por capa
7 × 7 49 píxeles 49 × Cin Primera capa de ResNet. Captura patrones de bajo nivel amplios
💡

¿Por qué 3×3 domina? Apilar dos capas con kernels 3×3 produce el mismo campo receptivo que una capa con kernel 5×5, pero con menos parámetros (2 × 9 = 18 vs 25) y más no-linealidades (una activación entre ambas capas). Este insight de VGGNet (Simonyan & Zisserman, 2014) cambió el diseño de CNNs para siempre. Antes de VGG, era común usar kernels grandes (11×11 en AlexNet, 7×7 en ZFNet); después, 3×3 se convirtió en el estándar casi universal.

Los kernels suelen ser cuadrados y de tamaño impar (3, 5, 7…) para que tengan un píxel central bien definido, lo que facilita el padding simétrico.

🔲 Padding (relleno)

Al aplicar un kernel de k \times k sobre un input de n \times n, el output es más pequeño: (n - k + 1) \times (n - k + 1). Esto ocurre porque el kernel no puede "salirse" del borde de la imagen.

El padding consiste en añadir valores (típicamente ceros) alrededor del input para controlar el tamaño de la salida. Puede parecer un detalle técnico menor, pero tiene un impacto significativo en el diseño de redes profundas: sin padding, cada capa convolucional reduce las dimensiones espaciales, lo que limita cuántas capas podemos apilar antes de que la resolución desaparezca. Con padding "same", podemos apilar tantas capas convolutionales como queramos sin perder resolución, y dejar la reducción de tamaño para las capas de pooling o strides.

Tipo Padding Output size Efecto
Valid (sin padding) p = 0 n - k + 1 Output se reduce. Se pierde información de los bordes
Same p = \lfloor k/2 \rfloor n Output del mismo tamaño que el input. El más usado
Full p = k - 1 n + k - 1 Output más grande que el input. Poco frecuente
Valid (p=0) 5×5 3×3 Same (p=1) 5×5 0 0 5×5 Con padding "same" y kernel 3×3, la salida conserva el tamaño

Padding "same" (p=1 para kernel 3×3) mantiene las dimensiones espaciales del input.

👣 Stride (paso)

El stride indica cuántos píxeles se desplaza el kernel entre una posición y la siguiente. Con stride 1, el kernel se mueve un píxel a la vez. Con stride 2, salta de dos en dos, reduciendo la salida a la mitad.

Tamaño del output con stride n_{\text{out}} = \left\lfloor \frac{n_{\text{in}} + 2p - k}{s} \right\rfloor + 1

Donde n_{\text{in}} es el tamaño del input, p es el padding, k es el tamaño del kernel, y s es el stride.

  • Stride = 1: resolución completa. El output tiene casi el mismo tamaño que el input (depende del padding).
  • Stride = 2: reduce la resolución a la mitad. Se usa como alternativa al pooling para hacer downsampling.
  • Stride > 2: poco frecuente; reduce la resolución agresivamente y puede perder información.
📝

Stride vs Pooling: los strides mayores que 1 y el max-pooling son dos formas de reducir la resolución. Arquitecturas modernas como ResNet combinan ambos: conv con stride 2 en las primeras capas y pooling en la salida. Algunas arquitecturas recientes (como los Vision Transformers) prescinden del pooling por completo.

🕳️ Dilation (dilatación)

La dilatación (o atrous convolution) introduce espacios entre los elementos del kernel. Un kernel 3×3 con dilation = 2 cubre una región efectiva de 5×5, pero solo usa 9 parámetros.

Kernel efectivo con dilatación k_{\text{eff}} = k + (k - 1)(d - 1)

Donde d es el factor de dilatación. Con d=1 (sin dilatación), el kernel es el estándar.

d = 1 3×3 efect. d = 2 5×5 efect. (9 params)

Un kernel 3×3 con dilatación 2 cubre el mismo campo que un 5×5, con solo 9 parámetros.

La dilatación es especialmente útil en segmentación semántica, donde necesitamos un campo receptivo grande sin perder resolución (sin pooling). Arquitecturas como DeepLab la utilizan extensivamente con sus módulos de Atrous Spatial Pyramid Pooling (ASPP), que aplican múltiples convoluciones dilatadas con distintos factores en paralelo para capturar contexto a varias escalas simultáneamente. La convolución dilatada fue introducida originalmente por Yu & Koltun (2016) y se usa ampliamente en tareas que requieren predicciones densas (segmentación, detección).

En la práctica, un error de diseño frecuente es usar dilataciones crecientes sin cuidado, lo que puede provocar "gridding artifacts": la red solo muestrea ciertos píxeles dejando huecos regulares. Esto se mitiga alternando convoluciones dilatadas con convoluciones estándar o usando factores de dilatación que no sean múltiplos entre sí. Para más detalles sobre segmentación, consulta el submódulo de Segmentación semántica.

📏 Fórmula general del tamaño de salida

La fórmula completa que determina el tamaño del output de una convolución 2D es:

Tamaño de salida (fórmula general) n_{\text{out}} = \left\lfloor \frac{n_{\text{in}} + 2p - d(k-1) - 1}{s} \right\rfloor + 1

Donde:

  • n_{\text{in}} — tamaño del input (alto o ancho)
  • k — tamaño del kernel
  • p — padding
  • s — stride
  • d — dilation (por defecto 1)

🧮 Calculadora: tamaño de salida de convolución

Ajusta los hiperparámetros y observa cómo cambia el tamaño del output.

32
1

🧱 La capa convolucional

Una capa convolucional aplica múltiples filtros sobre su input. Cada filtro produce un mapa de características (feature map). Si la capa tiene C_{\text{out}} filtros, produce C_{\text{out}} feature maps apilados, formando un volumen 3D.

Es una idea elegante: en lugar de tener un único filtro, usamos decenas o cientos de filtros distintos en cada capa. Cada uno aprende a detectar un patrón diferente (un tipo de borde, una textura, un gradiente de color…), y los resultados se apilan como capas de un volumen. Así, la salida de una capa convolucional no es una imagen 2D, sino un tensor 3D que codifica múltiples características del input en cada posición espacial.

Capa convolucional completa \mathbf{Y}_j = \sigma\!\left( \sum_{i=1}^{C_\text{in}} \mathbf{X}_i * \mathbf{K}_{i,j} + b_j \right), \quad j = 1, \dots, C_\text{out}

Donde \mathbf{X}_i es el canal i del input, \mathbf{K}_{i,j} es el kernel que conecta el canal i del input con el feature map j del output, b_j es el bias del filtro j, y \sigma es la función de activación (típicamente ReLU).

Canales y feature maps

En la primera capa, los canales de entrada son los canales de color de la imagen (1 para escala de grises, 3 para RGB). Cada filtro de la capa tiene un kernel separado para cada canal, y el resultado se suma.

A partir de la segunda capa, los "canales" ya no son R, G, B, sino los feature maps de la capa anterior: mapas de bordes detectados, texturas, patrones, etc. Las capas posteriores combinan estos mapas para detectar patrones de nivel más alto. Esta transición es sutil pero fundamental: cada capa opera sobre las representaciones aprendidas por la capa anterior, no sobre la imagen original. Esto permite construir una jerarquía de conceptos cada vez más abstractos.

Input: H×W×3 B G R 3 canales de color Conv2D k=3, n=64 H × W × 64 64 maps bordes, texturas... Conv2D k=3, n=128 H × W × 128 128 maps patrones complejos Cada filtro de la capa 2 opera sobre los 64 feature maps anteriores (no sobre RGB) Filtro Conv2: 3 × 3 × 64 pesos → suma sobre todos los canales → 1 feature map de salida

Cada capa convolucional transforma un volumen de entrada (H×W×Cin) en un volumen de salida (H'×W'×Cout). Los "canales" de la segunda capa son los feature maps de la primera.

🔽 Capas de pooling

Las capas de pooling reducen las dimensiones espaciales de los feature maps, manteniendo las características más importantes. Esto aporta:

  • Reducción de cómputo: menos píxeles → menos operaciones en capas posteriores.
  • Invarianza local: pequeños desplazamientos del patrón no cambian el resultado.
  • Control de overfitting: menos parámetros en las capas siguientes.

El pooling es una operación de submuestreo (downsampling) que reduce la resolución espacial pero no modifica el número de canales. Si la entrada tiene 64 feature maps, la salida también tendrá 64 feature maps, pero cada uno será de menor tamaño. Es importante entender que el pooling no tiene parámetros entrenables: es una operación fija y determinista.

Max Pooling

El max pooling toma el valor máximo de cada ventana. Es el tipo de pooling más utilizado porque preserva las activaciones más fuertes (las features más "presentes"):

Input 4×4 1 3 5 6 2 8 1 4 2 0 7 1 3 9 2 5 MaxPool 2×2, s=2 Output 2×2 6 8 7 9

Max Pooling 2×2 con stride 2: se toma el máximo de cada ventana 2×2, reduciendo la resolución a la mitad.

Average Pooling

El average pooling calcula la media de cada ventana. Es menos agresivo que el max pooling y se usa principalmente como Global Average Pooling (GAP) al final de la red, reduciendo cada feature map a un solo número:

Global Average Pooling \text{GAP}(\mathbf{F}_j) = \frac{1}{H \times W} \sum_{i=1}^{H} \sum_{k=1}^{W} F_j(i, k)
💡

GAP vs Flatten: Global Average Pooling reemplaza la operación de flatten + capa densa al final de la red. Un volumen de 7 \times 7 \times 512 se reduce a un vector de 512 valores (uno por feature map), sin necesidad de una capa FC de 25.088 → 4.096 neuronas. Esto reduce drásticamente los parámetros y el overfitting. Fue popularizado por Lin et al. (2013) en Network In Network y adoptado masivamente a partir de GoogLeNet/Inception.

🏗️ Arquitectura típica de una CNN

Una CNN típica sigue un patrón que se repite desde las primeras arquitecturas (LeNet-5, 1998) hasta las modernas:

Input
H × W × C
Conv + ReLU
Extrae features
Pooling
Reduce tamaño
Conv + ReLU
Pooling
…más bloques…
GAP / Flatten
→ vector
FC + Softmax
Clasificación

Conforme avanzamos por la red, las dimensiones espaciales (H, W) disminuyen y el número de canales (C) aumenta. Es decir, pasamos de información "amplia y superficial" (muchos píxeles, pocos canales) a "compacta y profunda" (pocos píxeles, muchos canales). Este patrón de diseño —conocido informalmente como "pirámide invertida"— es una de las ideas más robustas de la arquitectura de CNNs y aparece en prácticamente todas las redes modernas, desde VGG hasta EfficientNet:

Capa Tamaño típico Qué captura
Conv1 224 × 224 × 64 Bordes, colores, gradientes básicos
Conv2 112 × 112 × 128 Texturas, esquinas, patrones simples
Conv3 56 × 56 × 256 Partes de objetos, patrones complejos
Conv4 28 × 28 × 512 Objetos parciales, semántica
Conv5 14 × 14 × 512 Objetos completos, escenas
GAP 1 × 1 × 512 Representación compacta global
📝

Nota: en el submódulo de Clasificación y Transfer Learning profundizaremos en las arquitecturas famosas (LeNet, AlexNet, VGG, ResNet, EfficientNet…) y cómo se usan para clasificar imágenes. Aquí nos centramos en los componentes fundamentales.

Lecturas recomendadas: el paper original de LeNet-5 (LeCun et al., 1998) sigue siendo una lectura excelente para entender las bases. Para la arquitectura moderna más influyente, véase ResNet (He et al., 2015), que introdujo las conexiones residuales que permiten entrenar redes de cientos de capas.

Batch Normalization

En la práctica, casi todas las CNNs modernas incluyen una capa de Batch Normalization (Ioffe & Szegedy, 2015) después de cada convolución (y antes de la activación). BatchNorm normaliza las activaciones de cada mini-batch para que tengan media cero y varianza unitaria, y luego aplica una transformación afín aprendida:

Batch Normalization \hat{x}_i = \gamma \cdot \frac{x_i - \mu_B}{\sqrt{\sigma_B^2 + \epsilon}} + \beta

Donde \mu_B y \sigma_B^2 son la media y varianza del mini-batch, y \gamma, \beta son parámetros entrenables de escala y desplazamiento. BatchNorm aporta tres beneficios principales: estabiliza el entrenamiento (permite usar learning rates más altos), actúa como regularizador (reduce la necesidad de Dropout en muchos casos), y acelera la convergencia. El patrón típico de un bloque convolucional moderno es: Conv → BatchNorm → ReLU.

⚙️ Parámetros entrenables en una CNN

Una de las mayores ventajas de las CNNs es su eficiencia en parámetros. Mientras que en una red densa cada conexión tiene su propio peso, en una CNN los pesos del kernel se comparten (weight sharing) a lo largo de toda la imagen. Esta idea, que parece simple, tiene implicaciones profundas: reduce los parámetros en varios órdenes de magnitud, introduce una forma de regularización implícita, y —lo más elegante— codifica la suposición de que los patrones visuales son estacionarios: un borde vertical tiene la misma apariencia independientemente de dónde aparezca en la imagen.

Weight sharing: compartir pesos

Cuando un filtro de 3 \times 3 se aplica sobre una imagen de 224 \times 224, los mismos 9 pesos se usan en cada una de las 222 \times 222 = 49{,}284 posiciones. Esto implica:

  • Muchas menos variables a optimizar → entrenamiento más rápido y menos datos necesarios.
  • El mismo detector se aplica en toda la imagen → invarianza a traslación.
  • Generaliza mejor → un detector de bordes aprendido funciona en cualquier posición.

¿Cuántos parámetros tiene una capa Conv2D?

Cada capa convolucional tiene kernels + biases:

Parámetros de una capa Conv2D \text{Parámetros} = \underbrace{k \times k \times C_\text{in}}_{\text{pesos por filtro}} \times C_\text{out} + \underbrace{C_\text{out}}_{\text{biases}}

Es decir: cada filtro tiene k \times k \times C_\text{in} pesos (un kernel por cada canal de entrada), y hay C_\text{out} filtros, cada uno con su bias.

💡

Observación clave: el número de parámetros no depende del tamaño de la imagen. Un filtro 3×3 con 64 canales de entrada y 128 de salida tiene 3 \times 3 \times 64 \times 128 + 128 = 73{,}856 parámetros, sin importar si la imagen es 28×28 o 1024×1024.

Ejemplo: conteo completo de una CNN

Consideremos una CNN sencilla para clasificar imágenes de 32×32×3 (como CIFAR-10):

Capa Configuración Cálculo Parámetros
Conv1 3×3, 3→32 3 \times 3 \times 3 \times 32 + 32 896
Conv2 3×3, 32→64 3 \times 3 \times 32 \times 64 + 64 18.496
Conv3 3×3, 64→128 3 \times 3 \times 64 \times 128 + 128 73.856
MaxPool 2×2 0
GAP Global 0
Dense 128→10 128 \times 10 + 10 1.290
Total 94.538

Compáralo con un MLP equivalente: la misma imagen aplanada (32 \times 32 \times 3 = 3{,}072 inputs) con capas de 256, 128, 10 neuronas tendría más de 820.000 parámetros, ¡casi 9 veces más!

¿Y las capas de pooling?

Las capas de pooling no tienen parámetros entrenables. Son operaciones fijas (max, promedio) que no necesitan aprender nada. Lo mismo para operaciones como Dropout, BatchNorm (que sí tiene parámetros de escala γ y desplazamiento β), y Flatten.

Ejemplo en código

📦 Ver conteo de parámetros en PyTorch
import torch.nn as nn

class SimpleCNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(3, 32, kernel_size=3, padding=1),   # 896 params
            nn.ReLU(),
            nn.Conv2d(32, 64, kernel_size=3, padding=1),  # 18.496 params
            nn.ReLU(),
            nn.MaxPool2d(2),                               # 0 params
            nn.Conv2d(64, 128, kernel_size=3, padding=1), # 73.856 params
            nn.ReLU(),
            nn.AdaptiveAvgPool2d(1),                       # GAP, 0 params
        )
        self.classifier = nn.Linear(128, 10)               # 1.290 params

    def forward(self, x):
        x = self.features(x)
        x = x.view(x.size(0), -1)
        return self.classifier(x)

model = SimpleCNN()
total = sum(p.numel() for p in model.parameters())
print(f"Total parámetros: {total:,}")  # → 94,538
📦 Ver conteo de parámetros en TensorFlow / Keras
import tensorflow as tf

model = tf.keras.Sequential([
    tf.keras.layers.Conv2D(32, 3, padding='same', activation='relu',
                           input_shape=(32, 32, 3)),     # 896
    tf.keras.layers.Conv2D(64, 3, padding='same', activation='relu'),  # 18.496
    tf.keras.layers.MaxPooling2D(2),                      # 0
    tf.keras.layers.Conv2D(128, 3, padding='same', activation='relu'), # 73.856
    tf.keras.layers.GlobalAveragePooling2D(),             # 0
    tf.keras.layers.Dense(10),                            # 1.290
])

model.summary()
# Total params: 94,538

🧪 Calculadora de parámetros por capa

🔍 Extracción de características

La gran revolución de las CNNs es que aprenden automáticamente qué features extraer. Antes de las CNNs, los ingenieros diseñaban manualmente descriptores como SIFT, HOG o LBP —un proceso artesanal, costoso y que requería conocimiento experto del dominio. Las CNNs reemplazan ese proceso aprendiendo filtros óptimos directamente de los datos, lo que explica en gran parte su superioridad en tareas de visión. El momento clave fue AlexNet (Krizhevsky et al., 2012), que ganó la competición ImageNet con un margen enorme sobre los métodos basados en descriptores manuales, demostrando definitivamente la superioridad del aprendizaje automático de características.

Aprendizaje jerárquico

Las capas de una CNN forman una jerarquía de representaciones. Cada capa construye sobre las features de la anterior, creando detectores progresivamente más complejos y abstractos:

CAPAS 1-2 Low-level Bordes, colores, gradientes CAPAS 3-4 Mid-level Texturas, esquinas, formas simples CAPAS 5+ High-level Partes de objetos, objetos completos OUTPUT 🐱 gato 🐕 perro 🚗 coche

Jerarquía de features: las primeras capas detectan patrones simples, las capas profundas detectan conceptos semánticos complejos.

¿Por qué funciona la jerarquía?

Cada capa convolucional tiene un campo receptivo (receptive field) limitado. Un kernel de 3×3 solo "ve" 3×3 píxeles del input. Pero al apilar capas, el campo receptivo efectivo crece:

Campo receptivo efectivo (kernels 3×3, sin pooling) r_L = 1 + L \cdot (k - 1) = 1 + 2L

Con L=1 capa: campo de 3×3 → detecta bordes.
Con L=3 capas: campo de 7×7 → detecta texturas.
Con L=10 capas: campo de 21×21 → detecta objetos completos.

💡

Composicionalidad: un detector de "ojo" no necesita saber qué es un ojo directamente. Combina detectores de "borde curvo" + "círculo oscuro" + "brillo" de capas anteriores. Esta composición jerárquica es lo que hace que las CNNs sean tan potentes para visión. El trabajo de Zeiler & Fergus (2013) fue pionero en visualizar estas jerarquías, mostrando exactamente qué patrones aprende cada capa de una CNN entrenada en ImageNet. La herramienta interactiva Feature Visualization de Distill ofrece una exploración visual magnífica de este concepto.

🗺️ Visualización de feature maps

Podemos visualizar qué "ve" cada capa extrayendo las activaciones intermedias (feature maps) y mostrándolas como imágenes en escala de grises. Valores altos (claros) indican que el filtro ha detectado el patrón en esa posición.

Esta técnica es muy útil para depurar modelos: si los feature maps de las primeras capas no muestran bordes ni texturas reconocibles, algo va mal en el entrenamiento. También permite verificar que la red se enfoca en las partes correctas de la imagen para hacer su predicción, lo cual es especialmente importante en aplicaciones críticas como diagnóstico médico o conducción autónoma.

📦 Extraer feature maps en PyTorch
import torch
import torchvision.models as models
import matplotlib.pyplot as plt

# Cargar modelo preentrenado
model = models.vgg16(pretrained=True)
model.eval()

# Hook para capturar activaciones intermedias
activations = {}
def get_activation(name):
    def hook(model, input, output):
        activations[name] = output.detach()
    return hook

# Registrar hooks en las primeras capas conv
model.features[0].register_forward_hook(get_activation('conv1'))
model.features[5].register_forward_hook(get_activation('conv3'))
model.features[10].register_forward_hook(get_activation('conv6'))

# Forward pass con una imagen
img_tensor = torch.randn(1, 3, 224, 224)  # imagen de ejemplo
_ = model(img_tensor)

# Visualizar primeros 16 feature maps de conv1
fig, axes = plt.subplots(4, 4, figsize=(10, 10))
for i, ax in enumerate(axes.flat):
    ax.imshow(activations['conv1'][0, i].numpy(), cmap='viridis')
    ax.set_title(f'Filtro {i}')
    ax.axis('off')
plt.suptitle('Feature maps de Conv1')
plt.tight_layout()
plt.show()
📦 Extraer feature maps en TensorFlow / Keras
import tensorflow as tf
import numpy as np
import matplotlib.pyplot as plt

# Cargar modelo preentrenado
base_model = tf.keras.applications.VGG16(weights='imagenet')

# Crear modelo que devuelva las activaciones intermedias
layer_outputs = [layer.output for layer in base_model.layers[:8]]
activation_model = tf.keras.Model(
    inputs=base_model.input,
    outputs=layer_outputs
)

# Forward pass
img = np.random.rand(1, 224, 224, 3).astype(np.float32)
activations = activation_model.predict(img)

# Visualizar feature maps de la primera capa conv
first_layer_activation = activations[1]  # Después de Conv1
fig, axes = plt.subplots(4, 4, figsize=(10, 10))
for i, ax in enumerate(axes.flat):
    ax.imshow(first_layer_activation[0, :, :, i], cmap='viridis')
    ax.set_title(f'Filtro {i}')
    ax.axis('off')
plt.suptitle('Feature maps de la primera capa Conv2D')
plt.tight_layout()
plt.show()
🔗

Recursos: La extracción de feature maps intermedias es también la base del transfer learning y la feature extraction. En el submódulo de Clasificación y Transfer Learning veremos cómo reutilizar estas representaciones aprendidas para nuevas tareas.

🧠 Interpretabilidad de CNNs

Las CNNs suelen considerarse "cajas negras", pero existen técnicas para entender qué ha aprendido cada filtro y por qué la red toma una decisión. Esto es crucial tanto para depurar modelos como para aplicaciones donde la explicabilidad es necesaria (medicina, conducción autónoma…). La interpretabilidad no es un lujo académico: regulaciones como la GDPR europea incluyen un "derecho a la explicación" que exige que las decisiones automatizadas sean comprensibles. Además, un modelo interpretable es más fácil de depurar, mejorar y mantener en producción.

Visualización de filtros aprendidos

Los filtros (kernels) de las primeras capas se pueden visualizar directamente como pequeñas imágenes. En redes entrenadas en ImageNet, los filtros de la primera capa típicamente aprenden:

  • Detectores de bordes en distintas orientaciones (horizontales, verticales, diagonales).
  • Detectores de color que responden a combinaciones específicas de canales RGB.
  • Patrones de frecuencia similares a filtros Gabor (usados en visión clásica).
📝

Los filtros de las capas profundas son más difíciles de interpretar visualmente porque operan sobre features abstractas (no sobre píxeles). Para esas capas usamos técnicas como Grad-CAM o Feature Visualization.

🔥 Grad-CAM: mapas de calor de decisión

Grad-CAM (Gradient-weighted Class Activation Mapping) es la técnica más popular para entender dónde mira la red al hacer una predicción. Produce un mapa de calor que resalta las regiones de la imagen más relevantes para una clase concreta. Fue propuesto por Selvaraju et al. (2017) y se ha convertido en el estándar de facto para explicar decisiones de CNNs gracias a su sencillez y versatilidad: funciona con cualquier arquitectura CNN sin necesidad de modificar la red o reentrenarla.

¿Cómo funciona?

  1. Se realiza un forward pass con la imagen y se obtiene la predicción para la clase c.
  2. Se calcula el gradiente de la salida de la clase y^c respecto a los feature maps \mathbf{A}^k de la última capa convolucional.
  3. Se calcula la importancia de cada feature map k mediante global average pooling del gradiente:
Pesos de importancia (Grad-CAM) \alpha_k^c = \frac{1}{Z} \sum_{i} \sum_{j} \frac{\partial y^c}{\partial A^k_{ij}}
  1. El mapa de calor final es la combinación lineal ponderada de los feature maps, seguida de ReLU (solo nos interesan las activaciones positivas):
Mapa Grad-CAM L^c_{\text{Grad-CAM}} = \text{ReLU}\!\left( \sum_k \alpha_k^c \, A^k \right)

El resultado se redimensiona al tamaño de la imagen original y se superpone como un mapa de calor, mostrando qué regiones "activaron" la decisión de la red.

💡

¿Por qué ReLU? Solo nos interesan las features que tienen influencia positiva en la clase objetivo. Las activaciones negativas corresponden a features que pertenecen a otras clases.

📦 Implementación de Grad-CAM en PyTorch
import torch
import torch.nn.functional as F
import torchvision.models as models
import matplotlib.pyplot as plt
import numpy as np

model = models.resnet50(pretrained=True)
model.eval()

# Capturar feature maps y gradientes de la última capa conv
feature_maps = {}
gradients = {}

def forward_hook(module, input, output):
    feature_maps['last_conv'] = output

def backward_hook(module, grad_input, grad_output):
    gradients['last_conv'] = grad_output[0]

# Registrar hooks en layer4 (última capa conv de ResNet)
model.layer4[-1].register_forward_hook(forward_hook)
model.layer4[-1].register_full_backward_hook(backward_hook)

# Forward pass
img_tensor = torch.randn(1, 3, 224, 224)  # Tu imagen preprocesada
output = model(img_tensor)
class_idx = output.argmax(dim=1).item()

# Backward pass para la clase predicha
model.zero_grad()
output[0, class_idx].backward()

# Calcular Grad-CAM
grads = gradients['last_conv']           # (1, 2048, 7, 7)
fmaps = feature_maps['last_conv']        # (1, 2048, 7, 7)
weights = grads.mean(dim=[2, 3], keepdim=True)  # α_k = GAP de gradientes

cam = (weights * fmaps).sum(dim=1, keepdim=True)  # Combinación ponderada
cam = F.relu(cam)                                    # Solo positivos
cam = F.interpolate(cam, size=(224, 224), mode='bilinear', align_corners=False)
cam = cam.squeeze().detach().numpy()
cam = (cam - cam.min()) / (cam.max() - cam.min() + 1e-8)  # Normalizar [0,1]

# Visualizar
plt.imshow(img_tensor.squeeze().permute(1, 2, 0).numpy() * 0.5 + 0.5)
plt.imshow(cam, alpha=0.5, cmap='jet')
plt.title(f'Grad-CAM para clase {class_idx}')
plt.axis('off')
plt.show()
📦 Grad-CAM con tf-keras-vis (TensorFlow)
import tensorflow as tf
import numpy as np
import matplotlib.pyplot as plt

model = tf.keras.applications.ResNet50(weights='imagenet')
img = tf.random.normal((1, 224, 224, 3))

# Modelo que devuelve la última capa conv + predicción
last_conv_layer = model.get_layer('conv5_block3_out')
grad_model = tf.keras.Model(
    inputs=model.input,
    outputs=[last_conv_layer.output, model.output]
)

# Forward + gradientes
with tf.GradientTape() as tape:
    conv_output, predictions = grad_model(img)
    class_idx = tf.argmax(predictions[0])
    loss = predictions[:, class_idx]

# Gradientes respecto a los feature maps
grads = tape.gradient(loss, conv_output)      # (1, 7, 7, 2048)
weights = tf.reduce_mean(grads, axis=[1, 2])  # α_k: (1, 2048)

# Mapa Grad-CAM
cam = tf.reduce_sum(
    conv_output * weights[:, tf.newaxis, tf.newaxis, :],
    axis=-1
)[0]  # (7, 7)
cam = tf.nn.relu(cam)
cam = tf.image.resize(cam[..., tf.newaxis], (224, 224))[..., 0]
cam = (cam - tf.reduce_min(cam)) / (tf.reduce_max(cam) - tf.reduce_min(cam) + 1e-8)

plt.imshow(img[0] * 0.5 + 0.5)
plt.imshow(cam.numpy(), alpha=0.5, cmap='jet')
plt.title(f'Grad-CAM para clase {class_idx.numpy()}')
plt.axis('off')
plt.show()

🎯 Saliency maps y otras técnicas

Además de Grad-CAM, existen otras técnicas de interpretabilidad:

Saliency Maps (mapas de saliencia)

Un saliency map calcula el gradiente de la salida respecto a los píxeles de entrada directamente. La idea fue introducida por Simonyan et al. (2013) y es la forma más sencilla de interpretar una CNN:

Saliency Map S_{ij} = \left| \frac{\partial y^c}{\partial x_{ij}} \right|

Los píxeles con gradientes grandes son los más influyentes en la decisión. A diferencia de Grad-CAM (que da mapas suaves y gruesos), los saliency maps son de resolución completa pero más ruidosos. Para reducir el ruido, se puede utilizar SmoothGrad (Smilkov et al., 2017), que promedia los saliency maps de múltiples versiones ligeramente perturbadas de la imagen, produciendo mapas mucho más nítidos y fiables.

Comparación de técnicas

Técnica Resolución Qué muestra Complejidad
Grad-CAM Baja (del feature map) Regiones importantes para la clase Un backward pass
Saliency Map Alta (resolución input) Píxeles individuales influyentes Un backward pass
Guided Backprop Alta Features de alto nivel en alta resolución Backward modificado
Grad-CAM++ Baja Mejor para múltiples instancias Gradientes de 2º orden
LIME Media Superpíxeles relevantes Múltiples forward passes
SHAP (Deep) Alta Contribución de cada píxel Computacionalmente costoso
⚠️

Cuidado con la interpretación: estas técnicas muestran correlaciones, no necesariamente causalidad. Un mapa de calor que resalta el fondo de una imagen de "barco" puede indicar que la red ha aprendido a asociar "agua" con "barco", lo cual es un sesgo, no un buen razonamiento. Siempre interpreta estos mapas de forma crítica.

🧪 Explorador de interpretabilidad

Selecciona una técnica para ver su diagrama conceptual y cuándo usarla.

📚

Para profundizar en interpretabilidad: el artículo "The Building Blocks of Interpretability" (Distill, 2018) ofrece visualizaciones interactivas excepcionales. Para una visión general académica, véase el survey de Ribeiro et al. (2016) que introduce LIME, y el libro Interpretable Machine Learning de Christoph Molnar, disponible gratuitamente online.

🧪 Herramientas interactivas
🏭 Casos de uso