Le surentraînement

Le surentraînement illustré dans un problème de classification binaire en 2D.

garbage in, garbage out in a neural network

Introduction

Le sur-entraînement (overfitting) est l'un des problèmes majeurs en machine learning, si ce n'est le plus important.

Dans ce post, on va illustrer le sur-entraînement dans le contexte d'un petit problème de classification à 2D.

Mais ce que je vais expliquer ici est valable en général, et devrait toujours être gardé à l'esprit, même lorsque l'on travaille sur des problèmes plus complexes.

Nous allons apprendre:

  • ce qu'est le sur-entraînement, et le voir en action;
  • comment l'éviter;
  • que si on utilise un réseau de neurones trop complexe pour la quantité de données dont on dispose, on obtient juste n'importe quoi;
  • que les modèles complexes sont néanmoins nécessaires pour traiter les problèmes complexes, afin d'éviter le sous-entraînement (underfitting).

Pré-requis :

Pour faire tourner ce code, vous pouvez simplement l'ouvrir dans Google Colab

Une autre possibilité, si vous avez déjà installé Anaconda (2.X ou 3.X) est de:

  • télécharger le dépot contenant ce notebook
  • le décompresser, par exemple vers Downloads/maldives-master
  • lancer un notebook jupyter depuis l'Anaconda Navigator
  • dans jupyter notebook, naviguer vers Downloads/maldives-master/overfitting
  • ouvrir overfitting.ipynb

Tout d'abord, initialisons nos outils:

In [3]:
import matplotlib.pyplot as plt
import numpy as np
%matplotlib inline
np.random.seed(0xdeadbeef)
# blah

Construction d'un petit échantillon de données

Nous allons créer un échantillon d'exemples avec deux valeurs x1 et x2, appartenant à deux catégories. Pour la catégorie 0, la densité de probabilité sous-jacente est une Gaussienne centrée sur (0,0), de largeur 1 dans les deux direction. Pour la catégorie 1, la Gaussienne est centrée sur (1,1). On assigne l'étiquette 0 à la catégorie 0, et l'étiquette 1 à la catégorie 1.

In [4]:
def make_sample(nexamples, means=([0.,0.],[1.,1.]), sigma=1.):
    normal = np.random.multivariate_normal
    # largeur au carré:
    s2 = sigma**2.
    # ci-dessous on donne les coordonnées de la moyenne 
    # de la Gaussienne en premier argument, et ensuite 
    # la matrice de covariance qui décrit sa largeur suivant
    # les deux directions. 
    # on engendre nexamples examples pour chaque catégorie.
    sgx0 = normal(means[0], [[s2, 0.], [0.,s2]], nexamples)
    sgx1 = normal(means[1], [[s2, 0.], [0.,s2]], nexamples)
    # étiquettes pour chaque catégorie
    sgy0 = np.zeros((nexamples,))
    sgy1 = np.ones((nexamples,))
    sgx = np.concatenate([sgx0,sgx1])
    sgy = np.concatenate([sgy0,sgy1])
    return sgx, sgy

Ici, nous créons un tout petit échantillon d'entraînement avec seulement 30 exemples dans chaque catégorie, et un échantillon de test avec 200 exemples par catégorie. Nous prenons un petit échantillon de test car, comme nous allons le montrer, le sur-entraînement se produit lorsque la taille de l'échantillon est trop faible.

In [5]:
sgx, sgy = make_sample(30)
tgx, tgy = make_sample(200)
In [6]:
# Notez comme les deux catégories sont tracées
# d'un seul coup en donnant le tableau d'étiquettes 
# comme argument de couleur (c=sgy)
plt.scatter(sgx[:,0], sgx[:,1], alpha=0.5, c=sgy)
plt.xlabel('x1')
plt.ylabel('x2')
Out[6]:
Text(0, 0.5, 'x2')

Avec si peu d'exemples, il n'est pas évident à l'oeil que les échantillons suivent une densité de probabilité Gaussienne. De plus, comme les Gaussiennes sont proches, les deux catégories se mélangent et il va être difficile de les séparer.

Sur-entraînement

Essayons cependant d'effectuer la classification avec un réseau de neurones de scikit-learn. Voici une explication des paramètres utilisés ci-dessous:

  • trois couches cachées avec 50 neurones chacune. J'ai choisi cette configuration assez complexe volontairement pour illustrer le sur-entraînement, qui se produit lorsque le modèle est trop complexe pour la taille de l'échantillon d'entraînement.
  • une ReLU comme fonction d'activation pour ces neurones, car la ReLU facilite l'entraînement dans les réseaux de neurones avec des couches cachées.
  • un nombre maximum d'itérations élevé, pour donner au réseau la possibilité de converger.
  • une graine fixée pour le générateur aléatoire, de façon à ce que vous obteniez exactement les mêmes résultats que moi, à chaque fois que vous ferez tourner le code.
In [7]:
from sklearn.neural_network import MLPClassifier

mlp = MLPClassifier(hidden_layer_sizes=(50,50,50), activation='relu', max_iter=10000, random_state=1)
mlp.fit(sgx,sgy)
Out[7]:
MLPClassifier(activation='relu', alpha=0.0001, batch_size='auto', beta_1=0.9,
       beta_2=0.999, early_stopping=False, epsilon=1e-08,
       hidden_layer_sizes=(50, 50, 50), learning_rate='constant',
       learning_rate_init=0.001, max_iter=10000, momentum=0.9,
       n_iter_no_change=10, nesterovs_momentum=True, power_t=0.5,
       random_state=1, shuffle=True, solver='adam', tol=0.0001,
       validation_fraction=0.1, verbose=False, warm_start=False)

Maintenant, on va définir une petite fonction pour afficher nos résultats. Cette fonction tracera les exemples des deux catégories, ainsi que la probabilité qu'un point (x1,x2) appartienne à la catégorie 1 (le noir veut dire que cette probabilité est proche de 1, et le blanc de 0).

In [8]:
def plot_result(sample, targets, linrange=(-5,5,101)):
    xmin, xmax, npoints = linrange
    gridx1, gridx2 = np.meshgrid(np.linspace(xmin,xmax,npoints), np.linspace(xmin,xmax,npoints))
    grid = np.c_[gridx1.flatten(), gridx2.flatten()]
    probs = mlp.predict_proba(grid)
    plt.pcolor(gridx1, gridx2, probs[:,1].reshape(npoints,npoints), cmap='binary')
    plt.colorbar()
    plt.scatter(sample[:,0], sample[:,1], c=targets, cmap='plasma', alpha=0.5, marker='.')
    plt.xlabel('x1')
    plt.ylabel('x2')
    plt.show()
In [9]:
plot_result(sgx,sgy)

La distribution de probabilité est très loin d'être optimale. À la frontière, le réseau de neurones fait tout ce qu'il peut pour suivre les fluctuations de l'échantillon d'entraînement. Il est capable de le faire car son grand nombre de paramètres le rend très flexible.

Mais voyons ce qui se passe si on trace la distribution de probabilité avec l'échantillon de test, qui est plus grand:

In [10]:
plot_result(tgx,tgy)

De nombreux exemples de test sont classés dans la mauvaise catégorie. Ce réseau de neurones est très bon avec l'échantillon d'entraînement, mais a perdu sa généralité, et il est donc inutile en pratique.

C'est le sur-entraînement.

Éviter le sur-entraînement

Maintenant, essayons à nouveau, mais avec un réseau de neurones bien moins complexe : une seule couche cachée avec seulement 5 neurones. Le réseau est entraîné avec le petit échantillon d'entraînement, et les résultats sont affichés avec l'échantillon de test, de taille plus importante.

In [11]:
mlp = MLPClassifier(hidden_layer_sizes=(5,), activation='relu', max_iter=10000, random_state=1)
mlp.fit(sgx,sgy)
plot_result(tgx,tgy)

Cette fois-ci, le sur-entraînement ne pose pas de problème. Le réseau n'a pas assez de paramètres pour pouvoir suivre les fluctuations de l'échantillon d'entraînement. Par conséquent, il se comporte bien avec l'échantillon de test.

Essayons encore autre chose : un réseau complexe, mais avec beaucoup plus de données d'entrainement, 10 000 exemples par catégorie au lieu de 30.

In [12]:
sgx, sgy = make_sample(10000)
mlp = MLPClassifier(hidden_layer_sizes=(50,50,50), activation='relu', max_iter=10000, random_state=1)
mlp.fit(sgx,sgy)
plot_result(tgx, tgy)

L'entraînement du réseau a pris un peu plus de temps, mais il y a suffisamment de données d'entraînement pour contraindre l'ensemble des paramètres du réseau. Les performances de classification resteront bonnes en général.

Alors pourquoi des réseaux complexes?

Eh bien, pour pouvoir décrire des données complexes! Et pour que ces réseaux puissent être efficaces, nous devons les entraîner avec beaucoup de données.

Dans cette section, nous allons construire un échantillon complexe avec beaucoup de données, et voir si nous parvenons à le classer.

Pour construire l'échantillon, nous réutilisons juste notre fonction make_sample plusieurs fois, avant de concaténer les échantillons résultants.

In [13]:
sgxa, sgya = make_sample(1000, ([0.,0],[3.,3.]), 0.3)
sgxb, sgyb = make_sample(1000, ([1.,1],[4.,4.]), 0.3)
sgxc, sgyc = make_sample(1000, ([5.,5.],[-2.,-2.]), 0.6)
sgxd, sgyd = make_sample(1000, ([-1,3.],[3.,-1.]), 0.3)

sgx = np.concatenate([sgxa,sgxb,sgxc,sgxd])
sgy = np.concatenate([sgya,sgyb,sgyc,sgyd])
In [14]:
plt.scatter(sgx[:,0], sgx[:,1], alpha=0.5, c=sgy)
plt.xlabel('x1')
plt.ylabel('x2')
Out[14]:
Text(0, 0.5, 'x2')

Maintenant, essayons de classer tout ça avec un petit réseau:

In [15]:
mlp = MLPClassifier(hidden_layer_sizes=(3,), activation='relu', max_iter=10000, random_state=1)
mlp.fit(sgx,sgy)
Out[15]:
MLPClassifier(activation='relu', alpha=0.0001, batch_size='auto', beta_1=0.9,
       beta_2=0.999, early_stopping=False, epsilon=1e-08,
       hidden_layer_sizes=(3,), learning_rate='constant',
       learning_rate_init=0.001, max_iter=10000, momentum=0.9,
       n_iter_no_change=10, nesterovs_momentum=True, power_t=0.5,
       random_state=1, shuffle=True, solver='adam', tol=0.0001,
       validation_fraction=0.1, verbose=False, warm_start=False)
In [16]:
plot_result(sgx,sgy,linrange=(-4,7,201))

Le réseau n'a pas assez de paramètres pour s'adapter aux données d'entraînement.

C'est le sous-entraînement.

Cependant, le réseau fait quand même du bon boulot avec ses trois neurones.

Augmentons un peu le nombre de neurones sur la couche cachée:

In [17]:
mlp = MLPClassifier(hidden_layer_sizes=(5,), activation='relu', max_iter=10000, random_state=1)
mlp.fit(sgx,sgy)
plot_result(sgx,sgy,linrange=(-4,7,201))

Pas mal! 5 neurones sont déjà suffisants pour s'adapter aux données, mais nous avons eu de la chance. Avec une topologie différente, nous aurions pu rater un paquet Gaussien.

Augmentons encore la complexité du modèle:

In [18]:
mlp = MLPClassifier(hidden_layer_sizes=(50,50,50), activation='relu', max_iter=10000, random_state=1)
mlp.fit(sgx,sgy)
plot_result(sgx,sgy,linrange=(-4,7,201))

Toujours pas de surentraînement. Le réseau produit maintenant une frontière plus régulière, et je pense qu'il n'aurait aucun mal à s'adapter à des topologies différentes en cas de besoin.

Maintenant, revenons sur le site pour conclure!

Conclusion

Aujourd'hui, nous avons appris ce que sont le sur-entraînement et le sous-entraînement, et comment les éviter:

  • Si l'échantillon est simple, utilisez un modèle simple
  • Les modèles simples s'entraînent plus rapidement, avec moins de données.
  • Si l'échantillon est complexe, utilisez un modèle complexe
  • Les modèles complexes n'ont pas besoin d'avoir des tonnes de neurones. Parfois, il suffit d'ajouter une couche cachée ou de rajouter quelques neurones.
  • Les modèles complexes prennent plus de temps à entraîner, et leur entraînement nécessite plus de données.

Nous nous sommes mis dans le cadre d'un problème de classification en 2D pour pouvoir tracer la distribution de probabilité estimée par le modèle. Mais les concepts expliqués ici doivent être gardés à l'esprit même lorsque l'on travaille sur des problèmes de machine learning plus complexes.


N'hésitez pas à me donner votre avis dans les commentaires ! Je répondrai à toutes les questions.

Et si vous avez aimé cet article, vous pouvez souscrire à ma newsletter pour être prévenu lorsque j'en sortirai un nouveau. Pas plus d'un mail par semaine, promis!

Retour


Encore plus de data science et de machine learning !

Rejoignez ma mailing list pour plus de posts et du contenu exclusif:

Je ne partagerai jamais vos infos.
Partagez si vous aimez cet article: