Regroupez des individus avec k-means et la CAH
Des photos prises le long des quais de la Vilaine, à Rennes, forment-elles un seul ensemble ou plusieurs groupes autour de lieux précis ? Des clients d’une banque se répartissent-ils en quelques profils ? Dans les deux cas, personne ne vous donne les groupes : il faut les découvrir. C’est le partitionnement (clustering), aussi appelé classification non supervisée.
Ce chapitre ouvre la partie consacrée à la fouille de données. Vous verrez d’abord ce qu’est une « bonne » partition, puis deux algorithmes classiques : k-means, qui cherche un nombre fixé de groupes, et la classification ascendante hiérarchique (CAH), qui construit toute une hiérarchie de groupes. Notre fil rouge : six photos repérées par leur position le long d’un quai, en centaines de mètres depuis un pont, aux positions 1, 2, 4, 7, 8 et 10. C’est assez petit pour tout calculer à la main.
Qu’est-ce qu’une bonne partition ?
Section intitulée « Qu’est-ce qu’une bonne partition ? »Intuitivement, un bon partitionnement rassemble des individus semblables à l’intérieur de chaque groupe et différents d’un groupe à l’autre. L’inertie, rencontrée avec l’ACP, permet de le mesurer.
- L’inertie totale mesure la dispersion de tous les points autour du centre de gravité général .
- L’inertie intra-classe mesure la dispersion de chaque groupe autour de son propre centre ; on la veut petite (groupes compacts).
- L’inertie inter-classe mesure la dispersion des centres des groupes autour de , chacun pondéré par son effectif ; on la veut grande (groupes éloignés).
Le théorème de Huygens relie les trois :
(on divise souvent les trois termes par le nombre total d’individus , ce qui ne change rien au raisonnement). Comme l’inertie totale ne dépend pas de la partition, minimiser l’inertie intra-classe revient exactement à maximiser l’inertie inter-classe. On n’a donc qu’un seul critère à optimiser.
L’algorithme k-means
Section intitulée « L’algorithme k-means »L’algorithme k-means (k-moyennes) cherche groupes, chacun résumé par son centre (la moyenne de ses points). Il alterne deux étapes jusqu’à ce que plus rien ne bouge :
- choisir centres initiaux ;
- affecter chaque point au centre le plus proche ;
- recalculer chaque centre comme la moyenne des points qui lui sont affectés ;
- recommencer les étapes 2 et 3 jusqu’à ce que les affectations ne changent plus.
Chaque étape fait baisser (ou laisse stable) l’inertie intra-classe, qui ne peut pas descendre indéfiniment : l’algorithme converge toujours, et vite.
Malgré un départ maladroit, on a trouvé la meilleure partition en deux groupes. Ce n’est pas toujours le cas : k-means converge vers un minimum local, qui dépend des centres initiaux (l’exercice 3 en montre un exemple). En pratique, on relance l’algorithme plusieurs fois avec des initialisations différentes et l’on garde la meilleure inertie ; l’initialisation k-means++, qui écarte les centres initiaux les uns des autres, rend les mauvais départs rares.
import numpy as npfrom sklearn.cluster import KMeans
X = np.array([1, 2, 4, 7, 8, 10], dtype=float).reshape(-1, 1) # une colonne : la positionkm = KMeans(n_clusters=2, init="k-means++", n_init=10, random_state=0).fit(X)print(km.labels_) # groupe de chaque photoprint(km.cluster_centers_) # [[2.33], [8.33]] (l'ordre des groupes peut varier)print(km.inertia_) # 9.33 : l'inertie intra-classeCombien de groupes ?
Section intitulée « Combien de groupes ? »k-means demande à l’avance. Comment le choisir ? Bonne question, car l’inertie intra-classe baisse toujours quand augmente : avec autant de groupes que de points, elle vaut 0. On ne peut donc pas simplement la minimiser.
La méthode du coude. On trace l’inertie intra en fonction de et l’on cherche le point où la baisse ralentit nettement. Pour nos photos, les meilleures partitions donnent 63,3 pour , puis 9,3, 5,2, 1,0 et 0,5 pour à 5. La chute est spectaculaire de 1 à 2 groupes, bien plus modeste ensuite : le coude désigne .
La silhouette. Pour chaque point , on calcule , sa distance moyenne aux autres points de son groupe, et , sa distance moyenne aux points du groupe voisin le plus proche. Sa silhouette vaut
Elle est comprise entre −1 et 1 : proche de 1, le point est bien au cœur de son groupe ; proche de 0, il est à la frontière ; négative, il serait mieux dans le groupe voisin. On choisit le qui maximise la silhouette moyenne.
from sklearn.metrics import silhouette_score
for k in range(2, 5): labels = KMeans(n_clusters=k, n_init=10, random_state=0).fit_predict(X) print(k, round(silhouette_score(X, labels), 3))La classification ascendante hiérarchique
Section intitulée « La classification ascendante hiérarchique »La CAH ne fixe pas le nombre de groupes à l’avance. Elle part de la situation où chaque individu est un groupe, puis fusionne à chaque étape les deux groupes les plus proches, jusqu’à n’en avoir plus qu’un. L’historique des fusions forme un arbre, le dendrogramme.
Tout dépend de ce qu’on appelle « les deux groupes les plus proches ». Le critère le plus utilisé en analyse de données est celui de Ward : on fusionne les deux groupes dont l’union augmente le moins l’inertie intra-classe. Cette augmentation vaut
où et sont les effectifs des groupes et , et , leurs centres. Le facteur pénalise la fusion de deux gros groupes : Ward produit des groupes compacts et de tailles équilibrées, dans le même esprit que k-means.
Le dendrogramme se lit de bas en haut ; ses hauteurs sont ici les augmentations d’inertie (le schéma n’est pas à l’échelle). Attention, les logiciels n’utilisent pas tous cette échelle : scipy (méthode ward) affiche plutôt que . L’ordre des fusions et la lecture des sauts restent les mêmes. Pour obtenir une partition, on le coupe horizontalement, de préférence juste en dessous d’un grand saut de hauteur. Ici, le saut de 4,17 à 54 est énorme : on coupe en deux classes, et , la même partition que k-means.
D’autres critères de fusion existent : le lien simple (distance entre les deux points les plus proches, qui crée des chaînes), le lien complet (entre les deux points les plus éloignés) et le lien moyen. Ward reste le choix naturel quand on raisonne en inertie.
from scipy.cluster.hierarchy import dendrogram, fcluster, linkage
Z = linkage(X, method="ward") # historique des fusionsclasses = fcluster(Z, t=2, criterion="maxclust") # coupe en 2 classesdendrogram(Z, labels=[1, 2, 4, 7, 8, 10]) # trace l'arbre (avec matplotlib)Combiner ACP et classification
Section intitulée « Combiner ACP et classification »L’ACP et la CAH se complètent très bien. On fait d’abord une ACP, puis on classe les individus sur leurs premières composantes seulement. Les derniers axes, de faible inertie, contiennent surtout du bruit : les écarter stabilise la classification. Si le dendrogramme ne change pas quand on retire encore un axe, c’est que les axes restants suffisent.
Une fois les classes obtenues, il faut les décrire : pour chaque classe, on compare la moyenne de chaque variable à la moyenne générale. Un indicateur standard, la valeur-test, mesure cet écart en nombre d’écarts-types ; une valeur-test supérieure à 2 en valeur absolue signale une variable caractéristique de la classe. Enfin, on peut projeter les classes sur la carte de l’ACP, comme une variable illustrative qualitative, pour visualiser leur séparation.
En résumé
Section intitulée « En résumé »- Le partitionnement découvre des groupes sans étiquette ; un bon partitionnement a une faible inertie intra-classe et une forte inertie inter-classe, deux objectifs équivalents par le théorème de Huygens.
- k-means alterne affectation au centre le plus proche et recalcul des centres ; il converge toujours, mais vers un minimum local : on relance avec plusieurs initialisations (k-means++).
- k-means suppose des groupes convexes et affecte chaque point ; il faut standardiser les variables.
- On choisit avec la méthode du coude ou la silhouette .
- La CAH fusionne pas à pas les groupes les plus proches ; le critère de Ward minimise la hausse d’inertie intra.
- On coupe le dendrogramme sous le plus grand saut ; classer sur les premières composantes d’une ACP enlève du bruit.
Exercices
Section intitulée « Exercices »Exercice · Faire tourner k-means en deux dimensions
facileQuatre points : , , et . On lance k-means avec et les centres initiaux et . Déroulez l’algorithme jusqu’à convergence.
Voir la solution
Itération 1. est confondu avec et avec . et sont plus proches de (par exemple contre ). Groupes et . Nouveaux centres : et .
Itération 2. est à distance 1 de et à de : il rejoint . et restent avec . Groupes et , centres et .
Itération 3. Chaque point est plus proche du centre de son groupe : rien ne change, l’algorithme s’arrête.
En code
import numpy as npfrom sklearn.cluster import KMeans
P = np.array([[1, 1], [2, 1], [4, 3], [5, 4]], dtype=float)centres = np.array([[1, 1], [2, 1]], dtype=float) # centres initiaux imposés
for iteration in range(1, 10): distances = np.linalg.norm(P[:, None, :] - centres[None, :, :], axis=2) groupes = distances.argmin(axis=1) # affectation au centre le plus proche nouveaux = np.array([P[groupes == g].mean(axis=0) for g in range(2)]) print(iteration, groupes, nouveaux.round(2).tolist()) if np.allclose(nouveaux, centres): break centres = nouveaux# 1 [0 1 1 1] [[1.0, 1.0], [3.67, 2.67]]# 2 [0 0 1 1] [[1.5, 1.0], [4.5, 3.5]]# 3 [0 0 1 1] [[1.5, 1.0], [4.5, 3.5]] : plus rien ne bouge
km = KMeans(n_clusters=2, init=np.array([[1, 1], [2, 1]]), n_init=1).fit(P)print(km.labels_, km.cluster_centers_.tolist()) # [0 0 1 1] [[1.5, 1.0], [4.5, 3.5]]P <- matrix(c(1, 1, 2, 1, 4, 3, 5, 4), ncol = 2, byrow = TRUE)depart <- matrix(c(1, 1, 2, 1), ncol = 2, byrow = TRUE) # centres initiaux imposés
# Algorithme de Lloyd : exactement les étapes du courskm <- kmeans(P, centers = depart, algorithm = "Lloyd")km$cluster # 1 1 2 2km$centers # (1.5, 1) et (4.5, 3.5)km$iter # 3 itérations, comme à la mainExercice · Choisir une fusion avec Ward
moyenSur une droite, trois groupes ont été formés : , et . Calculez l’augmentation d’inertie de Ward pour chacune des trois fusions possibles, et dites laquelle la CAH effectue.
Voir la solution
Les centres sont , et .
- .
- .
- .
La CAH fusionne A et B, l’union qui augmente le moins l’inertie intra-classe. Remarquez que 6 est à distance 4 de et 5 de : ici, le plus proche centre et le critère de Ward désignent la même fusion, mais ce n’est pas toujours le cas, car Ward tient aussi compte des effectifs.
En code
from itertools import combinationsimport numpy as np
groupes = {"A": [1, 3], "B": [6], "C": [10, 12]}
def ward(g, h): """Hausse de l'inertie intra-classe si l'on fusionne g et h.""" na, nb = len(g), len(h) return na * nb / (na + nb) * (np.mean(g) - np.mean(h)) ** 2
for a, b in combinations(groupes, 2): print(a, b, round(ward(groupes[a], groupes[b]), 2))# A B 10.67 ; A C 81.0 ; B C 16.67 : la CAH fusionne A et B
# Vérification directe : inertie intra avant et après la fusion de A et Bintra = lambda g: ((np.array(g) - np.mean(g)) ** 2).sum()print(round(intra([1, 3, 6]) - intra([1, 3]) - intra([6]), 2)) # 10.67groupes <- list(A = c(1, 3), B = 6, C = c(10, 12))
# Hausse de l'inertie intra-classe si l'on fusionne g et hward <- function(g, h) { na <- length(g); nb <- length(h) na * nb / (na + nb) * (mean(g) - mean(h))^2}paires <- combn(names(groupes), 2)hausses <- apply(paires, 2, function(p) ward(groupes[[p[1]]], groupes[[p[2]]]))names(hausses) <- apply(paires, 2, paste, collapse = "-")round(hausses, 2) # A-B 10.67 A-C 81 B-C 16.67 : la CAH fusionne A et B
# Vérification directe : inertie intra avant et après la fusion de A et Bintra <- function(g) sum((g - mean(g))^2)intra(c(1, 3, 6)) - intra(c(1, 3)) - intra(6) # 10.67Exercice · Piéger k-means dans un minimum local
difficileQuatre points forment un rectangle allongé : , , et . On lance k-means avec .
- Quelle est la meilleure partition en deux groupes, et son inertie intra-classe ?
- On part des centres et . Montrez que l’algorithme s’arrête immédiatement, et calculez l’inertie obtenue.
- Qu’en concluez-vous, et comment s’en protège-t-on ?
Voir la solution
- La meilleure partition sépare la gauche et la droite : de centre et de centre . Chaque point est à distance 0,5 de son centre : l’inertie intra vaut .
- Avec les centres et , les deux points du bas sont à distance 2 de et à de : ils vont au premier centre ; symétriquement, ceux du haut vont au second. Les groupes sont « bas » et « haut », et leurs moyennes sont exactement et : les centres ne bougent pas, l’algorithme s’arrête. Chaque point est à distance 2 de son centre : l’inertie vaut , seize fois plus que l’optimum.
- k-means ne garantit qu’un minimum local, qui dépend de l’initialisation. On s’en protège en relançant l’algorithme avec plusieurs initialisations (
n_initdans scikit-learn) et en gardant la plus faible inertie, et en utilisant k-means++, qui choisit les centres initiaux parmi les points en favorisant les points éloignés des centres déjà choisis. Ici, si le premier centre est , les carrés des distances aux autres points valent 1, 16 et 17 : le second centre tombe du côté droit avec une probabilité de , soit environ 97 %, et l’on part alors vers la bonne partition.
En code
import numpy as npfrom sklearn.cluster import KMeans
X = np.array([[0, 0], [0, 1], [4, 0], [4, 1]], dtype=float)
# 1. Départ gauche / droite : l'optimumbon = KMeans(n_clusters=2, init=np.array([[0, 0.5], [4, 0.5]]), n_init=1).fit(X)# 2. Départ piège : centres (2, 0) et (2, 1)piege = KMeans(n_clusters=2, init=np.array([[2, 0], [2, 1]]), n_init=1).fit(X)print(bon.inertia_, piege.inertia_) # 1.0 16.0print(piege.cluster_centers_.tolist()) # [[2.0, 0.0], [2.0, 1.0]] : rien n'a bougé
# 3. k-means++ avec plusieurs départs retrouve l'optimumprint(KMeans(n_clusters=2, n_init=10, random_state=0).fit(X).inertia_) # 1.0d2 = ((X - X[0]) ** 2).sum(axis=1) # carrés des distances à (0, 0)print(d2, round((d2[2] + d2[3]) / d2.sum(), 2)) # [0 1 16 17] 0.97X <- matrix(c(0, 0, 0, 1, 4, 0, 4, 1), ncol = 2, byrow = TRUE)
# 1. Départ gauche / droite : l'optimumbon <- kmeans(X, centers = rbind(c(0, 0.5), c(4, 0.5)), algorithm = "Lloyd")# 2. Départ piège : centres (2, 0) et (2, 1)piege <- kmeans(X, centers = rbind(c(2, 0), c(2, 1)), algorithm = "Lloyd")c(bon$tot.withinss, piege$tot.withinss) # 1 16piege$centers # (2, 0) et (2, 1) : rien n'a bougé
# 3. Plusieurs départs aléatoires (nstart) : on garde la meilleure inertieset.seed(0)kmeans(X, centers = 2, nstart = 10)$tot.withinss # 1d2 <- rowSums(sweep(X, 2, X[1, ])^2) # carrés des distances à (0, 0)d2; round(sum(d2[3:4]) / sum(d2), 2) # 0 1 16 17 ; 0.97