Saltar al contenido
← LoRA y fine-tuning eficiente

Lección 9 de 10 · Más allá · 15 min

Variantes

rsLoRA, LoRA+ y DoRA: la escala, los learning rates y la forma del cambio

Notebook del episodio

El código de esta lección, para correrlo y modificarlo en Google Colab, gratis y con tu cuenta. Para guardar tus cambios: Archivo → Guardar una copia en Drive.


LoRA. Episodio nueve: variantes. LoRA es low-rank adaptation, adaptación de rango bajo. Desde dos mil veintiuno salieron decenas de variantes. Vamos a ver tres que peft trae listas, las tres de una línea de configuración, y las vamos a implementar y comparar en la misma tarea de juguete.

Tres perillas de la misma fórmula

  • rsLoRA: la escala, α/r\alpha/\sqrt{r} en vez de α/r\alpha/r
  • LoRA+: B aprende más rápido que A
  • DoRA: separa magnitud y dirección
  • Misma tarea sintética para las tres

Las tres tocan una pieza distinta de la misma fórmula. rsLoRA, rank-stabilized LoRA, cambia la escala: alfa sobre raíz de r, en vez de alfa sobre r. LoRA+ le da a B un learning rate más grande que a A. DoRA, weight-decomposed LoRA, separa cada fila de la matriz en un largo y una dirección, y entrena el largo aparte. Para compararlas: una capa de sesenta y cuatro por sesenta y cuatro y una tarea sintética, con Adam, adaptive moment estimation.

Parte 1 · rsLoRA: la escala

Con alfa fijo, subir el rango achica la escala alfa sobre r. ¿Eso frena el aprendizaje?

La escala que pone peft

Alfa fijo en ocho. Tres rangos: cuatro, dieciséis y sesenta y cuatro. use rslora es el único cambio. Y leemos la escala que peft guardó en la capa. Con LoRA común, alfa sobre r: dos, cero coma cinco, cero coma ciento veinticinco. Con rsLoRA, alfa sobre raíz de r: cuatro, dos y uno. Con rango sesenta y cuatro, la escala común es ocho veces más chica.

python
alpha = 8
print("   r   LoRA α/r   rsLoRA α/√r")
for r in [4, 16, 64]:
    esc = []
    for rs in [False, True]:
        cfg = LoraConfig(r=r, lora_alpha=alpha, target_modules=["proj"], use_rslora=rs)
        capa = get_peft_model(Capa(), cfg).base_model.model.proj
        esc.append(capa.scaling["default"])
    print(f"{r:4} {esc[0]:9.3f} {esc[1]:11.3f}")
text
   r   LoRA α/r   rsLoRA α/√r
   4     2.000       4.000
  16     0.500       2.000
  64     0.125       1.000

Por qué la raíz de r

γr=αr\gamma_r = \frac{\alpha}{r}

γr\gamma_r: la escala · LoRA común

∥BAx∥  ∼  r\lVert B A x \rVert \;\sim\; \sqrt{r}

r aportes que no se alinean: crecen como √r

γr∥BAx∥∼αrr=αr  →  γr=αr\gamma_r \lVert BAx \rVert \sim \frac{\alpha}{r}\sqrt{r} = \frac{\alpha}{\sqrt r}\;\to\; \gamma_r = \frac{\alpha}{\sqrt r}

rsLoRA: que el desvío no se achique con r

Gamma sub r es la escala que multiplica a B por A. Con LoRA común, alfa sobre r: en el dibujo, se achica rápido. Una intuición, no la prueba del paper. B por A por x suma r aportes, uno por columna de B. Al empezar, apuntan para cualquier lado y se cancelan en parte: la suma crece como raíz de r, no como r. En el dibujo, columnas con signos mezclados. Entonces el desvío entero va como alfa sobre r por raíz de r: alfa sobre raíz de r. Se achica al subir el rango. El paper de rsLoRA, de Kalajdzievski, de dos mil veintitrés, prueba que los adapters hay que dividirlos por la raíz del rango para que el aprendizaje no se frene.

Episodio tres: cuánto mueve un paso, según r

Esto ya lo medimos en el episodio tres: una capa con LoRA, un solo paso de Adam, y cuánto cambia la salida, para r de uno a sesenta y cuatro. Con alfa sobre r, el cambio cae de cero coma once a cero coma cero dieciocho: seis veces menos. Subir el rango frena el primer paso. Con alfa sobre raíz de r, queda casi plano: de cero coma once a cero coma ciento cuarenta y seis. Esa es la promesa de rsLoRA: elegís alfa una vez, y cambiar r no cambia cuánto aprende cada paso.

Tres rangos, dos escalas

La tarea: la base es una capa al azar, y el objetivo pide un cambio al azar de rango completo, así que un rango mayor debería ayudar. Tres rangos. Las dos escalas. Ciento cincuenta pasos de Adam, learning rate uno por diez a la menos tres. Miramos el paso cincuenta y el final. Con rango cuatro, casi lo mismo: cero coma cinco dos nueve contra cero coma cinco dos. Con rango sesenta y cuatro, la diferencia es enorme: con alfa sobre r termina en cero coma veinticinco; con alfa sobre raíz de r, en cero coma cero uno tres. Con la escala común, pasar de dieciséis a sesenta y cuatro casi no ayudó.

python
finales = {}
for r in [4, 16, 64]:
    for rs in [False, True]:
        hist = entrenar(r=r, rs=rs)            # 150 pasos de Adam
        finales[(r, rs)] = hist
        nombre = "rsLoRA α/√r" if rs else "LoRA   α/r "
        print(f"r={r:2} {nombre}: paso 50 {hist[50]:.3f},",
              f"final {hist[-1]:.3f}")
text
r= 4 LoRA   α/r : paso 50 0.596, final 0.529
r= 4 rsLoRA α/√r: paso 50 0.575, final 0.520
r=16 LoRA   α/r : paso 50 0.576, final 0.354
r=16 rsLoRA α/√r: paso 50 0.456, final 0.258
r=64 LoRA   α/r : paso 50 0.567, final 0.250
r=64 rsLoRA α/√r: paso 50 0.262, final 0.013

Pérdida, rango 64

Las curvas. Rango sesenta y cuatro con alfa sobre r: baja despacio. El mismo rango con alfa sobre raíz de r: se desploma antes del paso cincuenta. Y la punteada, rango dieciséis con la escala común, va casi pegada a la roja: cuadruplicar el rango no compró nada. Eso es lo que el paper llama aprendizaje frenado en rangos altos. Y es una trampa fácil: subís el rango esperando más capacidad, no ves mejora, y concluís que el rango no importa. Lo que no servía era la escala.

Parte 2 · LoRA+: dos learning rates

A y B no cumplen el mismo papel: B arranca en cero y A al azar. ¿Por qué deberían aprender al mismo ritmo?

B, λ veces más rápido

LoRA+ es una línea en el optimizador. Un grupo de parámetros para A, con el learning rate de siempre. Otro para B, con lambda veces más. Probamos lambda uno, que es LoRA común, cuatro y dieciséis. Rango dieciséis, la misma tarea. Con lambda uno, paso cincuenta cero coma cinco siete seis, final cero coma tres cinco cuatro. Con lambda dieciséis, paso cincuenta cero coma tres cinco ocho, final cero coma dos tres nueve. Y fijate en lambda cuatro: cero coma cuatro cinco seis y cero coma dos cinco ocho. Son exactamente los números de rsLoRA con rango dieciséis. No es casualidad.

python
# LoRA+: B aprende λ veces más rápido que A
def grupos(modelo, lr, lam):
    A = [p for n, p in modelo.named_parameters() if "lora_A" in n]
    B = [p for n, p in modelo.named_parameters() if "lora_B" in n]
    return [{"params": A, "lr": lr}, {"params": B, "lr": lam * lr}]

curvas = {}
for lam in [1, 4, 16]:                       # λ = 1 es LoRA común
    curvas[lam] = entrenar(r=16, lam=lam)
    h = curvas[lam]
    print(f"λ = {lam:2}: paso 25 {h[25]:.3f}, paso 50 {h[50]:.3f},",
          f"final {h[-1]:.3f}")
text
λ =  1: paso 25 0.627, paso 50 0.576, final 0.354
λ =  4: paso 25 0.561, paso 50 0.456, final 0.258
λ = 16: paso 25 0.453, paso 50 0.358, final 0.239

Pérdida según λ (r = 16)

LoRA común, punteada. Lambda cuatro, más rápido. Lambda dieciséis, todavía más. En este juguete, lambda dieciséis llega en cincuenta pasos a donde la común llega en ciento cincuenta. La explicación del paper, para modelos muy anchos: usar el mismo learning rate en A y en B hace que el aprendizaje sea ineficiente, y la corrección es darle a B uno mayor.

Con Adam, escala y lr de B se compensan

Adam:  ∣Δθ∣≈η\text{Adam}: \; \lvert \Delta \theta \rvert \approx \eta

θ\theta: cada parámetro · η\eta: learning rate

γ Bt≈γ ηB t\gamma\, B_t \approx \gamma\, \eta_B\, t

lo que importa es el producto γ · η_B

rsLoRAr=16  ≈  LoRA+λ=4\text{rsLoRA}_{r=16} \;\approx\; \text{LoRA+}_{\lambda = 4}

α/16=4⋅α/16\alpha/\sqrt{16} = 4 \cdot \alpha/16

¿Por qué coinciden? Adam divide cada gradiente por un promedio de su propio tamaño. Resultado: cada parámetro, theta, se mueve en cada paso más o menos eta, el learning rate, sin importar si el gradiente es grande o chico. En el dibujo, gradiente por cuatro, mismo paso. B arranca en cero y crece más o menos eta sub B por paso; el desvío es gamma por B. Lo que cuenta es el producto. Y A no se entera, porque Adam también le normaliza su gradiente. Con r dieciséis, rsLoRA multiplica la escala por cuatro: es lo mismo que LoRA+ con lambda cuatro. Por eso los números son idénticos.

Hayou, Ghosh y Yu, 2024 — LoRA+: Efficient Low Rank Adaptation of Large Models · arXiv:2402.12354

hasta ~2× más rápido · «LoRA+ improves performance (1-2 % improvements) and finetuning speed (up to ~ 2X SpeedUp), at the same computational cost as LoRA.»

El paper de LoRA+ reporta entre uno y dos puntos de mejora y hasta el doble de velocidad, al mismo costo. El lambda óptimo no es universal: con su inicialización, recomiendan alrededor de dos a la cuarta, dieciséis, para RoBERTa, y entre dos y cuatro para Llama. Y aclaran que depende del modelo y de la tarea.

Parte 3 · DoRA: magnitud y dirección

Cada fila de W es un vector: tiene un largo y una dirección. DoRA las entrena por separado.

Una fila de W: largo y dirección

Un esquema en dos dimensiones. Una fila de la matriz original, W sub cero. LoRA le suma un cambio: la punta se mueve. Llamemos V a la fila resultante: girada respecto de la original. DoRA se queda solo con la dirección de V y le pone el largo que decida un número entrenable, m. En el dibujo, m encoge la fila sin tocar su dirección. Con LoRA común, cambiar el largo de una fila sin girarla exige un cambio que se parezca a la fila entera.

esquema en 2D, valores inventados

DoRA, en fórmulas

mi=∥W0,i∥m_i = \lVert W_{0,i} \rVert

un número por fila: dd parámetros más

V=W0+s BAV = W_0 + s\, B A

la parte de LoRA: decide la dirección

Wi′=mi Vi∥Vi∥W'_i = m_i\, \frac{V_i}{\lVert V_i \rVert}

fila ii: largo mim_i, dirección de ViV_i

Al empezar, m sub i es el largo de la fila i de W sub cero: su norma, la raíz de la suma de sus cuadrados. Es un vector de d números, uno por fila, y entrena. En el dibujo, un largo por fila. V es W sub cero más s por B por A, LoRA de siempre: s es la escala. Cada fila de la matriz final es m sub i por V sub i dividido por su norma: el largo lo pone m, la dirección la pone V. En el dibujo, si m creció uno coma cinco veces, cada fila mide eso, apunte V adonde apunte. Un detalle: el paper habla de columnas; peft, con la matriz guardada como salidas por entradas, lo hace por filas.

DoRA a mano contra peft

Con use dora en verdadero, sobre una capa de ocho por ocho. peft guarda el vector de magnitud en la capa. Comprobamos que arranca igual al largo de cada fila de W sub cero. Después lo cambiamos, y B también, como si hubiéramos entrenado. V, la parte de LoRA. La matriz de DoRA, fila por fila. Y comparamos con la salida de peft. La magnitud tiene ocho números y arranca en el largo de cada fila. Las filas de nuestra matriz miden exactamente la magnitud. Con peft difiere en uno coma ocho por diez a la menos siete. Y entrenan cuarenta números: dieciséis de A, dieciséis de B y ocho de magnitud.

python
W0 = q.base_layer.weight                          # d × k, congelada
A, B = q.lora_A["default"].weight, q.lora_B["default"].weight
s = q.scaling["default"]
mag = q.lora_magnitude_vector["default"].weight   # d números, entrena
print("magnitud:", tuple(mag.shape))
print("inicio = normas de W0:",
      torch.allclose(mag, W0.norm(dim=1)))
with torch.no_grad():                             # como tras entrenar
    mag.mul_(1.5)
    B.normal_()

V = W0 + s * B @ A                                # la dirección, sin normalizar
W_dora = mag[:, None] * V / V.norm(dim=1, keepdim=True)
print("normas de W_dora = mag:",
      torch.allclose(W_dora.norm(dim=1), mag))
print(f"máx |peft − mía|: {(q(x) - x @ W_dora.T).abs().max():.1e}")
print("entrenables:", sum(p.numel() for p in m.parameters() if p.requires_grad))
text
magnitud: (8,)
inicio = normas de W0: True
normas de W_dora = mag: True
máx |peft − mía|: 1.8e-07
entrenables: 40

Liu et al., 2024 — DoRA: Weight-Decomposed Low-Rank Adaptation · arXiv:2402.09353

magnitud + dirección · «DoRA decomposes the pre-trained weight into two components, magnitude and direction, for fine-tuning».

La motivación del paper es una medición: en el fine-tuning completo, el cambio de largo y el de dirección muestran una relación más bien negativa; en LoRA, en cambio, aparecen atados, con una correlación positiva de cero coma ochenta y tres. DoRA los desata con un vector más. Y sugiere tratar la norma de V como constante en el backward para ahorrar memoria: es lo que hace peft.

Parte 4 · Las cuatro, en la misma tarea

Dos tareas sintéticas: un cambio al azar, y estirar o encoger cada fila de W.

LoRA, rsLoRA, LoRA+ y DoRA

Las cuatro con rango dieciséis. rsLoRA. LoRA+ con lambda dieciséis. DoRA. Esta vez, seiscientos pasos, para ver adónde llegan y no solo qué tan rápido, en dos tareas: el cambio al azar de antes, y otra que estira o encoge cada fila de W por un factor distinto. En la tarea al azar, todas terminan cerca de cero coma veintidós: es el mejor error posible con rango dieciséis, y rsLoRA y LoRA+ solo llegaban antes. En la de filas, LoRA, rsLoRA y LoRA+ se clavan en cero coma cero cero nueve tres. DoRA baja a cero coma cero cero cero nueve: diez veces menos.

python
variantes = {
    "LoRA":   dict(),
    "rsLoRA": dict(rs=True),
    "LoRA+":  dict(lam=16),
    "DoRA":   dict(dora=True),
}
finales = {}
print("pérdida, 600 pasos  al azar   filas")
for nombre, opciones in variantes.items():
    finales[nombre] = [entrenar(r=16, pasos=600, tipo=t, **opciones)[-1]
                       for t in ["azar", "filas"]]
    print(f"{nombre:18} {finales[nombre][0]:7.4f} {finales[nombre][1]:7.4f}")
text
pérdida, 600 pasos  al azar   filas
LoRA                0.2279  0.0093
rsLoRA              0.2264  0.0093
LoRA+               0.2253  0.0093
DoRA                0.2189  0.0009

Estirar filas: pérdida final (r = 16)

La tarea de estirar filas, en barras. LoRA. rsLoRA. LoRA+: las tres se clavan en el mismo valor. DoRA, diez veces más abajo. ¿Por qué? Estirar cada fila por un factor distinto es un cambio de rango completo, y rango dieciséis no lo alcanza; para DoRA, en cambio, es mover un número por fila: la magnitud. En la tarea al azar, en cambio, las cuatro empatan.

  • LoRA: 0,0093
  • rsLoRA: 0,0093
  • LoRA+: 0,0093
  • DoRA: 0,0009

Cuál usar

  • rsLoRA: use_rslora=True si subís el rango
  • LoRA+: create_loraplus_optimizer, λ entre 2 y 16
  • DoRA: use_dora=True; un forward más caro
  • Con Adam, escala y lr de B se compensan

En la práctica. Si vas a usar rangos altos, use rslora en verdadero: no cuesta nada. LoRA+ viene en peft como create loraplus optimizer, y el lambda hay que barrerlo. DoRA es use dora en verdadero; suma d parámetros por capa y un forward más caro, porque hay que normalizar la matriz. Y lo de hoy: con Adam, la escala y el learning rate de B se compensan; antes de comparar variantes, mirá si no estás comparando learning rates.

Resumiendo

  • rsLoRA: γ=α/r\gamma = \alpha/\sqrt{r}; con α/r\alpha/r, el rango alto aprende lento
  • LoRA+: ηB=λ ηA\eta_B = \lambda\, \eta_A, λ\lambda = 16 fue 3× más rápido acá
  • Con Adam: escala ×c\times c ≈ lr de B ×c\times c
  • DoRA: W′=m⋅V/∥V∥W' = m \cdot V/\lVert V \rVert, un largo por fila
  • DoRA: 10× menos error al estirar filas
  • Próximo: qué aprende y qué olvida LoRA

Papers del episodio

¿Qué tal esta lección?

Sé el primero en calificarla

Entra para calificar

¿Terminaste la lección?

Márcala para sumar +50 XP y mantener tu racha.