Architecture du transformer et implémentation avec Pytorch (Partie I)

transformer-architecture
Fig. Architecture du modèle du transformer

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:

process

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 de key 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. Les query et les key 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 math tokens d’entrée, il existe une matrice math 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 nbyn matrice contient maintenant tous les poids d’attention math

  • 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: update weights

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 math 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:

math

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 !!.

Endnote

Le notebook est disponible ici. Cheers ☕!

Ressources

Comments

comments powered by Disqus