Tutoriel: comparer trois modèles sans utiliser le futur

Régression logistique, arbre, forêt et validation temporelle

Objectif

Ce tutoriel construit une comparaison reproductible entre une régression logistique, un arbre de décision et une forêt aléatoire. La question n’est pas seulement de trouver la plus grande aire ROC. Il faut définir le futur à prédire, empêcher les fuites, répéter l’évaluation dans le temps, examiner la stabilité, réserver un test final et relier le choix à un mandat.

Les données décrivent 18 000 demandes de services citoyennes 311 créées à Montréal en 2024. La cible pédagogique distingue les demandes dont le dernier statut enregistré correspond ou non à une demande terminée dans les sept jours suivant la création. Cette fenêtre n’est pas une norme officielle de service. Elle fournit une définition opérationnelle commune pour apprendre la modélisation prédictive.

Carte théorique

Le tutoriel suit sept décisions:

  1. définir l’unité, la cible et le moment de prédiction;
  2. séparer l’information disponible de l’information future;
  3. réserver un test futur et construire des fenêtres temporelles;
  4. appliquer le même prétraitement aux trois candidats;
  5. comparer discrimination, précision-rappel, Brier et stabilité;
  6. ouvrir le test une seule fois;
  7. documenter le choix, la calibration, les limites et la surveillance.

Un modèle flexible n’est pas automatiquement meilleur. Il peut mieux représenter des relations non linéaires, mais aussi exploiter des particularités qui ne persistent pas. Une comparaison honnête doit donc donner aux candidats le même budget de données et une question identique.

Préparer l’environnement

# Load libraries
library(tidyverse)
library(tidymodels)
library(rpart)

# Set a common visual theme
theme_set(theme_minimal(base_size = 16))

tidymodels charge notamment rsample, recipes, parsnip, workflows, workflowsets, tune et yardstick. Le moteur rpart ajuste l’arbre. Le moteur ranger, appelé plus loin par parsnip, ajuste la forêt aléatoire.

1. Charger et documenter les données

requetes_ml <- read_csv(
  "data/requetes_311_montreal_2024_eiom.csv",
  show_col_types = FALSE
) |>
  mutate(
    date_creation = as.Date(date_creation),
    verite = factor(
      issue_7_jours,
      levels = c("non_terminee_7_jours", "terminee_7_jours")
    )
  ) |>
  arrange(date_creation, identifiant_requete)

glimpse(requetes_ml)
Rows: 18,000
Columns: 16
$ identifiant_requete        <chr> "24-103", "24-35", "24-57", "24-71", "24-72…
$ date_creation              <date> 2024-01-01, 2024-01-01, 2024-01-01, 2024-0…
$ mois_creation              <chr> "Janvier", "Janvier", "Janvier", "Janvier",…
$ jour_semaine               <chr> "Lundi", "Lundi", "Lundi", "Lundi", "Lundi"…
$ plage_horaire              <chr> "Soir, 18 h à 23 h 59", "Matin, 6 h à 11 h …
$ activite                   <chr> "Feux de circulation - Entretien", "Collect…
$ type_lieu                  <chr> "Intersection", "Adresse", "Adresse", "Inte…
$ arrondissement             <chr> "Mercier-Hochelaga-Maisonneuve", "L'Île-Biz…
$ provenance                 <chr> "Téléphone", "Téléphone", "Téléphone", "Tél…
$ dernier_statut             <chr> "Terminée", "Terminée", "Terminée", "Termin…
$ date_dernier_statut        <date> 2024-01-01, 2024-01-18, 2024-01-03, 2024-0…
$ delai_dernier_statut_jours <dbl> 0.036, 17.053, 1.833, 0.018, 2.754, 2.689, …
$ issue_7_jours              <chr> "terminee_7_jours", "non_terminee_7_jours",…
$ source_page_url            <chr> "https://www.donneesquebec.ca/recherche/dat…
$ access_date                <date> 2026-07-14, 2026-07-14, 2026-07-14, 2026-0…
$ verite                     <fct> terminee_7_jours, non_terminee_7_jours, ter…

Une ligne représente une demande de service. Le fichier ne décrit pas une personne et ne mesure pas directement la qualité du service. Les processus, les catégories et le transfert de certaines demandes peuvent varier selon le temps ou le territoire.

Vérifions les principaux repères:

resume_source <- requetes_ml |>
  summarise(
    lignes = n(),
    premiere_date = min(date_creation),
    derniere_date = max(date_creation),
    activites = n_distinct(activite),
    territoires = n_distinct(arrondissement),
    canaux = n_distinct(provenance)
  )

resume_source
# A tibble: 1 × 6
  lignes premiere_date derniere_date activites territoires canaux
   <int> <date>        <date>            <int>       <int>  <int>
1  18000 2024-01-01    2024-12-31          351          27      9

Le fichier contient 18 000 demandes, 351 activités, 27 valeurs territoriales et 9 canaux de provenance. Ces cardinalités expliquent pourquoi le prétraitement devra traiter les catégories rares et nouvelles.

2. Définir la cible et le moment de prédiction

La question prédictive est:

Au moment où une demande est créée, quelle est la probabilité que son dernier statut enregistré ne corresponde pas à une demande terminée dans les sept jours?

Cette formulation fixe quatre éléments:

Élément Définition
unité une demande de l’échantillon
moment date de création
événement positif non_terminee_7_jours
sortie du modèle probabilité conditionnelle

L’ordre des niveaux du facteur verite place l’événement positif en premier. Cette convention sera utilisée par yardstick avec event_level = "first".

3. Reconnaître la fuite d’information

La réponse est construite en observant ce qui arrive après la création. Cette observation future est nécessaire pour étiqueter les lignes historiques. Elle ne peut pas devenir une information prédictive disponible au moment annoncé.

Variables permises:

  • jour_semaine;
  • plage_horaire;
  • activite;
  • type_lieu;
  • arrondissement;
  • provenance.

Variables interdites comme prédicteurs:

  • dernier_statut;
  • date_dernier_statut;
  • delai_dernier_statut_jours;
  • issue_7_jours, qui est la réponse source.

Une fuite peut produire une performance spectaculaire tout en détruisant la validité opérationnelle. La bonne question n’est pas « cette colonne prédit-elle bien? », mais « sa valeur serait-elle connue lorsque la probabilité doit être produite? ».

4. Observer le taux de l’événement dans le temps

frequence_mensuelle <- requetes_ml |>
  mutate(mois = floor_date(date_creation, unit = "month")) |>
  summarise(
    demandes = n(),
    proportion_non_terminee = mean(verite == "non_terminee_7_jours"),
    .by = mois
  )

frequence_mensuelle
# A tibble: 12 × 3
   mois       demandes proportion_non_terminee
   <date>        <int>                   <dbl>
 1 2024-01-01     1437                   0.347
 2 2024-02-01      985                   0.380
 3 2024-03-01     1044                   0.428
 4 2024-04-01     1812                   0.475
 5 2024-05-01     1915                   0.502
 6 2024-06-01     1805                   0.482
 7 2024-07-01     1894                   0.460
 8 2024-08-01     1816                   0.422
 9 2024-09-01     1643                   0.411
10 2024-10-01     1471                   0.405
11 2024-11-01     1238                   0.397
12 2024-12-01      940                   0.382
ggplot(
  frequence_mensuelle,
  aes(mois, proportion_non_terminee)
) +
  geom_line(linewidth = 0.9, color = "#0f7c80") +
  geom_point(size = 2.5, color = "#0f7c80") +
  scale_y_continuous(labels = scales::label_percent()) +
  labs(
    x = NULL,
    y = "Proportion non terminée en sept jours"
  )

La fréquence de la cible varie entre les mois. Cette variation peut refléter la composition des demandes, des processus, des saisons ou d’autres mécanismes non observés. Le graphique justifie une évaluation qui respecte l’ordre temporel. Il ne fournit pas une explication causale de la variation.

5. Réserver un test futur

Les demandes d’octobre à décembre forment le test final. Les demandes antérieures servent à construire et comparer les candidats.

date_coupure <- as.Date("2024-10-01")

entrainement_ml <- requetes_ml |>
  filter(date_creation < date_coupure)

test_ml <- requetes_ml |>
  filter(date_creation >= date_coupure)

resume_partage <- requetes_ml |>
  mutate(
    ensemble = if_else(
      date_creation < date_coupure,
      "Entraînement",
      "Test futur"
    )
  ) |>
  summarise(
    n = n(),
    debut = min(date_creation),
    fin = max(date_creation),
    proportion_non_terminee = mean(verite == "non_terminee_7_jours"),
    .by = ensemble
  )

resume_partage
# A tibble: 2 × 5
  ensemble         n debut      fin        proportion_non_terminee
  <chr>        <int> <date>     <date>                       <dbl>
1 Entraînement 14351 2024-01-01 2024-09-30                   0.441
2 Test futur    3649 2024-10-01 2024-12-31                   0.396

L’entraînement contient 14 351 demandes de janvier à septembre, dont 44,1 % appartiennent à l’événement positif. Le test contient 3 649 demandes d’octobre à décembre, dont 39,6 % appartiennent à cet événement.

Le test ne sert ni à choisir les prédicteurs, ni à régler les modèles, ni à sélectionner le gagnant initial. Il répond à une question confirmatoire après que ces décisions sont écrites. Tidy Modeling with R insiste sur cette séparation entre le test et les opérations de sélection réalisées dans l’entraînement (M. Kuhn et J. Silge, 2022).

6. Construire une validation temporelle

Une seule séparation de l’entraînement serait sensible au mois choisi. Nous créons cinq fenêtres. Chaque fenêtre apprend sur quatre mois consécutifs et évalue le mois suivant.

plis_temporels <- sliding_period(
  entrainement_ml,
  index = date_creation,
  period = "month",
  lookback = 3,
  assess_stop = 1,
  complete = TRUE,
  step = 1
)

plis_temporels
# Sliding period resampling 
# A tibble: 5 × 2
  splits              id    
  <list>              <chr> 
1 <split [5278/1915]> Slice1
2 <split [5756/1805]> Slice2
3 <split [6576/1894]> Slice3
4 <split [7426/1816]> Slice4
5 <split [7430/1643]> Slice5

Avec sliding_period(), le groupe temporel courant appartient à l’analyse. lookback = 3 ajoute les trois groupes antérieurs, ce qui produit quatre mois d’apprentissage. assess_stop = 1 réserve le groupe suivant à l’évaluation.

resume_plis <- tibble(
  pli = plis_temporels$id,
  n_entrainement = map_int(
    plis_temporels$splits,
    ~ nrow(analysis(.x))
  ),
  periode_entrainement = map_chr(
    plis_temporels$splits,
    ~ paste(range(analysis(.x)$date_creation), collapse = " au ")
  ),
  n_validation = map_int(
    plis_temporels$splits,
    ~ nrow(assessment(.x))
  ),
  periode_validation = map_chr(
    plis_temporels$splits,
    ~ paste(range(assessment(.x)$date_creation), collapse = " au ")
  )
)

resume_plis
# A tibble: 5 × 5
  pli    n_entrainement periode_entrainement     n_validation periode_validation
  <chr>           <int> <chr>                           <int> <chr>             
1 Slice1           5278 2024-01-01 au 2024-04-30         1915 2024-05-01 au 202…
2 Slice2           5756 2024-02-01 au 2024-05-31         1805 2024-06-01 au 202…
3 Slice3           6576 2024-03-01 au 2024-06-30         1894 2024-07-01 au 202…
4 Slice4           7426 2024-04-01 au 2024-07-31         1816 2024-08-01 au 202…
5 Slice5           7430 2024-05-01 au 2024-08-31         1643 2024-09-01 au 202…

Chaque évaluation est strictement postérieure à son apprentissage. Les mois de validation sont mai, juin, juillet, août et septembre.

7. Comprendre ce que le protocole estime

Une validation aléatoire et une validation temporelle répondent à des questions différentes.

Protocole Question approximative
plis aléatoires le modèle prédit-il une ligne cachée parmi des observations contemporaines?
fenêtres temporelles le modèle entraîné récemment prédit-il le mois suivant?

Le second protocole imite mieux l’usage annoncé. Il révèle aussi la variation de performance entre les mois. Il ne garantit pas que l’année suivante ressemblera à 2024. Il produit une preuve plus pertinente pour cette cible, ce calendrier et ce mécanisme de collecte.

8. Définir un prétraitement commun

recette_ml <- recipe(
  verite ~ jour_semaine + plage_horaire + activite +
    type_lieu + arrondissement + provenance,
  data = entrainement_ml
) |>
  step_novel(all_nominal_predictors(), new_level = "Nouveau") |>
  step_unknown(all_nominal_predictors(), new_level = "Non précisé") |>
  step_other(activite, threshold = 0.01, other = "Autres activités") |>
  step_other(type_lieu, threshold = 0.01, other = "Autres lieux") |>
  step_other(
    arrondissement,
    threshold = 0.005,
    other = "Autres territoires"
  ) |>
  step_other(provenance, threshold = 0.005, other = "Autres canaux") |>
  step_zv(all_predictors())

recette_ml

Les étapes ont des rôles distincts:

  1. step_novel() réserve un niveau pour une catégorie jamais vue à l’apprentissage;
  2. step_unknown() transforme une valeur manquante en niveau explicite;
  3. step_other() regroupe les catégories trop rares selon l’apprentissage;
  4. step_zv() retire un prédicteur sans variation.

La recette n’est pas préparée avec le test ou toutes les validations. Dans fit_resamples(), elle est préparée séparément sur l’analyse de chaque fenêtre. Les fréquences qui déterminent les regroupements restent donc internes à l’apprentissage.

9. Comprendre un arbre de décision

Un arbre divise récursivement l’espace des prédicteurs. À chaque noeud, une règle sépare les observations en groupes plus homogènes par rapport à la cible. Une feuille terminale produit une probabilité à partir des réponses observées dans cette feuille.

Si une feuille contient 30 demandes, dont 18 appartiennent à l’événement positif, la probabilité estimée est 18 sur 30, soit 0,60. Le passage de cette probabilité à une classe exige encore un seuil. L’arbre n’impose pas à lui seul la décision.

Les arbres représentent naturellement des coupures et des interactions. Leur lisibilité peut être utile lorsque chaque prédiction doit être reliée à un chemin. Cette lisibilité ne garantit pas la stabilité: une petite modification des données peut changer une coupure précoce et donc une grande partie de l’arbre.

10. Contrôler le surajustement d’un arbre

Un arbre très profond peut créer des feuilles spécifiques à quelques observations. Son erreur d’entraînement diminue généralement, mais sa performance future peut se dégrader.

Trois paramètres contrôlent la complexité:

Paramètre Rôle
tree_depth limite la longueur des chemins
min_n exige assez de lignes avant une coupure
cost_complexity pénalise une branche dont le gain est trop faible

Le compromis biais-variance fournit une lecture utile. Un arbre peu profond peut manquer une structure réelle. Un arbre profond peut suivre le bruit. La validation hors échantillon, et non l’erreur d’entraînement seule, doit soutenir le choix.

11. Comprendre une forêt aléatoire

Une forêt construit de nombreux arbres à partir de perturbations des observations et des prédicteurs. Chaque arbre voit un rééchantillonnage des lignes. À chaque coupure, seulement un sous-ensemble aléatoire de prédicteurs est candidat. Les probabilités des arbres sont ensuite agrégées (L. Breiman, 2001).

Cette diversité réduit souvent la variance d’un arbre unique. Elle rend toutefois l’explication d’une prédiction plus indirecte. La forêt n’est pas une méthode causalement interprétable et son importance de variable ne mesure pas un effet.

Trois paramètres seront fixés:

  • trees = 200, nombre d’arbres;
  • mtry = 4, prédicteurs candidats à chaque coupure;
  • min_n = 30, taille minimale avant une coupure.

Ajouter des arbres peut stabiliser l’agrégation, mais ne corrige pas une cible mal définie, une fuite ou un protocole qui ne correspond pas à l’usage.

12. Définir les trois candidats

modele_logistique <- logistic_reg() |>
  set_engine("glm")

modele_arbre <- decision_tree(
  cost_complexity = 0.001,
  tree_depth = 10,
  min_n = 30
) |>
  set_engine("rpart") |>
  set_mode("classification")

modele_foret <- rand_forest(
  mtry = 4,
  min_n = 30,
  trees = 200
) |>
  set_engine(
    "ranger",
    num.threads = 2
  ) |>
  set_mode("classification")

modele_foret_final <- rand_forest(
  mtry = 4,
  min_n = 30,
  trees = 200
) |>
  set_engine(
    "ranger",
    importance = "permutation",
    num.threads = 2
  ) |>
  set_mode("classification")

La régression logistique sert de référence structurée. L’arbre ajoute des coupures et des interactions. La forêt ajoute l’agrégation. Les paramètres sont fixés avant le test. Ce tutoriel compare donc trois candidats définis, pas toutes les versions possibles de ces familles.

Les deux spécifications de forêt construisent le même candidat avec les mêmes paramètres et deux fils d’exécution. modele_foret sert aux cinq validations sans calculer une importance inutilisée. modele_foret_final active l’importance par permutation seulement pour l’ajustement final, où elle sera interprétée.

13. Construire les workflows comparables

modeles <- list(
  logistique = modele_logistique,
  arbre = modele_arbre,
  foret = modele_foret
)

ensemble_workflows <- workflow_set(
  preproc = list(pretraitement = recette_ml),
  models = modeles
)

ensemble_workflows
# A workflow set/tibble: 3 × 4
  wflow_id                 info             option    result    
  <chr>                    <list>           <list>    <list>    
1 pretraitement_logistique <tibble [1 × 4]> <opts[0]> <list [0]>
2 pretraitement_arbre      <tibble [1 × 4]> <opts[0]> <list [0]>
3 pretraitement_foret      <tibble [1 × 4]> <opts[0]> <list [0]>

Le prétraitement, les prédicteurs et la cible restent identiques. Seule la spécification du modèle change. Cette symétrie permet de relier une différence de performance à la famille et aux paramètres du candidat plutôt qu’à un avantage caché dans les données.

14. Définir les mesures avant les résultats

mesures_ml <- metric_set(
  roc_auc,
  pr_auc,
  brier_class
)

L’aire ROC mesure la capacité de classement sur tous les seuils. Elle peut être interprétée comme la probabilité qu’une observation positive choisie au hasard reçoive un score supérieur à une observation négative choisie au hasard, sous les conventions de la mesure. Elle n’est pas une exactitude.

L’aire précision-rappel met l’accent sur la récupération de l’événement positif et la précision des prédictions positives. Sa référence dépend de la fréquence de l’événement. Elle est donc utile lorsque l’événement positif et sa prévalence doivent rester visibles.

Le score de Brier est la moyenne de \((y - \widehat p)^2\) pour l’événement positif. Il vaut 0 pour des probabilités parfaites et augmente lorsque les probabilités s’éloignent des résultats. Il combine des aspects de calibration et de résolution, sans remplacer le graphique de calibration.

15. Évaluer dans les mêmes fenêtres

set.seed(20260828)

resultats_validation <- workflow_map(
  ensemble_workflows,
  "fit_resamples",
  resamples = plis_temporels,
  metrics = mesures_ml,
  control = control_resamples(
    save_pred = TRUE,
    save_workflow = TRUE
  )
)

tableau_validation <- collect_metrics(resultats_validation) |>
  transmute(
    modele = recode(
      wflow_id,
      pretraitement_logistique = "Logistique",
      pretraitement_arbre = "Arbre",
      pretraitement_foret = "Forêt"
    ),
    mesure = .metric,
    moyenne = mean,
    erreur_type = std_err
  )

tableau_validation
# A tibble: 9 × 4
  modele     mesure      moyenne erreur_type
  <chr>      <chr>         <dbl>       <dbl>
1 Logistique brier_class   0.214     0.00166
2 Logistique pr_auc        0.663     0.0141 
3 Logistique roc_auc       0.715     0.00359
4 Arbre      brier_class   0.213     0.00242
5 Arbre      pr_auc        0.674     0.0146 
6 Arbre      roc_auc       0.721     0.00315
7 Forêt      brier_class   0.210     0.00336
8 Forêt      pr_auc        0.680     0.0133 
9 Forêt      roc_auc       0.727     0.00724

La forêt est première en moyenne: aire ROC de 0,726, aire précision-rappel de 0,679 et Brier de 0,210. L’arbre suit avec 0,721, 0,674 et 0,213. La logistique obtient 0,715, 0,663 et 0,214.

Ces écarts sont modestes. Une conclusion doit donc considérer la stabilité et le mandat. Les erreurs types résument la variation des cinq estimations, mais ces fenêtres temporelles se chevauchent et ne sont pas indépendantes. Des intervalles calculés mécaniquement avec 1,96 fois l’erreur type seraient des repères descriptifs, pas un test universel de différence entre algorithmes.

16. Examiner la stabilité temporelle

resultats_par_fenetre <- collect_metrics(
  resultats_validation,
  summarize = FALSE
) |>
  transmute(
    modele = recode(
      wflow_id,
      pretraitement_logistique = "Logistique",
      pretraitement_arbre = "Arbre",
      pretraitement_foret = "Forêt"
    ),
    pli = id,
    mesure = .metric,
    valeur = .estimate
  )

resultats_par_fenetre |>
  filter(mesure == "roc_auc") |>
  ggplot(aes(pli, valeur, group = modele, color = modele)) +
  geom_line(linewidth = 0.9) +
  geom_point(size = 2.5) +
  labs(
    x = "Fenêtre de validation",
    y = "Aire ROC",
    color = "Modèle"
  )

stabilite_auc <- resultats_par_fenetre |>
  filter(mesure == "roc_auc") |>
  group_by(modele) |>
  summarise(
    minimum = min(valeur),
    maximum = max(valeur),
    ecart_type = sd(valeur),
    .groups = "drop"
  )

stabilite_auc
# A tibble: 3 × 4
  modele     minimum maximum ecart_type
  <chr>        <dbl>   <dbl>      <dbl>
1 Arbre        0.712   0.731    0.00705
2 Forêt        0.704   0.748    0.0162 
3 Logistique   0.705   0.725    0.00803

La forêt varie de 0,704 à 0,748, avec un écart-type d’environ 0,016. L’arbre varie de 0,712 à 0,731, avec un écart-type de 0,007. La logistique varie de 0,705 à 0,725, avec un écart-type de 0,008.

La forêt obtient donc le meilleur résultat moyen, mais la plus grande variation mensuelle dans ce protocole. Ce résultat ne prouve pas que toutes les forêts sont instables. Il décrit ce modèle, ces paramètres, ces données et ces cinq validations.

17. Choisir selon un mandat

Un modèle ne peut être choisi sans définir ce que l’organisation valorise.

Mandat Critère prioritaire possible
communication scientifique structure globale et limites explicables
tri prédictif expérimental meilleure performance moyenne avec instabilité documentée
règle inspectable chemin de décision lisible ligne par ligne
infrastructure minimale coût d’ajustement et de surveillance faible

Avant le test, une recommandation doit citer au moins deux mesures, la stabilité, un coût et un compromis. Plusieurs recommandations peuvent être cohérentes si les mandats diffèrent. Le test ne doit pas servir à inventer le mandat qui favorise le résultat observé.

18. Ajuster sur tout l’entraînement

Après le choix provisoire, nous ajustons les trois pipelines sur janvier à septembre afin de comparer leurs résultats confirmatoires.

modeles_finaux <- list(
  logistique = modele_logistique,
  arbre = modele_arbre,
  foret = modele_foret_final
)

workflows_ajustes <- map(
  modeles_finaux,
  ~ workflow() |>
    add_recipe(recette_ml) |>
    add_model(.x) |>
    fit(data = entrainement_ml)
)

Dans une procédure réelle, seuls les modèles nécessaires au plan d’analyse pourraient être ajustés. Nous conservons les trois ici pour comprendre les écarts sans effectuer de nouveau réglage.

19. Ouvrir le test futur une seule fois

predictions_finales <- imap_dfr(
  workflows_ajustes,
  function(ajustement, nom_modele) {
    bind_cols(
      test_ml |>
        select(date_creation, verite),
      predict(
        ajustement,
        new_data = test_ml,
        type = "prob"
      )
    ) |>
      mutate(modele = nom_modele)
  }
)

resultats_test <- predictions_finales |>
  group_by(modele) |>
  mesures_ml(
    truth = verite,
    .pred_non_terminee_7_jours,
    event_level = "first"
  ) |>
  ungroup()

resultats_test
# A tibble: 9 × 4
  modele     .metric     .estimator .estimate
  <chr>      <chr>       <chr>          <dbl>
1 arbre      roc_auc     binary         0.710
2 foret      roc_auc     binary         0.729
3 logistique roc_auc     binary         0.699
4 arbre      pr_auc      binary         0.631
5 foret      pr_auc      binary         0.640
6 logistique pr_auc      binary         0.600
7 arbre      brier_class binary         0.207
8 foret      brier_class binary         0.205
9 logistique brier_class binary         0.212

Sur le test, la forêt obtient une aire ROC de 0,730, une aire précision-rappel de 0,640 et un Brier de 0,205. L’arbre obtient 0,710, 0,631 et 0,207. La logistique obtient 0,699, 0,600 et 0,212.

La forêt reste première sur les trois mesures. La recommandation finale dépend toutefois du mandat écrit avant le test. Si le test conduit à modifier le candidat ou les paramètres, cette nouvelle décision devra être confirmée sur une période future indépendante. Le même test ne peut pas servir à modifier et à confirmer le pipeline.

20. Comparer validation et test

comparaison_validation_test <- bind_rows(
  tableau_validation |>
    transmute(
      modele,
      mesure,
      ensemble = "Validation",
      valeur = moyenne
    ),
  resultats_test |>
    transmute(
      modele = recode(
        modele,
        logistique = "Logistique",
        arbre = "Arbre",
        foret = "Forêt"
      ),
      mesure = .metric,
      ensemble = "Test futur",
      valeur = .estimate
    )
)

comparaison_validation_test |>
  ggplot(aes(ensemble, valeur, color = modele, group = modele)) +
  geom_line(linewidth = 0.9) +
  geom_point(size = 2.5) +
  facet_wrap(~ mesure, scales = "free_y") +
  labs(x = NULL, y = "Valeur", color = "Modèle")

Les aires diminuent davantage pour la logistique et l’arbre que pour la forêt. Le Brier du test est plus faible pour les trois candidats. Cette amélioration ne doit pas être attribuée uniquement au modèle, puisque la proportion de l’événement et la composition des demandes changent aussi.

21. Examiner la calibration

La discrimination et la calibration répondent à des questions différentes. Un modèle peut bien classer les demandes tout en produisant des probabilités trop élevées ou trop faibles.

calibration_modeles <- predictions_finales |>
  group_by(modele) |>
  mutate(groupe = ntile(.pred_non_terminee_7_jours, 10)) |>
  group_by(modele, groupe) |>
  summarise(
    probabilite_moyenne = mean(.pred_non_terminee_7_jours),
    frequence_observee = mean(verite == "non_terminee_7_jours"),
    n = n(),
    .groups = "drop"
  )

ggplot(
  calibration_modeles,
  aes(probabilite_moyenne, frequence_observee, color = modele)
) +
  geom_abline(
    slope = 1,
    intercept = 0,
    linetype = 2,
    color = "#52606d"
  ) +
  geom_line() +
  geom_point() +
  coord_equal(xlim = c(0, 1), ylim = c(0, 1)) +
  labs(
    x = "Probabilité moyenne prédite",
    y = "Fréquence observée",
    color = "Modèle"
  )

La diagonale représente une correspondance parfaite entre probabilité moyenne et fréquence observée dans chaque groupe. Plusieurs points de probabilité élevée se trouvent sous la diagonale, ce qui indique une surestimation locale dans ce test. Les dix groupes rendent le graphique lisible, mais leur choix reste descriptif et dépend de la taille du test.

22. Vérifier manuellement le Brier

verification_brier <- predictions_finales |>
  mutate(
    indicatrice = as.numeric(verite == "non_terminee_7_jours"),
    erreur_carree = (
      indicatrice - .pred_non_terminee_7_jours
    )^2
  ) |>
  group_by(modele) |>
  summarise(brier_manuel = mean(erreur_carree), .groups = "drop")

verification_brier
# A tibble: 3 × 2
  modele     brier_manuel
  <chr>             <dbl>
1 arbre             0.207
2 foret             0.205
3 logistique        0.212

Les résultats manuels correspondent à brier_class(). Cette vérification est importante parce que la probabilité sélectionnée doit correspondre au niveau défini comme événement. La documentation officielle de yardstick précise cette convention pour les réponses binaires.

23. Visualiser l’arbre final

arbre_final <- extract_fit_engine(workflows_ajustes$arbre)

par(mar = c(1, 1, 2, 1), xpd = TRUE)
plot(arbre_final, uniform = TRUE, margin = 0.08)
text(arbre_final, use.n = TRUE, cex = 0.58)

L’arbre permet de suivre une suite de règles. Les nombres dans les feuilles décrivent les observations d’entraînement associées à ces règles. Ils ne prouvent pas qu’une coupure serait stable dans une autre année ou qu’elle représente un mécanisme causal.

L’objet contient 20 coupures avec les paramètres retenus. Une autre pénalité, une autre profondeur ou un autre échantillon pourrait produire une structure différente. La lisibilité doit donc être accompagnée d’une preuve de stabilité.

24. Examiner l’importance de la forêt

importance_foret <- extract_fit_engine(
  workflows_ajustes$foret
)$variable.importance |>
  enframe(name = "variable", value = "importance") |>
  arrange(desc(importance))

importance_foret
# A tibble: 6 × 2
  variable       importance
  <chr>               <dbl>
1 activite          0.0751 
2 arrondissement    0.0565 
3 provenance        0.0126 
4 type_lieu         0.00451
5 plage_horaire     0.00267
6 jour_semaine      0.00195
importance_foret |>
  ggplot(aes(importance, fct_reorder(variable, importance))) +
  geom_col(fill = "#0f7c80") +
  labs(x = "Importance par permutation", y = NULL)

L’activité et l’arrondissement produisent les plus grandes pertes de performance lorsqu’ils sont permutés. L’importance peut être partagée entre des prédicteurs redondants ou corrélés et dépend de la mesure et du modèle. Elle ne donne pas le sens d’une relation, ne mesure pas un effet causal et ne hiérarchise pas la valeur des services ou des territoires.

25. Vérifier les performances par période et sous-groupe

Une moyenne globale peut cacher une dégradation localisée. Avant tout usage réel, il faudrait examiner les volumes, la fréquence de la cible, les mesures et la calibration selon des regroupements définis à l’avance.

Exemple de préparation d’un audit mensuel pour la forêt:

predictions_foret <- predictions_finales |>
  filter(modele == "foret") |>
  mutate(mois = floor_date(date_creation, unit = "month"))

audit_mensuel_foret <- predictions_foret |>
  group_by(mois) |>
  summarise(
    n = n(),
    frequence_observee = mean(verite == "non_terminee_7_jours"),
    probabilite_moyenne = mean(.pred_non_terminee_7_jours),
    brier = mean(
      (
        as.numeric(verite == "non_terminee_7_jours") -
          .pred_non_terminee_7_jours
      )^2
    ),
    .groups = "drop"
  )

audit_mensuel_foret
# A tibble: 3 × 5
  mois           n frequence_observee probabilite_moyenne brier
  <date>     <int>              <dbl>               <dbl> <dbl>
1 2024-10-01  1471              0.405               0.445 0.208
2 2024-11-01  1238              0.397               0.426 0.199
3 2024-12-01   940              0.382               0.420 0.208

Une ventilation par arrondissement ou activité demanderait de conserver ces colonnes lors de la construction des prédictions. Les groupes de faible taille devraient être signalés. Un écart descriptif n’établit pas une cause ou une discrimination institutionnelle, mais peut déclencher une investigation.

26. Construire une fiche de modèle

Une fiche concise doit contenir:

  1. la question, l’unité et la population cible;
  2. le moment de prédiction et les variables interdites;
  3. les périodes d’apprentissage, de validation et de test;
  4. le prétraitement, les candidats et les paramètres;
  5. les mesures moyennes et leur stabilité;
  6. la décision selon le mandat et le compromis accepté;
  7. les limites, la surveillance et la condition de retrait.

Une recommandation qui utilise le test pour changer de modèle doit conserver la trace de la décision initiale. Le candidat révisé n’est pas confirmé par ce même test.

27. Définir une surveillance et une condition de retrait

La surveillance peut être organisée en quatre niveaux:

Niveau Exemples
données volume, catégories nouvelles, valeurs manquantes
cible fréquence non terminée, définition du statut
prédictions distribution des scores, groupes extrêmes
performance aire ROC, aire précision-rappel, Brier, calibration

Une règle de retrait doit contenir une mesure, une période, un seuil et une action. Par exemple:

Si le Brier mensuel dépasse 0,24 pendant deux mois consécutifs, suspendre les scores et réexaminer les données, la cible et la calibration.

Le seuil 0,24 est un exemple pédagogique, pas une norme universelle. Dans un projet réel, il devrait être relié à une référence, aux conséquences des erreurs et au mandat.

28. Conditions de validité et limites

Les conclusions restent conditionnelles à plusieurs éléments:

  1. l’échantillon de demandes créé en 2024;
  2. la définition pédagogique de la cible;
  3. la disponibilité correcte des prédicteurs à la création;
  4. la stabilité du processus de collecte et de statut;
  5. la pertinence des fenêtres de quatre mois pour l’usage visé;
  6. le nombre limité de validations mensuelles;
  7. les paramètres fixés des trois candidats;
  8. l’absence d’une analyse complète par sous-groupe;
  9. l’absence de conclusion causale.

Une performance future observée en 2024 ne garantit pas une performance identique en 2025 ou 2026. Le pipeline doit être réévalué si les catégories, les processus, la cible ou le mandat changent.

29. Extensions possibles

Une suite plus avancée pourrait:

  1. régler cost_complexity, tree_depth, mtry et min_n à l’intérieur des fenêtres;
  2. comparer des fenêtres mobiles et expansives;
  3. utiliser une évaluation imbriquée pour séparer réglage et comparaison;
  4. ajouter des courbes précision-rappel et des intervalles par rééchantillonnage;
  5. recalibrer un candidat à partir de données réservées;
  6. étudier la dérive des prédicteurs;
  7. construire un plan de surveillance par sous-groupe;
  8. comparer le gain prédictif à un modèle de référence simple.

Ces extensions exigent un nouveau budget de données. Le test d’octobre à décembre ne doit pas devenir une ressource illimitée de réglage.

Lectures complémentaires

Lecture Utilité
Breiman, Random Forests construction et motivation originale des forêts aléatoires
Tidy Modeling with R, rééchantillonnage rôle du test, analyses et évaluations répétées
Documentation de sliding_period() fenêtres basées sur une période et signification de lookback
Documentation de decision_tree() profondeur, taille minimale et pénalité de complexité
Documentation de rand_forest() nombre d’arbres, mtry et min_n
Documentation de brier_class() orientation de l’événement et interprétation du score
Documentation de step_novel() traitement des catégories futures inconnues
Documentation de step_unknown() traitement explicite des catégories manquantes
Documentation de step_other() regroupement des catégories rares
Données ouvertes 311 de Montréal provenance et documentation de la source

Conclusion

Le résultat principal n’est pas qu’une forêt obtient ici la meilleure moyenne et reste première sur le test. La compétence transférable est le protocole: définir le moment de prédiction, protéger le futur, appliquer les mêmes règles, examiner moyenne et stabilité, puis relier le choix à un mandat et à une surveillance.

Un modèle défendable n’est pas seulement un objet ajusté. C’est une décision documentée dont les données, les limites, les conditions d’usage et les raisons de retrait sont explicites.

Les références

L. Breiman. (2001). Random Forests. Machine Learning. https://doi.org/10.1023/A:1010933404324.
M. Kuhn et J. Silge. (2022). Tidy Modeling with R. https://www.tmwr.org/.