Évaluez honnêtement un modèle
Vous savez maintenant que le score d’un modèle sur ses propres données d’apprentissage ne prouve rien. Mais alors, comment mesurer ce qu’il vaut vraiment ? Et surtout, que mesurer : un modèle qui a « 90 % de bonnes réponses » peut être excellent… ou totalement inutile. Ce chapitre vous donne le protocole et les métriques pour évaluer un modèle sans vous mentir.
Notre fil rouge : un modèle de dépistage des maladies cardiaques, qui doit signaler les patients à examiner en priorité à partir de mesures simples (âge, cholestérol, fréquence cardiaque maximale, etc.). C’est le jeu heart.csv, que nous avons utilisé en TP.
Mettre des données de côté : apprentissage, validation, test
Section intitulée « Mettre des données de côté : apprentissage, validation, test »L’idée de base est simple : pour estimer l’erreur réelle, on garde des exemples que le modèle ne verra pas pendant l’apprentissage. En pratique, on découpe le jeu de données en trois parts, chacune avec un rôle précis :
| Jeu | Rôle | Part typique |
|---|---|---|
| Apprentissage (train) | ajuster les paramètres du modèle | 60 à 80 % |
| Validation | choisir les hyperparamètres (profondeur d’un arbre, des voisins…), comparer des modèles | 10 à 20 % |
| Test | estimer, une seule fois et à la fin, l’erreur du modèle retenu | 20 à 30 % |
Pourquoi un jeu de validation et un jeu de test ? Bonne question ! Dès que vous utilisez un jeu pour choisir quelque chose (le meilleur parmi vingt valeurs, par exemple), le score obtenu sur ce jeu devient à son tour optimiste : vous avez retenu la valeur qui avait, entre autres, de la chance sur ces exemples-là. Le jeu de test, qu’on ne regarde qu’une fois, reste une estimation honnête.
En TP, sur heart.csv (303 patients), nous avons séparé 70 % des patients pour l’apprentissage et 30 % pour la validation, soit 212 et 91 patients. C’est sur ces 91 patients que nous avons choisi le niveau d’élagage de l’arbre (chapitre 4).
Deux règles pour le découpage :
- Tirer au hasard. Ne prenez jamais « les 100 dernières lignes » comme test : le fichier est peut-être trié par date ou par classe. Le jeu d’iris, par exemple, range ses 150 fleurs par espèce : les 50 dernières sont toutes des virginica.
- Stratifier en classification : conserver dans chaque part la même proportion de chaque classe que dans le jeu complet. C’est indispensable quand une classe est rare.
La validation croisée
Section intitulée « La validation croisée »Un seul découpage a un défaut : le score dépend du hasard du tirage, surtout avec peu de données. Avec 91 patients en validation, il suffit que quelques cas difficiles tombent d’un côté ou de l’autre pour faire bouger le score de plusieurs points.
La validation croisée à plis (k-fold cross-validation) utilise chaque exemple à la fois pour apprendre et pour évaluer :
- découper les données en parts de même taille (les plis) ;
- pour chaque pli : apprendre sur les autres, évaluer sur celui-ci ;
- moyenner les scores, et regarder leur écart-type.
On prend en général ou . Le cas extrême (un exemple par pli) s’appelle le leave-one-out : précis mais coûteux. En classification, on utilise la version stratifiée, où chaque pli respecte les proportions des classes. Et si les exemples ne sont pas indépendants (plusieurs rendez-vous d’un même patient, des mesures dans le temps), il faut découper par groupe ou dans l’ordre chronologique, sinon le modèle retrouve dans l’évaluation des cas qu’il connaît déjà.
En TP, sur les iris, une validation croisée à 10 plis d’un classifieur des 15 plus proches voisins a donné une exactitude moyenne de 0,973. Détail utile : avec cv=10, scikit-learn utilise automatiquement une validation croisée stratifiée pour un classifieur.
from sklearn.datasets import load_irisfrom sklearn.model_selection import StratifiedKFold, cross_val_scorefrom sklearn.neighbors import KNeighborsClassifier
X, y = load_iris(return_X_y=True)modele = KNeighborsClassifier(n_neighbors=15)
scores = cross_val_score(modele, X, y, cv=10) # comme en TP : 10 plis stratifiés, sans mélangeprint(f"exactitude : {scores.mean():.3f} (écart-type {scores.std():.3f})")# exactitude : 0.973 (écart-type 0.033)
cv = StratifiedKFold(n_splits=10, shuffle=True, random_state=0) # plis mélangésscores = cross_val_score(modele, X, y, cv=cv)print(f"exactitude : {scores.mean():.3f} (écart-type {scores.std():.3f})")# exactitude : 0.967 (écart-type 0.033)Le score change légèrement quand on mélange les données (shuffle=True), puisque les plis ne sont plus les mêmes : 0,967 au lieu de 0,973 ici. C’est normal, et c’est pour cela qu’on regarde aussi l’écart-type.
La matrice de confusion et ses mesures
Section intitulée « La matrice de confusion et ses mesures »Revenons au dépistage. Supposons que, sur 100 patients de test, 45 soient réellement malades, et que le modèle donne ceci (chiffres d’illustration) :
| Prédit malade | Prédit sain | |
|---|---|---|
| Réellement malade | 38 (vrais positifs, VP) | 7 (faux négatifs, FN) |
| Réellement sain | 10 (faux positifs, FP) | 45 (vrais négatifs, VN) |
C’est la matrice de confusion. La diagonale compte les bonnes réponses ; les deux autres cases sont deux erreurs de nature différente : un faux positif envoie un patient sain faire un examen inutile, un faux négatif renvoie chez lui un patient malade. Les mesures suivantes résument cette matrice :
| Mesure | Formule | Question posée | Ici |
|---|---|---|---|
| Exactitude (accuracy) | quelle part de bonnes réponses ? | ||
| Précision (precision) | quand je dis « malade », ai-je raison ? | ||
| Rappel (recall), sensibilité | quelle part des malades je retrouve ? | ||
| Spécificité | quelle part des sains je reconnais ? |
Dans ces formules, est le nombre total d’exemples évalués, et VP, VN, FP, FN sont les quatre cases de la matrice.
Précision et rappel tirent souvent en sens inverse : pour ne rater aucun malade (rappel élevé), on signale plus de monde, donc plus de faux positifs (précision plus basse). Le score F1 combine les deux par une moyenne harmonique :
où est la précision et le rappel. La moyenne harmonique punit les déséquilibres : si l’un des deux est proche de 0, le F1 l’est aussi. Ici, . Sa généralisation donne plus de poids au rappel quand , ce qui convient bien au dépistage.
Pour la régression, on n’a pas de matrice de confusion mais des erreurs moyennes : la MSE (moyenne des carrés des écarts), la RMSE (sa racine, exprimée dans l’unité de ), la MAE (moyenne des écarts absolus, moins sensible aux valeurs aberrantes) et le (part de la variance de expliquée par le modèle).
Faire varier le seuil : courbes ROC et précision-rappel
Section intitulée « Faire varier le seuil : courbes ROC et précision-rappel »La plupart des classifieurs ne répondent pas directement « malade » ou « sain » : ils calculent un score (souvent une probabilité), puis le comparent à un seuil, 0,5 par défaut. Baisser le seuil augmente le rappel et diminue la précision ; le monter fait l’inverse. Le bon seuil dépend du coût de chaque erreur, et c’est une décision métier, pas une décision technique.
Pour comparer des modèles indépendamment du seuil, on trace des courbes en balayant tous les seuils possibles :
- La courbe ROC porte le taux de vrais positifs (le rappel) en fonction du taux de faux positifs (, soit 1 moins la spécificité). L’AUC (aire sous la courbe) vaut 0,5 pour un modèle qui répond au hasard et 1 pour un modèle parfait. Elle a une interprétation très parlante : c’est la probabilité qu’un malade tiré au hasard reçoive un score plus élevé qu’un patient sain tiré au hasard.
- La courbe précision-rappel porte la précision en fonction du rappel. Elle est plus informative quand les positifs sont rares (fraude, maladie rare, absences), car la courbe ROC peut y paraître flatteuse : avec énormément de négatifs, même beaucoup de faux positifs ne font qu’un petit taux de faux positifs.
Deux garde-fous : la baseline et la fuite de données
Section intitulée « Deux garde-fous : la baseline et la fuite de données »Toujours comparer à une baseline
Section intitulée « Toujours comparer à une baseline »Un score n’a de sens que comparé à une référence simple, la baseline. La plus simple consiste à prédire toujours la classe majoritaire. Imaginez un cabinet médical où 80 % des patients viennent à leur rendez-vous : un « modèle » qui prédit toujours « le patient viendra » atteint 80 % d’exactitude… avec un rappel de 0 sur les absences, qui sont justement ce qu’on cherche. Tout modèle doit battre cette baseline, et sur la métrique qui compte.
Traquer la fuite de données
Section intitulée « Traquer la fuite de données »La fuite de données (data leakage) se produit quand une information indisponible au moment de la prédiction se glisse dans l’apprentissage. Le modèle paraît excellent en évaluation, puis déçoit en production. Les causes classiques :
- prétraiter avant de découper : normaliser, imputer ou sélectionner les variables sur tout le jeu, test compris ;
- utiliser une variable postérieure à l’événement (par exemple « a reçu un traitement cardiaque » pour prédire une maladie cardiaque) ;
- avoir le même individu dans l’apprentissage et dans le test (deux rendez-vous d’un même patient) ;
- découper au hasard une série temporelle, ce qui revient à prédire le passé avec le futur.
Le remède le plus efficace dans scikit-learn est le pipeline : on enchaîne prétraitement et modèle dans un seul objet, et la validation croisée réapprend le prétraitement dans chaque pli, uniquement sur la part d’apprentissage.
import pandas as pdfrom sklearn.dummy import DummyClassifierfrom sklearn.linear_model import LogisticRegressionfrom sklearn.model_selection import StratifiedKFold, cross_val_scorefrom sklearn.pipeline import make_pipelinefrom sklearn.preprocessing import StandardScaler
df = pd.read_csv("heart.csv")X, y = df.drop(columns=["target"]), df["target"]cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=0)
# Baseline : toujours la classe majoritairebase = cross_val_score(DummyClassifier(strategy="most_frequent"), X, y, cv=cv)
# Modèle : la normalisation est apprise dans chaque pli, jamais sur le pli d'évaluationpipe = make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000))rappel = cross_val_score(pipe, X, y, cv=cv, scoring="recall")auc = cross_val_score(pipe, X, y, cv=cv, scoring="roc_auc")
print(f"baseline (exactitude) : {base.mean():.3f}")print(f"modèle : rappel {rappel.mean():.3f}, AUC {auc.mean():.3f}")En résumé
Section intitulée « En résumé »- On découpe les données en apprentissage (ajuster), validation (choisir) et test (estimer une seule fois à la fin), par tirage aléatoire et stratifié.
- La validation croisée à plis donne une estimation plus stable qu’un seul découpage ; on la fait par groupe ou dans l’ordre du temps si les exemples sont liés.
- La matrice de confusion sépare vrais et faux positifs et négatifs ; précision, rappel, spécificité et F1 en découlent.
- Le seuil de décision est un choix métier ; les courbes ROC et précision-rappel comparent des modèles sur tous les seuils, la seconde étant préférable quand les positifs sont rares.
- Tout score se compare à une baseline (classe majoritaire), sur la métrique qui compte.
- Pour éviter les fuites de données, on met le prétraitement dans un pipeline évalué par validation croisée.
Exercices
Section intitulée « Exercices »Exercice · Calculer les mesures d'une matrice de confusion
facileUn modèle prédit les absences à des rendez-vous. Sur 1 000 rendez-vous de test, il y a eu 200 absences. Le modèle en a signalé 150, dont 90 étaient réellement des absences. Calculez l’exactitude, la précision, le rappel et le F1 sur la classe « absent ».
Voir la solution
Construisons d’abord la matrice, en prenant « absent » comme classe positive :
- VP = 90 (absences signalées à raison) ;
- FP = 150 − 90 = 60 (patients signalés mais venus) ;
- FN = 200 − 90 = 110 (absences non signalées) ;
- VN = 1000 − 90 − 60 − 110 = 740.
Alors :
- exactitude ;
- précision ;
- rappel ;
- .
Remarquez que la baseline « tout le monde vient » obtient déjà 0,80 d’exactitude : les 83 % du modèle ne sont qu’un petit gain sur cette mesure, alors que le rappel montre qu’il retrouve moins de la moitié des absences.
En code
import numpy as npfrom sklearn.metrics import accuracy_score, f1_score, precision_score, recall_score
# Reconstruire les 1 000 rendez-vous : 1 = absent (classe positive)VP, FP, FN, VN = 90, 150 - 90, 200 - 90, 1000 - 200 - (150 - 90)y_vrai = np.array([1] * VP + [0] * FP + [1] * FN + [0] * VN)y_pred = np.array([1] * VP + [1] * FP + [0] * FN + [0] * VN)
print(VP, FP, FN, VN) # 90 60 110 740print(accuracy_score(y_vrai, y_pred)) # 0.83print(precision_score(y_vrai, y_pred), recall_score(y_vrai, y_pred)) # 0.6 0.45print(round(f1_score(y_vrai, y_pred), 3)) # 0.514print(accuracy_score(y_vrai, np.zeros(1000))) # baseline « tout le monde vient » : 0.8# Matrice de confusion, « absent » comme classe positiveVP <- 90; FP <- 150 - 90; FN <- 200 - 90; VN <- 1000 - VP - FP - FN
exactitude <- (VP + VN) / 1000precision <- VP / (VP + FP)rappel <- VP / (VP + FN)f1 <- 2 * precision * rappel / (precision + rappel)
c(VN = VN, exactitude = exactitude, precision = precision, rappel = rappel, F1 = round(f1, 3))# VN 740, exactitude 0.83, precision 0.6, rappel 0.45, F1 0.514Exercice · Trouver les erreurs d'un protocole
moyenUn collègue veut régler le nombre de voisins d’un classifieur des k plus proches voisins sur un jeu de 5 000 patients (3 classes). Voici son protocole :
- il normalise toutes les variables sur les 5 000 patients ;
- il prend les 500 dernières lignes du fichier comme jeu de validation ;
- il teste ;
- il retient le qui a le meilleur score de validation, et annonce ce score comme performance attendue en production.
Relevez les problèmes et proposez une correction.
Voir la solution
- Fuite de données : la normalisation utilise les moyennes et écarts-types calculés avec les patients de validation. Il faut l’apprendre sur l’apprentissage seul, idéalement dans un pipeline.
- Découpage non aléatoire : les dernières lignes peuvent correspondre à une période, un service ou une classe particulière. Il faut tirer la validation au hasard, de façon stratifiée (ou faire une validation croisée stratifiée).
- Plage de trop étroite : trois petites valeurs, toutes du côté du surapprentissage. Il faut explorer une plage large (par exemple de 1 à 100, sur une grille) et, avec 3 classes, éviter les valeurs qui favorisent les égalités ou pondérer les votes.
- Score optimiste : le score de validation a servi à choisir , il est donc biaisé vers le haut. Il faut garder un jeu de test séparé, utilisé une seule fois après le choix, pour annoncer la performance.
En code
import numpy as npfrom sklearn.neighbors import KNeighborsClassifier
rng = np.random.default_rng(0)# 5 000 patients, 3 classes tirées au hasard : rien n'est prévisible (vraie exactitude 1/3)X = rng.normal(size=(5000, 5))y = rng.integers(0, 3, 5000)app, val, test = slice(0, 3500), slice(3500, 4250), slice(4250, 5000)
# Choisir k sur la validation, parmi 100 valeursscores = {k: KNeighborsClassifier(n_neighbors=k).fit(X[app], y[app]).score(X[val], y[val]) for k in range(1, 101)}k_retenu = max(scores, key=scores.get)modele = KNeighborsClassifier(n_neighbors=k_retenu).fit(X[app], y[app])
print(k_retenu, round(scores[k_retenu], 3)) # 2 0.379 : score de validation du k retenuprint(round(modele.score(X[test], y[test]), 3)) # 0.345 : sur le test, retour vers 1/3set.seed(0)# 5 000 patients, 3 classes tirées au hasard : rien n'est prévisible (vraie exactitude 1/3)n <- 5000; X <- matrix(rnorm(n * 5), n); y <- sample(1:3, n, replace = TRUE)app <- 1:3500; val <- 3501:4250; test <- 4251:5000
# Indices des 100 plus proches voisins (dans l'apprentissage) de chaque ligne de Bvoisins <- function(B) { D <- outer(rowSums(B^2), rowSums(X[app, ]^2), "+") - 2 * B %*% t(X[app, ]) t(apply(D, 1, function(d) order(d)[1:100]))}exactitude <- function(V, y_vrai, k) { pred <- apply(matrix(y[app][V[, 1:k]], ncol = k), 1, function(v) as.integer(names(which.max(table(v))))) # vote majoritaire mean(pred == y_vrai)}
V_val <- voisins(X[val, ])scores <- sapply(1:100, function(k) exactitude(V_val, y[val], k))k_retenu <- which.max(scores)c(k = k_retenu, validation = round(max(scores), 3), test = round(exactitude(voisins(X[test, ]), y[test], k_retenu), 3))# k = 2, validation 0.361, test 0.344 : le score de validation était optimisteExercice · Choisir une métrique
difficilePour chacun de ces trois projets, indiquez la métrique principale que vous suivriez et justifiez :
- un filtre anti-spam pour une messagerie professionnelle ;
- le dépistage d’une maladie grave, suivi d’un examen de confirmation peu coûteux ;
- un classement des 50 dossiers de crédit les plus risqués à faire vérifier chaque semaine, parmi 20 000.
Voir la solution
- Anti-spam : un faux positif (un courriel légitime envoyé dans les spams) coûte cher, un faux négatif (un spam qui passe) est seulement agaçant. On privilégie la précision sur la classe spam, en surveillant un rappel minimal.
- Dépistage : rater un malade est grave, et un faux positif ne coûte qu’un examen de confirmation. On privilégie le rappel (la sensibilité), par exemple en fixant un rappel d’au moins 95 % et en choisissant le seuil qui maximise la précision sous cette contrainte, ou avec un avec .
- Classement des dossiers : seul compte l’ordre des scores, et les positifs sont rares. Une mesure « au sommet du classement » est la plus parlante, comme la précision parmi les 50 premiers (combien des dossiers vérifiés sont réellement risqués). Pour comparer globalement des modèles, l’aire sous la courbe précision-rappel convient mieux que l’AUC ROC, que l’abondance de négatifs rend flatteuse.
En code
import numpy as npfrom sklearn.metrics import average_precision_score, roc_auc_score
rng = np.random.default_rng(0)# 20 000 dossiers, dont 1 % réellement risqués ; les risqués ont un score un peu plus hauty = (rng.random(20_000) < 0.01).astype(int)score = rng.normal(0, 1, 20_000) + 2 * y
print("AUC ROC :", round(roc_auc_score(y, score), 3)) # 0.911 : flatteurprint("aire précision-rappel :", round(average_precision_score(y, score), 3)) # 0.22top50 = np.argsort(-score)[:50] # les 50 dossiers vérifiés chaque semaineprint("précision parmi les 50 premiers :", y[top50].mean()) # 0.48set.seed(0)# 20 000 dossiers, dont 1 % réellement risqués ; les risqués ont un score un peu plus hautn <- 20000y <- as.integer(runif(n) < 0.01)score <- rnorm(n) + 2 * y
# AUC ROC : probabilité qu'un risqué ait un score plus haut qu'un non risqué (rangs)r <- rank(score); n1 <- sum(y); n0 <- n - n1auc <- (sum(r[y == 1]) - n1 * (n1 + 1) / 2) / (n1 * n0)
# Aire précision-rappel : moyenne des précisions aux rangs des vrais risquésordre <- order(-score)precisions <- cumsum(y[ordre]) / seq_len(n)ap <- mean(precisions[y[ordre] == 1])
c(AUC = round(auc, 3), aire_PR = round(ap, 3), precision_top50 = mean(y[ordre][1:50]))# AUC 0.932, aire PR 0.309, précision parmi les 50 premiers 0.58