Comparer des classificateurs#

Dans la feuille précédente, 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 classificateur (plus proches voisins).

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

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

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

  2. Visualiser les performances de chaque classificateur;

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

  4. Étudier un comité de classificateurs et l’incertitude dans les données;

  5. Pour aller plus loin ♣: Apprentissage profond et transfert d’apprentissage.

Entraînement des différents classificateurs#

Import des bibliothèques#

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 data
from intro_science_donnees import *

Import des données#

Nous chargeons les images prétraitées dans la feuille précédente :

dataset_dir = 'clean_data'

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

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

image_grid(images[:20])

Pour mettre en œuvre des classificateurs, chargez dans df_features les attributs extraits dans la fiche précédente.

# VOTRE CODE ICI
raise NotImplementedError()

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

df_features.describe()

Déclaration des classificateurs#

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

  • les noms des classificateurs dans la variable model_name;

  • les classificateurs eux-mêmes dans la variable model_list.

from sklearn.neural_network import MLPClassifier
from sklearn.neighbors import KNeighborsClassifier
from sklearn.neighbors import RadiusNeighborsClassifier
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", "Parzen Windows",  "Linear SVM", "RBF SVM", "Gaussian Process",
         "Decision Tree", "Random Forest", "Neural Net", "AdaBoost",
         "Naive Bayes", "QDA"]
model_list = [
    KNeighborsClassifier(3),
    RadiusNeighborsClassifier(radius=12.0),
    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 bibliothèque scikit-learn nous simplifie la tâche : bien que les classificateurs aient des fonctionnements très différents, leurs interfaces sont identiques (rappelez vous la notion d’encapsulation vue dans le cours «Programmation Modulaire»). Les méthodes sont:

  • .fit pour entraîner le classificateur 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 classificateur.

Visualisation des performances des classificateurs#

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.

??systematic_model_experiment

Que renvoit cette fonction ?

VOTRE RÉPONSE ICI

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.format(precision=2).background_gradient(cmap='Blues')

Exercice

Quelle méthode obtient les meilleures performances de test?

model_list[compar_results.perf_te.argmax()]

VOTRE RÉPONSE ICI

Exercice

On peut également représenter les performances dans un graphique en barres. Sachant que le nom de la métrique s’obtient avec sklearn_metric.__name__, ajouter à la figure le nom des axes pertinents.

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

# VOTRE CODE ICI
raise NotImplementedError()

Sous-apprentissage et surapprentissage#

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

  • Le sous-apprentissage (under-fitting).

  • Le sur-apprentissage (over-fitting).

Ces concepts sont vus en Semaine 8

Analysons quels classificateurs ont sur-appris respectivement sous-appris. Pour cela nous allons identifier les classificateurs dont les performances de test sont inférieures à la performance de test médiane. tebad est un vecteur de booléens.

tebad = compar_results.perf_te < compar_results.perf_te.median()
tebad

Exercice

Calculer maintenant dans trbad les classificateurs dont les performances d’entrainement sont inférieurs à la performance d’entrainement médiane. trbad est un vecteur de booléens.

# VOTRE CODE ICI
raise NotImplementedError()

On peut alors identifier les classificateurs qui ont sur-appris (qui correpondent à True) c’est-à-dire ceux dont la performance d’entraînement est supérieure à la médiane et celle de test est inférieure à la médiane. On écrit :

overfitted = tebad & ~trbad

Exercice

De la même facon, définir underfitted un vecteur de booléens qui indique les classificateurs dont les performances d’entrainement et de test sont inférieures à la médiane

# VOTRE CODE ICI
raise NotImplementedError()

Exercice

Implémenter en vous inspirant du code ci-dessus la fonction analyse_model_experiments qui:

  • prend en entrée les résultats de plusieurs classificateurs sous la forme d’un tableau

  • identifie par deux vecteurs de booléens les classificateurs dont les performances de test, respectivement d’entrainement, sont inférieures à la performance de test, resp d’entrainement médiane.

  • identifie dans des vecteurs de booléens les classificateurs qui ont sur-appris dans overfitted et respectivement sous-appris dans underfitted

  • ajoute deux colonnes au tableau puis le renvoit

show_source(analyze_model_experiments)

Puis on applique la fonction à compar_results

analyze_model_experiments(compar_results)

Exercice

Quelles sont les classificateurs qui ont sous-appris resp. sur-appris?

VOTRE RÉPONSE ICI

Comité de classificateurs et incertitude#

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

On pourrait considérer notre liste de classificateur model_list comme un comité de classificateurs, entraînés 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é simple: on définit les méthodes fit, predict, predict_proba et score comme faisant la synthèse des résultats des classificateurs 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)

On définit un comité à l’aide de ce code:

commitee = ClassifierCommittee(model_list)

Exercice

Quelles seraient les performances d’un tel classificateur? Calculer les performances de commitee

# VOTRE CODE ICI
raise NotImplementedError()

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

En effet, chaque classificateur 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 :

  • l’incertitude aléatorique : les classificateurs sont plus ou moins certains de leur prédiction.

  • l’incertitude épistémique : les classificateurs sont d’accord ou pas entre eux.

Ces incertitudes vont être d’autant plus fortes pour les images proches des frontières de décision (les bananes rouges par exemple). Intéressons nous aux images avec une faible incertitude épistémique (les classificateurs sont d’accord) et une grande incertitude aléatorique (les classificateurs 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)\]

\(x_i\) est la probabilité de classification sur la classe \(i\).

On récupère alors les prédictions de nos images pour les 11 classificateurs :

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 classificateur, nombre de classess). Appliquons l’entropie pour chaque prédiction de chaque classificateur :

from scipy.stats import entropy
entropies_per_classifier = entropy(np.swapaxes(prediction_probabilities, 0, 2))

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

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

Puis on ajoute ces valeurs dans une table 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,
                           "aleatoric" : entropies})
df_uncertainty = df_uncertainty.sort_values(by=['aleatoric'],ascending=False)
df_uncertainty.style.background_gradient(cmap='RdYlGn_r')

Affichons les 10 images les plus incertaines pour le comité de classificateurs, 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[:10])
# VOTRE CODE ICI
raise NotImplementedError()

Exercice

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

VOTRE RÉPONSE ICI

Incertitude épistémique#

Pour l’incertitude épistémique, on va simplement moyenner les écarts types entre entre les classificateurs. Comme on n’a que deux classes l’écart type entre les classificateurs pour la première classe est la même que pour la seconde:

# std entre les classificateurs
epistemic_uncertainty = np.std(prediction_probabilities[:,:,0], axis = 1)
print(epistemic_uncertainty.shape)

print(epistemic_uncertainty)

Exercice

Ajouter cette mesure au tableau df_uncertainty .

# VOTRE CODE ICI
raise NotImplementedError()
df_uncertainty = df_uncertainty.sort_values(by=['epistemic'],ascending=False)
df_uncertainty.style.background_gradient(cmap='RdYlGn_r')
df_uncertainty[["aleatoric",'epistemic']].corr()

Exercice

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

# VOTRE CODE ICI
raise NotImplementedError()

Exercice

Afficher les 10 images les plus certaines selon la mesure d’incertitude épistémique :

# VOTRE CODE ICI
raise NotImplementedError()

Exercice

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

VOTRE RÉPONSE ICI

Pour aller plus loin ♣: apprentissage profond#

En classe, vous avez vu ce qu’est un réseau de neurones artificiel et l”apprentissage profond. En résumé, il s’agit de modèles (classificateurs 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.

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 telles infrastructures sur le service JupyterHub de l’université.

Nous allons donc utiliser l”apprentissage par transfert (Transfer Learning) pour quand même faire du deep dans ce cours.

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. Il a été pré-entraîné pour reconnaître mille catégories différentes (dont des animaux, des objets de la vie courante etc.). Nous n’avons plus qu’à l’entraîner un peu plus pour notre tâche spécifique.

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 couches de ce réseau de neurones; chaque ligne ci-dessous décrit brièvement l’une des couches successives :

mobilenet.summary()

Exercice

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

VOTRE RÉPONSE ICI

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 geler 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 :

from tensorflow 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"),
    ]
)

Configurons le réseau mobilenet pour prendre en entrée des images de taille (32,32,3):

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?

# VOTRE CODE ICI
raise NotImplementedError()

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 (nimages, 32, 32, 3) c’est à dire (nombre d'images, largeur, hauteur, nombre de couches de couleurs) :

Exercice

Définissez le nombre d’images que vous avez dans nimages.

# VOTRE CODE ICI
raise NotImplementedError()
X = np.array(df_clean.drop(['class'], axis=1)).reshape((nimages, 32, 32, 3))

Il est nécessaire d’encoder les étiquettes sous la forme d’un one-hot vector c’est-à-dire d’un tableau y_onehot de taille (nombre d'images, nombre de classes) tel que y_onehot[i,j]==1 si la i-ème étiquette est la j-ème classe et 0 sinon. Dans notre cas, la i-ème ligne vaut (1,0) si le i-ème fruit est une pomme, et (0,1) si c’est une banane :

y = np.array(df_clean["class"])
classes = np.unique(y)
class_to_class_number = { cls: number for number, cls in enumerate(classes) }
y_onehot = np.zeros((len(y), len(np.unique(y))))
for i, label in enumerate(y):
    y_onehot[i, class_to_class_number[label]] = 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) :

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 !

Attention : Le temps de calcul étant potentiellement élevé, la cellule ci-dessous est désactivée par défaut. Mettez en commentaire la première ligne pour lancer le calcul.

%%script echo cellule désactivée

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 :

%%script echo cellule désactivée

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

Conclusion#

Cette feuille a fait un tour d’horizon d’outils de classification à votre disposition. Prenez ici quelques notes sur ce que vous avez appris, observé, interprété.

VOTRE RÉPONSE ICI

Faites maintenant votre propre analyse et préparez votre diaporama!