Transformez vos données sans fuite
Vos données sont propres, mais elles ne sont pas encore prêtes. Un algorithme de k-means ne sait pas calculer une distance entre « Cherbourg » et « Southampton ». Une ACP sur l’âge (en années) et le prix du billet (en livres) ne verra que le prix, parce que ses valeurs sont plus grandes. Et un prix très asymétrique, avec quelques billets hors normes, écrase tous les autres.
Ce chapitre présente les transformations classiques : encodage, discrétisation, mise à l’échelle et logarithme. Il se termine par la règle qui les gouverne toutes : ne jamais laisser le jeu de test influencer une transformation. Nous restons sur le Titanic.
Encodez les variables catégorielles
Section intitulée « Encodez les variables catégorielles »Beaucoup de modèles n’acceptent que des nombres : régressions, réseaux de neurones, SVM, et toutes les méthodes à base de distances (k plus proches voisins, k-means). D’autres, comme les arbres de décision, peuvent en principe travailler directement sur des catégories (selon l’implémentation : celle de scikit-learn demande tout de même des nombres). Il faut donc encoder les catégories.
L’encodage one-hot
Section intitulée « L’encodage one-hot »L’encodage one-hot crée une colonne binaire (0 ou 1) par modalité. Pour le port d’embarquement Embarked, qui vaut C (Cherbourg), Q (Queenstown) ou S (Southampton), on obtient trois colonnes Embarked_C, Embarked_Q et Embarked_S, dont une seule vaut 1 sur chaque ligne.
import pandas as pd
pd.get_dummies(df["Embarked"], prefix="Embarked") # 3 colonnespd.get_dummies(df["Embarked"], prefix="Embarked", drop_first=True) # 2 colonnesPourquoi l’option drop_first=True ? Parce que les trois colonnes somment toujours à 1 : la troisième se déduit des deux autres. Cette redondance gêne les modèles linéaires sans régularisation (les colonnes sont exactement colinéaires). En retirant la première modalité par ordre alphabétique (C), on garde toute l’information : un passager avec Embarked_Q = 0 et Embarked_S = 0 est forcément monté à Cherbourg.
L’encodage ordinal
Section intitulée « L’encodage ordinal »L’encodage ordinal (ou label encoding) remplace chaque modalité par un entier : C par 0, Q par 1, S par 2. Une seule colonne, c’est compact. C’est parfait pour une variable ordinale comme la classe du billet ou une taille « petit, moyen, grand », à condition de respecter l’ordre naturel.
Discrétisez les variables continues
Section intitulée « Discrétisez les variables continues »Discrétiser, c’est découper une variable continue en intervalles : l’âge devient « enfant, adulte, senior ». On y gagne en lisibilité, on réduit l’effet du bruit en regroupant des valeurs proches, et l’on prépare les données pour les méthodes qui ne travaillent qu’avec des catégories (comme la recherche de motifs fréquents, au chapitre 9).
Deux méthodes simples s’opposent :
- à largeur égale (equal width binning, EWB) : on coupe l’étendue en intervalles de même longueur (
pd.cut) ; - à fréquence égale (equal frequency binning, EFB) : on coupe aux quantiles pour que chaque intervalle contienne à peu près le même nombre d’individus (
pd.qcut).
La largeur égale est sensible aux valeurs extrêmes, qui étirent l’étendue ; la fréquence égale équilibre les effectifs mais produit des intervalles de largeurs très différentes. Il existe des méthodes plus fines : la discrétisation supervisée par l’entropie, qui place les coupures là où elles séparent le mieux la cible (comme un arbre de décision), la discrétisation par clustering (un k-means sur une seule variable), et des méthodes propres aux séries temporelles comme SAX.
df["age_largeur"] = pd.cut(df["Age"], bins=3) # intervalles de même longueurdf["age_frequence"] = pd.qcut(df["Age"], q=3) # intervalles de même effectifMettez les variables à la même échelle
Section intitulée « Mettez les variables à la même échelle »Deux transformations dominent.
La normalisation min-max ramène chaque variable dans :
où et sont le minimum et le maximum de la variable.
La standardisation (ou centrage-réduction, z-score) donne à chaque variable une moyenne de 0 et un écart-type de 1 :
où est la moyenne et l’écart-type de la variable. Une valeur standardisée se lit « à combien d’écarts-types de la moyenne ».
Ces transformations sont indispensables pour tout ce qui repose sur des distances ou des variances : k plus proches voisins, k-means, ACP, SVM. Elles accélèrent la descente de gradient et rendent comparables les coefficients d’un modèle linéaire régularisé. Elles sont en revanche inutiles pour les arbres et les modèles à base de règles, qui ne comparent qu’une variable à la fois à un seuil. On s’en passe aussi quand l’échelle d’origine a un sens à conserver, et l’on évite de centrer des données creuses (une matrice de mots, par exemple) : soustraire la moyenne remplirait de valeurs non nulles une matrice presque vide.
Réduisez l’asymétrie avec un logarithme
Section intitulée « Réduisez l’asymétrie avec un logarithme »La transformation logarithmique compresse les grandes valeurs et étire les petites. Elle réduit l’asymétrie d’une distribution étalée vers la droite (prix, revenus, populations), stabilise la variance et atténue l’effet des valeurs extrêmes, sans les supprimer.
Si la variable peut valoir 0, comme le prix du billet de certains membres d’équipage, on utilise pour éviter , qui n’existe pas. Sur nos dix billets, cette transformation donne des valeurs de 2,11 (pour 7,25) à 5,58 (pour 263) : le billet exceptionnel reste le plus grand, mais il n’est plus 36 fois plus gros que le plus petit, seulement 2,6 fois.
import numpy as npdf["log_fare"] = np.log1p(df["Fare"]) # ln(1 + x), défini même quand Fare vaut 0Pour finir, voici un récapitulatif par type de variable :
| Type | Transformations usuelles |
|---|---|
| Numérique | valeurs manquantes, valeurs aberrantes, mise à l’échelle, logarithme, discrétisation |
| Catégoriel | valeurs manquantes, choix de l’encodage (one-hot ou ordinal) |
| Texte | nettoyage (minuscules, accents, caractères spéciaux), vectorisation |
| Temporel | conversion en date, extraction de l’année, du mois, du jour, de l’heure |
Évitez la fuite de données avec un pipeline
Section intitulée « Évitez la fuite de données avec un pipeline »Toutes ces transformations apprennent quelque chose sur les données : la médiane d’imputation, le minimum et le maximum, la moyenne et l’écart-type, les coupures des quantiles, les modalités à encoder. Si elles apprennent sur le jeu complet, test compris, le modèle profite d’informations qu’il n’aurait pas en production. Son évaluation devient trop optimiste : c’est une fuite de données (data leakage).
La règle est simple : on ajuste (fit) chaque transformation sur le jeu d’apprentissage seulement, puis on l’applique (transform) telle quelle au jeu de test. Pour ne jamais l’oublier, scikit-learn propose de tout enchaîner dans un pipeline :
from sklearn.compose import make_column_transformerfrom sklearn.impute import SimpleImputerfrom sklearn.linear_model import LogisticRegressionfrom sklearn.model_selection import cross_val_score, train_test_splitfrom sklearn.pipeline import make_pipelinefrom sklearn.preprocessing import OneHotEncoder, StandardScaler
X = df[["Age", "Fare", "Sex", "Embarked", "Pclass"]]y = df["Survived"]X_app, X_test, y_app, y_test = train_test_split(X, y, test_size=0.25, stratify=y, random_state=0)
numeriques = make_pipeline(SimpleImputer(strategy="median"), StandardScaler())categorielles = make_pipeline(SimpleImputer(strategy="most_frequent"), OneHotEncoder(handle_unknown="ignore"))preparation = make_column_transformer( (numeriques, ["Age", "Fare"]), (categorielles, ["Sex", "Embarked", "Pclass"]),)modele = make_pipeline(preparation, LogisticRegression())
print(cross_val_score(modele, X_app, y_app, cv=5).mean()) # chaque pli réajuste la préparationmodele.fit(X_app, y_app) # tout est ajusté sur l'apprentissageprint(modele.score(X_test, y_test)) # le test ne sert qu'une foisLe gain est double : la validation croisée réajuste toute la préparation sur chaque pli, sans fuite, et le même objet s’applique ensuite en production sur de nouvelles données. Le cours d’Apprentissage automatique revient en détail sur ces protocoles d’évaluation.
En résumé
Section intitulée « En résumé »- L’encodage one-hot crée une colonne 0/1 par modalité (
drop_first=Trueretire la colonne redondante) ; l’encodage ordinal convient aux variables ordonnées mais invente un ordre sur les nominales. - La discrétisation à largeur égale est sensible aux extrêmes ; celle à fréquence égale équilibre les effectifs mais produit des largeurs inégales.
- La normalisation min-max ramène dans mais est écrasée par les extrêmes ; la standardisation donne moyenne 0 et écart-type 1.
- La mise à l’échelle est indispensable pour les méthodes à distance et l’ACP, inutile pour les arbres.
- Le logarithme ( si peut être nul) réduit l’asymétrie et l’effet des valeurs extrêmes.
- Toute transformation s’ajuste sur l’apprentissage seulement ; un pipeline l’automatise et évite la fuite de données.
Exercices
Section intitulée « Exercices »Exercice · Normaliser et standardiser à la main
facileUne variable prend les valeurs 2, 4, 6 et 8. Calculez ses valeurs après normalisation min-max, puis après standardisation (avec l’écart-type calculé en divisant par , comme le fait StandardScaler).
Voir la solution
Min-max : et , donc . On obtient , , et .
Standardisation : la moyenne vaut . Les écarts à la moyenne sont , de carrés , dont la moyenne est . Donc . Les valeurs standardisées sont , puis , et . Leur moyenne est bien 0 et leur écart-type 1.
En code
import numpy as npfrom sklearn.preprocessing import MinMaxScaler, StandardScaler
x = np.array([2, 4, 6, 8], dtype=float).reshape(-1, 1)
print(MinMaxScaler().fit_transform(x).ravel().round(3)) # [0. 0.333 0.667 1.]z = StandardScaler().fit_transform(x).ravel() # écart-type divisé par nprint(z.round(3)) # [-1.342 -0.447 0.447 1.342]print(round(x.std(), 3), z.mean(), z.std()) # 2.236 0.0 1.0x <- c(2, 4, 6, 8)
round((x - min(x)) / (max(x) - min(x)), 3) # 0 0.333 0.667 1sigma <- sqrt(mean((x - mean(x))^2)) # écart-type divisé par n, comme StandardScalerz <- (x - mean(x)) / sigma # attention : scale() divise par n - 1round(sigma, 3); round(z, 3) # 2.236 ; -1.342 -0.447 0.447 1.342Exercice · Choisir un encodage
moyenPour chacune de ces variables, proposez un encodage et justifiez-le, en supposant que le modèle est un k-means : (a) le niveau de diplôme (bac, licence, master, doctorat) ; (b) le département de naissance (101 valeurs possibles) ; (c) le moyen de transport préféré (voiture, vélo, bus, marche).
Voir la solution
(a) Encodage ordinal en respectant l’ordre (bac = 0, licence = 1, master = 2, doctorat = 3) : la variable est ordinale. On reste conscient que l’écart entre deux niveaux consécutifs est supposé constant.
(b) Le département est nominal. Un encodage ordinal (numéro du département) créerait des proximités absurdes pour un k-means (le 35 « proche » du 36). Le one-hot est correct mais produit 101 colonnes très creuses, qui pèseront lourd dans les distances. Mieux vaut souvent le remplacer par une information pertinente : région, coordonnées du chef-lieu, ou regroupement en quelques grandes zones.
(c) One-hot : 4 modalités sans ordre, cela ne fait que 4 colonnes (ou 3 avec drop_first).
En code
import numpy as npimport pandas as pd
# (a) Diplôme ordinal : un entier qui respecte l'ordreordre = {"bac": 0, "licence": 1, "master": 2, "doctorat": 3}print(pd.Series(["master", "bac", "doctorat"]).map(ordre).tolist()) # [2, 0, 3]
# (b) Département : le codage numérique invente des proximitésd35, d36, d29 = 35, 36, 29 # Ille-et-Vilaine, Indre, Finistèreprint(abs(d35 - d36), abs(d35 - d29)) # 1 6 : le 36 « plus proche » de Rennes que le 29
# (c) Transport : one-hot, toutes les modalités à la même distancetransport = pd.get_dummies(pd.Series(["voiture", "velo", "bus", "marche"]), dtype=int)print(transport.shape) # (4, 4)print(np.linalg.norm(transport.iloc[0] - transport.iloc[1]), np.linalg.norm(transport.iloc[0] - transport.iloc[3])) # 1.414 1.414# (a) Diplôme ordinal : un entier qui respecte l'ordreniveaux <- c("bac", "licence", "master", "doctorat")match(c("master", "bac", "doctorat"), niveaux) - 1 # 2 0 3
# (b) Département : le codage numérique invente des proximitésc(abs(35 - 36), abs(35 - 29)) # 1 6 : l'Indre « plus proche » de l'Ille-et-Vilaine que le Finistère
# (c) Transport : one-hot, toutes les modalités à la même distancetransport <- factor(c("voiture", "velo", "bus", "marche"))onehot <- model.matrix(~ transport - 1) # une colonne 0/1 par modalitédim(onehot) # 4 4dist(onehot) # 1.414 entre toutes les pairesExercice · Repérer une fuite de données
difficileUn collègue écrit le code suivant, puis annonce une exactitude de 0,84 en test. Expliquez ce qui ne va pas et corrigez-le.
X = df[["Age", "Fare"]].fillna(df[["Age", "Fare"]].median())X = StandardScaler().fit_transform(X)X_app, X_test, y_app, y_test = train_test_split(X, y, test_size=0.25, random_state=0)modele = LogisticRegression().fit(X_app, y_app)print(modele.score(X_test, y_test))Voir la solution
Les médianes d’imputation, ainsi que la moyenne et l’écart-type de la standardisation, sont calculés sur tout le jeu de données, avant le découpage. Les passagers du test ont donc contribué à la préparation : c’est une fuite de données, et le score de 0,84 est potentiellement optimiste (l’écart est souvent petit pour une médiane, mais il peut devenir important pour des transformations plus riches, comme une sélection de variables).
Correction : découper d’abord, puis enchaîner les transformations dans un pipeline ajusté sur l’apprentissage seulement.
X_app, X_test, y_app, y_test = train_test_split( df[["Age", "Fare"]], y, test_size=0.25, random_state=0)modele = make_pipeline(SimpleImputer(strategy="median"), StandardScaler(), LogisticRegression())modele.fit(X_app, y_app) # médianes, moyennes et écarts-types appris sur X_appprint(modele.score(X_test, y_test))En code
import numpy as npimport pandas as pdfrom sklearn.impute import SimpleImputerfrom sklearn.linear_model import LogisticRegressionfrom sklearn.model_selection import train_test_splitfrom sklearn.pipeline import make_pipelinefrom sklearn.preprocessing import StandardScaler
# Données simulées façon Titanic : 20 % d'âges manquants, survie liée au prixrng = np.random.default_rng(0)n = 891df = pd.DataFrame({"Age": rng.uniform(1, 80, n), "Fare": rng.lognormal(3, 1, n)})y = (rng.random(n) < 1 / (1 + np.exp(-(np.log(df["Fare"]) - 3)))).astype(int)df.loc[rng.random(n) < 0.2, "Age"] = np.nan
# Version corrigée : on découpe d'abord, la préparation est apprise sur X_app seulX_app, X_test, y_app, y_test = train_test_split(df, y, test_size=0.25, random_state=0)modele = make_pipeline(SimpleImputer(strategy="median"), StandardScaler(), LogisticRegression())modele.fit(X_app, y_app)print(round(modele.score(X_test, y_test), 3)) # 0.691
# Les paramètres appris ne sont pas ceux du jeu complet : c'est ce qui fuyaitprint(modele[0].statistics_.round(2)) # [44.25 19.63] : médianes sur X_appprint(df.median().round(2).tolist()) # [43.17, 19.74] : médianes sur tout le jeu# Données simulées façon Titanic : 20 % d'âges manquants, survie liée au prixset.seed(0)n <- 891df <- data.frame(Age = runif(n, 1, 80), Fare = rlnorm(n, 3, 1))df$y <- as.integer(runif(n) < plogis(log(df$Fare) - 3))df$Age[runif(n) < 0.2] <- NA
# On découpe d'abordidx_app <- sample(n, round(0.75 * n))app <- df[idx_app, ]; test <- df[-idx_app, ]
# Tout ce qui s'apprend (médianes, moyennes, écarts-types) vient de app seulmedianes <- sapply(app[c("Age", "Fare")], median, na.rm = TRUE)preparer <- function(d) { for (v in c("Age", "Fare")) d[[v]][is.na(d[[v]])] <- medianes[[v]] d}app <- preparer(app); test <- preparer(test)centres <- colMeans(app[c("Age", "Fare")]); echelles <- sapply(app[c("Age", "Fare")], sd)app[c("Age", "Fare")] <- scale(app[c("Age", "Fare")], centres, echelles)test[c("Age", "Fare")] <- scale(test[c("Age", "Fare")], centres, echelles) # mêmes paramètres
modele <- glm(y ~ Age + Fare, family = binomial, data = app)mean((predict(modele, test, type = "response") > 0.5) == test$y) # 0.628 : exactitude en test