Transferencia de aprendizaje con PyTorch

La transferencia de aprendizaje (Transfer Learning) se refiere a la técnica de tomar un modelo preentrenado en un conjunto de datos a gran escala y transferirlo a una tarea nueva con menos datos para entrenarlo.

Es una de las técnicas más utilizadas en la práctica actual del aprendizaje profundo: en la mayoría de los casos, la transferencia de aprendizaje produce mejores resultados que entrenar desde cero, es más rápida y requiere menos datos.


1. Idea central de la transferencia de aprendizaje

Las características aprendidas por las redes neuronales profundas en ImageNet son generalizables:

  • Capas superficiales: aprenden características de bajo nivel generales (bordes, texturas, gradientes de color)
  • Capas intermedias: aprenden características de nivel medio generales (formas, partes, combinaciones de texturas)
  • Capas profundas: aprenden características de alto nivel relacionadas con la tarea (rostros, ruedas, texto)

Estas características de nivel bajo y medio son útiles para la gran mayoría de tareas visuales y no es necesario volver a aprenderlas.

¿Cuándo utilizar la transferencia de aprendizaje?

Cantidad de datosSimilitud con la tarea originalEstrategia recomendada
Poca (< 1000)AltoSolo reemplazar el clasificador final y congelar todo el backbone
Poca (< 1000)bajoAjustar las capas más superficiales y congelar las profundas
Media (1000~10000)AltoAjustar toda la red con una tasa de aprendizaje pequeña
Media (1000~10000)bajoAjustar las capas más profundas y congelar las superficiales
Mucha (> 10000)CualquieraAjustar todo, o considerar entrenar desde cero

Comparación de tres estrategias principales

La estructura jerárquica de un modelo preentrenado (como ResNet50) es la siguiente:

  • Capas convolucionales 1~3 (características de bajo nivel: bordes/texturas): generalmente congeladas
  • Capas convolucionales 4~6 (características de nivel medio: formas/partes): opcionalmente congeladas
  • Capas convolucionales 7~N (características de alto nivel: información semántica): ajuste fino
  • Clasificador (Classifier Head): reemplazar y entrenar
┌─────────────────────────────────────────────────┐
│              预训练模型(如 ResNet50)             │
│  ┌──────────────────────────────────────────┐   │
│  │  Conv Layer 1~3(低级特征:边缘/纹理)      │  ← 通常冻结
│  ├──────────────────────────────────────────┤   │
│  │  Conv Layer 4~6(中级特征:形状/部件)      │  ← 可选冻结
│  ├──────────────────────────────────────────┤   │
│  │  Conv Layer 7~N(高级特征:语义信息)       │  ← 微调
│  ├──────────────────────────────────────────┤   │
│  │  Classifier Head(分类头)                 │  ← 替换 & 训练
│  └──────────────────────────────────────────┘   │
└─────────────────────────────────────────────────┘

2. Cargar un modelo preentrenado

PyTorch ofrece una gran cantidad de modelos preentrenados oficiales a través de torchvision.models, y cargarlos es muy sencillo.

Ejemplo

import torch
import torchvision.models as models

# Cargar modelo preentrenado (descarga automática de pesos)
# PyTorch >= 0.13 recomienda la nueva sintaxis: usar el parámetro weights
from torchvision.models import ResNet50_Weights

model = models.resnet50(weights=ResNet50_Weights.IMAGENET1K_V2)

# Sintaxis antigua (sigue funcionando, pero mostrará una advertencia de deprecación)
model = models.resnet50(pretrained=True)

# No cargar pesos preentrenados (solo usar la estructura de la red)
model = models.resnet50(weights=None)

Ver la estructura del modelo

Ejemplo

# Imprimir la estructura completa
print(model)

# Ver solo las últimas capas (clasificador)
print(model.fc)
# Linear(in_features=2048, out_features=1000, bias=True)

# Contar el número de parámetros
total_params    = sum(p.numel() for p in model.parameters())
trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"Total de parámetros: {total_params:,}")
print(f"Parámetros entrenables: {trainable_params:,}")

Nombre del clasificador de cada modelo

Diferentes modelos tienen diferentes nombres de atributo para el clasificador; al transferir, es necesario reemplazar la capa correspondiente:

ModeloAtributo del clasificador
ResNet / RegNetmodel.fc
VGG / AlexNetmodel.classifier[-1]
DenseNetmodel.classifier
EfficientNetmodel.classifier[-1]
MobileNetV2/V3model.classifier[-1]
ViT (Vision Transformer)model.heads.head
ConvNeXtmodel.classifier[-1]
Inception V3model.fc
Swin Transformermodel.head

3. Tres estrategias de transferencia

3.1 Estrategia 1: Extracción de características (congelar todo)

Congelar todos los parámetros del modelo preentrenado y entrenar solo el nuevo clasificador reemplazado.

Es adecuada cuando hay muy pocos datos (unos cientos de imágenes) o cuando la tarea es muy similar a la tarea original.

Ejemplo

import torch
import torch.nn as nn
import torchvision.models as models
from torchvision.models import ResNet18_Weights

NUM_CLASSES = 5   # Número de clases de la tarea objetivo

# Paso 1: Cargar el modelo preentrenado
model = models.resnet18(weights=ResNet18_Weights.IMAGENET1K_V1)

# Paso 2: Congelar todos los parámetros
for param in model.parameters():
    param.requires_grad = False

# Paso 3: Reemplazar el clasificador (estos parámetros tienen requires_grad=True por defecto)
in_features = model.fc.in_features    # 512
model.fc = nn.Linear(in_features, NUM_CLASSES)

# Verificar: solo el clasificador es entrenable
trainable = [(n, p.shape) for n, p in model.named_parameters() if p.requires_grad]
print(f"Capas entrenables: {len(trainable)}")
for name, shape in trainable:
    print(f"  {name}: {shape}")
# Salida:
#   fc.weight: torch.Size([5, 512])
#   fc.bias:   torch.Size([5])

# Paso 4: Pasar solo los parámetros entrenables al optimizador (más eficiente)
optimizer = torch.optim.Adam(
    filter(lambda p: p.requires_grad, model.parameters()),
    lr=1e-3
)
# O una forma equivalente pero más clara:
optimizer = torch.optim.Adam(model.fc.parameters(), lr=1e-3)

La extracción de características es la estrategia de transferencia de aprendizaje más simple y más utilizada, especialmente adecuada para escenarios con pocos datos.

3.2 Estrategia 2: Ajuste fino (Fine-tuning)

Descongelar todas o parte de las capas preentrenadas y entrenar todo el modelo con una tasa de aprendizaje más pequeña.

Es adecuada cuando hay una cantidad media de datos o cuando la tarea es algo diferente a la tarea original.

Ejemplo

import torch
import torch.nn as nn
import torchvision.models as models

NUM_CLASSES = 10
model = models.resnet50(weights='IMAGENET1K_V2')

# Método A: Ajuste fino completo (descongelar todas las capas)
# Primero congelar
for param in model.parameters():
    param.requires_grad = False

# Luego descongelar (equivalente al ajuste fino completo; esta sintaxis se usa a menudo en escenarios de descongelado progresivo)
for param in model.parameters():
    param.requires_grad = True

# Reemplazar el clasificador
model.fc = nn.Linear(model.fc.in_features, NUM_CLASSES)

# Ajuste fino completo: backbone con tasa de aprendizaje pequeña, cabeza con tasa grande (ver estrategia 3)
optimizer = torch.optim.SGD(model.parameters(), lr=1e-4, momentum=0.9)


# Método B: Descongelar las últimas N capas (ajuste fino parcial)
model = models.resnet50(weights='IMAGENET1K_V2')

# Primero congelar todo
for param in model.parameters():
    param.requires_grad = False

# Descongelar solo layer4 y fc (el último bloque de ResNet y el clasificador)
for param in model.layer4.parameters():
    param.requires_grad = True

model.fc = nn.Linear(model.fc.in_features, NUM_CLASSES)  # fc es entrenable por defecto

print("Parámetros entrenables:")
for name, param in model.named_parameters():
    if param.requires_grad:
        print(f"  {name}")

3.3 Estrategia 3: Tasa de aprendizaje diferenciada por capas

El backbone usa una tasa de aprendizaje pequeña (para conservar el conocimiento preentrenado), mientras que el clasificador usa una tasa de aprendizaje grande (para adaptarse rápidamente a la nueva tarea).

Esta es la estrategia de ajuste fino más utilizada en la industria y ofrece el mejor rendimiento general.

Ejemplo

import torch
import torch.nn as nn
import torchvision.models as models

NUM_CLASSES = 8
model = models.resnet50(weights='IMAGENET1K_V2')
model.fc = nn.Linear(model.fc.in_features, NUM_CLASSES)

# Opción 1: Dos tasas de aprendizaje (backbone vs cabeza)
optimizer = torch.optim.Adam([
    {'params': model.fc.parameters(),  'lr': 1e-3},     # Clasificador: tasa de aprendizaje grande
    {'params': [p for n, p in model.named_parameters()  # Backbone: tasa de aprendizaje pequeña
                if not n.startswith('fc')],
     'lr': 1e-5},
])


# Opción 2: Tasa de aprendizaje decreciente por capas (la más precisa)
# Cuanto más cerca de la salida, mayor es la tasa de aprendizaje
layer_groups = [
    (model.layer1, 1e-5),    # Capa más superficial, tasa de aprendizaje más pequeña
    (model.layer2, 3e-5),
    (model.layer3, 1e-4),
    (model.layer4, 3e-4),    # Capa más profunda del backbone
    (model.fc,     1e-3),    # Clasificador, tasa de aprendizaje máxima
]

param_groups = [
    {'params': layer.parameters(), 'lr': lr}
    for layer, lr in layer_groups
]
optimizer = torch.optim.Adam(param_groups)


# Opción 3: Descongelado progresivo (Gradual Unfreezing)
# Al inicio del entrenamiento solo se entrena la cabeza, y progresivamente se descongelan más capas (recomendado por fastai)
model = models.resnet50(weights='IMAGENET1K_V2')
for param in model.parameters():
    param.requires_grad = False
model.fc = nn.Linear(model.fc.in_features, NUM_CLASSES)

def unfreeze_layers(model, num_layers):
    """Descongela los últimos num_layers bloques de capas de ResNet"""
    layers = [model.layer4, model.layer3, model.layer2, model.layer1]
    for i in range(min(num_layers, len(layers))):
        for param in layers[i].parameters():
            param.requires_grad = True

# Época 1-5: solo entrenar el clasificador
# Época 6-10: descongelar layer4
unfreeze_layers(model, num_layers=1)
# Época 11+: descongelar más capas
unfreeze_layers(model, num_layers=3)

4. Modelos preentrenados comunes

4.1 Modelos de clasificación de imágenes

Ejemplo

import torchvision.models as models

# Serie ResNet (la más clásica, adecuada para la mayoría de tareas)
resnet18  = models.resnet18(weights='IMAGENET1K_V1')   # Ligero, adecuado para dispositivos de borde
resnet50  = models.resnet50(weights='IMAGENET1K_V2')   # Opción equilibrada
resnet101 = models.resnet101(weights='IMAGENET1K_V2')  # Más potente, más lento

# Serie EfficientNet (relación precisión-eficiencia muy alta)
effnet_b0 = models.efficientnet_b0(weights='IMAGENET1K_V1')  # El más ligero
effnet_b4 = models.efficientnet_b4(weights='IMAGENET1K_V1')  # Equilibrado
effnet_b7 = models.efficientnet_b7(weights='IMAGENET1K_V1')  # El más potente

# Vision Transformer (primera opción para tareas con grandes volúmenes de datos)
vit_b16 = models.vit_b_16(weights='IMAGENET1K_V1')    # ViT-Base/16
vit_l16 = models.vit_l_16(weights='IMAGENET1K_V1')    # ViT-Large/16

# MobileNet (despliegue en móvil/embebido)
mobilenet_v3 = models.mobilenet_v3_small(weights='IMAGENET1K_V1')

# ConvNeXt (versión moderna de CNN, rendimiento cercano a ViT)
convnext_t = models.convnext_tiny(weights='IMAGENET1K_V1')
convnext_b = models.convnext_base(weights='IMAGENET1K_V1')

Comparación de rendimiento de los modelos principales (ImageNet Top-1 Acc):

ModeloTop-1 AccCantidad de parámetrosVelocidad de inferenciaEscenarios de aplicación
ResNet-1869.8%11.7MMuy rápidoRecursos limitados, prototipado rápido
ResNet-5080.9%25.6MRápidoElección general preferida
EfficientNet-B483.4%19.3MMedioEquilibrio entre precisión y eficiencia
ConvNeXt-Base84.1%88.6MMedioCNN de alta precisión
ViT-B/1681.1%86.6MMedioEscenarios de grandes volúmenes de datos
ViT-L/1685.1%307MLentoMayor precisión

4.2 Modelos de detección de objetos

Ejemplo

import torchvision.models.detection as detection

# Faster R-CNN (clásico detector de dos etapas)
faster_rcnn = detection.fasterrcnn_resnet50_fpn(weights='DEFAULT')

# SSD (detector de una etapa, rápido)
ssd = detection.ssd300_vgg16(weights='DEFAULT')

# RetinaNet
retinanet = detection.retinanet_resnet50_fpn(weights='DEFAULT')

# FCOS
fcos = detection.fcos_resnet50_fpn(weights='DEFAULT')

# Reemplazar la cabeza de clasificación de Faster R-CNN (adaptar al nuevo número de clases)
from torchvision.models.detection.faster_rcnn import FastRCNNPredictor

NUM_CLASSES = 5 + 1   # 5 clases de objetos + 1 clase de fondo

faster_rcnn = detection.fasterrcnn_resnet50_fpn(weights='DEFAULT')
in_features = faster_rcnn.roi_heads.box_predictor.cls_score.in_features
faster_rcnn.roi_heads.box_predictor = FastRCNNPredictor(in_features, NUM_CLASSES)

4.3 Modelos de texto (HuggingFace)

El aprendizaje por transferencia para tareas de NLP generalmente usa la biblioteca HuggingFace transformers:

Ejemplo

# pip install transformers
from transformers import (
    BertForSequenceClassification,
    RobertaForSequenceClassification,
    AutoModelForSequenceClassification,
    AutoTokenizer,
)

NUM_CLASSES = 3

# BERT (primera opción para clasificación de texto)
model = BertForSequenceClassification.from_pretrained(
    'bert-base-chinese',     # BERT chino
    num_labels=NUM_CLASSES
)
tokenizer = AutoTokenizer.from_pretrained('bert-base-chinese')

# RoBERTa (versión mejorada de BERT, más potente)
model = RobertaForSequenceClassification.from_pretrained(
    'roberta-base',
    num_labels=NUM_CLASSES
)

# Método de carga genérico (identifica automáticamente el tipo de modelo)
model = AutoModelForSequenceClassification.from_pretrained(
    'hfl/chinese-roberta-wwm-ext',    # RoBERTa chino
    num_labels=NUM_CLASSES
)

5. Preprocesamiento y aumento de datos

Al usar pesos preentrenados de ImageNet en aprendizaje por transferencia, es obligatorio usar los mismos parámetros de normalización que los del preentrenamiento; de lo contrario, la distribución de características no coincidirá y el rendimiento caerá drásticamente.

Ejemplo

from torchvision import transforms

# Parámetros estándar de normalización de ImageNet (comunes a todos los modelos preentrenados de torchvision)
IMAGENET_MEAN = [0.485, 0.456, 0.406]
IMAGENET_STD  = [0.229, 0.224, 0.225]

# Conjunto de entrenamiento: con aumento de datos
train_transforms = transforms.Compose([
    transforms.RandomResizedCrop(224),          # Recorte aleatorio y redimensionado
    transforms.RandomHorizontalFlip(p=0.5),    # Volteo horizontal aleatorio
    transforms.ColorJitter(                    # Jitter de color
        brightness=0.2, contrast=0.2,
        saturation=0.2, hue=0.1
    ),
    transforms.RandomRotation(degrees=15),     # Rotación aleatoria
    transforms.ToTensor(),
    transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD),
])

# Conjunto de validación/prueba: sin aumento aleatorio
val_transforms = transforms.Compose([
    transforms.Resize(256),                    # Primero redimensionar a 256
    transforms.CenterCrop(224),                # Luego recorte central a 224
    transforms.ToTensor(),
    transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD),
])

# Carga del conjunto de datos (estructura de directorios: root/class_name/img.jpg)
from torchvision.datasets import ImageFolder
from torch.utils.data import DataLoader

train_dataset = ImageFolder(root='data/train', transform=train_transforms)
val_dataset   = ImageFolder(root='data/val',   transform=val_transforms)

train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True,
                          num_workers=4, pin_memory=True)
val_loader   = DataLoader(val_dataset,   batch_size=32, shuffle=False,
                          num_workers=4, pin_memory=True)

print(f"Número de muestras de entrenamiento: {len(train_dataset)}")
print(f"Lista de clases: {train_dataset.classes}")
print(f"Mapa de clases: {train_dataset.class_to_idx}")

Usar el preprocesamiento recomendado oficial de torchvision

Ejemplo

# PyTorch >= 0.13: obtener el preprocesamiento estándar directamente del objeto weights, sin necesidad de especificar parámetros manualmente
from torchvision.models import ResNet50_Weights

weights   = ResNet50_Weights.IMAGENET1K_V2
model     = models.resnet50(weights=weights)
preprocess = weights.transforms()   # Devuelve automáticamente el pipeline de preprocesamiento correspondiente

# preprocess ya incluye pasos como Resize(232), CenterCrop(224), Normalize, etc.
# Durante el entrenamiento, solo hay que añadir aumento de datos sobre esta base

Hay que usar los parámetros de normalización de ImageNet [0.485, 0.456, 0.406] y [0.229, 0.224, 0.225]; de lo contrario, las características preentrenadas no se alinearán correctamente.


6. Práctica completa: clasificación binaria de imágenes

Usando la clasificación de gatos y perros como ejemplo, se muestra el flujo completo de aprendizaje por transferencia desde la preparación de datos hasta la evaluación del entrenamiento:

Ejemplo

import os
import torch
import torch.nn as nn
import torch.optim as optim
from torch.optim.lr_scheduler import CosineAnnealingLR
from torchvision import models, transforms, datasets
from torch.utils.data import DataLoader, random_split
from torchvision.models import EfficientNet_B0_Weights

# Configuración
DEVICE      = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
NUM_CLASSES = 2
BATCH_SIZE  = 32
EPOCHS      = 20
BASE_LR     = 1e-3
DATA_DIR    = 'data/cats_and_dogs'

# Preparación de datos
train_tfm = transforms.Compose([
    transforms.RandomResizedCrop(224),
    transforms.RandomHorizontalFlip(),
    transforms.ColorJitter(brightness=0.3, contrast=0.3),
    transforms.ToTensor(),
    transforms.Normalize([0.485, 0.456, 0.406],
                         [0.229, 0.224, 0.225]),
])
val_tfm = transforms.Compose([
    transforms.Resize(256),
    transforms.CenterCrop(224),
    transforms.ToTensor(),
    transforms.Normalize([0.485, 0.456, 0.406],
                         [0.229, 0.224, 0.225]),
])

full_dataset = datasets.ImageFolder(DATA_DIR, transform=train_tfm)

n_val   = int(len(full_dataset) * 0.2)
n_train = len(full_dataset) - n_val
train_set, val_set = random_split(full_dataset, [n_train, n_val])
val_set.dataset = datasets.ImageFolder(DATA_DIR, transform=val_tfm)  # Reemplazar el transform de val

train_loader = DataLoader(train_set, BATCH_SIZE, shuffle=True,
                          num_workers=4, pin_memory=True)
val_loader   = DataLoader(val_set,   BATCH_SIZE, shuffle=False,
                          num_workers=4, pin_memory=True)

# Construir el modelo de transferencia
weights = EfficientNet_B0_Weights.IMAGENET1K_V1
model   = models.efficientnet_b0(weights=weights)

# Congelar el backbone
for param in model.parameters():
    param.requires_grad = False

# Reemplazar la cabeza de clasificación (estructura de la cabeza de EfficientNet-B0)
in_features = model.classifier[1].in_features  # 1280
model.classifier = nn.Sequential(
    nn.Dropout(p=0.2, inplace=True),
    nn.Linear(in_features, NUM_CLASSES),
)

model = model.to(DEVICE)

# Optimizador: tasa de aprendizaje por capas
optimizer = optim.Adam([
    {'params': model.classifier.parameters(), 'lr': BASE_LR},
    {'params': model.features.parameters(),   'lr': BASE_LR * 0.1},
])
criterion = nn.CrossEntropyLoss(label_smoothing=0.1)
scheduler = CosineAnnealingLR(optimizer, T_max=EPOCHS, eta_min=1e-6)

# Funciones de entrenamiento y validación
def train_epoch(model, loader, optimizer, criterion):
    model.train()
    total_loss, correct = 0.0, 0
    for imgs, labels in loader:
        imgs, labels = imgs.to(DEVICE), labels.to(DEVICE)
        optimizer.zero_grad()
        outputs = model(imgs)
        loss    = criterion(outputs, labels)
        loss.backward()
        optimizer.step()
        total_loss += loss.item() * imgs.size(0)
        correct    += (outputs.argmax(1) == labels).sum().item()
    n = len(loader.dataset)
    return total_loss / n, correct / n

def eval_epoch(model, loader, criterion):
    model.eval()
    total_loss, correct = 0.0, 0
    with torch.no_grad():
        for imgs, labels in loader:
            imgs, labels = imgs.to(DEVICE), labels.to(DEVICE)
            outputs = model(imgs)
            loss    = criterion(outputs, labels)
            total_loss += loss.item() * imgs.size(0)
            correct    += (outputs.argmax(1) == labels).sum().item()
    n = len(loader.dataset)
    return total_loss / n, correct / n

# Entrenamiento por etapas
print("=== Etapa 1: solo entrenar la cabeza de clasificación (Epoch 1-5) ===")
best_acc = 0.0

for epoch in range(1, EPOCHS + 1):
    # En el epoch 5 se descongela el backbone, entrando en la fase de ajuste fino completo
    if epoch == 6:
        print("\n=== Etapa 2: descongelar el backbone y ajuste fino completo (Epoch 6-20) ===")
        for param in model.features.parameters():
            param.requires_grad = True

    train_loss, train_acc = train_epoch(model, train_loader, optimizer, criterion)
    val_loss,   val_acc   = eval_epoch(model,  val_loader,   criterion)
    scheduler.step()

    print(f"Epoch {epoch:2d}/{EPOCHS} | "
          f"Train Loss: {train_loss:.4f}, Acc: {train_acc:.4f} | "
          f"Val Loss: {val_loss:.4f}, Acc: {val_acc:.4f} | "
          f"LR: {scheduler.get_last_lr()[0]:.2e}")

    if val_acc > best_acc:
        best_acc = val_acc
        torch.save(model.state_dict(), 'best_model.pth')
        print(f" ✓ Guardar el mejor modelo Acc={best_acc:.4f}")

print(f"\nEntrenamiento completado, mejor precisión de validación: {best_acc:.4f}")

Inferencia y predicción

Ejemplo

from PIL import Image

# Cargar el mejor modelo
model.load_state_dict(torch.load('best_model.pth', map_location=DEVICE))
model.eval()

# Predecir una sola imagen
def predict(image_path, model, class_names):
    img = Image.open(image_path).convert('RGB')
    tensor = val_tfm(img).unsqueeze(0).to(DEVICE)  # (1, 3, 224, 224)

    with torch.inference_mode():
        logits = model(tensor)
        probs  = torch.softmax(logits, dim=1)[0]
        pred   = probs.argmax().item()

    for cls, prob in zip(class_names, probs.tolist()):
        print(f"  {cls}: {prob:.4f}")
    print(f"Resultado de la predicción: {class_names[pred]}")
    return class_names[pred]

class_names = train_set.dataset.classes  # ['cat', 'dog']
predict('test_cat.jpg', model, class_names)

7. Práctica completa: clasificación de texto (BERT)

Ejemplo

# pip install transformers datasets
import torch
import torch.nn as nn
from torch.utils.data import Dataset, DataLoader
from transformers import (
    BertTokenizer,
    BertForSequenceClassification,
    AdamW,
    get_linear_schedule_with_warmup,
)

DEVICE      = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
MODEL_NAME  = 'bert-base-chinese'
NUM_CLASSES = 3       # Clasificación de sentimiento: positivo/neutral/negativo
MAX_LEN     = 128
BATCH_SIZE  = 16
EPOCHS      = 5
LR          = 2e-5    # Rango de tasa de aprendizaje recomendado para el ajuste fino de BERT: 1e-5 ~ 5e-5

# Dataset personalizado
class SentimentDataset(Dataset):
    def __init__(self, texts, labels, tokenizer, max_len):
        self.texts     = texts
        self.labels    = labels
        self.tokenizer = tokenizer
        self.max_len   = max_len

    def __len__(self):
        return len(self.texts)

    def __getitem__(self, idx):
        encoding = self.tokenizer(
            self.texts[idx],
            max_length=self.max_len,
            padding='max_length',
            truncation=True,
            return_tensors='pt',
        )
        return {
            'input_ids':      encoding['input_ids'].squeeze(0),
            'attention_mask': encoding['attention_mask'].squeeze(0),
            'label':          torch.tensor(self.labels[idx], dtype=torch.long),
        }

# Cargar el modelo preentrenado BERT
tokenizer = BertTokenizer.from_pretrained(MODEL_NAME)
model     = BertForSequenceClassification.from_pretrained(
    MODEL_NAME,
    num_labels=NUM_CLASSES,
    hidden_dropout_prob=0.1,
)
model = model.to(DEVICE)

# Congelar las N capas inferiores (opcional)
# BERT-base tiene 12 capas Transformer; se pueden congelar las primeras capas para ahorrar cómputo
FREEZE_LAYERS = 6   # Congelar las primeras 6 capas

for i, layer in enumerate(model.bert.encoder.layer):
    if i < FREEZE_LAYERS:
        for param in layer.parameters():
            param.requires_grad = False

print(f"Se han congelado las primeras {FREEZE_LAYERS} capas, reduciendo aproximadamente {FREEZE_LAYERS/12*100:.0f}% de las actualizaciones de parámetros")

# Carga de datos
# Datos de ejemplo (reemplazar por un conjunto de datos real)
train_texts  = ["¡Esta película es fantástica!", "El servicio es muy malo, decepcionante", "Normal, nada especial"]
train_labels = [2, 0, 1]  # 0: negativo 1: neutral 2: positivo

train_dataset = SentimentDataset(train_texts, train_labels, tokenizer, MAX_LEN)
train_loader  = DataLoader(train_dataset, BATCH_SIZE, shuffle=True)

# Optimizador y scheduler
# Configuración estándar de BERT: AdamW + warmup lineal
no_decay = ['bias', 'LayerNorm.weight']
optimizer_grouped = [
    {'params': [p for n, p in model.named_parameters()
                if not any(nd in n for nd in no_decay)], 'weight_decay': 0.01},
    {'params': [p for n, p in model.named_parameters()
                if     any(nd in n for nd in no_decay)], 'weight_decay': 0.0},
]
optimizer = AdamW(optimizer_grouped, lr=LR)

total_steps   = len(train_loader) * EPOCHS
warmup_steps  = int(total_steps * 0.1)   # El 10% de los pasos se usa para warmup
scheduler = get_linear_schedule_with_warmup(
    optimizer,
    num_warmup_steps=warmup_steps,
    num_training_steps=total_steps,
)

# Bucle de entrenamiento
for epoch in range(1, EPOCHS + 1):
    model.train()
    total_loss, correct = 0.0, 0

    for batch in train_loader:
        input_ids      = batch['input_ids'].to(DEVICE)
        attention_mask = batch['attention_mask'].to(DEVICE)
        labels         = batch['label'].to(DEVICE)

        optimizer.zero_grad()
        outputs = model(input_ids=input_ids,
                        attention_mask=attention_mask,
                        labels=labels)

        loss   = outputs.loss           # BERT ya calcula la CE Loss internamente
        logits = outputs.logits

        loss.backward()
        # Configuración estándar del ajuste fino de BERT: recorte de gradientes (gradient clipping) para evitar explosión de gradientes
        nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
        optimizer.step()
        scheduler.step()

        total_loss += loss.item()
        correct    += (logits.argmax(1) == labels).sum().item()

    avg_loss = total_loss / len(train_loader)
    acc      = correct / len(train_dataset)
    print(f"Epoch {epoch}/{EPOCHS} | Loss: {avg_loss:.4f} | Acc: {acc:.4f}")

# Inferencia
def predict_sentiment(text, model, tokenizer, id2label):
    model.eval()
    encoding = tokenizer(text, max_length=MAX_LEN, padding='max_length',
                         truncation=True, return_tensors='pt')
    with torch.inference_mode():
        outputs = model(
            input_ids      = encoding['input_ids'].to(DEVICE),
            attention_mask = encoding['attention_mask'].to(DEVICE),
        )
    pred = outputs.logits.argmax(1).item()
    return id2label[pred]

id2label = {0: 'negativo', 1: 'neutral', 2: 'positivo'}
print(predict_sentiment("¡La calidad de este producto es realmente buena!", model, tokenizer, id2label))

8. Técnicas para modificar la estructura del modelo

Método general para reemplazar el clasificador

Ejemplo

import torch.nn as nn
import torchvision.models as models

def build_transfer_model(arch, num_classes, pretrained=True, dropout=0.5):
    """
Función genérica de construcción de modelos de transferencia que identifica y reemplaza automáticamente la cabeza de clasificación
Soporta: resnet, efficientnet, densenet, mobilenet, vit, convnext
    """

    weights = 'IMAGENET1K_V1' if pretrained else None
    model   = getattr(models, arch)(weights=weights)
    name    = arch.lower()

    if 'resnet' in name or 'resnext' in name or 'inception' in name:
        in_f = model.fc.in_features
        model.fc = nn.Sequential(
            nn.Dropout(dropout),
            nn.Linear(in_f, num_classes)
        )

    elif 'efficientnet' in name or 'mobilenet' in name or 'convnext' in name:
        in_f = model.classifier[-1].in_features
        model.classifier[-1] = nn.Sequential(
            nn.Dropout(dropout),
            nn.Linear(in_f, num_classes)
        )

    elif 'densenet' in name:
        in_f = model.classifier.in_features
        model.classifier = nn.Linear(in_f, num_classes)

    elif 'vit' in name or 'swin' in name:
        in_f = model.heads.head.in_features
        model.heads.head = nn.Linear(in_f, num_classes)

    else:
        raise ValueError(f"Arquitectura no soportada: {arch}")

    return model


# Ejemplo de uso
model_resnet  = build_transfer_model('resnet50',        num_classes=10)
model_effnet  = build_transfer_model('efficientnet_b3', num_classes=10)
model_vit     = build_transfer_model('vit_b_16',        num_classes=10)

Añadir capas intermedias de extracción de características

Ejemplo

import torch
import torch.nn as nn
import torchvision.models as models

class TransferWithAttention(nn.Module):
    """Conectar un módulo de atención personalizado y una cabeza de clasificación después del backbone preentrenado"""
    def __init__(self, num_classes, dropout=0.5):
        super().__init__()
        backbone = models.resnet50(weights='IMAGENET1K_V2')

        # Eliminar la cabeza de clasificación original, conservar el extractor de características
        self.backbone  = nn.Sequential(*list(backbone.children())[:-1])
        self.feat_dim  = 2048   # Dimensión de características de ResNet50

        # Compuerta de atención personalizada (attention gating)
        self.attention = nn.Sequential(
            nn.Linear(self.feat_dim, 512),
            nn.Tanh(),
            nn.Linear(512, 1),
            nn.Sigmoid()
        )

        # Cabeza de clasificación
        self.classifier = nn.Sequential(
            nn.Dropout(dropout),
            nn.Linear(self.feat_dim, 256),
            nn.ReLU(),
            nn.Linear(256, num_classes),
        )

    def forward(self, x):
        feat   = self.backbone(x).flatten(1)          # (N, 2048)
        weight = self.attention(feat)                  # (N, 1)
        feat   = feat * weight                         # Características ponderadas
        return self.classifier(feat)

9. Mejores prácticas en transferencia de aprendizaje

Selección de la tasa de aprendizaje

Ejemplo

# Tasa de aprendizaje del backbone (capas preentrenadas)
# Normalmente se establece entre 1/10 y 1/100 de la tasa de aprendizaje de la cabeza
backbone_lr = 1e-5   # Estrategia conservadora, recomendada cuando hay pocos datos
head_lr     = 1e-3   # Las capas nuevas comienzan con inicialización aleatoria y necesitan una tasa de aprendizaje mayor

# Tasa de aprendizaje para Transformers como BERT / ViT
# Estos modelos grandes son extremadamente sensibles a la tasa de aprendizaje; salirse del rango puede dañar el conocimiento preentrenado
bert_lr = 2e-5        # Rango recomendado: 1e-5 ~ 5e-5

Problemas comunes y soluciones

Ejemplo

# Problema 1: memoria de video insuficiente (VRAM)
# Solución 1: reducir el batch size
# Solución 2: usar gradient checkpointing (intercambiar tiempo por memoria)
from torch.utils.checkpoint import checkpoint_sequential
model.features = lambda x: checkpoint_sequential(model.features, 4, x)

# Solución 3: congelar más capas (reducir el cómputo de retropropagación)

# Problema 2: alta precisión en entrenamiento, baja en validación (sobreajuste)
# Solución 1: aumentar el aumento de datos
# Solución 2: aumentar el Dropout
model.classifier = nn.Sequential(nn.Dropout(0.5), nn.Linear(in_f, num_classes))
# Solución 3: usar Label Smoothing (suavizado de etiquetas)
criterion = nn.CrossEntropyLoss(label_smoothing=0.1)
# Solución 4: congelar más capas del backbone, reducir parámetros entrenables

# Problema 3: entrenamiento inestable, fluctuación de la pérdida (Loss)
# Solución 1: reducir la tasa de aprendizaje (probar reduciéndola a la mitad)
# Solución 2: añadir warmup de calentamiento
# Solución 3: recorte de gradientes (gradient clipping)
nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
# Solución 4: cambiar de optimizador (Adam → AdamW)

# Problema 4: parámetros de normalización no coinciden
# Error: usar normalización personalizada no coincide con el preentrenamiento
transforms.Normalize([0.5, 0.5, 0.5], [0.5, 0.5, 0.5])
# Correcto: se deben usar la media y la desviación estándar de ImageNet
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])

Referencia rápida de estrategias generales

Problema → ProblemaPráctica recomendada → Práctica recomendada
Cantidad de datos < 500Congelar todas las capas, entrenar solo la cabeza de clasificación
Cantidad de datos 500~2000Congelar los primeros 2/3, ajustar finamente el último 1/3
Cantidad de datos 2000~10000 → Cantidad de datos 2000~10000Ajuste fino completo, columna vertebral con 1e-5, cabeza con 1e-3
Cantidad de datos > 10000Ajuste fino completo, considerar una tasa de aprendizaje mayor o entrenar desde cero
Entrenamiento lento → Entrenamiento lentoPrimero congelar y entrenar algunas épocas, luego descongelar; usar precisión mixta AMP
Cuello de botella de rendimientoCambiar a un backbone más grande/nuevo; probar ConvNeXt / ViT
El modelo necesita implementarse en producciónPriorizar modelos ligeros como EfficientNet-B0 / MobileNetV3
Tareas de PLN en chinoBERT-base-chinese o chinese-roberta-wwm-ext

Estrategia de entrenamiento completa recomendada

Primera fase (Epoch 1~N/4):

  • Congelar la columna vertebral, entrenar solo la cabeza de clasificación
  • Usar una tasa de aprendizaje mayor (1e-3) para converger rápidamente los parámetros de la cabeza

Segunda fase (Epoch N/4~N):

  • Descongelar la columna vertebral y ajustar finamente todo el modelo
  • Columna vertebral con tasa de aprendizaje pequeña (1e-5), cabeza mantiene (1e-3)
  • Combinar con recocido de coseno o ReduceLROnPlateau

Estrategia de guardado:

  • Guardar solo el checkpoint con mejor métrica en el conjunto de validación
  • Guardar también el estado de model / optimizer / scheduler
Otras extensiones