Le feature engineering est la transformation des données brutes en signaux exploitables par un algorithme. Sur des données tabulaires, c’est la phase qui sépare un projet médiocre d’un projet performant — beaucoup plus que le choix de l’algorithme lui-même. scikit-learn fournit une boîte à outils standardisée pour cela : ColumnTransformer applique des transformations différentes par colonne, Pipeline chaîne preprocessing et modèle dans un objet unique, et set_output(transform="pandas") conserve les noms de colonnes en sortie.
Pour le contexte général, voir le guide principal sur la stack data 2026. Ce tutoriel construit pas-à-pas un pipeline reproductible — depuis le DataFrame nettoyé jusqu’à un objet prêt à entraîner et à sérialiser.
Prérequis
- Python 3.10 ou supérieur
- pandas 3.0+, scikit-learn 1.8+, numpy
- Avoir nettoyé son jeu de données — voir le tutoriel de nettoyage
- Connaissance des bases du machine learning supervisé (variables, cible, train/test)
- Temps estimé : 90 minutes
Étape 1 — Charger les données et séparer X et y
Avant tout, on charge le DataFrame nettoyé et on isole la variable cible. La séparation X (features) / y (cible) doit être faite tôt et de manière propre, parce que toute fuite de la cible vers les features ruine l’évaluation. Le découpage train/test arrive immédiatement après — et tout calcul ultérieur (médiane, moyenne, encodage) ne doit utiliser que les données d’entraînement.
import pandas as pd
from sklearn.model_selection import train_test_split
df = pd.read_parquet("transactions_clean.parquet")
y = df["paye"] # cible binaire
X = df.drop(columns=["paye", "id_transaction"])
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
print(X_train.shape, X_test.shape)
L’argument stratify=y garantit que la proportion de classes positives et négatives est la même dans le train et dans le test. Sur une classification déséquilibrée (par exemple 5 % de défauts de paiement), un split aléatoire pur peut, par malchance, mettre presque toutes les classes minoritaires d’un côté. random_state=42 rend le split reproductible — la valeur exacte n’a pas d’importance, ce qui compte est de la fixer.
Étape 2 — Construire les features dérivées métier
Avant d’identifier les types de colonnes, on construit les features dérivées issues de la connaissance métier — un ratio panier moyen / nombre d’articles, le délai entre commande et paiement, un indicateur week-end. Le faire maintenant garantit que ces nouvelles colonnes seront prises en compte par le ColumnTransformer de l’étape suivante. Si l’on ajoute ces colonnes après coup, elles tomberont silencieusement dans le remainder="drop" et seront ignorées à l’entraînement.
for X in (X_train, X_test):
X["panier_par_article"] = X["montant"] / X["nb_articles"].clip(lower=1)
X["est_weekend"] = X["date_commande"].dt.weekday >= 5
X["heure_commande"] = X["date_commande"].dt.hour
X.drop(columns=["date_commande"], inplace=True)
Le clip(lower=1) évite la division par zéro qui produirait des infinis et casserait l’entraînement. On retire ensuite la colonne date_commande originale (non utilisable directement par le modèle) puisque les composantes utiles ont été extraites. Pour une version plus propre, on encapsulera cette logique dans une classe héritant de BaseEstimator et TransformerMixin et insérée dans le pipeline — point couvert plus loin.
Étape 3 — Identifier les types de colonnes
Les transformations à appliquer dépendent du type de colonne. Les variables numériques continues nécessitent une normalisation. Les catégorielles à faible cardinalité (moins de 10 modalités) se prêtent bien à un one-hot encoding. Les catégorielles à haute cardinalité (codes postaux, identifiants) nécessitent un encodage cible ou un hashing. Les booléens passent en numérique tels quels. La première étape consiste donc à classer les colonnes par type de traitement.
colonnes_num = X_train.select_dtypes(include=["number", "Float64", "Int64"]).columns.tolist()
colonnes_cat = X_train.select_dtypes(include=["object", "string", "category"]).columns.tolist()
colonnes_bool = X_train.select_dtypes(include=["boolean", "bool"]).columns.tolist()
print("Numériques :", colonnes_num)
print("Catégorielles :", colonnes_cat)
print("Booléennes :", colonnes_bool)
Cette classification automatique fonctionne dans la majorité des cas. Quand elle ne suffit pas (par exemple une colonne code postal au type string mais qu’on veut traiter comme catégorielle à haute cardinalité), on ajuste à la main la liste. Le résultat doit être validé avant de passer à la suite : une colonne mal classée brise tout le pipeline en aval.
Étape 4 — Construire les sous-pipelines
Pour chaque type de colonne, on construit un mini-pipeline qui enchaîne imputation et encodage. L’imputation doit être faite ici, dans le pipeline, et non en amont sur le DataFrame complet — sans cela, on injecte de l’information du test dans le train (data leakage). Pour les numériques, on impute par la médiane et on standardise. Pour les catégorielles, on impute par la modalité la plus fréquente puis on one-hot encode.
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
pipe_num = Pipeline([
("imputer", SimpleImputer(strategy="median")),
("scaler", StandardScaler()),
])
pipe_cat = Pipeline([
("imputer", SimpleImputer(strategy="most_frequent")),
("ohe", OneHotEncoder(handle_unknown="ignore", sparse_output=False)),
])
L’argument handle_unknown="ignore" du OneHotEncoder est crucial pour la production : si une nouvelle modalité apparaît dans le test ou en inférence (une catégorie absente à l’entraînement), le pipeline produira un vecteur de zéros au lieu de planter. sparse_output=False retourne un tableau dense — plus simple à déboguer même si légèrement plus coûteux en mémoire sur des très hautes cardinalités.
Étape 5 — Assembler avec ColumnTransformer
ColumnTransformer applique chaque sous-pipeline aux colonnes correspondantes et concatène les résultats. C’est le cœur de la mécanique : un seul objet qui transforme l’intégralité du DataFrame de manière cohérente, fittable une fois sur le train, applicable ensuite sur n’importe quel jeu de la même structure.
from sklearn.compose import ColumnTransformer
preprocessor = ColumnTransformer(
transformers=[
("num", pipe_num, colonnes_num),
("cat", pipe_cat, colonnes_cat),
("bool", "passthrough", colonnes_bool),
],
remainder="drop",
verbose_feature_names_out=False,
)
preprocessor.set_output(transform="pandas")
L’argument remainder="drop" élimine les colonnes non listées explicitement — c’est un filet de sécurité contre l’oubli. Le set_output(transform="pandas") est l’amélioration clé des versions récentes de scikit-learn : la sortie reste un DataFrame avec des noms de colonnes lisibles, ce qui simplifie énormément le débogage et l’inspection des transformations.
Étape 6 — Fitter et transformer
Une fois le préprocesseur défini, on l’entraîne sur les données de train uniquement. La méthode fit_transform apprend les paramètres (médianes, moyennes, écarts-types, modalités) et applique simultanément la transformation. Sur le test, on appelle ensuite transform seul — surtout pas fit_transform, sous peine de fuite.
X_train_prep = preprocessor.fit_transform(X_train)
X_test_prep = preprocessor.transform(X_test)
print("Shape train :", X_train_prep.shape)
print("Shape test :", X_test_prep.shape)
print("Colonnes :", list(X_train_prep.columns)[:10])
La forme du DataFrame transformé fait apparaître l’effet du one-hot encoding : si la colonne canal_paiement avait quatre modalités, elle devient quatre colonnes canal_paiement_carte, canal_paiement_especes, etc. Les noms générés permettent de retrouver immédiatement quelle colonne correspond à quoi, ce qui est indispensable pour interpréter ensuite les coefficients d’un modèle linéaire ou les importances d’un arbre.
Étape 7 — Encodage des variables à haute cardinalité
Le one-hot encoding explose en mémoire dès que la cardinalité dépasse quelques dizaines. Pour une colonne code postal avec 5000 valeurs uniques, créer 5000 colonnes serait absurde. Deux alternatives standards : le target encoding (remplacer la modalité par la moyenne de la cible dans cette modalité, calculée uniquement sur le train) et le hashing trick (hacher la modalité dans un nombre fixe de seaux).
from sklearn.preprocessing import TargetEncoder
# Sur la colonne code_postal à haute cardinalité
encoder_cible = TargetEncoder(target_type="binary", smooth="auto", random_state=42)
encoder_cible.fit(X_train[["code_postal"]], y_train)
X_train_cp = encoder_cible.transform(X_train[["code_postal"]])
X_test_cp = encoder_cible.transform(X_test[["code_postal"]])
print(X_train_cp.head())
TargetEncoder a été ajouté à scikit-learn en 1.3 et stabilisé depuis. Le smooth="auto" applique automatiquement un lissage bayésien qui réduit la sensibilité aux modalités rares (une modalité vue trois fois ne doit pas avoir un encodage à 1.0 simplement parce que les trois exemples étaient positifs). C’est exactement le piège que le target encoding naïf produit, et qu’un débutant rate régulièrement.
Étape 8 — Encapsuler les features dérivées dans un transformer
L’étape 2 a calculé les features dérivées en imperatif. C’est rapide à écrire mais cela disperse la logique entre le DataFrame source et le pipeline scikit-learn. Une approche plus propre consiste à encapsuler ces transformations dans une classe qui hérite de BaseEstimator et TransformerMixin, et à l’insérer en tête du pipeline. Avantage : le pipeline devient totalement autonome et reproductible — il prend un DataFrame brut en entrée et produit la prédiction.
from sklearn.base import BaseEstimator, TransformerMixin
class FeaturesMetier(BaseEstimator, TransformerMixin):
def fit(self, X, y=None):
return self
def transform(self, X):
X = X.copy()
X["panier_par_article"] = X["montant"] / X["nb_articles"].clip(lower=1)
if "date_commande" in X.columns:
X["est_weekend"] = X["date_commande"].dt.weekday >= 5
X["heure_commande"] = X["date_commande"].dt.hour
X = X.drop(columns=["date_commande"])
return X
# Pipeline complet : features métier → préprocesseur → modèle
from sklearn.linear_model import LogisticRegression
modele_complet = Pipeline([
("features", FeaturesMetier()),
("preprocessor", preprocessor),
("classifier", LogisticRegression(max_iter=1000, random_state=42)),
])
Avec ce pattern, on n’a plus besoin de modifier X_train/X_test à la main : le pipeline accepte le DataFrame brut, applique les features dérivées en interne, puis le préprocesseur, puis le modèle. La sérialisation jointe (joblib) capture tout l’état, et la prédiction en production se résume à modele_complet.predict(df_brut). C’est cette forme qu’on retient pour un projet sérieux.
Étape 9 — Entraîner le pipeline complet
L’objectif final est d’obtenir un seul objet qui contient à la fois les features métier, le preprocessing et le modèle, et qui peut être sérialisé d’un coup avec joblib. Cette approche évite l’erreur classique consistant à sérialiser séparément le préprocesseur et le modèle, puis à les recharger dans le mauvais ordre en production. Un seul fichier, une seule responsabilité.
# On utilise le modele_complet construit à l'étape 8
modele_complet.fit(X_train, y_train)
print("Score train :", round(modele_complet.score(X_train, y_train), 3))
print("Score test :", round(modele_complet.score(X_test, y_test), 3))
L’objet modele_complet est désormais un pipeline complet. Pour l’inférence, on lui passe directement un DataFrame brut au format de X_train et il s’occupe de tout. Le score d’entraînement et de test donne une première indication ; la différence entre les deux signale un éventuel sur-apprentissage. Pour une évaluation rigoureuse, voir le tutoriel sur la régression et la classification.
Étape 10 — Sauvegarder le pipeline
Pour mettre le modèle en production ou simplement pour ne pas avoir à le ré-entraîner à chaque fois, on le sérialise avec joblib. Le format est binaire et conserve l’objet exact, y compris les paramètres appris. Une seule ligne suffit. Le fichier produit pèse de quelques centaines de Ko à quelques Mo selon la taille du modèle.
import joblib
joblib.dump(modele_complet, "model_paiement.joblib", compress=3)
# Rechargement
modele_charge = joblib.load("model_paiement.joblib")
prediction = modele_charge.predict(X_test.head(5))
print(prediction)
L’option compress=3 applique une compression légère qui réduit le fichier de 30 à 50 % sans surcoût notable de temps. Le rechargement reproduit exactement l’objet original. C’est ce fichier qu’on glissera dans une application FastAPI pour exposer une API d’inférence — voir le tutoriel FastAPI.
Étape 11 — Inspecter ce que le pipeline a appris
Un pipeline opaque est un risque. On veut pouvoir vérifier ce qu’il a appris : quelles modalités existent dans l’encodeur, quels paramètres dans le scaler, quels coefficients dans le modèle. scikit-learn expose tous ces objets via les noms qu’on a donnés aux étapes du pipeline.
# Modalités apprises pour les variables catégorielles
ohe = modele_complet.named_steps["preprocessor"].named_transformers_["cat"].named_steps["ohe"]
print("Modalités canal_paiement :", ohe.categories_[colonnes_cat.index("canal_paiement")])
# Médianes apprises pour l'imputation numérique
imp = modele_complet.named_steps["preprocessor"].named_transformers_["num"].named_steps["imputer"]
print("Médianes :", dict(zip(colonnes_num, imp.statistics_)))
Cette inspection permet de répondre rapidement à des questions de débogage en production : « pourquoi cette prédiction ? » se résout souvent en regardant comment l’entrée a été transformée à chaque étape. C’est aussi un excellent garde-fou contre les régressions silencieuses lors d’un ré-entraînement : si les médianes changent du tout au tout entre deux versions, c’est un signal d’alerte.
Erreurs fréquentes
| Erreur | Cause | Solution |
|---|---|---|
| Data leakage par scaling sur tout le jeu | StandardScaler appliqué avant le split | Toujours encapsuler dans un Pipeline, fitter sur X_train uniquement |
| Modalité inconnue plante en production | OneHotEncoder par défaut lève une exception | handle_unknown= »ignore » |
| Mémoire saturée sur OneHotEncoder | Variable à très haute cardinalité | Passer à TargetEncoder ou HashingVectorizer |
| Noms de colonnes perdus en sortie | set_output non configuré | preprocessor.set_output(transform= »pandas ») |
| Sérialisation casse au rechargement | Versions scikit-learn différentes | Figer la version dans requirements.txt |
| Imputation par moyenne biaise les outliers | Distribution asymétrique | strategy= »median » |
| Pipeline trop lent à fitter | OHE sparse converti en dense partout | sparse_output=True pour les modèles linéaires qui le supportent |
Tutoriels associés
- Nettoyer un jeu de données réel pas-à-pas
- Régression et classification avec scikit-learn
- 🔝 Retour au guide principal : Sciences de données : la stack pratique 2026
Ressources officielles
FAQ
Faut-il toujours standardiser les variables numériques ?
Pour les modèles linéaires (régression logistique, SVM, k-NN) : oui, sous peine de résultats faussés. Pour les modèles à arbres (Random Forest, Gradient Boosting) : non, ils sont insensibles à l’échelle. Standardiser ne nuit pas mais ne sert à rien dans ce cas.
One-hot ou ordinal encoding pour les catégorielles ?
One-hot pour les modèles linéaires et les arbres. Ordinal seulement quand la variable a un ordre naturel (taille XS/S/M/L/XL). Encoder ordinal une variable sans ordre induit le modèle en erreur.
À quoi sert verbose_feature_names_out ?
Quand il vaut True (défaut), les noms en sortie sont préfixés par le nom du transformer (« num__age », « cat__canal »). À False, on garde les noms originaux, plus lisibles mais avec un risque de collision si deux transformers produisent la même colonne.
Comment ajouter une transformation maison ?
En créant une classe qui hérite de BaseEstimator et TransformerMixin, et qui implémente fit et transform. L’objet s’insère ensuite dans un Pipeline comme n’importe quel transformer scikit-learn natif.