En vez de cambiar los 7.000 millones de pesos de LLaMA, LoRA aprende solo la "diferencia" necesaria usando dos matrices pequeñas. El resultado: el 1% de los parámetros, el 95% del rendimiento. Esta lección lo explica desde la geometría hasta el código.
En fine-tuning completo, empezamos con los pesos del modelo preentrenado W y los modificamos directamente durante el entrenamiento. Al final, los pesos del modelo han cambiado de W a W_nuevo.
La diferencia entre el modelo original y el adaptado la podemos llamar ΔW (delta W — "el cambio en W"). Matemáticamente:
En fine-tuning completo, ΔW es una matriz del mismo tamaño que W — para una capa de atención 768×768 en BERT, ΔW también sería de 768×768 = 589.824 parámetros.
LoRA dice: "en la práctica, ΔW tiene rango bajo". Esto significa que, aunque ΔW sea una matriz de 768×768, el cambio real que importa vive en un espacio de dimensión mucho menor. Y si ΔW tiene rango bajo, podemos aproximarlo como el producto de dos matrices mucho más pequeñas:
Donde A tiene dimensiones d × r y B tiene dimensiones r × d, siendo r (el rango) un número mucho menor que d. Para BERT (d=768) con rango r=4: A sería 768×4 y B sería 4×768.
En el curso de álgebra lineal estudiaste que el rango de una matriz es el número de filas (o columnas) linealmente independientes. Una matriz de "rango bajo" es una que puede expresarse como combinación de pocas columnas base.
Más concretamente: una matriz de tamaño m × n con rango r puede expresarse siempre como el producto de una matriz m × r y otra r × n. Esto es la esencia de la descomposición de valor singular (SVD) — si un estudiante tiene el rango de esa lección frescos, sabrá que SVD factoriza W = U × Σ × V^T donde Σ es diagonal con los valores singulares.
Matriz W (3×3), ¿cuál es su rango?
W = [2 4 6]
[1 2 3]
[3 6 9]
Fila 1: [2, 4, 6] = 2 × [1, 2, 3]
Fila 2: [1, 2, 3] = 1 × [1, 2, 3]
Fila 3: [3, 6, 9] = 3 × [1, 2, 3]
¡Todas las filas son múltiplos de [1, 2, 3]! → rango = 1
Factorización de rango 1 (A × B):
A = [[2], [1], [3]] (columna de 3×1)
B = [[1, 2, 3]] (fila de 1×3)
A × B = [2×1 2×2 2×3] = [2 4 6]
[1×1 1×2 1×3] [1 2 3]
[3×1 3×2 3×3] [3 6 9] ✓
Parámetros originales: 3×3 = 9
Parámetros en A×B: 3×1 + 1×3 = 6 (ahorro del 33% — y con r=1)
Con r más pequeño que la dimensión, el ahorro escala mucho más.
El rango r (también escrito como lora_rank)
es el hiperparámetro más importante de LoRA. Controla cuánta capacidad de adaptación
tiene el modelo: cuán grande es el "módulo de aprendizaje" que añades.
W_Q en BERT: dimensiones 768 × 768 = 589.824 parámetros (fine-tuning completo) Con LoRA, añadimos A (768 × r) y B (r × 768): Parámetros de A = 768 × r Parámetros de B = r × 768 Total LoRA = 2 × 768 × r Calculemos para distintos valores de r: r = 1: 2 × 768 × 1 = 1.536 params → 0.26% del original r = 4: 2 × 768 × 4 = 6.144 params → 1.04% del original r = 8: 2 × 768 × 8 = 12.288 params → 2.08% del original r = 16: 2 × 768 × 16 = 24.576 params → 4.17% del original r = 32: 2 × 768 × 32 = 49.152 params → 8.33% del original r = 64: 2 × 768 × 64 = 98.304 params → 16.67% del original → Con r=4 o r=8, usamos solo el 1-2% de los parámetros originales. → En la práctica, r=4 u r=8 suele ser suficiente para la mayoría de tareas. → Tareas muy específicas o complejas pueden necesitar r=16 o r=32. → r > 64 raramente aporta beneficio.
La elección del rango afecta directamente la memoria y la calidad:
W (el modelo original, miles de millones de parámetros) está congelado. Solo A y B (miles de parámetros) se entrenan. La salida combina ambas ramas.
En el paper original de LoRA, los autores aplicaron los adaptadores a las matrices de proyección en el mecanismo de atención del Transformer. Del curso de Transformer recuerdas que la atención multi-cabeza tiene cuatro matrices de proyección:
En cada bloque Transformer, la atención multi-cabeza tiene: W_Q (Query): 768 × 768 = 589.824 params → proyecta x a queries W_K (Key): 768 × 768 = 589.824 params → proyecta x a keys W_V (Value): 768 × 768 = 589.824 params → proyecta x a values W_O (Output): 768 × 768 = 589.824 params → combina las cabezas Total por bloque de atención: 4 × 589.824 = 2.359.296 params BERT-base tiene 12 bloques → 12 × 2.359.296 = 28.311.552 params (42% de los 66M parámetros de BERT son solo estas matrices) Con LoRA r=8, adaptando W_Q y W_V (las más importantes): Por bloque: 2 × (2 × 768 × 8) = 2 × 12.288 = 24.576 params Total 12 bloques: 12 × 24.576 = 294.912 params entrenables Parámetros LoRA / Parámetros totales = 294.912 / 66.000.000 = 0.45% ¡Menos del 0.5% de los parámetros originales!
En la librería PEFT, el parámetro target_modules especifica qué matrices
adaptar. El valor típico para Transformers de tipo BERT es
["query", "value"] o ["q_proj", "v_proj"] dependiendo
de la nomenclatura interna del modelo.
En el diagrama de la sección 4 viste que la contribución de A × B se escala por
α/r antes de sumarse a la salida de W. Aquí α
(la letra griega alfa, que se lee "alfa") es un hiperparámetro llamado
lora_alpha en la librería PEFT.
La fórmula completa es:
El factor α/r controla cuánto "pesan" los adaptadores respecto a los pesos originales. En la práctica:
Sin factor de escala, la contribución de A×B crece con r:
Si r=4: A×B es 768×4 × 4×768 → produce salidas de tamaño 768×768
Si r=8: A×B es 768×8 × 8×768 → produce salidas de tamaño 768×768
→ El tamaño de la salida es igual, pero la "magnitud" de los valores
tiende a ser mayor con r más grande (más parámetros, más suma).
El factor α/r normaliza esto:
Con r=8 y alpha=16: escala = 16/8 = 2.0
Con r=4 y alpha=16: escala = 16/4 = 4.0
Con r=16 y alpha=16: escala = 16/16 = 1.0
Convención más común: alpha = 2 × r
r=4 → alpha=8 → escala = 8/4 = 2.0
r=8 → alpha=16 → escala = 16/8 = 2.0
r=16 → alpha=32 → escala = 32/16 = 2.0
(escala constante independiente de r — más fácil de comparar experimentos)
Otra convención: alpha = r (escala = 1.0 — sin amplificación)
¿Qué valor usar?
→ alpha = r es el más conservador (el paper original lo usa)
→ alpha = 2×r da un poco más de fuerza a los adaptadores
→ Experimentar entre r y 2r es lo habitual
lora_r=8 y
lora_alpha=16 (el doble). Si el modelo no converge o converge muy lento,
prueba lora_alpha=8 (igual al rango). Si la tarea es muy específica o
distinta del preentrenamiento, prueba lora_r=16.
La inicialización de las matrices A y B no es arbitraria — es crucial para que LoRA funcione sin perturbar el modelo al inicio del entrenamiento.
Al inicio del fine-tuning, queremos que la contribución de LoRA sea CERO: h = W·x + (α/r)·B·A·x Si B·A = 0 al inicio → h = W·x (solo el modelo original) ¿Cómo garantizar B·A = 0? · B se inicializa en todos ceros (B = 0) → B·A = 0 ✓ · A se inicializa con valores aleatorios pequeños (distribución Gaussiana) ¿Por qué A aleatoria y no también cero? Si tanto A como B fueran cero, el gradiente de A sería cero y nunca aprendería. Con A aleatoria, el gradiente de A es no-nulo y puede aprender desde el primer paso. Durante el entrenamiento: · Paso 1: B=0, A=aleatorio → ΔW=0 (igual al modelo original) · Paso 2: backward pass actualiza B y A · B deja de ser cero, empieza a desarrollar estructura · A también cambia ligeramente · Gradualmente, A×B representa el cambio específico para la tarea
La librería peft de HuggingFace implementa LoRA (y otras técnicas PEFT)
con muy poco código. Veamos el flujo completo para clasificación de sentimiento
con DistilBERT.
pip install peft transformers datasets evaluate accelerate
from peft import LoraConfig, get_peft_model, TaskType
from transformers import AutoModelForSequenceClassification
# Cargar el modelo base (sin LoRA todavía)
base_model = AutoModelForSequenceClassification.from_pretrained(
"distilbert-base-uncased",
num_labels=2
)
# Configuración de LoRA
lora_config = LoraConfig(
task_type=TaskType.SEQ_CLS, # SEQ_CLS = clasificación de secuencia
r=8, # rango (el hiperparámetro más importante)
lora_alpha=16, # factor de escala α
target_modules=["q_lin", "v_lin"], # matrices a adaptar en DistilBERT
lora_dropout=0.1, # dropout en los adaptadores (regularización)
bias="none", # no adaptar los biases
)
# Envolver el modelo base con los adaptadores LoRA
model = get_peft_model(base_model, lora_config)
# Ver cuántos parámetros son entrenables vs congelados
model.print_trainable_parameters()
Esta es la elegancia de LoRA: una vez que tienes el modelo envuelto con
get_peft_model(), el entrenamiento es idéntico al fine-tuning completo.
El Trainer no sabe (ni le importa) que la mayoría de los pesos están congelados.
from datasets import load_dataset
from transformers import (
AutoTokenizer, TrainingArguments, Trainer, DataCollatorWithPadding,
EarlyStoppingCallback
)
import evaluate
import numpy as np
## Dataset
dataset = load_dataset("imdb")
dataset["train"] = dataset["train"].select(range(5000))
dataset["test"] = dataset["test"].select(range(1000))
splits = dataset["train"].train_test_split(test_size=0.1, seed=42)
dataset["train"] = splits["train"]
dataset["validation"] = splits["test"]
## Tokenizar
tokenizer = AutoTokenizer.from_pretrained("distilbert-base-uncased")
def tok(b): return tokenizer(b["text"], truncation=True, max_length=256)
ds = dataset.map(tok, batched=True)
collator = DataCollatorWithPadding(tokenizer)
## Métricas
metric = evaluate.load("accuracy")
def compute_metrics(ep):
preds = np.argmax(ep[0], axis=-1)
return metric.compute(predictions=preds, references=ep[1])
## Training — argumentos idénticos al fine-tuning completo
args = TrainingArguments(
output_dir="./imdb-lora",
num_train_epochs=8,
per_device_train_batch_size=16,
per_device_eval_batch_size=32,
learning_rate=3e-4, # LoRA puede usar lr mayor que fine-tuning completo
warmup_steps=50,
weight_decay=0.01,
evaluation_strategy="epoch",
save_strategy="epoch",
load_best_model_at_end=True,
metric_for_best_model="eval_accuracy",
greater_is_better=True,
logging_steps=50,
report_to="none",
)
trainer = Trainer(
model=model, # el modelo con adaptadores LoRA
args=args,
train_dataset=ds["train"],
eval_dataset=ds["validation"],
tokenizer=tokenizer,
data_collator=collator,
compute_metrics=compute_metrics,
callbacks=[EarlyStoppingCallback(early_stopping_patience=2)],
)
trainer.train()
# Guardar solo los adaptadores LoRA (muy pequeño)
model.save_pretrained("./imdb-lora-adapters")
# Verificar el tamaño
import os
size_bytes = sum(
os.path.getsize(os.path.join("./imdb-lora-adapters", f))
for f in os.listdir("./imdb-lora-adapters")
)
print(f"Tamaño de los adaptadores: {size_bytes / 1e6:.1f} MB")
# Para usar el modelo más tarde:
from peft import PeftModel
base = AutoModelForSequenceClassification.from_pretrained(
"distilbert-base-uncased", num_labels=2
)
model_cargado = PeftModel.from_pretrained(base, "./imdb-lora-adapters")
print("Modelo LoRA cargado exitosamente")
# Para fusionar los pesos en producción (opcional, mejora velocidad de inferencia)
model_merged = model_cargado.merge_and_unload()
print("Adaptadores fusionados con el modelo base")
Los adaptadores solo ocupan 2.5 MB — comparado con los ~260 MB del modelo completo. Esto es conveniente para distribuir: puedes compartir solo los 2.5 MB de adaptadores y el usuario descarga el modelo base (que puede ya tenerlo) desde HuggingFace.
| Aspecto | Fine-tuning completo | LoRA (r=8) |
|---|---|---|
| Parámetros entrenables | 66.955.010 (100%) | 628.994 (0.93%) |
| Memoria GPU (DistilBERT) | ~1.4 GB | ~0.4 GB |
| Tiempo de entrenamiento | 187 segundos (5k ejemplos) | 94 segundos (5k ejemplos) |
| Accuracy en IMDB (5k train) | 91.11% | 90.89% |
| Tamaño del modelo guardado | ~260 MB (modelo completo) | ~2.5 MB (solo adaptadores) |
| Velocidad de inferencia | Normal | Idéntica después de merge_and_unload() |
| Learning rate típico | 2e-5 | 3e-4 (puede ser mayor) |
Ajusta la dimensión de la capa, el rango r y los módulos objetivo para ver exactamente cuántos parámetros se entrenan con LoRA en comparación con fine-tuning completo.
En la próxima lección: QLoRA y PEFT en práctica — combinar LoRA con cuantización de 4 bits para hacer fine-tuning de LLaMA-7B en Google Colab gratuito.