Fine-tuning — Módulo 9: preferencias y DPO en la práctica
Noveno módulo del Curso de Fine-tuning: cuándo el SFT no basta y hace falta entrenar con preferencias. Construir el dataset de pares prompt/chosen/rejected, código real con DPOTrainer de TRL, qué hace beta y el modelo de referencia, y ORPO y KTO como alternativas con menos requisitos de datos.
☰ Índice del contenido
- La tesis del módulo
- Cuándo el SFT no basta
- El dataset de pares
- De dónde salen los pares
- Cuántos pares y con qué balance
- El código: DPOTrainer
- Beta: qué tan lejos lo dejas ir
- El modelo de referencia y cuándo se puede omitir
- ORPO y KTO: las alternativas
- La tabla de decisión
- Cuando el DPO se pasa de rosca
- Errores comunes
- La frase para llevarse
- Qué viene
- Material relacionado
Con el módulo 8 cerramos el bloque intermedio: tienes un modelo afinado con SFT, medido contra un set de evaluación y con evidencia de que mejoró en lo que te importaba. Ese modelo hace lo que le pides. Y ahora aparece una clase de problema que el SFT no sabe resolver, y no porque le falten datos: porque le falta la forma del dato. El SFT te enseñó a mostrarle al modelo una respuesta correcta. Este módulo trata de lo que pasa cuando el problema no es “cuál es la respuesta correcta” sino “de estas dos respuestas correctas, cuál preferimos”. Arranca el bloque avanzado.
La tesis del módulo
El SFT enseña qué responder; las preferencias enseñan qué NO responder. Un dataset de pares le muestra al modelo dos salidas plausibles y le indica cuál ganó, y esa señal negativa —el
rejected— es información que ningún ejemplo de SFT contiene, por muchos ejemplos que juntes.
Ojo con el alcance de este módulo. La teoría de RLHF, el reward model, PPO y por qué DPO puede saltarse el reward model está explicada paso a paso en Transformers — Módulo 8: RLHF y alineamiento. No la voy a repetir. Aquí vamos a la parte que ese módulo no cubre: cómo se arma el dataset, qué código se escribe, qué hiperparámetros se tocan y cómo se nota cuando sale mal.
Cuándo el SFT no basta
La pregunta honesta antes de gastar una semana en esto: ¿tu problema es un problema de preferencias? La mayoría no lo son. Estas cuatro señales sí lo son.
El modelo acierta y responde feo. Contesta lo correcto pero con tres párrafos de preámbulo, disclaimers innecesarios o un tono que no es el de tu marca. Puedes escribir cien ejemplos de SFT con el tono bueno, pero el modelo sigue derivando hacia el estilo con el que fue preentrenado. El rejected le dice explícitamente “así no”.
Hay un modo de falla recurrente y específico. Inventa números cuando no los tiene, o cita una política que no existe, o responde en inglés cuando el usuario escribió en español. Un par donde el rejected es exactamente esa falla y el chosen es la versión buena ataca el modo de falla de frente.
Tienes feedback de producción y no lo estás usando. Pulgar arriba/abajo, ediciones del operador humano sobre la respuesta del modelo, respuestas regeneradas por el usuario. Eso ya es un dataset de preferencias esperando ser recogido, y es el más valioso porque viene de la distribución real.
Hay que enseñar a negarse. Que el modelo se abstenga en vez de inventar es difícil por SFT: si le muestras solo abstenciones se vuelve inútil, y si le muestras solo respuestas nunca aprende a abstenerse. Un par con chosen = abstención y rejected = invención sobre el mismo prompt le enseña dónde está la línea.
Y la señal en contra, que vale más que las cuatro anteriores: si tu modelo todavía falla en la tarea, no necesitas DPO, necesitas más SFT. DPO no enseña capacidades nuevas ni conocimiento nuevo. Ajusta preferencias sobre comportamientos que el modelo ya sabe producir. Aplicar DPO sobre un modelo que no domina la tarea es pulir una pieza que todavía no está fundida.
¿el modelo hace bien la tarea?
│
no ──────┴────── sí
│ │
▼ ▼
┌─────────┐ ¿la falla es de FORMA,
│ vuelve │ tono, verbosidad o un
│ al SFT │ modo de falla puntual?
└─────────┘ │
no ─────┴───── sí
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│ prompt / │ │ DPO │
│ RAG │ │ u ORPO │
└──────────┘ └──────────┘
El dataset de pares
El formato es simple y no tiene variantes. Tres campos:
{
"prompt": "¿Puedo devolver un producto abierto?",
"chosen": "Según la política vigente, un producto abierto se acepta dentro de 10 días si está completo y sin daño. Necesitas la boleta.",
"rejected": "¡Por supuesto! En Retail Ejemplo siempre queremos que estés feliz con tu compra, así que puedes devolver cualquier producto cuando quieras sin problema alguno."
}
Fíjate en lo que hace bueno ese par: las dos respuestas son plausibles. El rejected no es basura ni un error de tipeo, es una respuesta amable que un modelo genérico produciría con gusto y que en tu contexto es falsa y peligrosa. Un par donde el rejected es obviamente malo no le enseña nada al modelo, porque esa distinción ya la sabía.
Además, ambas respuestas deben compartir el mismo prompt exacto. La señal de DPO es la diferencia entre dos continuaciones del mismo prefijo; si los prompts difieren, estás comparando peras con manzanas y el gradiente aprende ruido.
De dónde salen los pares
| Fuente | Costo | Calidad de señal | Cuándo usarla |
|---|---|---|---|
| Feedback de producción | Bajo (ya lo tienes) | La más alta: distribución real | Siempre que tengas tráfico |
| Edición humana del output | Medio | Alta: el chosen es la corrección | Hay un operador revisando |
| Dos modelos compitiendo | Medio | Media-alta, depende del juez | No hay tráfico todavía |
| On-policy: tu modelo N veces | Medio | Alta: pares dentro de la distribución | Ya tienes el SFT andando |
| Datasets públicos (UltraFeedback, HH-RLHF) | Cero | Baja para tu dominio | Prototipo, aprender la mecánica |
| Reglas sintéticas | Bajo | Media, solo para el modo de falla | Falla específica y definible |
Las dos que más rinden merecen detalle.
Feedback de producción. Cada vez que un usuario regenera una respuesta y se queda con la segunda, tienes un par: la primera es rejected, la segunda es chosen. Cada vez que un operador edita el borrador del modelo antes de enviarlo, tienes un par: el borrador es rejected, el texto enviado es chosen. Instrumentar eso desde el día uno es la decisión de producto más rentable de todo el curso.
On-policy con tu propio modelo. Le pides a tu modelo afinado N respuestas al mismo prompt con temperature alta, y las rankeas —con humanos, con un juez LLM o con una regla verificable si la hay. La gracia es que los pares quedan on-policy: son textos que tu modelo genera de verdad, no textos de otro modelo. DPO funciona notoriamente mejor con pares on-policy, porque el gradiente empuja sobre la región del espacio donde el modelo realmente vive.
"""Generar pares on-policy con el modelo ya afinado (SFT del módulo 6).
Idea: N respuestas al mismo prompt, se rankean, el mejor par se guarda.
Los pares quedan DENTRO de la distribución del modelo, que es donde
DPO tiene más señal que dar.
"""
import json
from vllm import LLM, SamplingParams
# El modelo del módulo 6, ya mergeado (eso es el módulo 10).
modelo = LLM(model="./mi-modelo-sft", max_model_len=4096)
# temperature alta a propósito: si las N respuestas son idénticas,
# no hay nada que rankear. Buscamos diversidad, no la mejor respuesta.
sampling = SamplingParams(temperature=1.0, top_p=0.95,
n=4, max_tokens=512)
def generar_candidatos(prompts: list[str]) -> list[dict]:
salidas = modelo.generate(prompts, sampling)
return [
{"prompt": p, "candidatos": [o.text.strip() for o in s.outputs]}
for p, s in zip(prompts, salidas)
]
def armar_par(prompt: str, candidatos: list[str], puntuar) -> dict | None:
"""`puntuar(prompt, respuesta) -> float` es tu juez: humano,
LLM-as-a-judge con rúbrica, o una regla verificable."""
puntajes = [(puntuar(prompt, c), c) for c in candidatos]
puntajes.sort(reverse=True, key=lambda x: x[0])
mejor, peor = puntajes[0], puntajes[-1]
# Si el mejor y el peor puntúan casi igual, el par no tiene señal:
# descártalo en vez de meter ruido al entrenamiento.
if mejor[0] - peor[0] < 0.5:
return None
return {"prompt": prompt, "chosen": mejor[1], "rejected": peor[1]}
if __name__ == "__main__":
prompts = [l.strip() for l in open("prompts_reales.txt",
encoding="utf-8") if l.strip()]
pares = []
for item in generar_candidatos(prompts):
par = armar_par(item["prompt"], item["candidatos"], puntuar=mi_juez)
if par:
pares.append(par)
with open("pares_dpo.jsonl", "w", encoding="utf-8") as f:
for p in pares:
f.write(json.dumps(p, ensure_ascii=False) + "\n")
print(f"{len(pares)} pares con señal de {len(prompts)} prompts")
Ese if mejor[0] - peor[0] < 0.5: return None es la línea que más gente omite y la que más ensucia el entrenamiento. Un par donde ambas respuestas son igual de buenas le enseña al modelo una preferencia arbitraria. Botar esos pares reduce el dataset y sube la calidad.
Cuántos pares y con qué balance
Los órdenes de magnitud, presentados como tales y no como recetas exactas:
| Objetivo | Pares aproximados | Notas |
|---|---|---|
| Corregir un modo de falla puntual | 500 – 2.000 | Enfocados en ese modo de falla |
| Ajustar tono y verbosidad general | 2.000 – 10.000 | Cobertura amplia de tipos de prompt |
| Alineamiento general de un modelo | decenas de miles | Territorio de laboratorio, no de equipo |
Comparado con el SFT, DPO necesita menos datos para mover la aguja, porque cada par carga más información que un ejemplo suelto. Pero necesita más cuidado: un dataset de SFT con un 5% de ejemplos mediocres apenas se nota; un dataset de preferencias con un 5% de pares invertidos (donde el chosen es peor que el rejected) mete señal contradictoria que se paga en calidad.
Tres reglas de balance que valen más que el volumen:
- Cubre los tipos de prompt de tu tráfico real, no solo los casos donde el modelo falla. Si todos tus pares son sobre devoluciones, el modelo aprende un estilo de devoluciones y deriva en el resto.
- Largos comparables entre
chosenyrejected. Si tuchosenes sistemáticamente más largo, DPO aprende “más largo es mejor” y el modelo se vuelve verboso. Es el sesgo de longitud, y es el más fácil de introducir sin darse cuenta. Mide el largo promedio de ambas columnas antes de entrenar. - Mismo idioma y mismo registro en ambos lados. Si el
rejectedestá en inglés y elchosenen español, aprendiste un clasificador de idioma disfrazado de preferencia.
Cuidado: revisa la distribución de largos de tu dataset antes de la primera corrida. Dos líneas de pandas —
df.chosen.str.len().mean()ydf.rejected.str.len().mean()— te ahorran descubrir después de tres horas de GPU que entrenaste un modelo que responde el doble de largo porque tus respuestas buenas eran más largas.
El código: DPOTrainer
TRL (la librería de Hugging Face para entrenamiento con preferencias) expone DPOTrainer con una API muy parecida al SFTTrainer del módulo 6. El dataset se entrega con las tres columnas prompt, chosen y rejected, y el trainer se encarga del resto.
"""DPO sobre el adapter LoRA del SFT.
Requisitos del dataset: columnas prompt / chosen / rejected.
Con `is_encoder_decoder=False` (modelos causales tipo Llama/Qwen/Mistral)
TRL aplica el chat template si el dataset viene en formato conversacional.
Acá usamos el formato de strings planos, que es el más simple.
"""
import torch
from datasets import load_dataset
from peft import LoraConfig, PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer
from trl import DPOConfig, DPOTrainer
BASE = "Qwen/Qwen2.5-7B-Instruct"
ADAPTER_SFT = "./adapters/sft-v3"
tokenizer = AutoTokenizer.from_pretrained(BASE)
# 1) El modelo a entrenar: base + adapter del SFT, con el adapter
# ENTRENABLE (is_trainable=True). Sin ese flag el adapter se carga
# congelado y el entrenamiento no mueve ni un peso.
modelo = AutoModelForCausalLM.from_pretrained(
BASE,
dtype=torch.bfloat16, # transformers >= 4.56 (antes: torch_dtype)
device_map="auto",
attn_implementation="flash_attention_2", # si tu GPU lo soporta
)
modelo = PeftModel.from_pretrained(modelo, ADAPTER_SFT, is_trainable=True)
# 2) El dataset. TRL espera exactamente estas tres columnas.
ds = load_dataset("json", data_files="pares_dpo.jsonl", split="train")
ds = ds.train_test_split(test_size=0.05, seed=42)
config = DPOConfig(
output_dir="./adapters/dpo-v1",
# beta: el freno contra el modelo de referencia. La perilla del módulo.
beta=0.1,
# DPO usa learning rates MUCHO más bajos que el SFT. Un 2e-4 típico
# de SFT acá destruye el modelo en cientos de pasos.
learning_rate=5e-6,
lr_scheduler_type="cosine",
warmup_ratio=0.1,
# Cada ejemplo son DOS forward passes (chosen y rejected), y con
# modelo de referencia son CUATRO. El batch por device se achica.
per_device_train_batch_size=2,
gradient_accumulation_steps=8, # batch efectivo = 16
gradient_checkpointing=True, # cambia VRAM por tiempo
# Una o dos épocas. DPO sobreajusta rápido: más épocas es peor.
num_train_epochs=1,
# Largos: si el par se pasa, TRL trunca. Truncar el chosen a la mitad
# convierte una respuesta buena en una respuesta cortada.
max_length=1024,
max_prompt_length=512,
bf16=True,
logging_steps=10,
eval_strategy="steps",
eval_steps=50,
save_steps=50,
report_to="mlflow",
seed=42,
)
trainer = DPOTrainer(
model=modelo,
# ref_model=None con PEFT: TRL desactiva el adapter para obtener
# las probabilidades de referencia. Ver la sección de abajo.
ref_model=None,
args=config,
train_dataset=ds["train"],
eval_dataset=ds["test"],
processing_class=tokenizer,
)
trainer.train()
trainer.save_model("./adapters/dpo-v1")
Cuatro detalles de ese bloque que se pagan caro si se omiten.
is_trainable=True al cargar el adapter. Por defecto, PeftModel.from_pretrained carga el adapter en modo inferencia, congelado. El entrenamiento corre, la loss baja un poco por ruido numérico y el adapter guardado es idéntico al de entrada. Es un fallo silencioso y desconcertante.
learning_rate uno o dos órdenes de magnitud menor que el del SFT. El rango habitual de DPO está entre 1e-6 y 1e-5, contra el 1e-4–2e-4 de LoRA en SFT. La razón es que la loss de DPO empuja sobre diferencias de log-probabilidad, y esas diferencias crecen rápido: con un learning rate alto el modelo se aleja del original en pocos pasos y sale al otro lado.
Batch chico. Cada ejemplo requiere evaluar chosen y rejected, y también con el modelo de referencia. Son cuatro pasadas donde el SFT hacía una. Si tu SFT corría con per_device_train_batch_size=8, en DPO empieza en 2 y compensa con gradient_accumulation_steps.
Una época, máximo dos. DPO sobreajusta más rápido que el SFT, y el sobreajuste de DPO no se ve como una loss de validación que sube: se ve como un modelo que empieza a responder raro. Más abajo, las señales concretas.
Beta: qué tan lejos lo dejas ir
beta es el único hiperparámetro conceptualmente propio de DPO, y es el que decide el resultado. Lo que controla, dicho en una frase: cuánto castigo recibe el modelo por alejarse del modelo de referencia.
El modelo de referencia es una copia congelada del modelo con el que empezaste —tu modelo post-SFT. La loss de DPO no premia que el chosen tenga probabilidad alta en términos absolutos; premia que la tenga más alta que el rejected, comparado con lo que opinaba el modelo de referencia. beta es el factor que pesa esa comparación.
beta ALTO (0.5) beta BAJO (0.01)
correa corta correa larga
ref ref
● ●
╱ ╲ ╱ ╲
● ● ● ●
se mueve poco: se mueve mucho:
conserva calidad, aprende la preferencia,
aprende poco arriesga degradar
| beta | Comportamiento | Riesgo dominante |
|---|---|---|
| 0.5 – 1.0 | Casi no se mueve del modelo de referencia | No aprende la preferencia |
| 0.1 – 0.3 | Rango de trabajo habitual | Ninguno dominante; empieza acá |
| 0.05 – 0.1 | Aprende fuerte, se aleja notoriamente | Degradación de capacidades generales |
| < 0.05 | Se despega del original | Colapso: respuestas cortas y evasivas |
Regla práctica: empieza en beta=0.1, que es el valor por defecto de TRL y el que aparece en la mayoría de las recetas públicas. Si al evaluar ves que la preferencia no se movió, baja a 0.05. Si ves que la calidad general cayó, sube a 0.2 o 0.3. Y hazlo de a un cambio por vez, midiendo con el set de evaluación del módulo 8, porque cambiar beta y el learning rate juntos te deja sin saber cuál de los dos hizo qué.
El modelo de referencia y cuándo se puede omitir
En el DPO clásico corren dos modelos: el que entrenas y una copia congelada de referencia. Con full fine-tuning eso significa el doble de VRAM, y es una de las razones por las que DPO completo es caro.
Con LoRA hay un truco que lo hace gratis. El adapter es una modificación aditiva sobre pesos base congelados, así que desactivar el adapter devuelve exactamente el modelo de referencia. TRL lo sabe: si le pasas un modelo PEFT y ref_model=None, en vez de cargar una segunda copia desactiva temporalmente el adapter para calcular las log-probabilidades de referencia y lo vuelve a activar. Un solo modelo en VRAM.
DPO con full fine-tuning DPO con LoRA (ref_model=None)
┌──────────────┐ ┌──────────────────────┐
│ modelo train │ VRAM x1 │ base congelada │
└──────────────┘ │ ┌────────────────┐ │
┌──────────────┐ │ │ adapter on/off │ │
│ modelo ref │ VRAM x1 │ └────────────────┘ │
└──────────────┘ └──────────────────────┘
total x2 total x1 + adapter
adapter ON ──► modelo entrenado
adapter OFF ──► modelo de referencia
Esa es, en la práctica, la razón por la que casi todo el DPO que se hace fuera de laboratorios grandes se hace sobre LoRA. No es solo el ahorro del entrenamiento: es que el modelo de referencia deja de costar VRAM.
Ojo: el truco funciona porque el modelo de referencia correcto es el modelo antes del DPO, es decir el base más el adapter del SFT. Si cargas el adapter del SFT con
is_trainable=Truey sigues entrenando sobre él, desactivarlo te devuelve el base sin SFT, que no es tu referencia. Para ser estricto, la práctica más limpia es mergear el adapter del SFT al base (módulo 10), y entrenar el DPO sobre un adapter nuevo y vacío encima de ese modelo mergeado. Con datasets chicos la diferencia es menor, pero conviene saber qué estás haciendo.
ORPO y KTO: las alternativas
DPO tiene dos requisitos incómodos: necesita un modelo ya afinado con SFT (dos etapas de entrenamiento) y necesita pares, o sea dos respuestas al mismo prompt. Dos familias de métodos atacan cada uno de esos requisitos.
ORPO (Odds Ratio Preference Optimization) fusiona SFT y preferencias en una sola etapa. En vez de entrenar primero con SFT y después con DPO, entrena directamente sobre el dataset de pares con una loss que combina el objetivo de SFT sobre el chosen con un término que penaliza el rejected. No necesita modelo de referencia en absoluto —lo que baja aún más la VRAM— y sale un modelo entrenado en una sola pasada. Su hiperparámetro propio es lambda (beta en la API de TRL), que pesa cuánto manda el término de preferencia contra el de SFT.
KTO (Kahneman-Tversky Optimization) resuelve el otro requisito, y es el más útil en la práctica de casi cualquier equipo: no necesita pares. Le basta con ejemplos etiquetados como buenos o malos, sueltos, sin que exista una contraparte al mismo prompt. El nombre viene de la teoría prospectiva de Kahneman y Tversky: la loss modela la aversión a la pérdida, tratando lo malo con más peso que lo bueno, igual que las personas.
Por qué importa tanto: el feedback real de producción es exactamente eso. Nadie te entrega dos respuestas al mismo prompt; te entregan un pulgar arriba en una conversación y un pulgar abajo en otra. Convertir eso a pares obliga a descartar la mayoría de los datos. KTO los usa todos.
"""KTO: el dataset NO tiene pares. Tres columnas:
prompt / completion / label (bool).
"""
from datasets import load_dataset
from trl import KTOConfig, KTOTrainer
# {"prompt": "...", "completion": "...", "label": true}
# {"prompt": "...", "completion": "...", "label": false}
ds = load_dataset("json", data_files="feedback_produccion.jsonl",
split="train")
config = KTOConfig(
output_dir="./adapters/kto-v1",
beta=0.1,
learning_rate=5e-6,
# Los pesos compensan el desbalance del dataset. El feedback real
# suele traer muchos más positivos que negativos; estos factores
# evitan que el término mayoritario domine el gradiente.
desirable_weight=1.0,
undesirable_weight=1.0,
per_device_train_batch_size=2,
gradient_accumulation_steps=8,
num_train_epochs=1,
bf16=True,
)
trainer = KTOTrainer(
model=modelo,
args=config,
train_dataset=ds,
processing_class=tokenizer,
)
trainer.train()
Sobre desirable_weight y undesirable_weight: la documentación de TRL recomienda ajustarlos para que el producto del peso por la cantidad de ejemplos quede aproximadamente balanceado entre las dos clases. Si tienes 9.000 positivos y 1.000 negativos, subir undesirable_weight compensa el desbalance; dejarlo en 1.0 con esa proporción hace que el modelo casi ignore la señal negativa, que era justamente lo que querías capturar.
La tabla de decisión
| Método | Dato que necesita | Etapas | Modelo de referencia | VRAM relativa | Cuándo usarlo |
|---|---|---|---|---|---|
| SFT | prompt + respuesta ideal | 1 | No | x1 | Siempre primero. Enseña la tarea |
| DPO | prompt + chosen + rejected | 2 (SFT, luego DPO) | Sí (gratis con LoRA) | x1,3 | Tienes pares y un SFT bueno |
| ORPO | prompt + chosen + rejected | 1 (fusionada) | No | x1,2 | Tienes pares y quieres una sola pasada |
| KTO | prompt + salida + bueno/malo | 2 | Sí | x1,3 | Feedback suelto de producción |
Cómo leerla en la práctica. SFT no es opcional en ninguna ruta: es donde el modelo aprende la tarea. DPO es el default cuando ya tienes el SFT andando y consigues pares; es el más probado y el que más recetas públicas tiene. ORPO conviene cuando partes de cero con un dataset de pares y quieres ahorrarte una etapa completa de entrenamiento. KTO es el que hay que tener en el radar si operas un producto: convierte el pulgar arriba/abajo que ya estás recolectando en señal de entrenamiento sin trabajo de anotación adicional.
Cuando el DPO se pasa de rosca
El sobreajuste de DPO no se anuncia con una curva de validación que sube. Se anuncia con síntomas en el texto, y hay que saber reconocerlos porque el modelo puede verse mejor en las métricas de preferencia mientras se degrada en todo lo demás.
Respuestas cada vez más cortas y evasivas. El síntoma clásico. Si en tus pares el rejected tiende a ser una respuesta que se compromete con algo y el chosen una más prudente, el camino de menor resistencia para bajar la loss es no comprometerse con nada. El modelo termina respondiendo “depende de tu caso, te recomiendo consultar con un especialista” a todo.
Degradación en tareas que no tocaste. El modelo era bueno resumiendo y ahora resume peor, aunque ningún par hablaba de resúmenes. Es olvido catastrófico inducido por un beta bajo o demasiadas épocas: el modelo se movió tanto que arrastró capacidades que no estaban en discusión.
Repetición y bucles. Frases que se repiten dentro de la misma respuesta, o el modelo que no sabe terminar. Suele aparecer junto con las señales de beta muy bajo.
La brecha de log-probabilidades explota. TRL registra métricas durante el entrenamiento; la más útil es rewards/margins (la diferencia entre las recompensas implícitas del chosen y del rejected). Que suba es lo esperado. Que suba sin techo mientras logps/chosen baja en términos absolutos es la firma del problema: el modelo no está subiendo la probabilidad de lo bueno, está hundiendo la de lo malo y arrastrando todo con ella. Es un fenómeno bien documentado de DPO y la razón principal para no entrenar de más.
| Métrica de TRL | Qué es | Qué esperar | Alarma |
|---|---|---|---|
rewards/margins | Recompensa del chosen menos la del rejected | Sube y se aplana | Sube sin techo |
rewards/accuracies | Fracción de pares donde chosen > rejected | Sube a 0,7–0,9 | Llega a 1,0 en pocos pasos |
logps/chosen | Log-prob promedio del chosen | Baja poco | Se desploma |
logps/rejected | Log-prob promedio del rejected | Baja más que el chosen | — |
Cuidado:
rewards/accuraciescerca de 1,0 en las primeras decenas de pasos no es un éxito, es una advertencia. Significa que tus pares eran tan fáciles de distinguir que el modelo los separó de inmediato, y desde ahí en adelante el entrenamiento solo agranda la brecha sin aprender nada nuevo. Revisa el dataset: probablemente tusrejectedson basura obvia en vez de alternativas plausibles.
El antídoto es siempre el mismo y lo trajiste del módulo 8: evalúa el modelo con DPO contra el modelo con solo SFT en el mismo set, incluyendo casos que no tienen nada que ver con la preferencia que entrenaste. Si la preferencia mejoró y todo lo demás se mantuvo, ganaste. Si la preferencia mejoró y el resto bajó, subiste beta de menos o entrenaste de más.
Errores comunes
- Hacer DPO sobre un modelo que todavía no hace bien la tarea. DPO ajusta preferencias entre comportamientos que el modelo ya sabe producir; no enseña capacidades ni conocimiento. Si el modelo falla la tarea, el arreglo es más SFT y mejores datos, no una etapa extra de entrenamiento.
rejectedque es basura obvia. Un par donde la respuesta rechazada tiene errores de tipeo o está vacía no enseña nada, porque el modelo ya la descartaba. La señal está en pares donde ambas respuestas son plausibles y una es mejor por una razón que quieres transmitir.- Olvidar
is_trainable=Trueal cargar el adapter del SFT. El entrenamiento corre, la loss se mueve un poco y el adapter que guardas es el mismo que cargaste. No hay error ni warning: solo tres horas de GPU perdidas y un modelo idéntico al de ayer. - Usar el learning rate del SFT.
2e-4es normal en LoRA-SFT y es destructivo en DPO. El rango de DPO va de1e-6a1e-5. Con el LR alto verás la loss caer rápido y el modelo colapsar a respuestas cortas en unos cientos de pasos. chosensistemáticamente más largo querejected. El modelo aprende “largo = bueno” y se vuelve verboso en todo. Compara los largos promedio de las dos columnas antes de entrenar; si difieren mucho, el sesgo ya está en el dataset.- Entrenar tres o más épocas. DPO sobreajusta rápido y el sobreajuste se ve en el texto, no en la loss. Una época es el default sensato; dos ya requiere justificación medida.
- Descartar el feedback de producción por no venir en pares. Es el error de oportunidad más caro. Existe KTO exactamente para ese formato, y ese dato es el más valioso que tienes porque viene de la distribución real de tus usuarios.
La frase para llevarse
“El SFT le muestra al modelo el camino correcto; las preferencias le muestran el barranco al lado. Y si el barranco que le muestras es obvio, no le enseñaste nada.”
Qué viene
Módulo 10: mergear, cuantizar y servir. Tienes un adapter entrenado con SFT y otro con DPO, y en este momento no le sirven a nadie: son archivos de unos cientos de megas que solo saben cargarse encima de un modelo base en tu máquina de entrenamiento. Vamos a ver merge_and_unload() y la trampa de mergear sobre un modelo cuantizado, la exportación a GGUF y a AWQ/GPTQ, y la decisión de arquitectura que define tu costo de inferencia para siempre: servir el adapter en caliente con multi-LoRA sobre un solo modelo base, o servir el modelo mergeado con una copia por variante. Con la tabla de VRAM, latencia y costo para elegir con números.
Material relacionado
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.