Por qué los transformers usan seno y coseno para la codificación posicional
Una explicación paso a paso de la codificación posicional sinusoidal: por qué la atención ignora el orden de las palabras, relojes a distintas velocidades, un ejemplo resuelto en 12 dimensiones y la rotación.
¿Por qué los transformers usan seno y coseno para codificar la posición? Numerar las palabras 1, 2, 3 parece mucho más sencillo, y sin embargo responder bien a esa pregunta cuesta más de lo que parece.
La atención no tiene sentido del orden
Como operación, la atención trata su entrada como una bolsa de palabras. Dale a un transformer «el gato se come al ratón» y luego «el ratón se come al gato», sin ninguna información de posición, y la capa de atención verá las mismas tres representaciones las dos veces. No puede saber qué vino primero, y los productos escalares Q · K salen idénticos. Es un problema real, porque la primera frase es la cena y la segunda es una sorpresa. La posición tiene que inyectarse en algún punto antes de que se ejecute la primera capa de atención.
La solución obvia y por qué falla
Toma la frase «cat and dog», tres tokens con cat en la posición 1, «and» en la posición 2 y dog en la posición 3. Los embeddings reales de palabras no tienen dos o tres números. Los transformers en producción usan vectores de 512, 768 o incluso más dimensiones. Aquí están cat y dog con 768 dimensiones, mostrando las seis primeras entradas y la última para que se vea la forma:
cat ≈ [ 0.82, -0.14, 0.36, 0.05, -0.71, 0.29, ... , -0.18 ] (768 numbers)
dog ≈ [ 0.77, -0.09, 0.41, 0.11, -0.68, 0.24, ... , -0.15 ] (768 numbers)
Los dos vectores quedan cerca, lo cual tiene sentido: ambos son animales, y el espacio de embeddings coloca juntas las palabras de significado parecido. Pero nada en ninguno de los dos dice dónde está la palabra dentro de la frase. Ese es el hueco.
Lo primero que prueba casi todo el mundo es sumar el número de posición, como un escalar sin más, a cada entrada del embedding.
cat at position 1 → cat + 1 → [ 1.82, 0.86, 1.36, 1.05, 0.29, 1.29, ... , 0.82 ]
En la posición 1 parece inofensivo. Ahora imagina la misma palabra en la posición 500 de un documento largo:
cat at position 500 → cat + 500 → [ 500.82, 499.86, 500.36, 500.05, 499.29, 500.29, ... , 499.82 ]
El primer problema es el gordo. Los valores se disparan, y lo hacen igual en todas las dimensiones. Lo que hacía reconocible a cat, su secuencia particular de 0.82, -0.14, 0.36, etcétera, lo que lo distinguía como «gato» frente a «perro», se convierte en un error de redondeo al lado de 500. La señal queda ahogada. Dividir entre la longitud máxima de secuencia no lo arregla, porque esa longitud no se conoce cuando el modelo se ejecuta, y las frases cortas y los documentos muy largos quedarían en escalas incompatibles.
El segundo problema es más sutil. Incluso antes de dispararse, un único desplazamiento común sumado a todas las dimensiones es una forma pobre de expresar la posición. El modelo solo tiene un número con el que trabajar, y eso no da para construir un comportamiento basado en posiciones relativas, como «fíjate en la palabra dos puestos atrás».
Así que la posición debería ser un vector propio, con la misma forma que el embedding de la palabra para poder sumarlos sin más, y debería comportarse bien sin necesitar datos externos como la longitud de la secuencia.
Reduzcamos el problema para verlo con claridad. Olvida las 768 dimensiones e imagina solo 2. Cat en la posición 1 necesita algún vector [x, y], y «and» en la posición 2 necesita otro vector [p, q]. ¿Qué números deberían ser? Podrías elegirlos a mano:
position 1 → [0.2, 0.8]
position 2 → [0.4, 0.6]
position 3 → [0.6, 0.4]
Cada posición recibe un par distinto, sí, pero arbitrario. Nada liga la posición 4 con la 3 como lo están en una recta numérica. Pide el vector de la posición 4.857 y no hay regla que aplicar; alguien tendría que haberlo elegido de antemano, para cada posición que se pudiera llegar a usar.
Lo que hace falta es una función. Recibe una posición entera y devuelve un par [x, y], coloca cerca las posiciones vecinas y nunca deja que los números exploten, por grande que sea la posición. La geometría lo resolvió hace mucho. Se llama circunferencia.
Un reloj para la posición
Pon un punto sobre una circunferencia y déjalo moverse. Sus coordenadas son (cos θ, sin θ), así que cada ángulo da una coordenada 2D única, y ángulos vecinos dan coordenadas vecinas. La representación es suave. En lugar de decir «posición 17», podrías escribir:
position 17 → (sin 17, cos 17)
Son dos dimensiones codificando una posición, lo que ya supera a un escalar. Los valores se mantienen en [-1, 1], así que nada explota, y las posiciones 17 y 18 se parecen mucho.
Queda una pega: un solo reloj se repite. Tras una vuelta completa, la posición 1 y la posición 1 + 2π se ven iguales. Con una frase de 500 tokens y un reloj de periodo cercano a 6, hay colisiones por todas partes.
Muchos relojes a distintas velocidades
Lo que hace funcionar todo el esquema es tener muchos relojes a la vez, cada uno a su propio ritmo. Uno puede dar la vuelta cada 10 tokens más o menos, otro cada 100, otro cada 1.000, otro cada 10.000. Para cada posición se anotan todas las lecturas juntas. Dos posiciones pueden coincidir en el reloj rápido, pero no en todos los relojes a la vez, así que la lectura combinada funciona como una huella dactilar. El GPS usa un truco parecido con varios satélites, y un código de barras codifica un ID en franjas de distinto grosor.
En la fórmula real, una dimensión de embedding d da d/2 pares de relojes, y el par i gira con frecuencia 1 / 10000^(2i/d). Un i pequeño es un reloj rápido y uno grande, un reloj lento:
PE(pos, 2i) = sin(pos / 10000^(2i/d))
PE(pos, 2i+1) = cos(pos / 10000^(2i/d))
Esa es toda la fórmula.
Ejemplo resuelto: «cat and dog» en 12 dimensiones
Mirar las matemáticas en abstracto solo sirve hasta cierto punto, así que calculemos. Con d = 12 hay 6 pares de relojes, cada uno con su frecuencia; el par 1 es el más rápido y el par 6 el más lento:
pair 1: 10000^0/12 ≈ 1 (fastest)
pair 2: 10000^2/12 ≈ 21.54
pair 3: 10000^4/12 ≈ 464.16
pair 4: 10000^6/12 ≈ 1000
pair 5: 10000^8/12 ≈ 2154.43
pair 6: 10000^10/12 ≈ 4641.59 (slowest)
Toma el par 1 y cat en la posición 1: la fórmula da
Cada columna es el vector PE completo de 12 dimensiones de una palabra, y lo interesante está en las filas:
- La fila 1 oscila mucho entre palabras: 0.84, luego 0.91, luego 0.14. El reloj rápido da casi una vuelta entera en solo dos tics.
- La fila 6 apenas se mueve y muestra 1.0000 tres veces seguidas. El reloj lento todavía no ha avanzado de forma apreciable. Está reservado para separar posiciones muy alejadas, como 3 y 3000.
- Cada entrada es totalmente determinista. Nada en la tabla se aprende.
La entrada del transformer es entonces una simple suma elemento a elemento:
input = word_embedding(cat) + PE(1)
Misma forma, sin fusiones sofisticadas. Para ver el resultado real, los embeddings de palabras se reducen a las 12 dimensiones del PE de juguete (un modelo real usaría sus vectores de 768 dimensiones, y estos números son inventados solo para mostrar el mecanismo):
cat_embedding ≈ [ 0.20, 0.70, -0.10, 0.40, 0.15, -0.30, 0.55, 0.10, -0.20, 0.05, 0.30, -0.15]
dog_embedding ≈ [ 0.18, 0.68, -0.05, 0.35, 0.20, -0.25, 0.50, 0.15, -0.18, 0.08, 0.28, -0.10]
Igual que en las versiones de 768 dimensiones, cat y dog empiezan cerca porque los dos son animales. Ahora suma a cada palabra su PE de la tabla, dimensión por dimensión:
cat_final = cat_embedding + PE(1)
= [1.0415, 1.2403, -0.0536, 1.3989, 0.1522, 0.7000, 0.5510, 1.1000, -0.1995, 1.0500, 0.3002, 0.8500]
dog_final = dog_embedding + PE(3)
= [0.3211, -0.3100, 0.0888, 1.3403, 0.2065, 0.7500,
0.5030, 1.1500, -0.1786, 1.0800, 0.2806, 0.9000]
Esos dos vectores son los que llegan de verdad a la primera capa de atención. Compara la dimensión 2: ahí cat y dog eran casi iguales (0.70 frente a 0.68), y tras la señal posicional quedan en 1.24 frente a -0.31, a años luz. Es el reloj rápido (par 1, posición 1 frente a posición 3) haciendo su trabajo. Deja una marca posicional lo bastante clara como para que «cat, al principio de la frase» y «dog, más adelante en la frase» sean vectores claramente distintos aunque las palabras signifiquen cosas parecidas.
¿Cómo sabe el modelo que [0.84, 0.54, ...] es la posición 1?
No lo sabe. Ninguna dimensión de ese vector dice «1», y nada lo decodifica. El modelo nunca tiene que recuperar el número de posición, porque esa no es su tarea.
La tarea es más concreta. Supón que la query de dog se encuentra con la key de cat en un producto escalar. ¿Reflejará la puntuación que las dos palabras están a dos puestos de distancia? Eso es todo lo que significa «entender la posición» en esta capa. La codificación no es ruido. Es una mezcla suave de sinusoides de frecuencias conocidas, así que las proyecciones Q y K aprendidas pueden captar esos patrones y responder a ellos. Una query puede acabar significando «prefiere la key que esté un puesto antes» sin que intervenga nunca una variable llamada posición.
El reconocimiento facial ofrece una comparación útil. Un embedding facial de 512 dimensiones no tiene una dimensión etiquetada con el nombre de la persona. El vector completo, tomado en conjunto, es esa persona. El vector PE(1) de 12 dimensiones funciona igual: en conjunto, es la posición 1.
Por qué seno y coseno juntos
Esta es la parte que suele despacharse con vaguedades. La razón de fondo es que el par, junto, da una representación de la posición que se lleva bien con la rotación. Las identidades de suma de ángulos dicen:
sin(x + Δ) = sin(x)cos(Δ) + cos(x)sin(Δ)
cos(x + Δ) = cos(x)cos(Δ) − sin(x)sin(Δ)
Si conoces [sin(x), cos(x)], puedes calcular [sin(x+Δ), cos(x+Δ)] mediante una transformación lineal fija que depende solo de Δ y no de x. Desplazar la posición en Δ es, por tanto, la misma transformación empieces donde empieces. Eso permite a la atención aprender «mira Δ tokens atrás» como una única operación reutilizable, sin memorizar una regla distinta para cada par de posiciones. Con el seno solo no basta, porque hacen falta las dos mitades del par para fijar la rotación completa.
Qué pasa en la retropropagación
Durante el entrenamiento, ¿se ajustan las codificaciones posicionales con descenso de gradiente? Las sinusoidales, no. No tienen parámetros aprendibles y la fórmula es fija. Los gradientes atraviesan la suma (entrada = embedding + PE) sin encontrar nada que actualizar del lado del PE, igual que preguntar si el 5 de y = x + 5 se entrena.
La tabla de embeddings de palabras es otra historia. Cada vez que cat aparece en un batch, su fila en la tabla recibe un gradiente y se desplaza.
Merece la pena mencionar otra variante, porque la mayoría de los modelos modernos usan en realidad esta: las posiciones aprendidas. Ahí no hay fórmula. El modelo guarda una tabla entrenable con una fila por posición, hasta la longitud máxima de secuencia y tan ancha como la dimensión del modelo, y el entrenamiento decide qué debe contener la fila 17. Es más fácil de razonar y funciona bien dentro de las longitudes vistas en el entrenamiento, pero no se puede estirar a secuencias más largas como el PE sinusoidal. Ese compromiso, junto con enfoques más nuevos como ALiBi y RoPE, da para un tema aparte.