Les Fondamentaux du Deep Learning : Concepts et Architectures Clés

Modèles et Couches

Le développement de systèmes d'apprentissage profond repose sur l'interaction entre les modèles et les couches. Une couche est l'unité de calcul fondamentale, chargée d'opérations spécifiques telles que l'extraction de caractéristiques, la transformation des données ou la réduction de dimension. Un modèle, quant à lui, orchestre le flux complet des données, de l'entrée à la sortie, en encapsulant une ou plusieurs couches pour former une architecture cohérente.

Dans le contexte du traitement d'images, une image initiale peut être aplatie en un vecteur unidimensionnel pour des opérations linéaires (par exemple, dans une régression Softmax), où chaque élément représente une caractéristique d'entrée. Ce vecteur est ensuite traité par des couches cachées avant de produire un résultat. Pour gérer plusieurs images, ces opérations sont souvent vectorisées et regroupées en calculs matriciels, permettant un traitement efficace en parallèle.

Le nombre de fois qu'un modèle est entraîné correspond au nombre d'itérations d'ajustement de ses paramètres.

Propagation Avant

La propagation avant (forward propagation) est le processus itératif de calcul qui transforme les données d'entrée en une prédiction de sortie. Elle implique des transformations linéaires des poids, l'application de fonctions d'activation non linéaires, et le calcul final d'une fonction de perte. Ce mécanisme constitue la base de l'entraînement du modèle, fournissant les informations nécessaires à la phase de rétropropagation.

Rétropropagation

Après avoir calculé la perte (L) via la propagation avant, la rétropropagation (backpropagation) est utilisée pour ajuster les paramètres du modèle. Ce processus s'appuie sur la règle de la chaîne pour calculer les gradients de la fonction de perte par rapport à chaque paramètre du réseau. Les gradients sont propagés de la couche de sortie vers les couches d'entrée, permettant la mise à jour des poids et des biais de chaque couche.

Les étapes typiques de la rétropropagation comprennent :

  1. Calcul du gradient de la perte par rapport à la sortie de la couche.
  2. Calcul du gradient de la sortie de la couche par rapport à la transformation linéaire interne.
  3. Calcul du gradient de la transformation linéaire par rapport aux poids et biais de la couche.

Dans PyTorch, l'appel à loss.backward() déclenche automatiquement ce processus. Le mécanisme d'autograd de PyTorch construit un graphe de calcul lors de la propagation avant. L'appel à .backward(), généralement sur une perte scalaire (par exemple, après .sum() ou .mean()), indique à PyTorch de parcourir ce graphe en arrière et de calculer les gradients pour tous les tenseurs ayant requires_grad=True.

Définir l'attribut requires_grad d'un tenseur à True active le suivi des opérations sur ce tenseur, ce qui est essentiel pour la construction du graphe de calcul et la détermination automatique des gradients lors de la rétropropagation.

Descente de Gradient

La descente de gradient est un algorithme d'optimisation central en apprentissage profond, utilisé pour minimiser une fonction objectif (généralement la fonction de perte) et trouver les valeurs optimales des paramètres (poids et biais) d'un modèle. Son principe consiste à ajuster itérativement les paramètres dans la direction opposée du gradient de la fonction objectif jusqu'à ce que la convergence soit atteinte ou qu'un nombre maximal d'itérations soit exécuté.

Les étapes clés de la descente de gradient sont :

  1. Initialisation des paramètres : Les poids et biais du modèle sont initialisés avec des valeurs aléatoires.
  2. Calcul du gradient : Le gradient de la fonction objectif par rapport aux paramètres actuels est calculé (souvent via la rétropropagation).
  3. Mise à jour des paramètres : Les paramètres sont mis à jour selon la formule : ``` θ ← θ - α * ∇J(θ)
    
     où \\(θ\\) représente les paramètres du modèle, \\(α\\) est le taux d'apprentissage (learning rate), et \\(∇J(θ)\\) est le gradient de la fonction objectif \\(J\\) par rapport à \\(θ\\).
    
  4. Itération : Les étapes 2 et 3 sont répétées jusqu'à ce qu'un critère d'arrêt soit satisfait (par exemple, la perte converge sous un seuil donné ou le nombre maximal d'itérations est atteint).

Composants d'un Réseau Neuronal Classique

Un réseau neuronal se compose principalement de trois types de couches :

  1. Couche d'entrée : Reçoit les données brutes et les transmet au réseau sans effectuer de calculs.
  2. Couches cachées (intermédiaires) : Effectuent des calculs complexes, combinant des sommes pondérées et des fonctions d'activation non linéaires pour extraire des caractéristiques. Un réseau peut avoir plusieurs couches cachées, chacune apprenant des représentations différentes des données.
  3. Couche de sortie : Produit le résultat final du modèle, tel que des probabilités de classe pour une tâche de classification ou une valeur continue pour une tâche de régression.

Entraînement du Modèle

Après l'initialisation des paramètres du modèle, les phases de propagation avant et de rétropropagation sont alternées. La propagation avant calcule la sortie et la perte, puis la rétropropagation calcule les gradients nécessaires à la mise à jour des paramètres via un optimiseur. Durant ce processus, les variables intermmédiaires calculées pendant la propagation avant sont réutilisées pour éviter des calculs redondants lors de la rétropropagation. Cela signifie que ces variables ne peuvent pas être immédiatement libérées de la mémoire après la propagation avant, ce qui explique pourquoi l'entraînement consomme plus de mémoire que la simple prédiction. Le nombre et la taille de ces variables intermédiaires augmentent proportionnellement avec la profondeur du réseau, la taille du lot et la dimensionnalité des entrées, ce qui peut entraîner des problèmes de mémoire pour les architectures profondes avec de grands lots.

Explosion et Vanishing des Gradients

Lors de l'entraînement des réseaux de neurones, deux problèmes liés à la taille des gradients peuvent survenir :

  • Vanishing Gradients (Gradients évanescents) : Les gradients deviennent excessivement petits, ce qui ralentit considérablement, voire arrête, la mise à jour des paramètres des couches profondes. Le modèle peine alors à apprendre.
  • Exploding Gradients (Gradients explosifs) : Les gradients deviennent excessivement grands, entraînant des mises à jour de paramètres très importantes et instables. Cela peut provoquer des oscillations dans la fonction de perte et empêcher le modèle de converger.

Initialisation Aléatoire des Paramètres

Il est crucial d'initialiser aléatoirement les paramètres des modèles de réseaux neuronaux. Si tous les paramètres (notamment les poids) des unités cachées étaient initialisés à la même valeur, toutes ces unités effectueraient les mêmes calculs et produiraient les mêmes sorties. Par conséquent, lors de la rétropropagation, elles recevraient les mêmes gradients et seraient mises à jour de manière identique. Cela signifierait qu'elles ne pourraient pas apprendre des représentations distinctes et se comporteraient comme une seule unité, réduisant l'efficacité et la capacité expressive du réseau.

L'initialisation aléatoire rompt cette symétrie, permettant à chaque unité cachée de commencer avec des conditions uniques et d'apprendre des caractéristiques différentes des données, ce qui est essentiel pour la diversité et la richesse des informations traitées par le réseau.

PyTorch utilise des stratégies d'initialisation par défaut "raisonnables" pour les paramètres de ses modules nn.Module. Cependant, il est souvent utile de personnaliser cette initialisation. Le module torch.nn.init propose diverses méthodes préétablies, telles que normal_() pour l'initialisation normale ou constant_() pour des valeurs constantes.

Construction de Modèles

  1. Utilisation de nn.Sequential

nn.Sequential est un conteneur qui empile les couches de manière séquentielle, où la sortie d'une couche devient l'entrée de la suivante. C'est une méthode simple pour construire des modèles linéaires. Il accepte une liste d'instances de nn.Module ou un OrderedDict.

import torch
from torch import nn
from collections import OrderedDict

# Méthode 1: Passer les couches directement
model_seq_1 = nn.Sequential(
    nn.Linear(10, 20),
    nn.ReLU(),
    nn.Linear(20, 1)
)
print("Modèle Sequential (Méthode 1):", model_seq_1)

# Méthode 2: Ajouter des modules via add_module
model_seq_2 = nn.Sequential()
model_seq_2.add_module('conv_layer', nn.Conv2d(3, 3, 3))
model_seq_2.add_module('batch_norm_layer', nn.BatchNorm2d(3))
model_seq_2.add_module('activation_layer', nn.ReLU())
print("Modèle Sequential (Méthode 2):", model_seq_2)

# Méthode 3: Utiliser un OrderedDict
model_seq_3 = nn.Sequential(OrderedDict([
    ('conv_layer', nn.Conv2d(3, 3, 3)),
    ('batch_norm_layer', nn.BatchNorm2d(3)),
    ('activation_layer', nn.ReLU())
]))
print("Modèle Sequential (Méthode 3):", model_seq_3)

  1. Héritage de nn.Module

Pour des architectures plus complexes ou personnalisées, il est recommandé d'hériter de la classe de base nn.Module. Cela permet de définir ses propres couches et la logique de propagation avant.

Le processus implique de surcharger les fonctions __init__ (pour déclarer les couches avec paramètres) et forward (pour définir le calcul de la propagation avant).

import torch
from torch import nn

class MonMLP(nn.Module):
    def __init__(self, input_dim, hidden_dim, output_dim):
        super(MonMLP, self).__init__()
        self.couche_cachee = nn.Linear(input_dim, hidden_dim)
        self.activation = nn.ReLU()
        self.couche_sortie = nn.Linear(hidden_dim, output_dim)

    def forward(self, x):
        x = self.couche_cachee(x)
        x = self.activation(x)
        x = self.couche_sortie(x)
        return x

# Instanciation et test du modèle
mlp_model = MonMLP(input_dim=784, hidden_dim=256, output_dim=10)
input_data = torch.randn(2, 784) # Batch de 2 échantillons, 784 caractéristiques
output = mlp_model(input_data)
print("Sortie de MonMLP:", output.shape)

L'appel mlp_model(input_data) invoque la fonction __call__ de nn.Module, qui à son tour appelle la méthode forward que nous avons définie.

Un exemple de classe séquentielle personnalisée, similaire à nn.Sequential, est donné ci-dessous pour illustrer la flexibilité de nn.Module :

import torch
from torch import nn
from collections import OrderedDict

class MonSequentialPersonnalise(nn.Module):
    def __init__(self, *args):
        super(MonSequentialPersonnalise, self).__init__()
        if len(args) == 1 and isinstance(args[0], OrderedDict):
            for key, module in args[0].items():
                self.add_module(key, module)
        else:
            for idx, module in enumerate(args):
                self.add_module(str(idx), module)

    def forward(self, input_tensor):
        for module in self._modules.values():
            input_tensor = module(input_tensor)
        return input_tensor

# Utilisation de la classe personnalisée
X_tensor = torch.randn(2, 784)
custom_net = MonSequentialPersonnalise(
    nn.Linear(784, 256),
    nn.ReLU(),
    nn.Linear(256, 10),
)
print("Modèle personnalisé séquentiel:", custom_net)
output_custom = custom_net(X_tensor)
print("Sortie du modèle personnalisé:", output_custom.shape)

  1. nn.ModuleList

nn.ModuleList est un conteneur pour stocker une liste de modules, similaire à une liste Python classique. Il permet d'accéder aux modules par index et de modifier la liste (append, extend). Cependant, contrairement à nn.Sequential, ModuleList n'implémente pas de méthode forward par défaut ; il faut la définir manuellement pour spécifier comment les modules interagissent.

import torch
from torch import nn

reseau_list = nn.ModuleList([nn.Linear(784, 256), nn.ReLU()])
reseau_list.append(nn.Linear(256, 10))
print("Dernière couche:", reseau_list[-1])
print("ModuleList:", reseau_list)

# Pour utiliser ModuleList, vous devez définir le forward explicitement
class MonModuleAvecListe(nn.Module):
    def __init__(self, input_dim, output_dim, num_layers):
        super(MonModuleAvecListe, self).__init__()
        self.couches = nn.ModuleList([nn.Linear(input_dim, input_dim) for _ in range(num_layers)])
        self.output_layer = nn.Linear(input_dim, output_dim)

    def forward(self, x):
        for layer in self.couches:
            x = layer(x) + x # Exemple d'opération
        x = self.output_layer(x)
        return x

test_modulelist = MonModuleAvecListe(10, 1, 5)
print("ModuleList dans un modèle:", test_modulelist)
# test_modulelist(torch.randn(1, 10)) # Pourrait être utilisé

L'avantage de ModuleList par rapport à une liste Python standard est que les paramètres de ses modules sont automatiquement enregistrés et gérés par le modèle global.

  1. nn.ModuleDict

nn.ModuleDict est un conteneur qui stocke des modules dans un dictionnaire, permettant un accès par clé. Comme ModuleList, il ne définit pas de méthode forward et nécessite une implémentation manuelle pour spécifier l'interaction des modules.

import torch
from torch import nn

reseau_dict = nn.ModuleDict({
    'linear_input': nn.Linear(784, 256),
    'activation': nn.ReLU(),
})
reseau_dict['linear_output'] = nn.Linear(256, 10) # Ajout via clé
print("Couche linéaire d'entrée:", reseau_dict['linear_input'])
print("Couche de sortie via attribut:", reseau_dict.linear_output) # Accès via attribut
print("ModuleDict:", reseau_dict)

# Pour utiliser ModuleDict, vous devez définir le forward explicitement
class MonModuleAvecDict(nn.Module):
    def __init__(self, num_heads):
        super(MonModuleAvecDict, self).__init__()
        self.attention_heads = nn.ModuleDict({
            f'head_{i}': nn.Linear(64, 64) for i in range(num_heads)
        })
        
    def forward(self, x):
        outputs = []
        for key in self.attention_heads:
            outputs.append(self.attention_heads[key](x))
        return torch.cat(outputs, dim=-1)

test_moduledict = MonModuleAvecDict(num_heads=4)
print("ModuleDict dans un modèle:", test_moduledict)

Similaire à ModuleList, les paramètres des modules stockés dans ModuleDict sont automatiquement inclus dans la liste des paramètres du modèle principal.

Accès, Initialisation et Partage des Paramètres

  1. Accès aux paramètres du modèle

Pour un modèle, l'accès à ses paramètres est fondamental. La méthode named_parameters() d'une instance de nn.Module permet d'itérer sur tous les paramètres du modèle, en renvoyant leur nom et le tenseur Parameter correspondant.

import torch
from torch import nn

class SimpleNet(nn.Module):
    def __init__(self):
        super().__init__()
        self.hidden_layer = nn.Linear(4, 3)
        self.output_layer = nn.Linear(3, 1)

    def forward(self, x):
        return self.output_layer(self.hidden_layer(x))

net = SimpleNet()

print("Type de named_parameters:", type(net.named_parameters()))
for name, param in net.named_parameters():
    print(f"Nom: {name} | Taille: {param.size()}")

# Accès aux paramètres d'une couche spécifique dans nn.Sequential
sequential_net = nn.Sequential(
    nn.Linear(4, 3),
    nn.ReLU(),
    nn.Linear(3, 1)
)
for name, param in sequential_net[0].named_parameters():
    print(f"Paramètre de la couche 0: {name} | Taille: {param.size()} | Type: {type(param)}")

Les paramètres sont des instances de torch.nn.Parameter, une sous-classe de torch.Tensor qui est automatiquement enregistrée dans la liste des paramètres du module parent. On peut accéder à la valeur numérique d'un paramètre via son attribut .data et à son gradient via .grad après la rétropropagation.

Pour les modèles personnalisés héritant de nn.Module, l'accès peut se faire de la même manière ou directement via les attributs des couches :

import torch.nn as nn

class MyCustomModel(nn.Module):
    def __init__(self):
        super(MyCustomModel, self).__init__()
        self.conv_block = nn.Conv2d(1, 32, kernel_size=3)
        self.linear_block_1 = nn.Linear(32 * 26 * 26, 128)
        self.linear_block_2 = nn.Linear(128, 10)

    def forward(self, x):
        x = self.conv_block(x)
        x = x.view(x.size(0), -1)
        x = self.linear_block_1(x)
        x = self.linear_block_2(x)
        return x

model_custom = MyCustomModel()

print("\n--- Paramètres par named_parameters() ---")
for name, param in model_custom.named_parameters():
    print(f"Couche: {name} | Taille: {param.size()} | Valeurs (2 premières): {param.data.flatten()[:2]}")

print("\n--- Accès direct aux paramètres ---")
print(f"Poids de conv_block: {model_custom.conv_block.weight.data.flatten()[:2]}")
print(f"Biais de linear_block_1: {model_custom.linear_block_1.bias.data.flatten()[:2]}")

print("\n--- Paramètres par named_children() ---")
def afficher_parametres_recursif(module_instance):
    for name, submodule in module_instance.named_children():
        print(f"  Sous-module: {name}")
        for param_name, param in submodule.named_parameters():
            print(f"    Paramètre: {param_name} | Taille: {param.size()} | Valeurs (2 premières): {param.data.flatten()[:2]}")

afficher_parametres_recursif(model_custom)

  1. Initialisation des paramètres du modèle

Bien que PyTorch fournisse des stratégies d'initialisation par défaut, il est courant de vouloir les personnaliser. Le module torch.nn.init offre diverses fonctions pour cette tâche.

import torch.nn.init as init

# Exemple d'initialisation des poids avec une distribution normale et des biais à zéro
for name, param in net.named_parameters():
    if 'weight' in name:
        init.normal_(param, mean=0, std=0.01) # Initialisation normale
        print(f"Poids {name} (initialisé): {param.data.flatten()[:5]}")
    elif 'bias' in name:
        init.constant_(param, val=0) # Biais à zéro
        print(f"Biais {name} (initialisé): {param.data.flatten()[:5]}")

  1. Méthodes d'initialisation personnalisées

Si les méthodes d'initialisation standard ne suffisent pas, il est possible de créer des fonctions d'initialisation personnalisées. Le principe est de modifier directement l'attribut .data du tenseur de paramètre. Il est crucial d'envelopper ces opérations dans un bloc with torch.no_grad(): pour éviter d'enregistrer ces modifications dans le graphe de calcul de gradients.

def custom_initialization(tensor):
    with torch.no_grad():
        # Exemple: 50% de probabilité d'être 0, 50% de probabilité d'être dans [-10,-5] ou [5,10]
        mask = torch.rand_like(tensor) < 0.5
        tensor[mask] = 0
        tensor[~mask] = torch.rand_like(tensor[~mask]) * 5 + torch.sign(torch.rand_like(tensor[~mask]) - 0.5) * 5
    return tensor

# Application de l'initialisation personnalisée
param_example = nn.Parameter(torch.randn(5))
print(f"Paramètre avant init personnalisée: {param_example.data}")
custom_initialization(param_example)
print(f"Paramètre après init personnalisée: {param_example.data}")

L'attribut .data d'un tenseur de paramètre stocke sa valeur actuelle, tandis que .grad contient le gradient calculé par rétropropagation. Modifier .data change directement la valeur sans affecter le gradient existant.

  1. Partage de paramètres de modèle

Il est parfois souhaitable que plusieurs couches partagent les mêmes paramètres. Cela peut être réalisé de deux manières :

  1. En appelant la même instance de couche plusieurs fois dans la méthode forward d'un nn.Module.
  2. En passant la même instance de nn.Module à un conteneur comme nn.Sequential.

Dans ce cas, les deux couches référencent le même objet en mémoire, et leurs paramètres sont donc les mêmes. Lors de la rétropropagation, les gradients calculés pour ces couches partagées s'accumulent. Le gradient final pour le paramètre partagé sera la somme des gradients provenant de tous les appels où ce paramètre a été utilisé.

import torch
from torch import nn

# Exemple de couches partageant des paramètres
shared_linear = nn.Linear(5, 5)

class SharedParamModel(nn.Module):
    def __init__(self, shared_layer):
        super().__init__()
        self.layer1 = shared_layer
        self.layer2 = shared_layer # Partage de la même instance

    def forward(self, x):
        x = self.layer1(x)
        x = self.layer2(x)
        return x

model_shared = SharedParamModel(shared_linear)
# Ou avec Sequential
net_seq_shared = nn.Sequential(shared_linear, shared_linear)

# Vérifier que les paramètres sont identiques
print(f"Est-ce que layer1 et layer2 sont le même objet? {id(model_shared.layer1) == id(model_shared.layer2)}")
print(f"Est-ce que les poids de layer1 et layer2 sont le même tenseur? {id(model_shared.layer1.weight) == id(model_shared.layer2.weight)}")

Couches Personnalisées

PyTorch offre une grande variété de couches, mais il est parfois nécessaire de créer des couches personnalisées. On y parvient en héritant de nn.Module.

  1. Couche personnalisée sans paramètres

Une couche sans paramètres n'a pas de poids ou de biais à apprendre. Elle effectue une opération fixe sur ses entrées.

import torch
from torch import nn

class CoucheCentree(nn.Module):
    def __init__(self, **kwargs):
        super(CoucheCentree, self).__init__(**kwargs)
    def forward(self, x):
        return x - x.mean()

layer_centree = CoucheCentree()
test_tensor = torch.tensor([1.0, 2.0, 3.0, 4.0, 5.0], dtype=torch.float)
output_centree = layer_centree(test_tensor)
print(f"Entrée: {test_tensor} | Sortie centrée: {output_centree}")

  1. Couche personnalisée avec paramètres

Les couches personnalisées peuvent contenir des paramètres apprenables, qui doivent être définis comme des instances de nn.Parameter. nn.Parameter est une sous-classe de torch.Tensor qui s'enregistre automatiquement auprès du modèle parent.

Pour gérer des collections de paramètres, nn.ParameterList et nn.ParameterDict sont disponibles :

  • nn.ParameterList : Stocke une liste de nn.Parameter, accessible par index et modifiable (append, extend).
  • nn.ParameterDict : Stocke un dictionnaire de nn.Parameter, accessible par clé.
import torch
from torch import nn

class MonDensePersonnalise(nn.Module):
    def __init__(self):
        super(MonDensePersonnalise, self).__init__()
        # Création d'une liste de paramètres pour plusieurs couches "dense"
        self.params = nn.ParameterList([
            nn.Parameter(torch.randn(4, 4)) for _ in range(3)
        ])
        self.params.append(nn.Parameter(torch.randn(4, 1))) # Ajout d'un paramètre final

    def forward(self, x):
        for param in self.params:
            x = torch.mm(x, param) # Multiplication matricielle avec chaque paramètre
        return x

net_custom_dense = MonDensePersonnalise()
print("Modèle dense personnalisé avec ParameterList:\n", net_custom_dense)
input_custom = torch.randn(2, 4) # Exemple d'entrée (batch_size, input_dim)
output_custom_dense = net_custom_dense(input_custom)
print("Sortie du modèle dense personnalisé:", output_custom_dense.shape)

Lecture et Stockage de Modèles

La persistance des modèles entraînés est essentielle pour le déploiement et la réutilisation. La sérialisation convertit un objet (come un modèle ou un tenseur) en un format stockable ou transmissible, tandis que la désérialisation le restaure.

  1. Lecture et écriture de Tenseurs

PyTorch utilise la sérialisation Python (pickle) pour sauvegarder et charger des objets. Les fonctions torch.save() et torch.load() sont utilisées pour cela. Elles peuvent sauvegarder divers types d'objets, y compris des tenseurs, des modèles complets ou des dictionnaires.

import torch

# Sauvegarde d'un tenseur
x = torch.randn(2, 3)
torch.save(x, 'mon_tenseur.pt')

# Chargement d'un tenseur
loaded_x = torch.load('mon_tenseur.pt')
print(f"Tenseur chargé:\n{loaded_x}")

  1. Lecture et écriture de Modèles

Pour les modèles nn.Module, il est courant de sauvegarder uniquement l'état interne du modèle, appelé state_dict. Le state_dict est un dictionnaire qui mappe les noms des couches aux tenseurs de leurs paramètres apprenables (poids et biais). Les optimiseurs (comme torch.optim.SGD) ont également un state_dict qui contient leur état interne et leurs hyperparamètres.

import torch
from torch import nn

class MonMLPSimple(nn.Module):
    def __init__(self):
        super(MonMLPSimple, self).__init__()
        self.hidden = nn.Linear(3, 2)
        self.activation = nn.ReLU()
        self.output = nn.Linear(2, 1)

    def forward(self, x):
        return self.output(self.activation(self.hidden(x)))

net_mlp_simple = MonMLPSimple()
print("State_dict du modèle:\n", net_mlp_simple.state_dict())

# Sauvegarde de l'état du modèle
torch.save(net_mlp_simple.state_dict(), 'mon_modele_state.pt')

# Chargement de l'état du modèle
new_net_mlp = MonMLPSimple() # Il faut d'abord créer une instance du modèle
new_net_mlp.load_state_dict(torch.load('mon_modele_state.pt'))
new_net_mlp.eval() # Mettre le modèle en mode évaluation
print("\nModèle chargé et son state_dict:\n", new_net_mlp.state_dict())

Calcul sur GPU

L'utilisation d'un GPU NVIDIA peut accélérer considérablement les calculs d'apprentissage profond.

  1. Vérification de la disponibilité du GPU

PyTorch permet de spécifier le périphérique de calcul. Voici comment vérifier la disponibilité et les propriétés des GPU :

import torch
import torch.nn as nn

print(f"GPU disponible: {torch.cuda.is_available()}")
if torch.cuda.is_available():
    print(f"Nombre de GPUs: {torch.cuda.device_count()}")
    print(f"GPU courant (index): {torch.cuda.current_device()}")
    print(f"Nom du GPU 0: {torch.cuda.get_device_name(0)}")

  1. Calcul des Tenseurs sur GPU

Par défaut, les tenseurs sont créés sur le CPU. Pour les déplacer vers un GPU, on utilise .cuda() ou .to(device). Les opérations entre tenseurs doivent être effectuées sur le même périphérique.

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f"Périphérique sélectionné: {device}")

# Création d'un tenseur directement sur le GPU
tensor_gpu_direct = torch.tensor([1, 2, 3], device=device)
print(f"Tenseur sur GPU (création directe): {tensor_gpu_direct} | Périphérique: {tensor_gpu_direct.device}")

# Copie d'un tenseur CPU vers GPU
tensor_cpu = torch.tensor([4, 5, 6])
tensor_gpu_copied = tensor_cpu.to(device)
print(f"Tenseur sur GPU (copie): {tensor_gpu_copied} | Périphérique: {tensor_gpu_copied.device}")

# Opération sur GPU
result_gpu = tensor_gpu_direct + tensor_gpu_copied
print(f"Résultat d'opération sur GPU: {result_gpu} | Périphérique: {result_gpu.device}")

# Erreur si périphériques différents:
# tensor_cpu + tensor_gpu_direct # Cela lèverait une erreur

  1. Calcul des Modèles sur GPU

De même, les modèles nn.Module peuvent être déplacés vers le GPU en appelant .cuda() ou .to(device). Il est impératif que les entrées du modèle (tenseurs) et le modèle lui-même résident sur le même périphérique.

# Reprise du modèle MonMLPSimple
net_on_device = MonMLPSimple().to(device)
print(f"Périphérique des paramètres du modèle: {list(net_on_device.parameters())[0].device}")

# Assurez-vous que l'entrée est aussi sur le même périphérique
dummy_input = torch.randn(1, 3).to(device)
output_on_device = net_on_device(dummy_input)
print(f"Sortie du modèle sur GPU: {output_on_device} | Périphérique: {output_on_device.device}")

Réseaux de Neurones Convolutifs (CNN)

  1. Introduction

Les réseaux de neurones convolutifs (CNN) sont des architectures spécifiquement conçues pour traiter des données ayant une topologie de grille, telles que des images. Contrairement aux perceptrons multicouches (MLP) qui nécessitent d'aplatir les images en vecteurs, les CNN traitent les images dans leur format bidimensionnel original, ce qui leur permet de préserver la structure spatiale et de mieux capturer les dépendances locales entre les pixels.

Un CNN extrait des caractéristiques significatives (par exemple, des bords, des textures) des données d'entrée via des filtres convolutifs, puis utilise ces caractéristiques pour des tâches comme la classification.

  1. Principe d'une Couche Convolutive 2D

Le cœur d'une couche convolutive est l'opération de corrélation croisée. Celle-ci consiste à faire glisser une petite fenêtre (le noyau de convolution ou filtre) sur l'entrée, en effectuant une multiplication élément par élément entre le noyau et la région couverte, puis en sommant les résultats pour obtenir une seule valeur de sortie. Ce processus est répété sur toute l'entrée, produisant une carte de caractéristiques (feature map).

  • Tableau d'entrée : Les données sur lesquelles la convolution est appliquée (par exemple, une image).
  • Noyau de convolution (filtre) : Une petite matrice de poids qui "scanne" l'entrée pour détecter des motifs spécifiques.
  • Fenêtre de convolution : La région de l'entrée couverte par le noyau à un instant donné.
  1. La Couche Convolutive

Une couche convolutive est une unité de réseau qui applique l'opération de corrélation croisée entre son entrée et un noyau de convolution, puis ajoute un biais scalaire pour produire la sortie. Les paramètres d'un tel calque sont le noyau de convolution et le biais. Ces paramètres sont initialisés aléatoirement et optimisés par rétropropagation pendant l'entraînement.

  1. Détection des Bords d'Objets dans les Images

Les noyaux de convolution sont particulièrement efficaces pour détecter des caractéristiques visuelles. Par exemple, un noyau spécifique peut être conçu pour identifier les bords, là où les valeurs de pixels changent brusquement.

Un pixel est l'unité minimale d'une image numérique, représentant la couleur et la luminosité d'un point.

Un noyau K de forme 1x2, si appliqué à une image, produira une sortie non nulle aux emplacements où les pixels adjacents horizontalement sont différents, signalant un bord vertical.

  1. Apprentissage des Noyaux à partir des Données

Plutôt que de concevoir des noyaux manuellement, les CNN apprennent leurs noyaux via l'entraînement. On initialise un noyau aléatoirement, puis on utilise des paires entrée-sortie (image, carte de bords souhaitée) pour ajuster les poids du noyau à l'aide d'une fonction de perte (par exemple, erreur quadratique moyenne) et de la descente de gradient.

  1. Corrélation Croisée vs. Opération de Convolution

Bien que l'apprentissage profond utilise majoritairement l'opération de corrélation croisée, l'opération de convolution "pure" en traitement d'image requiert de retourner (faire pivoter de 180 degrés) le noyau avant d'effectuer la corrélation croisée. En pratique, puisque les noyaux sont appris, peu importe laquelle des deux opérations est utilisée, car le réseau apprendra le noyau approprié pour l'opération choisie.

  1. Carte de Caractéristiques (Feature Map) et Champ Récepteur (Receptive Field)

  • Champ Récepteur : C'est la zone de l'entrée qu'un neurone d'une certaine couche peut "voir". Pour un neurone de la première couche, c'est la taille du noyau. Pour les couches plus profondes, le champ récepteur s'agrandit, car chaque neurone intègre des informations provenant de régions plus larges de l'entrée.
  • Carte de Caractéristiques : C'est la sortie d'une couche convolutive. Chaque carte de caractéristiques représente une caractéristique spécifique extraite de l'entrée (par exemple, les bords verticaux), et elle est souvent visualisée comme une image pour comprendre ce que le modèle apprend.
  1. Remplissage (Padding) et Pas (Stride)

  • Remplissage (Padding) : Consiste à ajouter des éléments (généralement des zéros) autour des bords de l'entrée. Cela permet de contrôler la taille de la sortie de la couche convolutive et de s'assurer que les éléments aux bords de l'entrée sont traités autant que les éléments centraux.
  • Pas (Stride) : Définit le nombre de pixels par lequel la fenêtre de convolution se déplace à chaque étape. Un pas supérieur à 1 réduit la dimension spatiale de la carte de caractéristiques de sortie.
  1. Multiples Canaux d'Entrée et de Sortie

Les images couleur (RGB) ont trois canaux d'entrée. Pour gérer cela, le noyau de convolution doit avoir un nombre de canaux d'entrée correspondant à l'entrée. Chaque canal d'entrée est convolué séparément avec son propre noyau 2D, et les résultats sont ensuite sommés sur tous les canaux pour produire une seule sortie 2D.

Les canaux de sortie d'une couche convolutive correspondent au nombre de caractéristiques distinctes que la couche est conçue pour extraire. Un noyau de convolution unique est un tenseur 3D (hauteur, largeur, canaux d'entrée). Pour produire plusieurs canaux de sortie, une couche convolutive utilisera un ensemble de noyaux 3D, où chaque noyau est responsable de la détection d'une caractéristique différente, et chaque noyau produit un canal de sortie distinct.

La couche convolutive 1x1 utilise des noyaux de taille 1x1. Elle ne capture pas d'informations spatiales locales, mais elle peut modifier le nombre de canaux, agissant comme une couche de projection linéaire sur la dimension des canaux.

  1. Couches de Pooling

1. Contexte

Les couches de pooling (ou de sous-échantillonnage) ont été introduites pour rendre les réseaux de neurones convolutifs moins sensibles aux légers déplacements ou déformations des objets dans les images. En effet, un petit décalage dans la position d'un objet pourrait modifier la position de la caractéristique détectée par une couche convolutive, compliquant la tâche des couches ultérieures.

2. Max Pooling 2D et Average Pooling 2D

Une couche de pooling opère sur une fenêtre de taille fixe (fenêtre de pooling) en calculant une statistique agrégée des éléments à l'intérieur de cette fenêtre. Les deux types les plus courants sont :

  • Max Pooling : Renvoie la valeur maximale des éléments dans la fenêtre. Utile pour la détection de caractéristiques proéminentes.
  • Average Pooling : Renvoie la moyenne des éléments dans la fenêtre. Peut être utile pour lisser les caractéristiques.

La fenêtre de pooling glisse sur l'entrée avec un pas défini, similaire à la convolution.

3. Remplissage et Pas

Comme les couches convolutives, les couches de pooling peuvent utiliser le remplissage et le pas pour contrôler la dimensionnalité de la sortie.

4. Multi-canaux

Contrairement aux couches convolutives qui combinent les informations des canaux d'entrée, les couches de pooling traitent chaque canal d'entrée indépendamment. Le nombre de canaux de sortie d'une couche de pooling est donc égal au nombre de canaux d'entrée. Cela permet de conserver les caractéristiques spécifiques à chaque canal tout en réduisant la dimension spatiale.

  1. Architectures de Réseaux de Neurones Convolutifs

1. Introduction

Les réseaux de neurones convolutifs (CNN) ont émergé pour surmonter les limitations des perceptrons multicouches (MLP) dans le traitement d'images. Les MLP requièrent l'aplatissement des images, ce qui détruit la précieuse information de corrélation spatiale entre les pixels voisins et entraîne un nombre excessif de paramètres pour les grandes images. Les CNN résolvent ces problèmes en :

  • Préservant la forme de l'entrée, ce qui permet de capturer les corrélations spatiales et temporelles.
  • Utilisant le partage de poids via des noyaux glissants, ce qui réduit considérablement le nombre de paramètres.

La fonction d'une couche convolutive est d'extraire des caractéristiques locales (bords, textures). Les couches entièrement connectées subséquentes intègrent ces caractéristiques pour la prise de décision finale, comme la classification.

2. Qu'est-ce qu'un Réseau de Neurones Convolutif ?

Un réseau de neurones convolutif est simplement un réseau neuronal qui inclut au moins une couche convolutive.

Exemple: LeNet

L'architecture LeNet, pionnière des CNN, se compose de deux parties principales : un bloc convolutif et un bloc de couches entièrement connectées.

  1. Bloc Convolutif : Composé de couches convolutives suivies de couches de max pooling. Les couches convolutives extraient des motifs spatiaux (lignes, parties d'objets), tandis que les couches de max pooling réduisent la sensibilité à la position et la dimensionnalité. Dans LeNet, les couches de max pooling utilisent des fenêtres et des pas de 2x2.
  2. Bloc de Couches Entièrement Connectées : Les sorties du bloc convolutif sont aplaties en un vecteur 1D pour chaque échantillon du mini-lot (concaténant les dimensions de canaux, hauteur et largeur). Ce vecteur est ensuite alimenté dans un MLP standard. Le bloc entièrement connecté de LeNet comprend trois couches, avec des nombres de sorties de 120, 84 et 10 (pour la classification).

L'augmentation du nombre de canaux de sortie dans les couches convolutives profondes est une stratégie courante. Lorsque la dimension spatiale (hauteur et largeur) des cartes de caractéristiques est réduite par les opérations de pooling, il est nécessaire d'augmenter la dimensionnalité des canaux pour permettre au réseau d'apprendre un plus grand nombre de caractéristiques plus complexes et ainsi maintenir sa capacité expressive.

Réseaux Convolutifs Profonds (AlexNet)

  1. Apprentissage des Représentations de Caractéristiques

Avant l'avènement des méthodes modernes d'apprentissage profond, la vision par ordinateur reposait largement sur l'extraction manuelle de caractéristiques. Cependant, la recherche a évolué vers l'idée que les caractéristiques elles-mêmes devraient être apprises et organisées hiérarchiquement, des concepts simples aux plus abstraits. Par exemple, une première couche pourrait apprendre à détecter des bords, une couche suivante pourrait combiner ces bords pour former des motifs ou des textures, et des couches encore plus profondes pourraient assembler ces motifs en parties d'objets, menant finalement à une classification aisée.

Ce concept a longtemps été théorique en raison de deux obstacles majeurs :

  1. Données : Les modèles profonds nécessitent d'énormes volumes de données étiquetées pour surclasser les méthodes classiques. L'émergence de grands ensembles de données comme ImageNet (plus d'un millier de catégories, des milliers d'images par catégorie) a été un tournant majeur vers 2010.

  2. Matériel : L'apprentissage profond est gourmand en calcul. L'avènement des GPU à usage général (GPGPU) vers 2001, avec des plateformes comme CUDA, a fourni la puissance de calcul nécessaire pour entraîner des réseaux neuronaux complexes, grâce à leur capacité à gérer efficacement des multiplications matricielles et vectorielles massives, similaires aux opérations convolutives.

  3. AlexNet


En 2012, AlexNet, développé par Alex Krizhevsky et al., a remporté le défi ImageNet avec une marge significative, marquant une percée majeure. Ce modèle, composé de 8 couches (5 convolutives, 2 couches cachées entièrement connectées, 1 couche de sortie entièrement connectée), a été le premier à prouver que les caractéristiques apprises par le réseau pouvaient surpasser les caractéristiques conçues manuellement.

Bien que conceptuellement similaire à LeNet, AlexNet est beaucoup plus grand. Par exemple, sa première couche convolutive utilise des noyaux de 11x11, adaptés à la plus grande résolution des images ImageNet par rapport à MNIST.

LeNet : Sous-échantillonnage Spatial

Le sous-échantillonnage spatial (Spatial Downsampling) est une technique courante en traitement d'images et en CNN pour réduire la résolution d'une image ou d'une carte de caractéristiques, diminuant ainsi la dimensionnalité des données et les coûts de calcul. Les méthodes principales incluent :

  • Max Pooling : Sélectionne la valeur maximale dans une petite région.
  • Average Pooling : Calcule la moyenne des valeurs dans une petite région.
  • Convolution à pas (Strided Convolution) : Utilise un pas supérieur à 1 pour réduire la taille de la sortie.

Le sous-échantillonnage réduit les besoins en calcul, extrait les caractéristiques importantes et améliore la généralisation du modèle en le rendant plus robuste aux variations de position.

VGG (Visual Geometry Group)

  1. Présentation de VGG

Les réseaux VGG, développés par le Visual Geometry Group, sont remarquables par leur architecture modulaire et répétitive. Leur conception est facilitée par l'utilisation de blocs de construction récurrents, ce qui les rend faciles à implémenter dans les frameworks d'apprentissage profond modernes.

  1. Blocs VGG

Un bloc VGG est une séquence de couches convolutives de petite taille (généralement 3x3), suivie d'une couche de fonction d'activation (ReLU) et enfin d'une couche de max pooling pour le sous-échantillonnage spatial.

import torch
from torch import nn

def vgg_block(num_convs, in_channels, out_channels):
    layers = []
    for _ in range(num_convs):
        layers.append(nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1))
        layers.append(nn.ReLU())
        in_channels = out_channels # Pour les couches suivantes dans le même bloc
    layers.append(nn.MaxPool2d(kernel_size=2, stride=2))
    return nn.Sequential(*layers)

# Exemple d'utilisation d'un bloc VGG
# Un bloc avec 2 couches conv, 3 canaux d'entrée, 64 canaux de sortie
test_vgg_block = vgg_block(2, 3, 64)
print("Exemple de bloc VGG:\n", test_vgg_block)

  1. Architecture VGG

Le réseau VGG complet est construit en empilant plusieurs de ces blocs VGG, formant une première partie dédiée à l'extraction de caractéristiques (couches convolutives et de pooling). Cette partie est ensuite suivie d'une seconde partie composée de couches entièrement connectées pour la classification finale, de manière similaire à LeNet et AlexNet.

Réseaux de Neurones Récurrents (RNN)

  1. Séquences

Les données séquentielles, telles que la musique, la parole, le texte ou la vidéo, possèdent une structure temporelle intrinsèque où l'ordre des éléments est crucial. Une réorganisation de ces séquences altérerait ou détruirait leur sens original.

  1. Outils Statistiques pour les Séquences

1. Modèles Autorégressifs

Un modèle autorégressif (AR) prédit les valeurs futures d'une série temporelle en se basant sur ses propres valeurs passées. L'idée est que la valeur actuelle ou future d'une séquence est une fonction linéaire (ou non linéaire) de ses observations précédentes.

Le terme "autorégressif" signifie que la régression est effectuée sur la série elle-même (auto), en utilisant ses valeurs passées comme variables explicatives (régression). Un défi est la quantité croissante de données historiques à prendre en compte avec le temps. Pour gérer cela, on utilise souvent des approximations :

  • Fenêtre fixe : Limiter l'historique aux \(k\) observations les plus récentes, ce qui maintient un nombre constant de paramètres.
  • Résumé de l'historique : Calculer un résumé des observations passées pour prédire la prochaine, et mettre à jour ce résumé à chaque nouveau point temporel.

Ces approches reposent sur l'hypothèse de stationnarité, c'est-à-dire que la dynamique sous-jacente de la séquence (ses règles de comportement) reste stable dans le temps, même si les valeurs spécifiques changent. De nouvelles dynamiquse seraient influencées par de nouvelles données, non prédictibles par les données actuelles.

La probabilité conjointe d'une séquence \(x_1, \ldots, x_T\) peut être décomposée en un produit de probabilités conditionnelles :

P(x_1, \ldots, x_T) = P(x_1) * P(x_2 | x_1) * P(x_3 | x_1, x_2) * \ldots * P(x_T | x_1, \ldots, x_{T-1})

2. Modèles de Markov

Une simplification des modèles autorégressifs est le modèle de Markov, qui postule que la prochaine observation dépend uniquement d'un nombre limité d'observations précédentes. Dans un modèle de Markov d'ordre 1, la prochaine observation \(x_t\) dépend uniquement de l'observation précédente \(x_{t-1}\) :

P(x_t | x_1, \ldots, x_{t-1}) = P(x_t | x_{t-1})

Cette propriété est appelée la propriété de Markov.

  1. Modèles de Langage

1. Qu'est-ce qu'un Modèle de Langage ?

Un modèle de langage estime la distribution de probabilité d'une séquence de texte. Il quantifie la vraisemblance qu'une phrase donnée apparaisse dans une langue spécifique. Pour construire un modèle de langage, il faut calculer les probabilités des mots et les probabilités conditionnelles d'un mot étant donné les mots précédents.

L'expression d'un modèle de langage est la même que la probabilité conjointe d'une séquence de mots.

La probabilité d'un mot unique peut être estimée par sa fréquence relative dans un corpus d'entraînement. De même, la probabilité conditionnelle d'un mot peut être estimée à partir des fréquences de co-occurrence.

  • Probabilité Conditionnelle : La probabilité qu'un événement survienne étant donné qu'un autre événement est déjà survenu.
  • Probabilité Conjointe : La probabilité que deux ou plusieurs événements surviennent simultanément.

2. N-grammes et Chaînes de Markov

L'hypothèse de Markov d'ordre \(n-1\) est appliquée aux modèles de langage, stipulant que l'apparition d'un mot ne dépend que des \(n-1\) mots précédents. On parle alors de modèles de \(n\)-grammes :

  • Unigramme (\(n=1\)) : \(P(w_t)\) (chaque mot est indépendant).
  • Bigramme (\(n=2\)) : \(P(w_t | w_{t-1})\).
  • Trigramme (\(n=3\)) : \(P(w_t | w_{t-2}, w_{t-1})\).

Pour une séquence \(w_1, w_2, w_3, w_4\) :

  • Unigramme : \(P(w_1)P(w_2)P(w_3)P(w_4)\)
  • Bigramme : \(P(w_1)P(w_2|w_1)P(w_3|w_2)P(w_4|w_3)\)
  • Trigramme : \(P(w_1)P(w_2|w_1)P(w_3|w_1,w_2)P(w_4|w_2,w_3)\)

Les modèles de \(n\)-grammes souffrent d'un compromis : les petits \(n\) sont imprécis, tandis que les grands \(n\) nécessitent un calcul et un stockage massifs des fréquences. Les réseaux de neurones récurrents offrent une solution pour équilibrer ces deux aspects.

  1. Réseaux de Neurones Récurrents (RNN)

Les RNN sont conçus pour traiter des séquences en capturant des dépendances à long terme. Au lieu de mémoriser rigidement des séquences de longueur fixe, ils utilisent un état caché pour stocker des informations des pas de temps précédents. La particularité des RNN est qu'ils appliquent les mêmes poids et biais à chaque pas de temps, ce qui permet de partager des paramètres et d'apprendre des motifs qui se répètent à travers la séquence.

1. Séquences d'étiquettes et échantillonnage

Lors de l'entraînement des RNN, les données séquentielles et leurs étiquettes sont nécessaires. Les étiquettes peuvent être le caractère suivant dans la séquence pour une tâche de modélisation de langage. Il existe deux méthodes d'échantillonnage des données séquentielles :

  • Échantillonnage aléatoire : Chaque échantillon de mini-lot est une sous-séquence tirée aléatoirement de la séquence originale. Les séquences au sein d'un mini-lot sont ordonnées, mais deux mini-lots consécutifs ne sont pas nécessairement adjacents dans la séquence originale. Cela signifie que l'état caché final d'un mini-lot ne peut pas être utilisé pour initialiser l'état caché du mini-lot suivant, et l'état caché doit être réinitialisé pour chaque nouvel échantillon.
  • Échantillonnage contigu : Les mini-lots sont construits de manière à ce que les séquences d'un lot soient contiguës les unes aux autres, ce qui permet de propager l'état caché de la fin d'un lot vers le début du lot suivant, maintenant ainsi une forme de mémoire à travers de plus longues portions de la séquence.

Étiquettes: deep learning PyTorch Réseaux Neuronaux Propagation Avant Rétropropagation

Publié le 3 juillet à 05h34