📖 Teoría

Clasificación y Transfer Learning

De la arquitectura backbone + head a las CNNs más influyentes (VGG, ResNet, EfficientNet), y cómo reutilizar modelos preentrenados para resolver nuevas tareas con pocos datos mediante transfer learning.

🏗️ CNNs para clasificación de imágenes

Las redes neuronales convolucionales fueron concebidas originalmente para la clasificación de imágenes: dado un input visual, asignarle una de C clases posibles (por ejemplo, «gato», «perro», «pájaro»). El diseño canónico de una CNN de clasificación se divide en dos grandes bloques funcionales:

💡

Backbone + Head: toda CNN de clasificación sigue la misma filosofía. Un backbone (columna vertebral) que extrae características de la imagen, y un head (cabezal) que toma la decisión de clasificación a partir de esas características.

Input 224×224×3 BACKBONE (Feature Extractor) Conv +BN+ReLU 112×112×64 Conv Block 56×56×128 Conv Block 7×7×512 GAP 1×1×512 HEAD (Classifier) FC 512 + ReLU FC C Logits σ P(gato)=0.85 P(perro)=0.10 P(pájaro)=0.05

El Backbone: extractor de características

El backbone es la parte «pesada» de la red. Está compuesto por capas convolucionales apiladas que progresivamente transforman la imagen de entrada en una representación de alto nivel: un tensor de feature maps que codifica la información semántica de la imagen.

A medida que avanzamos por las capas del backbone:

  • La resolución espacial disminuye — de 224 \times 224 a 7 \times 7 (mediante pooling o stride).
  • El número de canales aumenta — de 3 (RGB) a 512 o más, capturando características cada vez más abstractas.
  • Primeras capas → bordes y texturas; últimas capas → patrones semánticos (ojos, ruedas, letras…).
📝

Backbone conocidos: cuando alguien dice «usamos ResNet-50 como backbone» o «el backbone es un EfficientNet-B3», se refiere a que toda la parte convolucional de la red sigue esa arquitectura. Es la parte que se puede reutilizar entre tareas diferentes (transfer learning).

Global Average Pooling (GAP)

Antes de pasar al clasificador, necesitamos convertir el tensor 3D de feature maps en un vector. El enfoque clásico era usar Flatten(), pero las redes modernas utilizan Global Average Pooling (GAP):

Global Average Pooling \text{GAP}: \mathbb{R}^{H \times W \times C} \to \mathbb{R}^{C} \qquad z_c = \frac{1}{H \times W} \sum_{i=1}^{H} \sum_{j=1}^{W} x_{i,j,c}

Cada canal del feature map se resume en un único número: su media. Si tenemos 512 canales, obtenemos un vector de 512 dimensiones. Las ventajas de GAP frente a Flatten son:

Flatten Global Average Pooling
Output H \times W \times C valores C valores
Ejemplo (7×7×512) 25.088 valores 512 valores
Parámetros FC 25.088 × 1.000 = 25M 512 × 1.000 = 512K
Invarianza tamaño ❌ Depende de H×W ✅ Funciona con cualquier resolución
Overfitting Alto riesgo Menor riesgo

GAP como estándar: desde la introducción de GoogLeNet (2014), prácticamente todas las arquitecturas modernas utilizan GAP en lugar de Flatten. Reduce drásticamente los parámetros y actúa como regularizador implícito.

El Head: clasificador

El head recibe el vector producido por GAP (o Flatten) y genera la predicción final. En su forma más simple, es una única capa lineal seguida de softmax:

Head mínimo \hat{\mathbf{y}} = \text{softmax}(\mathbf{W} \cdot \mathbf{z} + \mathbf{b}) \qquad \mathbf{W} \in \mathbb{R}^{C_\text{clases} \times d}, \; \mathbf{z} \in \mathbb{R}^{d}

Algunas arquitecturas usan un head más complejo con capas ocultas adicionales, dropout y activaciones, pero la tendencia moderna es mantener el head lo más simple posible (una o dos capas lineales), ya que la mayor parte del «trabajo» lo realiza el backbone.

📦 Ejemplo: head en PyTorch y TensorFlow

Head mínimo (1 capa):

# PyTorch
head = nn.Linear(in_features=512, out_features=num_classes)

# TensorFlow / Keras
head = tf.keras.layers.Dense(num_classes, activation='softmax')

Head con capa oculta:

# PyTorch
head = nn.Sequential(
    nn.Linear(512, 256),
    nn.ReLU(),
    nn.Dropout(0.3),
    nn.Linear(256, num_classes)
)

# TensorFlow / Keras
head = tf.keras.Sequential([
    tf.keras.layers.Dense(256, activation='relu'),
    tf.keras.layers.Dropout(0.3),
    tf.keras.layers.Dense(num_classes, activation='softmax')
])

🔄 Pipeline completo de clasificación

El pipeline de clasificación de imágenes con una CNN sigue siempre los mismos pasos, desde el preprocesamiento de la imagen hasta la predicción final:

Preprocess Resize, Norm Augmentation Backbone Conv → BN → ReLU × N bloques 224×224×3 → 7×7×512 GAP → vec(512) Head (FC) Linear + σ 512 → C clases Loss Cross-Entropy → Backprop Forward pass completo: imagen → features → vector → probabilidades → loss

Función de pérdida: Cross-Entropy

Para clasificación multiclase, la función de pérdida estándar es la Cross-Entropy (o Categorical Cross-Entropy):

Cross-Entropy Loss \mathcal{L} = -\sum_{c=1}^{C} y_c \log(\hat{y}_c)

Donde y_c es la etiqueta real (one-hot) y \hat{y}_c es la probabilidad predicha para la clase c. En la práctica, los frameworks combinan la capa softmax y la cross-entropy en una sola operación por estabilidad numérica:

📦 Cross-Entropy en PyTorch vs TensorFlow
# PyTorch: CrossEntropyLoss incluye softmax internamente
# El modelo debe devolver logits (sin softmax al final)
loss_fn = nn.CrossEntropyLoss()
loss = loss_fn(logits, labels)   # labels: índices enteros [0, C-1]

# TensorFlow: dos opciones equivalentes
# Opción A: modelo con softmax + CategoricalCrossentropy
loss_fn = tf.keras.losses.CategoricalCrossentropy()  # labels one-hot

# Opción B: modelo sin softmax + SparseCategoricalCrossentropy
loss_fn = tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True)
loss = loss_fn(labels, logits)   # labels: índices enteros
⚠️

Error frecuente: en PyTorch, nn.CrossEntropyLoss aplica log_softmax internamente. Si tu modelo ya tiene una capa softmax al final, estarás aplicando softmax dos veces, lo que degrada el entrenamiento. En PyTorch el modelo debe devolver logits crudos.

Métricas de evaluación

Aunque la loss guía el entrenamiento, evaluamos el rendimiento con métricas más interpretables:

  • Accuracy (exactitud): \frac{\text{predicciones correctas}}{\text{total de muestras}}. Simple y directa, pero engañosa con clases desbalanceadas.
  • Top-k Accuracy: la clase correcta está entre las k predicciones con mayor probabilidad. ImageNet usa Top-5.
  • Precision, Recall, F1: métricas por clase, esenciales cuando hay desbalanceo o cuando ciertos errores son más costosos que otros.
  • Confusion Matrix: visualización completa de qué clases se confunden entre sí. Imprescindible en el análisis de errores.

📜 Evolución de las arquitecturas CNN

Desde la primera CNN funcional (LeNet-5, 1998) hasta la explosión del deep learning a partir de 2012, cada nueva arquitectura introdujo ideas que resolvieron limitaciones concretas de la anterior. Esta evolución no fue gradual: cada hito supuso un salto cualitativo en rendimiento y en nuestra comprensión de cómo diseñar redes profundas.

LeNet-5 1998 AlexNet 2012 VGG 2014 GoogLeNet 2014 ResNet 2015 16.4% 7.3% 6.7% 3.6%

🧱 VGG: la fuerza bruta de la profundidad

VGGNet (Simonyan & Zisserman, 2014) demostró un principio simple pero poderoso: apilar muchas capas con filtros pequeños (3×3) es más eficaz que usar pocas capas con filtros grandes. Esta idea de «profundidad con simplicidad» sentó las bases de las arquitecturas posteriores.

Idea clave: solo filtros 3×3

Mientras que AlexNet usaba filtros de 11×11 y 5×5, VGG utiliza exclusivamente filtros 3×3. ¿Por qué funciona? Porque dos capas 3×3 apiladas tienen el mismo campo receptivo que una capa 5×5, pero con más no-linealidades y menos parámetros:

Campo receptivo equivalente \underbrace{3 \times 3 \to 3 \times 3}_{\text{campo receptivo } 5 \times 5} \quad \text{vs} \quad \underbrace{5 \times 5}_{\text{campo receptivo } 5 \times 5}
Comparación de parámetros (C canales) 2 \times (3^2 C^2) = 18C^2 \quad \text{vs} \quad 5^2 C^2 = 25C^2

Tres capas 3×3 equivalen a una 7×7 con 3 \times 9C^2 = 27C^2 frente a 49C^2 parámetros: un 45% menos, con tres ReLU en lugar de una.

Arquitectura VGG-16

VGG-16 organiza las capas en 5 bloques convolucionales, cada uno seguido de max pooling. Al final, tres capas fully-connected actúan como clasificador:

Bloque Capas Feature maps Resolución
Block 1 2 × Conv3×3-64 64 224 → 112 (pool)
Block 2 2 × Conv3×3-128 128 112 → 56 (pool)
Block 3 3 × Conv3×3-256 256 56 → 28 (pool)
Block 4 3 × Conv3×3-512 512 28 → 14 (pool)
Block 5 3 × Conv3×3-512 512 14 → 7 (pool)
FC layers FC-4096 → FC-4096 → FC-1000
Total parámetros ~138M
⚠️

El talón de Aquiles de VGG: de los 138M de parámetros, ~124M están en las capas FC (el head). Las capas convolucionales solo tienen ~14M. Esto hizo que VGG fuese muy costosa en memoria (>500 MB por modelo). Las arquitecturas posteriores abandonaron los heads densos masivos.

📦 VGG-16 en código (PyTorch / TensorFlow)
# PyTorch: VGG-16 preentrenada en ImageNet
import torchvision.models as models
vgg16 = models.vgg16(weights='IMAGENET1K_V1')

# Backbone: vgg16.features  (13 conv + 5 maxpool)
# Head:     vgg16.classifier (3 FC layers)
print(f"Params backbone: {sum(p.numel() for p in vgg16.features.parameters()):,}")
print(f"Params head:     {sum(p.numel() for p in vgg16.classifier.parameters()):,}")
# Params backbone: 14,714,688
# Params head:     123,642,856
# TensorFlow / Keras
from tensorflow.keras.applications import VGG16
vgg16 = VGG16(weights='imagenet', include_top=True)
vgg16.summary()  # Total params: 138,357,544

🔬 GoogLeNet / Inception: eficiencia multiescala

GoogLeNet (Szegedy et al., 2014) adoptó un enfoque radicalmente diferente a VGG: en lugar de apilar capas secuencialmente, introdujo el módulo Inception, que aplica múltiples operaciones en paralelo y concatena los resultados. Con solo ~6.8M de parámetros (20× menos que VGG), consiguió menor error en ImageNet.

El módulo Inception

La idea central es: ¿por qué elegir un tamaño de filtro cuando puedes usar varios a la vez? Cada módulo Inception aplica en paralelo:

  • Convoluciones 1×1 — capturan correlaciones entre canales.
  • Convoluciones 3×3 — patrones locales.
  • Convoluciones 5×5 — patrones más amplios.
  • Max pooling 3×3 — invarianza local.
Previous layer Conv 1×1 1×1 reduce Conv 3×3 1×1 reduce Conv 5×5 MaxPool 3×3 Conv 1×1 Concatenate (depth)

Convoluciones 1×1: la «botella» reductora

La innovación más influyente de Inception fue la convolución 1×1 usada como cuello de botella (bottleneck). Antes de aplicar las costosas convoluciones 3×3 y 5×5, una conv 1×1 reduce el número de canales:

Reducción con 1×1 (ejemplo) \underbrace{256}_{\text{canales entrada}} \xrightarrow{\text{Conv } 1\times1 \times 64} \underbrace{64}_{\text{canales reducidos}} \xrightarrow{\text{Conv } 5\times5 \times 64} \underbrace{64}_{\text{salida}}

Sin la reducción, una conv 5×5 sobre 256 canales necesitaría 5^2 \times 256 \times 64 = 409{,}600 parámetros. Con la reducción 1×1 previa: 1^2 \times 256 \times 64 + 5^2 \times 64 \times 64 = 16{,}384 + 102{,}400 = 118{,}784 — un 71% menos.

💡

Legado de Inception: las convoluciones 1×1 como reductoras de dimensionalidad se convirtieron en un patrón universal. ResNet, MobileNet, EfficientNet y prácticamente toda arquitectura moderna las utiliza.

ResNet: conexiones residuales

ResNet (He et al., 2015) es probablemente la arquitectura más influyente de la historia de las CNNs. Introdujo las conexiones residuales (skip connections), resolviendo el problema de la degradación que impedía entrenar redes muy profundas.

El problema de la degradación

Antes de ResNet, se observaba un fenómeno contra-intuitivo: al añadir más capas a una red, el error de entrenamiento (no solo el de validación) aumentaba. Esto no era overfitting, era un problema de optimización: el gradiente se desvanecía o explotaba a través de muchas capas, haciendo imposible que las capas profundas aprendieran.

⚠️

Degradación ≠ Vanishing Gradient: aunque están relacionados, la degradación es un problema más sutil. Incluso con BatchNorm (que estabiliza los gradientes), las redes de 56+ capas rendían peor que las de 20 capas. La optimización simplemente no encontraba buenas soluciones en paisajes de pérdida tan complejos.

La solución: bloques residuales

La idea genial de ResNet es deceptivamente simple: en lugar de aprender una transformación directa \mathcal{H}(\mathbf{x}), la red aprende el residuo \mathcal{F}(\mathbf{x}) = \mathcal{H}(\mathbf{x}) - \mathbf{x}. La salida del bloque es:

Bloque residual \mathbf{y} = \mathcal{F}(\mathbf{x}) + \mathbf{x}
x Conv 3×3 BN + ReLU Conv 3×3 identity (skip) + 𝓕(x) = camino principal x = skip connection y = 𝓕(x) + x Si 𝓕(x) = 0 → y = x (la red puede «saltarse» capas que no aportan mejora)

La magia está en que si las capas del bloque no aportan mejora, sus pesos pueden converger a cero (\mathcal{F}(\mathbf{x}) \to 0), y la salida será simplemente \mathbf{x}: la identidad. Esto garantiza que añadir capas nunca empeora el modelo.

Bottleneck residual block

Para redes profundas (ResNet-50 y superiores), los bloques usan un diseño bottleneck con tres convoluciones:

Capa Filtro Propósito
Conv 1×1 Reduce canales (e.g., 256→64) Compresión de dimensionalidad
Conv 3×3 Procesa en espacio reducido Extracción de features (operación costosa)
Conv 1×1 Expande canales (e.g., 64→256) Restaurar dimensionalidad original

Este diseño reduce el cómputo drásticamente: procesar 64 canales con un 3×3 es 16× más barato que procesar los 256 originales.

Variantes de ResNet

Modelo Capas Parámetros Top-1 Acc (ImageNet) Tipo de bloque
ResNet-18 18 11.7M 69.8% Basic (2 × 3×3)
ResNet-34 34 21.8M 73.3% Basic (2 × 3×3)
ResNet-50 50 25.6M 76.1% Bottleneck (1×1 → 3×3 → 1×1)
ResNet-101 101 44.5M 77.4% Bottleneck
ResNet-152 152 60.2M 78.3% Bottleneck

¿Por qué ResNet sigue siendo relevante? Más de 8 años después de su publicación, ResNet sigue siendo uno de los backbones más utilizados. Su diseño es simple, fácil de escalar, y las skip connections se han adoptado en prácticamente toda arquitectura posterior — incluyendo Transformers.

📦 ResNet-50 en código
# PyTorch
import torchvision.models as models
resnet50 = models.resnet50(weights='IMAGENET1K_V2')

# Inspeccionar la estructura
print(resnet50)
# ResNet(
#   (conv1): Conv2d(3, 64, kernel_size=7, stride=2, padding=3)
#   (bn1): BatchNorm2d(64)
#   (relu): ReLU(inplace=True)
#   (maxpool): MaxPool2d(kernel_size=3, stride=2, padding=1)
#   (layer1): Sequential(... 3 Bottleneck blocks ...)
#   (layer2): Sequential(... 4 Bottleneck blocks ...)
#   (layer3): Sequential(... 6 Bottleneck blocks ...)
#   (layer4): Sequential(... 3 Bottleneck blocks ...)
#   (avgpool): AdaptiveAvgPool2d(output_size=(1, 1))
#   (fc): Linear(in_features=2048, out_features=1000)
# )
# TensorFlow / Keras
from tensorflow.keras.applications import ResNet50
resnet50 = ResNet50(weights='imagenet', include_top=True)
resnet50.summary()  # Total params: 25,636,712

📊 Comparativa de arquitecturas clásicas

Cada arquitectura aportó una innovación clave. Esta tabla resume las contribuciones principales y las cifras más relevantes:

🧪 Explorador de arquitecturas clásicas

Arquitectura Año Parámetros Top-5 Error Innovación clave
AlexNet 2012 60M 16.4% ReLU, Dropout, GPU training
VGG-16 2014 138M 7.3% Profundidad con filtros 3×3
GoogLeNet 2014 6.8M 6.7% Módulos Inception, conv 1×1
ResNet-50 2015 25.6M 3.6% Skip connections, bottleneck
ResNet-152 2015 60.2M 3.6% Redes de 152 capas entrenables
📝

Tendencia clara: cada generación logró mejor rendimiento con menos parámetros (excepto la transición AlexNet→VGG, que apostó por la fuerza bruta). El verdadero salto vino con las skip connections de ResNet, que desbloquearon la posibilidad de entrenar redes arbitrariamente profundas.

📱 MobileNet: CNNs ligeras para dispositivos móviles

MobileNet (Howard et al., 2017) fue diseñada para ejecutarse en dispositivos con recursos limitados: smartphones, dispositivos IoT, drones. Su innovación clave es la convolución separable en profundidad (depthwise separable convolution), que reduce drásticamente el coste computacional.

Convolución separable en profundidad

Una convolución estándar aplica C_\text{out} filtros de tamaño k \times k \times C_\text{in}. La convolución separable la descompone en dos pasos:

  1. Depthwise convolution: un filtro k \times k independiente por canal. Cada canal se procesa por separado. Parámetros: k^2 \times C_\text{in}.
  2. Pointwise convolution: una conv 1 \times 1 que combina los canales. Parámetros: C_\text{in} \times C_\text{out}.
Convolución estándar Input H×W×Cin k×k×Cin × Cout H×W×Cout Coste: k²·Cin·Cout·H·W Depthwise Separable Input H×W×Cin Depthwise k×k × 1 por canal Pointwise 1×1 × Cin → Cout Out Coste: k²·Cin·H·W + Cin·Cout·H·W Ratio ≈ 1/Cout + 1/k² → para k=3, Cout=256: ≈9× menos FLOPs

Ahorro computacional

La ratio entre el coste de una convolución separable y una estándar es:

Factor de reducción \frac{\text{Depthwise Separable}}{\text{Standard}} = \frac{1}{C_\text{out}} + \frac{1}{k^2}

Para un filtro 3×3 con 256 canales de salida, esto es \frac{1}{256} + \frac{1}{9} \approx 0.115, es decir, ~8-9× menos operaciones.

MobileNetV2: inverted residuals

MobileNetV2 (Sandler et al., 2018) mejoró el diseño con bloques residuales invertidos (inverted residual blocks):

  1. Expansión: conv 1×1 que aumenta los canales (factor de expansión t, típicamente 6).
  2. Depthwise: conv 3×3 depthwise en el espacio expandido.
  3. Proyección: conv 1×1 que comprime de vuelta. Sin activación (linear bottleneck) para preservar información.
  4. Skip connection: como ResNet, pero conectando las capas estrechas (no las anchas).
💡

¿Por qué «invertido»? En ResNet el bottleneck comprime → procesa → expande. En MobileNetV2 es al revés: expande → procesa → comprime. Las skip connections van entre las representaciones comprimidas (estrechas), que son las que realmente almacenan la información útil.

📦 MobileNetV2 en código
# PyTorch
import torchvision.models as models
mobilenet = models.mobilenet_v2(weights='IMAGENET1K_V2')
print(f"Parámetros: {sum(p.numel() for p in mobilenet.parameters()):,}")
# Parámetros: 3,504,872  (~3.5M — ¡40× menos que VGG-16!)

# Backbone: mobilenet.features
# Head:     mobilenet.classifier
# TensorFlow / Keras
from tensorflow.keras.applications import MobileNetV2
mobilenet = MobileNetV2(weights='imagenet', include_top=True)
mobilenet.summary()  # Total params: 3,538,984

⚖️ EfficientNet: escalado compuesto

EfficientNet (Tan & Le, 2019) abordó una pregunta fundamental: dado un presupuesto computacional fijo, ¿cómo escalar una red de manera óptima? La respuesta fue el compound scaling (escalado compuesto).

Las tres dimensiones de escalado

Una CNN puede hacerse más grande en tres dimensiones:

  • Profundidad (depth): más capas. Captura features más complejos, pero las redes muy profundas sufren vanishing gradient.
  • Anchura (width): más canales por capa. Captura features más finos, pero redes anchas y poco profundas no capturan features de alto nivel.
  • Resolución: mayor resolución de entrada. Más detalle, pero más coste cuadrático en la resolución.

La observación clave de EfficientNet es que escalar solo una dimensión da rendimientos decrecientes rápidamente. Escalar las tres de forma coordinada es mucho más eficiente:

Compound Scaling \text{depth} = \alpha^\phi, \quad \text{width} = \beta^\phi, \quad \text{resolution} = \gamma^\phi \quad \text{sujeto a } \alpha \cdot \beta^2 \cdot \gamma^2 \approx 2

Donde \phi es un coeficiente de escalado controlado por el usuario, y \alpha, \beta, \gamma son constantes determinadas mediante búsqueda (NAS). La restricción \alpha \cdot \beta^2 \cdot \gamma^2 \approx 2 asegura que el coste computacional (FLOPs) se duplica con cada incremento de \phi.

Solo Width Solo Depth Solo Resolution 512×512 224×224 128×128 Compound Scaling ✓ + ancho + profundo + resolución

La familia EfficientNet

El modelo base EfficientNet-B0 fue descubierto mediante Neural Architecture Search (NAS) y es muy eficiente por sí mismo. Aplicando compound scaling con distintos valores de \phi se obtiene la familia B0-B7:

Modelo Resolución Parámetros FLOPs Top-1 Acc
B0 224 5.3M 0.39B 77.1%
B1 240 7.8M 0.70B 79.1%
B2 260 9.2M 1.0B 80.1%
B3 300 12M 1.8B 81.6%
B4 380 19M 4.2B 82.9%
B5 456 30M 9.9B 83.6%
B6 528 43M 19B 84.0%
B7 600 66M 37B 84.3%

EfficientNet-B0 consigue la misma accuracy que ResNet-50 con 5× menos parámetros y 11× menos FLOPs. EfficientNet-B7 superó al mejor modelo existente usando 8.4× menos cómputo.

📦 MBConv: el bloque básico de EfficientNet

EfficientNet usa Mobile Inverted Bottleneck (MBConv) — el mismo bloque de MobileNetV2 — con una adición importante: Squeeze-and-Excitation (SE), un mecanismo de atención por canal:

  1. Expansión: Conv 1×1 (expand ratio × canales).
  2. Depthwise: Conv k×k depthwise (k = 3 o 5).
  3. SE block: Global Average Pool → FC → ReLU → FC → Sigmoid. Recalibra la importancia de cada canal.
  4. Proyección: Conv 1×1 (reduce canales) sin activación.
  5. Skip connection: como MobileNetV2.
# PyTorch
from torchvision.models import efficientnet_b0
model = efficientnet_b0(weights='IMAGENET1K_V1')
print(f"Params: {sum(p.numel() for p in model.parameters()):,}")
# Params: 5,288,548
# TensorFlow / Keras
from tensorflow.keras.applications import EfficientNetB0
model = EfficientNetB0(weights='imagenet')
model.summary()  # Total params: 5,330,571

🕸️ DenseNet: conexiones densas

DenseNet (Huang et al., 2017) llevó la idea de las skip connections al extremo: en lugar de sumar la entrada a la salida (como ResNet), cada capa recibe como entrada todos los feature maps de las capas anteriores mediante concatenación.

DenseNet: conexión densa \mathbf{x}_\ell = H_\ell\left([\mathbf{x}_0, \mathbf{x}_1, \ldots, \mathbf{x}_{\ell-1}]\right)

Donde [\ldots] denota concatenación a lo largo de la dimensión de canales. Si cada capa produce k feature maps (growth rate), la capa \ell recibe k_0 + k \times (\ell - 1) canales de entrada.

Ventajas de DenseNet

  • Reutilización máxima de features: cada capa tiene acceso directo a los gradientes y features de todas las capas anteriores.
  • Flujo de gradiente mejorado: la supervisión implícita profunda facilita el entrenamiento.
  • Eficiencia en parámetros: con un growth rate bajo (k=12 o 32), DenseNet consigue buenos resultados con relativamente pocos parámetros.
Modelo Capas Growth rate Parámetros Top-1 Acc
DenseNet-121 121 32 8.0M 74.4%
DenseNet-169 169 32 14.1M 75.6%
DenseNet-201 201 32 20.0M 76.9%
📝

Concatenar vs Sumar: ResNet suma (\mathbf{y} = \mathcal{F}(\mathbf{x}) + \mathbf{x}), lo que preserva la dimensionalidad pero puede perder información. DenseNet concatena, preservando explícitamente cada representación pero incrementando los canales progresivamente. Ambas estrategias son complementarias.

📊 Comparativa: eficiencia vs. rendimiento

La evolución de las arquitecturas modernas se resume en una búsqueda constante del mejor trade-off entre accuracy, parámetros y coste computacional:

🧪 Comparador de eficiencia

Modelo Params FLOPs Top-1 Acc Acc/Param Mejor para
VGG-16 138M 15.5B 71.6% 0.52 Backbone sencillo, enseñanza
ResNet-50 25.6M 4.1B 76.1% 2.97 Backbone general, transfer learning
DenseNet-121 8.0M 2.9B 74.4% 9.30 Datasets pequeños, parámetros limitados
MobileNetV2 3.5M 0.3B 72.0% 20.6 Mobile, edge, tiempo real
EfficientNet-B0 5.3M 0.39B 77.1% 14.5 Mejor trade-off general
EfficientNet-B4 19M 4.2B 82.9% 4.36 Alta accuracy con presupuesto moderado
💡

¿Cuál elegir? La elección depende del contexto:

  • Producción en servidor: EfficientNet-B3/B4 o ResNet-50.
  • Dispositivo móvil/edge: MobileNetV2 o EfficientNet-B0.
  • Investigación/baseline: ResNet-50 (el estándar de facto).
  • Dataset pequeño: Cualquiera con transfer learning.

🔄 ¿Qué es el Transfer Learning?

Transfer learning (aprendizaje por transferencia) consiste en reutilizar un modelo entrenado en una tarea (normalmente un dataset masivo como ImageNet con 14 millones de imágenes y 1000 clases) para resolver una tarea diferente (por ejemplo, clasificar radiografías médicas con solo unos cientos de imágenes).

La intuición es sencilla: las features aprendidas por las capas convolucionales —bordes, texturas, formas, patrones— son suficientemente generales como para ser útiles en muchas tareas de visión, no solo en la tarea original.

💡

Analogía: es como aprender a conducir un coche y luego usar esa habilidad para conducir una furgoneta. No empiezas de cero: ya sabes mirar por los espejos, usar los pedales, interpretar señales. Solo necesitas adaptarte a las diferencias específicas del nuevo vehículo.

¿Por qué funciona?

Las CNNs aprenden features de forma jerárquica. Las primeras capas detectan patrones universales (bordes, gradientes, colores); las capas intermedias combinan estos en texturas y formas; las capas profundas capturan patrones semánticos específicos de la tarea:

Capas iniciales Bordes Gradientes Colores ✓ Universales Capas medias Texturas Patrones Formas básicas ≈ Semi-generales Capas profundas Ojos, ruedas Caras, texto Objetos parciales ✗ Específicas Head Clasificador 1000 clases ✗ Tarea-específico ←── más transferible ──────────────── menos transferible ──→

Los estudios de Yosinski et al. (2014) demostraron cuantitativamente que las primeras capas son altamente transferibles entre tareas, mientras que las últimas capas se vuelven cada vez más específicas de la tarea original.

Ventajas del Transfer Learning

  • Menos datos necesarios: entrenar una CNN desde cero para ImageNet requiere ~14M imágenes. Con TL, puedes obtener buenos resultados con cientos o miles de imágenes.
  • Entrenamiento más rápido: en lugar de días o semanas de entrenamiento, el fine-tuning toma minutos u horas.
  • Mejor generalización: las features preentrenadas actúan como un prior informativo, reduciendo el riesgo de overfitting.
  • Menos recursos: no necesitas una flota de GPUs para obtener un modelo competitivo.

🎯 Estrategias de Transfer Learning

Hay dos estrategias principales para aplicar transfer learning, y la elección entre ellas depende de la cantidad de datos disponibles y de la similitud entre tu dominio y el dominio original (ImageNet):

Estrategia 1: Feature Extraction (extracción de características)

Se utiliza el backbone preentrenado como un extractor de features fijo: se congelan todos los pesos del backbone y solo se entrena un nuevo head adaptado a la tarea.

🔒 BACKBONE CONGELADO Conv 🔒 frozen Conv 🔒 frozen GAP 🔒 🔓 HEAD NUEVO (ENTRENABLE) FC 256 + ReLU FC N clases + Softmax Pesos de ImageNet (no se actualizan) Se entrena desde cero
AspectoDetalle
Backbone100% congelado (🔒). Los gradientes no se propagan a estas capas.
HeadSe reemplaza completamente. Se diseña para las N nuevas clases.
EntrenamientoSolo se entrenan los parámetros del head (miles, no millones).
VelocidadMuy rápido. Minutos en una GPU modesta.
Cuándo usarloPocos datos (<1000 por clase) y dominio similar a ImageNet.

Estrategia 2: Fine-tuning (ajuste fino)

En fine-tuning, además de entrenar el nuevo head, se descongelan algunas (o todas) las capas del backbone y se re-entrenan con un learning rate mucho más bajo:

🔒 CAPAS INICIALES Conv 🔒 Conv 🔒 🔓 CAPAS PROFUNDAS (lr bajo) Conv 🔓 lr=1e-5 Conv 🔓 lr=1e-5 🔓 HEAD NUEVO (lr alto) FC 256 lr=1e-3 FC N clases lr=1e-3 Learning rates diferenciados: bajo en backbone, alto en head
AspectoDetalle
Capas inicialesCongeladas (🔒). Features universales que no necesitan cambiar.
Capas profundasDescongeladas (🔓) con learning rate bajo (10-100× menor).
HeadNuevo, entrenado con learning rate normal.
RiesgoSi el lr del backbone es demasiado alto → se destruyen los pesos preentrenados (catastrophic forgetting).
Cuándo usarloDatos moderados (1000-10000+ por clase) o dominio diferente.
⚠️

Catastrophic forgetting: si el learning rate del backbone es demasiado alto, los pesos preentrenados se «olvidan» de las features aprendidas en ImageNet, perdiendo toda la ventaja del transfer learning. Regla de oro: usa un lr 10-100× menor para el backbone que para el head.

🧭 ¿Cuándo usar cada estrategia?

La decisión depende de dos factores: cuántos datos tienes y cuán similar es tu dominio al dominio de preentrenamiento:

🧪 Selector de estrategia de Transfer Learning

Matriz de decisión

Dominio similar Dominio diferente
Pocos datos Feature Extraction
Congelar backbone, entrenar solo head. Riesgo de overfitting si se descongelan capas.
Feature Extraction (con cuidado)
Probar congelar backbone. Si no funciona, considerar modelos preentrenados en dominios más cercanos.
Muchos datos Fine-tuning
Descongelar capas profundas con lr bajo. Muchos datos = bajo riesgo de overfitting.
Fine-tuning agresivo o desde cero
Descongelar todo con lr bajo. Si el dominio es muy diferente, considerar entrenar desde cero.

Protocolo de fine-tuning progresivo

Una técnica muy efectiva es el fine-tuning progresivo (progressive unfreezing), que reduce drásticamente el riesgo de catastrophic forgetting:

  1. Fase 1: Congelar todo el backbone. Entrenar solo el head nuevo durante 5-10 epochs (learning rate normal: 1e-3).
  2. Fase 2: Descongelar las últimas 2-3 capas del backbone. Entrenar con lr bajo (1e-5) para backbone, normal para head.
  3. Fase 3 (opcional): Descongelar todo el backbone. Learning rate muy bajo (1e-6) con un scheduler de coseno.

Tip profesional: antes de decidir qué estrategia usar, siempre empieza con feature extraction (backbone 100% congelado). Es rápido, barato y te da un baseline sólido. Si no es suficiente, pasa a fine-tuning progresivo.

Otros consejos prácticos

📦 Tips avanzados de Transfer Learning
  • Data augmentation agresivo: especialmente con pocos datos, usa augmentation (rotaciones, flips, color jitter, cutout, mixup) para regularizar.
  • Normalización coherente: usa exactamente la misma normalización que el modelo preentrenado (media y desviación estándar de ImageNet: mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]).
  • Batch size: con fine-tuning y learning rates muy bajos, los batches pequeños (8-16) suelen funcionar mejor que los grandes.
  • Scheduler: un cosine annealing o warmup+decay es preferible a un learning rate constante.
  • Early stopping: monitoriza el validation loss. Con pocos datos, el modelo puede empezar a sobreajustar rápidamente.
  • Discriminative learning rates: en lugar de un lr global, asigna learning rates decrecientes por bloque (más bajo en capas tempranas, más alto en capas tardías).

🔥 Transfer Learning en PyTorch

Vamos a implementar transfer learning paso a paso en PyTorch, usando ResNet-50 preentrenado en ImageNet para clasificar un dataset personalizado (por ejemplo, 5 clases de flores). Veremos ambas estrategias: feature extraction y fine-tuning.

Paso 1: Cargar el modelo preentrenado

📦 Cargar ResNet-50 y preparar el dataset
import torch
import torch.nn as nn
import torch.optim as optim
from torchvision import models, transforms, datasets
from torch.utils.data import DataLoader

# ─── Hiperparámetros ───
NUM_CLASSES = 5       # Nuestras clases (e.g., flores)
BATCH_SIZE = 32
LR_HEAD = 1e-3        # Learning rate para el head
LR_BACKBONE = 1e-5    # Learning rate para el backbone (fine-tuning)
EPOCHS_FE = 10        # Epochs para feature extraction
EPOCHS_FT = 10        # Epochs para fine-tuning
DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

# ─── Transforms (IMPORTANTE: usar normalización de ImageNet) ───
imagenet_mean = [0.485, 0.456, 0.406]
imagenet_std  = [0.229, 0.224, 0.225]

train_transform = transforms.Compose([
    transforms.RandomResizedCrop(224),
    transforms.RandomHorizontalFlip(),
    transforms.ColorJitter(brightness=0.2, contrast=0.2),
    transforms.ToTensor(),
    transforms.Normalize(imagenet_mean, imagenet_std),
])

val_transform = transforms.Compose([
    transforms.Resize(256),
    transforms.CenterCrop(224),
    transforms.ToTensor(),
    transforms.Normalize(imagenet_mean, imagenet_std),
])

# ─── Datasets (estructura: data/train/clase1/, data/train/clase2/, ...) ───
train_dataset = datasets.ImageFolder('data/train', transform=train_transform)
val_dataset   = datasets.ImageFolder('data/val',   transform=val_transform)
train_loader  = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True)
val_loader    = DataLoader(val_dataset,   batch_size=BATCH_SIZE)

# ─── Modelo preentrenado ───
model = models.resnet50(weights='IMAGENET1K_V2')
print(f"Head original: {model.fc}")
# Head original: Linear(in_features=2048, out_features=1000)

Paso 2: Feature Extraction (backbone congelado)

📦 Feature Extraction en PyTorch
# ─── FEATURE EXTRACTION ───
# 1. Congelar TODO el backbone
for param in model.parameters():
    param.requires_grad = False

# 2. Reemplazar el head (la última capa FC)
model.fc = nn.Sequential(
    nn.Linear(2048, 256),
    nn.ReLU(),
    nn.Dropout(0.3),
    nn.Linear(256, NUM_CLASSES)   # Sin softmax (CrossEntropyLoss lo incluye)
)

# 3. Solo los parámetros del head nuevo son entrenables
trainable = [p for p in model.parameters() if p.requires_grad]
print(f"Parámetros entrenables: {sum(p.numel() for p in trainable):,}")
# Parámetros entrenables: 526,597 (vs 25.6M totales de ResNet-50)

model = model.to(DEVICE)

# 4. Optimizador (solo parámetros del head)
optimizer = optim.Adam(model.fc.parameters(), lr=LR_HEAD)
loss_fn = nn.CrossEntropyLoss()

# 5. Entrenamiento
for epoch in range(EPOCHS_FE):
    model.train()
    total_loss, correct, total = 0, 0, 0

    for images, labels in train_loader:
        images, labels = images.to(DEVICE), labels.to(DEVICE)

        # Forward
        outputs = model(images)
        loss = loss_fn(outputs, labels)

        # Backward
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

        total_loss += loss.item() * images.size(0)
        correct += (outputs.argmax(1) == labels).sum().item()
        total += images.size(0)

    train_acc = correct / total
    avg_loss = total_loss / total

    # Validación
    model.eval()
    val_correct, val_total = 0, 0
    with torch.no_grad():
        for images, labels in val_loader:
            images, labels = images.to(DEVICE), labels.to(DEVICE)
            outputs = model(images)
            val_correct += (outputs.argmax(1) == labels).sum().item()
            val_total += images.size(0)

    val_acc = val_correct / val_total
    print(f"Epoch {epoch+1}/{EPOCHS_FE} | "
          f"Loss: {avg_loss:.4f} | "
          f"Train Acc: {train_acc:.3f} | "
          f"Val Acc: {val_acc:.3f}")

Paso 3: Fine-tuning (descongelar capas profundas)

📦 Fine-tuning progresivo en PyTorch
# ─── FINE-TUNING (después de feature extraction) ───
# 1. Descongelar las últimas capas del backbone (layer4 de ResNet)
for param in model.layer4.parameters():
    param.requires_grad = True

# Opcionalmente, descongelar también layer3:
# for param in model.layer3.parameters():
#     param.requires_grad = True

trainable_ft = [p for p in model.parameters() if p.requires_grad]
print(f"Parámetros entrenables (fine-tuning): {sum(p.numel() for p in trainable_ft):,}")

# 2. Discriminative learning rates:
#    - lr bajo para backbone (capas descongeladas)
#    - lr normal para head
optimizer_ft = optim.Adam([
    {'params': model.layer4.parameters(), 'lr': LR_BACKBONE},      # lr = 1e-5
    {'params': model.fc.parameters(),     'lr': LR_HEAD * 0.1},    # lr = 1e-4
], weight_decay=1e-4)

# 3. Scheduler (cosine annealing)
scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer_ft, T_max=EPOCHS_FT)

# 4. Entrenamiento fine-tuning
for epoch in range(EPOCHS_FT):
    model.train()
    total_loss, correct, total = 0, 0, 0

    for images, labels in train_loader:
        images, labels = images.to(DEVICE), labels.to(DEVICE)

        outputs = model(images)
        loss = loss_fn(outputs, labels)

        optimizer_ft.zero_grad()
        loss.backward()
        optimizer_ft.step()

        total_loss += loss.item() * images.size(0)
        correct += (outputs.argmax(1) == labels).sum().item()
        total += images.size(0)

    scheduler.step()

    # Validación
    model.eval()
    val_correct, val_total = 0, 0
    with torch.no_grad():
        for images, labels in val_loader:
            images, labels = images.to(DEVICE), labels.to(DEVICE)
            outputs = model(images)
            val_correct += (outputs.argmax(1) == labels).sum().item()
            val_total += images.size(0)

    val_acc = val_correct / val_total
    current_lr = scheduler.get_last_lr()[0]
    print(f"[Fine-tune] Epoch {epoch+1}/{EPOCHS_FT} | "
          f"Loss: {total_loss/total:.4f} | "
          f"Val Acc: {val_acc:.3f} | "
          f"LR: {current_lr:.2e}")

# 5. Guardar el modelo
torch.save(model.state_dict(), 'resnet50_flowers.pth')
💡

Estructura de ResNet en PyTorch: los bloques accesibles son conv1, bn1, layer1, layer2, layer3, layer4, avgpool y fc. Para fine-tuning gradual, descongelar en orden inverso: layer4layer3layer2.

🧠 Transfer Learning en TensorFlow / Keras

Keras hace el transfer learning especialmente sencillo gracias a su API de applications y al argumento include_top. Veamos el mismo flujo con EfficientNetB0:

Paso 1: Preparación

📦 Dataset y modelo base en Keras
import tensorflow as tf
from tensorflow.keras import layers, models, optimizers, callbacks
from tensorflow.keras.applications import EfficientNetB0
from tensorflow.keras.applications.efficientnet import preprocess_input

# ─── Hiperparámetros ───
NUM_CLASSES = 5
IMG_SIZE = 224
BATCH_SIZE = 32
EPOCHS_FE = 10
EPOCHS_FT = 10

# ─── Dataset (estructura: data/train/clase1/, data/train/clase2/, ...) ───
train_ds = tf.keras.utils.image_dataset_from_directory(
    'data/train',
    image_size=(IMG_SIZE, IMG_SIZE),
    batch_size=BATCH_SIZE,
    label_mode='int',
)
val_ds = tf.keras.utils.image_dataset_from_directory(
    'data/val',
    image_size=(IMG_SIZE, IMG_SIZE),
    batch_size=BATCH_SIZE,
    label_mode='int',
)

# ─── Data augmentation ───
data_augmentation = tf.keras.Sequential([
    layers.RandomFlip("horizontal"),
    layers.RandomRotation(0.1),
    layers.RandomZoom(0.1),
    layers.RandomContrast(0.1),
])

# ─── Prefetch para rendimiento ───
AUTOTUNE = tf.data.AUTOTUNE
train_ds = train_ds.prefetch(buffer_size=AUTOTUNE)
val_ds = val_ds.prefetch(buffer_size=AUTOTUNE)

Paso 2: Feature Extraction

📦 Feature Extraction en Keras
# ─── FEATURE EXTRACTION ───
# 1. Cargar backbone SIN head (include_top=False)
base_model = EfficientNetB0(
    weights='imagenet',
    include_top=False,          # ← Sin la capa de clasificación
    input_shape=(IMG_SIZE, IMG_SIZE, 3),
)

# 2. Congelar el backbone
base_model.trainable = False

# 3. Construir el modelo completo
inputs = layers.Input(shape=(IMG_SIZE, IMG_SIZE, 3))
x = data_augmentation(inputs)              # Augmentation
x = preprocess_input(x)                    # Normalización EfficientNet
x = base_model(x, training=False)          # Backbone (en modo inferencia)
x = layers.GlobalAveragePooling2D()(x)     # GAP → vector
x = layers.Dropout(0.3)(x)
x = layers.Dense(256, activation='relu')(x)
x = layers.Dropout(0.2)(x)
outputs = layers.Dense(NUM_CLASSES)(x)     # Logits (sin softmax)

model = models.Model(inputs, outputs)
model.summary()
# Trainable params: ~330K (solo head)
# Non-trainable params: ~4M (backbone congelado)

# 4. Compilar y entrenar
model.compile(
    optimizer=optimizers.Adam(learning_rate=1e-3),
    loss=tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True),
    metrics=['accuracy'],
)

history_fe = model.fit(
    train_ds,
    validation_data=val_ds,
    epochs=EPOCHS_FE,
    callbacks=[
        callbacks.EarlyStopping(patience=3, restore_best_weights=True),
    ],
)

Paso 3: Fine-tuning

📦 Fine-tuning en Keras
# ─── FINE-TUNING ───
# 1. Descongelar el backbone
base_model.trainable = True

# 2. Congelar las primeras N capas (mantener features universales)
# EfficientNetB0 tiene 237 capas. Congelamos las primeras 200.
FINE_TUNE_FROM = 200
for layer in base_model.layers[:FINE_TUNE_FROM]:
    layer.trainable = False

trainable_count = sum(
    tf.keras.backend.count_params(w) for w in model.trainable_weights
)
print(f"Parámetros entrenables (fine-tuning): {trainable_count:,}")

# 3. Re-compilar con learning rate MUY bajo para el backbone
model.compile(
    optimizer=optimizers.Adam(learning_rate=1e-5),  # lr 100× menor
    loss=tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True),
    metrics=['accuracy'],
)

# 4. Fine-tuning
history_ft = model.fit(
    train_ds,
    validation_data=val_ds,
    epochs=EPOCHS_FE + EPOCHS_FT,       # Continuar desde epoch anterior
    initial_epoch=history_fe.epoch[-1],  # Empezar donde se quedó
    callbacks=[
        callbacks.EarlyStopping(patience=5, restore_best_weights=True),
        callbacks.ReduceLROnPlateau(factor=0.5, patience=2),
    ],
)

# 5. Guardar el modelo completo
model.save('efficientnet_flowers.keras')
📝

Diferencia clave PyTorch vs Keras:

  • En Keras, base_model.trainable = False congela todas las capas de una sola vez. Para descongelar parcialmente, se itera por base_model.layers.
  • En PyTorch, se congela/descongela cada parámetro individualmente con param.requires_grad = False/True.
  • Keras requiere re-compilar tras cambiar trainable. PyTorch no.

🚀 Inferencia con el modelo entrenado

Una vez entrenado (feature extraction + fine-tuning), el modelo se usa para predecir nuevas imágenes:

📦 Inferencia en PyTorch
from PIL import Image

# Cargar modelo
model.load_state_dict(torch.load('resnet50_flowers.pth'))
model.eval()

# Preparar imagen
img = Image.open('nueva_flor.jpg').convert('RGB')
img_tensor = val_transform(img).unsqueeze(0).to(DEVICE)  # [1, 3, 224, 224]

# Predecir
with torch.no_grad():
    logits = model(img_tensor)
    probs = torch.softmax(logits, dim=1)
    pred_class = probs.argmax(1).item()
    confidence = probs[0, pred_class].item()

class_names = train_dataset.classes  # ['daisy', 'dandelion', 'roses', ...]
print(f"Predicción: {class_names[pred_class]} ({confidence:.1%})")
# Predicción: roses (94.3%)
📦 Inferencia en TensorFlow/Keras
import numpy as np

# Cargar modelo
model = tf.keras.models.load_model('efficientnet_flowers.keras')

# Preparar imagen
img = tf.keras.utils.load_img('nueva_flor.jpg', target_size=(224, 224))
img_array = tf.keras.utils.img_to_array(img)
img_array = tf.expand_dims(img_array, 0)  # [1, 224, 224, 3]

# Predecir
logits = model.predict(img_array)
probs = tf.nn.softmax(logits[0]).numpy()
pred_class = np.argmax(probs)
confidence = probs[pred_class]

class_names = train_ds.class_names
print(f"Predicción: {class_names[pred_class]} ({confidence:.1%})")
# Predicción: roses (94.3%)

Resumen del flujo completo:

  1. Elegir backbone preentrenado (ResNet-50, EfficientNet, etc.).
  2. Reemplazar el head con uno adaptado a tus clases.
  3. Feature extraction: congelar backbone, entrenar head (5-10 epochs).
  4. Fine-tuning: descongelar capas profundas con lr bajo (5-10 epochs más).
  5. Evaluar en test set y desplegar.
🧪 Herramientas interactivas
🏭 Casos de uso