AI & GPU
Pourquoi ai-je une faible utilisation du GPU ? Voici la solution :

Comprendre les causes d'une faible utilisation du GPU

Misskey AI

Identifier les goulots d'étranglement

L'une des principales raisons d'une faible utilisation du GPU dans les charges de travail d'apprentissage profond est la présence de goulots d'étranglement dans les exigences de calcul de l'application, les spécifications matérielles et le pipeline de données. Pour résoudre ces problèmes, nous devons analyser en détail l'application et le matériel sous-jacent pour identifier les goulots d'étranglement potentiels.

Analyser les exigences de calcul de l'application

La première étape pour comprendre les causes d'une faible utilisation du GPU consiste à analyser les exigences de calcul de votre application d'apprentissage profond. Cela implique d'examiner l'architecture du modèle, la taille et la complexité des données d'entrée, ainsi que la charge de travail d'entraînement ou d'inférence. En comprenant les demandes de calcul de votre application, vous pouvez mieux évaluer les ressources matérielles nécessaires pour atteindre une utilisation optimale du GPU.

Par exemple, considérons un réseau neuronal convolutif (CNN) pour la classification d'images. Les exigences de calcul du modèle dépendront de facteurs tels que le nombre de couches convolutives, la taille des images d'entrée, le nombre de cartes de caractéristiques et la complexité des couches entièrement connectées. Si le modèle est particulièrement profond ou si les images d'entrée sont haute résolution, les exigences de calcul peuvent dépasser les capacités du matériel GPU disponible, entraînant une faible utilisation du GPU.

Examiner les spécifications et les capacités du matériel

Ensuite, vous devez examiner attentivement les spécifications et les capacités du/des GPU que vous utilisez pour votre charge de travail d'apprentissage profond. Cela inclut des facteurs tels que la mémoire GPU, le nombre de cœurs, la bande passante mémoire, etc. En comprenant les capacités de votre matériel GPU, vous pourrez mieux évaluer s'il est adapté à vos besoins de calcul. La puissance de calcul, la capacité de mémoire, la bande passante mémoire et la configuration générale du système (par exemple, le CPU, la RAM, le stockage).

Par exemple, si vous utilisez un GPU avec une capacité de mémoire limitée, vous pouvez être limité dans la taille du lot que vous pouvez utiliser pendant l'entraînement, ce qui peut entraîner une sous-utilisation des ressources de calcul du GPU. De même, si la bande passante mémoire du GPU est insuffisante pour les exigences de transfert de données de votre application, vous pouvez rencontrer des goulots d'étranglement dans le pipeline de données, entraînant à nouveau une faible utilisation du GPU.

Identification des goulots d'étranglement potentiels dans le pipeline de données

Un autre aspect crucial à prendre en compte est le pipeline de données, qui comprend le chargement des données, le prétraitement et le transfert entre le CPU et le GPU. Une gestion inefficace des données peut avoir un impact significatif sur l'utilisation du GPU, car le GPU peut être inactif dans l'attente du chargement ou du transfert des données.

Par exemple, si vos étapes de prétraitement des données sont gourmandes en calcul et effectuées sur le CPU, le GPU peut être sous-utilisé dans l'attente du transfert des données prétraitées. Alternativement, si le transfert de données entre le CPU et le GPU n'est pas optimisé, le GPU peut être inactif pendant ces opérations de transfert de données.

En analysant les exigences de calcul de l'application, les spécifications matérielles et le pipeline de données, vous pouvez identifier les goulots d'étranglement potentiels qui contribuent à une faible utilisation du GPU dans votre charge de travail d'apprentissage profond.

Optimisation du pipeline de données

L'un des facteurs clés contribuant à une faible utilisation du GPU est l'efficacité du pipeline de données, qui comprend le chargement des données, le prétraitement et le transfert de données entre le CPU et le GPU. En optimisant le pipeline de données, vous pouvez vous assurer que le GPU est occupé et pleinement utilisé pendant le processus d'entraînement ou d'inférence.

Chargement et prétraitement efficaces des données

Pour optimiser le pipeline de données, vous devez d'abord vous concentrer sur un chargement et un prétraitement efficaces des données. Cela implique des techniques telles que :

  1. **Chargement asynchrone des données.

  2. Chargement asynchrone des données : Utilisez des techniques de chargement asynchrone des données, comme le DataLoader de PyTorch avec le paramètre num_workers ou le tf.data.Dataset de TensorFlow avec tf.data.experimental.AUTOTUNE, pour charger et prétraiter les données en parallèle sur le CPU pendant que le GPU effectue les calculs.

  3. Prétraitement efficace des données : Déléguez les étapes de prétraitement des données coûteuses en calcul au GPU, si possible, pour tirer parti de ses capacités de traitement parallèle. Cela peut inclure des opérations comme le redimensionnement d'images, la normalisation et l'augmentation.

  4. Mise en cache et mémoïsation des données : Mettez en cache les données prétraitées ou utilisez des techniques de mémoïsation pour éviter les prétraitements redondants, en particulier pour les grands jeux de données utilisés de manière répétée pendant l'entraînement.

En optimisant les étapes de chargement et de prétraitement des données, vous pouvez vous assurer que le GPU n'est pas inactif dans l'attente de la disponibilité des données, améliorant ainsi l'utilisation globale du GPU.

Minimiser le transfert de données entre le CPU et le GPU

Un autre aspect important de l'optimisation du pipeline de données est de minimiser le transfert de données entre le CPU et le GPU. Un transfert de données excessif peut entraîner des goulots d'étranglement significatifs en termes de performances et une faible utilisation du GPU.

Voici quelques techniques pour minimiser le transfert de données :

  1. Optimisation de la taille des lots : Déterminez la taille de lot optimale pour votre modèle, en tenant compte de la mémoire GPU disponible et du compromis entre la taille de lot et les performances du modèle.

  2. Mémoire épinglée : Utilisez la mémoire épinglée (également connue sous le nom de mémoire verrouillée) pour vos données d'entrée afin de permettre des transferts de données plus rapides entre le CPU et le GPU.

  3. Optimisation de la disposition des données : Assurez-vous que vos données sont stockées dans un format favorable au GPU, comme le format NCHW (Batch, Canaux, Hauteur, Largeur) pour les images, afin de minimiser le besoin de réorganisation des données pendant le transfert.

  4. Structures de données économes en mémoire : Utilisez des structures de données économes en mémoire, comme torch.Tensor de PyTorch ou tf.Tensor de TensorFlow, pour réduire l'empreinte mémoire globale et les besoins en transfert de données. Par la minimisation du transfert de données entre le CPU et le GPU, vous pouvez réduire le temps passé sur ces opérations de déplacement de données, permettant au GPU de se concentrer sur les tâches gourmandes en calcul et d'améliorer l'utilisation globale du GPU.

Tirer parti des techniques de chargement de données asynchrones

Pour optimiser davantage le pipeline de données, vous pouvez tirer parti des techniques de chargement de données asynchrones. Cela implique de chevaucher le chargement et le prétraitement des données avec les calculs réels du modèle sur le GPU, en veillant à ce que le GPU reste occupé et non inactif dans l'attente des données.

Dans PyTorch, vous pouvez utiliser la classe DataLoader avec le paramètre num_workers pour activer le chargement de données asynchrone. Dans TensorFlow, vous pouvez utiliser l'API tf.data.Dataset avec le paramètre tf.data.experimental.AUTOTUNE pour obtenir un effet similaire.

Voici un exemple de la façon dont vous pouvez configurer le chargement de données asynchrone dans PyTorch :

import torch
from torch.utils.data import DataLoader
 
# Définissez votre jeu de données
dataset = VotreJeuDeDonnées()
 
# Créez le DataLoader avec le chargement de données asynchrone
dataloader = DataLoader(dataset, batch_size=32, num_workers=4, pin_memory=True)
 
# Itérez sur le dataloader
for batch in dataloader:
    # Effectuez votre entraînement ou votre inférence sur le lot
    sorties = votre_modèle(batch)
    # ...

En tirant parti du chargement de données asynchrone, vous pouvez vous assurer que le GPU reste occupé pendant que le CPU est chargé de récupérer et de prétraiter le prochain lot de données, ce qui conduit à une meilleure utilisation du GPU.

Améliorer la taille du lot et le parallélisme

Un autre aspect crucial de l'optimisation de l'utilisation du GPU est de trouver le bon équilibre entre la taille du lot et le parallélisme. La taille du lot et la capacité à tirer parti du parallélisme multi-GPU peuvent avoir un impact significatif sur l'efficacité du GPU.

Déterminer la taille de lot optimale pour votre modèle

La taille du lot est un hyperparamètre important qui peut grandement affecter l'utilisation du GPU et les performances globales de votre modèle d'apprentissage profond. Une taille de lot plus importante c. En général, cela conduit à une meilleure utilisation du GPU, car cela permet au GPU de traiter plus de données simultanément, réduisant ainsi la surcharge des lancements de noyaux et de la gestion de la mémoire.

Cependant, l'augmentation de la taille du lot n'est pas sans ses limites. La taille de lot maximale est limitée par la mémoire GPU disponible, car des lots plus importants nécessitent plus de mémoire pour stocker les activations intermédiaires et les gradients pendant l'entraînement.

Pour déterminer la taille de lot optimale pour votre modèle, vous pouvez suivre ces étapes :

  1. Commencez avec une petite taille de lot : Commencez avec une petite taille de lot, comme 32 ou 64, et observez l'utilisation du GPU et les métriques de performance.
  2. Augmentez progressivement la taille du lot : Augmentez de manière incrémentielle la taille du lot, en surveillant l'utilisation du GPU et les performances du modèle (par exemple, la perte d'entraînement, la précision de validation) à chaque étape.
  3. Identifiez le point idéal : Continuez à augmenter la taille du lot jusqu'à ce que vous observiez une baisse significative de l'utilisation du GPU ou une dégradation des performances du modèle. C'est votre taille de lot optimale.

En trouvant le bon équilibre entre la taille du lot et les contraintes de mémoire GPU, vous pouvez maximiser l'utilisation du GPU et obtenir de meilleures performances globales.

Exploration des techniques pour augmenter la taille du lot sans épuiser la mémoire

Si vous constatez que la taille de lot optimale pour votre modèle est limitée par la mémoire GPU disponible, vous pouvez explorer des techniques pour augmenter la taille du lot sans épuiser la mémoire. Voici quelques-unes de ces techniques :

  1. Entraînement en précision mixte : Utilisez l'entraînement en précision mixte, qui consiste à effectuer des calculs dans une précision inférieure (par exemple, FP16) tout en maintenant la précision du modèle en FP32. Cela peut réduire considérablement l'empreinte mémoire et vous permettre d'utiliser une taille de lot plus importante.

  2. Accumulation des gradients : Mettez en œuvre l'accumulation des gradients, où vous accumulez les gradients sur plusieurs petits lots avant d'effectuer une mise à jour des paramètres. Cela augmente effectivement la taille du lot sans augmenter les exigences en mémoire.

  3. **M. Choisir des architectures de modèles plus économes en mémoire : Choisissez des architectures de modèles plus économes en mémoire, comme les réseaux de neurones convolutifs légers (par exemple, MobileNet, EfficientNet) ou les modèles basés sur les transformers (par exemple, BERT, GPT).

  4. Point de contrôle/Redémarrage : Utilisez des techniques de point de contrôle/redémarrage, où vous sauvegardez périodiquement l'état du modèle et le rechargez pendant l'entraînement. Cela vous permet d'augmenter efficacement la taille du lot sans manquer de mémoire.

En utilisant ces techniques, vous pouvez repousser les limites des contraintes de mémoire de votre GPU et atteindre des tailles de lot plus élevées, ce qui conduit à une meilleure utilisation du GPU.

Utiliser le parallélisme multi-GPU pour distribuer la charge de travail

En plus d'optimiser la taille du lot, vous pouvez également tirer parti du parallélisme multi-GPU pour distribuer la charge de calcul et améliorer l'utilisation globale du GPU. Cela peut se faire par le biais du parallélisme des données ou du parallélisme des modèles, selon les exigences spécifiques de votre application d'apprentissage profond.

  1. Parallélisme des données : Dans le parallélisme des données, vous répliquez le modèle sur plusieurs GPU et divisez le lot d'entrées sur les GPU. Chaque GPU traite une partie du lot, et les gradients sont ensuite agrégés et appliqués aux paramètres du modèle.

  2. Parallélisme des modèles : Dans le parallélisme des modèles, vous partitionnez le modèle lui-même sur plusieurs GPU, chaque GPU étant responsable du traitement d'une partie du modèle. Cette approche est particulièrement utile pour les modèles volumineux et complexes qui ne tiennent pas entièrement sur un seul GPU.

Voici un exemple de la façon dont vous pouvez configurer le parallélisme des données à l'aide du module nn.DataParallel de PyTorch :

import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
 
# Définissez votre modèle
model = VotreModèle()
 
# Créez un modèle parallèle aux données
model = nn.DataParallel(model)
 
# Définissez votre optimiseur et votre fonction de perte
optimizer = optim.Adam(model.parameters(), lr=0.001)
criterion = nn.CrossEntropyLoss()
 
# Créez le chargeur de données
datal.
dataloader = DataLoader(dataset, batch_size=64, shuffle=True)
 
# Entraîner le modèle
for epoch in range(num_epochs):
    for batch in dataloader:
        # Passe avant
        outputs = model(batch)
        loss = criterion(outputs, labels)
 
        # Passe arrière et optimisation
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

En tirant parti du parallélisme multi-GPU, vous pouvez répartir la charge de calcul sur plusieurs GPU, augmentant ainsi l'utilisation globale du GPU et réduisant le temps d'entraînement ou d'inférence.

Conception d'une architecture de modèle efficace

La conception de l'architecture du modèle d'apprentissage profond peut également avoir un impact significatif sur l'utilisation du GPU. En choisissant la bonne architecture de modèle et en optimisant sa complexité, vous pouvez vous assurer que les ressources du GPU sont utilisées de manière efficace.

Choisir la bonne architecture de modèle pour votre tâche

Lors de la sélection d'une architecture de modèle pour votre tâche d'apprentissage profond, il est essentiel de choisir celle qui est bien adaptée au problème en question. Différentes architectures de modèles ont des exigences de calcul, des empreintes mémoire et des capacités de parallélisation variables, ce qui peut avoir un impact direct sur l'utilisation du GPU.

Par exemple, si votre tâche est la classification d'images, vous pourriez envisager d'utiliser une architecture de réseau neuronal convolutif (CNN), car les CNN sont conçus pour traiter et extraire efficacement les caractéristiques des données d'image. En revanche, si votre tâche implique le traitement du langage naturel, une architecture basée sur les transformeurs, comme BERT ou GPT, pourrait être plus appropriée.

En alignant l'architecture du modèle sur les exigences spécifiques de votre tâche d'apprentissage profond, vous pouvez optimiser l'utilisation du GPU et obtenir de meilleures performances globales.

Réduire la complexité du modèle et le nombre de paramètres

Un autre aspect important de la conception de modèles efficaces est de réduire la complexité et le nombre de paramètres du modèle. Les modèles trop complexes avec un grand nombre de paramètres peuvent entraîner des besoins mémoire accrus et. Réduction de la complexité et des exigences de calcul, ce qui peut entraîner une faible utilisation du GPU.

Pour réduire la complexité du modèle, vous pouvez explorer des techniques telles que :

  1. Élagage du réseau : Supprimez les paramètres de modèle inutiles ou redondants grâce à des techniques comme l'élagage des poids, ce qui peut réduire la taille du modèle et l'empreinte mémoire.
  2. Distillation des connaissances : Entraînez un modèle étudiant plus petit et plus efficace en distillant les connaissances d'un modèle enseignant plus grand et plus complexe.
  3. Recherche d'architecture : Utilisez des algorithmes de recherche d'architecture automatisés pour découvrir des architectures de modèles efficaces adaptées à votre problème spécifique et à vos contraintes matérielles.

En optimisant la complexité du modèle et le nombre de paramètres, vous pouvez vous assurer que les ressources du GPU sont

Réseaux de neurones convolutifs (CNN)

Les réseaux de neurones convolutifs (CNN) sont un type spécialisé de réseau de neurones conçu pour fonctionner avec des données en grille, comme les images. Contrairement aux réseaux de neurones traditionnels qui traitent l'entrée comme un vecteur plat, les CNN tirent parti des relations spatiales au sein des données d'entrée, ce qui les rend très efficaces pour des tâches comme la reconnaissance et la classification d'images.

Les principaux composants d'une architecture CNN sont :

  1. Couches convolutives : Ces couches appliquent un ensemble de filtres apprenants à l'image d'entrée, extrayant des caractéristiques comme les bords, les formes et les textures. Chaque filtre est convoluté sur la largeur et la hauteur de l'entrée, produisant une carte d'activation 2D qui met en évidence les emplacements des caractéristiques détectées.
import torch.nn as nn
 
class ConvBlock(nn.Module):
    def __init__(self, in_channels, out_channels, kernel_size, stride=1, padding=0):
        super(ConvBlock, self).__init__()
        self.conv = nn.Conv2d(in_channels, out_channels, kernel_size, stride=stride, padding=padding)
        self.bn = nn.BatchNorm2d(out_channels)
        self.relu = nn.ReLU(inplace=True)
 
    def forward(self, x):
        x = self.conv(x)
        x = self.bn(x)
        x = self.relu(x)
        return x
  1. Couches de mise en commun : Ces couches réduisent les dimensions spatiales des cartes de caractéristiques, tout en préservant les informations les plus importantes. Les opérations de mise en commun courantes incluent le maximum de mise en commun et la moyenne de mise en commun.
import torch.nn as nn
 
class PoolingBlock(nn.Module):
    def __init__(self, kernel_size, stride):
        super(PoolingBlock, self).__init__()
        self.pool = nn.MaxPool2d(kernel_size=kernel_size, stride=stride)
 
    def forward(self, x):
        x = self.pool(x)
        return x
  1. Couches entièrement connectées : Ces couches sont similaires à celles que l'on trouve dans les réseaux de neurones traditionnels, et elles sont utilisées pour faire les prédictions finales en fonction des caractéristiques extraites.
import torch.nn as nn
 
class LinearBlock(nn.Module):
    def __init__(self, in_features, out_features):
        super(LinearBlock, self).__init__()
        self.fc = nn.Linear(in_features, out_features)
        self.relu = nn.ReLU(inplace=True)
 
    def forward(self, x):
        x = self.fc(x)
        x = self.relu(x)
        return x

L'architecture globale d'un CNN suit généralement un schéma de couches de convolution et de mise en commun alternées, suivies d'une ou plusieurs couches entièrement connectées. Cette structure permet au réseau d'apprendre des caractéristiques hiérarchiques, en partant de motifs de bas niveau comme les bords et les formes, et en construisant progressivement des représentations plus complexes et de haut niveau.

Voici un exemple d'une architecture CNN simple pour la classification d'images :

import torch.nn as nn
 
class SimpleCNN(nn.Module):
    def __init__(self, num_classes):
        super(SimpleCNN, self).__init__()
        self.conv1 = ConvBlock(3, 32, 3, 1, 1)
        self.pool1 = PoolingBlock(2, 2)
        self.conv2 = ConvBlock(32, 64, 3, 1, 1)
        self.pool2 = PoolingBlock(2, 2)
        self.fc1 = LinearBlock(64 * 7 * 7, 128)
        self.fc2 = nn.Linear(128, num_classes)
 
    def forward(self, x):
        x = self.conv1(x)
        x = self.pool1(x)
        x = self.conv2(x)
        x = self.pool2(x)
        .

x = x.view(x.size(0), -1) x = self.fc1(x) x = self.fc2(x) return x


Cette architecture se compose de deux couches de convolution, deux couches de mise en commun (pooling) et deux couches entièrement connectées. Les couches de convolution extraient les caractéristiques de l'image d'entrée, les couches de mise en commun réduisent les dimensions spatiales, et les couches entièrement connectées effectuent les prédictions de classification finales.

## Réseaux de neurones récurrents (RNNs)

Les réseaux de neurones récurrents (RNNs) sont un type de réseau de neurones conçu pour fonctionner avec des données séquentielles, telles que le texte, la parole ou les séries temporelles. Contrairement aux réseaux de neurones feedforward, qui traitent les entrées de manière indépendante, les RNNs maintiennent un état caché qui leur permet d'incorporer les informations des entrées précédentes dans la sortie actuelle.

Les principaux composants d'une architecture RNN sont :

1. **Cellule récurrente** : Il s'agit du bloc de construction fondamental d'un RNN, responsable du traitement de l'entrée actuelle et de l'état caché précédent pour produire l'état caché et la sortie actuels.

```python
import torch.nn as nn

class RNNCell(nn.Module):
    def __init__(self, input_size, hidden_size):
        super(RNNCell, self).__init__()
        self.i2h = nn.Linear(input_size, hidden_size)
        self.h2h = nn.Linear(hidden_size, hidden_size)
        self.activation = nn.Tanh()

    def forward(self, x, h_prev):
        # Traiter l'entrée actuelle et l'état caché précédent pour produire l'état caché actuel
        h_current = self.activation(self.i2h(x) + self.h2h(h_prev))
        return h_current
  1. Traitement de séquence : Les RNNs traitent les données séquentielles en itérant sur la séquence d'entrée, un élément à la fois, en mettant à jour l'état caché et en produisant une sortie à chaque étape.
import torch.nn as nn
 
class RNN(nn.Module):
    def __init__(self, input_size, hidden_size, num_layers):
        super(RNN, self).__init__()
        self.num_layers = num_layers
        self.hidden_size = hidden_size
        self.rnn_cells = nn.ModuleList([RNNCell(input_size, hidden_size) for _ in range(num_layers)])
 
    def forward(self, x):
        batch_size, seq_len,.
_ = x.size()
        h = torch.zeros(self.num_layers, batch_size, self.hidden_size, device=x.device)
        for t in range(seq_len):
            for l in range(self.num_layers):
                if l == 0:
                    h[l] = self.rnn_cells[l](x[:, t, :], h[l])
                else:
                    h[l] = self.rnn_cells[l](h[l-1], h[l])
        return h[-1]
  1. Variantes : Il existe plusieurs variantes de RNN, comme les Long Short-Term Memory (LSTM) et les Gated Recurrent Units (GRU), qui résolvent le problème du gradient qui s'évanouit et améliorent la capacité à capturer les dépendances à long terme dans les données.
import torch.nn as nn
 
class LSTM(nn.Module):
    def __init__(self, input_size, hidden_size, num_layers):
        super(LSTM, self).__init__()
        self.num_layers = num_layers
        self.hidden_size = hidden_size
        self.lstm_cells = nn.ModuleList([nn.LSTMCell(input_size if l == 0 else hidden_size, hidden_size) for l in range(num_layers)])
 
    def forward(self, x):
        batch_size, seq_len, _ = x.size()
        h = torch.zeros(self.num_layers, batch_size, self.hidden_size, device=x.device)
        c = torch.zeros(self.num_layers, batch_size, self.hidden_size, device=x.device)
        for t in range(seq_len):
            for l in range(self.num_layers):
                if l == 0:
                    h[l], c[l] = self.lstm_cells[l](x[:, t, :], (h[l], c[l]))
                else:
                    h[l], c[l] = self.lstm_cells[l](h[l-1], (h[l], c[l]))
        return h[-1]

Les RNN sont particulièrement utiles pour les tâches impliquant le traitement de données séquentielles, comme la modélisation du langage, la traduction automatique et la reconnaissance vocale. En maintenant un état caché, les RNN peuvent capturer les dépendances temporelles dans les données d'entrée, leur permettant de faire des prédictions plus informées.

Modèles Transformer

Les modèles Transformer, introduits dans l'article "Attention is All You Need" de Vaswani et al., ont révolutionné le domaine du traitement du langage naturel. (Traitement du langage naturel) et ont depuis été appliqués à divers autres domaines, notamment la vision par ordinateur et la reconnaissance vocale.

Les principaux composants d'une architecture Transformer sont :

  1. Mécanisme d'attention : Les Transformers s'appuient sur le mécanisme d'attention, qui permet au modèle de se concentrer sur les parties les plus pertinentes de l'entrée lors de la génération de la sortie. Cela est réalisé en calculant une somme pondérée des éléments d'entrée, où les poids sont déterminés par la similarité entre l'entrée actuelle et les entrées précédentes.
import torch.nn as nn
import torch.nn.functional as F
 
class MultiHeadAttention(nn.Module):
    def __init__(self, d_model, num_heads):
        super(MultiHeadAttention, self).__init__()
        self.d_model = d_model
        self.num_heads = num_heads
        self.d_k = d_model // num_heads
 
        self.q_linear = nn.Linear(d_model, d_model)
        self.k_linear = nn.Linear(d_model, d_model)
        self.v_linear = nn.Linear(d_model, d_model)
        self.out_linear = nn.Linear(d_model, d_model)
 
    def forward(self, q, k, v, mask=None):
        batch_size = q.size(0)
 
        # Projeter l'entrée en requêtes, clés et valeurs
        q = self.q_linear(q).view(batch_size, -1, self.num_heads, self.d_k)
        k = self.k_linear(k).view(batch_size, -1, self.num_heads, self.d_k)
        v = self.v_linear(v).view(batch_size, -1, self.num_heads, self.d_k)
 
        # Calculer les scores d'attention
        scores = torch.matmul(q, k.transpose(-2, -1)) / (self.d_k ** 0.5)
        if mask is not None:
            scores = scores.masked_fill(mask == 0, -1e9)
        attention_weights = F.softmax(scores, dim=-1)
 
        # Calculer la somme pondérée des valeurs
        context = torch.matmul(attention_weights, v)
        context = context.transpose(1, 2).contiguous().view(batch_size, -1, self.d_model)
        output = self.out_linear(context)
        return output
  1. Architecture encodeur-décodeur : Les modèles Transformer ont généralement une structure encodeur-décodeur.
import torch.nn as nn
 
class TransformerEncoder(nn.Module):
    def __init__(self, d_model, num_heads, num_layers, dropout=0.1):
        super(TransformerEncoder, self).__init__()
        self.layers = nn.ModuleList([
            TransformerEncoderLayer(d_model, num_heads, dropout) for _ in range(num_layers)
        ])
 
    def forward(self, x, mask=None):
        # Passe chaque couche de l'encodeur
        for layer in self.layers:
            x = layer(x, mask)
        return x
 
class TransformerEncoderLayer(nn.Module):
    def __init__(self, d_model, num_heads, dropout=0.1):
        super(TransformerEncoderLayer, self).__init__()
        self.self_attn = MultiHeadAttention(d_model, num_heads)
        self.feedforward = nn.Sequential(
            nn.Linear(d_model, d_model * 4),
            .