Menez un projet de bout en bout
Connaître les modèles ne suffit pas : un projet réel se joue autant dans la préparation des données, le protocole et l’interprétation des résultats que dans le choix de l’algorithme. Ce dernier chapitre raconte un projet mené en groupe en TP, prédire les rendez-vous médicaux manqués, en deux temps : d’abord ce que nous avons fait et obtenu, avec les chiffres exacts de notre notebook ; puis ce qu’un regard plus expérimenté y trouve à redire, à la lumière des chapitres précédents.
Ce chapitre est aussi celui que vous pourrez raconter en entretien. Un recruteur s’intéresse moins à votre score qu’à votre capacité à dire pourquoi il est trompeur et comment vous feriez mieux.
Le problème et les données
Section intitulée « Le problème et les données »Vous vous souvenez du cabinet médical du chapitre 1, qui perdait des créneaux à cause des absences ? Nous avons travaillé sur un jeu public de la plateforme Kaggle, « Medical Appointment No Shows », qui recense des rendez-vous médicaux pris au Brésil en 2016. La question posée : quelles caractéristiques permettent de prédire qu’un patient ne viendra pas ? C’est une classification binaire, dont l’intérêt pratique est clair : relancer les patients à risque, ou surréserver certains créneaux.
Voici ce que nous avons observé dans le notebook :
- 110 527 rendez-vous, tous distincts (un identifiant unique chacun) ;
- 62 299 patients distincts : beaucoup de patients ont donc plusieurs rendez-vous ;
- 14 colonnes : identifiant du patient, identifiant du rendez-vous, genre, date et heure de prise du rendez-vous, date du rendez-vous, âge, quartier, bénéficiaire d’une aide sociale, hypertension, diabète, alcoolisme, handicap, SMS de rappel reçu, et la cible
No-show(« Yes » si le patient ne s’est pas présenté).
Un chiffre manque à cette liste, et il est essentiel : la proportion d’absences. Notre notebook ne l’a jamais calculée. Pour ce jeu de données, le chiffre couramment cité est d’environ 20 % d’absences ; nous le prenons comme un ordre de grandeur connu, non vérifié dans notre travail. Gardez-le en tête pour la suite.
La préparation
Section intitulée « La préparation »Les modèles de scikit-learn ne manipulent que des nombres. Nous avons donc transformé les colonnes ainsi :
- le genre et le quartier ont été encodés en entiers avec
LabelEncoder(un entier par modalité), et la cible en 0 (présent) ou 1 (absent) ; - les deux dates ont été converties en horodatages (secondes écoulées depuis 1970) ;
- nous avons créé une variable,
AppointmentDelay, le délai en jours entre la prise du rendez-vous et le rendez-vous. Comme la date du rendez-vous ne comporte pas d’heure, un rendez-vous pris le jour même donnait un délai légèrement négatif, ramené à 0 ; - la date du rendez-vous a ensuite été remplacée par son numéro de jour dans l’année ;
- les identifiants du patient et du rendez-vous ont été retirés.
Enfin, pour que les comparaisons entre modèles tiennent, nous avons fait un seul découpage apprentissage / test, 75 % / 25 % (82 895 et 27 632 rendez-vous), partagé par tous les modèles.
Les modèles et les résultats
Section intitulée « Les modèles et les résultats »Nous avons pris une forêt aléatoire de 100 arbres comme référence, puis essayé des k plus proches voisins, un Bayes naïf et du boosting, en réglant les hyperparamètres sur une partie de validation tirée du jeu d’apprentissage. Toutes les mesures sont des exactitudes :
| Modèle | Protocole | Exactitude |
|---|---|---|
| Forêt aléatoire (100 arbres) | validation croisée à 4 plis sur tout le jeu | 78,06 % |
| Forêt aléatoire | apprentissage puis test | 79,8 % |
| k-NN, | apprentissage puis test | 79,76 % |
| k-NN, , choisi en validation parmi des valeurs de 1 à 1 000 | apprentissage puis test | 79,77 % |
| Bayes naïf catégoriel, variables catégorielles seules | apprentissage puis test | 79,76 % |
| Bayes naïf catégoriel, âge et délai en tranches, jours de la semaine | moyenne sur 4 découpages apprentissage / validation | 79,08 % |
| AdaBoost de souches, nombre d’estimateurs réglé en validation | meilleur réglage : 1 seul estimateur dans l’exécution conservée | pas de score de test |
Pour le Bayes naïf, qui ne sait pas mélanger variables continues et catégorielles, nous avions discrétisé l’âge (7 tranches, de « enfant » à « très âgé ») et le délai (6 tranches, de « le jour même » à « plus de deux mois »), et remplacé les dates par le jour de la semaine. Pour AdaBoost, la meilleure valeur du nombre d’estimateurs changeait d’une exécution à l’autre, sans jamais dépasser durablement 80 %.
La forêt aléatoire a aussi fourni l’importance des variables (par impureté, voir le chapitre 8) :
| Variable | Importance |
|---|---|
| Délai de prise de rendez-vous (créée par nous) | 26,32 % |
| Date de prise du rendez-vous | 24,66 % |
| Âge | 17,22 % |
| Quartier | 15,82 % |
| Jour du rendez-vous | 9,37 % |
| Genre | 1,92 % |
| SMS reçu | 1,27 % |
| Autres (aide sociale, hypertension, diabète, handicap, alcoolisme) | moins de 1 % chacune |
La conclusion du groupe à l’époque : tous les modèles plafonnent autour de 78 à 80 %, aucun ne dépasse 80 % de façon stable, le k-NN avec un bien choisi est le meilleur, et le boosting n’apporte rien. Quant à la variable que nous avions créée, le délai, elle arrive en tête des importances.
L’analyse critique : ce que ces 80 % veulent vraiment dire
Section intitulée « L’analyse critique : ce que ces 80 % veulent vraiment dire »Relisons ces résultats avec les outils des chapitres 3 à 8. Plusieurs signaux auraient dû nous alerter.
Les 80 % sont probablement la baseline
Section intitulée « Les 80 % sont probablement la baseline »Si environ 20 % des patients ne viennent pas, un « modèle » qui prédit toujours « le patient viendra » obtient environ 80 % d’exactitude sans rien apprendre. Nos modèles font tous entre 78 et 80 % : ils ne battent pas, ou à peine, cette baseline.
Un détail le confirme presque : le k-NN à et le Bayes naïf obtiennent exactement 79,76 % sur le même jeu de test. Deux modèles de natures si différentes qui tombent sur le même score au centième près, c’est le signe probable qu’ils prédisent tous deux « présent » pour (presque) tout le monde, et que 79,76 % est simplement la part de patients présents dans ce jeu de test. Nous ne l’avons pas vérifié à l’époque : une matrice de confusion l’aurait montré immédiatement. Le fait qu’AdaBoost retienne une seule souche va dans le même sens : aucun empilement de modèles ne faisait mieux que la décision la plus simple.
La mauvaise métrique
Section intitulée « La mauvaise métrique »Nous n’avons mesuré que l’exactitude, alors que la classe utile est la classe minoritaire : les absents. Ce qui compte pour le cabinet, c’est le rappel sur les absents (quelle part des absences on anticipe) et la précision (parmi les patients relancés, combien auraient vraiment manqué leur rendez-vous). Nous avons même choisi en maximisant l’exactitude, donc en récompensant les modèles qui se rapprochent de la baseline.
Des défauts de préparation
Section intitulée « Des défauts de préparation »- Pas de normalisation pour le k-NN. Les horodatages valent environ 1,46 milliard de secondes, l’âge quelques dizaines, les variables binaires 0 ou 1. La distance ne voyait pratiquement que la date de prise du rendez-vous (chapitre 6).
- Le quartier codé comme un nombre.
LabelEncoderattribue un entier à chacun des 81 quartiers, dans l’ordre alphabétique : le modèle croit que les quartiers 39 et 40 se ressemblent. Il fallait un encodage one-hot, ou un regroupement pertinent. - Une importance à prendre avec prudence. La date de prise, deuxième variable la plus « importante », est un horodatage à la seconde, avec énormément de valeurs distinctes : exactement ce que l’importance par impureté surestime (chapitre 8).
Des défauts de protocole
Section intitulée « Des défauts de protocole »- Le même patient des deux côtés. Avec 62 299 patients pour 110 527 rendez-vous, un découpage aléatoire place souvent un rendez-vous d’un patient dans l’apprentissage et un autre dans le test. Le modèle peut alors reconnaître le patient plutôt qu’apprendre des régularités générales. Il faut découper par patient (
GroupKFold). - Un découpage non stratifié et non reproductible. La graine aléatoire était l’heure courante (
time.time()) : impossible de refaire exactement la même expérience. Et la validation croisée de la forêt a été faite sur tout le jeu, test compris.
Ce que nous referions aujourd’hui
Section intitulée « Ce que nous referions aujourd’hui »Voici le protocole que nous mettrions en place. Nous ne l’avons pas exécuté dans le cadre du TP : c’est la démarche, pas un résultat.
import pandas as pdfrom sklearn.compose import ColumnTransformerfrom sklearn.dummy import DummyClassifierfrom sklearn.linear_model import LogisticRegressionfrom sklearn.model_selection import StratifiedGroupKFold, cross_validatefrom sklearn.pipeline import make_pipelinefrom sklearn.preprocessing import OneHotEncoder, StandardScaler
df = pd.read_csv("no_show.csv", parse_dates=["ScheduledDay", "AppointmentDay"])y = (df["No-show"] == "Yes").astype(int)groupes = df["PatientId"] # pour découper par patient
# Délai en jours (dates ramenées à minuit) et jour de la semaine du rendez-vousdf["Delai"] = (df["AppointmentDay"].dt.normalize() - df["ScheduledDay"].dt.normalize()).dt.days.clip(lower=0)df["JourSemaine"] = df["AppointmentDay"].dt.dayofweek
numeriques = ["Age", "Delai"]binaires = ["Scholarship", "Hipertension", "Diabetes", "Alcoholism", "Handcap", "SMS_received"]categorielles = ["Gender", "Neighbourhood", "JourSemaine"]X = df[numeriques + binaires + categorielles]
prep = ColumnTransformer([ ("num", StandardScaler(), numeriques), # normalisation ("bin", "passthrough", binaires), ("cat", OneHotEncoder(handle_unknown="ignore"), categorielles), # one-hot])modele = make_pipeline(prep, LogisticRegression(class_weight="balanced", max_iter=1000))
cv = StratifiedGroupKFold(n_splits=5, shuffle=True, random_state=0)metriques = ["accuracy", "recall", "precision", "average_precision", "roc_auc"]for nom, m in [("baseline", DummyClassifier(strategy="most_frequent")), ("logistique", modele)]: r = cross_validate(m, X, y, cv=cv, groups=groupes, scoring=metriques) print(nom, {k: round(float(r[f"test_{k}"].mean()), 3) for k in metriques})Les choix, un par un :
- une baseline évaluée avec le même protocole et les mêmes métriques ;
- des métriques centrées sur les absents : rappel, précision, aire sous la courbe précision-rappel (
average_precision) et AUC ROC ; - un découpage stratifié et groupé par patient ;
- un pipeline : normalisation et encodage sont appris dans chaque pli, sans fuite ;
class_weight="balanced", qui donne plus de poids aux absents pendant l’apprentissage, pour que le modèle cesse de les ignorer ;- ensuite seulement, des modèles plus puissants (gradient boosting, chapitre 8) et de meilleures variables, comme le nombre d’absences passées du patient, calculé uniquement à partir des rendez-vous antérieurs (sinon, c’est une fuite) ;
- enfin, un seuil de décision fixé d’après les coûts : le prix d’un créneau perdu face à celui d’une relance inutile.
Le raconter en entretien
Section intitulée « Le raconter en entretien »Ce récit suit une structure simple, qui marche pour n’importe quel projet : le problème, la démarche, le résultat, puis le recul. C’est souvent cette dernière partie qui fait la différence.
En résumé
Section intitulée « En résumé »- Le projet : prédire les absences sur 110 527 rendez-vous médicaux (62 299 patients), avec un seul découpage 75 / 25 partagé par tous les modèles.
- Les résultats du TP : une forêt aléatoire à 79,8 % en test, des k-NN à 79,76 % et 79,77 %, un Bayes naïf à 79,76 %, AdaBoost sans gain ; le délai de prise de rendez-vous, créé par nous, en tête des importances.
- Avec environ 20 % d’absents, ces scores sont proches de la baseline « tout le monde vient » ; deux modèles au même score exact suggèrent qu’ils prédisent presque toujours « présent ».
- Il fallait mesurer le rappel et la précision sur les absents, pas seulement l’exactitude.
- Défauts à corriger : absence de normalisation pour le k-NN, quartier codé comme un nombre, même patient dans l’apprentissage et le test, graine non reproductible.
- La bonne démarche : baseline, métriques métier, découpage stratifié et groupé, pipeline,
class_weight, puis seuil choisi selon les coûts.
Exercices
Section intitulée « Exercices »Exercice · Interpréter un score face à la baseline
facileDans un jeu de test de 27 632 rendez-vous, on suppose que 20,24 % des patients ne sont pas venus.
- Quelle exactitude obtient le modèle qui prédit toujours « présent » ?
- Un modèle obtient 79,76 %. Que pouvez-vous en dire ? Que faudrait-il regarder pour conclure ?
Voir la solution
- Le modèle constant a raison pour tous les présents, soit % des rendez-vous (environ 22 040 sur 27 632).
- Le modèle fait exactement le score de la baseline. C’est compatible avec un modèle qui prédit « présent » pour tout le monde, mais aussi, en théorie, avec un modèle qui repère quelques absents au prix d’autant de fausses alertes. Pour conclure, il faut la matrice de confusion : le nombre d’absents prédits, le rappel et la précision sur la classe « absent ». Si le rappel est nul, le modèle n’a rien appris d’utile.
En code
import numpy as npfrom sklearn.metrics import accuracy_score, recall_score
n = 27_632n_absents = round(n * 0.2024) # 5 593 absents, 22 039 présentsy = np.array([1] * n_absents + [0] * (n - n_absents))
toujours_present = np.zeros(n, dtype=int)print(round(accuracy_score(y, toujours_present), 4), recall_score(y, toujours_present)) # 0.7976 0.0
# Un autre modèle : signale 100 patients, dont 50 vrais absentsautre = toujours_present.copy()autre[:50] = 1 # 50 absents repérésautre[-50:] = 1 # 50 fausses alertesprint(round(accuracy_score(y, autre), 4), round(recall_score(y, autre), 4)) # 0.7976 0.0089n <- 27632n_absents <- round(n * 0.2024) # 5 593 absents, 22 039 présentsy <- c(rep(1, n_absents), rep(0, n - n_absents))
mesures <- function(pred) c(exactitude = round(mean(pred == y), 4), rappel = round(sum(pred == 1 & y == 1) / sum(y == 1), 4))
toujours_present <- rep(0, n)mesures(toujours_present) # exactitude 0.7976, rappel 0
# Un autre modèle : signale 100 patients, dont 50 vrais absentsautre <- toujours_presentautre[1:50] <- 1 # 50 absents repérésautre[(n - 49):n] <- 1 # 50 fausses alertesmesures(autre) # exactitude 0.7976, rappel 0.0089Exercice · Une variable qui fuit
moyenPour améliorer le modèle, un collègue ajoute la variable « taux d’absence du patient », calculée ainsi : pour chaque patient, le nombre de ses rendez-vous manqués divisé par son nombre total de rendez-vous, sur tout le jeu de données. Le rappel bondit de 0,30 à 0,75. Pourquoi faut-il s’en méfier, et comment corriger ?
Voir la solution
C’est une fuite de données. Le taux est calculé sur tous les rendez-vous du patient, y compris celui qu’on cherche à prédire et ceux qui ont lieu après lui. Pour un patient qui n’a qu’un rendez-vous, la variable vaut directement la cible (0 ou 1) ! En production, au moment de prédire un rendez-vous futur, on ne connaît évidemment pas son issue.
Correction : calculer la variable uniquement à partir des rendez-vous antérieurs à celui qu’on prédit (par exemple, le nombre et la proportion d’absences passées, avec une valeur par défaut pour un premier rendez-vous), en triant les rendez-vous par date. Et découper par patient, ou mieux, dans l’ordre chronologique, pour que l’évaluation ressemble à l’usage réel.
En code
import numpy as npimport pandas as pdfrom sklearn.metrics import roc_auc_score
rng = np.random.default_rng(0)n = 20_000rdv = pd.DataFrame({"patient": rng.integers(0, 8_000, n), "date": rng.permutation(n)})rdv["absent"] = (rng.random(n) < 0.2).astype(int) # absences au hasard : rien à prédirerdv = rdv.sort_values(["patient", "date"])par_patient = rdv.groupby("patient")["absent"]
# Variable qui fuit : taux calculé sur tous les rendez-vous du patient, celui-ci comprisrdv["taux_fuite"] = par_patient.transform("mean")
# Variable correcte : absences des rendez-vous antérieurs seulement (0,2 par défaut)absences_passees = par_patient.cumsum() - rdv["absent"]nb_passes = rdv.groupby("patient").cumcount()rdv["taux_passe"] = (absences_passees / nb_passes).fillna(0.2)
print(round(roc_auc_score(rdv["absent"], rdv["taux_fuite"]), 3)) # 0.886 : trop beauprint(round(roc_auc_score(rdv["absent"], rdv["taux_passe"]), 3)) # 0.488 : le hasard, comme il se doitset.seed(0)n <- 20000rdv <- data.frame(patient = sample(0:7999, n, replace = TRUE), date = sample(n))rdv$absent <- as.integer(runif(n) < 0.2) # absences au hasard : rien à prédirerdv <- rdv[order(rdv$patient, rdv$date), ]
# Variable qui fuit : taux calculé sur tous les rendez-vous du patient, celui-ci comprisrdv$taux_fuite <- ave(rdv$absent, rdv$patient, FUN = mean)
# Variable correcte : absences des rendez-vous antérieurs seulement (0,2 par défaut)passees <- ave(rdv$absent, rdv$patient, FUN = cumsum) - rdv$absentnb_passes <- ave(rdv$absent, rdv$patient, FUN = seq_along) - 1rdv$taux_passe <- ifelse(nb_passes > 0, passees / nb_passes, 0.2)
auc <- function(y, s) { # AUC par les rangs (Mann-Whitney) r <- rank(s); n1 <- sum(y); n0 <- length(y) - n1 (sum(r[y == 1]) - n1 * (n1 + 1) / 2) / (n1 * n0)}round(c(fuite = auc(rdv$absent, rdv$taux_fuite), passe = auc(rdv$absent, rdv$taux_passe)), 3)# fuite 0.890 : trop beau ; passe 0.507 : le hasard, comme il se doitExercice · Choisir un seuil selon les coûts
difficileLe cabinet envisage d’appeler les patients à risque la veille. Un créneau perdu coûte 50 €. Un appel coûte 2 € (temps de secrétariat), et on estime qu’il évite l’absence dans 30 % des cas où le patient allait manquer son rendez-vous. Le modèle fournit, pour chaque rendez-vous, une probabilité d’absence (supposée bien calibrée).
- Pour quelles valeurs de l’appel est-il rentable en moyenne ?
- Pourquoi ce seuil est-il très différent de 0,5 ? Quelle propriété du modèle devient importante ?
Voir la solution
- Appeler coûte 2 € à coup sûr. Le gain moyen de l’appel est la probabilité d’absence, multipliée par la probabilité que l’appel l’évite, multipliée par le coût d’un créneau perdu : euros. L’appel est rentable si , soit . On appelle donc tous les patients dont la probabilité d’absence dépasse environ 13 %.
- Le seuil de 0,5 minimise le nombre d’erreurs en supposant que toutes les erreurs coûtent pareil. Ici, un faux positif (appel inutile, 2 €) coûte bien moins qu’un faux négatif évitable (jusqu’à 50 €) : il est rationnel d’accepter beaucoup de fausses alertes. Mais ce raisonnement suppose que soit une vraie probabilité : il faut donc un modèle bien calibré, ce qui se vérifie (courbe de calibration) et se corrige si besoin. Avec
class_weight="balanced", par exemple, les probabilités sont déformées vers le haut et doivent être recalibrées avant d’appliquer ce seuil.
En code
import numpy as np
cout_appel, cout_creneau, efficacite = 2, 50, 0.3seuil = cout_appel / (efficacite * cout_creneau) # gain 15p > 2 <=> p > 2/15print(round(seuil, 3)) # 0.133
# Simulation : 100 000 rendez-vous avec des probabilités bien calibréesrng = np.random.default_rng(0)p = rng.beta(2, 8, 100_000) # 20 % d'absences en moyenneabsent = rng.random(100_000) < p
for s in [0.05, seuil, 0.5]: appeles = p > s evites = rng.random(appeles.sum()) < efficacite # l'appel évite 30 % des absences gain = cout_creneau * (absent[appeles] & evites).sum() - cout_appel * appeles.sum() print(f"seuil {s:.3f} : {appeles.sum()} appels, gain net {gain} €")# seuil 0.050 : 92920 appels, gain net 105660 €# seuil 0.133 : 65752 appels, gain net 120746 € (le meilleur des trois)# seuil 0.500 : 1969 appels, gain net 10262 €cout_appel <- 2; cout_creneau <- 50; efficacite <- 0.3seuil <- cout_appel / (efficacite * cout_creneau) # gain 15p > 2 <=> p > 2/15round(seuil, 3) # 0.133
# Simulation : 100 000 rendez-vous avec des probabilités bien calibréesset.seed(0)p <- rbeta(1e5, 2, 8) # 20 % d'absences en moyenneabsent <- runif(1e5) < p
for (s in c(0.05, seuil, 0.5)) { appeles <- p > s evites <- runif(sum(appeles)) < efficacite # l'appel évite 30 % des absences gain <- cout_creneau * sum(absent[appeles] & evites) - cout_appel * sum(appeles) cat("seuil", round(s, 3), ":", sum(appeles), "appels, gain net", gain, "€\n")}# seuil 0.05 : 92837 appels, gain net 107276 €# seuil 0.133 : 65711 appels, gain net 127178 € (le meilleur des trois)# seuil 0.5 : 1910 appels, gain net 11530 €