Réseau entièrement connecté#

Architecture du réseau#

Dans les cours précédents (cours 2), nous avons construit des réseaux de neurones entièrement connectés pour des problèmes de classification. Ici, nous traitons un problème de prédiction avec des données discrètes.

Inspiration du modèle#

Le réseau présenté dans ce notebook s’inspire de l’article “A Neural Probabilistic Language Model”.

Voici l’architecture de ce réseau :

Bengio

Figure extraite de l’article original.

Dans l’article, le modèle utilise trois mots en entrée pour prédire le mot suivant. Dans notre cas, nous allons utiliser des caractères, comme dans le notebook précédent.

Matrice d’embedding \(C\) : On observe que le réseau contient une matrice \(C\) qui encode les mots (ou caractères) dans un espace latent. Cette pratique est courante en NLP car elle rapproche les mots similaires dans cet espace. Par exemple, dans la plupart des phrases, on peut interchanger “chien” et “chat”, ce qui signifie que ces mots auront une représentation proche dans l’espace latent, contrairement à “chien” et “est”.

Reste du réseau : Le reste du réseau est plus classique. Il prend en entrée la concaténation des embeddings des différents mots (ou caractères) et prédit un mot (ou caractère) en sortie.

Le modèle de l’article est entraîné par minimisation du log-vraisemblance négative (comme nous l’avons fait dans le notebook précédent avec le modèle bigramme).

Notre approche#

Dans l’article, ils utilisent trois mots pour prédire le quatrième mot. Nous allons appliquer le même principe et prédire le quatrième caractère à partir des trois caractères précédents. La dimension de l’espace latent utilisé dans l’article est de 30 pour un dictionnaire contenant 17 000 mots distincts. Comme nous avons 46 caractères, nous choisirons une dimension d’embedding de 10 de manière arbitraire.

Implémentation du réseau#

Commençons par reconstruire nos listes stoi et itos du notebook précédent :

import torch
import torch.nn.functional as F
import matplotlib.pyplot as plt
from torch.utils.data import TensorDataset, DataLoader, random_split
%matplotlib inline
words = open('prenoms.txt', 'r').read().splitlines()
chars = sorted(list(set(''.join(words))))
stoi = {s:i+1 for i,s in enumerate(chars)}
stoi['.'] = 0
itos = {i:s for s,i in stoi.items()}

Création du dataset et du dataloader#

Construisons notre dataset, qui diffère légèrement car les entrées seront au nombre de trois au lieu d’une.

block_size = 3 # La longueur du contexte, combien de caractères pour prédire le suivant ?
X, Y = [], []
for k,w in enumerate(words):
  context = [0] * block_size
  for ch in w + '.':
    ix = stoi[ch]
    X.append(context)
    Y.append(ix)
    if (k<2): ## On affiche ce à quoi ressemble le dataset pour les deux premiers mots
      print(''.join(itos[i] for i in context), '--->', itos[ix])
    context = context[1:] + [ix] # crop and append
... ---> M
..M ---> A
.MA ---> R
MAR ---> I
ARI ---> E
RIE ---> .
... ---> J
..J ---> E
.JE ---> A
JEA ---> N
EAN ---> .
X = torch.tensor(X)
Y = torch.tensor(Y)
print(X.shape, X.dtype, Y.shape, Y.dtype)
torch.Size([226325, 3]) torch.int64 torch.Size([226325]) torch.int64

Nous allons maintenant utiliser PyTorch pour construire nos datasets d’entraînement, de validation et de test.

dataset=TensorDataset(X, Y)
train_size = int(0.8 * len(dataset))
val_size = int(0.1 * len(dataset))
test_size = len(dataset) - train_size - val_size
train_dataset, val_dataset, test_dataset = random_split(TensorDataset(X, Y),[train_size, val_size, test_size])
print("Taille du dataset de training : ",len(train_dataset))
print("Taille du dataset de validation : ",len(val_dataset))
print("Taille du dataset de test : ",len(test_dataset))
Taille du dataset de training :  181060
Taille du dataset de validation :  22632
Taille du dataset de test :  22633

Nous allons également créer nos dataloaders pour l’optimisation par mini-lots.

train_loader = DataLoader(train_dataset, batch_size=256, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=256, shuffle=False)
test_loader = DataLoader(test_dataset, batch_size=256, shuffle=False)

Couches du réseau#

Pour bien comprendre le réseau que nous construisons, nous n’utiliserons pas les fonctions nn.Linear() de PyTorch pour créer les couches. Nous allons d’abord définir le nombre de neurones pour chaque couche.

embed_dim=10 # Dimension de l'embedding de C
hidden_dim=200 # Dimension de la couche cachée

Construisons notre matrice \(C\) d’embedding (avec des paramètres apprenables).

C = torch.randn((46, embed_dim))
C[X].shape
torch.Size([226325, 3, 10])

En appelant C[X], grâce à l’indexation de PyTorch (lien), on obtient les valeurs d’embedding de chacun des trois caractères de nos 226 325 exemples.

Nous pouvons maintenant créer nos couches cachées \(W_1\) et \(W_2\) ainsi que leurs biais \(b_1\) et \(b_2\).

W1 = torch.randn((block_size*embed_dim, hidden_dim))
b1 = torch.randn(hidden_dim)
W2 = torch.randn((hidden_dim, 46))
b2 = torch.randn(46)
parameters = [C, W1, b1, W2, b2]
print("Nombre de paramètres du modèle : ",sum(p.nelement() for p in parameters))
Nombre de paramètres du modèle :  15906

Pour entraîner ces couches, nous devons activer le paramètre requires_grad de PyTorch. Cela permet de spécifier que nous voulons calculer les gradients pour ces éléments.

for p in parameters:
  p.requires_grad = True

Comment choisir le bon taux d’apprentissage ?#

Le choix du taux d’apprentissage est crucial lors de l’entraînement d’un réseau de neurones et il est souvent difficile de déterminer la bonne valeur sans tests préalables. Une bonne méthode pour choisir le taux d’apprentissage consiste à :

  • Créer une liste de 1000 valeurs entre -3 et 0

  • Prendre \(10^{valeur}\) pour chaque valeur Cela nous donne une liste de valeurs entre \(10^{-3} = 0.001\) et \(10^{0} = 1\), qui sont des candidats potentiels pour le taux d’apprentissage. Les valeurs -3 et 0 peuvent varier, vous devez essayer de trouver des valeurs encadrant le taux d’apprentissage optimal.

lre = torch.linspace(-3, 0, 1000)
lrs = 10**lre

Ensuite, nous allons suivre les valeurs de perte en fonction du taux d’apprentissage pour l’ensemble des valeurs d’entraînement.

lri = []
lossi = []
count=0
while count<999:
  for x,y in train_loader:
    count+=1
    if count==999:
        break
    # forward pass
    emb = C[x]
    h = torch.tanh(emb.view(-1, block_size*embed_dim) @ W1 + b1)
    logits = h @ W2 + b2 
    loss = F.cross_entropy(logits, y)
    
    # retropropagation
    for p in parameters:
        p.grad = None
    loss.backward()
    
    # Mise à jour des poids du modèle
    lr = lrs[count]
    for p in parameters:
        p.data += -lr * p.grad

    lri.append(lre[count])
    lossi.append(loss.log10().item())
plt.plot(lri, lossi)
[<matplotlib.lines.Line2D at 0x72e6c044e590>]
../_images/d87f5c2e2164363a0ee2d2129f191939e3e2ad1c239a539830b13cb9b7bec15f.png

Cette courbe montre qu’une bonne valeur de taux d’apprentissage se situe entre \(10^{-1}\) et \(10^{-0.5}\). Nous choisirons donc un taux d’apprentissage de 0.2, que nous diminuerons au cours de l’entraînement (une pratique courante pour une convergence rapide et une optimisation précise en fin d’entraînement).

La fonction tangente hyperbolique#

Dans notre optimisation, nous avons utilisé la fonction tangente hyperbolique comme fonction d’activation. Elle est définie comme suit : \(\tanh(x) = \frac{\sinh(x)}{\cosh(x)} = \frac{e^x - e^{-x}}{e^x + e^{-x}}\) On peut la visualiser en Python :

import numpy as np
x = np.linspace(-10, 10, 400)

y = np.tanh(x)

plt.figure(figsize=(4, 3))
plt.plot(x, y, label='tanh(x)')
plt.title('Tangente Hyperbolique')
plt.xlabel('x')
plt.ylabel('tanh(x)')
plt.grid(True)
plt.legend()
plt.show()
../_images/e56adb0b5d58615b7885cd5030095fbf538c1b71b28c8b39f355df08a6a0f512.png

En général, dans les couches cachées de notre réseau, nous privilégions l’utilisation de la fonction tanh plutôt que de la fonction sigmoïde pour plusieurs raisons :

  • La plage de sortie centrée sur zéro (-1 à 1) facilite l’apprentissage.

  • Les gradients sont plus importants pour des valeurs entre -2 et 2 que pour la fonction sigmoïde.

  • Ces deux points contribuent à réduire le problème de gradient évanescent et permettent une convergence plus rapide lors de l’entraînement.

Optimisation du réseau#

Passons maintenant à l’optimisation de notre réseau. Définissons nos hyperparamètres :

lr=0.2
epochs=100

# Reinitialisons les paramètres pour plus de simplicité si on a besoin de relancer l'entraînement
C = torch.randn((46, embed_dim))
W1 = torch.randn((block_size*embed_dim, hidden_dim))
b1 = torch.randn(hidden_dim)
W2 = torch.randn((hidden_dim, 46))
b2 = torch.randn(46)
parameters = [C, W1, b1, W2, b2]
for p in parameters:
  p.requires_grad = True
lossi=[]
lossvali=[]
stepi = []
for epoch in range(epochs):
  loss_epoch=0
  for x,y in train_loader:
    
    # forward pass
    emb = C[x]
    h = torch.tanh(emb.view(-1, block_size*embed_dim) @ W1 + b1)
    logits = h @ W2 + b2 
    loss = F.cross_entropy(logits, y)
    
    # retropropagation
    for p in parameters:
        p.grad = None
    loss.backward()
    
    # Mise à jour des poids du modèle
    lr=lr if epoch<50 else lr*0.1
    for p in parameters:
        p.data += -lr * p.grad
    loss_epoch+=loss

  loss_epoch=loss_epoch/len(train_loader)
  stepi.append(epoch)
  lossi.append(loss_epoch.item())
  # Calcul du loss de validation (pour surveiller l'overfitting)
  loss_val=0
  for x,y in val_loader:
    emb = C[x]
    h = torch.tanh(emb.view(-1, block_size*embed_dim) @ W1 + b1)
    logits = h @ W2 + b2 
    loss = F.cross_entropy(logits, y)
    loss_val+=loss
  loss_val=loss_val/len(val_loader)
  lossvali.append(loss_val.item())
  if epoch%10==0:
    print(f"Epoch {epoch} - Training loss: {loss_epoch.item():.3f}, Validation loss: {loss_val.item():.3f}")
Epoch 0 - Training loss: 5.273, Validation loss: 3.519
Epoch 10 - Training loss: 2.424, Validation loss: 2.594
Epoch 20 - Training loss: 2.337, Validation loss: 2.421
Epoch 30 - Training loss: 2.289, Validation loss: 2.468
Epoch 40 - Training loss: 2.259, Validation loss: 2.424
Epoch 50 - Training loss: 2.327, Validation loss: 2.372
Epoch 60 - Training loss: 2.326, Validation loss: 2.372
Epoch 70 - Training loss: 2.326, Validation loss: 2.372
Epoch 80 - Training loss: 2.326, Validation loss: 2.372
Epoch 90 - Training loss: 2.326, Validation loss: 2.372

Traçons les courbes d’entraînement et de validation.

plt.plot(stepi, lossi)
plt.plot(stepi,lossvali)
[<matplotlib.lines.Line2D at 0x72e6a443edd0>]
../_images/23414160c30529c2b749e1a1a3cade0ef88dfac42e913362334cc5209978e6e6.png

Test du modèle#

Maintenant que le modèle est entraîné, nous allons vérifier ses performances sur les données de test. Si la perte sur les données de test est similaire à celle de l’entraînement, alors le modèle est bien entraîné. Sinon, il peut y avoir un surapprentissage (overfitting).

# On annule le calcul des gradients car on n'est plus en phase d'entraînement.
for p in parameters:
  p.requires_grad = False
loss_test=0
for x,y in test_loader:
      
  # forward pass
  emb = C[x]
  h = torch.tanh(emb.view(-1, 30) @ W1 + b1)
  logits = h @ W2 + b2 
  loss = F.cross_entropy(logits, y)

  loss_test+=loss
loss_test=loss_test/len(test_loader)
print(loss_test)
tensor(2.3505)

La vraisemblance sur les données de test est relativement proche de celle des données d’entraînement, ce qui montre que l’entraînement s’est bien déroulé.

On observe que la valeur du log-vraisemblance négative de notre modèle est inférieure à celle du modèle bigramme du notebook précédent (\(2.3 < 2.5\)). La qualité des prénoms générés devrait donc être améliorée.

Génération de prénoms avec notre modèle#

Générons une vingtaine de prénoms pour évaluer nous-mêmes la qualité de la génération.

for _ in range(20):
  out = []
  context = [0] * block_size 
  while True:
    emb = C[torch.tensor([context])] 
    h = torch.tanh(emb.view(1, -1) @ W1 + b1)
    logits = h @ W2 + b2
    probs = F.softmax(logits, dim=1)
    ix = torch.multinomial(probs, num_samples=1).item()
    context = context[1:] + [ix]
    out.append(ix)
    if ix == 0:
      break
  
  print(''.join(itos[i] for i in out))
JAÏMANT.
SONELIUWAN.
LYPHELSÏL.
DJELINATHEYMONDALYANE.
ERNANDRAN.
ESMALLOONIS.
ASHAMLANCHOND.
ANNAE.
CHALLA.
ETTE.
ASSANE.
MARIANE.
FIHAYLAY.
SHANA.
ALPHENELIESON.
ESÏL.
EVEY.
YSLALLYSSIA.
ETHELDOF.
KELLAH.

Les prénoms générés sont encore étranges, mais ils ressemblent déjà beaucoup plus à des prénoms “possibles” comparés à ceux produits par le modèle bigramme.

Exercice : Essayez de modifier le nombre de neurones des couches ou les hyperparamètres pour améliorer le modèle et observer la différence dans la qualité de génération.

Visualisation des embeddings#

Plus tôt dans le notebook, nous avons expliqué l’intuition derrière la matrice d’embedding \(C\), qui permet de rapprocher les mots (ou caractères) ayant un sens proche. Il n’est pas facile de visualiser la position de chaque caractère dans la matrice \(C\). Pour y parvenir, nous allons réentraîner un modèle avec une dimension d’embedding de 2 au lieu de 10. Cela nous permettra de visualiser la matrice \(C\).

Note : Pour visualiser les embeddings de dimension supérieure à 2 en 2D, on peut utiliser la méthode T-SNE ou UMAP.

lr=0.2
epochs=100

C = torch.randn((46, 2)) # 2 au lieu de embed_dim
W1 = torch.randn((block_size*2, hidden_dim))
b1 = torch.randn(hidden_dim)
W2 = torch.randn((hidden_dim, 46))
b2 = torch.randn(46)
parameters = [C, W1, b1, W2, b2]
for p in parameters:
  p.requires_grad = True
lossi=[]
stepi = []
for epoch in range(epochs):
  loss_epoch=0
  for x,y in train_loader:
    # forward pass
    emb = C[x]
    h = torch.tanh(emb.view(-1, 6) @ W1 + b1) #6 au lieu de 30
    logits = h @ W2 + b2 
    loss = F.cross_entropy(logits, y)
    # retropropagation
    for p in parameters:
        p.grad = None
    loss.backward()
    # Mise à jour des poids du modèle
    lr=lr if epoch<50 else lr*0.1
    for p in parameters:
        p.data += -lr * p.grad
    loss_epoch+=loss
  loss_epoch=loss_epoch/len(train_loader)
  stepi.append(epoch)
  lossi.append(loss_epoch.item())
  
  # Validation
  loss_val=0
  for x,y in val_loader:
    emb = C[x]
    h = torch.tanh(emb.view(-1, 6) @ W1 + b1) #6 au lieu de 30
    logits = h @ W2 + b2 
    loss = F.cross_entropy(logits, y)
    loss_val+=loss
  loss_val=loss_val/len(val_loader)
  lossvali.append(loss_val.item())
  if epoch%10==0:
    print(f"Epoch {epoch} - Training loss: {loss_epoch.item():.3f}, Validation loss: {loss_val.item():.3f}")
Epoch 0 - Training loss: 3.822, Validation loss: 3.294
Epoch 10 - Training loss: 2.490, Validation loss: 2.616
Epoch 20 - Training loss: 2.425, Validation loss: 2.532
Epoch 30 - Training loss: 2.388, Validation loss: 2.498
Epoch 40 - Training loss: 2.365, Validation loss: 2.529
Epoch 50 - Training loss: 2.386, Validation loss: 2.399
Epoch 60 - Training loss: 2.385, Validation loss: 2.399
Epoch 70 - Training loss: 2.386, Validation loss: 2.399
Epoch 80 - Training loss: 2.385, Validation loss: 2.399
Epoch 90 - Training loss: 2.385, Validation loss: 2.399

Comme vous pouvez le voir, la perte est plus élevée car une dimension d’embedding de 2 est insuffisante pour représenter correctement chaque caractère. En revanche, nous pouvons maintenant visualiser la position des caractères dans l’espace latent.

# visualize dimensions 0 and 1 of the embedding matrix C for all characters
plt.figure(figsize=(8,8))
plt.scatter(C[:,0].data, C[:,1].data, s=200)
for i in range(C.shape[0]):
  plt.text(C[i,0].item(), C[i,1].item(), itos[i], ha="center", va="center", color='white')
plt.grid('minor')
../_images/91dd4c81a795a62bb6459bc5ec052888b92875155dd17e178e88bdb5321e2394.png

On observe une tendance avec un regroupement des voyelles et des consonnes (souvent interchangeables dans un prénom). Les caractères rares ont des embeddings distincts (‘ç’, ‘ö’, ‘ë’). On remarque aussi la proximité entre ‘.’ et ‘-’ ce qui est logique pour un prénom composé en français. Cela montre que la matrice \(C\) a appris une sorte de mapping des caractères en fonction de leur proximité sémantique.