Datos y SFT: el 80% del resultado está aquí
dominar lo que de verdad decide la calidad de un fine-tune — los datos y su formato — no los hiperparámetros. Un dataset bien construido con LoRA básico bate a un dataset mediocre con la mejor receta.
3.1La verdad incómoda del fine-tuning
La mayoría de fine-tunes fallidos no fallan por hiperparámetros: fallan por datos mal formateados, máscaras mal puestas o ejemplos inconsistentes. El orden de impacto es: calidad de los datos ≫ formato/máscaras ≫ hiperparámetros ≫ método PEFT. Esta lección ataca los tres primeros.
3.2Formatos de datos
Tres formatos que verás:
- ChatML / formato de chat del modelo: mensajes con roles (
system,user,assistant) y tokens especiales. Es el estándar moderno y el que produceapply_chat_template. Úsalo siempre que entrenes un modelo de chat. - ShareGPT: conversaciones multi-turno como lista de turnos (
{"from": "human"/"gpt", "value": ...}). Común en datasets; conviértelo a chat template. - Alpaca:
{instruction, input, output}. Simple, single-turn. Lo conviertes a chat.
Regla: internamente todo acaba pasando por el chat template del modelo objetivo. Cada modelo tiene su propia plantilla (sus tokens de rol); usar la equivocada degrada o rompe el entrenamiento.
3.3Máscaras de pérdida: no entrenes sobre el prompt
Idea crítica y a menudo ignorada: en SFT solo quieres que el modelo aprenda a generar la respuesta, no a "predecir el prompt". Por eso los tokens del prompt (system + user) deben tener la pérdida enmascarada (label = -100, que PyTorch ignora en cross-entropy); solo los tokens de la respuesta del assistant contribuyen a la pérdida.
Por qué importa: si entrenas también sobre el prompt, el modelo gasta capacidad aprendiendo a reproducir instrucciones (inútil) y diluye la señal de lo que importa (responder). En tareas con prompts largos y respuestas cortas, esto degrada notablemente.
1# Concepto de máscara (TRL/Unsloth lo hacen por ti con train_on_responses_only,
2# pero debes entender qué pasa por debajo):
3# labels = input_ids.clone()
4# labels[: len(prompt_tokens)] = -100 # ignora el prompt en la loss
5# loss = cross_entropy(logits, labels, ignore_index=-100)En la práctica, con Unsloth usas el helper:
1from unsloth.chat_templates import train_on_responses_only
2trainer = train_on_responses_only(
3 trainer,
4 instruction_part="<|im_start|>user\n",
5 response_part="<|im_start|>assistant\n",
6) # enmascara todo menos la parte del assistant3.4Packing: aprovechar cada token de cómputo
Las secuencias tienen longitudes distintas; si rellenas con padding hasta max_seq, desperdicias cómputo en tokens de relleno. Packing concatena varios ejemplos cortos en una sola secuencia de longitud max_seq (con separadores y máscara de atención que evita que un ejemplo "vea" al siguiente). Sube el throughput de entrenamiento sin cambiar el resultado.
1SFTConfig(..., packing=True) # TRL empaqueta automáticamente3.5Preservar el razonamiento (al fine-tunear reasoners)
Si tu base es un modelo con razonamiento (genera <think>...), un SFT con respuestas directas puede matar esa capacidad (le enseñas a no razonar). Regla empírica (Unsloth/Qwen3): mantén ≥75% de ejemplos con cadena de pensamiento y ≤25% directos si quieres conservar el razonamiento. O sepáralos por un flag de control.
3.6Laboratorio L3.1 — Construir un dataset de SFT verificable (genérico)
Construiremos un dataset texto→SQL, porque es una tarea verificable (podrás darle recompensa en L7/L8) y genérica. El patrón sirve para cualquier tarea con respuesta comprobable.
1# lab_n2l3_dataset.py — prepara un dataset SFT texto->SQL en formato chat con máscara
2from datasets import load_dataset
3
4raw = load_dataset("gretelai/synthetic_text_to_sql", split="train[:8000]")
5
6SYSTEM = ("Eres un asistente que convierte preguntas en lenguaje natural a SQL. "
7 "Responde SOLO con la consulta SQL, sin explicación.")
8
9def to_chat(ex, tokenizer):
10 msgs = [
11 {"role":"system","content": SYSTEM},
12 {"role":"user","content": f"Esquema:\n{ex['sql_context']}\n\nPregunta: {ex['sql_prompt']}"},
13 {"role":"assistant","content": ex["sql"]},
14 ]
15 return {"text": tokenizer.apply_chat_template(msgs, tokenize=False),
16 "gold_sql": ex["sql"], "schema": ex["sql_context"]} # guardamos gold para evaluar/recompensar
17
18# Buenas prácticas de calidad ANTES de entrenar:
19# 1) deduplicar prompts casi idénticos
20# 2) filtrar respuestas vacías o malformadas
21# 3) verificar que el SQL parsea (sqlglot.parse_one) -> descarta ejemplos rotos
22import sqlglot
23def is_valid_sql(ex):
24 try: sqlglot.parse_one(ex["sql"]); return True
25 except Exception: return False
26raw = raw.filter(is_valid_sql)
27print(f"Ejemplos válidos: {len(raw)}")Líneas no triviales:
- Guardamos
gold_sqlyschema: los necesitarás como verificador de recompensa en L8 (¿la SQL generada produce el mismo resultado que la gold?). Diseña los datos pensando ya en cómo verificarás. is_valid_sql: filtrar ejemplos rotos antes de entrenar evita enseñarle basura. La limpieza de datos es trabajo de ingeniería, no opcional.systemconsistente: un system prompt fijo y claro reduce la varianza de comportamiento.
3.7Laboratorio L3.2 — SFT con máscara de respuestas
1# lab_n2l3_sft.py — entrena el SFT texto->SQL con máscara correcta
2from unsloth import FastLanguageModel
3from unsloth.chat_templates import train_on_responses_only
4from trl import SFTTrainer, SFTConfig
5
6model, tok = FastLanguageModel.from_pretrained("unsloth/Qwen3-4B-Instruct", max_seq_length=2048, load_in_4bit=True)
7model = FastLanguageModel.get_peft_model(model, r=16, lora_alpha=16,
8 target_modules=["q_proj","k_proj","v_proj","o_proj","gate_proj","up_proj","down_proj"],
9 use_gradient_checkpointing="unsloth")
10
11# (usa el dataset de L3.1, mapeado con este tokenizer)
12trainer = SFTTrainer(model=model, tokenizer=tok, train_dataset=ds,
13 args=SFTConfig(per_device_train_batch_size=4, gradient_accumulation_steps=2,
14 num_train_epochs=2, learning_rate=2e-4, packing=False,
15 logging_steps=20, optim="paged_adamw_8bit", output_dir="./sft_sql"))
16trainer = train_on_responses_only(trainer,
17 instruction_part="<|im_start|>user\n", response_part="<|im_start|>assistant\n")
18trainer.train()
19model.save_pretrained("adapters/sql-sft") # tu baseline SFT para comparar contra RL en L8Este adaptador
sql-sftes tu baseline. En L8, el agente entrenado con RL deberá batirlo — ese es el checkpoint C2b(b).
3.8Ejercicios
E1. Entrena el mismo SFT con y sin train_on_responses_only. Evalúa exact-match en un holdout. ¿Cuánto mejora enmascarar el prompt?
E2. Activa packing=True y mide el throughput de entrenamiento (tok/s) vs sin packing. ¿Cuánto subió?
E3. Introduce a propósito 500 ejemplos con SQL malformada (sin filtrar) y entrena. ¿Cómo se degrada el exact-match? (Lección sobre calidad de datos.)
3.9Trampas comunes
- No enmascarar el prompt → el modelo "aprende" a repetir instrucciones.
- Chat template equivocado (de otro modelo) → tokens de rol incorrectos.
- No limpiar/validar datos → enseñas errores.
- Matar el razonamiento al hacer SFT directo sobre un reasoner.
3.10Referencias
- HF LLM Course (fine-tuning), docs Unsloth (chat templates,
train_on_responses_only), TRL SFTTrainer (packing).