Aller au contenu

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.

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.

présents : environ 80 %absents :≈ 20 %« tout le monde vient » a raison ici : environ 80 % d’exactitude sans rien apprendrela classe utile, celle qu’on veut repérer ↑
Avec environ 20 % d'absents (ordre de grandeur cité pour ce jeu, non vérifié dans notre notebook), l'exactitude part de 80 % avant tout apprentissage, et la classe qui intéresse le cabinet est la plus petite.

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.

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èleProtocoleExactitude
Forêt aléatoire (100 arbres)validation croisée à 4 plis sur tout le jeu78,06 %
Forêt aléatoireapprentissage puis test79,8 %
k-NN, k=70k = 70apprentissage puis test79,76 %
k-NN, k=60k = 60, choisi en validation parmi des valeurs de 1 à 1 000apprentissage puis test79,77 %
Bayes naïf catégoriel, variables catégorielles seulesapprentissage puis test79,76 %
Bayes naïf catégoriel, âge et délai en tranches, jours de la semainemoyenne sur 4 découpages apprentissage / validation79,08 %
AdaBoost de souches, nombre d’estimateurs réglé en validationmeilleur réglage : 1 seul estimateur dans l’exécution conservéepas 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) :

VariableImportance
Délai de prise de rendez-vous (créée par nous)26,32 %
Date de prise du rendez-vous24,66 %
Âge17,22 %
Quartier15,82 %
Jour du rendez-vous9,37 %
Genre1,92 %
SMS reçu1,27 %
Autres (aide sociale, hypertension, diabète, handicap, alcoolisme)moins de 1 % chacune
0510152025303540importance par impureté (%)Délai de prise (créé par nous)Date de prise du rendez-vousÂgeQuartierJour du rendez-vousGenreSMS reçu26,32 %24,66 %17,22 %15,82 %9,37 %1,92 %1,27 %← horodatage à la seconde : la MDI le surestimeaide sociale, hypertension, diabète,handicap, alcoolisme : < 1 % chacune
Le classement de la forêt aléatoire. La deuxième variable, un horodatage à la seconde aux valeurs presque toutes distinctes, est exactement le cas que l'importance par impureté surestime (chapitre 8).

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

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 à k=70k = 70 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.

77,578,078,579,079,580,080,581,081,5exactitude (%)Forêt aléatoire, validation croisée 4 plisForêt aléatoire, testk-NN k = 70, testk-NN k = 60 (choisi en validation), testBayes naïf, variables catégorielles, testBayes naïf, tranches, 4 découpagesbaseline « tout le monde vient »≈ 80 % (ordre de grandeur)78,06 %79,80 %79,76 %79,77 %79,76 %79,08 %en ambre : deux modèles très différents au même score exact, 79,76 %
Tous les modèles se serrent sous la baseline d'environ 80 %. Les deux points ambre, un k-NN et un Bayes naïf, tombent au même score au centième près : le signe probable qu'ils prédisent presque toujours « présent ».

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 kk en maximisant l’exactitude, donc en récompensant les modèles qui se rapprochent de la baseline.

  • 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. LabelEncoder attribue 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).
11 0001 million1 milliardordre de grandeur des valeurs (échelle logarithmique)date de prise(secondes depuis 1970)âge (ans)variables binaires≈ 1,46 milliardquelques dizaines0 ou 1
Neuf ordres de grandeur séparent l'horodatage des variables binaires : dans une distance euclidienne sans normalisation, seules les dates comptent.
  • 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.
patient 1des deux côtéspatient 2patient 3patient 4des deux côtéspatient 5patient 6patient 7apprentissagetestDécoupage aléatoire des rendez-vouspatient 1patient 2patient 3patient 4patient 5patient 6patient 7chaque patient d’un seul côtéDécoupage par patient (GroupKFold)
Un découpage aléatoire des rendez-vous met souvent un même patient en apprentissage et en test ; découper par patient garantit que le test ne contient que des patients inconnus du modèle.

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 pd
from sklearn.compose import ColumnTransformer
from sklearn.dummy import DummyClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedGroupKFold, cross_validate
from sklearn.pipeline import make_pipeline
from 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-vous
df["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.
pipeline, réapprisdans chaque plidonnées110 527 rdvdécoupagestratifié etgroupé par patientnormalisationencodage one-hotmodèleclass_weightmétriquessur les absentsseuilselon les coûtsà comparer à la baseline, même protocolerappel, précision,aire PR, AUCStratifiedGroupKFold
Le protocole que nous referions, dans l'ordre. Tout ce qui apprend quelque chose des données (normalisation, encodage, modèle) est dans le pipeline, donc réappris sur la seule part d'apprentissage de chaque pli.

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.

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

Exercice · Interpréter un score face à la baseline

facile

Dans un jeu de test de 27 632 rendez-vous, on suppose que 20,24 % des patients ne sont pas venus.

  1. Quelle exactitude obtient le modèle qui prédit toujours « présent » ?
  2. Un modèle obtient 79,76 %. Que pouvez-vous en dire ? Que faudrait-il regarder pour conclure ?
Voir la solution
  1. Le modèle constant a raison pour tous les présents, soit 100−20,24=79,76100 - 20{,}24 = 79{,}76 % des rendez-vous (environ 22 040 sur 27 632).
  2. 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 np
from sklearn.metrics import accuracy_score, recall_score
n = 27_632
n_absents = round(n * 0.2024) # 5 593 absents, 22 039 présents
y = 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 absents
autre = toujours_present.copy()
autre[:50] = 1 # 50 absents repérés
autre[-50:] = 1 # 50 fausses alertes
print(round(accuracy_score(y, autre), 4), round(recall_score(y, autre), 4)) # 0.7976 0.0089

Exercice · Une variable qui fuit

moyen

Pour 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 np
import pandas as pd
from sklearn.metrics import roc_auc_score
rng = np.random.default_rng(0)
n = 20_000
rdv = 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édire
rdv = 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 compris
rdv["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 beau
print(round(roc_auc_score(rdv["absent"], rdv["taux_passe"]), 3)) # 0.488 : le hasard, comme il se doit

Exercice · Choisir un seuil selon les coûts

difficile

Le 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 pp (supposée bien calibrée).

  1. Pour quelles valeurs de pp l’appel est-il rentable en moyenne ?
  2. Pourquoi ce seuil est-il très différent de 0,5 ? Quelle propriété du modèle devient importante ?
Voir la solution
  1. 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 : p×0,3×50=15pp \times 0{,}3 \times 50 = 15p euros. L’appel est rentable si 15p>215p > 2, soit p>2/15≈0,13p > 2/15 \approx 0{,}13. On appelle donc tous les patients dont la probabilité d’absence dépasse environ 13 %.
  2. 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 pp 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.3
seuil = cout_appel / (efficacite * cout_creneau) # gain 15p > 2 <=> p > 2/15
print(round(seuil, 3)) # 0.133
# Simulation : 100 000 rendez-vous avec des probabilités bien calibrées
rng = np.random.default_rng(0)
p = rng.beta(2, 8, 100_000) # 20 % d'absences en moyenne
absent = 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 €