Introduction au ML — Séance 4 (TP)

La Séance 3 a entraîné un arbre sur des données impeccables.
Télécharger le notebook Ouvrir dans Colab Les sorties sont celles enregistrées dans le notebook : rien n'est réexécuté.

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.

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__)
# 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 57

sklearn : 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’étape StandardScaler.
  • 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 ?
Retour au sommet