Módulo 03 · Entrenamiento

SFT — Supervised Fine-Tuning

El fine-tuning supervisado toma un modelo base y le enseña a responder con ejemplos de entrada → respuesta esperada. La mecánica sigue siendo la misma predicción del siguiente token del Módulo 0: solo cambian los pesos para que, ante un prompt, el modelo asigne más probabilidad a la respuesta que queremos. La decisión de diseño que marca la diferencia es sobre qué tokens se calcula la pérdida: normalmente quieres que se calcule sobre la respuesta correcta (el turno del assistant), no sobre la pregunta que escribe el usuario final. Este módulo fija esa mecánica y la matemática de la máscara.

Al terminar sabrás

  • Escribir el objetivo autoregresivo del SFT y por qué es cross-entropy token a token.
  • Explicar el desplazamiento entre input_ids y labels (predecir la posición t+1).
  • Enmascarar con -100 (ignore_index) para que el prompt no contribuya al gradiente.
  • Distinguir entrenar toda la conversación vs solo la respuesta del assistant (completion-only).
  • Reconocer qué hacen SFTTrainer de TRL, DataCollatorForCompletionOnlyLM y el sequence packing.

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):

$$ \mathcal{L}(\theta) = -\frac{1}{|U|}\sum_{t \in U} \log p_\theta(x_t \mid x_{<t}) $$

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:

$$ \mathcal{L}_t = -\sum_{v} y_{t,v}\,\log p_\theta(v \mid x_{<t}); \quad \text{con } y \text{ one-hot esto es } -\log p_\theta(x_t \mid x_{<t}) $$

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_idslabels

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] → .
Predecir "el siguiente" significa comparar el logit de 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:

La regla en una línea

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).

Nota · por qué -100

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:

$$ \mathcal{L} = \dfrac{1}{|U|}\sum_{t\in U}\mathcal{L}_t, \qquad U = \{\,t : \text{label}_t \neq -100\,\} $$

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):

$$ \theta \leftarrow \theta - \eta\,\nabla_\theta \mathcal{L} $$

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

EstrategiaQué tokens tienen labelCuándo conviene
Secuencia completaTodos (system + user + assistant)Continual pretraining, estilo, aprender el formato de la conversación
Completion-onlySolo el turno del assistant; el resto a -100Seguir instrucciones: no queremos que el modelo genere el prompt
Multi-turnoCada turno de assistant; los turnos de user a -100Diá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 expone completion_only_loss / assistant_only_loss.
  • DataCollatorForCompletionOnlyLM: el collator que construye los labels poniendo a -100 todo lo anterior al marcador de respuesta del assistant, dejando con label solo la completion.
Cuidado

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
En el teléfono

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.

Cómo practicar
  1. Instala el entorno: pip install trl peft transformers.
  2. Prepara un dataset conversacional pequeño (formato messages).
  3. Entrena con SFTTrainer de TRL en modo secuencia completa.
  4. Repite en modo completion-only con DataCollatorForCompletionOnlyLM y verifica, imprimiendo el tensor de labels, que los tokens del prompt son -100.
  5. Compara la val loss y unas cuantas generaciones entre ambos modos.
Herramientas: Python · trl · peft · transformers
Rust · on-device

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.

Ejercicio 1 · calentamiento

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.

Ejercicio 2

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]).

Ejercicio 3

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ú.

Ejercicio 4

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.

Ejercicio 5 · ejercicio top

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.

Puntos clave
  • 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 labels va desplazado respecto a input_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 + DataCollatorForCompletionOnlyLM automatizan la máscara; el packing reduce padding. Siempre verifica los -100.