💻 Tutorial paso a paso

Transfer Learning paso a paso con PyTorch

Aprenderás a reutilizar modelos preentrenados en ImageNet para resolver tu propio problema de clasificación de imágenes. Desde explorar y descargar backbones hasta feature extraction, fine-tuning, evaluación e inferencia.

⏱️ ~45 min 📊 Nivel: intermedio 🔥 Framework: PyTorch 2.x + torchvision

Requisitos previos

1

Explorar modelos preentrenados

El Transfer Learning consiste en reutilizar un modelo que ya fue entrenado en un dataset grande (típicamente ImageNet, con 1.2 millones de imágenes y 1,000 clases) y adaptarlo a tu problema específico. En lugar de aprender desde cero, aprovechas las features jerárquicas que el modelo ya ha extraído: bordes, texturas, formas, y patrones complejos.

PyTorch ofrece modelos preentrenados a través de torchvision.models. Adicionalmente, la librería timm (PyTorch Image Models) ofrece más de 1,200 modelos con pesos preentrenados. Veamos qué hay disponible:

Python explorar modelos
import torchvision.models as models

# Ver todos los modelos disponibles con pesos preentrenados
available = models.list_models(module=models)
print(f"Modelos en torchvision: {len(available)}")
print(available[:15])  # Primeros 15

# Con timm (instalar: pip install timm)
import timm
timm_models = timm.list_models(pretrained=True)
print(f"\nModelos en timm: {len(timm_models)}")

# Buscar modelos de una familia específica
resnet_models = timm.list_models('resnet*', pretrained=True)
efficientnet_models = timm.list_models('efficientnet*', pretrained=True)
print(f"ResNets disponibles: {len(resnet_models)}")
print(f"EfficientNets disponibles: {len(efficientnet_models)}")
Salida Modelos en torchvision: 86 ['alexnet', 'convnext_base', 'convnext_large', 'convnext_small', 'convnext_tiny', ...] Modelos en timm: 1284 ResNets disponibles: 112 EfficientNets disponibles: 94

Galería de modelos populares

Haz clic en cualquier modelo para ver cómo se carga en PyTorch. Cada tarjeta muestra los parámetros, accuracy top-1 en ImageNet y características clave:

Python cargar modelo seleccionado
# 👆 Haz clic en un modelo de la galería para ver cómo cargarlo
model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2)
💡 ¿Cuál elegir? Como regla general:
  • Pocos datos (<1,000 imágenes): ResNet-18 o MobileNetV2 (menos parámetros = menos overfitting)
  • Datos moderados (1K-10K): ResNet-50 o EfficientNet-B0 (buen balance)
  • Muchos datos (>10K) y GPU: EfficientNet-B3/B4 o ConvNeXt (máximo accuracy)
  • Despliegue en móvil: MobileNetV2 o EfficientNet-B0 (pocos FLOPs)

torchvision.models es la opción oficial de PyTorch. Ventajas:

  • Mantenido por el equipo de PyTorch
  • API estable y bien documentada
  • Pesos multi-versión con Weights enum (v2 incluye mejores transforms)
  • Sin dependencias extra

timm (PyTorch Image Models) es la librería de Ross Wightman. Ventajas:

  • +1,200 modelos con pesos preentrenados
  • Modelos de última generación (ConvNeXt V2, EfficientNet V2, MetaFormer, etc.)
  • API unificada: timm.create_model('nombre', pretrained=True)
  • Pesos entrenados con mejores recetas de entrenamiento

Recomendación: usa torchvision si el modelo que quieres está disponible. Si necesitas algo más específico o moderno, usa timm.

Error 1: API obsoleta de pesos

Antes de torchvision 0.13, se usaba pretrained=True. Ahora se usa weights=:

# ❌ Obsoleto (funciona pero da warning)
model = models.resnet50(pretrained=True)

# ✅ Correcto (torchvision >= 0.13)
model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2)

Error 2: No verificar la resolución de entrada

Cada modelo espera una resolución específica. ResNet usa 224×224, pero EfficientNet-B4 usa 380×380. Los pesos incluyen las transforms correctas:

weights = models.ResNet50_Weights.IMAGENET1K_V2
preprocess = weights.transforms()  # Incluye resize, crop, normalización
2

Descargar y cargar un backbone

Vamos a trabajar con ResNet-50 como ejemplo principal. Al cargar un modelo con pesos preentrenados, PyTorch descarga automáticamente los pesos (~98 MB) y los almacena en caché:

Python descargar backbone
import torch
import torch.nn as nn
import torchvision.models as models
from torchvision.models import ResNet50_Weights

# Cargar ResNet-50 con pesos de ImageNet (V2 = mejores pesos)
weights = ResNet50_Weights.IMAGENET1K_V2
backbone = models.resnet50(weights=weights)

print(f"Modelo cargado: ResNet-50")
print(f"Parámetros totales: {sum(p.numel() for p in backbone.parameters()):,}")
print(f"Output del backbone: {backbone.fc.in_features} features")
print(f"Clases originales: {backbone.fc.out_features}")

# Las transforms recomendadas para este modelo
preprocess = weights.transforms()
print(f"\nTransforms recomendadas:\n{preprocess}")
L6ResNet50_Weights.IMAGENET1K_V2 — la versión V2 usa mejores recetas de entrenamiento y da +1.5% accuracy vs V1.
L7La primera vez descarga los pesos. Después los lee de ~/.cache/torch/hub/checkpoints/.
L11backbone.fc.in_features — dimensión del vector de features antes de la capa final (2048 en ResNet-50).
L15weights.transforms() — transforms que usaron los autores al entrenar. Incluye resize a 232, center crop a 224 y normalización con media/std de ImageNet.
Salida Modelo cargado: ResNet-50 Parámetros totales: 25,557,032 Output del backbone: 2048 features Clases originales: 1000 Transforms recomendadas: ImageClassification( crop_size=[224] resize_size=[232] mean=[0.485, 0.456, 0.406] std=[0.229, 0.224, 0.225] interpolation=InterpolationMode.BILINEAR )

Verificar que funciona: predicción con ImageNet

Antes de modificar nada, comprobemos que el modelo funciona con una imagen de prueba. Esto te servirá como sanity check:

Python sanity check
from PIL import Image
from torchvision.transforms import functional as F

# Descargar una imagen de ejemplo (o usa una tuya)
import urllib.request
url = "https://upload.wikimedia.org/wikipedia/commons/thumb/2/26/YellowLabradorLooking_new.jpg/1200px-YellowLabradorLooking_new.jpg"
urllib.request.urlretrieve(url, "test_dog.jpg")
img = Image.open("test_dog.jpg")

# Preprocesar con las transforms del modelo
batch = preprocess(img).unsqueeze(0)  # (3,224,224) → (1,3,224,224)

# Predecir
backbone.eval()
with torch.no_grad():
    logits = backbone(batch)
    probs = torch.softmax(logits, dim=1)
    top5 = torch.topk(probs, 5)

# Mostrar top-5 predicciones
categories = weights.meta["categories"]
for i in range(5):
    idx = top5.indices[0][i].item()
    prob = top5.values[0][i].item() * 100
    print(f"  {categories[idx]:30s} → {prob:.1f}%")
Salida Labrador retriever → 72.4% golden retriever → 15.2% Chesapeake Bay retriever → 3.1% kuvasz → 1.8% tennis ball → 0.9%
💡 Nota: Si la predicción es razonable, el modelo está bien cargado. Ahora vamos a desmontarlo y reconstruirlo para nuestro problema.

Alternativa: cargar con timm

Python cargar con timm
import timm

# timm tiene una API unificada para todos los modelos
model_timm = timm.create_model('resnet50', pretrained=True)

# Para ver la configuración del modelo
data_config = timm.data.resolve_model_data_config(model_timm)
print(data_config)
# {'input_size': (3, 224, 224), 'interpolation': 'bicubic',
#  'mean': (0.485, 0.456, 0.406), 'std': (0.229, 0.224, 0.225), ...}

# También puedes crear el modelo ya sin la cabeza de clasificación
backbone_timm = timm.create_model('resnet50', pretrained=True, num_classes=0)
# Ahora backbone_timm produce directamente un vector de 2048 features

Los pesos se descargan en estas ubicaciones por defecto:

  • torchvision: ~/.cache/torch/hub/checkpoints/
  • timm: ~/.cache/huggingface/hub/ (o ~/.cache/torch/hub/checkpoints/)

Puedes cambiar la ruta con la variable de entorno TORCH_HOME:

export TORCH_HOME=/ruta/personalizada
# O en Python:
import os
os.environ['TORCH_HOME'] = '/ruta/personalizada'

Para entornos offline (servidores sin internet), descarga los pesos previamente y cárgalos manualmente:

state_dict = torch.load("resnet50_v2.pth", weights_only=True)
model = models.resnet50()
model.load_state_dict(state_dict)
3

Entender la arquitectura: backbone + head

Un modelo de clasificación CNN se divide en dos partes fundamentales: el backbone (extractor de features) y el head (clasificador). La clave del transfer learning es reutilizar el backbone y reemplazar el head.

Imagen 224×224×3 Conv1 7×7, 64 🔒 Layer1 3×3, 256 🔒 Layer2 3×3, 512 🔒 Layer3 3×3, 1024 🔒 Layer4 3×3, 2048 🔓 BACKBONE (feature extractor) GAP 2048→2048 HEAD Linear 2048→N 🔓 Nuevo Predicción N clases Congelado (frozen) Entrenable (fine-tuning) Head nuevo (siempre entrenable)

El diagrama muestra la estrategia de feature extraction: se congela todo el backbone (🔒) y solo se entrena el head (🔓). En el fine-tuning, también se descongelan las últimas capas del backbone.

Inspeccionar la estructura del modelo

Python inspeccionar ResNet-50
# Ver las capas principales de ResNet-50
for name, module in backbone.named_children():
    params = sum(p.numel() for p in module.parameters())
    print(f"{name:12s} → {params:>10,} params  ({type(module).__name__})")
Salida conv1 → 9,408 params (Conv2d) bn1 → 128 params (BatchNorm2d) relu → 0 params (ReLU) maxpool → 0 params (MaxPool2d) layer1 → 215,808 params (Sequential) layer2 → 1,219,584 params (Sequential) layer3 → 7,098,368 params (Sequential) layer4 → 14,964,736 params (Sequential) avgpool → 0 params (AdaptiveAvgPool2d) fc → 2,049,000 params (Linear)
conv1-maxpoolLas primeras capas detectan features de bajo nivel: bordes, gradientes, colores.
layer1-2Detectan texturas y patrones locales. Muy transferibles entre dominios.
layer3-4Features de alto nivel: partes semánticas (ojos, ruedas, hojas...). Más específicas al dominio original.
avgpoolGlobal Average Pooling: comprime el feature map (7×7×2048) en un vector (2048,).
fcLa capa final de clasificación: 2048 → 1000 (clases de ImageNet). Esta es la que reemplazaremos.
🔑 Clave: Las features tempranas (bordes, texturas) son universales y transfieren bien a cualquier dominio. Las features profundas (semánticas) son más específicas y pueden necesitar fine-tuning si tu dominio es muy diferente a ImageNet.

Reemplazar el head

El head original clasifica en 1,000 clases de ImageNet. Necesitamos reemplazarlo por uno que clasifique en nuestras N clases. En este tutorial usaremos un problema de clasificación binaria (perros vs gatos) como ejemplo:

Python reemplazar head
NUM_CLASSES = 2  # perros vs gatos (o tu número de clases)

# El head original
print(f"Head original: {backbone.fc}")

# Opción A: head simple (una sola capa lineal)
backbone.fc = nn.Linear(backbone.fc.in_features, NUM_CLASSES)

# Opción B: head con más capacidad (recomendado para few-shot)
backbone.fc = nn.Sequential(
    nn.Linear(2048, 512),
    nn.ReLU(),
    nn.Dropout(0.3),
    nn.Linear(512, NUM_CLASSES),
)

print(f"Head nuevo: {backbone.fc}")
print(f"Parámetros del head: {sum(p.numel() for p in backbone.fc.parameters()):,}")
Salida Head original: Linear(in_features=2048, out_features=1000, bias=True) Head nuevo: Sequential( (0): Linear(in_features=2048, out_features=512, bias=True) (1): ReLU() (2): Dropout(p=0.3, inplace=False) (3): Linear(in_features=512, out_features=2, bias=True) ) Parámetros del head: 1,049,602

Head simple (nn.Linear(2048, N)):

  • Menos parámetros → menos riesgo de overfitting
  • Ideal cuando tienes muchos datos o cuando las features del backbone ya son muy buenas para tu dominio
  • Entrenamiento más rápido

Head multicapa (con capas ocultas + Dropout):

  • Permite aprender combinaciones no lineales de features
  • Útil cuando el dominio es diferente a ImageNet (médicas, satélite, microscopía)
  • El Dropout (0.3-0.5) ayuda a regularizar

Regla práctica: empieza con head simple. Si el accuracy se estanca, prueba multicapa.

Tabla comparativa de heads por modelo

Modelo Atributo del head Features in Cómo reemplazar
ResNet-* model.fc 512 / 2048 model.fc = nn.Linear(N_in, N_cls)
VGG-* model.classifier[6] 4096 model.classifier[6] = nn.Linear(4096, N_cls)
EfficientNet-* model.classifier[1] 1280 model.classifier[1] = nn.Linear(1280, N_cls)
MobileNetV2 model.classifier[1] 1280 model.classifier[1] = nn.Linear(1280, N_cls)
DenseNet-* model.classifier 1024 model.classifier = nn.Linear(1024, N_cls)
ConvNeXt-* model.classifier[2] 768 / 1024 model.classifier[2] = nn.Linear(N_in, N_cls)
⚠️ Cuidado: Cada familia de modelos tiene una estructura diferente de head. Antes de reemplazar, siempre inspecciona el modelo con print(model) o model.named_children() para encontrar la capa correcta.
4

Preparar tu dataset personalizado

Para transfer learning necesitas tus propias imágenes etiquetadas. La forma más sencilla es organizar las imágenes en carpetas donde cada carpeta es una clase. PyTorch las carga automáticamente con ImageFolder.

Bash estructura de carpetas
dataset/
├── train/
│   ├── gatos/        # Clase 0
│   │   ├── cat_001.jpg
│   │   ├── cat_002.jpg
│   │   └── ...
│   └── perros/       # Clase 1
│       ├── dog_001.jpg
│       ├── dog_002.jpg
│       └── ...
├── val/
│   ├── gatos/
│   └── perros/
└── test/
    ├── gatos/
    └── perros/

Transforms y Data Augmentation

Las transforms son cruciales en transfer learning. Para entrenamiento aplicamos data augmentation (para regularizar y que el modelo generalice mejor). Para validación y test, solo redimensionamos y normalizamos.

Python transforms
from torchvision import transforms, datasets
from torch.utils.data import DataLoader

# ── Transforms para ENTRENAMIENTO (con augmentation) ─────
train_transforms = transforms.Compose([
    transforms.RandomResizedCrop(224, scale=(0.8, 1.0)),
    transforms.RandomHorizontalFlip(p=0.5),
    transforms.RandomRotation(15),
    transforms.ColorJitter(brightness=0.2, contrast=0.2,
                           saturation=0.2, hue=0.1),
    transforms.ToTensor(),
    transforms.Normalize(mean=[0.485, 0.456, 0.406],
                         std=[0.229, 0.224, 0.225]),
])

# ── Transforms para VALIDACIÓN / TEST (sin augmentation) ─
val_transforms = transforms.Compose([
    transforms.Resize(256),
    transforms.CenterCrop(224),
    transforms.ToTensor(),
    transforms.Normalize(mean=[0.485, 0.456, 0.406],
                         std=[0.229, 0.224, 0.225]),
])

# ── Crear datasets ────────────────────────────────────────
train_dataset = datasets.ImageFolder("dataset/train", transform=train_transforms)
val_dataset   = datasets.ImageFolder("dataset/val",   transform=val_transforms)
test_dataset  = datasets.ImageFolder("dataset/test",  transform=val_transforms)

print(f"Clases encontradas: {train_dataset.classes}")
print(f"Mapping: {train_dataset.class_to_idx}")
print(f"Train: {len(train_dataset)} | Val: {len(val_dataset)} | Test: {len(test_dataset)}")

# ── Crear DataLoaders ─────────────────────────────────────
BATCH_SIZE = 32

train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE,
                          shuffle=True, num_workers=4, pin_memory=True)
val_loader   = DataLoader(val_dataset, batch_size=BATCH_SIZE,
                          shuffle=False, num_workers=4, pin_memory=True)
test_loader  = DataLoader(test_dataset, batch_size=BATCH_SIZE,
                          shuffle=False, num_workers=4, pin_memory=True)
L6RandomResizedCrop(224) — recorta aleatoriamente entre el 80-100% de la imagen y redimensiona a 224×224.
L7RandomHorizontalFlip — voltea la imagen horizontalmente con 50% de probabilidad. No uses flip vertical si no tiene sentido para tu dominio (ej: texto).
L9-10ColorJitter — variaciones aleatorias de brillo, contraste, saturación y hue. Simula diferentes condiciones de iluminación.
L12-13Normalizeobligatorio. Usa las mismas media y std que se usaron para preentrenar en ImageNet.
L18-19Para val/test: Resize(256) + CenterCrop(224) es el pipeline estándar de ImageNet. Sin augmentation.
L37num_workers=4 — procesos paralelos para cargar imágenes. Evita que la CPU sea el cuello de botella. pin_memory=True acelera la transferencia CPU→GPU.
Salida Clases encontradas: ['gatos', 'perros'] Mapping: {'gatos': 0, 'perros': 1} Train: 20000 | Val: 2500 | Test: 2500
💡 ¿No tienes datos? Descárgatelos — Para seguir este tutorial puedes usar el dataset Cats vs Dogs de Kaggle o generarlo directamente desde PyTorch:
Python alternativa: dataset de torchvision
# Alternativa rápida: usar un dataset de torchvision
from torchvision.datasets import OxfordIIITPet

# Descarga automáticamente ~800 MB
train_dataset = OxfordIIITPet(root="./data", split="trainval",
                              target_types="category",
                              transform=train_transforms, download=True)
test_dataset  = OxfordIIITPet(root="./data", split="test",
                              target_types="category",
                              transform=val_transforms, download=True)

NUM_CLASSES = 37  # 37 razas de perros y gatos
print(f"Clases: {len(train_dataset.classes)} razas de mascotas")

Principio: la augmentation debe generar variaciones que podrían ocurrir en el mundo real.

  • Siempre útiles: flip horizontal, pequeños recortes, variación de color
  • Con cuidado: rotaciones grandes (solo si tiene sentido), flip vertical (no para texto o caras)
  • Avanzado: transforms.RandAugment(), transforms.TrivialAugmentWide(), MixUp, CutMix

Si tienes muy pocos datos (<500 imágenes):

  • Augmentation agresiva (rotaciones, elastic deformations)
  • Considera transforms.AutoAugment(transforms.AutoAugmentPolicy.IMAGENET)
  • MixUp y CutMix pueden ayudar mucho

Errores comunes:

  • Augmentation en validación/test (nunca hacerlo, sesga las métricas)
  • Olvidar la normalización con la media/std de ImageNet
  • Usar augmentation demasiado agresiva que destruye la información relevante

Error: FileNotFoundError: Found no valid file for the classes

→ Las imágenes no están en subdirectorios. Revisa que la estructura sea train/clase/imagen.jpg.

Error: RuntimeError: stack expects each tensor to be equal size

→ Las imágenes tienen tamaños diferentes y no estás usando Resize/CenterCrop.

Error: num_workers > 0 crashes en Windows

→ En Windows necesitas if __name__ == '__main__': antes de invocar el DataLoader. Alternativamente, usa num_workers=0.

Error: Imágenes corruptas o con canales extras (RGBA, grayscale)

→ Añade transforms.Lambda(lambda x: x.convert('RGB')) al principio de las transforms, o filtra las imágenes antes de entrenar.

5

Feature extraction: backbone congelado

La primera estrategia de transfer learning es feature extraction: congelamos todos los pesos del backbone y solo entrenamos el head nuevo. El backbone actúa como un extractor de features fijo.

¿Por qué funciona? Porque las features aprendidas en ImageNet (bordes, texturas, formas) son universales y útiles para casi cualquier problema de visión.

Python congelar backbone
import torch
import torch.nn as nn
import torchvision.models as models
from torchvision.models import ResNet50_Weights

# 1. Cargar modelo preentrenado
model = models.resnet50(weights=ResNet50_Weights.IMAGENET1K_V2)

# 2. CONGELAR todo el backbone
for param in model.parameters():
    param.requires_grad = False

# 3. Reemplazar el head (se crea con requires_grad=True por defecto)
NUM_CLASSES = 2
model.fc = nn.Sequential(
    nn.Linear(2048, 512),
    nn.ReLU(),
    nn.Dropout(0.3),
    nn.Linear(512, NUM_CLASSES),
)

# Verificar qué se entrena y qué no
total_params = sum(p.numel() for p in model.parameters())
trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
frozen_params = total_params - trainable_params
print(f"Parámetros totales:     {total_params:>12,}")
print(f"Parámetros congelados:  {frozen_params:>12,} ({frozen_params/total_params*100:.1f}%)")
print(f"Parámetros entrenables: {trainable_params:>12,} ({trainable_params/total_params*100:.1f}%)")

# Mover a GPU
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = model.to(device)
L10-11param.requires_grad = False — congela cada parámetro. PyTorch no calculará gradientes para ellos → no se actualizarán durante el entrenamiento.
L15-20El head nuevo se crea con requires_grad=True por defecto. Estos son los únicos parámetros que se entrenarán.
L23-24Verificación importante: confirmamos que solo ~1M de 24.6M parámetros son entrenables.
Salida Parámetros totales: 24,559,634 Parámetros congelados: 23,508,032 (95.7%) Parámetros entrenables: 1,051,602 ( 4.3%)
🔑 Ventaja: Solo entrenamos el 4.3% de los parámetros. Esto significa entrenamiento mucho más rápido, menor consumo de memoria y menor riesgo de overfitting. Ideal cuando tienes pocos datos.

Configurar el optimizador (solo head)

Es importante que el optimizador solo reciba los parámetros entrenables. Si le pasas todos, funciona igualmente (los congelados no tienen gradientes), pero es más limpio y eficiente filtrar:

Python optimizador para feature extraction
import torch.optim as optim

# Solo los parámetros que requieren gradiente
optimizer = optim.Adam(
    filter(lambda p: p.requires_grad, model.parameters()),
    lr=1e-3,              # Learning rate relativamente alto para el head
    weight_decay=1e-4,    # Regularización L2
)

# Loss function
criterion = nn.CrossEntropyLoss()

# Scheduler: reducir LR cuando se estanca la mejora
scheduler = optim.lr_scheduler.ReduceLROnPlateau(
    optimizer, mode='min', factor=0.5, patience=3, verbose=True
)

print(f"Optimizador: Adam (lr=1e-3)")
print(f"Parámetros en el optimizer: {sum(p.numel() for group in optimizer.param_groups for p in group['params']):,}")
L4filter(lambda p: p.requires_grad, ...) — filtra solo los parámetros del head. Más limpio que pasar todos.
L6lr=1e-3 — para feature extraction usamos un learning rate relativamente alto (1e-3). El head empieza con pesos aleatorios y necesita aprender rápido.
L7weight_decay=1e-4 — regularización L2. Evita que los pesos del head crezcan demasiado.
L14-16ReduceLROnPlateau — si la val_loss no mejora en 3 epochs, reduce el lr a la mitad. Muy útil para convergencia fina.

La decisión depende de dos factores: cuántos datos tienes y cuánto se parece tu dominio a ImageNet:

Dominio similar a ImageNetDominio diferente
Pocos datos (<1K)Feature extraction ✅Feature extraction + augmentation agresiva
Datos moderados (1K-10K)Feature extraction o fine-tuning parcialFine-tuning de las últimas capas
Muchos datos (>10K)Fine-tuning completoFine-tuning completo (quizá desde scratch)

Regla práctica: empieza siempre con feature extraction. Si el accuracy no es suficiente, pasa a fine-tuning.

6

Entrenar el head

Ahora escribimos el training loop con validación en cada epoch. El loop monitoriza tanto la loss como el accuracy, y guarda el mejor modelo según la validation loss (early stopping manual):

Python training loop completo
import time
from pathlib import Path

def train_one_epoch(model, loader, criterion, optimizer, device):
    """Entrena un epoch completo y devuelve loss y accuracy."""
    model.train()
    running_loss, correct, total = 0.0, 0, 0

    for images, labels in loader:
        images, labels = images.to(device), labels.to(device)

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

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

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

    return running_loss / total, correct / total


@torch.no_grad()
def validate(model, loader, criterion, device):
    """Evalúa en el conjunto de validación."""
    model.eval()
    running_loss, correct, total = 0.0, 0, 0

    for images, labels in loader:
        images, labels = images.to(device), labels.to(device)
        outputs = model(images)
        loss = criterion(outputs, labels)

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

    return running_loss / total, correct / total


# ── Entrenamiento ─────────────────────────────────────────
EPOCHS = 15
best_val_loss = float('inf')
patience_counter = 0
PATIENCE = 5  # early stopping: parar si no mejora en 5 epochs
history = {'train_loss': [], 'val_loss': [], 'train_acc': [], 'val_acc': []}

save_dir = Path("checkpoints")
save_dir.mkdir(exist_ok=True)

print("Epoch  Train Loss  Train Acc  Val Loss   Val Acc   LR        Tiempo")
print("─" * 75)

for epoch in range(EPOCHS):
    t0 = time.time()

    train_loss, train_acc = train_one_epoch(
        model, train_loader, criterion, optimizer, device
    )
    val_loss, val_acc = validate(
        model, val_loader, criterion, device
    )

    # Learning rate scheduler
    scheduler.step(val_loss)
    current_lr = optimizer.param_groups[0]['lr']

    elapsed = time.time() - t0

    # Guardar historial
    history['train_loss'].append(train_loss)
    history['val_loss'].append(val_loss)
    history['train_acc'].append(train_acc)
    history['val_acc'].append(val_acc)

    # Early stopping + guardar mejor modelo
    marker = ""
    if val_loss < best_val_loss:
        best_val_loss = val_loss
        patience_counter = 0
        torch.save(model.state_dict(), save_dir / "best_model.pth")
        marker = " ★ saved"
    else:
        patience_counter += 1
        if patience_counter >= PATIENCE:
            print(f"\n⏹  Early stopping en epoch {epoch+1} (sin mejora en {PATIENCE} epochs)")
            break

    print(f"{epoch+1:3d}/{EPOCHS}  {train_loss:.4f}      {train_acc:.4f}     "
          f"{val_loss:.4f}     {val_acc:.4f}    {current_lr:.1e}  {elapsed:.1f}s{marker}")
L6model.train() — activa Dropout y BatchNorm en modo entrenamiento.
L26@torch.no_grad() — decorador que desactiva autograd. Más eficiente que with torch.no_grad(): para funciones completas.
L48PATIENCE = 5 — si la val_loss no mejora en 5 epochs consecutivos, paramos. Evita overentrenar.
L80Guardamos el modelo solo cuando mejora la val_loss. Al final tendremos el mejor modelo, no el último.
Salida (ejemplo con Cats vs Dogs) Epoch Train Loss Train Acc Val Loss Val Acc LR Tiempo ─────────────────────────────────────────────────────────────────────────── 1/15 0.4521 0.7832 0.2983 0.8760 1.0e-03 12.3s ★ saved 2/15 0.2614 0.8921 0.2215 0.9120 1.0e-03 11.8s ★ saved 3/15 0.2098 0.9153 0.1987 0.9240 1.0e-03 11.9s ★ saved 4/15 0.1842 0.9267 0.1876 0.9280 1.0e-03 12.1s ★ saved 5/15 0.1703 0.9334 0.1823 0.9320 1.0e-03 11.7s ★ saved 6/15 0.1598 0.9389 0.1801 0.9340 1.0e-03 12.0s ★ saved 7/15 0.1521 0.9421 0.1798 0.9360 1.0e-03 11.9s ★ saved 8/15 0.1467 0.9445 0.1812 0.9340 1.0e-03 12.2s 9/15 0.1398 0.9478 0.1795 0.9380 1.0e-03 11.8s ★ saved 10/15 0.1354 0.9498 0.1803 0.9360 1.0e-03 12.0s 11/15 0.1312 0.9515 0.1810 0.9340 1.0e-03 11.9s 12/15 0.1285 0.9523 0.1815 0.9360 5.0e-04 12.1s 13/15 0.1201 0.9567 0.1792 0.9400 5.0e-04 11.8s ★ saved 14/15 0.1178 0.9582 0.1798 0.9380 5.0e-04 12.0s ⏹ Early stopping en epoch 14 (sin mejora en 5 epochs)

Con solo el head entrenado, ya alcanzamos ~94% de accuracy en clasificación binaria. Impresionante, considerando que entrenamos menos del 5% de los parámetros del modelo.

💡 Tip: El entrenamiento de feature extraction es rápido porque: (1) calculamos gradientes solo para el head, y (2) podemos usar un batch size más grande (el backbone no necesita almacenar activaciones para backprop).

Si el accuracy no es suficiente con backbone congelado, antes de pasar a fine-tuning prueba:

  1. Head más grande: Añade más capas ocultas (512→256→N) con ReLU y Dropout
  2. Más augmentation: RandAugment, AutoAugment, más variaciones de color
  3. Otro backbone: Prueba EfficientNet o ConvNeXt, que suelen dar mejores features
  4. Diferente learning rate: Prueba 1e-4 a 5e-3
  5. Más epochs: Aumenta la paciencia del early stopping

Si nada de esto funciona, es hora del fine-tuning (paso siguiente).

Problema: Loss no baja

  • Verifica que las transforms incluyen la normalización de ImageNet
  • Comprueba que las etiquetas son correctas (class_to_idx)
  • Asegúrate de que el head está descongelado (requires_grad=True)

Problema: Val loss sube desde el principio (overfitting inmediato)

  • Reduce el learning rate
  • Aumenta el Dropout en el head (0.5 en vez de 0.3)
  • Añade más data augmentation
  • Reduce la complejidad del head

Problema: CUDA out of memory

  • Reduce el batch_size (16, 8, o incluso 4)
  • Verifica que el backbone está congelado (no se almacenan activaciones para backprop)
  • Usa un modelo más pequeño (ResNet-18, MobileNetV2)
7

Fine-tuning del backbone

El fine-tuning consiste en descongelar parte (o todo) el backbone y reentrenarlo con un learning rate muy bajo. Esto permite que las features se adapten a tu dominio específico sin destruir lo aprendido en ImageNet.

La clave es usar learning rates diferenciados: un lr bajo para el backbone (que ya tiene pesos buenos) y un lr más alto para el head (que necesita aprender más).

🎛️ Experimenta: ¿cuántas capas congelar?

Arrastra el slider para congelar más o menos capas. Observa cómo cambia el número de parámetros entrenables:

-
Congelados
-
Entrenables
-
% Congelado

Estrategia recomendada: descongelado progresivo

La técnica más robusta es el progressive unfreezing: primero entrenas solo el head (paso anterior), luego descongelas las últimas capas del backbone y reduces el learning rate. Esto evita destruir las features de las capas tempranas:

Python fine-tuning con LR diferenciados
# ── Fase 2: Fine-tuning ───────────────────────────────────

# 1. Cargar el mejor modelo de feature extraction
model.load_state_dict(torch.load("checkpoints/best_model.pth", weights_only=True))

# 2. Descongelar las últimas capas del backbone
#    Estrategia: descongelar layer4 (y opcionalmente layer3)
for name, param in model.named_parameters():
    param.requires_grad = False  # Congelar todo primero

# Descongelar layer4 + head
for name, param in model.named_parameters():
    if "layer4" in name or "fc" in name:
        param.requires_grad = True

# Verificar
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
total = sum(p.numel() for p in model.parameters())
print(f"Entrenables ahora: {trainable:,} / {total:,} ({trainable/total*100:.1f}%)")

# 3. Optimizador con LEARNING RATES DIFERENCIADOS
#    - Backbone (layer4): lr bajo para no destruir features
#    - Head: lr más alto para que siga aprendiendo
optimizer_ft = optim.Adam([
    {"params": [p for n, p in model.named_parameters()
                if "layer4" in n and p.requires_grad],
     "lr": 1e-5},      # ← LR bajo para el backbone
    {"params": model.fc.parameters(),
     "lr": 5e-4},      # ← LR más alto para el head
], weight_decay=1e-4)

# 4. Nuevo scheduler
scheduler_ft = optim.lr_scheduler.CosineAnnealingLR(
    optimizer_ft, T_max=10, eta_min=1e-6
)

print("\nGrupos del optimizador:")
for i, group in enumerate(optimizer_ft.param_groups):
    n_params = sum(p.numel() for p in group['params'])
    print(f"  Grupo {i}: lr={group['lr']:.1e}, params={n_params:,}")
L4Cargamos el mejor modelo de la fase de feature extraction como punto de partida.
L8-9Primero congelamos todo, luego descongelamos selectivamente. Es más limpio que descongelar todo y re-congelar.
L12-13Descongelamos layer4 (las capas más profundas del backbone) y el fc (head).
L23-29LR diferenciados: el backbone recibe lr=1e-5 (10× más bajo que el head). Esto preserva las features aprendidas mientras permite adaptación fina.
L32-34CosineAnnealingLR — reduce el learning rate siguiendo una curva coseno. Más suave que ReduceLROnPlateau.
Salida Entrenables ahora: 16,016,338 / 24,559,634 (65.2%) Grupos del optimizador: Grupo 0: lr=1.0e-05, params=14,964,736 Grupo 1: lr=5.0e-04, params=1,051,602

Entrenar la fase de fine-tuning

Python entrenamiento fine-tuning
# ── Fase 2: Training loop de fine-tuning ──────────────────
FT_EPOCHS = 10
best_val_loss_ft = float('inf')
patience_counter_ft = 0

print("\n📌 Fine-tuning (layer4 + head)")
print("Epoch  Train Loss  Train Acc  Val Loss   Val Acc   Tiempo")
print("─" * 65)

for epoch in range(FT_EPOCHS):
    t0 = time.time()

    train_loss, train_acc = train_one_epoch(
        model, train_loader, criterion, optimizer_ft, device
    )
    val_loss, val_acc = validate(model, val_loader, criterion, device)

    scheduler_ft.step()
    elapsed = time.time() - t0

    marker = ""
    if val_loss < best_val_loss_ft:
        best_val_loss_ft = val_loss
        patience_counter_ft = 0
        torch.save(model.state_dict(), "checkpoints/best_model_finetuned.pth")
        marker = " ★ saved"
    else:
        patience_counter_ft += 1
        if patience_counter_ft >= 5:
            print(f"\n⏹  Early stopping en epoch {epoch+1}")
            break

    print(f"{epoch+1:3d}/{FT_EPOCHS}  {train_loss:.4f}      {train_acc:.4f}     "
          f"{val_loss:.4f}     {val_acc:.4f}    {elapsed:.1f}s{marker}")
Salida (ejemplo) 📌 Fine-tuning (layer4 + head) Epoch Train Loss Train Acc Val Loss Val Acc Tiempo ───────────────────────────────────────────────────────────────── 1/10 0.1089 0.9612 0.1523 0.9480 18.7s ★ saved 2/10 0.0876 0.9698 0.1287 0.9560 18.3s ★ saved 3/10 0.0745 0.9743 0.1098 0.9640 18.5s ★ saved 4/10 0.0632 0.9789 0.0987 0.9680 18.2s ★ saved 5/10 0.0548 0.9821 0.0912 0.9720 18.4s ★ saved 6/10 0.0489 0.9845 0.0876 0.9740 18.1s ★ saved 7/10 0.0423 0.9867 0.0865 0.9740 18.6s ★ saved 8/10 0.0378 0.9884 0.0871 0.9720 18.3s 9/10 0.0341 0.9895 0.0868 0.9760 18.5s ★ saved 10/10 0.0312 0.9903 0.0872 0.9740 18.2s

Con fine-tuning, subimos de 94% → 97.6% accuracy. La adaptación de las features de layer4 al dominio específico ha supuesto una mejora significativa.

⚠️ Cuidado con el fine-tuning:
  • Si el lr del backbone es demasiado alto, destruirás las features preentrenadas (catastrophic forgetting)
  • Si descongelas demasiadas capas con pocos datos, overfitting
  • Monitoriza siempre la val_loss: si sube mientras train_loss baja → overfitting

1. Progressive unfreezing (gradual):

Descongela bloque por bloque en epochs sucesivos:

# Epoch 1-5: solo head
# Epoch 6-10: head + layer4
# Epoch 11-15: head + layer4 + layer3
layers_to_unfreeze = ['layer4', 'layer3', 'layer2']
for i, layer_name in enumerate(layers_to_unfreeze):
    # Descongelar la siguiente capa cada 5 epochs
    for name, param in model.named_parameters():
        if layer_name in name:
            param.requires_grad = True

2. Discriminative fine-tuning (ULMFiT-style):

Cada capa recibe un lr diferente, decreciendo exponencialmente hacia las capas tempranas:

base_lr = 1e-3
param_groups = []
for i, (name, layer) in enumerate(reversed(list(model.named_children()))):
    lr = base_lr * (0.3 ** i)  # Cada capa anterior: lr × 0.3
    param_groups.append({'params': layer.parameters(), 'lr': lr})

3. LORA / LoRA (Low-Rank Adaptation):

En lugar de fine-tunear todos los pesos, inserta matrices de bajo rango. Usado principalmente en LLMs pero aplicable a CNNs. Implementación: peft (Hugging Face).

8

Evaluar el modelo

La evaluación final se hace en el test set (que el modelo nunca ha visto, ni siquiera para decidir early stopping). Además del accuracy, calcularemos métricas detalladas: precision, recall, F1-score y la matriz de confusión.

Python evaluación completa
import numpy as np
from sklearn.metrics import classification_report, confusion_matrix

# 1. Cargar el mejor modelo fine-tuned
model.load_state_dict(
    torch.load("checkpoints/best_model_finetuned.pth", weights_only=True)
)
model.eval()

# 2. Recoger todas las predicciones del test set
all_preds = []
all_labels = []
all_probs = []

with torch.no_grad():
    for images, labels in test_loader:
        images = images.to(device)
        outputs = model(images)
        probs = torch.softmax(outputs, dim=1)

        all_preds.extend(outputs.argmax(1).cpu().numpy())
        all_labels.extend(labels.numpy())
        all_probs.extend(probs.cpu().numpy())

all_preds = np.array(all_preds)
all_labels = np.array(all_labels)
all_probs = np.array(all_probs)

# 3. Accuracy global
test_acc = (all_preds == all_labels).mean()
print(f"🎯 Test Accuracy: {test_acc:.4f} ({test_acc*100:.1f}%)")
print(f"   Correctas: {(all_preds == all_labels).sum()} / {len(all_labels)}")

# 4. Classification report (precision, recall, F1)
class_names = test_dataset.classes  # ['gatos', 'perros']
print(f"\n📊 Classification Report:")
print(classification_report(all_labels, all_preds, target_names=class_names))

# 5. Matriz de confusión
cm = confusion_matrix(all_labels, all_preds)
print(f"Confusion Matrix:")
print(cm)
Salida 🎯 Test Accuracy: 0.9760 (97.6%) Correctas: 2440 / 2500 📊 Classification Report: precision recall f1-score support gatos 0.98 0.97 0.97 1250 perros 0.97 0.98 0.97 1250 accuracy 0.98 2500 macro avg 0.98 0.98 0.98 2500 weighted avg 0.98 0.98 0.98 2500 Confusion Matrix: [[1213 37] [ 23 1227]]
L20torch.softmax(outputs, dim=1) — convierte logits en probabilidades. Útil para análisis de confianza.
L36classification_report de sklearn — calcula precision, recall y F1 por clase. Esencial para datasets desbalanceados.
L40La confusion matrix muestra: 37 gatos predichos como perros (FP) y 23 perros predichos como gatos (FN).

Análisis de errores: imágenes más confusas

Una buena práctica es inspeccionar las imágenes donde el modelo se equivoca o tiene menos confianza. Esto revela patrones de error (imágenes borrosas, ángulos raros, razas ambiguas...):

Python análisis de errores
# Encontrar las predicciones incorrectas
errors_idx = np.where(all_preds != all_labels)[0]
print(f"Errores totales: {len(errors_idx)} / {len(all_labels)}")

# Encontrar las predicciones con menor confianza
confidence = all_probs.max(axis=1)
least_confident_idx = np.argsort(confidence)[:10]  # 10 menos confiables

print(f"\n🔍 Top-10 predicciones menos confiables:")
for idx in least_confident_idx:
    pred_class = class_names[all_preds[idx]]
    real_class = class_names[all_labels[idx]]
    conf = confidence[idx] * 100
    status = "✅" if all_preds[idx] == all_labels[idx] else "❌"
    print(f"  {status} Imagen {idx}: pred={pred_class} ({conf:.1f}%) | real={real_class}")

# Métricas adicionales
from sklearn.metrics import roc_auc_score
if NUM_CLASSES == 2:
    auc = roc_auc_score(all_labels, all_probs[:, 1])
    print(f"\n📈 AUC-ROC: {auc:.4f}")
Salida Errores totales: 60 / 2500 🔍 Top-10 predicciones menos confiables: ❌ Imagen 834: pred=perros (52.3%) | real=gatos ❌ Imagen 1203: pred=gatos (53.1%) | real=perros ❌ Imagen 421: pred=perros (54.7%) | real=gatos ✅ Imagen 1876: pred=gatos (56.2%) | real=gatos ❌ Imagen 2091: pred=gatos (57.8%) | real=perros ... 📈 AUC-ROC: 0.9941

Comparativa: features extraction vs fine-tuning

Estrategia Test Accuracy Params entrenados Tiempo/epoch GPU RAM
Feature extraction (head) 94.0% 1.05M (4.3%) ~12s ~2 GB
Fine-tuning (layer4 + head) 97.6% 16.0M (65.2%) ~18s ~5 GB
Desde cero (sin pretrained) ~85% 24.6M (100%) ~20s ~6 GB
🔑 Conclusión: El fine-tuning gana claramente: +3.6% sobre feature extraction y +12.6% sobre entrenar desde cero. Y convergió en solo 10 epochs (vs 50+ desde cero).

1. Test-Time Augmentation (TTA):

Aplica varias augmentations a la misma imagen y promedia las predicciones:

# TTA: predecir con la imagen original + flip horizontal
preds_original = model(img)
preds_flipped = model(torch.flip(img, dims=[3]))
final_pred = (preds_original + preds_flipped) / 2

2. Calibración de probabilidades:

Las probabilidades de softmax no siempre están bien calibradas. Usa sklearn.calibration.CalibratedClassifierCV o temperature scaling.

3. Visualizar con GradCAM:

GradCAM muestra qué parte de la imagen activa la predicción. Puedes usar la librería pytorch-grad-cam:

pip install pytorch-grad-cam
from pytorch_grad_cam import GradCAM
cam = GradCAM(model=model, target_layers=[model.layer4[-1]])
9

Inferencia y exportación

Una vez entrenado y evaluado el modelo, queremos usarlo para clasificar imágenes nuevas. Vamos a ver cómo hacer inferencia, guardar el modelo completo y exportarlo a ONNX para despliegue en producción.

Inferencia sobre una imagen nueva

Python inferencia
from PIL import Image

def predict_image(model, image_path, transform, class_names, device):
    """Clasifica una imagen y devuelve clase y confianza."""
    img = Image.open(image_path).convert("RGB")
    img_tensor = transform(img).unsqueeze(0).to(device)  # (1, 3, 224, 224)

    model.eval()
    with torch.no_grad():
        logits = model(img_tensor)
        probs = torch.softmax(logits, dim=1)
        confidence, pred_idx = probs.max(dim=1)

    pred_class = class_names[pred_idx.item()]
    confidence = confidence.item() * 100
    return pred_class, confidence, probs[0].cpu().numpy()


# Usar el modelo
class_names = ['gatos', 'perros']
pred, conf, probs = predict_image(
    model, "nueva_imagen.jpg", val_transforms, class_names, device
)
print(f"Predicción: {pred} (confianza: {conf:.1f}%)")
print(f"Probabilidades: {dict(zip(class_names, [f'{p:.3f}' for p in probs]))}")
Salida Predicción: perros (confianza: 98.7%) Probabilidades: {'gatos': '0.013', 'perros': '0.987'}

Inferencia en batch

Python inferencia en batch
from pathlib import Path

def predict_batch(model, image_dir, transform, class_names, device):
    """Clasifica todas las imágenes de un directorio."""
    model.eval()
    results = []

    image_paths = list(Path(image_dir).glob("*.jpg")) + \
                  list(Path(image_dir).glob("*.png"))

    for path in image_paths:
        img = Image.open(path).convert("RGB")
        img_tensor = transform(img).unsqueeze(0).to(device)

        with torch.no_grad():
            logits = model(img_tensor)
            probs = torch.softmax(logits, dim=1)
            confidence, pred_idx = probs.max(dim=1)

        results.append({
            "file": path.name,
            "class": class_names[pred_idx.item()],
            "confidence": confidence.item() * 100,
        })

    return results

# Ejemplo
results = predict_batch(model, "nuevas_imagenes/", val_transforms, class_names, device)
for r in results:
    print(f"  {r['file']:25s} → {r['class']} ({r['confidence']:.1f}%)")

Guardar el modelo completo

Python guardar checkpoint completo
# Checkpoint completo: para poder reentrenar o compartir
checkpoint = {
    "model_state_dict": model.state_dict(),
    "class_names": class_names,
    "num_classes": NUM_CLASSES,
    "backbone": "resnet50",
    "input_size": 224,
    "normalize_mean": [0.485, 0.456, 0.406],
    "normalize_std": [0.229, 0.224, 0.225],
    "test_accuracy": test_acc,
    "training_info": {
        "feature_extraction_epochs": EPOCHS,
        "finetuning_epochs": FT_EPOCHS,
        "strategy": "progressive_unfreezing",
    }
}
torch.save(checkpoint, "model_final.pth")
print("✅ Checkpoint completo guardado")

# Para cargar después:
ckpt = torch.load("model_final.pth", weights_only=False)
model_loaded = models.resnet50(weights=None)  # Sin pesos preentrenados
model_loaded.fc = nn.Sequential(
    nn.Linear(2048, 512), nn.ReLU(), nn.Dropout(0.3),
    nn.Linear(512, ckpt["num_classes"]),
)
model_loaded.load_state_dict(ckpt["model_state_dict"])
model_loaded.eval()
print(f"✅ Modelo cargado: {ckpt['backbone']}, acc={ckpt['test_accuracy']:.3f}")

Exportar a ONNX (producción)

Para desplegar el modelo en producción (servidor web, app móvil, edge device), exportarlo a ONNX es la opción más portable. ONNX es compatible con ONNX Runtime, TensorRT, OpenVINO, CoreML y más:

Python exportar a ONNX
# Exportar a ONNX
model.eval()
dummy_input = torch.randn(1, 3, 224, 224).to(device)

torch.onnx.export(
    model,
    dummy_input,
    "model_transfer.onnx",
    input_names=["image"],
    output_names=["logits"],
    dynamic_axes={
        "image": {0: "batch_size"},
        "logits": {0: "batch_size"},
    },
    opset_version=17,
)
print("✅ Modelo exportado a ONNX")

# Verificar con ONNX Runtime
# pip install onnxruntime
import onnxruntime as ort

session = ort.InferenceSession("model_transfer.onnx")
ort_input = {"image": dummy_input.cpu().numpy()}
ort_output = session.run(None, ort_input)
print(f"Output shape: {ort_output[0].shape}")  # (1, 2)
💡 Alternativas de exportación:
  • TorchScript: model_scripted = torch.jit.script(model) — para C++ o servidores PyTorch
  • TensorRT: torch_tensorrt.compile(model) — máxima velocidad en GPU NVIDIA
  • CoreML: ct.convert(traced_model) — para iOS/macOS
  • TFLite: convierte ONNX → TFLite para Android

1. torch.compile() (PyTorch 2.x):

# Compila y optimiza el modelo (hasta 2× más rápido)
model_compiled = torch.compile(model, mode="reduce-overhead")

2. Half precision (FP16):

# Convierte a FP16 (la mitad de memoria, ~2× más rápido en GPU)
model_fp16 = model.half()
with torch.no_grad():
    output = model_fp16(input_tensor.half())

3. Quantización (INT8):

# Quantización dinámica (CPU): 2-4× más rápido, ~1% menos accuracy
model_quantized = torch.quantization.quantize_dynamic(
    model.cpu(), {nn.Linear}, dtype=torch.qint8
)

4. Batching: Procesa múltiples imágenes a la vez. Es más eficiente que una a una, especialmente en GPU.

10

Script completo y referencias

Aquí tienes el script completo que integra todo lo que hemos visto: carga del backbone, preparación de datos, feature extraction, fine-tuning, evaluación e inferencia. Cópialo y ejecútalo directamente.

📄 Script: transfer_learning.py

Python transfer_learning.py
"""
Transfer Learning paso a paso con PyTorch.
Clasificación binaria (gatos vs perros) con ResNet-50 preentrenado.
"""
import time
from pathlib import Path

import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
from torchvision import datasets, models, transforms
from torchvision.models import ResNet50_Weights

# ── Config ──────────────────────────────────────────────
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
NUM_CLASSES = 2
BATCH_SIZE = 32
FE_EPOCHS = 15        # Feature extraction epochs
FT_EPOCHS = 10        # Fine-tuning epochs
FE_LR = 1e-3          # LR para feature extraction
FT_LR_BACKBONE = 1e-5 # LR para backbone en fine-tuning
FT_LR_HEAD = 5e-4     # LR para head en fine-tuning
PATIENCE = 5

DATA_DIR = Path("dataset")
SAVE_DIR = Path("checkpoints")
SAVE_DIR.mkdir(exist_ok=True)

print(f"Device: {DEVICE}")

# ── 1. Datos ────────────────────────────────────────────
train_transforms = transforms.Compose([
    transforms.RandomResizedCrop(224, scale=(0.8, 1.0)),
    transforms.RandomHorizontalFlip(),
    transforms.RandomRotation(15),
    transforms.ColorJitter(0.2, 0.2, 0.2, 0.1),
    transforms.ToTensor(),
    transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
])
val_transforms = transforms.Compose([
    transforms.Resize(256),
    transforms.CenterCrop(224),
    transforms.ToTensor(),
    transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
])

train_ds = datasets.ImageFolder(DATA_DIR / "train", train_transforms)
val_ds   = datasets.ImageFolder(DATA_DIR / "val",   val_transforms)
test_ds  = datasets.ImageFolder(DATA_DIR / "test",  val_transforms)

train_loader = DataLoader(train_ds, BATCH_SIZE, shuffle=True, num_workers=4, pin_memory=True)
val_loader   = DataLoader(val_ds,   BATCH_SIZE, shuffle=False, num_workers=4, pin_memory=True)
test_loader  = DataLoader(test_ds,  BATCH_SIZE, shuffle=False, num_workers=4, pin_memory=True)
class_names  = train_ds.classes
print(f"Clases: {class_names} | Train: {len(train_ds)} | Val: {len(val_ds)} | Test: {len(test_ds)}")

# ── 2. Modelo ───────────────────────────────────────────
model = models.resnet50(weights=ResNet50_Weights.IMAGENET1K_V2)
for p in model.parameters():
    p.requires_grad = False
model.fc = nn.Sequential(
    nn.Linear(2048, 512), nn.ReLU(), nn.Dropout(0.3),
    nn.Linear(512, NUM_CLASSES),
)
model = model.to(DEVICE)

# ── Helpers ─────────────────────────────────────────────
def train_one_epoch(model, loader, criterion, optimizer):
    model.train()
    loss_sum, correct, total = 0.0, 0, 0
    for x, y in loader:
        x, y = x.to(DEVICE), y.to(DEVICE)
        out = model(x); loss = criterion(out, y)
        optimizer.zero_grad(); loss.backward(); optimizer.step()
        loss_sum += loss.item() * x.size(0)
        correct += out.argmax(1).eq(y).sum().item(); total += y.size(0)
    return loss_sum / total, correct / total

@torch.no_grad()
def evaluate(model, loader, criterion):
    model.eval()
    loss_sum, correct, total = 0.0, 0, 0
    for x, y in loader:
        x, y = x.to(DEVICE), y.to(DEVICE)
        out = model(x); loss = criterion(out, y)
        loss_sum += loss.item() * x.size(0)
        correct += out.argmax(1).eq(y).sum().item(); total += y.size(0)
    return loss_sum / total, correct / total

def run_training(model, optimizer, scheduler, epochs, tag, save_name):
    criterion = nn.CrossEntropyLoss()
    best_vl = float('inf'); wait = 0
    print(f"\n{'='*60}\n📌 {tag}\n{'='*60}")
    for ep in range(epochs):
        t0 = time.time()
        tl, ta = train_one_epoch(model, train_loader, criterion, optimizer)
        vl, va = evaluate(model, val_loader, criterion)
        scheduler.step(vl) if hasattr(scheduler, 'step') else None
        mk = ""
        if vl < best_vl:
            best_vl = vl; wait = 0
            torch.save(model.state_dict(), SAVE_DIR / save_name); mk = " ★"
        else:
            wait += 1
            if wait >= PATIENCE:
                print(f"  ⏹ Early stopping ep {ep+1}"); break
        print(f"  {ep+1:2d}/{epochs} tl={tl:.4f} ta={ta:.4f} vl={vl:.4f} va={va:.4f} {time.time()-t0:.1f}s{mk}")
    model.load_state_dict(torch.load(SAVE_DIR / save_name, weights_only=True))
    return best_vl

# ── 3. Feature extraction ──────────────────────────────
opt1 = optim.Adam(filter(lambda p: p.requires_grad, model.parameters()),
                  lr=FE_LR, weight_decay=1e-4)
sch1 = optim.lr_scheduler.ReduceLROnPlateau(opt1, patience=3, factor=0.5)
run_training(model, opt1, sch1, FE_EPOCHS, "Feature Extraction (head only)", "best_fe.pth")

# ── 4. Fine-tuning ─────────────────────────────────────
for n, p in model.named_parameters():
    p.requires_grad = "layer4" in n or "fc" in n
opt2 = optim.Adam([
    {"params": [p for n, p in model.named_parameters() if "layer4" in n and p.requires_grad], "lr": FT_LR_BACKBONE},
    {"params": model.fc.parameters(), "lr": FT_LR_HEAD},
], weight_decay=1e-4)
sch2 = optim.lr_scheduler.CosineAnnealingLR(opt2, T_max=FT_EPOCHS, eta_min=1e-6)
run_training(model, opt2, sch2, FT_EPOCHS, "Fine-tuning (layer4 + head)", "best_ft.pth")

# ── 5. Evaluación final ────────────────────────────────
criterion = nn.CrossEntropyLoss()
test_loss, test_acc = evaluate(model, test_loader, criterion)
print(f"\n🎯 Test Accuracy: {test_acc:.4f} ({test_acc*100:.1f}%)")

# ── 6. Guardar modelo final ────────────────────────────
torch.save({
    "model_state_dict": model.state_dict(),
    "class_names": class_names, "num_classes": NUM_CLASSES,
    "backbone": "resnet50", "test_accuracy": test_acc,
}, SAVE_DIR / "model_final.pth")
print("💾 Modelo final guardado")

Resumen del flujo completo

1. Backbone Preentrenado 2. Nuevo Head N clases 3. Feature Extraction 🔒 backbone 4. Fine- tuning 🔓 layer4+head 5. Evaluar Test set 6. Deploy ONNX / API

Referencias y recursos

🎉 ¡Enhorabuena! Has completado el tutorial de Transfer Learning. Has aprendido a explorar modelos preentrenados, adaptar el head, congelar y descongelar el backbone, entrenar con learning rates diferenciados, evaluar con métricas detalladas y exportar para producción. Ahora aplícalo a tu propio dataset.