TD2: Partitionnement avec l'algorithme des k-Means

Table des matières

kmeans.png

Figure 1 : kmeans clustering spectacular application hyperrealistic white background (image générée par Midjourney)

Introduction

L'objectif de ce TD est d'expérimenter les méthodes de partitionnement en utilisant en particulier l'algorithme des moyennes mobiles (k-Means).

Nous testerons ces méthodes avec différents jeux de données afin d'illustrer leurs propriétés caractéristiques.

Enfin, nous tenterons d'implémenter notre propre version de l'algorithme k-Means en Python.

Modules Python utilisés dans ce TD

Dans ce TD, nous utiliserons les modules Python suivants :

  • pandas, pour la manipulation des données ;
  • plotly, pour les représentations graphiques ;
  • numpy, pour utiliser des fonctions de calculs numériques "bas niveau", e.g. génération de nombres aléatoires ;
  • scikit-learn, pour les algorithmes de machine learning (dont k-Means).

Ces modules ne sont pas forcément installés dans votre environnement local ou distant. Vous pouvez donc utiliser la commande !pip install <nom_module> pour les installer :

# À décommenter si besoin
#!pip install pandas=='1.1.5'
#!pip install plotly=='4.14.3'
#!pip install scikit-learn=='0.24.1'

Rappel : code d'import des modules :

import pandas as pd  # Manipulation des données
import numpy as np # Calcul numérique
import plotly as pl # Librairie principale pour avoir le n° de version
import plotly.express as px # Package plotly pour utiliser les visualisations de haut niveau
import plotly.io as pio           # Nécessaire avec Spyder
pio.renderers.default = 'browser' # Nécessaire avec Spyder
import sklearn as sk # Librairie principale pour avoir le n° de version
import sklearn.cluster as skc # Package sklearn dédié au clustering
import sklearn.decomposition as skd   # Package sklearn dédié aux méthodes factorielles

# Vérification des versions des librairies utilisées
{"plotly": pl.__version__, 
 "pandas": pd.__version__, 
 "numpy": np.__version__, 
 "sklearn": sk.__version__}

Exercice 1 : Données Wine

Nous allons reprendre les données des analyses chimiques sur le vin. Les données sont disponibles à ici.

Chargement des données

  1. Chargez les données Wine dans un DataFrame nommé wine_ori_df (ori pour originales).

    wine_path = "https://roland-donat.github.io/cours-class-non-sup/td/td1/wine.csv"
    wine_ori_df = pd.read_csv(wine_path, sep=",")
    

Moyennes mobiles

Nous allons utiliser l'algorithme des moyennes mobiles (k-Means) afin de partitionner automatiquement nos vins à partir de leurs caractéristiques physico-chimiques. Pour ce faire, le package sklearn.cluster (ou skc pour nous) de la librairie sklearn propose une implémentation de l'algorithme k-Means dans la classe skc.KMeans.

La grande majorité des algorithmes/modèles de machine learning disponibles dans la librairie sklearn fonctionne sur le principe suivant :

  1. Initialisation de la classe de l'algorithme avec les paramètres nécessaires.
  2. Utilisation de la méthode fit pour ajuster l'algorithme/modèle avec les données disponibles.
  3. Utilisation optionnelle de la méthode predict pour les méthodes permettant de faire des prédictions.

Dans la suite, nous déclinerons la démarche précédente dans le cas particulier de l'algorithme k-Means :

  1. Initialiser l'algorithme des moyennes mobiles sur les données wine_ori_df :

    km_ori = skc.KMeans(n_clusters=3)
    
    • L'objet km_ori représente l'algorithme des moyennes mobiles.
    • Le paramètre n_clusters permet de configurer le nombre de groupes recherché. Dans cet exemple, l'algorithme est prêt pour partitionner en 3 groupes.
    • Notons que pour l'instant, aucun traitement n'a été réalisé puisqu'aucune données n'a été mise en relation avec notre objet km_ori.
    • Question : D'après le cours, ne faut-il pas fournir un élément supplémentaire pour initialiser l'algorithme ?
  2. Utiliser ensuite la méthode fit pour lancer le partitionnement sur les données wine_ori_df :

    km_ori.fit(wine_ori_df)
    
    • La méthode fit exécute l'algorithme des k-Means tel que vu en cours. Des partitionnements alternés avec le calcul des centres associés sont effectués successivement jusqu'à la convergence de l'algorithme.
    • Selon votre version de sklearn, un message Warning concernant l'initialisation de l'algorithme peut apparaître. Ce message est à ignorer pour l'instant, nous reviendrons sur ce sujet ultérieurement.
    • Note : On pourra également désigner l'objet km_ori comme le modèle km_ori. En effet, on rappelle que la méthode k-Means (et plus largement les méthodes de classification non supervisée) servent à modéliser un ensemble d'individus à partir d'un ensemble restreint de groupes d'individus.
  3. Après partitionnement, l'objet km_ori possède de nouveaux attributs. Utiliser la documentation de la classe KMeans pour comprendre la signification des attributs suivants :

    km_ori.labels_
    
    km_ori.cluster_centers_
    
    km_ori.inertia_
    
  4. Calculer l'inertie expliquée par la partition obtenue avec le modèle km_ori.

    # SOLUTION
    # --------
    # Calcul de l'inertie totale des données
    wine_ori_it = ((wine_ori_df - wine_ori_df.mean())**2).sum(axis=1).sum()
    km_ori_ie = 1 - km_ori.inertia_/wine_ori_it
    
  5. Représenter graphiquement la partition obtenue avec le modèle km_ori à l'aide d'un diagramme en paires et analyser le résultat.

    px.scatter_matrix(wine_ori_df,
                      color=km_ori.labels_.astype(str), 
                      title="Partition km_ori").show()
    
  6. Analyser le profil moyen des groupes obtenus avec la partition km_ori.

    km_ori_mean = \
        pd.DataFrame(km_ori.cluster_centers_,
                     columns=wine_ori_df.columns)
    km_ori_mean
    
  7. Analyser à présent le profil statistique détaillé des groupes obtenus avec la partition km_ori.

    km_ori_prof = \
        wine_ori_df.groupby(km_ori.labels_).describe()
    km_ori_prof
    
  8. Représenter graphiquement les profils des groupes sous la forme d'un boxplot.

    px.box(wine_ori_df, color=km_ori.labels_.astype(str),
           title="Profils des groupes obtenus par la classification km_ori").show()
    
  9. Étudier le paramétrage de la classe skc.kMeans. Donner en particulier la signification des paramètres :
    • init ;
    • n_init ;
    • max_iter ;
    • tol ;
    • random_state.
  10. Réalisez un second modèle km_ori_2 paramétré comme suit :

    • recherche de 3 groupes ;
    • initialisation aléatoire (cf. paramètre init) contrôlée en fixant le paramètre random_state à la valeur 12345 ;
    • initialisation unique, cf. paramètre n_init.
    # SOLUTION
    # --------
    km_ori_2 = skc.KMeans(
        n_clusters=3,
        init="random",
        n_init=1,
        random_state=12345,
        )
    km_ori_2.fit(wine_ori_df)
    
  11. Calculer l'inertie expliquée par la partition obtenue avec le modèle km_ori_2. Comparer ce résultat avec l'inertie expliquée par le modèle km_ori.

    # SOLUTION
    # --------
    km_ori_2_ie = 1 - km_ori_2.inertia_/wine_ori_it
    
  12. Représenter graphiquement la partition obtenue avec le modèle km_ori_2 à l'aide d'un diagramme en paires et comparer le résultat obtenu.

    # SOLUTION
    # --------
    px.scatter_matrix(wine_ori_df,
                      color=km_ori_2.labels_.astype(str), 
                      title="Partition km_ori_2").show()
    

Partitionnement sur données centrées-réduites

Dans la section précédente, nous avons constaté que les partitions obtenues avec k-Means ne reposaient uniquement que sur les valeurs de la variable Proline. L'échelle de cette variable étant significativement supérieure à celle des autres variables, les calculs de distances entre les individus réalisés en dimension 13 au cours de la procédure k-Means sont approximativement identiques à des calculs de distance en dimension 1 sur la variable Proline. Ceci explique pourquoi les partitions élaborées font ressortir des groupes sur les nuages de points faisant intervenir la variable Proline uniquement.

Par conséquent, afin de limiter l'influence des échelles des variables, nous proposons de travailler dans la suite sur des données centrées et réduites.à

  1. Centrer et réduire les données afin d'obtenir un nouveau DataFrame wine_cr_df (cr pour centré-réduit).

    # SOLUTION
    # --------
    wine_cr_df = (wine_ori_df - wine_ori_df.mean())/wine_ori_df.std()
    
  2. Créer un modèle k-Means, nommé km_cr, permettant de partitionner les données en 3 groupes en utilisant le paramétrage par défaut de la classe skc.kMeans.

    # SOLUTION
    # --------
    km_cr = skc.KMeans(n_clusters=3)
    
  3. Utiliser ensuite la méthode fit pour lancer le partitionnement sur les données wine_cr_df :

    km_cr.fit(wine_cr_df)
    
  4. Calculer l'inertie expliquée par la parition obtenue avec le modèle km_cr.

    # SOLUTION
    # --------
    # Calcul de l'inertie totale des données
    wine_cr_it = ((wine_cr_df - wine_cr_df.mean())**2).sum(axis=1).sum()
    km_cr_ie = 1 - km_cr.inertia_/wine_cr_it
    
  5. Représenter graphiquement la partition obtenue avec le modèle km_cr à l'aide d'un diagramme en paires, analyser le résultat obtenu et comparer avec les partitions précédentes.

    # SOLUTION
    # --------
    px.scatter_matrix(wine_cr_df,
                      color=km_cr.labels_.astype(str), 
                      title="Partition km_cr").show()
    
  6. En déduire le profil moyen et le profil statistique complet des groupes obtenus avec la partition km_cr dans l'espace centré-réduit.

    # SOLUTION
    # --------
    km_cr_mean = \
        pd.DataFrame(km_cr.cluster_centers_,
                     columns=wine_cr_df.columns)
    km_cr_mean
    
    # SOLUTION
    # --------
    km_cr_prof = \
        wine_cr_df.groupby(km_cr.labels_).describe()
    km_cr_prof
    
  7. En déduire le profil moyen et le profil statistique complet des groupes obtenus avec la partition km_cr dans l'espace original (c'est à dire des données initiales sans transformation).

    # SOLUTION
    # --------
    km_cr_ori_mean = \
        wine_ori_df.groupby(km_cr.labels_).mean()
    km_cr_ori_mean
    
    # SOLUTION
    # --------
    km_cr_ori_prof = \
        wine_ori_df.groupby(km_cr.labels_).describe()
    km_cr_ori_prof
    
  1. Représenter et analyser graphiquement les profils des groupes sous la forme d'un boxplot. Dans quel espace l'interprétation des profils est-elle la plus aisée ? L'espace centré-réduit ou l'espace original ?

    # SOLUTION
    # --------
    # Espace centré-réduit -> profils interprétables mais ne pas oublier de tenir compte du centrage/réduction
    px.box(wine_cr_df, color=km_cr.labels_.astype(str),
           title="Profils des groupes obtenus par la classification km_cr (espace centré-réduit)").show()
    # Espace original -> profils interprétables directement du point de vu métier
    px.box(wine_ori_df, color=km_cr.labels_.astype(str),
           title="Profils des groupes obtenus par la classification km_cr (espace original)").show()
    

Partitionnement dans l'espace de l'ACP

  1. Réaliser une Analyse en Composantes Principales (ACP) sur les données centrées-réduites wine_cr_df en utilisant la classe PCA du package sklearn.decomposition (ou skd pour nous).

    wine_acp = skd.PCA().fit(wine_cr_df)
    wine_acp.explained_variance_ratio_
    
  2. Projeter les données dans l'espace de l'ACP en utilisant la méthode transform.

    wine_acp_df = pd.DataFrame(wine_acp.transform(wine_cr_df)) 
    wine_acp_df
    
  3. Créer un DataFrame wine_acp_2d_df correspondant aux données centrées-réduites projetées sur les deux premiers axes de l'ACP.

    # SOLUTION
    # --------
    wine_acp_2d_df = wine_acp_df[[0, 1]]
    
  4. Représenter graphiquement le nuage de points des données wine_acp_2d_df.

    px.scatter(wine_acp_2d_df,
               x=0, y=1,
               labels={"0":"Axe ACP 1", "1":"Axe ACP 2"},
               title="Projection des données sur les deux premiers axes de l'ACP").show()
    
  5. Réaliser un modèle k-Means, nommé km_acp, permettant de partitionner les données wine_acp_2d_df en 3 groupes. Visualiser la partition obtenue.

    # SOLUTION
    # --------
    km_acp = skc.KMeans(n_clusters=3).fit(wine_acp_2d_df)
    px.scatter(wine_acp_2d_df,
               x=0, y=1,
               color=km_acp.labels_.astype(str),
               labels={"0":"Axe ACP 1", "1":"Axe ACP 2"},
               title="Partition km_acp").show()
    
  6. En déduire les profils moyens et statistiques complets des groupes obtenus dans l'espace centré-réduit et l'espace original.

    # SOLUTION
    # --------
    km_acp_cr_mean = wine_cr_df.groupby(km_acp.labels_).mean()
    km_acp_cr_prof = wine_cr_df.groupby(km_acp.labels_).describe()
    
    km_acp_ori_mean = wine_ori_df.groupby(km_acp.labels_).mean()
    km_acp_ori_prof = wine_ori_df.groupby(km_acp.labels_).describe()
    
  7. Représenter et analyser graphiquement les profils des groupes sous la forme d'un boxplot. Dans quel espace est-il le plus pertinent d'analyser les groupes obtenus ? L'espace de l'ACP, l'espace centré-réduit, l'espace original ?

    # SOLUTION
    # --------
    # Espace ACP -> profils impossibles à interpréter
    px.box(wine_acp_2d_df, color=km_acp.labels_.astype(str),
           title="Profils des groupes obtenus par la classification km_acp (espace ACP)").show()
    # Espace centré-réduit -> profils interprétables mais ne pas oublier de tenir compte du centrage/réduction
    px.box(wine_cr_df, color=km_acp.labels_.astype(str),
           title="Profils des groupes obtenus par la classification km_acp (espace centré-réduit)").show()
    # Espace original -> profils interprétables directement du point de vu métier
    px.box(wine_ori_df, color=km_acp.labels_.astype(str),
           title="Profils des groupes obtenus par la classification km_acp (espace original)").show()
    
    
  8. Calculer l'inertie expliquée par la partition km_acp dans l'espace de l'ACP, dans l'espace centré-réduit et dans l'espace original.

    # SOLUTION
    # --------
    # Création d'une fonction permettant d'évaluer une partition
    def eval_partition(data_df, partition):
        """
        Entrées :
        - data_df (pandas.DataFrame) : Données quantitatives
        - partition (list, numpy.array ou pandas.Series) : partition des données
        Sortie :
        - inertie_intra : inertie intra-classe de la partition
        - inertie_score : Inertie expliquée par la partition entre 0 et 1
        """
    
        # Calcul de l'inertie totale des données (cf. TD1)
        mu_data = data_df.mean()
        d2_data_mu = ((data_df - mu_data)**2).sum(axis=1)
        inertie_totale = d2_data_mu.sum()
    
        # Calcul de l'inertie interne aux classes (cf. TD1)
        inertie_intra = 0
        for cls, data_cls_df in data_df.groupby(partition):
            # Centre de gravité de la classe cls
            mu_cls = data_cls_df.mean()
            # Distances au carré entre les données de la classe et le centre de la classe
            d2_data_cls = ((data_cls_df - mu_cls)**2).sum(axis=1)
            # Sommation pour obtenir l'inertie interne à la classe
            inertie_intra += d2_data_cls.sum()
      
        # Inertie expliquée par la partition
        inertie_score = 1 - inertie_intra/inertie_totale
    
        return inertie_intra, inertie_score
    
    
    # Utilisation de la fonction pour calculer l'inertie expliquée de la partition km_acp
    km_acp_iw, km_acp_ie = eval_partition(wine_acp_2d_df, km_acp.labels_)
    km_acp_cr_iw, km_acp_cr_ie = eval_partition(wine_cr_df, km_acp.labels_)
    km_acp_ori_iw, km_acp_ori_cr_ie = eval_partition(wine_ori_df, km_acp.labels_)
    

Exercice 2 : Compression d'images

Cet exercice propose d'explorer la façon dont les méthodes de classification non supervisée peuvent être appliquées pour la compression d'images et en particulier à la problématique de quantification en couleurs (color quantization en anglais). Ce traitement d'image vise à réduire le nombre de couleurs dans une image sans pour autant changer son aspect visuel général.

D'un point de vue informatique, une image est une série de pixels représentés par trois coordonnées associées à leur niveau de rouge, vert et bleu. Une image peut donc être considérée comme un tableau de données quantitatives à trois dimensions.

Chargement d'une image

  1. Installer et importer la librairie skimage permettant de faire du traitement d'image : import skimage.io.

    !pip install scikit-image
    
    import skimage                        # Librairie de traitement d'image
    import skimage.io                     # Package skimage dédié au chargement et la sauvegarde des images
    
  2. Utiliser la fonction skimage.io.imread pour lire ce fichier image.

    image_path = "https://roland-donat.github.io/cours-class-non-sup/td/td2/streetball.jpg"
    image_data = skimage.io.imread(image_path)
    px.imshow(image_data)
    
  3. Transformer l'image en un DataFrame à trois variables en utilisant la méthode .reshape des numpy.array.

    image_nb_pixels = image_data.shape[0]*image_data.shape[1]
    image_data_2d = image_data.reshape((image_nb_pixels, 3))
    image_data_df = pd.DataFrame(image_data_2d, columns=["R", "G", "B"])
    

Application des moyennes mobiles

  1. Appliquez la méthode des moyennes mobiles afin de partitionner les données de pixels en 4 classes. Le modèle k-Means utilisé sera appelé km_image.

    # SOLUTION
    # --------
    km_image = skc.KMeans(n_clusters=4).fit(image_data_df)
    
  2. Reconstruire une image en remplaçant chaque pixel par le centre de sa classe.

    image_data_clust = km_image.cluster_centers_[km_image.labels_]
    
  3. Afficher l'image obtenue sans oublier de retransformer les données de pixels dans la forme de l'image originale.

    px.imshow(image_data_clust.reshape(image_data.shape))
    
  4. Recommencer le traitement en jouant sur le nombre de classes de la partition et tenter d'interpréter les résultats.

Méthode du coude

  1. Partitionner l'image en faisant varier le nombre de classes et en calculant les inerties expliquées correspondantes.

    # Calcul de l'inertie totale
    image_data_it = ((image_data_df - image_data_df.mean())**2).sum(axis=1).sum()
    
    # Méthode du coude
    inertie_ie = []
    K_list = range(2, 20)
    for k in K_list:
        print(f"# groupes = {k}")
        # Calcul d'une partition à k classes
        km_image = skc.KMeans(n_clusters=k, n_init='auto').fit(image_data_df)
        inertie_ie.append(1 - km_image.inertia_/image_data_it)
    
  2. Représenter graphiquement l'évolution de l'inertie expliquée en fonction du nombre de classes. Comment interpréter l'inertie expliquée dans cette application ?

    # Représentation graphique des résultats
    px.line(x=K_list, y=inertie_ie, 
            title="Inertie expliquée vs nb de classes (méthode du coude)",
            markers=True,
            width=800, height=800)
    

Exercice 3 : Implémentation des moyennes mobiles

Comme on ne maîtrise jamais vraiment un algorithme tant que l'on ne l'a pas programmé, cette dernière partie propose de créer votre propre version de la méthode des moyennes mobiles.

Pour ce faire, il vous faudra créer les trois fonctions suivantes :

  1. La fonction calcule_centres qui calcule le centre de chaque classe à partir de données quantitative et d'une partition.

    # SOLUTION
    # --------
    def calcule_centres(data, partition):
      return data.groupby(partition).mean()
    
  2. La fonction affecte_classe qui prend en entrée des données et les centres des classes et qui affecte à chaque individu la classe ayant le centre le plus proche au sens de la distance euclidienne. La fonction cdist de package scipy.spatial.distance peut être très utile…

    # SOLUTION
    # --------
    # Pour faciliter le calcul des distances
    # avec la fonction scd.cdist
    import scipy.spatial.distance as scd 
    
    def affecte_classe(data, centres):
        dist_cls_centers = scd.cdist(data, centres, metric="euclidean")
        return dist_cls_centers.argmin(axis=1)
    
  3. La fonction my_kmeans qui prend en entrée des données et un nombre de classes et qui applique la méthode des moyennes mobiles. L'initialisation est supposée aléatoire et on arrêtera l'algorithme dès que la partition construite n'évolue plus entre deux itérations. Cette fonction doit utiliser vos fonctions calcule_centres et affecte_classe.

    # SOLUTION
    # --------
    def my_kmeans(data, K):
      
      # Partition initiale
      partition = np.random.choice(K, len(data))
      
      critere_arret = False
      
      while not critere_arret:
    
        # On enregistre la dernière partition
        partition_old = partition
        
        # Calcul des nouveaux centres
        #centres = <à compléter>
    
        # Affectation des classes
        #partition = <à compléter>
        
        # Calcul du critère d'arrêt
        critere_arret = (partition == partition_old).all()
    
      return partition, centres
    
  4. Utiliser votre algorithme sur les données des exercices précédents et comparer vos résultats avec la méthode KMeans de scikit-learn.