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

Hugging Face · LLM Course · Capítulo 11

Fine-tuning de LLMs: lo que el Capítulo 3 adelantó, ahora en serio

Este es el capítulo donde el curso se pone al día con 2026 de verdad: SFT (Supervised Fine-Tuning) reemplazó al fine-tuning de tarea única del Capítulo 3 como el camino estándar para adaptar un LLM — y es exactamente lo que ya usaste en tus propios proyectos.

Antes de leercap. 3 §5, LoRA · cap. 6, tokens especiales · cap. 5, Datasets

🎯 Al terminar vas a poder

  • Explicar qué hace un chat template, ver con tus ojos el texto exacto que produce para un modelo concreto, y reconocer el síntoma de haberlo usado mal.
  • Armar un dataset en formato conversacional, entrenar con SFTTrainer + LoRA, elegir el learning rate correcto y decidir si entrenar sobre toda la conversación o solo sobre los turnos del asistente.
  • Fusionar un adapter LoRA en el modelo base y explicar cuándo eso no es posible.
  • Correr una evaluación con lighteval, leer una tabla de benchmarks sabiendo qué mide cada uno y qué trampa tiene, y decidir cuándo hace falta un set de evaluación propio.

1. Chat templates, en serio

Ya los mencionamos en los Capítulos 2 y 6 como la pieza que reemplaza a token_type_ids para modelos de chat. Acá se formaliza: un modelo base (recién preentrenado) solo sabe completar texto; un modelo instruct fue afinado para seguir el formato de conversación de su chat template específico — y ese formato varía por familia de modelo (Llama, Mistral, Gemma, cada uno con sus propios tokens de rol y delimitadores).

El template no es cosmético: define dónde empieza y termina cada turno, qué texto es del sistema y cuál del usuario, y en los modelos más nuevos, cómo se representan llamadas a herramientas o inputs multimodales. Usar el template equivocado para un modelo — o concatenar strings a mano en vez de usar el que trae el tokenizer — es una de las formas más comunes de que un modelo instruct responda peor de lo que debería, sin ningún error visible.

Lo mejor que podés hacer para dejar de tratarlo como magia es mirar el string. El tokenizer guarda el template (una plantilla Jinja) y lo aplica sobre una lista de mensajes con role/content:

from transformers import AutoTokenizer

tok = AutoTokenizer.from_pretrained("google/gemma-4-E2B-it")
mensajes = [
    {"role": "system", "content": "Respondés en español, en una frase."},
    {"role": "user", "content": "¿Qué es un adapter LoRA?"},
]
print(tok.apply_chat_template(mensajes, tokenize=False, add_generation_prompt=True))
→ <bos><|turn>system\nRespondés en español, en una frase.<turn|>\n<|turn>user\n¿Qué es un adapter LoRA?<turn|>\n<|turn>model\n

Tres cosas que se ven ahí y que importan para entrenar. Primero, los delimitadores (<|turn> y <turn|> en Gemma 4, que además escribe el rol model donde vos pusiste assistant y antepone <bos>; SmolLM2 y Qwen usan el formato "ChatML", <|im_start|> / <|im_end|>; Llama 3, <|start_header_id|>; Gemma 3, <start_of_turn>) son tokens especiales, uno solo cada uno en el vocabulario. El tokenizer los reconoce aunque los escribas vos como texto — el riesgo de concatenar a mano no es ese, sino reproducir el formato exacto: los saltos de línea, el orden, el mensaje de sistema por defecto que algunos templates insertan solos. Un espacio de diferencia y el modelo ve una secuencia que nunca vio en entrenamiento. Segundo, add_generation_prompt=True agrega el encabezado del turno del asistente vacío — es lo que se usa en inferencia para que el modelo empiece a responder; en entrenamiento no se usa, porque el turno del asistente ya viene completo en el dataset. Tercero, el turno del asistente en un ejemplo de entrenamiento tiene que terminar con el token de fin de turno (<|im_end|>): es lo que le enseña al modelo a parar. Un modelo que después de fine-tuning "no se calla" casi siempre fue entrenado con ejemplos sin ese token.

⚠️ Cuidado

Un modelo base no trae chat template, o trae uno genérico que nunca vio en entrenamiento. Si le hacés SFT a un modelo base, el template que uses en el dataset es el que el modelo aprende — elegilo una vez (el de la versión instruct de esa misma familia es la opción obvia), fijalo en el tokenizer que guardás junto al modelo, y usá exactamente ese en inferencia.

2. Fine-tuning supervisado con SFTTrainer

SFT es el sucesor natural del fine-tuning de tarea única del Capítulo 3: en vez de entrenar para una sola tarea (clasificar, traducir), entrenás sobre ejemplos de conversación completa — pares instrucción/respuesta — para que el modelo aprenda a seguir instrucciones en general. Es, literalmente, el paso que convierte un modelo base en algo parecido a ChatGPT o Claude.

Tiene sentido cuando necesitás controlar el formato exacto de salida, o adaptar el modelo a un dominio específico que un modelo instruct genérico no cubre bien — los mismos dos motivos del Capítulo 7, aplicados a instrucciones en vez de a una tarea puntual.

El formato del dataset

SFTTrainer (de la librería trl) espera uno de dos formatos. El conversacional es el que vas a usar casi siempre: una columna messages con la lista de turnos, cada uno un diccionario {"role": ..., "content": ...} — el mismo formato que le pasaste a apply_chat_template arriba. El otro es el estándar: columnas prompt y completion de texto plano (o una sola columna text ya formateada). Con el conversacional, el trainer aplica el chat template del tokenizer por vos — no tenés que formatear nada a mano, y esa es justamente la gracia: el formato que ve el modelo en entrenamiento es idéntico al de inferencia porque lo produce la misma función.

{"messages": [
    {"role": "system",    "content": "Sos un asistente que explica finanzas en español rioplatense."},
    {"role": "user",      "content": "¿Qué es el EBITDA?"},
    {"role": "assistant", "content": "Es la ganancia operativa antes de intereses, impuestos, depreciación y amortización..."}
]}

Y el entrenamiento, con la configuración mínima:

from trl import SFTTrainer, SFTConfig

trainer = SFTTrainer(
    model="HuggingFaceTB/SmolLM2-1.7B",
    train_dataset=dataset,  # columna "messages" — el trainer aplica el chat template
    args=SFTConfig(
        max_length=2048,        # largo máximo en tokens; lo que sobra se trunca
        packing=True,
        learning_rate=2e-5,     # fine-tuning completo; con LoRA, ~10× más (§3)
        num_train_epochs=1,
        output_dir="sft-smollm2",
    ),
)
trainer.train()

🟢 Actualización 2026

El parámetro se llama max_length. El nombre viejo, max_seq_length, que todavía aparece en el curso original y en muchos tutoriales, fue renombrado en versiones recientes de TRL; si lo usás, según la versión vas a recibir un aviso de deprecación o un error de argumento desconocido.

packing=True concatena varios ejemplos cortos en una sola secuencia de longitud máxima — evita desperdiciar cómputo en padding cuando tus ejemplos son mucho más cortos que el límite del modelo. Todo lo del Capítulo 3 sobre leer curvas de aprendizaje aplica igual acá: loss que baja y se estabiliza es sano, un gap creciente entre train y validación es overfitting, sin importar que ahora el objetivo sea "seguir instrucciones" en vez de "clasificar una reseña".

¿Loss sobre toda la conversación o solo sobre el asistente?

Por defecto el modelo se entrena a predecir todos los tokens de la secuencia — incluido el prompt del sistema y lo que escribió el usuario. Eso tiene un costo: parte del gradiente se gasta en aprender a "escribir preguntas de usuario", que no es lo que querés, y si tus prompts son largos y tus respuestas cortas, la mayoría de la loss ni siquiera mide lo que te importa. La alternativa es enmascarar los tokens del prompt (ponerles etiqueta -100, que la loss ignora) y computar la loss solo sobre los turnos del asistente. En versiones recientes de TRL eso se pide con assistant_only_loss=True en SFTConfig, y requiere que el chat template del modelo marque dónde empiezan y terminan los turnos del asistente (los templates más nuevos lo traen; con uno que no, el trainer te lo va a decir). Para el formato prompt/completion, el equivalente es entrenar solo sobre la completion, que es el comportamiento por defecto para ese formato. Como regla: con respuestas largas y prompts cortos la diferencia es chica; con prompts largos (RAG, documentos pegados en el contexto) es grande y conviene enmascarar.

📐 Formulación matemática de SFT condicional y empaquetado por bloques (Packing)

Sea una secuencia tokenizada x=[x1,…,xN] descompuesta en el prefijo de instrucción xprompt=[x1,…,xP] y la respuesta del asistente xtarget=[xP+1,…,xN]. La función de pérdida supervisada condicional es:

LSFT(θ)=−1N−P∑t=P+1NlogPθ(xt∣x1,…,xt−1)

Asignando yt=−100 para todo t≤P, se garantiza ∂L∂zt=0. De este modo, la capacidad de los adaptadores LoRA se concentra exclusivamente en modelar la variedad condicional P(respuesta∣prompt), sin consumir gradientes en aprender a autogenerar las preguntas del usuario.

Empaquetado (Multipack) y Máscara Diagonal por Bloques: Al concatenar múltiples ejemplos independientes E1,…,EK en una única secuencia de longitud L=∑|Ek|≤Lmax, una atención causal estándar provocaría que el inicio de E2 atienda al final de E1. FlashAttention-2 resuelve esto mediante tensores de longitud acumulada cu_seqlens:

Mi,j={0si j≤i y doc_id(i)=doc_id(j)−∞en caso contrario

Esto elimina por completo el costo en memoria y FLOPs del padding sin fuga de atención entre documentos.

Sobre qué tokens se calcula la loss: toda la conversación o solo el asistente Una conversación formateada con el chat template: turno de sistema, turno de usuario y turno del asistente con su token de fin. Con las etiquetas por defecto, cada token lleva su id y todos entran a la loss. Con assistant only loss, los tokens del sistema, del usuario y el encabezado del asistente llevan menos cien, y la loss se calcula solo sobre la respuesta y su token de fin de turno. tokens labels por defecto assistant_only_loss tokens labels por defecto assistant_only_loss <|im_start|>system id -100 Respondés en español. id -100 <|im_end|> id -100 <|im_start|>user id -100 ¿Qué es LoRA? id -100 <|im_end|> id -100 …sigue en la línea de abajo (es una sola secuencia) <|im_start|>assistant id -100 Dos matrices de rango bajo, A y B… id id <|im_end|> id id ← cierra el turno: así aprende a callarse por defecto la loss cubre el 100 % de los tokens; con un prompt de 1.500 tokens y una respuesta de 50, el 97 % del gradiente se gasta en aprender a escribir prompts
La misma conversación tokenizada con el chat template y las dos formas de etiquetarla, token a token. Enmascarar con -100 el sistema, el usuario y el encabezado del asistente hace que el gradiente vaya solo a lo que el asistente dijo, incluido el token de fin de turno.

3. LoRA, con código real

El Capítulo 3 introdujo la idea; acá está la implementación real, vía peft integrado directo en SFTTrainer:

from peft import LoraConfig

peft_config = LoraConfig(
    r=16, lora_alpha=32, lora_dropout=0.05,
    target_modules=["q_proj", "v_proj"],   # o "all-linear" para cubrir todas las proyecciones
    task_type="CAUSAL_LM",
)
trainer = SFTTrainer(
    model=model, train_dataset=dataset, peft_config=peft_config,
    args=SFTConfig(learning_rate=2e-4, max_length=2048, output_dir="sft-lora"),
)
trainer.train()
trainer.save_model("sft-lora/adapter")   # guarda solo el adapter, unos pocos MB

🟢 Con Gemma 4, target_modules necesita un regex

["q_proj", "v_proj"] funciona con SmolLM2 y falla con google/gemma-4-E2B-it: el encoder de visión también tiene módulos llamados q_proj, envueltos en una clase (Gemma4ClippableLinear) que peft no sabe adaptar, y el error es Target module … is not supported. La solución es apuntar al modelo de texto por nombre completo: target_modules=r".*language_model.*\.(q_proj|k_proj|v_proj|o_proj|gate_proj|up_proj|down_proj)$". "all-linear" también anda, pero pone adapters en las torres de visión y audio. Probado con peft 0.20 y trl 1.12; el ejercicio 2 de esta página trae la corrida completa.

Tres decisiones que la config deja en tus manos. r y lora_alpha: r es el rango de las matrices A/B (más rango, más capacidad y más parámetros), y la corrección se escala por lora_alpha / r — la convención de alpha = 2r del ejemplo es un punto de partida razonable, no una ley. target_modules: el ejemplo del curso toca solo las proyecciones de query y value de la atención; aplicar LoRA a todas las capas lineales (atención y MLP) suele rendir mejor a cambio de un adapter más grande — hoy es lo habitual. learning_rate: con LoRA se usa uno del orden de 1e-4 a 2e-4, unas diez veces más alto que en fine-tuning completo, porque las matrices del adapter arrancan en cero (B) y necesitan moverse rápido; usar el 2e-5 de un fine-tuning completo es la causa más común de un LoRA que "no aprendió nada". Y task_type="CAUSAL_LM" no es decorativo: le dice a peft qué tipo de modelo está envolviendo, y sin él el forward puede no propagar bien los argumentos (labels, cache) al modelo base.

El resultado de entrenar así no son pesos nuevos del modelo completo — es un adapter, un puñado de archivos chicos con las matrices A/B entrenadas. Podés servirlo separado (cargando el adapter sobre el modelo base en runtime) o fusionarlo — sumar A·B directamente a los pesos originales — para quedarte con un único checkpoint normal, sin dependencias extra de peft en producción.

from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel

base = AutoModelForCausalLM.from_pretrained("HuggingFaceTB/SmolLM2-1.7B")
model = PeftModel.from_pretrained(base, "sft-lora/adapter")   # base + adapter, separados
model = model.merge_and_unload()                              # W ← W + (alpha/r)·B·A ; devuelve el modelo "plano"
model.save_pretrained("sft-fusionado")
AutoTokenizer.from_pretrained("HuggingFaceTB/SmolLM2-1.7B").save_pretrained("sft-fusionado")  # ¡con el chat template!

Fusionar es reversible solo si guardaste el adapter aparte — el modelo fusionado ya no distingue qué parte de cada peso vino de dónde. Lo cargás después con AutoModelForCausalLM.from_pretrained("sft-fusionado") como cualquier otro checkpoint, y lo servís con vLLM sin flags especiales.

🔧 El límite de "fusionar" que ya conocés

Fusionar A·B en los pesos solo tiene sentido cuando esos pesos son números de punto flotante normales. En la página de NVFP4 en difusión viste el caso límite: si el modelo base ya está empaquetado en NVFP4 (4 bits por valor, en bloques de 16), no existe un "peso intermedio" al que sumarle la corrección — por eso nunchaku tiene que fusionar el cómputo de LoRA en el kernel, en cada forward, en vez de fusionarlo en los pesos como acá. Mismo concepto de LoRA, weights float vs. weights cuantizados.

🟢 El paso siguiente a SFT: preferencias

SFT enseña cómo responder a partir de ejemplos buenos; no enseña a distinguir una respuesta buena de una mediocre. Para eso está la alineación por preferencias: un dataset de pares chosen/rejected para el mismo prompt (juicio humano del tipo que Argilla captura con una RankingQuestion, Capítulo 10) y un trainer que empuja la probabilidad de la respuesta elegida por encima de la rechazada. En trl es DPOTrainer (Direct Preference Optimization: sin reward model separado, optimiza directamente sobre los pares), y variantes como ORPO que fusionan SFT y preferencias en un solo paso. No entramos en detalle acá; el Capítulo 12 cubre el otro camino — RL con GRPO, donde no hay pares fijos sino una función de reward.

De modelo base a asistente: SFT y después preferencias o RL Un modelo base solo completa texto. SFT con pares instrucción y respuesta, formateados con el chat template, lo convierte en un modelo instruct que sigue instrucciones. Después, alineación por preferencias con pares elegido y rechazado vía DPO, o RL con una función de reward vía GRPO, le enseña a distinguir una respuesta buena de una mediocre. LoRA sirve en cualquiera de los dos pasos. modelo base preentrenado: completa texto sabe casi todo, no conversa SFT datos: pares instrucción → respuesta con el chat template SFTTrainer modelo instruct sigue instrucciones aprendió formato y estilo preferencias o RL pares chosen / rejected DPOTrainer o prompts + reward GRPOTrainer · cap. 12 alineado / razonador distingue buena de mediocre o razona antes de responder LoRA sirve en cualquiera de los dos pasos · mil ejemplos bien curados (cap. 10) pesan más que cien mil sin revisar
Las dos etapas de post-entrenamiento y qué datos pide cada una. SFT enseña cómo responder a partir de ejemplos buenos; preferencias o RL enseñan a preferir una respuesta sobre otra. Con lo del capítulo 3, LoRA se aplica igual en cualquiera de las dos.

4. Evaluación: benchmarks y sus límites

Un LLM instruction-tuned se evalúa contra baterías de benchmarks estandarizados — útiles para comparar modelos entre sí, con la salvedad de que un buen score no garantiza que sirva para tu caso de uso específico. Cada uno mide algo distinto y cada uno tiene una forma conocida de mentir:

BenchmarkQué mideCómo se puntúaLa trampa
MMLUConocimiento general en 57 materias (derecho, medicina, matemática…), preguntas de opción múltipleExactitud sobre la letra elegida, típicamente 5-shotContaminación (las preguntas circulan por todo internet) y sensibilidad al formato del prompt: cambiar el orden de las opciones mueve puntos.
GSM8KProblemas de matemática de escuela primaria con varios pasosCoincidencia exacta de la respuesta numérica finalEl split de train se usa muchísimo para SFT y GRPO (Capítulo 12): si el test se filtró al entrenamiento, el score mide memoria. Y la extracción de la respuesta final depende de un regex.
BBHRazonamiento en tareas donde los modelos rendían peor que humanos (lógica, seguimiento de objetos, fechas)Exactitud con chain-of-thought de pocos ejemplosEl resultado depende tanto del prompt de razonamiento como del modelo.
TruthfulQAResistencia a reproducir conceptos erróneos comunes ("¿qué pasa si tragás un chicle?")Opción múltiple o juicio de un modeloMide falsedades imitativas, no veracidad general; la variante de opción múltiple se puede aprender.
HumanEvalGeneración de código funcional: 164 problemas de Python con testspass@k: fracción de problemas donde alguna de k generaciones pasa los testsSon pocos problemas, todos en GitHub desde hace años; un modelo entrenado con código público casi seguro los vio.
IFEvalSeguir instrucciones verificables ("respondé en menos de 50 palabras", "sin comas")Chequeo automático de cada restricciónMide obediencia de formato, no calidad del contenido.

La herramienta del curso para correr esto es lighteval, de Hugging Face. Una tarea se nombra con cuatro partes separadas por |: la suite, la tarea, la cantidad de ejemplos few-shot, y si se permite truncar esos ejemplos cuando no entran en el contexto. Un comando típico, evaluando un modelo local con accelerate:

pip install lighteval

lighteval accelerate \
    "model_name=./sft-fusionado" \
    "lighteval|gsm8k|0|0" \
    --output-dir resultados

El resultado es un JSON por corrida con la métrica de cada tarea (para GSM8K, la exactitud de la respuesta final) más los detalles por ejemplo si los pedís — que son los que te interesan: leer veinte fallos concretos enseña más que el número agregado. En versiones anteriores de lighteval el argumento del modelo se escribía pretrained=... y las tareas del leaderboard iban con el prefijo leaderboard|; si un comando copiado de un tutorial falla, es casi seguro eso. La misma herramienta permite definir tareas propias — un archivo de Python con tu dataset y tu métrica — que es la forma de meter tu set de evaluación custom en el mismo pipeline que los benchmarks públicos.

Cuando el benchmark genérico no alcanza, dos alternativas: LLM-as-judge (otro LLM evalúa la respuesta contra un criterio) o arenas (comparación humana entre respuestas de distintos modelos) — y para lo más específico, un set de evaluación propio, hecho a medida. Las dos primeras tienen sus propias trampas: un juez LLM prefiere respuestas largas y con formato, y tiende a preferir las de su propia familia; una arena mide qué respuesta gusta, no cuál es correcta.

🟢 Ya construiste las dos alternativas que este capítulo sugiere

El claim_verifier.py de tu gate anti-alucinación es exactamente un LLM-as-judge — un modelo evaluando la salida de otro contra un criterio preciso (¿esta cita respalda el reclamo?) — con el agregado de que el veredicto del juez se verifica en código, no se toma como palabra final. Y model_acceptance.py es tu set de evaluación custom: no un benchmark genérico, sino un puñado de casos reales (el incidente Micron, dos casos limpios) que importan específicamente para tu producto. El capítulo lo presenta como la mejor práctica; vos ya la aplicás.

5. Errores comunes

Casi ninguno tira una excepción. El síntoma es un modelo que entrena "bien" según la loss y responde mal.

SíntomaCausa probableQué hacer
El modelo fine-tuneado responde peor que el instruct original, o "no se calla" y sigue generando turnos de usuario.Se entrenó sin chat template (texto concatenado a mano) o con un template distinto al de inferencia; o los ejemplos no terminan con el token de fin de turno.Dataset en formato messages y dejar que SFTTrainer aplique el template; verificar con apply_chat_template(tokenize=False) que el texto de entrenamiento y el de inferencia son idénticos (§1, §2).
La loss baja pero las respuestas no mejoran; el modelo aprendió a imitar el estilo de las preguntas.Loss computada sobre toda la secuencia con prompts largos: la mayor parte del gradiente va al prompt.assistant_only_loss=True (o el formato prompt/completion, que enmascara el prompt por defecto) (§2).
Con packing=True el modelo mezcla información entre ejemplos o "continúa" una conversación con contenido de otra.Los ejemplos no son independientes — partes de una misma conversación larga, o documentos troceados que dependen del contexto anterior — y packing los concatena y ordena arbitrariamente.Packing solo con ejemplos autocontenidos. Si dependen entre sí, unirlos en un solo ejemplo (y subir max_length) o desactivar packing (§2).
El adapter LoRA entrena sin errores, la loss casi no se mueve, y el modelo fusionado es indistinguible del base.Learning rate de fine-tuning completo (2e-5) aplicado a LoRA.Subir a 1e-4–2e-4; comprobar que la loss cae claramente en los primeros cientos de pasos (§3).
Error extraño en el forward al entrenar con peft (argumentos inesperados, labels ignorados), o un adapter que se guarda sin metadatos de tarea.Falta task_type="CAUSAL_LM" en LoraConfig.Ponerlo siempre; es lo que le dice a peft cómo envolver el modelo (§3).
Ejemplos truncados: el modelo aprende a cortar respuestas a mitad de frase.max_length más corto que muchos ejemplos del dataset; la truncación corta el final, que es la respuesta.Medir longitudes en tokens antes de entrenar (Capítulo 10, §6) y filtrar o subir max_length (§2).
Score altísimo en GSM8K después de SFT, malo en problemas nuevos.Contaminación: el test set (o paráfrasis) estaba en el dataset de entrenamiento.Chequeo de solapamiento de n-gramas contra el test antes de entrenar; mirar los detalles por ejemplo de lighteval (§4).

6. Resumen

  1. Un chat template no es cosmético — define el contrato de formato entre vos y el modelo, distinto por familia. Mirá el string con apply_chat_template(tokenize=False); los delimitadores son tokens especiales y el turno del asistente tiene que cerrar con el token de fin.
  2. SFT es fine-tuning para seguir instrucciones en general, no para una sola tarea — el paso que convierte un modelo base en algo usable como asistente. SFTTrainer toma una columna messages, aplica el template solo, y con assistant_only_loss entrena solo sobre lo que dijo el asistente. El largo se fija con max_length (antes max_seq_length).
  3. LoRA con peft + SFTTrainer entrena un adapter chico — con learning rate de 1e-4–2e-4, no el de fine-tuning completo — que podés servir separado o fusionar con merge_and_unload(); fusionar solo funciona sobre pesos en punto flotante, no sobre pesos ya cuantizados.
  4. Después de SFT viene la alineación por preferencias (DPO sobre pares chosen/rejected) o RL con reward functions (GRPO, Capítulo 12).
  5. Los benchmarks estandarizados sirven para comparar modelos entre sí, cada uno mide una cosa y cada uno tiene una trampa (casi siempre contaminación); lighteval los corre con un comando. LLM-as-judge y evaluación custom son lo que realmente te dice si el modelo sirve para tu caso — y ya construiste ambos.

7. Ponete a prueba

Respondé antes de abrir cada respuesta. Si fallás una, volvé a la sección indicada.

1. ¿Qué diferencia hay entre llamar a apply_chat_template con add_generation_prompt=True y sin él, y en cuál de los dos casos lo usás para entrenar?

Con True agrega el encabezado vacío del turno del asistente al final, para que el modelo empiece a responder: es para inferencia. Para entrenar no se usa, porque el turno del asistente ya viene completo en el ejemplo y tiene que cerrar con el token de fin de turno. (§1)

2. Tu dataset tiene columnas pregunta y respuesta en texto plano. ¿Qué tenés que hacer antes de pasárselo a SFTTrainer?

Convertirlo a uno de los dos formatos que entiende: conversacional (una columna messages con la lista de {"role", "content"}) o estándar (prompt/completion). Con el conversacional el trainer aplica el chat template por vos — no formatees a mano. (§2)

3. ¿Qué pasa con la loss si tus prompts tienen 1.500 tokens de documento pegado y las respuestas 50, y no enmascarás el prompt?

El 97 % de los tokens sobre los que se calcula la loss son del prompt: el modelo gasta el gradiente en aprender a reproducir documentos, y la loss que ves casi no mide la calidad de las respuestas. assistant_only_loss=True (o prompt/completion) pone -100 en las etiquetas del prompt y computa solo sobre el asistente. (§2)

4. Copiás una config de fine-tuning completo (learning_rate=2e-5) y le agregás peft_config. La loss baja apenas. ¿Por qué?

LoRA necesita un learning rate unas diez veces mayor (1e-4–2e-4): las matrices del adapter arrancan de cero y con 2e-5 se mueven tan poco que el modelo fusionado es casi el base. (§3)

5. Después de merge_and_unload() y save_pretrained(), ¿podés recuperar el adapter desde el modelo fusionado? ¿Y qué archivo te olvidaste de guardar si el modelo fusionado responde raro en vLLM?

No: la fusión suma B·A a los pesos y el resultado no distingue qué vino de dónde; guardá el adapter aparte antes. Y el tokenizer con su chat template: sin él, vLLM aplica otro formato (o ninguno) y el modelo ve un contrato distinto del que aprendió. (§1, §3)

6. Un modelo pasa de 45 % a 80 % en GSM8K después de SFT con un dataset público de matemática. ¿Qué chequeás antes de festejar?

Contaminación: solapamiento de n-gramas entre tu dataset y el test de GSM8K (los datasets públicos de matemática suelen incluirlo o parafrasearlo). Y mirar los detalles por ejemplo de lighteval: si acierta problemas del test palabra por palabra y falla en problemas nuevos del mismo estilo, es memoria. (§4, Capítulo 10 §6)

8. Para practicar

✏️ Ejercicio 1 — Ver el chat template y sus tokens especiales

Sin GPU y sin bajar pesos (solo el tokenizer): compará el string que produce el template con y sin add_generation_prompt, y comprobá que los delimitadores son un solo token cada uno. Después probá con el tokenizer de otra familia (por ejemplo Qwen/Qwen2.5-0.5B-Instruct) y mirá qué cambia.

from transformers import AutoTokenizer

tok = AutoTokenizer.from_pretrained("HuggingFaceTB/SmolLM2-135M-Instruct")
msgs = [{"role": "user", "content": "Hola"},
        {"role": "assistant", "content": "Hola, ¿en qué te ayudo?"}]

entrenamiento = tok.apply_chat_template(msgs, tokenize=False)
inferencia    = tok.apply_chat_template(msgs[:1], tokenize=False, add_generation_prompt=True)
print(repr(entrenamiento)); print(repr(inferencia))

ids = tok(entrenamiento, add_special_tokens=False)["input_ids"]
print([tok.decode([i]) for i in ids][:12])          # ¿"<|im_start|>" es UN token?
print(len(tok("<|im_start|>", add_special_tokens=False)["input_ids"]))    # 1: lo reconoce dentro del texto
print(len(tok("<|im_start|", add_special_tokens=False)["input_ids"]))     # un carácter menos: ya no es especial
Pista / qué esperar

El string de entrenamiento termina con <|im_end|>\n después de la respuesta; el de inferencia termina con <|im_start|>assistant\n y sin respuesta. Los delimitadores decodifican como un token único porque están registrados como tokens especiales, y el tokenizer los reconoce incluso dentro de un string; la variante con un carácter menos se parte en varios ids normales — es lo que le pasa al modelo cuando el formato concatenado a mano no coincide exactamente con el template.

✏️ Ejercicio 2 — SFT + LoRA en CPU con un modelo de 135M

Un fine-tuning mínimo, de verdad, sin GPU: diez ejemplos que enseñan un estilo (responder siempre con "Dato:" adelante), 30 pasos, LoRA sobre SmolLM2-135M-Instruct. Tarda pocos minutos en CPU. pip install trl peft datasets. Observá la loss caer y luego generá con el adapter.

from datasets import Dataset
from peft import LoraConfig
from trl import SFTTrainer, SFTConfig

pares = [("¿Capital de Francia?", "Dato: París."), ("¿Cuánto es 2+2?", "Dato: 4."),
         ("¿Color del cielo?", "Dato: azul."), ("¿Autor del Quijote?", "Dato: Cervantes."),
         ("¿Planeta más grande?", "Dato: Júpiter."), ("¿Idioma de Brasil?", "Dato: portugués."),
         ("¿Símbolo del oro?", "Dato: Au."), ("¿Año de la Revolución de Mayo?", "Dato: 1810."),
         ("¿Océano más grande?", "Dato: Pacífico."), ("¿Moneda de Japón?", "Dato: yen.")]
ds = Dataset.from_list([{"messages": [{"role": "user", "content": q}, {"role": "assistant", "content": a}]}
                        for q, a in pares])

trainer = SFTTrainer(
    model="HuggingFaceTB/SmolLM2-135M-Instruct",
    train_dataset=ds,
    peft_config=LoraConfig(r=8, lora_alpha=16, target_modules="all-linear", task_type="CAUSAL_LM"),
    args=SFTConfig(output_dir="mini-sft", max_length=128, learning_rate=2e-4,
                   max_steps=30, per_device_train_batch_size=2, logging_steps=5,
                   use_cpu=True, report_to="none"),
)
trainer.train()
trainer.save_model("mini-sft/adapter")

tok = trainer.processing_class
prompt = tok.apply_chat_template([{"role": "user", "content": "¿Capital de Italia?"}],
                                 tokenize=False, add_generation_prompt=True)
out = trainer.model.generate(**tok(prompt, return_tensors="pt"), max_new_tokens=12, do_sample=False)
print(tok.decode(out[0], skip_special_tokens=True))
Pista / qué esperar

La loss tiene que caer de forma visible entre el paso 5 y el 30. La respuesta debería arrancar con "Dato:" — el modelo aprendió el formato con diez ejemplos (LIMA en miniatura); si además acierta "Roma", el conocimiento ya lo tenía. Variante con Gemma 4, corrida en la 5090: model="google/gemma-4-E2B-it", el regex de target_modules del callout de §3, bf16=True y sin use_cpu. 12,1M de parámetros entrenables; la loss baja de 4,26 a 0,27 en 30 pasos, que tardan 7 s (unos 20 s con la carga) y llegan a un pico de 20 GB de VRAM. Con el adapter responde Dato: Roma.; sin él (with model.disable_adapter():), La capital de Italia es **Roma**. El formato es lo único que cambió. Repetí con learning_rate=2e-5 y compará: es el error de §5 en vivo. Si use_cpu o processing_class no existen en tu versión de TRL, son no_cuda y tokenizer en versiones anteriores.

✏️ Ejercicio 3 — Fusionar el adapter y comprobar que no cambió nada

Sobre el adapter del ejercicio anterior: cargá base + adapter, fusioná, guardá, y verificá que el modelo fusionado (cargado como un checkpoint normal, sin peft) produce exactamente los mismos logits que base + adapter.

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel

tok  = AutoTokenizer.from_pretrained("HuggingFaceTB/SmolLM2-135M-Instruct")
base = AutoModelForCausalLM.from_pretrained("HuggingFaceTB/SmolLM2-135M-Instruct")
con_adapter = PeftModel.from_pretrained(base, "mini-sft/adapter")
x = tok("¿Capital de Italia?", return_tensors="pt")
with torch.no_grad():
    logits_adapter = con_adapter(**x).logits.clone()

fusionado = con_adapter.merge_and_unload()
fusionado.save_pretrained("mini-sft/fusionado"); tok.save_pretrained("mini-sft/fusionado")

plano = AutoModelForCausalLM.from_pretrained("mini-sft/fusionado")   # sin peft
with torch.no_grad():
    logits_plano = plano(**x).logits
print("diferencia máxima:", (logits_adapter - logits_plano).abs().max().item())
print("parámetros:", sum(p.numel() for p in plano.parameters()))
Pista / qué esperar

La diferencia máxima tiene que ser del orden de 1e-5 o menor (ruido de punto flotante), y el conteo de parámetros del fusionado es el del modelo base: el adapter desapareció dentro de los pesos. Mirá el tamaño de mini-sft/adapter contra mini-sft/fusionado en disco.

9. Fuentes y para seguir