Extraction d’attributs#

Les traitements réalisés dans cette feuille sont majoritairement des rappels des semaines précédentes. Le but de cette feuille est de vous fournir une trame afin d’appliquer sereinement les traitements vus les semaines précédentes

La feuille comprends trois parties:

  1. Le prétraitement de vos données par extraction d’avant plan, recentrage et recadrage (rappels de la semaine 6)

  2. L’extraction d’attributs ad-hoc (rappels de la semaine 4)

  3. La sélection d’attributs pertinents

Dans un premier temps, vous exécuterez cette feuille sur le jeu de données de pommes et de bananes. Puis vous la reprendrez avec votre propre jeu de données, en visualisant les résultats à chaque étape, et en ajustant comme nécessaire.

1. Prétraitement (rappels de la semaine 6)#

Nous allons détecter le centre du fruit et recadrer l’image sur ce centre (comme en Semaine 6). Nous ferons ensuite une sauvegarde intermédiaire: Enfin nous enregistrerons les images prétraitées dans un dossier clean_data, ainsi qu’une table des pixels dans clean_data.csv.

Import des bibliothèques#

Nous commençons par importer les bibliothèques dont nous aurons besoin. Comme d’habitude, nous vous fournissons quelques utilitaires dans le fichier utilities.py. Vous pouvez ajouter vos propres fonctions au fur et à mesure du projet:

# Automatically reload code when changes are made
%load_ext autoreload
%autoreload 2
import os
from PIL import Image, ImageDraw, ImageFont
import matplotlib.pyplot as plt
%matplotlib inline
import scipy
from scipy import signal
import pandas as pd
import seaborn as sns
from glob import glob as ls
import sys
from sklearn.neighbors import KNeighborsClassifier
from sklearn.metrics import balanced_accuracy_score as sklearn_metric

from utilities import *
from intro_science_donnees import data
from intro_science_donnees import *

Pour avoir une première idée de l’impact des prétraitements sur la classification, nous calculerons au fur et à mesure les performances d’un classificateur – ici du plus proche voisin kNN – et nous les stockerons dans une table de données performances:

sklearn_model = KNeighborsClassifier(n_neighbors=3)
performances = pd.DataFrame(columns = ['Traitement', 'perf_tr', 'std_tr', 'perf_te', 'std_te'])
performances

Import des données#

En mettant en commentaire la ligne 2 ou la ligne 1, vous choisirez ici sur quel jeu de données le prétraitement s’applique: d’abord les pommes et les bananes, puis le vôtre.

dataset_dir = os.path.join(data.dir, 'ApplesAndBananas')
# dataset_dir = 'data'

images = load_images(dataset_dir, "?[012]?.png")
image_grid(images)

Calculons les performances de notre classificateur en l’appliquant directement à la représentation en pixels des images :

df_raw = images.apply(image_to_series)
df_raw['class'] = df_raw.index.map(lambda name: 1 if name[0] == 'a' else -1)

Exercice

Calculez la validation croisée en appliquant df_cross_validate à la table df_raw en nommant les performances p_tr et p_te ainsi que les écart-types s_tr et s_te, pour les ensembles d’entrainement et de test respectivement. La métrique de performance a été définie lors de l’import des librairies et se nomme sklearn_metric.

# Validation croisée
# VOTRE CODE ICI
raise NotImplementedError()
metric_name = sklearn_metric.__name__.upper()
print("AVERAGE TRAINING {0:s} +- STD: {1:.2f} +- {2:.2f}".format(metric_name, p_tr, s_tr))
print("AVERAGE TEST {0:s} +- STD: {1:.2f} +- {2:.2f}".format(metric_name, p_te, s_te))

Ajoutons ces résultats à notre table performances :

performances.loc[0] = ["Images brutes", p_tr, s_tr, p_te, s_te]
performances.style.format(precision=2).background_gradient(cmap='Blues')

Extraction de l’avant-plan#

Pour trouver le centre du fruit, il faut déjà arriver à séparer les pixels qui appartiennent au fruit de ceux qui appartiennent au fond. Souvenez-vous, vous avez déjà fait cela en Semaine 6 avec la fonction foreground_filter.

Dans l’exemple suivant, on utilise une variante foreground_redness_filter qui sépare le fruit du fond en prenant en compte la rougeur de l’objet et pas uniquement les valeurs de luminosité.

Attention

Comme pour foreground_filter, cette fonction a besoin d’un seuil (compris entre 0 et 1) sur les valeurs des pixels à partir duquel on décide s’il s’agit du fruit ou du fond. Avec les pommes et les bananes, on se contentera de la valeur 2/3. Avec vos données, faites varier cette valeur de seuil pour trouver la valeur qui semble la plus adéquate.

foreground_imgs = [foreground_redness_filter(img, theta=.75)
            for img in images]

image_grid(foreground_imgs)

On peut voir que selon s’il s’agit d’objets sombres sur fond clair ou d’objets clairs sur fond sombre, on n’obtient pas les mêmes valeurs booléenne en sortie de foreground_filter. La fonction invert_if_light_background inverse simplement les valeurs booléennes si une majorité de True est détectée. Voilà le résultat.

foreground_imgs = [invert_if_light_background(foreground_redness_filter(img, theta=.75))
            for img in images]
image_grid(foreground_imgs)

C’est légèrement mieux mais les images restent très bruitées; on choisit d’appliquer un filtre afin de réduire les pixels isolés. C’est ce qu’on fait avec la fonction scipy.ndimage.gaussian_filter().

foreground_imgs = [scipy.ndimage.gaussian_filter(
              invert_if_light_background(
                  foreground_redness_filter(img, theta=.6)),
              sigma=.2)
            for img in images]
image_grid(foreground_imgs)

Les traitements précédents résultent d’un certain nombre de choix faits au fil de l’eau (seuil, filtre par rougeur, débruitage).

Exercice

Définissez ci-dessous la fonction my_foreground_filter qui prend en entrée une image et:

  1. lui applique foreground_redness_filter

  2. inverse les valeurs booléennes si le fond est blanc avec la fonction invert_if_light_background

  3. applique le filtre gaussien et supprime les pixels isolés avec un sigma à 0.2

Cette fonction extrait donc l’objet de l’arrière-plan

# VOTRE CODE ICI
raise NotImplementedError()

Cette fonction fait partie intégrale de la narration des traitements que nous menons dans cette feuille. C’est pour cela que nous la définissons directement dans cette feuille, et non dans utilities.py comme on l’aurait fait pour du code réutilisable.

Détection du centre du fruit#

Nous allons à présent déterminer la position du centre du fruit, en prenant la moyenne des coordonnées des pixels de l’avant plan.

Faisons cela sur la première image:

img = images.iloc[0]
img

Exercice

Calculez l’avant plan de l’image dansforeground avec la fonction my_foreground_filter que vous venez de créer.

On extrait ensuite les coordonnées de ses pixels :

# VOTRE CODE ICI
raise NotImplementedError()
coordinates = np.argwhere(foreground)

que l’on affiche comme un nuage de points :

plt.scatter(coordinates[:,1], -coordinates[:,0], marker="x");

Exercice

Calculez les coordonnées du barycentre des pixels de l’avant plan – c’est-à-dire la moyenne des coordonnées sur les X et les Y – afin d’estimer les coordonnées du centre du fruit. On garde le résultat dans center. Pour rappel, vous avez déjà calculé cela en Semaine 6.

# VOTRE CODE ICI
raise NotImplementedError()
plt.scatter(coordinates[:,1], -coordinates[:,0], marker="x");
plt.scatter(center[0], -center[1], 300, c='r', marker='+',linewidth=5);

Ce n’est pas parfait: du fait des groupes de pixels à droite qui ont été détectées comme de l’avant plan, le centre calculé est plus à droite que souhaité. Mais cela reste un bon début.

Recadrage#

Maintenant que nous avons (approximativement) détecté le centre du fruit, il nous suffit de recadrer autour de ce centre. Une fonction crop_around_center est fournie pour cela. Comparons le résultat de cette fonction par rapport à crop_image utilisé en feuille2 :

crop_image(img) 

Exercice : Finissez d’écrire la fonction crop_around_center. Il ne manque que les coordonnées à droite (right) et en bas (bottom).

crop_around_center(img, center)

On constate que le recadrage sur le fruit est amélioré, même si pas encore parfait.

Récapitulatif du prétraitement#

À nouveau, nous centralisons tous les choix faits au fil de l’eau en une unique fonction effectuant le prétraitement. Cela facilite l’application de ce traitement à toute image et permet de documenter les choix faits :

def my_preprocessing(img):
    """
    Prétraitement d'une image
    
    - Calcul de l'avant plan
    - Mise en transparence du fond
    - Calcul du centre
    - Recadrage autour du centre
    """
    foreground = my_foreground_filter(img)
    img = transparent_background(img, foreground)
    coordinates = np.argwhere(foreground)
    if len(coordinates) == 0: # Cas particulier: il n'y a aucun pixel dans l'avant plan
        width, height = img.size
        center = (width/2, height/2)
    else:
        center = (np.mean(coordinates[:, 1]), np.mean(coordinates[:, 0]))
    img = crop_around_center(img, center)
    return img
plt.imshow(my_preprocessing(images.iloc[0]));

Exercice

Appliquez le prétraitement à toutes les images que vous mettrez dans clean_images.

# VOTRE CODE ICI
raise NotImplementedError()
image_grid(clean_images)

Performance de la classification après prétraitement#

Convertissons maintenant les images prétraitées dans leurs représentations en pixels, regroupées dans une table:

# conversion
df_clean = clean_images.apply(image_to_series)
# ajout des étiquettes
df_clean['class'] = df_clean.index.map(lambda name: 1 if name[0] == 'a' else -1)

Exercice

À l’aide de la fonction df_cross_validate, calculez les performance du classificateur sur les images prétraitées.

# VOTRE CODE ICI
raise NotImplementedError()

Exercice

Ajoutez ces résultats à la table performances.

# VOTRE CODE ICI
raise NotImplementedError()
performances.style.format(precision=2).background_gradient(cmap='Blues')

Sauvegarde intermédiaire#

Nous sauvegardons maintenant les images prétraitées dans le répertoire clean_data au format PNG :

os.makedirs('clean_data', exist_ok=True)
for name, img in clean_images.items():
    img.save(os.path.join('clean_data', os.path.splitext(name)[0]+".png"))

Explication

splitext sépare un nom de fichier de son extension :

os.path.splitext("machin.jpeg")

Nous sauvegardons la table de données dans un fichier clean_data.csv :

df_clean.to_csv('clean_data.csv')

Ainsi il sera possible de travailler sur la suite de cette feuille et les feuilles ultérieures sans avoir besoin de refaire le prétraitement :

df_clean = pd.read_csv('clean_data.csv', index_col=0)

Extraction des attributs (rappels de la semaine 4)#

Durant les semaines précédentes, vous avez déjà implémenté des attributs tels que :

  • La rougeur (redness) et l’élongation durant la semaine 4;

  • D’autres attributs (adhoc, pattern matching, PCA, etc.) durant le premier projet.

L’idée de cette section est de réappliquer ces attributs sur vos nouvelles données.

Filtres#

Récapitulons les types de filtres que nous avons en magasin:

La rougeur (redness)#

Il s’agit d’un filtre de couleur qui extrait la différence entre la couche rouge et la couche verte (R-G). Lors de la Semaine 2, nous avons fait la moyenne de ce filtre sur les fruits pour obtenir un attribut (valeur unique par image). Ici, affichons simplement la différence R-G :

image_grid([redness_filter(img)
            for img in clean_images])

Exercice

À quelles couleurs correspondent les zones claires resp. sombres? Pourquoi le fond n’apparaît-il pas toujours avec la même clarté?

VOTRE RÉPONSE ICI

Variante de la rougeur#

Il s’agit d’un filtre de couleur qui extrait la rougeur de chaque pixel calculée avec \(R-(G+B)/2\) :

image_grid([difference_filter(img)
            for img in clean_images])

Pour d’autres idées de mesures sur les couleurs, consulter cette page wikipédia.

Seuillage#

Souvenez vous que vous pouvez également seuiller les valeurs des pixels (par couleur ou bien par luminosité). C’est ce que l’on fait dans les fonctions foreground_filter ou foreground_color_filter.

Indication

N’oubliez pas de convertir les images en tableau numpy pour appliquer les opérateurs binaires <, >, ==, etc.

image_grid([np.mean(np.array(img), axis = 2) < 100 
            for img in clean_images])

Contours#

Pour extraire les contours d’une image (préalablement seuillée), on doit soustraire l’image seuillée avec elle même en la décalant d’un pixel vers le haut (resp. à droite). On fourni cette extraction de contours avec la fonction contours :

image_grid([contours(np.mean(np.array(img), axis = 2) < 100 ) 
            for img in clean_images])

Création d’attributs à partir des filtres#

Maintenant que nous avons récapitulé les filtres en notre possession, nous allons calculer un large ensemble d’attributs sur nos images. Une fois cet ensemble recueilli, nous allons ensuite sélectionner uniquement les attributs les plus pertinents.

On se propose d’utiliser trois attributs sur les couleurs:

  1. redness : moyenne de la différence des couches rouges et vertes (R-G), en enlevant le fond avec foreground_color_filter;

  2. greenness : La même chose avec les couches (G-B);

  3. blueness : La même chose avec les couches (B-R).

Ainsi que trois autres attributs sur la forme:

  1. elongation : différence de variance selon les axes principaux des pixels du fruits (cf Semaine2);

  2. perimeter : nombre de pixels extraits du contour;

  3. surface : nombre de pixels True après avoir extrait la forme.

Exercice

Créez la table df_features qui contient ces six paramètres ainsi que les attributs que vous avez implémenté dans le projet précédent.

Indications

Dans le Projet 1, nous avions défini la table ainsi:

df = pd.DataFrame({
        'redness':    mesimages.apply(redness),
        'elongation': mesimages.apply(elongation),
        'class':      mesimages.index.map(lambda name: 1 if name[0] == 'a' else -1),
})
df
# VOTRE CODE ICI
raise NotImplementedError()

df_features

Les amplitudes des valeurs sont très différentes. Il faut donc normaliser ce tableau de données (rappel de la semaine 3 et du projet

  1. afin que les moyennes des colonnes soient égales à 0 et les déviations standard des colonnes soient égales à 1.

Rappel: notez l’utilisation d’une toute petite valeur epsilon pour éviter une division par 0 au cas où une colonne soit constante :

epsilon = sys.float_info.epsilon
df_features = (df_features - df_features.mean())/(df_features.std() + epsilon) # normalisation 
df_features.describe() # nouvelles statistiques de notre jeu de donnée

On ajoute nos étiquettes (1 pour les pommes, -1 pour les bananes) dans la dernière colonne :

df_features["class"] = df_clean["class"]

Et on remplace les NA par des 0, par défaut:

df_features[df_features.isna()] = 0
df_features.style.background_gradient(cmap='coolwarm')

Exercice

Calculer les performances de notre classificateur sur les attributs et ajouter les à notre table performances :

# VOTRE CODE ICI
raise NotImplementedError()
performances.style.format(precision=2).background_gradient(cmap='Blues')

Sélection des attributs (nouveau!)#

Maintenant que nous avons extrait un ensemble d’attributs, nous souhaitons analyser lesquels améliorent le plus les performances de notre classificateur. Pour cela, nous tenterons deux approches :

  • Analyse de variance univariée : On considère que les attributs qui, pris individuellement, corrèlent le plus avec nos étiquettes amélioreront le plus la performance une fois groupés.

  • Analyse de variance multi-variée : On considère qu’il existe un sous-ensemble d’attributs permettant d’améliorer davantage les performances que les attributs étudiés séparément.

Analyse de variance univariée#

Dans cette approche, on commence par calculer les corrélations de chacun de nos attributs avec les étiquettes :

# Compute correlation matrix
corr = df_features.corr()
corr.style.format(precision=2).background_gradient(cmap='coolwarm')

Pour les pommes et les bananes, seule la « redness » resp. l“« elongation » a une légère correlation resp. anti-corrélation avec les étiquettes. Nous allons rajouter de nouveaux attributs sur les couleurs afin d’identifier s’il y aurait un attribut qui corrélerait davantage avec les étiquettes.

NB: On en profite pour renormaliser en même temps que l’on ajoute des attributs.

On utilise la fonction get_colors() donnée dans utilities

show_source(get_colors)

Exercice

Appliquez get_colors() à vos images clean_images.

# VOTRE CODE ICI
raise NotImplementedError()
s = df_features.iloc[3].replace({'redness': 4})
df_features
header = ['R','G','B','M=maxRGB', 'm=minRGB', 'C=M-m', 'R-(G+B)/2', 'G-B', 'G-(R+B)/2', 'B-R', 'B-(G+R)/2', 'R-G', '(G-B)/C', '(B-R)/C', '(R-G)/C', '(R+G+B)/3', 'C/V']

df_features_large = df_features.drop("class", axis = 1)

df_features_large = pd.concat([df_features_large, clean_images.apply(get_colors)], axis=1)

Exercice

Normalisez df_features_large. On utilisera un epsilon au cas où certaines valeurs de std() soit nulles.

# VOTRE CODE ICI
raise NotImplementedError()

Exercice

Remplacez les valeurs de NA par des 0 puis rajouter les étiquettes (1 pour les pommes, -1 pour les bananes) dans la dernière colonne (sans normalisation!)

# VOTRE CODE ICI
raise NotImplementedError()

df_features_large

On vérifie les performances de notre classificateur sur ce large ensemble d’attributs et on les ajoute à notre table performances :

# Validation croisée (LENT)
p_tr, s_tr, p_te, s_te = df_cross_validate(df_features_large, sklearn_model, sklearn_metric)
metric_name = sklearn_metric.__name__.upper()
print("AVERAGE TRAINING {0:s} +- STD: {1:.2f} +- {2:.2f}".format(metric_name, p_tr, s_tr))
print("AVERAGE TEST {0:s} +- STD: {1:.2f} +- {2:.2f}".format(metric_name, p_te, s_te))
performances.loc[3] = ["23 attributs ad-hoc", p_tr, s_tr, p_te, s_te]
performances.style.format(precision=2).background_gradient(cmap='Blues')

Exercice

Calculez la matrice de correlation comme on a fait plus haut.

# VOTRE CODE ICI
raise NotImplementedError()

Dans l’approche univariée, les attributs qui nous intéresse le plus sont ceux qui ont une grande corrélation en valeur absolue avec les étiquettes. Autrement dit, les valeurs très positives (corrélation) ou très négatives (anti-corrélation) de la dernière colonne sont intéressants pour nous.

On va donc ordonner les attributs qui corrèlent le plus avec nos étiquettes (en valeur absolue) :

# Sort by the absolute value of the correlation coefficient
sval = corr_large['class'][:-1].abs().sort_values(ascending=False)
ranked_columns = sval.index.values
print(ranked_columns) 

Sélectionnons seulement les trois premiers attributs et visualisons leur valeurs dans un pair-plot :

col_selected = ranked_columns[0:3]
df_features_final = pd.DataFrame.copy(df_features_large)
df_features_final = df_features_final[col_selected]

df_features_final['class'] = df_features_large["class"]
g = sns.pairplot(df_features_final, hue="class", markers=["o", "s"], diag_kind="hist")

Trouver le nombre optimal d’attributs#

On s’intéresse à présent au nombre optimal d’attributs. Pour cela, on calcule les performances en rajoutant les attributs dans l’ordre du classement fait dans la sous-section précédente (classement en fonction de la corrélation avec les étiquettes).

Exercice

Importez le modèle KNN avec trois voisins dans l’objet sklearn_model.

# On importe notre modèle
from sklearn.metrics import balanced_accuracy_score as sklearn_metric

# VOTRE CODE ICI
raise NotImplementedError()

feat_lc_df, ranked_columns = feature_learning_curve(df_features_large, sklearn_model, sklearn_metric)
plt.errorbar(feat_lc_df.index+1, feat_lc_df['perf_tr'], yerr=feat_lc_df['std_tr'], label='Training set')
plt.errorbar(feat_lc_df.index+1, feat_lc_df['perf_te'], yerr=feat_lc_df['std_te'], label='Test set')
plt.xticks(np.arange(1, 22, 1)) 
plt.xlabel('Number of features')
plt.ylabel(sklearn_metric.__name__)
plt.legend(loc='lower right');

Exercice Combien d’attributs pensez-vous utile de conserver? Justifiez.

VOTRE RÉPONSE ICI

Exercice

Exportez votre table df_features_final dans le fichier features_data.csv contenant les attributs utiles. Pour l’exemple, nous exporterons les trois premiers attributs comme dans l’exemple plus haut :

# VOTRE CODE ICI
raise NotImplementedError()

Enfin, on ajoute les performance de notre classificateur sur ce sous-ensemble d’attributs sélectionnées par analyse de variance univariée et on les ajoute à notre tableau de données performances :

# Validation croisée
p_tr, s_tr, p_te, s_te = df_cross_validate(df_features_final, sklearn_model, sklearn_metric)
metric_name = sklearn_metric.__name__.upper()
print("AVERAGE TRAINING {0:s} +- STD: {1:.2f} +- {2:.2f}".format(metric_name, p_tr, s_tr))
print("AVERAGE TEST {0:s} +- STD: {1:.2f} +- {2:.2f}".format(metric_name, p_te, s_te))
performances.loc[4] = ["3 attributs par analyse de variance univarié", p_tr, s_tr, p_te, s_te]
performances.style.format(precision=2).background_gradient(cmap='Blues')

♣ Analyse de variance multi-variée#

La seconde approche est de considérer les attributs de manière groupés et non pas par ordre d’importance en fonction de leur corrélation individuelle avec les étiquettes. Peut-être que deux attributs, ayant chacun une faible corrélation avec les étiquettes, permettront une bonne performance de classification pris ensemble.

Pour analyser cela, on considère toutes les paires d’attributs et on calcule nos performances avec ces paires. Si le calcul est trop long, réduisez le nombre d’attributs à considérer.

best_perf = -1
std_perf = -1
best_i = 0
best_j = 0
nattributs = 23
for i in np.arange(nattributs): 
    for j in np.arange(i+1,nattributs): 
        df = df_features_large[[ranked_columns[i], ranked_columns[j], 'class']]
        p_tr, s_tr, p_te, s_te = df_cross_validate(df_features_large, sklearn_model, sklearn_metric)
        if p_te > best_perf: 
            best_perf = p_te
            std_perf = s_te
            best_i = i
            best_j = j
            
metric_name = sklearn_metric.__name__.upper()
print('BEST PAIR: {}, {}'.format(ranked_columns [best_i], ranked_columns[best_j]))
print("AVERAGE TEST {0:s} +- STD: {1:.2f} +- {2:.2f}".format(metric_name, p_te, s_te))

Exercice

Quelle est la paire d’attributs qui donne les meilleurs performances? Est-ce que l’approche multi-variée est nécessaire avec les pommes et les bananes? Avec votre jeu de données?

VOTRE RÉPONSE ICI

Conclusion#

Cette feuille a fait un tour d’horizon d’outils à votre disposition pour le prétraitement de vos images et l’extraction d’attributs. Prenez ici quelques notes sur ce que vous avez appris, observé, interprété.

VOTRE RÉPONSE ICI

Si le cours de la Semaine 8 a eu lieu, vous pouvez passer à la feuille sur la comparaison de classificateurs!

Sinon, commencez votre propre analyse.