Construire des réseaux de neurones avec nn.Module en PyTorch
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)
)
__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

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)
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}")
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)
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)
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 !
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__()etforward() - ✅ 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 :
- Explorez la documentation nn pour découvrir tous les layers disponibles
- Lisez notre série sur les architectures CNN pour computer vision
- Découvrez les Transformers pour le NLP
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é !