Statistiques multivariées#

La plupart des jeux de données réels comportent des dizaines, voire des centaines de variables mesurées simultanément sur les mêmes individus. Analyser ces variables une par une revient à ignorer l’information contenue dans leurs relations. Les statistiques multivariées exploitent précisément ces relations : elles décrivent la structure d’un nuage de points en grande dimension, détectent des groupes naturels, réduisent la dimensionnalité tout en préservant l’information essentielle.

Ce chapitre couvre les outils fondamentaux : la distribution normale multivariée, l’analyse en composantes principales (ACP), l’analyse factorielle des correspondances (AFC), les méthodes de clustering (k-means, clustering hiérarchique, DBSCAN), et un aperçu des méthodes de réduction de dimension modernes.

Hide code cell source

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.patches import Ellipse
import seaborn as sns
from scipy import stats
from scipy.cluster.hierarchy import dendrogram, linkage, fcluster
from scipy.spatial.distance import pdist
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans, DBSCAN
from sklearn.metrics import silhouette_score, silhouette_samples
from sklearn.datasets import load_iris, load_wine, make_blobs, make_moons
import warnings
warnings.filterwarnings('ignore')

# Style global
plt.rcParams.update({
    'figure.dpi': 110,
    'axes.spines.top': False,
    'axes.spines.right': False,
    'font.size': 11,
})
rng = np.random.default_rng(42)

Distribution normale multivariée#

Définition et paramètres#

Un vecteur aléatoire \(\mathbf{X} = (X_1, \ldots, X_p)^\top\) suit une loi normale multivariée \(\mathcal{N}(\boldsymbol{\mu}, \boldsymbol{\Sigma})\) si sa densité est :

\[f(\mathbf{x}) = \frac{1}{(2\pi)^{p/2} |\boldsymbol{\Sigma}|^{1/2}} \exp\!\left(-\frac{1}{2}(\mathbf{x} - \boldsymbol{\mu})^\top \boldsymbol{\Sigma}^{-1} (\mathbf{x} - \boldsymbol{\mu})\right)\]

Les paramètres sont :

  • \(\boldsymbol{\mu} \in \mathbb{R}^p\) : vecteur des moyennes marginales

  • \(\boldsymbol{\Sigma} \in \mathbb{R}^{p \times p}\) : matrice de covariance, symétrique définie positive

La matrice de covariance \(\boldsymbol{\Sigma}\) contient sur la diagonale les variances \(\text{Var}(X_i) = \sigma_i^2\), et hors-diagonale les covariances \(\text{Cov}(X_i, X_j) = \rho_{ij}\sigma_i\sigma_j\)\(\rho_{ij}\) est la corrélation entre \(X_i\) et \(X_j\).

Ellipses de confiance#

En dimension 2, les courbes de niveau de la densité sont des ellipses. L’ellipse à \((1 - \alpha)\)% de confiance est l’ensemble des points \(\mathbf{x}\) vérifiant :

\[(\mathbf{x} - \boldsymbol{\mu})^\top \boldsymbol{\Sigma}^{-1} (\mathbf{x} - \boldsymbol{\mu}) \leq \chi^2_{p,\, 1-\alpha}\]

\(\chi^2_{p, 1-\alpha}\) est le quantile \((1-\alpha)\) du chi-deux à \(p\) degrés de liberté. Cette distance au centre est la distance de Mahalanobis, qui généralise la distance euclidienne en tenant compte des corrélations.

Hide code cell source

def ellipse_confiance(ax, mu, sigma, niveau=0.95, couleur='steelblue', label=None):
    """Trace une ellipse de confiance multivariée."""
    chi2_val = stats.chi2.ppf(niveau, df=2)
    vals, vecs = np.linalg.eigh(sigma)
    angle = np.degrees(np.arctan2(vecs[1, 1], vecs[0, 1]))
    width, height = 2 * np.sqrt(chi2_val * vals[::-1])
    ell = Ellipse(xy=mu, width=width, height=height, angle=angle,
                  edgecolor=couleur, facecolor=couleur, alpha=0.15, linewidth=2)
    ax.add_patch(ell)
    ell2 = Ellipse(xy=mu, width=width, height=height, angle=angle,
                   edgecolor=couleur, facecolor='none', linewidth=2, label=label)
    ax.add_patch(ell2)

fig, axes = plt.subplots(1, 3, figsize=(14, 4))
configs = [
    (np.array([0, 0]), np.array([[1, 0], [0, 1]]),    "Indépendantes\n(ρ = 0)"),
    (np.array([0, 0]), np.array([[2, 1.6], [1.6, 2]]), "Corrélées\n(ρ = 0.8)"),
    (np.array([0, 0]), np.array([[3, -1.5], [-1.5, 1]]), "Anti-corrélées\n(ρ = -0.87)"),
]

for ax, (mu, sigma, titre) in zip(axes, configs):
    n = 300
    X = rng.multivariate_normal(mu, sigma, n)
    ax.scatter(X[:, 0], X[:, 1], alpha=0.35, s=15, color='steelblue')
    ellipse_confiance(ax, mu, sigma, 0.68, 'steelblue', '68%')
    ellipse_confiance(ax, mu, sigma, 0.95, 'orangered', '95%')
    ellipse_confiance(ax, mu, sigma, 0.99, 'seagreen', '99%')
    ax.set_title(titre, fontsize=11)
    ax.set_xlabel('$X_1$'); ax.set_ylabel('$X_2$')
    ax.set_aspect('equal')

axes[0].legend(fontsize=9, loc='upper right')
fig.suptitle('Ellipses de confiance — Loi normale bivariée', fontsize=13, y=1.01)
plt.tight_layout()
plt.show()
_images/51ed43bbc48349b0c1e9fdf900c34af7c0b6a56a83fe54ea9aec116b0d76628b.png

La forme des ellipses révèle immédiatement la structure de dépendance : des ellipses allongées selon une diagonale indiquent une corrélation forte entre les variables.


Analyse en Composantes Principales (ACP)#

Principe et objectif#

L’ACP est une transformation linéaire orthogonale qui réexprime les données dans un nouveau système d’axes — les composantes principales — ordonnés par variance décroissante. La première composante principale (CP1) est la direction qui maximise la variance des projections ; la deuxième (CP2) est orthogonale à CP1 et maximise la variance résiduelle, etc.

Quand l’utiliser ?

  • Visualiser des données en grande dimension (projection sur CP1, CP2)

  • Réduire la dimensionnalité avant un modèle (débruitage, décorrélation)

  • Détecter la structure latente dans les données

  • Diagnostiquer des problèmes (multicolinéarité)

Prétraitement indispensable#

Avant toute ACP, il faut centrer et réduire les variables (soustraction de la moyenne, division par l’écart-type). Sans réduction, les variables à grande variance domineront artificiellement les premières composantes. La réduction est impérative dès que les variables sont exprimées dans des unités différentes.

Algorithme (rappel)#

  1. Centrer-réduire la matrice \(\mathbf{X}\) (\(n \times p\)) → \(\mathbf{Z}\)

  2. Calculer la matrice de corrélation \(\mathbf{R} = \frac{1}{n-1}\mathbf{Z}^\top\mathbf{Z}\)

  3. Décomposer en valeurs propres : \(\mathbf{R} = \mathbf{V}\boldsymbol{\Lambda}\mathbf{V}^\top\)

  4. Les loadings sont les colonnes de \(\mathbf{V}\) (vecteurs propres)

  5. Les scores sont \(\mathbf{F} = \mathbf{Z}\mathbf{V}\) (coordonnées dans le nouveau repère)

Choix du nombre de composantes#

Deux critères courants :

  • Scree plot : chercher le « coude » dans la courbe des valeurs propres

  • Règle de Kaiser : conserver les composantes dont la valeur propre \(> 1\) (c’est-à-dire celles qui expliquent plus de variance qu’une variable originale)

  • Variance cumulée : viser 70-80% de variance expliquée cumulée

Hide code cell source

# Chargement du jeu de données Vin (13 variables chimiques, 3 cépages)
wine = load_wine()
X_wine = pd.DataFrame(wine.data, columns=wine.feature_names)
y_wine = wine.target
labels_wine = wine.target_names

# ACP avec sklearn
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X_wine)

pca = PCA()
scores = pca.fit_transform(X_scaled)

variance_expliquee = pca.explained_variance_ratio_
variance_cumulee   = np.cumsum(variance_expliquee)

fig, axes = plt.subplots(1, 3, figsize=(15, 4))

# --- Scree plot ---
ax = axes[0]
ax.bar(range(1, len(variance_expliquee)+1), variance_expliquee * 100,
       color='steelblue', alpha=0.7, label='Individuelle')
ax.plot(range(1, len(variance_cumulee)+1), variance_cumulee * 100,
        'o-', color='orangered', label='Cumulée')
ax.axhline(80, color='gray', linestyle='--', linewidth=1, label='80%')
ax.axvline(np.argmax(variance_cumulee >= 0.80) + 1, color='gray', linestyle=':')
# Règle de Kaiser : val propre > 1  ↔  variance > 100/p %
kaiser_seuil = 100 / X_wine.shape[1]
ax.axhline(kaiser_seuil, color='seagreen', linestyle='--', linewidth=1, label=f'Kaiser ({kaiser_seuil:.1f}%)')
ax.set_xlabel('Composante principale')
ax.set_ylabel('Variance expliquée (%)')
ax.set_title('Scree plot')
ax.legend(fontsize=9)

# --- Projection CP1-CP2 ---
ax = axes[1]
couleurs = ['#e74c3c', '#3498db', '#2ecc71']
for i, (nom, col) in enumerate(zip(labels_wine, couleurs)):
    mask = y_wine == i
    ax.scatter(scores[mask, 0], scores[mask, 1], c=col, label=nom, alpha=0.75, s=40)
ax.set_xlabel(f'CP1 ({variance_expliquee[0]*100:.1f}%)')
ax.set_ylabel(f'CP2 ({variance_expliquee[1]*100:.1f}%)')
ax.set_title('Projection CP1–CP2')
ax.legend(fontsize=9)

# --- Biplot : scores + loadings ---
ax = axes[2]
scale = np.sqrt(pca.explained_variance_[0]) * 2
for i, (nom, col) in enumerate(zip(labels_wine, couleurs)):
    mask = y_wine == i
    ax.scatter(scores[mask, 0] / scale, scores[mask, 1] / scale,
               c=col, alpha=0.4, s=25)

loadings = pca.components_.T  # shape (p, n_components)
for j, feat in enumerate(wine.feature_names):
    ax.annotate('', xy=(loadings[j, 0], loadings[j, 1]), xytext=(0, 0),
                arrowprops=dict(arrowstyle='->', color='orangered', lw=1.5))
    ax.text(loadings[j, 0]*1.1, loadings[j, 1]*1.1, feat[:8],
            fontsize=7, ha='center', va='center', color='orangered')

ax.axhline(0, color='gray', linewidth=0.5)
ax.axvline(0, color='gray', linewidth=0.5)
ax.set_xlabel(f'CP1 ({variance_expliquee[0]*100:.1f}%)')
ax.set_ylabel(f'CP2 ({variance_expliquee[1]*100:.1f}%)')
ax.set_title('Biplot (scores + loadings)')

plt.suptitle('ACP sur le jeu de données Vin (13 variables, 178 échantillons)', fontsize=13, y=1.01)
plt.tight_layout()
plt.show()

print(f"Variance expliquée par CP1 : {variance_expliquee[0]*100:.1f}%")
print(f"Variance expliquée par CP1+CP2 : {variance_cumulee[1]*100:.1f}%")
print(f"Nombre de composantes pour 80% de variance : "
      f"{np.argmax(variance_cumulee >= 0.80) + 1}")
_images/6af4c59d3de8348f500068537ef7516d0e267e827688d617b91d22a2a40b2c76.png
Variance expliquée par CP1 : 36.2%
Variance expliquée par CP1+CP2 : 55.4%
Nombre de composantes pour 80% de variance : 5

Interprétation du biplot#

Le biplot superpose deux informations :

  • Les scores (points) : position de chaque individu dans le nouveau repère

  • Les loadings (flèches) : contribution de chaque variable originale aux composantes

Des flèches longues indiquent des variables bien représentées sur le plan. Des flèches proches (angle faible) correspondent à des variables fortement corrélées entre elles. Des flèches opposées (angle ≈ 180°) correspondent à une anti-corrélation.

Analyse des loadings#

Hide code cell source

pca3 = PCA(n_components=3)
pca3.fit(X_scaled)

loadings_df = pd.DataFrame(
    pca3.components_.T,
    index=wine.feature_names,
    columns=['CP1', 'CP2', 'CP3']
)

fig, ax = plt.subplots(figsize=(10, 5))
loadings_df.sort_values('CP1').plot(kind='barh', ax=ax, width=0.7,
                                     color=['steelblue', 'orangered', 'seagreen'])
ax.axvline(0, color='black', linewidth=0.8)
ax.set_title('Loadings des 3 premières composantes principales — Données Vin', fontsize=12)
ax.set_xlabel('Loading (corrélation variable–composante)')
ax.legend(title='Composante', fontsize=9)
plt.tight_layout()
plt.show()
_images/e13303779ee9993a809af345890a166e33ab34701348ff12a7afe02cc47cb60e.png

Interprétation des loadings

Les loadings peuvent être interprétés comme des corrélations entre les variables originales et les composantes. Un loading de 0.8 signifie que la variable est corrélée à 80% avec cette composante. En pratique, on nomme une composante d’après les variables qui ont les loadings les plus élevés (en valeur absolue).


Analyse Factorielle des Correspondances (AFC)#

L’AFC est l’analogue de l’ACP pour les tableaux de contingence (variables catégorielles). Elle décompose le \(\chi^2\) d’un tableau de contingence en dimensions successives et permet de visualiser les relations entre modalités de lignes et de colonnes.

Principe#

Pour un tableau de contingence \(\mathbf{N}\) (\(r \times c\)) :

  1. Calculer le tableau des profils (fréquences relatives par ligne et colonne)

  2. Calculer la statistique du \(\chi^2\) et l”inertie totale \(= \chi^2 / n\)

  3. Décomposition en valeurs singulières du tableau des résidus standardisés

  4. Les axes factoriels maximisent l’inertie expliquée

Hide code cell source

# Exemple : AFC sur un tableau fumeurs × catégorie socio-professionnelle
np.random.seed(42)
tableau = pd.DataFrame({
    'Non-fumeur': [135,  85, 210, 60],
    'Fumeur occ.': [ 45,  30,  70, 25],
    'Fumeur rég.': [ 20,  55,  35, 40],
    'Gros fumeur': [  5,  25,  10, 30],
}, index=['Cadre', 'Employé', 'Ouvrier', 'Étudiant'])

# AFC manuelle (SVD)
N   = tableau.values.astype(float)
n   = N.sum()
P   = N / n          # tableau de fréquences
r   = P.sum(axis=1)  # masses lignes
c   = P.sum(axis=0)  # masses colonnes
Dr  = np.diag(r)
Dc  = np.diag(c)
# Matrice des résidus standardisés
S   = np.diag(1/np.sqrt(r)) @ (P - np.outer(r, c)) @ np.diag(1/np.sqrt(c))
U, d, Vt = np.linalg.svd(S)
inertie_totale = (d**2).sum()
inertie_axe    = d**2

print(f"Inertie totale (= χ²/n) : {inertie_totale:.4f}")
print(f"χ² associé              : {inertie_totale * n:.2f}")
print(f"Inertie axe 1            : {inertie_axe[0]/inertie_totale*100:.1f}%")
print(f"Inertie axes 1+2         : {inertie_axe[:2].sum()/inertie_totale*100:.1f}%")

# Coordonnées lignes et colonnes
F_r = np.diag(1/np.sqrt(r)) @ U[:, :2] @ np.diag(d[:2])  # lignes
F_c = np.diag(1/np.sqrt(c)) @ Vt[:2, :].T @ np.diag(d[:2])  # colonnes

fig, ax = plt.subplots(figsize=(7, 5))
for i, nom in enumerate(tableau.index):
    ax.scatter(F_r[i, 0], F_r[i, 1], s=120, color='steelblue', zorder=5)
    ax.annotate(nom, (F_r[i, 0], F_r[i, 1]), textcoords='offset points',
                xytext=(6, 4), fontsize=11, color='steelblue')

for j, nom in enumerate(tableau.columns):
    ax.scatter(F_c[j, 0], F_c[j, 1], s=120, marker='D', color='orangered', zorder=5)
    ax.annotate(nom, (F_c[j, 0], F_c[j, 1]), textcoords='offset points',
                xytext=(6, -10), fontsize=10, color='orangered')

ax.axhline(0, color='gray', linewidth=0.5)
ax.axvline(0, color='gray', linewidth=0.5)
ax.set_xlabel(f'Axe 1 ({inertie_axe[0]/inertie_totale*100:.1f}% d\'inertie)')
ax.set_ylabel(f'Axe 2 ({inertie_axe[1]/inertie_totale*100:.1f}% d\'inertie)')
ax.set_title('AFC — Catégorie professionnelle × habitudes tabagiques', fontsize=12)

legend_elems = [
    mpatches.Patch(color='steelblue', label='Catégories (lignes)'),
    mpatches.Patch(color='orangered', label='Statut tabagique (colonnes)'),
]
ax.legend(handles=legend_elems, fontsize=9)
plt.tight_layout()
plt.show()
Inertie totale (= χ²/n) : 0.1243
χ² associé              : 109.37
Inertie axe 1            : 96.8%
Inertie axes 1+2         : 100.0%
_images/cc91e8e83555abafb519be62e6357a67873340caa1ab25a4f104a0241944a8c6.png

Note

En AFC, les modalités proches sur le graphique sont associées : les cadres se retrouvent du côté « non-fumeur » tandis que les employés se rapprochent des « gros fumeurs ». Les modalités proches de l’origine sont banales (elles ne dépassent pas la fréquence attendue sous indépendance).


Clustering non supervisé#

Le clustering consiste à regrouper des individus similaires sans connaissance préalable des groupes. Contrairement à la classification supervisée, il n’y a pas d’étiquettes d’entraînement.

K-means#

Algorithme#

L’algorithme de Lloyd (communément appelé k-means) procède par itérations :

  1. Initialiser \(k\) centroïdes aléatoirement (ou par k-means++)

  2. Affecter chaque point au centroïde le plus proche (distance euclidienne)

  3. Mettre à jour chaque centroïde : nouvelle position = moyenne du cluster

  4. Répéter jusqu’à convergence (les affectations ne changent plus)

L’algorithme minimise l”inertie intra-cluster (aussi appelée WCSS, Within-Cluster Sum of Squares) :

\[\text{WCSS} = \sum_{j=1}^{k} \sum_{\mathbf{x}_i \in C_j} \|\mathbf{x}_i - \boldsymbol{\mu}_j\|^2\]

Méthode du coude et silhouette#

Hide code cell source

# Données simulées : 4 clusters bien séparés
X_blobs, y_true = make_blobs(n_samples=400, centers=4, cluster_std=0.9,
                              random_state=42)

# Méthode du coude
k_range = range(2, 11)
wcss = []
sil_scores = []

for k in k_range:
    km = KMeans(n_clusters=k, random_state=42, n_init=10)
    labels = km.fit_predict(X_blobs)
    wcss.append(km.inertia_)
    sil_scores.append(silhouette_score(X_blobs, labels))

fig, axes = plt.subplots(1, 3, figsize=(15, 4))

# Coude
ax = axes[0]
ax.plot(list(k_range), wcss, 'o-', color='steelblue', linewidth=2)
ax.axvline(4, color='orangered', linestyle='--', label='k optimal = 4')
ax.set_xlabel('Nombre de clusters k')
ax.set_ylabel('Inertie intra-cluster (WCSS)')
ax.set_title('Méthode du coude')
ax.legend()

# Silhouette
ax = axes[1]
ax.plot(list(k_range), sil_scores, 'o-', color='seagreen', linewidth=2)
ax.axvline(4, color='orangered', linestyle='--', label='k optimal = 4')
ax.set_xlabel('Nombre de clusters k')
ax.set_ylabel('Score silhouette moyen')
ax.set_title('Score silhouette')
ax.legend()

# Clustering final avec k=4
km_final = KMeans(n_clusters=4, random_state=42, n_init=10)
labels_km = km_final.fit_predict(X_blobs)

ax = axes[2]
palette = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12']
for j in range(4):
    mask = labels_km == j
    ax.scatter(X_blobs[mask, 0], X_blobs[mask, 1],
               c=palette[j], alpha=0.65, s=30, label=f'Cluster {j+1}')
ax.scatter(km_final.cluster_centers_[:, 0], km_final.cluster_centers_[:, 1],
           c='black', marker='X', s=150, zorder=10, label='Centroïdes')
ax.set_title('K-means (k=4)')
ax.legend(fontsize=9)

plt.suptitle('Sélection du k optimal et résultat K-means', fontsize=13, y=1.01)
plt.tight_layout()
plt.show()

print(f"Score silhouette pour k=4 : {silhouette_score(X_blobs, labels_km):.3f}")
_images/421ec3f4184d48fe8938f461dca4560d5a9fb04479bc7035dc9345439086ca45.png
Score silhouette pour k=4 : 0.814

Silhouette plot détaillé#

Hide code cell source

fig, ax = plt.subplots(figsize=(8, 5))
km4 = KMeans(n_clusters=4, random_state=42, n_init=10)
labels4 = km4.fit_predict(X_blobs)
sil_vals = silhouette_samples(X_blobs, labels4)

y_lower = 10
couleurs_sil = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12']
for j in range(4):
    sil_j = np.sort(sil_vals[labels4 == j])
    taille = len(sil_j)
    y_upper = y_lower + taille
    ax.fill_betweenx(np.arange(y_lower, y_upper), 0, sil_j,
                     alpha=0.7, color=couleurs_sil[j], label=f'Cluster {j+1}')
    ax.text(-0.05, y_lower + taille / 2, str(j+1), fontsize=9)
    y_lower = y_upper + 10

ax.axvline(silhouette_score(X_blobs, labels4), color='black',
           linestyle='--', linewidth=1.5, label=f'Moyenne = {silhouette_score(X_blobs, labels4):.3f}')
ax.set_xlabel('Coefficient silhouette')
ax.set_ylabel('Individus (par cluster)')
ax.set_title('Silhouette plot — K-means k=4')
ax.legend(fontsize=9)
plt.tight_layout()
plt.show()
_images/d2cf8cb358d8c377aad88777b91d83becd869d4561604a2926b93187c2fe8837.png

Interpréter le score silhouette

Le coefficient silhouette \(s_i\) d’un point \(i\) compare sa distance moyenne aux points de son cluster (\(a_i\)) à la distance moyenne au cluster le plus proche (\(b_i\)) :

\[s_i = \frac{b_i - a_i}{\max(a_i, b_i)} \in [-1, 1]\]
  • \(s_i \approx 1\) : le point est bien placé dans son cluster

  • \(s_i \approx 0\) : le point est à la frontière entre deux clusters

  • \(s_i < 0\) : le point est probablement mal classé

Clustering hiérarchique agglomératif (CAH)#

La CAH construit un dendrogramme en fusionnant itérativement les clusters les plus proches. Contrairement au k-means, elle n’impose pas de nombre de clusters a priori et produit une hiérarchie complète.

Méthodes de liaison#

La définition de la distance entre clusters varie selon la méthode :

  • Ward : minimise l’augmentation de l’inertie totale lors de la fusion (recommandée pour des clusters compacts et de taille similaire)

  • Complete (lien maximum) : distance entre les deux points les plus éloignés

  • Average (UPGMA) : distance moyenne entre tous les couples de points

  • Single (lien minimum) : distance entre les deux points les plus proches (sensible aux chaînes)

Hide code cell source

fig, axes = plt.subplots(1, 4, figsize=(16, 5))
methodes = ['ward', 'complete', 'average', 'single']
titres    = ['Ward', 'Complete (max)', 'Average', 'Single (min)']

for ax, meth, titre in zip(axes, methodes, titres):
    Z = linkage(X_blobs[:80], method=meth)  # 80 points pour lisibilité
    dendrogram(Z, ax=ax, no_labels=True, color_threshold=0.7*max(Z[:, 2]))
    ax.set_title(f'Liaison : {titre}', fontsize=10)
    ax.set_xlabel('Individus')
    ax.set_ylabel('Distance de fusion')

plt.suptitle('Dendrogrammes — Comparaison des méthodes de liaison (CAH)', fontsize=13, y=1.01)
plt.tight_layout()
plt.show()
_images/9e51a9d7f8cbc12a6b2d5e4ecc243e54f964ca73786202fc8ffc0f134da5d6da.png

Hide code cell source

# CAH complète et partition en 4 clusters
Z_ward = linkage(X_blobs, method='ward')
labels_cah = fcluster(Z_ward, t=4, criterion='maxclust') - 1

fig, axes = plt.subplots(1, 2, figsize=(12, 5))

# Dendrogramme tronqué
ax = axes[0]
dendrogram(Z_ward, ax=ax, no_labels=True, truncate_mode='lastp', p=20,
           color_threshold=Z_ward[-3, 2])
ax.axhline(Z_ward[-3, 2], color='orangered', linestyle='--', linewidth=1.5,
           label=f'Coupe → 4 clusters')
ax.set_title('Dendrogramme Ward (tronqué, 20 dernières fusions)')
ax.set_ylabel('Distance de fusion')
ax.legend()

# Résultat
ax = axes[1]
for j in range(4):
    mask = labels_cah == j
    ax.scatter(X_blobs[mask, 0], X_blobs[mask, 1],
               c=palette[j], alpha=0.65, s=30, label=f'Cluster {j+1}')
ax.set_title('Résultat CAH Ward (4 clusters)')
ax.legend(fontsize=9)

plt.suptitle('Clustering hiérarchique agglomératif', fontsize=13, y=1.01)
plt.tight_layout()
plt.show()

print(f"Score silhouette CAH Ward : {silhouette_score(X_blobs, labels_cah):.3f}")
print(f"Score silhouette K-means  : {silhouette_score(X_blobs, labels_km):.3f}")
_images/3a0da96a890e9e624d30ae28f4999d1b32a3bdb7a019f4929e4280b01fbbb648.png
Score silhouette CAH Ward : 0.814
Score silhouette K-means  : 0.814

DBSCAN#

DBSCAN (Density-Based Spatial Clustering of Applications with Noise) est un algorithme basé sur la densité plutôt que sur la distance aux centroïdes. Il est particulièrement adapté aux clusters de forme arbitraire et identifie automatiquement les points de bruit.

Paramètres clés :

  • \(\epsilon\) (eps) : rayon de voisinage — distance maximale pour qu’un point soit considéré comme voisin

  • minPts (min_samples) : nombre minimal de voisins pour qu’un point soit un point cœur

Classification des points :

  • Point cœur : au moins minPts voisins dans le rayon \(\epsilon\)

  • Point frontière : dans le voisinage d’un point cœur, mais pas assez de voisins lui-même

  • Point de bruit : ni cœur ni frontière (label = −1 dans sklearn)

Hide code cell source

# DBSCAN est particulièrement adapté aux formes non convexes
X_moons, y_moons = make_moons(n_samples=400, noise=0.07, random_state=42)
X_anneaux = np.vstack([
    rng.standard_normal((200, 2)) * 0.5,
    rng.standard_normal((200, 2)) * 1.5 + 2.5,
])

fig, axes = plt.subplots(2, 3, figsize=(14, 8))

datasets = [
    (X_moons,   'Deux lunes'),
    (X_blobs,   'Blobs gaussiens'),
    (X_anneaux, 'Distributions imbriquées'),
]

params_dbscan = [
    {'eps': 0.15, 'min_samples': 5},
    {'eps': 0.8,  'min_samples': 5},
    {'eps': 0.35, 'min_samples': 5},
]

for col, (X_d, titre), params in zip(range(3), datasets, params_dbscan):
    # K-means
    km_comp = KMeans(n_clusters=2, random_state=42, n_init=10).fit(X_d)
    lk = km_comp.labels_
    axes[0, col].scatter(X_d[:, 0], X_d[:, 1], c=lk, cmap='Set1',
                          alpha=0.6, s=20)
    axes[0, col].set_title(f'{titre}\nK-means (k=2)', fontsize=10)

    # DBSCAN
    db = DBSCAN(**params).fit(X_d)
    ld = db.labels_
    n_bruit = np.sum(ld == -1)
    couleurs_db = np.where(ld == -1, 'gray', np.where(ld == 0, '#e74c3c', '#3498db'))
    axes[1, col].scatter(X_d[:, 0], X_d[:, 1], c=couleurs_db, alpha=0.6, s=20)
    axes[1, col].set_title(f'{titre}\nDBSCAN (ε={params["eps"]}, {n_bruit} bruits)',
                            fontsize=10)

axes[0, 0].set_ylabel('K-means', fontsize=11)
axes[1, 0].set_ylabel('DBSCAN',  fontsize=11)
plt.suptitle('Comparaison K-means vs DBSCAN sur différentes géométries', fontsize=13, y=1.01)
plt.tight_layout()
plt.show()
_images/85c5a87b85d49e1123756f7d2dcaed36d273e5ebe229fcf4067233a20a251ed6.png

Limites du K-means

Le K-means suppose des clusters convexes et de variance homogène. Il est inadapté aux formes en lune, en anneau ou allongées. DBSCAN gère ces cas mais requiert un bon réglage de ε et minPts. Pour choisir ε, on trace la courbe des distances au k-ième plus proche voisin (k = minPts) et on cherche le « coude ».


Réduction de dimension : t-SNE et UMAP#

Comparaison avec l’ACP#

Méthode

Type

Global/Local

Déterministe

Utilisable pour modélisation

ACP

Linéaire

Global

Oui

Oui

t-SNE

Non-linéaire

Local

Non (stochastique)

Non (embedding seulement)

UMAP

Non-linéaire

Global + Local

Presque

Avec précautions

t-SNE (t-distributed Stochastic Neighbor Embedding) minimise la divergence KL entre distributions de similarités dans l’espace d’origine et dans l’espace 2D. Il préserve les structures locales mais distord les distances globales. Les distances entre clusters sur un t-SNE ne sont pas interprétables directement.

UMAP (Uniform Manifold Approximation and Projection) repose sur une théorie différentielle des variétés riemanniennes. Il est plus rapide que t-SNE, préserve mieux la structure globale, et peut être utilisé pour projeter de nouveaux points.

Hide code cell source

from sklearn.manifold import TSNE

# ACP vs t-SNE sur les données Vin
pca2 = PCA(n_components=2)
X_pca = pca2.fit_transform(X_scaled)

tsne = TSNE(n_components=2, perplexity=30, random_state=42, max_iter=1000)
X_tsne = tsne.fit_transform(X_scaled)

fig, axes = plt.subplots(1, 2, figsize=(12, 4))
couleurs_vin = ['#e74c3c', '#3498db', '#2ecc71']

for i, (nom, col) in enumerate(zip(labels_wine, couleurs_vin)):
    mask = y_wine == i
    axes[0].scatter(X_pca[mask, 0], X_pca[mask, 1], c=col, label=nom, alpha=0.75, s=40)
    axes[1].scatter(X_tsne[mask, 0], X_tsne[mask, 1], c=col, label=nom, alpha=0.75, s=40)

axes[0].set_title(f'ACP — CP1 ({variance_expliquee[0]*100:.1f}%) + CP2 ({variance_expliquee[1]*100:.1f}%)')
axes[0].set_xlabel('CP1'); axes[0].set_ylabel('CP2')
axes[0].legend()

axes[1].set_title('t-SNE (perplexité=30)')
axes[1].set_xlabel('Dim 1'); axes[1].set_ylabel('Dim 2')
axes[1].legend()

plt.suptitle('ACP vs t-SNE — Données Vin', fontsize=13, y=1.01)
plt.tight_layout()
plt.show()
_images/54a62aaa8ae93a0e7b96832bafe8233c8bfb7e469f4745fce5e5510fabc4cc25.png

MANOVA#

La MANOVA (Multivariate Analysis of Variance) est l’extension multivariée de l’ANOVA. Là où l’ANOVA teste si la moyenne d”une variable dépend d’un facteur de groupe, la MANOVA teste si le vecteur de moyennes de plusieurs variables dépend simultanément d’un facteur.

Hypothèses :

  • \(H_0\) : \(\boldsymbol{\mu}_1 = \boldsymbol{\mu}_2 = \ldots = \boldsymbol{\mu}_k\) (tous les vecteurs moyens sont égaux)

  • \(H_1\) : au moins deux vecteurs moyens diffèrent

Statistiques de test (plusieurs variantes) : Lambda de Wilks \(\Lambda\), Trace de Pillai, Trace d’Hotelling, Valeur propre maximale de Roy.

from statsmodels.multivariate.manova import MANOVA

# MANOVA sur les données Iris (3 groupes, 4 variables)
iris = load_iris()
df_iris = pd.DataFrame(iris.data, columns=['sepal_length', 'sepal_width',
                                            'petal_length', 'petal_width'])
df_iris['species'] = iris.target_names[iris.target]

manova = MANOVA.from_formula(
    'sepal_length + sepal_width + petal_length + petal_width ~ species',
    data=df_iris
)
result = manova.mv_test()
print(result)
                   Multivariate linear model
================================================================
                                                                
----------------------------------------------------------------
       Intercept         Value  Num DF  Den DF   F Value  Pr > F
----------------------------------------------------------------
          Wilks' lambda  0.0170 4.0000 144.0000 2086.7720 0.0000
         Pillai's trace  0.9830 4.0000 144.0000 2086.7720 0.0000
 Hotelling-Lawley trace 57.9659 4.0000 144.0000 2086.7720 0.0000
    Roy's greatest root 57.9659 4.0000 144.0000 2086.7720 0.0000
----------------------------------------------------------------
                                                                
----------------------------------------------------------------
        species          Value  Num DF  Den DF   F Value  Pr > F
----------------------------------------------------------------
          Wilks' lambda  0.0234 8.0000 288.0000  199.1453 0.0000
         Pillai's trace  1.1919 8.0000 290.0000   53.4665 0.0000
 Hotelling-Lawley trace 32.4773 8.0000 203.4024  582.1970 0.0000
    Roy's greatest root 32.1919 4.0000 145.0000 1166.9574 0.0000
================================================================

Note

Le Lambda de Wilks proche de 0 indique que les groupes sont bien séparés dans l’espace multivarié. Ici, \(p < 0.001\) confirme que les trois espèces d’iris diffèrent significativement sur l’ensemble des quatre mesures morphologiques.


Comparaison complète des méthodes de clustering#

Hide code cell source

from sklearn.mixture import GaussianMixture

# Comparaison sur les données Vin (après ACP, 2 premières CP)
X_wine_2d = PCA(n_components=2).fit_transform(X_scaled)

methodes_clustering = {
    'K-means (k=3)'   : KMeans(n_clusters=3, random_state=42, n_init=10).fit_predict(X_wine_2d),
    'DBSCAN'          : DBSCAN(eps=0.5, min_samples=5).fit_predict(X_wine_2d),
    'GMM (k=3)'       : GaussianMixture(n_components=3, random_state=42).fit_predict(X_wine_2d),
}

Z_wine = linkage(X_wine_2d, method='ward')
labels_cah_wine = fcluster(Z_wine, t=3, criterion='maxclust') - 1
methodes_clustering['CAH Ward (k=3)'] = labels_cah_wine

fig, axes = plt.subplots(2, 2, figsize=(12, 9))
axes_flat = axes.flatten()

for ax, (nom_meth, labels_meth) in zip(axes_flat, methodes_clustering.items()):
    n_clusters = len(set(labels_meth)) - (1 if -1 in labels_meth else 0)
    cmap_vals  = plt.cm.Set1(np.linspace(0, 0.8, n_clusters + 1))
    label_map  = {l: i for i, l in enumerate(sorted(set(labels_meth)))}

    for l in sorted(set(labels_meth)):
        mask = labels_meth == l
        nom_cluster = 'Bruit' if l == -1 else f'Cluster {l+1}'
        col = 'gray' if l == -1 else cmap_vals[label_map[l]]
        ax.scatter(X_wine_2d[mask, 0], X_wine_2d[mask, 1],
                   c=[col], alpha=0.65, s=35, label=nom_cluster)

    # Vraies étiquettes en contour
    for i, (nom, col) in enumerate(zip(labels_wine, couleurs_vin)):
        mask_true = y_wine == i
        ax.scatter(X_wine_2d[mask_true, 0], X_wine_2d[mask_true, 1],
                   edgecolors=col, facecolors='none', s=60, linewidth=1, alpha=0.4)

    if -1 not in labels_meth:
        sil = silhouette_score(X_wine_2d, labels_meth)
        ax.set_title(f'{nom_meth}\n(silhouette = {sil:.3f})', fontsize=10)
    else:
        ax.set_title(f'{nom_meth}\n({np.sum(labels_meth == -1)} bruits)', fontsize=10)

    ax.set_xlabel('CP1'); ax.set_ylabel('CP2')
    ax.legend(fontsize=8)

plt.suptitle('Comparaison des méthodes de clustering — Données Vin (projection ACP)',
             fontsize=13, y=1.01)
plt.tight_layout()
plt.show()
_images/2ad207e65c8fb063bb64f9ac09aa23622c53566f271c7e0d7d4db8e3b63dbf6f.png

Résumé#

Méthode

Type

Hypothèses

Points forts

Limites

ACP

Réduction dim.

Linéarité, variance maximale

Interprétable, rapide

Liaisons non-linéaires perdues

AFC

Réduction dim.

Tableau de contingence

Variables catégorielles

Données quantitatives non adaptées

K-means

Clustering

Clusters convexes, sphériques

Rapide, scalable

Sensible aux outliers, k fixé

CAH

Clustering

Aucune sur la forme

Dendrogramme, k libre

Lent sur grands jeux, irréversible

DBSCAN

Clustering

Densité uniforme

Formes arbitraires, bruit

Paramètres sensibles

t-SNE

Visualisation

Structures locales

Non interprétable quantitativement

Bonnes pratiques en analyse multivariée

  1. Toujours centrer-réduire avant l’ACP si les variables ont des unités différentes.

  2. Vérifier la stabilité du clustering : relancer k-means plusieurs fois, comparer avec CAH.

  3. Ne pas sur-interpréter les distances sur un t-SNE : seuls les regroupements locaux sont fiables.

  4. Valider les clusters sur des données externes quand c’est possible.

  5. L’ACP est exploratoire : elle ne teste pas d’hypothèse. Pour des conclusions inférentielles, utiliser MANOVA ou des tests sur les scores.