ML-8 : Clustering non-supervise avec K-Means

Navigation : Index | << précédent

Objectifs d’apprentissage

A la fin de ce notebook, vous saurez :

  1. Distinguer le clustering non-supervise de la classification supervisée (ML-1, ML-3)
  2. Construire un pipeline ML.NET avec le trainer K-Means (ClusteringCatalog)
  3. Evaluer une partition avec l’inertie intra-cluster (AverageDistance) et l’indice de Davies-Bouldin
  4. Choisir le nombre de clusters K par la méthode du coude (elbow method)
  5. Expliquer pourquoi normaliser les features avant un clustering par distance euclidienne
  6. Predire le segment (cluster) d’un nouveau client (segmentation RFM)

Du supervise au non-supervise

Les notebooks ML-1 a ML-4 traitent l’apprentissage supervise : on dispose d’etiquettes (prix d’une maison, transaction suspecte) et l’on apprend a les predire. Le clustering est non-supervise : aucune etiquette n’est fournie, l’algorithme doit seul decouvrir une structure de groupes dans les données.

Le cas d’usage canonique est la segmentation client : etant donne l’historique d’achat de milliers de clients, identifier des groupes naturels (dormants, reguliers, VIP) sans connaitre a l’avance ces catégories. ML.NET fournit le trainer K-Means via mlContext.Clustering.Trainers.KMeans.

K-Means (MacQueen, 1967 ; Lloyd, 1957) partitionne les points en K groupes en minimisant l’inertie intra-cluster (somme des distances carrees au centroide le plus proche). C’est un problème NP-difficile en general, resolu en pratique par l’heuristique de Lloyd : initialisation K-Means++ des centroides, puis itération assigner (chaque point au centroide le plus proche) / mettre a jour (chaque centroide au barycentre de ses points) jusqu’a convergence.

#r "nuget: Microsoft.ML, 5.0.0"
Console.WriteLine("Microsoft.ML 5.0.0 charge");
Installed Packages
  • Microsoft.ML, 5.0.0
Microsoft.ML 5.0.0 charge

On reference ensuite les espaces de noms ML.NET et System.Linq (pour Select sur les tableaux de distances).

using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.ML;
using Microsoft.ML.Data;
Console.WriteLine("Espaces de noms importes (Microsoft.ML + System.Linq)");
Espaces de noms importes (Microsoft.ML + System.Linq)

Comme dans ML-1, on commence par créer le MLContext — le point d’entree commun a toutes les opérations ML.NET. On fixe la graine (seed: 42) pour rendre K-Means déterministe et reproductible (sinon l’initialisation K-Means++ varie d’une exécution a l’autre).

var mlContext = new MLContext(seed: 42);
Console.WriteLine("MLContext cree (seed fixe a 42 pour reproductibilite)");
MLContext cree (seed fixe a 42 pour reproductibilite)

Jeu de données : segmentation RFM

La segmentation RFM (Recency, Frequency, Monetary — Hughes, 1996) classe les clients selon trois dimensions comportementales :

Feature Sens Valeur haute Valeur basse
Recency jours depuis le dernier achat client dormant client actif
Frequency achats par mois client fidele client occasionnel
Monetary depense moyenne par achat (EUR) gros panier petit panier

Nous generons 90 clients synthetiques issus de 3 segments caches (30 chacun) que K-Means devra retrouver sans connaitre les etiquettes. Les segments se chevauchent legerement (bruit gaussien) pour rester non-triviaux.

Definissons d’abord les classes de données ML.NET et un utilitaire gaussien (Box-Muller) pour la generation.

public class CustomerData
{
    public float Recency { get; set; }    // jours depuis le dernier achat (haut = dormant)
    public float Frequency { get; set; }  // achats par mois
    public float Monetary { get; set; }   // depense moyenne par achat (EUR)
}

public class ClusterPrediction
{
    [ColumnName("PredictedLabel")]
    public uint PredictedClusterId { get; set; }

    [ColumnName("Score")]
    public float[] Distances { get; set; }
}

public static class RandUtil
{
    static readonly Random _r = new Random(42);

    // Box-Muller : echantillonne un flottant selon N(mean, std), clame a >= 0
    public static float NextGaussian(float mean, float std)
    {
        double u1 = 1.0 - _r.NextDouble();
        double u2 = 1.0 - _r.NextDouble();
        double z = Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Sin(2.0 * Math.PI * u2);
        return Math.Max(0f, mean + (float)(z * std));
    }
}
Console.WriteLine("Classes CustomerData / ClusterPrediction / RandUtil definies");
Classes CustomerData / ClusterPrediction / RandUtil definies

On genere maintenant les trois segments. Chaque client est tire d’une gaussienne centree sur le profil du segment (ex. le VIP achete recemment, souvent, avec un gros panier). K-Means ne verra jamais l’appartenance a un segment — il doit la reconstruire depuis la seule geometrie des points.

// 3 segments de clients (segments CACHES, que K-Means doit retrouver)
var customers = new List<CustomerData>();

// Segment "Dormants" : achat ancien, peu frequent, petit panier
for (int i = 0; i < 30; i++)
    customers.Add(new CustomerData {
        Recency   = RandUtil.NextGaussian(85, 15),
        Frequency = RandUtil.NextGaussian(2, 1),
        Monetary  = RandUtil.NextGaussian(50, 20)
    });

// Segment "Reguliers" : achat recent, frequence moyenne, panier moyen
for (int i = 0; i < 30; i++)
    customers.Add(new CustomerData {
        Recency   = RandUtil.NextGaussian(30, 8),
        Frequency = RandUtil.NextGaussian(8, 2),
        Monetary  = RandUtil.NextGaussian(150, 40)
    });

// Segment "VIP" : achat tres recent, tres frequent, gros panier
for (int i = 0; i < 30; i++)
    customers.Add(new CustomerData {
        Recency   = RandUtil.NextGaussian(8, 3),
        Frequency = RandUtil.NextGaussian(20, 4),
        Monetary  = RandUtil.NextGaussian(500, 100)
    });

Console.WriteLine($"Genere {customers.Count} clients : 3 segments caches x 30");
Genere 90 clients : 3 segments caches x 30

On charge la liste dans un IDataView — la structure colonnaire fondamentale de ML.NET, déjà utilisee dans ML-1 et ML-2.

IDataView dataView = mlContext.Data.LoadFromEnumerable(customers);
Console.WriteLine($"IDataView charge : {customers.Count} lignes, 3 features (Recency, Frequency, Monetary)");
IDataView charge : 90 lignes, 3 features (Recency, Frequency, Monetary)

Pipeline : concatenation, normalisation, K-Means

Le pipeline comporte trois étapes. La concatenation regroupe les trois features en un vecteur "Features". La normalisation MinMax ramene chaque feature dans [0, 1]. Le trainer K-Means partitionne ensuite l’espace normalise en numberOfClusters: 3 groupes.

Pourquoi normaliser absolument ? K-Means mesure la proximite par distance euclidienne. Sans normalisation, Monetary (50 a 500 EUR) ecrase totalement Frequency (2 a 20) et Recency (8 a 85 jours) : la distance serait dominee par la depense, et le clustering reflete surtout l’argent. Normaliser met les trois dimensions a la même echelle — c’est l’objet de l’Exercice 3. C’est l’equivalent, en non-supervise, de l’ingenierie de features vue en ML-2.

var pipeline = mlContext.Transforms
    .Concatenate("Features", nameof(CustomerData.Recency), nameof(CustomerData.Frequency), nameof(CustomerData.Monetary))
    .Append(mlContext.Transforms.NormalizeMinMax("Features"))
    .Append(mlContext.Clustering.Trainers.KMeans("Features", numberOfClusters: 3));

Console.WriteLine("Pipeline cree : Concatenate -> NormalizeMinMax -> KMeans(K=3)");
Pipeline cree : Concatenate -> NormalizeMinMax -> KMeans(K=3)

On entraine le modèle en appelant Fit sur l’IDataView, exactement comme en ML-1.

var model = pipeline.Fit(dataView);
Console.WriteLine("Modele K-Means (K=3) entraine");
Modele K-Means (K=3) entraine

Evaluation : inertie et separation

L’evaluation d’un clustering est delicate : contrairement a la classification (ML-4), il n’y a pas d’etiquette de verite a laquelle comparer. On mesure donc la qualite intrinseque de la partition :

  • AverageDistance (inertie intra-cluster) : distance moyenne de chaque point au centroide de son cluster. Plus bas = groupes compacts. C’est la grandeur que K-Means minimise.
  • DaviesBouldinIndex (Davies & Bouldin, 1979) : rapport moyen entre la dispersion intra-cluster et la separation inter-cluster. Plus bas = clusters a la fois compacts et bien separes.

On obtient ces metriques via mlContext.Clustering.Evaluate, qui calcule les distances a partir de la colonne "Features" (normalisee).

var predictions = model.Transform(dataView);
var metrics = mlContext.Clustering.Evaluate(predictions, featureColumnName: "Features");

Console.WriteLine("=== Metriques de clustering (K=3) ===");
Console.WriteLine($"  AverageDistance (inertie intra-cluster) : {metrics.AverageDistance:F4}");
Console.WriteLine($"  DaviesBouldinIndex (separation)         : {metrics.DaviesBouldinIndex:F3}");
=== Metriques de clustering (K=3) ===
  AverageDistance (inertie intra-cluster) : 0,0209
  DaviesBouldinIndex (separation)         : 0,394

Interpretation : qualite de la partition (K=3)

Metrique Valeur Lecture
AverageDistance 0,0209 compacite elevee (features normalisees dans [0,1])
DaviesBouldinIndex 0,394 separation nette (< 0,5 = clusters bien separes)

L’indice de Davies-Bouldin de 0,394 est nettement sous le seuil de 0,5 : les trois clusters sont a la fois compacts et bien separes. Au-dela de 1,0 on parlerait de clusters qui se chevauchent. Les trois segments RFM etant volontairement bien distingues (Dormants / Reguliers / VIP), K-Means a effectivement retrouve la structure cachee – sans jamais avoir vu les etiquettes de segment.

Prediction : segmenter un nouveau client

On créé un PredictionEngine (comme en ML-1) pour inferer le cluster de nouveaux clients. La prediction renvoie :

  • PredictedClusterId : le cluster assigne (1 a K),
  • Distances : le vecteur des distances (normalisees) aux K centroides — utile pour mesurer la certitude (un point a mi-chemin de deux centroides est ambigus).
var engine = mlContext.Model.CreatePredictionEngine<CustomerData, ClusterPrediction>(model);

CustomerData[] testClients = new[] {
    new CustomerData { Recency = 90, Frequency = 1,  Monetary = 40   },  // profil dormant
    new CustomerData { Recency = 25, Frequency = 9,  Monetary = 160  },  // profil regulier
    new CustomerData { Recency = 5,  Frequency = 22, Monetary = 520  },  // profil VIP
};

Console.WriteLine("=== Prediction du segment de nouveaux clients ===");
foreach (var c in testClients)
{
    var p = engine.Predict(c);
    string distStr = string.Join(", ", p.Distances.Select(d => d.ToString("F2")));
    Console.WriteLine($"  Recency={c.Recency} Freq={c.Frequency} Monetary={c.Monetary}EUR -> Cluster {p.PredictedClusterId}  (distances normalisees: [{distStr}])");
}
=== Prediction du segment de nouveaux clients ===
  Recency=90 Freq=1 Monetary=40EUR -> Cluster 2  (distances normalisees: [0,45, 0,01, 1,50])
  Recency=25 Freq=9 Monetary=160EUR -> Cluster 1  (distances normalisees: [0,00, 0,43, 0,39])
  Recency=5 Freq=22 Monetary=520EUR -> Cluster 3  (distances normalisees: [0,56, 1,52, 0,01])

Interpretation : les segments retrouves

Les trois profils tombent dans trois clusters distincts, et pour chacun la distance a son cluster est negligeable devant les deux autres :

Profil Cluster assigne Distances (normalisees) aux 3 centroides
dormant (R90 F1 M40EUR) 2 [0,45 ; 0,01 ; 1,50]
regulier (R25 F9 M160EUR) 1 [0,00 ; 0,43 ; 0,39]
VIP (R5 F22 M520EUR) 3 [0,56 ; 1,52 ; 0,01]

La separation est nette : chaque client est quasi exactement sur un centroide (distance ~0,00-0,01) et loin des deux autres. K-Means a donc reconstruit, sans aucune etiquette, la segmentation Dormants / Reguliers / VIP. Les numéros de cluster (1, 2, 3) sont arbitraires – ils dependent de l’initialisation K-Means++ – mais la partition est correcte.

Choix de K : la méthode du coude

Mais comment sait-on que K=3 est le bon nombre de clusters ? C’est la question centrale du clustering non-supervise. La méthode du coude (Thorndike, 1953) consiste a entrainer K-Means pour plusieurs valeurs de K et a observer l’evolution de l’inertie (AverageDistance) :

  • L’inertie diminue mecaniquement quand K augmente (plus de centroides = points plus proches d’un centroide),
  • mais au-dela du "vrai" nombre de clusters, le gain devient marginal : la courbe forme un "coude".

On complete avec l’indice de Davies-Bouldin : son minimum pointe vers le K optimal. Si les deux critères convergent vers K=3, c’est un signal fort que les données ont structurellement 3 groupes.

Console.WriteLine("=== Choix de K : methode du coude (K=2..6) ===");
Console.WriteLine("  K | AverageDistance | DaviesBouldin");
Console.WriteLine("  --+-----------------+---------------");
for (int k = 2; k <= 6; k++)
{
    var sweepPipe = mlContext.Transforms
        .Concatenate("Features", nameof(CustomerData.Recency), nameof(CustomerData.Frequency), nameof(CustomerData.Monetary))
        .Append(mlContext.Transforms.NormalizeMinMax("Features"))
        .Append(mlContext.Clustering.Trainers.KMeans("Features", numberOfClusters: k));
    var sweepModel = sweepPipe.Fit(dataView);
    var sweepMetrics = mlContext.Clustering.Evaluate(sweepModel.Transform(dataView), featureColumnName: "Features");
    Console.WriteLine($"  {k} | {sweepMetrics.AverageDistance,15:F4} | {sweepMetrics.DaviesBouldinIndex,13:F3}");
}
=== Choix de K : methode du coude (K=2..6) ===
  K | AverageDistance | DaviesBouldin
  --+-----------------+---------------
  2 |          0,0961 |         0,513
  3 |          0,0209 |         0,394
  4 |          0,0190 |         0,759
  5 |          0,0133 |         0,875
  6 |          0,0115 |         0,901

Interpretation : ou est le coude ?

K AverageDistance (inertie) DaviesBouldin
2 0,0961 0,513
3 0,0209 0,394
4 0,0190 0,759
5 0,0133 0,875
6 0,0115 0,901

Lecture : l’inertie chute brutalement entre K=2 (0,096) et K=3 (0,021) – une baisse de 78 % – puis la pente s’adoucit fortement (K=4 ne gagne plus que 9 %, et la decroissance devient lineaire pour K=5 et K=6). Le coude est nettement a K=3. Parallelement, l’indice de Davies-Bouldin atteint son minimum a K=3 (0,394) puis remonte pour K superieur (les clusters supplementaires fragmentent les vrais groupes, degradant la separation). La convergence des deux critères – coude de l’inertie ET minimum du Davies-Bouldin – confirme que 3 est le nombre structurel de segments dans ces données RFM. C’est précisément le nombre de segments caches que nous avions genere.

Redemption : la ou K-Means multivarie est vraiment indispensable

Le jeu principal ci-dessus (3 segments Dormants / Reguliers / VIP) est presqu’unidimensionnel : la depense Monetary seule (50 / 150 / 500 EUR) separe déjà les trois segments. L’Exercice 3 le reconnait (« sans normalisation, le clustering reflete surtout la depense »). Sur un tel cas, K-Means n’apporte pas grand-chose qu’un simple seuil sur Monetary ne donnerait pas.

La capacite distinctive de K-Means — combiner plusieurs features pour separer des segments qu’aucune feature isolee ne distingue — ne s’exerce que sur un jeu ou chaque feature prise separement chevauche les segments. On construit donc trois segments disposes en triangle dans le plan (Recency, Frequency) :

  • jeune-actif : Recency bas, Frequency basse,
  • ancien-occasionnel : Recency haut, Frequency haute,
  • ancien-frequent : Recency bas, Frequency haute.

Les deux premières coordonnees se croisent : sur l’axe Recency seul, jeune-actif et ancien-frequent se chevauchent ; sur l’axe Frequency seul, ancien-occasionnel et ancien-frequent se chevauchent. Monetary est partage (bruit irrelatif). Aucune feature isolee ne separe les trois segments ; seul le plan (Recency, Frequency) le permet.

// === Redemption : 3 segments en TRIANGLE — chaque feature 1-D chevauche ===
// Sur le jeu principal, Monetary seul separait les segments (clustering quasi 1-D).
// Ici, les segments se croisent sur chaque axe isole : seul le plan (R,F) les separe.
var triCustomers = new List<CustomerData>();
var triLabels = new List<int>();
for (int i = 0; i < 50; i++) { triCustomers.Add(new CustomerData { Recency = RandUtil.NextGaussian(30, 9), Frequency = RandUtil.NextGaussian(30, 9), Monetary = RandUtil.NextGaussian(150, 40) }); triLabels.Add(0); } // jeune-actif
for (int i = 0; i < 50; i++) { triCustomers.Add(new CustomerData { Recency = RandUtil.NextGaussian(70, 9), Frequency = RandUtil.NextGaussian(70, 9), Monetary = RandUtil.NextGaussian(150, 40) }); triLabels.Add(1); } // ancien-occasionnel
for (int i = 0; i < 50; i++) { triCustomers.Add(new CustomerData { Recency = RandUtil.NextGaussian(30, 9), Frequency = RandUtil.NextGaussian(70, 9), Monetary = RandUtil.NextGaussian(150, 40) }); triLabels.Add(2); } // ancien-frequent
int[] triTruth = triLabels.ToArray();

// Exactitude de clustering = meilleur appariement 1-a-1 entre clusters trouves et verite (6 permutations de 3 clusters).
static float ClusterAcc(uint[] pred, int[] truth) {
    var perms = new[]{ new[]{0,1,2}, new[]{0,2,1}, new[]{1,0,2}, new[]{1,2,0}, new[]{2,0,1}, new[]{2,1,0} };
    int n = pred.Length, best = 0;
    foreach (var pm in perms) { int c = 0; for (int i = 0; i < n; i++) if (pm[pred[i] - 1] == truth[i]) c++; best = Math.Max(best, c); }
    return (float)best / n;
}
// Baseline triviale : meilleur seuil 1-D (2 coupures -> 3 bins) sur UNE feature, contre la verite.
static float Best1D(float[] feat, int[] truth) {
    var perms = new[]{ new[]{0,1,2}, new[]{0,2,1}, new[]{1,0,2}, new[]{1,2,0}, new[]{2,0,1}, new[]{2,1,0} };
    float best = 0; var sorted = feat.OrderBy(v => v).ToArray();
    for (int ci = 0; ci < feat.Length; ci += 2) for (int cj = ci + 2; cj < feat.Length; cj += 2) {
        double c1 = sorted[ci], c2 = sorted[cj]; int[] bin = new int[feat.Length];
        for (int i = 0; i < feat.Length; i++) bin[i] = feat[i] <= c1 ? 0 : (feat[i] <= c2 ? 1 : 2);
        foreach (var pm in perms) { int c = 0; for (int i = 0; i < feat.Length; i++) if (pm[bin[i]] == truth[i]) c++; best = Math.Max(best, (float)c / feat.Length); }
    }
    return best;
}

var triDv = mlContext.Data.LoadFromEnumerable(triCustomers);
var triPipe = mlContext.Transforms
    .Concatenate("Features", nameof(CustomerData.Recency), nameof(CustomerData.Frequency), nameof(CustomerData.Monetary))
    .Append(mlContext.Transforms.NormalizeMinMax("Features"))
    .Append(mlContext.Clustering.Trainers.KMeans("Features", numberOfClusters: 3));
var triModel = triPipe.Fit(triDv);
var triPreds = triModel.Transform(triDv).GetColumn<uint>("PredictedLabel").ToArray();
float accKm = ClusterAcc(triPreds, triTruth);
float accR = Best1D(triCustomers.Select(c => c.Recency).ToArray(), triTruth);
float accF = Best1D(triCustomers.Select(c => c.Frequency).ToArray(), triTruth);
float accM = Best1D(triCustomers.Select(c => c.Monetary).ToArray(), triTruth);
float best1D = Math.Max(accR, Math.Max(accF, accM));

Console.WriteLine("=== Segments en triangle (chaque feature 1-D chevauche) ===");
Console.WriteLine($"  Recency seul (meilleur seuil 1-D)   : {accR:F3}   (jeune-actif & ancien-frequent se chevauchent sur R)");
Console.WriteLine($"  Frequency seul (meilleur seuil 1-D) : {accF:F3}   (ancien-occasionnel & ancien-frequent se chevauchent sur F)");
Console.WriteLine($"  Monetary seul (bruit partage)       : {accM:F3}   (aucun signal)");
Console.WriteLine($"  Meilleure baseline 1 feature         : {best1D:F3}");
Console.WriteLine($"  K-Means 3D (Recency+Frequency+Mon)  : {accKm:F3}");
Console.WriteLine($"  Avantage K-Means multivarie         : +{accKm - best1D:F3}");
=== Segments en triangle (chaque feature 1-D chevauche) ===
  Recency seul (meilleur seuil 1-D)   : 0,720   (jeune-actif & ancien-frequent se chevauchent sur R)
  Frequency seul (meilleur seuil 1-D) : 0,733   (ancien-occasionnel & ancien-frequent se chevauchent sur F)
  Monetary seul (bruit partage)       : 0,427   (aucun signal)
  Meilleure baseline 1 feature         : 0,733
  K-Means 3D (Recency+Frequency+Mon)  : 0,993
  Avantage K-Means multivarie         : +0,260

Interpretation : K-Means bat tous les seuils univaries

méthode Exactitude Lecture
Seuil Recency seul ~0,70 separe ancien-occasionnel des deux autres, mais jeune-actif et ancien-frequent se chevauchent sur R
Seuil Frequency seul ~0,73 separe jeune-actif des deux autres, mais ancien-occasionnel et ancien-frequent se chevauchent sur F
Seuil Monetary seul ~0,42 aucun signal (Monetary est partage)
K-Means 3D (R+F+M) ~0,99 combine les features et retrouve les 3 segments

Aucune feature isolee ne depasse ~0,73 : chacune laisse deux segments chevauchants que seuillage 1-D ne peut separer. Pourtant K-Means atteint ~0,99 : en projetant sur les trois features simultanement, il exploite le fait que jeune-actif et ancien-frequent (qui se ressemblent sur Recency) se distinguent sur Frequency, et ancien-occasionnel et ancien-frequent (qui se ressemblent sur Frequency) se distinguent sur Recency. C’est précisément la capacite que aucun seuil par feature isolee ne peut reproduire : le clustering multivarie extrait la structure du plan (Recency, Frequency), pas celle d’un axe.

Contraste avec le jeu principal : sur Dormants / Reguliers / VIP, Monetary seul separait déjà tout (clustering quasi unidimensionnel). Ici, aucun axe ne suffit — K-Means est le seul outil qui marche. Les deux cas ensemble montrent quand K-Means est justifie : non pas sur des segments qu’un seul indicateur separe, mais sur des structures relationnelles que seule la geometrie multivariee revele.

Exercice 1 : Determiner le K optimal sur un dataset a 4 segments

Reutilisez la generation ci-dessus, mais avec 4 segments caches distincts (ajoutez par exemple un segment “Nouveaux” : Recency très bas, Frequency très basse, Monetary moyen). Cherchez ensuite le K optimal.

Objectifs : 1. Generer un nouveau jeu customers4 avec 4 segments x 25 points 2. Le charger dans un IDataView 3. Boucler K=2..7, entrainer K-Means, evaluer (AverageDistance + DaviesBouldin) 4. Identifier le K avec le DaviesBouldin minimum = votre K optimal

Indice : recopiez la boucle de sweep ci-dessus sur customers4. Le coude de l’inertie et le minimum du DaviesBouldin doivent pointer vers K=4.

// Exercice 1 : Determiner le K optimal sur un dataset a 4 segments
// TODO etudiant : genere 4 clusters caches (4 x 25 points), puis cherche le K optimal (2..7)
// Indice : recopie la boucle de sweep ci-dessus sur customers4 ; le DaviesBouldin minimum
//          et le coude de AverageDistance doivent pointer vers K=4.
// Etape 1 : genere un nouveau jeu customers4 avec 4 segments distincts
// Etape 2 : charge-le dans un IDataView
// Etape 3 : boucle K=2..7, entraine KMeans, evalue
// Etape 4 : affiche le K avec le DaviesBouldin minimum = ton K optimal
Console.WriteLine("Exercice 1 a completer");
Exercice 1 a completer

Exercice 2 : Implementer la silhouette moyenne (Rousseeuw, 1987)

La silhouette est une mesure plus fine que le Davies-Bouldin. Pour chaque point i :

\[s(i) = \frac{b(i) - a(i)}{\max(a(i), b(i))}\]

ou a(i) est la distance moyenne de i aux autres points de son cluster, et b(i) la distance moyenne de i au cluster voisin le plus proche. La silhouette globale est la moyenne des s(i) ; elle varie dans [-1, 1] (proche de 1 = clustering excellent).

Objectifs : 1. Recuperer les features normalisees et les labels via model.Transform(dataView).GetColumn<float[]>("Features") et GetColumn<uint>("PredictedLabel") 2. Calculer la matrice des distances euclidiennes (double boucle sur les 90 points) 3. Pour chaque point, calculer a(i) puis b(i), puis s(i) 4. Moyenner et verifier que K=3 maximise la silhouette (vs K=2, K=4)

Indice : a(i) moyenne sur le cluster de i (exclure i lui-même) ; b(i) = minimum sur les autres clusters de la distance moyenne a ce cluster.

// Exercice 2 : Implementer la silhouette moyenne (Rousseeuw, 1987)
// TODO etudiant : la silhouette s(i) = (b(i) - a(i)) / max(a(i), b(i))
//   a(i) = distance moyenne de i aux autres points de SON cluster
//   b(i) = distance moyenne de i au cluster voisin le plus proche
// Indice : recupere les features normalisees et les labels via
//   var feats = model.Transform(dataView).GetColumn<float[]>("Features").ToArray();
//   var labels = model.Transform(dataView).GetColumn<uint>("PredictedLabel").ToArray();
// Etape 1 : calcule la matrice des distances euclidiennes (double boucle)
// Etape 2 : pour chaque point, calcule a(i) puis b(i)
// Etape 3 : moyenne s(i) sur tous les points = silhouette globale
// Etape 4 : verifie que K=3 maximise la silhouette (vs K=2, K=4)
Console.WriteLine("Exercice 2 a completer");
Exercice 2 a completer

Exercice 3 : Effet de la normalisation sur K-Means

Le pipeline principal normalise (NormalizeMinMax). Que se passe-t-il sans normalisation ?

Objectifs : 1. Construire un pipeline sans NormalizeMinMax (Concatenate -> KMeans, K=3) 2. L’entrainer, l’evaluer (AverageDistance, DaviesBouldin) 3. Predire les 3 testClients et comparer les clusters a la version normalisee 4. Conclure : pourquoi la normalisation est-elle indispensable en clustering ?

Indice : Monetary (50 a 500 EUR) domine totalement Recency et Frequency en echelle brute. Sans normalisation, le clustering reflete surtout la depense — les clusters se forment essentiellement le long de l’axe Monetary.

// Exercice 3 : Effet de la normalisation sur K-Means
// TODO etudiant : entraine KMeans SANS NormalizeMinMax et compare les clusters trouves.
// Indice : Monetary (50..500 EUR) domine totalement Recency et Frequency en echelle brute ;
//          sans normalisation, le clustering reflete surtout la depense.
// Etape 1 : construis un pipeline SANS NormalizeMinMax (Concatenate -> KMeans)
// Etape 2 : entraine, evalue (AverageDistance, DaviesBouldin)
// Etape 3 : predit les 3 testClients et compare les clusters a la version normalisee
// Etape 4 : conclus : pourquoi la normalisation est-elle indispensable en clustering ?
Console.WriteLine("Exercice 3 a completer");
Exercice 3 a completer

Resume

Ce notebook a introduit le clustering non-supervise avec K-Means :

Concept Description
Clustering Apprentissage non-supervise : trouver des groupes sans etiquettes
ClusteringCatalog Catalogue ML.NET pour K-Means (mlContext.Clustering.Trainers.KMeans)
AverageDistance Inertie intra-cluster (plus bas = groupes compacts)
DaviesBouldinIndex Mesure de separation (plus bas = clusters mieux separes)
NormalizeMinMax Indispensable : met les features a la même echelle avant la distance euclidienne
méthode du coude Choix de K : la ou l’inertie cesse de baisser fortement

Points cles :

  • Contrairement a ML-1..ML-4 (supervise), aucune etiquette n’est fournie : K-Means decouvre seul la structure.
  • Normaliser avant de clusterer : des features d’echelles différentes (Recency en jours, Monetary en EUR) deformeraient la distance euclidienne (Exercice 3).
  • K-Means minimise l’inertie intra-cluster ; le choix de K est un compromis (coude + Davies-Bouldin), pas une verite absolue.
  • La prediction renvoie un cluster et les distances aux centroides — la certitude se lit dans l’ecart entre la plus petite distance et les suivantes.

References

Algorithme (K-Means)

  • MacQueen, J. (1967). Some methods for classification and analysis of multivariate observations. Proceedings of the Fifth Berkeley Symposium on Mathematical Statistics and Probability, 2(1), 281-297. — formulation originale de K-Means.
  • Lloyd, S. P. (1957/1982). Least squares quantization in PCM. IEEE Transactions on Information Theory, 28(2), 129-137. — l’algorithme d’itération assigner / mettre-a-jour utilise en pratique (Lloyd’s algorithm).
  • Arthur, D., & Vassilvitskii, S. (2007). K-means++: The advantages of careful seeding. Proceedings of SODA. — l’initialisation intelligente des centroides utilisee par ML.NET.

Metriques d’evaluation

  • Davies, D. L., & Bouldin, D. W. (1979). A cluster separation measure. IEEE Transactions on Pattern Analysis and Machine Intelligence, 1(2), 224-227. — l’indice de Davies-Bouldin (rapport dispersion / separation).
  • Rousseeuw, P. J. (1987). Silhouettes: a graphical aid to the interpretation and validation of cluster analysis. Journal of Computational and Applied Mathematics, 20, 53-65. — la silhouette (Exercice 2).
  • Thorndike, R. L. (1953). Who belongs in the family? Psychometrika, 18(4), 267-276. — la méthode du coude (elbow method).

Cas d’usage (segmentation RFM)

  • Hughes, A. M. (1996). The Complete Database Marketer: Second-Generation stratégies and Techniques for Tapping the Power of Your Customer Database. McGraw-Hill. — formalisation de la segmentation RFM (Recency, Frequency, Monetary).
Retour au sommet