Comparer des classifieurs

Dans la partie précédente (pretraitement.ipynb), nous avons observé que les performances pouvaient varier selon le niveau de prétraitement des données (données brutes, données nettoyées, extractions d’attributs etc.). Cependant, l’analyse de performance n’a été conduite qu’avec un seul classifieur (plus proches voisins).

Il est posssible que chaque classifieur donne des résultats différents pour un niveau de pré-traitement donné.

Dans cette feuille, on étudie les performances selon le type de classifieur. Les étapes seront:

  1. Déclarer et entraîner les différents classifieurs sur nos données

  2. Visualiser les performances de chaque classifieur

  3. Comprendre et identifier le sous-apprentisssage et le surapprentissage

  4. Pour aller plus loin \(\clubsuit\): Étudier un comité de classifieurs et étudier l’incertitude dans les données

  5. Pour aller plus loin \(\clubsuit\): Apprentissage profond et transfert d’apprentissage

Entraînement des différents classifieurs

Import des bibliothèques

On commence par importer les bibliothèques dont nous aurons besoin. Comme d’habitude, nous utiliserons un fichier utilities.py où nous vous fournissons quelques fonctions et que vous complèterez au fur et à mesure du projet:

# Load general libraries
import os, re
from glob import glob as ls
from PIL import Image
import numpy as np
import matplotlib.pyplot as plt
%matplotlib inline
import pandas as pd
import seaborn as sns; sns.set()
from PIL import Image
%load_ext autoreload
%autoreload 2
import warnings
warnings.filterwarnings("ignore")
from sys import path

from utilities import *
from intro_science_donnees import *

Import des données

En commentant la ligne 1 ou la ligne 2, vous choisirez ici sur quel jeu de donnée vous travaillerez: les pommes et les bananes ou le vôtre.

dataset_dir = os.path.join(data_dir, 'apples_and_bananas')
# dataset_dir = 'data'

images = load_images(dataset_dir, "*.png")

Vérifions l’import des images en affichant les 20 premières images:

image_grid(images[:20])

Plutôt que de repartir des données brutes, n’oubliez pas que la fiche pretraitement.ipynb vous a permis d’extraire des attributs utiles pour la classification. On peut donc repartir de ces données sur les attributs pour étudier nos classifieurs:

# Re-load features data as a data frame
df_features = pd.read_csv('features_data.csv', index_col=0)
df_features

On vérifie que nos données sont normalisées.

df_features.describe()

Déclaration des classifieurs

Nous allons à présent importer les classifieurs depuis la librairie scikit-learn. Pour cela, on stockera:

  • Les noms des classifieurs dans la variable model_name

  • Les classifieurs eux-mêmes dans la variable model_list

from sklearn.neural_network import MLPClassifier
from sklearn.neighbors import KNeighborsClassifier
from sklearn.svm import SVC
from sklearn.gaussian_process import GaussianProcessClassifier
from sklearn.gaussian_process.kernels import RBF
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier, AdaBoostClassifier
from sklearn.naive_bayes import GaussianNB
from sklearn.discriminant_analysis import QuadraticDiscriminantAnalysis

model_name = ["Nearest Neighbors", "Linear SVM", "RBF SVM", "Gaussian Process",
         "Decision Tree", "Random Forest", "Neural Net", "AdaBoost",
         "Naive Bayes", "QDA"]
model_list = [
    KNeighborsClassifier(3),
    SVC(kernel="linear", C=0.025, probability=True),
    SVC(gamma=2, C=1, probability=True),
    GaussianProcessClassifier(1.0 * RBF(1.0)),
    DecisionTreeClassifier(max_depth=10),
    RandomForestClassifier(max_depth=10, n_estimators=10, max_features=1),
    MLPClassifier(alpha=1, max_iter=1000),
    AdaBoostClassifier(),
    GaussianNB(),
    QuadraticDiscriminantAnalysis()]

La librairie scikit-learn nous simplifie la tâche: bien que les classifieurs aient des fonctionnements très différents, leurs interfaces sont identiques, avec les méthodes:

  • .fit pour entraîner le classifieur sur des données d’entraînement

  • .predict pour prédire des étiquettes sur les données de test

  • .predict_proba pour obtenir des prédictions sur les données de test sous forme de probabilités sur les classes

  • .score pour calculer la performance du classifieur.

Visualisation des performances des classifieurs

Nous allons à présent faire des tests systématiques sur cet ensemble de données (attributs de l’analyse de variance univiarié). La fonction systematic_model_experiment(data_df, model_name, model_list, sklearn_metric) permet de réaliser ces tests systématiques:

from sklearn.metrics import balanced_accuracy_score as sklearn_metric
compar_results = systematic_model_experiment(df_features, model_name, model_list, sklearn_metric)
compar_results.style.set_precision(2).background_gradient(cmap='Blues')

Exercice: Quelle méthode obtient les meilleures performances de test?

model_list[compar_results.perf_te.argmax()]

On peut également représenter les performances dans un graphique en barres:

compar_results[['perf_tr', 'perf_te']].plot.bar()
plt.ylim(0.5, 1)
plt.ylabel(sklearn_metric.__name__)

3. Sous-apprentissage et surapprentissage

Lorsque l’on entraîne un classifieur sur des données, deux comportements sont à éviter:

  • Lorsque à la fois les performances d’entraînement perf_tr et les performances de test perf_te sont mauvaises, on dit alors que le classifieur a sous-appris (under-fitting).

  • Lorsque les performances d’entraînement perf_tr sont bonnes mais les performances de test perf_te sont mauvaises, on dit alors que le classifieur a sur-appris (over-fitting).

Ces notions sont liés à la granularité de la frontière de décision, qui peut être illustré par les graphiques suivants: Under-fitting, optimal and Over-fitting models

Analysons quels classifieurs ont sur-appris resp. sous-appris. Pour cela nous allons:

  1. Identifier les classifieurs dont les performances de test sont inférieures à la performance de test médiane.

  2. Parmi ceux-ci, nous dirons que les classifieurs dont la performance d’entraînement est inférieure à la médiane ont sous-appris, tandis que ceux dont la performance d’entraînement est supérieure à la médiane ont sur-appris.

analyze_model_experiments(compar_results)

Exercice: Quelles sont les classifieurs qui ont sous-appris resp. sur-appris ?

YOUR ANSWER HERE

5. Pour aller plus loin \(\clubsuit\) : Comité de classifieurs et incertitude

Un comité de classifieur est un classifieur dans lequel les réponses de plusieurs classifieurs sont combinées en une seule réponse. En d’autres termes, les classifieurs votent pour les prédictions émises.

On pourrait considérer notre liste de classifieur model_list comme un comité de classifieurs, entraînée sur les mêmes données d’entraînement, et faisant des prédictions sur les mêmes données de test. Regardez le code suivant, il est en réalité très simple: on définit les méthodes fit, predict, predict_proba et score comme étant la concaténation des résultats des classifieurs contenus dans self.model_list.

class ClassifierCommittee():
    def __init__(self, model_list):
        self.model_list = model_list
        
    def fit(self,X,y):
        for model in self.model_list:
            model.fit(X,y)
    def predict(self,X):
        predictions = []
        for model in self.model_list:
            predictions.append(model.predict(X))
        predictions = np.mean(np.array(predictions),axis = 0)
        results = []
        for v in predictions:
            if v < 0:
                results.append(-1)
            else:
                results.append(1)
        return np.array(results)
    
    def predict_proba(self,X):
        predictions = []
        for model in self.model_list:
            predictions.append(model.predict_proba(X))
        return np.swapaxes(np.array(predictions), 0, 1)
    def score(self,X):
        scores = []
        for model in self.model_list:
            scores.append(model.score(X,y))
        return np.swapaxes(np.array(scores), 0, 1)

Quelles seraient les performances d’un tel classifieur?

commitee = ClassifierCommittee(model_list)
perf_tr, std_tr, perf_te, std_te = df_cross_validate(df_features, commitee, sklearn_metric)
print(perf_te, std_te)

La performance de l’ensemble de classifieurs n’est pas forcément meilleure que les classifieurs pris individuellement. Cependant, l’accord ou le désaccord des classifieurs sur les prédictions peut nous donner des informations sur l’incertitude des données.

En effet, chaque classifieur peut émettre des probabilités sur les classes avec la méthode predict_proba. Pour quantifier l’incertitude d’une image, on peut distinguer deux cas de figure:

  • Les classifieurs du comité sont d’accord sur des probabilités incertaines (à gauche sur la figure): chaque classifieur émet une probabilité proche de [0.5, 0.5]. On parle alors d’incertitude aléatorique, et elle est lié à l’ambiguité intrinsèque de la tâche de classification.

  • Les classifieurs du comité sont certains de leurs prédictions mais ils sont en désaccord entre eux (à droite sur la figure): les classifieurs émettent chacun une probabilité confiante mais différentes: [1.,0.], [0., 1.], [0.9, 0.1], [0.05, 0.95], etc. Dans ce cas on parle d’incertitude épistémique, et elle est lié à l’idée de nouveauté dans les données. Cette incertitude peut être réduite en ajoutant de nouvelles données.

Under-fitting, optimal and Over-fitting models

Intéressons nous aux images avec une faible incertitude épistémique (les classifieurs sont d’accord) et une grande incertitude aléatorique (les classifieurs sont incertains de la prédiction). Cela correspond à des images se situant aux abords de la frontière de décision, entre nos deux catégories.

Incertitude aléatorique

Nous allons utiliser l’entropie de Shannon, qui est une mesure en théorie de l’information permettant d’estimer la quantité d’information contenu dans une source d’information (ici notre probabilité sur les classes): \(H(X) = - \sum_{i=1}^{n}P(x_i)log_2(x_i)\) avec \(x_i\) étant la probabilité de classification sur la classe i.

from scipy.stats import entropy

On récupère alors les prédictions de nos images et pour les 10 classifieurs.

X = df_features.iloc[:, :-1].to_numpy()
Y = df_features.iloc[:, -1].to_numpy()
commitee.fit(X, Y)
prediction_probabilities = commitee.predict_proba(X)
prediction_probabilities.shape

La dimension de notre matrice de prédiction est donc: (nombre d’images, nombre de classifieur, nombre de classess). Appliquons l’entropie pour chaque prédiction de chaque classifieur.

entropies_per_classifier = entropy(np.swapaxes(prediction_probabilities, 0, 2))

On moyenne les entropies d’une image entre les classifieurs:

entropies = np.mean(entropies_per_classifier, axis = 0)

Puis on ajoute ces valeurs dans un tableau (DataFrame) que l’on trie par ordre décroissant d’entropie. Les images avec l’entropie la plus grande sont les plus incertaines et donc les plus informatives pour le modèle.

df_uncertainty = pd.DataFrame({"images" : images,
                           "entropy" : entropies})
df_uncertainty = df_uncertainty.sort_values(by=['entropy'],ascending=False)
df_uncertainty.style.background_gradient(cmap='RdYlGn_r')

Affichons les 10 images les plus incertaines pour le comité de classifieurs, selon cette mesure aléatorique de l’incertitude. Ces images nous donne une idée de l’ambiguité intrinsèque de notre base de données.

uncertain_aleatoric_images = df_uncertainty['images'].tolist()
image_grid(uncertain_aleatoric_images[:20])

Affichons maintenant les 10 images les moins incertaines pour notre comité de classifieur:

image_grid(uncertain_aleatoric_images[-20:])

Exercice: Ces résultats vous semblent-ils surprenants? Expliquer.

Incertitude épistémique

Pour l’incertitude épistémique, on va simplement moyenner entre les classifieurs les écarts types entre les classes:

# std entre les classses
epistemic_uncertainty = np.std(prediction_probabilities, axis = 2)
print(epistemic_uncertainty.shape)
# mean entre les classifieurs
epistemic_uncertainty = np.mean(epistemic_uncertainty, axis = 1)
print(epistemic_uncertainty.shape)

On ajoute cette mesure au tableau puis on classe les images par ordre décroissant.

df_uncertainty["std_epistemic"] = epistemic_uncertainty
df_uncertainty = df_uncertainty.sort_values(by=['std_epistemic'],ascending=False)
df_uncertainty.style.background_gradient(cmap='RdYlGn_r')
df_uncertainty.corr()

Les valeurs d’incertitude aléatorique (entropie) et épistémiques (std) ne semblent pas très corrélées. Affichons les images les plus incertaines selon cette mesure d’incertitude épistémique.

uncertain_epistemic_images = df_uncertainty['images'].tolist()
# les images où les classifieurs sont le moins d'accord
image_grid(uncertain_epistemic_images[:20])
# les images où les classifieurs sont le plus d'accord
image_grid(uncertain_epistemic_images[-20:])

Exercice: Ces résultats vous semblent-ils surprenants? Expliquer.

6. Pour aller plus loin \(\clubsuit\): apprentissage profond

En classe, vous apprendrez ce qu’est un réseau de neurone artificiel et l’apprentissage profond. En résumé, il s’agit de modèles (classifieurs par exemple) dont l’architecture est articulée en “couches” composées de différentes transformations non linéaires: des couches de neurones, des convolutions ou d’autres transformations.

Des poids (paramètres appris par le réseau) sont associés aux neurones et sont optimisés lors de l’entraînement sur les données par une technique appelée descente de gradient. Quand le réseau fait une prédiction, les valeurs des données brutes (pixels) sont propagées dans le réseau jusqu’à donner le résultat en sortie. La spécificité de ces architectures est qu’elles manipulent des données brutes, sans utiliser d’attributs: les attributs sont en quelque sorte appris par le réseau.

Pour mieux comprendre l’architecture connexioniste d’un réseau de neurone profond, consultez ce site créé par Adam Harley de l’Université Carnegie Mellon. Il présente un réseau convolutionnel pour la reconnaissance de chiffres manuscript. On peut y dessiner des chiffres (en haut à gauche) et visualiser les prédictions faites lors de la propagation des valeurs des pixels dans le réseau. Passez votre souris sur un neurone et vous verrez ses connexions avec les neurones des autres couches s’afficher.

Ces réseaux profonds comptent beaucoup de paramètres (neurones) à optimiser. Ils nécessitent des infrastructures spéciales pour les entraîner: de puissantes cartes graphiques (GPU), comme celles utilisées pour les jeux vidéos, qui permettent de paralléliser les calculs. Nous ne disposons pas de tels infrastructures sur le service JupyterHub de l’université.

Nous allons donc voir comment transférer les connaissances apprises par un réseau pour l’adapter à votre problème. On appelle cela l’apprentissage par transfert (ou Transfer Learning) et nous permet de ne pas avoir à réentraîner un réseau de neurones en entier mais seulement en partie.

MobileNet et transfert

Nous allons utiliser un réseau pré-entraîné appelé MobileNet, dont l’architecture a été optimisé pour fonctionner sur les téléphones portables. Le réseau a été entraîné pour reconnaître 1000 catégories différentes (dont des animaux, des objets de la vie courante etc.). Depuis la librairie keras, importons le modèle pré-entraîné sur la base de donnée imagenet.

import tensorflow as tf
tf.config.run_functions_eagerly(True)


mobilenet = tf.keras.applications.MobileNet(include_top=False, weights='imagenet', input_shape = (32,32,3))

Observons l’architecture en couche de ce réseau de neurones:

mobilenet.summary()

Chaque couche est indiqué par une ligne.

Exercice: Combien ce réseau a-t-il de paramètres? Parmi ces paramètres, combien sont entraînables?

En déclarant le réseau avec le paramètre include_top=False, on a retiré la dernière couche de neurone. Le transfert d’apprentissage va pouvoir être fait en ajoutant des nouvelles couches de neurones entraînables à la fin de ce réseau pré-entraîné (dont les paramètres vont être gelés). Ainsi, on pourra entraîner seulement les dernières couches de notre réseau sans avoir à réentraîner tout le réseau, ce qui nécessiterait autrement une puissante carte graphique. En d’autres termes, le réseau mobilenet va nous donner des attributs abstraits sur lesquels nous allons réentraîner un nouveau réseau de neurones, plus petit.

On commence par gèler les poids du réseau mobilenet:

for layer in mobilenet.layers:
    layer.trainable=False

Vérifiez que tous les paramètres de mobilenet sont à présent non-entraînables.

mobilenet.summary()

On va à présent ajouter de nouvelles couches entraînables de neurones:

import keras
from keras.models import Model
from keras.layers import Dense,GlobalAveragePooling2D


myneuralnet = keras.Sequential(
    [
        mobilenet,
        GlobalAveragePooling2D(),
        Dense(64, activation="relu", name="layer1"),
        Dense(64, activation="relu", name="layer2"),
        Dense(2, activation="softmax",name="layer3"),
    ]
)

Le réseau MobileNet prends des images de taille (32,32,3) comme indiqué en paramètre dans la déclaration du réseau.

myneuralnet.build((None,32,32,3))
myneuralnet.summary()

Exercice: À présent combien ce réseau a-t-il de paramètres? Parmi ces paramètres, combien sont entraînables?

Chargeons nos données recentrées et recadrées de taille 32x32.

df_clean = pd.read_csv('clean_data.csv', index_col=0)  # chargement du fichier dans le notebook
df_clean

On souhaite avoir une variable X de type np.ndarray de taille (491, 32, 32, 3) c’est à dire (nombre d'images, largeur, hauteur, nombre de couches de couleurs).

X = np.array(df_clean.drop(['étiquette'], axis=1)).reshape((491, 32, 32, 3))

Pour nos étiquettes, on souhaite les mettre sous la forme de “one-hot vectors” c’est à dire des tableau de taille (nombre d'images, nombre de classe) contenant seulement des 1 ou des 0. Pour les pommes, on mettra 1 dans la première colonne, pour les bananes dans la deuxième.

y = np.array(df_clean["étiquette"])
y_onehot = np.zeros((len(y), len(np.unique(y))))
for i, label in enumerate(y):
    for j, classe in enumerate(np.unique(y)):
        if label == classe:
            y_onehot[i,j] = 0
        else:
            y_onehot[i,j] = 1

On doit maintenant définir l’optimisateur et la fonction de coût qui vont permettre d’optimiser les paramètres de nos neurones. Les poids des neurones vont être optimisés pour minimiser la fonction de coût (loss en anglais).

opt = keras.optimizers.Adam(learning_rate=0.01)
myneuralnet.compile(loss='categorical_crossentropy', metrics=["accuracy"], optimizer=opt)

Avant de pouvoir entraîner notre réseau, on va d’abord diviser nos données en deux: les données d’entraînement et les données de test.

test = np.array(y == 1, dtype = int)
X_train = np.concatenate((X[:165], X[333:333+ 79]), axis = 0)
y_train = np.concatenate((y_onehot[:165], y_onehot[333:333+ 79]), axis = 0)

X_test = np.concatenate((X[165:333], X[333+ 79:]), axis = 0)
y_test = np.concatenate((y_onehot[165:333], y_onehot[333+ 79:]), axis = 0)

On peut finalement entraîner notre réseau de neurone!

from sklearn.model_selection import StratifiedKFold
import pdb
stop = pdb.set_trace
def create_model():
    myneuralnet = keras.Sequential(
    [
        mobilenet,
        GlobalAveragePooling2D(),
        Dense(64, activation="relu", name="layer1"),
        Dense(64, activation="relu", name="layer2"),
        Dense(2, activation="softmax",name="layer3"),
    ])
    myneuralnet.build((None,32,32,3))
    opt = keras.optimizers.Adam(learning_rate=0.01)
    myneuralnet.compile(loss='categorical_crossentropy', metrics=["accuracy"], optimizer=opt)
    return myneuralnet

def train_evaluate(model, x_train, y_train, x_test, y_test):
    history = model.fit(x_train, y_train, epochs = 10 shuffle = True)
    return model.evaluate(x_test, y_test)

kFold = StratifiedKFold(n_splits=10)
scores = np.zeros(10)
idx = 0
for train, test in kFold.split(X, y):
    model = create_model()
    scores[idx] = train_evaluate(model, X[train], y_onehot[train], X[test], y_onehot[test])[1]
    idx += 1
print(scores)
print(scores.mean())

Afficher les valeurs d’accuracy au fur et à mesure des itérations d’optimisations:

plt.plot(scores)
plt.xlabel("Nombre d'itération d'optimisations")
plt.ylabel("Accuracy")

Conclusion

Terminez toujours par une conclusion. Qu’avez-vous appris ? Qu’est-ce qui pourrait être amélioré ?

Bonne pratique: Terminez toujours votre travail par “Kernel > Restart & Run all” pour vérifier que toutes vos cellules fonctionnent dans leur ordre d’exécution.