import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
np.set_printoptions(precision=2, suppress=True)
pd.set_option("display.precision", 2)
print("Outils prêts. NumPy", np.__version__, "· pandas", pd.__version__)Introduction au ML — Séance 4 (TP)
Des données propres : préparer avant d’apprendre
Dr. El Hadji Bassirou TOURÉ · DMI · FST · UCAD
La Séance 3 a entraîné un arbre sur des données impeccables. Le vrai monde n’est jamais aussi propre. Ce TP affronte un vrai jeu médical — Pima (768 patientes, le diabète à prédire) — trouées, hétérogènes, piégées, et construit à la main chaque outil de préparation avant de le confier à scikit-learn.
Ce que tu sauras faire à la fin : - repérer et réparer les valeurs manquantes (taux de manquants, imputation par la médiane) ; - encoder les catégories (one-hot) et mettre à l’échelle (z-score) ; - entraîner un second modèle, les \(k\) plus proches voisins, et comprendre pourquoi l’échelle est pour lui une condition de validité ; - déjouer la fuite de données et souder la chaîne avec un Pipeline : \(f = m \circ \varphi\).
Durée estimée : 1 h 30 à 2 h. · Règle d’or : exécute chaque cellule, dans l’ordre.
Partie 0 — Mise en place
On importe les outils, on télécharge Pima, et on regarde les données en face.
# Pima : 768 patientes, 8 mesures cliniques, cible = diabète (0/1)
URL = "https://raw.githubusercontent.com/jbrownlee/Datasets/master/pima-indians-diabetes.data.csv"
cols = ["grossesses", "glycemie", "pression", "pli_cutane", "insuline",
"imc", "pedigree", "age", "diabete"]
df = pd.read_csv(URL, header=None, names=cols)
print("dimensions :", df.shape)
df.head()# La cible : combien de patientes diabétiques ?
print(df["diabete"].value_counts())
print("\nproportion de positives :", round(df["diabete"].mean(), 3))Lecture. 768 patientes, 268 positives (diabète = 1) et 500 négatives. Une ligne par patiente, une colonne par mesure : la matrice \(X\) « à l’endroit », comme en Séance 3.
Partie 1 — Le poison caché : des zéros impossibles
Regardons les statistiques de base. Une valeur va nous mettre la puce à l’oreille.
df.describe().round(1)Le minimum de glycemie, pression, imc, insuline… vaut 0. Or une glycémie de 0 ou une pression artérielle de 0 sont physiologiquement impossibles : ce ne sont pas des mesures, ce sont des absences de mesure codées paresseusement par un 0. Comptons-les.
impossibles = ["glycemie", "pression", "pli_cutane", "insuline", "imc"]
for c in impossibles:
n = (df[c] == 0).sum()
print(f"{c:11s} : {n:4d} zéros impossibles ({100*n/len(df):4.1f} %)")Le diagnostic est sévère : près d’une patiente sur deux n’a pas de dosage d’insuline (374/768). Ces zéros ne sont pas des données — ce sont des trous déguisés. Première réparation : les rendre visibles en les remplaçant par NaN (Not a Number).
X = df.drop(columns="diabete").copy()
y = df["diabete"].copy()
X[impossibles] = X[impossibles].replace(0, np.nan) # le 0 impossible devient un vrai trou
X[impossibles].isna().sum()Le taux de manquants, à la main
Le taux de manquants de la colonne \(j\) est la proportion de cases vides : \[ m_j = \frac{1}{n}\sum_{i=1}^{n} \mathbf{1}\big[x_{ij}\text{ manquant}\big]. \] C’est exactement la mécanique du risque empirique de la Séance 3 — une moyenne d’indicatrices — appliquée aux trous plutôt qu’aux erreurs.
n = len(X)
for c in impossibles:
m = X[c].isna().sum() / n
print(f"m_{c:11s} = {X[c].isna().sum():3d} / {n} = {m:.3f}")À toi. Calcule, sur le même modèle, le taux de manquants de la seule colonne pression, et affiche-le sous la forme m_pression = .../768 = ....
# À toi : reprends la ligne ci-dessus pour la seule colonne "pression"Pourquoi ne pas simplement jeter les lignes trouées ?
complet = X.dropna()
print("lignes complètes :", len(complet), "sur", n)
print("on perdrait :", n - len(complet), "patientes,",
f"soit {100*(n-len(complet))/n:.0f} % du jeu")49 % du jeu sacrifié — et pas au hasard : les patientes sans dosage d’insuline sont souvent celles des structures les moins équipées. Les jeter biaiserait l’échantillon en croyant le nettoyer. La bonne voie : garder les lignes, remplir les trous. C’est l’imputation.
Partie 2 — Réparer : l’imputation par la médiane
Avec quelle valeur remplir un trou ? Une valeur typique de la colonne. La moyenne semble naturelle, mais elle a un défaut rédhibitoire sur les données médicales : les valeurs extrêmes la tirent. La médiane, elle, est robuste.
# Jouet : cinq insulines, dont une extrême
jouet = np.array([88, 94, 105, 130, 480])
print("triées :", np.sort(jouet))
print("médiane :", np.median(jouet), " <- la valeur du milieu")
print("moyenne :", round(jouet.mean(), 1), " <- tirée vers le haut par 480")La médiane (105) reste au cœur du groupe ; la moyenne (179,4) ne ressemble à aucune des cinq patientes. Visualisons-le.
fig, ax = plt.subplots(figsize=(8, 2.6))
ax.scatter(jouet, [1]*5, s=90, color="#2585D6", zorder=3)
ax.scatter([480], [1], s=110, color="#C0392B", zorder=4)
ax.axvline(np.median(jouet), color="#2E8B57", lw=2.4, label="médiane = 105")
ax.axvline(jouet.mean(), color="#D9822B", lw=2.4, ls="--", label=f"moyenne = {jouet.mean():.1f}")
ax.set_yticks([]); ax.set_xlabel("insuline (5 patientes)"); ax.legend()
ax.set_title("Une valeur extrême déplace la moyenne, pas la médiane")
plt.show()Imputer, formellement
\[ \tilde x_{ij} = \begin{cases} x_{ij} & \text{si observée}\\ \text{med}_j & \text{si manquante}\end{cases} \]
Point décisif : \(\text{med}_j\) est un paramètre appris des données — et comme tout paramètre appris, il s’apprend sur le train uniquement (la raison précise viendra avec la fuite de données). On découpe donc d’abord.
from sklearn.model_selection import train_test_split
X_tr, X_te, y_tr, y_te = train_test_split(
X, y, test_size=0.2, stratify=y, random_state=0)
print("train :", len(X_tr), "| test :", len(X_te))# Médianes apprises sur le TRAIN, puis imputation à la main
medianes = X_tr.median()
print("médianes (train) :")
print(medianes.round(1).to_string())
X_tr_imp = X_tr.fillna(medianes)
X_te_imp = X_te.fillna(medianes) # MÊMES médianes appliquées au test
print("\ntrous restants train/test :", X_tr_imp.isna().sum().sum(),
"/", X_te_imp.isna().sum().sum())Vérifions sur une patiente concrète du test dont l’insuline manquait.
i = X_te.index[X_te["insuline"].isna()][0]
print("patiente n°", i)
print(" insuline brute :", X_te.loc[i, "insuline"], "(NaN)")
print(" insuline imputée :", X_te_imp.loc[i, "insuline"],
"= médiane insuline du train")
print(" glycémie (intacte):", X_te_imp.loc[i, "glycemie"])sklearn fait pareil — confrontons
SimpleImputer(strategy="median") apprend les médianes au fit (sur le train) et les applique au transform. On doit retrouver, au flottant près, notre imputation manuelle.
from sklearn.impute import SimpleImputer
imp = SimpleImputer(strategy="median")
imp.fit(X_tr) # apprend les médianes (train)
X_tr_sk = imp.transform(X_tr)
print("médianes apprises par sklearn :", imp.statistics_.round(1))
print("médianes calculées à la main :", X_tr.median().values.round(1))
print("identiques ?", np.allclose(imp.statistics_, X_tr.median().values))À toi. Au lieu de la médiane, demande à SimpleImputer la stratégie "mean", et compare la valeur d’imputation de l’insuline entre "median" et "mean". Laquelle est la plus proche d’une patiente typique ?
# À toi : SimpleImputer(strategy="mean"), puis compare .statistics_ pour l'insuline
# (l'insuline est la 5e colonne, indice 4)Partie 3 — Encoder les catégories
Les modèles ne mangent que des nombres. Pima est déjà entièrement numérique, mais les vraies études portent des catégories (région, sexe, type d’admission). Le piège : les coder par des entiers 1, 2, 3…, ce qui invente un ordre et des distances qui n’existent pas.
La bonne traduction est le one-hot : une colonne binaire par modalité, une seule « chaude ».
# Une colonne "région" fictive ajoutée à 6 patientes pour la démonstration
regions = pd.Series(["Dakar", "Thies", "Kaolack", "Dakar", "Saint-Louis", "Thies"],
name="region")
pd.get_dummies(regions).astype(int)Chaque ligne n’a qu’un seul 1 : aucune région n’est « supérieure » à une autre, toutes sont à égale distance. Comparons avec le mauvais encodage entier.
mauvais = regions.map({"Dakar": 1, "Thies": 2, "Kaolack": 3, "Saint-Louis": 4})
print(pd.DataFrame({"region": regions, "entier (FAUX)": mauvais}).to_string(index=False))
print("\nProblème : 'Saint-Louis' (4) serait 4x 'Dakar' (1), et 2x plus loin que 'Thies' (2).")
print("Cet ordre est inventé — les distances de la Partie 4 le prendraient au sérieux.")À toi. Encode en one-hot la petite colonne admission ci-dessous (4 modalités), puis affiche le vecteur de la ligne « transfert ».
admission = pd.Series(["urgences", "consultation", "transfert", "programmee"], name="admission")
# À toi : pd.get_dummies(...) puis sélectionne la ligne 2 (transfert)Partie 4 — Mettre à l’échelle, et le modèle qui en dépend
Sur Pima, l’âge va de 21 à 81 ; l’insuline de 14 à 846. Tout calcul qui les mélange (une distance) sera dominé par les grands nombres. La standardisation (z-score) remet tout le monde dans la même monnaie : \[ z = \frac{x - \mu}{\sigma}. \]
# z-score à la main : mu, sigma APPRIS sur le train
mu = X_tr_imp.mean()
sigma = X_tr_imp.std(ddof=0)
print("glycémie : mu =", round(mu["glycemie"], 1), " sigma =", round(sigma["glycemie"], 1))
# une patiente à glycémie 180
z = (180 - mu["glycemie"]) / sigma["glycemie"]
print(f"glycémie 180 -> z = (180 - {mu['glycemie']:.1f}) / {sigma['glycemie']:.1f} = {z:.2f}")
print("=> près de 2 écarts-types au-dessus : nettement élevée")À toi. Calcule le z-score d’une patiente de 57 ans (avec mu["age"] et sigma["age"]) et interprète-le par rapport à la glycémie 180 ci-dessus.
# À toi : z de l'âge 57sklearn : StandardScaler
from sklearn.preprocessing import StandardScaler
sc = StandardScaler().fit(X_tr_imp)
print("mu sklearn (glycémie) :", round(sc.mean_[1], 1), " | à la main :", round(mu["glycemie"], 1))
print("sigma sklearn (glycémie) :", round(sc.scale_[1], 1), " | à la main :", round(sigma["glycemie"], 1))Le modèle kNN : prédire comme ses voisines
Le \(k\) plus proches voisins prédit la classe majoritaire parmi les \(k\) exemples d’entraînement les plus proches. La proximité se mesure par la distance euclidienne \(d(x,x') = \sqrt{\sum_j (x_j - x'_j)^2}\). Refaisons un vote à la main.
# 5 voisines connues, une patiente à classer en (4, 4)
voisines = np.array([[5,5],[2,4],[6,3],[1,1],[7,7]])
classes = np.array([ 1, 0, 1, 0, 1 ])
q = np.array([4, 4])
d = np.sqrt(((voisines - q)**2).sum(axis=1))
for (v, c, dist) in zip(voisines, classes, d):
print(f"voisine {tuple(v)} classe {c} : d = {dist:.2f}")
ordre = np.argsort(d)
for k in (3, 5):
vote = classes[ordre[:k]]
pred = int(vote.sum() >= (k/2))
print(f"\nk={k} : classes des voisins = {vote.tolist()} -> prédiction {pred}")Pourquoi k impair ? Avec un \(k\) pair, le vote peut être à égalité (2–2) ; un \(k\) impair garantit toujours une majorité nette.
Partie 5 — Pourquoi l’échelle décide du résultat du kNN
Voici la démonstration la plus importante de la séance. Trois patientes, deux mesures (âge, insuline). Qui est la plus proche de A ?
A = np.array([25.0, 100.0]) # jeune, insuline normale
B = np.array([26.0, 400.0]) # quasi même âge, insuline très haute
C = np.array([60.0, 105.0]) # bien plus âgée, insuline normale
dist = lambda u, v: np.sqrt(((u - v)**2).sum())
print("EN BRUT :")
print(f" d(A,B) = {dist(A,B):5.1f} (1 an d'écart, mais 300 d'insuline)")
print(f" d(A,C) = {dist(A,C):5.1f} (35 ans d'écart, mais 5 d'insuline)")
print(" => C paraît BIEN plus proche de A que B : l'insuline a tout écrasé.")# Standardisons avec mu, sigma du train (âge et insuline)
def standard(p):
return np.array([(p[0]-mu["age"])/sigma["age"],
(p[1]-mu["insuline"])/sigma["insuline"]])
Az, Bz, Cz = standard(A), standard(B), standard(C)
print("STANDARDISÉ :")
print(f" d(A,B) = {dist(Az,Bz):.2f}")
print(f" d(A,C) = {dist(Az,Cz):.2f}")
print(" => l'ordre s'INVERSE : B redevient le plus proche. Chaque variable pèse selon ses écarts réels.")Sur Pima en entier, le verdict chiffré. On entraîne un kNN (\(k=5\)) sans, puis avec standardisation (imputation médiane dans les deux cas).
from sklearn.neighbors import KNeighborsClassifier
# sans échelle
knn = KNeighborsClassifier(n_neighbors=5).fit(X_tr_imp, y_tr)
acc_brut = knn.score(X_te_imp, y_te)
# avec échelle (mu, sigma du train)
X_tr_z = (X_tr_imp - mu) / sigma
X_te_z = (X_te_imp - mu) / sigma
knn_z = KNeighborsClassifier(n_neighbors=5).fit(X_tr_z, y_tr)
acc_z = knn_z.score(X_te_z, y_te)
print(f"kNN k=5 SANS standardisation : {acc_brut:.3f}")
print(f"kNN k=5 AVEC standardisation : {acc_z:.3f}")Pour un modèle à distances, la mise à l’échelle n’est pas un raffinement — c’est une condition de validité du calcul lui-même.
Partie 6 — Régler k : la complexité, encore elle
Comme la profondeur de l’arbre en Séance 3, \(k\) est un bouton de complexité — mais inversé : petit \(k\) = modèle complexe. Traçons la courbe train/test.
ks = range(1, 31)
acc_tr, acc_te = [], []
for k in ks:
m = KNeighborsClassifier(n_neighbors=k).fit(X_tr_z, y_tr)
acc_tr.append(m.score(X_tr_z, y_tr))
acc_te.append(m.score(X_te_z, y_te))
best = list(ks)[int(np.argmax(acc_te))]
fig, ax = plt.subplots(figsize=(8.5, 4.2))
ax.plot(list(ks), acc_tr, "o-", color="#2585D6", ms=4, label="train (réviser)")
ax.plot(list(ks), acc_te, "s-", color="#2E8B57", ms=4, label="test (examen)")
ax.axhline(max(y_te.mean(), 1-y_te.mean()), color="grey", ls="--", lw=1.5, label="baseline")
ax.axvline(best, color="#C0392B", ls=":", lw=2)
ax.set_xlabel("k (petit k = modèle COMPLEXE)"); ax.set_ylabel("accuracy")
ax.set_title("Courbe de complexité du kNN sur Pima"); ax.legend(); ax.grid(alpha=.3)
plt.show()
print(f"k=1 : train {acc_tr[0]:.3f} / test {acc_te[0]:.3f} (mémorisation)")
print(f"meilleur k = {best} : test {max(acc_te):.3f}")À toi. À \(k=1\), pourquoi l’accuracy d’entraînement vaut-elle exactement 1,000 ? (Indice : quelle est la voisine la plus proche d’une patiente du train… si elle-même est dans le train ?) Écris ta réponse en commentaire.
# À toi (réponse en commentaire) :
# k=1 : la voisine la plus proche d'une patiente du train est ...Partie 7 — La fuite de données : des scores qui mentent
Un score trop beau n’est pas une victoire, c’est une alarme. Deux fuites classiques.
Fuite n°1 — la variable fuyarde
Une colonne qui contient (déguise) la réponse. Ajoutons un traitement antidiabétique, prescrit après le diagnostic — donc indisponible au moment de prédire.
from sklearn.tree import DecisionTreeClassifier
X_leak = X.copy()
X_leak["traitement"] = y.values * 1.0 # = la réponse, déguisée
Xl_tr, Xl_te, yl_tr, yl_te = train_test_split(
X_leak, y, test_size=0.2, stratify=y, random_state=0)
arbre = DecisionTreeClassifier(max_depth=4, random_state=0)
arbre.fit(Xl_tr.fillna(Xl_tr.median()), yl_tr)
print("accuracy avec la colonne 'traitement' :",
round(arbre.score(Xl_te.fillna(Xl_tr.median()), yl_te), 3))
print("=> 1,000 : trop beau. La colonne EST la réponse. À l'admission, elle n'existe pas.")Le test à appliquer à chaque colonne : cette information existera-t-elle au moment de prédire ? Si non, elle sort — quel que soit le score qu’elle promet.
Fuite n°2 — le prétraitement fuyard
Apprendre les médianes / \(\mu\) / \(\sigma\) sur le jeu complet (test inclus), c’est laisser le test influencer l’apprentissage. Ici l’écart est minuscule — et c’est précisément le danger : la fuite ne se voit pas au score, mais le protocole ment par construction.
# Protocole FUYARD : on standardise TOUT le jeu, puis on découpe
mu_all, sd_all = X.fillna(X.median()).mean(), X.fillna(X.median()).std(ddof=0)
X_all_z = (X.fillna(X.median()) - mu_all) / sd_all
Xz_tr, Xz_te, _, _ = train_test_split(X_all_z, y, test_size=0.2, stratify=y, random_state=0)
knn_bad = KNeighborsClassifier(n_neighbors=5).fit(Xz_tr, y_tr)
print("protocole fuyard :", round(knn_bad.score(Xz_te, y_te), 3),
" | protocole honnête :", round(acc_z, 3))
print("Écart quasi nul ICI — mais sur séries temporelles ou petits jeux, il devient majeur.")
print("Règle non négociable : tout fit se fait sur le train.")Partie 8 — Le Pipeline : souder la chaîne, \(f = m \circ \varphi\)
Imputer → standardiser → prédire : trois maillons, chacun avec ses paramètres appris. Les enchaîner à la main multiplie les occasions de fuite. Le Pipeline soude la chaîne : un seul fit (sur le train), un seul predict. La fuite de prétraitement devient impossible par construction.
from sklearn.pipeline import Pipeline
modele = Pipeline([
("imputation", SimpleImputer(strategy="median")),
("echelle", StandardScaler()),
("knn", KNeighborsClassifier(n_neighbors=22)),
])
modele.fit(X_tr, y_tr) # UN fit : médianes, mu/sigma, voisins
print("accuracy (test) du Pipeline kNN k=22 :", round(modele.score(X_te, y_te), 3))Le triptyque fit / predict / score de la Séance 3, inchangé : le Pipeline est un modèle comme les autres. Suivons une patiente à travers la chaîne \(\varphi\).
# phi(x) pas à pas, patiente n°i (insuline manquante)
brut = X.loc[i, ["glycemie", "insuline", "age"]]
med_i = X_tr["insuline"].median()
print("patiente n°", i)
print(" brut :", brut.to_dict())
print(f" après imputation : insuline NaN -> {med_i:.1f} (médiane train)")
for col in ["glycemie", "insuline", "age"]:
val = brut[col] if col != "insuline" else med_i
z = (val - mu[col]) / sigma[col]
print(f" z-score {col:8s} : ({val:.0f} - {mu[col]:.1f}) / {sigma[col]:.1f} = {z:+.2f}")
print(" puis le kNN vote sur ce vecteur réparé : f(x) = m(phi(x))")Le bilan chiffré de la séance
baseline = max(y_te.mean(), 1 - y_te.mean())
tree_pipe = Pipeline([("imp", SimpleImputer(strategy="median")),
("tree", DecisionTreeClassifier(max_depth=4, random_state=0))]).fit(X_tr, y_tr)
resultats = pd.DataFrame({
"modèle": ["baseline (classe majoritaire)", "arbre prof.4 (S3) + imputation",
"kNN k=5 sans échelle", "kNN k=5 Pipeline complet",
"kNN k=22 Pipeline complet", "n'importe quoi + variable fuyarde"],
"accuracy_test": [round(baseline,3), round(tree_pipe.score(X_te,y_te),3),
round(acc_brut,3), round(acc_z,3),
round(modele.score(X_te,y_te),3), 1.000],
})
print(resultats.to_string(index=False))Deux leçons. Le roi de la Séance 3 est détrôné : sur ces données, le kNN bien préparé bat nettement l’arbre — aucun modèle n’est roi partout. Et la ligne fuyarde à 1,000 n’est pas un modèle : c’est un mensonge. La préparation, elle, a fait passer le kNN de 0,714 à 0,786.
Synthèse — ce que j’ai appris
Complète ces phrases avec tes propres mots (double-clique pour éditer) :
- Un zéro impossible est ……… ; on le transforme en ……… avant tout.
- La médiane est préférable à la moyenne pour imputer parce que ……… .
- On encode les catégories en one-hot plutôt qu’en entiers parce que ……… .
- Sans standardisation, la distance du kNN est dominée par ……… .
- Pour le kNN, un petit \(k\) correspond à un modèle ……… (biais / variance ?).
- Une fuite de données se produit quand ……… ; le Pipeline l’empêche parce que ……… .
Le réflexe de la séance : avant de modéliser, auditer — trous, échelles, colonnes fuyardes.
Pour aller plus loin (optionnel, sans correction)
- Remplace le kNN du Pipeline par l’arbre
DecisionTreeClassifier(max_depth=6). L’arbre a-t-il besoin de la standardisation ? Teste en retirant l’étapeStandardScaler. - Sur la courbe de complexité, repère la zone de sous-apprentissage (grand \(k\)) : que vaut l’accuracy quand \(k\) approche le nombre de patientes du train ?