Construire des réseaux de neurones avec nn.Module en PyTorch

tl;dr: nn.Module = classe base réseaux neurones PyTorch. Créer modèle : hériter nn.Module, __init__ (layers), forward() (passage avant). Layers : Linear, Conv2d, BatchNorm, Dropout, LSTM. Activations : ReLU, GELU, Softmax. nn.Sequential = empilement simple. ModuleList/Dict = structures complexes. Params : .parameters(), .named_parameters().

Maintenant que vous maîtrisez les tensors et leurs opérations, il est temps de construire votre premier réseau de neurones ! PyTorch fournit le module nn (neural network) avec nn.Module, la classe de base pour tous les modèles. Dans cet article, nous allons apprendre à créer des architectures de réseaux de neurones, des plus simples aux plus complexes.

La classe nn.Module : Fondation de tout modèle

nn.Module est la pierre angulaire de PyTorch. Tous les modèles héritent de cette classe.

Structure de base d’un modèle

import torch
import torch.nn as nn

class SimpleNet(nn.Module):
    def __init__(self, input_size, hidden_size, output_size):
        # 1. Appeler le constructeur parent
        super().__init__()  # ou super(SimpleNet, self).__init__()

        # 2. Définir les layers
        self.fc1 = nn.Linear(input_size, hidden_size)
        self.relu = nn.ReLU()
        self.fc2 = nn.Linear(hidden_size, output_size)

    def forward(self, x):
        # 3. Définir le passage avant (forward pass)
        x = self.fc1(x)
        x = self.relu(x)
        x = self.fc2(x)
        return x

# Créer une instance du modèle
model = SimpleNet(input_size=784, hidden_size=128, output_size=10)
print(model)

Résultat :

SimpleNet(
  (fc1): Linear(in_features=784, out_features=128, bias=True)
  (relu): ReLU()
  (fc2): Linear(in_features=128, out_features=10, bias=True)
)
💡 Deux méthodes essentielles : __init__() pour définir les layers (exécuté une fois), et forward() pour définir le flux de données (exécuté à chaque prédiction). PyTorch calcule automatiquement backward() grâce à autograd !

Utiliser le modèle

# Créer des données d'exemple
batch_size = 32
input_data = torch.randn(batch_size, 784)  # 32 images de 28x28 pixels aplaties

# Forward pass
output = model(input_data)  # Appelle automatiquement forward()
print(f"Output shape : {output.shape}")  # (32, 10)

# Le modèle retourne des logits (scores bruts)
# Convertir en probabilités avec softmax
probabilities = torch.softmax(output, dim=1)
print(f"Probabilités shape : {probabilities.shape}")  # (32, 10)
print(f"Somme des probs (première image) : {probabilities[0].sum()}")  # 1.0

Schéma d’un réseau de neurones simple avec layers

Layers essentiels de PyTorch

PyTorch fournit une riche bibliothèque de layers pré-construits dans torch.nn.

Linear (Fully Connected / Dense)

Le layer le plus basique : transformation linéaire y = xW^T + b

import torch.nn as nn

# Linear layer: 784 → 256
linear = nn.Linear(in_features=784, out_features=256)

# Avec ou sans biais
linear_no_bias = nn.Linear(784, 256, bias=False)

# Utilisation
x = torch.randn(32, 784)  # Batch de 32 exemples
output = linear(x)
print(f"Output shape : {output.shape}")  # (32, 256)

# Accéder aux poids
print(f"Weight shape : {linear.weight.shape}")  # (256, 784)
print(f"Bias shape : {linear.bias.shape}")      # (256,)

Convolution 2D (pour images)

Pour traiter des images avec des réseaux convolutifs (CNN) :

# Conv2D : extraction de features d'images
conv = nn.Conv2d(
    in_channels=3,      # RGB (3 canaux)
    out_channels=64,    # 64 filtres
    kernel_size=3,      # Kernel 3x3
    stride=1,           # Pas de 1
    padding=1           # Padding pour garder la taille
)

# Input : batch d'images RGB
x = torch.randn(32, 3, 224, 224)  # (batch, channels, height, width)
output = conv(x)
print(f"Conv output shape : {output.shape}")  # (32, 64, 224, 224)

# Paramètres
print(f"Conv weight shape : {conv.weight.shape}")  # (64, 3, 3, 3)
# 64 filtres, 3 canaux input, kernel 3x3

Pooling (réduction de dimensions)

# Max Pooling : prend le maximum dans chaque région
maxpool = nn.MaxPool2d(kernel_size=2, stride=2)

x = torch.randn(32, 64, 224, 224)
pooled = maxpool(x)
print(f"After maxpool : {pooled.shape}")  # (32, 64, 112, 112)

# Average Pooling
avgpool = nn.AvgPool2d(kernel_size=2, stride=2)
avg_pooled = avgpool(x)
print(f"After avgpool : {avg_pooled.shape}")  # (32, 64, 112, 112)

# Adaptive Average Pooling (pour n'importe quelle taille → taille fixe)
adaptive_pool = nn.AdaptiveAvgPool2d((1, 1))  # → 1x1
output = adaptive_pool(x)
print(f"After adaptive pool : {output.shape}")  # (32, 64, 1, 1)

Batch normalization

Normalise les activations pour stabiliser et accélérer l’entraînement :

# BatchNorm pour fully connected layers
bn1d = nn.BatchNorm1d(num_features=256)

x = torch.randn(32, 256)  # Batch de 32 exemples, 256 features
normalized = bn1d(x)
print(f"Normalized shape : {normalized.shape}")  # (32, 256)

# BatchNorm pour convolutions (par canal)
bn2d = nn.BatchNorm2d(num_features=64)

x = torch.randn(32, 64, 224, 224)
normalized = bn2d(x)
print(f"BN2d output : {normalized.shape}")  # (32, 64, 224, 224)

# LayerNorm (utilisé dans Transformers)
ln = nn.LayerNorm(normalized_shape=256)
x = torch.randn(32, 10, 256)  # (batch, sequence, features)
output = ln(x)
print(f"LayerNorm output : {output.shape}")  # (32, 10, 256)
🔎 Tip
BatchNorm vs LayerNorm : BatchNorm normalise sur le batch (axe 0), utilisé en computer vision. LayerNorm normalise sur les features (dernier axe), utilisé en NLP/Transformers. GroupNorm est un compromis entre les deux.

Dropout (régularisation)

Désactive aléatoirement des neurones pendant l’entraînement pour éviter l’overfitting :

# Dropout : désactive 50% des neurones pendant training
dropout = nn.Dropout(p=0.5)

x = torch.randn(32, 256)

# En mode training
dropout.train()
output_train = dropout(x)
print(f"Training - Quelques zéros : {(output_train == 0).sum().item()} sur {x.numel()}")

# En mode eval (désactivé)
dropout.eval()
output_eval = dropout(x)
print(f"Eval - Pas de zéros : {(output_eval == 0).sum().item()}")

Recurrent layers (pour séquences)

# LSTM : pour séquences temporelles
lstm = nn.LSTM(
    input_size=100,   # Dimension des embeddings
    hidden_size=256,  # Dimension de l'état caché
    num_layers=2,     # 2 couches LSTM empilées
    batch_first=True, # Format (batch, seq, features)
    dropout=0.3       # Dropout entre couches
)

# Input : (batch, sequence_length, input_size)
x = torch.randn(32, 10, 100)  # 32 séquences de 10 tokens, embeddings 100D
output, (h_n, c_n) = lstm(x)

print(f"LSTM output shape : {output.shape}")      # (32, 10, 256)
print(f"Hidden state shape : {h_n.shape}")        # (2, 32, 256)
print(f"Cell state shape : {c_n.shape}")          # (2, 32, 256)

# GRU : variante plus simple que LSTM
gru = nn.GRU(input_size=100, hidden_size=256, num_layers=2, batch_first=True)
output, h_n = gru(x)
print(f"GRU output shape : {output.shape}")  # (32, 10, 256)

Fonctions d’activation

Les fonctions d’activation ajoutent de la non-linéarité au réseau. Sans elles, un réseau profond serait équivalent à une simple régression linéaire !

ReLU (Rectified Linear Unit)

La plus utilisée : f(x) = max(0, x)

relu = nn.ReLU()

x = torch.tensor([-2.0, -1.0, 0.0, 1.0, 2.0])
output = relu(x)
print(f"ReLU : {output}")  # [0., 0., 0., 1., 2.]

# Variante in-place (économie mémoire)
relu_inplace = nn.ReLU(inplace=True)

# LeakyReLU : f(x) = max(0.01x, x) - évite les "dead neurons"
leaky_relu = nn.LeakyReLU(negative_slope=0.01)
output = leaky_relu(x)
print(f"LeakyReLU : {output}")  # [-0.02, -0.01, 0., 1., 2.]

GELU (Gaussian Error Linear Unit)

Utilisée dans les Transformers (BERT, GPT) : plus douce que ReLU

gelu = nn.GELU()

x = torch.linspace(-3, 3, 100)
output = gelu(x)

# GELU approximative : f(x) ≈ 0.5x(1 + tanh(√(2/π)(x + 0.044715x³)))
print(f"GELU à 0 : {gelu(torch.tensor(0.0))}")  # ≈ 0
print(f"GELU à 1 : {gelu(torch.tensor(1.0))}")  # ≈ 0.841

Sigmoid et Tanh

sigmoid = nn.Sigmoid()
tanh = nn.Tanh()

x = torch.tensor([-2.0, -1.0, 0.0, 1.0, 2.0])

print(f"Sigmoid : {sigmoid(x)}")  # [0.119, 0.268, 0.5, 0.731, 0.880]
print(f"Tanh : {tanh(x)}")        # [-0.964, -0.762, 0., 0.762, 0.964]

Softmax

Convertit des logits en probabilités (somme = 1) :

softmax = nn.Softmax(dim=-1)  # Dernière dimension

# Logits pour 3 classes
logits = torch.tensor([[2.0, 1.0, 0.1],
                       [0.5, 2.5, 0.2]])

probs = softmax(logits)
print(f"Probabilities :\n{probs}")
print(f"Somme par ligne : {probs.sum(dim=1)}")  # [1., 1.]

# LogSoftmax : pour usage avec NLLLoss
log_softmax = nn.LogSoftmax(dim=-1)
log_probs = log_softmax(logits)
print(f"Log probabilities :\n{log_probs}")
💡 Quelle activation choisir ? ReLU : défaut pour CNN/MLP. GELU : Transformers, NLP. LeakyReLU/PReLU : si dead neurons. Sigmoid : classification binaire (dernière couche). Tanh : RNN (LSTM utilise tanh+sigmoid en interne). Softmax : classification multi-classe (dernière couche).

nn.Sequential : Empiler des layers simplement

nn.Sequential permet de créer des modèles simples en empilant des layers :

import torch.nn as nn

# Modèle simple avec Sequential
model = nn.Sequential(
    nn.Linear(784, 256),
    nn.ReLU(),
    nn.Dropout(0.2),
    nn.Linear(256, 128),
    nn.ReLU(),
    nn.Dropout(0.2),
    nn.Linear(128, 10),
    nn.Softmax(dim=1)
)

print(model)

# Utilisation
x = torch.randn(32, 784)
output = model(x)
print(f"Output shape : {output.shape}")  # (32, 10)

# Accéder à un layer spécifique
first_layer = model[0]  # nn.Linear(784, 256)
print(f"Premier layer : {first_layer}")

Sequential avec noms

from collections import OrderedDict

model = nn.Sequential(OrderedDict([
    ('fc1', nn.Linear(784, 256)),
    ('relu1', nn.ReLU()),
    ('dropout1', nn.Dropout(0.2)),
    ('fc2', nn.Linear(256, 128)),
    ('relu2', nn.ReLU()),
    ('dropout2', nn.Dropout(0.2)),
    ('fc3', nn.Linear(128, 10))
]))

# Accéder par nom
print(model.fc1)  # nn.Linear(784, 256)
🔎 Tip
Quand utiliser Sequential ? Pour des architectures simples et linéaires (un layer après l’autre). Pour des architectures complexes avec skip connections, branches multiples, ou logique conditionnelle, utilisez une classe custom avec nn.Module.

Architectures personnalisées

Pour des modèles plus complexes, créez une classe héritant de nn.Module :

Réseau avec skip connection (ResNet Style)

class ResidualBlock(nn.Module):
    def __init__(self, in_features, out_features):
        super().__init__()
        self.fc1 = nn.Linear(in_features, out_features)
        self.bn1 = nn.BatchNorm1d(out_features)
        self.relu = nn.ReLU()
        self.fc2 = nn.Linear(out_features, out_features)
        self.bn2 = nn.BatchNorm1d(out_features)

        # Shortcut connection si dimensions différentes
        if in_features != out_features:
            self.shortcut = nn.Linear(in_features, out_features)
        else:
            self.shortcut = nn.Identity()  # Pas de transformation

    def forward(self, x):
        # Main path
        residual = x
        out = self.fc1(x)
        out = self.bn1(out)
        out = self.relu(out)
        out = self.fc2(out)
        out = self.bn2(out)

        # Add skip connection
        out += self.shortcut(residual)
        out = self.relu(out)

        return out

# Test
block = ResidualBlock(256, 512)
x = torch.randn(32, 256)
output = block(x)
print(f"ResBlock output : {output.shape}")  # (32, 512)

Réseau multi-branches

class MultiBranchNet(nn.Module):
    def __init__(self):
        super().__init__()
        # Branch 1 : Convolutions
        self.conv_branch = nn.Sequential(
            nn.Conv2d(3, 32, kernel_size=3, padding=1),
            nn.ReLU(),
            nn.MaxPool2d(2),
            nn.Conv2d(32, 64, kernel_size=3, padding=1),
            nn.ReLU(),
            nn.AdaptiveAvgPool2d((1, 1))
        )

        # Branch 2 : Global features
        self.global_branch = nn.Sequential(
            nn.AdaptiveAvgPool2d((1, 1)),
            nn.Flatten(),
            nn.Linear(3, 64),
            nn.ReLU()
        )

        # Fusion
        self.classifier = nn.Sequential(
            nn.Linear(64 + 64, 128),
            nn.ReLU(),
            nn.Dropout(0.5),
            nn.Linear(128, 10)
        )

    def forward(self, x):
        # Branch 1
        conv_features = self.conv_branch(x)
        conv_features = conv_features.view(conv_features.size(0), -1)

        # Branch 2
        global_features = self.global_branch(x)

        # Concatenate
        combined = torch.cat([conv_features, global_features], dim=1)

        # Classify
        output = self.classifier(combined)
        return output

# Test
model = MultiBranchNet()
x = torch.randn(16, 3, 64, 64)
output = model(x)
print(f"Multi-branch output : {output.shape}")  # (16, 10)

ModuleList et ModuleDict

Pour des structures dynamiques ou répétitives :

ModuleList

class DynamicNet(nn.Module):
    def __init__(self, num_layers, hidden_size):
        super().__init__()
        # Liste de layers
        self.layers = nn.ModuleList([
            nn.Linear(hidden_size, hidden_size)
            for _ in range(num_layers)
        ])
        self.activations = nn.ModuleList([
            nn.ReLU() for _ in range(num_layers)
        ])

    def forward(self, x):
        for layer, activation in zip(self.layers, self.activations):
            x = activation(layer(x))
        return x

# Créer un réseau avec 5 couches
model = DynamicNet(num_layers=5, hidden_size=256)
x = torch.randn(32, 256)
output = model(x)
print(f"Dynamic net output : {output.shape}")  # (32, 256)

ModuleDict

class MultiTaskNet(nn.Module):
    def __init__(self):
        super().__init__()
        # Encoder partagé
        self.encoder = nn.Sequential(
            nn.Linear(784, 256),
            nn.ReLU(),
            nn.Linear(256, 128),
            nn.ReLU()
        )

        # Heads pour différentes tâches
        self.heads = nn.ModuleDict({
            'classification': nn.Linear(128, 10),
            'regression': nn.Linear(128, 1),
            'segmentation': nn.Linear(128, 784)
        })

    def forward(self, x, task='classification'):
        # Encoder commun
        features = self.encoder(x)

        # Head spécifique à la tâche
        output = self.heads[task](features)
        return output

# Test
model = MultiTaskNet()
x = torch.randn(32, 784)

# Différentes tâches
class_output = model(x, task='classification')
reg_output = model(x, task='regression')
seg_output = model(x, task='segmentation')

print(f"Classification : {class_output.shape}")  # (32, 10)
print(f"Regression : {reg_output.shape}")        # (32, 1)
print(f"Segmentation : {seg_output.shape}")      # (32, 784)
⚠️ Warning
Attention : N’utilisez jamais des listes Python [] ou dicts {} pour stocker des layers ! PyTorch ne les détectera pas. Utilisez toujours nn.ModuleList et nn.ModuleDict pour que les paramètres soient enregistrés.

Accéder aux paramètres

PyTorch permet d’inspecter et de manipuler les paramètres du modèle :

model = nn.Sequential(
    nn.Linear(784, 256),
    nn.ReLU(),
    nn.Linear(256, 10)
)

# Compter les paramètres
total_params = sum(p.numel() for p in model.parameters())
print(f"Total paramètres : {total_params:,}")  # 203,530

# Paramètres entraînables uniquement
trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"Paramètres entraînables : {trainable_params:,}")

# Itérer sur les paramètres avec noms
for name, param in model.named_parameters():
    print(f"{name:20s} | shape: {str(param.shape):20s} | {param.numel():>7,} params")

# Résultat :
# 0.weight            | shape: torch.Size([256, 784]) | 200,704 params
# 0.bias              | shape: torch.Size([256])      |     256 params
# 2.weight            | shape: torch.Size([10, 256])  |   2,560 params
# 2.bias              | shape: torch.Size([10])       |      10 params

Geler des layers (Transfer Learning)

# Geler tous les paramètres
for param in model.parameters():
    param.requires_grad = False

# Dégeler seulement la dernière couche
for param in model[2].parameters():  # Dernière Linear layer
    param.requires_grad = True

# Vérifier
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
frozen = sum(p.numel() for p in model.parameters() if not p.requires_grad)
print(f"Trainable : {trainable:,} | Frozen : {frozen:,}")

Initialisation des poids

import torch.nn.init as init

class InitializedNet(nn.Module):
    def __init__(self):
        super().__init__()
        self.fc1 = nn.Linear(784, 256)
        self.fc2 = nn.Linear(256, 10)

        # Initialisation personnalisée
        self._init_weights()

    def _init_weights(self):
        # Xavier/Glorot initialization
        init.xavier_uniform_(self.fc1.weight)
        init.zeros_(self.fc1.bias)

        # Kaiming/He initialization (pour ReLU)
        init.kaiming_normal_(self.fc2.weight, mode='fan_out', nonlinearity='relu')
        init.zeros_(self.fc2.bias)

    def forward(self, x):
        x = torch.relu(self.fc1(x))
        x = self.fc2(x)
        return x

model = InitializedNet()

Modes training et évaluation

Les modèles PyTorch ont deux modes : training et evaluation.

model = nn.Sequential(
    nn.Linear(784, 256),
    nn.Dropout(0.5),  # Actif uniquement en training
    nn.BatchNorm1d(256),  # Comportement différent selon mode
    nn.ReLU(),
    nn.Linear(256, 10)
)

# Mode training (défaut)
model.train()
print(f"En training mode : {model.training}")  # True

x = torch.randn(32, 784)
output_train = model(x)

# Mode evaluation (pour inférence)
model.eval()
print(f"En eval mode : {model.training}")  # False

with torch.no_grad():  # Désactiver autograd aussi
    output_eval = model(x)

# Dropout et BatchNorm se comportent différemment !
⚠️ Warning
TOUJOURS appeler model.eval() avant l’inférence/test ! Sinon Dropout restera actif et BatchNorm utilisera les stats du batch au lieu des moyennes globales. Cela dégradera vos performances de 5-15% !

Exercices pratiques

MLP pour MNIST

Créez un MLP (Multi-Layer Perceptron) pour classifier MNIST :

class MNISTClassifier(nn.Module):
    def __init__(self):
        super().__init__()
        # Votre architecture ici
        # Input : 28×28 = 784
        # Hidden : 128, 64
        # Output : 10 classes
        pass

    def forward(self, x):
        # Votre code ici
        pass

# Test
model = MNISTClassifier()
x = torch.randn(32, 784)  # Batch de 32 images
output = model(x)
print(f"Output shape : {output.shape}")  # Devrait être (32, 10)

ResBlock implémentation

Implémentez un bloc résiduel avec BatchNorm :

class ResBlock(nn.Module):
    def __init__(self, in_features, out_features):
        super().__init__()
        # Votre code ici
        # Path principal : Linear → BN → ReLU → Linear → BN
        # Skip connection si dimensions différentes
        pass

    def forward(self, x):
        # Votre code ici
        pass

Compter les paramètres

Écrivez une fonction qui affiche un résumé du modèle :

def model_summary(model):
    """
    Affiche :
    - Nombre total de paramètres
    - Paramètres par layer
    - Taille en mémoire (MB)
    """
    # Votre code ici
    pass

# Test
model = nn.Sequential(
    nn.Linear(784, 256),
    nn.ReLU(),
    nn.Linear(256, 10)
)
model_summary(model)

Conclusion

Dans cet article, vous avez appris à construire des réseaux de neurones avec nn.Module :

  • ✅ Structure de base : __init__() et forward()
  • ✅ Layers essentiels : Linear, Conv2d, BatchNorm, Dropout, LSTM
  • ✅ Fonctions d’activation : ReLU, GELU, Sigmoid, Softmax
  • ✅ nn.Sequential pour architectures simples
  • ✅ Architectures personnalisées avec skip connections
  • ✅ ModuleList et ModuleDict pour structures dynamiques
  • ✅ Gestion des paramètres et initialisation
  • ✅ Modes training vs evaluation

Prochaine étape : Dans le prochain article, nous allons entraîner notre premier modèle ! Vous apprendrez les loss functions, les optimizers (SGD, Adam), et comment implémenter une boucle d’entraînement complète.

Pour aller plus loin :

🔎 Tip
Projet pratique : Créez un classifieur MNIST complet avec un MLP de 3 couches. Expérimentez avec différentes tailles de layers, dropout rates, et fonctions d’activation. Comparez les architectures en comptant les paramètres. La pratique est la clé !