← Todo el material
Curso Intermedio 23 de agosto de 2026

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

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étodoRango típico de LRPunto de partida
Full fine-tuning5e-6 – 3e-51e-5
LoRA / QLoRA (r 8–32)5e-5 – 3e-42e-4
LoRA con r alto (≥ 64)5e-5 – 1e-41e-4 (más capacidad, paso más corto)
DPO y preferencias (módulo 9)1e-6 – 5e-65e-6 (mucho más bajo, se verá allá)

Ojo: el default de SFTConfig es learning_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 de lora_alpha del 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.

SchedulerComportamientoCuándo
cosineBaja siguiendo un coseno hasta casi ceroEl default recomendado para SFT. Termina suave
linearBaja en línea recta (default de HF)Funciona; el cosine suele rendir un pelo mejor
constant_with_warmupSube y se queda planoCorridas exploratorias, continued pretraining
cosine_with_restartsCosine que reinicia varias vecesCorridas 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 ejemplos3 – 4 (y desconfía: el problema real es el dataset)
1.000 – 5.0002 – 3 — el caso típico
10.000 – 50.0001 – 2
> 100.0001, 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_modules del 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íntomaCausa probableQué tocar
Val loss sube mientras train bajaSobreajusteMenos épocas; EarlyStoppingCallback; subir lora_dropout
Loss errática, sube y baja sin convergerLR demasiado altoLR ÷ 3; verificar max_grad_norm=1.0
Loss NaN desde los primeros pasosfp16 con modelo bf16, o falta prepare_model_for_kbit_trainingbf16=True; agregar la preparación k-bit
Loss baja lentísimo, queda con pendienteLR demasiado bajoLR × 5; revisar que no siga en el default 2e-5
Loss casi no se mueve y el modelo responde igual que el basetarget_modules sin match, o alpha/r diminutoprint_trainable_parameters(); lora_alpha explícito
Train se desploma a ~0, val plana y altaDataset muy chicoMás datos; menos épocas; bajar r
Val loss buena pero el modelo responde mal en la prácticaFormato / chat template equivocadoDecodificar un batch (módulo 6)
Loss con escalones bruscos hacia arribaBatch efectivo muy chico, o un ejemplo tóxicoSubir gradient_accumulation_steps; buscar outliers
CUDA out of memory a mitad de caminoPico de secuencia largaoptim="paged_adamw_8bit"; bajar max_length; packing=True
Val loss mejora pero el modelo perdió capacidades generalesCatastrophic forgettingEs 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.

Corridar / alphaLRÉpocasBatch ef.Train loss finalVal loss finalNotas
v1-baseline16 / 322e-43160.620.94Val sube desde el paso 400
v2-2ep16 / 322e-42160.780.87Mejor. Sin quiebre
v3-r3232 / 641e-42160.740.86Empate con v2, adapter 2× más pesado
v4-lr1e416 / 321e-42320.810.89Peor. 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_rate en el default con LoRA. 2e-5 es 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_dataset solo 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_norm porque grad_norm sale alto. Estás quitando el airbag en vez de frenar. El grad_norm alto 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 fp16 en GPUs modernas. bf16 tiene el mismo rango dinámico que fp32 y es mucho más estable. fp16 solo 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

Fine-tuningLoRAHiperparámetrosLearning rateOverfitting

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.