En el panorama en rápida evolución de la inteligencia artificial y el aprendizaje automático, una innovación se destaca por su profundo impacto en la forma en que procesamos, entendemos y generamos datos: transformadores. Los transformadores han revolucionado el campo del procesamiento del lenguaje natural (PLN) y más allá, impulsando algunas de las aplicaciones de inteligencia artificial más avanzadas de la actualidad. Pero, ¿qué son exactamente los Transformers y cómo logran transformar los datos de formas tan innovadoras? Este artículo desmitifica el funcionamiento interno de los modelos Transformer, centrándose en el arquitectura del codificador. Comenzaremos repasando la implementación de un codificador Transformer en Python, desglosando sus componentes principales. Luego, visualizaremos cómo los Transformers procesan y adaptan los datos de entrada durante el entrenamiento.
Si bien este blog no cubre todos los detalles arquitectónicos, proporciona una implementación y una comprensión general del poder transformador de Transformers. Para obtener una explicación detallada de Transformers, le sugiero que consulte el excelente curso Stanford CS224-n.
También recomiendo seguir las repositorio de GitHub asociado con este artículo para obtener detalles adicionales. 😊
Esta imagen muestra la arquitectura Transformer original, que combina un codificador y un decodificador para tareas de lenguaje de secuencia a secuencia.
En este artículo, nos centraremos en la arquitectura del codificador (el bloque rojo en la imagen). Esto es lo que el popular modelo BERT utiliza bajo el capó: el enfoque principal está en Comprender y representar los datos., en lugar de generar secuencias. Se puede utilizar para una variedad de aplicaciones: clasificación de texto, reconocimiento de entidades nombradas (NER), respuesta extractiva a preguntas, etc.
Entonces, ¿cómo transforma realmente esta arquitectura los datos? Explicaremos cada componente en detalle, pero aquí hay una descripción general del proceso.
- El texto de entrada es tokenizado: la cadena de Python se transforma en una lista de tokens (números)
- Cada token pasa a través de un Capa de incrustación que genera una representación vectorial para cada token
- Las incrustaciones luego se codifican aún más con un Capa de codificación posicionalagregando información sobre la posición de cada token en la secuencia
- Estas nuevas incorporaciones son transformadas por una serie de Capas de codificadorutilizando un mecanismo de autoatención
- A cabeza de tarea específica Puede ser añadido. Por ejemplo, luego usaremos un encabezado de clasificación para clasificar las reseñas de películas como positivas o negativas.
Es importante comprender que la arquitectura Transformer transforma los vectores de incrustación mapeándolos de una representación en un espacio de alta dimensión a otra dentro del mismo espacio, aplicando una serie de transformaciones complejas.
La capa del codificador posicional
A diferencia de los modelos RNN, el mecanismo de atención no utiliza el orden de la secuencia de entrada. La clase PositionalEncoder agrega codificaciones posicionales a las incrustaciones de entrada, utilizando dos funciones matemáticas: coseno y seno.
Tenga en cuenta que las codificaciones posicionales no contienen parámetros entrenables: existen resultados de cálculos deterministas, lo que hace que este método sea muy manejable. Además, las funciones seno y coseno toman valores entre -1 y 1 y tienen propiedades de periodicidad útiles para ayudar al modelo a aprender patrones sobre el posiciones relativas de las palabras.
class PositionalEncoder(nn.Module):
def __init__(self, d_model, max_length):
super(PositionalEncoder, self).__init__()
self.d_model = d_model
self.max_length = max_length# Initialize the positional encoding matrix
pe = torch.zeros(max_length, d_model)
position = torch.arange(0, max_length, dtype=torch.float).unsqueeze(1)
div_term = torch.exp(torch.arange(0, d_model, 2, dtype=torch.float) * -(math.log(10000.0) / d_model))
# Calculate and assign position encodings to the matrix
pe[:, 0::2] = torch.sin(position * div_term)
pe[:, 1::2] = torch.cos(position * div_term)
self.pe = pe.unsqueeze(0)
def forward(self, x):
x = x + self.pe[:, :x.size(1)] # update embeddings
return x
Autoatención de múltiples cabezas
El mecanismo de autoatención es el componente clave de la arquitectura del codificador. Ignoremos el “multicabezal” por ahora. La atención es una forma de determinar para cada token (es decir, cada incrustación) la Relevancia de todas las demás incorporaciones a ese token.para obtener una codificación más refinada y contextualmente relevante.
Hay 3 pasos en el mecanismo de autoatención.
- Utilice las matrices Q, K y V para transformar respectivamente las entradas «consulta”, “llave» y «valor”. Tenga en cuenta que para la autoatención, la consulta, la clave y los valores son todos iguales a nuestra incrustación de entrada.
- Calcule la puntuación de atención utilizando la similitud del coseno (un producto escalar) entre los consulta y el llave. Las puntuaciones se escalan según la raíz cuadrada de la dimensión de incorporación para estabilizar los gradientes durante el entrenamiento.
- Utilice una capa softmax para hacer estas puntuaciones. probabilidades
- El resultado es el promedio ponderado de valoresutilizando las puntuaciones de atención como ponderaciones
Matemáticamente, esto corresponde a la siguiente fórmula.
¿Qué significa «multicabezal»? Básicamente, podemos aplicar el proceso del mecanismo de autoatención descrito varias veces, en paralelo, y concatenar y proyectar las salidas. Esto permite que cada cabeza fCentrarse en diferentes aspectos semánticos de la oración..
Comenzamos definiendo el número de cabezas, la dimensión de las incrustaciones (d_model) y la dimensión de cada cabeza (head_dim). También inicializamos las matrices Q, K y V (capas lineales) y la capa de proyección final.
class MultiHeadAttention(nn.Module):
def __init__(self, d_model, num_heads):
super(MultiHeadAttention, self).__init__()
self.num_heads = num_heads
self.d_model = d_model
self.head_dim = d_model // num_headsself.query_linear = nn.Linear(d_model, d_model)
self.key_linear = nn.Linear(d_model, d_model)
self.value_linear = nn.Linear(d_model, d_model)
self.output_linear = nn.Linear(d_model, d_model)
Cuando utilizamos atención de múltiples cabezas, aplicamos cada cabeza de atención con una dimensión reducida (head_dim en lugar de d_model) como en el artículo original, lo que hace que el costo computacional total sea similar a una capa de atención de una sola cabeza con dimensionalidad completa. Tenga en cuenta que esta es sólo una división lógica. Lo que hace que la atención múltiple sea tan poderosa es que aún se puede representar mediante una única operación matricial, lo que hace que los cálculos sean muy eficientes en las GPU.
def split_heads(self, x, batch_size):
# Split the sequence embeddings in x across the attention heads
x = x.view(batch_size, -1, self.num_heads, self.head_dim)
return x.permute(0, 2, 1, 3).contiguous().view(batch_size * self.num_heads, -1, self.head_dim)
Calculamos las puntuaciones de atención y utilizamos una máscara para evitar utilizar la atención en fichas acolchadas. Aplicamos una activación softmax para hacer que estas puntuaciones sean probabilidades.
def compute_attention(self, query, key, mask=None):
# Compute dot-product attention scores
# dimensions of query and key are (batch_size * num_heads, seq_length, head_dim)
scores = query @ key.transpose(-2, -1) / math.sqrt(self.head_dim)
# Now, dimensions of scores is (batch_size * num_heads, seq_length, seq_length)
if mask is not None:
scores = scores.view(-1, scores.shape[0] // self.num_heads, mask.shape[1], mask.shape[2]) # for compatibility
scores = scores.masked_fill(mask == 0, float('-1e20')) # mask to avoid attention on padding tokens
scores = scores.view(-1, mask.shape[1], mask.shape[2]) # reshape back to original shape
# Normalize attention scores into attention weights
attention_weights = F.softmax(scores, dim=-1)return attention_weights
El atributo forward realiza la división lógica de múltiples cabezas y calcula los pesos de atención. Luego, obtenemos el resultado multiplicando estos pesos por los valores. Finalmente, remodelamos la salida y la proyectamos con una capa lineal.
def forward(self, query, key, value, mask=None):
batch_size = query.size(0)query = self.split_heads(self.query_linear(query), batch_size)
key = self.split_heads(self.key_linear(key), batch_size)
value = self.split_heads(self.value_linear(value), batch_size)
attention_weights = self.compute_attention(query, key, mask)
# Multiply attention weights by values, concatenate and linearly project outputs
output = torch.matmul(attention_weights, value)
output = output.view(batch_size, self.num_heads, -1, self.head_dim).permute(0, 2, 1, 3).contiguous().view(batch_size, -1, self.d_model)
return self.output_linear(output)
La capa del codificador
Este es el componente principal de la arquitectura, que aprovecha la autoatención de múltiples cabezas. Primero implementamos una clase simple para realizar una operación de avance a través de 2 capas densas.
class FeedForwardSubLayer(nn.Module):
def __init__(self, d_model, d_ff):
super(FeedForwardSubLayer, self).__init__()
self.fc1 = nn.Linear(d_model, d_ff)
self.fc2 = nn.Linear(d_ff, d_model)
self.relu = nn.ReLU()def forward(self, x):
return self.fc2(self.relu(self.fc1(x)))
Ahora podemos codificar la lógica para la capa del codificador. Comenzamos aplicando atención propia a la entrada, lo que da un vector de la misma dimensión. Luego usamos nuestra mini red de retroalimentación con capas Layer Norm. Tenga en cuenta que también utilizamos conexiones de omisión antes de aplicar la normalización.
class EncoderLayer(nn.Module):
def __init__(self, d_model, num_heads, d_ff, dropout):
super(EncoderLayer, self).__init__()
self.self_attn = MultiHeadAttention(d_model, num_heads)
self.feed_forward = FeedForwardSubLayer(d_model, d_ff)
self.norm1 = nn.LayerNorm(d_model)
self.norm2 = nn.LayerNorm(d_model)
self.dropout = nn.Dropout(dropout)def forward(self, x, mask):
attn_output = self.self_attn(x, x, x, mask)
x = self.norm1(x + self.dropout(attn_output)) # skip connection and normalization
ff_output = self.feed_forward(x)
return self.norm2(x + self.dropout(ff_output)) # skip connection and normalization
Poniendo todo junto
Es hora de crear nuestro modelo final. Pasamos nuestros datos a través de una capa de incrustación. Esto transforma nuestros tokens sin procesar (enteros) en un vector numérico. Luego aplicamos nuestro codificador posicional y varias capas de codificador (num_layers).
class TransformerEncoder(nn.Module):
def __init__(self, vocab_size, d_model, num_layers, num_heads, d_ff, dropout, max_sequence_length):
super(TransformerEncoder, self).__init__()
self.embedding = nn.Embedding(vocab_size, d_model)
self.positional_encoding = PositionalEncoder(d_model, max_sequence_length)
self.layers = nn.ModuleList([EncoderLayer(d_model, num_heads, d_ff, dropout) for _ in range(num_layers)])def forward(self, x, mask):
x = self.embedding(x)
x = self.positional_encoding(x)
for layer in self.layers:
x = layer(x, mask)
return x
También creamos una clase ClassifierHead que se utiliza para transformar la incrustación final en probabilidades de clase para nuestra tarea de clasificación.
class ClassifierHead(nn.Module):
def __init__(self, d_model, num_classes):
super(ClassifierHead, self).__init__()
self.fc = nn.Linear(d_model, num_classes)def forward(self, x):
logits = self.fc(x[:, 0, :]) # first token corresponds to the classification token
return F.softmax(logits, dim=-1)
Tenga en cuenta que las capas densa y softmax solo se aplican en la primera incrustación (correspondiente al primer token de nuestra secuencia de entrada). Esto se debe a que al tokenizar el texto, el primer token es el [CLS] token que significa «clasificación». El [CLS] El token está diseñado para agregar la información de la secuencia completa en un único vector de incrustación, que sirve como una representación resumida que se puede utilizar para tareas de clasificación.
Nota: el concepto de incluir un [CLS] El token se origina en BERT, que inicialmente fue entrenado en tareas como la predicción de la siguiente oración. El [CLS] Se insertó un token para predecir la probabilidad de que la oración B siga a la oración A, con un [SEP] token que separa las 2 oraciones. Para nuestro modelo, el [SEP] token simplemente marca el final de la oración de entrada, como se muestra a continuación.
Cuando lo piensas, es realmente alucinante que este sencillo [CLS] La incrustación es capaz de capturar mucha información sobre toda la secuencia, gracias a la capacidad del mecanismo de autoatención para sopesar y sintetizar la importancia de cada parte del texto en relación entre sí.
Con suerte, la sección anterior le brindará una mejor comprensión de cómo nuestro modelo Transformer transforma los datos de entrada. Ahora escribiremos nuestro proceso de capacitación para nuestra tarea de clasificación binaria utilizando el conjunto de datos IMDB (reseñas de películas). Luego, visualizaremos la incrustación del [CLS] token durante el proceso de entrenamiento para ver cómo nuestro modelo lo transformó.
Primero definimos nuestros hiperparámetros, así como un tokenizador BERT. En el repositorio de GitHub, puede ver que también codifiqué una función para seleccionar un subconjunto del conjunto de datos con solo 1200 ejemplos de entrenamiento y 200 de prueba.
num_classes = 2 # binary classification
d_model = 256 # dimension of the embedding vectors
num_heads = 4 # number of heads for self-attention
num_layers = 4 # number of encoder layers
d_ff = 512. # dimension of the dense layers in the encoder layers
sequence_length = 256 # maximum sequence length
dropout = 0.4 # dropout to avoid overfitting
num_epochs = 20
batch_size = 32loss_function = torch.nn.CrossEntropyLoss()
dataset = load_dataset("imdb")
dataset = balance_and_create_dataset(dataset, 1200, 200) # check GitHub repo
tokenizer = AutoTokenizer.from_pretrained('bert-base-uncased', model_max_length=sequence_length)
Puedes intentar usar el tokenizador BERT en una de las oraciones:
print(tokenized_datasets['train']['input_ids'][0])
Cada secuencia debe comenzar con el token 101, correspondiente a [CLS]seguido de algunos números enteros distintos de cero y rellenados con ceros si la longitud de la secuencia es menor que 256. Tenga en cuenta que estos ceros se ignoran durante el cálculo de autoatención utilizando nuestra «máscara».
tokenized_datasets = dataset.map(encode_examples, batched=True)
tokenized_datasets.set_format(type='torch', columns=['input_ids', 'attention_mask', 'label'])train_dataloader = DataLoader(tokenized_datasets['train'], batch_size=batch_size, shuffle=True)
test_dataloader = DataLoader(tokenized_datasets['test'], batch_size=batch_size, shuffle=True)
vocab_size = tokenizer.vocab_size
encoder = TransformerEncoder(vocab_size, d_model, num_layers, num_heads, d_ff, dropout, max_sequence_length=sequence_length)
classifier = ClassifierHead(d_model, num_classes)
optimizer = torch.optim.Adam(list(encoder.parameters()) + list(classifier.parameters()), lr=1e-4)
Ahora podemos escribir nuestra función de tren:
def train(dataloader, encoder, classifier, optimizer, loss_function, num_epochs):
for epoch in range(num_epochs):
# Collect and store embeddings before each epoch starts for visualization purposes (check repo)
all_embeddings, all_labels = collect_embeddings(encoder, dataloader)
reduced_embeddings = visualize_embeddings(all_embeddings, all_labels, epoch, show=False)
dic_embeddings[epoch] = [reduced_embeddings, all_labels]encoder.train()
classifier.train()
correct_predictions = 0
total_predictions = 0
for batch in tqdm(dataloader, desc="Training"):
input_ids = batch['input_ids']
attention_mask = batch['attention_mask'] # indicate where padded tokens are
# These 2 lines make the attention_mask a matrix instead of a vector
attention_mask = attention_mask.unsqueeze(-1)
attention_mask = attention_mask & attention_mask.transpose(1, 2)
labels = batch['label']
optimizer.zero_grad()
output = encoder(input_ids, attention_mask)
classification = classifier(output)
loss = loss_function(classification, labels)
loss.backward()
optimizer.step()
preds = torch.argmax(classification, dim=1)
correct_predictions += torch.sum(preds == labels).item()
total_predictions += labels.size(0)
epoch_accuracy = correct_predictions / total_predictions
print(f'Epoch {epoch} Training Accuracy: {epoch_accuracy:.4f}')
Puede encontrar las funciones Collect_embeddings y visualize_embeddings en el repositorio de GitHub. Ellos almacenan el [CLS] incrustación de tokens para cada oración del conjunto de entrenamiento, aplique una técnica de reducción de dimensionalidad llamada t-SNE para convertirlos en vectores 2D (en lugar de vectores de 256 dimensiones) y guarde una gráfica animada.
Visualicemos los resultados.
Observando la trama de proyectado. [CLS] incorporaciones para cada punto de entrenamiento, podemos ver la clara distinción entre oraciones positivas (azul) y negativas (roja) después de algunas épocas. Esta imagen muestra la notable capacidad de la arquitectura Transformer para adaptar las incorporaciones a lo largo del tiempo y destaca el poder del mecanismo de autoatención. Los datos se transforman de tal manera que las incrustaciones de cada clase estén bien separadas, simplificando así significativamente la tarea para el jefe del clasificador.
Al concluir nuestra exploración de la arquitectura Transformer, es evidente que estos modelos son expertos en adaptar datos a una tarea determinada. Con el uso de codificación posicional y autoatención de múltiples cabezales, los Transformers van más allá del mero procesamiento de datos: interpretan y comprenden información con un nivel de sofisticación nunca antes visto. La capacidad de sopesar dinámicamente la relevancia de diferentes partes de los datos de entrada permite una comprensión y representación más matizada del texto de entrada. Esto mejora el rendimiento en una amplia gama de tareas posteriores, incluida la clasificación de texto, la respuesta a preguntas, el reconocimiento de entidades con nombre y más.
Ahora que comprende mejor la arquitectura del codificador, está listo para profundizar en los modelos de decodificador y codificador-decodificador, que son muy similares a lo que acabamos de explorar. Los decodificadores desempeñan un papel fundamental en las tareas generativas y son el núcleo de los populares modelos GPT.