ITSkillsCenter
Blog

Régression et classification avec scikit-learn : cross-val et GridSearchCV pas à pas

13 min de lecture

Une fois les données nettoyées et le pipeline de feature engineering en place, vient l’étape qui captive tout le monde : entraîner et régler un modèle. La tentation est de tester immédiatement dix algorithmes et de garder celui qui sort la meilleure métrique sur le test. C’est exactement la mauvaise approche : sans validation croisée et sans réglage rigoureux des hyperparamètres, le résultat n’a pas de valeur statistique. scikit-learn fournit les outils pour faire ça proprement, et ils tiennent en quelques fonctions clés : cross_val_score, StratifiedKFold, GridSearchCV.

Pour le contexte global, voir le guide principal sur la stack data 2026. Ce tutoriel construit pas-à-pas un modèle de classification et un modèle de régression, avec validation croisée et recherche sur grille d’hyperparamètres.

Prérequis

  • Python 3.10+, scikit-learn 1.8+, pandas 3.0+, numpy
  • Avoir un pipeline de feature engineering — voir le tutoriel feature engineering
  • Connaissance des concepts : surapprentissage, validation croisée, hyperparamètres
  • Temps estimé : 2 heures

Étape 1 — Choisir la métrique avant l’algorithme

L’erreur la plus fréquente consiste à entraîner d’abord et à choisir la métrique ensuite. C’est l’inverse qu’il faut faire. Sur une classification déséquilibrée (5 % de défauts de paiement par exemple), maximiser l’accuracy conduit le modèle à toujours prédire « pas de défaut » : il atteint 95 % de précision en n’identifiant rien d’utile. Selon l’enjeu métier, on optera pour le rappel (ne rater aucun cas critique), la précision (limiter les fausses alertes), le F1 (compromis), ou l’AUC ROC (capacité de discrimination indépendante du seuil).

Une bonne pratique est d’écrire en clair la fonction de coût avant de coder. Combien coûte un faux positif ? Combien coûte un faux négatif ? Si les coûts sont asymétriques, c’est cette asymétrie qui pilote le choix du seuil de décision et de la métrique. Sans ce raisonnement préalable, l’optimisation est une activité mécanique sans pertinence métier.

Étape 2 — Préparer les données et le pipeline

On reprend le pipeline de prétraitement défini précédemment et on ajoute le modèle. Pour la première démonstration, on prend une régression logistique régularisée — simple, rapide, interprétable. Le pipeline complet est ensuite passé à la validation croisée comme un seul objet : c’est ce qui garantit qu’aucune fuite de données n’apparaît, parce que les transformations sont fittées indépendamment sur chaque pli.

import pandas as pd
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
# preprocessor : ColumnTransformer construit dans le tutoriel précédent

modele = Pipeline([
    ("prep", preprocessor),
    ("clf", LogisticRegression(max_iter=1000, class_weight="balanced", random_state=42)),
])

L’argument class_weight="balanced" rééquilibre automatiquement les classes en pondérant inversement à leur fréquence. Sur une classification très déséquilibrée, c’est souvent le geste qui sauve le rappel. max_iter=1000 évite l’avertissement « lbfgs failed to converge » qui apparaît sur des données pas encore standardisées ou très volumineuses — comme le pipeline les standardise, on est tranquille.

Étape 3 — Validation croisée stratifiée

La validation croisée découpe le train en k plis, entraîne sur k-1 et évalue sur le dernier, k fois. La moyenne et l’écart-type des k scores donnent une estimation honnête de la performance, contrairement à un score unique sur un test set qui peut être trompeur par chance. Pour la classification, on utilise StratifiedKFold qui conserve la proportion des classes dans chaque pli.

from sklearn.model_selection import StratifiedKFold, cross_val_score

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(modele, X_train, y_train, cv=cv, scoring="f1", n_jobs=-1)
print(f"F1 par pli : {scores.round(3)}")
print(f"F1 moyen : {scores.mean():.3f} ± {scores.std():.3f}")

Le résultat fournit cinq scores F1, un par pli, leur moyenne et leur écart-type. Un écart-type élevé (par exemple 0,15) signale un modèle instable dont la performance dépend trop des données vues — soit le jeu est petit, soit le modèle est trop variable, soit la cible est trop déséquilibrée. n_jobs=-1 exploite tous les cœurs CPU pour paralléliser l’entraînement des plis. Sur un quad-core, le gain est typiquement de 3 à 4 fois.

Étape 4 — GridSearchCV pour régler les hyperparamètres

Les hyperparamètres ne s’apprennent pas pendant l’entraînement : ce sont les réglages qu’on fixe avant. Pour une régression logistique, c’est le coefficient de régularisation C, le type de pénalité (L1 ou L2), le solveur. GridSearchCV teste toutes les combinaisons d’une grille fournie, en validation croisée, et retient la meilleure. C’est exhaustif mais devient coûteux dès que la grille grossit.

from sklearn.model_selection import GridSearchCV

grille = {
    "clf__C": [0.01, 0.1, 1.0, 10.0],
    "clf__penalty": ["l1", "l2"],
    "clf__solver": ["liblinear"],   # supporte L1 et L2
}

recherche = GridSearchCV(modele, grille, cv=cv, scoring="f1", n_jobs=-1, verbose=1)
recherche.fit(X_train, y_train)
print("Meilleurs paramètres :", recherche.best_params_)
print(f"Meilleur score CV : {recherche.best_score_:.3f}")

Les noms de paramètres sont préfixés par le nom de l’étape du pipeline (clf__C désigne le paramètre C de l’étape clf). Cette convention permet de régler conjointement preprocessing et modèle dans une même recherche. Le solveur liblinear est choisi parce qu’il supporte à la fois L1 et L2 ; pour de gros jeux on préférerait saga. Le verbose=1 imprime un compteur de progression rassurant sur les longues recherches.

Étape 5 — Comparer plusieurs algorithmes

Une régression logistique est une référence robuste mais rarement la meilleure sur des données tabulaires complexes. Les modèles à arbres — Random Forest et surtout Gradient Boosting — sont presque toujours plus performants au prix d’une moindre interprétabilité. Une comparaison équitable se fait sur la même validation croisée, en prenant les paramètres par défaut dans un premier temps puis en les réglant dans un second temps.

from sklearn.ensemble import RandomForestClassifier, HistGradientBoostingClassifier

resultats = {}
for nom, clf in [
    ("logreg", LogisticRegression(max_iter=1000, class_weight="balanced", random_state=42)),
    ("rf", RandomForestClassifier(n_estimators=200, n_jobs=-1, class_weight="balanced", random_state=42)),
    ("hgb", HistGradientBoostingClassifier(max_iter=200, random_state=42)),
]:
    pipe = Pipeline([("prep", preprocessor), ("clf", clf)])
    s = cross_val_score(pipe, X_train, y_train, cv=cv, scoring="f1", n_jobs=-1)
    resultats[nom] = (s.mean(), s.std())
    print(f"{nom:8s}  F1 = {s.mean():.3f} ± {s.std():.3f}")

Sur la majorité des jeux tabulaires, HistGradientBoostingClassifier arrive en tête avec un écart d’au moins 5 points de F1 sur la régression logistique. Random Forest se classe entre les deux. Cette hiérarchie est suffisamment stable pour qu’on commence souvent par tester d’abord le boosting, en gardant la régression logistique uniquement comme référence ou comme modèle simple à interpréter pour le métier.

Étape 6 — RandomizedSearchCV pour les grilles larges

GridSearchCV devient impraticable dès que la grille dépasse quelques centaines de combinaisons. RandomizedSearchCV tire un nombre fixé de combinaisons au hasard et donne des résultats équivalents avec dix fois moins de calcul. Pour un Gradient Boosting avec une demi-douzaine d’hyperparamètres, c’est l’outil de choix.

from sklearn.model_selection import RandomizedSearchCV
from scipy.stats import randint, loguniform

pipe_hgb = Pipeline([("prep", preprocessor), ("clf", HistGradientBoostingClassifier(random_state=42))])

grille_alea = {
    "clf__max_iter": randint(100, 500),
    "clf__learning_rate": loguniform(1e-2, 3e-1),
    "clf__max_depth": [None, 4, 8, 16],
    "clf__min_samples_leaf": randint(10, 100),
}

recherche_alea = RandomizedSearchCV(
    pipe_hgb, grille_alea, n_iter=40, cv=cv, scoring="f1",
    n_jobs=-1, random_state=42, verbose=1,
)
recherche_alea.fit(X_train, y_train)
print("Meilleurs paramètres :", recherche_alea.best_params_)
print(f"Meilleur score CV : {recherche_alea.best_score_:.3f}")

Les distributions randint et loguniform de scipy permettent de tirer des valeurs continues plutôt que de se restreindre à une liste discrète. C’est particulièrement utile pour des paramètres comme learning_rate dont la sensibilité varie sur plusieurs ordres de grandeur. Quarante itérations couvrent généralement assez bien l’espace pour trouver une combinaison à 0,1 point de la meilleure absolue.

Étape 7 — Régression : passer de la classification

La régression suit la même logique en remplaçant l’algorithme et la métrique. Pour prédire un montant continu, on choisit RMSE (racine de l’erreur quadratique moyenne) ou MAE (erreur absolue moyenne) selon qu’on veut pénaliser fortement les gros écarts ou rester linéaire. KFold simple suffit — pas besoin de stratification puisque la cible n’a pas de classes.

from sklearn.ensemble import HistGradientBoostingRegressor
from sklearn.model_selection import KFold, cross_val_score

# y_reg : variable cible continue (par exemple montant)
y_reg = df["montant"]
# On retire montant ET paye (cible de la classification précédente, sans rapport métier ici)
X_reg = df.drop(columns=["montant", "paye", "id_transaction"], errors="ignore")
X_tr, X_te, y_tr, y_te = train_test_split(X_reg, y_reg, test_size=0.2, random_state=42)

# Le préprocesseur est reconstruit pour les colonnes effectives de X_reg
prep_reg = ColumnTransformer(
    transformers=[
        ("num", pipe_num, X_reg.select_dtypes(include="number").columns.tolist()),
        ("cat", pipe_cat, X_reg.select_dtypes(include=["object", "string", "category"]).columns.tolist()),
    ],
    remainder="drop",
)
pipe_reg = Pipeline([("prep", prep_reg), ("reg", HistGradientBoostingRegressor(random_state=42))])
cv_reg = KFold(n_splits=5, shuffle=True, random_state=42)
scores_rmse = -cross_val_score(pipe_reg, X_tr, y_tr, cv=cv_reg, scoring="neg_root_mean_squared_error", n_jobs=-1)
print(f"RMSE moyen : {scores_rmse.mean():.2f} ± {scores_rmse.std():.2f}")

Le préfixe neg_ dans le nom du scoring est une convention scikit-learn : tous les scorers respectent la règle « plus c’est haut, mieux c’est », et l’erreur étant à minimiser, on utilise sa version négative. On inverse le signe à la lecture pour obtenir un RMSE positif lisible. Sur un jeu de prédiction de montants, un RMSE qui vaut un quart de l’écart-type de la cible est généralement considéré comme un bon résultat de départ.

Étape 8 — Évaluation finale sur le jeu de test

Une fois la meilleure configuration choisie en validation croisée, on entraîne le modèle final sur tout le train et on l’évalue une seule fois sur le test mis de côté au début. Cette évaluation finale est sacrée : on ne la regarde pas plusieurs fois en ajustant le modèle, sous peine de la transformer en une seconde validation et de perdre toute estimation honnête de la généralisation.

from sklearn.metrics import classification_report, confusion_matrix

# best_estimator_ est déjà refitté sur l'ensemble du train par GridSearchCV/RandomizedSearchCV
# (refit=True par défaut), inutile de relancer fit
modele_final = recherche_alea.best_estimator_
y_pred = modele_final.predict(X_test)

print(classification_report(y_test, y_pred, digits=3))
print("Matrice de confusion :")
print(confusion_matrix(y_test, y_pred))

Le rapport affiche précision, rappel et F1 par classe ainsi que les moyennes pondérées. La matrice de confusion détaille les vrais positifs, faux positifs, vrais négatifs et faux négatifs — c’est elle qui donne la lecture métier la plus claire. Pour une analyse plus fine avec courbes ROC et précision-rappel, voir le tutoriel sur l’évaluation.

Étape 9 — Comprendre les erreurs

Au-delà des métriques agrégées, on apprend beaucoup en regardant les exemples mal classés. Quels types de transactions le modèle rate-t-il systématiquement ? Y a-t-il un segment client surreprésenté dans les erreurs ? Cette inspection conduit souvent à découvrir des features manquantes ou un biais dans les données.

erreurs = X_test.assign(reel=y_test, predit=y_pred).query("reel != predit")
print(f"{len(erreurs)} erreurs sur {len(X_test)} ({len(erreurs)/len(X_test)*100:.1f}%)")
print(erreurs.groupby("canal_paiement").size().sort_values(ascending=False))

Si les erreurs se concentrent disproportionnellement sur un canal de paiement, c’est probablement que ce canal manque de données ou que ses caractéristiques sont sous-représentées dans le jeu d’entraînement. Une stratégie possible est d’augmenter la pondération de ce segment via sample_weight ou de créer des variables dérivées spécifiques. C’est une boucle d’amélioration itérative qui transforme un modèle correct en un modèle excellent.

Étape 10 — Sauvegarder le modèle final

Une fois validé, le modèle est sérialisé pour la production avec joblib, comme à l’étape de feature engineering. On peut aussi sauvegarder l’objet recherche_alea entier pour conserver la trace de tous les essais, ce qui est très utile en debug — mais c’est plus volumineux. La bonne pratique est de coupler joblib et MLflow : MLflow garde l’historique des essais, joblib sert le modèle final.

import joblib
joblib.dump(modele_final, "model_final.joblib", compress=3)

# Charger en production
modele_prod = joblib.load("model_final.joblib")
echantillon = X_test.head(3)
print("Prédictions :", modele_prod.predict(echantillon))
print("Probabilités :", modele_prod.predict_proba(echantillon).round(3))

La méthode predict_proba retourne les probabilités estimées par classe, ce qui permet d’ajuster le seuil de décision a posteriori en fonction du compromis précision/rappel souhaité. Pour la mise en production sous forme d’API, voir le tutoriel FastAPI. Pour la traçabilité des expériences, voir le tutoriel MLflow.

Erreurs fréquentes

ErreurCauseSolution
Score test > score validation croiséeStatistique : un test favorable n’est pas représentatifFaire confiance à la moyenne CV, pas au score isolé
GridSearchCV n’en finit pasGrille trop largePasser à RandomizedSearchCV
Class_weight ignoré dans GridSearchPréfixage manquant : utiliser clf__class_weightVérifier les noms avec model.get_params().keys()
Modèle gagne sur F1 mais perd en productionDistribution réelle différente du trainSurveiller le data drift, ré-entraîner périodiquement
RandomForest très lentn_estimators trop élevé sans n_jobsn_jobs=-1, ou bien passer au HistGradientBoosting
Score différent entre deux exécutionsrandom_state non fixéFixer random_state partout (modèle, CV, search)
RMSE énorme inexplicableCible avec des outliers extrêmesÉvaluer aussi en MAE, transformer la cible (log) si distribution log-normale

Tutoriels associés

Ressources officielles

FAQ

Combien de plis pour la validation croisée ?

Cinq plis sont la valeur standard : compromis entre précision de l’estimation et coût de calcul. Dix plis donnent une estimation un peu plus précise mais doublent le temps. Au-delà, le rendement marginal devient nul.

GridSearch ou RandomizedSearch ?

GridSearch quand la grille a moins de 100 combinaisons. RandomizedSearch dès qu’on cherche sur des plages continues ou que la grille dépasse quelques centaines. Pour les très gros budgets de calcul, regarder du côté d’Optuna ou Hyperopt qui font de la recherche bayésienne.

Faut-il tester XGBoost et LightGBM ?

Oui, ils sont souvent meilleurs que HistGradientBoosting de scikit-learn, surtout sur de très gros volumes. La syntaxe est compatible scikit-learn via leurs wrappers, donc l’intégration au pipeline est triviale.

Comment choisir le seuil de classification ?

Le seuil par défaut de 0,5 n’est presque jamais optimal. On le choisit en visualisant la courbe précision-rappel et en sélectionnant le point qui correspond au compromis métier souhaité. Voir le tutoriel sur l’évaluation pour la procédure complète.

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité