TD1: Introduction et notions fondamentales

Table des matières

vigneronnes.png

Figure 1 : Image générée par Midjourney

1. Préambule

1.1. Crédit

Ce TD reprend la trame du TD de classification non supervisée proposé par Mme. Arlette Antoni en 2019-2020. Le TD original était réalisé avec le logiciel R tandis que nous allons utiliser le logiciel Python.

1.2. Environnement logiciel

Les travaux se dérouleront sous Python. Dans ce TD, nous utiliserons en particulier les modules 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 ;
  • scipy, pour utiliser d'autres fonctions de calculs numériques plus "haut niveau", e.g. calcul de distances.

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

!pip install pandas
!pip install plotly
!pip install scipy

Rappel : code d'import des modules :

import pandas as pd
import numpy as np
import plotly as pl
import plotly.io as pio           # Nécessaire avec Spyder
pio.renderers.default = 'browser' # Nécessaire avec Spyder
import plotly.express as px
import scipy as sc
import scipy.spatial.distance as scd
import sklearn as sk

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

2. Introduction

L'objectif de ce TD est de mettre en pratique les principales notions vues en cours en les appliquant sur un jeu de données réel.

Pour ce faire, nous utiliserons une adaptation des données libres Wine Data Set ne conservant que les variables quantitatives pour la mise en oeuvre de méthodes de classification non supervisée.

Les données correspondent aux résultats d'analyses chimiques sur des vins issus d'une même région d'Italie mais produits par trois vignerons différents. L'analyse quantifie la présence des 13 éléments suivants :

  • Alcohol
  • Malic acid
  • Ash
  • Alcalinity of ash
  • Magnesium
  • Total phenols
  • Flavanoids
  • Nonflavanoid phenols
  • Proanthocyanins
  • Color intensity
  • Hue
  • OD280/OD315 of diluted wines
  • Proline

Les données retravaillées à utiliser dans ce TD sont disponibles à ici.

3. Chargement des données et premières analyses

  1. Charger les données dans votre environnement à l'aide de la fonction pd.read_csv.

    data_path = "https://roland-donat.github.io/cours-class-non-sup/td/td1/wine.csv"
    data_df = pd.read_csv(data_path, sep=",")
    
  2. Faire une rapide analyse exploratoire de vos données :
    • de manière macroscopique avec les méthodes .head, .describe et .info ;
    • de manière visuelle en représentant la distribution de chaque variable en utilisant la fonction px.box. Que constatez-vous ?

      data_df.describe()
      data_df.info()
      px.box(data_df, title="Boxplot de chaque variable (donnée originale)").show()
      
  3. Centrez les données.

    data_ctr_df = data_df - data_df.mean()
    
  4. Réduisez les données centrées précédemment et stocker le résultat dans un nouveau DataFrame nommé data_scaled_df. Aide : utilisez la méthode .std des DataFrame.

    # SOLUTION
    # --------
    data_scaled_df = data_ctr_df/data_df.std()
    
  5. Calculer la matrice des corrélations linéaires avec la méthode .corr des DataFrame. Quelles sont les caractéristiques remarquables de cette matrice ?

    data_corr_df = data_df.corr()
    
  6. Afficher un diagramme en paires et une carte de chaleur des corrélations linéaires.

    # Diagramme en paires
    px.scatter_matrix(data_df, 
                     title="Diagramme en paires des données origiales").show()
    # Carte de chaleur
    px.imshow(data_df.corr(), 
              color_continuous_midpoint=0, 
              title="Corrélations linéaires des données sous forme de carte de chaleur (heatmap)").show()
    

4. Distance euclidienne

Dans la suite du TD, nous allons travailler avec les données centrées réduites calculées précédemment dans le DataFrame data_scaled_df.

  1. Calculer la distance euclidienne entre les deux premiers individus en utilisant la méthode sum des DataFrame.

    d2_ind_01 = ((data_scaled_df.loc[0] - data_scaled_df.loc[1])**2).sum()
    d_ind_01 = d2_ind_01**(0.5)
    d_ind_01
    
  2. Calculer la distance euclidienne entre le premier individu et le troisième, puis entre le deuxième et le troisième.

    # SOLUTION
    # --------
    d2_ind_02 = ((data_scaled_df.loc[0] - data_scaled_df.loc[2])**2).sum()
    d_ind_02 = d2_ind_02**(0.5)
    d_ind_02
    
  3. Calculer la matrice des distances euclidiennes. Aide : utilisez les fonctions pdist et squareform du package scipy.spatial.distance. Quelles sont les dimensions de cette matrice, quelles sont ses propriétés remarquables ?

    dist_array = scd.pdist(data_scaled_df, metric="euclidean")
    dist_mat = scd.squareform(dist_array)
    dist_mat
    
  4. Transformer la matrice de distances précédente sous la forme d'un DataFrame pour en améliorer la lisibilité. Vérifier que les distances calculées aux points 1 et 2 grâce à la matrice des distances calculée au point 3.

    # SOLUTION
    # --------
    dist_mat_df = \
        pd.DataFrame(dist_mat,
                     index=data_scaled_df.index,
                     columns=data_scaled_df.index)
    dist_mat_df
    

5. Inertie totale

Dans la suite du TD, les données seront considérées comme étant équipondérées avec un poids de 1 pour chaque observation. Par ailleurs, la distance utilisée sera la distance euclidienne.

Nous travaillons toujours avec les données centrées réduites du DataFrame data_scaled_df.

  1. Calculer le centre de gravité du nuage d'observations. Que remarquez-vous ?

    mu_data = data_scaled_df.mean()
    mu_data
    
  2. Calculer les distances au carré entre les observations et le centre de gravité du nuage.

    # SOLUTION
    # --------
    d2_data_mu = ((data_scaled_df - mu_data)**2).sum(axis=1)
    d2_data_mu
    
  3. En déduire l'inertie totale des données.

    # SOLUTION
    # --------
    I_T = d2_data_mu.sum()
    I_T
    
  4. Calculer la somme des variances empiriques de chaque variable. Aide : utilisez la méthode .var des DataFrame ? Quels calculs fait exactement la méthode .var ? Aurait-on pu prévoir le résultat dans ce cas ?

    # SOLUTION
    # --------
    S2_var = data_scaled_df.var().sum()
    S2_var
    
  5. Calculer le rapport entre l'inertie totale et la somme des variances de chaque variable. Expliquer le résultat.

    # SOLUTION
    # --------
    I_T/S2_var
    

6. Première partition \(C_{\text{A}}\)

6.1. Construction des classes

Nous allons nous donner une première partition arbitraire, notée \(C_{\text{A}}\), consistant à affecter :

  • la classe \(c_{1}\) aux individus d'indices 0-49 ;
  • la classe \(c_{2}\) aux individus d'indices 50-99 ;
  • la classe \(c_{3}\) aux individus d'indices 100-177.

Rappel : N'oubliez pas que Python indexe les listes, vecteurs, tableaux en commençant à 0 !

  1. Ajoutez une nouvelle variable cls_A contenant la classe de chaque individu suivant le schéma décrit précédemment.

    data_scaled_df["cls_A"] = "c1"
    data_scaled_df["cls_A"].loc[50:99] = "c2"
    data_scaled_df["cls_A"].loc[100:] = "c3"
    
  2. Visualiser le nuage d'individus sur les variables OD280 et Alcohol en faisant apparaître votre partition. Pour ce faire, utiliser la fonction px.scatter avec l'option color pour colorer les individus en fonction de leur classe.

    px.scatter(data_scaled_df,
               x="OD280", y="Alcohol",
               color="cls_A",
               title="Visualisation de la partition A sur les données",
               labels={"cls_A": "Partition"}).show()
    
  3. Calculer l'effectif de chaque classe avec la méthode .value_counts.

    N_cls_A = data_scaled_df["cls_A"].value_counts()
    N_cls_A
    
  4. Réaliser les boxplot de chaque variable en fonction de leur classe.

    px.box(data_scaled_df.select_dtypes(include=np.number),
           color=data_scaled_df["cls_A"],
           title="Visualisation de la partition A sur les données",
           labels={"cls_A": "Partition"}).show()
    

6.2. Inertie intra-classe

  1. Calculer le centre de gravité de chaque classe. Pour ce faire, utiliser la méthode groupby des DataFrame afin de regrouper les données sur la variable de classe et réaliser les traitements appropriés sur chaque groupe.

    # Création des groupes selon la partition A
    data_cls_A_grp = data_scaled_df.groupby("cls_A")
    # Calcul des centres de chaque classe
    mu_cls_A = data_cls_A_grp.mean()
    mu_cls_A
    

    Pour accéder au centre de la classe "c1", utiliser l'accesseur .loc de la façon suivante mu_cls_A.loc["c1"].

  2. Calculez l'inertie interne de la classe "c1".

    # Récupération des observations de la classe "c1"
    data_c1_df = data_cls_A_grp.get_group("c1")
    d2_data_c1 = ((data_c1_df - mu_cls_A.loc["c1"])**2).sum(axis=1)
    I_W_c1 = d2_data_c1.sum()
    
  3. Calculer l'inertie interne de chacune des classes de la partition A. Pour rendre votre code générique et réutilisable, utiliser une boucle for. Pour vous aider, inspirez-vous du code à compléter suivant :

    # On initialise un vecteur de trois élements nommés c1, c2 et c3 ayant pour valeur 0.
    # Ce vecteur servira à récupérer l'inertie interne des 3 groupes dans la boucle.
    I_W_cls_A = pd.Series(0, index=mu_cls_A.index)
    # Note : il est possible d'itérer sur un objet `groupby` avec un couple de variables.
    #        Dans la boucle ci-dessous cls prendra successivement les valeurs "c1", "c2" et "c3" ;
    #        et data_cls_df contiendra successivement les individus des classes "c1", "c2" et "c3". 
    for cls, data_cls_df in data_cls_A_grp:
      # Calcul des distances au carré entre chaque individu de la classe courante avec le centre de cette classe.
      d2_data_cls = # À COMPLÉTER
      # Sommation des distances au carré pour obtenir l'inertie de la classe courante
      I_W_cls_A.loc[cls] = # À COMPLÉTER
    
    I_W_cls_A
    
    # SOLUTION
    # --------
    I_W_cls_A = pd.Series(0, index=mu_cls_A.index)
    for cls, data_cls_df in data_cls_A_grp:
      d2_data_cls = ((data_cls_df - mu_cls_A.loc[cls])**2).sum(axis=1)
      I_W_cls_A.loc[cls] = d2_data_cls.sum() 
    
    I_W_cls_A
    
  4. En déduire l'inertie intra-classe de la partition.

    # SOLUTION
    # --------
    I_W_A = I_W_cls_A.sum()
    I_W_A
    
  5. En déduire l'inertie inter-classe de la partition.

    # SOLUTION
    # --------
    I_B_A = I_T - I_W_A
    I_B_A
    
  6. Calculer le pourcentage d'inertie expliquée par la partition.

    # SOLUTION
    # --------
    PIE_A = 100*(1 - I_W_A/I_T)
    PIE_A_bis = 100*I_B_A/I_T
    (PIE_A, PIE_A_bis)
    
  7. Calculer la somme des variances empiriques corrigées des variables au sein de chaque classe.

    # SOLUTION
    # --------
    S2d_cls_A = data_cls_A_grp.var()
    S2d_cls_A_sum = S2d_cls_A.sum(axis=1)
    S2d_cls_A_sum
    
  8. Calculer le rapport des inerties internes et la somme des variances empiriques corrigées des variables au sein de chaque classe (résultat de la question précdente).

    # SOLUTION
    # --------
    I_W_cls_A/S2d_cls_A_sum
    
  9. Comment calculer l'inertie intra-classe en utilisant la somme des variances empiriques corrigées des variables au sein de chaque classe et l'effectif de chaque classe ?

    # SOLUTION
    # --------
    (S2d_cls_A_sum*(N_cls_A - 1)).sum()
    

6.3. Inertie inter-classe

  1. Calculer le carré des distances entre le centre de gravité des classes et le centre de gravité des données.

    # SOLUTION
    # --------
    d2_mu_cls_A = ((mu_cls_A - mu_data)**2).sum(axis=1)
    d2_mu_cls_A
    
  2. En déduire l'inertie inter-classe de la partition.

    # SOLUTION
    # --------
    # - On oublie pas de pondérer le calcul par le poids de chaque classe
    # - Ici omega = 1 pour tous les individus, donc poids de la classe k = effectif de la classe k
    I_B_A = (N_cls_A*d2_mu_cls_A).sum()
    I_B_A
    

7. Seconde partition \(C_{\text{B}}\)

Reprendre les questions de la partie précédente en construisant une partition \(C_{\text{B}}\) au hasard. Pour ce faire, utilisez la fonction np.ramdom.choice pour affecter les classes aux individus. N'hésitez pas à fixer la graine du générateur aléatoire avec la fonction np.random.seed afin de reproduire le "même hasard" d'une exécution à l'autre de votre script.

# On fixe la graîne du générateur de nombre aléatoire pour 
# reproduire le même "hasard" d'une exécution à l'autre
np.random.seed(56)

# Initalisation au hasard de la partition
data_scaled_df["cls_B"] = np.random.choice(["c1", "c2", "c3"],
                                            len(data_scaled_df))

N_cls_B = data_scaled_df["cls_B"].value_counts()
N_cls_B
# SOLUTION
# --------
# Dérouler tous les traitements de la partie précédente en changeant "cls_A" par "cls_B" ;)