← Todo el material
Curso Intermedio 23 de agosto de 2026

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

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: bitsandbytes necesita 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 datasetComportamiento por defecto de la lossCómo forzarlo
prompt + completionSolo sobre la completion. Es lo que quierescompletion_only_loss=True (ya es el default)
messages (conversacional)Sobre toda la secuencia, incluido lo del usuarioassistant_only_loss=True

Cuidado: este es el error silencioso más caro del curso. Con un dataset de messages y 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=True no 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:

  1. 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.
  2. 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.
  3. 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ónTamañ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
VelocidadReferenciaNotablemente más rápido en modelos soportados
VRAMReferenciaMenor: permite secuencias más largas o batches más grandes
Modelos soportadosPrácticamente todosLista curada (Llama, Mistral, Qwen, Gemma, Phi y derivados)
Multi-GPUNativo vía accelerate / FSDP / DeepSpeedLimitado en la versión abierta
Arquitecturas nuevasSoporte casi inmediatoVa a la zaga; hay que esperar
DebuggingStack estándar, mucha documentaciónKernels propios: errores más opacos
Portabilidad del resultadoAdapter PEFT estándarAdapter 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=True en 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_limit sin definir. Con save_steps=50 en 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

Fine-tuningLoRATRLSFTPEFT

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.