nantesmetropole / school_meal_forecast_xgboost

MIT License
4 stars 4 forks source link

Use regex instead of litteral matching to solve menu misclassifications (aka. "the mystery of the vegan bolognese") #4

Open fBedecarrats opened 2 years ago

fBedecarrats commented 2 years ago

On constate que l'application https://github.com/nantesmetropole/school_meal_forecast_xgboost/ des "accidents de prédiction" entraînent des sous-estimations rares mais d'une ampleur significative (plusieurs centaines de repas). Cela obligeait initialement à prendre des marges d'erreur assez importante pour minimiser le risque de ces accidents, ce qui dégradait la performance des modèles (gaspillage > 10%). Après l'analyse, le problème de l'outil prédictif ne venait en fait pas de la modélisation, mais de l'étape de pré-traitement des données : certains menus étaient classifiés par erreur comme "avec viande". alors qu'ils étaient "végétarien" ou avec "poisson". Du coup les modèles n'anticipaient pas dans ces cas-là la hausse de fréquentation associé au végétarien (+400 convives environ) ou au poisson (+800 convives environ).

Par exemple :

L'origine du problème se situe à la dernière ligne de :

dict_special_dishes = {}
    with open(os.path.join(data_path, "calculators/menus.json")) as f_in:
        dict_special_dishes = json.load(f_in)

    for spcial_menu, list_of_food in dict_special_dishes.items():
        menus[spcial_menu] = menus['plat']
        menus[spcial_menu] = menus[spcial_menu].apply(lambda plat: any(word in plat.lower() for word in list_of_food))

Ce problème vient du fait qu'on utilise la fonction in qui exécue une analyse de correspondance littérale, ce qui provoque des erreurs de classifications pour certains menus ambigus (ex. "bolognaise végétarienne"), qui se traduisent par des "accidents de prédictions" (plusieurs centaines de repas manquants dans ces cas, heureusement rares). On envisage 3 options :

On crée un test pour comparer le rendu de ce code avec le précédent. Le résultat doit être en l'état identique. On commence donc par générer et stocker le produit actuel du processus de classification des menus :

library(tidyverse)

reticulate::source_python("main.py")

# Cette fonction exécute la fonction python smarter_process_data, pour rappel ses arguments :
# smarter_process_data(args.data_path, min_date, args.end_date, school_cafeterias, include_wednesday, date_format)
run_preprocess <- function(data_path = "tests/data",
                           min_date = '2018-09-02',
                           end_date = '2019-07-07',
                           school_cafeterias = NULL, 
                           include_wednesday = FALSE,
                           date_format = "%Y-%m-%d") {
  # On passe les arguments à pyton au travers d'une classe
  args <- reticulate::PyClass(classname = "arguments", 
                              defs = list(
                                data_path = data_path,
                                end_date = end_date))
  smarter_process_data(args$data_path, min_date, args$end_date, school_cafeterias, include_wednesday, date_format)
}

preprocess_file_path <- function(start_d =  "2018-09-02", end_d = "2019-07-07") {
  paste0("output/staging/prepared_data_", start_d, "_", end_d, ".csv")
}

start_d <- "2018-09-02"
end_d <- "2019-07-07"
run_preprocess(min_date = start_d, end_date = end_d)
out_initial <- readr::read_csv(preprocess_file_path(start_d, end_d))

initial_classif <- out_initial %>%
  select(date_str, info_menu:dessert) %>%
  unique

write_csv(initial_classif, "initial_classif.csv")

Après analyse, la modification qu'on souhaite apporter au code python est reportée ci-dessous (l'original est commenté). Cela consiste à utiliser re.search à la place de in.

dict_special_dishes = {}
    with open(os.path.join(data_path, "calculators/menus.json")) as f_in:
        dict_special_dishes = json.load(f_in)

    for spcial_menu, list_of_food in dict_special_dishes.items():
        menus[spcial_menu] = menus['plat']
       ## Section modifée ---------------------------------------------------------------------------------------------------
       # menus[spcial_menu] = menus[spcial_menu].apply(lambda plat: any(word in plat.lower() for word in list_of_food))
        menus[spcial_menu] = menus[spcial_menu].apply(lambda plat: any(re.search(re.escape(word), plat.lower()) for word in list_of_food))
      ## Fin section modifiée -----------------------------------------------------------------------------------------------

On génère une version mofifiée avec regex (au préalable on edémarre la session R et on s'assure que l'environnement python n'a rien conservé pour s'assurer qu'on n'a pas d'interférences avec le test précédent).

library(tidyverse)
replace_in_file <- function(target_file = "test.py", initial_str = "blabla", new_str = "blibli") {
  read_lines(target_file) %>%
    str_replace(initial_str, new_str) %>%
    writeLines(target_file)
}

initial = "menus\\[spcial_menu\\] = menus\\[spcial_menu\\]\\.apply\\(lambda plat\\: any\\(word in plat\\.lower\\(\\) for word in list_of_food\\)\\)"
new = "menus\\[spcial_menu\\] = menus\\[spcial_menu\\]\\.apply\\(lambda plat: any\\(re\\.search\\(re.escape\\(word\\), plat\\.lower\\(\\)\\) for word in list_of_food\\)\\)"
replace_in_file(target_file = "app/calculators/process_menu.py", initial_str = initial, new_str = new)

reticulate::source_python("main.py")

# On relance la fonction run_preprocess pour être sûrs que les modifications seront prises en compte
run_preprocess <- function(data_path = "tests/data",
                           min_date = '2018-09-02',
                           end_date = '2019-07-07',
                           school_cafeterias = NULL, 
                           include_wednesday = FALSE,
                           date_format = "%Y-%m-%d") {
  # On passe les arguments à pyton au travers d'une classe
  args <- reticulate::PyClass(classname = "arguments", 
                              defs = list(
                                data_path = data_path,
                                end_date = end_date))
  smarter_process_data(args$data_path, min_date, args$end_date, school_cafeterias, include_wednesday, date_format)
}

preprocess_file_path <- function(start_d =  "2018-09-02", end_d = "2019-07-07") {
  paste0("output/staging/prepared_data_", start_d, "_", end_d, ".csv")
}

start_d <- "2018-09-02"
end_d <- "2019-07-07"
run_preprocess(min_date = start_d, end_date = end_d)
out_modif <- readr::read_csv(preprocess_file_path(start_d, end_d))

modif_classif <- out_modif %>%
  select(date_str, info_menu:dessert) %>%
  unique

write_csv(modif_classif, "modif_classif.csv")

# On remet comme au départ
replace_in_file(target_file = "app/calculators/process_menu.py", initial_str = new, new_str = initial)

On compare maintenant les deux versions.

initial_classif <- read_csv("initial_classif.csv")
waldo::compare(initial_classif, modif_classif)

{waldo} confirme que les résultats sont bien identique. La ligne revue dans le code python pour la classification des menus génère donc le même résultat qu'avant.
On va donc maintenant pouvoir utiliser la puissance des expressions régulière pour résoudre des ambiguités telles que le cas de notre bolognaise végétarienne. Illustration :

import re
plat1 = "bolognaise vegetale"
plat2 = "bolognaise végétariene"
word1 = "bolognaise"
word2 = "bolognaise(?!végéta|vegeta)"

# Ces termes sont pris avec la formule précédente, comme par'in'
bool(re.search(re.escape(word1), plat1.lower()))
bool(re.search(re.escape(word1), plat2.lower()))

# mais les formes modifiées en regex permetten maintenant d'éviter ces erreurs
bool(re.search(re.escape(word2), plat1.lower()))
bool(re.search(re.escape(word2), plat2.lower()))

Ces modifs ont été apportées à la version de travail de fBedecarrats. Elles seront incluses dans la prochaine pull request vers ce repo.