Aller au contenu

É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 :

JeuRôlePart typique
Apprentissage (train)ajuster les paramètres du modèle60 à 80 %
Validationchoisir les hyperparamètres (profondeur d’un arbre, kk des voisins…), comparer des modèles10 à 20 %
Testestimer, une seule fois et à la fin, l’erreur du modèle retenu20 à 30 %
apprentissage (60 à 80 %)ajuster les paramètres(les poids, les seuils de l’arbre…)validationchoisir leshyperparamètrestestestimer l’erreur,une fois, à la finjeu de données complet, mélangé au hasard (et stratifié)
Trois parts, trois rôles : on ajuste sur l'apprentissage, on choisit sur la validation, et l'on ne regarde le test qu'une fois, à la toute fin.

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

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 à kk plis (k-fold cross-validation) utilise chaque exemple à la fois pour apprendre et pour évaluer :

  1. découper les données en kk parts de même taille (les plis) ;
  2. pour chaque pli : apprendre sur les k−1k - 1 autres, évaluer sur celui-ci ;
  3. moyenner les kk scores, et regarder leur écart-type.

On prend en général k=5k = 5 ou k=10k = 10. Le cas extrême k=mk = m (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.

0,933tour 10,933tour 21,000tour 31,000tour 41,000tour 50,933tour 60,933tour 71,000tour 81,000tour 91,000tour 10exactitudemoyenne0,973écart-type0,033plis d’apprentissage (135 fleurs)pli évalué (15 fleurs)
Les dix tours de la validation croisée du TP (iris, 15 voisins, cv=10 sans mélange). Chaque fleur est évaluée exactement une fois ; un pli de 15 fleurs ne peut valoir que 1 ou 14/15 ≈ 0,933, d'où l'intérêt de moyenner.
from sklearn.datasets import load_iris
from sklearn.model_selection import StratifiedKFold, cross_val_score
from 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élange
print(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és
scores = 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.

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 maladePrédit sain
Réellement malade38 (vrais positifs, VP)7 (faux négatifs, FN)
Réellement sain10 (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 :

MesureFormuleQuestion poséeIci
Exactitude (accuracy)VP+VNN\frac{VP + VN}{N}quelle part de bonnes réponses ?83/100=0,8383/100 = 0{,}83
Précision (precision)VPVP+FP\frac{VP}{VP + FP}quand je dis « malade », ai-je raison ?38/48≈0,7938/48 \approx 0{,}79
Rappel (recall), sensibilitéVPVP+FN\frac{VP}{VP + FN}quelle part des malades je retrouve ?38/45≈0,8438/45 \approx 0{,}84
SpécificitéVNVN+FP\frac{VN}{VN + FP}quelle part des sains je reconnais ?45/55≈0,8245/55 \approx 0{,}82

Dans ces formules, NN est le nombre total d’exemples évalués, et VP, VN, FP, FN sont les quatre cases de la matrice.

38vrais positifs7faux négatifs10faux positifs45vrais négatifsprédit maladeprédit sainréellementmalade (45)réellementsain (55)rappel38 / 45 ≈ 0,84précision 38 / 48 ≈ 0,79exactitude (38 + 45) / 100 = 0,83
Le rappel se lit sur la ligne des vrais malades, la précision sur la colonne des patients signalés : les deux mesures partagent les 38 vrais positifs mais pas leur dénominateur.

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 :

F1=2 P RP+RF_1 = \frac{2 \, P \, R}{P + R}

où PP est la précision et RR 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, F1=2×0,79×0,84/(0,79+0,84)≈0,82F_1 = 2 \times 0{,}79 \times 0{,}84 / (0{,}79 + 0{,}84) \approx 0{,}82. Sa généralisation Fβ=(1+β2)PRβ2P+RF_\beta = \frac{(1 + \beta^2) P R}{\beta^2 P + R} donne plus de poids au rappel quand β>1\beta > 1, 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 yy), la MAE (moyenne des écarts absolus, moins sensible aux valeurs aberrantes) et le R2R^2 (part de la variance de yy 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.

0,00,20,40,60,81,0score calculé par le modèledensitéseuil 0,5faux positifsfaux négatifsbaisser le seuil : rappel ↑, précision ↓patients sainspatients malades
Déplacer le seuil ne change pas le modèle, seulement le partage des erreurs : vers la gauche, moins de faux négatifs mais plus de faux positifs (distributions inventées).

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 (FP/(FP+VN)FP / (FP + VN), 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.
0,00,20,40,60,81,0taux de faux positifs0,00,20,40,60,81,0taux de vrais positifs (rappel)hasardCourbe ROC50 % de positifs : AUC 0,921 % de positifs : AUC 0,910,00,20,40,60,81,0rappel0,00,20,40,60,81,0précisionCourbe précision-rappel50 % de positifs : aire 0,921 % de positifs : aire 0,22
Même modèle (score = bruit + 2 pour les positifs), deux proportions de positifs. La courbe ROC ne voit presque pas la différence ; la courbe précision-rappel montre que, quand les positifs sont rares, la plupart des patients signalés sont des faux positifs. Le cas à 1 % reprend le code de l'exercice « Choisir une métrique ».
01/32/31taux de faux positifs01/32/31taux de vrais positifs0,90,80,70,40,30,2AUC = 7 / 9 ≈ 0,78paires malordonnées
En balayant les scores du plus haut au plus bas, chaque malade fait monter la courbe d'un tiers et chaque sain la fait avancer d'un tiers. Chaque case de la grille est une paire (malade, sain) : l'aire sous l'escalier compte exactement les 7 paires bien ordonnées.

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 »

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.

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 pd
from sklearn.dummy import DummyClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.pipeline import make_pipeline
from 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 majoritaire
base = 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'évaluation
pipe = 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}")
  • 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 à kk 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.

Exercice · Calculer les mesures d'une matrice de confusion

facile

Un 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 =(90+740)/1000=0,83= (90 + 740)/1000 = 0{,}83 ;
  • précision =90/150=0,60= 90 / 150 = 0{,}60 ;
  • rappel =90/200=0,45= 90 / 200 = 0{,}45 ;
  • F1=2×0,60×0,45/(0,60+0,45)=0,54/1,05≈0,51F_1 = 2 \times 0{,}60 \times 0{,}45 / (0{,}60 + 0{,}45) = 0{,}54 / 1{,}05 \approx 0{,}51.

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 np
from 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 740
print(accuracy_score(y_vrai, y_pred)) # 0.83
print(precision_score(y_vrai, y_pred), recall_score(y_vrai, y_pred)) # 0.6 0.45
print(round(f1_score(y_vrai, y_pred), 3)) # 0.514
print(accuracy_score(y_vrai, np.zeros(1000))) # baseline « tout le monde vient » : 0.8

Exercice · Trouver les erreurs d'un protocole

moyen

Un collègue veut régler le nombre de voisins kk d’un classifieur des k plus proches voisins sur un jeu de 5 000 patients (3 classes). Voici son protocole :

  1. il normalise toutes les variables sur les 5 000 patients ;
  2. il prend les 500 dernières lignes du fichier comme jeu de validation ;
  3. il teste k∈{1,2,3}k \in \lbrace 1, 2, 3 \rbrace ;
  4. il retient le kk 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
  1. 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.
  2. 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).
  3. Plage de kk 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.
  4. Score optimiste : le score de validation a servi à choisir kk, 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 np
from 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 valeurs
scores = {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 retenu
print(round(modele.score(X[test], y[test]), 3)) # 0.345 : sur le test, retour vers 1/3

Exercice · Choisir une métrique

difficile

Pour chacun de ces trois projets, indiquez la métrique principale que vous suivriez et justifiez :

  1. un filtre anti-spam pour une messagerie professionnelle ;
  2. le dépistage d’une maladie grave, suivi d’un examen de confirmation peu coûteux ;
  3. un classement des 50 dossiers de crédit les plus risqués à faire vérifier chaque semaine, parmi 20 000.
Voir la solution
  1. 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.
  2. 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 FβF_\beta avec β>1\beta > 1.
  3. 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 np
from 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 haut
y = (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 : flatteur
print("aire précision-rappel :", round(average_precision_score(y, score), 3)) # 0.22
top50 = np.argsort(-score)[:50] # les 50 dossiers vérifiés chaque semaine
print("précision parmi les 50 premiers :", y[top50].mean()) # 0.48