Saltar al contenido
← Álgebra lineal para LLMs

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

Sij=qi⋅kjdkS_{ij} = \frac{q_i \cdot k_j}{\sqrt{d_k}}

Alineación, ep. 1: el score de atención

y^=[ x⋅r>τ ]\hat y = \big[\, x \cdot r > \tau \,\big]

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.

python
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))
text
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.

python
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())
text
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

a⋅b=a1b1+a2b2=3⋅1+1⋅2=5a \cdot b = a_1 b_1 + a_2 b_2 = 3 \cdot 1 + 1 \cdot 2 = 5

cada coordenada por la suya, y sumar

a⋅b=∑i=1dai bia \cdot b = \textstyle\sum_{i=1}^{d} a_i\, b_i

lo mismo para dd coordenadas

a⋅b=∥a∥ ∥b∥cos⁡θa \cdot b = \lVert a \rVert\, \lVert b \rVert \cos\theta

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.

python
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}°")
text
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.

python
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}")
text
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 → pi=xi yip_i = x_i\, y_i — 2304 productos, sin sumar
  • topk(6) → los 6 ∣pi∣ maˊs grandes\text{los 6 } |p_i| \text{ más grandes} — dónde está el peso
  • print("suma → x⋅y=∑i=12304pix \cdot y = \textstyle\sum_{i=1}^{2304} p_i — el producto punto
  • perro.norm() * gato.norm() → cos⁡θ=x⋅y∥x∥ ∥y∥\cos\theta = \dfrac{x \cdot y}{\lVert x\rVert\,\lVert y\rVert} — dividido por los largos
python
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))
text
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) → r=μ+−μ−r = \mu_+ - \mu_- — media de los «sí» menos media de los «no»
  • puntaje = X @ r → s=Xr,sn=xn⋅rs = X r,\quad s_n = x_n \cdot r — un producto punto por fila
  • tau = → τ=12 (μ+⋅r+μ−⋅r)\tau = \tfrac{1}{2}\,(\mu_+ \cdot r + \mu_- \cdot r) — a mitad de camino
  • y_hat = (puntaje > tau) → y^n=[ sn>τ ]\hat y_n = \big[\, s_n > \tau \,\big] — 1 si se cumple, 0 si no
python
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())
text
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.

python
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}")
text
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

Var(q⋅k)=∑i=1dVar(qiki)=d\mathrm{Var}(q \cdot k) = \textstyle\sum_{i=1}^{d} \mathrm{Var}(q_i k_i) = d

d términos de varianza 1

Sij=qi⋅kjdkS_{ij} = \frac{q_i \cdot k_j}{\sqrt{d_k}}

dividir por el desvío: el score vuelve a escala 1

y^=[ x⋅r>τ ]\hat y = \big[\, x \cdot r > \tau \,\big]

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 dd números; se suma y se escala coordenada a coordenada
  • Producto punto: a⋅b=∑iaibia \cdot b = \sum_i a_i b_i, multiplicar y sumar
  • a⋅b=∥a∥ ∥b∥cos⁡θa \cdot b = \lVert a\rVert\,\lVert b\rVert \cos\theta: positivo, cero o negativo
  • En Gemma: perro·gato 0,29; perro·auto 0,01
  • Probe =[x⋅r>τ]= [x \cdot r > \tau]; atención =qi⋅kj/dk= q_i \cdot k_j / \sqrt{d_k}
  • Próximo: matrices, fila por columna

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.