Algorithme de classification par les K plus proches voisins
Exercice 1 : prédiction
En guise d'introduction au problème de classification par l'algorithme des k plus proches voisins, répondez au QCM réalisé par Alain Busser :
Algorithme de prédiction par les K plus proches voisins
-
Entrées :
- Un jeu de données d'apprentissage étiquetées c'est-à-dire qu'on dispose d'une table d'enregistrements qui sont caractérisés par des descripteurs et pour lesquels on connaît une étiquette. Par exemple, on a une collection de Pokemons décrits par leur
'nom', leur'vitesse'et leur'défense'et pour chacun on connaît une étiquette de'type'qui peut être'Feu'ou'Roche':
🐍 Console Python[{'nom': 'Salameche', 'defense': '43', 'vitesse': '65', 'type': 'Feu'}, {'nom': 'Reptincel', 'defense': '58', 'vitesse': '80', 'type': 'Feu'}, {'nom': 'Goupix', 'defense': '40', 'vitesse': '65', 'type': 'Feu'}, {'nom': 'Feunard', 'defense': '75', 'vitesse': '100', 'type': 'Feu'}, ..., {'nom': 'Geolithe', 'defense': '105', 'vitesse': '20', 'type': 'Roche'}, {'nom': 'Gigalithe', 'defense': '130', 'vitesse': '25', 'type': 'Roche'}]-
Une donnée dont l'étiquette est inconnue c'est-à-dire un enregistrement absent du jeu de données d'apprentissage avec les mêmes descripteurs mais sans étiquette.
-
Un paramètre de voisinage \(K\)
-
Une distance.
Plusieurs distances possibles
Si on caractérise chaque Pokemon par deux descripteurs
'vitesse'et'defense', on peut les représenter dans le plan par les points A de coordonnées \((58, 65)\) et B de coordonnées \((80, 58)\). On peut alors calculer la distance AB de plusieurs façons :La distance euclidienne qui correspond à celle de la règle graduée : \(AB=\sqrt{(x_{B}-x_{A})^{2}+(y_{B}-y_{A})^{2}}=\sqrt{(80-58)^{2}+(58-65)^{2}} \approx 23,087\)

La distance de Manhattan qui correspond à la longueur du chemin parcouru pour aller de \(A\) à \(B\) en se déplaçant le long des arêtes d'un quadrillage généré par un repère orthonormé du plan, comme dans le plan d'une ville où les rues se coupent à angle droit : \(AB=\vert x_{B}-x_{A} \vert + \vert y_{B}-y_{A} \vert =\vert 80 - 58 \vert + \vert 58 - 65 \vert = 22 + 7 = 29\)

- Un jeu de données d'apprentissage étiquetées c'est-à-dire qu'on dispose d'une table d'enregistrements qui sont caractérisés par des descripteurs et pour lesquels on connaît une étiquette. Par exemple, on a une collection de Pokemons décrits par leur
-
Problème à résoudre :
Comment prédire l'étiquette de la nouvelle donnée à partir des données du jeu de données d'apprentissage ?
Exemple : prédiction du type d'un Pokémon
Par exemple on connaît les types des Pokémons du jeu de données d'apprentissage, qu'on a représenté dans le plan en fonction des valeurs de leurs descripteurs
'vitesse'et'défense'. On attrape un nouveau Pokémon (marqué par une croix), on dispose de sa'vitesse'et de sa'défense', comment prédire son type ?
-
Traitement :
- On calcule, en fonction des descripteurs et de la distance sélectionnés, les distances entre l'enregistrement sans étiquette et tous les enregistrements du jeu de données d'apprentissage.
- On sélectionne les K plus proches voisins du nouvel enregistrement.
- On détermine l'étiquette majoritaire (la plus représentée) parmi ces K plus proches voisins.
Imparité
Pour éviter les cas d'égalité il est important de choisir K impair !
-
Sortie :
- L'algorithme renvoie la prédiction d'une valeur d'étiquette pour l' enregistrement sans étiquette. Il a choisi la valeur majoritaire parmi les étiquettes de ses K plus proches voisins dans le jeu de données d'apprentissage.
Un algorithme d'apprentissage supervisé
L'algorithme de prédiction par les K plus proches voisins est un exemple simple d'algorithme de classification par apprentissage supervisé (machine learning): il exploite un jeu de données connu pour apporter une information, plausible mais pas forcément exacte sur une nouvelle donnée. C'est un apprentissage paresseux car il ne construit pas de modèle comme pour un réseau de neurones, il mémorise les données d'entraînement et cherche les K points les plus proches parmi les données d'entraînement pour classifier le nouveau point.
Cette famille d'algorithmes est rattachée à la branche de l'informatique dénommée intelligence artificielle qui peut être définie comme la conception des agents rationnels, autonomes et adaptatifs pour résoudre des problèmes « comme ou mieux » que ne le ferait un être humain.
L'intelligence artificielle est apparue dès les débuts de l'informatique dans les années 1950, le mythe d'une créature artificielle intelligente étant un classique de la littérature (Golem, Frankenstein ...). Après avoir connu, un hiver pendant les années 1980-1990, un renouveau important a lieu depuis les années 2000. Le développement d'idées anciennes comme les réseaux de neurones a été favorisé par une accélération des performances matérielles (GPU) et des volumes de données disponibles (essor du web). Les réseaux de neurones profonds (deep learning) rendent possibles des applications proches de certaines aptitudes considérées auparavant comme caractéristiques de l'humain: reconnaissance d'image, du langage, prise de décision ...
Exercice 2 : ours ou phoque ?
Pour expérimenter l'algorithme de prédiction par les K plus proches voisins, tester l'application développée par Frédéric Boissac. Il faut d'abord positionner des ours ou des phoques sur la banquise, qui constituent le jeu de données d'apprentissage. Puis on place un animal inconnu. Enfin on détermine l'espèce de cet animal avec l'algorithme de prédiction par les K plus proches voisins. On peut faire varier les valeurs du paramètre K.
Influence du paramètre K
La prédiction par l'algorithme des K plus proches voisins dépend du paramètre K choisi (toujours impair !).
On donne deux exemples de prédiction réalisées avec l'application de l'exercice 21 dans le cas ci-dessous :



Exercice 3 : classification d'un Pokemon
Compléter les codes sur 💻 Capytale
Dans toutes les questions de cet exercice, on utilise un jeu de données d'apprentissage constitué d'une table table_pokemons de Pokemons représentés par des dictionnaires avec quatre clefs :
'nom'de valeur le nom du Pokemon de typestr'vitesse'de valeur la vitesse du Pokemon de typeint'defense'de valeur la défense du Pokemon de typeint'type'de valeur le type du Pokemon de typestr
A chaque Pokemon on associe dans un repère du plan le couple constitué de sa vitesse et de sa défense. On définit la distance entre deux Pokemons comme la distance euclidienne entre leurs couples de coordonnées.
On veut prédire le type d'un nouveau Pokemon dont on connaît la vitesse et la défense mais pas le type, en fonction du type majoritaire parmi ses K plus proches voisins.
Dans cet exercice, vous pouvez réutiliser dans une question les fonctions qui ont validé les tests des questions précédentes.
Table de pokemons
table_pokemons = [
{"nom": "Salameche", "vitesse": 65, "defense": 43, "type": "Feu"},
{"nom": "Reptincel", "vitesse": 80, "defense": 58, "type": "Feu"},
{"nom": "Goupix", "vitesse": 65, "defense": 40, "type": "Feu"},
{"nom": "Feunard", "vitesse": 100, "defense": 75, "type": "Feu"},
{"nom": "Caninos", "vitesse": 60, "defense": 45, "type": "Feu"},
{"nom": "Arcanin", "vitesse": 95, "defense": 80, "type": "Feu"},
{"nom": "Ponyta", "vitesse": 90, "defense": 55, "type": "Feu"},
{"nom": "Galopa", "vitesse": 105, "defense": 70, "type": "Feu"},
{"nom": "Magmar", "vitesse": 93, "defense": 57, "type": "Feu"},
{"nom": "Pyroli", "vitesse": 65, "defense": 60, "type": "Feu"},
{"nom": "Hericendre", "vitesse": 65, "defense": 43, "type": "Feu"},
{"nom": "Feurisson", "vitesse": 80, "defense": 58, "type": "Feu"},
{"nom": "Typhlosion", "vitesse": 100, "defense": 78, "type": "Feu"},
{"nom": "Limagma", "vitesse": 20, "defense": 40, "type": "Feu"},
{"nom": "Magby", "vitesse": 83, "defense": 37, "type": "Feu"},
{"nom": "Entei", "vitesse": 100, "defense": 85, "type": "Feu"},
{"nom": "Poussifeu", "vitesse": 45, "defense": 40, "type": "Feu"},
{"nom": "Chartor", "vitesse": 20, "defense": 140, "type": "Feu"},
{"nom": "Ouisticram", "vitesse": 61, "defense": 44, "type": "Feu"},
{"nom": "Maganon", "vitesse": 83, "defense": 67, "type": "Feu"},
{"nom": "Gruikui", "vitesse": 45, "defense": 45, "type": "Feu"},
{"nom": "Flamajou", "vitesse": 64, "defense": 48, "type": "Feu"},
{"nom": "Flamoutan", "vitesse": 101, "defense": 63, "type": "Feu"},
{"nom": "Darumarond", "vitesse": 50, "defense": 45, "type": "Feu"},
{"nom": "Darumacho", "vitesse": 95, "defense": 55, "type": "Feu"},
{"nom": "Aflamanoir", "vitesse": 65, "defense": 66, "type": "Feu"},
{"nom": "Feunnec", "vitesse": 60, "defense": 40, "type": "Feu"},
{"nom": "Roussil", "vitesse": 73, "defense": 58, "type": "Feu"},
{"nom": "Simularbre", "vitesse": 30, "defense": 115, "type": "Roche"},
{"nom": "Tarinor", "vitesse": 30, "defense": 135, "type": "Roche"},
{"nom": "Regirock", "vitesse": 50, "defense": 200, "type": "Roche"},
{"nom": "Kranidos", "vitesse": 58, "defense": 40, "type": "Roche"},
{"nom": "Charkos", "vitesse": 58, "defense": 60, "type": "Roche"},
{"nom": "Manzai", "vitesse": 10, "defense": 95, "type": "Roche"},
{"nom": "Nodulithe", "vitesse": 15, "defense": 85, "type": "Roche"},
{"nom": "Geolithe", "vitesse": 20, "defense": 105, "type": "Roche"},
{"nom": "Gigalithe", "vitesse": 25, "defense": 130, "type": "Roche"},
]
Question 1
Compléter la fonction pokemon_to_coord qui à un Pokemon associe son couple de coordonnées (vitesse, distance).
def pokemon_to_coord(pok):
"""
Parameters
----------
pok représente un pokemon sous la forme d'un dictionnaire de clefs
"nom", "defense", "vitesse", "type" :
{"nom": "Manzai", "defense": 95, "vitesse": 10, "type": "Roche"}
Returns
-------
couple de valeurs (vitesse, defense) de type tuple du pokemon
"""
return ...
Correction
def pokemon_to_coord(pok):
"""
Parameters
----------
pok représente un pokemon sous la forme d'un dictionnaire de clefs
"nom", "defense", "vitesse", "type" :
{"nom": "Manzai", "defense": 95, "vitesse": 10, "type": "Roche"}
Returns
-------
couple de valeurs (vitesse, defense) de type tuple du pokemon
"""
return (pok["vitesse"], pok["defense"])
Question 2
Compléter la fonction distance_euclidienne qui prend en arguments deux couples de coordonnées (vitesse, distance) et renvoie la distance eurclidienne entre les deux.
Comparaison de deux flottants
Pour comparer deux flottants on utilise la fonctions indiscernables qui teste si l'écart entre les deux est inférieur à \(10^{-15}\).
def indiscernables(f1, f2):
"""Test si deux flottants sont indiscernables
près
Args:
f1 et f2 de type float
Returns:
booléen
"""
return abs(f1 - f2) < 10 ** (-15)
import math
def indiscernables(f1, f2):
"""Test si deux flottants sont indiscernables
près
Args:
f1 et f2 de type float
Returns:
booléen
"""
return abs(f1 - f2) < 10 ** (-15)
def distance_euclidienne(coord1, coord2):
"""
Parameters
----------
coord1 et coord2 deux couples d'entiers représentant
des coordonnées (vitesse, defense) associées à des Pokemons
Returns
-------
distance euclidienne entre coord1 et coord2 de type float
"""
# déballage de tuple
x1, y1 = coord1
x2, y2 = coord2
return ...
Correction
def distance_euclidienne(coord1, coord2):
"""
Parameters
----------
coord1 et coord2 deux couples d'entiers représentant
des coordonnées (vitesse, defense) associées à des Pokemons
Returns
-------
distance euclidienne entre coord1 et coord2 de type float
"""
x1, y1 = coord1
x2, y2 = coord2
return math.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2)
Question 3
Compléter la fonction table_distance qui prend en argument un nouveau Pokemon représenté par un dictionnaire ayant au moins pour clefs vitesse et defense et qui renvoie un tableau des couples (distance entre le nouveau Pokemon et un Pokemon du jeu de données, type/étiquette du Pokemon du jeu de données) pour tous les Pokemons du jeu de données d'apprentissage de table_pokemons.
import math
def table_distance(nouveau_pok):
"""
Parameters
----------
nouveau_pok: un nouveau Pokemon représenté par un dictionnaire
avec au moins les clefs 'vitesse' et 'defense' associées à des valeurs
de type int
Returns
-------
tab_distance un tableau de tuples (distance entre nouveau_pok et un
pokemon du jeu de données table_pokemons, étiquette de ce pokemon)
"""
...
Correction
def table_distance(nouveau_pok):
"""
Parameters
----------
nouveau_pok: un nouveau Pokemon représenté par un dictionnaire
avec au moins les clefs 'vitesse' et 'defense' associées à des valeurs
de type int
Returns
-------
tab_distance un tableau de tuples (distance entre nouveau_pok et un
pokemon du jeu de données table_pokemons, étiquette de ce pokemon)
"""
coord_nouveau = pokemon_to_coord(nouveau_pok)
return [
(distance_euclidienne(coord_nouveau, pokemon_to_coord(pok)), pok["type"])
for pok in table_pokemons
]
Question 4
Compléter la fonction kpp_voisins qui prend deux arguments :
- un tableau renvoyé par fonction
table_distance: couples (distance, type/étiquette) par rapport à tous les Pokemons du jeu de données d'apprentissage detable_pokemonspour un nouveau Pokemon - un entier positif
k
kpp_voisins renvoie le tableau des types/étiquettes des k plus proches voisins c'est-à-dire des k premiers couples lorsqu'on a trié les couples par distance croissante.
def clef_tri(couple):
"""Clef de tri pour trier un couple de valeurs selon
la première composante"""
return couple[0]
def kpp_voisins(tab_distance, k):
"""
Parameters
----------
tab_distance un tableau de tuple (distance entre deux pokemons
, type/étiquette) renvoyé par table_distance
k : un entier positif
Returns
-------
kppv tableau des types/étiquetets des k plus proches voisins
selon le critère de distance
"""
# précondition
assert 1 <= k <= len(tab_distance)
# tri selon la première composante de chaque tuple (la distance)
tab_tri = sorted(tab_distance, key=...)
kppv = ...
return kppv
Correction
def kpp_voisins(tab_distance, k):
"""
Parameters
----------
tab_distance un tableau de tuple (distance entre deux pokemons
, type/étiquette) renvoyé par table_distance
Returns
-------
kppv tableau des types/étiquetets des k plus proches voisins
selon le critère de distance
"""
# précondition
assert 1 <= k <= len(tab_distance)
# tri selon la première composante de chaque tuple (la distance)
tab_tri = sorted(tab_distance, key=clef_tri)
kppv = [tab_tri[i][1] for i in range(k)]
return kppv
Question 5
Compléter la fonction element_majoritaire qui prend en argument un tableau de types/étiquettes de type str et qui renvoie le type/étiquette majoritaire.
def element_majoritaire(k_voisins):
"""
Parameters
----------
k_voisins : tableau des types/étiquettes des k plus proches voisins
Returns
-------
voisin_majoritaire : chaine de caractère type str, étiquette majoritaire
parmi les kplus proches voisins voisins
"""
occurence = dict()
voisin_majoritaire = k_voisins[0]
for voisin in k_voisins:
if ...:
occurence[voisin] = 1
else:
occurence[voisin] = ...
if occurence[...] > occurence[...]:
voisin_majoritaire = ...
return voisin_majoritaire
Correction
def element_majoritaire(k_voisins):
"""
Parameters
----------
k_voisins : tableau des types/étiquettes des k plus proches voisins
Returns
-------
voisin_majoritaire : chaine de caractère type str, étiquette majoritaire
parmi les kplus proches voisins voisins
"""
occurence = dict()
voisin_majoritaire = k_voisins[0]
for voisin in k_voisins:
if voisin not in occurence:
occurence[voisin] = 1
else:
occurence[voisin] = occurence[voisin] + 1
if (
occurence[voisin] > occurence[voisin_majoritaire]
):
voisin_majoritaire = voisin
return voisin_majoritaire
Question 6
Compléter la fonction prediction_kpp qui prend deux arguments :
- un nouveau Pokemon représenté sous la forme dictionnaire avec au moins deux clefs
vitesseetdefense, dont on ne connaît pas le type/étiquette - un entier positif
k
prediction_kpp renvoie une prédiction du type type/étiquette de ce nouveau Pokemon par sélection de l'étiquette majoritaire parmi les étiquettes des k plus proches voisins du nouveau Pokemon dans le jeu de données d'apprentissage table_pokemons.
def prediction_kpp(nouveau_pok, k):
"""
Parameters
----------
nouveau_pok: un nouveau Pokemon représenté par un dictionnaire
avec au moins les clefs 'vitesse' et 'defense' associées à des valeurs
de type in
k : un entier positif, paramètre de voisinage
Returns
-------
voisin_majoritaire : une chaine de caractère
l'étiquette majoritaire parmi les k plus proches voisins
dans le jeu de données table_pokemons
pour la distance euclidienne
"""
td = table_distance(...)
kppv = kpp_voisins(..., ...)
voisin_majoritaire = element_majoritaire(...)
return voisin_majoritaire
Correction
def prediction_kpp(nouveau_pok, k):
"""
Parameters
----------
nouveau_pok: un nouveau Pokemon représenté par un dictionnaire
avec au moins les clefs 'vitesse' et 'defense' associées à des valeurs
de type in
k : un entier positif, paramètre de voisinage
Returns
-------
voisin_majoritaire : une chaine de caractère
l'étiquette majoritaire parmi les k plus proches voisins
dans le jeu de données table_pokemons
pour la distance euclidienne
"""
td = table_distance(nouveau_pok)
kppv = kpp_voisins(td, k)
voisin_majoritaire = element_majoritaire(kppv)
return voisin_majoritaire