Aller au contenu

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.

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 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 colonnes
pd.get_dummies(df["Embarked"], prefix="Embarked", drop_first=True) # 2 colonnes

Pourquoi 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 (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.

C = 0Q = 1S = 2112 : « deux fois plus loin »Q se retrouve « entre » C et Sencodage ordinal : un ordre inventé√2√2√2C(1, 0, 0)Q(0, 1, 0)S(0, 0, 1)one-hot : trois ports équidistants
L'encodage ordinal place Q entre C et S et met S deux fois plus loin de C que Q. En one-hot, les trois ports sont à la même distance les uns des autres : aucun ordre n'est inventé.

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 [min⁡,max⁡][\min, \max] 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).
6 passagers3 passagers1 passager2854largeur égale (pd.cut) : intervalles de 26 ans01020304050607080âge (années)4 passagers3 passagers3 passagers2235fréquence égale (pd.qcut) : coupures aux tiers
Même données, deux découpages : la largeur égale laisse le passager de 80 ans seul dans son intervalle, la fréquence égale équilibre les effectifs au prix d'intervalles très inégaux.

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 longueur
df["age_frequence"] = pd.qcut(df["Age"], q=3) # intervalles de même effectif

Deux transformations dominent.

La normalisation min-max ramène chaque variable dans [0,1][0, 1] :

x′=x−xmin⁡xmax⁡−xmin⁡x' = \frac{x - x_{\min}}{x_{\max} - x_{\min}}

où xmin⁡x_{\min} et xmax⁡x_{\max} 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 :

z=x−μσz = \frac{x - \mu}{\sigma}

où μ\mu est la moyenne et σ\sigma 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.

0,00,20,40,60,81,09 billets entre 0,00 et 0,181,00min-max−0,50,00,51,01,52,02,53,09 billets entre −0,47 et 0,142,95standardisation024681012valeur transformée (échelles différentes sur chaque ligne)9 billets entre −0,24 et 2,0912,76RobustScaler
Avec min-max, les neuf billets ordinaires occupent moins d'un cinquième de l'intervalle. RobustScaler, calé sur la médiane et l'écart interquartile, laisse les billets ordinaires s'étaler et repousse seul l'extrême.

La transformation logarithmique y=ln⁡(x)y = \ln(x) 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 y=ln⁡(1+x)y = \ln(1 + x) pour éviter ln⁡(0)\ln(0), 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.

0501001502002507,25263,00prix brut (livres) : 263 est 36 fois le plus petit01234562,115,58ln(1 + prix) : il n’est plus que 2,6 fois le plus petit
Le logarithme resserre les grandes valeurs : le billet à 263 reste le plus grand, mais il ne tasse plus les neuf autres dans un coin.
import numpy as np
df["log_fare"] = np.log1p(df["Fare"]) # ln(1 + x), défini même quand Fare vaut 0

Pour finir, voici un récapitulatif par type de variable :

TypeTransformations usuelles
Numériquevaleurs manquantes, valeurs aberrantes, mise à l’échelle, logarithme, discrétisation
Catégorielvaleurs manquantes, choix de l’encodage (one-hot ou ordinal)
Textenettoyage (minuscules, accents, caractères spéciaux), vectorisation
Temporelconversion en date, extraction de l’année, du mois, du jour, de l’heure

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_transformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score, train_test_split
from sklearn.pipeline import make_pipeline
from 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éparation
modele.fit(X_app, y_app) # tout est ajusté sur l'apprentissage
print(modele.score(X_test, y_test)) # le test ne sert qu'une fois

Le 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.

donnéescomplètesapprentissage(75 %)test(25 %)fitmédiane, μ, σ,modalitésparamètres appristransform→ modèle.fittransform→ score finalappliquer tel quel✕fuite :le test ne doitjamais servir au fit
Seul le jeu d'apprentissage sert au fit ; le test est transformé avec les paramètres déjà appris, tels quels. Toute flèche du test vers le fit est une fuite de données.
  • L’encodage one-hot crée une colonne 0/1 par modalité (drop_first=True retire 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 [0,1][0, 1] 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 (ln⁡(1+x)\ln(1 + x) si xx 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.

Exercice · Normaliser et standardiser à la main

facile

Une 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 nn, comme le fait StandardScaler).

Voir la solution

Min-max : xmin⁡=2x_{\min} = 2 et xmax⁡=8x_{\max} = 8, donc x′=(x−2)/6x' = (x - 2)/6. On obtient 00, 1/3≈0,3331/3 \approx 0{,}333, 2/3≈0,6672/3 \approx 0{,}667 et 11.

Standardisation : la moyenne vaut μ=5\mu = 5. Les écarts à la moyenne sont −3,−1,1,3-3, -1, 1, 3, de carrés 9,1,1,99, 1, 1, 9, dont la moyenne est 20/4=520/4 = 5. Donc σ=5≈2,236\sigma = \sqrt{5} \approx 2{,}236. Les valeurs standardisées sont −3/2,236≈−1,342-3/2{,}236 \approx -1{,}342, puis −0,447-0{,}447, 0,4470{,}447 et 1,3421{,}342. Leur moyenne est bien 0 et leur écart-type 1.

En code

import numpy as np
from 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 n
print(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.0

Exercice · Choisir un encodage

moyen

Pour 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 np
import pandas as pd
# (a) Diplôme ordinal : un entier qui respecte l'ordre
ordre = {"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és
d35, d36, d29 = 35, 36, 29 # Ille-et-Vilaine, Indre, Finistère
print(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 distance
transport = 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

Exercice · Repérer une fuite de données

difficile

Un 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_app
print(modele.score(X_test, y_test))

En code

import numpy as np
import pandas as pd
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
# Données simulées façon Titanic : 20 % d'âges manquants, survie liée au prix
rng = np.random.default_rng(0)
n = 891
df = 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 seul
X_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 fuyait
print(modele[0].statistics_.round(2)) # [44.25 19.63] : médianes sur X_app
print(df.median().round(2).tolist()) # [43.17, 19.74] : médianes sur tout le jeu