Clustering¶
La notion de clustering recouvre un ensemble de procédés qui permettent, à partir d’une base de données, de regrouper les données similaires en agrégats, ou clusters, afin de réduire leur complexité apparente et de mettre à jour leur structure. Les classifications ainsi obtenues peuvent ensuite être utilisées dans diverses applications. Par exemple,
- En bio-informatique, des méthodes de clustering sont utilisées pour grouper les espèces d’êtres vivants similaires, afin de pouvoir généraliser à tout le groupe les propriétés connues d’une espèce particulière. Par exemple, un traitement efficace contre un certain agent pathogène peut s’avérer aussi efficace à l’égard d’autres micro-organismes du même cluster.
- Dans le domaine de la vision, le nombre de pixels de différentes nuances de gris dans une image peut être réduit en regroupant plusieurs pixels en un même cluster, et en leur attribuant une nuance de gris commune.
- La classification des utilisateurs d’un site internet à partir de leurs habitudes de consultation du site peut permettre de concevoir des interfaces différentes pour chaque classe, adaptées à leurs attentes spécifiques. De façon similaire, la classification du contenu d’un site internet peut aider à décider comment diviser ce contenu en rubriques thématiques.
Dans cette série d’exercices, nous vous proposons de travailler avec deux sortes de données :
- Une liste de maladies avec leurs symptômes. La classification de ces maladies doit permettre d’identifier des groupes d’affections similaires, susceptibles d’être traitées par des traitements similaires.
- Une liste d’entreprises actives dans le domaine de l’informatique, dont une classification en différents groupes doit permettre d’identifier les concurrents proches sur le marché.
Modules squelettes¶
Voici pour commencer les modules partiellement implémentés qui serviront de base à nos exercices. Le dernier constitue un module de test.
Module .../moteurs_clustering/cluster.py :
class Cluster:
""" Représentation d'un cluster générique. """
def __init__(self, donnees, nom=''):
""" Initialise un cluster avec un nom et une liste de données.
:param list donnees: les données du cluster.
:param str nom: le nom du cluster.
"""
self.donnees = []
self.ajoute_donnees(donnees)
self.nom = nom
def ajoute_donnee(self, donnee):
""" Ajoute une donnée au cluster.
:param donnee: la donnée à ajouter.
"""
self.donnees.append(donnee)
def ajoute_donnees(self, donnees):
""" Ajoute une liste de données au cluster.
:param list donnees: les données à ajouter.
"""
for donnee in donnees:
self.ajoute_donnee(donnee)
Module .../moteurs_clustering/cluster_mean.py :
from .cluster import Cluster
class ClusterMean(Cluster):
""" Représentation d'un cluster utilisé dans l'algorithme du\
k-means.
"""
def __init__(self, donnees, nom):
""" Initialise le cluster avec un nom et une liste de données.
:param list donnees: les données du cluster.
:param str nom: le nom du cluster.
"""
Cluster.__init__(self, donnees, nom)
self.noyau = self.donnees[0] if len(self.donnees) > 0 else None
def centre(self, dist_f):
""" Recentre le noyau du cluster en fonction des données qu'il contient.
:param dist_f: la fonction de distance entre deux données.
"""
print('à compléter')
def vide(self, garde_noyau=False):
""" Vide la liste des données du cluster avec l'option de garder le\
noyau.
"""
if garde_noyau:
self.donnees = [self.noyau]
else:
self.donnees = []
self.noyau = None
def __repr__(self):
""" Représentation d'un cluster sous forme de string. """
rep = 'Cluster {}: \n'.format(self.nom)
for donnee in self.donnees:
indent = '--->' if donnee == self.noyau else ' '*4
rep += '{}{}\n'.format(indent, donnee)
return rep
Module .../moteurs_clustering/cluster_hierarchique.py :
from .cluster import Cluster
class ClusterHierarchique(Cluster):
""" Représentation d'un cluster utilisé dans l'algorithme de\
clustering hiérarchique.
"""
def __init__(self, donnees, gauche=None, droite=None):
"""
:param list donnees: les données du cluster.
:param ClusterHierarchique gauche: le sous-cluster de gauche.
:param ClusterHierarchique droite: le sous-cluster de droite.
"""
Cluster.__init__(self, donnees)
self.gauche = gauche
self.droite = droite
def est_terminal(self):
""" Teste si le cluster courant est terminal (c'est-à-dire s'il\
n'a pas de sous-clusters ni à gauche ni à droite).
:return: ``True`` quand le cluster courant est terminal.
"""
return self.gauche is None and self.droite is None
def repr_hierarchie(self, level=0):
""" Représentation sous forme de string de la hiérarchie\
de laquelle le cluster courant est la racine.
"""
if self.est_terminal():
rep = ' '*(level-1) + '|---' + str(self.donnees[0]) + '\n'
else:
rep = ' '*level + '|---' + '\n'
if self.gauche is not None:
rep += self.gauche.repr_hierarchie(level+1)
if self.droite is not None:
rep += self.droite.repr_hierarchie(level+1)
return rep
def __repr__(self):
""" Représentation du cluster sous forme de string. """
return 'Cluster racine: \n{}'.format(self.repr_hierarchie(level=0))
Module .../moteurs_clustering/clustering.py :
class Clustering:
"""
Classe générique pour le clustering.
Devra être sous-classée selon le type de\
clustering.
"""
def __init__(self):
self.clusters = []
def initialise_clusters(self, donnees):
""" Initialise les clusters (à implémenter différemment pour le\
clustering k-means et le clustering hiérarchique).
:param list donnees: les données à regrouper dans des clusters.
"""
return []
def revise_clusters(self):
""" Révise les clusters (à implémenter différemment pour le clustering\
k-means et le clustering hiérarchique).
"""
return []
def fini(self, anciens_clusters):
""" Teste si les clusters ont changé par rapport aux anciens clusters
(à implémenter différemment pour le clustering k-means\
et le clustering hiérarchique).
:param list anciens_clusters: la liste des anciens clusters.
"""
return False
def itere(self, donnees):
""" Regroupe les données dans des clusters de façon itérative.
:param list donnees: les données à regrouper dans des clusters.
"""
# Sauvegarde des clusters.
anciens_clusters = []
self.initialise_clusters(donnees)
# Continue le clustering tant que les nouveaux clusters ont changé par
# rapport à l'itération précédente.
while not self.fini(anciens_clusters):
anciens_clusters = self.clusters[:]
self.revise_clusters()
Module .../moteurs_clustering/clustering_kmeans.py :
from .cluster_mean import ClusterMean
from .clustering import Clustering
class ClusteringKMeans(Clustering):
""" K-means clustering. """
def __init__(self, k, dist_f):
"""
:param k: le nombre de clusters à construire.
:param dist_f: la fonction de distance entre deux données.
"""
super().__init__()
self.k = k
self.dist_f = dist_f
def noyaux(self, clusters):
""" Extrait les noyaux d'une liste de clusters.
:param list clusters: une liste de clusters dont les noyaux doivent\
être retournés.
:return: la liste des noyaux des clusters.
"""
return [cluster.noyau for cluster in clusters]
def initialise_clusters(self, donnees):
""" Initialise les clusters.
:param list donnees: les données à regrouper dans des clusters.
"""
print('à compléter')
def fini(self, anciens_clusters):
""" Teste si les clusters ont changé par rapport aux anciens clusters.
C'est le cas si les noyaux ont changé depuis l'itération précédente.
:param list anciens_clusters: la liste des anciens clusters.
"""
print('à compléter')
def revise_clusters(self):
""" Révise les clusters. """
print('à compléter')
def affiche_clusters(self):
""" Affiche les clusters construits par l'algorithme."""
print('\n'.join([str(cluster) for cluster in self.clusters]))
Module .../moteurs_clustering/clustering_hierarchique.py :
from .cluster_hierarchique import ClusterHierarchique
from .clustering import Clustering
class ClusteringHierarchique(Clustering):
""" Clustering hiérarchique. """
liens = {
'single': min,
'complete': max,
}
def __init__(self, type_lien, dist_f):
"""
:param str lien: le type de distance entre deux clusters,\
'single' ou 'complete'.
:param dist_f: la fonction de distance entre deux données.
"""
super().__init__()
self.dist_f = dist_f
# Permet d'utiliser min ou max de manière générique en fonction du
# paramètre type_lien.
self.lien = self.liens[type_lien]
def fusionne_clusters(self, cluster1, cluster2):
""" Fusionne deux clusters.
Le nouveau cluster contiendra ``cluster1`` à gauche et ``cluster2``\
à droite.
:param cluster1: un noeud qui ira à droite du nouveau cluster.
:param cluster2: un noeud qui ira à gauche du nouveau cluster.
:return: le nouveau cluster.
"""
donnees = cluster1.donnees + cluster2.donnees
return ClusterHierarchique(donnees, cluster1, cluster2)
def calcule_distance(self, cluster1, cluster2):
""" Calcule la distance entre deux clusters. """
print('à compléter')
def initialise_clusters(self, donnees):
""" Initialise les clusters.
:param list donnees: les données à regrouper dans des clusters.
"""
print('à compléter')
def fini(self, anciens_clusters):
""" Teste si les clusters ont changé par rapport aux anciens clusters.
C'est le cas si leur nombre a diminué.
:param list anciens_clusters: la liste des anciens clusters.
"""
print('à compléter')
def revise_clusters(self):
""" Révise les clusters. """
print('à compléter')
def affiche_clusters(self):
""" Affiche les clusters découverts par l'algorithme."""
print('\n'.join([str(cluster) for cluster in self.clusters]))
Module .../exemple_clustering.py :
from sys import argv, exit
from moteurs_clustering.clustering_kmeans import ClusteringKMeans
from moteurs_clustering.clustering_hierarchique import ClusteringHierarchique
def distance(donnee1, donnee2):
""" Calcule la distance entre deux données en comptant le nombre\
d'attributs non identiques.
:param donnee1: la première donnée.
:param donnee2: la seconde donnée.
"""
if len(donnee1) != len(donnee2):
raise Exception('Les deux données doivent avoir le même nombre d\'attributs.')
return sum(attrib1 != attrib2 for attrib1, attrib2 in zip(donnee1, donnee2))
def est_entier_positif(s):
" Teste si une string représente un entier positif. "
try:
return int(s) > 0
except ValueError:
return False
profits = [
('down', 'old', 'no', 'software'),
('down', 'midlife', 'yes', 'software'),
('up', 'midlife', 'no', 'hardware'),
('down', 'old', 'no', 'hardware'),
('up', 'new', 'no', 'hardware'),
('up', 'new', 'no', 'software'),
('up', 'midlife', 'no', 'software'),
('up', 'new', 'yes', 'software'),
('down', 'midlife', 'yes', 'hardware'),
('down', 'old', 'yes', 'software'),
]
maladies = [
('angine-érythémateuse', 'élevée', 'gonflées', 'oui', 'oui', 'non', 'non',
'non', 'normale', 'normales', 'normaux'),
('angine-pultacée', 'élevée', 'points-blancs', 'oui', 'oui', 'non', 'non',
'non', 'normale', 'normales', 'normaux'),
('angine-diphtérique', 'légère', 'enduit-blanc', 'oui', 'oui', 'non', 'non',
'non', 'normale', 'normales', 'normaux'),
('appendicite', 'légère', 'normales', 'non', 'non', 'oui', 'non',
'non', 'normale', 'normales', 'normaux'),
('bronchite', 'légère', 'normales', 'oui', 'non', 'non', 'oui',
'oui', 'gênée', 'normales', 'normaux'),
('coqueluche', 'légère', 'normales', 'non', 'oui', 'non', 'oui',
'oui', 'gênée', 'normales', 'normaux'),
('pneumonie', 'élevée', 'normales', 'non', 'non', 'non', 'oui',
'non', 'rapide', 'rouges', 'normaux'),
('rougeole', 'légère', 'normales', 'non', 'oui', 'non', 'oui',
'oui', 'normale', 'normales', 'larmoyants'),
('rougeole', 'légère', 'normales', 'non', 'oui', 'non', 'oui',
'oui', 'normale', 'taches-rouges', 'larmoyants'),
('rubéole', 'légère', 'normales', 'oui', 'non', 'non', 'non',
'non', 'normale', 'taches-rouges', 'normaux'),
('rubéole', 'non', 'normales', 'oui', 'non', 'non', 'non',
'non', 'normale', 'taches-rouges', 'normaux'),
('rubéole', 'non', 'normales', 'oui', 'non', 'non', 'non',
'non', 'normale', 'normales', 'normaux'),
]
if len(argv) < 4:
print('On attend trois arguments: ' +
'type des exemples ("profits", "maladies"), ' +
'nombre de clusters (pour le clustering k-means), '
'type de lien ("single", "complete", pour le clustering hiérarchique)')
exit(1)
if argv[1].lower() == 'profits':
donnees = profits
elif argv[1].lower() == 'maladies':
donnees = maladies
else:
print('Type des exemples accepté : profits ou maladies')
exit(1)
if not est_entier_positif(argv[2]):
print('Nombre de clusters accepté : entier positif')
exit(1)
k = int(argv[2])
if argv[3].lower() not in ('single', 'complete'):
print('Type de lien accepté : single ou complete')
exit(1)
type_lien = argv[3]
clustering = ClusteringKMeans(k, distance)
clustering.itere(donnees)
print('Clustering k-means:')
clustering.affiche_clusters()
clustering = ClusteringHierarchique(type_lien, distance)
clustering.itere(donnees)
print('Clustering hiérarchique:')
clustering.affiche_clusters()
L’algorithme général de clustering¶
Les deux algorithmes de clustering que vous allez implémenter sont des algorithmes itératifs, qui construisent des clusters en suivant l’algorithme général ci-dessous :
clusters <- liste vide
Clustering(données)
1. anciens_clusters <- liste vide
2. clusters <- initialise_clusters(données)
3. WHILE NOT fini(anciens_clusters) DO
4. anciens_clusters <- sauvegarde des clusters
5. clusters <- revise_clusters(clusters)
6. END WHILE
END Clustering
La classe Cluster¶
La classe Cluster représente un cluster défini par un nom (optionnel) et par une liste de données. Cette classe contient donc deux attributs :
donnees: la liste des données du cluster ;nom: le nom (optionnel) du cluster.
Cette classe contient aussi des méthodes utilitaires qui permettent d’ajouter des données aux clusters.
La classe Clustering¶
La classe Clustering implémente l’algorithme général de clustering que nous avons vu ci-dessus. La méthode itere implémente cet algorithme en appelant trois méthodes : initialise_clusters, revise_clusters et fini ; elle joue donc le rôle d’un wrapper pour ces trois méthodes. itere initialise les clusters, puis les révise de manière itérative jusqu’à ce qu’ils soient stabilisés, et ne changent plus d’une itération à l’autre.
Clustering impose une structure générale que ses sous-classes sont contraintes de respecter. Afin d’obtenir le comportement désiré pour le clustering
-means et le clustering hiérarchique, les méthodes initialise_clusters, revise_clusters et fini doivent cependant être implémentées différemment dans chaque sous-classe.
Exercice 1 : Le clustering
-means (clustering de partitionnement)¶
L’algorithme
-means part d’une liste de noyaux, qui sont des données autour de chacune desquelles sera construit un cluster. Au cours d’une itération, chaque donnée est réaffectée au cluster du noyau duquel elle est le plus proche. Chaque cluster est ensuite recentré autour d’un nouveau noyau. L’algorithme se termine quand l’ensemble des noyaux n’a pas changé d’une itération à la suivante. Ils existe plusieurs méthodes pour recentrer un noyau, qui donnent lieu à différentes variantes de l’algorithme. Dans cet exercice, nous appliquerons une variante adaptée à l’usage de données discrètes, qui est aussi appelée algorithme des
-médoïdes.
La classe ClusterMean¶
La classe ClusterMean étend la classe mère Cluster avec un attribut noyau qui doit aussi faire partie de la liste des données du cluster. Le constructeur initialise cet attribut au premier élément de la liste de données.
La méthode principale à implémenter est centre, qui met à jour le noyau d’un cluster afin que celui-ci soir au centre des données du cluster. Le nouveau noyaux sera la donnée qui minimise la somme quadratique des distances aux autres données du cluster. En d’autres termes, le noyau
du cluster
doit minimiser l’expression suivante :

C’est pour cette raison que la méthode prend un argument dist_f, qui est la fonction de distance entre deux données d’un cluster. La distance que nous utiliserons sera calculée comme le nombre d’attributs différents dans les tuples représentant les éléments à comparer (voir exemple_clustering.py).
À plusieurs reprises, vous allez devoir parcourir une liste pour trouver l’élément qui minimise une certaine fonction. En Python, ceci peut être implémenté en une seule ligne, grâce à la fonction min(liste, key=fonction), qui prend en paramètres une liste d’éléments et une fonction que l’on cherche à minimiser. Par exemple, pour trouver l’élément de la liste [1, 2, -3] qui a le plus petit carré, on peut utiliser la ligne de code m = min([1, 2, -3], key=lambda x: x**2).
La classe ClusteringKMeans¶
La classe ClusteringKMeans étend la classe mère Clustering afin d’implémenter l’algorithme
-means. Elle ajoute deux nouveaux attributs :
k: le nombre des clusters à construire ;dist_f: la fonction de distance entre deux éléments.
Le constructeur de la classe prend ainsi en arguments le nombre de clusters souhaités et la fonction de distance.
Comme nous l’avons vu ci-dessus, les méthodes principales à implémenter sont initialise_clusters, revise_clusters et fini. La méthode initialise_clusters prend en argument la liste des données à regrouper. Elle initialise la liste self.clusters de sorte que celle-ci contienne
clusters avec comme noyaux les
premiers éléments de la liste de données. Tous les autres éléments sont affectés au premier cluster.
La méthode revise_clusters s’exécute en deux étapes :
- Calcul des nouveaux clusters : initialisation des clusters avec uniquement les noyaux, calcul des distances de chaque élément aux noyaux et ajout de l’élément dans le cluster le plus proche ;
- Pour chaque cluster ainsi obtenu, le noyau est mis à jour afin d’être au centre du cluster.
La méthode fini prend en argument la liste des anciens clusters. Elle compare cette liste avec les clusters actuels, afin de tester si l’algorithme a convergé. Afin d’implémenter cette méthode, vous pouvez comparer les listes de noyaux de ces deux ensembles de clusters : les noyaux ne changent pas si et seulement si les clusters restent les mêmes.
Astuce: Afin d’implémenter ces deux dernières méthodes, vous pouvez vous aider des méthodes vide de la classe ClusterMean, qui vide un cluster avec l’option de garder son noyau, et noyaux de la classe ClusteringKMeans, qui retourne les noyaux d’une liste de clusters.
Exercice 2 : Le clustering hiérarchique¶
À la différence du clustering de partitionnement, le clustering hiérarchique par agglomération construit une classification en clusters de plus en plus larges, qui peut se présenter sous la forme d’un dendrogramme. La classification hiérarchique ainsi obtenue est organisée en groupes et en sous-groupes, afin de discerner les agrégats de similarité grossière des agrégats de similarité plus fine.
L’algorithme part d’un ensemble de clusters ne contenant chacun qu’une seule donnée, et, lors de chaque itération, fusionne les deux clusters les plus similaires. L’algorithme se termine quand toutes les données ont été regroupées en un seul cluster. La mesure de similitude entre deux clusters peut être calculée de différentes façons. Dans cet exercice, nous vous en proposons deux : la distance single-link et la distance complete-link. La distance single-link définit la similitude de deux clusters comme la plus courte distance entre deux données de ces clusters. À l’inverse, le complete-link considère la plus longue distance.
La classe ClusterHierarchique¶
La classe ClusterHierarchique représente un cluster (un noeud) dans le dendrogramme (un arbre binaire) construit par le clustering hiérarchique. Elle contient deux nouveaux attributs :
gauche: le sous-cluster de gauche ;droite: le sous-cluster de droite.
La classe ClusteringHierarchique¶
La classe ClusteringHierarchique implémente le clustering hiérarchique. Elle étend la classe mère Clustering avec deux nouveaux attributs :
type_lien: le type de distance entre deux clusters ('single'ou'complete') ;dist_f: la fonction de distance entre deux données.
Le constructeur de la classe prend en arguments la fonction de distance entre deux données et le type de distance entre deux clusters.
Les méthodes principales que vous devez implémenter sont initialise_clusters, revise_clusters, clusters_distances et fini. La méthode initialise_clusters initialise la liste self.clusters de sorte qu’elle contienne un cluster (un noeud) pour chaque donnée de la liste passée en argument.
La méthode revise_clusters cherche les deux clusters les plus proches et les fusionne en un seul grâce à la méthode fusion de la même classe, qui retourne le nouveau noeud. Elle les retire ensuite de la liste des clusters pour y ajouter le produit de leur fusion. Cette méthode s’appuie sur la méthode calcule_distance, qui doit, selon le type de lien indiqué, retourner le minimum ou le maximum des distances entre chaque paire des données des deux clusters. Selon que la distance entre deux clusters est définie comme le minimum des distances entre chaque paire d’éléments ou comme le maximum, on obtient en effet une distance de type single-link ou de type complete-link.
Rappelons que c’est la méthode Clustering.itere qui implémente l’algorithme général de clustering, et qui joue le rôle d’un wrapper pour les méthodes initialise_clusters et revise_clusters. Dans le cas du clustering hiérarchique, itere va donc initialiser les clusters, puis les fusionner de manière itérative jusqu’à ce qu’il ne reste qu’un seul élément dans la liste self.clusters. Cet élément constituera la racine de la hiérarchie construite par le clustering. La méthode fini, quant à elle, arrête l’algorithme quand la taille de la liste des clusters est réduite à un seul élément.
Test du programme¶
Une fois que vous avez terminé l’implémentation des méthodes manquantes, il ne vous reste plus qu’à tester ces deux algorithmes sur les deux exemples fournis : maladies et profits d’enterprises.
Pour le clustering
-means, faites varier le nombre de clusters (ainsi que l’initialisation des clusters et leurs noyaux, si vous êtes motivés). Que pensez-vous de la qualité des clusters ? Les données classifiées dans un même cluster sont-elles toujours très similaires ? Est-il facile de choisir le nombre de clusters ?
Pour le clustering hiérarchique, quelles différences obtenez-vous si vous alternez entre les méthodes single-link et complete-link ? Que pensez-vous de la qualité des clusters ainsi obtenus, en comparaison avec l’algorithme
-means ?