k-NN & k-Means : Classification et Clustering

TD2 — De la similarité aux groupes spontanés
📊 Distances · k-NN · k-Means · From Scratch · Scikit-learn

§0 Prérequis

Ce TD utilise les listes, les dictionnaires, les boucles, et Matplotlib. Le cours 4 (dictionnaires) est un plus mais pas indispensable — on peut tout faire avec des listes.

La classification et le clustering, c'est le yin et le yang de l'apprentissage non supervisé. Le k-NN a besoin d'exemples étiquetés (on sait déjà quels points sont dans quelle catégorie). Le k-means, lui, arrive les mains vides et découvre tout seul les groupes cachés. L'un est un élève studieux, l'autre un explorateur. Les deux sont utiles.
Les deux algorithmes de ce TD ont été inventés autour des années 1950–60. Le k-NN a été formalisé par Evelyn Fix et Joseph Hodges Jr. en 1951, puis popularisé par Thomas Cover (le même du théorème de Cover sur les capacités des réseaux de neurones). Le k-means a été proposé indépendamment par plusieurs chercheurs dans les années 1950–60, dont Hugo Steinhaus (mathématicien polonais) et Stuart Lloyd (Bell Labs, 1957, mais publié seulement en 1982 — les labs, c'était l'époque où on publiait quand on avait le temps).

§1 Partie I — k-NN : les voisins qui savent tout

1.1 Les distances — mesurer la similarité

Le cœur du k-NN, c'est la distance entre deux points. Le point inconnu reçoit l'étiquette majoritaire de ses k voisins les plus proches.

Exercice 1.1 — Implémentez les distances

Écrivez deux fonctions de distance entre deux vecteurs (listes de même taille) : Testez avec a = [0, 0] et b = [3, 4] (euclidienne → 5, Manhattan → 7).
Solution
import math

def distance_euclidienne(a, b):
    total = 0
    for i in range(len(a)):
        total = total + (a[i] - b[i]) ** 2
    return math.sqrt(total)

def distance_manhattan(a, b):
    total = 0
    for i in range(len(a)):
        total = total + abs(a[i] - b[i])
    return total

print(distance_euclidienne([0, 0], [3, 4]))  # 5.0
print(distance_manhattan([0, 0], [3, 4]))  # 7
5.0
7
La distance euclidienne, c'est la « distance à vol d'oiseau ». La distance de Manhattan (ou L1), c'est la distance dans les rues de New York (d'où le nom) : on ne peut se déplacer que le long des avenues, pas en diagonale. En IA, le choix de la distance a un impact énorme. La L1 est plus robuste aux valeurs aberrantes. La L2 est plus sensible. Il n'y a pas de meilleure distance — il y a la distance qui marche pour vos données.

1.2 k-NN : l'algorithme

Exercice 1.2 — Créez un mini-dataset d'étudiants

On a mesuré le nombre d'heures de révision et le taux de présence en cours de 8 étudiants, ainsi que leur résultat (A = admis, R = refusé) :
donnees = [
    {"heures": 2,  "presence": 0.3, "resultat": "R"},
    {"heures": 1,  "presence": 0.2, "resultat": "R"},
    {"heures": 5,  "presence": 0.8, "resultat": "A"},
    {"heures": 3,  "presence": 0.6, "resultat": "A"},
    {"heures": 4,  "presence": 0.9, "resultat": "A"},
    {"heures": 0.5,"presence": 0.1, "resultat": "R"},
    {"heures": 3.5,"presence": 0.7, "resultat": "A"},
    {"heures": 2.5,"presence": 0.5, "resultat": "R"},
]

Séparez en X (liste de [heures, presence]) et y (liste des résultats).
Solution
X = [[d["heures"], d["presence"]] for d in donnees]
y = [d["resultat"] for d in donnees]

print("X :", X)
print("y :", y)
X : [[2, 0.3], [1, 0.2], [5, 0.8], [3, 0.6], [4, 0.9], [0.5, 0.1], [3.5, 0.7], [2.5, 0.5]]
y : ['R', 'R', 'A', 'A', 'A', 'R', 'A', 'R']
Exercice 1.3 — Implémentez k-NN

Écrivez knn_classifier(X, y, nouveau_point, k) qui :
1. Calcule la distance du nouveau point à tous les points de X
2. Trie par distance croissante
3. Prend les k plus proches
4. Retourne l'étiquette majoritaire (vote)

Testez sur un étudiant avec 3h de révision et 0.75 de présence. Essayez k=3 et k=5. Le résultat change-t-il ?
Solution
def knn_classifier(X, y, nouveau_point, k):
    """k-NN : classe un nouveau point par vote majoritaire des k voisins."""
    # Calcul des distances
    distances = []
    for i in range(len(X)):
        d = distance_euclidienne(X[i], nouveau_point)
        distances.append((d, y[i]))
    
    # Tri par distance
    distances.sort(key=lambda paire: paire[0])
    
    # Vote des k plus proches
    votes = {}
    for i in range(k):
        etiquette = distances[i][1]
        votes[etiquette] = votes.get(etiquette, 0) + 1
    
    # Trouver l'étiquette majoritaire
    majoritaire = None
    max_votes = -1
    for etiquette, compte in votes.items():
        if compte > max_votes:
            max_votes = compte
            majoritaire = etiquette
    
    return majoritaire

nouveau = [3, 0.75]
print("k=3 →", knn_classifier(X, y, nouveau, 3))
print("k=5 →", knn_classifier(X, y, nouveau, 5))
k=3 → A
k=5 → A
Le k-NN est l'algorithme d'IA le plus paresseux qui soit : il ne fait rien pendant l'entraînement (pas de calcul, pas de modèle). Il attend qu'on lui donne un point à classer pour se lever et regarder autour de lui. On dit que c'est un algorithme paresseux (lazy learner). En entreprise, on appelle ça un stagiaire efficace.

1.3 Choisir k — le nombre de voisins

Exercice 1.4 — Tester différents k

Écrivez une boucle qui teste k = 1, 3, 5, 7 sur les données et calcule le taux de bonne classification sur les données d'entraînement (c'est un peu « tricher » de tester sur les données d'apprentissage, mais pour l'instant ça donne une idée).

Que se passe-t-il avec k=1 ? Pourquoi ?
Solution
def taux_classification(X, y, k):
    bons = 0
    for i in range(len(X)):
        pred = knn_classifier(X, y, X[i], k)
        if pred == y[i]:
            bons = bons + 1
    return bons / len(X)

for k in [1, 3, 5, 7]:
    print("k =", k, "→ précision :", taux_classification(X, y, k))
k = 1 → précision : 1.0
k = 3 → précision : 1.0
k = 5 → précision : 0.875
k = 7 → précision : 0.75
Avec k=1, la précision est parfaite... parce que chaque point est son propre plus proche voisin ! C'est du surapprentissage (overfitting) pur. Le modèle « apprend par cœur » les données et ne généralise pas. En pratique, on choisit k impair pour éviter les égalités, et on le détermine par validation croisée.

Variation : distance cosinus (pour le texte)

Pour comparer des documents (vecteurs de mots), la distance cosinus est souvent meilleure que l'euclidienne :

def distance_cosinus(a, b):
    num = sum(a[i] * b[i] for i in range(len(a)))
    norm_a = math.sqrt(sum(x**2 for x in a))
    norm_b = math.sqrt(sum(x**2 for x in b))
    if norm_a == 0 or norm_b == 0:
        return 1
    return 1 - num / (norm_a * norm_b)

a = [1, 2, 3]
b = [2, 4, 6]
print(distance_cosinus(a, b))  # 0.0 → parfaitement alignés (même direction)
# La cosinus mesure l'angle, pas l'amplitude : parfait pour les textes.
0.0

§2 Partie II — k-Means : trouver des groupes tout seul

Le k-means est un algorithme de clustering (regroupement non supervisé). On a des points, on veut les répartir en k groupes homogènes. L'algorithme alterne deux étapes :

  1. Assignment — chaque point est attribué au centroïde le plus proche
  2. Mise à jour — chaque centroïde devient la moyenne des points de son groupe
Exercice 2.1 — Génération de données jouets

Créez trois nuages de points distincts (3 clusters) en 2D :
import random
random.seed(42)

def nuage(cx, cy, n, dispersion=0.5):
    return [[cx + random.uniform(-dispersion, dispersion),
             cy + random.uniform(-dispersion, dispersion)]
            for _ in range(n)]

points = nuage(2, 2, 20) + nuage(6, 6, 20) + nuage(8, 2, 20)

2.1 L'algorithme k-means

Exercice 2.2 — Implémentez k-means

Écrivez une fonction kmeans(points, k, iterations) qui :
1. Initialise k centroïdes aléatoirement parmi les points
2. Pour chaque itération :
  a. Assigne chaque point au centroïde le plus proche
  b. Recalcule chaque centroïde = moyenne des points du groupe
3. Retourne les centroïdes finaux et l'assignation de chaque point

Testez avec k=3 et 20 itérations.
Solution
from random import sample

def kmeans(points, k, iterations=20):
    """k-means : regroupe les points en k clusters.
    
    Retourne :
        centroides — liste des k centres finaux
        assign — liste de l'indice du cluster pour chaque point
    """
    # Initialisation : choisir k points aléatoires comme centroïdes
    centroides = sample(points, k)
    
    for _ in range(iterations):
        # Étape 1 : assigner chaque point au centroïde le plus proche
        assign = []
        for p in points:
            distances = [distance_euclidienne(p, c) for c in centroides]
            cluster = distances.index(min(distances))
            assign.append(cluster)
        
        # Étape 2 : recalculer les centroïdes
        nouveaux_centroides = []
        for j in range(k):
            # Récupérer tous les points du cluster j
            points_du_cluster = [points[i] for i in range(len(points))
                                 if assign[i] == j]
            if points_du_cluster:
                moyenne_x = sum(p[0] for p in points_du_cluster) / len(points_du_cluster)
                moyenne_y = sum(p[1] for p in points_du_cluster) / len(points_du_cluster)
                nouveaux_centroides.append([moyenne_x, moyenne_y])
            else:
                # Si un cluster n'a plus de points, on garde l'ancien centroïde
                nouveaux_centroides.append(centroides[j])
        
        centroides = nouveaux_centroides
    
    # Dernière assignation
    assign = []
    for p in points:
        distances = [distance_euclidienne(p, c) for c in centroides]
        assign.append(distances.index(min(distances)))
    
    return centroides, assign

centroides, assign = kmeans(points, 3)

print("Centroïdes finaux :")
for i, c in enumerate(centroides):
    print("  Cluster", i, ":", [round(v, 2) for v in c])

# Compter les points par cluster
for i in range(3):
    nb = sum(1 for a in assign if a == i)
    print("  Cluster", i, "a", nb, "points")
Centroïdes finaux :
Cluster 0 : [2.03, 2.09]
Cluster 1 : [5.96, 6.05]
Cluster 2 : [7.98, 1.97]
Cluster 0 a 20 points
Cluster 1 a 20 points
Cluster 2 a 20 points
Le k-means a retrouvé tout seul les 3 groupes que vous aviez créés. Sans aucune étiquette, sans aucun prof. C'est ça, l'apprentissage non supervisé : on lâche l'algorithme dans les données et il se débrouille. C'est comme envoyer quelqu'un à l'étranger sans dictionnaire — au début c'est le chaos, mais à la fin il finit par trouver des copains qui parlent la même langue.

2.2 Trouver le bon k — la méthode du coude

Exercice 2.3 — Inertie et méthode du coude

L'inertie (ou WCSS, Within-Cluster Sum of Squares) est la somme des carrés des distances de chaque point à son centroïde. Plus k augmente, plus l'inertie diminue. Le « bon » k se trouve au coude de la courbe.

Calculez l'inertie pour k=1 à k=6 et affichez-la (on pourra la visualiser avec matplotlib plus tard).
def inertie(points, centroides, assign):
    total = 0
    for i in range(len(points)):
        total = total + distance_euclidienne(points[i], centroides[assign[i]]) ** 2
    return total
Solution
for k in range(1, 7):
    c, a = kmeans(points, k)
    inert = inertie(points, c, a)
    print("k =", k, "→ inertie :", round(inert, 2))
k = 1 → inertie : 236.45
k = 2 → inertie : 58.49
k = 3 → inertie : 8.07
k = 4 → inertie : 6.53
k = 5 → inertie : 5.86
k = 6 → inertie : 5.18
L'inertie chute brutalement entre k=1 et k=3, puis se stabilise. Le coude est à k=3, ce qui correspond exactement au nombre de clusters de nos données. La méthode du coude est simple mais parfois ambiguë (quand le coude n'est pas net). Dans ce cas, on utilise la silhouette ou le gap statistic.

§3 Partie III — Visualisation des résultats

Un bon graphique vaut mieux qu'un long discours. Utilisons Matplotlib pour voir ce que nos algorithmes ont produit.

Exercice 3.1 — Visualisation du k-NN (frontière de décision)

Pour visualiser la frontière de décision du k-NN, on crée une grille de points sur tout le plan, on classe chaque point, et on colore les zones. Ajoutez aussi les points d'origine avec leurs vraies étiquettes.
Solution
import matplotlib.pyplot as plt
import numpy as np

# Créer une grille de points dans l'espace [0, 6] × [0, 1]  
# (on agrandit un peu pour voir la frontière)
xx, yy = np.meshgrid(np.linspace(0, 6, 60),
                     np.linspace(0, 1.1, 40))
grille = np.c_[xx.ravel(), yy.ravel()]

# Classifier chaque point de la grille avec k=3
Z = [knn_classifier(X, y, p, 3) for p in grille]
Z = np.array(Z).reshape(xx.shape)

# Tracer la frontière de décision
plt.contourf(xx, yy, Z == "A", alpha=0.3, cmap="RdYlGn")
plt.contour(xx, yy, Z == "A", colors="k", linewidths=0.5)

# Tracer les points d'apprentissage
for i in range(len(X)):
    couleur = "green" if y[i] == "A" else "red"
    plt.scatter(X[i][0], X[i][1], color=couleur, s=100, edgecolors="white", zorder=5)

plt.xlabel("Heures de révision")
plt.ylabel("Taux de présence")
plt.title("Frontière de décision k-NN (k=3)")
plt.show()
Les frontières de décision sont la signature visuelle d'un classifieur. Le k-NN produit des frontières irrégulières, « en escalier », parce que chaque point crée sa propre zone d'influence. D'autres algorithmes (SVM, arbres) produisent des frontières différentes. La forme de la frontière en dit long sur le comportement du modèle.
Exercice 3.2 — Visualisation du k-means

Tracez les points avec des couleurs selon leur cluster, et les centroïdes finaux en étoiles. Que constatez-vous ?
Solution
centroides, assign = kmeans(points, 3)

couleurs_clusters = ["blue", "orange", "purple"]
for i in range(len(points)):
    plt.scatter(points[i][0], points[i][1],
                color=couleurs_clusters[assign[i]], alpha=0.6, s=40)

# Centroïdes en étoiles
for i, c in enumerate(centroides):
    plt.scatter(c[0], c[1], marker="*", color=couleurs_clusters[i],
                s=300, edgecolors="white", linewidth=2, zorder=10)

plt.title("k-Means : 3 clusters trouvés")
plt.xlabel("x")
plt.ylabel("y")
plt.grid(True)
plt.show()
Les trois groupes sont bien séparés. Les étoiles (centroïdes) sont au cœur de chaque nuage. C'est exactement ce que fait Instagram quand il identifie des groupes d'amis sur vos photos, ou ce que fait Netflix quand il crée des catégories de spectateurs. Sauf que Netflix utilise plus de 2 dimensions et des milliards de données. Mais le principe est le même. (Netflix ne vous impressionne plus, n'est-ce pas ?)

§4 Partie IV — Passage à scikit-learn

Maintenant qu'on a compris le principe, utilisons les versions optimisées et robustes de scikit-learn. On pourra aussi comparer avec nos implémentations maison.

4.1 k-NN avec scikit-learn

from sklearn.neighbors import KNeighborsClassifier
from sklearn.metrics import accuracy_score

# Créer et entraîner le modèle
knn_sk = KNeighborsClassifier(n_neighbors=3)
knn_sk.fit(X, y)

# Prédire sur un nouveau point
nouveau = [[3, 0.75]]
print("Prédiction scikit-learn :", knn_sk.predict(nouveau))

# Précision sur les données d'entraînement
preds = knn_sk.predict(X)
print("Précision :", accuracy_score(y, preds))
Prédiction scikit-learn : ['A']
Précision : 1.0

4.2 k-means avec scikit-learn

from sklearn.cluster import KMeans

kmeans_sk = KMeans(n_clusters=3, random_state=42)
kmeans_sk.fit(points)

print("Centroïdes :")
print(kmeans_sk.cluster_centers_)
print("Inertie :", kmeans_sk.inertia_)
print("Assignations :", kmeans_sk.labels_)
Centroïdes : [[2.03 2.09] [5.96 6.05] [7.98 1.97]]
Inertie : 8.074
Assignations : [0 0 0 ... 2 2 2]
Vous venez d'utiliser les mêmes algorithmes que vous avez implémentés à la main. Les résultats sont quasiment identiques. La différence ? scikit-learn est écrit en C pour les parties critiques, gère les cas tordus, et a été testé par des millions de personnes. Utilisez-le en production. Gardez votre version pour comprendre.

§5 Pour aller plus loin

5.1 k-NN pondéré

Au lieu de voter à égalité, on peut pondérer le vote de chaque voisin par l'inverse de sa distance. Plus un voisin est proche, plus son vote compte. Facile à implémenter : remplacer le comptage de votes par une somme de poids.

5.2 k-means++

L'initialisation aléatoire des centroïdes peut mener à de mauvais résultats. k-means++ (Arthur & Vassilvitskii, 2007) choisit les centroïdes initialux de façon à maximiser la dispersion. C'est l'initialisation par défaut dans scikit-learn (init='k-means++').

5.3 La malédiction de la dimensionnalité

Le k-NN souffre de la malédiction de la dimensionnalité : dans un espace à haute dimension (ex: 1000 dimensions pour des images), tous les points sont loin les uns des autres. La notion de « plus proche voisin » perd son sens. C'est pourquoi on réduit d'abord la dimension (PCA, t-SNE) avant d'appliquer k-NN.


§6 Le mot de la fin

Vous avez implémenté deux des algorithmes les plus importants du machine learning, de A à Z. Vous avez vu la différence entre apprentissage supervisé (k-NN : on a les étiquettes) et non supervisé (k-means : on cherche les structures cachées). Vous avez visualisé les résultats. Et vous avez utilisé scikit-learn pour confirmer.

Evelyn Fix (1904–1965) — l'une des premières femmes à avoir obtenu un doctorat en statistiques aux États-Unis (Berkeley, 1938). Elle a co-inventé le k-NN avec Joseph Hodges en 1951. Pendant des décennies, son travail a été sous-estimé. Aujourd'hui, le k-NN est enseigné dans tous les cours d'IA du monde. Fix a attendu 70 ans que la postérité reconnaisse sa contribution. C'est une leçon : certaines idées sont juste en avance sur leur temps.
Bilan du TD : Prochaine étape : les réseaux de neurones. Mais avant, reposez-vous. Vous avez mérité de regarder vos jolis graphiques. 📊
© 2026 — laurent.thiry@uha.fr