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:
Si conectamos esa entrada a una primera capa oculta de tan solo 1024 neuronas, necesitamos:
¡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í.
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.
¿Qué necesitamos?
Los tres problemas anteriores apuntan a la misma solución: necesitamos una arquitectura que:
Cada neurona mira solo una región pequeña de la entrada
Los mismos pesos se aplican en toda la imagen
Detecta patrones sin importar su posición
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).
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.
Convolución continua
En matemáticas, la convolución de dos funciones continuas f y g se define como:
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:
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:
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:
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.
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.
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 (2014) cambió el diseño de CNNs para siempre.
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:
| 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 |
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.
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.
Donde d es el factor de dilatación. Con d=1 (sin dilatación), el kernel es el estándar.
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.
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:
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)
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.
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.
Cada capa convolucional transforma un volumen de entrada (H×W×Cin) en un volumen de salida (H'×W'×Cout).
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.
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"):
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:
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.
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:
H × W × C
Extrae features
Reduce tamaño
→ vector
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):
| 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.
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.
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:
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
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. Las CNNs reemplazan ese proceso aprendiendo filtros óptimos directamente de los datos.
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:
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:
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.
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.
📦 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…).
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.
¿Cómo funciona?
- Se realiza un forward pass con la imagen y se obtiene la predicción para la clase c.
- 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.
- Se calcula la importancia de cada feature map k mediante global average pooling del gradiente:
- El mapa de calor final es la combinación lineal ponderada de los feature maps, seguida de ReLU (solo nos interesan las activaciones positivas):
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:
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.
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.