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.
Antes de leercap. 2 §2, cargar modelos · fast.ai L3 §2, SGD a mano (para el loop de §3)
🎯 Al terminar vas a poder
- Preparar un dataset para fine-tuning de punta a punta: tokenizar con
map, rellenar por batch conDataCollatorWithPadding, y definir uncompute_metricsvectorizado. - Escribir el training loop completo en PyTorch — optimizador, scheduler, forward/backward, evaluación con
no_grad— y explicar la física de hardware detrás de cada llamada. - Demostrar matemáticamente por qué AdamW desacopla el weight decay frente al fallo de Adam L2, y calcular el desglose exacto de 16 bytes por parámetro en VRAM (pesos, gradientes, copia maestra FP32 y momentos).
- Leer curvas de entrenamiento diagnosticando overfitting, underfitting o divergencia numérica por learning rate o gradientes sin reiniciar.
- Derivar la mecánica matricial de LoRA y QLoRA: inicialización asimétrica , asociatividad de FLOPs, eliminación de estados de optimizador para la base congelada, y fusión sin latencia para producción.
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. Se construye con el tokenizer, porque necesita saber cuál es el
pad_token_id y de qué lado rellenar:
from transformers import DataCollatorWithPadding
data_collator = DataCollatorWithPadding(tokenizer=tokenizer)
# Probalo a mano con 8 ejemplos: cada uno mide distinto, el batch sale rectangular
samples = [tokenized_datasets["train"][i] for i in range(8)]
samples = [{k: v for k, v in s.items() if k not in ["idx", "sentence1", "sentence2"]} for s in samples]
batch = data_collator(samples)
{k: v.shape for k, v in batch.items()}
# {'input_ids': torch.Size([8, 67]), 'attention_mask': ..., 'token_type_ids': ..., 'labels': torch.Size([8])}
El 67 es el largo del ejemplo más largo de esos ocho — otro batch va a tener otro ancho. Fijate que las columnas de texto se sacan antes: el collator solo sabe rellenar tensores, y un string lo hace fallar.
2. Fine-tunear con la Trainer API
Falta una pieza antes de entrenar: la función que convierte las predicciones en una métrica. Sin ella,
Trainer solo reporta la loss, que sirve para ver si el entrenamiento avanza pero no te dice
cuántos pares clasificó bien. Trainer le pasa una tupla (logits, labels) sobre
todo el set de evaluación; hay que convertir los logits en una clase con argmax y comparar:
import evaluate
import numpy as np
metric = evaluate.load("glue", "mrpc") # accuracy + F1, las métricas oficiales de la tarea
def compute_metrics(eval_preds):
logits, labels = eval_preds
predictions = np.argmax(logits, axis=-1) # [n, 2] → [n]: la clase con mayor logit
return metric.compute(predictions=predictions, references=labels)
# → {'accuracy': 0.85..., 'f1': 0.89...} después de entrenar
Con los datos y la métrica 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.
Cuatro flags de TrainingArguments que definen el rendimiento y la viabilidad física en hardware:
| Flag | Mecánica de hardware y cómputo |
|---|---|
bf16=True / fp16=True | Entrena en precisión mixta (16 bits) utilizando los Tensor Cores de la GPU (2x a 4x throughput de GEMM frente a FP32). En fp16 se requiere GradScaler para mitigar el subdesbordamiento (underflow) de gradientes menores a o desbordamiento (overflow) por encima de 65.504 escalando la loss por un factor . En bf16, al conservar 8 bits de exponente (igual que FP32, hasta ), no se necesita escalador dinámico alguno. |
gradient_accumulation_steps=N | Permite simular un batch efectivo sin multiplicar la memoria de activaciones. Cada micro-batch ejecuta forward y backward acumulando matemáticamente el gradiente en p.grad += \nabla \mathcal{L}_{\mu} / N. El optimizador solo ejecuta step() y zero_grad() cada iteraciones. |
gradient_checkpointing=True | Activation recomputation: en vez de almacenar en VRAM todos los tensores intermedios de activación de cada capa durante el forward pass para el backward, los descarta tras usarlos y los recalcula sobre la marcha en el backward pass. Reduce el consumo de VRAM de activaciones de a a costa de únicamente un 33% adicional de FLOPs en cómputo. |
lr_scheduler_type="cosine" | Ajusta la tasa de aprendizaje siguiendo una curva de decaimiento cosenoidal tras un periodo inicial de warmup, permitiendo una convergencia suave hacia los valles del paisaje de pérdida sin oscilaciones caóticas. |
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(). Primero la preparación, que es donde
Trainer esconde más trabajo: dejar el dataset con solo las columnas que el modelo acepta,
armar los DataLoader con el collator, y crear el optimizador y el scheduler:
import torch
from torch.optim import AdamW
from torch.utils.data import DataLoader
from transformers import get_scheduler
# El modelo espera input_ids, attention_mask, token_type_ids y labels — nada más
tokenized_datasets = tokenized_datasets.remove_columns(["sentence1", "sentence2", "idx"])
tokenized_datasets = tokenized_datasets.rename_column("label", "labels")
tokenized_datasets.set_format("torch")
train_dataloader = DataLoader(tokenized_datasets["train"], shuffle=True, batch_size=8, collate_fn=data_collator)
eval_dataloader = DataLoader(tokenized_datasets["validation"], batch_size=8, collate_fn=data_collator)
model = AutoModelForSequenceClassification.from_pretrained(checkpoint, num_labels=2)
optimizer = AdamW(model.parameters(), lr=5e-5)
num_epochs = 3
num_training_steps = num_epochs * len(train_dataloader) # 3 × 459 = 1377 pasos
lr_scheduler = get_scheduler(
"linear", optimizer=optimizer, num_warmup_steps=0, num_training_steps=num_training_steps
) # el lr baja linealmente de 5e-5 a 0 a lo largo de los 1377 pasos
device = torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu")
model.to(device)
Y el loop en sí:
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).
.grad, el optimizador cambia los pesos, el scheduler cambia el
learning rate, y zero_grad() deja los gradientes en cero para que el próximo backward no
sume sobre los viejos.
La evaluación es el mismo recorrido sin las tres últimas líneas, y con dos cosas que
Trainer también hace sin que lo veas: model.eval() apaga el dropout (si no,
cada evaluación da un número distinto), y torch.no_grad() evita construir el grafo de
gradientes — más rápido y varias veces menos memoria:
metric = evaluate.load("glue", "mrpc")
model.eval()
for batch in eval_dataloader:
batch = {k: v.to(device) for k, v in batch.items()}
with torch.no_grad():
outputs = model(**batch)
predictions = torch.argmax(outputs.logits, dim=-1)
metric.add_batch(predictions=predictions, references=batch["labels"])
metric.compute() # {'accuracy': 0.85..., 'f1': 0.89...}
model.train() # si vas a seguir entrenando, volvé al modo train
El optimizador estándar universal para transformers es AdamW (Loshchilov & Hutter, 2017).
Para entender por qué Adam clásico falla y por qué AdamW es indispensable, hay que observar su formulación matemática exacta:
Cómo se lee. En cada paso , es el promedio móvil del gradiente (momento de primer orden) y es el promedio móvil del gradiente al cuadrado (momento de segundo orden no centrado). Tras corregir el sesgo de inicialización (), la actualización aplica dos términos separados: la contracción directa de peso (weight decay desacoplado) y el paso adaptativo de gradiente normalizado por .
¿Por qué falla Adam con regularización L2 clásica? En SGD tradicional, minimizar suma al gradiente: . Si se mete ese gradiente modificado dentro de Adam, el paso efectivo resulta:
El término de penalización queda dividido por . Esto produce una catástrofe matemática: los pesos que reciben gradientes grandes y frecuentes tienen un enorme, suprimiendo casi por completo su regularización. Por el contrario, pesos con gradientes diminutos o infrecuentes sufren una contracción desproporcionada. AdamW soluciona esto desacoplando el decay: resta directamente, logrando una penalización constante e invariante a la escala de los gradientes para todos los parámetros de la red.
La física de VRAM: ¿por qué fine-tunear consume 16 bytes por parámetro?
En inferencia (Capítulo 2) solo se cargan los pesos del modelo ( por parámetro en BF16). En entrenamiento completo con precisión mixta y AdamW, el estado estático de la GPU escala a 16 bytes por parámetro:
| Componente | Precisión | Bytes por parámetro | VRAM para modelo 8B |
|---|---|---|---|
| Pesos del modelo () | BF16 | 2 bytes | 16 GB |
| Gradientes () | BF16 | 2 bytes | 16 GB |
| Copia maestra de pesos | FP32 | 4 bytes | 32 GB |
| Primer momento AdamW () | FP32 | 4 bytes | 32 GB |
| Segundo momento AdamW () | FP32 | 4 bytes | 32 GB |
| Total estático del optimizador | — | 16 bytes / param | 128 GB |
¿Por qué es estrictamente obligatoria la copia maestra en FP32? En BF16, la mantisa dispone de únicamente 7 bits, con un épsilon de máquina de . Si el learning rate es y el paso de actualización es , al sumarlo a un peso de magnitud en BF16 ocurre subdesbordamiento aritmético: . La actualización desaparece por completo y el modelo queda congelado en el tiempo. La copia maestra en FP32 () acumula estos micro-pasos con fidelidad, y luego proyecta el resultado a BF16 para el siguiente forward pass.
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.
r es el ancho del cuello entre
A y B: lo que hace que dos matrices de 4096 × 16 pesen 128 veces menos que
la de 4096 × 4096 que acompañan. alpha/r escala esa rama antes de sumarla.
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) # base_model: Llama-2-7B
model.print_trainable_parameters()
# trainable params: 8,388,608 || all params: 6,746,804,224 || trainable%: 0.1243
El número de parámetros entrenables se puede verificar a mano, y vale la pena hacerlo una vez. Cada
matriz W apuntada, de tamaño dout × din, recibe un
A de r × din y un B de dout × r: en total
r · (din + dout) parámetros nuevos. En Llama-2-7B, q_proj y
v_proj son de 4096 × 4096, y hay 32 capas:
16 × (4096 + 4096) = 131.072 parámetros por matriz
× 2 matrices (q_proj, v_proj) × 32 capas = 8.388.608 ✓
Cómo se lee. La salida suma la proyección congelada preentrenada y la rama adaptativa de bajo rango . La matriz proyecta la dimensión de entrada al cuello de botella de rango , y expande de nuevo a , escalado por la constante .
1. Inicialización asimétrica: preservación estricta de la función base en
Para no destruir las representaciones aprendidas durante el preentrenamiento al iniciar el fine-tuning, LoRA inicializa:
En el paso , es idénticamente la matriz nula: el modelo adaptado produce exactamente las mismas salidas que el modelo base original. Si también se inicializara con ruido gaussiano, la suma agregaría perturbaciones aleatorias masivas sobre cada capa que destruirían el conocimiento preentrenado en los primeros pasos.
2. Ley asociativa y eficiencia de FLOPs: ¿por qué no multiplicar primero?
Para un tensor de activaciones , la multiplicación aprovecha la asociatividad:
- proyecta : consume FLOPs.
- Multiplicar por proyecta : consume FLOPs.
- Total de FLOPs en LoRA: .
Si por error calculáramos primero, materializaríamos una matriz densa de , costando FLOPs y destruyendo el ahorro de memoria. La relación de cómputo adicional de LoRA frente a la capa base () es:
Para dimensiones estándar de Llama (): . El cómputo del adaptador LoRA agrega menos del 1% de FLOPs al forward pass.
3. El colapso del estado del optimizador en VRAM
Como la matriz base está congelada (requires_grad=False), PyTorch autograd no calcula ni reserva sus gradientes. En consecuencia, AdamW no crea copia maestra en FP32 ni momentos para . En un modelo de 8B, esto elimina de golpe 112 GB de VRAM estática (los 14 bytes por parámetro de gradientes + estados de optimizador).
Los gradientes solo se propagan a y :
En Llama-3-8B con target_modules="all-linear" y , los parámetros entrenables suman apenas (). El estado de optimizador completo para ellos ocupa: .
4. Fusión de pesos para inferencia con latencia cero
Al finalizar el entrenamiento, si el modelo base está en punto flotante (BF16 o FP16), no es necesario mantener la rama paralela en inferencia:
Mediante model.merge_and_unload(), la corrección de bajo rango se suma directamente en la matriz de pesos del modelo base. El modelo final resultante tiene exactamente la misma arquitectura y tamaño que el original, ejecutándose con cero sobrecarga de latencia y sin kernels especiales en vLLM o TGI.
Los dos números de LoraConfig que más tenés que entender:
res el rango: el ancho deAyB. Cuanto más alto, más capacidad tiene el adaptador para representar cambios (y más parámetros pesa, linealmente). Valores habituales: 8 a 64. Unrmás alto no siempre mejora: el paper de LoRA muestra que rangos muy bajos (4-8) ya capturan casi todo en muchas tareas.lora_alphaes un factor de escala: el productoB·Ase multiplica poralpha / rantes de sumarse a la salida deW. Conr=16, lora_alpha=32la escala es 2. Tener el cociente fijo permite cambiarrsin tener que retocar el learning rate — por eso la convenciónalpha = 2res tan común.
Y target_modules: el ejemplo clásico (y el del paper original de LoRA) apunta solo a
q_proj y v_proj. El paper de QLoRA midió esto con cuidado y encontró que
apuntar todas las capas lineales — las cuatro de atención (q_proj,
k_proj, v_proj, o_proj) más las del MLP (gate_proj,
up_proj, down_proj en Llama) — era necesario para igualar la calidad del
fine-tuning completo; con solo atención se quedaba corto. Hoy es el default razonable
(target_modules="all-linear" en PEFT), a cambio de unas pocas veces más parámetros en el
adaptador — que siguen siendo una fracción ínfima del modelo.
🟢 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. Errores comunes
La mayoría se ve en la curva de loss antes que en un traceback — por eso la sección 4 viene antes que esta.
| Síntoma | Causa probable | Qué hacer |
|---|---|---|
| La loss baja un par de pasos y después explota o se vuelve errática; el modelo termina peor que sin entrenar | Faltó optimizer.zero_grad(): los gradientes se suman batch tras batch y el paso crece sin control | Los cinco pasos, siempre en orden: forward, backward, step, scheduler, zero_grad (§3) |
loss: nan o la loss sube desde el primer paso | Learning rate demasiado alto (para un fine-tune de transformer, 5e-5 es alto ya; 1e-3 lo rompe) | Bajar el lr un orden de magnitud, agregar warmup, activar gradient clipping (max_grad_norm=1.0 es el default de Trainer) (§3, §4) |
| La accuracy de validación cambia entre dos evaluaciones del mismo modelo sin entrenar en el medio | Evaluaste en modo train: el dropout sigue activo y cada pasada es distinta | model.eval() antes de evaluar, model.train() después (§3) |
| El entrenamiento es lento y la VRAM está al límite con un batch chico | padding="max_length" al tokenizar: cada ejemplo se rellenó hasta 512 aunque mida 40 | Tokenizar sin padding y rellenar por batch con DataCollatorWithPadding (§1) |
IndexError: Target 2 is out of bounds o el modelo nunca predice una de las clases | num_labels no coincide con las etiquetas del dataset | Contar las clases reales (raw_datasets["train"].features["label"]) y pasar ese número (§2) |
| Accuracy de validación sospechosamente alta (0.99) y en producción el modelo no sirve | Train y validation se mezclaron al tokenizar o al hacer train_test_split después de duplicar filas: el modelo ya vio las respuestas | Tokenizar el DatasetDict completo (que preserva los splits) y no volver a partir después (§1) |
TypeError en el collator, o el modelo se queja de un argumento inesperado como sentence1 | Quedaron columnas de texto en el dataset que va al DataLoader | remove_columns de todo lo que no sea un tensor que el modelo acepte (§3) |
| Con LoRA, la loss casi no se mueve aunque el lr esté bien | Solo se apuntó q_proj/v_proj con r muy bajo, o el adaptador quedó apuntando a nombres de módulos que no existen en ese modelo (PEFT no siempre avisa) | Verificar con print_trainable_parameters() que el número sea el que calculaste; apuntar todas las capas lineales (§5) |
7. 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.
- AdamW desacopla el weight decay del paso adaptativo de Adam; por eso es el optimizador por defecto para transformers, y por eso
lryweight_decayse ajustan por separado. - 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.
res capacidad,alpha/res escala, y apuntar todas las capas lineales es lo que iguala al fine-tune completo.
8. Ponete a prueba
Respondé antes de abrir cada respuesta. Si fallás una, volvé a la sección indicada.
1. ¿Por qué tokenize_function no pasa padding=True, si el modelo necesita batches rectangulares?
Porque el relleno lo hace DataCollatorWithPadding al armar cada batch, hasta el largo del ejemplo más largo de ese batch. Rellenar en map obligaría a usar un único largo para todo el dataset (el máximo global o uno fijo), gastando cómputo en tokens de relleno que la attention mask después ignora. (§1)
2. Sacás optimizer.zero_grad() del loop. No hay error. ¿Qué pasa con la loss?
loss.backward() suma gradientes sobre los que ya había en .grad; sin limpiarlos, el paso 2 usa el gradiente del paso 1 + el del paso 2, el paso 3 usa la suma de tres, y así. El paso efectivo crece sin control y la loss explota o se vuelve errática después de unos pocos batches. (§3)
3. Evaluás el mismo modelo dos veces seguidas y obtenés 0.84 y 0.86 de accuracy. ¿Qué te olvidaste?
model.eval(). En modo train, el dropout apaga neuronas al azar en cada pasada, así que dos evaluaciones idénticas dan resultados distintos. torch.no_grad() no lo arregla: solo evita construir el grafo de gradientes (ahorra memoria y tiempo), no cambia el comportamiento de las capas. (§3)
4. La loss de train sigue bajando en la época 5 y la de validación viene subiendo desde la 3. ¿Qué es y con qué checkpoint te quedás?
Overfitting: el modelo empezó a memorizar el train. Te quedás con el checkpoint de la época 2 o 3 (el mínimo de validación) — con load_best_model_at_end=True en Trainer — y para la próxima corrida: early stopping, más regularización o más datos. (§4)
5. Cambiás r=16, lora_alpha=32 por r=64, lora_alpha=128 sobre los mismos módulos. ¿Cuántos parámetros entrenables tenés ahora en Llama-2-7B, y qué pasó con la escala?
Cuatro veces más: 64 × 8192 × 64 matrices = 33.554.432 (~33,5M). La escala alpha/r sigue siendo 2 — por eso se sube alpha junto con r: mantener el cociente fijo evita tener que retocar el learning rate. (§5)
6. ¿Por qué QLoRA puede fine-tunear un modelo de 70B en una GPU donde el fine-tune completo de un 8B no entra?
Porque el fine-tune completo necesita, además de los pesos, gradientes y dos estados de Adam por parámetro — varias veces el tamaño del modelo. QLoRA congela el modelo base cuantizado a 4 bits (70B ≈ 35 GB) y solo entrena los adaptadores LoRA, que son una fracción ínfima: los gradientes y estados del optimizador existen solo para ellos. (§5)
7. ¿Por qué es obligatoria la copia maestra en FP32 si el forward y backward corren en BF16?
Porque la mantisa de BF16 tiene solo 7 bits (). Si el learning rate es pequeño (), sumar el paso a un peso en BF16 produce exactamente por subdesbordamiento aritmético (underflow). La copia maestra en FP32 acumula esos micro-pasos con precisión de 24 bits de mantisa sin perder la señal. (§3)
8. En LoRA, ¿por qué la matriz se inicializa en cero mientras que se inicializa con ruido gaussiano?
Porque al fijar , en el paso la corrección es exactamente nula: . El entrenamiento arranca exactamente desde el estado óptimo preentrenado. Si ambas matrices tuvieran ruido gaussiano, se inyectaría una perturbación aleatoria destructiva en cada capa que degradaría el modelo de inmediato. (§5)
9. Para practicar
✏️ Ejercicio 1 — el fine-tune de MRPC completo, en chico
Corré el pipeline entero con Trainer sobre 500 ejemplos de train y toda la validación,
con DistilBERT. En CPU tarda unos minutos; en GPU, segundos. Anotá la accuracy antes y
después de entrenar.
import numpy as np, evaluate
from datasets import load_dataset
from transformers import (AutoTokenizer, AutoModelForSequenceClassification,
DataCollatorWithPadding, TrainingArguments, Trainer)
ckpt = "distilbert-base-uncased"
raw = load_dataset("glue", "mrpc")
tok = AutoTokenizer.from_pretrained(ckpt)
tokenized = raw.map(lambda ex: tok(ex["sentence1"], ex["sentence2"], truncation=True), batched=True)
metric = evaluate.load("glue", "mrpc")
def compute_metrics(p):
return metric.compute(predictions=np.argmax(p[0], axis=-1), references=p[1])
model = AutoModelForSequenceClassification.from_pretrained(ckpt, num_labels=2)
args = TrainingArguments("mrpc-mini", eval_strategy="epoch", num_train_epochs=2,
per_device_train_batch_size=16, report_to="none")
trainer = Trainer(model, args,
train_dataset=tokenized["train"].shuffle(seed=42).select(range(500)),
eval_dataset=tokenized["validation"],
data_collator=DataCollatorWithPadding(tokenizer=tok),
processing_class=tok, compute_metrics=compute_metrics)
print("antes:", trainer.evaluate())
trainer.train()
print("después:", trainer.evaluate())
Pista / qué esperar
"Antes" da alrededor de 0,68 o de 0,32 de accuracy: el head recién inicializado predice casi siempre una misma clase, y 0,68 es la proporción de pares "equivalentes" en validación. "Después" tiene que superar 0,68 con claridad (0,75–0,80 con 500 ejemplos; ~0,85 con el train completo). Si no sube, mirá la loss por época: si no baja, algo de la tabla de §6 está pasando.
✏️ Ejercicio 2 — romper el loop a propósito
Con el mismo dataset chico, escribí el loop manual de §3 y corré 60 pasos en tres variantes: correcto,
sin zero_grad(), y con lr=1e-3. Imprimí la loss cada 10 pasos.
import torch
from torch.optim import AdamW
from torch.utils.data import DataLoader
ds = tokenized["train"].shuffle(seed=42).select(range(480))
ds = ds.remove_columns(["sentence1", "sentence2", "idx"]).rename_column("label", "labels")
ds.set_format("torch")
dl = DataLoader(ds, batch_size=8, shuffle=True, collate_fn=DataCollatorWithPadding(tokenizer=tok))
def correr(lr, con_zero_grad):
torch.manual_seed(0)
m = AutoModelForSequenceClassification.from_pretrained(ckpt, num_labels=2)
opt = AdamW(m.parameters(), lr=lr)
m.train()
for paso, batch in enumerate(dl):
loss = m(**batch).loss
loss.backward()
opt.step()
if con_zero_grad:
opt.zero_grad()
if paso % 10 == 0:
print(f"lr={lr} zero_grad={con_zero_grad} paso {paso}: {loss.item():.3f}")
correr(5e-5, True)
correr(5e-5, False)
correr(1e-3, True)
Pista / qué esperar
La primera baja de ~0,69 hacia 0,5–0,6. La segunda arranca igual y a los 20–30 pasos se dispara o se queda clavada en un valor alto. La tercera se va a ~0,69 y se queda ahí o da nan: el modelo colapsó a predecir siempre lo mismo. Son las dos primeras filas de la tabla de §6, vistas en vivo.
✏️ Ejercicio 3 — verificar la cuenta de LoRA con PEFT
Aplicá LoRA a Gemma 4 E2B y comprobá que print_trainable_parameters() coincide con la
fórmula r · (din + dout) por matriz. Necesitás pip install peft y unos 10 GB de VRAM
(o device_map="cpu", más lento); con HuggingFaceTB/SmolLM2-135M corre en CPU en segundos.
import torch
from transformers import AutoModelForCausalLM
from peft import LoraConfig, get_peft_model
base = AutoModelForCausalLM.from_pretrained("google/gemma-4-E2B-it", dtype=torch.bfloat16, device_map="cuda")
cfg = base.config.get_text_config()
print(cfg.num_hidden_layers, cfg.hidden_size, cfg.num_attention_heads, cfg.num_key_value_heads, cfg.num_kv_shared_layers)
for r in (8, 16):
m = get_peft_model(base, LoraConfig(r=r, lora_alpha=2*r, task_type="CAUSAL_LM",
target_modules=r".*language_model.*\.(q_proj|v_proj)$")) # solo el modelo de texto
m.print_trainable_parameters()
m.unload() # saca el adaptador para poder aplicar otro sobre el mismo base
Pista / qué esperar
Salida en la 5090 con transformers 5.16 y peft 0.20: 35 1536 8 1 20, y después 1,34M entrenables con r = 8 y 2,68M con r = 16, sobre 5,10B (0,026 % y 0,052 %). Dos cosas para verificar con la fórmula. q_proj es 1536 × 2048 y v_proj 1536 × 256 (una sola cabeza KV de 256): las dos matrices no pesan lo mismo. Y 20 de las 35 capas comparten el KV cache de otras (num_kv_shared_layers) y no tienen v_proj propio, así que la cuenta es 35 × r × (1536 + 2048) + 15 × r × (1536 + 256). Si escribís target_modules=["q_proj", "v_proj"] a secas, peft falla: el encoder de visión de Gemma 4 también tiene módulos llamados q_proj, envueltos en una clase que peft no soporta; el regex restringe al modelo de texto. "all-linear" también funciona (19,0M entrenables) pero toca las torres de visión y audio.
10. Fuentes y para seguir
- Hugging Face LLM Course — Chapter 3: Fine-tuning a pretrained model — el capítulo original, con el ejemplo MRPC completo en
Trainer, loop manual y Accelerate. - Transformers docs — Trainer — la referencia de
TraineryTrainingArguments; buscáload_best_model_at_end,max_grad_normygradient_accumulation_steps. - Decoupled Weight Decay Regularization (Loshchilov & Hutter, 2017) — el paper de AdamW; la figura 2 muestra en una imagen por qué el decay acoplado de Adam no funciona como debería.
- LoRA: Low-Rank Adaptation of Large Language Models (Hu et al., 2021) — el paper original; la sección 7 es el estudio de qué rango y qué matrices apuntar.
- QLoRA: Efficient Finetuning of Quantized LLMs (Dettmers et al., 2023) — NF4, doble cuantización, y el resultado de que hay que apuntar todas las capas lineales para igualar al fine-tune completo.
- PEFT — documentación —
LoraConfigcon todos sus campos, y las guías de cómo combinar, fusionar y servir adaptadores.