Architecture du transformer et implémentation avec Pytorch (Partie I)¶
Qu’est ce qu’un transformer ?: Un modèle de transformer est un réseau neuronal qui apprend le contexte et donc le sens en suivant les relations dans les données séquentielles comme les mots de cette phrase. Dans la version originale de leur papier Attention Is All You Need, les auteurs définissent le transformer comme une nouvelle architecture de réseau simple basée uniquement sur les mécanismes d’attention, exemptée entièrement de récurrence ou de convolution.
Applications
Les transformers constituent ce qu’on appelle maintenant la fondation des modèles de deep learning. Ils sont utilisés entre autres pour les tâches comme:
Question réponse
Classification de texte
Extraction d’information
Reconnaissance d’objets
Analyse de sentiment
Légende des images
etc
Le transformer est constitué de deux grandes parties que sont l’encodeur et le décodeur. L’encodeur prend la séquence d’entrée et crée une représentation contextuelle (également appelée contexte) de celle-ci. Le décodeur prend cette représentation contextuelle en entrée et génère la séquence de sortie. Le processus peut être résumé comme suit:
X constitue l’entrée et Y la sortie.
Dans cette première partie, nous allons suivre le processus bloc par bloc afin de construire notre propre encoder du tranformer. Commençons par le point le plus important: le self attention
.
Self-Attention ou Auto-Attention¶
Il existe plusieurs façons de mettre en œuvre une couche d’auto-attention, mais la plus courante est l’attention par produit scalaire, tirée de l’article présentant l’architecture du transformer Quatre étapes principales sont nécessaires pour mettre en œuvre ce mécanisme :
Projection de chaque encastrement de jeton dans trois vecteurs appelés
key
,query
,value
.Calculer les scores d’attention. Nous déterminons dans quelle mesure les vecteurs de
query
et dekey
sont liés les uns aux autres en utilisant une fonction de similarité. Comme son nom l’indique, la fonction de similarité pour l’attention par produit scalaire est le produit scalaire, calculé efficacement en utilisant la multiplication matricielle des incorporations. Lesquery
et leskey
qui sont similaires auront un produit scalaire important, tandis que ceux qui n’ont pas beaucoup de points communs n’auront que peu ou pas de chevauchement. Les résultats de cette étape sont appelés les scores d’attention, et pour une séquence de tokens d’entrée, il existe une matrice correspondante de scores d’attention.Calculer les poids d’attention. Les produits scalaires peuvent en général produire des nombres arbitrairement grands, ce qui peut déstabiliser le processus de formation. Pour y remédier, les scores d’attention sont d’abord multipliés par un facteur d’échelle afin de normaliser leur variance, puis normalisés à l’aide d’un softmax afin de s’assurer que la somme de toutes les valeurs des colonnes est égale à 1. Le résultat des matrice contient maintenant tous les poids d’attention
Mise à jour de l’intégration des jetons. Une fois les poids d’attention calculés, nous les multiplions par le vecteur de valeurs (
value
) afin d’obtenir une représentation actualisée pour l’incorporation:
En premier temps, nous allons extraire les tokens de notre texte:
PS: Pour plus de facilité nous travaillerons avec les hyper-paramètres utilisés dans l’architecture BERT. Retrouvez le model de Bert sur huggingface ici et le papier ici
from transformers import AutoTokenizer
model_ckpt = "bert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(model_ckpt)
text = "time flies like an arrow"
inputs = tokenizer(text, return_tensors="pt", add_special_tokens=False)
inputs.input_ids
tensor([[ 2051, 10029, 2066, 2019, 8612]])
Nous avons ajouté le add_special_tokens=False
pour ignorer les tokens spéciales comme [CLS] et [SEP].
Ensuite, nous devons créer des incorporations denses. Dans ce contexte, dense signifie que chaque entrée dans les incorporations contient une valeur non nulle. Ces incorporations sont des vecteurs zéros avec une seule valeur de 1 à une position donnée: one hot encoding
.
from torch import nn
from transformers import AutoConfig
config = AutoConfig.from_pretrained(model_ckpt)
config
BertConfig {
"architectures": [
"BertForMaskedLM"
],
"attention_probs_dropout_prob": 0.1,
"classifier_dropout": null,
"gradient_checkpointing": false,
"hidden_act": "gelu",
"hidden_dropout_prob": 0.1,
"hidden_size": 768,
"initializer_range": 0.02,
"intermediate_size": 3072,
"layer_norm_eps": 1e-12,
"max_position_embeddings": 512,
"model_type": "bert",
"num_attention_heads": 12,
"num_hidden_layers": 12,
"pad_token_id": 0,
"position_embedding_type": "absolute",
"transformers_version": "4.11.3",
"type_vocab_size": 2,
"use_cache": true,
"vocab_size": 30522
}
token_emb = nn.Embedding(config.vocab_size, config.hidden_size)
token_emb
Embedding(30522, 768)
inputs_embeds = token_emb(inputs.input_ids)
inputs_embeds.size()
torch.Size([1, 5, 768])
Pour l’instant, nous allons remettre à plus tard l’encodage de position et passer à la création des clés, requêtes et valeurs en utilisant le produit scalaire comme fonction de similarité.
import torch
from math import sqrt
query = key = value = inputs_embeds
dim_k = key.size(-1)
scores = torch.bmm(query, key.transpose(1,2)) / sqrt(dim_k)
scores.size()
torch.Size([1, 5, 5])
La division par du score permet de ne pas avoir de grandes valeurs durant l’entraînement. Par la suite, appliquons la fonction softmax qui va permettre d’avoir une somme des scores égale à 1.
import torch.nn.functional as F
weights = F.softmax(scores, dim=-1)
weights.sum(dim=-1)
tensor([[1., 1., 1., 1., 1.]], grad_fn=<SumBackward1>)
attn_outputs = torch.bmm(weights, value)
attn_outputs.shape
torch.Size([1, 5, 768])
Nous venons de finir une implémentation simplifiée d’auto-attention. Nous rappelons que tous le processus est juste une multiplication matricielle et une fonction softmax.
def scaled_dot_product_attention(query, key, value):
dim_k = key.size(-1)
scores = torch.bmm(query, key.transpose(1,2)) / sqrt(dim_k)
weights = F.softmax(scores, dim=-1)
return torch.bmm(weights, value)
Le self attention est finalement calculé comme suit:
Multi-head attention: attention à têtes multiples¶
En pratique, la couche d’auto-attention applique trois transformations linéaires indépendantes à chaque incorporation pour générer les vecteurs de requête, de clé et de valeur. Ces transformations projettent les enchâssements et chaque projection porte son propre ensemble de paramètres apprenables, ce qui permet à la couche d’auto-attention de se concentrer sur différents aspects sémantiques de la séquence.
Il s’avère également avantageux de disposer de plusieurs ensembles de projections linéaires, chacun représentant une tête d’attention.Mais pourquoi avons-nous besoin de plus d’une tête d’attention ? La raison est que la softmax d’une tête a tendance à se concentrer sur un seul aspect de la similarité. Le fait d’avoir plusieurs têtes permet au modèle de se concentrer sur plusieurs aspects à la fois. Par exemple, une tête peut se concentrer sur l’interaction sujet-verbe, tandis qu’une autre trouve des adjectifs proches. Il est évident que nous n’intégrons pas ces relations dans le modèle et qu’elles sont entièrement apprises à partir des données. Une analogie peut être faite avec les modèles de vision par ordinateur. Nous avons les filtres des réseaux neuronaux convolutifs, où un filtre peut être responsable de la détection des visages et un autre de la recherche des roues de voitures dans les images.
Commençons d’abord par implémenter une seule attention à tête:
class AttentionHead(nn.Module):
def __init__(self, embed_dim, head_dim):
super().__init__()
self.q = nn.Linear(embed_dim, head_dim)
self.k = nn.Linear(embed_dim, head_dim)
self.v = nn.Linear(embed_dim, head_dim)
def forward(self, hidden_state):
attn_outputs = scaled_dot_product_attention(
self.q(hidden_state), self.k(hidden_state), self.v(hidden_state)
)
return attn_outputs
En pratique, on choisit la valeur de embed_dim
pour qu’elle soit un multiple de head_dim
. En prenant l’exemple de l’architecture BERT, la dimension de l’entête est de 768/12 = 64.
Maintenant que nous avons une seule tête d’attention, nous pouvons concaténer les sorties de chacune d’elles pour mettre en œuvre la couche d’attention multi-têtes complète :
class MultiHeadAttention(nn.Module):
def __init__(self, config):
super().__init__()
embed_dim = config.hidden_size
num_heads = config.num_attention_heads
head_dim = embed_dim // num_heads
self.heads = nn.ModuleList(
[AttentionHead(embed_dim, head_dim) for _ in range(num_heads)]
)
# A la sortie de cette couche, nous avons un vecteur [batch, embed_dim, head_dim]
self.output_linear = nn.Linear(embed_dim, embed_dim)
def forward(self, hidden_state):
x = torch.cat([h(hidden_state) for h in self.heads], dim=-1)
# la fonction cat permet de concatener les sorties de la couche d'attention à
# seule tête pour avoir un vecteur [batch, embed_dim, head_dim*num_heads]
# head_dim*num_heads encore égal à embed_dim: entrée de la couche linéaire suivante.
x = self.output_linear(x)
return x
multihead_attn = MultiHeadAttention(config)
attn_output = multihead_attn(inputs_embeds)
attn_output.size()
torch.Size([1, 5, 768])
The Feed-Forward Layer ou couche à propagation avant¶
Cette sous-couche dans le transformer est un simple réseau neuronal entièrement connecté à deux couches, mais avec une particularité : au lieu de traiter la séquence entière d’incorporations comme un vecteur unique, elle traite chaque incorporation indépendamment. C’est la raison pour laquelle cette couche est souvent appelée couche à propagation avant en fonction de la position.
Une règle empirique tirée de la littérature est que le hidden_size
de la première couche doit être quatre fois supérieure à la taille des embeddings, et une fonction d’activation GELU
est le plus souvent utilisée.
class FeedForward(nn.Module):
def __init__(self, config):
super().__init__()
self.linear_1 = nn.Linear(config.hidden_size, config.intermediate_size)
self.linear_2 = nn.Linear(config.intermediate_size, config.hidden_size)
self.gelu = nn.GELU()
self.dropout = nn.Dropout(config.hidden_dropout_prob)
def forward(self, x):
x = self.gelu(self.linear_1(x))
x = self.linear_2(x)
x = self.dropout(x)
return x
feed_forward = FeedForward(config)
ff_outputs = feed_forward(attn_output)
ff_outputs.size()
torch.Size([1, 5, 768])
Nous avons maintenant tous les ingrédients pour créer une couche d’encodeur du transformer ! La seule décision qui reste à prendre est de savoir où placer les connexions de saut et la normalisation de la couche.
Layer Normalisation ou normalisation de couche¶
Le transformer normalise chaque entrée du lot pour qu’elle ait une moyenne nulle et une variance unitaire. Les connexions de saut passent un tenseur (non traité) à la couche suivante du modèle et l’ajoute au tenseur traité. Dans la littérature, nous avons deux options possibles pour la normalisation:
Normalisation post-couche: ici la normalisation est effectuée entre les connexions de saut. Cette disposition est délicate à former à partir de zéro car les gradients peuvent diverger. Pour cette raison, vous verrez souvent un concept connu sous le nom de
learning rate warm-up
, où le taux d’apprentissage est progressivement augmenté d’une petite valeur à une valeur maximale pendant l’entraînement.Normalisation de la pré-couche: Il s’agit de la disposition la plus courante trouvée dans la littérature; elle place la normalisation de couche dans la portée des connexions de saut. Elle tend à être beaucoup plus stable pendant l’apprentissage et ne nécessite généralement pas l’usage du
learning rate warm-up
.
Nous allons utiliser la seconde option et écrire l’encodeur de notre transformer de la façon suivante:
class TransformerEncoderLayer(nn.Module):
def __init__(self, config):
super().__init__()
self.l_norm_1 = nn.LayerNorm(config.hidden_size)
self.l_norm_2 = nn.LayerNorm(config.hidden_size)
self.attention = MultiHeadAttention(config)
self.feed_forward = FeedForward(config)
def forward(self, x):
# 1- layer normalisation
hidden_state = self.l_norm_1(x)
# 2- apply attention with skip connection
x = x + self.attention(hidden_state)
# 3- feed forward layer with skip connection
x = x + self.feed_forward(self.l_norm_2(x))
return x
encoder_layer = TransformerEncoderLayer(config)
inputs_embeds.shape, encoder_layer(inputs_embeds).shape
(torch.Size([1, 5, 768]), torch.Size([1, 5, 768]))
Actuellement les couches de l’encodeur sont invariants par rapport à la position des token. Nous allons ajouter cette nouvelle information en utilisant le positional embeddings
Positional embeddings ou encastrement positionnels¶
Son but est de permettre au modèle d’apprendre la formation des tokens. Etant donné qu’une phrase n’a de sens que si les ordres des mots sont respectés. Cette couche apprend cette constitution des mots.
Créons un module Embeddings personnalisé qui combine une couche d’embeddings de tokens qui projette les input_ids
au hidden state dense avec l’embedding positionnel qui fait la même chose pour les position_ids
.
Dans la configuration de BERT par exemple, la taille maximale d’un paragraphe pris en compte est de 512, ce qui veut dire que nous allons fixé comme position maximale d’un token à 512.
L’incorporation résultante est simplement la somme des deux incorporations :
class Embeddings(nn.Module):
def __init__(self, config):
super().__init__()
self.token_embeddings = nn.Embedding(config.vocab_size, config.hidden_size)
self.position_embeddings = nn.Embedding(config.max_position_embeddings, config.hidden_size)
self.layer_norm = nn.LayerNorm(config.hidden_size, eps=1e-12)
self.dropout = nn.Dropout()
def forward(self, input_ids):
# Positions ids for the inputs
seq_length = input_ids.size(1)
positions_ids = torch.arange(seq_length, dtype=torch.long).unsqueeze(0)
# Now we create position and token embeddings
token_embeddings = self.token_embeddings(input_ids)
position_embeddings = self.position_embeddings(positions_ids)
# token combination
embeddings = token_embeddings + position_embeddings
embeddings = self.layer_norm(embeddings)
embeddings = self.dropout(embeddings)
return embeddings
embedding_layer = Embeddings(config)
embedding_layer(inputs.input_ids).size()
torch.Size([1, 5, 768])
Comprendre plus sur le positional embedding ici
Combinons ces différentes étapes pour construire la couche d’encodage.
class TransformerEncoder(nn.Module):
def __init__(self, config):
super().__init__()
self.embedding = Embeddings(config)
self.layers = nn.ModuleList([TransformerEncoderLayer(config)
for _ in range(config.num_hidden_layers)])
def forward(self, x):
x = self.embedding(x)
for layer in self.layers:
x = layer(x)
return x
encoder = TransformerEncoder(config)
encoder(inputs.input_ids).shape
torch.Size([1, 5, 768])
A cette étape, nous avons l’état caché de chaque token. Un grand avantage des modèles de transformer est qu’il peuvent être divisé en deux parties:
en un corps indépendant de la tâche et
une tête spécifique à la tâche.
Notre encodeur étant prêt à l’utilisation, ajoutons une tête de couche qui sera utilisé pour la classification.
class TransformerForSequenceClassification(nn.Module):
def __init__(self, config):
super().__init__()
self.encoder = TransformerEncoder(config)
self.dropout = nn.Dropout(config.hidden_dropout_prob)
self.classifier = nn.Linear(config.hidden_size, config.num_labels)
def forward(self, x):
x = self.encoder(x)[:, 0, :]
x = self.dropout(x)
x = self.classifier(x)
return x
config.num_labels = 3
encoder_classifier = TransformerForSequenceClassification(config)
encoder_classifier(inputs.input_ids).shape
torch.Size([1, 3])
Nous avons défini pour notre tâche de classification 3 catégories, les données sont envoyées vers la sous-couche de classification après passage de l’encodeur et du dropout.
Ceci marque la fin de l’encodeur !!.