Hugging Face · LLM Course · Capítulo 3
Fine-tuning: de usar un modelo a entrenarlo vos
Todo lo del Capítulo 2 asumía un modelo ya entrenado. Acá lo entrenás vos — primero con la forma clásica (ajustar todos los pesos), después con la que de verdad vas a usar en 2026: tocar una fracción mínima de ellos.
0. De usar un modelo a entrenarlo
El ejemplo del capítulo original sigue siendo el correcto para aprender la mecánica: tomar BERT (un encoder-only, Capítulo 1) y ajustarlo para una tarea concreta — detectar si dos frases dicen lo mismo (el dataset MRPC, 5.801 pares de oraciones). Es chico, rápido de entrenar, y expone cada pieza del proceso sin la complejidad de un LLM de 30B parámetros de por medio.
Vas a ver tres formas de llegar al mismo resultado, de más alto nivel a más bajo nivel:
Trainer API
Unas pocas líneas, la librería maneja el resto — lo que usás el 95% del tiempo.
Training loop manual
Cada paso explícito en PyTorch puro — para cuando necesitás control que la API no te da.
🤗 Accelerate
El mismo loop manual, portable a multi-GPU/TPU con casi ningún cambio de código.
1. Preparar los datos
Con datasets (la librería, no el concepto) cargar un dataset del Hub es una línea:
from datasets import load_dataset
raw_datasets = load_dataset("glue", "mrpc")
# DatasetDict con splits train (3668) / validation (408) / test (1725)
Tokenizar un par de oraciones (no una sola, como en el Capítulo 2) agrega un campo nuevo:
inputs = tokenizer("Esta es la primera oración.", "Esta es la segunda.")
# input_ids, attention_mask, y ahora también token_type_ids:
# [0,0,0,0,0,0,0,0, 1,1,1,1,1,1,1] — 0 = primera oración, 1 = segunda
token_type_ids le dice al modelo dónde termina una oración y empieza la otra — BERT lo
necesita porque se preentrenó con un objetivo llamado next sentence prediction (predecir si
una oración sigue a otra en el texto original).
🟢 Actualización 2026
token_type_ids es una particularidad de BERT y su familia — ni DistilBERT lo devuelve.
Los LLM decoder-only de hoy (Capítulo 2) no tienen equivalente: la noción de "dos segmentos" la
resuelve el chat template, con roles (system/user/
assistant) en vez de un ID binario de segmento. Es la misma necesidad — decirle al
modelo qué parte del input es qué — resuelta de forma completamente distinta una generación después.
Para tokenizar el dataset completo sin cargarlo todo en RAM, Dataset.map(batched=True)
aplica la función en lotes, aprovechando que el tokenizer de tokenizers está escrito en
Rust y paraleliza internamente:
def tokenize_function(example):
return tokenizer(example["sentence1"], example["sentence2"], truncation=True)
tokenized_datasets = raw_datasets.map(tokenize_function, batched=True)
Notá que acá no se pasa padding=True. La razón es padding dinámico:
rellenar cada lote hasta su propio máximo (no el máximo de todo el dataset) ahorra cómputo real cuando
las longitudes varían mucho. DataCollatorWithPadding hace ese relleno al momento de armar
cada batch, no antes.
2. Fine-tunear con la Trainer API
Con los datos listos, definir el entrenamiento son tres objetos:
from transformers import TrainingArguments, AutoModelForSequenceClassification, Trainer
training_args = TrainingArguments("test-trainer", eval_strategy="epoch")
model = AutoModelForSequenceClassification.from_pretrained(checkpoint, num_labels=2)
trainer = Trainer(
model, training_args,
train_dataset=tokenized_datasets["train"],
eval_dataset=tokenized_datasets["validation"],
data_collator=data_collator,
processing_class=tokenizer,
compute_metrics=compute_metrics,
)
trainer.train()
num_labels=2 importa: BERT nunca vio esta tarea en su preentrenamiento, así que
AutoModelForSequenceClassification descarta el head original y le pone uno nuevo,
inicializado al azar — el warning que ves al cargar el modelo es exactamente eso, no un error.
compute_metrics es la función que convierte logits en accuracy/F1 usando la librería
evaluate; sin ella, Trainer solo te va a reportar la loss.
Tres flags de TrainingArguments que valen la pena conocer de memoria:
| Flag | Qué hace |
|---|---|
fp16=True | Entrena en precisión mixta (16 bits) — más rápido, menos memoria, casi sin pérdida de calidad |
gradient_accumulation_steps=N | Acumula gradientes de N batches chicos antes de actualizar — simula un batch N veces más grande sin más VRAM |
lr_scheduler_type="cosine" | Cambia cómo decae el learning rate durante el entrenamiento (el default es lineal) |
3. Un training loop a mano
Todo lo que Trainer hace por vos, en PyTorch puro, es esto — vale la pena leerlo una vez
para saber qué se esconde detrás del .train():
model.train()
for epoch in range(num_epochs):
for batch in train_dataloader:
batch = {k: v.to(device) for k, v in batch.items()}
outputs = model(**batch)
loss = outputs.loss
loss.backward()
optimizer.step()
lr_scheduler.step()
optimizer.zero_grad()
Cinco pasos, siempre en este orden: forward (calcular la salida y la loss), backward (propagar el gradiente), optimizer.step() (actualizar pesos con ese gradiente), scheduler.step() (ajustar el learning rate), zero_grad() (limpiar el gradiente para el próximo batch — si te olvidás este paso, los gradientes se acumulan silenciosamente entre batches).
El optimizador es AdamW — Adam con weight decay desacoplado, el estándar para
entrenar transformers. Para correr el mismo loop en varias GPUs sin reescribirlo, Accelerate
envuelve los mismos objetos:
from accelerate import Accelerator
accelerator = Accelerator()
train_dl, eval_dl, model, optimizer = accelerator.prepare(train_dataloader, eval_dataloader, model, optimizer)
# reemplazar loss.backward() por accelerator.backward(loss), nada más
4. Leer las curvas de entrenamiento
Un número de loss aislado no te dice nada — la forma de la curva en el tiempo sí. Esta parte no estaba en el curso original de 2022; se agregó porque interpretar mal una curva es de los errores más comunes y más caros (entrenás horas para nada, o peor, publicás un modelo roto).
| Patrón | Síntoma | Qué probar |
|---|---|---|
| Overfitting | Loss de train sigue bajando, la de validación sube o se estanca; accuracy de train mucho más alta que la de validación | Early stopping, más regularización (dropout, weight decay), más datos, un modelo más chico |
| Underfitting | Ambas losses se quedan altas y se estancan temprano | Más capacidad, más épocas, ajustar el learning rate, revisar la calidad de los datos |
| Errática | Fluctuaciones fuertes, sin tendencia clara | Bajar el learning rate, subir el batch size, gradient clipping |
Un detalle que confunde la primera vez: la curva de accuracy avanza en escalones, no suave como la loss. La loss puede mejorar aunque la predicción siga siendo incorrecta (0.3 → 0.4 en un clasificador binario sigue siendo "0"); la accuracy solo salta cuando la predicción cruza el umbral que la vuelve correcta.
5. Lo que cambió: fine-tuning completo ya no es la norma
Todo lo de arriba ajusta los 110 millones de parámetros de BERT, todos. Para un modelo de ese tamaño está perfecto — es rápido, es simple, y no hay ninguna razón para complicarlo. Para un LLM de 8B, 30B o 70B parámetros, hacer lo mismo significa guardar en memoria el estado del optimizador (Adam guarda dos números extra por parámetro) para cada uno de esos parámetros — varias veces el tamaño del modelo, solo en overhead de entrenamiento.
LoRA (Low-Rank Adaptation) ataca esto desde otro ángulo: en vez de tocar la matriz de
pesos original W, la congelás, y entrenás dos matrices mucho más chicas
(A y B) cuyo producto se suma a la salida de W. El
modelo base nunca cambia; lo único que se entrena y se guarda es ese par de matrices de bajo rango —
típicamente menos del 1% de los parámetros originales.
from peft import LoraConfig, get_peft_model
config = LoraConfig(r=16, lora_alpha=32, target_modules=["q_proj", "v_proj"])
model = get_peft_model(base_model, config)
model.print_trainable_parameters()
# trainable params: 4,194,304 || all params: 7,000,000,000 || trainable%: 0.06%
🟢 Esto ya lo viste correr en tus propios proyectos
El ciclo generacional de interpretante-lacaniano
entrena exactamente así: CPT inicial sobre el corpus, y después cada generación se ajusta con LoRA
vía Unsloth sobre la producción curada — nunca se reentrena el modelo de 31B completo. Y en
la página de NVFP4 en difusión
viste el caso límite: cuando el modelo base ya está cuantizado a 4 bits, ni siquiera podés sumarle
A·B a los pesos — hay que fusionar esa suma dentro del kernel en cada forward, porque no
existe un "peso intermedio" al que sumarle algo en un formato de 16 valores por bloque.
QLoRA combina las dos ideas: el modelo base se carga cuantizado (originalmente en NF4, un formato de 4 bits distinto al NVFP4 de Blackwell que ya conocés) y congelado, y las matrices LoRA se entrenan encima en precisión más alta — la razón por la que hoy podés fine-tunear un modelo de 70B en una sola GPU de consumo.
| Fine-tuning completo | LoRA | QLoRA | |
|---|---|---|---|
| Parámetros entrenados | 100% | <1% | <1% |
| Peso base | Se modifica | Congelado | Congelado y cuantizado (4 bits) |
| Mejor para | Modelos chicos, máxima calidad | LLMs grandes, hardware limitado | LLMs grandes, hardware muy limitado |
Nada de esto invalida las secciones anteriores — Trainer, el training loop manual, y las
curvas de aprendizaje funcionan exactamente igual con LoRA. Lo único que cambia es qué parámetros
tienen requires_grad=True.
6. Resumen
- Fine-tuning parte de datos tokenizados en lotes con padding dinámico — nunca rellenes al máximo del dataset entero si podés rellenar al máximo del batch.
Trainerresuelve el 95% de los casos en pocas líneas; el training loop manual existe para cuando necesitás control fino, yAcceleratelo escala a multi-GPU casi gratis.- Las curvas de loss y accuracy tienen formas reconocibles — overfitting, underfitting, entrenamiento errático — cada una con su propio remedio.
- Ajustar el 100% de los parámetros es la excepción hoy, no la regla: LoRA y QLoRA son el camino por defecto para cualquier LLM que no te entre cómodo en memoria para un fine-tune completo — y ya los viste corriendo en tus propios proyectos.