Les biais dans les données#

Dans cette fiche, vous allez essayer de trouver des biais dans le jeu de données sur les pommes et les bananes proposé dans la fiche précédente. Pour cela, nous allons analyser les métadonnées. On les appelle métadonnées car ce sont des données à propos d’autres données. Dans notre cas, elles contiennent des informations sur la manière dont les images ont été collectés: quand, où, dans quelles conditions d’éclairages, etc.

La question qui se pose est: peut-on prédire les classes « pomme » ou « banane » sur la seule base de ces métadonnées? Si oui, cela mettrait en exergue un biais, ou un facteur de confusion, dans la manière dont les données ont été collectées.

Pour votre projet, nous vous encourageaons à:

  • Collecter des métadonnées. Pour les images prises avec un téléphone ou appareil photo, elles sont automatiquement enregistrées dans le fichier de l’image lui même.

  • Utiliser cette feuille sur vos propres données pour identifier les facteurs de biais potentiels dans la manière dont ont été collectés les données.

Les stratégies pour détecter et corriger les biais comprennent la stratification des données et l”inclusion des méta-données dans l’ensemble des variables prédictives. Consultez le cours pour plus de détails.

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 ipydatagrid import DataGrid
from PIL import Image
%load_ext autoreload
%autoreload 2
import warnings
warnings.filterwarnings("ignore")
from sys import path
from intro_science_donnees import *
from intro_science_donnees import data

from utilities import *

Étape 1: Charger les méta-données#

Les métadonnées ont été extraites des images et collectées dans un table stocké dans un fichier CSV (Comma Separated Value) qui peut être chargé comme une table Pandas (DataFrame):

dataset_dir = os.path.join(data.dir, 'ApplesAndBananas')
metadata = pd.read_csv(os.path.join(dataset_dir, 'metadata.csv'))
DataGrid(metadata)

Les métadonnées que nous avons comprennent des informations sur notre ensemble de photos telles que la date et l’heure de la prise de vue, les coordonnées GPS, la vitesse de l’obturateur, le temps de numérisation, le temps d’exposition ou bien la valeur de luminosité. Elles sont complétées par une étiquette (label) spécifiant le type de fruit(s) présent sur la photos.

En classe, nous avons analysé uniquement les images ne contenant qu’un seul fruit (une seule pomme ou une seule banane). Ici, les images peuvent contenir plusieurs fruits, voire aucun. Leur nombre est indiqué dans la colonne Count (attention, ce n’est pas un chiffre mais des mots clés).

Exercice: Sélectionner deux ensembles de données stockés dans les variable apple_subset et banana_subset contenant respectivement une seule pomme et une seule banane.

Indication: Le nombre de fruits sur la photo est indiqué dans la colonne Count

Rappel: la variable metadata est du type pandas.DataFrame. Rappelez-vous qu’on peut appliquer des filtres sur ses valeurs en utilisant des tableaux de booléens (indifféremment de type list, numpy.array ou pandas.Series). Par exemple, metadata["Color"] == "COLOR_NA" renverra une série de type pandas.Series de booléens avec des True quand la condition est vérifiée (c’est-à-dire que les fruits photographiés n’ont pas de couleur identifiée) ou False sinon. Ensuite, metadata[metadata["Color"] == "COLOR_NA"] renverra une table ne contenant que les lignes dont la couleur du fruit n’est pas identifié. Pour faire le lien avec le cours de Programmation Modulaire, vous pouvez voir cela comme une surcharge de l’opérateur == par la classe pandas.DataFrame.

# VOTRE CODE ICI
raise NotImplementedError()
assert apple_subset.shape == (333,16)
assert banana_subset.shape == (231,16)
assert banana_subset.loc[:, 'Fruit'].unique() == "BANANA"
assert apple_subset.loc[:, 'Fruit'].unique() == "APPLE"

Exercice: Les classes sont elles équilibrées dans le jeu de données (nombre d’échantillons pour chaque type de fruit)?

# VOTRE CODE ICI
raise NotImplementedError()

VOTRE RÉPONSE ICI

Puisque nous nous limitons au sous-ensemble de données où il n’y a qu’un seul fruit par image, on peut alors supprimer les lignes qui ne nous intéresse pas et la colonne Count

metadata = pd.concat([apple_subset, banana_subset])
metadata = metadata.drop(columns=['Count'])
metadata.shape

La méthode describe de Pandas vous permet de visualiser les statistiques sur des variables numériques uniquement. Cependant notre jeu de données contient de nombreuses variables catégorielles (encodées sous forme de chaînes de caractères). L’une des premières chose à faire sera donc de convertir les variables catégorielles en variables numériques. C’est ce que nous allons voir dans la suite.

metadata.describe(include="all")

Étape 2: Prétraitement des métadonnées#

Gestion des valeurs manquantes#

Souvent les jeux de données comportent des valeurs manquantes, encodées comme NaN (Not a Number). Le nôtre ne fait pas exception. C’est un problème pour certains algorithmes d’apprentissage automatique ou de méthodes statistiques, aussi faut-il gérer ces valeurs manquantes.

Rappel : en Semaine 2 (jeu de données du Titanic et des pays européens), nous avions vu et discuté les stratégies suivantes :

  • Si très peu d’échantillons sont concernés par rapport à la taille de l’ensemble du jeu de donnée, vous pouvez simplement les supprimer complètement.

  • Sinon, vous pouvez aussi remplacer les valeurs manquantes par une valeur fixe (moyenne, médiane, valeur la plus fréquente etc.). Votre choix devra être justifié.

Utiliser une valeur fixe peut mener à des échantillons incohérents. Par exemple, si vous avez des données physiologiques d’individus, remplacer les valeurs manquantes de tailles par la moyenne de toutes les tailles peut mener à des incohérences: un enfant de quelques mois pourrait se retrouver avec une taille de plus d’un mètre cinquante. Dans ce cas, il est possible d’utiliser un classificateur tel qu’un arbre de décision ou un kNN pour déduire la valeur approprié en fonction du reste des données (les autres colonnes). Nous n’utiliserons pas cette approche dans ce TP.

Ci-dessous, nous listons la ou les lignes comportant des valeurs manquantes:

na_val = check_na(metadata)

Exercice: Afficher une image d’une des lignes concernées par des valeurs manquantes. L’image semble-t-elle correcte?

# VOTRE CODE ICI
raise NotImplementedError()

VOTRE RÉPONSE ICI

Exercice: Traitez les données manquantes en supprimant les échantillons concernés dans la table metadata. Justifier pourquoi ce choix de traitement est raisonnable.

# VOTRE CODE ICI
raise NotImplementedError()
assert metadata.shape == (563,15)
assert check_na(metadata).empty

VOTRE RÉPONSE ICI

Conversion des variables catégorielles en variables numériques#

L’une de vos tâches consistera d’abord à convertir les variables catégorielles en variables numériques.

Exercice: Afficher le nom et le nombre de valeurs uniques de chaque colonne de metadata.

Indication : Utiliser la méthode unique() d’une série pandas.Series.

for col in metadata:
    # VOTRE CODE ICI
    raise NotImplementedError()

On peut également afficher les types de chaque attributs dans notre jeu de données.

metadata.convert_dtypes().dtypes

Subset, Fruit et Color sont de véritables variables catégorielles; les autres sont des variables qui peuvent être naturellement transformées en variables numériques.

Dans la section suivante, nous vous montrons un certain nombre d’astuces pour traiter ces dernières.

Conversion en valeurs numériques#

File#

Le nom des fichiers encode le numéro des images. Il est donc aisé de transformer la variable file en variable numérique en extrayant et convertissant ce numéro:

def get_file_num(s):
    start = 1 #file names start with a unique letter a or b
    end = s.find(".png")
    return int(s[start:end])

metadata['File'] = metadata['File'].apply(get_file_num)

metadata.head()

DateTime#

De même, la date peut-être encodé comme une valeur numérique; par convention, une telle date est représentée en interne par le nombre de secondes depuis le 1er janvier 1970. On peut la convertir de la manière suivante:

metadata['DateTime'] = pd.to_datetime(metadata['DateTime']).apply(lambda x: x.value)
metadata.head()

Complément: Ci-dessus, nous avons utilisé une fonction anonyme lambda x: x.value: c’est une fonction d’une seule ligne déclarée sans nom. Elle peut avoir un avoir un nombre quelconque d’arguments (ici x), mais ne peut avoir qu’une seule expression (ici: x.value). C’est un outil pratique lorsque l’on un besoin ponctuel d’une petite fonction triviale. Le nom lambda vient de la théorie du \(\lambda\)-calcul qui est un des fondements de la programmation fonctionnelle et de la théorie de la calculabilité.

GPSAltitude, GPSLatitude and GPSLongitude#

GPSAltitude est déjà numérique. Pour GPSLatitude et GPSLongitude, nous avons utilisons des routines provenant de ce forum et de celui-ci. qui convertissent les angles exprimés en degrés entiers et secondes d’arc en degrés décimaux:

def convert(tude):
    multiplier = 1 if tude[-1] in ['N', 'E'] else -1
    return multiplier * sum(float(x) / 60 ** n for n, x in enumerate(tude[:-1].replace(';','.').split('.')))

metadata['GPSLatitude'] = metadata['GPSLatitude'].apply(convert)
metadata['GPSLongitude'] = metadata['GPSLongitude'].apply(convert)
metadata.head()

GPSImgDirection and GPSDestBearing#

Nous modifions les valeurs de PSImgDirection et GPSDestBearing en nous appuyons sur les explications de ce forum de discussion :

import math
def convert(dir):
    f, s = dir.split('/')
    return 180*(float(f)/float(s))/math.pi
    
metadata['GPSImgDirection'] = metadata['GPSImgDirection'].apply(convert)
#metadata['GPSDestBearing'] = metadata['GPSDestBearing'].apply(convert)
metadata.head()
metadata['GPSDestBearing'] = metadata['GPSDestBearing'].apply(convert)
metadata.head()

ShutterSpeedValue#

De même, nous convertisons les valeurs de ShutterSpeedValue, selon ce forum. Cela correpond à la vitesse de fermeture de l’obturateur dans un appareil photo.

def convert(spval):
    f, s = spval.split('/')
    return 2**(-float(f)/float(s))

metadata['ShutterSpeedValue'] = metadata['ShutterSpeedValue'].apply(convert)
metadata.head()

SubsecTimeDigitized, ExposureTime and BrightnessValue#

SubsecTimeDigitized est déjà numérique: elle mesure le temps de numérisation de l’image prise.

Pour ExposureTime et BrightnessValue (temps d’exposition et luminosité), sont données par des nombres rationnels que nous convertissons en nombres flottants:

def convert(rat):
    f, s = rat.split('/')
    return float(f)/float(s)

metadata['ExposureTime'] = metadata['ExposureTime'].apply(convert)
metadata['BrightnessValue'] = metadata['BrightnessValue'].apply(convert)

metadata.head()

Conversion des valeurs catégorielle en numérique#

Fruit#

metadata['Fruit'].unique()

Nos catégories (ou classes) que l’on souhaite prédire ne sont qu’au nombre de deux.

Exercice: En utilisant une liste par compréhension et le dictionnaire ci-dessous, remplacer l’attribut Fruit (chaînes de caractères) par des valeur binaire entières (-1 ou 1 selon la catégorie).

labels = metadata['Fruit']
association = {'APPLE':1, 'BANANA':-1}
# VOTRE CODE ICI
raise NotImplementedError()

Rappel: En Python, un dictionnaire (de type dict) est composé de clés et de valeurs. Ici les clés sont “APPLE” et “BANANA” tandis que les valeurs associées aux clés sont 1 et -1. On peut accéder à la valeur d’une clé de la manière suivante association['APPLE'].

Rappel: Une liste par compréhension s’écrit comme:

`[f(<valeur>) for <valeur> in <itérateur> if <expression booléenne>]`. 

Avec f une fonction quelconque, <valeur> la valeur extraite d’un <itérateur> (liste, série pandas ou autre). Le résultat de l’expression f(<valeur>) est stockée dans la liste seulement si l”<expression booléenne> est vraie.

assert metadata['Fruit'].dtype == 'int64'

Subset et Color#

Pour les deux autres, examinons leurs histogrammes:

metadata['Subset'].hist();

Il semble qu’il y ait trois types de pommes et deux types de bananes.

metadata['Color'].hist()

De même, il y a trois couleurs de fruits et une catégorie de couleur non identifiée.

On peut utiliser la fonction pandas.get_dummies qui va automatiquement convertir tous les attributs multi-classe en plusieurs attributs binaires. Par exemple, on a deux attributs contenant respectivement 5 et 4 catégories. En appliquant cette fonction, on obtient 4+5=9 attributs binaires (contenant des 0 ou des 1).

metadata = pd.get_dummies(metadata)
metadata.head()

Pour plus de clarté, mettons la colonne « Fruit » en dernier car il s’agit de notre valeur cible.

cols = metadata.columns.tolist()
cols.remove("Fruit")
cols.append("Fruit")
metadata=metadata[cols]
metadata.head()

Nous avons terminé le pré-traitement des méta-données! La description statistique de nos données peut enfin être appliquée.

metadata.describe()

Étape 3 : Visualisation des données#

On s’intéresse à présent à la visualisation de nos données.

Exercice :

  • Visualiser la table metadata à l’aide d’une carte de chaleur:

# VOTRE CODE ICI
raise NotImplementedError()
  • Pour une visualisation plus compacte, utilisez la fonction sns.heatmap de la bibliothèque Seaborn.

# VOTRE CODE ICI
raise NotImplementedError()
  • Vous avez dû être décu ou décue par le résultat; c’est que heatmap fait l’hypothèse que la table est normalisée! Normalisez la table et stockez le résultat dans metadata_scaled (comme d’habitude, à l’exception des colonnes de fruits). Cela nous resservira par la suite.

# VOTRE CODE ICI
raise NotImplementedError()
metadata_scaled.describe()
assert metadata_scaled.shape == metadata.shape
assert (metadata_scaled['Fruit'] == metadata['Fruit']).all()
assert (abs(metadata_scaled.drop('Fruit', axis=1).std() - 1)<= 0.001).all()
assert (abs(metadata_scaled.drop('Fruit', axis=1).mean())<= 0.001).all()
  • Essayez à nouveau la fonction sns.heatmap:

# VOTRE CODE ICI
raise NotImplementedError()
  • À présent, visualisez la carte thermique de la matrice de corrélation de vos données.

# VOTRE CODE ICI
raise NotImplementedError()

Certains attributs des métadonnées sont très fortement (voir parfaitement) corrélés ou anti-corrélés. Identifier les attributs corrélés (sans considérer l’attribut Fruit pour le moment) et expliquer brièvement les raisons de leurs corrélations.

VOTRE RÉPONSE ICI

La fonction ci-dessous affiche une carte thermique clusterisée.

sns.clustermap(metadata.corr(), cmap="coolwarm");

Exercice: Pouvez-vous expliquer la signification de ce graphique?

VOTRE RÉPONSE ICI

La valeur absolue de la matrice de corrélation est également informative.

sns.clustermap(np.abs(metadata.corr()), cmap="coolwarm");

Interprétation#

Notre rôle à présent est d’interpréter les différentes corrélations entre les attributs listés ci-dessous. Ces corrélations sont-elles toutes légitimes? Lesquelles sont des biais?

cols

Exercice: Rapporter ci-dessous vos observations et interprétations des corrélations:

VOTRE RÉPONSE ICI

Diagramme de dispersion#

Pour obtenir des résultats intéressants avec des diagrammes de dispersion, nous devons simplifier la matrice de métadonnées.

Exercice: Creer la table metadata_subset avec les colonnes suivantes

  • Garder Num et supprimer DateTime (qui en principe devrait apporter la même information, mais semble bidon).

  • Garder les coordonnées GPS.

  • Ne garder que GPSImgDirection et supprimer le redondant GPSDestBearing.

  • Conserver uniquement BrightnessValue et supprimer les redondants ShutterSpeedValue et ExposureTime.

  • Conservez SubsecTimeDigitized.

  • Conservez Fruit comme étiquette et supprimer les autres variables catégorielles.

# VOTRE CODE ICI
raise NotImplementedError()
metadata_subset.head()
assert metadata_subset.shape == (563,8)
sns.pairplot(metadata_subset, hue="Fruit", diag_kind="hist", palette='Set2')

Des tendances intéressantes peuvent être observées : bien que certaines variables ne soient pas très corrélées, il existe des dépendances notables. Par exemple

  • Seules les pommes sont trouvées à basse altitude : cela peut être un biais.

  • Les bananes ont une valeur de luminosité plus élevée que les pommes.

Étape 4: Prédire les catégories#

Nous essayons maintenant de prédire la valeur cible (pomme ou banane) à partir des métadonnées en utilisant le classificateur à 3 plus proches voisins.

from sklearn.neighbors import KNeighborsClassifier
from sklearn.metrics import balanced_accuracy_score as sklearn_metric
sklearn_model = KNeighborsClassifier(n_neighbors=3)
p_tr, s_tr, p_te, s_te = df_cross_validate(metadata_subset, sklearn_model, sklearn_metric, n=10, verbose=False)
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))

De façon surprenante, nous prédisons la séparation avec une précision de 87 % sur les données de test. Quelles variables de métadonnées révèlent un biais? Lesquelles devraient être utilisées pour « ajuster » les résultats?

VOTRE RÉPONSE ICI

Il serait erroné d’utiliser la variable « Num » pour prédire la cible, car la plupart des images de pommes ont été collectées avant les bananes, donc inclure Num est une forme de « fuite de données ». Num ne semble pas être en corrélation avec d’autres variables, il est donc correct de la supprimer. Gardons toutes les autres.

new_subset = metadata_subset.drop(columns=['Num'])
p_tr, s_tr, p_te, s_te = df_cross_validate(new_subset, sklearn_model, sklearn_metric, n=10, verbose=False)
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))

Lorsque nous abandonnons « Num », nous ne pouvons pas vraiment faire de prédictions utiles, donc l’ensemble de données n’est pas trop biaisé. Néanmoins, on observe que “GPSAltitude” et “BrightnessValue” semblent être très prédictifs, étudions-les :

new_subset = metadata_subset[['GPSAltitude', 'BrightnessValue', 'Fruit']]
p_tr, s_tr, p_te, s_te = df_cross_validate(new_subset, sklearn_model, sklearn_metric, n=10, verbose=False)
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))

En effet, ils sont assez prédictifs. Nous devons approfondir nos recherches. Peut-être que l’altitude est révélatrice d’un arrière-plan différent ? Peut-être que les bananes ont été photographiées un jour plus ensoleillé ?

Étape 5: Interprétation des résultats#

# Décommenter si vous voulez vraiment charger les données (lent)

#all_files = load_images(data.dir+'/ApplesAndBananas/', "*.png")
#print('All CROPPED IMAGES')
#image_grid(all_files, columns = 10)

Vous trouverez ci-dessous une image de l’ensemble des données que vous pouvez recharger rapidement. Vos inquiétudes concernant l’altitude et la luminosité sont-elles légitimes ? Vérifiez s’il y a des images avec des fonds différents pour les pommes et les bananes (cela pourrait être un effet de l’altitude). Vérifiez si dans les images plus lumineuses, l’image entière est plus lumineuse (y compris le fond) ou seulement le fruit.

Note : dans cette image, les exemples ne sont pas ordonnés comme dans la base de données originale, toutes les pommes sont en premier et toutes les bananes sont en dernier.

img = Image.open('media/all_data.png')
img

Étape 6: Correction des facteurs de confusion#

Exercice: Devrions-nous corriger les facteurs de confusion ? Si oui, suggérez comment vous le feriez.

VOTRE RÉPONSE ICI

Conclusion#

Prenez ici quelques notes sur ce que vous avez appris dans cette feuille, puis passez à la feuille sur le l’analyse.

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