Curso / HF LLM Course / Capítulo 3
HF LLM Course · 3 de 12

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 con DataCollatorWithPadding, y definir un compute_metrics vectorizado.
  • 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 B=0, 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:

FlagMecánica de hardware y cómputo
bf16=True / fp16=TrueEntrena 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 5.96×10−8 o desbordamiento (overflow) por encima de 65.504 escalando la loss por un factor S. En bf16, al conservar 8 bits de exponente (igual que FP32, hasta 3.4×1038), no se necesita escalador dinámico alguno.
gradient_accumulation_steps=NPermite simular un batch efectivo Beff=Bμ×N sin multiplicar la memoria de activaciones. Cada micro-batch Bμ 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 N iteraciones.
gradient_checkpointing=TrueActivation 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 O(L) a O(1) 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).

Los cinco pasos de un paso de entrenamiento Por cada batch: forward calcula salida y loss, backward llena el gradiente de cada parámetro, optimizer.step actualiza los pesos, scheduler.step ajusta el learning rate y zero_grad vacía los gradientes; después vuelve al forward con el siguiente batch. La evaluación es solo el forward, con model.eval y torch.no_grad. Un paso de entrenamiento = un batch TOCA: ACTIVACIONES TOCA: .grad TOCA: PESOS TOCA: lr TOCA: .grad forward outputs = model(**batch) → loss backward loss.backward() → .grad de cada peso optimizer.step() w ← w − lr · grad → pesos nuevos scheduler.step() baja el lr según el plan → lr del próximo paso zero_grad() .grad ← 0 → listo para el próximo siguiente batch · 3 épocas × 459 batches = 1377 pasos Si falta zero_grad(): backward() suma sobre el .grad anterior y el paso crece batch tras batch. Evaluación: solo forward, con model.eval() (sin dropout) y torch.no_grad() (sin grafo de gradientes).
El loop manual de §3 como ciclo. Cada paso toca un estado distinto: el forward produce activaciones y loss, el backward escribe .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:

mt=β1mt−1+(1−β1)gt,vt=β2v}_{tt−1+(1−β2)gt2 m^t=mt1−β1t,v^t=vt1−β2t Wt+1=Wt−ηtλWt−ηtv^t+ϵm^t

Cómo se lee. En cada paso t, mt es el promedio móvil del gradiente (momento de primer orden) y vt es el promedio móvil del gradiente al cuadrado (momento de segundo orden no centrado). Tras corregir el sesgo de inicialización (m^t,v^t), la actualización aplica dos términos separados: la contracción directa de peso −ηtλWt (weight decay desacoplado) y el paso adaptativo de gradiente normalizado por v^t.

¿Por qué falla Adam con regularización L2 clásica? En SGD tradicional, minimizar L+12λ∥W∥2 suma λW al gradiente: g~t=gt+λWt. Si se mete ese gradiente modificado dentro de Adam, el paso efectivo resulta:

Wt+1=Wt−ηtv^t+ϵ(gt+λWt)⟹decay efectivo=−ηtλv^t+ϵWt

El término de penalización queda dividido por v^t. Esto produce una catástrofe matemática: los pesos que reciben gradientes grandes y frecuentes tienen un vt 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 ηtλWt 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 (2 B 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:

ComponentePrecisiónBytes por parámetroVRAM para modelo 8B
Pesos del modelo (W)BF162 bytes16 GB
Gradientes (∇WL)BF162 bytes16 GB
Copia maestra de pesosFP324 bytes32 GB
Primer momento AdamW (mt)FP324 bytes32 GB
Segundo momento AdamW (vt)FP324 bytes32 GB
Total estático del optimizador—16 bytes / param128 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 ϵBF16=2−7≈7.81×10−3. Si el learning rate es η=10−5 y el paso de actualización es ΔW∼10−5, al sumarlo a un peso de magnitud ≈1.0 en BF16 ocurre subdesbordamiento aritmético: 1.0+10−5=1.0. La actualización desaparece por completo y el modelo queda congelado en el tiempo. La copia maestra en FP32 (ϵFP32≈1.19×10−7) 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).

Curva sana vs. overfitting Loss de entrenamiento y validación: en la curva sana ambas bajan y convergen; en overfitting la de validación empieza a subir mientras la de entrenamiento sigue bajando. Sana épocas → — train ┄ val Overfitting épocas →
Izquierda: train y validación bajan juntas y convergen. Derecha: la de entrenamiento sigue bajando pero la de validación empieza a subir — el modelo está memorizando, no aprendiendo.
PatrónSíntomaQué probar
OverfittingLoss de train sigue bajando, la de validación sube o se estanca; accuracy de train mucho más alta que la de validaciónEarly stopping, más regularización (dropout, weight decay), más datos, un modelo más chico
UnderfittingAmbas losses se quedan altas y se estancan tempranoMás capacidad, más épocas, ajustar el learning rate, revisar la calidad de los datos
ErráticaFluctuaciones fuertes, sin tendencia claraBajar 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.

LoRA: la matriz original congelada, más un par de matrices chicas en paralelo La entrada x pasa por la matriz W original, congelada, de 4096 por 4096, y en paralelo por A, de 16 por 4096, y B, de 4096 por 16. La salida de B se escala por alpha sobre r y se suma a la de W. Solo A y B se entrenan: 131.072 parámetros contra 16,8 millones de W. x [4096] W 4096 × 4096 congelada: no entrena A · 16 × 4096 B 4096 × 16 × α/r + h = W·x + (α/r)·B·A·x Por matriz apuntada W: 4096 × 4096 = 16,8M A + B: 16 · (4096 + 4096) = 131.072 entrenables el 0,78 % de W se entrena y se guarda congelado, viene del checkpoint Al terminar, si W es float, se puede fusionar: W′ = W + (α/r)·B·A, y el modelo queda sin capas extra.
Una sola matriz de atención con LoRA encima. El rango 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   ✓
h=Wx+ΔWx=Wx+αrBAx,B∈ℝdout×r,A∈ℝr×din

Cómo se lee. La salida h suma la proyección congelada preentrenada Wx y la rama adaptativa de bajo rango αrBAx. La matriz A proyecta la dimensión de entrada din al cuello de botella de rango r, y B expande de nuevo a dout, escalado por la constante α/r.

1. Inicialización asimétrica: preservación estricta de la función base en t=0

Para no destruir las representaciones aprendidas durante el preentrenamiento al iniciar el fine-tuning, LoRA inicializa:

A∼N(0,1r),B=0⟹ΔW0=αr⋅0⋅A=0

En el paso t=0, ΔW es idénticamente la matriz nula: el modelo adaptado produce exactamente las mismas salidas que el modelo base original. Si B 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 B⋅A primero?

Para un tensor de activaciones X∈ℝB×N×din, la multiplicación aprovecha la asociatividad:

hlora=αr((XA⊤)B⊤)
  • XA⊤ proyecta [B,N,din]×[din,r]→[B,N,r]: consume 2BNdinr FLOPs.
  • Multiplicar por B⊤ proyecta [B,N,r]×[r,dout]→[B,N,dout]: consume 2BNrdout FLOPs.
  • Total de FLOPs en LoRA: 2BNr(din+dout).

Si por error calculáramos ΔW=BA primero, materializaríamos una matriz densa de [dout,din], costando 2doutrdin+2BNdindout FLOPs y destruyendo el ahorro de memoria. La relación de cómputo adicional de LoRA frente a la capa base (2BNdindout) es:

FLOPsLoRAFLOPsBase=2BNr(din+dout)2BNdindout=r(1dout+1din)

Para dimensiones estándar de Llama (din=dout=4096,r=16): 16×24096=324096=1128≈0.78%. 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 W está congelada (requires_grad=False), PyTorch autograd no calcula ∇WL ni reserva sus gradientes. En consecuencia, AdamW no crea copia maestra en FP32 ni momentos mt,vt para W. 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 A y B:

∂L∂B=αr(∂L∂h)⊤(XA⊤),∂L∂A=αrB⊤(∂L∂h)X⊤

En Llama-3-8B con target_modules="all-linear" y r=16, los parámetros entrenables suman apenas ∼40M (≈0.5%). El estado de optimizador completo para ellos ocupa: 40×106×16 bytes≈640 MB.

4. Fusión de pesos para inferencia con latencia cero

Al finalizar el entrenamiento, si el modelo base W está en punto flotante (BF16 o FP16), no es necesario mantener la rama paralela en inferencia:

Wfused=W+αrBA

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:

  • r es el rango: el ancho de A y B. 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. Un r más alto no siempre mejora: el paper de LoRA muestra que rangos muy bajos (4-8) ya capturan casi todo en muchas tareas.
  • lora_alpha es un factor de escala: el producto B·A se multiplica por alpha / r antes de sumarse a la salida de W. Con r=16, lora_alpha=32 la escala es 2. Tener el cociente fijo permite cambiar r sin tener que retocar el learning rate — por eso la convención alpha = 2r es 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 completoLoRAQLoRA
Parámetros entrenados100%<1%<1%
Peso baseSe modificaCongeladoCongelado y cuantizado (4 bits)
Mejor paraModelos chicos, máxima calidadLLMs grandes, hardware limitadoLLMs grandes, hardware muy limitado
Memoria de entrenamiento para un modelo de 8B: fine-tuning completo, LoRA y QLoRA Tres barras a la misma escala. Fine-tuning completo con AdamW en precisión mixta: pesos en bf16, gradientes en bf16, copia maestra en fp32 y dos estados de Adam en fp32, unos 128 gigabytes, 16 bytes por parámetro. LoRA: base congelada en bf16 de 16 gigabytes más adaptadores con su optimizador de menos de 1 gigabyte, unos 17 en total. QLoRA: base congelada en 4 bits de unos 4 gigabytes más los adaptadores, unos 6 en total. Una línea marca los 24 gigabytes de una GPU de consumo. Fine-tuning completo AdamW, precisión mixta pesos · 16 grad · 16 copia maestra fp32 · 32 estados de Adam (m, v) fp32 · 64 ≈ 128 GB · 16 bytes por parámetro LoRA base congelada en bf16 base · 16 ≈ 17 GB ← adaptadores + sus gradientes y estados: < 1 GB QLoRA base congelada en 4 bits ≈ 6 GB ← base NF4 ≈ 4 GB + adaptadores 24 GB: una GPU de consumo pesos gradientes copia fp32 optimizador congelado (sin grad ni optimizador) adaptadores LoRA
Solo lo que ocupa el estado de entrenamiento de un modelo de 8B, sin activaciones ni KV cache. El fine-tuning completo paga 16 bytes por parámetro entre pesos, gradientes, copia maestra y los dos estados de Adam. Congelar la base le saca a la cuenta todo menos los pesos; cuantizarla achica también los pesos. Por eso QLoRA entra donde el fine-tune completo de un 8B ni se acerca.

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íntomaCausa probableQué hacer
La loss baja un par de pasos y después explota o se vuelve errática; el modelo termina peor que sin entrenarFaltó optimizer.zero_grad(): los gradientes se suman batch tras batch y el paso crece sin controlLos cinco pasos, siempre en orden: forward, backward, step, scheduler, zero_grad (§3)
loss: nan o la loss sube desde el primer pasoLearning 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 medioEvaluaste en modo train: el dropout sigue activo y cada pasada es distintamodel.eval() antes de evaluar, model.train() después (§3)
El entrenamiento es lento y la VRAM está al límite con un batch chicopadding="max_length" al tokenizar: cada ejemplo se rellenó hasta 512 aunque mida 40Tokenizar sin padding y rellenar por batch con DataCollatorWithPadding (§1)
IndexError: Target 2 is out of bounds o el modelo nunca predice una de las clasesnum_labels no coincide con las etiquetas del datasetContar 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 sirveTrain y validation se mezclaron al tokenizar o al hacer train_test_split después de duplicar filas: el modelo ya vio las respuestasTokenizar 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 sentence1Quedaron columnas de texto en el dataset que va al DataLoaderremove_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é bienSolo 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

  1. 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.
  2. Trainer resuelve el 95% de los casos en pocas líneas; el training loop manual existe para cuando necesitás control fino, y Accelerate lo escala a multi-GPU casi gratis.
  3. Las curvas de loss y accuracy tienen formas reconocibles — overfitting, underfitting, entrenamiento errático — cada una con su propio remedio.
  4. AdamW desacopla el weight decay del paso adaptativo de Adam; por eso es el optimizador por defecto para transformers, y por eso lr y weight_decay se ajustan por separado.
  5. 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. r es capacidad, alpha/r es 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 (ϵ≈7.8×10−3). Si el learning rate es pequeño (10−5), sumar el paso ΔW∼10−5 a un peso ≈1.0 en BF16 produce exactamente 1.0 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 B se inicializa en cero mientras que A se inicializa con ruido gaussiano?

Porque al fijar B=0, en el paso t=0 la corrección es exactamente nula: ΔW=αrBA=0. 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