Saltar al contenido
← Álgebra lineal para LLMs

Lección 6 de 10 · Descomposiciones · 16 min

Autovalores, SVD y rango bajo

Direcciones que no giran, valores singulares y por qué LoRA alcanza

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.


Álgebra lineal para LLMs. Episodio seis: autovalores, SVD y rango bajo. La SVD es la descomposición en valores singulares. Vamos a desarmar una matriz en piezas ordenadas por importancia, a comprimir una matriz real de Gemma, y a entender por qué funciona LoRA, low-rank adaptation.

La fórmula que nos trae: LoRA

W′=W+αr BAW' = W + \frac{\alpha}{r}\, B A

Alineación, ep. 2: el parche de rango bajo

rango(BA)≤r\mathrm{rango}(BA) \le r

cada columna de BA mezcla solo r columnas de B

W≈?Wrango bajoW \overset{?}{\approx} W_{\text{rango bajo}}

la pregunta de hoy

En Alineación, episodio dos, vimos LoRA. W es una matriz de pesos congelada, de d filas por k columnas. Le sumamos B por A, escalado por alfa sobre r, dos números que elegís vos. B es alta y flaca, de d por r; A es baja y ancha, de r por k. En el dibujo, el producto tiene el tamaño de W. Y su rango, del episodio tres, es como mucho r: en el dibujo, cada columna de B por A mezcla solo las r columnas de B. La pregunta de hoy: ¿la W original también cabe en pocas direcciones? Para medirlo hace falta la SVD, singular value decomposition.

Parte 1 · Direcciones que no giran

Una matriz transforma vectores: los gira y los estira. Algunos vectores, en cambio, solo se estiran.

Autovalores en NumPy

Arrancamos con números chicos. A es una matriz de dos por dos: dos, uno; uno, dos. La multiplicamos por el vector uno, cero. Y por uno, uno. eigh, de NumPy, devuelve autovalores y autovectores de una matriz simétrica, es decir, igual a su transpuesta. Y comprobamos la definición. A por uno, cero da dos, uno: cambió de dirección. A por uno, uno da tres, tres: misma dirección, tres veces más largo. Los autovalores son uno y tres; los autovectores, columnas de largo uno, con cero coma siete uno en cada coordenada. Y la comprobación da cero.

python
A = np.array([[2.0, 1.0],
              [1.0, 2.0]])            # una matriz 2 × 2

x = np.array([1.0, 0.0])
print("A @ (1, 0) =", A @ x)          # cambia de dirección

u = np.array([1.0, 1.0])
print("A @ (1, 1) =", A @ u)          # misma dirección, ×3

lam, Q = np.linalg.eigh(A)            # autovalores y autovectores
print("autovalores:", lam)
print("autovectores (columnas):")
print(Q)
print("A @ q2 - 3 * q2 =", A @ Q[:, 1] - lam[1] * Q[:, 1])
text
A @ (1, 0) = [2. 1.]
A @ (1, 1) = [3. 3.]
autovalores: [1. 3.]
autovectores (columnas):
[[-0.71  0.71]
 [ 0.71  0.71]]
A @ q2 - 3 * q2 = [0. 0.]

Lo que hace A, dibujado

En el plano. x, uno, cero, apunta a la derecha. A por x es dos, uno: se estiró y giró hacia arriba. q sub dos, el autovector, en la diagonal. A por q sub dos sigue en la diagonal, tres veces más largo: tres es su autovalor. Y q sub uno, en la otra diagonal, tiene autovalor uno: A lo deja igual. Las dos diagonales son las direcciones que A no gira, y son perpendiculares: en una matriz simétrica, siempre.

Autovalores, en fórmulas

A q=λ qA\, q = \lambda\, q

qq: autovector · λ\lambda: autovalor (un escalar)

A=Q Λ Q⊤A = Q\, \Lambda\, Q^\top

QQ: autovectores en columnas · Λ\Lambda: autovalores en la diagonal

A x=∑iλi (qi⊤x) qiA\,x = \textstyle\sum_i \lambda_i\, (q_i^\top x)\, q_i

proyectar en cada autovector, estirar, sumar

La definición. q es un vector, el autovector; lambda, la letra griega, es un número, el autovalor. A por q da lambda por q: la matriz solo estira. En el dibujo, A por q, una columna, da otra columna. Con los autovectores como columnas de Q, y los autovalores en la diagonal de Lambda mayúscula, A es Q por Lambda por Q transpuesta. Y esto dice qué hace A con cualquier x: proyecta x sobre cada autovector, con el producto punto del episodio cinco, estira cada pedazo por su lambda, y suma. En el dibujo, r sombrero es q sub dos: x tiene cero coma siete uno sobre él, y lo que queda va sobre q sub uno. Ese pedazo se estira por tres, el otro por uno, y sumados dan dos, uno.

Parte 2 · La SVD: para cualquier matriz

Los autovectores perpendiculares solo están garantizados en matrices cuadradas y simétricas. Las de un LLM casi nunca lo son.

Singular value decomposition

  • M=UΣV⊤M = U \Sigma V^\top, para cualquier MM de m×nm \times n
  • V⊤V^\top gira, Σ\Sigma estira, UU vuelve a girar
  • Valores singulares ordenados: σ1≥σ2≥⋯≥0\sigma_1 \ge \sigma_2 \ge \dots \ge 0
  • Rango = cuántos σi\sigma_i no son cero

La SVD generaliza la idea. Cualquier matriz M, de m filas por n columnas, es U por Sigma por V transpuesta. U y V tienen columnas de largo uno, perpendiculares; Sigma mayúscula es diagonal. Leído de derecha a izquierda: V transpuesta gira la entrada, Sigma estira cada eje, y U gira hacia la salida. La diagonal de Sigma son los valores singulares, sigma sub i: nunca negativos, ordenados de mayor a menor. Y el rango de M es cuántos no son cero.

La SVD de una matriz de 2 × 3

Ahora una matriz que no es cuadrada. M tiene dos filas y tres columnas. linalg punto svd devuelve las tres piezas; full matrices en falso deja solo lo necesario. Comprobamos la reconstrucción. Una aproximación: solo el primer término, sigma uno por el producto exterior de la primera columna de U con la primera fila de V transpuesta. Y el error, con la norma de Frobenius: la raíz de la suma de las diferencias al cuadrado. U es de dos por dos, hay dos sigma, y V transpuesta es de dos por tres. Los sigma: tres coma cuarenta y seis y tres coma dieciséis. El aproximado tiene rango uno, y su error, tres coma dieciséis, es exactamente sigma dos.

python
M = np.array([[3.0, 1.0, 1.0],
              [-1.0, 3.0, 1.0]])      # 2 × 3: no es cuadrada

U, S, Vt = np.linalg.svd(M, full_matrices=False)
print("U:", U.shape, " S:", S.shape, " Vt:", Vt.shape)
print("valores singulares:", S)
print("reconstruye M:", np.allclose(U @ np.diag(S) @ Vt, M))

M1 = S[0] * np.outer(U[:, 0], Vt[0])  # solo el término 1
error = np.linalg.norm(M - M1)        # norma de Frobenius
print("rango de M1:", np.linalg.matrix_rank(M1))
print(f"error de M1: {error:.2f}   sigma_2 = {S[1]:.2f}")
text
U: (2, 2)  S: (2,)  Vt: (2, 3)
valores singulares: [3.46 3.16]
reconstruye M: True
rango de M1: 1
error de M1: 3.16   sigma_2 = 3.16

Una matriz como suma de capas de rango 1

M=U Σ V⊤M = U\,\Sigma\,V^\top

UU: 2×22\times2 · Σ\Sigma: diagonal · V⊤V^\top: 2×32 \times 3

σ1 u1v1⊤\sigma_1\, u_1 v_1^\top

columna por fila: una matriz de rango 1

M=σ1u1v1⊤+σ2u2v2⊤M = \sigma_1 u_1 v_1^\top + \sigma_2 u_2 v_2^\top

σ₁ = 3,46 · σ₂ = 3,16

∥M−M1∥F=∑i>1σi2=σ2\lVert M - M_1 \rVert_F = \sqrt{\textstyle\sum_{i>1} \sigma_i^2} = \sigma_2

lo que tirás es exactamente lo que falta

Las formas: U por Sigma, de dos por dos, por V transpuesta, de dos por tres, da M. En el dibujo, fila por columna. Un solo término: la columna u sub uno por la fila v sub uno transpuesta, por sigma uno. Es el producto exterior del episodio tres: una matriz entera, de rango uno. En el dibujo, cada celda es un único producto. M es la suma de esas capas de rango uno, pesadas por su sigma: en el dibujo, tres coma cuarenta y seis y tres coma dieciséis. Si te quedás con las primeras k, el error es la raíz de la suma de los sigma al cuadrado que tiraste. En el dibujo: sigma uno al cuadrado, doce, se queda; sigma dos al cuadrado, diez, se pierde. Raíz de diez: tres coma dieciséis.

Eckart–Young: cortar la SVD es lo mejor posible

  • MkM_k: los kk términos con σ\sigma más grande
  • Ninguna matriz de rango kk queda más cerca de MM
  • Energía: ∥M∥F2=∑iσi2\lVert M \rVert_F^2 = \sum_i \sigma_i^2
  • Error relativo: ∑i>kσi2 / ∑iσi2\sqrt{\sum_{i>k} \sigma_i^2 \,/\, \sum_i \sigma_i^2}

Un teorema de Eckart y Young, de mil novecientos treinta y seis, hace útil todo esto. M sub k es quedarte con los k términos de sigma más grande. Ninguna otra matriz de rango k está más cerca de M: cortar la SVD es la mejor compresión posible. La suma de todos los números de M al cuadrado, la energía, es igual a la suma de los sigma al cuadrado. Entonces el error relativo es la raíz de la energía que tirás sobre la total: un número entre cero y uno.

Parte 3 · Comprimir una matriz de Gemma

Ahora una matriz de verdad: la proyección de queries de la capa doce de Gemma 2 2B.

La SVD de q_proj, capa 12

La matriz que convierte el residual stream en queries, del episodio uno de Alineación. La leemos directo del archivo de pesos, sin cargar el modelo. svdvals calcula solo los valores singulares. La energía de cada término, normalizada para que sume uno. Para cada rango r, el error es la raíz de la energía que tiramos. Y guardar la versión comprimida cuesta r por m más n números. W es de dos mil cuarenta y ocho por dos mil trescientos cuatro: casi cuatro millones setecientos mil números. Sigma uno, dos coma cuarenta y nueve; el último, cero coma cero doce. Rango ocho: error noventa y siete por ciento. Doscientos cincuenta y seis: sesenta y uno. Mil veinticuatro: veinte por ciento, guardando casi tantos números como W.

python
nombre = "model.layers.12.self_attn.q_proj.weight"
W = cargar(nombre).float()                  # una matriz real de Gemma
m, n = W.shape
print("W:", m, "x", n, "=", f"{m * n:,}", "números")

S = torch.linalg.svdvals(W)                 # 2048 valores singulares
print("sigma_1 =", round(S[0].item(), 2), " sigma_2048 =", round(S[-1].item(), 3))

energia = S**2 / (S**2).sum()
for r in [8, 64, 256, 1024]:
    error = energia[r:].sum().sqrt()        # lo que tiramos
    print(f"r={r:5}: error {error:.0%}  params {r * (m + n):>9,}")
text
W: 2048 x 2304 = 4,718,592 números
sigma_1 = 2.49  sigma_2048 = 0.012
r=    8: error 97%  params    34,816
r=   64: error 84%  params   278,528
r=  256: error 61%  params 1,114,112
r= 1024: error 20%  params 4,456,448

Error según el rango

En el eje horizontal, el rango que guardamos; en el vertical, el error relativo. La punteada es una matriz al azar de la misma forma y escala: baja despacio, porque no tiene direcciones preferidas. La de Gemma baja más rápido: en rango doscientos cincuenta y seis, sesenta y uno por ciento contra ochenta. Tiene estructura, pero no rango bajo: para un error chico necesitás cientos de direcciones.

Comprimir: cuánto guardás y cuánto perdés

Wr=∑i=1rσi uivi⊤W_r = \textstyle\sum_{i=1}^{r} \sigma_i\, u_i v_i^\top

los r primeros; el resto se apaga

r (m+n)⏟guardaˊs  vs.  m n⏟original\underbrace{r\,(m+n)}_{\text{guardás}} \;\text{vs.}\; \underbrace{m\,n}_{\text{original}}

r = 256: 1,1 M contra 4,7 M

errorr=∑i>rσi2∑iσi2\text{error}_r = \sqrt{\tfrac{\sum_{i>r} \sigma_i^2}{\sum_i \sigma_i^2}}

r = 256 → 0,61

Comprimir a rango r es sumar solo los r primeros términos. En el dibujo, un esquema con tres encendidos. Guardás dos matrices flacas: U sub r, de dos mil cuarenta y ocho por r, y Sigma por V transpuesta, de r por dos mil trescientos cuatro. Con r doscientos cincuenta y seis: uno coma un millones de números contra cuatro coma siete. Y el error. En el dibujo, la energía real de Gemma por tramos de valores singulares: los ocho primeros tienen apenas seis por ciento. Con rango doscientos cincuenta y seis se pierden los dos últimos tramos: cero coma treinta y siete de la energía. Su raíz, cero coma sesenta y uno.

Parte 4 · Por qué LoRA alcanza

Si W no cabe en ocho direcciones, ¿cómo puede alcanzar un parche de rango ocho?

Un parche de rango 8, por dentro

La clave es qué se comprime: LoRA no comprime W, comprime el cambio. B, de dos mil cuarenta y ocho por ocho. A, de ocho por dos mil trescientos cuatro. El parche, delta, es B por A: del tamaño de W. Sus valores singulares. Y los parámetros. Los ocho primeros sigma, por mil, van de doscientos treinta y uno a doscientos siete. Del noveno en adelante, cero: rango ocho, por construcción. El parche son treinta y cuatro mil ochocientos dieciséis números: cero coma setenta y cuatro por ciento de W.

python
r = 8
B = torch.randn(m, r) * 0.01                # m × r: alta y flaca
A = torch.randn(r, n) * 0.01                # r × n: baja y ancha
delta = B @ A                               # el parche: m × n

S = torch.linalg.svdvals(delta)
print("10 primeros sigma (x1000):", (S[:10] * 1e3).round().int().tolist())
print("sigma no nulos:", int((S > 1e-4 * S[0]).sum()))
print(f"params del parche: {r * (m + n):,}")
print(f"params de W:       {m * n:,}")
print(f"proporción: {r * (m + n) / (m * n):.2%}")
text
10 primeros sigma (x1000): [231, 227, 224, 219, 214, 210, 208, 207, 0, 0]
sigma no nulos: 8
params del parche: 34,816
params de W:       4,718,592
proporción: 0.74%

Comprimir W contra comprimir el cambio

Lado a lado. La W preentrenada guarda lo que el modelo sabe y usa todas sus direcciones: a rango doscientos cincuenta y seis ya pierde sesenta y uno por ciento. El parche de LoRA no tiene que guardar eso: W queda entera, congelada, y B por A solo agrega el cambio del fine-tuning. La apuesta es que ese cambio vive en pocas direcciones. Ojo: la apuesta es sobre el cambio, no sobre W.

Hu et al., 2021 — LoRA: Low-Rank Adaptation of Large Language Models · arXiv:2106.09685

10.000× · «LoRA can reduce the number of trainable parameters by 10,000 times and the GPU memory requirement by 3 times.»

El paper es de dos mil veintiuno, de Microsoft. Comparado con el fine-tuning completo de GPT-3, de ciento setenta y cinco mil millones de parámetros, LoRA entrena diez mil veces menos parámetros y usa un tercio de la memoria de la GPU, graphics processing unit. La serie de LoRA lo recorre entero.

Bonus: PCA es una SVD

La SVD tiene otro uso frecuente en interpretabilidad: PCA, principal component analysis. X son veinticuatro embeddings de Gemma: ocho números, ocho colores y ocho días, de dos mil trescientos cuatro dimensiones cada uno. Primero centramos: a cada fila le restamos el promedio. La SVD da las direcciones de mayor varianza, y sigma al cuadrado, cuánta explica cada una. Proyectamos sobre las dos primeras: dos números por palabra. Y el centro de cada grupo. La primera componente explica veintiséis por ciento; la segunda, dieciocho. Números arriba a la izquierda, colores abajo, días a la derecha.

python
# X: 24 embeddings de Gemma (8 números, 8 colores, 8 días)
print("X:", tuple(X.shape))

Xc = X - X.mean(dim=0)                      # 1) centrar
U, S, Vt = torch.linalg.svd(Xc, full_matrices=False)
var = S**2 / (S**2).sum()                   # varianza por componente
print("varianza PC1, PC2:", [round(v, 2) for v in var[:2].tolist()])

P = Xc @ Vt[:2].T                           # 2) proyectar en 2 dims
for g, nombre in enumerate(["números", "colores", "días"]):
    c = P[8 * g: 8 * g + 8].mean(dim=0)
    print(f"{nombre:8} centro en ({c[0]:+.2f}, {c[1]:+.2f})")
text
X: (24, 2304)
varianza PC1, PC2: [0.26, 0.18]
números  centro en (-0.73, +0.56)
colores  centro en (-0.23, -0.80)
días     centro en (+0.96, +0.24)

24 embeddings, en dos dimensiones

Cada punto es una palabra, aplastada de dos mil trescientos cuatro dimensiones a dos. Los números forman un grupo. Los colores, otro. Y los días, otro; el punto suelto es weekend. PCA no sabía qué palabra era qué: buscó las direcciones donde más varían los datos, y los grupos aparecieron solos.

Volvemos a la fórmula de Alineación

W′=W+αr BAW' = W + \frac{\alpha}{r}\, B A

W: rango completo, congelada · BA: rango ≤ r

σi(BA)=0    para i>r\sigma_i(BA) = 0 \;\; \text{para } i > r

la SVD del parche tiene r términos

Ahora la fórmula se lee entera. W es la preentrenada: rango completo, y no se toca. B por A es el cambio; en el dibujo, con la forma real de la capa doce y r igual a ocho. En la SVD del parche, del noveno valor singular en adelante, todo es cero: ocho capas de rango uno. Alfa sobre r solo lo escala. LoRA no aproxima W: le suma un cambio que vive en r direcciones.

Resumiendo

  • Autovector: Aq=λqA q = \lambda q, la dirección que AA no gira
  • SVD: M=UΣV⊤=∑iσiuivi⊤M = U \Sigma V^\top = \sum_i \sigma_i u_i v_i^\top, para cualquier matriz
  • Eckart–Young: quedarte con los kk σ\sigma más grandes es la mejor aproximación
  • Gemma q_proj: rango 256 pierde 61 %; los pesos no tienen rango bajo
  • LoRA: el cambio BABA tiene rango ≤r\le r; PCA es la SVD de datos centrados
  • Próximo: exponencial, logaritmo y softmax

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.