Fine-tuning — Módulo 5: LoRA y QLoRA a fondo
Quinto módulo del Curso de Fine-tuning: qué hace LoRA por dentro. La actualización de bajo rango ΔW = B·A y la hipótesis del rango intrínseco, los hiperparámetros que de verdad mueven la aguja (r, lora_alpha y su escalado, dropout, target_modules), y QLoRA con NF4, double quantization y paged optimizers. Cuánta VRAM se ahorra y cuánta calidad se paga.
☰ Índice del contenido
- La tesis del módulo
- Qué entrena el fine-tuning completo, y qué entrena LoRA
- Por qué funciona: la hipótesis del rango intrínseco
- Los hiperparámetros que importan
- r — el rank
- lora_alpha y el escalado
- lora_dropout
- target_modules — dónde se enchufa
- LoRA en código
- QLoRA: entrenar sobre un modelo comprimido
- Las tres piezas
- QLoRA en código
- Qué cuesta y qué se ahorra
- Errores comunes
- La frase para llevarse
- Qué viene
- Material relacionado
En el módulo 4 elegiste modelo base y contaste la VRAM, y la conclusión fue incómoda: entrenar todos los pesos de un 8B en una GPU de consumidor no da. La cuenta no cerraba por los optimizadores, no por los pesos. Este módulo abre la caja de la técnica que hace que sí dé — LoRA — y lo hace en serio: no “se agregan unas matrices chicas”, sino qué matrices, de qué dimensiones, dónde se enchufan, cómo se escala su aporte y qué pasa cuando el modelo base viene comprimido a 4 bits. En Transformers módulo 7 vimos el qué; acá va el cómo y, sobre todo, el con qué números.
La tesis del módulo
LoRA no aproxima el fine-tuning completo: aproxima el cambio que el fine-tuning completo le haría al modelo. Y ese cambio, empíricamente, es de rango bajo — cabe en dos matrices flacas. Entender que lo que entrenas es ΔW y no W es lo que convierte los hiperparámetros de LoRA de perillas mágicas en decisiones con lógica.
Qué entrena el fine-tuning completo, y qué entrena LoRA
En un fine-tuning completo, cada matriz de pesos W del modelo se convierte en W + ΔW después de entrenar. Ese ΔW (glosar: delta W; la diferencia entre los pesos finales y los pesos originales, o sea todo lo que el entrenamiento efectivamente cambió) es una matriz del mismo tamaño que W. Para una proyección de atención de un modelo 8B, W puede ser de 4096 × 4096: unos 16.7 millones de números que hay que guardar, y por los que hay que propagar gradientes.
La observación de LoRA, del paper de Hu et al. (2021), es que ese ΔW gigante no es realmente gigante en información. Se puede factorizar:
ΔW = B · A, donde
Aes der × dyBes ded × r, conrmucho menor qued.
r es el rank (glosar: rango; el número de dimensiones independientes que tiene una matriz — cuánta información distinta cabe en ella, más allá de su tamaño aparente). Si d = 4096 y r = 16, el producto B · A sigue siendo una matriz de 4096 × 4096, pero para construirla solo necesitas guardar y entrenar 2 × 4096 × 16 = 131.072 números en vez de 16.7 millones. Es 128 veces menos.
LoRA: la factorización de bajo rango
entrada x (dimensión d)
│
├──────────────────────┐
│ │
▼ ▼
┌──────────┐ ┌────────────┐
│ W │ │ A: r x d │ baja a rank r
│ d x d │ └─────┬──────┘
│ CONGELADA│ │ (dimensión r)
│ 16.7 M │ ▼
│ params │ ┌────────────┐
│ │ │ B: d x r │ sube de vuelta
└─────┬────┘ └─────┬──────┘
│ │ x (alpha / r)
│ W·x │ B·A·x
└───────► ( + ) ◄────┘
│
▼
h = W·x + (alpha/r)·B·A·x
Entrenables: solo A y B → 131 K params (128x menos)
Dos detalles del diagrama que importan y casi nunca se explican:
A se inicializa aleatoria y B se inicializa en cero. Por eso, al empezar el entrenamiento, B · A = 0 y el modelo se comporta exactamente igual que el base. LoRA arranca siendo la identidad y se va separando. Es una propiedad valiosa: no hay un “salto” inicial que desestabilice.
El camino LoRA es un cuello de botella. La entrada baja de d dimensiones a r, y vuelve a subir. Todo lo que el adapter puede aprender tiene que pasar por ese embudo de r dimensiones. Ahí está, literalmente, la capacidad de tu fine-tune.
Por qué funciona: la hipótesis del rango intrínseco
La pregunta obvia es por qué una matriz tan flaca alcanza. La respuesta se llama hipótesis del rango intrínseco (glosar: intrinsic rank hypothesis; la conjetura de que adaptar un modelo pre-entrenado a una tarea nueva requiere muchos menos grados de libertad de los que el modelo tiene disponibles).
La intuición: el pre-entrenamiento ya dejó en el modelo casi todo lo que necesita — gramática, hechos, razonamiento, estilos. Cuando lo afinas para que responda tickets de soporte en español formal, no le estás enseñando el idioma ni la lógica. Le estás pidiendo un reajuste direccional: enfatizar circuitos que ya existen, apagar otros. Ese reajuste es de baja dimensionalidad porque no es conocimiento nuevo, es reponderación.
Ojo: este es también el límite de LoRA, y explica una de las frustraciones más comunes del curso. Si tu tarea de verdad requiere conocimiento que el modelo base no tiene (un dominio técnico completamente ausente del pre-entrenamiento, un idioma que apenas vio), el embudo de
rdimensiones no da abasto y ningún rank razonable lo arregla. Ese es el escenario donde full fine-tuning o continued pretraining ganan — y donde, como vimos en el módulo 1, a veces la respuesta correcta era RAG.
Los hiperparámetros que importan
LoraConfig tiene una docena larga de campos. Cuatro deciden el resultado.
r — el rank
Es el ancho del embudo, y por lo tanto la capacidad del adapter. Más alto = más expresivo, más parámetros entrenables, más VRAM y más riesgo de sobreajuste con datasets chicos.
La curva de retorno es marcadamente decreciente. Subir de 8 a 16 se nota; de 16 a 32 se nota menos; de 64 a 128 casi nunca se nota salvo en tareas muy exigentes, y sí se nota la factura de memoria.
| Tipo de tarea | Rank sugerido | Por qué |
|---|---|---|
| Formato y tono (responder en JSON, adoptar una voz de marca) | 4 – 8 | Es reponderación pura, casi no necesita capacidad |
| Clasificación / extracción con etiquetas fijas | 8 – 16 | Tarea acotada, el espacio de salidas es chico |
| Asistente de dominio (soporte, legal, clínico) sobre vocabulario que el base ya vio | 16 – 32 | El punto de partida por defecto |
| Razonamiento o estilo complejo, cadenas largas, formatos anidados | 32 – 64 | Necesita más grados de libertad reales |
| Dominio muy lejano del pre-entrenamiento, idioma poco representado | 64 – 128 (+ use_rslora) | Zona de rendimientos decrecientes; evalúa full FT |
Regla de trabajo: parte en r=16, mide, y solo sube si la evaluación del módulo 8 te dice que el modelo se queda corto. Subir el rank “por si acaso” es la forma más común de gastar VRAM sin ganar nada.
lora_alpha y el escalado
Este es el parámetro peor entendido de LoRA. lora_alpha no es un tamaño: es un factor de escala. La salida del adapter se multiplica por alpha / r antes de sumarse:
h = W·x + (alpha / r) · B·A·x
O sea, alpha regula cuánto pesa el adapter frente al modelo congelado. Y como se divide por r, la consecuencia práctica es esta: si mantienes la razón alpha/r constante y cambias r, el aporte del adapter mantiene su magnitud, y el learning rate que te servía sigue sirviendo. Ese es exactamente el motivo del diseño.
Las convenciones del ecosistema:
| Convención | Escalado resultante | Cuándo |
|---|---|---|
alpha = 2r (ej. r=16, alpha=32) | 2.0 | El default de facto. Buen punto de partida |
alpha = r (ej. r=16, alpha=16) | 1.0 | Más conservador; útil si el entrenamiento se va a las pampas |
alpha = 4r o más | 4.0+ | Adapter agresivo; sube el riesgo de inestabilidad |
use_rslora=True | alpha / √r | Cuando usas r alto (≥ 64): estabiliza y deja que el rank alto rinda |
Cuidado: el default de
LoraConfigen PEFT esr=8, lora_alpha=8— escalado 1.0, más conservador que las recetas que circulan. Si copiasr=64de un tutorial y dejaslora_alphaen su valor por defecto, tu escalado cae a 0.125 y el adapter casi no influye: vas a entrenar horas para obtener un modelo que responde igual que el base. Setea siemprelora_alphaexplícitamente.
Sobre use_rslora: la documentación de PEFT lo describe como reemplazar alpha/r por alpha/√r, lo que “estabiliza los adapters y aumenta el potencial de rendimiento al usar un r más alto”. Con r chico da lo mismo; con r grande es la diferencia entre que el rank alto sirva o solo consuma memoria.
lora_dropout
Dropout (glosar: técnica de regularización que, durante el entrenamiento, apaga al azar una fracción de las activaciones en cada paso, para que la red no dependa de neuronas puntuales) aplicado a la entrada del adapter. Combate el sobreajuste, que en fine-tuning es el enemigo número uno porque los datasets son chicos.
- Dataset grande (> 10.000 ejemplos):
0.0–0.05. - Dataset típico (1.000 – 10.000):
0.05–0.1. El valor0.05es el más usado. - Dataset chico (< 1.000):
0.1, y prioriza recortar épocas antes que subir dropout.
target_modules — dónde se enchufa
La decisión con más impacto después de r, y la que más gente deja en el default sin pensar. En un bloque Transformer los candidatos son:
DÓNDE SE ENCHUFAN LOS ADAPTERS LORA
┌─────────────────────────────────────────┐
│ BLOQUE TRANSFORMER │
│ │
│ ATENCIÓN │
│ ┌────────┬────────┬────────┬───────┐ │
│ │ q_proj │ k_proj │ v_proj │o_proj │ │
│ └────────┴────────┴────────┴───────┘ │
│ qué buscar qué contenido mezcla│
│ │
│ MLP (donde vive gran parte del saber)│
│ ┌──────────┬────────────┬──────────┐ │
│ │ gate_proj│ up_proj │down_proj │ │
│ └──────────┴────────────┴──────────┘ │
│ ~2/3 de los parámetros del bloque │
└─────────────────────────────────────────┘
solo q,v ──► barato, cambia el foco
q,k,v,o ──► atención completa
+ las MLP ──► "all-linear", máxima capacidad
Los nombres (q_proj, gate_proj, etc.) son los de las familias Llama, Mistral y Qwen. Varían por arquitectura: en modelos tipo BERT son query y value, en GPT-2 son c_attn. Por eso PEFT acepta target_modules="all-linear", que resuelve los nombres automáticamente aplicando LoRA a todas las capas lineales.
| Configuración | Params entrenables (8B, r=16) | Cuándo usarla |
|---|---|---|
["q_proj", "v_proj"] | ~0.08 % | El LoRA original. Máximo ahorro, tareas de formato y tono |
["q_proj","k_proj","v_proj","o_proj"] | ~0.17 % | Atención completa. Buen equilibrio si la tarea cambia a qué atiende el modelo |
"all-linear" (atención + MLP) | ~0.5 % | El default recomendado hoy. Estilo QLoRA: mejor calidad por un costo aún marginal |
La evidencia del paper de QLoRA es bastante clara en este punto: el número de módulos alcanzados importa más que el rank. Un r=8 sobre todas las capas lineales suele superar a un r=64 sobre solo q y v. La razón es estructural — las MLP concentran cerca de dos tercios de los parámetros del bloque y son donde se almacena buena parte del conocimiento factual; dejarlas fuera es amputar el adapter.
Ojo:
target_modules="all-linear"en PEFT excluye deliberadamente la capa de salida (lm_head) para no romper el modelo. Si tu tarea requiere tokens nuevos en el vocabulario, esos embeddings hay que agregarlos conmodules_to_save=["embed_tokens", "lm_head"], y eso sí engorda el adapter de megabytes a gigabytes.
LoRA en código
"""Configuración de LoRA con PEFT. Los cuatro parámetros que deciden
el resultado, con los valores explícitos (nunca confiar en defaults)."""
from peft import LoraConfig, get_peft_model
from transformers import AutoModelForCausalLM
modelo = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen2.5-7B-Instruct",
dtype="bfloat16",
device_map="auto",
)
lora = LoraConfig(
r=16, # ancho del embudo: capacidad del adapter
lora_alpha=32, # escalado alpha/r = 2.0 (SIEMPRE explícito)
lora_dropout=0.05, # regularización: dataset de miles de ejemplos
target_modules="all-linear", # atención + MLP; PEFT resuelve los nombres
bias="none", # no entrenar los bias: aporta poco, complica el merge
task_type="CAUSAL_LM", # imprescindible: define cómo PEFT envuelve el modelo
)
modelo = get_peft_model(modelo, lora)
modelo.print_trainable_parameters()
# trainable params: 40,370,176 || all params: 7,655,986,688 || trainable%: 0.5273
Ese print_trainable_parameters() es el chequeo de cordura más barato del curso. Córrelo siempre. Si te dice trainable%: 0.0000 o un número absurdo, target_modules no encontró nada y estás a punto de entrenar durante horas un modelo que no va a cambiar. Es el bug silencioso más caro de PEFT.
Para inspeccionar los nombres reales de las capas de un modelo que no conoces:
# Los nombres varían por arquitectura. Antes de escribir target_modules
# a mano, míralos:
for nombre, _ in modelo.named_modules():
if any(k in nombre for k in ("proj", "attn", "mlp", "fc")):
print(nombre)
# ...model.layers.0.self_attn.q_proj
# ...model.layers.0.mlp.gate_proj
QLoRA: entrenar sobre un modelo comprimido
QLoRA (Dettmers et al., 2023) es LoRA aplicado sobre un modelo base cuantizado a 4 bits. El paper resume su aporte en tres piezas y una promesa: afinar un modelo de 65B en una sola GPU de 48 GB “preservando el rendimiento del fine-tuning completo en 16 bits”.
La pregunta que todos hacen primero: ¿cómo se entrena sobre pesos de 4 bits, si 4 bits no tienen resolución para representar un gradiente? La respuesta es que no se entrenan. Los pesos de 4 bits están congelados — nunca reciben una actualización. Lo que se entrena son las matrices A y B, que viven en bf16 aparte. El modelo cuantizado solo participa en el forward y deja pasar el gradiente hacia el adapter:
FORWARD BACKWARD
x ──► [ W en NF4 ] gradiente
descomprime ATRAVIESA W
a bf16 al vuelo (no se acumula:
para el matmul W está congelada)
│ │
▼ ▼
W·x (bf16) llega a B y A
+ │
(alpha/r)·B·A·x ◄── bf16 ───────────┘
│ AQUÍ sí se
▼ actualizan pesos
salida
Los 4 bits son un formato de ALMACENAMIENTO.
El cálculo siempre ocurre en bf16.
Esa última línea es la clave conceptual del módulo: la cuantización de QLoRA es de almacenamiento, no de cómputo. Cada bloque de pesos se descomprime a bf16 justo antes de su multiplicación y se descarta enseguida. Pagas algo de velocidad (esa descompresión cuesta) y ahorras muchísima memoria. Es el mismo mecanismo que vimos en GPU módulo 4, acá puesto al servicio del entrenamiento.
Las tres piezas
NF4 (4-bit NormalFloat). El paper lo define como “un tipo de dato que es teóricamente óptimo en información para pesos distribuidos normalmente”. La idea: los pesos de una red entrenada se distribuyen aproximadamente como una campana de Gauss centrada en cero. Un formato de 4 bits ofrece 16 valores posibles; NF4 los coloca de modo que cada uno reciba aproximadamente la misma masa de probabilidad — muchos niveles apiñados cerca de cero, donde están casi todos los pesos, y pocos en las colas. Compáralo con FP4, que reparte sus 16 niveles según una lógica de punto flotante genérica y desperdicia resolución donde no hay pesos.
Double quantization. Cuantizar requiere guardar constantes de escala por bloque (típicamente uno cada 64 pesos). Esas constantes son floats de 32 bits y suman: alrededor de 0.5 bits por parámetro, que en un 8B son cientos de MB. La double quantization cuantiza las constantes de cuantización, bajando ese overhead a algo del orden de 0.1 bits por parámetro. Es un ahorro de segundo orden — decenas a cientos de MB en un modelo mediano — pero sale gratis en calidad y a veces es lo que hace que el modelo entre justo.
Paged optimizers. El uso de memoria durante el entrenamiento tiene picos: una secuencia larga o un batch desafortunado disparan las activaciones y revientan la GPU con un CUDA out of memory a los 40 minutos de entrenamiento. Los paged optimizers usan memoria unificada de NVIDIA para que los estados del optimizador se muevan a RAM del sistema cuando la VRAM se aprieta, igual que un sistema operativo pagina a disco. El entrenamiento se pone lento en ese momento en vez de morir. En el Trainer de Hugging Face se activan con optim="paged_adamw_8bit" o optim="paged_adamw_32bit".
QLoRA en código
"""Carga en 4-bit para QLoRA. Los defaults de BitsAndBytesConfig NO son
los de QLoRA: hay que setear nf4 y double quant explícitamente."""
import torch
from transformers import AutoModelForCausalLM, BitsAndBytesConfig
from peft import LoraConfig, prepare_model_for_kbit_training, get_peft_model
bnb = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4", # default real: "fp4". NF4 es mejor.
bnb_4bit_use_double_quant=True, # default real: False. Actívalo.
bnb_4bit_compute_dtype=torch.bfloat16, # dtype del cálculo, no del storage
)
modelo = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen2.5-7B-Instruct",
quantization_config=bnb,
device_map="auto",
)
# Prepara el modelo cuantizado para entrenar: castea normas y embeddings
# a fp32 (son sensibles), activa gradient checkpointing y deja que el
# gradiente fluya hacia el adapter.
modelo = prepare_model_for_kbit_training(modelo)
lora = LoraConfig(
r=16,
lora_alpha=32,
lora_dropout=0.05,
target_modules="all-linear", # estilo QLoRA: todas las lineales
bias="none",
task_type="CAUSAL_LM",
)
modelo = get_peft_model(modelo, lora)
modelo.print_trainable_parameters()
Cuidado con los defaults de
BitsAndBytesConfig. La documentación de transformers es explícita:bnb_4bit_quant_typeviene en"fp4"ybnb_4bit_use_double_quantenFalse. Si escribes soloload_in_4bit=Truecreyendo que “eso es QLoRA”, estás usando FP4 sin double quantization — que funciona, pero no es la técnica del paper ni su calidad. Los tres campos van explícitos, siempre.
Un detalle de rendimiento: bnb_4bit_compute_dtype=torch.bfloat16 no afecta el tamaño en disco (eso lo fija load_in_4bit), afecta la velocidad. Dejarlo en su default float32 hace la descompresión y el matmul notablemente más lentos sin ganar calidad apreciable. En GPUs Ampere o posteriores (RTX 30xx/40xx, A100, H100) usa siempre bfloat16.
Qué cuesta y qué se ahorra
Órdenes de magnitud para un modelo de 7-8B con secuencias de ~1024 tokens, presentados como tales: la cifra exacta depende de tu GPU, largo de secuencia, batch y versión de librerías.
| Enfoque | VRAM aproximada | Velocidad relativa | Calidad relativa |
|---|---|---|---|
| Full fine-tuning (bf16 + AdamW) | ~90 – 120 GB | 1.0× | Referencia |
| LoRA sobre base en bf16 | ~20 – 28 GB | ~0.9× | Muy cercana a la referencia |
| QLoRA (NF4 + double quant) | ~8 – 12 GB | ~0.6 – 0.75× | Muy cercana a la referencia |
Las dos lecturas que hay que sacar de esa tabla:
El ahorro grande no viene de los pesos. Viene de los estados del optimizador. AdamW guarda dos momentos por parámetro entrenable; en full FT eso son ~8 GB por cada mil millones de parámetros solo en momentos, más los gradientes. Al congelar más del 99 % del modelo, ese costo desaparece casi entero. Los pesos en 4 bits ahorran otro tanto encima, pero la mordida principal es el optimizador.
QLoRA cambia memoria por tiempo, no por calidad. Es más lento que LoRA en bf16 porque hay que descomprimir en cada forward. La pérdida de calidad, en cambio, es la parte que el paper de QLoRA argumenta que es despreciable frente al fine-tuning en 16 bits. La decisión práctica se vuelve simple: si tu modelo cabe en bf16 con LoRA, usa eso y entrena más rápido; si no cabe, QLoRA es lo que te deja entrenar.
Ojo: hay un costo de QLoRA que no aparece en la tabla y muerde después. El adapter que entrenaste vio un modelo base cuantizado; si en producción lo sirves sobre el base en bf16, el par no es exactamente el que entrenaste y puede haber una degradación pequeña. Se maneja evaluando en la misma configuración en que vas a servir. El tema completo es el módulo 10.
Errores comunes
- Dejar
lora_alphaen el default y subirr. Escaladoalpha/rde 0.125, adapter que casi no influye, horas de GPU quemadas para obtener el modelo base con pasos extra. Setealora_alphaexplícitamente, siempre. - Apuntar solo a
q_projyv_projpor costumbre. Es el default histórico del paper original de 2021, no la mejor práctica de hoy. Las MLP concentran la mayoría de los parámetros;all-linearcuesta poco más y suele rendir claramente mejor. - No correr
print_trainable_parameters(). Sitarget_modulesno matchea nada — típico al cambiar de familia de modelo — el entrenamiento corre perfecto, la loss baja algo, y el modelo resultante es idéntico al base. Dos líneas te ahorran el día. - Creer que
load_in_4bit=Truees QLoRA. Sinbnb_4bit_quant_type="nf4"estás en FP4, y sinbnb_4bit_use_double_quant=Trueestás pagando el overhead completo de las constantes. Tres campos, no uno. - Subir el rank para arreglar un dataset malo.
r=128sobre 300 ejemplos mediocres no produce un modelo mejor: produce un modelo que memorizó 300 ejemplos mediocres. El rank amplifica lo que hay en los datos, y lo que hay en los datos lo decidiste en el módulo 2. - Olvidar
prepare_model_for_kbit_trainingen QLoRA. Sin esa llamada, las capas de normalización quedan en un dtype que produce entrenamientos inestables o loss enNaN, y el diagnóstico es confuso porque el error aparece lejos de la causa.
La frase para llevarse
“LoRA no entrena el modelo: entrena la corrección. Y como la corrección cabe en dos matrices flacas, el modelo entero puede quedarse congelado — incluso comprimido a cuatro bits — mientras tú afinas el medio por ciento que de verdad cambia.”
Qué viene
Módulo 6: tu primer SFT completo, de punta a punta. Se acabó la teoría. Un script único y comentado que carga un modelo en 4 bits, prepara el dataset con el chat template correcto, arma el SFTTrainer con la LoraConfig que acabas de entender, entrena, guarda el adapter e infiere con él cargado. Con las verificaciones previas que evitan quemar una noche de GPU — decodificar un batch para ver exactamente qué está leyendo el modelo, correr diez pasos de prueba — y con la revisión honesta de Unsloth: qué acelera de verdad y qué te amarra.
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.