Cuando se trabaja con entradas texto, éstas deben ser codificadas como vectores en $\mathbb{R}^d$. Para realizar esto se utilizan
Las capas de embedding son capas lineales que toman como entrada el índice de un tóken y regresan un vector que representa a ese tóken. Generalmente se suele representar cada tóken por medio de un vector one-hot, que, dada la secuencia de tókens $w_1, w_2,...,w_n$ con índices $1,2,...,n$, se define como:
$$onehot(w_i)_j = \begin{cases} 1 & \text{ si } i = j \\ 0 & \text{ en otro caso} \end{cases}$$Es decir, el vector tiene $N$ entradas, donde $N$ es el número total de tókens y contiene un 1 en la entrada que corresponde al índice del tóken representado y 0 en todas las otras entradas.
De esta forma, la capa de embedding se obtendrá a partir de multiplicar el vector one-hot por una matriz de parámetros $W_e \in \mathbb{R}^{d \times N}$:
$$e_i = W_e onehot(w_i)$$Es fácil observar que ya que el vector one-hot tiene 0's en casi todas la entradas excepto en una, en la que contiene un 1, este producto sólo anula las columnas de $W_e$ que no corresponden al índice $i$ del tóken. Por tanto, puede simplificarse el cálculo del embeding sólo recuperando la $i$-ésima columna de la matriz $W_e$. Es decir, $e_i = W_e[:,i]$ corresponde al embedding del tóken $w_i$.
Por tanto, para implementar la capa de embedding en los transformadores requerimos definir funciones que nos permitan indexar los datos de entrada y que generen un vocabulario que asocie los tókens con sus correspondientes índices:
import torch
import torch.nn as nn
import numpy as np
import matplotlib.pyplot as plt
from collections import defaultdict
def vocab():
#Función para generar vocabulario
vocab = defaultdict()
vocab.default_factory = lambda: len(vocab)
return vocab
def index(corpus, voc):
#Indexa las palabras del corpus
for sent in corpus:
yield torch.tensor([voc[w] for w in sent.split()], dtype=torch.long)
La función vocab() genera un diccionario que asocia cada tóken en el vocabulario con su índice, moentras que la función index índexa las cadenas de texto y guarda los índices correspondientes en el vocabulario. Tomemos por caso el siguiente ejemplo:
corpus = ['el gato saltó por la ventana']
voc = vocab()
idx = torch.stack(list(index(corpus, voc)))
print('Texto original: {}\nÍndices: {}'.format(corpus[0], idx[0]))
Texto original: el gato saltó por la ventana Índices: tensor([0, 1, 2, 3, 4, 5])
Los mecanismos de auto-atención que se utilizan en los transformadores no tienen noción de la estructura de entrada. Asumen que todos los elementos de entrada están conectados con todos los otros elementos, incluyendo a sí mismos (gráfica completamente conectada). Por tanto, dada la naturaleza secuencial del lenguaje natural, la auto-atención debe tomar como entrada cierta información de la posición de los tókens en la cadena de entrada. Para esto se proponen la codificación posicional.
Codificación posicional: Un vector de codificación posicional es un vector en $\mathbb{R}^d$ que codifica la posición $t$ de un tóken en la cadena de entrada a partir de funciones senos y cosenos:
$$pe_{2t} = \sin\Big(\frac{t}{warm^{2t/d}}\Big)$$$$pe_{2t+1} = \cos\Big(\frac{t}{warm^{2t/d}}\Big)$$Donde $warm$ o
Para la implementación de la codificación posicional definimos una capa que regresa una matriz donde cada renglón es el vector posicional del tóken en la posición del renglón. La codificación posicional, si bien parte del modelo, no contiene parámetros que se deban actualizar durante el entrenamiento.
class PositionalEncoding(nn.Module):
#Codificación posicional
def __init__(self, d_model, max_len=1000, warmup=10000):
super(PositionalEncoding, self).__init__()
self.d_model = d_model
pe = torch.zeros(max_len, d_model)
position = torch.arange(0, max_len).unsqueeze(1)
div_term = warmup**(2*torch.arange(0, d_model, 2)/d_model)
pe[:, 0::2] = torch.sin(position / div_term)
pe[:, 1::2] = torch.cos(position / div_term)
self.register_buffer('pe', pe)
def forward(self, n):
return self.pe[:n, :self.d_model]
La codificación posicional así definida puede interpretarse visualmente. Este tipo de codificación se define por ciertas curvas de nivel que cambian con dependencia de la posición. Como se puede ver en la Figura de abajo las posiciones más pequeñas tienen mayor predominancia de valores cercanos a 1, mientras que entre más alejada esté la posición, van aparenciendo más valores cercanos a -1.
pe = PositionalEncoding(d_model=512)
pos = pe(100)
vis = plt.matshow(pos.detach().numpy())
plt.gcf().colorbar(vis)
plt.title('Codificación posicional')
plt.xlabel('Dimensiones')
plt.ylabel('Posición de tóken')
plt.show()
Finalmente, la codificación de entrada serán los embeddings de los tókens más la codificación posicional de cada uno. Vaswani et al. (2018) también propone que los embeddings se escalen a partri de multiplicar por la reaíz cuadrada del modelo. De tal forma que tenemos el tóken $w_i$ con índice $i$ se va a codificar como:
$$x_i = \sqrt{d} e_i + pe_i$$Si no se escalan, las representaciones serán de la forma $x_i = e_i + pe_i$. Entonces, la entrada para los modulos del tranformador serán de esta forma. Su implementación se realizará por medio de un módulo que regresará los vectores de embeddings más su posición.
class Encoding(nn.Module):
#Módulo de codificación de la entrada
def __init__(self, vocab_size, d_model, scale=True):
super(Encoding, self).__init__()
self.d_model = d_model
self.scale = scale
self.emb = nn.Embedding(vocab_size, d_model)
self.pe = PositionalEncoding(d_model=d_model)
def forward(self, x):
if self.scale:
encoding = np.sqrt(self.d_model)*self.emb(x) + self.pe(x.size(0))
else:
encoding = self.emb(x) + self.pe(x.size(0))
return encoding
La codificación regresará un conjunto de vectores de dimensión $d$ (que llamaremos la dimensión del modelo). Estos vectores tienen información posicional, pero la información "semántica" de los embeddings todavía no ha sido aprendida. Los embeddings tendrán una representación correspondiente a la tarea cuando se entrenen dentro de toda la arquitectura.
enc = Encoding(vocab_size=len(voc), d_model=2)
x = enc(idx).detach().numpy()[0]
print(x)
fig, ax = plt.subplots(figsize=(5,5))
ax.scatter(x[:,0], x[:,1], s=1)
for i, x_i in zip(idx[0], x):
ax.annotate(list(voc.keys())[i], (x_i[0], x_i[1]), size=10)
plt.title('Codificación de una cadena de entrada (sin entrenar)')
plt.show()
[[ 2.765799 0.56030613] [ 0.18494642 1.521946 ] [ 0.7918153 3.071725 ] [ 1.4536927 -0.50653267] [ 0.22189595 2.429769 ] [ 3.4358659 -1.3326149 ]]
La codificación posicional que hemos trabajado aquí se conoce como absoluta pues codifican la posición de un tóken a partir de una relación "absoluta" con todos los otros tókens; es decir, la codificación de posición se basa en toda la cadena de entrada para determinar la posición del tóken actual. En la posición absoluta, los vectores de query, key y value se presentan como (asumiendo que no son escalados):
$$q_i = W_q(e_i + pe_i) \\ k_i = W_k(e_i + pe_i) \\ v_i = W_v(e_i + pe_i) $$Sin embargo, se han propuesto otras formas de codificar la posición en los transformadores: dos de ellas son la codificación posicional relativa y los embeddings rotacionales.
La codificación por posición relativa es una alternativa a la posición absoluta propuesta por Shaw et al. (2021) que busca codificar la posición de un tóken en base a una distancia relativa con otros tókens.
Distancia relativa: Sean $w_i$ y $w_j$ dos tókens con posiciones absolutas $i$ y $j$, respectivamente, la distancia relativa (o clip) se estima como: $$clip(j-i, k) = \max\{-k, \min\{ j-i, k \}\}$$ donde $k$ es la posición relativa máxima.
En general, el valor $k$ puede fijarse para representar una relación con respecto a un tóken de a lo más distancia $k$. De esta forma, la codificación posicional propone agregar información de una conexión en una gráfica completamente conectada para los vectores de key y value.
Adyacencia posicional: Sean $k_1,..,k_n$ y $v_1,...,v_n$ los datos de entrada proyectados al espacio de keys y de values, respectivamente, definimos las matrices de adyacencia entre para cada uno de estos conjuntos de datos como:
$$a_{i,j}^k = w_{clip(j-i,k)}^k \\ a_{i,j}^v = w_{clip(j-i,k)}^v$$donde $w_{-k}^k,...,w_0^k,...,w_{k}^k$ y $w_{-k}^v,...,w_0^v,...,w_{k}^v$ son parámetros de la red.
Estos pesos que se aprenden conforma, como hemos señalado, matrices de adyacencia. Finalmente, para obtener las representaciones primero se calculan los pesos de atención como:
$$\alpha(x_i,x_j) = Softmax\Big( \frac{(W_qx_i)^T(W_k x_j + a_{i,j}^k)}{\sqrt{d}} \Big)$$Y finalmente, las representaciones en la auto-atención se obtienen como:
$$h_i = \sum_j \alpha(x_i, x_j) \big( W_v x_j + a_{i,j}^v \big)$$Otra alternativa para la codificación posicional son los embeddings rotacionales propuestos por Su et al. (2024). Estos embeddins se enfocan a las proyecciones en el espacio de queries y de keys. Ante esto, proponen aplicar una rotación de la siguiente forma:
$$q_i = R_{\Theta, i}^d W_q x_i \\ k_i = R_{\Theta, i}^dW_q x_i$$Donde $R_{\Theta, i}^d$ es una matriz que aplica una rotación, y que está determinada como:
$$R^d_{\Theta, i} = \begin{pmatrix} \cos i\theta_1 & -\sin i\theta_1 & 0 & 0 & \cdots & 0 & 0 \\ \sin i\theta_1 & \cos i\theta_1 & 0 & 0 & \cdots & 0 & 0 \\ 0 & 0 &\cos i\theta_2 & -\sin i\theta_2 & \cdots & 0 & 0 \\ 0 & 0 & \sin i\theta_2 & \cos i\theta_2 & \cdots & 0 & 0 \\ \vdots & \vdots & \vdots & \vdots & \ddots & \vdots & \vdots \\ 0 & 0 & 0 & 0 & \cdots & \cos i\theta_{d/2} & -\sin i\theta_{d/2} \\ 0 & 0 & 0 & 0 & \cdots & -\sin i\theta_{d/2} & \cos i\theta_{d/2} \end{pmatrix}$$Y donde $\Theta = \{ \theta_j = 10 000^{-2(j-1)/d} : j \in [1, 2, ..., d/2] \}$. La idea detrás de aplicar una rotación a las proyecciones es que al realizar el producto de una query por una key se conservará cierta información posicional relativa entre los dos elementos del producto:
$$q^T_i \cdot k_j = \big(R^d_{\Theta, i} W^{(q)} x_i \big)^T\big(R^d_{\Theta, j} W^{(k)} x_j \big) = x_i^T W^{(q)} R^d_{\Theta, j-i} W^{(k)} x_j $$Ahora la matriz $R^d_{\Theta, j-i}$ tiene información de la posición del query $i$ con respecto a la key $j$.
Dar, G., Geva, M., Gupta, A., & Berant, J. (2022). Analyzing transformers in embedding space. arXiv preprint arXiv:2209.02535.
Su, J., Ahmed, M., Lu, Y., Pan, S., Bo, W., & Liu, Y. (2024). Roformer: Enhanced transformer with rotary position embedding. Neurocomputing, 568, 127063.
Shaw, P., Uszkoreit, J., & Vaswani, A. (2018). Self-attention with relative position representations. arXiv preprint arXiv:1803.02155.
Vaswani, A., Shazeer, N., Parmar, N., Uszkoreit, J., Jones, L., Gomez, A. N., ... & Polosukhin, I. (2017).