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, en vez de
- 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.
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}")
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
: la escala · LoRA común
r aportes que no se alinean: crecen como √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ó.
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}")
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.
# 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}")
λ = 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
: cada parámetro · : learning rate
lo que importa es el producto γ · η_B
¿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
un número por fila: parámetros más
la parte de LoRA: decide la dirección
fila : largo , dirección de
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.
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))
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.
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}")
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: ; con , el rango alto aprende lento
- LoRA+: , = 16 fue 3× más rápido acá
- Con Adam: escala ≈ lr de B
- DoRA: , 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
¿Terminaste la lección?
Márcala para sumar +50 XP y mantener tu racha.
Tutor IA
Contexto: Variantes
Conectando con el tutor…