with open("toto.yaml", "r", encoding="utf-8") as stream:
= yaml.safe_load(stream) dict_config
Application
Une application fil rouge pour illustrer l’intérêt d’appliquer graduellement les bonnes pratiques dans une optique de mise en production d’une application de data science.
Dérouler les slides ci-dessous ou cliquer ici pour afficher les slides en plein écran.
L’objectif de cette mise en application est d’illustrer les différentes étapes qui séparent la phase de développement d’un projet de celle de la mise en production. Elle permettra de mettre en pratique les différents concepts présentés tout au long du cours.
Celle-ci est un tutoriel pas à pas pour avoir un projet reproductible et disponible sous plusieurs livrables. Toutes les étapes ne sont pas indispensables à tous les projets de data science.
Nous nous plaçons dans une situation initiale correspondant à la fin de la phase de développement d’un projet de data science. On a un notebook un peu monolithique, qui réalise les étapes classiques d’un pipeline de machine learning :
- Import de données ;
- Statistiques descriptives et visualisations ;
- Feature engineering ;
- Entraînement d’un modèle ;
- Evaluation du modèle.
L’objectif est d’améliorer le projet de manière incrémentale jusqu’à pouvoir le mettre en production, en le valorisant sous une forme adaptée.
Illustration de notre point de départ
Illustration de l’horizon vers lequel on se dirige
Il est important de bien lire les consignes et d’y aller progressivement. Certaines étapes peuvent être rapides, d’autres plus fastidieuses ; certaines être assez guidées, d’autres vous laisser plus de liberté. Si vous n’effectuez pas une étape, vous risquez de ne pas pouvoir passer à l’étape suivante qui en dépend.
Bien que l’exercice soit applicable sur toute configuration bien faite, nous recommandons de privilégier l’utilisation du SSP Cloud, où tous les outils nécessaires sont pré-installés et pré-configurés. Le service VSCode
ne sera en effet que le point d’entrée pour l’utilisation d’outils plus exigeants sur le plan de l’infrastructure: Argo, MLFLow, etc.
Partie 0 : initialisation du projet
Les premières étapes consistent à mettre en place son environnement de travail sur Github
:
Générer un jeton d’accès (token) sur
GitHub
afin de permettre l’authentification en ligne de commande à votre compte. La procédure est décrite ici. Vous ne voyez ce jeton qu’une fois, ne fermez pas la page de suite.Mettez de côté ce jeton en l’enregistrant dans un gestionnaire de mot de passe ou dans l’espace “Mon compte” du
SSP Cloud
.Forker le dépôt
Github
: https://github.com/ensae-reproductibilite/application-correction en faisant attention à deux choses:- Décocher la case “Copy the
main
branch only” afin de copier également les tagsGit
qui nous permettront de faire les checkpoint
- Décocher la case “Copy the
Ce que vous devriez voir sur la page de création du fork
Il est maintenant possible de ce lancer dans la création de l’environnement de travail:
- Ouvrir un service
VSCode
sur le SSP Cloud. Vous pouvez aller dans la pageMy Services
et cliquer surNew service
. Sinon, vous pouvez initialiser la création du service en cliquant directement ici. Modifier les options suivantes:- Dans l’onglet
Kubernetes
, sélectionner le rôleAdmin
; - Dans l’onglet
Networking
, cliquer sur “Enable a custom service port” et laisser la valeur par défaut 5000 pour le numéro du port
- Dans l’onglet
- Clôner votre dépôt
Github
en utilisant le terminal depuisVisual Studio
(Terminal > New Terminal
) et en passant directement le token dans l’URL selon cette structure:
terminal
$ git clone https://$TOKEN@github.com/$USERNAME/application-correction.git
où $TOKEN
et $USERNAME
sont à remplacer, respectivement, par le jeton que vous avez généré précédemment et votre nom d’utilisateur.
- Se placer avec le terminal dans le dossier en question :
terminal
$ cd application-correction
- Se placer sur une branche de travail en faisant:
terminal
$ git checkout -b dev
Partie 1 : qualité du script
Cette première partie vise à rendre le projet conforme aux bonnes pratiques présentées dans le cours.
Elle fait intervenir les notions suivantes :
- Utilisation du terminal (voir Linux 101) ;
- Qualité du code (voir Qualité du code) ;
- Architecture de projets (voir Architecture des projets) ;
- Contrôle de version avec
Git
(voir RappelsGit
) ; - Travail collaboratif avec
Git
etGitHub
(voir RappelsGit
).
Le plan de la partie est le suivant :
- S’assurer que le script fonctionne ;
- Nettoyer le code des scories formelles avec un linter et un formatter ;
- Paramétrisation du script ;
- Utilisation de fonctions.
Étape 1 : s’assurer que le script s’exécute correctement
On va partir du fichier notebook.py
qui reprend le contenu du notebook1 mais dans un script classique. Le travail de nettoyage en sera facilité.
La première étape est simple, mais souvent oubliée : vérifier que le code fonctionne correctement. Pour cela, nous recommandons de faire un aller-retour entre le script ouvert dans VSCode
et un terminal pour le lancer.
- Ouvrir dans
VSCode
le scripttitanic.py
; - Exécuter le script en ligne de commande (
python titanic.py
)2 pour détecter les erreurs ; - Corriger les deux erreurs qui empêchent la bonne exécution ;
- Vérifier le fonctionnement du script en utilisant la ligne de commande:
terminal
$ python titanic.py
Le code devrait afficher des sorties.
Aide sur les erreurs rencontrées
La première erreur rencontrée est une alerte FileNotFoundError
, la seconde est liée à un package.
Il est maintenant temps de commit les changements effectués avec Git
3 :
terminal
$ git add titanic.py
$ git commit -m "Corrige l'erreur qui empêchait l'exécution"
$ git push
terminal
1$ git stash
$ git checkout appli1
- 1
- Pour annuler les modifications depuis le dernier commit
Étape 2: utiliser un linter puis un formatter
On va maintenant améliorer la qualité de notre code en appliquant les standards communautaires. Pour cela, on va utiliser le linter classique PyLint
et le formatter Black
. Si vous désirez un outil deux en un, il est possible d’utiliser Ruff
en complément ou substitut.
Ce nettoyage automatique du code permettra, au passage, de restructurer notre script de manière plus naturelle.
Le linter renvoie alors une série d’irrégularités, en précisant à chaque fois la ligne de l’erreur et le message d’erreur associé (ex : mauvaise identation). Il renvoie finalement une note sur 10, qui estime la qualité du code à l’aune des standards communautaires évoqués dans la partie Qualité du code.
- Diagnostiquer et évaluer la qualité de
titanic.py
avecPyLint
. Regarder la note obtenue. - Utiliser
black titanic.py --diff --color
pour observer les changements de forme que va induire l’utilisation du formatterBlack
. Cette étape n’applique pas les modifications, elle ne fait que vous les montrer. - Appliquer le formatter
Black
- Réutiliser
PyLint
pour diagnostiquer l’amélioration de la qualité du script et le travail qui reste à faire. - Comme la majorité du travail restant est à consacrer aux imports:
- Délimiter des parties dans votre code pour rendre sa structure plus lisible. Si des parties vous semblent être dans le désordre, vous pouvez réordonner le script (mais n’oubliez pas de le tester)
terminal
1$ git stash
$ git checkout appli2
- 1
- Pour annuler les modifications depuis le dernier commit
Le code est maintenant lisible, il obtient à ce stade une note formelle proche de 10. Mais il n’est pas encore totalement intelligible ou fiable. Il y a notamment quelques redondances de code auxquelles nous allons nous attaquer par la suite. Néanmoins, avant cela, occupons-nous de mieux gérer certains paramètres du script: jetons d’API et chemin des fichiers.
Étape 3: gestion des paramètres
L’exécution du code et les résultats obtenus dépendent de certains paramètres définis dans le code. L’étude de résultats alternatifs, en jouant sur des variantes des (hyper)paramètres, est à ce stade compliquée car il est nécessaire de parcourir le code pour trouver ces paramètres. De plus, certains paramètres personnels comme des jetons d’API ou des mots de passe n’ont pas vocation à être présents dans le code.
Il est plus judicieux de considérer ces paramètres comme des variables d’entrée du script. Cela peut être fait de deux manières:
- Avec des arguments optionnels appelés depuis la ligne de commande (Application 3a). Cela peut être pratique pour mettre en oeuvre des tests automatisés mais n’est pas forcément pertinent pour toutes les variables. Nous allons montrer cet usage avec le nombre d’arbres de notre random forest ;
- En utilisant un fichier de configuration dont les valeurs sont importées dans le script principal (Application 3b).
Un exemple de définition d’un argument pour l’utilisation en ligne de commande
prenom.py
import argparse
= argparse.ArgumentParser(description="Qui êtes-vous?")
parser
parser.add_argument("--prenom", type=str, default="Toto", help="Un prénom à afficher"
)= parser.parse_args()
args print(args.prenom)
Exemples d’utilisations en ligne de commande
terminal
$ python prenom.py
$ python prenom.py --prenom "Zinedine"
- En s’inspirant de l’exemple ci-dessus 👆️, créer une variable
n_trees
qui peut éventuellement être paramétrée en ligne de commande et dont la valeur par défaut est 20 ; - Tester cette paramétrisation en ligne de commande avec la valeur par défaut puis 2, 10 et 50 arbres.
L’exercice suivant permet de mettre en application le fait de paramétriser un script en utilisant des variables définies dans un fichier YAML.
Nous allons mettre 4 paramètres dans notre YAML. Celui-ci prendra la forme suivante:
config.yaml
jeton_api: ####
data_path: ####
Avec ####
des valeurs à remplacer.
- Créer à la racine du projet un fichier
config.yaml
à partir du modèle 👆️ ; - Repérer les valeurs dans le code associé et compléter.
Maintenant, nous allons exploiter ce fichier:
- Pour éviter d’avoir à le faire plus tard, créer une fonction
import_yaml_config
qui prend en argument le chemin d’un fichierYAML
et renvoie le contenu de celui-ci en output. Vous pouvez suivre le conseil du chapitre sur la Qualité du code en adoptant le type hinting ;
Indice si vous ne trouvez pas comment lire un fichier YAML
Si le fichier s’appelle toto.yaml
, vous pouvez l’importer de cette manière:
- Dans la fonction
import_yaml_config
, créer une condition logique pour tenir compte du fait que le YAML de configuration peut ne pas exister4 ;
Indice si vous ne savez comment conditionner la création de la configuration à l’existence du fichier
Voici la ligne qui peut vous aider. L’idéal est d’insérer ceci dans import_yaml_config
:
= 'config.yaml'
CONFIG_PATH = {}
config if os.path.exists(CONFIG_PATH):
# lecture du fichier
- Utiliser le canevas de code suivant pour créer les variables adéquates
= config.get("jeton_api")
API_TOKEN = config.get("train_path", "train.csv")
TRAIN_PATH = config.get("test_path", "test.csv")
TEST_PATH = config.get("test_fraction", .1) TEST_FRACTION
et remplacer dans le code ;
- Tester en ligne de commande que l’exécution du fichier est toujours sans erreur et sinon corriger ;
- Refaire un diagnostic avec
PyLint
et corriger les éventuels messages ; - Créer un fichier
.gitignore
(cf. ChapitreGit
). Ajouter dans ce fichierconfig.yaml
car il ne faut pas committer ce fichier. Au passage ajouter__pycache__/
au.gitignore
5, cela évitera d’avoir à le faire ultérieurement ; - Créer un fichier
README.md
où vous indiquez qu’il faut créer un fichierconfig.yaml
pour pouvoir utiliser l’API.
terminal
1$ git stash
$ git checkout appli3
- 1
- Pour annuler les modifications depuis le dernier commit
Étape 4 : Privilégier la programmation fonctionnelle
Nous allons mettre en fonctions les parties importantes de l’analyse. Ceci facilitera l’étape ultérieure de modularisation de notre projet.
Cet exercice étant chronophage, il n’est pas obligatoire de le réaliser en entier. L’important est de comprendre la démarche et d’adopter fréquemment une approche fonctionnelle6. Pour obtenir une chaine entièrement fonctionnalisée, vous pouvez reprendre le checkpoint.
Cette application peut être chronophage, vous pouvez aller plus ou moins loin dans la fonctionalisation de votre script en fonction du temps dont vous disposez.
- Créer une fonction générique pour réduire la redondance de code dans l’étape d’exploration des données où on utilise
split
; - Créer une fonction qui réalise le split train/test en fonction d’un paramètre représentant la proportion de l’échantillon de test et d’arguments optionnels sur les chemins d’écriture des deux échantillons en csv.
- Créer une fonction qui intègre les différentes étapes du pipeline (preprocessing et définition du modèle). Cette fonction prend en paramètre le nombre d’arbres (argument obligatoire) et des arguments optionnels supplémentaires (les colonnes sur lesquelles s’appliquent les différentes étapes du pipeline,
max_depth
etmax_features
). - Créer une fonction d’évaluation renvoyant le score obtenu et la matrice de confusion, à l’issue d’une estimation (mais cette estimation est faite en amont de la fonction, pas au sein de celle-ci)
- Déplacer toutes les fonctions ensemble, en début de script.
Le fait d’appliquer des fonctions a déjà amélioré la fiabilité du processus en réduisant le nombre d’erreurs de copier-coller. Néanmoins, pour vraiment fiabiliser le processus, il faudrait utiliser un pipeline de transformations de données.
Ceci n’est pas encore au programme du cours mais le sera dans une prochaine version.
terminal
1$ git stash
$ git checkout appli4
- 1
- Pour annuler les modifications depuis le dernier commit
Cela ne se remarque pas encore vraiment car nous avons de nombreuses définitions de fonctions mais notre chaine de production est beaucoup plus concise (le script fait environ 300 lignes dont 250 de définitions de fonctions génériques). Cette auto-discipline facilitera grandement les étapes ultérieures. Cela aurait été néanmoins beaucoup moins coûteux en temps d’adopter ces bons gestes de manière plus précoce.
Partie 2 : adoption d’une structure modulaire
Dans la partie précédente, on a appliqué de manière incrémentale de nombreuses bonnes pratiques vues tout au long du cours. Ce faisant, on s’est déjà considérablement rapprochés d’un possible partage du code : celui-ci est lisible et intelligible. Le code est proprement versionné sur un dépôt GitHub
. Cependant, le projet est encore perfectible: il est encore difficile de rentrer dedans si on ne sait pas exactement ce qu’on recherche. L’objectif de cette partie est d’isoler les différentes étapes de notre pipeline. Outre le gain de clarté pour notre projet, nous économiserons beaucoup de peines pour la mise en production ultérieure de notre modèle.
Illustration de l’état actuel du projet
Dans cette partie nous allons continuer les améliorations incrémentales de notre projet avec les étapes suivantes:
- Modularisation du code
Python
pour séparer les différentes étapes de notre pipeline ; - Adopter une structure standardisée pour notre projet afin d’autodocumenter l’organisation de celui-ci ;
- Documenter les packages indispensables à l’exécution du code ;
- Stocker les données dans un environnement adéquat afin de continuer la démarche de séparer conceptuellement les données du code en de la configuration.
Étape 1 : modularisation
Nous allons profiter de la modularisation pour adopter une structure applicative pour notre code. Celui-ci n’étant en effet plus lancé que depuis la ligne de commande, on peut considérer qu’on construit une application générique où un script principal (main.py
) encapsule des éléments issus d’autres scripts Python
.
- Déplacer les fonctions dans une série de fichiers dédiés:
import_data.py
: fonctions d’import et d’exploration de donnéesbuild_features.py
: fonctions regroupant la définition des échantillons d’apprentissage et de test ainsi que le pipelinetrain_evaluate.py
: fonctions d’évaluation du modèle
- Spécifier les dépendances (i.e. les packages à importer) dans les modules pour que ceux-ci puissent s’exécuter indépendamment ;
- Renommer
titanic.py
enmain.py
pour suivre la convention de nommage des projetsPython
; - Importer les fonctions nécessaires à partir des modules.
- Vérifier que tout fonctionne bien en exécutant le script
main
à partir de la ligne de commande :
terminal
$ python main.py
terminal
1$ git stash
$ git checkout appli5
- 1
- Pour annuler les modifications depuis le dernier commit
Étape 2 : adopter une architecture standardisée de projet
On dispose maintenant d’une application Python
fonctionnelle. Néanmoins, le projet est certes plus fiable mais sa structuration laisse à désirer et il serait difficile de rentrer à nouveau dans le projet dans quelques temps.
Etat actuel du projet 🙈
├── .gitignore
├── data.csv
├── train.csv
├── test.csv
├── README.md
├── config.yaml
├── import_data.py
├── build_features.py
├── train_evaluate.py
├── titanic.ipynb
└── main.py
Comme cela est expliqué dans la partie Structure des projets, on va adopter une structure certes arbitraire mais qui va faciliter l’autodocumentation de notre projet. De plus, une telle structure va faciliter des évolutions optionnelles comme la packagisation du projet. Passer d’une structure modulaire bien faite à un package est quasi-immédiat en Python
.
On va donc modifier l’architecture de notre projet pour la rendre plus standardisée. Pour cela, on va s’inspirer des structures cookiecutter
qui génèrent des templates de projet. En l’occurrence notre source d’inspiration sera le template datascience issu d’un effort communautaire.
L’idée de cookiecutter
est de proposer des templates que l’on utilise pour initialiser un projet, afin de bâtir à l’avance une structure évolutive. La syntaxe à utiliser dans ce cas est la suivante :
terminal
$ pip install cookiecutter
$ cookiecutter https://github.com/drivendata/cookiecutter-data-science
Ici, on a déjà un projet, on va donc faire les choses dans l’autre sens : on va s’inspirer de la structure proposée afin de réorganiser celle de notre projet selon les standards communautaires.
En s’inspirant du cookiecutter data science on va adopter la structure suivante:
Structure recommandée
application
├── main.py
├── README.md
├── data
│ ├── raw
│ │ └── data.csv
│ └── derived
│ ├── test.csv
│ └── train.csv
├── configuration
│ └── config.yaml
├── notebooks
│ └── titanic.ipynb
└── src
├── data
│ └── import_data.py
├── pipeline
│ └── build_pipeline.py
└── models
└── train_evaluate.py
- (optionnel) Analyser et comprendre la structure de projet proposée par le template ;
- Modifier l’arborescence du projet selon le modèle ;
- Mettre à jour l’import des dépendances, le fichier de configuration et
main.py
avec les nouveaux chemins ;
terminal
1$ git stash
$ git checkout appli6
- 1
- Pour annuler les modifications depuis le dernier commit
Étape 3: indiquer l’environnement minimal de reproductibilité
Le script main.py
nécessite un certain nombre de packages pour être fonctionnel. Chez vous les packages nécessaires sont bien sûr installés mais êtes-vous assuré que c’est le cas chez la personne qui testera votre code ?
Afin de favoriser la portabilité du projet, il est d’usage de “fixer l’environnement”, c’est-à-dire d’indiquer dans un fichier toutes les dépendances utilisées ainsi que leurs version. Nous proposons de créer un fichier requirements.txt
minimal, sur lequel nous reviendrons dans la partie consacrée aux environnements reproductibles.
Le fichier requirements.txt
est conventionnellement localisé à la racine du projet. Ici on ne va pas fixer les versions, on raffinera ce fichier ultérieurement.
requirements.txt
- Créer un fichier
requirements.txt
avec la liste des packages nécessaires - Ajouter une indication dans
README.md
sur l’installation des packages grâce au fichierrequirements.txt
terminal
1$ git stash
$ git checkout appli7
- 1
- Pour annuler les modifications depuis le dernier commit
Étape 4 : stocker les données de manière externe
S3
Pour mettre en oeuvre cette étape, il peut être utile de comprendre un peu comme fonctionne le SSP Cloud. Vous devrez suivre la documentation du SSP Cloud pour la réaliser. Une aide-mémoire est également disponible dans le cours de 2e année de l’ENSAE Python pour la data science.
Le chapitre sur la structure des projets développe l’idée qu’il est recommandé de converger vers un modèle où environnements d’exécution, de stockage du code et des données sont conceptuellement séparés. Ce haut niveau d’exigence est un gain de temps important lors de la mise en production car au cours de cette dernière, le projet est amené à être exécuté sur une infrastructure informatique dédiée qu’il est bon d’anticiper.
A l’heure actuelle, les données sont stockées dans le dépôt. C’est une mauvaise pratique. En premier lieu, Git
n’est techniquement pas bien adapté au stockage de données. Ici ce n’est pas très grave car il ne s’agit pas de données volumineuses et ces dernières ne sont pas modifiées au cours de notre chaine de traitement. La raison principale est que les données traitées par les data scientists sont généralement soumises à des clauses de confidentialités (RGPD, secret statistique…). Mettre ces données sous contrôle de version c’est prendre le risque de les divulguer à un public non habilité. Il est donc recommandé de privilégier des outils techniques adaptés au stockage de données.
L’idéal, dans notre cas, est d’utiliser une solution de stockage externe. On va utiliser pour cela MinIO
, la solution de stockage de type S3
offerte par le SSP Cloud. Cela nous permettra de supprimer les données de Github
tout en maintenant la reproductibilité de notre projet 7.
A partir de la ligne de commande, utiliser l’utilitaire MinIO pour copier les données data/raw/data.csv
vers votre bucket personnel. Les données intermédiaires peuvent être laissées en local mais doivent être ajoutées au .gitignore
.
Indice
Structure à adopter:
terminal
$ mc cp data/raw/data.csv s3/$BUCKET_PERSONNEL/ensae-reproductibilite/data/raw/data.csv
$BUCKET_PERSONNEL
, l’emplacement de votre bucket personnel
Pour se simplifier la vie, on va utiliser des URL de téléchargement des fichiers (comme si ceux-ci étaient sur n’importe quel espace de stockage) plutôt que d’utiliser une librairie S3
compatible comme boto3
ou s3fs
. Par défaut, le contenu de votre bucket est privé, seul vous y avez accès. Néanmoins, vous pouvez rendre accessible à tous en lecture le contenu de votre bucket en faisant lui donnant des droits anonymes. Pour cela, en ligne de commande, faire:
terminal
$ mc anonymous set download s3/$BUCKET_PERSONNEL/ensae-reproductibilite/data/raw/
en modifiant $BUCKET_PERSONNEL
. Les URL de téléchargement seront de la forme https://minio.lab.sspcloud.fr/$BUCKET_PERSONNEL/ensae-reproductibilite/data/raw/data.csv
- Modifier
configuration/config.yaml
pour utiliser directement les URL dans l’import ; - Modifier les valeurs par défaut dans votre code ;
- Ajouter le dossier
data/
au.gitignore
- Supprimer le dossier
data
de votre projet et faitesgit rm --cached -r data
- Vérifier le bon fonctionnement de votre application.
Maintenant qu’on a arrangé la structure de notre projet, c’est l’occasion de supprimer le code qui n’est plus nécessaire au bon fonctionnement de notre projet (cela réduit la charge de maintenance8).
Pour vous aider, vous pouvez utiliser vulture
de manière itérative pour vous assister dans le nettoyage de votre code.
terminal
$ vulture main.py src/
Exemple de sortie
terminal
$ vulture main.py src/
21: unused variable 'jeton_api' (60% confidence)
main.py:36: unused variable 'ticket_count' (60% confidence)
main.py:37: unused variable 'name_count' (60% confidence) main.py:
terminal
1$ git stash
$ git checkout appli8
- 1
- Pour annuler les modifications depuis le dernier commit
Partie 2bis: packagisation de son projet (optionnel)
Cette série d’actions n’est pas forcément pertinente pour tous les projets. Elle fait un peu la transition entre la modularité et la portabilité.
Étape 1 : proposer des tests unitaires (optionnel)
Notre code comporte un certain nombre de fonctions génériques. On peut vouloir tester leur usage sur des données standardisées, différentes de celles du Titanic.
Même si la notion de tests unitaires prend plus de sens dans un package, nous pouvons proposer dans le projet des exemples d’utilisation de la fonction, ceci peut être pédagogique.
Nous allons utiliser unittest
pour effectuer des tests unitaires. Cette approche nécessite quelques notions de programmation orientée objet ou une bonne discussion avec ChatGPT
.
Dans le dossier tests/
, créer avec l’aide de ChatGPT
ou de Copilot
un test pour la fonction split_and_count
.
- Effectuer le test unitaire en ligne de commande avec
unittest
(python -m unittest tests/test_split.py
). Corriger le test unitaire en cas d’erreur. - Si le temps le permet, proposer des variantes ou d’autres tests.
terminal
1$ git stash
$ git checkout appli9
- 1
- Pour annuler les modifications depuis le dernier commit
Lorsqu’on effectue des tests unitaires, on cherche généralement à tester le plus de lignes possibles de son code. On parle de taux de couverture (coverage rate) pour désigner la statistique mesurant cela.
Cela peut s’effectuer de la manière suivante avec le package coverage
:
terminal
$ coverage run -m unittest tests/test_create_variable_title.py
$ coverage report -m
Name Stmts Miss Cover Missing-------------------------------------------------------------------
/features/build_features.py 34 21 38% 35-36, 48-58, 71-74, 85-89, 99-101, 111-113
src/test_create_variable_title.py 21 1 95% 54
tests-------------------------------------------------------------------
55 22 60% TOTAL
Le taux de couverture est souvent mis en avant par les gros projets comme indicateur de leur qualité. Il existe d’ailleurs des badges Github
dédiés.
Étape 2 : transformer son projet en package (optionnel)
Notre projet est modulaire, ce qui le rend assez simple à transformer en package, en s’inspirant de la structure du cookiecutter
adapté, issu de cet ouvrage.
On va créer un package nommé titanicml
qui encapsule tout notre code et qui sera appelé par notre script main.py
. La structure attendue est la suivante:
Structure visée
ensae-reproductibilite-application
├── docs ┐
│ ├── main.py │
│ └── notebooks │ Package documentation and examples
│ └── titanic.ipynb │
├── configuration ┐ Configuration (pas à partager avec Git)
│ └── config.yaml ┘
├── README.md
├── pyproject.toml ┐
├── requirements.txt │
├── titanicml │
│ ├── __init__.py │ Package source code, metadata
│ ├── data │ and build instructions
│ │ ├── import_data.py │
│ │ └── test_create_variable_title.py │
│ ├── features │
│ │ └── build_features.py │
│ └── models │
│ └── train_evaluate.py ┘
└── tests ┐
└── test_create_variable_title.py ┘ Package tests
Rappel: structure actuelle
ensae-reproductibilite-application
├── notebooks
│ └── titanic.ipynb
├── configuration
│ └── config.yaml
├── main.py
├── README.md
├── requirements.txt
└── src
├── data
│ ├── import_data.py
│ └── test_create_variable_title.py
├── features
│ └── build_features.py
└── models
└── train_evaluate.py
Il existe plusieurs frameworks pour construire un package. Nous allons privilégier Poetry
à Setuptools
.
Pour créer la structure minimale d’un package, le plus simple est d’utiliser le cookiecutter
adapté, issu de cet ouvrage.
Comme on a déjà une structure très modulaire, on va plutôt recréer cette structure dans notre projet déjà existant. En fait, il ne manque qu’un fichier essentiel, le principal distinguant un projet classique d’un package : pyproject.toml
.
terminal
$ cookiecutter https://github.com/py-pkgs/py-pkgs-cookiecutter.git
Dérouler pour voir les choix possibles
author_name [Monty Python]: Daffy Duck
package_name [mypkg]: titanicml
package_short_description []: Impressive Titanic survival analysis0.1.0]:
package_version [3.9]:
python_version [
Select open_source_license:1 - MIT
2 - Apache License 2.0
3 - GNU General Public License v3.0
4 - Creative Commons Attribution 4.0
5 - BSD 3-Clause
6 - Proprietary
7 - None
from 1, 2, 3, 4, 5, 6 [1]:
Choose
Select include_github_actions:1 - no
2 - ci
3 - ci+cd
from 1, 2, 3 [1]: Choose
- Renommer le dossier
titanicml
pour respecter la nouvelle arborescence ; - Créer un fichier
pyproject.toml
sur cette base ;
pyproject.toml
[tool.poetry]= "titanicml"
name = "0.0.1"
version = "Awesome Machine Learning project"
description = ["Daffy Duck <daffy.duck@fauxmail.fr>", "Mickey Mouse"]
authors = "MIT"
license = "README.md"
readme
-system]
[build= ["poetry-core"]
requires -backend = "poetry.core.masonry.api"
build
[tool.pytest.ini_options]= true
log_cli = "WARNING"
log_cli_level = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)"
log_cli_format = "%Y-%m-%d %H:%M:%S" log_cli_date_format
- Créer le dossier
docs
et mettre les fichiers indiqués dedans - Dans
titanicml/
, créer un fichier__init__.py
9
__init__.py
from .import_data import (
import_data, import_yaml_config
)from .build_features import (
create_variable_title,
fill_na_titanic,
label_encoder_titanic,
check_has_cabin,
ticket_length
)from .train_evaluate import random_forest_titanic
= [
__all__ "import_data", "import_yaml_config",
"create_variable_title",
"fill_na_titanic",
"label_encoder_titanic",
"check_has_cabin",
"ticket_length",
"random_forest_titanic"
]
- Installer le package en local avec
pip install -e .
- Modifier le contenu de
docs/main.py
pour importer les fonctions de notre packagetitanicml
et tester en ligne de commande notre fichiermain.py
terminal
1$ git stash
$ git checkout appli10
- 1
- Pour annuler les modifications depuis le dernier commit
Partie 3 : construction d’un projet portable et reproductible
Dans la partie précédente, on a appliqué de manière incrémentale de nombreuses bonnes pratiques vues dans les chapitres Qualité du code et Structure des projets tout au long du cours.
Ce faisant, on s’est déjà considérablement rapprochés d’une possible mise en production : le code est lisible, la structure du projet est normalisée et évolutive, et le code est proprement versionné sur un dépôt GitHub
.
Illustration de l’état actuel du projet
A présent, nous avons une version du projet qui est largement partageable. Du moins en théorie, car la pratique est souvent plus compliquée : il y a fort à parier que si vous essayez d’exécuter votre projet sur un autre environnement (typiquement, votre ordinateur personnel), les choses ne se passent pas du tout comme attendu. Cela signifie qu’en l’état, le projet n’est pas portable : il n’est pas possible, sans modifications coûteuses, de l’exécuter dans un environnement différent de celui dans lequel il a été développé.
Dans cette troisème partie de notre travail vers la mise en production, nous allons voir comment normaliser l’environnement d’exécution afin de produire un projet portable. Autrement dit, nous n’allons plus nous contenter de modularité mais allons rechercher la portabilité. On sera alors tout proche de pouvoir mettre le projet en production.
On progressera dans l’échelle de la reproductibilité de la manière suivante:
- Environnements virtuels ;
- Créer un script shell qui permet, depuis un environnement minimal, de construire l’application de A à Z ;
- Images et conteneurs
Docker
.
Nous allons repartir de l’application 8, c’est-à-dire d’un projet modulaire mais qui n’est pas, à strictement parler, un package (objet des applications optionnelles suivantes 9 et 10).
Pour se replacer dans l’état du projet à ce niveau, il est possible d’utiliser le tag ad hoc.
terminal
$ git checkout appli8
Étape 1 : un environnement pour rendre le projet portable
Pour qu’un projet soit portable, il doit remplir deux conditions:
- Ne pas nécessiter de dépendance qui ne soient pas renseignées quelque part ;
- Ne pas proposer des dépendances inutiles, qui ne sont pas utilisées dans le cadre du projet.
Le prochain exercice vise à mettre ceci en oeuvre. Comme expliqué dans le chapitre portabilité, le choix du gestionnaire d’environnement est laissé libre. Il est recommandé de privilégier venv
si vous découvrez la problématique de la portabilité.
L’approche la plus légère est l’environnement virtuel. Nous avons en fait implicitement déjà commencé à aller vers cette direction en créant un fichier requirements.txt
.
venv
- Exécuter
pip freeze
en ligne de commande et observer la (très) longue liste de package - Créer l’environnement virtuel
titanic
en s’inspirant de la documentation officielle10 ou du chapitre dédié - Utiliser
ls
pour observer et comprendre le contenu du dossiertitanic/bin
installé - Activer l’environnement et vérifier l’installation de
Python
maintenant utilisée par votre machine - Vérifier directement depuis la ligne de commande que
Python
exécute bien une commande11 avec:
terminal
$ python -c "print('Hello')"
- Faire la même chose mais avec
import pandas as pd
- Installer les packages à partir du
requirements.txt
. Tester à nouveauimport pandas as pd
pour comprendre la différence. - Exécuter
pip freeze
et comprendre la différence avec la situation précédente. - Vérifier que le script
main.py
fonctionne bien. Sinon ajouter les packages manquants dans lerequirements.txt
et reprendre de manière itérative à partir de la question 7. - Ajouter le dossier
titanic/
au.gitignore
pour ne pas ajouter ce dossier àGit
.
Aide pour la question 4
Après l’activation, vous pouvez vérifier quel python
est utilisé de cette manière
terminal
(titanic) $ which python
terminal
1$ git stash
$ git checkout appli11a
- 1
- Pour annuler les modifications depuis le dernier commit
Les environnements conda
sont plus lourds à mettre en oeuvre que les environnements virtuels mais peuvent permettre un contrôle plus formel des dépendances.
conda
- Exécuter
conda env export
en ligne de commande et observer la (très) longue liste de package - Créer un environnement
titanic
avecconda create
- Activer l’environnement et vérifier l’installation de
Python
maintenant utilisée par votre machine - Vérifier directement depuis la ligne de commande que
Python
exécute bien une commande12 avec:
terminal
$ python -c "print('Hello')"
- Faire la même chose mais avec
import pandas as pd
- Installer les packages qu’on avait listé dans le
requirements.txt
précédemment. Ne pas faire unpip install -r requirements.txt
afin de privilégierconda install
- Exécuter à nouveau
conda env export
et comprendre la différence avec la situation précédente13. - Vérifier que le script
main.py
fonctionne bien. Sinon installer les packages manquants et reprndre de manière itérative à partir de la question 7. - Quand
main.py
fonctionne, faireconda env export > environment.yml
pour figer l’environnement de travail.
terminal
1$ git stash
$ git checkout appli11b
- 1
- Pour annuler les modifications depuis le dernier commit
Étape 2: construire l’environnement de notre application via un script shell
Les environnements virtuels permettent de mieux spécifier les dépendances de notre projet, mais ne permettent pas de garantir une portabilité optimale. Pour cela, il faut recourir à la technologie des conteneurs. L’idée est de construire une machine, en partant d’une base quasi-vierge, qui permette de construire étape par étape l’environnement nécessaire au bon fonctionnement de notre projet. C’est le principe des conteneurs Docker
.
Leur méthode de construction étant un peu difficile à prendre en main au début, nous allons passer par une étape intermédiaire afin de bien comprendre le processus de production.
- Nous allons d’abord créer un script
shell
, c’est à dire une suite de commandesLinux
permettant de construire l’environnement à partir d’une machine vierge ; - Nous transformerons celui-ci en
Dockerfile
dans un deuxième temps. C’est l’objet de l’étape suivante.
- Créer un service
ubuntu
sur le SSP Cloud - Ouvrir un terminal
- Cloner le dépôt
- Se placer dans le dossier du projet avec
cd
- Se placer au niveau du checkpoint 11a avec
git checkout appli11a
- Via l’explorateur de fichiers, créer le fichier
install.sh
à la racine du projet avec le contenu suivant:
Script à créer sous le nom install.sh
install.sh
#!/bin/bash
# Install Python
apt-get -y update
apt-get install -y python3-pip python3-venv
# Create empty virtual environment
python3 -m venv titanic
source titanic/bin/activate
# Install project dependencies
pip install -r requirements.txt
- Changer les permissions sur le script pour le rendre exécutable
terminal
$ chmod +x install.sh
- Exécuter le script depuis la ligne de commande avec des droits de super-utilisateur (nécessaires pour installer des packages via
apt
)
terminal
$ sudo ./install.sh
- Vérifier que le script
main.py
fonctionne correctement dans l’environnement virtuel créé
terminal
$ source titanic/bin/activate
$ python3 main.py
terminal
1$ git stash
$ git checkout appli12a
- 1
- Pour annuler les modifications depuis le dernier commit
- Créer un service
ubuntu
sur le SSP Cloud - Ouvrir un terminal
- Cloner le dépôt
- Se placer dans le dossier du projet avec
cd
- Se placer au niveau du checkpoint 11b avec
git checkout appli11b
- Via l’explorateur de fichiers, créer le fichier
install.sh
à la racine du projet avec le contenu suivant:
Script à créer sous le nom install.sh
install.sh
apt-get -y update && apt-get -y install wget
wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh && \
bash Miniconda3-latest-Linux-x86_64.sh -b -p /miniconda && \
rm -f Miniconda3-latest-Linux-x86_64.sh
PATH="/miniconda/bin:${PATH}"
# Create environment
conda create -n titanic pandas PyYAML scikit-learn -c conda-forge
conda activate titanic
PATH="/miniconda/envs/titanic/bin:${PATH}"
python main.py
- Changer les permissions sur le script pour le rendre exécutable
terminal
$ chmod +x install.sh
- Exécuter le script depuis la ligne de commande avec des droits de super-utilisateur (nécessaires pour installer des packages via
apt
)
terminal
$ sudo ./install.sh
- Vérifier que le script
main.py
fonctionne correctement dans l’environnement virtuel créé
terminal
$ conda activate titanic
$ python3 main.py
terminal
1$ git stash
$ git checkout appli12b
- 1
- Pour annuler les modifications depuis le dernier commit
Étape 3: conteneuriser l’application avec Docker
Cette application nécessite l’accès à une version interactive de Docker
. Il n’y a pas beaucoup d’instances en ligne disponibles.
Nous proposons deux solutions:
- Installer
Docker
sur sa machine ; - Se rendre sur l’environnement bac à sable Play with Docker
Sinon, elle peut être réalisée en essai-erreur par le biais des services d’intégration continue de Github
ou Gitlab
. Néanmoins, nous présenterons l’utilisation de ces services plus tard, dans la prochaine partie.
Maintenant qu’on sait que ce script préparatoire fonctionne, on va le transformer en Dockerfile
pour anticiper la mise en production. Comme la syntaxe Docker
est légèrement différente de la syntaxe Linux
classique (voir le chapitre portabilité), il va être nécessaire de changer quelques instructions mais ceci sera très léger.
On va tester le Dockerfile
dans un environnement bac à sable pour ensuite pouvoir plus facilement automatiser la construction de l’image Docker
.
Docker
Se placer dans un environnement avec Docker
, par exemple Play with Docker
Création du Dockerfile
- Dans le terminal
Linux
, cloner votre dépôtGithub
- Repartir de la dernière version à disposition. Par exemple, si vous avez privilégié l’environnement virtuel
venv
, ce sera:
terminal
1$ git stash
$ git checkout appli12a
- 1
- Pour annuler les modifications depuis le dernier commit
- Créer via la ligne de commande un fichier texte vierge nommé
Dockerfile
(la majuscule au début du mot est importante)
Commande pour créer un Dockerfile
vierge depuis la ligne de commande
terminal
$ touch Dockerfile
- Ouvrir ce fichier via un éditeur de texte et copier le contenu suivant dedans:
Premier Dockerfile
terminal
FROM ubuntu:22.04
WORKDIR ${HOME}/titanic
# Install Python
RUN apt-get -y update && \
apt-get install -y python3-pip
# Install project dependencies
COPY requirements.txt .
RUN pip install -r requirements.txt
CMD ["python3", "main.py"]
Construire (build) l’image
- Utiliser
docker build
pour créer une image avec le tagmy-python-app
terminal
$ docker build . -t my-python-app
- Vérifier les images dont vous disposez. Vous devriez avoir un résultat proche de celui-ci :
terminal
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE-python-app latest 188957e16594 About a minute ago 879MB my
Tester l’image: découverte du cache
L’étape de build
a fonctionné: une image a été construite.
Mais fait-elle effectivement ce que l’on attend d’elle ?
Pour le savoir, il faut passer à l’étape suivante, l’étape de run
.
terminal
$ docker run -it my-python-app
't open file '/~/titanic/main.py': [Errno 2] No such file or directory python3: can
Le message d’erreur est clair : Docker
ne sait pas où trouver le fichier main.py
. D’ailleurs, il ne connait pas non plus les autres fichiers de notre application qui sont nécessaires pour faire tourner le code, par exemple le dossier src
.
- Avant l’étape
CMD
, copier les fichiers nécessaires sur l’image afin que l’application dispose de tous les éléments nécessaires pour être en mesure de fonctionner.
Nouveau Dockerfile
terminal
FROM ubuntu:22.04
WORKDIR ${HOME}/titanic
# Install Python
RUN apt-get -y update && \
apt-get install -y python3-pip
# Install project dependencies
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY main.py .
COPY src ./src
CMD ["python3", "main.py"]
Refaire tourner l’étape de
build
Refaire tourner l’étape de
run
. A ce stade, la matrice de confusion doit fonctionner 🎉. Vous avez créé votre première application reproductible !
Ici, le cache permet d’économiser beaucoup de temps. Par besoin de refaire tourner toutes les étapes, Docker
agit de manière intelligente en faisant tourner uniquement les étapes qui ont changé.
terminal
1$ git stash
$ git checkout appli13
- 1
- Pour annuler les modifications depuis le dernier commit
Partie 4 : automatisation avec l’intégration continue
Imaginez que vous êtes au restaurant et qu’on ne vous serve pas le plat mais seulement la recette et que, de plus, on vous demande de préparer le plat chez vous avec les ingrédients dans votre frigo. Vous seriez quelque peu déçu. En revanche, si vous avez goûté au plat, que vous êtes un réel cordon bleu et qu’on vous donne la recette pour refaire ce plat ultérieurement, peut-être que vous appréciriez plus.
Cette analogie illustre l’enjeu de définir le public cible et ses attentes afin de fournir un livrable adapté. Une image Docker
est un livrable qui n’est pas forcément intéressant pour tous les publics. Certains préféreront avoir un plat bien préparé qu’une recette ; certains apprécieront avoir une image Docker
mais d’autres ne seront pas en mesure de construire celle-ci ou ne sauront pas la faire fonctionner. Une image Docker
est plus souvent un moyen pour faciliter la mise en service d’une production qu’une fin en soi.
Nous allons donc proposer plusieurs types de livrables plus classiques par la suite. Ceux-ci correspondront mieux aux attendus des publics utilisateurs de services construits à partir de techniques de data science. Docker
est néanmoins un passage obligé car l’ensemble des types de livrables que nous allons explorer reposent sur la standardisation permise par les conteneurs.
Cette approche nous permettra de quitter le domaine de l’artisanat pour s’approcher d’une industrialisation de la mise à disposition de notre projet. Ceci va notamment nous amener à mettre en oeuvre l’approche pragmatique du DevOps
qui consiste à intégrer dès la phase de développement d’un projet les contraintes liées à sa mise à disposition au public cible (cette approche est détaillée plus amplement dans le chapitre sur la mise en production).
L’automatisation et la mise à disposition automatisée de nos productions sera faite progressivement, au cours des prochaines parties. Tous les projets n’ont pas vocation à aller aussi loin dans ce domaine. L’opportunité doit être comparée aux coûts humains et financiers de leur mise en oeuvre et de leur cycle de vie. Avant de faire une production en série de nos modèles, nous allons déjà commencer par automatiser quelques tests de conformité de notre code. On va ici utiliser l’intégration continue pour deux objectifs distincts:
- la mise à disposition de l’image
Docker
; - la mise en place de tests automatisés de la qualité du code sur le modèle de notre
linter
précédent.
Nous allons utiliser Github Actions
pour cela. Il s’agit de serveurs standardisés mis à disposition gratuitement par Github
. Gitlab
, l’autre principal acteur du domaine, propose des services similaires. L’implémentation est légèrement différente mais les principes sont identiques.
terminal
$ git checkout appli13
Étape 1: mise en place de tests automatisés
Avant d’essayer de mettre en oeuvre la création de notre image Docker
de manière automatisée, nous allons présenter la logique de l’intégration continue en testant de manière automatisée notre script main.py
.
Pour cela, nous allons partir de la structure proposée dans l’action officielle. La documentation associée est ici. Des éléments succincts de présentation de la logique déclarative des actions Github
sont disponibles dans le chapitre sur la mise en production. Néanmoins, la meilleure école pour comprendre le fonctionnement de celles-ci est de parcourir la documentation du service et d’observer les actions Github
mises en oeuvre par vos projets favoris, celles-ci seront fort instructives !
A partir de l’exemple présent dans la documentation officielle de Github
, on a déjà une base de départ qui peut être modifiée. Les questions suivantes permettront d’automatiser les tests et le diagnostic qualité de notre code14
- Créer un fichier
.github/workflows/test.yaml
avec le contenu de l’exemple de la documentation - Avec l’aide de la documentation, introduire une étape d’installation des dépendances. Utiliser le fichier
requirements.txt
pour installer les dépendances. - Utiliser
pylint
pour vérifier la qualité du code. Ajouter l’argument--fail-under=6
pour renvoyer une erreur en cas de note trop basse15 - Utiliser une étape appelant notre application en ligne de commande (
python main.py
) pour tester que la matrice de confusion s’affiche bien. - Aller voir votre test automatisé dans l’onglet
Actions
de votre dépôt surGithub
terminal
1$ git stash
$ git checkout appli14
- 1
- Pour annuler les modifications depuis le dernier commit
Maintenant, nous pouvons observer que l’onglet Actions
s’est enrichi. Chaque commit
va entraîner une série d’actions automatisées.
Si l’une des étapes échoue, ou si la note de notre projet est mauvaise, nous aurons une croix rouge (et nous recevrons un mail). On pourra ainsi détecter, en développant son projet, les moments où on dégrade la qualité du script afin de la rétablir immédiatemment.
Étape 2: Automatisation de la livraison de l’image Docker
Maintenant, nous allons automatiser la mise à disposition de notre image sur DockerHub
(le lieu de partage des images Docker
). Cela facilitera sa réutilisation mais aussi des valorisations ultérieures.
Là encore, nous allons utiliser une série d’actions pré-configurées.
Pour que Github
puisse s’authentifier auprès de DockerHub
, il va falloir d’abord interfacer les deux plateformes. Pour cela, nous allons utiliser un jeton (token) DockerHub
que nous allons mettre dans un espace sécurisé associé à votre dépôt Github
.
- Se rendre sur https://hub.docker.com/ et créer un compte. Il est recommandé d’associer ce compte à votre compte
Github
. - Créer un dépôt public
application-correction
- Aller dans les paramètres de votre compte et cliquer, à gauche, sur
Security
- Créer un jeton personnel d’accès, ne fermez pas l’onglet en question, vous ne pouvez voir sa valeur qu’une fois.
- Dans le dépôt
Github
de votre projet, cliquer sur l’ongletSettings
et cliquer, à gauche, surSecrets and variables
puis dans le menu déroulant en dessous surActions
. Sur la page qui s’affiche, aller dans la sectionRepository secrets
- Créer un jeton
DOCKERHUB_TOKEN
à partir du jeton que vous aviez créé surDockerhub
. Valider - Créer un deuxième secret nommé
DOCKERHUB_USERNAME
ayant comme valeur le nom d’utilisateur que vous avez créé surDockerhub
Etape optionnelle supplémentaire si on met en production un site web
- Dans le dépôt
Github
de votre projet, cliquer sur l’ongletSettings
et cliquer, à gauche, surActions
. Donner les droits d’écriture à vos actions sur le dépôt du projet (ce sera nécessaire pourGithub Pages
)
A ce stade, nous avons donné les moyens à Github
de s’authentifier avec notre identité sur Dockerhub
. Il nous reste à mettre en oeuvre l’action en s’inspirant de la documentation officielle. On ne va modifier que trois éléments dans ce fichier. Effectuer les actions suivantes:
Docker
- En s’inspirant de ce template, créer le fichier
.github/workflows/prod.yml
qui va build et push l’image sur leDockerHub
. Il va être nécessaire de changer légèrement ce modèle :- Retirer la condition restrictive sur les commits pour lesquels sont lancés cette automatisation. Pour cela, remplacer le contenu de
on
de sorte à avoiron: push: branches: - main - dev
- Changer le tag à la fin pour mettre
username/application-correction:latest
oùusername
est le nom d’utilisateur surDockerHub
; - Optionnel: changer le nom de l’action
- Retirer la condition restrictive sur les commits pour lesquels sont lancés cette automatisation. Pour cela, remplacer le contenu de
- Faire un
commit
et unpush
de ces fichiers
Comme on est fier de notre travail, on va afficher ça avec un badge sur le README
(partie optionnelle).
- Se rendre dans l’onglet
Actions
et cliquer sur une des actions listées. - En haut à droite, cliquer sur
...
- Sélectionner
Create status badge
- Récupérer le code
Markdown
proposé - Copier dans votre
README.md
le code markdown proposé
Créer le badge
Maintenant, il nous reste à tester notre application dans l’espace bac à sable ou en local, si Docker
est installé.
- Se rendre sur l’environnement bac à sable Play with Docker ou dans votre environnement
Docker
de prédilection. - Récupérer et lancer l’image :
terminal
$ docker run -it username/application-correction:latest
🎉 La matrice de confusion doit s’afficher ! Vous avez grandement facilité la réutilisation de votre image.
terminal
1$ git stash
$ git checkout appli15
- 1
- Pour annuler les modifications depuis le dernier commit
Partie 5: expérimenter en local des valorisations puis automatiser leur production
Nous avons automatisé les étapes intermédiaires de notre projet. Néanmoins nous n’avons pas encore réfléchi à la valorisation à mettre en oeuvre pour notre projet. On va supposer que notre projet s’adresse à des data scientists mais aussi à une audience moins technique. Pour ces premiers, nous pourrions nous contenter de valorisations techniques, comme des API, mais pour ces derniers il est conseillé de privilégier des formats plus user friendly.
terminal
$ git checkout appli15
Afin de faire le parallèle avec les parcours possibles pour l’évaluation, nous allons proposer trois valorisations16:
- Une API facilitant la réutilisation du modèle en “production” ;
- Un site web statique exploitant cette API pour exposer les prédictions à une audience moins technique.
La solution que nous allons proposer pour les sites statiques, Quarto
associé à Github Pages
, peut être utilisée dans le cadre des parcours “rapport reproductible” ou “dashboard / application interactive”.
Pour ce dernier parcours, d’autres approches techniques sont néanmoins possibles, comme Streamlit
. Celles-ci sont plus exigeantes sur le plan technique puisqu’elles nécessitent de mettre en production sur des serveurs conteuneurisés (comme la mise en production de l’API) là où le site statique ne nécessite qu’un serveur web, mis à disposition gratuitement par Github
.
La distinction principale entre ces deux approches est qu’elles s’appuient sur des serveurs différents. Un site statique repose sur un serveur web là où Streamlit
s’appuie sur serveur classique en backend. La différence principale entre ces deux types de serveurs réside principalement dans leur fonction et leur utilisation:
- Un serveur web est spécifiquement conçu pour stocker, traiter et livrer des pages web aux clients. Cela inclut des fichiers HTML, CSS, JavaScript, images, etc. Les serveurs web écoutent les requêtes HTTP/HTTPS provenant des navigateurs des utilisateurs et y répondent en envoyant les données demandées.
- Un serveur backend classique est conçu pour effectuer des opérations en réponse à un front, en l’occurrence une page web. Dans le contexte d’une application
Streamlit
, il s’agit d’un serveur avec l’environnementPython
ad hoc pour exécuter le code nécessaire à répondre à toute action d’un utilisateur de l’appliacation.
Étape 1: développer une API en local
Le premier livrable devenu classique dans un projet impliquant du machine learning est la mise à disposition d’un modèle par le biais d’une API (voir chapitre sur la mise en production). Le framework FastAPI
va permettre de rapidement transformer notre application Python
en une API fonctionnelle.
terminal
$ git checkout appli15
- Installer
fastAPI
etuvicorn
puis les ajouter aurequirements.txt
- Renommer le fichier
main.py
entrain.py
. Dans ce script, ajouter une sauvegarde du modèle après l’avoir entraîné, sous le formatjoblib
. - Faire tourner
terminal
$ python train.py
pour enregistrer en local votre modèle de production.
Modifier les appels à
main.py
dans votreDockerfile
et vos actionsGithub
sous peine d’essuyer des échecs lors de vos actionsGithub
après le prochain push.Ajouter
model.joblib
au.gitignore
carGit
n’est pas fait pour ce type de fichiers.
Nous allons maintenant passer au développement de l’API. Comme découvrir FastAPI
n’est pas l’objet de cet enseignement, nous donnons directement le modèle pour créer l’API. Si vous désirez tester de vous-mêmes, vous pouvez créer votre fichier sans vous référer à l’exemple
- Créer le fichier
api.py
permettant d’initialiser l’API:
Fichier api.py
src/models/train_evaluation.py
"""A simple API to expose our trained RandomForest model for Tutanic survival."""
from fastapi import FastAPI
from joblib import load
import pandas as pd
= load('model.joblib')
model
= FastAPI(
app ="Prédiction de survie sur le Titanic",
title=
description"Application de prédiction de survie sur le Titanic 🚢 <br>Une version par API pour faciliter la réutilisation du modèle 🚀" +\
"<br><br><img src=\"https://media.vogue.fr/photos/5faac06d39c5194ff9752ec9/1:1/w_2404,h_2404,c_limit/076_CHL_126884.jpg\" width=\"200\">"
)
@app.get("/", tags=["Welcome"])
def show_welcome_page():
"""
Show welcome page with model name and version.
"""
return {
"Message": "API de prédiction de survie sur le Titanic",
"Model_name": 'Titanic ML',
"Model_version": "0.1",
}
@app.get("/predict", tags=["Predict"])
async def predict(
str = "female",
sex: float = 29.0,
age: float = 16.5,
fare: str = "S"
embarked: -> str:
) """
"""
= pd.DataFrame(
df
{"Sex": [sex],
"Age": [age],
"Fare": [fare],
"Embarked": [embarked],
}
)
= "Survived 🎉" if int(model.predict(df)) == 1 else "Dead ⚰️"
prediction
return prediction
- Déployer en local l’API avec la commande
terminal
$ uvicorn api:app --reload --host "0.0.0.0" --port 5000
- A partir du
README
du service, se rendre sur l’URL de déploiement, ajouter/docs/
à celui-ci et observer la documentation de l’API - Se servir de la documentation pour tester les requêtes
/predict
- Récupérer l’URL d’une des requêtes proposées. La tester dans le navigateur et depuis
Python
avecrequests
:
import request
requests.get(url).json()
- Une fois que vous avez testé, vous pouvez tuer l’application en faisant CTRL+C. Retester votre bout de code
Python
et comprendre l’origine du problème.
terminal
1$ git stash
$ git checkout appli17
- 1
- Pour annuler les modifications depuis le dernier commit
Étape 2: déployer l’API de manière manuelle
terminal
$ git checkout appli16
A ce stade, nous avons déployé l’API seulement localement, dans le cadre d’un terminal qui tourne en arrière-plan. C’est une mise en production manuelle, pas franchement pérenne. Ce mode de déploiement est très pratique pour la phase de développement, afin de s’assurer que l’API fonctionne comme attendu. Pour pérenniser la mise en production, on va éliminer l’aspect artisanal de celle-ci.
Il est temps de passer à l’étape de déploiement, qui permettra à notre API d’être accessible via une URL sur le web et d’avoir un serveur, en arrière plan, qui effectuera les opérations pour répondre à une requête. Pour se faire, on va utiliser les possibilités offertes par Kubernetes
, sur lequel est basé le SSP Cloud.
Pour rendre la structure du projet plus lisible, déplacer
api.py
->api/main.py
Créer un script
api/run.sh
à la racine du projet qui lance le scripttrain.py
puis déploie localement l’API
Fichier run.sh
api/run.sh
#/bin/bash
python3 train.py
uvicorn api.main:app --reload --host "0.0.0.0" --port 5000
Donner au script
api/run.sh
des permissions d’exécution :chmod +x api/run.sh
Ajouter
COPY api ./api
pour avoir les fichiers nécessaires au lancement dans l’API dans l’imageChanger l’instruction
CMD
duDockerfile
pour exécuter le scriptapi/run.sh
au lancement du conteneur (CMD ["bash", "-c", "./api/run.sh"]
)Mettre à jour votre
requirements.txt
pour tenir compte des nouveaux packages utilisésCommit et push les changements
Une fois le CI terminé, récupérer la nouvelle image dans votre environnement de test de
Docker
et vérifier que l’API se déploie correctement
Nous avons préparé la mise à disposition de notre API mais à l’heure actuelle elle n’est pas disponible de manière aisée car il est nécessaire de lancer manuellement une image Docker
pour pouvoir y accéder. Ce type de travail est la spécialité de Kubernetes
que nous allons utiliser pour gérer la mise à disposition de notre API.
Cette partie nécessite d’avoir à disposition une infrastructure cloud.
Créer un dossier
deployment
à la racine du projet qui va contenir les fichiers de configuration nécessaires pour déployer sur un clusterKubernetes
En vous inspirant de la documentation, y ajouter un premier fichier
deployment.yaml
qui va spécifier la configuration du Pod à lancer sur le cluster
Fichier deployment/deployment.yaml
deployment/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: titanic-deployment
labels:
app: titanic
spec:
replicas: 1
selector:
matchLabels:
app: titanic
template:
metadata:
labels:
app: titanic
spec:
containers:
- name: titanic
image: linogaliana/application-correction:latest
ports:
- containerPort: 5000
- En vous inspirant de la documentation, y ajouter un second fichier
service.yaml
qui va créer une ressourceService
permettant de donner une identité fixe auPod
précédemment créé au sein du cluster
Fichier deployment/service.yaml
deployment/service.yaml
apiVersion: v1
kind: Service
metadata:
name: titanic-service
spec:
selector:
app: titanic
ports:
- protocol: TCP
port: 80
targetPort: 5000
- En vous inspirant de la documentation, y ajouter un troisième fichier
ingress.yaml
qui va créer une ressourceIngress
permettant d’exposer le service via une URL en dehors du cluster
Fichier deployment/ingress.yaml
deployment/ingress.yaml
/v1
apiVersion: networking.k8s.io
kind: Ingress
metadata:-ingress
name: titanic
annotations:/rewrite-target: /
nginx.ingress.kubernetes.io
spec:
ingressClassName: nginx
tls:- hosts:
1- # METTRE URL ICI
rules:2- host: # METTRE URL ICI
http:
paths:- path: /
pathType: Prefix
backend:
service:-service
name: titanic
port:80 number:
- 1
-
Mettez l’URL auquel vous voulez exposer votre service. Sur le modèle de
titanic.kub.sspcloud.fr
(mais ne tentez pas celui-là, il est déjà pris 😃) - 2
- Mettre ici aussi
Appliquer ces fichiers de configuration sur le cluster :
kubectl apply -f deployment/
Si tout a correctement fonctionné, vous devriez pouvoir accéder depuis votre navigateur à l’API à l’URL spécifiée dans le fichier
deployment/ingress.yaml
. Par exemplehttps://toto.kub.sspcloud.fr/
si vous avez mis celui-ci plus tôt (ethttps://toto.kub.sspcloud.fr/docs
pour la documentation).
terminal
1$ git stash
$ git checkout appli18
- 1
- Pour annuler les modifications depuis le dernier commit
Notre API est accessible sans problème depuis Python
ou notre navigateur.
En revanche, si on désire utiliser JavaScript
pour créer une application interactive il est indispensable de mettre les lignes un peu obscure sur le CORS dans le fichier ingress.yaml
.
Comme c’est un point technique qui ne concerne pas les compétences liées à ce cours, nous donnons directement les mises à jour nécessaires du projet.
Ceci consiste principalement à ajouter la ligne suivante au fichier ingress.yaml
:
nginx.ingress.kubernetes.io/enable-cors: "true"
On peut remarquer quelques voies d’amélioration de notre approche qui seront ultérieurement traitées:
- L’entraînement du modèle est ré-effectué à chaque lancement d’un nouveau conteneur. On relance donc autant de fois un entraînement qu’on déploie de conteneurs pour répondre à nos utilisateurs. Ce sera l’objet de la partie MLOps de fiabiliser et optimiser cette partie du pipeline.
- il est nécessaire de (re)lancer manuellement
kubectl apply -f deployment/
à chaque changement de notre code. Autrement dit, lors de cette application, on a amélioré la fiabilité du lancement de notre API mais un lancement manuel est encore indispensable. Comme dans le reste de ce cours, on va essayer d’éviter un geste manuel pouvant être source d’erreur en privilégiant l’automatisation et l’archivage dans des scripts. C’est l’objet de la prochaine étape.
Etape 3: automatiser le déploiement (déploiement en continu)
A partir de maintenant, il est nécessaire de clarifier la branche principale sur laquelle nous travaillons. De manière traditionnelle, on utilise la branche main
. Néanmoins, pour être cohérent avec les instructions du début, qui étaient de créer une branche dev
, tous les exemples ultérieures partiront de cette hypothèse.
Si vous avez fait les applications les unes après les autres, et que vous vous situez toujours sur dev
, vous pouvez passer aux applications suivantes. Si vous avez changé de branche, vous pouvez continuer mais en tenir compte dans les exemples ultérieurs.
Si vous avez utilisé un tag
pour sauter une ou plusieurs étapes, il va être nécessaire de se placer sur une branche car vous êtes en head detached. Pour cela, après avoir committé les fichiers que vous désirez garder
terminal
- 1
-
Supprime la branche
dev
locale (si elle existe). - 2
-
Supprime la branche
dev
remote (si elle existe). - 3
-
Crée une nouvelle branche
dev
locale et on se place sur cette branche. - 4
-
Pousse la branche
dev
et active la synchronisation entre la branche locale et la branche remote.
Qu’est-ce qui peut déclencher une évolution nécessitant de mettre à jour l’ensemble de notre processus de production ?
Regardons à nouveau notre pipeline:
Les inputs de notre pipeline sont donc:
- La configuration. Ici, on peut considérer que notre YAML de configuration relève de cette catégorie ;
- Les données. Nos données sont statiques et n’ont pas vocation à évoluer. Si c’était le cas, il faudrait en tenir compte dans notre automatisation. ;
- Le code. C’est l’élément principal qui évolue chez nous. Idéalement, on veut automatiser le processus au maximum en faisant en sorte qu’à chaque mise à jour de notre code (un push sur
Github
), les étapes ultérieures (production de l’imageDocker
, etc.) se lancent. Néanmoins, on veut aussi éviter qu’une erreur puisse donner lieu à une mise en production non-fonctionnelle, on va donc maintenir une action manuelle minimale comme garde-fou.
Pour automatiser au maximum la mise en production, on va utiliser un nouvel outil : ArgoCD
. Ainsi, au lieu de devoir appliquer manuellement la commande kubectl apply
à chaque modification des fichiers de déploiement (présents dans le dossier kubernetes/
), c’est l’opérateur ArgoCD
, déployé sur le cluster, qui va détecter les changements de configuration du déploiement et les appliquer automatiquement. C’est l’approche dite GitOps : le dépôt Git
du déploiement fait office de source de vérité unique de l’état voulu de l’application, tout changement sur ce dernier doit donc se répercuter immédiatement sur le déploiement effectif.
- Lancer un service
ArgoCD
sur leSSPCloud
depuis la pageMes services
(catalogueAutomation
). Laisser les configurations par défaut. - Sur
GitHub
, créer un dépôtapplication-deployment
qui va servir de dépôt GitOps, c’est à dire un dépôt qui spécifie le paramétrage du déploiement de votre application. - Ajouter un dossier
deployment
à votre dépôtGitOps
, dans lequel on mettra les trois fichiers de déploiement qui permettent de déployer notre application surKubernetes
(deployment.yaml
,service.yaml
,ingress.yaml
). - A la racine de votre dépôt
GitOps
, créez un fichierapplication.yml
avec le contenu suivant, en prenant bien soin de modifier les lignes surlignées avec les informations pertinentes :
application.yaml
/v1alpha1
apiVersion: argoproj.io
kind: Application
metadata:-mlops
name: ensae
spec:
project: default
source:1//github.com/<your_github_username>/application-deployment.git
repoURL: https:2
targetRevision: main3
path: deployment
destination://kubernetes.default.svc
server: https:4-<your_sspcloud_username>
namespace: user
syncPolicy:
automated: selfHeal: true
- 1
-
L’URL de votre dépôt
Github
- 2
- La branche à partir de laquelle vous déployez
- 3
-
Le nom du dossier contenant vos fichiers de déploiement
Kubernetes
- 4
-
Votre namespace
Kubernetes
. Sur le SSPCloud, cela prend la formeuser-${username}
- Dans
ArgoCD
, cliquez surNew App
puisEdit as a YAML
. Copiez-collez le contenu deapplication.yml
et cliquez surCreate
. - Observez dans l’interface d’
ArgoCD
le déploiement progressif des ressources nécessaires à votre application sur le cluster. Joli non ? - Vérifiez que votre API est bien déployée en utilisant l’URL définie dans le fichier
ingress.yml
.
terminal
1$ git stash
$ git checkout appli19a
- 1
- Pour annuler les modifications depuis le dernier commit
A présent, nous avons tous les outils à notre disposition pour construire un vrai pipeline de CI/CD, automatisé de bout en bout. Il va nous suffire pour cela de mettre à bout les composants :
dans la partie 4 de l’application, nous avons construit un pipeline de CI : on a donc seulement à faire un commit sur le dépôt de l’application pour lancer l’étape de build et de mise à disposition de la nouvelle image sur le
DockerHub
;dans l’application précédente, nous avons construit un pipeline de CD :
ArgoCD
suit en permanence l’état du dépôtGitOps
, tout commit sur ce dernier lancera donc automatiquement un redéploiement de l’application.
Il y a donc un élément qui fait la liaison entre ces deux pipelines et qui nous sert de garde-fou en cas d’erreur : la version de l’application.
Jusqu’à maintenant, on a utilisé le tag latest pour définir la version de notre application. En pratique, lorsqu’on passe de la phase de développement à celle de production, on a plutôt envie de versionner proprement les versions de l’application afin de savoir ce qui est déployé. On va pour cela utiliser les tags avec Git
, qui vont se propager au nommage de l’image Docker
.
- Modifier le fichier de CI
prod.yml
pour assurer la propagation des tags.
Fichier .github/workflows/prod.yml
.github/workflows/prod.yml
.github/workflows/prod.yml
name: Construction image Docker
on:
push:
branches:
- main
- dev
tags:
- 'v*.*.*'
jobs:
docker:
runs-on: ubuntu-latest
steps:
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: linogaliana/application-correction
-
name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Build and push
uses: docker/build-push-action@v5
with:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- Dans le dépôt de l’application, mettre à jour le code dans
api/main.py
pour changer un élément de l’interface de votre documentation. Par exemple, mettre en gras un titre.
= FastAPI(
app ="Prédiction de survie sur le Titanic",
title=
description"<b>Application de prédiction de survie sur le Titanic</b> 🚢 <br>Une version par API pour faciliter la réutilisation du modèle 🚀" +\
"<br><br><img src=\"https://media.vogue.fr/photos/5faac06d39c5194ff9752ec9/1:1/w_2404,h_2404,c_limit/076_CHL_126884.jpg\" width=\"200\">"
)
Commit et push les changements.
Tagger le commit effectué précédemment et push le nouveau tag :
terminal
$ git tag v1.0.0
$ git push --tags
- Vérifier sur le dépôt
GitHub
de l’application que ce commit lance bien un pipeline de CI associé au tag v1.0.0. Une fois terminé, vérifier sur leDockerHub
que le tagv1.0.0
existe bien parmi les tags disponibles de l’image.
La partie CI a correctement fonctionné. Intéressons-nous à présent à la partie CD.
- Sur le dépôt
GitOps
de l’application, mettre à jour la version de l’image à déployer en production dans le fichierdeployment/deployment.yaml
Fichier deployment/deployment.yaml
deployment/deployment.yaml
deployment/deployment.yaml
/v1
apiVersion: apps
kind: Deployment
metadata:-deployment
name: titanic
labels:
app: titanic
spec:1
replicas:
selector:
matchLabels:
app: titanic
template:
metadata:
labels:
app: titanic
spec:
containers:- name: titanic
/application-correction:v1.0.0
image: linogaliana
ports:- containerPort: 5000
- Remplacer
username
par la valeur adéquate
- Après avoir committé et pushé, observer dans
ArgoCD
le statut de votre application. Normalement, l’opérateur devrait avoir automatiquement identifié le changement, et mettre à jour le déploiement pour en tenir compte.
- Vérifier que l’API a bien été mise à jour.
terminal
1$ git stash
$ git checkout appli19b
- 1
- Pour annuler les modifications depuis le dernier commit
Etape 4: construire un site web
terminal
$ git checkout appli19
$ git checkout -b dev
$ git push origin dev
On va proposer un nouveau livrable pour parler à un public plus large. Pour faire ce site web, on va utiliser Quarto
et déployer sur Github Pages
.
terminal
$ quarto create project website mysite
- Faire remonter d’un niveau
_quarto.yml
- Supprimer
about.qmd
, déplacerindex.qmd
vers la racine de notre projet. - Remplacer le contenu de
index.qmd
par celui-ci et retirerabout.qmd
des fichiers à compiler. - Déplacer
styles.css
à la racine du projet - Mettre à jour le
.gitignore
avec les instructions suivantes
/.quarto/
*.html
*_files
_site/
- En ligne de commande, faire
quarto preview
(ajouter les arguments--port 5000 --host 0.0.0.0
si vous passez par leSSPCloud
) - Observer le site web généré en local
Enfin, on va construire et déployer automatiquement ce site web grâce au combo Github Actions
et Github Pages
:
- Créer une branche
gh-pages
à partir du contenu de cette page - Revenir à votre branche
- Créer un fichier
.github/workflows/website.yaml
avec le contenu de ce fichier - Modifier le
README
pour indiquer l’URL de votre site web et de votre API
terminal
1$ git stash
$ git checkout appli20
- 1
- Pour annuler les modifications depuis le dernier commit
Partie 6: adopter une approche MLOps pour améliorer notre modèle
terminal
$ git checkout appli20
$ git checkout -b dev
$ git push origin dev
Nous allons dans cette partie faire de la validation croisée. Pour éviter le problème du data leakage, nous proposons de revoir notre pipeline pour exclure la variable Title
dont certaines modalités rares posent problème dans les découpages multiples d’échantillons lors de la validation croisée.
Restructurer le pipeline pour fluidifier la mise en production
- Faire les modifications suivantes pour restructurer notre pipeline afin de mieux distinguer les étapes d’estimation et d’évaluation
Modification de src/models/train_evaluation.py
à effectuer
src/models/train_evaluation.py
def build_pipeline(
int = 20,
n_trees: =["Age", "Fare"],
numeric_features=["Title", "Embarked", "Sex"]):
categorical_features"""Random forest model for Titanic survival
Args:
n_trees (int, optional): _description_. Defaults to 20.
Returns:
_type_: _description_
"""
= Pipeline(
numeric_transformer =[
steps"imputer", SimpleImputer(strategy="median")),
("scaler", MinMaxScaler()),
(
]
)
= Pipeline(
categorical_transformer =[
steps"imputer", SimpleImputer(strategy="most_frequent")),
("onehot", OneHotEncoder()),
(
]
)
= ColumnTransformer(
preprocessor =[
transformers"Preprocessing numerical", numeric_transformer, numeric_features),
(
("Preprocessing categorical",
categorical_transformer,
categorical_features,
),
]
)
= Pipeline(
pipe
["preprocessor", preprocessor),
("classifier", RandomForestClassifier(n_estimators=n_trees)),
(
]
)
return pipe
Modification de src/features/build_features.py
pour enchaîner les étapes de feature engineering
src/features/build_features.py
def feature_engineering(data: pd.DataFrame) -> pd.DataFrame:
"""Applying our feature engineering pipeline
Args:
data (pd.DataFrame): Initial dataframe
Returns:
pd.DataFrame: Dataframe with feature engineering being handled
"""
= create_variable_title(data)
data_training = check_has_cabin(data_training)
data_training = ticket_length(data_training)
data_training return data_training
Modification de train.py
pour faire une grid search
train.py
"""
Prediction de la survie d'un individu sur le Titanic
"""
# GESTION ENVIRONNEMENT --------------------------------
from pathlib import Path
import argparse
from joblib import dump
from sklearn.model_selection import GridSearchCV
import src.data.import_data as imp
import src.features.build_features as bf
import src.models.train_evaluate as te
# PARAMETRES -------------------------------
# Paramètres ligne de commande
= argparse.ArgumentParser(description="Paramètres du random forest")
parser "--n_trees", type=int, default=20, help="Nombre d'arbres")
parser.add_argument("--appli", type=str, default="appli21", help="Application number")
parser.add_argument(= parser.parse_args()
args
# Paramètres YAML
= imp.import_yaml_config("configuration/config.yaml")
config = (
base_url "https://minio.lab.sspcloud.fr/projet-formation/ensae-reproductibilite/data/raw"
)= config.get("jeton_api")
API_TOKEN = config.get("train_path", f"{base_url}/train.csv")
LOCATION_TRAIN = config.get("test_path", f"{base_url}/test.csv")
LOCATION_TEST = config.get("test_fraction", 0.1)
TEST_FRACTION = args.n_trees
N_TREES = args.appli
APPLI_ID = "titanicml"
EXPERIMENT_NAME
# FEATURE ENGINEERING --------------------------------
= imp.import_data(LOCATION_TRAIN)
titanic_raw
# Create a 'Title' variable
= bf.feature_engineering(titanic_raw)
titanic_intermediate
= te.split_train_test_titanic(
train, test =TEST_FRACTION
titanic_intermediate, fraction_test
)= train.drop("Survived", axis="columns"), train["Survived"]
X_train, y_train = test.drop("Survived", axis="columns"), test["Survived"]
X_test, y_test
def log_local_data(data, filename):
f"data/intermediate/{filename}.csv", index=False)
data.to_csv(
= Path("data/intermediate")
output_dir =True, exist_ok=True)
output_dir.mkdir(parents
"X_train")
log_local_data(X_train, "X_test")
log_local_data(X_test, "y_train")
log_local_data(y_train, "y_test")
log_local_data(y_test,
# MODELISATION: RANDOM FOREST ----------------------------
= te.build_pipeline(n_trees=N_TREES, categorical_features=["Embarked", "Sex"])
pipe
= {
param_grid "classifier__n_estimators": [10, 20, 50],
"classifier__max_leaf_nodes": [5, 10, 50],
}
= GridSearchCV(
pipe_cross_validation
pipe,=param_grid,
param_grid=["accuracy", "precision", "recall", "f1"],
scoring="f1",
refit=5,
cv=5,
n_jobs=1,
verbose
)
pipe_cross_validation.fit(X_train, y_train)= pipe_cross_validation.best_estimator_
pipe
"model.joblib") dump(pipe,
Fichier eval.py
pour évaluer la meilleure validation croisée
eval.py
import pandas as pd
from sklearn.metrics import confusion_matrix, accuracy_score
from joblib import load
# EVALUATE ----------------------------
= load("model.joblib")
loaded_model
# Predict on a Pandas DataFrame.
= pd.read_csv("data/intermediate/X_test.csv")
X_test = pd.read_csv("data/intermediate/y_test.csv")
y_test = loaded_model.predict(X_test)
y_test_predict
# EVALUATE ----------------------------
= confusion_matrix(y_test, y_test_predict)
matrix
print("Accuracy:")
print(f"{accuracy_score(y_test, y_test_predict):.0%}")
print("Matrice de confusion:")
print(matrix)
Modification de api/main.py
à effectuer
api/main.py
"""A simple API to expose our trained RandomForest model for Tutanic survival."""
import requests
from fastapi import FastAPI
from joblib import load
import pandas as pd
# GET PRODUCTION MODEL -------------
= "lgaliana"
username_sspcloud = f"https://minio.lab.sspcloud.fr/{username_sspcloud}/ensae-reproductibilite/model/model.joblib"
url = "model.joblib"
local_filename
with open(local_filename, mode = "wb") as file:
file.write(requests.get(url).content)
= load(local_filename)
model
# USE PRODUCTION MODEL IN APP ----------
= FastAPI(
app ="Prédiction de survie sur le Titanic",
title=
description"<b>Application de prédiction de survie sur le Titanic</b> 🚢 <br>Une version par API pour faciliter la réutilisation du modèle 🚀" +\
"<br><br><img src=\"https://media.vogue.fr/photos/5faac06d39c5194ff9752ec9/1:1/w_2404,h_2404,c_limit/076_CHL_126884.jpg\" width=\"200\">"
)
@app.get("/", tags=["Welcome"])
def show_welcome_page():
"""
Show welcome page with model name and version.
"""
return {
"Message": "API de prédiction de survie sur le Titanic",
"Model_name": 'Titanic ML',
"Model_version": "0.1",
}
@app.get("/predict", tags=["Predict"])
async def predict(
int = 3,
pclass: str = "female",
sex: float = 29.0,
age: int = 1,
sib_sp: int = 1,
parch: float = 16.5,
fare: str = "S",
embarked: int = 1,
has_cabin: int = 7
ticket_len: -> str:
) """
"""
= pd.DataFrame(
df
{"Pclass": [pclass],
"Sex": [sex],
"Age": [age],
"SibSp": [sib_sp],
"parch": [parch],
"Fare": [fare],
"Embarked": [embarked],
"hasCabin": [has_cabin],
"Ticket_Len": [ticket_len]
}
)
= "Survived 🎉" if int(model.predict(df)) == 1 else "Dead ⚰️"
prediction
return prediction
- Tester votre script
train.py
et uploader le meilleur modèle sur S3 de la manière suivante:
terminal
- 1
-
Uploader sur
S3
- 2
- Ouvrir les droits en lecture de ce fichier pour simplifier la récupération17
où <BUCKET_PERSONNEL>
est à remplacer par votre nom d’utilisateur sur le SSPCloud
Tester en local
api/main.py
pour vérifier le caractère fonctionnel de l’API. Les deux modifications principales de celle-ci sont les suivantes:- On n’utilise plus la variable
Title
puisque celle-ci a été retirée des données en entrée du modèle - On récupère la version de “production” sur le système de stockage S3, on ne fait plus l’entraînement à chaque initialisation d’un conteneur
- On n’utilise plus la variable
Modifier
deployment/deployment.yaml
et.github/workflows/prod.yaml
pour définir et utiliser le tagv0.0.3
de l’imageDocker
Retirer la ligne
python train.py
du fichierrun.sh
Dans
index.qmd
, remplacer la ligne suivante:
```{ojs}
prediction = `https://titanic.kub.sspcloud.fr/predict?pclass=${class_boat}&sex=${gender}&age=${age}&sib_sp=1&parch=1&fare=16.5&embarked=S&has_cabin=1&ticket_len=7`
```
par celle-ci
```{ojs}
prediction = `https://titanic.kub.sspcloud.fr/predict?pclass=${class_boat}&sex=${gender}&age=${age}&sib_sp=1&parch=1&fare=16.5&embarked=S&has_cabin=1&ticket_len=7`
```
Faire un commit et un push des modifications
terminal
1$ git stash
$ git checkout appli21
- 1
- Pour annuler les modifications depuis le dernier commit
Garder une trace des entraînements de notre modèle grâce au register de MLFlow
terminal
$ git checkout appli21
$ git checkout -b dev
$ git push origin dev
MLFlow
Lancer
MLFlow
depuis l’onflet Mes services du SSPCloud. Attendre que le service soit bien lancé. Cela créera un service dont l’URL est de la formehttps://user-{username}-{pod_id}.user.lab.sspcloud.fr
, oùpod_id
est un identifiant aléatoire. Ce serviceMLFlow
communiquera avec lesVSCode
que vous ouvrirez ultérieurement à partir de cet URL ainsi qu’avec le système de stockageS3
18.Regarder la page
Experiments
. Elle est vide à ce stade, c’est normal
Une fois le service
MLFlow
fonctionnel, lancer un nouveauVSCode
pour bénéficier de la configuration automatiséeClôner votre projet, vous situer sur la branche de travail (nous supposerons qu’il s’agit de
dev
).Depuis un terminal
Python
, lancer les commandes suivantes:
import mlflow
= "titanicml"
mlflow_experiment_name =mlflow_experiment_name) mlflow.set_experiment(experiment_name
Retourner sur l’UI et observer la différence, à gauche.
- Créer un fichier
src/models/log.py
Contenu du fichier src/models/log.py
src/models/log.py
import mlflow
import os
def log_gsvc_to_mlflow(
str = "appli21"
gscv, mlflow_experiment_name, application_number:
):"""Log a scikit-learn trained GridSearchCV object as an MLflow experiment."""
# Set up MLFlow context
=mlflow_experiment_name)
mlflow.set_experiment(experiment_name
for run_idx in range(len(gscv.cv_results_["params"])):
# For each hyperparameter combination we trained the model with, we log a run in MLflow
= f"run {run_idx}"
run_name with mlflow.start_run(run_name=run_name):
# Log hyperparameters
= gscv.cv_results_["params"][run_idx]
params for param in params:
mlflow.log_param(param, params[param])
# Log fit metrics
= [
scores
scorefor score in gscv.cv_results_
if "mean_test" in score or "std_test" in score
]for score in scores:
mlflow.log_metric(score, gscv.cv_results_[score][run_idx])
# Log model as an artifact
"gscv_model")
mlflow.sklearn.log_model(gscv, # Log training data URL
"appli", application_number) mlflow.log_param(
- Modifier le fichier
train.py
pour ajouter la ligne
mlog.log_gsvc_to_mlflow(pipe_cross_validation, EXPERIMENT_NAME, APPLI_ID)
avec
import src.models.log as mlog
- Faire tourner avec le paramètre
--appli appli22
:
terminal
$ python train.py --appli appli22
Observer l’évolution de la page
Experiments
. Cliquer sur un des run. Observer toutes les métadonnées archivées (hyperparamètres, métriques d’évaluation,requirements.txt
dontMLFlow
a fait l’inférence, etc.)Observer le code proposé par
MLFlow
pour récupérer le run en question. Modifier le fichiereval.py
à partir de cet exemple et du modèle suivant pour utiliser un des modèles archivés dansMLFlow
Retourner à la liste des runs en cliquant à nouveau sur “titanicml” dans les expérimentations
Dans l’onglet
Table
, sélectionner plusieurs expérimentations, cliquer surColumns
et ajoutermean_test_f1
. Ajuster la taille des colonnes pour la voir et classer les modèles par score décroissantsCliquer sur
Compare
après en avoir sélectionné plusieurs. Afficher un scatterplot des performances en fonction du nombre d’estimateurs. Conclure.
terminal
1$ git stash
$ git checkout appli22
- 1
- Pour annuler les modifications depuis le dernier commit
Cette appplication illustre l’un des premiers apports de MLFlow
: on garde une trace de nos expérimentations et on peut déjà mieux comprendre la manière dont certains paramètres de notre modèle peuvent influencer la qualité de nos prédictions.
Néanmoins, persistent un certain nombre de voies d’amélioration:
- On entraîne le modèle en local, de manière séquentielle, et en lançant nous-mêmes le script
train.py
- On n’archive pas les jeux de données associés à ces modèles (les jeux d’entraînement et de test). On doit alors le faire manuellement si on désire évaluer les performances ex post, ce qui est pénible.
- On récupère manuellement les modèles ce qui n’est pas très pérenne.
- Notre API n’utilise pas encore l’un des modèles archivé sur
MLFlow
.
Les prochaines applications permettront d’améliorer ceci.
Mise en production d’un modèle
- A partir du tableau de performance précédent, choisir le modèle avec le F1 score maximal. Accéder à celui-ci.
Créer un script dans mlflow/predict.py
pour illustrer l’utilisation d’un modèle depuis MLFlow. Nous allons progressivement l’améliorer.
- Copier-coller le contenu ci-dessous afin de se simplifier la création de données en entrée de notre code
mlflow/predict.py
import pandas as pd
def create_data(
int = 3,
pclass: str = "female",
sex: float = 29.0,
age: int = 1,
sib_sp: int = 1,
parch: float = 16.5,
fare: str = "S",
embarked: int = 1,
has_cabin: int = 7
ticket_len: -> str:
) """
"""
= pd.DataFrame(
df
{"Pclass": [pclass],
"Sex": [sex],
"Age": [age],
"SibSp": [sib_sp],
"parch": [parch],
"Fare": [fare],
"Embarked": [embarked],
"hasCabin": [has_cabin],
"Ticket_Len": [ticket_len]
}
)
return df
= pd.concat([
data
create_data(),="male")
create_data(sex ])
- Cliquer sur votre meilleur modèle et introduire dans
mlflow/predict.py
le morceau de code suggéré parMLFlow
, du type de celui-ci:
import mlflow
1= #A CHANGER
logged_model
# Load model as a PyFuncModel.
= mlflow.pyfunc.load_model(logged_model)
loaded_model
# Predict on a Pandas DataFrame.
import pandas as pd
loaded_model.predict(pd.DataFrame(data))
- 1
- Hash du modèle
Lancer depuis la ligne de commande ce script et observer l’application obtenue.
A ce stade, nous avons amélioré la fiabilité de notre modèle car nous utilisons le meilleur. Néanmoins, celui-ci n’est pas forcément pratique à récupérer car nous utilisons un hash qui certes identifie de manière unique notre modèle mais présente l’inconvénient d’être peu intelligible. Nous allons passer de l’expérimentation à la mise en production en sélectionnant explicitement notre meilleur modèle.
- Dans la page du modèle en question sur
MLFlow
, cliquer surRegister model
et le nommertitanic
. - Aller dans l’onglet
Models
et observer le changement par rapport à précédemment. - Mettre à jour le code dans
mlflow/predict.py
pour utiliser la version en production :
{.python filename = "mlflow/predict.py"} model_name = "titanic" model_version = 1 loaded_model = mlflow.pyfunc.load_model( model_uri=f"models:/{model_name}/{model_version}" )
Tester cette application. Si celle-ci fonctionne, modifier la récupération du modèle dans votre script d’API.
Tester en local cette API mise à jour
terminal
uvicorn api.main:app --reload --host "0.0.0.0" --port 5000
Ajouter
mlflow
aurequirements.txt
Mettre à jour les fichiers
.github/worflows/prod.yaml
etkubernetes/deployment.yaml
pour produire et utiliser le tagv0.0.5
.github/worflows/prod.yaml
name: Construction image Docker
on:
push:
branches:- main
- dev
jobs:
docker:-on: ubuntu-latest
runs
steps:-
name: Set up QEMU/setup-qemu-action@v3
uses: docker-
name: Set up Docker Buildx/setup-buildx-action@v3
uses: docker-
name: Login to Docker Hub/login-action@v3
uses: dockerwith:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}-
and push
name: Build /build-push-action@v5
uses: dockerwith:
push: true1/application-correction:v0.0.7 tags: linogaliana
- 1
- Modifier l’image ici
# Creating MLflow deployment
/v1
apiVersion: apps
kind: Deployment
metadata:
name: titanicml
spec:1
replicas:
selector:
matchLabels:
app: titanicml
template:
metadata:
labels:
app: titanicml
spec:
containers:- name: api
1/application-correction:v0.0.7
image: linogaliana
imagePullPolicy: Always
env:- name: MLFLOW_TRACKING_URI
2//user-{USERNAME}-mlflow.user.lab.sspcloud.fr
value: https:- name: MLFLOW_MODEL_NAME
value: titanic- name: MLFLOW_MODEL_VERSION
"1"
value:
resources:
limits:"2Gi"
memory: "1000m" cpu:
- 1
-
Modifier l’image
Docker
- 2
-
Modifier l’URL de
MLFlow
terminal
1$ git stash
$ git checkout appli23
- 1
- Pour annuler les modifications depuis le dernier commit
Industrialiser les entraînements de nos modèles
Pour industrialiser nos entraînements, nous allons créer des processus parallèles indépendants pour chaque combinaison de nos hyperparamètres. Pour cela, l’outil pratique sur le SSPCloud est Argo workflows
. Chaque combinaison d’hyperparamètres sera un processus isolé à l’issue duquel sera loggué le résultat dans MLFlow
. Ces entraînements auront lieu en parallèle.
- Lancer un service Argo Workflows
- Dans
mlflow/training.yaml
/v1alpha1
apiVersion: argoproj.io
kind: Workflow
metadata:-training-workflow-
generateName: titanic
spec:
entrypoint: main
arguments:
parameters:# The MLflow tracking server is responsible to log the hyper-parameter and model metrics.
- name: mlflow-tracking-uri
1//user-lgaliana-argo-workflows.user.lab.sspcloud.fr
value: https:- name: mlflow-experiment-name
2
value: titanicml- name: model-training-conf-list
|
value:
["dim": 25, "lr": 0.1 },
{ "dim": 100, "lr": 0.2 },
{ "dim": 150, "lr": 0.3 }
{
]
templates:# Entrypoint DAG template
- name: main
dag:
tasks:# Task 0: Start pipeline
- name: start-pipeline
-pipeline-wt
template: start# Task 1: Train model with given params
- name: train-model-with-params
-pipeline ]
dependencies: [ start-model-training-wt
template: run
arguments:
parameters:- name: dim
"{{item.dim}}"
value: - name: lr
"{{item.lr}}"
value: # Pass the inputs to the task using "withParam"
"{{workflow.parameters.model-training-conf-list}}"
withParam:
# Now task container templates are defined
# Worker template for task 0 : start-pipeline
- name: start-pipeline-wt
inputs:
container:
image: busybox-c ]
command: [ sh, "echo Starting pipeline" ]
args: [
# Worker template for task-1 : train model with params
- name: run-model-training-wt
inputs:
parameters:- name: dim
- name: lr
container:/formation-mlops:main
image: inseefrlab
imagePullPolicy: Always-c]
command: [sh, "mlflow run .
args: [ --env-manager=local
-P remote_server_uri=$MLFLOW_TRACKING_URI
-P experiment_name=$MLFLOW_EXPERIMENT_NAME
-P dim={{inputs.parameters.dim}}
-P lr={{inputs.parameters.lr}}"]
env:
- name: MLFLOW_TRACKING_URI
"{{workflow.parameters.mlflow-tracking-uri}}"
value: - name: MLFLOW_EXPERIMENT_NAME
"{{workflow.parameters.mlflow-experiment-name}}" value:
- 1
- Changer
- 2
-
titanicml
max_depth
max_features “sqrt”, “log2”
Pour aller plus loin
Créer un service label studio pour évaluer la qualité du modèle
Footnotes
L’export dans un script
.py
a été fait directement depuisVSCode
. Comme cela n’est pas vraiment l’objet du cours, nous passons cette étape et fournissons directement le script expurgé du texte intermédiaire. Mais n’oubliez pas que cette démarche, fréquente quand on a démarré sur un notebook et qu’on désire consolider en faisant la transition vers des scripts, nécessite d’être attentif pour ne pas risquer de faire une erreur.↩︎Il est également possible avec
VSCode
d’exécuter le script ligne à ligne de manière interactive ligne à ligne (MAJ+ENTER). Néanmoins, cela nécessite de s’assurer que le working directory de votre console interactive est le bon. Celle-ci se lance selon les paramètres préconfigurés deVSCode
et les votres ne sont peut-être pas les mêmes que les notres. Vous pouvez changer le working directory dans le script en utilisant le packageos
mais peut-être allez vous découvrir ultérieurement qu’il y a de meilleures pratiques…↩︎Essayez de commit vos changements à chaque étape de l’exercice, c’est une bonne habitude à prendre.↩︎
Ici, le jeton d’API n’est pas indispensable pour que le code fonctionne. Afin d’éviter une erreur non nécessaire lorsqu’on automatisera le processus, on peut créer une condition qui vérifie la présence ou non de ce fichier. Le script reste donc reproductible même pour un utilisateur n’ayant pas le fichier
secrets.yaml
.↩︎Il est normal d’avoir des dossiers
__pycache__
qui traînent en local : ils se créent automatiquement à l’exécution d’un script enPython
. Néanmoins, il ne faut pas associer ces fichiers àGit
, voilà pourquoi on les ajoute au.gitignore
.↩︎Nous proposons ici d’adopter le principe de la programmation fonctionnelle. Pour encore fiabiliser un processus, il serait possible d’adopter le paradigme de la programmation orientée objet (POO). Celle-ci est plus rebutante et demande plus de temps au développeur. L’arbitrage coût-avantage est négatif pour notre exemple, nous proposons donc de nous en passer. Néanmoins, pour une mise en production réelle d’un modèle, il est recommandé de l’adopter. C’est d’ailleurs obligatoire avec des pipelines
scikit
.↩︎Attention, les données ont été committées au moins une fois. Les supprimer du dépôt ne les efface pas de l’historique. Si cette erreur arrive, le mieux est de supprimer le dépôt en ligne, créer un nouvel historique
Git
et partir de celui-ci pour des publications ultérieures surGithub
. Néanmoins l’idéal serait de ne pas s’exposer à cela. C’est justement l’objet des bonnes pratiques de ce cours: un.gitignore
bien construit et une séparation des environnements de stockage du code et des données seront bien plus efficaces pour vous éviter ces problèmes que tout les conseils de vigilance que vous pourrez trouver ailleurs.↩︎Lorsqu’on développe du code qui finalement ne s’avère plus nécessaire, on a souvent un cas de conscience à le supprimer et on préfère le mettre de côté. Au final, ce syndrôme de Diogène est mauvais pour la pérennité du projet : on se retrouve à devoir maintenir une base de code qui n’est, en pratique, pas utilisée. Ce n’est pas un problème de supprimer un code ; si finalement celui-ci s’avère utile, on peut le retrouver grâce à l’historique
Git
et les outils de recherche surGithub
. Le packagevulture
est très pratique pour diagnostiquer les morceaux de code inutiles dans un projet.↩︎Le fichier
__init__.py
indique àPython
que le dossier est un package. Il permet de proposer certaines configurations lors de l’import du package. Il permet également de contrôler les objets exportés (c’est-à-dire mis à disposition de l’utilisateur) par le package par rapport aux objets internes au package. En le laissant vide, nous allons utiliser ce fichier pour importer l’ensemble des fonctions de nos sous-modules. Ce n’est pas la meilleure pratique mais un contrôle plus fin des objets exportés demanderait un investissement qui ne vaut, ici, pas le coût.↩︎Si vous désirez aussi contrôler la version de
Python
, ce qui peut être important dans une perspective de portabilité, vous pouvez ajouter une option, par exemple-p python3.10
. Néanmoins nous n’allons pas nous embarasser de cette nuance pour la suite car nous pourrons contrôler la version dePython
plus finement par le biais deDocker
.↩︎L’option
-c
passée après la commandepython
permet d’indiquer àPython
que la commande ne se trouve pas dans un fichier mais sera dans le texte qu’on va directement lui fournir.↩︎L’option
-c
passée après la commandepython
permet d’indiquer àPython
que la commande ne se trouve pas dans un fichier mais sera dans le texte qu’on va directement lui fournir.↩︎Pour comparer les deux listes, vous pouvez utiliser la fonctionnalité de split du terminal sur
VSCode
pour comparer les outputs deconda env export
en les mettant en face à face.↩︎Il est tout à fait normal de ne pas parvenir à créer une action fonctionnelle du premier coup. N’hésitez pas à pusher votre code après chaque question pour vérifier que vous parvenez bien à réaliser chaque étape. Sinon vous risquez de devoir corriger bout par bout un fichier plus conséquent.↩︎
Il existe une approche alternative pour faire des tests réguliers: les hooks
Git
. Il s’agit de règles qui doivent être satisfaites pour que le fichier puisse être committé. Cela assure que chaquecommit
remplisse des critères de qualité afin d’éviter le problème de la procrastination.La documentation de pylint offre des explications supplémentaires. Ici, nous allons adopter une approche moins ambitieuse en demandant à notre action de faire ce travail d’évaluation de la qualité de notre code↩︎
Vous n’êtes pas obligés pour l’évaluation de mettre en oeuvre les jalons de plusieurs parcours. Néanmoins, vous découvrirez que chaque nouveau pas en avant est moins coûteux que le précédent si vous avez mis en oeuvre les réflexes des bonnes pratiques.↩︎
Ce n’est pas indispensable si vous avez une manière cohérente de gérer vos jetons d’accès aux données dans votre API, par exemple par le biais de service account. Néanmoins, pour se faciliter la tâche, on ne va pas se poser de question sur les droits d’accès au modèle.↩︎
Par conséquent,
MLFLow
bénéficie de l’injection automatique des tokens pour pouvoir lire/écrire sur S3. Ces jetons ont la même durée avant expiration que ceux de vos services interactifsVSCode
. Il faut donc supprimer et rouvrir un serviceMLFLow
régulièrement. La manière d’éviter cela est de créer des service account sur https://minio-console.lab.sspcloud.fr/ et de les renseigner sur la page.↩︎