1El objetivo: predecir el siguiente token, otra vez
El SFT no inventa un objetivo nuevo. Reutiliza el modelado de lenguaje causal: para cada posición $t$ de la secuencia, el modelo produce una distribución sobre el vocabulario y se penaliza que no coincida con el token real que venía después. Con los pesos $\theta$, la pérdida es la log-verosimilitud negativa promediada sobre los tokens que sí cuentan (el conjunto $U$ de posiciones no enmascaradas):
Que la suma vaya solo sobre $U$ — los tokens no enmascarados — es todo el módulo. Cada término $-\log p_\theta(x_t \mid x_{<t})$ es la sorpresa del modelo ante el token correcto: si ya le asignaba alta probabilidad, aporta poco; si lo consideraba improbable, aporta mucho al gradiente para corregirlo.
2Cross-entropy por posición
En cada posición la pérdida es una entropía cruzada entre la distribución objetivo $y_t$ (qué token debía salir) y la predicha $p_\theta$. Como el objetivo es un único token conocido, $y_t$ es un vector one-hot:
La suma sobre el vocabulario $v$ colapsa a un solo término porque $y_{t,v}=1$ únicamente
en el token correcto y $0$ en el resto. Por eso, en la práctica, la cross-entropy de un
modelo de lenguaje es simplemente menos el log-probabilidad del token verdadero. Esa
es la cantidad que torch.nn.functional.cross_entropy calcula internamente.
3Desplazamiento: input_ids ↔ labels
El modelo predice la posición $t+1$ a partir de todo lo anterior. Para alinear predicción y
objetivo, las etiquetas se desplazan una posición respecto a la entrada: el logit de
la posición $t$ se compara con el token de la posición $t+1$. En la mayoría de
implementaciones (incluida la de HuggingFace) pasas labels = input_ids y la
propia clase hace el shift por dentro:
# entrada y objetivo apuntan a la misma secuencia; el shift lo hace el modelo input_ids = [ <s>, El, gato, duerme, . ] labels = [ <s>, El, gato, duerme, . ] # internamente se comparan así (logits en t → token en t+1): logits[<s>] → El logits[El] → gato logits[gato] → duerme logits[duerme] → .
t con el token de t+1.Entender el desplazamiento es lo que hace inteligible el enmascarado: cuando "ignoramos" un token del prompt, lo que ponemos a $-100$ es su label, no su input. El token del prompt sigue estando en el contexto (el modelo lo lee), pero no se le exige predecirlo.
4La máscara -100 (ignore_index)
Aquí está la decisión central del SFT. Si entrenamos sobre toda la conversación, el
modelo también aprende a generar el texto del system y del user —
algo que nunca tendrá que producir, y que lo empuja a "recitar" el prompt. Para evitarlo se
marcan esos tokens con la etiqueta especial -100, que la función de pérdida
trata como ignore_index y salta:
label = -100 (ignore_index) en los tokens de prompt/system ⇒ no contribuyen al gradiente. Completion-only = enmascarar todo menos los tokens del assistant.
El efecto: los tokens del prompt siguen visibles como contexto (el modelo los usa
para condicionar su respuesta), pero no producen error ni gradiente. Solo se aprende
a predecir la respuesta del assistant. A esto se le llama completion-only loss
(o assistant_only_loss según la librería).
El valor -100 no es mágico: es el ignore_index por defecto de nn.CrossEntropyLoss en PyTorch. Cualquier posición cuya etiqueta sea -100 se excluye del promedio de la pérdida. Enmascarar el prompt es, literalmente, poner -100 en sus labels y dejar intactos los del assistant.
5Conteo efectivo: solo cuentan los tokens de la respuesta
Como la pérdida se promedia únicamente sobre las posiciones no enmascaradas, conviene nombrar ese conjunto. Sea $U$ el conjunto de posiciones de completion (los tokens del assistant, no enmascarados). La pérdida efectiva es:
Dos consecuencias prácticas. Primero, si un ejemplo tiene un prompt larguísimo y una respuesta corta, con completion-only casi todo el ejemplo se ignora y solo pesan unos pocos tokens — algo a vigilar al balancear el dataset. Segundo, el número que ves como "val loss" no es comparable entre secuencia-completa y completion-only, porque el denominador $|U|$ es distinto: hay que comparar generaciones, no solo el escalar.
6El paso de gradiente
Con la pérdida definida, el entrenamiento es descenso de gradiente estándar: se calcula $\nabla_\theta \mathcal{L}$ por backpropagation y se actualizan los pesos con una tasa de aprendizaje $\eta$ (más los refinamientos del optimizador, típicamente AdamW):
Como los tokens enmascarados no aportan a $\mathcal{L}$, tampoco aportan a $\nabla_\theta \mathcal{L}$: el gradiente que ajusta los pesos proviene exclusivamente de los tokens del assistant. Esa es la razón mecánica por la que completion-only reduce la tendencia del modelo a repetir el prompt.
7Toda la conversación vs solo la respuesta
| Estrategia | Qué tokens tienen label | Cuándo conviene |
|---|---|---|
| Secuencia completa | Todos (system + user + assistant) | Continual pretraining, estilo, aprender el formato de la conversación |
| Completion-only | Solo el turno del assistant; el resto a -100 | Seguir instrucciones: no queremos que el modelo genere el prompt |
| Multi-turno | Cada turno de assistant; los turnos de user a -100 | Diálogos con varios intercambios en un mismo ejemplo |
En instruction-tuning, completion-only es el default sensato. Entrenar la secuencia completa tiene usos (aprender un formato muy específico, adaptar estilo), pero para "responde a lo que te piden" enmascarar el prompt casi siempre da mejores generaciones.
8Sequence packing y lo que hace TRL
Los ejemplos tienen longitudes distintas; rellenar cada uno hasta la longitud máxima con padding desperdicia cómputo. El sequence packing concatena varios ejemplos en una sola secuencia de longitud fija para llenar la ventana, marcando los límites para que la atención no cruce ejemplos. Menos padding = más tokens útiles por batch.
SFTTrainer(TRL): envuelve el bucle de entrenamiento de HuggingFace para SFT — tokeniza, aplica el chat template, opcionalmente hace packing y exponecompletion_only_loss/assistant_only_loss.DataCollatorForCompletionOnlyLM: el collator que construye loslabelsponiendo a-100todo lo anterior al marcador de respuesta del assistant, dejando con label solo la completion.
El packing y el completion-only interactúan con el chat template del tokenizer (Módulo 1). Si el marcador de inicio de respuesta que le pasas al collator no coincide exactamente con los tokens que produce el template, la máscara quedará mal y podrías enmascarar de más o de menos. Siempre imprime el tensor de labels de un batch y verifica dónde están los -100 antes de lanzar un entrenamiento largo.
9La idea en código
Un esqueleto mínimo con TRL. Lo esencial no es la API exacta (cambia entre versiones) sino el patrón: template del tokenizer, un collator que enmascara el prompt, y el trainer que corre el objetivo autoregresivo solo sobre la completion.
from trl import SFTTrainer, DataCollatorForCompletionOnlyLM from transformers import AutoModelForCausalLM, AutoTokenizer model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen3-0.6B") tok = AutoTokenizer.from_pretrained("Qwen/Qwen3-0.6B") # marcador tras el cual empieza la respuesta del assistant en el chat template response_template = "<|im_start|>assistant\n" collator = DataCollatorForCompletionOnlyLM(response_template, tokenizer=tok) trainer = SFTTrainer( model=model, train_dataset=dataset, # cada fila: mensajes {role, content} data_collator=collator, # pone -100 en todo lo previo a la respuesta # completion_only_loss=True # según versión de TRL ) trainer.train() # VERIFICAR la máscara antes de entrenar en serio: batch = collator([tok(example)]) print(batch["labels"][0]) # los tokens del prompt deben aparecer como -100
El SFT ocurre offline (en tu máquina o un servidor). El resultado —pesos con el LoRA fusionado— se cuantiza (Módulo 9) y ese artefacto es el que corre en el teléfono. El dispositivo nunca entrena.
- Instala el entorno:
pip install trl peft transformers. - Prepara un dataset conversacional pequeño (formato
messages). - Entrena con
SFTTrainerde TRL en modo secuencia completa. - Repite en modo completion-only con
DataCollatorForCompletionOnlyLMy verifica, imprimiendo el tensor delabels, que los tokens del prompt son-100. - Compara la val loss y unas cuantas generaciones entre ambos modos.
El entrenamiento SFT vive en Python (TRL/PEFT): no hay equivalente maduro en Rust y forzarlo cuesta más de lo que ahorra.
El papel de Rust llega después, en la inferencia on-device (Módulos 9–10), cuando el modelo ya entrenado se empaqueta para el teléfono.
Lecturas y recursos
✦Ejercicios
De menor a mayor complejidad. Los cuatro primeros construyen las piezas del último.
Formatea un ejemplo con el chat template
Toma un par {instrucción, respuesta} y aplícale tokenizer.apply_chat_template de Qwen3-0.6B. Imprime el texto renderizado y los input_ids.
Entrega: texto con los tokens especiales visibles + longitud en tokens. Pista: apply_chat_template(messages, tokenize=False) para verlo como string.
Cross-entropy a mano sobre una posición
Dado un vector de logits y el id del token correcto, calcula $\mathcal{L}_t = -\log p_\theta(x_t \mid x_{<t})$ en NumPy y verifica que coincide con cross_entropy de PyTorch.
Entrega: los dos valores y su diferencia (≈0). Pista: softmax estable, luego -log(p[target]).
Construye los labels con desplazamiento
Para una secuencia corta, arma manualmente el par (input_ids, labels) y demuestra con un forward que el logit de la posición t se compara con el token t+1.
Entrega: tabla posición → (predicho, objetivo). Pista: HuggingFace hace el shift interno si pasas labels=input_ids; compáralo con hacerlo tú.
Enmascara el prompt con -100
Usa DataCollatorForCompletionOnlyLM con el response_template correcto y verifica que todos los tokens del prompt quedan como -100 y solo la respuesta conserva su id.
Entrega: el tensor de labels impreso, marcando dónde empieza la completion. Pista: si nada queda a -100, el response_template no coincide con los tokens del chat template.
SFT: secuencia completa vs completion-only
Fine-tunea Qwen3-0.6B sobre un set de instrucciones pequeño de DOS formas — loss de secuencia completa vs completion-only (máscara -100 del prompt) — usando SFTTrainer de TRL con DataCollatorForCompletionOnlyLM; verifica imprimiendo el tensor de labels que los tokens del prompt son -100; luego compara la val loss y un puñado de generaciones para mostrar que completion-only reduce el loro del prompt.
Entrega: tabla de loss lado a lado + diff cualitativo de generaciones + la máscara de labels impresa probando el límite. Pista: recuerda que la val loss no es comparable entre ambas (denominador $|U|$ distinto): el juez son las generaciones.
- El SFT reutiliza el objetivo autoregresivo: cross-entropy token a token, $-\log p_\theta(x_t \mid x_{<t})$.
- El logit de la posición $t$ predice el token $t+1$: por eso
labelsva desplazado respecto ainput_ids. - Enmascarar con
-100(ignore_index) saca al prompt del gradiente sin sacarlo del contexto. - Completion-only: solo el turno del assistant tiene label; reduce que el modelo recite el prompt.
SFTTrainer+DataCollatorForCompletionOnlyLMautomatizan la máscara; el packing reduce padding. Siempre verifica los-100.