Fine-tuning — Módulo 7: hiperparámetros y cómo leer las curvas
Séptimo módulo del Curso de Fine-tuning: learning rate y schedulers con rangos reales para LoRA y full FT, por qué las épocas son 1 a 3, cómo se calcula el batch efectivo y qué significa cada forma de la curva de loss. Con cinco curvas dibujadas, una tabla de diagnóstico síntoma-causa-arreglo y cómo registrar experimentos.
☰ Índice del contenido
- La tesis del módulo
- Learning rate: el hiperparámetro que manda
- Schedulers: el LR no es constante
- Épocas: entre una y tres, y punto
- Batch efectivo: la fórmula que hay que tener a mano
- Los hiperparámetros de segundo orden
- Las cinco curvas
- 1. Entrenamiento sano
- 2. Sobreajuste
- 3. Learning rate demasiado alto
- 4. Learning rate demasiado bajo
- 5. Dataset demasiado chico
- Tabla de diagnóstico
- Registrar los experimentos
- Errores comunes
- La frase para llevarse
- Qué viene
- Material relacionado
En el módulo 6 corriste un SFT completo con una configuración que funcionó. La pregunta que quedó colgando es la que importa: ¿cómo sabes que esos números eran los correctos? Un learning_rate=2e-4 que produce una curva bonita puede estar dejando calidad sobre la mesa, y una curva de loss que baja no significa que el entrenamiento haya salido bien. Este módulo convierte los hiperparámetros de valores copiados de un tutorial en decisiones que puedes justificar, y te enseña a leer la única señal que tienes durante el entrenamiento: la forma de las curvas.
La tesis del módulo
La curva de loss no te dice si el modelo sirve, pero te dice si el entrenamiento está roto. Son dos preguntas distintas y este módulo responde la segunda. Un entrenamiento sano es condición necesaria y no suficiente: sirve para descartar, no para aprobar. Aprobar es el trabajo del módulo 8.
Learning rate: el hiperparámetro que manda
El learning rate (LR, glosar: tasa de aprendizaje; el tamaño del paso que da el optimizador en cada actualización — cuánto se mueven los pesos en la dirección que indica el gradiente) es el único hiperparámetro cuyo error se paga siempre. Todos los demás toleran estar mal por un factor de dos; el LR mal por un factor de diez arruina la corrida.
La regla más importante del módulo es esta: LoRA usa un learning rate entre 10 y 100 veces mayor que el full fine-tuning. No es folclore, tiene una razón mecánica. En full FT estás moviendo pesos que ya están en un óptimo delicado del pre-entrenamiento y cualquier paso grande destruye información. En LoRA estás entrenando matrices que arrancan en cero (recuerda del módulo 5: B se inicializa en cero) y que además son pocas. No hay nada que destruir y hay mucho camino que recorrer. La propia documentación de TRL lo señala: al entrenar adapters se usa un learning rate más alto, del orden de 1e-4, porque solo se están aprendiendo parámetros nuevos.
| Método | Rango típico de LR | Punto de partida |
|---|---|---|
| Full fine-tuning | 5e-6 – 3e-5 | 1e-5 |
LoRA / QLoRA (r 8–32) | 5e-5 – 3e-4 | 2e-4 |
LoRA con r alto (≥ 64) | 5e-5 – 1e-4 | 1e-4 (más capacidad, paso más corto) |
| DPO y preferencias (módulo 9) | 1e-6 – 5e-6 | 5e-6 (mucho más bajo, se verá allá) |
Ojo: el default de
SFTConfigeslearning_rate=2e-5, que es un valor de full fine-tuning. Si entrenas LoRA sin tocarlo, el entrenamiento no falla — simplemente avanza tan poco que la loss baja apenas y el adapter resultante casi no cambia el modelo. Es el gemelo del error delora_alphadel módulo 5: dos formas distintas de entrenar en vano sin recibir ni un warning.
Schedulers: el LR no es constante
Un scheduler (glosar: planificador; la regla que cambia el learning rate a lo largo del entrenamiento, en vez de mantenerlo fijo) resuelve una tensión real: al principio conviene avanzar rápido, y al final conviene afinar con pasos chicos para asentarse en un mínimo en vez de rebotar alrededor.
SCHEDULERS: LR a lo largo del entrenamiento
LR
│ warmup
│ ╱────╲___
│ ╱ ╲──────___ cosine
│╱ ╲─────____
│ ╲────___
└──────────────────────────────────────► pasos
LR
│ ╱─╲
│ ╱ ╲──── linear
│╱ ╲────
│ ╲────
│ ╲────
└──────────────────────────────────────► pasos
LR
│ ╱────────────────────────────── constant
│ ╱ con warmup
│╱
└──────────────────────────────────────► pasos
warmup: los primeros pasos suben el LR desde ~0
Warmup (glosar: calentamiento; una fase inicial en que el LR sube gradualmente desde casi cero hasta su valor máximo, en vez de partir de golpe) existe porque los primeros pasos son los más peligrosos: los estados del optimizador Adam todavía no tienen estimaciones confiables de los momentos y un paso grande con información mala puede desestabilizar todo. Un 3 a 5 % de los pasos totales alcanza.
| Scheduler | Comportamiento | Cuándo |
|---|---|---|
cosine | Baja siguiendo un coseno hasta casi cero | El default recomendado para SFT. Termina suave |
linear | Baja en línea recta (default de HF) | Funciona; el cosine suele rendir un pelo mejor |
constant_with_warmup | Sube y se queda plano | Corridas exploratorias, continued pretraining |
cosine_with_restarts | Cosine que reinicia varias veces | Corridas largas; poco frecuente en SFT |
args = SFTConfig(
learning_rate=2e-4,
lr_scheduler_type="cosine", # baja suave hasta ~0 al final
warmup_ratio=0.03, # 3% de los pasos totales calentando
)
warmup_ratio es la forma cómoda: se expresa como fracción del total y se adapta solo si cambias el tamaño del dataset o las épocas. warmup_steps es un número absoluto y hay que recalcularlo a mano cada vez.
Épocas: entre una y tres, y punto
Una época (glosar: epoch; una pasada completa por todo el dataset de entrenamiento) es la unidad de repetición. Y en fine-tuning el rango sano es angosto: 1 a 3.
La razón es aritmética. En pre-entrenamiento se ve billones de tokens una sola vez, así que memorizar es imposible. En fine-tuning tienes quizá 3.000 ejemplos: en la tercera pasada el modelo ya vio cada uno tres veces y empieza a memorizarlos literalmente en vez de aprender el patrón que los une. Eso es sobreajuste (glosar: overfitting; cuando el modelo aprende los ejemplos concretos del entrenamiento en vez de la regla general, y por eso rinde bien en ellos y mal en datos nuevos).
| Tamaño del dataset | Épocas sugeridas |
|---|---|
| < 500 ejemplos | 3 – 4 (y desconfía: el problema real es el dataset) |
| 1.000 – 5.000 | 2 – 3 — el caso típico |
| 10.000 – 50.000 | 1 – 2 |
| > 100.000 | 1, y a veces ni siquiera completa |
Fíjate en la dirección: más datos, menos épocas. Lo que importa no son las pasadas sino el total de actualizaciones sobre ejemplos distintos. Con datasets grandes se llega al mismo número de pasos sin repetir nada, que siempre es mejor.
Cuidado: si tu modelo “no aprende lo suficiente” con 3 épocas, la salida no es subir a 8. Es revisar el learning rate, el
target_modulesdel módulo 5 y, sobre todo, el dataset del módulo 2. Las épocas de más no agregan información: solo repiten la que ya hay hasta memorizarla.
Batch efectivo: la fórmula que hay que tener a mano
batch efectivo =
per_device_train_batch_size×gradient_accumulation_steps× número de GPUs
El gradient accumulation (glosar: acumulación de gradientes; procesar varios mini-batches sumando sus gradientes y actualizar los pesos una sola vez al final, para simular un batch grande sin la memoria de un batch grande) es lo que hace posible entrenar con batch efectivo 32 en una GPU que solo aguanta 2 ejemplos a la vez.
BATCH EFECTIVO = 2 x 8 x 1 = 16
micro-batch grad. accumulation update
┌────┐
│ 2 │──► gradiente ──┐
└────┘ │
┌────┐ │
│ 2 │──► gradiente ──┤
└────┘ │ se SUMAN, no se
... │ aplican todavía
┌────┐ │
│ 2 │──► gradiente ──┤
└────┘ │
(8 veces) ▼
┌─────────┐
│ 16 ejs. │──► optimizer.step()
└─────────┘ UN paso real
El batch efectivo es el número que importa; el per_device_train_batch_size es solo lo que cabe en tu GPU. Dos configuraciones con el mismo batch efectivo dan resultados casi idénticos, aunque una use batch=8, accum=2 y la otra batch=2, accum=8. La primera es más rápida (menos overhead), la segunda usa menos VRAM. Ajusta el micro-batch a lo que aguante la tarjeta y compensa con acumulación.
Rango sano para SFT con LoRA: batch efectivo entre 8 y 64, con 16 o 32 como punto de partida. Batches muy chicos dan gradientes ruidosos y curvas erráticas; muy grandes desperdician cómputo y pueden generalizar peor con datasets chicos.
Ojo: cambiar el batch efectivo cambia el LR óptimo. La heurística clásica es que si duplicas el batch, puedes subir el LR alrededor de √2. Si vas a comparar dos corridas, cambia una cosa a la vez — batch o LR, no ambos — o no vas a poder atribuir la diferencia.
Los hiperparámetros de segundo orden
weight_decay. Penaliza pesos grandes para regularizar. Default de HF: 0.0. Para SFT con LoRA, 0.01 es el valor habitual y ayuda un poco contra el sobreajuste. No es una perilla que mueva mucho la aguja; setéala y olvídala.
gradient_checkpointing. Durante el forward, en vez de guardar todas las activaciones para el backward, guarda solo algunas y recalcula el resto. Cambia cómputo por memoria: alrededor de un 30 % más lento a cambio de una reducción sustancial de VRAM. En SFTConfig viene en True por defecto (a diferencia de TrainingArguments, donde es False). Si te sobra VRAM, apagarlo es velocidad gratis.
max_grad_norm. El gradient clipping (glosar: recorte de gradientes; si la norma del gradiente supera un umbral, se reescala hacia abajo para que no dé un paso descomunal) es el airbag contra los picos que producen NaN. Default 1.0, y déjalo ahí. Si ves en los logs que grad_norm está permanentemente muy por encima de 1.0, no subas el umbral: baja el learning rate, que es la causa.
optim. Para QLoRA, "paged_adamw_8bit" combina estados del optimizador en 8 bits con paginación a RAM en los picos (módulo 5). Alternativas válidas: "paged_adamw_32bit" si sospechas de inestabilidad numérica, "adamw_torch_fused" si tienes VRAM de sobra y quieres velocidad.
seed. Default 42. No es cosmético: sin fijarlo, dos corridas idénticas dan resultados distintos y no puedes atribuir una mejora al cambio que hiciste.
Las cinco curvas
Acá está el corazón del módulo. Durante el entrenamiento ves dos series: la train loss (sobre los datos que el modelo está viendo) y la validation loss (sobre el split que apartaste y que el modelo nunca entrena). La relación entre ambas es todo el diagnóstico.
1. Entrenamiento sano
loss
2.0│╲
│ ╲___ train ── ── ──
1.5│ ╲ ╲──__ val ──────
│ ╲ ╲──___
1.0│ ── ╲──__ ╲────___
│ ╲──___ ╲─────
0.5│ ╲────__________
│
└────────────────────────────────► pasos
0 200 400 600
Ambas bajan. La val queda un poco por ENCIMA
y se aplana al final. Esto es lo que quieres.
Las dos bajan, la validación queda algo por encima de la de entrenamiento (normal: el modelo siempre rinde mejor en lo que vio) y ambas se aplanan hacia el final. La brecha existe y es estable. Este es el objetivo.
2. Sobreajuste
loss
2.0│╲
│ ╲___ train ── ── ──
1.5│ ╲ ╲──__ val ──────
│ ╲ ╲──__ ______──────
1.0│ ── ╲──__ ╲───╱
│ ╲──__ punto de quiebre:
0.5│ ╲──__ val EMPIEZA A SUBIR
│ ╲──__
└────────────────────────────────► pasos
0 200 400 600
train sigue bajando, val se da vuelta.
Todo lo entrenado después del quiebre EMPEORA
el modelo. Ahí había que parar.
La firma inconfundible: train baja, val sube. El modelo dejó de aprender el patrón y empezó a memorizar ejemplos. Todo el cómputo posterior al quiebre no solo se desperdicia, degrada activamente el modelo.
El arreglo es directo: menos épocas, o dejar que el framework lo haga por ti.
"""Early stopping: parar cuando la val loss deja de mejorar."""
from transformers import EarlyStoppingCallback
args = SFTConfig(
eval_strategy="steps",
eval_steps=50,
save_strategy="steps",
save_steps=50, # debe coincidir con eval_steps
load_best_model_at_end=True,
metric_for_best_model="eval_loss",
greater_is_better=False, # en loss, menor es mejor
)
trainer = SFTTrainer(
...,
callbacks=[EarlyStoppingCallback(early_stopping_patience=3)],
)
load_best_model_at_end=True es el seguro barato: aunque la corrida siga de largo, al final te quedas con el checkpoint de mejor validación en vez del último. save_steps debe coincidir con eval_steps o el Trainer se queja.
3. Learning rate demasiado alto
loss
3.0│ ╱╲ ╱╲
│╲ ╱ ╲ ╱╲ ╱ ╲ ╱╲
2.0│ ╲ ╱ ╲╱ ╲╱ ╲ ╱ ╲
│ ╲╱ ╲╱ ╲
1.5│ ╲╱╲
│
1.0│
└────────────────────────────────► pasos
0 200 400 600
Loss errática, no converge, a veces sube.
El paso es tan grande que salta sobre el mínimo.
Caso extremo: NaN y se acabó.
El optimizador da pasos tan grandes que pasa por encima del mínimo y rebota. Baja el LR a la mitad o a un tercio. Si aparece NaN, además revisa que estés en bf16 y no en fp16 — fp16 tiene un rango dinámico mucho más estrecho y se desborda con facilidad en modelos modernos.
4. Learning rate demasiado bajo
loss
2.0│─────___
│ ────____
1.8│ ─────_____
│ ──────___
1.6│
│
1.0│
└────────────────────────────────► pasos
0 200 400 600
Baja, pero apenas. Después de 600 pasos
todavía queda MUCHO por bajar.
El modelo apenas se movió del base.
La loss baja, pero lentísimo, y al terminar las épocas todavía tiene pendiente. El síntoma en el mundo real es un modelo afinado que responde prácticamente igual que el base. Sube el LR por un factor de 3 a 10. Y si ya estás en 2e-4 con LoRA, sospecha de las otras dos causas del mismo síntoma: lora_alpha mal escalado o target_modules que no matcheó nada (módulo 5).
5. Dataset demasiado chico
loss
2.0│╲
│ ╲ train ── ── ──
1.5│ ╲╲ val ──────
│ ╲ ╲__________________________
1.0│ ╲ val PLANA y ALTA
│ ╲ desde el principio
0.5│ ╲__
│ ╲─────___
0.0│ ╲───____
└────────────────────────────────► pasos
0 200 400 600
train se desploma casi a cero (memorizó todo),
val nunca se movió. Brecha enorme.
No hay señal que aprender: hay ejemplos que copiar.
La train loss se desploma hacia cero mientras la validación se queda plana y alta desde temprano. Es sobreajuste inmediato: con 200 ejemplos, el modelo los memoriza en la primera época y no hay patrón general que extraer. La brecha enorme entre ambas curvas es la firma.
Ninguna perilla arregla esto. La solución está en el módulo 2: más datos, o más diversos, o mejor curados. Un mínimo defendible para una tarea acotada anda por los 500-1.000 ejemplos de calidad.
Tabla de diagnóstico
| Síntoma | Causa probable | Qué tocar |
|---|---|---|
| Val loss sube mientras train baja | Sobreajuste | Menos épocas; EarlyStoppingCallback; subir lora_dropout |
| Loss errática, sube y baja sin converger | LR demasiado alto | LR ÷ 3; verificar max_grad_norm=1.0 |
Loss NaN desde los primeros pasos | fp16 con modelo bf16, o falta prepare_model_for_kbit_training | bf16=True; agregar la preparación k-bit |
| Loss baja lentísimo, queda con pendiente | LR demasiado bajo | LR × 5; revisar que no siga en el default 2e-5 |
| Loss casi no se mueve y el modelo responde igual que el base | target_modules sin match, o alpha/r diminuto | print_trainable_parameters(); lora_alpha explícito |
| Train se desploma a ~0, val plana y alta | Dataset muy chico | Más datos; menos épocas; bajar r |
| Val loss buena pero el modelo responde mal en la práctica | Formato / chat template equivocado | Decodificar un batch (módulo 6) |
| Loss con escalones bruscos hacia arriba | Batch efectivo muy chico, o un ejemplo tóxico | Subir gradient_accumulation_steps; buscar outliers |
CUDA out of memory a mitad de camino | Pico de secuencia larga | optim="paged_adamw_8bit"; bajar max_length; packing=True |
| Val loss mejora pero el modelo perdió capacidades generales | Catastrophic forgetting | Es el módulo 8 |
Ojo: hay una fila en esa tabla que es distinta a todas las demás. “Val loss buena pero el modelo responde mal” no tiene arreglo de hiperparámetro, porque el problema no está en el entrenamiento. Y es más común de lo que parece — tanto que le dedicamos el módulo siguiente entero.
Registrar los experimentos
Con seis hiperparámetros que se pueden mover, la memoria no da. Después de cinco corridas no vas a recordar cuál tenía r=32, y sin ese registro no hay forma de saber qué te llevó al mejor resultado.
Nivel 1: una tabla. Un CSV o un Markdown en el repo, actualizado a mano. Es rudimentario y ya es infinitamente mejor que nada.
| Corrida | r / alpha | LR | Épocas | Batch ef. | Train loss final | Val loss final | Notas |
|---|---|---|---|---|---|---|---|
v1-baseline | 16 / 32 | 2e-4 | 3 | 16 | 0.62 | 0.94 | Val sube desde el paso 400 |
v2-2ep | 16 / 32 | 2e-4 | 2 | 16 | 0.78 | 0.87 | Mejor. Sin quiebre |
v3-r32 | 32 / 64 | 1e-4 | 2 | 16 | 0.74 | 0.86 | Empate con v2, adapter 2× más pesado |
v4-lr1e4 | 16 / 32 | 1e-4 | 2 | 32 | 0.81 | 0.89 | Peor. Dos cambios a la vez: no concluyente |
Fíjate en v4: cambió LR y batch. Cuando el resultado es peor, no hay forma de saber cuál de los dos lo causó. La corrida se desperdició. Una variable a la vez.
Nivel 2: tracking automático. El SFTConfig tiene report_to, que empuja las métricas a una herramienta de seguimiento sin que hagas nada más.
args = SFTConfig(
output_dir="./runs/v2-2ep",
run_name="v2-2ep-r16-lr2e4", # nombre descriptivo: se lee en la UI
report_to="trackio", # o "wandb", "tensorboard", "mlflow"
logging_steps=10,
eval_strategy="steps",
eval_steps=50,
)
Las opciones habituales: TensorBoard (local, cero configuración, suficiente para ver curvas), Trackio (el tracker liviano de Hugging Face, integrado nativamente en TRL), Weights & Biases (el estándar de la industria, comparación de corridas superponiendo curvas) y MLflow (si ya tienes ese stack; es el que cubre el curso de MLOps).
Un hábito que vale por sí solo: poner el run_name igual al nombre del directorio de salida y al del branch de git. Así, tres semanas después, la curva que estás mirando en la UI se conecta con el commit exacto que la produjo.
Errores comunes
- Dejar el
learning_rateen el default con LoRA.2e-5es un valor de full fine-tuning. Con LoRA produce un entrenamiento que técnicamente ocurre y prácticamente no cambia nada. - Cambiar dos hiperparámetros en la misma corrida. El resultado no es atribuible y quemaste la GPU. Una variable a la vez, siempre.
- Entrenar sin split de validación. Sin
eval_datasetsolo ves la train loss, que baja siempre — incluso mientras el modelo se degrada. Es entrenar con los ojos vendados. - Perseguir la val loss más baja posible. La val loss mide predicción del siguiente token en tu distribución, no utilidad. Un modelo con val loss 0.86 puede ser peor que uno con 0.89 (módulo 8).
- Subir
max_grad_normporquegrad_normsale alto. Estás quitando el airbag en vez de frenar. Elgrad_normalto es un síntoma del LR, no la enfermedad. - No fijar la
seed. Dos corridas idénticas dan resultados distintos y cualquier mejora del ±2 % que midas puede ser ruido. - Usar
fp16en GPUs modernas.bf16tiene el mismo rango dinámico que fp32 y es mucho más estable.fp16solo en tarjetas anteriores a Ampere que no soportan bf16.
La frase para llevarse
“La curva de loss es un detector de humo, no un certificado de calidad. Te avisa cuando algo se está quemando; nunca te dice que la comida quedó buena.”
Qué viene
Módulo 8: evaluar si de verdad sirvió, y el cierre del bloque intermedio. La val loss bajó, las curvas se ven sanas, el adapter pesa 80 MB — y todavía no sabes si tu modelo es mejor que el base. Vamos a montar el holdout que nunca se tocó, hacer la comparación A/B contra el modelo base con exactamente el mismo prompt (y evitar la trampa de compararlo contra un base mal formateado, que infla tus resultados), elegir métricas según el tipo de tarea, usar LLM-as-judge para salidas libres, y detectar el catastrophic forgetting: el modelo que mejoró en tu tarea y empeoró en todo lo demás. Con una tabla de resultados de un caso donde el fine-tune no valió la pena.
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.