VI-ME-RÉ-BAR sur des données «réelles»

Aujourd’hui nous allons analyser un jeu de données qui comporte des images de pommes et de bananes avec comme objectif de classer automatiquement les images de pommes versus celles de bananes.

Import des bibliothèques

import os, re
from glob import glob as ls
import numpy as np                    # Matrix algebra library
import pandas as pd                   # Data table (DataFrame) library
import seaborn as sns; sns.set()      # Graphs and visualization library
from PIL import Image                 # Image processing library
import matplotlib.pyplot as plt       # Library to make graphs 
# Configuration intégration dans Jupyter
%matplotlib inline

Import des utilitaires:

%load_ext autoreload
%autoreload 2

from utilities import *

Étape 0: chargement du jeu de données

Exercice: En vous inspirant de la feuille de bonnes pratiques, chargez les images du jeu de données “ApplesAndBananasSimple”, dans la variable images, et affichez les:

### BEGIN SOLUTION
from intro_science_donnees import data
dataset_dir = os.path.join(data.dir, 'ApplesAndBananasSimple')
images = load_images(dataset_dir, "*.png")
image_grid(images, titles=images.index)
### END SOLUTION

Étape 1: prétraitement et [VI]sualisation

Observez les images en détails. Êtes vous capable de différencier les pommes des bananes ? Essayez d’imaginer comment votre cerveau réussi un tel exercice !

Prétraitement

Les données sont très souvent prétraitées c’est-à-dire résumées selon différentes caractéristiques : chaque élément du jeu de données est décrit par un ensemble d”attributs – propriétés ou caractéristiques mesurables de cet élément ; pour un animal, cela peut être sa taille, sa température corporelle, etc.

C’est également le cas dans notre jeu de données : une image est décrite par la couleur de chacun de ses pixels. Cependant les pixels sont trop nombreux pour nos besoins. Nous voulons à la place juste quelques attributs résumant les propriétés du fruit dans l’image, comme sa couleur ou sa forme moyenne: ce sont les données prétraitées. Nous verrons la semaine prochaine comment extraire ces attributs depuis les images.

Pour ce TP, une table contenant les données prétraitées vous est fournie dans le fichier attributs.csv qu’il suffit donc de charger:

df = pd.read_csv("attributs.csv", index_col=0)
df

Cette table de données contient en colonne les attributs (features) de nos images ainsi que leurs classes (vérité terrain, ground truth): 1 si c’est une image de pomme et -1 si c’est une banane.

Cette table est tout ce dont nous aurons besoin dans la suite.

Visualisation

Exercice: Tout d’abord, extrayez quelques statistiques simples et observez votre table avec la méthode describe:

### BEGIN SOLUTION
df.describe()
### END SOLUTION

Nous pouvons visualiser cette table sous forme de carte de chaleur (heat map) aisément:

df.style.background_gradient(cmap='coolwarm')

La carte de chaleur est appliquée à chaque colonne indépendamment. Ainsi, nous visualisons la variation de chaque colonne même si les valeurs sont dans des unités différentes ou avec des ordres de grandeur différents, comme c’est le cas ici.

La prochaine étape est de calculer la corrélation entre les colonnes; comme vu en cours, ces corrélations ne sont pas représentatives quand les colonnes n’ont pas le même ordre de grandeur. Nous devons donc standardiser chaque colonne de sorte à avoir la moyenne à \(0\) et l’écart-type à \(1\).

Exercice: Standardisez la table : colonne par colonne, soustrayez la moyenne puis divisez par l’écart-type; affectez le résultat à dfstd. Comme la semaine dernière, pas besoin de boucle. Utilisez la vectorisation!

### BEGIN SOLUTION
dfstd =  (df - df.mean()) / df.std()
dfstd
### END SOLUTION

Vérifiez que cette nouvelle table est effectivement standarisée à l’aide des commandes suivantes (vérifiez que la moyenne est à 0 et l’écart type à 1):

dfstd.describe()
assert dfstd.shape == df.shape
assert dfstd.index.equals(df.index)
assert dfstd.columns.equals(df.columns)
assert (abs(dfstd.mean()) < 0.01).all()
assert (abs(dfstd.std() - 1) < 0.1).all()

Mince, ce n’est pas exactement ce qu’on veut ! La valeur du fruit correspond à une étiquette et pas à une valeur; on veut donc garder les valeurs originelles -1 pour les bananes et 1 pour les pommes.

Pour cela, le plus simple est de réaffecter la colonne d’origine:

dfstd['class'] = df['class']
dfstd

Observations

Exercice : Regardez la carte de chaleur de la table standarisée; elle devrait être identique à la précédente:

### BEGIN SOLUTION
dfstd.style.background_gradient(cmap='coolwarm')
### END SOLUTION

Exercice :

  1. Produisez une carte de chaleur de la matrice de corrélation.

    Indication: si nécessaire, consultez le TP de la semaine précédente.

### BEGIN SOLUTION
dfstd.corr().style.background_gradient(cmap ='coolwarm', axis=None)
### END SOLUTION
  1. Quel(s) attribut(s) est (sont) correlé(s) au type de fruit ? Pouvez vous indiquer quel type de fruit est le «plus» rouge et lequel est le «plus» allongé ? Y a-t-il des exceptions ?

    BEGIN SOLUTION

    L’élongation est fortement corrélée (négativement) avec le type de fruit, les bananes étant, en moyenne, plus allongées que les pommes.

    La couleur est faiblement corrélée avec le type de fruit, les pommes tendant légèrement à être plus rouges que les bananes.

    END SOLUTION

Il existe quelques valeurs aberrantes (outliers), comme, par exemple deux bananes rouges; on peut les identifier à l’aide d’une visualisation en nuage de points (scatter plot), où chaque image est positionnée en fonction des valeurs de ses attributs:

make_scatter_plot(dfstd, images, axis='square')

On peut aussi visualiser le jeu de données à l’aide d’un pair plot.

Exercice : En vous inspirant du TP de la semaine dernière, utilisez Seaborn pour produire un pair plot. Utilisez l’option diag_kind pour choisir une représentation en histogrammes sur la diagonale et l’option palette pour prendre Set2 comme palette de couleurs:

### BEGIN SOLUTION
sns.pairplot(dfstd, hue="class", diag_kind="hist", palette='Set2');
### END SOLUTION

Remarque importante : un seul attribut (la rougeur ou l’élongation) est presque suffisant pour parfaitement séparer les pommes des bananes.

Étape 2: [ME]sure de performance ([ME]tric)

Mesurer les performances de ce problème de classification revient à déterminer notre capacité à séparer les pommes des bananes (un problème de classification).

Partition (split) du jeu de données en ensemble d’entraînement (training set) et ensemble de test (test set)

Séparons d’abord les colonnes de notre table selon:

  • X: les attributs permettant d’effectuer des prédictions

  • Y: la vérité terrain: ce que l’on veut prédire; ici le type de fruit. Pour cela on définit:

X = dfstd[['redness', 'elongation']]
Y = dfstd['class']

Pourquoi cette notation \(X\) et \(Y\) ? Car on essaye de définir un modèle prédictif \(f\); pour chaque élément d’index \(i\), on veut prédire la vérité terrain \(Y_i\) à partir de la valeur \(X_i\) des attributs. Idéalement on aurait \(Y_i = f(X_i)\), pour tout \(i\).

Nous allons maintenant partitionner nos images en un ensemble d’entraînement et un ensemble de test.

  • L’ensemble d’entraînement servira à entraîner notre modèle prédictif \(f\), c’est-à-dire à ajuster ses paramètres pour qu’il colle au mieux aux données de cet ensemble.

  • L’ensemble de test servira à mesurer la performance de ce modèle prédictif.

Comme notre problème est de petite taille, on va partitionner les images en deux ensembles de même taille.

L’utilitaire split_data fourni dans utilities.py partitionne aléatoirement l’index des lignes de X et Y en deux ensembles de même taille:

train_index, test_index = split_data(X, Y, seed=0)
train_index, test_index

Séparons en conséquence la table des attributs X:

Xtrain = X.iloc[train_index]
Xtest = X.iloc[test_index]

Exercice : Faites de même pour les étiquettes.

### BEGIN SOLUTION
Ytrain = Y.iloc[train_index]
Ytest = Y.iloc[test_index]
### END SOLUTION
assert Ytest.shape == Ytrain.shape
assert pd.concat([Ytest, Ytrain]).sort_index().equals(Y.sort_index())
assert Ytest.value_counts().sort_index().equals(Ytrain.value_counts().sort_index())

On peut maintenant regarder les images qui serviront à entraîner notre modèle prédictif.

image_grid(images.iloc[train_index], titles=train_index)

Exercice : Faites de même avec les images de test.

### BEGIN SOLUTION
image_grid(images.iloc[test_index], titles=test_index)
### END SOLUTION

Notez que l’ensemble d’entraînement et l’ensemble de test contiennent la même proportion de pommes et de bananes que le jeu de données initial. L’utilitaire split_dataa été conçu pour le garantir!

L’utilitaire make_scatter_plot fourni permet aussi de visualiser les ensembles d’entrainement et de test comme des nuages de points, en représentant chaque donnée d’entraînement par l’image associée et chaque donnée de test un point d’interrogation car sa classe (pomme/banane) est cachée.

make_scatter_plot(dfstd, images, train_index, test_index, axis='square')

Calcul du taux d’erreur

On suppose que l’on a :

  • Un vecteur solutions contenant les valeurs cibles (les « vraies » classes : 1 pour les pommes et -1 pour les bananes).

  • Un vecteur prédictions contenant des valeur prédites (les classes estimées).

Le taux d’erreur (error rate) est défini comme la fraction \(\frac e n\), où \(e\) est le nombre de prédictions incorrectes et \(n\) le nombre total de prédictions. C’est une mesure de performance les prédictions.

Exercice :

  1. Déterminer à la main \(e\), \(n\) et le taux d’erreur pour l’exemple suivant :

solutions = pd.Series([1, 1, -1, 1, 1])
predictions = pd.Series([1, -1, 1, 1, 1])

BEGIN SOLUTION

\(e = 2\), \(n=5\), \(\frac{e}{n}= \frac{2}{5} = 0.4\)

END SOLUTION

  1. Calculez le même taux d’erreur avec Python
    Défi : cela peut se faire en une petite ligne 😉

### BEGIN SOLUTION
(predictions != solutions).mean()
### END SOLUTION
  1. Ouvrez le fichier utilities.py, et complétez le code de la fonction error_rate en généralisant votre calcul précédent.

  2. Vérifiez votre fonction sur l’exemple ci-dessus

### BEGIN SOLUTION
error_rate(solutions, predictions)
### END SOLUTION
  1. Écrivez des tests (avec assert) qui vérifient que :

    • le taux d’erreur entre solution=Ytrain et prediction=Ytrain est nul (pourquoi ?)

      BEGIN SOLUTION

      Il n’y a aucune erreur de prédiction: \(e=0\).

      END SOLUTION

    • le taux d’erreur entre solution=Ytrain et prediction=[1,...,1] est de 0,5 (pourquoi ?)

      BEGIN SOLUTION

      La prédiction est erronnée chaque fois que Ytrain[i] vaut \(-1\), soit une fois sur deux.

      END SOLUTION

    • le taux d’erreur entre solution=Ytrain et prediction=[0,...,0] est de un (pourquoi ?)

      BEGIN SOLUTION

      La prédiction \(0\) est toujours erronnée car Ytrain[i] vaut soit \(-1\), soit \(-1\).

      END SOLUTION

    Astuce : vous pouvez utiliser np.zeros(Ytrain.shape) pour générer un tableau [0,...,0] de même taille que Ytrain, et de même np.ones(Ytrain.shape) pour [1,. ..,1].

### BEGIN SOLUTION
assert error_rate(Ytrain, Ytrain) == 0
assert error_rate(Ytrain, np.ones(Ytrain.shape)) == 0.5
assert error_rate(Ytrain, np.zeros(Ytrain.shape)) == 1
### END SOLUTION

La bibliothèque d’apprentissage statistique scikit-learn également appelée sklearn a une fonction accuracy_score qui calcule la précision des prédictions. Ainsi, et comme vérification supplémentaire, nous pouvons tester que error_rate + accuracy_score = 1 en utilisant les mêmes exemples que ci-dessus.

from sklearn.metrics import accuracy_score
assert abs(error_rate(Ytrain, Ytrain)                 + accuracy_score(Ytrain, Ytrain)                 - 1) <= .1
assert abs(error_rate(Ytrain, np.zeros(Ytrain.shape)) + accuracy_score(Ytrain, np.zeros(Ytrain.shape)) - 1) <= .1
assert abs(error_rate(Ytrain, np.ones(Ytrain.shape))  + accuracy_score(Ytrain, np.ones (Ytrain.shape)) - 1) <= .1

Étape 3: [RÉ]férence (base line)

Mon premier classificateur : plus proche voisin

Le classificateur du plus proche voisin est une méthode très simple: pour classer une entrée non étiquetée, nous cherchons l’entrée étiquettée la plus proche et prenons son étiquette. Vous pourrez facilement le mettre en oeuvre plus tard dans le semestre en fin de projet 1.

Pour le moment nous allons utiliser scikit-learn qui implante un classificateur plus général appelé \(k\)-plus proches voisins (KNN: \(k\)-Nearest Neighbors). Avec ce classificateur, une entrée non étiquetée est classée en fonction des étiquettes de ses \(k\) plus proches voisins. L’entrée sera prédite comme appartenant à la classe C si la majorité de ces \(k\) plus proches voisins appartient à la classe C. Le classificateur du plus proche voisin correspond au cas particulier \(k=1\).

Exercice :

  1. Importez le classificateur KNeighborsClassifier à partir de sklearn.neighbors. Utilisez-le pour construire un nouveau modèle, en fixant le nombre de voisins à un (comme vu lors du CM3). Appelez-le classifier.

  2. Entraînez ce modèle avec Xtrain en appelant la méthode « fit ».

  3. Utilisez ensuite le modèle formé pour créer deux vecteurs de prédiction Ytrain_predicted et Ytest_predicted en appelant le méthode predict.

  4. Calculez e_tr, le taux d’erreur de l’entrainement et e_te le taux d’erreur des tests.

Indication : consultez le cours et la documentation aussi souvent que nécessaire, en commençant par taper KNeighborsClassifier?

### BEGIN SOLUTION
from sklearn.neighbors import KNeighborsClassifier
classifier = KNeighborsClassifier(n_neighbors=1)
classifier.fit(Xtrain, Ytrain) 
Ytrain_predicted = classifier.predict(Xtrain)
Ytest_predicted = classifier.predict(Xtest)
e_tr = error_rate(Ytrain, Ytrain_predicted)
e_te = error_rate(Ytest, Ytest_predicted)
### END SOLUTION
print("NEAREST NEIGHBOR CLASSIFIER")
print("Training error:", e_tr)
print("Test error:", e_te)

Ce problème est simple: nous n’avons aucune erreur dans l’ensemble d’entraînement et deux erreurs dans l’ensemble de test.

Ensuite, nous superposons les prédictions faites sur l’ensemble de test sur un nuage de points …

# The training examples are shown as white circles and the test examples are black squares.
# The predictions made are shown as letters in the black squares.
make_scatter_plot(X, images,
                  train_index, test_index, 
                  predicted_labels=Ytest_predicted, axis='square')

Exercice : Observez la figure. Qui y a-t-il selon l’axe des x et des y ? Rédigez la réponse.

BEGIN SOLUTION

  • en x: la rougeur du fruit (redness)

  • en y: l’élongation du fruit (elongation)

END SOLUTION

… ensuite, nous montrons la « vérité terrain » et calculons le taux d’erreur

# The training examples are shown as white circles and the test examples are blue squares.
make_scatter_plot(X, images.apply(transparent_background_filter),
                  train_index, test_index, 
                  predicted_labels='GroundTruth', axis='square')

Étape 4: [BAR]res d’erreur (error bar)

Une fois les résultats obtenus nous devons évaluer leur significativité en calculant les barres d’erreurs. Évidemment, comme nous n’avons que dix exemples de test, on ne peut observer plus que dix erreurs; dans l’absolu ce n’est pas énorme mais pour notre exemple ça le serait !

Barre d’erreur 1-sigma

Exercice : Déterminez une première estimation de la barre d’erreur en calculant l’erreur standard \(\sigma\) sur l’ensemble d’entrainement. Pour rappel, \(\sigma = \sqrt{\frac{e_{te} * (1-e_{te})}{n}}\)\(e_{te}\) est le taux d’erreur et \(n\) est le nombre de tests effectués.

De combien d’exemples aurions nous eu besoin pour diviser cette barre d’erreur par un facteur de deux?

### BEGIN SOLUTION
n_te = len(Ytest)
sigma = np.sqrt(e_te * (1-e_te) / n_te)
### END SOLUTION
print(f"TEST SET ERROR RATE: {e_te:.2f}")
print(f"TEST SET STANDARD ERROR: {sigma:.2f}")
assert abs( sigma - 0.13 ) < 0.1

Barre d’erreur par validation croisée (cross-validation)

Calculons maintenant une autre estimation de la barre d’erreur en répétant l’évaluation de performance pour de multiples partitions entre ensemble d’entraînement et ensemble de test. Pour cela on peut répéter 100 fois la partition du jeu de données en ensemble d’entraînement et de test et calculer la moyenne et l’écart-type de l’erreur standard de ces réplicats. En un sens, ce résultat est plus informatif puisque il prend en compte la variabilité de l’ensemble d’entraînement et de test.

Remarque: cette estimation reste cependant biaisée; mais cela est hors programme.

n_te = 10
SSS = StratifiedShuffleSplit(n_splits=n_te, test_size=0.5, random_state=5)
E = np.zeros([n_te, 1])
k = 0
for train_index, test_index in SSS.split(X, Y):
    print("TRAIN:", train_index, "TEST:", test_index)
    Xtrain, Xtest = X.iloc[train_index], X.iloc[test_index]
    Ytrain, Ytest = Y.iloc[train_index], Y.iloc[test_index]
    classifier.fit(Xtrain, Ytrain.ravel()) 
    Ytrain_predicted = classifier.predict(Xtrain)
    Ytest_predicted = classifier.predict(Xtest)
    e_te = error_rate(Ytest, Ytest_predicted)
    print("TEST ERROR RATE:", e_te)
    E[k] = e_te
    k = k + 1
    
e_te_ave = np.mean(E)
# It is bad practice to show too many decimal digits:
print("\n\nCV ERROR RATE: {e_te_ave:.2f}")
print("CV STANDARD DEVIATION: {np.std(E):.2f}")

sigma = np.sqrt(e_te_ave * (1-e_te_ave) / n_te)
print(f"TEST SET STANDARD ERROR (for comparison): {sigma:.2f}")

Conclusion

C’est la fin de notre première classification d’images par apprentissage statistique. Nous avons appliqué le schéma VI-ME-RÉ-BAR: nous avons [VI]sualisé les données prétraitées, introduit une [ME]sure pour le problème de classification, à savoir le taux d’erreur pour les prédictions. Nous avons procédé avec une méthode de [RÉ]férence (baseline en anglais) en utilisant le classificateur du plus proche voisin vu en cours. Enfin, nous avons estimé les performances de ce premier classificateur en calculant les [BAR]res d’erreurs sur de nombreux échantillons d’ensembles d’apprentissage/de test.

Les prédictions obtenues sont assez robustes, ce qui n’est pas surprenant étant donné que, à quelques valeurs aberrantes près, les images sont bien contraintes (fond blanc, taille constante des images et des fruits, …).

La semaine prochaine nous verrons comment prétraiter un nouveau jeu de données que vous aurez choisi; la semaine suivante nous testerons d’autres classificateurs.

Mettez à jour votre rapport et déposez votre travail.

Pour préparer la semaine prochaine, il vous reste à choisir un jeu de données parmi ceux proposés. Pour cela ouvrez la feuille jeux de données.