Aller au contenu principal

Clustering : regrouper automatiquement

Mis à jour le 29 juillet 2026

Objectifs

  • Regrouper des textes par thème sans labels prédéfinis
  • Utiliser K-Means et HDBSCAN sur des embeddings
  • Nommer automatiquement les clusters

Pourquoi le clustering ?

La classification de la leçon précédente supposait que vous saviez déjà quelles catégories existent. Le clustering répond à la situation inverse, qui est aussi la plus fréquente quand on découvre un corpus : personne ne sait ce qu’il contient. Vous récupérez huit mille verbatims d’une enquête de satisfaction, ou six mois de tickets de support, et la première question n’est pas « combien de bugs » mais « de quoi parlent les gens, au juste ». Le clustering découvre les groupes thématiques sans qu’on lui ait rien dit.

Trois usages reviennent constamment : analyser des milliers de feedbacks clients pour en extraire les motifs dominants, identifier les sujets récurrents dans des tickets de support afin d’alimenter une base de connaissances, et explorer un corpus de documents inconnu avant de décider comment l’indexer.

K-Means sur des embeddings

K-Means est l’algorithme de référence : on lui donne un nombre de groupes, il place autant de centres et déplace itérativement chacun vers la moyenne des points qu’il attire. Appliqué à des embeddings plutôt qu’à un sac de mots, il regroupe par sens : « l’application crash au démarrage » et « impossible de se connecter depuis hier » se retrouvent ensemble alors qu’ils ne partagent aucun mot.

Le corpus d’exemple ci-dessous contient volontairement quatre familles — des bugs, des demandes de fonctionnalités, des questions et des compliments — pour que vous puissiez vérifier à l’œil nu le regroupement obtenu. Le random_state=42 garantit que vous obtiendrez les mêmes clusters d’une exécution à l’autre, ce qui est indispensable dès qu’il s’agit de comparer deux réglages.

from openai import OpenAI
from sklearn.cluster import KMeans
import numpy as np

client = OpenAI()

# Corpus d'exemple
textes = [
    "L'application crash au démarrage sur Android",
    "Bug : impossible de se connecter depuis hier",
    "Le bouton de validation ne fonctionne pas",
    "Pourriez-vous ajouter un export Excel ?",
    "Suggestion : un mode hors-ligne serait utile",
    "Feature request : intégrer Slack",
    "Comment changer mon adresse email ?",
    "Où trouver ma facture de janvier ?",
    "Quel est le prix de l'abonnement annuel ?",
    "Votre produit est excellent, bravo !",
    "Merci pour le support réactif",
    "Je recommande vivement cette application",
]

# Générer les embeddings
response = client.embeddings.create(
    input=textes,
    model="text-embedding-3-large"
)
embeddings = np.array(
    [d.embedding for d in sorted(response.data, key=lambda x: x.index)]
)

# Clustering K-Means
n_clusters = 4
kmeans = KMeans(n_clusters=n_clusters, random_state=42, n_init=10)
labels = kmeans.fit_predict(embeddings)

# Afficher les clusters
for cluster_id in range(n_clusters):
    print(f"\n--- Cluster {cluster_id} ---")
    indices = [i for i, l in enumerate(labels) if l == cluster_id]
    for idx in indices:
        print(f"  {textes[idx]}")

Trouver le bon nombre de clusters

Sur douze phrases préparées, choisir quatre était évident. Sur huit mille verbatims, personne ne connaît la bonne valeur. Deux indicateurs aident à la deviner. L’inertie, c’est-à-dire la dispersion résiduelle à l’intérieur des groupes, décroît mécaniquement quand on ajoute des clusters : on cherche le « coude », le point au-delà duquel elle ne baisse plus franchement. Le score silhouette est plus décisif car il mesure autre chose : à quel point chaque point est plus proche de son propre groupe que du groupe voisin. Contrairement à l’inertie, il admet un maximum, et ce maximum désigne directement un candidat.

from sklearn.metrics import silhouette_score
import matplotlib.pyplot as plt

inertias = []
silhouettes = []
K_range = range(2, 10)

for k in K_range:
    km = KMeans(n_clusters=k, random_state=42, n_init=10)
    km.fit(embeddings)
    inertias.append(km.inertia_)
    silhouettes.append(silhouette_score(embeddings, km.labels_))

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))

ax1.plot(K_range, inertias, "bo-")
ax1.set_xlabel("Nombre de clusters")
ax1.set_ylabel("Inertie")
ax1.set_title("Méthode du coude")

ax2.plot(K_range, silhouettes, "ro-")
ax2.set_xlabel("Nombre de clusters")
ax2.set_ylabel("Score silhouette")
ax2.set_title("Score silhouette")

plt.tight_layout()
plt.savefig("choix_k.png", dpi=150)
plt.show()

meilleur_k = K_range[np.argmax(silhouettes)]
print(f"Meilleur k selon silhouette : {meilleur_k}")

HDBSCAN : clustering sans K

K-Means souffre d’un défaut structurel sur des données réelles : il affecte obligatoirement chaque point à un cluster. Le verbatim isolé qui ne ressemble à rien d’autre finira rattaché de force au groupe le moins éloigné, où il polluera l’interprétation. HDBSCAN raisonne en termes de densité et accepte de dire non : il détermine lui-même le nombre de groupes et range les points isolés dans une catégorie à part, étiquetée -1.

Ces outliers ne sont pas des déchets. Dans un corpus de tickets, ce sont souvent les incidents rares, les demandes hors périmètre ou les signaux faibles — exactement ce qui mérite une lecture humaine. Le code ci-dessous les affiche donc séparément plutôt que de les jeter.

import hdbscan

clusterer = hdbscan.HDBSCAN(
    min_cluster_size=3,
    min_samples=2,
    metric="euclidean"
)
labels_hdbscan = clusterer.fit_predict(embeddings)

n_clusters = len(set(labels_hdbscan)) - (1 if -1 in labels_hdbscan else 0)
n_outliers = list(labels_hdbscan).count(-1)

print(f"Clusters trouvés : {n_clusters}")
print(f"Outliers : {n_outliers}")

for cluster_id in range(n_clusters):
    print(f"\n--- Cluster {cluster_id} ---")
    indices = [i for i, l in enumerate(labels_hdbscan) if l == cluster_id]
    for idx in indices:
        print(f"  {textes[idx]}")

if n_outliers > 0:
    print("\n--- Outliers ---")
    for i, l in enumerate(labels_hdbscan):
        if l == -1:
            print(f"  {textes[i]}")

Nommer les clusters automatiquement

Un algorithme de clustering vous rend des numéros. « Cluster 2 » ne se présente pas dans un rapport et ne se met pas dans un tableau de bord. L’étape qui rend le résultat exploitable consiste à soumettre au LLM une dizaine de membres représentatifs et à lui demander un nom court : « Problèmes de connexion », « Demandes d’intégration », « Questions de facturation ». La limitation à dix exemples maîtrise le coût, la température à zéro assure qu’un même cluster reçoit toujours le même nom, et la boucle ignore le label -1 puisque les outliers n’ont, par définition, pas de thème commun.

def nommer_clusters(
    textes: list[str],
    labels: list[int]
) -> dict[int, str]:
    """Nomme chaque cluster avec un LLM."""
    noms = {}
    clusters = set(l for l in labels if l >= 0)

    for cluster_id in clusters:
        exemples = [
            textes[i] for i, l in enumerate(labels)
            if l == cluster_id
        ][:10]  # Max 10 exemples

        response = client.chat.completions.create(
            model="gpt-5.6-terra",
            messages=[{
                "role": "user",
                "content": (
                    f"Voici des textes regroupés dans un même cluster :\n\n"
                    + "\n".join(f"- {e}" for e in exemples) +
                    f"\n\nDonne un nom court (2-4 mots) qui décrit "
                    f"le thème commun de ce groupe. "
                    f"Retourne uniquement le nom."
                )
            }],
            temperature=0,
            max_tokens=20
        )
        noms[cluster_id] = response.choices[0].message.content.strip()

    return noms

# Utilisation
noms = nommer_clusters(textes, labels)
for cluster_id, nom in noms.items():
    print(f"Cluster {cluster_id} : {nom}")
    indices = [i for i, l in enumerate(labels) if l == cluster_id]
    for idx in indices:
        print(f"  - {textes[idx]}")

Pipeline de clustering complet

Les trois étapes — embedding, clustering, nommage — se réunissent naturellement dans une classe unique. Le paramètre methode accepte "auto", qui délègue à HDBSCAN le choix du nombre de groupes, ou une valeur numérique passée en chaîne quand vous voulez imposer un K à K-Means. Le dictionnaire renvoyé contient les labels, les noms lisibles et les deux compteurs qui vous diront immédiatement si le résultat mérite d’être exploité : un n_outliers proche du total signifie que votre corpus n’a pas de structure dense, et qu’il faut revenir à K-Means.

class PipelineClustering:
    def __init__(self, model: str = "text-embedding-3-large"):
        self.client = OpenAI()
        self.model = model

    def analyser(self, textes: list[str], methode: str = "auto") -> dict:
        """Pipeline complet : embedding → clustering → naming."""
        # 1. Embeddings
        response = self.client.embeddings.create(
            input=textes, model=self.model
        )
        embs = np.array([
            d.embedding for d in sorted(response.data, key=lambda x: x.index)
        ])

        # 2. Clustering
        if methode == "auto":
            clusterer = hdbscan.HDBSCAN(min_cluster_size=3)
            labels = clusterer.fit_predict(embs)
        else:
            km = KMeans(n_clusters=int(methode), random_state=42)
            labels = km.fit_predict(embs)

        # 3. Nommer les clusters
        noms = nommer_clusters(textes, labels)

        return {
            "labels": labels.tolist(),
            "noms": noms,
            "n_clusters": len(set(l for l in labels if l >= 0)),
            "n_outliers": list(labels).count(-1)
        }

pipeline = PipelineClustering()
resultats = pipeline.analyser(textes)

Résumé

  • K-Means quand vous connaissez le nombre de clusters
  • HDBSCAN pour détecter automatiquement les groupes et les outliers
  • Le score silhouette aide à choisir le bon K
  • Un LLM nomme automatiquement les clusters découverts
  • Les embeddings rendent le clustering sémantique, pas juste lexical