Lección 1 de 10 · Vectores y matrices · 17 min
Vectores y producto punto
Similitud, ángulo y por qué una probe es un producto punto
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 uno: vectores y producto punto. Esta serie explica, una por una, las fórmulas que aparecieron en Alineación a bajo nivel: cada concepto con números chicos, su dibujo y su línea de NumPy o de PyTorch. Hoy, la cuenta que más veces hace un modelo de lenguaje.
El mapa de la serie
El recorrido tiene diez episodios. Los tres primeros son las piezas: vectores y producto punto, matrices, y la transpuesta con el rango, que es lo que hace chico a LoRA, low-rank adaptation. Después, geometría: medir largos con normas, proyectar sobre una dirección y descomponer una matriz. Luego, probabilidad: exponencial, softmax, entropía y divergencias. Y al final, cómo aprende un modelo: gradientes y optimización. Cada episodio arranca en una fórmula de Alineación y termina leyéndola entera.
Vectores · punto → Matrices → Transpuesta · rango → Normas → Proyecciones → Autovalores · SVD → exp · softmax → Probabilidad · KL → Gradientes → Optimización
De dónde venimos
Alineación, ep. 1: el score de atención
Alineación, ep. 8: leer con una probe
En Alineación, episodio uno, vimos la atención. Q y K son dos matrices: una fila por token. En el dibujo, la fila i de Q por la columna j de K transpuesta da una sola celda, S i j: cuánto le interesa el token i al token j. Esa cuenta, q i punto k j, es un producto punto. Y en el episodio ocho, una probe: x punto r, comparado con un umbral, tau. En el dibujo, cada coordenada de x por la de r, y todo sumado. Dos fórmulas, la misma operación. Al final de hoy las vamos a leer enteras, raíz de d incluida.
Parte 1 · Un vector
Antes del producto, el vector: una lista de números que también se puede dibujar como una flecha.
Un vector en NumPy
Un vector es una lista de números. El vector a tiene dos: tres y uno. Su forma, el shape de NumPy, es dos: un vector de dos dimensiones. Sumar dos vectores es sumar coordenada a coordenada. Multiplicar por un número suelto, un escalar, multiplica cada coordenada. El largo sale de Pitágoras: raíz de tres al cuadrado más uno al cuadrado. NumPy lo trae hecho: norm, la norma, que se escribe con dos barras dobles. a más b da cuatro, tres. Dos por a, seis, dos. Y el largo de a, raíz de diez: tres coma dieciséis.
a = np.array([3.0, 1.0]) # un vector de 2 números
b = np.array([1.0, 2.0])
print("a =", a, " forma:", a.shape)
print("a + b =", a + b) # coordenada a coordenada
print("2 * a =", 2 * a) # cada coordenada por 2
print("a[0] =", a[0]) # la primera coordenada
largo = np.sqrt(a[0]**2 + a[1]**2) # Pitágoras
print("‖a‖ =", round(largo, 2))
print("norm =", round(np.linalg.norm(a), 2))
a = [3. 1.] forma: (2,)
a + b = [4. 3.]
2 * a = [6. 2.]
a[0] = 3.0
‖a‖ = 3.16
norm = 3.16
Un vector es una flecha
El vector a es una flecha que sale del origen: tres pasos a la derecha y uno para arriba. El b: uno a la derecha y dos para arriba. Sumar es poner una flecha a continuación de la otra: b arranca donde termina a. Y la flecha suma, a más b, va del origen hasta esa punta: cuatro, tres. En dos dimensiones se dibuja. Gemma usa vectores de dos mil trescientos cuatro números, y la cuenta es exactamente la misma, coordenada a coordenada.
Parte 2 · El producto punto
Entran dos vectores y sale un solo número. Es la cuenta que más se repite adentro de un modelo de lenguaje.
Multiplicar y sumar
Con los mismos a y b. Arrancamos un acumulador en cero. Para cada coordenada, multiplicamos la de a por la de b y lo sumamos. El arroba de NumPy hace lo mismo en una línea. Y en PyTorch es torch punto dot. Tres por uno, tres. Uno por dos, dos. Suman cinco. Las cuatro formas dan cinco. Eso es el producto punto: multiplicar coordenada a coordenada, y sumar todo.
a = np.array([3.0, 1.0])
b = np.array([1.0, 2.0])
# a mano: multiplicar coordenada a coordenada y sumar
total = 0.0
for i in range(len(a)):
print(f"a[{i}]*b[{i}] = {a[i]} * {b[i]} = {a[i]*b[i]}")
total += a[i] * b[i]
print("suma =", total)
print("a @ b =", a @ b) # NumPy
print("np.dot =", np.dot(a, b))
print("torch.dot =", torch.dot(torch.tensor(a), torch.tensor(b)).item())
a[0]*b[0] = 3.0 * 1.0 = 3.0
a[1]*b[1] = 1.0 * 2.0 = 2.0
suma = 5.0
a @ b = 5.0
np.dot = 5.0
torch.dot = 5.0
El producto punto, dibujado
cada coordenada por la suya, y sumar
lo mismo para coordenadas
largo por largo por el coseno del ángulo
La cuenta del código: a sub uno por b sub uno, más a sub dos por b sub dos. Tres más dos, cinco. En el dibujo, cada barra es un producto, y abajo, la suma. Para d coordenadas, la sigma mayúscula significa sumar, para cada i de uno a d. En el dibujo, un adelanto real: perro por gato en Gemma. Seis coordenadas aportan cero coma dos; las otras dos mil doscientas noventa y ocho, juntas, cero coma cincuenta y dos. Y la lectura geométrica: el largo de a, por el largo de b, por el coseno de theta, el ángulo entre los dos. En el dibujo, la sombra de b sobre la dirección de a: el producto punto mide cuánto de b va hacia donde apunta a. El dibujo gira b: la sombra se recalcula.
El signo dice hacia dónde apuntan
El vector a. b forma cuarenta y cinco grados con a: el producto da cinco, positivo. Van para el mismo lado. c es perpendicular a a, a noventa grados: el producto da cero exacto, menos tres más tres. Y d apunta justo al revés: el producto da menos veinte. Positivo, se parecen. Cero, no tienen nada que ver. Negativo, se oponen. Los arcos marcan cada ángulo. Pero el número también depende de los largos. Para comparar solo direcciones, hay que dividir.
El coseno: solo la dirección
Definimos el coseno. Es el producto punto dividido por la norma de u y por la norma de v. Así el resultado no depende de qué tan largas son las flechas, solo de hacia dónde apuntan, y siempre queda entre menos uno y uno. El arcocoseno, la función inversa, lo convierte en ángulo. Con b: coseno cero coma setecientos siete, cuarenta y cinco grados. Con c: cero, noventa grados. Con d: menos uno, ciento ochenta grados. d es menos dos por a: la misma recta, al revés.
def coseno(u, v):
return (u @ v) / (np.linalg.norm(u) * np.linalg.norm(v))
a = np.array([3.0, 1.0])
otros = {"b": [1.0, 2.0], "c": [-1.0, 3.0], "d": [-6.0, -2.0]}
for nombre, v in otros.items():
v = np.array(v)
cos = coseno(a, v)
grados = np.degrees(np.arccos(np.clip(cos, -1, 1)))
print(f"a·{nombre} = {a @ v:+5.1f} cos = {cos:+.3f} ángulo = {grados:5.1f}°")
a·b = +5.0 cos = +0.707 ángulo = 45.0°
a·c = +0.0 cos = +0.000 ángulo = 90.0°
a·d = -20.0 cos = -1.000 ángulo = 180.0°
Parte 3 · Similitud en Gemma
Con dos coordenadas se ve todo. Ahora, los vectores de verdad: dos mil trescientos cuatro números por token.
Coseno entre embeddings de Gemma 2 2B
De Gemma dos, dos B, abrimos solo el archivo de pesos, sin cargar el modelo entero. Sacamos la matriz de embeddings, W sub E: una fila por cada uno de los doscientos cincuenta y seis mil tokens del vocabulario, y dos mil trescientas cuatro columnas. Un embedding es eso: el vector con el que entra cada token. El vector de una palabra es su fila. Con espacio adelante, como en medio de una frase. El coseno, igual que antes, con tensores de PyTorch. Y cuatro pares. Perro y gato: cero coma veintinueve. Perro y auto: cero coma cero uno, casi perpendiculares. Rey y reina: cero coma treinta y seis. Lunes y martes: cero coma sesenta y cuatro.
tok = AutoTokenizer.from_pretrained(REPO)
with safe_open(archivo, framework="pt") as f: # solo esa matriz
W_E = f.get_tensor("model.embed_tokens.weight") # 256000 × 2304
print("W_E:", tuple(W_E.shape))
def vec(palabra): # su fila de W_E
(i,) = tok(palabra, add_special_tokens=False).input_ids
return W_E[i]
def coseno(u, v):
return (u @ v / (u.norm() * v.norm())).item()
for a, b in [(" perro", " gato"), (" perro", " auto"),
(" rey", " reina"), (" lunes", " martes")]:
print(f"{a:7} ·{b:7} cos = {coseno(vec(a), vec(b)):+.2f}")
W_E: (256000, 2304)
perro · gato cos = +0.29
perro · auto cos = +0.01
rey · reina cos = +0.36
lunes · martes cos = +0.64
Coseno entre embeddings (salida real)
Perro y gato, cero coma veintinueve. Perro y auto, casi cero. Rey y reina, cero coma treinta y seis. Lunes y martes, cero coma sesenta y cuatro, el par más parecido. Ojo con la escala: en dos mil trescientas dimensiones, dos vectores al azar dan cosenos de alrededor de cero coma cero dos; cero coma tres ya es mucho. Y son embeddings de entrada, antes de la primera capa: el contexto todavía no entró.
- perro · gato: 0,29
- perro · auto: 0,01
- rey · reina: 0,36
- lunes · martes: 0,64
Abrir el 0,29 coordenada por coordenada
Abramos ese cero coma veintinueve. Multiplicar dos tensores con asterisco da otro tensor: coordenada por coordenada, dos mil trescientos cuatro productos, todavía sin sumar. Buscamos los seis que más pesan, por valor absoluto. La suma de todos es el producto punto. Y dividido por los dos largos, el coseno. Una sola coordenada, la mil trescientos noventa y tres, aporta cero coma dieciocho. Las otras cinco del top, casi nada. El resto, miles de aportes chiquitos, suma cero coma cincuenta y dos. Total, cero coma setecientos veintidós. Los largos, uno coma cincuenta y tres y uno coma sesenta y dos. Coseno, cero coma dos ocho nueve.
Cada línea, su término:
productos = perro * gato→ — 2304 productos, sin sumartopk(6)→ — dónde está el pesoprint("suma→ — el producto puntoperro.norm() * gato.norm()→ — dividido por los largos
perro, gato = vec(" perro"), vec(" gato")
productos = perro * gato # 2304 productos, sin sumar
print("cuántos :", productos.numel())
top = productos.abs().topk(6).indices # los 6 que más pesan
print("coord. :", top.tolist())
print("productos :", productos[top].numpy().round(3))
resto = productos.sum() - productos[top].sum()
print("el resto :", round(resto.item(), 3))
print("suma :", round(productos.sum().item(), 3))
print("perro@gato:", round((perro @ gato).item(), 3))
print("‖perro‖ :", round(perro.norm().item(), 3))
print("‖gato‖ :", round(gato.norm().item(), 3))
cos = perro @ gato / (perro.norm() * gato.norm())
print("coseno :", round(cos.item(), 3))
cuántos : 2304
coord. : [1393, 1136, 1361, 2031, 808, 383]
productos : [ 0.182 0.008 0.007 -0.007 0.007 0.007]
el resto : 0.518
suma : 0.722
perro@gato: 0.722
‖perro‖ : 1.534
‖gato‖ : 1.625
coseno : 0.289
Parte 4 · Una probe es un producto punto
Volvamos a Alineación. Una probe lee un concepto del residual stream, la corriente por la que viaja todo, y lo lee con un producto punto.
Dos nubes y una dirección
En dos dimensiones, para verlo. Puntos verdes, los «sí», y rojos, los «no»: acá se ven ocho de cada lado. Pensalos como el vector de una frase verdadera y el de una falsa. La dirección r es el promedio de los verdes menos el promedio de los rojos: apunta de una nube hacia la otra. La probe calcula x punto r para cada punto: cuánto va x en la dirección de r. Los que pasan el umbral tau son «sí». La línea punteada es donde x punto r vale exactamente tau, y es perpendicular a r: de un lado, los verdes; del otro, los rojos.
La probe más simple: diferencia de medias
La dirección: media de los «sí» menos media de los «no». Mu es el promedio, un vector de dos números. X apila los cuarenta puntos, uno por fila, y X arroba r hace cuarenta productos punto de una vez. Eso es multiplicar una matriz por un vector, el tema del episodio dos. El umbral tau queda a mitad de camino entre los puntajes de las dos medias. Y la predicción, y sombrero: los corchetes valen uno si lo de adentro se cumple. r da dos coma sesenta y seis, menos cero coma cincuenta y tres. Los «sí» puntúan entre cuatro y cinco coma seis; los «no», todos negativos. Tau, cero coma setenta y dos. Acierta todos.
Cada línea, su término:
r = pos.mean(axis=0) - neg.mean(axis=0)→ — media de los «sí» menos media de los «no»puntaje = X @ r→ — un producto punto por filatau =→ — a mitad de caminoy_hat = (puntaje > tau)→ — 1 si se cumple, 0 si no
rng = np.random.default_rng(0)
pos = rng.normal([2.0, 1.0], 0.5, (20, 2)) # 20 puntos «sí»
neg = rng.normal([-1.0, 1.5], 0.5, (20, 2)) # 20 puntos «no»
r = pos.mean(axis=0) - neg.mean(axis=0)
print("r =", r.round(2))
X = np.vstack([pos, neg])
y = np.array([1] * 20 + [0] * 20)
puntaje = X @ r # un producto punto por fila
tau = (pos.mean(0) @ r + neg.mean(0) @ r) / 2 # a mitad
y_hat = (puntaje > tau).astype(int)
print("puntajes (3 sí):", puntaje[:3].round(2))
print("puntajes (3 no):", puntaje[20:23].round(2))
print("tau =", round(tau, 2), " aciertos:", (y_hat == y).mean())
r = [ 2.66 -0.53]
puntajes (3 sí): [5. 5.63 3.99]
puntajes (3 no): [-5.53 -1.86 -3.02]
tau = 0.72 aciertos: 1.0
Alain y Bengio, 2016 — Understanding intermediate layers using linear classifier probes · arXiv:1610.01644
probes · «We use linear classifiers, which we refer to as "probes", trained entirely independently of the model itself.»
El nombre viene de acá. En dos mil dieciséis, Guillaume Alain y Yoshua Bengio propusieron clasificadores lineales, entrenados aparte del modelo, a los que llamaron probes, para entender qué hay en cada capa intermedia de una red. Lineal quiere decir exactamente lo que acabamos de hacer: un producto punto con un vector de pesos, y un umbral. Si eso alcanza para acertar, la información está ahí, escrita en una dirección.
Parte 5 · Por qué raíz de d
Falta un detalle de la fórmula de la atención: la división por raíz de d sub k.
El producto punto crece con la dimensión
Generamos diez mil pares de vectores al azar, con coordenadas de varianza uno, en cuatro tamaños: d igual a cuatro, sesenta y cuatro, doscientos cincuenta y seis, y dos mil trescientos cuatro. La varianza es el promedio del cuadrado de la distancia a la media; su raíz es el desvío estándar. El producto punto de cada par. Medimos su desvío: qué tan lejos de cero suele caer. Y lo mismo después de dividir por raíz de d. El desvío crece como raíz de d: dos, ocho, dieciséis, cuarenta y ocho. Dividido por raíz de d, queda en uno, para cualquier d.
for d in [4, 64, 256, 2304]:
q = torch.randn(10_000, d) # 10.000 pares de vectores al azar
k = torch.randn(10_000, d) # coordenadas de varianza 1
punto = (q * k).sum(dim=1) # q·k para cada par
print(f"d={d:5} desvío de q·k = {punto.std():6.2f}"
f" √d = {d ** 0.5:5.1f}"
f" desvío de q·k/√d = {(punto / d ** 0.5).std():.2f}")
d= 4 desvío de q·k = 1.99 √d = 2.0 desvío de q·k/√d = 1.00
d= 64 desvío de q·k = 8.01 √d = 8.0 desvío de q·k/√d = 1.00
d= 256 desvío de q·k = 16.13 √d = 16.0 desvío de q·k/√d = 1.01
d= 2304 desvío de q·k = 47.68 √d = 48.0 desvío de q·k/√d = 0.99
Las dos fórmulas, leídas enteras
d términos de varianza 1
dividir por el desvío: el score vuelve a escala 1
la probe: un producto punto y un umbral
Por qué crece así. q punto k es una suma de d términos. Si cada uno tiene varianza uno y son independientes, las varianzas se suman: d. Y el desvío es la raíz: raíz de d. En el dibujo, d igual a cuatro: varianza cuatro, desvío dos, como en la salida. Por eso la atención divide por raíz de d sub k, el largo de cada consulta: la celda i j vuelve a desvío uno, y el softmax no termina dándole todo el peso a un solo token. En el dibujo, la fila i de Q por la columna j de K transpuesta. Y la probe: el primer punto verde, dos coma cero seis por dos coma sesenta y seis, más cero coma noventa y tres por menos cero coma cincuenta y tres. Da cinco, pasa tau: es «sí».
Resumiendo
- Vector: lista de números; se suma y se escala coordenada a coordenada
- Producto punto: , multiplicar y sumar
- : positivo, cero o negativo
- En Gemma: perro·gato 0,29; perro·auto 0,01
- Probe ; atención
- Próximo: matrices, fila por columna
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: Vectores y producto punto
Conectando con el tutor…