Fine-tuning — Módulo 6: tu primer SFT completo, de punta a punta
Sexto módulo del Curso de Fine-tuning: el script real. Cargar el modelo en 4 bits, preparar el dataset con el chat template correcto, configurar LoRA, entrenar con SFTTrainer, guardar el adapter e inferir con él. Con las verificaciones previas que evitan quemar una noche de GPU y una comparación honesta de Unsloth.
☰ Índice del contenido
- La tesis del módulo
- El pipeline completo
- El entorno
- El script, por partes
- 1. Dataset
- 2. Modelo base en 4 bits
- 3. LoRA
- 4. Verificación previa (el paso que nadie hace)
- 5. Entrenamiento
- 6. Qué queda en disco
- 7. Inferencia con el adapter
- Unsloth: el atajo, con su letra chica
- Errores comunes
- La frase para llevarse
- Qué viene
- Material relacionado
El módulo 5 te dejó entendiendo qué hace LoRA por dentro y cómo se configura QLoRA sin caer en los defaults traicioneros de BitsAndBytesConfig. Tienes el dataset del módulo 3, el modelo base y la GPU del módulo 4. Falta lo único que importa: correrlo. Este módulo es un script completo, comentado línea por línea, y el conjunto de verificaciones que separan un entrenamiento exitoso de una noche de GPU quemada para descubrir en la mañana que el modelo estuvo aprendiendo a predecir tokens de relleno.
La tesis del módulo
El fine-tuning falla mucho más seguido en la preparación de datos que en el entrenamiento. El bug clásico no lanza excepción: la loss baja bonito, el proceso termina, el adapter se guarda, y el modelo no sirve porque estuvo entrenando sobre texto mal formateado. Diez minutos de verificación antes de
train()valen más que cualquier ajuste de hiperparámetros posterior.
El pipeline completo
Antes del código, el mapa. Todo SFT con LoRA tiene exactamente esta forma:
PIPELINE DE UN SFT CON LORA
START ──► ┌──────────────────┐
│ 1. dataset JSONL │ messages / prompt
│ conversacional│ + completion
└────────┬─────────┘
▼
┌──────────────────┐
│ 2. modelo base │ BitsAndBytesConfig
│ en NF4 │ nf4 + double quant
└────────┬─────────┘
▼
┌──────────────────┐
│ 3. LoraConfig │ r, alpha, all-linear
│ adapters │ 0.5% entrenable
└────────┬─────────┘
▼
┌──────────────────┐
│ 4. VERIFICAR │ decodificar un batch
│ antes de todo │ 10 pasos de prueba
└────────┬─────────┘
│
ok ▼ error │
┌──────────────────┐ │ vuelve a 1
│ 5. SFTTrainer │◄───┘
│ train() │
└────────┬─────────┘
▼
┌──────────────────┐
│ 6. adapter en │ ~40-160 MB
│ disco │ no el modelo entero
└────────┬─────────┘
▼
┌──────────────────┐
│ 7. inferencia │ base + adapter
│ con adapter │ o merge (M10)
└────────┬─────────┘
▼
END
El paso 4 es el que casi todos se saltan y el que este módulo insiste en que hagas.
El entorno
# Versiones compatibles entre sí. Fijar versiones no es paranoia:
# la API de TRL cambió de nombre varias veces (max_seq_length -> max_length,
# tokenizer -> processing_class) y los tutoriales viejos ya no corren.
pip install "transformers>=4.56" "trl>=0.20" "peft>=0.14" \
"datasets>=3.0" "accelerate>=1.0" "bitsandbytes>=0.45"
Ojo:
bitsandbytesnecesita GPU NVIDIA con CUDA. En Mac (MPS) y en CPU no hay carga en 4 bits: puedes hacer LoRA sobre un modelo chico en bf16, pero no QLoRA. Si estás en Windows, funciona, aunque el camino con menos fricción sigue siendo WSL2 o un contenedor Linux.
El script, por partes
1. Dataset
TRL acepta cuatro formatos y detecta cuál le pasaste. Los dos que importan acá:
# Conversacional (lo normal para un asistente)
{"messages": [
{"role": "system", "content": "Eres un asistente de soporte."},
{"role": "user", "content": "¿Cuánto demora el despacho a regiones?"},
{"role": "assistant", "content": "El despacho a regiones demora 3 a 5 días hábiles."}
]}
# Prompt-completion (mejor cuando la tarea es entrada -> salida fija)
{"prompt": [{"role": "user", "content": "Clasifica: 'no me llegó el pedido'"}],
"completion": [{"role": "assistant", "content": "categoria: logistica"}]}
La diferencia no es cosmética y define dónde se calcula la loss, el tema del módulo 3:
| Formato del dataset | Comportamiento por defecto de la loss | Cómo forzarlo |
|---|---|---|
prompt + completion | Solo sobre la completion. Es lo que quieres | completion_only_loss=True (ya es el default) |
messages (conversacional) | Sobre toda la secuencia, incluido lo del usuario | assistant_only_loss=True |
Cuidado: este es el error silencioso más caro del curso. Con un dataset de
messagesy la configuración por defecto, el modelo también aprende a generar las preguntas del usuario. Entrena sin quejarse, la loss baja, y el modelo resultante tiene una tendencia rara a completar el turno del usuario o a alucinar preguntas. Si tu dataset es conversacional,assistant_only_loss=Trueno es opcional.
Y la letra chica: assistant_only_loss=True requiere que el chat template del modelo incluya los marcadores {% generation %} / {% endgeneration %}. TRL parcha automáticamente el template para familias conocidas (Qwen3, entre otras); para el resto hay que verificar que el template los traiga. Si no los trae, el camino robusto es convertir el dataset al formato prompt/completion.
"""Carga y partición del dataset. El split de validación se saca ANTES
de cualquier otra cosa y no se toca hasta el módulo 8."""
from datasets import load_dataset
ds = load_dataset("json", data_files="datos/sft_train.jsonl", split="train")
# 5% para validación: sirve para leer las curvas del módulo 7.
# OJO: esto NO es el holdout de evaluación final (módulo 8), que se
# aparta antes y nunca entra a este archivo.
split = ds.train_test_split(test_size=0.05, seed=42)
train_ds, eval_ds = split["train"], split["test"]
print(f"train: {len(train_ds)} | eval: {len(eval_ds)}")
print(train_ds[0]) # míralo con tus ojos, siempre
2. Modelo base en 4 bits
"""Carga cuantizada. Los tres campos de QLoRA van explícitos (módulo 5)."""
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
MODELO = "Qwen/Qwen2.5-7B-Instruct"
bnb = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_use_double_quant=True,
bnb_4bit_compute_dtype=torch.bfloat16,
)
tok = AutoTokenizer.from_pretrained(MODELO)
# Sin pad_token no se pueden armar batches. Casi ningún modelo causal
# trae uno; la convención es reutilizar el EOS.
if tok.pad_token is None:
tok.pad_token = tok.eos_token
modelo = AutoModelForCausalLM.from_pretrained(
MODELO,
quantization_config=bnb,
device_map="auto",
dtype=torch.bfloat16,
)
modelo.config.use_cache = False # incompatible con gradient checkpointing
use_cache = False merece una nota. El KV cache acelera la inferencia guardando estados de tokens anteriores, pero durante el entrenamiento choca con gradient checkpointing y solo gasta memoria. TRL lo desactiva por su cuenta, pero dejarlo explícito evita un warning confuso. Hay que volver a activarlo para inferir, y lo veremos en el paso 7.
3. LoRA
"""LoraConfig del módulo 5. Nada nuevo, pero explícito."""
from peft import LoraConfig
lora = LoraConfig(
r=16,
lora_alpha=32, # escalado 2.0
lora_dropout=0.05,
target_modules="all-linear",
bias="none",
task_type="CAUSAL_LM",
)
No llamamos get_peft_model() acá: se lo pasamos al SFTTrainer vía peft_config y él lo aplica. Si lo haces a mano y además pasas peft_config, terminas con adapters sobre adapters.
4. Verificación previa (el paso que nadie hace)
Antes de train(), dos chequeos que cuestan un minuto y evitan la mayoría de los desastres.
Chequeo A: mira exactamente el texto que va a leer el modelo.
"""Decodificar lo que realmente entra al modelo. Esto es lo que atrapa
chat templates mal aplicados, tokens especiales duplicados y BOS/EOS
faltantes — bugs que NO lanzan excepción."""
texto = tok.apply_chat_template(
train_ds[0]["messages"],
tokenize=False,
)
print(repr(texto))
# '<|im_start|>system\nEres un asistente de soporte.<|im_end|>\n
# <|im_start|>user\n¿Cuánto demora el despacho...<|im_end|>\n
# <|im_start|>assistant\nEl despacho a regiones...<|im_end|>\n'
Qué buscar en ese repr, en orden de frecuencia del bug:
- Los marcadores de rol aparecen y son los del modelo. Si ves
<|im_start|>en un Llama o<|start_header_id|>en un Qwen, aplicaste el template equivocado. - Hay un token de fin al final del turno del asistente. Sin EOS, el modelo aprende a no parar nunca — el síntoma clásico de generación que sigue y sigue inventando turnos.
- No hay marcadores duplicados. Si tu JSONL ya traía los tokens especiales y aplicas el template encima, quedan dobles y el modelo entrena sobre un formato que jamás verá en producción.
Chequeo B: mira dónde cae la loss.
"""Verificar el enmascarado: los tokens con label -100 se ignoran en la
loss. Deben ser el prompt; los tokens con label real, la respuesta."""
ejemplo = trainer.train_dataset[0]
ids = ejemplo["input_ids"]
labels = ejemplo["labels"]
visible = [tok.decode([i]) for i, l in zip(ids, labels) if l != -100]
print("El modelo aprende a generar:", "".join(visible))
# Debe imprimir SOLO la respuesta del asistente.
# Si aparece la pregunta del usuario, te falta assistant_only_loss=True.
Este segundo chequeo es el que confirma en un segundo si el enmascarado quedó bien. -100 es el índice que PyTorch ignora al calcular cross-entropy; todo token con esa etiqueta no contribuye al gradiente.
Chequeo C: diez pasos de prueba.
# Antes de lanzar 3 épocas, corre una configuración idéntica con
# max_steps=10. Confirma que: (a) no hay OOM, (b) la loss es un número
# razonable (~1-3 para un modelo instruct, NO NaN), (c) el throughput
# permite estimar cuánto va a durar la corrida completa.
args_prueba = SFTConfig(output_dir="./tmp-prueba", max_steps=10, logging_steps=1)
Si la loss inicial es NaN, casi siempre es fp16 con un modelo que quiere bf16, o falta prepare_model_for_kbit_training. Si la loss inicial está sobre 8-10 en un modelo instruct, el formato está mal: el modelo está viendo algo que no reconoce.
5. Entrenamiento
"""El entrenamiento. SFTConfig hereda TODOS los campos de
TrainingArguments; acá van los que importan para un SFT con LoRA."""
from trl import SFTTrainer, SFTConfig
args = SFTConfig(
output_dir="./qwen7b-soporte-lora",
# --- duración ---
num_train_epochs=2, # 1-3 épocas. Más es sobreajuste (M7)
max_length=1024, # trunca; súbelo si tus ejemplos son largos
# --- batch efectivo = 2 x 8 = 16 ---
per_device_train_batch_size=2, # lo que aguante la VRAM
gradient_accumulation_steps=8, # acumula para simular un batch grande
# --- optimización ---
learning_rate=2e-4, # LoRA usa LR ~10x mayor que full FT
lr_scheduler_type="cosine",
warmup_ratio=0.03,
weight_decay=0.01,
max_grad_norm=1.0, # clipping: corta gradientes explosivos
optim="paged_adamw_8bit", # paged: sobrevive a los picos (M5)
# --- precisión y memoria ---
bf16=True, # en Ampere+; usa fp16=True si es más vieja
gradient_checkpointing=True, # cambia cómputo por VRAM
# --- loss ---
assistant_only_loss=True, # dataset conversacional: IMPRESCINDIBLE
# --- observabilidad (módulo 7) ---
logging_steps=10,
eval_strategy="steps",
eval_steps=50,
save_strategy="steps",
save_steps=50,
save_total_limit=3, # no llenar el disco de checkpoints
load_best_model_at_end=True, # se queda con el de mejor val loss
report_to="none", # "trackio" o "wandb" para registrar
)
trainer = SFTTrainer(
model=modelo,
args=args,
train_dataset=train_ds,
eval_dataset=eval_ds,
processing_class=tok, # ojo: NO es `tokenizer=` (API vieja)
peft_config=lora, # acá se aplica LoRA
)
trainer.train()
trainer.save_model("./qwen7b-soporte-lora/final")
Dos cambios de API que rompen los tutoriales viejos y conviene tener presentes: el argumento es processing_class, no tokenizer; y el campo de largo en SFTConfig es max_length, no max_seq_length. Si copias código de 2023 y explota, es casi seguro por uno de esos dos.
Una alternativa más corta que la documentación de TRL habilita: SFTTrainer acepta quantization_config directamente y carga el modelo desde un string, lo que ahorra el bloque del paso 2 entero.
# Versión compacta: TRL carga y cuantiza el modelo por ti.
trainer = SFTTrainer(
model="Qwen/Qwen2.5-7B-Instruct",
args=args,
train_dataset=train_ds,
quantization_config=bnb, # combina con peft_config para QLoRA
peft_config=lora,
)
Es más limpia, pero pierdes el control sobre el tokenizer antes de entrenar — y ese control es justamente el que te deja hacer el chequeo A. Para tu primer SFT, usa la versión larga.
6. Qué queda en disco
Terminado el entrenamiento, ./qwen7b-soporte-lora/final/ contiene:
adapter_model.safetensors ~80 MB los pesos A y B
adapter_config.json ~1 KB r, alpha, target_modules, modelo base
tokenizer.json ~7 MB el tokenizer
tokenizer_config.json ~5 KB chat template incluido
special_tokens_map.json ~1 KB
README.md ~2 KB model card autogenerada
El modelo base no está ahí. Son ~80 MB frente a los ~15 GB del modelo completo en bf16. Esa es la propiedad que hace a LoRA operativamente cómodo: un adapter se versiona en git-lfs, se sube al Hub en segundos y puedes tener veinte para veinte tareas sobre un solo modelo base cargado en memoria.
El tamaño escala con r y con target_modules. Órdenes de magnitud para un 7B:
| Configuración | Tamaño del adapter |
|---|---|
r=8, solo q_proj + v_proj | ~5 – 15 MB |
r=16, all-linear | ~60 – 90 MB |
r=64, all-linear | ~250 – 350 MB |
r=16, all-linear + modules_to_save=["embed_tokens","lm_head"] | ~2 – 4 GB |
Esa última fila explica por qué agregar tokens nuevos al vocabulario deja de ser gratis: los embeddings completos viajan con el adapter.
Ese adapter_config.json guarda el nombre del modelo base en base_model_name_or_path. Un adapter solo funciona sobre el modelo con el que se entrenó. Cargarlo sobre otro no lanza error necesariamente: produce salidas degradadas, que es peor.
7. Inferencia con el adapter
"""Cargar base + adapter e inferir. La configuración de carga debe ser
la MISMA con que entrenaste (4-bit si entrenaste en 4-bit)."""
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import PeftModel
ADAPTER = "./qwen7b-soporte-lora/final"
bnb = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_use_double_quant=True,
bnb_4bit_compute_dtype=torch.bfloat16,
)
base = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen2.5-7B-Instruct",
quantization_config=bnb,
device_map="auto",
)
modelo = PeftModel.from_pretrained(base, ADAPTER)
modelo.eval()
modelo.config.use_cache = True # reactivar para generar rápido
tok = AutoTokenizer.from_pretrained(ADAPTER)
mensajes = [
{"role": "system", "content": "Eres un asistente de soporte."},
{"role": "user", "content": "¿Cuánto demora el despacho a Punta Arenas?"},
]
# add_generation_prompt=True agrega el marcador que le dice al modelo
# "ahora te toca a ti". Sin esto, el modelo no sabe que debe responder.
entrada = tok.apply_chat_template(
mensajes, add_generation_prompt=True, return_tensors="pt"
).to(modelo.device)
with torch.no_grad():
salida = modelo.generate(
entrada,
max_new_tokens=256,
do_sample=False, # determinista: OBLIGATORIO para comparar (M8)
pad_token_id=tok.pad_token_id,
)
print(tok.decode(salida[0][entrada.shape[-1]:], skip_special_tokens=True))
do_sample=False merece énfasis. Para comparar tu modelo afinado contra el base necesitas que ambos sean deterministas; si muestreas, cualquier diferencia que veas puede ser azar. Es la base metodológica del módulo 8.
Y el detalle de salida[0][entrada.shape[-1]:]: generate devuelve el prompt más la continuación. Sin ese recorte estarías imprimiendo tu propia pregunta y creyendo que el modelo la repite.
Unsloth: el atajo, con su letra chica
Unsloth es una librería que reimplementa las operaciones más pesadas del entrenamiento con kernels de Triton escritos a mano y una función de pérdida optimizada. Es compatible con la API de Hugging Face y la documentación de TRL la integra oficialmente, describiéndola como entrenamiento “hasta 2× más rápido con hasta 70 % menos de VRAM”.
| Stack HF (transformers + TRL + PEFT) | Unsloth | |
|---|---|---|
| Velocidad | Referencia | Notablemente más rápido en modelos soportados |
| VRAM | Referencia | Menor: permite secuencias más largas o batches más grandes |
| Modelos soportados | Prácticamente todos | Lista curada (Llama, Mistral, Qwen, Gemma, Phi y derivados) |
| Multi-GPU | Nativo vía accelerate / FSDP / DeepSpeed | Limitado en la versión abierta |
| Arquitecturas nuevas | Soporte casi inmediato | Va a la zaga; hay que esperar |
| Debugging | Stack estándar, mucha documentación | Kernels propios: errores más opacos |
| Portabilidad del resultado | Adapter PEFT estándar | Adapter PEFT estándar (compatible) |
La recomendación honesta: aprende el stack de Hugging Face primero, y usa Unsloth cuando la velocidad sea tu cuello de botella real. Los motivos son prácticos. El stack HF es el que vas a encontrar en la gran mayoría de la documentación y el código ajeno; cuando algo falle, habrá respuestas. Unsloth te ahorra tiempo de GPU, no tiempo de aprendizaje — y en tu primer fine-tune el cuello de botella no es la GPU, es entender qué está pasando.
La buena noticia es que la migración es barata: el adapter que produce Unsloth es un adapter PEFT estándar, así que puedes cambiar de una a otra sin rehacer nada aguas abajo.
Errores comunes
- Entrenar sin decodificar un batch primero. El bug de formato no lanza excepción. La loss baja, el proceso termina, el adapter se guarda, y el modelo es inútil.
print(repr(texto))es el paso más rentable del módulo. - Dataset conversacional sin
assistant_only_loss=True. El modelo aprende a generar los turnos del usuario. Se manifiesta como un modelo que responde y después inventa la siguiente pregunta. - Olvidar el
pad_token. Casi ningún modelo causal trae uno y la construcción de batches explota, o peor, alguna ruta usa el token 0 en silencio y contamina el entrenamiento. add_generation_prompt=Trueen el entrenamiento o su ausencia en la inferencia. Al entrenar, el turno del asistente ya está en los datos. Al inferir, es imprescindible. Invertirlos produce un modelo que “no responde” o que responde en un formato torcido.- Inferir con una configuración de carga distinta a la del entrenamiento. Entrenaste con el base en NF4 y evalúas con el base en bf16: no es el par que entrenaste. Las diferencias son sutiles y confunden el diagnóstico.
- No recortar el prompt de la salida de
generate. “El modelo me repite la pregunta” casi siempre es esto, no un problema del fine-tune. save_total_limitsin definir. Consave_steps=50en una corrida larga, decenas de checkpoints de cientos de MB llenan el disco y el entrenamiento muere por espacio en el minuto 200.
La frase para llevarse
“El código del fine-tuning son cuarenta líneas que copias en diez minutos. Lo que separa un adapter que sirve de uno que no son los tres minutos anteriores: decodificar un batch y confirmar que el modelo está leyendo lo que tú crees que está leyendo.”
Qué viene
Módulo 7: hiperparámetros y cómo leer las curvas. Ya tienes un entrenamiento que corre; ahora hay que saber si corrió bien. Learning rate y schedulers con rangos concretos para LoRA frente a full fine-tuning, por qué las épocas se cuentan con los dedos de una mano, cómo se calcula el batch efectivo y qué significa cada forma que puede tomar la curva de loss. Vamos a dibujar las cinco curvas que te vas a encontrar — sana, sobreajustada, LR demasiado alto, LR demasiado bajo, dataset demasiado chico — y armar la tabla de diagnóstico que traduce síntoma a perilla.
Material relacionado
Preguntas sobre este curso
Regístrate gratis para hacer preguntas y guardar tu progreso.
✉ Entérate de lo nuevo
Recibe un aviso cuando publique nuevos cursos, workshops o investigaciones. Sin spam.