Travaux pratiques : introduction aux modèles génératifs

L’objectif de cette séance de TP est de réaliser un bref tour d’horizon de trois types de modèles génératifs simples:

  • modèles de mélanges gaussiens,

  • chaînes de Markov,

  • autoencodeurs.

La séance est divisée en 3 exercices indépendants pouvant être traités dans le désordre.

Exercice 1 : Modèles de mélanges gaussiens

Ce premier exercice est une application directe des modèles de mélanges gaussiens. L’objectif est d’illustrer le fonctionnement de l’échantillonnage de nouvelles données à partir de la densité estimée.

import matplotlib as mpl
import matplotlib.pyplot as plt
import numpy as np

Pour cet exercice, nous allons travailler sur un nuage de points synthétique qui est généré à partir d’une sinusoïde. Les observations \(X\) sont bi-dimensionnelles avec:

\[x_2 = \sin(x_1) + \epsilon\]

avec:

  • \(x_1\) une variable aléatoire tirée uniformément dans \([0, 10]\),

  • \(\epsilon\) un bruit aléatoire tiré d’une loi normale \(\mathcal{N}(0, 0.1)\).

n_samples = 300
x1 = 10 * np.random.rand(n_samples)
x2 = np.sin(x1) + 0.1*np.random.randn(n_samples)
X = np.vstack((x1, x2))
X = np.transpose(X, (1, 0))

# Affichage du nuage de point
plt.scatter(X[:,0], X[:,1], s=10)
plt.title
plt.show()

Question

Appliquer un modèle de mélange gaussien à la matrice d’observations \(X\). On choisira de façon empirique un nombre de composantes qui semble adapté au nuage de points.

def make_ellipses(gmm, ax):
    colors = ['navy', 'firebrick', 'darkorange', 'dimgrey', 'forestgreen',
              'purple', 'turquoise', 'yellow', 'hotpink', 'springgreen']

    for n, color in enumerate(colors[:gmm.n_components]):
        if gmm.covariance_type == 'full':
            covariances = gmm.covariances_[n][:2, :2]
        elif gmm.covariance_type == 'tied':
            covariances = gmm.covariances_[:2, :2]
        elif gmm.covariance_type == 'diag':
            covariances = np.diag(gmm.covariances_[n][:2])
        elif gmm.covariance_type == 'spherical':
            covariances = np.eye(gmm.means_.shape[1]) * gmm.covariances_[n]
        v, w = np.linalg.eigh(covariances)
        u = w[0] / np.linalg.norm(w[0])
        angle = np.arctan2(u[1], u[0])
        angle = 180 * angle / np.pi  # convert to degrees
        v = 2. * np.sqrt(2.) * np.sqrt(v)
        ell = mpl.patches.Ellipse(gmm.means_[n, :2], v[0], v[1],
                                  180 + angle, color=color)
        ell.set_clip_box(ax.bbox)
        ell.set_alpha(0.5)
        ax.add_artist(ell)
        ax.text(*(gmm.means_[n, :2]+0.1), str(n), fontsize=14)

Le code ci-dessous permet d’afficher les nuage de points et les ellipses correspondants aux gaussiennes (centrées sur la moyenne et d’axes égaux aux covariances) en surimpression.

Note: il n’est pas indispensable de comprendre les détails de la fonction make_ellipses pour ce TP, celle-ci faisant appel à des fonctionnalités avancées de la bibliothèque graphique matplotlib.

ax = plt.axes()
make_ellipses(gmm, ax)
ax.scatter(X[:, 0], X[:, 1], s=3)
ax.set_xlim(0, 10)
ax.set_ylim((-1.5, 1.5))
plt.tight_layout()
plt.show()

Question

Tirer aléatoirement 200 points dans le modèle de mélange. Afficher le résultat.

Indice: utiliser une des méthodes de l’objet GaussianMixture.

Question

Tirer aléatoirement 100 points dans la première composante du mélange. Afficher le résultat.

Indice: la moyenne et la matrice de variance-covariance de la composante numéro \(i\) du mélange sont stockées respectivement dans gmm.means_[i] et gmm.covariances_[i]. La fonction multivariate_normal de NumPy peut être utile.

Exercice 2 : Autoencodeurs et génération d’images

Cet exercice présente l’utilisation des autoencodeurs avec PyTorch. Nous allons utiliser un modèle simple, entièrement connecté, permettant de compresser une image en une représentation vectorielle.

Cet exercice utilise MNIST (ou FashionMNIST) comme jeu de données.

# Imports liés à torch
import torch
import torchvision
import torch.nn as nn
import torch.nn.functional as F

# Matplotlib
import matplotlib.pyplot as plt

from tqdm.notebook import tqdm, trange
mnist = torchvision.datasets.FashionMNIST("data/", download=True, transform=torchvision.transforms.ToTensor())

Question

Visualiser les premières images du jeu de données.

Question

En utilisant l’interface nn.Sequential de PyTorch, écrire le code qui définit un modèle autoencodeur entièrement connecté.

Ce modèle prend en entrée une image de dimensions \(28\times28\) sous forme d’un vecteur aplati de longeur \(784\). On définira une variable hidden_state qui permet de contrôler la taille du code \(z\) en sortie de l’encodeur.

Le décodeur devra prendre en entrée un code \(z\) de longueur hidden_state et produire un vecteur aplati de longueur \(28\times28 = 784\) (identique à l’image). On choisira une valeur raisonnable pour la dimension du code (par exemple, entre 30 et 250).

Question

Y a-t-il un intérêt à utiliser une fonction de non-linéarité en sortie du décodeur? Justifier.

def train(model, dataset, epochs=20, device="cpu"):
    dataloader = torch.utils.data.DataLoader(dataset, batch_size=64, shuffle=True, num_workers=1)
    criterion = nn.L1Loss()
    optimizer = torch.optim.Adam(model.parameters())
    flatten = nn.Flatten()
    model = model.to(device)
    model.train()

    t = trange(1, epochs + 1, desc="Entraînement du modèle")
    for e in t:
        avg_loss = 0.
        for batch, _ in tqdm(dataloader, leave=False):
            batch = batch.to(device)
            model.zero_grad()
            batch = flatten(batch)
            reconstruction = model(batch)
            loss = criterion(reconstruction, batch)
            loss.backward()
            optimizer.step()
            avg_loss += loss.item()
        avg_loss /= len(dataloader)
        t.set_description(f"Epoch {e}: loss = {avg_loss:.3f}")
    return model

Question

Pourquoi privilégie-t-on dans ce cas précis la fonction de perte \(L_1\) (mean absolute error) plutôt que de la fonction de perte \(L_2\) (mean squared error) ?

Question

En utilisant la fonction train() définie ci-dessus, réaliser l’apprentissage de l’autoencodeur défini précédemment.

Question

Pour quelques images du jeu de données, visualiser l’image originale et sa reconstruction.

Question

Choisir deux images du jeu de données de classes différentes. Calculer leurs codes \(z_1\) et \(z_2\) à l’aide de l’encodeur. Afficher la reconstruction du code moyen \(0,5 z_1 + 0,5 z_2\). Que constatez-vous ?

Question

Afficher les résultats des reconstructions de l’interpolation entre \(z_1\) et \(z_2\) pour différentes valeurs du facteur d’interpolation \(\alpha\).

On remarque que l’interpolation donne des résultats peu plausibles. Lorsque l’on s’éloigne des petites valeurs de \(\alpha\). L’espace latent de l’auto-encodeur n’est pas aussi régulier qu’espéré.

Question

Construire un nouveau jeu de données contenant:

  • une matrice codes, qui contient pour chaque ligne \(i\) le code \(z_i\) obtenu en encodant l’image \(i\) du jeu de données mnist,

  • une liste labels dont l’élément \(i\) représente l’étiquette de l’image \(i\) du jeu de données mnist.

Appliquer une réduction de dimension non-linéaire par l’algorithme t-SNE sur cette matrice de codes de sorte à pouvoir afficher un nuage de points dans le plan. On prendra soin de colorer chaque point en fonction de son étiquette.

Que constatez-vous ?

Question

Approfondissement (ne faites cette question qu’à la fin si vous avez encore du temps)

Réaliser la même visualisation par t-SNE en utilisant non pas le code intermédiaire mais directement l’image aplatie. Que constatez-vous? Pourquoi ne peut-on pas utiliser cette approche dans le cas général?

Exercice 3 : chaîne de Markov pour la génération de texte

L’objectif de cet exercice est d’implémenter en Python pur une chaîne de Markov simple. Rappelons qu’une chaîne de Markov représente une séquence \((X_t)\) où l’on s’intéresse à modéliser les probabilités de transitions qui permettent de passer d’un état \(i\) à un état \(j\), c’est-à-dire à la matrice:

\[p_{i,j} = P(X_{t+1} = j | X_t = i)\]

Pour cet exercice, nous allons utiliser un corpus textuel correspondant à la traduction française du roman Frankenstein ou le Prométhée moderne de l’écrivaine Mary Shelley (1797-1851).

La modélisation du problème est la suivante:

  • un mot représente un état \(i\) de la chaîne,

  • on considère la fréquence des bi-grammes de mots comme probabilités de transition.

Pour être plus précis, on considérera que l’ensemble \(E\) des états possibles correspond à un dictionnaire (fini) de mots. Pour chaque bi-gramme, c’est-à-dire chaque paire de mots \((X_t = i, X_{t+1} = j)\), nous allons calculer son nombre d’occurrences \(N_{i,j}\).

Pour un mot donné \(i\), sa probabilité de transition vers le mot suivant \(j\) correspond à la probabilité d’occurrence du bigramme \((i,j)\), c’est-à-dire :

\[p_{i,j} = \frac{N_{i,j}}{\sum_k N_{i,k}}\]

Observons que le cas des chaînes de Markov d’ordre 2 et supérieur correspond à s’intéresser aux n-grammes pour \(n>2\).

Note: les mots du dictionnaire \(E\) sont à prendre au sens large. En particulier, on considérera que les symboles de ponctuation (?, !, …, etc.) constituent des mots à part entière dans le dictionnaire.

Afin de débuter cet exercice, commençons par examiner le corpus et créer le dictionnaire d’états \(E\). Pour ce faire, nous transformer la chaîne de caractères qui constitue l’intégralité du texte en une séquence de mots. Cette opération s’appelle la tokenization (ou segmentation). Elle s’appuie sur des règles de grammaire propres à chaque langage.

Note: Le fonctionnement précis de ce processus est hors-sujet pour ce cours, se référer au module de traitement automatique du langage de RCP217 pour plus de détails.

Nous allons utiliser la bibliothèque de traitement du langage nltk pour réaliser la tokenization:

import numpy as np
from nltk.tokenize import word_tokenize
import nltk
nltk.download('punkt')
import requests
# Téléchargement du fichier
r = requests.get("https://cedric.cnam.fr/vertigo/Cours/RCP211/data/frankenstein.txt")
with open("frankenstein.txt", "wb") as f:
    f.write(r.content)
with open("frankenstein.txt") as f:
    corpus = word_tokenize(f.read(), language='french')

Nous pouvons désormais afficher le début du corpus:

" ".join(corpus[:50])

Question

Combien de mots uniques ce corpus contient-il ?

Afin de créer une chaîne de Markov d’ordre 1 sur ces données, il nous faut produire la liste de tous les bi-grammes contenus dans le texte. Nous pouvons les générer avec la fonction suivante :

def make_pairs(corpus):
    for i in range(len(corpus) - 1):
        yield (corpus[i], corpus[i+1])

Nous allons utiliser l’algorithme suivant pour construire la matrice de transition:

Créer un dictionnaire d vide
Pour chaque paire (i, j) du corpus:
      Si d n'a pas d'entrée pour i:
          d[i] = dictionnaire vide
      Si d[i] n'a pas d'entrée pour j:
          d[i][j] = 1
      Sinon:
          d[i][j] += 1
Renvoyer d

Remarquons que cet algorithme ne construit pas exactement la matrice de transition. Elle produit un double dictionnaire (c’est-à-dire un dictionnaire dont les éléments sont également des dictionnaires) tel que d[mot1][mot2] renvoie le nombre d’occurrences du bi-gramme (mot1, mot2) dans le corpus.

Question

Écrire une fonction build_transition_matrix qui implémente cet algorithme. Les paires sont calculées à l’aide de la fonction make_pairs.

def build_transition_matrix(corpus):
    """
        Construit un double dictionnaire d tel que:
            d[mot1][mot2] = nombre d'occurrences du bi-gramme (mot1, mot2) dans le corpus

        Arguments
        ---------
        corpus (str list): une liste de mots

        Renvoie
        -------
        dict: double dictionnaire des nombres d'occurrences des bi-grammes
    """
    pairs = make_pairs(corpus)
    # à compléter

Question

Pourquoi utiliser un dictionnaire plutôt qu’une matrice (dense) de transition ?

Nous pouvons désormais calculer la “matrice de transition”:

transition_matrix = build_transition_matrix(corpus)

Question

Afficher les 10 bi-grammes les plus fréquents.

Question

Afficher les probabilités de transition pour tous les bi-grammes commençant par “une”.

Question

Écrire une fonction sample_next_word(word, transition_matrix) qui, à partir d’un mot word et de la matrice de transition tire au hasard le mot suivant en respectant les probabilités de transition \(p_{i,j}\).

Indice: on pourra utiliser à bon escient la fonction np.random.choice de NumPy.

def sample_next_word(word, transition_matrix):
    """
       Renvoie un mot au hasard complétant la séquence en suivant les probabilités de transition

       Arguments
       ---------
        - word (str): un mot (première moitié du bi-gramme)
        - transition_matrix (dict): double dictionnaire du nombre d'occurrences

       Renvoie
       -------
         - str: un mot complétant le bi-gramme
    """
    # à compléter
    ...
    return ...

Question

Utiliser cette fonction pour générer une phrase commençant par “Une”.

Question

Approfondissement (ne faites cette question que si vous avez le temps)

Réaliser le même travail mais dans le cas de la chaîne de Markov d’ordre 2 (raisonner sur des triplets plutôt que des pairs).

Indice: on pourra utiliser defaultdict pour écrire plus simplement la fonction de construction de la matrice de transition.