À la fin de cette section, vous saurez :
torch.nn.torch-summary pour inspecter l’architecture du réseau.Le perceptron multi-couches (MLP de Multi-Layers Perceptron en anglais) est la brique de base des réseaux de neurones modernes. Dans ce chapitre, nous allons l’appliquer à des problèmes de régression simple. Avant de commencer, voici quelques rappels.
Le perceptron est le bloc de base d’un réseau de neurones. Il réalise une transformation linéaire suivie (ou pas) d’une fonction d’activation telle que :
$$y = \sigma(Wx + b)$$où :
- \(y\) est la sortie du perceptron,
- \(\sigma\) est une fonction d’activation,
- \(W\) est la matrice des poids,
- \(b\) est le biais et
- \(x\) est l'ensemble des entrées du perceptron.
avec \(y= \sigma(x_1*w_1 + x_2*w_2 + ...+ x_i*w_i + ... + x_n*w_n + b)\)
💡 Intuition :
Un perceptron possède deux types de paramètres : les poids et le biais.
Lors de l’entraînement, on souhaite ajuster ces paramètres pour améliorer les prédictions du modèle. Pour cela, il faut mettre à jour les poids après avoir calculé la loss grâce à la fonction de perte et le gradient grâce à l'optimiseur comme expliqué dans le chapitre précédent.
Pour rappel, on met à jour les paramètres du modèle grâce à l'équation introduite dans le chapitre précédent.
$$\theta \leftarrow \theta - \eta \, \nabla_\theta \mathcal{L}(\theta)$$où :
Un perceptron simple ne peut résoudre que les problèmes linéairement séparables puisqu'en trouvant les paramètres du modèle, le perceptron trace une droite dans le plan des entrées et sépare les points selon qu’ils sont au-dessus ou en dessous de cette droite.
Exemple 1 : porte logique ET
| x₁ | x₂ | y=ET |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 0 |
| 1 | 0 | 0 |
| 1 | 1 | 1 |
Dans ce cas, une droite sépare bien les deux classes :
Un perceptron simple peut donc apprendre cette fonction.
Exemple 2 : porte logique XOR
| x₁ | x₂ | y=XOR |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 0 |
Ici, il est impossible de tracer une seule droite qui sépare correctement les classes. Autrement dit, XOR n’est pas linéairement séparable.
Conclusion :
Pour créer un perceptron simple dans PyTorch, on peut utiliser la fonction Linear de torch.nn, qui implémente une couche linéaire (ou affine) : \(y = Wx + b\). La fonction Linear prend en entrée le nombre d'entrée \(x\) et le nombre de sortie \(y\).
import torch
import torch.nn as nn
# Données ET
X = torch.tensor([[0,0],[0,1],[1,0],[1,1]], dtype=torch.float32)
y = torch.tensor([[0],[0],[0],[1]], dtype=torch.float32)
# Modèle linéaire (perceptron)
model = nn.Linear(2, 1, bias=True)
# Loss function et optimiseur
loss_fc = nn.MSELoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.1)
# Entraînement
for _ in range(500):
optimizer.zero_grad()
loss = loss_fc(model(X), y)
loss.backward()
optimizer.step()
# Résultat
with torch.no_grad():
print((model(X)).round())
print(model.weight, model.bias)
Remarque : si maintenant on change les entrées et sorties pour le XOR, le modèle ne pourra pas apprendre correctement la fonction (les \(W\) restent à 0 comme à l'initialisation). Vous pouvez faire le test pour vérifier.
Les fonctions d’activation introduisent de la non-linéarité dans le modèle, ce qui permet de mieux capturer des relations complexes dans les données. Sans une fonction d'activation, un perceptron (ou même plusieurs formant un réseau de neurones de plusieurs couches) ne ferait que des combinaisons linéaires et ne pourrait pas résoudre des problèmes non linéaires comme XOR.
Voici quatre fonctions d’activation couramment utilisées :
Dans PyTorch, les fonctions d'activation sont disponibles dans la bibliothèque torch.nn. Voici quelques exemples :
nn.sigmoid(x)nn.tanh(x)nn.relu(x)nn.softmax(x, dim=1)Reprenons le problème ET avec un perceptron.
Remarque : Dans le cas où le problème à résoudre est non linéairement séparable (comme XOR), une fonction d’activation seule ne suffit pas. Il faut empiler plusieurs couches de neurones avec des fonctions d’activation entre chaque couche pour capturer la complexité des données.
Voici un exemple d'utilisation des fonctions d'activation pour le problème ET avec un perceptron :
import torch
import torch.nn as nn
# Données ET
X = torch.tensor([[0,0],[0,1],[1,0],[1,1]], dtype=torch.float32)
y = torch.tensor([[0],[0],[0],[1]], dtype=torch.float32)
# --- Cas 1 : Perceptron sans activation ---
linear = nn.Linear(2, 1, bias=True)
with torch.no_grad():
linear.weight[:] = torch.tensor([[1., 1.]]) # w1=1, w2=1
linear.bias[:] = torch.tensor([-1.5]) # b=-1.5
z = linear(X) # sortie brute
print("Sorties sans activation :")
print(z)
# --- Cas 2 : Perceptron avec sigmoïde ---
sigmoid = nn.Sigmoid()
y_hat = sigmoid(z)
print("\nSorties avec sigmoïde :")
print(y_hat)
Une sortie brute comme -1.5 devient 0.18 après sigmoïde, et 0.5 devient 0.62 : la sigmoid transforme les nombres en valeurs entre 0 et 1, les rendant interprétables comme des probabilités.
On peut choisir la fonction d’activation en fonction de plusieurs critères: le problème à résoudre ou la convergence de l'entraînement.
Choix selon le contexte :
Impact sur l’apprentissage :
Certaines fonctions d’activation influencent la vitesse de convergence. Par exemple, la sigmoïde borne les sorties, ce qui peut réduire l’amplitude des gradients et ralentir l’apprentissage pour de grandes valeurs absolues.
Lorsqu’on entraîne un modèle de machine learning, il est nécessaire de présenter plusieurs fois l’ensemble des données d’apprentissage \(x\) au modèle afin d’ajuster correctement ses paramètres.
Exemple :
Si vous disposez de 1000 exemples et que vous utilisez des mini-batchs de 100 exemples chacun, une epoch correspond à 10 itérations (1000 ÷ 100). Après chaque epoch, chaque exemple de l’ensemble d’apprentissage a été utilisé exactement une fois pour mettre à jour les paramètres du modèle.
Au début de l’entraînement, le modèle commet souvent de grandes erreurs. Chaque epoch permet aux poids et aux biais de s’ajuster progressivement, améliorant ainsi les prédictions. En pratique, plusieurs dizaines ou centaines d’epochs sont souvent nécessaires pour que la loss se stabilise et que le modèle converge vers une bonne solution.
💡 Intuition : imaginez un perceptron comme un élève qui apprend : il ne retient pas tout parfaitement du premier coup. Il faut plusieurs passages sur les mêmes exercices pour maîtriser la tâche.
Avant d'entraîner un modèle, il est important de préparer les données pour que l’apprentissage soit efficace. Pour cela, deux opérations courantes sont la normalisation et la standardisation.
La normalisation consiste à mettre les valeurs dans une plage donnée, souvent entre 0 et 1. Cela est utile lorsque les données ont des échelles très différentes. Pour cela, il faut appliquer la formule suivante à chaque donnée:
$$x'_i = \frac{x_i - x_\text{min}}{x_\text{max} - x_\text{min}}$$import torch
X = torch.tensor([[1., 50.],[2., 60.],[3., 55.]])
X_min = X.min(dim=0).values
X_max = X.max(dim=0).values
X_norm = (X - X_min) / (X_max - X_min)
print(X_norm)
La standardisation consiste à centrer et réduire les variables : on soustrait la moyenne et on divise par l’écart-type. C’est particulièrement utile pour les algorithmes basés sur le gradient (comme les perceptrons), car cela accélère la convergence. Pour standardiser les données voici la formule à appliquer pour chaque donnée :
$$x'_i = \frac{x_i - \mu}{\sigma}$$Contrairement à la normalisation, la standardisation a une fonction dans PyTorch pré-implémentée nommée
torch.nn.BatchNorm1d. Voici comment l'implémenter avec PyTorch :
import torch
import torch.nn as nn
X = torch.tensor([[1., 50.],[2., 60.],[3., 55.]], dtype=torch.float32)
# Standardisation "manuelle"
X_mean = X.mean(dim=0)
X_std = X.std(dim=0)
X_stdized = (X - X_mean) / X_std
print("Standardisation manuelle :")
print(X_stdized)
# Standardisation avec BatchNorm1d
batchnorm = nn.BatchNorm1d(num_features=2, affine=False)
X_stdized_bn = batchnorm(X)
print("\nStandardisation avec BatchNorm1d :")
print(X_stdized_bn)
La standardisation est souvent préférée à la normalisation car elle est plus robuste aux valeurs aberrantes et permet une convergence plus rapide du modèle.
Après avoir centré et réduit les données, la standardisation permet généralement d'avoir une moyenne proche de 0 et un écart-type proche de 1 pour chaque variable.
Pourquoi ?
Si la standardisation est appliquée sur un mini-batch (par exemple avec BatchNorm1d), la moyenne et l’écart-type sont calculés sur ce mini-batch. Dans ce cas, la moyenne n’est pas exactement 0 et l’écart-type n’est pas exactement 1 pour l’ensemble du dataset. De plus, certains modules comme BatchNorm peuvent apprendre un scale et un shift, modifiant légèrement ces valeurs finales.
Est-ce grave si ce n'est pas exactement 0 et 1 ?
Les réseaux de neurones multi-couches (MLP, de l'anglais Multi-Layer Perceptron) permettent de résoudre des problèmes non linéaires comme XOR, que le perceptron simple ne peut pas gérer. Un MLP se compose de couches linéaires suivies de fonctions d'activation, et peut être construit très simplement avec torch.nn.Sequential.
Pour construire un MLP, il faut choisir le nombre de couches et de neurones par couche ainsi que la fonction d'activation à utiliser après chaque couche. Il n’est généralement pas possible de connaître à l’avance le nombre exact à mettre. On teste plusieurs architectures pour trouver celle qui converge correctement et rapidement.
💡 Résumé :
Chaque couche d’un MLP est un ensemble de perceptrons. Les couches cachées permettent de modéliser la non-linéarité, et le nombre de couches et de neurones doit être choisi en fonction de la complexité du problème et de la performance souhaitée.
torch.nnPour créer un MLP dans PyTorch, on utilise principalement :
Sequential : permet d’empiler facilement les couches les unes après les autres.Linear : crée une couche affine, c’est-à-dire une transformation de la forme \(y = Wx + b\).nn.ReLU() ou nn.Sigmoid()).Exemple minimal d’un réseau de neurones pour une régression 1D avec un MLP à deux couches cachées :
import torch.nn as nn
model = nn.Sequential(
nn.Linear(1, 10), # couche d'entrée 1D -> première couche cachée 10 neurones
nn.ReLU(), # activation non linéaire
nn.Linear(10, 5), # deuxième couche cachée avec 5 neurones
nn.ReLU(), # activation non linéaire
nn.Linear(5, 1) # couche de sortie 1D
)
💡 Remarques :
Important : La dimension de sortie d’une couche doit correspondre à la dimension d’entrée de la couche suivante.
Vous pouvez visualiser le réseau de neurones en utilisant le code ci-dessous :
import matplotlib.pyplot as plt
def draw_mlp(layers):
fig, ax = plt.subplots(figsize=(8,4))
ax.axis("off")
x_spacing = 2
y_spacing = 1.0
for i, n_neurons in enumerate(layers):
x = i * x_spacing
for j in range(n_neurons):
y = j * y_spacing - (n_neurons-1)/2
circle = plt.Circle((x,y), 0.25, fill=True, color="skyblue", ec="k")
ax.add_artist(circle)
# Connexions avec la couche précédente
if i > 0:
for k in range(layers[i-1]):
y_prev = k * y_spacing - (layers[i-1]-1)/2
ax.plot([x-x_spacing, x], [y_prev, y], "k-", lw=0.5)
ax.set_xlim(-1, x_spacing*(len(layers)-1)+1)
ax.set_ylim(-max(layers)/2-1, max(layers)/2+1)
plt.show()
# Exemple : 1 entrée → 10 neurones cachés → 5 neurones cachés → 1 sortie
draw_mlp([1, 10, 5, 1])
Dans PyTorch, il est courant de définir un modèle en créant une classe qui hérite de nn.Module. Cela permet de mieux organiser le code, de réutiliser facilement le modèle. Dans ce cas, la méthode forward décrit comment les données traversent le réseau.
Voici le même exemple que précédemment avec une classe :
import torch
import torch.nn as nn
class SimpleMLP(nn.Module):
def __init__(self):
super(SimpleMLP, self).__init__()
self.fc1 = nn.Linear(1, 10) # première couche cachée
self.fc2 = nn.Linear(10, 5) # deuxième couche cachée
self.fc3 = nn.Linear(5, 1) # couche de sortie
self.relu = nn.ReLU() # fonction d'activation
def forward(self, x):
x = self.relu(self.fc1(x))
x = self.relu(self.fc2(x))
x = self.fc3(x)
return x
# Création d'une instance du modèle
model = SimpleMLP()
💡 Remarques :
forward définit comment les données passent de la couche d'entrée à la sortie, en appliquant les fonctions d’activation entre les couches.Comme expliqué précédemment, un perceptron simple ne peut pas résoudre le problème XOR, même avec une fonction d’activation, car il ne fait qu’une séparation linéaire (une droite).
Exemple minimal en PyTorch avec une seule couche cachée et une activation non-linéaire :
import torch
import torch.nn as nn
import torch.optim as optim
import matplotlib.pyplot as plt
# Données XOR
X = torch.tensor([[0,0],[0,1],[1,0],[1,1]], dtype=torch.float32)
y = torch.tensor([[0],[1],[1],[0]], dtype=torch.float32)
# Définition du MLP avec une classe
class XORMLP(nn.Module):
def __init__(self):
super(XORMLP, self).__init__()
self.fc1 = nn.Linear(2, 4) # couche cachée 1
self.fc2 = nn.Linear(4, 1) # couche de sortie
self.activation = nn.ReLU()
self.out_activation = nn.Sigmoid()
def forward(self, x):
x = self.activation(self.fc1(x))
x = self.out_activation(self.fc2(x))
return x
# Création du modèle
model = XORMLP()
# Optimiseur et fonction de perte
optimizer = optim.Adam(model.parameters(), lr=0.05)
fc_loss = nn.MSELoss()
# Entraînement
for epoch in range(5000):
y_pred = model(X)
loss = fc_loss(y_pred, y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
# Vérification numérique
with torch.no_grad():
y_pred_train = model(X)
y_class = (y_pred_train > 0.5).float()
print("Prédictions (probabilités) :\n", y_pred_train)
print("Classes prédites :\n", y_class)
print("Classes réelles :\n", y)
correct = (y_class == y).all()
print("Toutes les prédictions sont correctes :", correct)
# Affichage de la frontière de décision
xx, yy = torch.meshgrid(torch.linspace(-0.5, 1.5, 200),
torch.linspace(-0.5, 1.5, 200))
grid = torch.cat([xx.reshape(-1,1), yy.reshape(-1,1)], dim=1)
with torch.no_grad():
zz = model(grid).reshape(xx.shape)
plt.contourf(xx, yy, zz, levels=[0,0.5,1], alpha=0.3, colors=["red","blue"])
plt.scatter(X[:,0], X[:,1], c=y[:,0], cmap="bwr", edgecolors="k", s=100)
plt.title("Frontière de décision XOR avec MLP en classe")
plt.xlabel("x1")
plt.ylabel("x2")
plt.show()
💡 Remarques :
forward rend le code plus modulable et facilite l’expérimentation avec différentes architectures de MLP.On repart avec un exemple de régression simple pour illustrer l'importance de la standardisation des données avant l'entraînement d'un MLP. L'objectif est de prédire la sortie y pour de nouvelles entrées x que celles sur lesquelles le modèle a été entraîné.
import torch
import torch.nn as nn
import torch.optim as optim
import matplotlib.pyplot as plt
# Données
X = torch.tensor([[0.],[10.],[20.],[30.],[40.],[50.]])
y = 2*X + 1 # relation linéaire exacte
# y = 2*X + 1 + torch.randn_like(X)*5 # relation linéaire bruitée
# Standardisation
X_mean, X_std = X.mean(), X.std()
X_stdized = (X - X_mean)/X_std
# Modèle simple
class MLP(nn.Module):
def __init__(self):
super().__init__()
self.net = nn.Sequential(
nn.Linear(1, 5),
nn.ReLU(),
nn.Linear(5,1)
)
def forward(self, x):
return self.net(x)
# Modèles
model_no_std = MLP()
model_std = MLP()
# Optimiseur
optimizer_no_std = optim.SGD(model_no_std.parameters(), lr=0.001)
optimizer_std = optim.SGD(model_std.parameters(), lr=0.01)
# Entraînement
for _ in range(5000):
# Sans standardisation
pred_no_std = model_no_std(X)
loss_no_std = ((pred_no_std - y)**2).mean()
optimizer_no_std.zero_grad()
loss_no_std.backward()
optimizer_no_std.step()
# Avec standardisation
pred_std = model_std(X_stdized)
loss_std = ((pred_std - y)**2).mean()
optimizer_std.zero_grad()
loss_std.backward()
optimizer_std.step()
# Test des prédictions
X_test = torch.tensor([[0.],[60.]])
X_test_std = (X_test - X_mean)/X_std
with torch.no_grad():
preds_no_std = model_no_std(X_test)
preds_std = model_std(X_test_std)
print("Prédictions finales (Sans standardisation) :", preds_no_std.squeeze().tolist())
print("Prédictions finales (Avec standardisation) :", preds_std.squeeze().tolist())
# Visualisation
plt.scatter(X, y, color='black', label='Données')
plt.scatter(X_test, preds_no_std, color='red', label='Sans standardisation')
plt.scatter(X_test, preds_std, color='blue', label='Avec standardisation')
plt.legend()
plt.title("Impact de la standardisation sur la prédiction finale")
plt.xlabel("x")
plt.ylabel("y")
plt.show()
Les sorties attendues sont \(y_{true} = [1, 121]\).
💡 Conclusion :
y = 2*X + 1 + torch.randn_like(X)*5 et relancer l'entraînement.Dans cet exercice, vous allez implémenter une boucle d'entraînement simple pour ajuster les paramètres d’un modèle polynômial comme dans le chapitre 1, puis comparer les résultats avec ceux d'un modèle MLP.
On vous donne les données suivantes :
torch.manual_seed(0)
X = torch.linspace(-3, 3, 100).unsqueeze(1)
y_true = torch.sin(2*X) + 0.1 * torch.randn(X.size()) # fonction sinusoïdale bruitée
Objectif : Comparer deux modèles pour approximer la fonction :
nn.ModuleReLU pour l'activationConsigne : Écrire un programme qui :
1) Ajuste les paramètres du polynôme cubique aux données en utilisant PyTorch.
2) Affiche les paramètres appris \(a, b, c, d\).
3) Implémente ensuite un MLP et entraîne-le sur les mêmes données pendant 5000 epochs avec un learning rate de 0.01.
4) Compare visuellement les deux modèles avec les données réelles sur un même graphique.
5) Que remarquez-vous sur les performances des deux modèles ?
6) Que se passe-t-il si vous augmentez le nombre de degrés du polynôme ?
Astuce :
torch.randn(1, requires_grad=True).nn.MSELoss() comme fonction de perte pour les deux modèles.nn.Module et définissez forward.optimizer.zero_grad(), loss.backward(), optimizer.step() à chaque itération.Résultat attendu : Vous devez obtenir un graphique similaire à celui ci-dessous où :
y_true)