Classez par voisinage avec les k plus proches voisins
Vous trouvez en forêt une fleur que vous ne connaissez pas. Le réflexe le plus naturel consiste à ouvrir un guide et à chercher les photos qui lui ressemblent le plus. Si elles montrent presque toutes la même espèce, vous tenez votre réponse. Ce raisonnement, « dis-moi qui sont tes voisins, je te dirai qui tu es », est exactement celui des k plus proches voisins (k-nearest neighbors, ou k-NN).
C’est sans doute l’algorithme le plus simple de ce cours, et il cache pourtant les questions essentielles : qu’est-ce qu’être « proche » ? Combien de voisins consulter ? Et pourquoi la méthode s’effondre-t-elle quand les caractéristiques sont très nombreuses ? Notre fil rouge : les fleurs d’iris, que nous avons classées en TP.
Le principe : voter entre voisins
Section intitulée « Le principe : voter entre voisins »Le jeu d’iris décrit 150 fleurs de trois espèces (setosa, versicolor, virginica) par quatre mesures : longueur et largeur des sépales, longueur et largeur des pétales, en centimètres. Pour classer une nouvelle fleur , l’algorithme est le suivant :
- calculer la distance entre et chacune des fleurs du jeu d’apprentissage ;
- garder les fleurs les plus proches ;
- prédire l’espèce majoritaire parmi ces voisins (en régression, on prendrait la moyenne de leurs valeurs).
Remarquez qu’il n’y a pas vraiment de phase d’apprentissage : le « modèle », c’est le jeu de données lui-même. On dit que les k-NN sont un algorithme paresseux (lazy) et non paramétrique (chapitre 1). Tout le travail a lieu au moment de la prédiction.
Qu’est-ce qu’être proche ? Distances et normalisation
Section intitulée « Qu’est-ce qu’être proche ? Distances et normalisation »Choisir une distance
Section intitulée « Choisir une distance »La distance la plus courante est la distance euclidienne, celle de la règle graduée :
où et sont les valeurs de la caractéristique pour les deux exemples, et le nombre de caractéristiques. On utilise aussi la distance de Manhattan, (la distance « en suivant les rues »), moins sensible à un écart isolé très grand. Les deux sont des cas particuliers de la distance de Minkowski, , avec et . Pour des variables catégorielles, on peut compter le nombre de caractéristiques qui diffèrent (distance de Hamming).
Le choix de la distance est le biais inductif des k-NN : c’est lui qui décide ce que « se ressembler » veut dire.
Normaliser les variables
Section intitulée « Normaliser les variables »Pour les iris, toutes les mesures sont en centimètres et du même ordre de grandeur. Mais imaginez des patients décrits par leur âge (de 0 à 100) et leur revenu mensuel (de 0 à 10 000 €). Un écart de 1 000 € domine complètement un écart de 30 ans dans la distance : le modèle ne regarde plus que le revenu, sans que personne l’ait décidé.
La solution est de mettre toutes les variables à la même échelle avant de calculer les distances. La méthode la plus courante est la standardisation (centrer-réduire) :
où et sont la moyenne et l’écart-type de la caractéristique calculés sur le jeu d’apprentissage. Chaque variable a alors une moyenne de 0 et un écart-type de 1. Une alternative est la mise à l’échelle entre 0 et 1 (min-max).
Choisir k : le compromis biais-variance en action
Section intitulée « Choisir k : le compromis biais-variance en action »Mais alors, comment choisir ? Bonne question ! C’est l’hyperparamètre principal, et il règle directement le compromis du chapitre 2 :
- petit (à l’extrême ) : la prédiction dépend d’un seul voisin, donc du moindre exemple bruité ou mal étiqueté. La frontière est très irrégulière (pour , elle suit les cellules de Voronoï autour de chaque exemple). Variance élevée : c’est le surapprentissage. Avec , l’erreur sur les données d’apprentissage est d’ailleurs nulle, puisque chaque point est son propre voisin.
- grand : le vote porte sur une large région, la frontière devient lisse, puis trop lisse. À l’extrême, prédit toujours la classe majoritaire du jeu entier. Biais élevé : c’est le sous-apprentissage, avec une tendance à favoriser la classe la plus fréquente.
Une règle empirique donne un ordre de grandeur, où est le nombre d’exemples et le nombre de classes (environ 7 pour les iris). Mais en pratique, on choisit par validation croisée, en testant une grille de valeurs. Quelques réglages complètent le choix :
- en classification binaire, un impair évite les égalités ;
- un vote pondéré par la distance (chaque voisin reçoit le poids ) donne plus d’influence aux voisins les plus proches et règle aussi les égalités.
En TP, un classifieur à sur les quatre mesures des iris a atteint une exactitude moyenne de 0,973 en validation croisée à 10 plis. Sur ce jeu, les pétales séparent très bien setosa des deux autres espèces ; les confusions portent surtout sur versicolor et virginica, qui se chevauchent, comme dans l’exemple ci-dessus. Nous l’avions d’ailleurs pressenti en TP en traçant les sépales seuls, où ces deux espèces se mélangent largement.
from sklearn.datasets import load_irisfrom sklearn.model_selection import GridSearchCV, StratifiedKFoldfrom sklearn.neighbors import KNeighborsClassifierfrom sklearn.pipeline import make_pipelinefrom sklearn.preprocessing import StandardScaler
X, y = load_iris(return_X_y=True)
# La normalisation fait partie du pipeline : elle est réapprise dans chaque plipipe = make_pipeline(StandardScaler(), KNeighborsClassifier())grille = { "kneighborsclassifier__n_neighbors": list(range(1, 31, 2)), # k impairs de 1 à 29 "kneighborsclassifier__weights": ["uniform", "distance"],}cv = StratifiedKFold(n_splits=10, shuffle=True, random_state=0)recherche = GridSearchCV(pipe, grille, cv=cv).fit(X, y)print(recherche.best_params_, round(recherche.best_score_, 3))Pourquoi une méthode aussi simple fonctionne-t-elle ? Parmi les voisins de , la proportion de ceux qui appartiennent à la classe est une estimation locale de . Voter pour la classe majoritaire revient donc à appliquer la règle MAP du chapitre 5 avec des probabilités estimées sur le voisinage. On montre que, lorsque le nombre d’exemples tend vers l’infini (avec qui grandit, mais beaucoup moins vite que ), l’erreur des k-NN tend vers l’erreur de Bayes. Même le simple 1-NN a, asymptotiquement, une erreur au plus double de l’erreur de Bayes.
Le fléau de la dimension
Section intitulée « Le fléau de la dimension »Les k-NN ont deux faiblesses. La première est leur coût de prédiction : comparer chaque nouvel exemple à tous les exemples d’apprentissage devient lourd avec des millions de lignes (des structures comme les arbres k-d accélèrent la recherche en faible dimension). La seconde, plus profonde, est le fléau de la dimension (curse of dimensionality).
Imaginez des points répartis uniformément dans un cube de côté 1. Pour capturer 10 % des points autour d’un exemple, il faut un petit cube de côté :
| Dimension | Côté du cube qui contient 10 % des points |
|---|---|
| 1 | 0,10 |
| 2 | 0,32 |
| 10 | 0,79 |
| 100 | 0,98 |
En dimension 100, le « voisinage » qui contient 10 % des données couvre 98 % de l’étendue de chaque variable : il n’a plus rien de local ! Plus généralement, quand grandit, les distances entre points se ressemblent toutes, l’écart entre le plus proche et le plus lointain voisin se réduit, et la notion même de « plus proche » perd son sens. Les méthodes à base de distance en souffrent en premier.
Les remèdes : réduire le nombre de caractéristiques (sélection de variables, analyse en composantes principales), apprendre une meilleure représentation, ou préférer un modèle moins sensible à la dimension.
En résumé
Section intitulée « En résumé »- Les k plus proches voisins prédisent la classe majoritaire (ou la moyenne) des exemples les plus proches ; c’est une méthode paresseuse et non paramétrique.
- La distance (euclidienne, Manhattan, Hamming) définit ce que « se ressembler » veut dire : c’est le biais inductif de la méthode.
- Il faut normaliser les variables, avec des paramètres calculés sur l’apprentissage seul, sinon la variable de plus grande échelle domine.
- petit : surapprentissage ; grand : sous-apprentissage. On choisit par validation croisée, éventuellement avec un vote pondéré par la distance.
- La proportion de voisins de chaque classe estime ; asymptotiquement, l’erreur tend vers l’erreur de Bayes.
- En grande dimension, les distances perdent leur sens : c’est le fléau de la dimension.
Exercices
Section intitulée « Exercices »Exercice · Voter avec différents k
facileUne nouvelle fleur a des pétales de 4,8 cm sur 1,6 cm. Avec les cinq fleurs A à E de l’exemple, calculez les distances euclidiennes et donnez la prédiction pour , et .
Voir la solution
Distances à (4,8 ; 1,6) :
- A (5,0 ; 1,8) : (virginica) ;
- B (4,9 ; 1,5) : (versicolor) ;
- C (5,1 ; 1,9) : (virginica) ;
- D (4,7 ; 1,4) : (versicolor) ;
- E (4,5 ; 1,5) : (versicolor).
Classement : B, D, A, E, C.
- : B, versicolor.
- : B, D, A, soit 2 versicolor contre 1 virginica, versicolor.
- : 3 versicolor contre 2 virginica, versicolor.
Cette fleur est un peu plus « à l’intérieur » de la zone versicolor : tous les s’accordent.
En code
import numpy as npfrom collections import Counter
noms = np.array(list("ABCDE"))fleurs = np.array([[5.0, 1.8], [4.9, 1.5], [5.1, 1.9], [4.7, 1.4], [4.5, 1.5]])especes = np.array(["virginica", "versicolor", "virginica", "versicolor", "versicolor"])nouvelle = np.array([4.8, 1.6])
distances = np.sqrt(((fleurs - nouvelle) ** 2).sum(axis=1))print(distances.round(3)) # [0.283 0.141 0.424 0.224 0.316]ordre = np.argsort(distances)print(noms[ordre]) # ['B' 'D' 'A' 'E' 'C']
for k in [1, 3, 5]: vote = Counter(especes[ordre[:k]]).most_common(1)[0][0] # espèce majoritaire print(k, vote) # versicolor pour k = 1, 3 et 5fleurs <- data.frame( longueur = c(5.0, 4.9, 5.1, 4.7, 4.5), largeur = c(1.8, 1.5, 1.9, 1.4, 1.5), espece = c("virginica", "versicolor", "virginica", "versicolor", "versicolor"), row.names = c("A", "B", "C", "D", "E"))nouvelle <- c(4.8, 1.6)
distances <- sqrt((fleurs$longueur - nouvelle[1])^2 + (fleurs$largeur - nouvelle[2])^2)names(distances) <- rownames(fleurs)round(sort(distances), 3) # B 0.141, D 0.224, A 0.283, E 0.316, C 0.424
ordre <- order(distances)for (k in c(1, 3, 5)) { votes <- table(fleurs$espece[ordre[1:k]]) cat(k, names(which.max(votes)), "\n") # versicolor pour k = 1, 3 et 5}Exercice · L'effet de la normalisation
moyenTrois patients sont décrits par (âge, revenu mensuel en euros) : A = (30 ; 2 000), B = (32 ; 3 500), C = (60 ; 2 100).
- Sans normalisation, quel patient est le plus proche de A (distance euclidienne) ?
- On standardise avec, sur le jeu d’apprentissage, un âge moyen de 40 ans (écart-type 12) et un revenu moyen de 2 500 € (écart-type 800 €). Même question.
- Commentez.
Voir la solution
- Sans normalisation : et . C est le plus proche.
- Après standardisation : A devient , B devient et C devient . Alors et . B est le plus proche.
- Sans normalisation, le revenu (des milliers d’euros) écrase l’âge (des dizaines d’années) : un écart de 30 ans compte moins qu’un écart de 1 500 €. Après standardisation, chaque variable est mesurée en « nombre d’écarts-types », et l’écart d’âge entre A et C redevient important. Le voisin choisi, et donc la prédiction, change complètement.
En code
import numpy as np
patients = {"A": np.array([30, 2000]), "B": np.array([32, 3500]), "C": np.array([60, 2100])}moyenne, ecart_type = np.array([40, 2500]), np.array([12, 800]) # appris sur l'apprentissage
def distances(p): return {n: round(float(np.linalg.norm(p[n] - p["A"])), 2) for n in ["B", "C"]}
print(distances(patients)) # {'B': 1500.0, 'C': 104.4} : C le plus prochestandardises = {n: (x - moyenne) / ecart_type for n, x in patients.items()}print({n: z.round(3) for n, z in standardises.items()}) # A (-0.833, -0.625), B (-0.667, 1.25), C (1.667, -0.5)print(distances(standardises)) # {'B': 1.88, 'C': 2.5} : B le plus prochepatients <- rbind(A = c(30, 2000), B = c(32, 3500), C = c(60, 2100))colnames(patients) <- c("age", "revenu")moyenne <- c(40, 2500); ecart_type <- c(12, 800) # appris sur l'apprentissage
round(as.matrix(dist(patients))["A", ], 1) # B 1500.0, C 104.4 : C le plus proche
# Standardisation : (x - moyenne) / écart-type, colonne par colonnez <- sweep(sweep(patients, 2, moyenne), 2, ecart_type, "/")round(z, 3) # A (-0.833 ; -0.625), B (-0.667 ; 1.25), C (1.667 ; -0.5)round(as.matrix(dist(z))["A", ], 2) # B 1.88, C 2.50 : B le plus procheExercice · Mesurer le fléau de la dimension
difficileDes points sont répartis uniformément dans un hypercube de côté 1 en dimension . On veut que le voisinage d’un point (un petit hypercube de côté ) contienne 1 % des points.
- Exprimez en fonction de .
- Calculez pour et .
- Qu’en concluez-vous pour les k-NN sur des données de grande dimension ?
Voir la solution
- Le volume du petit hypercube est , et il doit valoir 1 % du volume total (qui vaut 1). Donc , soit .
- Pour : . Pour : .
- En dimension 10, un voisinage qui ne contient que 1 % des points doit couvrir 63 % de l’étendue de chaque variable : les « voisins » d’un point ne lui ressemblent plus vraiment. Pour garder des voisinages réellement locaux, il faudrait un nombre d’exemples qui croît exponentiellement avec la dimension. En pratique, il faut réduire la dimension (sélection de variables, ACP) avant d’utiliser les k-NN, ou choisir un autre modèle.
En code
import numpy as np
# Côté du petit hypercube qui contient 1 % du volume : c = 0,01^(1/d)for d in [2, 10, 100]: print(d, round(0.01 ** (1 / d), 3)) # 0.1, 0.631, 0.955
# Vérification par simulation en dimension 10rng = np.random.default_rng(0)d, c = 10, 0.01 ** (1 / 10)points = rng.random((200_000, d))centre = np.full(d, 0.5)dans_le_cube = np.all(np.abs(points - centre) <= c / 2, axis=1)print(dans_le_cube.mean()) # 0.00977, proche de 0,01# Côté du petit hypercube qui contient 1 % du volume : c = 0,01^(1/d)d <- c(2, 10, 100)round(0.01^(1 / d), 3) # 0.1, 0.631, 0.955
# Vérification par simulation en dimension 10set.seed(0)c10 <- 0.01^(1 / 10)points <- matrix(runif(200000 * 10), ncol = 10)dans_le_cube <- apply(abs(points - 0.5) <= c10 / 2, 1, all)mean(dans_le_cube) # 0.0102, proche de 0,01