Módulo 05 · Eficiencia

LoRA y PEFT

Reentrenar todos los pesos de un modelo grande es caro y frágil. LoRA propone algo más modesto y mucho más práctico: no tocas los millones de parámetros base — los dejas congelados — y entrenas dos matrices pequeñas de bajo rango que representan el cambio. El resultado es un adaptador portátil de pocos megabytes que se enchufa sobre el modelo original. Este módulo fija la matemática de esa descomposición y una regla que ahorra tiempo: más módulos adaptados no es mejor; casi siempre es sobreajuste y memoria desperdiciada.

Al terminar sabrás

  • Explicar qué pesos quedan congelados y qué matrices nuevas ($B$, $A$) se añaden.
  • Elegir r, lora_alpha y lora_dropout entendiendo qué controla cada uno.
  • Decidir target_modules: all-linear vs. solo la atención, y por qué importa.
  • Distinguir un adaptador separado de un modelo fusionado con merge_and_unload.
  • Calcular el ratio de compresión de parámetros entrenables y leer la intuición de "rango intrínseco bajo".

1La idea: el cambio de pesos vive en pocas dimensiones

Un fine-tuning completo aprende una actualización $\Delta W$ para cada matriz de pesos y la suma al valor entrenado previamente: $W = W_0 + \Delta W$. LoRA parte de una observación empírica: aunque $W_0$ sea enorme, la actualización que hace falta para especializar el modelo tiene un "rango intrínseco" bajo. Es decir, $\Delta W$ se puede aproximar bien con el producto de dos matrices delgadas. En lugar de aprender los $d\times k$ números de $\Delta W$, aprendes solo los de esas dos matrices.

El truco es puramente algebraico. Para un peso congelado $W_0 \in \mathbb{R}^{d\times k}$, LoRA aprende:

$$ \Delta W = BA, \qquad B\in\mathbb{R}^{d\times r}, \quad A\in\mathbb{R}^{r\times k}, \quad r \ll \min(d,k) $$

$W_0$ nunca cambia: sus gradientes están apagados. Los únicos parámetros que reciben gradiente son los de $B$ y $A$. Como $r$ (el rank) es pequeño — típicamente 8, 16 o 32 frente a dimensiones de miles — el número de parámetros entrenables se desploma.

2El forward con factor de escala

En cada capa adaptada, la salida se calcula como el peso base más el aporte de bajo rango, ponderado por un factor de escala $\alpha/r$:

$$ h = W_0 x + \Delta W x = W_0 x + \frac{\alpha}{r}\,B A x $$

El escalar $\alpha$ es lora_alpha. Dividir por $r$ mantiene la magnitud del aporte estable cuando cambias el rank: si duplicas $r$, el factor $\alpha/r$ se reduce a la mitad, de modo que subir el rank no infla automáticamente la contribución del adaptador. Una convención común es fijar $\alpha = 2r$ (o $\alpha = r$) y ajustar desde ahí.

Nota · inicialización

$A$ se inicializa aleatoria y $B$ en cero: $A \sim \mathcal{N}(0,\sigma^2)$, $B = 0 \Rightarrow \Delta W = 0$ al inicio. Esto es deliberado: el entrenamiento arranca exactamente en el modelo base, sin perturbarlo, y a partir de ahí $B$ se despega del cero. Nada de "romper" el modelo en el paso 0.

3Cuántos parámetros ahorras

Un fine-tuning completo de una matriz entrena $d\cdot k$ parámetros. LoRA entrena solo los de $B$ y $A$: $r\,d + r\,k = r\,(d+k)$. El ratio de compresión por matriz adaptada es:

$$ |\theta_{\text{LoRA}}| = r\,(d+k) \quad\text{vs}\quad d\cdot k \;\Longrightarrow\; \text{ratio} = \frac{r(d+k)}{dk} $$

Con $d=k=4096$ y $r=8$, ese ratio es $\approx 0{,}39\%$: entrenas menos de cuatro de cada mil parámetros de esa capa. Por eso el adaptador cabe en unos pocos megabytes y el entrenamiento entra en GPUs modestas. Esa economía es la razón de ser de PEFT (parameter-efficient fine-tuning): mover el mínimo de pesos posible.

4Qué capas adaptar: target_modules

LoRA no se aplica a todo el modelo, sino a un conjunto elegido de capas lineales. En un Transformer los candidatos naturales son las proyecciones de la atención (q_proj, k_proj, v_proj, o_proj) y las del bloque MLP (gate_proj, up_proj, down_proj).

ElecciónQué adaptaCuándo
Solo atenciónq_proj, k_proj, v_proj, o_projMenos parámetros, buen punto de partida; suele bastar para estilo/formato.
Atención + MLPLo anterior + gate_proj, up_proj, down_projMás capacidad; tareas que exigen "conocimiento" nuevo.
all-linearTodas las capas lineales del modeloCómodo y frecuente, pero el que más infla parámetros.
Cuidado

Adaptar más módulos no es adaptar mejor. Cada matriz nueva agrega parámetros entrenables, riesgo de sobreajuste y peso al adaptador. Empieza por la atención, mide, y sube a MLP solo si la métrica de eval lo justifica. La regla de oro: el codo de la curva, no el máximo de módulos.

lora_dropout aplica dropout a la entrada del aporte de bajo rango; es una regularización barata que ayuda cuando el conjunto de datos es pequeño y el adaptador tiende a memorizar.

5Adaptador separado vs. modelo fusionado

Durante el entrenamiento y también en despliegue puedes mantener el adaptador aparte: cargas el modelo base una vez y le enchufas los pesos de $B$ y $A$. Esto habilita algo potente: entrenar varios adaptadores sobre un mismo base (uno por tarea, cliente o idioma) y cambiar entre ellos sin duplicar los gigabytes del modelo original.

Cuando ya no necesitas esa flexibilidad y quieres cero overhead en inferencia, fusionas el adaptador dentro de los pesos base con merge_and_unload(). La operación es la misma suma del forward, resuelta una sola vez:

$$ W_{\text{merged}} = W_0 + \frac{\alpha}{r}\,BA $$

El resultado es una sola matriz: desaparece la ruta paralela de LoRA y la inferencia corre exactamente como el modelo original, sin la multiplicación extra por $B$ y $A$. A cambio pierdes la modularidad — ya no puedes desenchufar el adaptador. Fusiona cuando el adaptador esté congelado y elegido; mantenlo separado mientras experimentes.

6Configurarlo en PEFT

Con la librería peft de HuggingFace, todo lo anterior es una LoraConfig. Fíjate en los tres controles del módulo: r, lora_alpha y target_modules.

from peft import LoraConfig, get_peft_model

config = LoraConfig(
    r=16,                       # rank de la descomposición
    lora_alpha=32,              # escala α (aquí α = 2r)
    lora_dropout=0.05,          # regularización del aporte
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
    bias="none",
    task_type="CAUSAL_LM",
)

model = get_peft_model(base_model, config)
model.print_trainable_parameters()   # p.ej. 0.4% del total
Solo las capas nombradas reciben matrices B y A; el resto queda congelado.

Tras entrenar y validar, fusionas para servir el modelo como uno solo:

# fusiona los adaptadores en los pesos base y descarga la envoltura PEFT
merged = model.merge_and_unload()
merged.save_pretrained("qwen3-0.6b-especializado")
Después de merge_and_unload, la inferencia no paga overhead de LoRA.
En el teléfono

Para on-device se fusiona el adaptador LoRA en los pesos base (merge_and_unload) antes de exportar. Así el teléfono corre una sola matriz por capa, con cero overhead de inferencia: el adaptador no viaja aparte. LoRA es una herramienta de entrenamiento eficiente; en producción móvil se aplana.

Cómo practicar
  1. Instala pip install peft transformers.
  2. Configura LoraConfig con r, lora_alpha y target_modules.
  3. Entrena barriendo r ∈ {4,8,16,32,64}.
  4. Grafica la métrica de eval vs el número de parámetros entrenables e identifica el codo.
  5. Fusiona el mejor adaptador con merge_and_unload y verifica que generate() coincide con el adaptador sin fusionar.
Herramientas: Python · peft · transformers
Rust · on-device

El entrenamiento de LoRA es Python (PEFT). Pero para inferencia, candle sabe cargar y fusionar adaptadores LoRA en Rust.

Lo habitual para móvil es exportar el modelo ya fusionado a GGUF (Módulo 9) y cargarlo desde el runtime del teléfono.

Lecturas y recursos

Ejercicios

De menor a mayor complejidad. El último es el que hace un practicante de verdad.

Ejercicio 1 · calentamiento

Cuenta los parámetros entrenables

Envuelve Qwen3-0.6B con una LoraConfig de r=8 sobre solo la atención y llama a print_trainable_parameters(). Anota el porcentaje frente al total.

Entrega: el número de parámetros entrenables y su ratio.   Pista: compáralo con la fórmula $\dfrac{r(d+k)}{dk}$ por matriz.

Ejercicio 2

Verifica la inicialización en cero

Justo después de get_peft_model, extrae los pesos de $B$ y $A$ de una capa adaptada. Comprueba que $B = 0$ y que, por tanto, $\Delta W = BA = 0$ antes de entrenar.

Entrega: assert de que la norma de $BA$ es cero al inicio.   Pista: recorre named_parameters() buscando lora_A / lora_B.

Ejercicio 3

Barrido de lora_alpha

Con r fijo, entrena adaptadores idénticos variando lora_alpha $\in \{r, 2r, 4r\}$ sobre un conjunto pequeño. Grafica la pérdida de eval frente a $\alpha$ y explica el efecto del factor $\alpha/r$.

Entrega: curva eval-vs-α + una frase sobre por qué escala el aporte.   Pista: mantén todo lo demás constante (semilla, datos, pasos).

Ejercicio 4

Solo atención vs. atención + MLP

Entrena dos adaptadores con el mismo r: uno con target_modules solo de atención y otro añadiendo gate_proj, up_proj, down_proj. Compara métrica de eval, parámetros entrenables y tamaño del adaptador.

Entrega: tabla de 2 filas (eval, nº params, MB) + cuál gana por parámetro.   Pista: mira si el MLP paga su costo o solo agrega sobreajuste.

Ejercicio 5 · ejercicio top

Frontera de Pareto y test de fusión

Barre $r \in \{4,8,16,32,64\}$ y target_modules ∈ {solo-atención, atención+MLP} sobre Qwen3-0.6B, fijando todo lo demás; grafica la métrica de eval vs. el número de parámetros entrenables (la frontera de Pareto) e identifica el codo; luego fusiona el mejor adaptador y asegura que generate() coincide con el adaptador sin fusionar dentro de tolerancia numérica.

Entrega: scatter eval-vs-params-entrenables con el $(r, \alpha, \text{target\_modules})$ elegido + un test de equivalencia de la fusión.   Pista: compara generate() antes y después de merge_and_unload() con torch.allclose sobre los logits.

Puntos clave
  • LoRA congela $W_0$ y aprende $\Delta W = BA$ con $r \ll \min(d,k)$: dos matrices delgadas en vez de una densa.
  • El forward suma $\dfrac{\alpha}{r}BAx$; lora_alpha/r mantiene estable el aporte al cambiar el rank.
  • Init $B=0$ ⇒ el entrenamiento arranca exactamente en el modelo base, sin perturbarlo.
  • El adaptador es portátil: varios sobre un mismo base; merge_and_unload lo fusiona para cero overhead.
  • Más target_modules ≠ mejor: busca el codo de la curva, no el máximo de módulos adaptados.