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.
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):
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:
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:
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):
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.
🧱 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:
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.
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:
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:
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:
- Depthwise convolution: un filtro k \times k independiente por canal. Cada canal se procesa por separado. Parámetros: k^2 \times C_\text{in}.
- Pointwise convolution: una conv 1 \times 1 que combina los canales. Parámetros: C_\text{in} \times C_\text{out}.
Ahorro computacional
La ratio entre el coste de una convolución separable y una estándar es:
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):
- Expansión: conv 1×1 que aumenta los canales (factor de expansión t, típicamente 6).
- Depthwise: conv 3×3 depthwise en el espacio expandido.
- Proyección: conv 1×1 que comprime de vuelta. Sin activación (linear bottleneck) para preservar información.
- 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:
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.
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:
- Expansión: Conv 1×1 (expand ratio × canales).
- Depthwise: Conv k×k depthwise (k = 3 o 5).
- SE block: Global Average Pool → FC → ReLU → FC → Sigmoid. Recalibra la importancia de cada canal.
- Proyección: Conv 1×1 (reduce canales) sin activación.
- 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.
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:
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.
| Aspecto | Detalle |
|---|---|
| Backbone | 100% congelado (🔒). Los gradientes no se propagan a estas capas. |
| Head | Se reemplaza completamente. Se diseña para las N nuevas clases. |
| Entrenamiento | Solo se entrenan los parámetros del head (miles, no millones). |
| Velocidad | Muy rápido. Minutos en una GPU modesta. |
| Cuándo usarlo | Pocos 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:
| Aspecto | Detalle |
|---|---|
| Capas iniciales | Congeladas (🔒). Features universales que no necesitan cambiar. |
| Capas profundas | Descongeladas (🔓) con learning rate bajo (10-100× menor). |
| Head | Nuevo, entrenado con learning rate normal. |
| Riesgo | Si el lr del backbone es demasiado alto → se destruyen los pesos preentrenados (catastrophic forgetting). |
| Cuándo usarlo | Datos 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:
- Fase 1: Congelar todo el backbone. Entrenar solo el head nuevo durante 5-10 epochs (learning rate normal: 1e-3).
- Fase 2: Descongelar las últimas 2-3 capas del backbone. Entrenar con lr bajo (1e-5) para backbone, normal para head.
- 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:
layer4 → layer3 → layer2.
🧠 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 = Falsecongela todas las capas de una sola vez. Para descongelar parcialmente, se itera porbase_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:
- Elegir backbone preentrenado (ResNet-50, EfficientNet, etc.).
- Reemplazar el head con uno adaptado a tus clases.
- Feature extraction: congelar backbone, entrenar head (5-10 epochs).
- Fine-tuning: descongelar capas profundas con lr bajo (5-10 epochs más).
- Evaluar en test set y desplegar.