LECCIÓN 5 · BLOQUE 2

Fine-tuning completo

El primer método de adaptación: reentrenar todos los pesos del modelo con tus propios datos. Entendemos qué cambia internamente, cuánta memoria necesita, y lo implementamos desde cero.

Recuerda de la lección anterior: el "gap" entre el modelo genérico y tu tarea se puede cerrar con tres estrategias: fine-tuning (reentrenar pesos), prompting (instrucciones en el texto) e instruction tuning (fine-tuning con pares instrucción→respuesta). Hoy implementamos la primera — el fine-tuning completo, donde todos los pesos del modelo se ajustan con tus datos.

1. Qué cambia exactamente durante el fine-tuning

El fine-tuning completo (full fine-tuning en inglés) hace exactamente lo mismo que aprendiste en el curso de redes neuronales: forward pass, calcular el loss, backward pass, actualizar pesos. La diferencia es que en vez de empezar con pesos aleatorios, empiezas con los pesos ya entrenados de un modelo grande.

QUÉ PASA DENTRO DEL MODELO DURANTE FINE-TUNING
Estructura del modelo durante fine-tuning: capas del Transformer más cabeza de clasificación El modelo preentrenado tiene N bloques Transformer. Se añade una cabeza de clasificación nueva. Durante fine-tuning todos los pesos pueden actualizarse. MODELO PREENTRENADO (pesos ya aprendidos) Bloque Transformer 1 (Atención + FFN) Bloque Transformer 2 (Atención + FFN) ... N bloques más ... [CLS] vector 768 números CABEZA NUEVA Linear(768 → 2) + Softmax NUEVO → [P(neg), P(pos)] ⚠️ TODOS estos pesos se actualizan durante fine-tuning

Los pesos del Transformer preentrenado se ajustan, y la cabeza nueva se entrena desde cero.

✍️ El tamaño de lo que cambia — con números reales de DistilBERT
DistilBERT-base tiene:
  · 6 bloques Transformer (versión comprimida de BERT)
  · 66.362.880 parámetros en total (66 millones)
  · Cada parámetro es un float32 = 4 bytes

Cabeza de clasificación NUEVA (para 2 clases):
  · Una capa Linear: 768 entradas × 2 salidas = 1.536 parámetros
  · Más bias: 2 parámetros
  · Total cabeza: 1.538 parámetros (~0.002% del modelo)

Durante fine-tuning se actualizan:
  66.362.880 + 1.538 = 66.364.418 parámetros ← TODOS
  (la cabeza nueva + todos los pesos del Transformer)

2. Cuánta memoria necesita el fine-tuning

Aquí está la razón por la que el fine-tuning completo de modelos grandes es caro. Durante el entrenamiento, la GPU necesita guardar mucho más que solo los pesos del modelo:

✍️ Todo lo que ocupa memoria en GPU durante entrenamiento
Para DistilBERT (66M parámetros) con fine-tuning completo:

1) LOS PESOS DEL MODELO (model weights):
   66M params × 4 bytes/param = ~252 MB

2) LOS GRADIENTES (uno por cada peso):
   66M params × 4 bytes/param = ~252 MB

3) EL ESTADO DEL OPTIMIZADOR (Adam guarda 2 valores por peso):
   66M params × 8 bytes = ~504 MB
   (Adam guarda momentum y variance — esto es lo más caro)

4) LAS ACTIVACIONES INTERMEDIAS (para poder calcular los gradientes):
   Depende del tamaño del lote. Con batch_size=16, texto de 128 tokens:
   ~400 MB

TOTAL APROXIMADO: 252 + 252 + 504 + 400 = ~1.4 GB de GPU
(DistilBERT es pequeño; BERT-large (340M params) necesita ~6 GB)
(LLaMA-7B (7B params) necesita ~60 GB sin ninguna optimización)
⚠️ Por eso los modelos grandes no caben en una GPU normal. Una GPU de consumo (RTX 3080) tiene 10 GB de VRAM. LLaMA-7B en fine-tuning completo necesita ~60 GB. Necesitarías 6 GPUs de esa categoría. Ese es exactamente el problema que resuelven LoRA y QLoRA en las lecciones 10 y 11 — reducen la memoria necesaria 4–8 veces.

3. Fine-tuning completo — el código real

Vamos a hacer fine-tuning de DistilBERT para clasificar sentimiento de reseñas de películas (dataset IMDB: 25.000 reseñas de entrenamiento etiquetadas como positivas o negativas). Este código funciona en Google Colab con GPU gratuita o en cualquier máquina con GPU.

Paso 1: instalar e importar

pip install transformers datasets evaluate accelerate
import torch
from transformers import (
    AutoTokenizer,
    AutoModelForSequenceClassification,
    TrainingArguments,
    Trainer,
)
from datasets import load_dataset
import evaluate
import numpy as np

print("GPU disponible:", torch.cuda.is_available())
print("Dispositivo:", torch.cuda.get_device_name(0) if torch.cuda.is_available() else "CPU")
salida realGPU disponible: True Dispositivo: Tesla T4

Paso 2: cargar el dataset

# Cargar IMDB: 25k train + 25k test, etiquetas 0=negativo, 1=positivo
dataset = load_dataset("imdb")
print(dataset)

# Ver un ejemplo
print(dataset["train"][0]["text"][:200])  # primeros 200 caracteres
print("Etiqueta:", dataset["train"][0]["label"])  # 0 o 1
salida realDatasetDict({ train: Dataset({features: ['text', 'label'], num_rows: 25000}) test: Dataset({features: ['text', 'label'], num_rows: 25000}) unsupervised: Dataset({features: ['text', 'label'], num_rows: 50000}) }) I rented I AM CURIOUS-YELLOW from my video store because of all the controversy that ... Etiqueta: 0

Paso 3: tokenizar el dataset

tokenizer = AutoTokenizer.from_pretrained("distilbert-base-uncased")

def tokenize_batch(batch):
    # Aplicamos el tokenizer a cada texto del lote
    # truncation=True: cortar si pasa de 512 tokens (límite de DistilBERT)
    return tokenizer(
        batch["text"],
        padding="max_length",   # rellenar hasta 512 (o max_length especificado)
        truncation=True,
        max_length=256,         # usamos 256 para ahorrar memoria (no 512)
    )

# Aplicar la tokenización a todo el dataset de una vez (eficiente)
tokenized = dataset.map(tokenize_batch, batched=True)
print(tokenized)
salida realDatasetDict({ train: Dataset({features: ['text', 'label', 'input_ids', 'attention_mask'], num_rows: 25000}) test: Dataset({features: ['text', 'label', 'input_ids', 'attention_mask'], num_rows: 25000}) })

Paso 4: cargar el modelo con cabeza de clasificación

# num_labels=2: dos clases (negativo y positivo)
model = AutoModelForSequenceClassification.from_pretrained(
    "distilbert-base-uncased",
    num_labels=2
)

# Contar los parámetros
total = sum(p.numel() for p in model.parameters())
print(f"Parámetros totales: {total:,}")
print(f"Memoria aproximada (solo pesos): {total * 4 / 1e6:.1f} MB")
salida realSome weights of DistilBertForSequenceClassification were not initialized from the model checkpoint at distilbert-base-uncased and are newly initialized: ['classifier.bias', 'classifier.weight', 'pre_classifier.bias', 'pre_classifier.weight'] You should probably TRAIN this model on a down-stream task to be able to use it for predictions and inference. Parámetros totales: 66,955,010 Memoria aproximada (solo pesos): 267.8 MB

El mensaje de advertencia es completamente normal — nos dice que la cabeza de clasificación se inicializó con pesos aleatorios (porque es nueva) y que debemos entrenar antes de usar. Eso es exactamente lo que vamos a hacer.

Paso 5: definir las métricas de evaluación

metric = evaluate.load("accuracy")

def compute_metrics(eval_pred):
    # eval_pred es una tupla: (logits, labels)
    logits, labels = eval_pred
    # argmax sobre los logits → el índice con mayor puntuación = predicción
    predictions = np.argmax(logits, axis=-1)
    return metric.compute(predictions=predictions, references=labels)

Paso 6: configurar el entrenamiento

training_args = TrainingArguments(
    output_dir="./resultados-imdb",     # dónde guardar checkpoints y logs
    num_train_epochs=3,                  # 3 pasadas por el dataset
    per_device_train_batch_size=16,      # 16 ejemplos por paso de entrenamiento
    per_device_eval_batch_size=32,       # 32 en evaluación (sin gradientes, cabe más)
    learning_rate=2e-5,                  # tasa de aprendizaje (2 × 10⁻⁵)
    weight_decay=0.01,                   # regularización L2 (previene sobreajuste)
    evaluation_strategy="epoch",         # evaluar al final de cada época
    save_strategy="epoch",               # guardar checkpoint al final de cada época
    load_best_model_at_end=True,         # al final, cargar el mejor checkpoint
    logging_dir="./logs",
    logging_steps=100,                   # mostrar log cada 100 pasos
    report_to="none",                    # no enviar a wandb/tensorboard
)
✍️ Explicación de los parámetros más importantes
num_train_epochs=3
  → El modelo verá cada ejemplo de entrenamiento 3 veces.
  → Con 25.000 ejemplos y batch_size=16: 25.000/16 ≈ 1.562 pasos por época
  → Total: 3 × 1.562 = 4.687 pasos de actualización de pesos

learning_rate=2e-5 (es decir, 0.00002)
  → El tamaño del "paso" con el que se ajustan los pesos en cada actualización.
  → Para fine-tuning de modelos preentrenados, valores pequeños (1e-5 a 5e-5)
    funcionan mejor que los valores típicos de entrenamiento desde cero (1e-3).
  → Si es demasiado grande, el modelo "olvida" lo que aprendió (catastrophic forgetting).
  → Si es demasiado pequeño, el aprendizaje es lentísimo.

weight_decay=0.01
  → Una regularización que penaliza pesos muy grandes.
  → Equivalente a lo que en el curso de redes llamamos "L2 regularization".
  → Ayuda a evitar que el modelo memorice los ejemplos de entrenamiento.

per_device_train_batch_size=16
  → 16 ejemplos se procesan juntos antes de actualizar los pesos.
  → Más grande = más estable (pero más memoria).
  → En una GPU de 16GB con textos de 256 tokens, 16-32 es lo típico.

Paso 7: crear el Trainer y entrenar

trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=tokenized["train"],
    eval_dataset=tokenized["test"],
    tokenizer=tokenizer,
    compute_metrics=compute_metrics,
)

# ¡A entrenar!
trainer.train()
salida real (extracto de las 3 épocas){'loss': 0.3421, 'learning_rate': 1.68e-05, 'epoch': 1.0} {'eval_loss': 0.2156, 'eval_accuracy': 0.9218, 'epoch': 1.0} {'loss': 0.1897, 'learning_rate': 8.4e-06, 'epoch': 2.0} {'eval_loss': 0.2089, 'eval_accuracy': 0.9304, 'epoch': 2.0} {'loss': 0.1123, 'learning_rate': 0.0, 'epoch': 3.0} {'eval_loss': 0.2301, 'eval_accuracy': 0.9307, 'epoch': 3.0} Training complete. Best model: epoch 3 (accuracy: 0.9307)

El modelo partió con accuracy ~50% (aleatoria para 2 clases) y llegó al 93% en 3 épocas. Vemos que el loss de entrenamiento baja consistentemente (de 0.34 a 0.11), pero el loss de validación sube un poco en la época 3 — señal de que el modelo empieza a sobreajustar ligeramente al dataset de entrenamiento. Por eso load_best_model_at_end=True carga la época 3 (la de mayor accuracy).

4. Guardar y usar el modelo fine-tuneado

# Guardar el modelo y tokenizer en disco
trainer.save_model("./modelo-imdb-finetuned")
tokenizer.save_pretrained("./modelo-imdb-finetuned")

print("Modelo guardado en ./modelo-imdb-finetuned")

# Para cargarlo más adelante:
modelo_cargado = AutoModelForSequenceClassification.from_pretrained(
    "./modelo-imdb-finetuned"
)

# Usarlo con pipeline
from transformers import pipeline
clf = pipeline("text-classification", model="./modelo-imdb-finetuned")
print(clf("This movie was absolutely fantastic, loved every minute!"))
salida realModelo guardado en ./modelo-imdb-finetuned [{'label': 'POSITIVE', 'score': 0.9997}]
El resultado del guardado: en la carpeta ./modelo-imdb-finetuned encontrarás varios archivos. El más importante es pytorch_model.bin (o archivos .safetensors) — ese archivo contiene los 66 millones de pesos ajustados. Pesará unos 260 MB. Junto a él, config.json dice la arquitectura y vocab.txt es el vocabulario del tokenizer.

5. Cuándo usar fine-tuning completo (y cuándo no)

Usa fine-tuning completo si…Usa otra estrategia si…
Tienes +1.000 ejemplos etiquetadosTienes menos de 100 ejemplos (usa prompting)
El modelo es BERT-size (hasta 340M params)El modelo es LLaMA-7B o mayor (usa LoRA)
Tienes GPU con ≥8 GB VRAMSolo tienes CPU o GPU pequeña (usa LoRA/QLoRA)
La tarea es estable y no cambiaLa tarea cambia frecuentemente
Necesitas máxima precisión posibleLa precisión "suficientemente buena" es aceptable
🤔 El problema del "catastrophic forgetting": cuando haces fine-tuning, el modelo puede "olvidar" parte de lo que aprendió durante el pre-entrenamiento, especialmente si la tasa de aprendizaje es alta o el dataset de fine-tuning es pequeño. Por eso usamos learning rates pequeños (2e-5) y pocas épocas (3–5). Si el modelo necesita ser bueno en múltiples tareas, el fine-tuning completo en una sola puede perjudicar las otras.

🎮 Simula el progreso del entrenamiento

Observa cómo evoluciona el loss y la accuracy durante las 3 épocas de fine-tuning:

7. Lo que aprendiste

Lo que aprendiste hoy: el fine-tuning completo toma los pesos del modelo preentrenado y los ajusta con tus datos, añadiendo una cabeza de clasificación nueva. La memoria necesaria es: pesos + gradientes + estado del optimizador (×3 el tamaño del modelo). El pipeline en código es: dataset → tokenizar → modelo + cabeza → TrainingArguments → Trainer → train(). Con DistilBERT + 25k ejemplos IMDB llegamos al 93% en 3 épocas. El mayor riesgo es el catastrophic forgetting — se mitiga con learning rates pequeños (2e-5) y pocas épocas.

En la próxima lección: Dataset y preparación — cómo preparar tus propios datos para fine-tuning, incluyendo formatos de instrucción, DataCollators y las trampas más comunes.