Initiation à Pytorch : Multilayer perceptron

La plupart de ces notebooks peuvent tourner sans acceleration GPU. Vous pouvez utiliser Google Colab ou installer jupyter-notebook sur votre ordinateur. Pytorch est déjà installé sur Colab, par contre il faut suivre https://pytorch.org/get-started/locally/ en local.

In [ ]:
## example de deployment local
# virtualenv -ppython3.7 pstaln-env
# . pstaln-env/bin/activate
# pip install torch==1.7.0+cpu -f https://download.pytorch.org/whl/torch_stable.html
# pip install matplotlib ipykernel
# python -m ipykernel install --user --name=pstaln-env
# jupyter-notebook
## puis sélectionner le noyeau pstaln-evn

On commence par importer les sous-modules de pytorch.

  • torch rassemble les fonctions de création de tenseurs et les opérations sur les tenseurs
  • torch.nn rassemble les couches de réseau de neurones
  • torch.nn.functional rassemble une version sans état des couches de NN utilisable comme des fonctions
  • torch.optim contient les optimiseurs pour l'apprentissage
  • torch.autograd contient le moteur de calcul automatique de gradient, et en particulier la classe Variable que l'on doit utiliser pour décorer tous les tenseurs impliqués dans un calcul nécessitant un gradient
In [ ]:
%matplotlib inline
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim

Nous allons générer des points dans le plan dans l'intervalle $[-1.5, 1.5]\times[-1.5,1.5]$.

La fonction torch.rand() renvoie un tenseur de taille les dimensions passées en paramètres, contenant des nombres aléatoires entre 0 et 1.

Comme dans numpy, X[:10] renvoie les 10 premières lignes du tenseur. Il existe d'autres operations pour récupérer des sous-tenseurs, ou des scalaires.

In [ ]:
n = 1000
X = torch.rand(n, 2) * 3 - 1.5

print(X[:10]) # 10 elements
print(X[0]) # first row
print(X[:,0]) # first column
print(X[0,1]) # element at a given location

X.size() ou X.shape renvoie les dimensions d'un tenseur

In [ ]:
print(X.size())
print(X.size(1))
print(X.shape)
print(X.shape[1])

Le problème que nous souhaitons résoudre est un problème simple : un point est-il dans le disque centré sur l'origine, de rayon 1 ?

Un exemple $x$ aura pour étiquette 1 si $x_0^2 + x_1^2 < 1$, 0 sinon.

Nous créons donc un tenseur Y contenant ces étiquettes. Comme une condition sur un tenseur renvoie un tenseur de type ByteTensor, nous devons le convertir en LongTensor qui est la représentation de base des étiquettes dans pytorch.

In [ ]:
Y = X[:,0] ** 2 + X[:,1] ** 2 < 1
Y = Y.long()
Y[:10]

On peut ensuite afficher le résultat avec matplotlib. plt.scatter prend pour arguments un vecteur d'abscices (la première colonne de X), un vecteur d'ordonnées (la 2ième colonne de X) et un vecteur de valeurs pour les couleurs.

In [ ]:
import matplotlib.pyplot as plt
plt.scatter(X[:,0], X[:,1], c=Y)
plt.show()

Nous allons maintenant entraîner un modèle linéaire à résoudre ce problème. Pour celà, nous devons étendre la classe nn.Module. Ce modèle contient une couche linéaire (nn.Linear) qui fait une transformation affine de ses entrées:

$y = W x + b$

Les deux paramètres du constructeur de nn.Linear sont le nombre la dimension d'un vecteur d'entrée (2 dans le plan) et la dimension du vecteur de sortie (2 étiquettes possibles). Celui-ci s'occupe d'initialiser les poids du réseau W et b de manière aléatoire.

Notre classe doit appeler le constructeur de sa classe mère pour fonctionner. Elle a une fonction forward qui est appelée lorsque l'on veut calculer la sortie du réseau pour une instance donnée.

In [ ]:
class LinearModel(nn.Module):
    def __init__(self):
        super().__init__()
        self.l1 = nn.Linear(2, 2)
    def forward(self, x):
        return self.l1(x)

# dans ce cas, model = nn.Linear(2, 2) est identique à notre modèle
model = LinearModel()
model

Une fois un modèle instancié, on peut l'appeler sur un exemple sans passer directement par forward. Il est indispensable d'inclure le tenseur d'entrée dans une Variable. C'est cette class qui s'occupe de tracer les opérations executées pour pouvoir ensuite calculer automatiquement le gradient.

Le résultat est une transformation aléatoire de l'entrée, puis les paramètres de la couche ont été initialisés aléatoirement.

In [ ]:
model(X[0])

On peut facilement récupérer la valeur des paramètres du modèle.

In [ ]:
print(model.l1.weight)
print(model.l1.bias)

Il est important qu'un modèle puisse traiter plusieurs exemples à la fois. On parle de batch. Un batch est un tenseur regroupant plusieurs exemples qui seront traités en parallèle par les différentes opérations matricielles. Les batches sont indispensables pour la vitesse d'exécution mais aussi parce qu'ils permettent de régulariser l'optimisation du gradient (on optimise la fonction en direction du gradient moyen d'un batch).

Par convention, la première dimension d'un tenseur est la taille du batch.

In [ ]:
print(X[:3])
model(X[:3])

Pour l'entrainement, nous allons devoir parcourir les données d'entraînement batch par batch, en mélangeant les exemples à chaque époque. Pytorch offre des classes qui font ce travail et gèrent le cas relativement facile où toutes les données d'entraînement sont dans un tenseur. Il existe d'autres cas où le chargement des données est moins simple.

In [ ]:
from torch.utils.data import TensorDataset, DataLoader
train_set = TensorDataset(X, Y)
train_loader = DataLoader(train_set, batch_size=4, shuffle=True)

Nous allons créer une fonction qui entraîne un modèle par descente de gradient.

Cette fonction repose sur une fonction de coût ainsi qu'un algorithme d'optimisation. Pour la fonction de coût, nous utiliserons CrossEntropyLoss qui calcule l'entropie croisée entre la distribution produite par le système et les étiquettes de référence. Dans pytorch, cette fonction inclut le softmax qui s'assure que les scores soient entre 0 et 1, donc il faut bien éviter de l'appeler en fin d'inférence dans le modèle. Nous utiliserons ici l'optimiseur Adam qui converge plus rapidement que SGD (le gradient stochastique classique) grâce à un learning rate adaptatif. Son seul désavantage est qu'il utilise plus de mémoire ce qui peut poser problème sur GPU.

La boucle d'entrainement fait plusieurs époques (passages sur toutes les données), où l'on parcourt les données batch par batch (ce que fait le trainloader).

Pour chaque batch, il faut remettre à zéro l'accumulateur de gradient, calculer les sorties du modèles, puis la fonction de loss entre les scores prédits et les étiquettes de référence.

On appelle loss.backward() pour calculer le gradient par back-propagation et optimizer.step() pour appliquer le gradient au modèle.

In [ ]:
def fit(model, epochs):
    criterion = nn.CrossEntropyLoss()
    optimizer = optim.Adam(model.parameters())
    for epoch in range(epochs):
        total_loss = 0
        num = 0
        for x, y in train_loader:
            optimizer.zero_grad()
            y_scores = model(x)
            loss = criterion(y_scores, y)
            loss.backward()
            optimizer.step()
            total_loss += loss.item()
            num += len(y)
        if epoch % (epochs // 10) == 0:
            print(epoch, total_loss / num)

Nous pouvons entraîner le modèle sur 10 époques.

In [ ]:
fit(model, 50)

On observe que le loss moyen sur les données d'entraînement descend mais converge très vite.

On peut maintenant produire un ensemble de validation indépendent X_val et générer les scores du modèle. La décision de ce modèle est l'étiquette pour laquelle ce score est maximal. La fonction torch.max, appliquée sur les lignes de la matrice X_score, renoie deux éléments : le vecteur des maximums, et le vecteur de indices des maximums. C'est ce dernier qui nous intéresse et que nous allons afficher.

Il est possible que le classifieur produise toujours la même étiquette, ce qui se traduit par une couleur de points uniformes. Sinon, l'espace doit être divisé en deux par le separateur linéaire appris par le modèle. Clairement, un modèle linéaire n'est pas capable de discriminer les points à l'interieur d'un cercle.

In [ ]:
X_val = torch.rand(n, 2) * 3 - 1.5
Y_score = model(X_val)
Y_pred = torch.max(Y_score, 1)[1]

plt.scatter(X_val[:,0], X_val[:,1], c=Y_score[:,0].data)
plt.show()

plt.scatter(X_val[:,0], X_val[:,1], c=Y_pred.data)
plt.show()

Afin d'obtenir un meilleur classifieur, nous allons maintenant faire un perceptron multicouches (MLP) avec une fonction d'activation non linéaire.

$y = W_2 tanh(W_1 x + b_1) + b_2$

Ce modèle a deux couches linéaires, la première projetant les entrées dans un espace de 10 dimensions. Elle est suivie d'une fonction d'activation $tanh$ pour rendre le modèle non linéaire (la composition de fonctions linéaires serait elle même linéaire), et d'une seconde projection dans un espace de 2 dimensions pour les deux étiquettes à prédire.

In [ ]:
class MultiLayerPerceptron(nn.Module):
    def __init__(self):
        super().__init__()
        self.l1 = nn.Linear(2, 10)
        self.l2 = nn.Linear(10, 2)
    def forward(self, x):
        return self.l2(torch.tanh(self.l1(x)))

mlp = MultiLayerPerceptron()
mlp

On peut alors entraîner ce modèle, et le loss baisse considérablement plus vite.

In [ ]:
fit(mlp, 50)

De la même manière, on peut produire les scores et les décisions pour le même ensemble de validation. La surface de décision produite est beaucoup plus pertinente.

In [ ]:
Y_score = mlp(X_val)
Y_pred = torch.max(Y_score, 1)[1]

plt.scatter(X_val[:,0], X_val[:,1], c=Y_score[:,0].data)
plt.show()

plt.scatter(X_val[:,0], X_val[:,1], c=Y_pred.data)
plt.show()

Exercice

Prenons un problème plus difficile: les points à reconnaître appartiennent maintenant à deux cercles séparés dans le plan : $ y = (2 x_0 - \frac{3}{2})^2 + x_1^2 < \frac{1}{2} \vee (2 x_0 + \frac{3}{2})^2 + x_1^2 < \frac{1}{2} $

Le MLP à deux couches est-il capable de résoudre ce problème ? Qu'en serait-il d'un MLP à trois couches ? Et que se passe-t-il si on change la fonction d'activation pour utiliser des ReLU (F.relu dans pytorch) ?

Voici le travail à effectuer (à titre indicatif) :

  1. générer des étiquettes $Y$ pour $X$ selon l'équation donnée ci-dessus (l'opérateur $\vee$ signifie un "ou" logique entre ses arguments, et peut être appliqué à deux matrices booléennes grâce à l'opérateur |), afficher ces nouvelles étiquettes
  2. recréer un DataLoader à partir des nouvelles données
  3. réinstancier un MLP et l'entraîner sur ces données
  4. afficher la surface de décision correspondante
  5. créer une nouvelle classe de modèle avec une couche supplémentaire et la tester
  6. remplacer la fonction d'activation F.tanh par F.relu
In [ ]: