27. Clasificación de imágenes con ResNet

27.1 Introducción

En los temas anteriores vimos qué es el transfer learning y cómo usar modelos preentrenados. Ahora vamos a concentrarnos en un caso concreto y muy importante: la clasificación de imágenes con ResNet.

ResNet es una de las arquitecturas más influyentes de la historia del Deep Learning visual. Además de su valor conceptual, es una herramienta extremadamente práctica, porque existe en múltiples variantes preentrenadas y funciona muy bien como base para muchos problemas reales.

En este tema veremos cómo tomar una ResNet preentrenada, adaptarla a un problema nuevo de clasificación y organizar un pipeline completo de entrenamiento, validación e inferencia.

27.2 ¿Por qué ResNet es una buena elección?

ResNet combina varias ventajas:

  • Es una arquitectura muy conocida y probada.
  • Tiene implementaciones estables en torchvision.
  • Dispone de pesos preentrenados ampliamente usados.
  • Sirve bien como punto de partida para muchos problemas de clasificación.

Por eso, aprender a usar ResNet es una forma excelente de dominar una gran parte del flujo práctico de visión por computadora con PyTorch.

27.3 Recordatorio: qué aporta la idea residual

ResNet introdujo conexiones residuales que facilitan el entrenamiento de redes profundas. Eso permitió construir modelos más profundos y estables, con gran capacidad representacional.

Para usar una ResNet en la práctica no necesitamos reimplementar toda esa lógica desde cero, pero sí conviene recordar que esa arquitectura ya viene optimizada para aprender representaciones visuales ricas.

27.4 Elegir una variante de ResNet

En torchvision suelen aparecer variantes como:

  • ResNet18
  • ResNet34
  • ResNet50
  • ResNet101

Las variantes más profundas suelen tener más capacidad, pero también más costo computacional. Para empezar y para fines didácticos, ResNet18 suele ser una excelente elección.

27.5 Cargar ResNet18 preentrenada

La forma moderna de hacerlo en torchvision es:

from torchvision import models

weights = models.ResNet18_Weights.DEFAULT
model = models.resnet18(weights=weights)

Con esto obtenemos una ResNet18 con pesos preentrenados listos para reutilizarse.

27.6 Inspeccionar la última capa

Antes de adaptarla, conviene inspeccionar la parte final del modelo:

print(model.fc)

En ResNet, la última capa de clasificación se encuentra en model.fc. Esa capa fue entrenada originalmente para 1000 clases de ImageNet.

27.7 Adaptar ResNet a un nuevo problema

Si nuestro nuevo problema tiene, por ejemplo, 3 clases, la salida de 1000 categorías ya no sirve. Debemos reemplazar la capa final:

import torch.nn as nn

num_features = model.fc.in_features
model.fc = nn.Linear(num_features, 3)

Ahora ResNet sigue usando toda su base preentrenada, pero su salida está adaptada a nuestro nuevo conjunto de clases.

27.8 Congelar el backbone

Una estrategia inicial muy común es congelar todas las capas y entrenar solo la nueva capa final:

for param in model.parameters():
    param.requires_grad = False

num_features = model.fc.in_features
model.fc = nn.Linear(num_features, 3)

Como la nueva capa se crea después del congelamiento, queda lista para entrenarse.

27.9 ¿Por qué empezar entrenando solo la cabeza?

Porque es una estrategia simple, estable y muy útil cuando el dataset nuevo es pequeño. Aprovechamos las representaciones ya aprendidas por ResNet y solo enseñamos una nueva capa a mapear esas características hacia nuestras clases específicas.

Es una forma muy práctica de obtener una línea base fuerte sin necesidad de ajustar toda la red.

27.10 Transformaciones de entrada

Como todo modelo preentrenado, ResNet espera imágenes procesadas de una forma determinada. Lo más recomendable es reutilizar las transforms asociadas a los pesos:

preprocess = weights.transforms()

Esto asegura coherencia entre el modo en que las imágenes fueron vistas durante el preentrenamiento y el modo en que las alimentamos ahora.

27.11 Dataset y DataLoader

El pipeline de datos se organiza igual que en otros clasificadores. Necesitamos:

  • Un dataset.
  • Un DataLoader de entrenamiento.
  • Un DataLoader de validación o test.

Para mantener el ejemplo autocontenido, en este tema seguiremos usando FakeData.

27.12 Estructura general del pipeline

El flujo completo de trabajo con ResNet adaptada suele ser:

  1. Cargar pesos preentrenados.
  2. Obtener transforms correctas.
  3. Crear datasets y dataloaders.
  4. Congelar el backbone.
  5. Reemplazar la capa final.
  6. Entrenar la cabeza.
  7. Validar el desempeño.

27.13 Preparar el optimizador

Si solo queremos entrenar la última capa, conviene pasar al optimizador únicamente sus parámetros:

optimizer = torch.optim.Adam(model.fc.parameters(), lr=0.001)

Esto deja claro que no vamos a modificar el resto de la red en esta primera etapa.

27.14 Función de pérdida

Como seguimos en un problema de clasificación multiclase, una opción natural es:

criterion = nn.CrossEntropyLoss()

La salida de ResNet adaptada serán logits, así que esta función de pérdida encaja perfectamente.

27.15 Ejemplo completo con ResNet18

Este ejemplo reúne carga del modelo, adaptación y entrenamiento básico:

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


device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

weights = models.ResNet18_Weights.DEFAULT
preprocess = weights.transforms()

train_dataset = datasets.FakeData(
    size=300,
    image_size=(3, 224, 224),
    num_classes=3,
    transform=preprocess
)

val_dataset = datasets.FakeData(
    size=100,
    image_size=(3, 224, 224),
    num_classes=3,
    transform=preprocess
)

train_loader = DataLoader(train_dataset, batch_size=16, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=16, shuffle=False)

model = models.resnet18(weights=weights)

for param in model.parameters():
    param.requires_grad = False

num_features = model.fc.in_features
model.fc = nn.Linear(num_features, 3)
model = model.to(device)

criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.fc.parameters(), lr=0.001)

num_epochs = 3

for epoch in range(num_epochs):
    model.train()
    train_loss = 0.0
    train_correct = 0
    train_total = 0

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

        optimizer.zero_grad()
        outputs = model(images)
        loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()

        train_loss += loss.item() * images.size(0)
        preds = outputs.argmax(dim=1)
        train_correct += (preds == labels).sum().item()
        train_total += labels.size(0)

    avg_train_loss = train_loss / train_total
    train_acc = train_correct / train_total

    model.eval()
    val_correct = 0
    val_total = 0

    with torch.no_grad():
        for images, labels in val_loader:
            images = images.to(device)
            labels = labels.to(device)

            outputs = model(images)
            preds = outputs.argmax(dim=1)

            val_correct += (preds == labels).sum().item()
            val_total += labels.size(0)

    val_acc = val_correct / val_total

    print(
        f"Epoca {epoch+1}/{num_epochs} | "
        f"train_loss={avg_train_loss:.4f} | train_acc={train_acc:.4f} | "
        f"val_acc={val_acc:.4f}"
    )

Este código ya expresa de forma bastante completa la lógica de un clasificador basado en ResNet con transferencia de aprendizaje.

27.16 Qué está pasando en ese código

Conviene leer el ejemplo por etapas:

  • Primero se cargan pesos y transforms de ResNet18.
  • Luego se crean datasets y dataloaders.
  • Después se congela el backbone.
  • Se reemplaza la última capa por una salida de 3 clases.
  • Finalmente se entrena solo esa cabeza y se valida.

Este patrón es uno de los más usados en problemas reales con datasets pequeños o medianos.

27.17 Inferencia con la ResNet adaptada

Una vez entrenado el modelo, podemos usarlo para clasificar nuevas imágenes siguiendo el flujo habitual:

  1. Cargar la imagen.
  2. Aplicar las mismas transforms.
  3. Agregar dimensión batch.
  4. Pasar la imagen por el modelo en eval().
  5. Tomar la clase con mayor logit.

27.18 Ejemplo de inferencia

Un ejemplo mínimo sería:

from PIL import Image

model.eval()

imagen = Image.open("foto1.jpg").convert("RGB")
entrada = preprocess(imagen).unsqueeze(0).to(device)

with torch.no_grad():
    salida = model(entrada)

pred = salida.argmax(dim=1).item()
print("Clase predicha:", pred)

En un proyecto real, ese índice debería mapearse luego a nombres de clase concretos.

27.19 Guardar el modelo entrenado

Si el entrenamiento produce buenos resultados, podemos guardar el estado del modelo:

torch.save(model.state_dict(), "resnet_clasificador.pth")

Esto permite reutilizar el clasificador más adelante sin repetir todo el entrenamiento.

27.20 Cargar el modelo guardado

Para volver a usarlo después, se reconstruye la arquitectura y se cargan los pesos:

model = models.resnet18(weights=weights)
num_features = model.fc.in_features
model.fc = nn.Linear(num_features, 3)
model.load_state_dict(torch.load("resnet_clasificador.pth"))
model.eval()

Este paso es muy importante: la arquitectura debe coincidir con la que tenía el modelo cuando fue guardado.

27.21 Cuándo conviene hacer fine tuning adicional

Si entrenar solo la cabeza final no alcanza, una siguiente estrategia razonable es descongelar los últimos bloques de ResNet y hacer un ajuste más profundo con una tasa de aprendizaje baja.

Esto puede mejorar el desempeño cuando el nuevo dominio se aleja más del problema original de ImageNet.

27.22 Ventajas de trabajar específicamente con ResNet

ResNet resulta especialmente cómoda porque:

  • Su arquitectura es estable y conocida.
  • La capa final fc es fácil de localizar.
  • Existen múltiples variantes según el costo deseado.
  • Está muy bien soportada en documentación y ejemplos.

27.23 Limitaciones prácticas

Aunque muy útil, ResNet no es la solución ideal para todos los escenarios. Puede resultar pesada para algunos dispositivos, y no siempre será la arquitectura más eficiente para despliegues con recursos muy limitados.

Además, como cualquier modelo preentrenado, su utilidad depende de qué tan compatible sea el dominio nuevo con el tipo de imágenes sobre las que fue entrenado originalmente.

27.24 Errores comunes al trabajar con ResNet

Entre los errores más frecuentes están:

  • No usar el tamaño y preprocesamiento esperados por el modelo.
  • Olvidar reemplazar la capa final.
  • Pasar todos los parámetros al optimizador cuando solo queremos entrenar la cabeza.
  • Olvidar model.eval() en inferencia.
  • No mover correctamente datos y modelo al mismo dispositivo.

27.25 Qué debes recordar de este tema

  • ResNet es una de las arquitecturas más útiles y prácticas para clasificación con transfer learning.
  • En PyTorch puede cargarse fácilmente desde torchvision.models.
  • La capa final de ResNet se encuentra normalmente en model.fc.
  • Una estrategia muy común es congelar el backbone y entrenar solo una nueva cabeza de clasificación.
  • Las transforms asociadas a los pesos son claves para preparar correctamente las imágenes.
  • La lógica general incluye carga, adaptación, entrenamiento, validación, guardado e inferencia.

27.26 Conclusión

La clasificación de imágenes con ResNet es uno de los puntos más clásicos y productivos de entrada al trabajo práctico con modelos preentrenados en visión por computadora. Reúne en un solo flujo muchas de las ideas fundamentales del curso: datasets, transforms, entrenamiento, validación, evaluación y transferencia de conocimiento.

Dominar este pipeline significa contar ya con una herramienta muy potente para resolver numerosos problemas reales de clasificación visual. Además, deja una base sólida para dar el siguiente salto hacia tareas más complejas.

En el próximo tema abriremos justamente esa nueva etapa con una introducción a detección de objetos, donde ya no bastará con clasificar una imagen completa, sino que tendremos que localizar objetos dentro de ella.