TP2. Clustering : méthodes modernes et évaluation¶
Durée : 2 h 30.
Objectifs¶
- Regrouper des observations non étiquetées avec K-Means, et connaître ses limites.
- Comprendre pourquoi un clustering par densité (DBSCAN, HDBSCAN) résout certains cas où K-Means échoue, sans être universellement meilleur.
- Choisir un nombre de clusters avec une métrique, plutôt qu'à l'œil.
- Réduire la dimension d'un jeu de données pour le visualiser après clustering.
Prérequis¶
Le TP1 : manipuler un DataFrame. Bibliothèques supplémentaires :
Ressources¶
Un jeu de données synthétique, pas Mall Customers
L'exercice classique de ce TP utilise le jeu Kaggle Mall Customers (âge, revenu, score de dépense). Pour rester autonome sans compte ni téléchargement, ce TP génère un jeu synthétique de même structure et du même esprit (200 à 300 clients fictifs, trois variables), avec une graine fixe pour que les résultats soient reproductibles.
Étape 1. K-Means, et pourquoi il faut normaliser (25 min)¶
import numpy as np
import pandas as pd
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import adjusted_rand_score
rng = np.random.default_rng(0)
n_par_groupe = 60
centres = [(25, 20), (25, 80), (45, 50), (65, 20), (65, 80)]
vrais_groupes = []
morceaux = []
for i, (cx, cy) in enumerate(centres):
age = rng.normal(30, 6, n_par_groupe)
revenu = rng.normal(cx * 1000, 8000, n_par_groupe) # en euros, ordre de grandeur 25 000-65 000
score = rng.normal(cy, 10, n_par_groupe).clip(1, 100) # score de dépense, 1-100
morceaux.append(pd.DataFrame({"age": age, "revenu": revenu, "score_depense": score}))
vrais_groupes += [i] * n_par_groupe
clients = pd.concat(morceaux, ignore_index=True)
X = clients[["age", "revenu", "score_depense"]].values
print(clients.describe().round(1))
revenu varie sur des dizaines de milliers d'euros, score_depense sur une échelle
1-100 : deux échelles très différentes. K-Means mesure une distance euclidienne entre
points, donc une variable à grande échelle domine mécaniquement le calcul, au détriment
des autres.
L'IA vous le donne en trois secondes
Demandez à un assistant d'appliquer K-Means sur ce DataFrame. Il propose souvent
KMeans(n_clusters=5).fit(X) directement, sans normaliser. Comparez :
km_brut = KMeans(n_clusters=5, random_state=42, n_init=10).fit(X)
print("ARI sans normalisation :", round(adjusted_rand_score(vrais_groupes, km_brut.labels_), 3))
Xs = StandardScaler().fit_transform(X)
km_norm = KMeans(n_clusters=5, random_state=42, n_init=10).fit(Xs)
print("ARI avec normalisation :", round(adjusted_rand_score(vrais_groupes, km_norm.labels_), 3))
L'ARI (Adjusted Rand Index) mesure ici l'accord entre les clusters trouvés et les
cinq groupes réellement utilisés pour générer les données (1 = accord parfait, 0 =
accord au hasard). Sans normalisation, l'ARI vaut environ 0.295 ; avec
StandardScaler, il monte à 0.58, presque le double. Un assistant qui connaît
K-Means en théorie ne pense pas toujours à normaliser en pratique.
Exercice 1.1 : K-Means échoue sur des clusters non convexes
Générez make_moons(n_samples=500, noise=0.1, random_state=42) (deux croissants
entrelacés) et appliquez K-Means avec n_clusters=2. Mesurez l'ARI contre les vraies
étiquettes de make_moons.
Résultat attendu : un ARI autour de 0.27, loin de 1. K-Means suppose des
clusters à peu près sphériques (il assigne chaque point au centre le plus proche) ; un
croissant n'est pas une forme sphérique, K-Means le découpe donc n'importe comment.
Corrigé
from sklearn.datasets import make_moons
X_moons, y_moons = make_moons(n_samples=500, noise=0.1, random_state=42)
km_moons = KMeans(n_clusters=2, random_state=42, n_init=10).fit_predict(X_moons)
print("ARI K-Means sur make_moons :", round(adjusted_rand_score(y_moons, km_moons), 3))
Ce n'est pas un problème de réglage : aucune valeur de n_clusters ni
d'initialisation ne corrige ce défaut, parce qu'il est structurel à la méthode.
L'étape suivante montre une famille d'algorithmes construite sur un principe
différent, justement pour ce genre de forme.
Étape 2. DBSCAN : des clusters définis par la densité (25 min)¶
DBSCAN ne suppose aucune forme : un cluster est une région dense de points, séparée des
autres par une région clairsemée. Deux paramètres suffisent : eps (le rayon de
voisinage) et min_samples (le nombre de voisins minimal pour qu'un point soit « dense »).
from sklearn.cluster import DBSCAN
db_moons = DBSCAN(eps=0.2, min_samples=5).fit_predict(X_moons)
print("ARI DBSCAN sur make_moons :", round(adjusted_rand_score(y_moons, db_moons), 3))
print("clusters trouvés :", sorted(set(db_moons)))
Exercice 2.1 : choisir eps avec le graphe des k-distances
Plutôt que de deviner eps, tracez la distance de chaque point à son 5ᵉ plus proche
voisin, triée par ordre croissant (NearestNeighbors(n_neighbors=5)). Le « coude » de
cette courbe est une estimation raisonnable de eps.
Résultat attendu : une courbe globalement croissante avec un coude net autour
d'une valeur proche de 0.15 à 0.2, cohérente avec le eps=0.2 utilisé ci-dessus.
Corrigé
from sklearn.neighbors import NearestNeighbors
voisins = NearestNeighbors(n_neighbors=5).fit(X_moons)
distances, _ = voisins.kneighbors(X_moons)
distances_triees = np.sort(distances[:, -1])
import matplotlib.pyplot as plt
plt.figure(figsize=(7, 4))
plt.plot(distances_triees)
plt.xlabel("Points, triés")
plt.ylabel("Distance au 5ᵉ plus proche voisin")
plt.title("Graphe des k-distances")
plt.tight_layout()
plt.show()
print("distance au coude, approx :", round(distances_triees[480], 3))
Ce graphe répond à une question précise : « à partir de quelle distance la majorité des points cessent-ils d'avoir un voisinage dense ? ». Avant le coude, les distances sont petites et régulières (l'intérieur des clusters) ; après, elles augmentent brusquement (les points isolés, ou la transition entre clusters). C'est la même logique que la méthode du coude sur l'inertie de K-Means, appliquée à une autre quantité.
Étape 3. Combien de clusters ? Le score de silhouette (25 min)¶
Sur le jeu de clients (étape 1), rien n'indique a priori qu'il faille 5 clusters. Le
score de silhouette mesure, pour chaque point, s'il est plus proche de son propre cluster
que du cluster voisin le plus proche (entre -1 et 1, plus haut est meilleur) :
from sklearn.metrics import silhouette_score
resultats = []
for k in range(2, 9):
km = KMeans(n_clusters=k, random_state=42, n_init=10).fit(Xs)
resultats.append((k, km.inertia_, silhouette_score(Xs, km.labels_)))
for k, inertie, silhouette in resultats:
print(f"k={k} : inertie={inertie:.1f}, silhouette={silhouette:.3f}")
Exercice 3.1 : le score retrouve-t-il le bon nombre de groupes ?
Identifiez le k qui maximise le score de silhouette dans la liste resultats
ci-dessus. Correspond-il au nombre de groupes réellement utilisés pour générer
clients à l'étape 1 (variable centres) ?
Résultat attendu : le maximum de silhouette est atteint à k=5 (silhouette
d'environ 0.347), exactement le nombre de centres utilisés à la génération. Ce n'est
pas garanti en général (un vrai jeu de données n'a pas de « bon » nombre de clusters
connu d'avance), mais ça confirme que la métrique fonctionne sur un cas où la réponse
est connue.
Corrigé
meilleur_k, _, meilleure_silhouette = max(resultats, key=lambda t: t[2])
print(f"meilleur k : {meilleur_k}, silhouette : {meilleure_silhouette:.3f}")
print("nombre de centres à la génération :", len(centres))
La méthode du coude sur l'inertie (la première colonne de resultats) pointe
souvent dans la même direction, mais elle demande un jugement visuel (« où
l'inertie cesse-t-elle de baisser vite ? ») là où la silhouette donne un nombre
directement comparable entre valeurs de k. Les deux se complètent plutôt qu'elles
ne s'excluent.
Étape 4. HDBSCAN : plus robuste, mais pas toujours meilleur (25 min)¶
HDBSCAN prolonge DBSCAN en évitant de choisir eps à la main, et en s'adaptant à des
densités variables selon les régions. Sur make_moons, il fait aussi bien que DBSCAN.
Sur le jeu de clients (des groupes à peu près sphériques, exactement l'hypothèse que fait
K-Means), la comparaison est plus intéressante :
import hdbscan
clusterer = hdbscan.HDBSCAN(min_cluster_size=15, min_samples=5)
labels_hdbscan = clusterer.fit_predict(Xs)
print("clusters trouvés par HDBSCAN :", sorted(set(labels_hdbscan)))
print(pd.Series(labels_hdbscan).value_counts())
print("ARI HDBSCAN vs vrais groupes :", round(adjusted_rand_score(vrais_groupes, labels_hdbscan), 3))
print("ARI K-Means vs vrais groupes :", round(adjusted_rand_score(vrais_groupes, km_norm.labels_), 3))
Exercice 4.1 : pourquoi HDBSCAN fait moins bien ici
Comptez combien de clusters distincts (hors bruit, étiqueté -1) HDBSCAN trouve sur
Xs. Comparez à 5, le vrai nombre de groupes. Avec ce que vous savez du principe de
HDBSCAN (des régions denses séparées par des creux de densité), pourquoi cinq
gaussiennes qui se chevauchent légèrement sont-elles plus difficiles pour HDBSCAN que
pour K-Means ?
Résultat attendu : HDBSCAN ne trouve que 2 clusters (plus du bruit) sur ce jeu,
contre 5 pour K-Means, et son ARI (0.139) est nettement inférieur à celui de
K-Means (0.58). Quand des groupes gaussiens se touchent, la densité ne baisse jamais
vraiment entre eux : il n'y a pas de « creux » net à détecter, alors que K-Means, qui
ne cherche pas de creux mais des centres, n'a pas ce problème.
Corrigé
nb_clusters = len(set(labels_hdbscan) - {-1})
print("clusters trouvés (hors bruit) :", nb_clusters, "contre", len(centres), "réels")
Retenez la leçon au delà de ce cas précis : DBSCAN et HDBSCAN excellent quand les
clusters ont des formes arbitraires séparées par du vide (comme make_moons), et
perdent leur avantage quand les clusters sont convexes et se touchent, exactement
le terrain où K-Means est le plus à l'aise. « Plus récent » ou « plus sophistiqué »
ne veut pas dire « meilleur en toutes circonstances » : le bon algorithme dépend de
la forme réelle des données, jamais de sa date de publication.
Étape 5. Réduire la dimension pour visualiser (20 min)¶
Le jeu de clients a trois variables : impossible à représenter directement sur un graphique à deux axes. PCA et UMAP projettent les données en deux dimensions en essayant de préserver au mieux leur structure :
from sklearn.decomposition import PCA
import umap
import matplotlib.pyplot as plt
X_pca = PCA(n_components=2, random_state=42).fit_transform(Xs)
X_umap = umap.UMAP(n_neighbors=15, min_dist=0.1, random_state=42).fit_transform(Xs)
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
axes[0].scatter(X_pca[:, 0], X_pca[:, 1], c=km_norm.labels_, cmap="tab10", alpha=0.7)
axes[0].set_title("PCA")
axes[1].scatter(X_umap[:, 0], X_umap[:, 1], c=km_norm.labels_, cmap="tab10", alpha=0.7)
axes[1].set_title("UMAP")
plt.tight_layout()
plt.show()
Exercice 5.1 : la variance expliquée par la PCA
Affichez pca.explained_variance_ratio_ (nécessite de refaire pca = PCA(...) avant
fit_transform pour garder l'objet). Que signifie la somme de ces deux valeurs ?
Résultat attendu : deux valeurs dont la somme est inférieure à 1 : c'est la
proportion de la variance totale des trois variables originales que les deux axes de
la PCA parviennent à représenter. Une projection en 2D perd nécessairement une partie
de l'information d'un jeu à 3 variables ou plus.
Corrigé
pca = PCA(n_components=2, random_state=42)
X_pca = pca.fit_transform(Xs)
print(pca.explained_variance_ratio_.round(3))
print("variance totale représentée :", round(pca.explained_variance_ratio_.sum(), 3))
Une PCA qui ne représente que 60 % de la variance signifie que 40 % de ce qui distingue les points entre eux n'apparaît pas sur le graphique : deux points proches sur le graphique PCA peuvent donc être plus éloignés qu'ils n'en ont l'air sur la troisième variable, absente de la projection. UMAP n'a pas cette limite de variance expliquée, mais il ne préserve pas non plus les distances globales de façon aussi directement quantifiable : les deux méthodes se lisent différemment, aucune n'est une carte fidèle à 100 % des données originales.
Pour aller plus loin¶
- Clustering hiérarchique (
AgglomerativeClustering) construit un dendrogramme, un arbre de fusions successives, qui évite de fixer un nombre de clusters à l'avance et se lit visuellement à n'importe quelle hauteur de coupe. - Gaussian Mixture Models (
sklearn.mixture.GaussianMixture) font un clustering « souple » : chaque point reçoit une probabilité d'appartenance à chaque cluster plutôt qu'une assignation unique, utile quand les frontières entre groupes sont floues. - Les métriques externes (
adjusted_rand_scoredéjà vu, mais aussinormalized_mutual_info_score,v_measure_score) exigent de connaître les vraies étiquettes, ce qui n'est presque jamais le cas en pratique : elles servent surtout à valider une méthode sur un jeu de données de test avant de l'appliquer à l'aveugle.
Ce qu'il faut retenir¶
K-Means suppose des clusters à peu près sphériques et de taille comparable, et exige de normaliser les variables avant de mesurer une distance entre elles. DBSCAN et HDBSCAN définissent un cluster par la densité plutôt que par une forme, ce qui leur permet de réussir là où K-Means échoue (des clusters non convexes) et de rater là où K-Means excelle (des groupes convexes qui se touchent). Le score de silhouette aide à choisir un nombre de clusters sans étiquette connue. Et une réduction de dimension (PCA, UMAP) rend un jeu de données à plusieurs variables visualisable en 2D, au prix d'une perte d'information qu'il faut savoir quantifier ou, pour UMAP, garder à l'esprit.
Auto-évaluation¶
Avant de passer au TP suivant, vous devez pouvoir, sans regarder le corrigé :
- expliquer pourquoi normaliser les variables change le résultat de K-Means ;
- citer un cas où DBSCAN réussit là où K-Means échoue, et un cas inverse ;
- utiliser le score de silhouette pour choisir un nombre de clusters ;
- distinguer ce que PCA et UMAP préservent chacun d'une donnée à plusieurs variables ;
- dire pourquoi un algorithme plus récent n'est pas automatiquement meilleur.
Pas encore de QCM pour ce TP
Contrairement au module IA générative, ce TP n'a pas de QCM d'auto-évaluation prêt à
l'emploi : les TP d'IA prédictive n'en ont jamais eu. En créer un est possible avec le
skill qcm-generator, mais reste à faire.