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
Alineación, ep. 2: el parche de rango bajo
cada columna de BA mezcla solo r columnas de B
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.
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])
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
: autovector · : autovalor (un escalar)
: autovectores en columnas · : autovalores en la diagonal
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
- , para cualquier de
- gira, estira, vuelve a girar
- Valores singulares ordenados:
- Rango = cuántos 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.
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}")
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
: · : diagonal · :
columna por fila: una matriz de rango 1
σ₁ = 3,46 · σ₂ = 3,16
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
- : los términos con más grande
- Ninguna matriz de rango queda más cerca de
- Energía:
- Error relativo:
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.
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,}")
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
los r primeros; el resto se apaga
r = 256: 1,1 M contra 4,7 M
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.
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%}")
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.
# 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})")
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: rango completo, congelada · BA: rango ≤ 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: , la dirección que no gira
- SVD: , para cualquier matriz
- Eckart–Young: quedarte con los más grandes es la mejor aproximación
- Gemma q_proj: rango 256 pierde 61 %; los pesos no tienen rango bajo
- LoRA: el cambio tiene rango ; 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
¿Terminaste la lección?
Márcala para sumar +50 XP y mantener tu racha.
Tutor IA
Contexto: Autovalores, SVD y rango bajo
Conectando con el tutor…