Cada posición, un puñado de relojes

Cada posición, un puñado de relojes

29 min de lectura

Queda una casilla por rellenar y no está en la capa, sino en lo que se le entrega. La atención multi-head cerró todo lo demás —hh maneras de mirar la frase, concatenadas y proyectadas de vuelta al precio de una—, y dejó dicho que ninguna cantidad de cabezas consigue que X\mathbf{X} tenga una fila primera y una última: eso hay que escribirlo en cada fila antes de que la capa la lea. De qué se escribe ahí trata esta lección.

Las respuestas fáciles fallan de tres maneras, y conviene mirarlas porque entre las tres describen lo que hace falta. Escribir el número de la posición en una coordenada rompe la escala: en la posición 300300 esa coordenada vale 300300 mientras el resto del vector se mueve entre 1-1 y 11, y se lleva por delante cualquier producto escalar en el que entre. Dividirlo entre la longitud de la frase, pos/T\text{pos}/T, arregla la escala y estropea otra cosa: el 0.50.5 de una frase de ocho tokens señalaría el cuarto token y el de un texto de trescientos señalaría el ciento cincuenta, y sería la misma coordenada con el mismo valor. Y aprender un vector por posición, una tabla como la de los embeddings, funciona —hay modelos que lo hacen— a cambio de que la tabla tenga exactamente tantas filas como posiciones se hayan entrenado, y de que la posición 77 y la 88 sean dos filas sin ninguna relación entre ellas: nada dice que sean vecinas.

Ese último defecto es el que apunta a lo que se quiere de verdad. Piensa en un cuentakilómetros: la rueda de la derecha gira deprisa y distingue perfectamente dos posiciones seguidas, pero se repite cada diez; la de la izquierda casi no se mueve, así que no distingue vecinas, pero sí el principio del recorrido del final. Ninguna rueda sola sirve y todas juntas dan un número que no se repite —y donde, además, dos lecturas separadas por la misma cantidad se parecen igual en cualquier tramo, porque cada rueda ha avanzado lo mismo—. Los senos y cosenos son esa idea sin escalones: una pareja de coordenadas por «rueda», cada pareja girando a su propia velocidad.

Las columnas de la izquierda parpadean cada pocas posiciones y las de la derecha son una franja plana: son la misma función a velocidades que se van dividiendo. Mueve la posición y mira el número de abajo, el que puntúan entre sí dos posiciones separadas por k: no se mueve en toda la frase.

Una escalera de longitudes de onda

El artículo define la codificación posicional entrada por entrada, con pos\text{pos} como índice de la posición —es el tt del resto del curso, escrito con la letra del artículo— y con PE\text{PE} de positional encoding:

PE(pos,2i)=sin(pos100002i/dmodel),PE(pos,2i+1)=cos(pos100002i/dmodel)\begin{aligned} \text{PE}_{(\text{pos},\,2i)} &= \sin\left(\frac{\text{pos}}{10\,000^{2i/d_{\text{model}}}}\right), \\[4pt] \text{PE}_{(\text{pos},\,2i+1)} &= \cos\left(\frac{\text{pos}}{10\,000^{2i/d_{\text{model}}}}\right) \end{aligned}

para i=0,1,,dmodel/21i = 0, 1, \dots, d_{\text{model}}/2 - 1. Léelo despacio, porque la estructura está en los índices: las coordenadas van por parejas, la pareja ii ocupa las coordenadas 2i2i y 2i+12i+1, y las dos comparten el mismo argumento. Llamemos ωi\omega_i a ese divisor invertido, que es lo que la pareja gira por cada posición que se avanza:

ωi=1100002i/dmodel,2πωi=2π100002i/dmodel.\omega_i = \frac{1}{10\,000^{2i/d_{\text{model}}}}, \qquad \frac{2\pi}{\omega_i} = 2\pi \cdot 10\,000^{2i/d_{\text{model}}}.

A la derecha está la longitud de onda: cuántas posiciones tarda esa pareja en volver al mismo sitio. La pareja 00 tiene ω0=1\omega_0 = 1 y vuelve cada 2π6.282\pi \approx 6.28 posiciones; la última se queda justo por debajo de 100002π10\,000 \cdot 2\pi, unas 6300063\,000 posiciones, que sobre cualquier texto es una franja que apenas se mueve. Entre las dos, las longitudes de onda crecen multiplicándose siempre por el mismo factor: son una progresión geométrica, y eso es la escalera que el explorable dibuja de izquierda a derecha.

Lo que sale de ahí es una matriz PERT×dmodel\text{PE} \in \mathbb{R}^{T \times d_{\text{model}}} cuya fila pos\text{pos} es PEpos\text{PE}_{\text{pos}}^{\top}, exactamente la forma de la X\mathbf{X} de las tres lecciones anteriores. Y se junta con ella sumando:

xt=ewt+PEt.\mathbf{x}_t = \mathbf{e}_{w_t} + \text{PE}_t.

Fíjate en que la primera pega ha desaparecido sola: seno y coseno viven en [1,1][-1, 1] pase lo que pase con TT, así que la codificación de la posición 33 y la de la posición 30003\,000 son del mismo tamaño. La segunda también, y por una razón distinta: TT no aparece en ninguna parte de la fórmula, de modo que la posición 55 se codifica igual en una frase corta que en un texto largo.

Sumar en lugar de concatenar es una decisión, y merece decirse en voz alta lo que cuesta. Concatenar mantendría separadas por construcción las coordenadas del significado y las de la posición, y se pagaría en anchura: reservar 6464 de las 512512 para la posición deja 448448 para todo lo demás. Sumando no se paga anchura ninguna, y a cambio las dos señales comparten las mismas coordenadas y nada garantiza que se puedan volver a separar. Lo que hay a favor es que WQ\mathbf{W}^Q y WK\mathbf{W}^K se aprenden y pueden quedarse con unas direcciones y no con otras, y que la codificación tiene una estructura muy suya que un embedding entrenado no tiene por qué imitar. Plausible, entonces; no demostrado.

Desplazarse una distancia fija es girar un ángulo fijo

De las tres pegas del principio queda la tercera, que era la que de verdad pedía algo: que dos posiciones vecinas se parezcan, y que se parezcan igual estén donde estén. Aquí se puede demostrar, y ocupa tres líneas.

Fija un desplazamiento kk y quédate con una sola pareja. Las fórmulas de la suma de ángulos dicen qué le pasa a esa pareja al avanzar kk posiciones:

sin(ωi(pos+k))=cos(ωik)sin(ωipos)+sin(ωik)cos(ωipos),cos(ωi(pos+k))=sin(ωik)sin(ωipos)+cos(ωik)cos(ωipos)\begin{aligned} \sin\left(\omega_i(\text{pos}+k)\right) &= \cos(\omega_i k)\,\sin(\omega_i \text{pos}) + \sin(\omega_i k)\,\cos(\omega_i \text{pos}), \\[2pt] \cos\left(\omega_i(\text{pos}+k)\right) &= -\sin(\omega_i k)\,\sin(\omega_i \text{pos}) + \cos(\omega_i k)\,\cos(\omega_i \text{pos}) \end{aligned}

—y las he escrito con los factores en ese orden a propósito, porque así las dos líneas son ya un producto de matrices. Las coordenadas nuevas son una combinación de las dos viejas, y los coeficientes de esa combinación son cuatro números que sólo miran a ωi\omega_i y a kk:

[PE(pos+k,2i)PE(pos+k,2i+1)]=[cos(ωik)sin(ωik)sin(ωik)cos(ωik)][PE(pos,2i)PE(pos,2i+1)].\begin{bmatrix}\text{PE}_{(\text{pos}+k,\,2i)} \\[2pt] \text{PE}_{(\text{pos}+k,\,2i+1)}\end{bmatrix} = \begin{bmatrix}\cos(\omega_i k) & \sin(\omega_i k) \\[2pt] -\sin(\omega_i k) & \cos(\omega_i k)\end{bmatrix} \begin{bmatrix}\text{PE}_{(\text{pos},\,2i)} \\[2pt] \text{PE}_{(\text{pos},\,2i+1)}\end{bmatrix}.

Mira lo que no está en esa matriz: pos\text{pos}. La posición de partida se ha quedado entera dentro del vector de la derecha, y el desarrollo la ha dejado ahí. Como cada pareja se transforma sin tocar a las demás, apilar las dmodel/2d_{\text{model}}/2 matrices de 2×22 \times 2 a lo largo de la diagonal da una sola matriz para el vector entero:

PEpos+k=MkPEpos,MkRdmodel×dmodel.\text{PE}_{\text{pos}+k} = \mathbf{M}_k\,\text{PE}_{\text{pos}}, \qquad \mathbf{M}_k \in \mathbb{R}^{d_{\text{model}} \times d_{\text{model}}}.

Esa es la propiedad, y conviene leer bien lo que dice: hay una matriz por cada distancia kk, y esa matriz vale para todas las posiciones de la frase. Avanzar tres posiciones es aplicar M3\mathbf{M}_3, se avance desde la primera palabra o desde la última. Cada bloque es además un giro —sus dos columnas son perpendiculares y de longitud 11—, así que Mk\mathbf{M}_k no estira ni encoge nada: todas las filas de PE\text{PE} miden lo mismo.

Lo que de todo esto puede ver una puntuación

Mk\mathbf{M}_k es una propiedad de la codificación, y la capa de atención no mira matrices: mira productos escalares. Merece la pena hacer uno y ver qué queda. Con cos(AB)=cosAcosB+sinAsinB\cos(A - B) = \cos A \cos B + \sin A \sin B aplicado pareja a pareja:

PEposPEpos+k=i[sin(ωipos)sin(ωi(pos+k))+cos(ωipos)cos(ωi(pos+k))]=i=0dmodel/21cos(ωik).\text{PE}_{\text{pos}}^{\top}\text{PE}_{\text{pos}+k} = \sum_{i} \left[\sin(\omega_i \text{pos})\sin\left(\omega_i(\text{pos}+k)\right) + \cos(\omega_i \text{pos})\cos\left(\omega_i(\text{pos}+k)\right)\right] = \sum_{i=0}^{d_{\text{model}}/2 - 1} \cos(\omega_i k).

pos\text{pos} ha vuelto a desaparecer. Dos posiciones separadas por kk puntúan entre ellas el mismo número al principio de la frase y en la mitad, y ese número sólo depende de la distancia. Con k=0k = 0 cada término vale 11 y la suma da dmodel/2d_{\text{model}}/2, que es lo máximo: ninguna posición puntúa contra otra tanto como contra sí misma.

Y ahora la concesión, porque esto es menos de lo que parece. La capa no calcula PEposPEpos+k\text{PE}_{\text{pos}}^{\top}\text{PE}_{\text{pos}+k}: calcula qk\mathbf{q}^{\top}\mathbf{k} con las dos proyectadas desde ewt+PEt\mathbf{e}_{w_t} + \text{PE}_t, así que al desarrollar el producto salen cuatro términos y sólo uno de ellos es puramente posicional. Lo que la forma sinusoidal garantiza es que ese término existe y que depende sólo de la distancia; una cabeza que quiera aprender «el determinante que tengo justo delante» —el la de la casa amarilla— tiene con qué construirlo, porque la señal de «una posición atrás» es la misma en toda la frase. Si lo construye o no lo decide el entrenamiento, y no esta lección.

La escalera en NumPy

La primera celda monta PE\text{PE} con dos rodajas y sin un solo bucle, y después imprime la longitud de onda de unas cuantas parejas.

import numpy as np

T, d_model = 24, 32
i = np.arange(d_model // 2) # una pareja de coordenadas por cada i
omega = 1.0 / (10000 ** (2 * i / d_model))
pos = np.arange(T)[:, None] # (T, 1) contra (d_model/2,)

PE = np.zeros((T, d_model))
PE[:, 0::2] = np.sin(pos * omega) # coordenadas pares: el seno
PE[:, 1::2] = np.cos(pos * omega) # impares: el coseno de ese mismo angulo

print("PE", PE.shape, " valores entre", round(PE.min(), 3), "y", round(PE.max(), 3))
print("fila 0 :", np.round(PE[0, :8], 3))
print("fila 1 :", np.round(PE[1, :8], 3))
print("fila 12:", np.round(PE[12, :8], 3))

print("\npareja omega longitud de onda")
for j in (0, 1, 2, 8, 15):
print(f"{j:6d} {omega[j]:.6f} {2 * np.pi / omega[j]:14.1f}")

norma = np.linalg.norm(PE, axis=1)
print("\ntodas las filas miden lo mismo:",
bool(np.allclose(norma, np.sqrt(d_model / 2))), "->", round(float(norma[0]), 3))
numpy

La primera ejecución descarga el intérprete de Python (~15 MB). Después queda en la caché del navegador.

La fila 00 sale (0,1,0,1,)(0, 1, 0, 1, \dots), que es lo que tiene que salir: en la posición 00 todos los ángulos valen 00. Las tres primeras filas de la tabla son la escalera: la pareja 00 vuelve cada 6.36.3 posiciones, la 11 cada 11.211.2, la 22 cada 19.919.9 —cada una 1.781.78 veces la anterior— y la 1515, la última de este dmodeld_{\text{model}}, cada 35332.935\,332.9, que sobre 2424 posiciones es una recta. La última línea es la observación del final de la sección anterior comprobada: todas las filas miden 44, que es 32/2\sqrt{32/2}.

La segunda celda comprueba las dos propiedades, que es donde está el contenido de la lección.

import numpy as np

T, d_model, k = 24, 32, 3
i = np.arange(d_model // 2)
omega = 1.0 / (10000 ** (2 * i / d_model))
pos = np.arange(T + k)[:, None]
PE = np.zeros((T + k, d_model))
PE[:, 0::2] = np.sin(pos * omega)
PE[:, 1::2] = np.cos(pos * omega)

c, s = np.cos(k * omega), np.sin(k * omega) # los cuatro numeros de cada bloque
M = np.zeros((d_model, d_model))
M[0::2, 0::2] = np.diag(c)
M[0::2, 1::2] = np.diag(s)
M[1::2, 0::2] = np.diag(-s)
M[1::2, 1::2] = np.diag(c)

movidas = PE[:T] @ M.T # M por columna == fila por M traspuesta
print("una sola M mueve las", T, "filas a la vez; error maximo",
f"{np.abs(movidas - PE[k:T + k]).max():.2e}")

print("\nPE_pos . PE_pos+k, desde cuatro posiciones distintas:")
print(" ", np.round([float(PE[p] @ PE[p + k]) for p in (0, 5, 11, 19)], 6))
print(" suma de cosenos:", round(float(np.cos(k * omega).sum()), 6))

print("\ny como cambia con la distancia:")
for kk in (0, 1, 2, 3, 8, 20):
print(f" k = {kk:2d} -> {np.cos(kk * omega).sum():7.3f}")
numpy

La primera ejecución descarga el intérprete de Python (~15 MB). Después queda en la caché del navegador.

La primera línea es Mk\mathbf{M}_k movida a través de las veinticuatro posiciones de una vez, y el error máximo se queda en el orden de 101510^{-15}: la misma matriz, todas las filas. La segunda es la otra propiedad —cuatro posiciones de partida y cuatro veces 12.27239812.272398, que es exactamente la suma de cosenos— y la tabla del final dice cómo se comporta esa cantidad al alejarse: 1616 contra sí misma, 15.31415.314 a un paso, 12.27212.272 a tres, y luego baja despacio y sin ser monótona, porque es una suma de cosenos y no una distancia.

Comprueba tu intuición

Cinco preguntas: por qué no vale el número de la posición, de qué depende Mk\mathbf{M}_k, cuánto puntúa una posición contra sí misma, qué se rompe al quitar los cosenos, y qué garantiza sumar en vez de concatenar.

¿Qué rompe escribir el número de la posición —00, 11, 22, …— en una coordenada de xt\mathbf{x}_t?

La matriz Mk\mathbf{M}_k cumple PEpos+k=MkPEpos\text{PE}_{\text{pos}+k} = \mathbf{M}_k\text{PE}_{\text{pos}}. ¿De qué depende?

Con dmodel=512d_{\text{model}} = 512, ¿cuánto vale PEposPEpos\text{PE}_{\text{pos}}^{\top}\text{PE}_{\text{pos}}?

Se acepta un margen de ±0.

Te quedas sólo con los senos: una coordenada por pareja, sin su coseno. Marca lo que sigue siendo cierto.

Marca todas las opciones correctas. Se corrige todo o nada: no hay puntuación parcial.

Sumar la codificación al embedding, en vez de concatenarla, garantiza que el modelo pueda volver a separar significado y posición.

Escribe las dos piezas de la lección.

codificacion_posicional(T, d_model) devuelve PE\text{PE} de forma (T,dmodel)(T, d_{\text{model}}), con la fila pos\text{pos} llevando sin(posωi)\sin(\text{pos} \cdot \omega_i) en las coordenadas pares y cos(posωi)\cos(\text{pos} \cdot \omega_i) en las impares, donde ωi=1/100002i/dmodel\omega_i = 1/10\,000^{2i/d_{\text{model}}} y la pareja ii ocupa las coordenadas 2i2i y 2i+12i+1.

desplazamiento(k, d_model) devuelve Mk\mathbf{M}_k de forma (dmodel,dmodel)(d_{\text{model}}, d_{\text{model}}): ceros salvo un bloque 2×22 \times 2 por pareja, [cos(ωik)sin(ωik)sin(ωik)cos(ωik)]\begin{bmatrix}\cos(\omega_i k) & \sin(\omega_i k) \\ -\sin(\omega_i k) & \cos(\omega_i k)\end{bmatrix}, colocado en las filas y columnas 2i2i y 2i+12i+1.

Las dos tienen que encajar: Mk\mathbf{M}_k aplicada a la fila pos\text{pos} da la fila pos+k\text{pos}+k, en toda la frase. Supón dmodeld_{\text{model}} par.

La primera comprobación descarga el intérprete de Python (~15 MB); después queda en la caché del navegador. Este desafío se resuelve mejor con un teclado físico: en el móvil puedes leerlo y volver luego.


Con esto la entrada está completa: cada fila de X\mathbf{X} trae lo que dice el token y dónde estaba, y la capa de atención de las cuatro lecciones anteriores ya se puede calcular entera sobre ella. Lo que queda es apilarla, que es lo que convierte una capa en un modelo. Y ahí aparece algo que esta lección deja pendiente sin decirlo: la posición se suma una vez, abajo del todo, y tiene que llegar viva hasta la última capa.

Lo que el artículo dibuja alrededor de la atención —una conexión que se salta la capa entera, una normalización, un perceptrón por posiciones— parece fontanería alrededor de lo importante y no lo es. Es lo que decide si una señal metida abajo sigue ahí arriba, y el bloque 3 ya midió por qué al seguir un gradiente a lo largo de una cadena de productos y verlo desvanecerse. Eso es la lección siguiente, sobre el bloque completo con residuales y layer norm.

Para profundizar2 fuentes · 1 paper, 1 artículo

De dónde sale lo de esta lección, y dónde seguir si quieres más. Nada de aquí hace falta para continuar el curso.

  • Attention Is All You Need
    paperVaswani, Shazeer, Parmar y otros, 2017arXiv:1706.03762EN

    Su §3.5 define los senos y cosenos y afirma —sin probarlo— que desplazarse k posiciones es una función lineal de PE_pos. Esa prueba, la matriz M_k, es el centro de esta lección; el artículo solo dice que existe.

  • Transformer Architecture: The Positional Encoding
    artículoAmirhossein Kazemnejad, 2019kazemnejad.comEN

    Hace la cuenta que el artículo se salta: monta la matriz de giro M_k pareja a pareja y comprueba que no depende de la posición, tu misma demostración. Estira algo más el tema hacia la distancia relativa entre posiciones.