Sommaire
0. Mise en situation 1. Création du jeu de données 2. Charger et explorer les données avec pandas 3. Visualiser les données 4. Calculer une distance 5. Premier classificateur k-NN 6. Programmer une fonction knn 7. Comprendre le rôle de k 8. Évaluer l’algorithme sur des données de test 9. Aller plus loin : utiliser trois caractéristiques 10. Point important : les échelles de valeurs 11. Bilan 12. Mini-projet final, 20 à 30 minutes Proposition de découpage des 2 hCorrigé complet — Première NSI — Découvrir et comprendre l’algorithme k-NN avec pandas
Durée conseillée : 2 h Version professeur corrigée
Ce notebook reprend l’activité élève et propose une correction complète : code attendu, réponses rédigées et exemples de sorties à commenter.
Objectifs travaillés :
- manipuler un tableau de données avec
pandas; - représenter des données en deux dimensions ;
- calculer une distance entre deux objets ;
- programmer l’algorithme des k plus proches voisins ;
- comprendre l’influence du paramètre
k; - évaluer un classificateur avec un jeu de test ;
- comprendre l’intérêt de la normalisation.
Le notebook est autonome : il crée son propre fichier CSV.
0. Mise en situation
On dispose de mesures faites sur des fruits :
- leur masse en grammes ;
- leur diamètre en millimètres ;
- leur couleur moyenne codée par un nombre entre 0 et 10 ;
- leur type :
pomme,orangeoubanane.
On veut entraîner un programme à prédire le type d’un fruit inconnu à partir de ses mesures.
La méthode k-NN repose sur une idée simple :
Pour classer un nouvel objet, on regarde les k objets connus qui lui ressemblent le plus. La classe majoritaire parmi ces voisins devient la prédiction.
# Cellule à exécuter au début du notebook
import pandas as pd
import matplotlib.pyplot as plt
from math import sqrt
from collections import Counter
# Pour que les graphiques s'affichent dans le notebook
%matplotlib inline1. Création du jeu de données
Exécutez la cellule suivante. Elle crée un fichier fruits_knn.csv directement dans Basthon.
csv = """masse_g,diametre_mm,couleur,type
145,72,3.1,pomme
152,74,3.4,pomme
160,76,3.2,pomme
138,70,2.8,pomme
170,79,3.6,pomme
155,75,3.5,pomme
149,73,3.0,pomme
165,78,3.7,pomme
142,71,2.9,pomme
158,76,3.3,pomme
180,80,7.5,orange
190,84,7.8,orange
175,78,7.2,orange
200,86,8.0,orange
185,82,7.6,orange
195,85,8.1,orange
178,79,7.1,orange
188,83,7.9,orange
205,88,8.3,orange
182,81,7.4,orange
115,38,9.0,banane
120,40,9.2,banane
110,36,8.8,banane
130,42,9.5,banane
125,41,9.3,banane
118,39,9.1,banane
108,35,8.7,banane
132,43,9.6,banane
122,40,9.4,banane
112,37,8.9,banane
151,74,3.2,pomme
186,82,7.7,orange
117,39,9.0,banane
168,78,3.8,pomme
198,86,8.2,orange
128,42,9.5,banane
"""
with open("fruits_knn.csv", "w", encoding="utf-8") as f:
f.write(csv)
print("Fichier fruits_knn.csv créé.")2. Charger et explorer les données avec pandas
Question 1
Chargez le fichier CSV dans une variable df, puis affichez les cinq premières lignes.
df = pd.read_csv("fruits_knn.csv")
df.head()Correction question 1.
On obtient un tableau DataFrame contenant les colonnes masse_g, diametre_mm, couleur et type. Les cinq premières lignes correspondent à des pommes.
Question 2
Affichez :
- le nombre de lignes et de colonnes ;
- les noms des colonnes ;
- le nombre de fruits de chaque type.
print("Dimensions du tableau :", df.shape)
print("Colonnes :", list(df.columns))
print("Nombre de fruits par type :")
print(df["type"].value_counts())Correction question 2.
Le tableau contient 36 lignes et 4 colonnes.
Il y a 12 fruits de chaque type : 12 pommes, 12 oranges et 12 bananes. Le jeu de données est donc équilibré.
Question 3
Affichez les statistiques descriptives du tableau.
Puis répondez à l’oral :
- quelle colonne contient les classes à prédire ?
- quelles colonnes peuvent servir à faire une prédiction ?
df.describe()Correction question 3.
La colonne contenant les classes à prédire est type.
Les colonnes qui peuvent servir à faire une prédiction sont les colonnes numériques : masse_g, diametre_mm et couleur.
Dans un premier temps, on utilisera seulement masse_g et diametre_mm pour visualiser plus facilement les données dans un graphique en deux dimensions.
3. Visualiser les données
Pour commencer, on utilise seulement deux caractéristiques :
- la masse ;
- le diamètre.
Question 4
Exécutez la cellule suivante. Que remarquez-vous ? Les trois types de fruits semblent-ils faciles à séparer ?
for fruit in df["type"].unique():
sous_table = df[df["type"] == fruit]
plt.scatter(sous_table["masse_g"], sous_table["diametre_mm"], label=fruit)
plt.xlabel("Masse en grammes")
plt.ylabel("Diamètre en millimètres")
plt.title("Représentation des fruits selon deux mesures")
plt.legend()
plt.grid()
plt.show()Question 5
On observe maintenant un fruit inconnu :
fruit_inconnu = {"masse_g": 150, "diametre_mm": 73}Ajoutez ce fruit sur le graphique précédent avec un marqueur différent.
Correction question 4.
Les trois groupes sont assez bien séparés :
- les bananes ont une masse et un diamètre plus faibles ;
- les oranges ont une masse et un diamètre plus élevés ;
- les pommes se situent entre les deux, mais plutôt vers les masses moyennes.
Avec seulement la masse et le diamètre, la séparation semble déjà assez facile pour ce jeu de données.
fruit_inconnu = {"masse_g": 150, "diametre_mm": 73}
for fruit in df["type"].unique():
sous_table = df[df["type"] == fruit]
plt.scatter(sous_table["masse_g"], sous_table["diametre_mm"], label=fruit)
plt.scatter(
fruit_inconnu["masse_g"],
fruit_inconnu["diametre_mm"],
marker="x",
s=150,
label="fruit inconnu"
)
plt.xlabel("Masse en grammes")
plt.ylabel("Diamètre en millimètres")
plt.title("Position du fruit inconnu")
plt.legend()
plt.grid()
plt.show()Correction question 5.
Le fruit inconnu est très proche du groupe des pommes. On peut donc s’attendre à ce que k-NN le classe comme une pomme.
4. Calculer une distance
Pour déterminer les fruits les plus proches, il faut mesurer une distance.
On utilise ici la distance euclidienne dans le plan :
Dans notre cas :
xcorrespond à la masse ;ycorrespond au diamètre.
Question 6
Complétez la fonction distance_2d.
def distance_2d(fruit1, fruit2):
"""Renvoie la distance entre deux fruits décrits par masse_g et diametre_mm."""
dm = fruit1["masse_g"] - fruit2["masse_g"]
dd = fruit1["diametre_mm"] - fruit2["diametre_mm"]
return sqrt(dm**2 + dd**2)
# Test attendu : la distance entre un point et lui-même vaut 0
print(distance_2d({"masse_g": 150, "diametre_mm": 73}, {"masse_g": 150, "diametre_mm": 73}))Correction question 6.
La distance entre un fruit et lui-même vaut bien 0. La fonction calcule l’écart sur la masse, l’écart sur le diamètre, puis applique la formule de la distance euclidienne.
Question 7
Calculez la distance entre le fruit inconnu et chaque fruit du tableau.
Ajoutez une colonne distance à df.
df["distance"] = df.apply(lambda ligne: distance_2d(fruit_inconnu, ligne), axis=1)
df.head()Correction question 7.
La colonne distance donne la distance entre chaque fruit connu et le fruit inconnu. Plus cette valeur est faible, plus le fruit connu est proche du fruit inconnu.
Question 8
Triez le tableau par distance croissante et affichez les 5 fruits les plus proches du fruit inconnu.
df.sort_values("distance").head(5)5. Premier classificateur k-NN
On choisit un entier k. On regarde les k lignes les plus proches. La classe la plus fréquente devient la prédiction.
Question 9
Avec k = 3, quelle classe prédisez-vous pour le fruit inconnu ?
k = 3
voisins = df.sort_values("distance").head(k)
print(voisins[["masse_g", "diametre_mm", "type", "distance"]])
print()
print("Répartition des classes parmi les voisins :")
print(voisins["type"].value_counts())
print()
print("Prédiction :", voisins["type"].value_counts().idxmax())Correction question 9.
Avec k = 3, les trois plus proches voisins sont des pommes. La classe majoritaire est donc pomme.
Le fruit inconnu est prédit comme une pomme.
6. Programmer une fonction knn
Question 10
Complétez la fonction suivante. Elle doit renvoyer la classe prédite pour un fruit inconnu.
def knn_2d(table, fruit, k):
"""
table : DataFrame contenant masse_g, diametre_mm et type
fruit : dictionnaire contenant masse_g et diametre_mm
k : nombre de voisins utilisés
renvoie : classe prédite
"""
table = table.copy()
# 1. Calculer les distances
table["distance"] = table.apply(lambda ligne: distance_2d(fruit, ligne), axis=1)
# 2. Garder les k plus proches voisins
voisins = table.sort_values("distance").head(k)
# 3. Trouver la classe majoritaire
prediction = voisins["type"].value_counts().idxmax()
return prediction
print(knn_2d(df, fruit_inconnu, 3))Correction question 10.
La fonction knn_2d automatise les étapes faites précédemment : calculer les distances, trier, garder les k plus proches voisins, puis choisir la classe majoritaire.
Question 11
Testez la fonction avec les fruits inconnus suivants et plusieurs valeurs de k : 1, 3, 5, 7.
Que remarquez-vous ?
fruits_inconnus = [
{"masse_g": 150, "diametre_mm": 73},
{"masse_g": 190, "diametre_mm": 84},
{"masse_g": 119, "diametre_mm": 39},
{"masse_g": 165, "diametre_mm": 82}, # cas possiblement ambigu
]
for fruit in fruits_inconnus:
print("Fruit :", fruit)
for k in [1, 3, 5, 7]:
print(" k =", k, "->", knn_2d(df, fruit, k))
print()7. Comprendre le rôle de k
Question 12
Pourquoi k = 1 peut-il être risqué ? Pourquoi choisir un k trop grand peut-il aussi poser problème ?
Répondez en quelques lignes dans la cellule Markdown ci-dessous.
Correction question 12.
k = 1 peut être risqué, car la prédiction dépend d’un seul voisin. Si ce voisin est une donnée atypique, bruitée ou mal étiquetée, la prédiction sera fausse.
Un k trop grand peut aussi poser problème, car on prend alors en compte des fruits de plus en plus éloignés. Ces voisins lointains peuvent appartenir à d’autres groupes et faire perdre l’information locale.
Il faut donc choisir un compromis : assez de voisins pour limiter l’effet du hasard, mais pas trop pour rester proche de l’objet à classer.
8. Évaluer l’algorithme sur des données de test
Un modèle n’est pas seulement évalué sur les données qu’il connaît déjà.
On sépare donc le tableau en deux parties :
- un ensemble d’apprentissage : données connues par l’algorithme ;
- un ensemble de test : données gardées à part pour vérifier les prédictions.
Exécutez la cellule suivante.
# On mélange les lignes avec random_state pour obtenir toujours le même résultat
melange = df.sample(frac=1, random_state=42).reset_index(drop=True)
# 75 % apprentissage, 25 % test
limite = int(0.75 * len(melange))
train = melange.iloc[:limite].copy()
test = melange.iloc[limite:].copy()
print("Taille apprentissage :", len(train))
print("Taille test :", len(test))Question 13
Pour chaque ligne de test, prédisez le type avec knn_2d(train, fruit, 3).
Ajoutez une colonne prediction à test, puis affichez les colonnes masse_g, diametre_mm, type et prediction.
def predire_ligne(ligne):
fruit = {"masse_g": ligne["masse_g"], "diametre_mm": ligne["diametre_mm"]}
return knn_2d(train, fruit, 3)
test["prediction"] = test.apply(predire_ligne, axis=1)
test[["masse_g", "diametre_mm", "type", "prediction"]]Correction question 13.
Chaque fruit de l’ensemble de test est classé en utilisant seulement l’ensemble d’apprentissage. On peut ensuite comparer la prédiction à la vraie valeur de la colonne type.
Question 14
Calculez le pourcentage de bonnes prédictions.
Ce pourcentage s’appelle l’accuracy, ou exactitude.
bonnes_reponses = test["type"] == test["prediction"]
accuracy = bonnes_reponses.mean()
print("Bonnes réponses :")
print(bonnes_reponses.value_counts())
print()
print("Accuracy :", accuracy)
print("Accuracy en % :", round(accuracy * 100, 1), "%")Correction question 14.
L’accuracy correspond à la proportion de prédictions correctes.
Par exemple, une accuracy de 1.0 signifie 100 % de bonnes réponses sur l’ensemble de test. Une accuracy de 0.8 signifie 80 % de bonnes réponses.
9. Aller plus loin : utiliser trois caractéristiques
Jusqu’ici, on n’a utilisé que :
- la masse ;
- le diamètre.
Mais notre tableau contient aussi la couleur moyenne. On peut donc calculer une distance en trois dimensions.
Question 15
Complétez la fonction distance_3d, puis programmez une fonction knn_3d.
def distance_3d(fruit1, fruit2):
"""Distance utilisant masse_g, diametre_mm et couleur."""
dm = fruit1["masse_g"] - fruit2["masse_g"]
dd = fruit1["diametre_mm"] - fruit2["diametre_mm"]
dc = fruit1["couleur"] - fruit2["couleur"]
return sqrt(dm**2 + dd**2 + dc**2)
def knn_3d(table, fruit, k):
table = table.copy()
table["distance"] = table.apply(lambda ligne: distance_3d(fruit, ligne), axis=1)
voisins = table.sort_values("distance").head(k)
return voisins["type"].value_counts().idxmax()
fruit_inconnu_3d = {"masse_g": 165, "diametre_mm": 82, "couleur": 7.5}
print(knn_3d(df, fruit_inconnu_3d, 3))Correction question 15.
La distance en trois dimensions ajoute l’écart de couleur. Le principe de k-NN ne change pas : on calcule les distances, on trie, puis on prend la classe majoritaire parmi les k plus proches voisins.
10. Point important : les échelles de valeurs
La masse varie environ de 100 à 200. Le diamètre varie environ de 35 à 90. La couleur varie seulement de 0 à 10.
Dans une distance, une colonne avec de grands nombres peut avoir trop d’influence.
Une solution fréquente consiste à normaliser les données : transformer chaque colonne numérique pour qu’elle soit comprise entre 0 et 1.
Question 16
Exécutez la cellule suivante, puis observez les nouvelles colonnes.
df_norm = df.copy()
for colonne in ["masse_g", "diametre_mm", "couleur"]:
mini = df_norm[colonne].min()
maxi = df_norm[colonne].max()
df_norm[colonne + "_norm"] = (df_norm[colonne] - mini) / (maxi - mini)
df_norm.head()Question 17
Complétez une distance normalisée qui utilise :
masse_g_norm;diametre_mm_norm;couleur_norm.
def distance_norm(fruit1, fruit2):
dm = fruit1["masse_g_norm"] - fruit2["masse_g_norm"]
dd = fruit1["diametre_mm_norm"] - fruit2["diametre_mm_norm"]
dc = fruit1["couleur_norm"] - fruit2["couleur_norm"]
return sqrt(dm**2 + dd**2 + dc**2)
def knn_norm(table, fruit, k):
table = table.copy()
table["distance"] = table.apply(lambda ligne: distance_norm(fruit, ligne), axis=1)
voisins = table.sort_values("distance").head(k)
return voisins["type"].value_counts().idxmax()
# Exemple : on doit normaliser aussi le fruit inconnu
fruit = {"masse_g": 165, "diametre_mm": 82, "couleur": 7.5}
fruit_norm = fruit.copy()
for colonne in ["masse_g", "diametre_mm", "couleur"]:
mini = df[colonne].min()
maxi = df[colonne].max()
fruit_norm[colonne + "_norm"] = (fruit[colonne] - mini) / (maxi - mini)
print(fruit_norm)
print(knn_norm(df_norm, fruit_norm, 3))11. Bilan
Répondez aux questions suivantes.
- Que signifie le
kdans k-NN ? - Pourquoi doit-on parfois normaliser les données ?
- Pourquoi faut-il séparer les données en apprentissage et en test ?
- Donnez un avantage de k-NN.
- Donnez une limite de k-NN.
Correction du bilan.
- Le
kdans k-NN désigne le nombre de voisins les plus proches utilisés pour effectuer la prédiction. - On normalise parfois les données pour éviter qu’une variable ayant de grandes valeurs, comme la masse, domine artificiellement le calcul de distance.
- On sépare les données en apprentissage et en test pour évaluer le modèle sur des données qu’il n’a pas utilisées pour faire ses prédictions.
- Un avantage de k-NN est qu’il est simple à comprendre et à programmer.
- Une limite de k-NN est qu’il dépend fortement du choix de
k, du choix de la distance et de l’échelle des variables. Il peut aussi être coûteux si le jeu de données est très grand.
12. Mini-projet final, 20 à 30 minutes
Choisissez l’un des défis suivants.
Défi A — Tableau de comparaison
Créez un tableau qui compare l’accuracy obtenue pour k = 1, k = 3, k = 5 et k = 7.
Défi B — Visualisation des erreurs
Affichez les fruits de test sur un graphique :
- une forme ou une couleur pour le vrai type ;
- un marqueur particulier pour les erreurs.
Défi C — Interface simple
Écrivez une fonction classifier_fruit(masse, diametre, couleur, k) qui affiche une phrase du type :
Le fruit est probablement une orange.# Défi A — Tableau de comparaison de l'accuracy pour plusieurs valeurs de k
def accuracy_pour_k(k):
predictions = []
for _, ligne in test.iterrows():
fruit = {"masse_g": ligne["masse_g"], "diametre_mm": ligne["diametre_mm"]}
predictions.append(knn_2d(train, fruit, k))
return (test["type"].values == predictions).mean()
resultats = []
for k in [1, 3, 5, 7]:
resultats.append({"k": k, "accuracy": accuracy_pour_k(k)})
df_resultats = pd.DataFrame(resultats)
df_resultats["accuracy_%"] = (df_resultats["accuracy"] * 100).round(1)
df_resultats# Défi B — Visualisation simple des erreurs
test_visu = test.copy()
test_visu["correct"] = test_visu["type"] == test_visu["prediction"]
for fruit in test_visu["type"].unique():
sous_table = test_visu[test_visu["type"] == fruit]
plt.scatter(sous_table["masse_g"], sous_table["diametre_mm"], label=fruit)
erreurs = test_visu[test_visu["correct"] == False]
plt.scatter(erreurs["masse_g"], erreurs["diametre_mm"], marker="x", s=180, label="erreur")
plt.xlabel("Masse en grammes")
plt.ylabel("Diamètre en millimètres")
plt.title("Visualisation des erreurs sur l'ensemble de test")
plt.legend()
plt.grid()
plt.show()# Défi C — Interface simple de classification
def classifier_fruit(masse, diametre, couleur, k=3):
fruit = {"masse_g": masse, "diametre_mm": diametre, "couleur": couleur}
prediction = knn_3d(df, fruit, k)
article = "une" if prediction == "orange" else "un"
print(f"Le fruit est probablement {article} {prediction}.")
return prediction
classifier_fruit(150, 73, 3.2, 3)
classifier_fruit(190, 84, 7.8, 3)
classifier_fruit(119, 39, 9.1, 3)Correction mini-projet.
Les trois défis montrent trois usages complémentaires : comparer des valeurs de k, analyser visuellement les erreurs, puis rendre l’algorithme plus simple à utiliser avec une fonction d’interface.
---
Annexe professeur — éléments de correction rapide
Cette section peut être supprimée avant distribution aux élèves.
# Correction compacte des fonctions principales
def distance_2d(fruit1, fruit2):
dm = fruit1["masse_g"] - fruit2["masse_g"]
dd = fruit1["diametre_mm"] - fruit2["diametre_mm"]
return sqrt(dm**2 + dd**2)
def knn_2d(table, fruit, k):
table = table.copy()
table["distance"] = table.apply(lambda ligne: distance_2d(fruit, ligne), axis=1)
voisins = table.sort_values("distance").head(k)
return voisins["type"].value_counts().idxmax()
def distance_3d(fruit1, fruit2):
dm = fruit1["masse_g"] - fruit2["masse_g"]
dd = fruit1["diametre_mm"] - fruit2["diametre_mm"]
dc = fruit1["couleur"] - fruit2["couleur"]
return sqrt(dm**2 + dd**2 + dc**2)
def knn_3d(table, fruit, k):
table = table.copy()
table["distance"] = table.apply(lambda ligne: distance_3d(fruit, ligne), axis=1)
voisins = table.sort_values("distance").head(k)
return voisins["type"].value_counts().idxmax()
def distance_norm(fruit1, fruit2):
dm = fruit1["masse_g_norm"] - fruit2["masse_g_norm"]
dd = fruit1["diametre_mm_norm"] - fruit2["diametre_mm_norm"]
dc = fruit1["couleur_norm"] - fruit2["couleur_norm"]
return sqrt(dm**2 + dd**2 + dc**2)
def knn_norm(table, fruit, k):
table = table.copy()
table["distance"] = table.apply(lambda ligne: distance_norm(fruit, ligne), axis=1)
voisins = table.sort_values("distance").head(k)
return voisins["type"].value_counts().idxmax()Proposition de découpage des 2 h
- 0 à 15 min : rappel
pandas, chargement, exploration. - 15 à 35 min : visualisation et intuition de proximité.
- 35 à 60 min : distance et voisins les plus proches.
- 60 à 85 min : programmation de
knn_2d. - 85 à 105 min : ensemble de test, accuracy.
- 105 à 120 min : normalisation ou mini-projet selon l’avancement.