TP2: Introduction aux réseaux de neurones et implémentation¶

Dans ce TP on s'interesse à l'implémentation des MLP (Multilayers perceptron) qui sont des réseaux feed forward (feed forward neural network). Nous allons voir comment les utiliser pour approcher des fonctions simples.

1) Approximation de fonction simples¶

a) 1er exemple¶

On va utiliser un MLP pour approcher la fonction sin du TP1.

On se donner un jeu de donnée $(x_1, y_1),\dots,(x_n,y_n)$, où les $x_1,\dots,x_n$ correspondent à une grille uniforme de $[-\pi,\pi]$ (données d'entrée), et les $y_1,\dots,y_n$ (données de sortie) sont données par

$$ y_j = \sin(2x_j) + \varepsilon_j \qquad j\in\{1,\dots,n\}, $$

où les $\varepsilon_j$ sont des variables aléatoires i.i.d de loi $N(0,\sigma^2)$, et modélisent des erreurs de mesure.

L'idée est de minimiser le coût/risque quadratique

$$ R_n(\theta) = \sum_{j=1}^n (y_j - N_\theta(x_j))^2 $$

en $\theta$ pour determiner les coefficients $\theta$ du réseau. Notre réseau devra être capable de bien représenter les données.

Illustration des données¶

In [1]:
import numpy as np
from math import pi

import matplotlib.pyplot as plt
In [2]:
x = np.linspace(-pi, pi, 50) # données d'entrée
y0 = np.sin(2*x)
noise = 0.1 * np.random.randn(len(x))
y = y0 + noise
In [3]:
plt.plot(x, y, "o", label="data")
plt.plot(x, y0, lw=2, label="ground truth")
plt.xlabel("x", fontsize=18)
plt.legend(fontsize=12)
plt.show()
No description has been provided for this image

Mettons les données dans un format approprié

In [4]:
import torch
import torch.nn as nn
In [5]:
# les données sont sous une forme de matrice ligne.

x_data = torch.tensor(x, dtype=torch.float32).reshape(-1,1)
y_data = torch.tensor(y, dtype=torch.float32).reshape(-1,1)

Pour commencer, on va considérer un réseau à une couche cachée de 10 neuronnes de profondeur, avec tanh comme fonction d'activation.

In [6]:
model = nn.Sequential(
    nn.Linear(1,10),
    nn.Tanh(),
    nn.Linear(10,1)
)

Définnissons le learning rate, le nombre d'epoch pour l'entrainement, la fonction de coût (la loss), et l'algorithme d'optimisation.

In [7]:
learning_rate = 1e-3
n_epoch = 15000
In [8]:
# on cosidère une fonction de coût quadratique
loss_fct = torch.nn.MSELoss(reduction='sum')

# Algorithme d'optimisation: descente de gradient stochastique
optimizer = torch.optim.SGD(model.parameters(), lr=learning_rate)

# vecteur pour dessiner l'évolution de la loss
loss_plot = np.zeros(n_epoch)
In [9]:
# Entrainement du réseau

for t in range(n_epoch):
    
    y_pred = model(x_data)
    loss = loss_fct(y_pred, y_data)
    loss_plot[t] = loss
    
    if t%1000 == 0:
        print("iteration=%s, loss=%s" % (t,loss.item()))
        
    # On met à zéro les gradients avant de les calculer.
    optimizer.zero_grad()
    
    # On calcul les gradients
    loss.backward() 
    
    # On met à jour les paramètres
    optimizer.step()

print("iteration=%s, loss=%s" % (t,loss.item()))
iteration=0, loss=45.294952392578125
iteration=1000, loss=3.8436532020568848
iteration=2000, loss=3.091489553451538
iteration=3000, loss=2.5776941776275635
iteration=4000, loss=2.0562374591827393
iteration=5000, loss=1.5320309400558472
iteration=6000, loss=1.1966335773468018
iteration=7000, loss=0.9965047836303711
iteration=8000, loss=0.8490756750106812
iteration=9000, loss=0.7322078347206116
iteration=10000, loss=0.6381136775016785
iteration=11000, loss=0.5618182420730591
iteration=12000, loss=0.4995132088661194
iteration=13000, loss=0.45187807083129883
iteration=14000, loss=0.4318889379501343
iteration=14999, loss=0.42147117853164673
In [10]:
# Illustration de l'adéquation des prédiction du réseau avec les données
# et par rapport à la fonction x-> sin(2x)

plt.plot(x, y, "o", label="data")
plt.plot(x, y_pred.detach(), "o", label="predictions")
plt.plot(x, y0, lw=2, label="ground truth")
plt.xlabel("x", fontsize=18)
plt.legend(fontsize=12, loc='upper right')
plt.show()
No description has been provided for this image
In [11]:
# Illustration de l'évolution de la loss

plt.plot(loss_plot[10:], label="loss values")
plt.xlabel("nbre d'epoch", fontsize=18)
plt.show()
No description has been provided for this image

b) 2ème exemple¶

Changeons l'intervalle $[-\pi,\pi]$ en $[0,20\pi]$, et faisons exactement la même opération. Avec la transformation $x\mapsto x/10 - \pi$ ce la revient à considérer la fonction $x\mapsto \sin(2(x/10-\pi))$ sur $[0,20\pi]$.

Illustration des données¶

In [12]:
x = np.linspace(0, 20*pi, 50) # données d'entrée
y0 = np.sin(2*(x/10-pi))
y = y0 + noise
In [13]:
plt.plot(x, y, "o", label="data")
plt.plot(x, y0, lw=2, label="ground truth")
plt.xlabel("x")
plt.legend()
plt.show()
No description has been provided for this image
In [14]:
x_data = torch.tensor(x, dtype=torch.float32).reshape(-1,1)
y_data = torch.tensor(y, dtype=torch.float32).reshape(-1,1)

Reprennons la même configuration de réseau et d'entrainement que précédemment.

In [15]:
model = nn.Sequential(
    nn.Linear(1,10),
    nn.Tanh(),
    nn.Linear(10,1)
)
In [16]:
learning_rate = 1e-3
n_epoch = 15000
In [21]:
loss_fct = torch.nn.MSELoss(reduction='sum')

# On pourra tester Adam à la place de SGD
optimizer = torch.optim.SGD(model.parameters(), lr=learning_rate)

loss_plot = np.zeros(n_epoch)
In [22]:
# Entrainement du réseau

for t in range(n_epoch):
    
    y_pred = model(x_data)
    loss = loss_fct(y_pred, y_data)
    loss_plot[t] = loss
    
    if t%1000 == 0:
        print("iteration=%s, loss=%s" % (t,loss.item()))
        
    # On met à zéro les gradients avant de les calculer.
    optimizer.zero_grad()
    
    # On calcul les gradients
    loss.backward() 
    
    # On met à jour les paramètres
    optimizer.step()

print("iteration=%s, loss=%s" % (t,loss.item()))
iteration=0, loss=0.708846926689148
iteration=1000, loss=17.060224533081055
iteration=2000, loss=17.052589416503906
iteration=3000, loss=17.04593849182129
iteration=4000, loss=17.042583465576172
iteration=5000, loss=17.040864944458008
iteration=6000, loss=17.039493560791016
iteration=7000, loss=17.038251876831055
iteration=8000, loss=17.037092208862305
iteration=9000, loss=17.035995483398438
iteration=10000, loss=17.034940719604492
iteration=11000, loss=17.03392791748047
iteration=12000, loss=17.032943725585938
iteration=13000, loss=17.0319881439209
iteration=14000, loss=17.031051635742188
iteration=14999, loss=17.030139923095703
In [23]:
plt.plot(x, y, "o", label="data")
plt.plot(x, y_pred.detach(), "o", label="predictions")
plt.plot(x, y0, lw=2, label="ground truth")
plt.xlabel("x", fontsize=18)
plt.legend(fontsize=12, loc='upper right')
plt.show()
No description has been provided for this image

Oups! Ca marche moins bien! Pourquoi?¶

Ici, la différence entre les valeurs de $x_j$ affecte les amplitudes des points des neuronnes et rend plus difficile son entrainement.

On peut voir qu'Adam marche mieux que SGD pour entrainer le réseau. Par contre, ca reste moins facile que quand les données d'entrées ont des valeurs contenues comme précédemment dans $[-\pi,\pi]$.

Il faut donc penser à normaliser ces données.

c) 3ème exemple: On reprend le 1er exemple, mais on considère l'intervalle $[-1,1]$ pour les $x$ et la fonction $x\mapsto \sin(2\pi x)$ sur cette intervalle. On pourra essayer avec un réseau à une couche cachée de 5 neurones.¶

In [24]:
x = np.linspace(-1, 1, 50)
In [25]:
y0 = np.sin(2*pi*x)
y = y0 + noise
In [26]:
x_data = torch.tensor(x, dtype=torch.float32).reshape(-1,1)
y_data = torch.tensor(y, dtype=torch.float32).reshape(-1,1)
In [27]:
learning_rate = 1e-3
n_epoch = 15000
In [28]:
model = nn.Sequential(
    nn.Linear(1,5),
    nn.Tanh(),
    nn.Linear(5,1)
)
In [29]:
loss_fct = torch.nn.MSELoss(reduction='sum')
optimizer = torch.optim.SGD(model.parameters(), lr=learning_rate)

loss_plot = np.zeros(n_epoch)
In [30]:
for t in range(n_epoch):
    
    y_pred = model(x_data)
    loss = loss_fct(y_pred, y_data)
    loss_plot[t] = loss
    
    if t%1000 == 0:
        print("iteration=%s, loss=%s" % (t,loss.item()))
        
    # On met à zéro les gradients avant de les calculer.
    optimizer.zero_grad()
    
    # On calcul les gradients
    loss.backward() 
    
    # On met à jour les paramètres
    optimizer.step()

print("iteration=%s, loss=%s" % (t,loss.item()))
iteration=0, loss=53.54950714111328
iteration=1000, loss=21.623794555664062
iteration=2000, loss=21.588838577270508
iteration=3000, loss=21.561233520507812
iteration=4000, loss=21.52503204345703
iteration=5000, loss=21.45871353149414
iteration=6000, loss=21.33314323425293
iteration=7000, loss=21.158235549926758
iteration=8000, loss=20.979969024658203
iteration=9000, loss=20.71510887145996
iteration=10000, loss=19.85545539855957
iteration=11000, loss=7.089118003845215
iteration=12000, loss=2.8711769580841064
iteration=13000, loss=2.5627188682556152
iteration=14000, loss=2.2384681701660156
iteration=14999, loss=1.7420483827590942
In [31]:
plt.plot(x, y, "o", label="data")
plt.plot(x, y_pred.detach(), "o", label="predictions")
plt.plot(x, y0, lw=2, label="ground truth")
plt.xlabel("x", fontsize=18)
plt.legend(fontsize=12)
plt.show()
No description has been provided for this image
In [32]:
plt.plot(loss_plot[10:], label="loss values")
plt.xlabel("nbre d'epoch", fontsize=18)
plt.show()
No description has been provided for this image

On peut voir que l'entrainement n'a pas été si simple, mais cela reste mieux que sur l'intervalle $[0,20\pi]$, et ce même avec un réseau plus petit.

d) 4ème exemple: Que se passe-t-il si on considère l'intervalle $[-0.1,0.1]$ pour la fonction $x\mapsto \sin(20\pi x)$?¶

In [33]:
x = np.linspace(-0.1,0.1,50)
In [34]:
y0 = np.sin(20*pi*x)
y = y0 + noise
In [35]:
x_data = torch.tensor(x, dtype=torch.float32).reshape(-1,1)
y_data = torch.tensor(y, dtype=torch.float32).reshape(-1,1)
In [36]:
model = nn.Sequential(
    nn.Linear(1,10),
    nn.Tanh(),
    nn.Linear(10,1)
)
In [37]:
learning_rate = 1e-3
n_epoch = 15000
In [38]:
loss_fct = torch.nn.MSELoss(reduction='sum')
optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)
In [39]:
for t in range(n_epoch):
    
    y_pred = model(x_data)
    loss = loss_fct(y_pred, y_data)
    
    if t%1000 == 0:
        print("iteration=%s, loss=%s" % (t,loss.item()))
        
    # On met à zéro les gradients avant de les calculer.
    optimizer.zero_grad()
    
    # On calcul les gradients
    loss.backward() 
    
    # On met à jour les paramètres
    optimizer.step()

print("iteration=%s, loss=%s" % (t,loss.item()))
iteration=0, loss=25.61466407775879
iteration=1000, loss=21.553333282470703
iteration=2000, loss=21.552804946899414
iteration=3000, loss=21.551851272583008
iteration=4000, loss=21.55023765563965
iteration=5000, loss=21.547744750976562
iteration=6000, loss=21.54418182373047
iteration=7000, loss=21.53826904296875
iteration=8000, loss=21.524202346801758
iteration=9000, loss=21.487756729125977
iteration=10000, loss=21.370471954345703
iteration=11000, loss=20.53572654724121
iteration=12000, loss=18.813823699951172
iteration=13000, loss=17.79069709777832
iteration=14000, loss=16.94384765625
iteration=14999, loss=15.827191352844238
In [40]:
plt.plot(x, y, "o", label="data")
plt.plot(x, y_pred.detach(), "o", label="predictions")
plt.plot(x, y0, lw=2, label="ground truth")
plt.xlabel("x", fontsize=18)
plt.legend(fontsize=12)
plt.show()
No description has been provided for this image

Encore une fois, ca marche moins bien.

e) 5ème exemple: Même question pour la fonction $x\mapsto \sin(10\pi x)$ sur l'intervalle $[-1,1]$. On pourra jouer avec le nombre de point de la grille des $x_j$ et la taille du réseau.¶

Génération des données¶

In [41]:
x = np.linspace(-1, 1, 200)
In [42]:
y0 = np.sin(10*pi*x)
noise = 0.1 * np.random.randn(len(x))
y = y0 + noise
In [43]:
x_data = torch.tensor(x, dtype=torch.float32).reshape(-1,1)
y_data = torch.tensor(y, dtype=torch.float32).reshape(-1,1)
In [44]:
learning_rate = 1e-3
n_epoch = 15000
In [45]:
model = nn.Sequential(
    nn.Linear(1,16),
    nn.Tanh(),
    nn.Linear(16,32),
    nn.Tanh(),
    nn.Linear(32,16),
    nn.Tanh(),
    nn.Linear(16,1)
)
In [46]:
loss_fct = torch.nn.MSELoss(reduction='sum')
optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)
In [47]:
for t in range(n_epoch):
    
    y_pred = model(x_data)
    loss = loss_fct(y_pred, y_data)
    
    if t%1000 == 0:
        print("iteration=%s, loss=%s" % (t,loss.item()))
        
    # On met à zéro les gradients avant de les calculer.
    optimizer.zero_grad()
    
    # On calcul les gradients
    loss.backward() 
    
    # On met à jour les paramètres
    optimizer.step()

print("iteration=%s, loss=%s" % (t,loss.item()))
iteration=0, loss=105.27815246582031
iteration=1000, loss=101.01466369628906
iteration=2000, loss=99.89808654785156
iteration=3000, loss=97.90980529785156
iteration=4000, loss=74.51273345947266
iteration=5000, loss=43.20464324951172
iteration=6000, loss=19.956443786621094
iteration=7000, loss=2.0992343425750732
iteration=8000, loss=2.263878345489502
iteration=9000, loss=1.901151418685913
iteration=10000, loss=1.8677173852920532
iteration=11000, loss=1.8439821004867554
iteration=12000, loss=1.8264466524124146
iteration=13000, loss=1.8127532005310059
iteration=14000, loss=1.8026986122131348
iteration=14999, loss=1.8797768354415894
In [48]:
plt.plot(x, y, "o", label="data")
plt.plot(x, y_pred.detach(), "o", label="predictions")
plt.plot(x, y0, lw=2, label="ground truth")
plt.xlabel("x", fontsize=18)
plt.legend(fontsize=12, loc='upper right')
plt.show()
No description has been provided for this image

En jouant sur la taille du réseau, on voit qu'il est plus ou moins facile de capturer la haute fréquence. En augmentant la taille du réseau on le rend plus expressif, et donc plus à même de capturer la haute fréquence.

f) 6ème exemple (sur apprentissage): Même chose pour la fonction $x\mapsto \sin(\pi x)$ sur l'intervalle $[-1,1]$, mais cette fois on prend volontairement un grand réseau (5 couches par exemples).¶

Génération des données¶

In [49]:
x = np.linspace(-1,1,40)
In [50]:
y0 = np.sin(pi*x)
noise = 0.2 * np.random.randn(len(x))
y = y0 + noise
In [51]:
x_data = torch.tensor(x, dtype=torch.float32).reshape(-1,1)
y_data = torch.tensor(y, dtype=torch.float32).reshape(-1,1)
In [52]:
learning_rate = 1e-3
n_epoch = 5000    

Remarque: On peut jouer sur le nombre d'epoch pour eviter le sur apprentissage.¶

In [53]:
model1 = nn.Sequential(
    nn.Linear(1,8),
    nn.Tanh(),
    nn.Linear(8,1)
)
In [54]:
model2 = nn.Sequential(
    nn.Linear(1,32),
    nn.Tanh(),
    nn.Linear(32,128),
    nn.Tanh(),
    nn.Linear(128,256),
    nn.Tanh(),
    nn.Linear(256,128),
    nn.Tanh(),
    nn.Linear(128,32),
    nn.Tanh(),
    nn.Linear(32,1)
)
In [55]:
loss_fct = torch.nn.MSELoss(reduction='sum')

optimizer1 = torch.optim.Adam(model1.parameters(), lr=learning_rate)
optimizer2 = torch.optim.Adam(model2.parameters(), lr=learning_rate)
In [56]:
for t in range(n_epoch):
    
    y_pred1 = model1(x_data)
    loss = loss_fct(y_pred1, y_data)
    
    if t%1000 == 0:
        print("iteration=%s, loss=%s" % (t,loss.item()))
        
    # On met à zéro les gradients avant de les calculer.
    optimizer1.zero_grad()
    
    # On calcul les gradients
    loss.backward() 
    
    # On met à jour les paramètres
    optimizer1.step()

print("iteration=%s, loss=%s" % (t,loss.item()))
iteration=0, loss=28.613441467285156
iteration=1000, loss=7.465003967285156
iteration=2000, loss=3.9898436069488525
iteration=3000, loss=1.684462547302246
iteration=4000, loss=1.3863723278045654
iteration=4999, loss=1.3202569484710693
In [57]:
for t in range(n_epoch):
    
    y_pred2 = model2(x_data)
    loss = loss_fct(y_pred2, y_data)
    loss_plot[t] = loss
    
    if t%1000 == 0:
        print("iteration=%s, loss=%s" % (t,loss.item()))
        
    # On met à zéro les gradients avant de les calculer.
    optimizer2.zero_grad()
    
    # On calcul les gradients
    loss.backward() 
    
    # On met à jour les paramètres
    optimizer2.step()

print("iteration=%s, loss=%s" % (t,loss.item()))
iteration=0, loss=19.408687591552734
iteration=1000, loss=0.273794025182724
iteration=2000, loss=0.035442329943180084
iteration=3000, loss=0.00011954765068367124
iteration=4000, loss=0.019675984978675842
iteration=4999, loss=6.75494156894274e-05
In [58]:
plt.plot(x, y, "o", label="data")
plt.plot(x, y_pred1.detach(), label="model 1")
plt.plot(x, y_pred2.detach(), label="model 2")
plt.plot(x, y0, lw=2, label="ground truth")
plt.xlabel("x", fontsize=18)
plt.legend(fontsize=12)
plt.show()
No description has been provided for this image

Jouer avec les différent parametres (architecture du réseau, learning rate, algorithme d'optimisation, niveau de bruit dans les données et taille des données) et voir comment cela se comporte.¶

Essayer avec d'autres fonctions que sin, et aussi à deux variables ($(x,y)\mapsto \sin(2xy)$ par exemple).¶

2) Implémentation du MLP¶

Ecrire un modèle sous la forme suivante n'est pas forcement très pratique pour tester de manière automatique différentes architecture de reseau:

In [59]:
model = nn.Sequential(
    nn.Linear(1,8),
    nn.Tanh(),
    nn.Linear(8,1)
)

Comme dans la correction du TP1, on peut implémenter un réseau à l'aide d'une sous-classe.

In [60]:
class MLP(nn.Module):
    def __init__(self):
        super(MLP).__init__()
        
        self.layer = nn.Sequential(
        nn.Linear(1,8),
        nn.Tanh(),
        nn.Linear(8,1)
        )
        
    def forward(self,x):
        return self.layer(x)

Cette écriture n'apporte rien par rapport à la précédente, mais on peut donner des arguments à une classe, comme l'architecture du raiseau et les fonctions d'activation.

In [61]:
class MLP(nn.Module):
    def __init__(self, layers, activations):
        """
        Parameters:
        - layers: List of integer, the number of neurons in each layer (including input and output layers).
        - activations: List of the activation functions for each layer.
          The length of activations should be len(layers) - 2 (one for each hidden layer).
        """
        super(MLP, self).__init__()
        
        if len(activations) != len(layers) - 2:
            raise ValueError("The number of activation functions must match the number of hidden layers.")

        # Build the network
        self.layers = nn.Sequential()
        for i in range(len(layers) - 1):
            self.layers.add_module(f"linear_{i}", nn.Linear(layers[i], layers[i + 1]))
            if i < len(activations):  # Apply activation to all but the last layer
                self.layers.add_module(f"activation_{i}", activations[i])

    def forward(self, x):
        return self.layers(x)

Utilisons cette classe sur l'exemple e) précédent avec $x\mapsto \sin(10\pi x)$.

In [62]:
layers = [1, 32, 64, 32, 1]
activations = [nn.Tanh(), nn.Tanh(), nn.Tanh()]
In [63]:
model = MLP(layers, activations)

génération des données¶

In [64]:
x = np.linspace(-1, 1, 200)
In [65]:
y0 = np.sin(10*pi*x)
noise = 0.2 * np.random.randn(len(x))
y = y0 + noise
In [66]:
x_data = torch.tensor(x, dtype=torch.float32).reshape(-1,1)
y_data = torch.tensor(y, dtype=torch.float32).reshape(-1,1)
In [67]:
learning_rate = 1e-3
n_epoch = 15000
In [68]:
loss_fct = torch.nn.MSELoss(reduction='sum')
optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)
In [69]:
for t in range(n_epoch):
    
    y_pred = model(x_data)
    loss = loss_fct(y_pred, y_data)
    
    if t%1000 == 0:
        print("iteration=%s, loss=%s" % (t,loss.item()))
        
    # On met à zéro les gradients avant de les calculer.
    optimizer.zero_grad()
    
    # On calcul les gradients
    loss.backward() 
    
    # On met à jour les paramètres
    optimizer.step()

print("iteration=%s, loss=%s" % (t,loss.item()))
iteration=0, loss=107.62730407714844
iteration=1000, loss=102.43451690673828
iteration=2000, loss=75.59066772460938
iteration=3000, loss=50.018104553222656
iteration=4000, loss=41.405494689941406
iteration=5000, loss=32.99094009399414
iteration=6000, loss=29.72494888305664
iteration=7000, loss=27.3754825592041
iteration=8000, loss=23.03763198852539
iteration=9000, loss=16.297521591186523
iteration=10000, loss=6.425240993499756
iteration=11000, loss=5.968374252319336
iteration=12000, loss=5.7656354904174805
iteration=13000, loss=5.539782524108887
iteration=14000, loss=5.236714839935303
iteration=14999, loss=4.771312236785889
In [70]:
plt.plot(x, y, "o", label="données")
plt.plot(x, y_pred.detach(), label="prédictions")
plt.plot(x, y0, lw=2, label="ground truth")
plt.xlabel("x", fontsize=18)
plt.legend(fontsize=12)
plt.show()
No description has been provided for this image