Construire des Pipelines Robustes pour le Traitement des Données et la Modélisation avec Scikit-learn

L'exploration de données, ou data mining, englobe généralement plusieurs étapes, telles que l'acquisition des données, l'analyse exploratoire, l'ingénierie des caractéristiques, l'entraînement du modèle et son évaluation. La bibliothèque Scikit-learn (sklearn) en Python se révèle être un outil exceptionnel pour rationaliser l'ingénierie des caractéristiques et l'entraînement des modèles, offrant une interface élégante et cohérente.

Dans sklearn, les classes dédiées à la transformation des caractéristiques et à l'entraînement des modèles partagent une philosophie de conception commune, centrée autour des méthodes fit et transform (ou predict pour les modèles). La méthode fit_transform, souvent présente, est une optimisation qui combine l'appel à fit puis à transform.

La méthode transform est dédiée à la modification des caractéristiques. Ces transformations peuvent être classées selon l'information qu'elles utilisent :

  • Transformations sans information : Elles ne nécessitent aucune donnée supplémentaire pour opérer, comme l'application d'une fonction logarithmique ou exponentielle.
  • Transformations avec information : Elles s'appuient sur des statistiques ou des propriétés dérivées des données.
    • Non supervisées : Elles utilisent uniquement les statistiques des caractéristiques (moyenne, écart-type, bornes, etc.), comme la standardisation (centrage-réduction) ou la réduction de dimensionnalité via ACP (Analyse en Composantes Principales).
    • Supervisées : Elles exploitent à la fois les caractéristiques et la variable cible. Des exemples incluent la sélection de caractéristiques basée sur un modèle ou la réduction de dimensionnalité via ADL (Analyse Discriminante Linéaire).

Le tableau suivant récapitule quelques transformateurs courants dans sklearn et indique si leur méthode fit est activement utilisée pour extraire des inforamtions :

Module Classe Paramètres fit Catégorie fit utile ? Description
sklearn.preprocessing StandardScaler Caractéristiques Non supervisée Oui Standardisation des caractéristiques
sklearn.preprocessing MinMaxScaler Caractéristiques Non supervisée Oui Mise à l'échelle dans un intervalle
sklearn.preprocessing Normalizer Caractéristiques Sans information Non Normalisation (norme unitaire)
sklearn.preprocessing Binarizer Caractéristiques Sans information Non Binarisation des valeurs numériques
sklearn.preprocessing OneHotEncoder Caractéristiques Non supervisée Oui Encodage One-Hot des caractéristiques catégorielles
sklearn.impute SimpleImputer Caractéristiques Non supervisée Oui Gestion des valeurs manquantes
sklearn.preprocessing PolynomialFeatures Caractéristiques Sans information Non Génération de caractéristiques polynomiales
sklearn.preprocessing FunctionTransformer Caractéristiques Sans information Non Application d'une fonction personnalisée
sklearn.feature_selection VarianceThreshold Caractéristiques Non supervisée Oui Sélection par variance
sklearn.feature_selection SelectKBest Caract./Caract.+Cible Non supervisée/Supervisée Oui Sélection des K meilleures caractéristiques
sklearn.feature_selection RFE Caract.+Cible Supervisée Oui Élimination récursive de caractéristiques
sklearn.feature_selection SelectFromModel Caract.+Cible Supervisée Oui Sélection basée sur l'importance des caractéristiques d'un modèle
sklearn.decomposition PCA Caractéristiques Non supervisée Oui Réduction de dimensionnalité par ACP
sklearn.discriminant_analysis LinearDiscriminantAnalysis Caract.+Cible Supervisée Oui Réduction de dimensionnalité par ADL

Il est clair que la méthode fit n'est réellement utile que pour les transformations qui nécessitent d'apprendre des propriétés des données. Son rôle est alors d'extraire des informations pertinentes (comme la moyenne et l'écart-type pour la standardisation) ou d'ajuster des paramètres (comme les poids pour un modèle de sélection de caractéristiques). Cette similitude conceptuelle entre le fit d'un transformateur et le fit d'un modèle d'apprentissage est au cœur de la cohérence de sklearn.

Cette uniformité de l'API permet de combiner ces étapes de manière structurée. Les opérations peuvent être enchaînées séquentiellement (un pipeline où la sortie d'une étape est l'entrée de la suivante) ou en parallèle (où plusieurs opérations reçoivent la même entrée et leurs sorties sont ensuite concaténées). Le module pipeline de sklearn est conçu pour faciliter ces compositions.

Préparation des Données d'Exemple

Pour illustrer ces concepts, nous allons utiliser le célèbre jeu de données Iris, en y ajoutant quelques modifications pour simuler des scénarios d'ingénierie de caractéristiques plus complexes : des valeurs manquantes et une caractéristique catégorielle supplémentaire.

import numpy as np
from sklearn.datasets import load_iris

# Charger le dataset Iris
donnees_iris = load_iris()
X_original = donnees_iris.data
y_original = donnees_iris.target

# Augmenter les données pour l'exemple
# Ajout d'une ligne avec des valeurs manquantes (NaN)
ligne_manquante = np.array([np.nan, np.nan, np.nan, np.nan]).reshape(1, -1)
X_modifie = np.vstack((X_original, ligne_manquante))

# Ajout d'une colonne pour la couleur de la fleur (0, 1, 2)
# La couleur est aléatoire et n'influence pas la classification pour l'exemple
couleurs_aleatoires = np.random.choice([0, 1, 2], size=X_modifie.shape[0]).reshape(-1, 1)
X_modifie = np.hstack((couleurs_aleatoires, X_modifie))

# Ajout d'une cible correspondant à la ligne manquante (ici, la médiane des cibles originales)
y_modifie = np.hstack((y_original, np.array([np.median(y_original)])))

# Mettre à jour les attributs de l'objet iris
donnees_iris.data = X_modifie
donnees_iris.target = y_modifie

print(f"Nouvelles dimensions des caractéristiques (X) : {donnees_iris.data.shape}")
print(f"Nouvelles dimensions de la cible (y) : {donnees_iris.target.shape}")

Techniques Clés

Les piliers d'une utilisation élégante de sklearn pour l'exploration de données sont le traitement parallèle, les pipelines séquentiels, l'optimisation automatique des hyperparamètres et la persistance des modèles. Ces techniques permettent de combiner diverses opérations d'ingénierie de caractéristiques et d'entraînement en un flux de travail unique et cohérent, de réduire la complexité de l'ajustement manuel et de sauvegarder les modèles entraînés pour une réutilisation ultérieure.

Traitement Parallèle des Caractéristiques

Le traitement parallèle permet d'appliquer plusieurs transformations indépendamment sur les mêmes données d'entrée et de fusionner leurs résultats. Selon la manière dont les caractéristiques sont alimentées à chaque transformateur, on peut distinguer le traitement parallèle global ou partiel.

Traitement Parallèle Global

La classe FeatureUnion de sklearn.pipeline permet d'effectuer des traitements parallèles où chaque transformateur reçoit l'intégralité de la matrice de caractéristiques en entrée. Les sorties de tous les transformateurs sont ensuite concaténées horizontalement.

from numpy import log1p
from sklearn.preprocessing import FunctionTransformer, Binarizer
from sklearn.pipeline import FeatureUnion

# Création d'un transformateur pour appliquer la fonction log1p à l'ensemble des caractéristiques
transformation_log = ('log_transform', FunctionTransformer(log1p, validate=False)) # validate=False pour fonctionner avec des NaN potentiels

# Création d'un binariseur pour l'ensemble des caractéristiques
binariseur_global = ('binarizer_global', Binarizer(threshold=0.5))

# Combinaison parallèle des transformateurs
# La méthode fit/transform de FeatureUnion appellera en parallèle les méthodes correspondantes
# de chaque transformateur, puis concaténera leurs sorties.
union_globale = ('union_features_globale', FeatureUnion(transformer_list=[
    transformation_log,
    binariseur_global
]))

# Exemple d'utilisation (non exécuté ici, juste pour illustration)
# X_transforme = union_globale.fit_transform(donnees_iris.data)
# print(f"Dimensions après union globale : {X_transforme.shape}")

Traitement Parallèle Partiel sur des Colonnes Spécifiques

Le FeatureUnion standard ne permet pas de spécifier des sous-ensembles de colonnes différentes pour chaque transformateur. Dans de nombreux scénarios, nous souhaitons appliquer des transformations spécifiques à certaines colonnes seulement. Pour cela, nous pouvons étendre la classe FeatureUnion pour y intégrer cette fonctionnalité de sélection de colonnes.

import numpy as np
from scipy import sparse
from sklearn.pipeline import FeatureUnion
from joblib import Parallel, delayed # Utilisation de joblib directement

class UnionCaracteristiquesPartielles(FeatureUnion):
    """
    Extension de FeatureUnion permettant d'appliquer des transformateurs en parallèle
    sur des sous-ensembles spécifiques de colonnes de la matrice de caractéristiques.
    """
    def __init__(self, transformateurs, indices_colonnes, n_jobs=None, poids_transformateurs=None):
        self.indices_colonnes = indices_colonnes
        # La liste de transformateurs est passée au constructeur parent
        liste_transformateurs_base = [(nom, obj) for nom, obj in transformateurs]
        super().__init__(transformer_list=liste_transformateurs_base, n_jobs=n_jobs,
                         transformer_weights=poids_transformateurs)

    def fit(self, X_data, y_target=None):
        """
        Adapte chaque transformateur sur le sous-ensemble de données spécifié par ses indices de colonnes.
        """
        toutes_les_operations = [
            (nom_op, op_obj, idx_cols)
            for (nom_op, op_obj), idx_cols in zip(self.transformer_list, self.indices_colonnes)
        ]

        # Exécuter l'adaptation en parallèle pour chaque transformateur sur ses colonnes dédiées
        transformateurs_ajustes = Parallel(n_jobs=self.n_jobs)(
            delayed(lambda tr, X_sub, y_t: tr.fit(X_sub, y_t))(transformateur_obj, X_data[:, indices], y_target)
            for nom, transformateur_obj, indices in toutes_les_operations
        )
        # Mettre à jour la liste des transformateurs ajustés dans l'objet parent
        self._update_transformer_list(list(zip([n for n, _ in self.transformer_list], transformateurs_ajustes)))
        return self

    def transform(self, X_data):
        """
        Applique la transformation de chaque transformateur sur son sous-ensemble de données.
        """
        toutes_les_operations = [
            (nom_op, op_obj, idx_cols)
            for (nom_op, op_obj), idx_cols in zip(self.transformer_list, self.indices_colonnes)
        ]

        # Exécuter la transformation en parallèle
        resultats_transformation = Parallel(n_jobs=self.n_jobs)(
            delayed(lambda tr, X_sub: tr.transform(X_sub))(transformateur_obj, X_data[:, indices])
            for nom, transformateur_obj, indices in toutes_les_operations
        )

        # Concaténer les résultats horizontalement. Gère les matrices creuses.
        if any(sparse.issparse(f) for f in resultats_transformation):
            return sparse.hstack(resultats_transformation).tocsr()
        else:
            return np.hstack(resultats_transformation)

    def fit_transform(self, X_data, y_target=None, **fit_params):
        """
        Adapte et transforme simultanément en parallèle.
        """
        toutes_les_operations = [
            (nom_op, op_obj, idx_cols)
            for (nom_op, op_obj), idx_cols in zip(self.transformer_list, self.indices_colonnes)
        ]

        # Exécute fit et transform pour chaque opération en parallèle
        resultats_parallel = Parallel(n_jobs=self.n_jobs)(
            delayed(lambda tr, X_sub, y_t: (tr.fit(X_sub, y_t), tr.transform(X_sub)))(transformateur_obj, X_data[:, indices], y_target)
            for nom, transformateur_obj, indices in toutes_les_operations
        )

        transformateurs_ajustes, Xs = zip(*resultats_parallel)
        self._update_transformer_list(list(zip([n for n, _ in self.transformer_list], transformateurs_ajustes)))

        if any(sparse.issparse(f) for f in Xs):
            return sparse.hstack(Xs).tocsr()
        else:
            return np.hstack(Xs)

Dans notre scénario modifié du jeu de données Iris, la première colonne représente la couleur de la fleur (catégorielle), les colonnes 1 à 3 sont des caractéristiques numériques (longueur/largeur sépale/pétale), et la dernière colonne (la 4e dans l'indexation 0-basée) est une caractéristique numérique simple. Nous voulons appliquer des transformations différentes : un encodage One-Hot pour la couleur, une transformation logarithmique pour les caractéristiques continues, et une binarisation pour la dernière caractéristique.

from sklearn.preprocessing import OneHotEncoder, FunctionTransformer, Binarizer
from numpy import log1p

# Transformateur pour la colonne de couleur (index 0)
encodeur_couleur = ('encodeur_couleur', OneHotEncoder(sparse_output=False, handle_unknown='ignore'))
# Transformateur pour les caractéristiques continues (indices 1, 2, 3)
transformateur_log_continu = ('log_caracteristiques', FunctionTransformer(log1p, validate=False))
# Transformateur pour la dernière caractéristique (indice 4)
binariseur_derniere_carac = ('binariseur_caracteristique', Binarizer(threshold=1.5)) # Seuil ajusté pour l'exemple

# Instanciation de l'union partielle avec les transformateurs et leurs colonnes cibles
union_caracteristiques = ('union_caracteristiques', UnionCaracteristiquesPartielles(
    transformateurs=[encodeur_couleur, transformateur_log_continu, binariseur_derniere_carac],
    indices_colonnes=[[0], [1, 2, 3], [4]] # Indices des colonnes pour chaque transformateur
))

Pipelines Séquentiels

La classe Pipeline de sklearn.pipeline permet d'enchaîner les opérations de manière séquentielle. Chaque étape du pipeline (sauf la dernière) exécute fit_transform, passant sa sortie comme entrée à l'étape suivante. La dernière étape doit implémenter fit (pour l'entraînement) et peut optionnellement implémenter transform (si c'est une transformation finale) ou predict (si c'est un modèle prédictif).

Combinons l'imputation des valeurs manquantes, notre UnionCaracteristiquesPartielles, la mise à l'échelle, la sélection de caractéristiques, la réduction de dimensionnalité et un modèle de régression logistique en un seul pipeline cohérent :

from sklearn.impute import SimpleImputer
from sklearn.preprocessing import MinMaxScaler
from sklearn.feature_selection import SelectKBest, chi2
from sklearn.decomposition import PCA
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline

# Étape 1: Gestion des valeurs manquantes (sur l'ensemble des données d'abord)
etape_imputation = ('imputer_nan', SimpleImputer(strategy='median'))

# Étape 2: Traitement parallèle et spécifique des caractéristiques (déjà défini ci-dessus)
# union_caracteristiques = ... (code précédent)

# Étape 3: Mise à l'échelle des caractéristiques résultantes
etape_mise_echelle = ('mise_echelle_minmax', MinMaxScaler())

# Étape 4: Sélection de caractéristiques par test du chi-carré
# Le chi2 nécessite des valeurs non négatives, assurez-vous que les étapes précédentes le garantissent.
etape_selection_carac = ('selection_chi2', SelectKBest(chi2, k=3))

# Étape 5: Réduction de dimensionnalité via PCA
etape_pca = ('reduction_pca', PCA(n_components=2))

# Étape 6: Modèle d'apprentissage final
modele_classification = ('regression_logistique', LogisticRegression(max_iter=1000, solver='liblinear'))

# Construction du pipeline complet
pipeline_final = Pipeline(steps=[
    etape_imputation,
    union_caracteristiques, # Notre union personnalisée
    etape_mise_echelle,
    etape_selection_carac,
    etape_pca,
    modele_classification
])

Optimisation Automatique des Hyperparamètres

L'ajustement des hyperparamètres est une tâche cruciale et souvent fastidieuse. Scikit-learn propose des outils comme GridSearchCV pour automatiser ce processus par recherche sur grille. Cet outil permet d'explorer systématiquement un ensemble prédéfini de combinaisons d'hyperparamètres et de trouver la meilleure configuration pour un modèle ou un pipeline.

from sklearn.model_selection import GridSearchCV

# Définition de la grille de paramètres à explorer pour notre pipeline
# Le format est "nom_etape__nom_sous_etape__parametre"
grille_parametres = {
    # Ajustement du seuil du binariseur dans l'union de caractéristiques partielles
    'union_caracteristiques__binariseur_caracteristique__threshold': [0.5, 1.0, 2.0],
    # Ajustement du paramètre de régularisation C de la régression logistique
    'regression_logistique__C': [0.01, 0.1, 1.0, 10.0]
}

# Instanciation de l'objet de recherche sur grille
# Le premier argument est l'estimateur (notre pipeline), le second est la grille de paramètres
recherche_grille = GridSearchCV(pipeline_final, grille_parametres, cv=3, verbose=1, n_jobs=-1)

# Lancer la recherche et l'entraînement
# Cela va entraîner le pipeline pour chaque combinaison d'hyperparamètres et évaluer leurs performances.
recherche_grille.fit(donnees_iris.data, donnees_iris.target)

print(f"Meilleurs hyperparamètres trouvés : {recherche_grille.best_params_}")
print(f"Meilleur score de validation croisée : {recherche_grille.best_score_:.3f}")

# Le meilleur estimateur ajusté est accessible via .best_estimator_
meilleur_pipeline = recherche_grille.best_estimator_

Persistance des Modèles

Après un entraînement long et une optimisation poussée, il est essentiel de pouvoir sauvegarder le modèle entraîné pour une utilisation future sans avoir à le ré-entraîner. La bibliothèque joblib (souvent incluse avec sklearn) offre des fonctions efficaces pour la sérialisation et la désérialisation d'objets Python, y compris les modèles sklearn.

from joblib import dump, load

# Sauvegarde de l'objet de recherche sur grille (qui contient le meilleur pipeline entraîné)
# Le paramètre 'compress' permet de spécifier le niveau de compression (0 pour pas de compression, 3 est un bon compromis)
chemin_fichier_modele = 'modele_pipeline_optimise.joblib'
dump(recherche_grille, chemin_fichier_modele, compress=3)
print(f"Modèle sauvegardé sous : {chemin_fichier_modele}")

# Chargement du modèle depuis le fichier
modele_charge = load(chemin_fichier_modele)
print(f"Modèle chargé. Meilleurs paramètres : {modele_charge.best_params_}")

# Utilisation du modèle chargé pour faire des prédictions
# predictions = modele_charge.predict(donnees_test)

Il est important de noter que la persistance via joblib (qui utilise la sérialisation Pickle) peut rencontrer des limitations avec des objets complexes ou des fonctions définies par lambda, qui peuvent ne pas être sérialisables.

Récapitulatif des Outils Clés

Module Classe ou Fonction Description
sklearn.pipeline Pipeline Construction de chaînes de transformateurs et de modèles
sklearn.pipeline FeatureUnion Combinaison parallèle de transformateurs
sklearn.model_selection GridSearchCV Recherche sur grille pour l'optimisation des hyperparamètres
joblib dump Persistance des objets Python vers un fichier
joblib load Chargement des objets Python depuis un fichier

Étiquettes: Scikit-learn Machine Learning Pipelines Feature Engineering Hyperparameter Tuning Model Persistence

Publié le 7 août à 00h11