Cours Python · Module 9: Machine learning

MLflow - suivi des expériences

28 min de lecture
Dans cette leçon14

Il y a trois leçons de cela, tu as entraîné une forêt aléatoire avec scikit-learn, puis une caravane d'arbres avec XGBoost, et pour finir un réseau de neurones en PyTorch. À chaque fois tu as changé un petit quelque chose : une fois la profondeur des arbres, une fois le nombre d'estimateurs, une fois la vitesse d'apprentissage. À chaque fois tu as regardé l'exactitude affichée dans le terminal et tu t'es dit "tiens, c'est mieux". Alors réponds-moi maintenant sans aller vérifier nulle part : quel jeu de réglages exactement a donné ce meilleur résultat ? Et où se trouve le fichier de ce modèle-là ?

Je connais ce silence. Je l'ai vu dans plus d'un camp de recherche : vingt pisteurs rentrent de la savane, chacun avec une poignée de mesures griffonnées sur un bout de papier ou gardées en tête, et le soir, autour du feu, on s'aperçoit que personne n'est capable de refaire l'observation qui paraissait la plus intéressante. La note a pris la pluie, la fiche est tombée d'une poche, et son auteur se souvient seulement que "l'animal était grand". En apprentissage automatique, le rôle du papier détrempé revient à la cellule de carnet que tu as écrasée et à la variable que tu as réaffectée.

Cette leçon parle d'un journal d'expédition qu'on ne peut pas perdre. Tu vas découvrir MLflow - un outil qui, à chaque entraînement, enregistre au même endroit trois choses : les paramètres avec lesquels le modèle est né, les métriques qu'il a obtenues, et le modèle lui-même, sous forme de fichier prêt à être rechargé. Tu verras comment consigner une première expérience, comment examiner les résultats dans un navigateur, comment confier la tenue du journal à une machine, et comment extraire le vainqueur de vingt essais en une seule commande.

Le carnet que personne ne tient

Avant de sortir l'outil, regardons à quoi ressemble un suivi d'expériences fait à la main. Presque tout le monde passe par cette étape : tu crées une simple liste, tu y ajoutes après chaque entraînement un dictionnaire contenant les réglages et le résultat, puis à la fin tu choisis la meilleure entrée avec la fonction native max. Le paramètre key lui indique selon quoi comparer les éléments - ici selon la valeur rangée sous la clé accuracy. Cela fonctionne, et c'est mieux que rien, alors examinons la chose honnêtement avant de la critiquer.

1# suivi manuel des experiences: une liste de dictionnaires
2resultats = []
3
4resultats.append({"n_estimators": 100, "max_depth": 10, "accuracy": 0.973})
5resultats.append({"n_estimators": 300, "max_depth": 6, "accuracy": 0.981})
6
7print(max(resultats, key=lambda r: r["accuracy"]))
8# {'n_estimators': 300, 'max_depth': 6, 'accuracy': 0.981}

La fonction max a désigné la deuxième entrée, parce que 0,981 est plus grand que 0,973. Maintenant regarde ce que cette liste ne contient pas, et n'a jamais contenu. Il n'y a pas de modèle dedans - il y a des nombres qui décrivent un modèle, mais pas le modèle lui-même, si bien que pour utiliser le vainqueur tu dois le réentraîner en espérant retomber sur les mêmes conditions. Il n'y a ni date ni version des données, donc dans une semaine tu ne distingueras plus un résultat obtenu avant une correction du carnet de terrain d'un résultat obtenu après. Il n'y a pas une seule entrée que tu n'aies ajoutée à la main, et la main oublie - surtout au vingtième entraînement, à minuit. Et le plus grave : cette liste vit dans la mémoire du processus. Quand tu fermeras le carnet ou que le noyau redémarrera, toute l'expédition disparaîtra.

Ce que MLflow est, et ce qu'il n'est pas

MLflow est une plateforme de suivi des expériences d'apprentissage automatique : elle enregistre les paramètres, les métriques et les modèles. Cette phrase unique répond à elle seule à la question de savoir pourquoi on installe cet outil, et elle mérite d'être retenue exactement sous cette forme - paramètres, métriques, modèles. Tout le reste de ce que MLflow sait faire pousse sur ces trois racines.

Écartons tout de suite trois malentendus, car ils collent obstinément à ce nom. Premièrement, MLflow ne sert pas uniquement à entraîner des modèles - en réalité il n'entraîne rien du tout. Il ne contient pas un seul algorithme d'apprentissage. Ce sont toujours scikit-learn, XGBoost ou PyTorch qui entraînent, exactement comme dans les leçons précédentes, pendant que MLflow se tient à côté avec son carnet et note ce qui s'est passé. Si tu le retirais du projet, les modèles naîtraient rigoureusement identiques, simplement personne n'aurait consigné comment.

Deuxièmement, MLflow ne sert pas à la création d'interfaces utilisateur. Ce malentendu est sournois, parce que dans un instant nous allons lancer l'interface web de MLflow et tu verras dans ton navigateur un tableau de résultats. Mais cette interface est livrée toute faite et n'est pas modifiable : c'est une visionneuse de tes expériences, pas un outil pour construire l'écran que verront les utilisateurs de ton application. Pour cela, il existe des bibliothèques complètement différentes, par exemple Streamlit ou Gradio du côté de Python.

Troisièmement, MLflow ne sert pas à l'écriture de tests unitaires. Les tests unitaires vérifient qu'une fonction renvoie ce qu'elle doit renvoyer, et tu les écris avec pytest ou unittest. MLflow ne se prononce jamais sur la justesse du code - il consigne les résultats d'un entraînement, même quand cet entraînement était complètement raté. Un modèle à quinze pour cent d'exactitude sera enregistré avec autant de soin que le meilleur, parce qu'un journal d'expédition note aussi les sorties infructueuses.

Installation et quatre notions pour démarrer

MLflow s'installe avec une seule commande du gestionnaire de paquets, comme Pandas ou XGBoost avant lui. Le paquet est volumineux, car il embarque un serveur web et une base de données, l'installation prendra donc plus de temps que d'habitude - c'est normal.

1pip install mlflow

Après l'installation tu disposes de deux choses à la fois : une bibliothèque que tu importeras dans ton code, et un programme lancé depuis le terminal par la commande mlflow. C'est le même paquet sous deux déguisements, et nous allons nous servir des deux. Rien d'autre n'a bougé dans ton code - aucun import de scikit-learn ou de NumPy n'a besoin de la moindre correction après cela.

Avant d'écrire la première ligne, voici quatre mots qui reviendront jusqu'à la fin de la leçon. Un run est une exécution d'entraînement : un jeu de paramètres, un jeu de métriques, un modèle. Une expérience est un ensemble nommé de runs portant sur le même problème, par exemple toutes les tentatives de reconnaissance d'espèces. Un paramètre est un nombre ou un texte que tu as fixé avant l'entraînement et qui décrit la configuration. Une métrique est un nombre qui est sorti de l'entraînement et avec lequel tu mesures la qualité. Cette différence entre paramètre et métrique n'a rien de cosmétique : MLflow les traite différemment, comme tu le constateras dans la section consacrée à la comparaison des runs.

Le carnet de terrain de cette leçon

Il nous faut quelque chose à entraîner, mais restons modestes - le héros de cette leçon, c'est le journal, pas les données. Nous allons recenser trois cents spécimens de trois espèces, chacun décrit par deux mesures : la masse en kilogrammes et la vitesse en kilomètres par heure. Le générateur np.random.default_rng te vient de la leçon sur NumPy, et train_test_split de l'apprentissage supervisé, je rappelle donc seulement que stratify=y maintient des proportions d'espèces identiques dans les deux parties et que random_state=42 rend le découpage rigoureusement reproductible. Cette fois nous passons à la méthode normal deux listes au lieu de deux nombres : la première donne les moyennes des deux mesures, la seconde leurs dispersions, et size=(100, 2) produit cent lignes de deux colonnes.

1import numpy as np
2from sklearn.model_selection import train_test_split
3
4rng = np.random.default_rng(7)
5
6# chaque espece: 100 specimens, colonnes = masse [kg] et vitesse [km/h]
7lion = rng.normal([190, 58], [30, 8], size=(100, 2))
8guepard = rng.normal([55, 110], [10, 12], size=(100, 2))
9hyene = rng.normal([60, 65], [12, 9], size=(100, 2))
10
11X = np.vstack([lion, guepard, hyene])
12y = np.repeat([0, 1, 2], 100)          # 0 = lion, 1 = guepard, 2 = hyene
13
14X_train, X_test, y_train, y_test = train_test_split(
15    X, y, test_size=0.2, random_state=42, stratify=y
16)
17
18print(X_train.shape, X_test.shape)     # (240, 2) (60, 2)

Deux cent quarante animaux partent à l'entraînement, soixante attendent l'examen. La fonction np.vstack a collé les trois troupeaux les uns sous les autres, si bien que l'ordre des lignes correspond exactement à l'ordre des étiquettes fabriquées par np.repeat. Remarque que fixer la graine du générateur n'a strictement rien à voir avec MLflow - c'est de la simple hygiène de reproductibilité, et elle va nous servir doublement ici : nous allons comparer des runs entre eux, et nous voulons que les écarts viennent des paramètres du modèle, pas du fait que le tirage a sorti d'autres animaux.

Premier run : expérience et paramètre

Nous commençons à tenir le journal. Trois appels suffisent pour démarrer. La fonction mlflow.set_experiment prend un nom d'expérience et la désigne comme expérience courante ; si elle n'existe pas encore, MLflow la crée. Pour l'échauffement, nous ouvrons une expérience à part nommée "safari-essais", afin que ces premiers runs incomplets n'encombrent pas le journal définitif. La fonction mlflow.start_run ouvre un run unique et nous l'employons dans une construction with, sur laquelle je reviendrai dans un instant. L'argument facultatif run_name donne au run un nom lisible - sans lui, tu récolteras un surnom tiré au hasard du genre "silent-hound-42", amusant mais inutilisable au moment de comparer. Enfin, mlflow.log_param enregistre un paramètre : d'abord son nom, ensuite sa valeur.

1import mlflow
2
3mlflow.set_experiment("safari-essais")
4
5with mlflow.start_run(run_name="foret_aleatoire_v1"):
6    mlflow.log_param("n_estimators", 100)
7    mlflow.log_param("max_depth", 10)
8
9    print(mlflow.active_run().info.run_id)

À la sortie du bloc with, le run est fermé et enregistré. Le programme a affiché une longue chaîne de caractères : c'est l'identifiant du run, l'empreinte digitale unique de cette exécution précise, dont nous nous servirons plus loin pour recharger le modèle. Je ne la reproduis pas ici, car la tienne sera différente ; MLflow la tire au hasard pour chaque run. Regarde surtout ce qui n'a pas changé : aucun fichier que tu aurais toi-même nommé n'est apparu dans le dossier du projet, et tes données sont restées intactes. MLflow s'est créé un dossier mlruns à côté du script et c'est là qu'il range tout le journal. Retiens ce nom, car dans un instant tu comprendras pourquoi l'interface web doit être lancée depuis ce même dossier.

Arrêtons-nous encore sur l'appel qui enregistre le paramètre, car son ordre prête à confusion. Lu élément par élément, cela donne : mlflow, ., log_param, (, "n_estimators", 100, ). D'abord le paquet, puis le point, puis le nom de la fonction, la parenthèse ouvrante, à l'intérieur d'abord le nom du paramètre sous forme de chaîne, après la virgule sa valeur, et pour finir la parenthèse fermante. L'ordre des arguments n'est pas réversible : si tu écrivais mlflow.log_param(100, "n_estimators"), MLflow arrêterait net le programme sur une erreur, parce qu'il exige que le nom du paramètre soit une chaîne de caractères et que le nombre 100 n'en est pas une. Plus sournoise encore est la même inversion avec les guillemets au bon endroit : mlflow.log_param("100", "n_estimators") passe sans le moindre avertissement et inscrit au journal un paramètre nommé "100" valant "n_estimators", que personne ne viendra jamais corriger. En Python, les apostrophes et les guillemets sont équivalents, donc 'n_estimators' et "n_estimators" désignent exactement la même chose.

Puisque nous parlons de with : on peut ouvrir un run à l'ancienne, en appelant mlflow.start_run() sans with puis en le refermant par mlflow.end_run(). Ma recommandation est sans ambiguïté : utilise toujours with. La raison est pratique, pas esthétique. Si l'entraînement lève une exception en cours de route - et cela arrivera, tôt ou tard - la version avec with fermera quand même le run en le marquant comme interrompu. La version sans with le laissera ouvert, puisque l'appel à end_run n'aura jamais lieu. Tout ce que tu enregistreras ensuite s'ajoutera silencieusement à ce run estropié, et quand tu essaieras d'en ouvrir un nouveau, MLflow interrompra le programme en signalant qu'un run est déjà actif. Une demi-heure passée à chercher pourquoi "rien ne s'enregistre dans le nouveau run", voilà le prix de trois caractères économisés.

La métrique est un résultat, le paramètre est une décision

Les paramètres sont consignés, mais un journal sans résultats n'est qu'une liste de souhaits. Pour enregistrer un résultat, on utilise mlflow.log_metric, qui prend les deux mêmes arguments que log_param : un nom et une valeur. La différence est ailleurs. Un paramètre ne peut être fixé qu'une seule fois par run et peut être n'importe quoi - un nombre, une chaîne, le nom d'un algorithme. Une métrique doit être un nombre, parce que MLflow en trace des courbes et trie les tableaux dessus ; essayer d'enregistrer du texte comme métrique se solde par une erreur. En revanche, une métrique peut être écrite plusieurs fois au cours d'un même run, ce qui rend service avec les réseaux de neurones, où l'on veut noter la perte courante après chaque époque.

Entraînons donc une forêt aléatoire, dont la syntaxe te vient de la leçon sur scikit-learn, et consignons son exactitude. La métrique accuracy_score te vient de l'apprentissage supervisé : c'est le pourcentage de prédictions correctes.

1from sklearn.ensemble import RandomForestClassifier
2from sklearn.metrics import accuracy_score
3
4with mlflow.start_run(run_name="foret_aleatoire_v2"):
5    mlflow.log_param("n_estimators", 100)
6    mlflow.log_param("max_depth", 10)
7
8    model = RandomForestClassifier(
9        n_estimators=100, max_depth=10, random_state=42
10    )
11    model.fit(X_train, y_train)
12
13    exactitude = accuracy_score(y_test, model.predict(X_test))
14    mlflow.log_metric("accuracy", exactitude)

L'exactitude tombera aux alentours de 98 pour cent - je ne donne pas le chiffre exact en commentaire, car il dépend de la version de scikit-learn, alors exécute le code et vérifie chez toi. Plus important : remarque que l'entraînement est exactement le même que dans la leçon sur scikit-learn. La même classe, les mêmes hyperparamètres, la même méthode fit. MLflow ne s'est pas mis en travers du chemin du modèle, pas d'un millimètre : il n'a pas modifié son résultat et il n'a pas ralenti les calculs de façon perceptible. Nous n'avons ajouté que trois appels, qui décrivent ce qui se produisait de toute manière. Note aussi la répétition des nombres : le cent et le dix apparaissent chacun deux fois, une fois dans log_param, une fois dans le constructeur du modèle. C'est le point faible de l'enregistrement manuel, et dans deux sections je te montrerai comment t'en débarrasser.

Le modèle aussi est un résultat qu'il vaut la peine de garder

Reste le troisième élément du trio promis. Un modèle est un objet Python, il ne tiendra donc ni dans un paramètre ni dans une métrique - pour l'enregistrer, MLflow dispose de fonctions séparées, une par bibliothèque. Cette découpe s'appelle un flavor, une saveur : mlflow.sklearn prend en charge les modèles scikit-learn, mlflow.xgboost les modèles XGBoost, mlflow.pytorch les réseaux PyTorch. Le module de la saveur s'importe séparément, à côté de mlflow lui-même. La fonction log_model reçoit le modèle et le nom du sous-dossier où le ranger à l'intérieur du run ; l'usage veut qu'on y écrive simplement "model".

1import mlflow.sklearn
2
3with mlflow.start_run(run_name="foret_aleatoire_v3"):
4    model = RandomForestClassifier(n_estimators=100, random_state=42)
5    model.fit(X_train, y_train)
6
7    mlflow.sklearn.log_model(model, "model")

Après cet appel, un jeu complet de fichiers a atterri dans le dossier mlruns à côté des paramètres et des métriques : le modèle sérialisé, la liste des versions de bibliothèques nécessaires pour le recharger, et un fichier MLmodel qui indique avec quelle saveur le relire. Grâce à cela, le vainqueur d'il y a un mois se recharge sans réentraînement, y compris sur une autre machine. Ce deuxième argument porte des noms variables : les documentations anciennes l'appellent artifact_path, les plus récentes name. Le passer par position, comme ci-dessus, t'épargne ce choix, et si tu vois un jour un avertissement au sujet d'un nom d'argument obsolète, il concernera précisément cet endroit. L'objet model lui-même n'a pas bougé d'un pouce au passage : tu peux toujours appeler predict dessus, car log_model s'est contenté de le lire.

Le modèle ne clôt pas la liste des choses qui méritent d'être conservées. On peut rattacher à un run n'importe quel fichier - un graphique d'importance des variables, un fichier CSV listant les erreurs, une note en texte brut - et cela se fait avec mlflow.log_artifact, à qui tu passes le chemin d'un fichier existant. Je ne le montre pas dans un bloc à part, car il faudrait d'abord produire un graphique, mais retiens que la possibilité existe : dans le vocabulaire de MLflow, un artefact est tout fichier épinglé à un run, et un modèle n'en est qu'un cas particulier.

Un run complet, du début à la fin

Rassemblons maintenant tout cela en une seule recette, car c'est exactement sous cette forme que tu t'en serviras en pratique. Ce fragment mérite d'être mémorisé comme squelette : tu ouvres un run, tu consignes les décisions, tu entraînes, tu mesures, tu consignes les résultats et tu enregistres le modèle. Une seule nouveauté - la fonction mlflow.log_params au pluriel, qui reçoit un dictionnaire et enregistre toutes ses paires d'un coup. Nous passons ensuite ce même dictionnaire au constructeur du modèle à l'aide des deux étoiles **, qui le dépaquettent en arguments nommés, si bien qu'aucune valeur n'est plus écrite à deux endroits.

1import mlflow
2import mlflow.sklearn
3from sklearn.ensemble import RandomForestClassifier
4from sklearn.metrics import accuracy_score
5
6parametres = {"n_estimators": 200, "max_depth": 8, "random_state": 42}
7
8mlflow.set_experiment("safari-classification")
9
10with mlflow.start_run(run_name="foret_aleatoire_finale"):
11    mlflow.log_params(parametres)
12    mlflow.log_param("nb_specimens_entrainement", len(X_train))
13
14    model = RandomForestClassifier(**parametres)
15    model.fit(X_train, y_train)
16
17    exactitude = accuracy_score(y_test, model.predict(X_test))
18    mlflow.log_metric("accuracy", exactitude)
19
20    mlflow.sklearn.log_model(model, "model")

Voilà le trio au complet : paramètres, métrique et modèle dans un seul run, c'est-à-dire exactement les trois choses par lesquelles nous avons ouvert la leçon. Remarque que l'appel à set_experiment nous a fait passer de l'expérience d'échauffement "safari-essais" à l'expérience définitive "safari-classification" - les runs incomplets précédents sont restés là où ils étaient et ne viendront pas brouiller les comparaisons. J'ai consigné le nombre de spécimens d'entraînement comme paramètre et non comme métrique, et ce n'est pas un hasard : c'est un fait sur les données d'entrée, pas le résultat d'une mesure de qualité. S'il tombait parmi les métriques, MLflow te proposerait une courbe du nombre de spécimens dans le temps, ce qui ne veut rien dire. Note enfin que le dictionnaire parametres a servi deux fois mais n'est défini qu'une seule, si bien qu'il n'est désormais plus possible d'enregistrer d'autres réglages que ceux réellement utilisés. Le with lui-même n'a renvoyé aucune valeur réutilisable et n'a rien affiché - toute la trace de cette exécution est partie sur le disque.

mlflow ui - le navigateur de tes expériences

Le journal existe, mais jusqu'ici tu ne le regardes que par le trou de la serrure, c'est-à-dire à travers des affichages dans le terminal. Il est temps de déplier la carte du campement. L'interface web de MLflow se lance avec la commande mlflow ui tapée dans le terminal - pas en Python - dans le dossier où se trouve le répertoire mlruns. C'est important : la commande lit le journal à partir d'un chemin relatif, donc lancée un étage plus haut elle affichera un tableau vide et tu seras persuadé que rien ne s'est enregistré.

1mlflow ui
2mlflow ui --port 5000

Les deux lignes font la même chose, car 5000 est le port par défaut - je montre la seconde version pour que tu saches comment le changer quand le port sera occupé par un autre programme. Une fois la commande lancée, ouvre dans le navigateur l'adresse affichée dans le terminal et tu verras le tableau des runs : nom, date, paramètres et métriques en colonnes. Tu peux trier selon une métrique, cocher plusieurs runs et les comparer sur un graphique. Le serveur tourne tant que tu ne l'arrêtes pas, et l'interrompre au clavier n'efface rien - les données sont dans mlruns, l'interface ne fait que les afficher.

Trois autres commandes que l'on essaie souvent de taper à cet endroit ne fonctionneront pas, et il vaut la peine de savoir pourquoi. python mlflow.py n'a aucune chance d'aboutir, car ce fichier n'existe pas dans ton projet - et si tu le créais toi-même pour "avoir quelque chose à lancer", tu te ferais un vrai tort : un fichier nommé mlflow.py dans le dossier de travail masque la véritable bibliothèque et import mlflow cesse de fonctionner dans tout le projet. mlflow start se termine par un message de commande inconnue, car le programme mlflow n'a pas de sous-commande start ; il a entre autres ui, server, run et models. run mlflow est à l'envers - une commande commence toujours par le nom du programme, et par ailleurs mlflow run sert à exécuter un projet MLflow depuis un dossier ou un dépôt, pas à afficher l'interface.

Autolog - quand on n'a pas envie d'écrire log_param

Revenons à cette maladresse des nombres répétés à deux endroits. Un modèle scikit-learn connaît pourtant ses propres réglages - ils sont stockés en attributs dans l'objet - donc les recopier à la main dans le journal est un travail que la machine devrait faire. Et elle le fait. L'appel mlflow.sklearn.autolog() fait que MLflow enregistre automatiquement les paramètres et les métriques des modèles scikit-learn, sans un seul log_param de ta part. Il suffit de l'appeler une fois, avant l'entraînement.

1import mlflow
2import mlflow.sklearn
3from sklearn.ensemble import RandomForestClassifier
4
5mlflow.sklearn.autolog()
6
7with mlflow.start_run(run_name="foret_aleatoire_auto"):
8    model = RandomForestClassifier(n_estimators=150, max_depth=12)
9    model.fit(X_train, y_train)

Dans ce run il n'y a pas un seul appel d'enregistrement explicite, et pourtant tu trouveras dans le journal l'ensemble des hyperparamètres de la forêt, des métriques calculées sur le jeu d'entraînement et le modèle enregistré. Le mécanisme est simple : autolog remplace la méthode fit par sa propre version, qui note d'abord les réglages, appelle ensuite l'originale, puis enregistre les résultats à la fin. C'est précisément pour cela qu'il ne fonctionne qu'avec les bibliothèques que MLflow connaît. Remarque que l'entraînement n'a pas changé d'un iota - c'est toujours ton appel à fit qui fait tout le travail, autolog se contente d'écouter à la porte.

Puisqu'il est question des limites de ce mécanisme, précisons ce qu'il ne fait pas. Autolog ne nettoie pas les données : les mesures manquantes et les saisies fautives resteront dans le tableau exactement là où elles étaient, et le ménage relève des méthodes de Pandas que tu as apprises avec le carnet de terrain. Autolog n'entraîne pas les modèles - si tu supprimais la ligne avec fit, rien n'apparaîtrait dans le journal en dehors d'un run vide, puisqu'il n'y aurait plus rien à écouter. Autolog ne crée pas de rapports PDF ni aucun autre document ; les résultats se regardent dans l'interface web ou se récupèrent dans le code, et nous en parlons dans un instant. Il fait une seule chose : enregistrer automatiquement les paramètres et les métriques des modèles de la bibliothèque concernée.

Les équivalents pour les autres bibliothèques portent des noms prévisibles : mlflow.xgboost.autolog() pour XGBoost et mlflow.pytorch.autolog() pour PyTorch. Il existe aussi mlflow.autolog() sans nom de saveur, qui active l'enregistrement automatique pour toutes les bibliothèques reconnues d'un coup. En pratique, c'est la combinaison des deux approches qui marche le mieux : autolog prend en charge les hyperparamètres du modèle, et toi tu ajoutes à la main les informations dont la bibliothèque n'a aucune idée - la version des données, le nom du pisteur, le numéro de l'expédition.

Comparer les runs sans cliquer

L'interface web est commode pour regarder, mais quand il y a cent cinquante runs, tu veux poser ta question avec du code et non avec les yeux. C'est le rôle de mlflow.search_runs, qui renvoie un DataFrame que tu connais depuis la leçon sur Pandas - avec tout ce que tu sais déjà lui faire. Il faut d'abord indiquer de quelle expérience on parle, et c'est mlflow.get_experiment_by_name qui s'en charge : elle prend un nom et renvoie un objet expérience, dont nous extrayons le champ experiment_id. La fonction de recherche attend une liste d'identifiants, car elle permet de fouiller plusieurs expériences à la fois, nous emballons donc notre identifiant unique entre crochets.

1import mlflow
2
3experience = mlflow.get_experiment_by_name("safari-classification")
4runs = mlflow.search_runs(experiment_ids=[experience.experiment_id])
5
6print(runs.shape)
7print(runs[["params.n_estimators", "params.max_depth", "metrics.accuracy"]].head())

Tu obtiens un tableau ordinaire : une ligne par run, et en colonnes tout ce que tu as enregistré. Les noms de colonnes portent des préfixes - params. pour les paramètres et metrics. pour les métriques - car sans eux un paramètre et une métrique de même nom se télescoperaient dans une seule colonne. Il y a ici un piège dans lequel tout le monde tombe : les valeurs des colonnes params. sont des chaînes de caractères, pas des nombres, car MLflow conserve les paramètres sous forme de texte. Trier selon params.n_estimators placera donc "100" avant "50", comme dans un dictionnaire et non comme en mathématiques. Les colonnes metrics., elles, contiennent de vrais nombres à virgule flottante et se trient normalement.

Puisque les métriques sont des nombres, trouver le vainqueur tient en un appel à sort_values, vu dans la leçon sur Pandas, avec ascending=False pour un ordre décroissant. Nous récupérons la première ligne du tableau trié par iloc[0], c'est-à-dire par position, et nous en tirons la colonne run_id - ce même identifiant que nous avions affiché au premier run. Le modèle se recharge avec load_model, à qui l'on donne une adresse au format runs:/identifiant/nom_du_sous_dossier. Nous assemblons cette adresse avec une f-string, puisque l'identifiant est une variable.

1import mlflow.sklearn
2
3meilleurs = runs.sort_values("metrics.accuracy", ascending=False)
4id_gagnant = meilleurs.iloc[0]["run_id"]
5
6gagnant = mlflow.sklearn.load_model(f"runs:/{id_gagnant}/model")
7print(gagnant.predict(X_test)[:5])

Le modèle rechargé est un objet scikit-learn tout à fait ordinaire et répond à predict comme s'il sortait de l'entraînement - alors qu'il pourrait avoir été construit il y a un mois sur une autre machine. C'est là tout l'intérêt d'enregistrer le modèle en même temps que les métriques. Remarque que sort_values n'a pas modifié le tableau runs mais en a renvoyé une copie triée, exactement comme dans la leçon sur Pandas, et que le segment model de l'adresse est le même nom de sous-dossier que celui passé plus haut comme second argument de log_model. Si tu y avais écrit "foret", l'adresse devrait s'écrire runs:/identifiant/foret.

De là découle une règle pratique sur laquelle il vaut la peine d'appuyer toute ta discipline de travail : enregistre le modèle dans chaque run qui a la moindre chance de gagner. Si le sommet du tableau trié était occupé par un run où tu n'as consigné que la métrique - comme notre "foret_aleatoire_v2" d'échauffement - le rechargement échouerait sur une erreur d'artefact absent, parce qu'il n'y a tout simplement rien à cette adresse. C'est exactement pour cela que nous gardons les essais dans une expérience séparée et que seuls des runs complets atterrissent dans l'expérience définitive.

Model Registry - un seul endroit pour le modèle de production

Une adresse avec identifiant de run est précise mais peu maniable : personne ne retient trente-deux caractères hexadécimaux, et dans le code d'une application une telle chaîne a l'air d'un accident. C'est pourquoi MLflow possède un registre de modèles - un catalogue de modèles nommés avec des versions numérotées. Un modèle entre au registre au moment de son enregistrement, si tu ajoutes l'argument registered_model_name. La première fois, c'est la version un qui naît, et chaque enregistrement suivant sous le même nom ajoute une version deux, trois, et ainsi de suite. Tes modèles précédents ne disparaissent nulle part au passage.

1with mlflow.start_run(run_name="candidat_au_registre"):
2    model = RandomForestClassifier(n_estimators=200, random_state=42)
3    model.fit(X_train, y_train)
4
5    mlflow.sklearn.log_model(
6        model, "model", registered_model_name="ClassifieurSafari"
7    )

À partir de cet instant, le modèle a un nom lisible par un humain et une version, et un onglet distinct consacré au registre apparaît dans l'interface web. Rien d'autre n'a changé - le run ressemble à tous les autres, les paramètres et les métriques s'enregistrent de la même façon, et l'objet model reste utilisable dans le même script.

Le rechargement depuis le registre a un air de déjà-vu, seul le préfixe de l'adresse change : models: au lieu de runs:, puis le nom du modèle, et après la barre oblique le numéro de version.

1enregistre = mlflow.sklearn.load_model("models:/ClassifieurSafari/1")
2print(enregistre.predict(X_test)[:5])

Le chiffre un à la fin est un numéro de version, pas un nombre de modèles - pour aller chercher la plus récente, tu écris deux. Le code qui consomme le modèle n'a plus besoin de savoir quoi que ce soit sur les runs ni sur les identifiants. Dans des documents plus anciens tu rencontreras ici une écriture avec un nom d'étape, par exemple models:/ClassifieurSafari/Production ; c'est un reste du mécanisme des étapes, que MLflow a déclaré obsolète et remplacé par des alias - tes propres étiquettes du genre @production, que tu épingles sur une version choisie et que tu déplaces quand le vainqueur change. Le principe reste le même : l'application demande un nom, et c'est toi qui décides quelle version se cache derrière.

La boucle de réglage avec le journal en arrière-plan

Pour finir, relions le journal au réglage des hyperparamètres de la leçon précédente. Dans un balayage manuel des combinaisons, MLflow montre toute sa puissance : chaque combinaison reçoit son propre run, si bien qu'à la fin de la boucle tu disposes d'un tableau prêt à comparer, au lieu de fixer un terminal qui défile. Nous utiliserons cross_val_score, connu depuis l'apprentissage supervisé - il découpe les données d'entraînement en cinq parties et renvoie cinq notes, une par découpage. De ces cinq notes nous consignons deux nombres : la moyenne, c'est-à-dire la qualité attendue, et l'écart type, c'est-à-dire la stabilité du résultat d'un découpage à l'autre.

1import mlflow
2import mlflow.xgboost
3import xgboost as xgb
4from sklearn.model_selection import cross_val_score
5
6mlflow.set_experiment("safari-xgboost-reglage")
7
8combinaisons = [
9    {"max_depth": 3, "learning_rate": 0.1},
10    {"max_depth": 5, "learning_rate": 0.1},
11    {"max_depth": 3, "learning_rate": 0.05},
12    {"max_depth": 5, "learning_rate": 0.05},
13]
14
15for parametres in combinaisons:
16    with mlflow.start_run():
17        mlflow.log_params(parametres)
18
19        model = xgb.XGBClassifier(**parametres, n_estimators=100, random_state=42)
20        scores = cross_val_score(model, X_train, y_train, cv=5)
21
22        mlflow.log_metric("cv_moyenne", scores.mean())
23        mlflow.log_metric("cv_ecart_type", scores.std())
24
25        model.fit(X_train, y_train)
26        mlflow.log_metric("test_accuracy", model.score(X_test, y_test))
27        mlflow.xgboost.log_model(model, "model")

Quatre combinaisons, quatre runs, douze métriques - et pas un seul nombre noté sur un bout de papier. Je ne donne pas les résultats en commentaire, car ils dépendent de la version de XGBoost et des données, et surtout ce n'est pas d'eux qu'il s'agit ici : ce qui compte, c'est qu'une fois la boucle terminée il suffit d'un mlflow.search_runs de la section précédente pour voir les quatre essais côte à côte. Fais attention à deux choses. Premièrement, le with se trouve à l'intérieur de la boucle, pas à l'extérieur. Dans le cas contraire, toutes les combinaisons tomberaient dans un run unique, et au deuxième tour de boucle log_params interromprait le programme - souviens-toi qu'un paramètre ne peut être écrit qu'une fois par run, et que la deuxième combinaison essaie de remplacer max_depth en passant de trois à cinq. Deuxièmement, le modèle XGBoost s'enregistre avec la saveur mlflow.xgboost et non mlflow.sklearn, bien que XGBClassifier se fasse passer pour un classifieur scikit-learn ; chaque bibliothèque a sa propre façon de sérialiser et son propre jeu de versions à mémoriser.

Résumé

  • MLflow sert au suivi des expériences ML : il enregistre les paramètres, les métriques et les modèles. Il n'entraîne pas les modèles, il ne sert pas à la création d'interfaces utilisateur et il ne remplace pas les tests unitaires.
  • Un run est une exécution d'entraînement, une expérience est un ensemble nommé de runs. Tu ouvres un run avec with mlflow.start_run(): - la version sans with laisse le run ouvert après chaque exception.
  • Un paramètre s'enregistre en six éléments : mlflow, ., log_param, (, "n_estimators", 100, ) - d'abord le nom, ensuite la valeur. Pour en passer plusieurs d'un coup, log_params accepte un dictionnaire.
  • Une métrique doit être un nombre et peut être écrite plusieurs fois dans un même run ; un paramètre peut être n'importe quoi, mais une seule fois.
  • Le modèle s'enregistre avec la saveur propre à la bibliothèque : mlflow.sklearn.log_model, mlflow.xgboost.log_model, mlflow.pytorch.log_model. N'importe quel fichier se rattache avec log_artifact.
  • L'interface web se lance dans le terminal par la commande mlflow ui, depuis le dossier qui contient mlruns. Pas python mlflow.py, pas mlflow start, pas run mlflow.
  • mlflow.sklearn.autolog() enregistre automatiquement les paramètres et les métriques des modèles scikit-learn. Il ne nettoie pas les données, n'entraîne pas les modèles et ne génère pas de rapports PDF.
  • mlflow.search_runs renvoie un DataFrame - les valeurs des colonnes params. sont des chaînes, celles des colonnes metrics. des nombres.
  • Le registre de modèles donne au modèle un nom et des versions numérotées : registered_model_name à l'enregistrement, l'adresse models:/Nom/1 à la lecture.

Trois projets t'attendent pour clore ce module : un classifieur bâti sur un Pipeline avec validation croisée, un XGBoost avec réglage des hyperparamètres, et un réseau de neurones en PyTorch. Fais chacun d'eux à l'intérieur d'un with mlflow.start_run(): - c'est dix minutes de travail en plus, et au lieu de trois scripts il te restera un journal d'expédition auquel tu reviendras dans six mois. Car un modèle sans paramètres consignés est comme un spécimen sans étiquette : superbe dans la vitrine, inutile pour la science.

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. À quoi sert MLflow ?

  2. 2. Comment lancer l'interface web de MLflow ?

Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Éditeur de code

    Enregistre les paramètres, les métriques et le modèle avec mlflow.start_run().

  • Tri horizontal

    Range le code pour enregistrer un paramètre dans MLflow :

  • Éditeur de code

    Utilise un Pipeline avec prétraitement et RandomForest, évalue avec une validation croisée.

  • Éditeur de code

    Utilise GridSearchCV ou Optuna pour trouver les meilleurs paramètres.

  • Éditeur de code

    Crée un modèle avec plusieurs couches, entraîne-le et sauvegarde-le.

Articles utiles