Implémentez la pipeline CI de votre projet ML avec Github Actions

Jusqu’ici, nous savons comment exposer un modèle via API et comment encapsuler notre projet dans une image Docker. Par contre, tous nos travaux pour le moment sont restés en local !

L’objectif est quand même de rendre tout notre travail utilisable à distance par des utilisateurs métiers. Nous avons brièvement évoqué comment faire, en parlant de Container Registry pour stocker nos images Docker. Mais vous vous doutez bien qu’on ne va pas manuellement copier-coller notre image dans un container registry.

Bien au contraire, l’idée est d’automatiser le maximum possible le processus de déploiement d’un nouveau modèle. Cela tombe bien, notre ami Git propose, en plus du versionning de code, une boîte à outils assez intéressante pour nous faciliter la tâche ! Il s’agit des pipelines CI/CD.

Souvent, ces deux concepts sont groupés ensemble, un peu comme l’UX et l’UI, alors qu’ils font référence à des étapes différentes, servant des besoins distincts. Commençons par la CI !

Découvrez la CI appliquée au ML

Avant de parler des spécificités du ML, abordons en premier l’intérêt de la CI et les étapes conventionnelles qu’on y trouve, dans tous les projets de software engineering en Python.

La CI (Continuous Integration) est un ensemble de processus automatisés (on parle de workflow ou de pipeline) qui s’activent quand un développeur souhaite intégrer une nouvelle version de son code dans un repo Git. Le but de ce workflow est de détecter automatiquement des failles dans la logique du nouveau code ou des problèmes de qualité de code, afin de stopper son intégration dans le repo et alerter le développeur pour qu’il fasse les corrections nécessaires.

On appelle le type de problèmes cités plus haut des problèmes d’intégration, sous-entendu des problèmes d’intégration du code d’un collègue dans le projet global. Le cas le plus souvent où vous allez rencontrer la CI est le suivant :

  • Vous travaillez avec plusieurs collègues sur un projet, bien évidemment versionné sur Git (ou en tout cas, je l’espère pour vous)

  • Vous utilisez les bonnes pratiques de Git, c’est-à-dire : vous implémentez votre nouveau code sur une branche créée à partir de la branche principale (appelée souvent main ou master)

  • Vous décidez de rapatrier votre code dans la branche principale en ouvrant une pull request, puis vous demander à votre collègue de regarder votre code et valider le merge (la fusion) de votre branche avec la branche principale

Eh bien, la CI est souvent insérée précisément à ce moment-là ! Dès que vous créez votre pull request, ce workflow s’active pour exécuter des tests automatisés et d’autres processus de vérification pour détecter les problèmes d’intégration. Cela fera gagner du temps à votre collègue et à vous, qui n’auront à se concentrer que sur d’éventuels bugs, plus subtiles.

Je comprends le principe, mais peux-tu me donner un exemple de tels problèmes ?

On va citer deux catégories de problèmes fréquents dans cette section et une troisième plus tard dans ce chapitre. D’abord les erreurs de linting. Illustrons par une situation très fréquente en Data Science :

  • Vous êtes en pleine phase de feature engineering pour votre premier modèle de ML. Naturellement, il s’agit d’une phase extrêmement exploratoire, à la limite de la R&D.

  • Vous testez plein de features, installez plusieurs packages… certains seront plus pertinentes que d’autres.

  • Vous êtes enfin satisfait de votre modèle et vous faites le tri parmi les features testées. Les moins pertinentes sont archivées dans votre code, les plus pertinentes sont pérennisées dans des fonctions ou des classes dans un script à part.

Comme il s’agit d’un processus très exploratoire, vous avez de très grandes chances, surtout si vous codez sous pression sans avoir beaucoup de temps pour du refactoring :

  • D’avoir des packages importés, mais non utilisés. Ceci alourdit l'exécution de vos scripts

  • D’avoir créé plusieurs variables intermédiaires dans votre code qui ne sont jamais utilisées (le plus souvent, en copiant à gogo des Dataframes pandas). Ceci occupe inutilement votre RAM et peut causer votre code s’arrêter par manque de mémoire pour réaliser un calcul gourmand.

  • D’avoir des fonctions avec des entrées ou des sorties inutilisées. Ce qui rend votre code plus difficile à appréhender par vos collègues.

Passons au deuxième type de risques, les incompatibilités de packages Python. Pas besoin d’aller chercher un autre cas concret, nous pouvons reprendre le même que précédemment ! Dans votre phase de feature engineering, vous avez probablement installé plusieurs packages Python. Qui dit installation de nouveaux packages, dit risque de ‘dependency hell’ (l’enfer des dépendances), quand vous allez souhaiter intégrer votre boulot avec le code de vos collègues.

Si vous faites une pull request avec ces nouveaux packages, sans faire le nécessaire pour rendre votre environnement virtuel reproductible et répétable, vous allez prendre le risque de casser la branche main et le code de vos collègues, qui n’avaient pas prévu de travailler avec vos nouveaux packages. Ainsi, vous allez souvent trouver dans les pipelines CI une étape de test d’installations de tous vos packages.

Pour résumer le tout, quand vous allez créer un pull request à votre collègue, la CI va automatiquement s’activer pour détecter des erreurs de Linting et pour s’assurer que votre nouvel environnement virtuel est solvable et reproductible, avant même que votre collègue regarde la pull request ! Pratique non ?

Comprenez la structure d’un fichier de CI

Sur le papier, vous avez déjà tout compris ! Mais il reste “the elephant in the room”, où et comment coder une CI...

Ok, comment coder une CI ? 

Tout le workflow de la CI est codé dans un fichier YAML (.yml), que nous allons examiner dans un instant. Dans ce qui suit, nous allons utiliser un environnement GitHub, qui utilise ce que l’on appelle GitHub Actions pour implémenter et gérer des pipelines CI/CD.

Voici un fichier YAML très basique, dont nous allons décomposer la syntaxe et le fonctionnement :

# ==================== Nom de la CI ====================
# Le nom qui apparaîtra dans l'interface GitHub Actions
name: <NOM_DE_LA_CI>

# ==================== Déclencheurs ====================
# Définit QUAND cette CI doit s'exécuter
on:
  <EVENEMENT>:  # Exemples: push, pull_request, schedule, workflow_dispatch

# ==================== Jobs ====================
# Un ou plusieurs jobs qui s'exécutent (par défaut en parallèle)
jobs:
  
  <nom_du_job>:  # Identifiant unique du job
    
    # Machine virtuelle sur laquelle le job s'exécute
    runs-on: <TYPE_DE_MACHINE>  # Exemples: ubuntu-latest, windows-latest, macos-latest
    
    # ==================== Steps ====================
    # Liste des étapes séquentielles qui composent le job
    steps:
    
    # Étape 1 : Utilisation d'une action prédéfinie
    - uses: <ORGANISATION>/<ACTION>@<VERSION>
      with:
        <parametre>: <valeur>
    
    # Étape 2 : Exécution d'une commande shell
    - name: <Description de l'étape>
      run: |
        <commande_1>
        <commande_2>
    
    # Étape 3 : Une autre action ou commande
    - name: <Description de l'étape>
      run: <commande_simple>

Les commentaires au-dessus des lignes de codes expliquent leur rôle et les expressions entre <> sont des placeholders du vrai code qui sera inséré. On verra un exemple avec du vrai code dans la section juste après, mais en attendant, les sections les plus importantes sont les suivantes : 

  • On :  L’action dans Git qui déclenche la CI. Les plus courants sontpush,pull_request, ouschedule(exécution planifiée par un orchestrateur, vous saurez de quoi je parle lors du dernier chapitre de ce cours). Vous pouvez même combiner plusieurs déclencheurs.

  • Jobs : La section qui contient tous les jobs à exécuter. Chaque job est un ensemble de Steps et possède un identifiant unique (commetest,build,deploy). Par défaut, les jobs s'exécutent en parallèle, mais vous pouvez définir des dépendances pour leur exécution avecneeds.

  • Steps : La liste ordonnée des étapes qui composent le job. Elles s'exécutent séquentiellement dans l'ordre défini. Si une étape échoue, les suivantes ne s'exécutent pas (sauf configuration contraire). 

Run de la pipeline CI sur Github Actions

Nous allons maintenant mettre en musique tous les concepts que nous avons vus à date ! Je vous propose une démo où l’on essaye de pousser dans le repo Git un nouveau script Python qui contient des analyses exploratoires en vrac. Situation qui arrive très souvent quand on est Data Scientists et que l’on doit implémenter une nouvelle modélisation, ou comprendre pourquoi une modélisation existante ne fonctionne pas. On va ensuite vouloir merge dans la branche principale, pour voir comment la CI s’active et comment elle nous aide à gagner en efficacité.

Application de la CI au ML

Vous savez désormais lire la structure d’un fichier YAML pour de la CI ! Lors du screencast, nous avons un peu survolé l’étape lié à l'exécution des tests unitaires. Elle reste toutefois cruciale dans un projet ML. Regardons ensemble un exemple.

Si vous venez du cours Maitrisez l’apprentissage supervisé, vous savez que la qualité du feature engineering est le facteur le plus essentiel dans la construction d’un modèle ML. Il faut absolument se donner les moyens de garantir la cohérence et la qualité de ces features. Dans ce même cours, on a un chapitre sur le package Evidently, celui-ci s’assure que les features n’ont pas subi un drift statistique, excellent pour le monitoring. 

Cependant, Evidently ne va pas vérifier quelque chose de beaucoup plus bête : Si le code qui calcule la feature, la calcule bien comme il faut ! Pour répondre à ce besoin les tests unitaires sont incontournables. 

Dans le cas d’usage de l’immobilier, nous centralisons le code pour calculer les features dans un script  feature_engineering_functions.py, qui contient une série de fonctions en Python. 

Si nous voulons que notre code soit vraiment robuste, il faudrait que chacune de ses fonctions soient accompagnées d’un test unitaire ! 

Dans cette section, concentrons-nous sur une seule fonction :compute_price_per_m2_features. Elle calcule plusieurs features laggés ainsi que des moyennes glissantes, groupées par ville. Chaque calcul de cette fonction peut introduire des NaN de façon silencieuse, si elle n’est pas testée.

Il serait logique d’écrire un test qui peut détecter ces effets de bord, comme les suivants : 

  • Villes avec moins de 6 mois de données, qui seraient incompatibles avec une moyenne glissante sur 6 mois

  • Villes avec une seule transaction, les comparaisons au mois derniers peuvent renvoyer qu’un simple NaN 

  • Données non triées (le sort est critique à cause de l’opération  .over() ) 

La fonction  compute_price_per_m2_features  peut tourner sans renvoyer le moindre message d’erreur, mais crée des données de faible qualité, si nous n ' élaborons pas un test ! 

import polars as pl
import pytest
import os

@pytest.fixture()
def db_connection():
    """Connexion à la base PostgreSQL de test"""
    return os.getenv("DATABASE_URL")

def test_compute_price_features_conserve_villes_avec_historique(db_connection):
    """Vérifie qu'on ne perd pas de villes avec ≥6 mois de données"""
    
    # Requête SQL pour récupérer les données historiques
    query = """
        SELECT 
            departement,
            ville,
            id_ville,
            annee_transaction,
            mois_transaction,
            prix_m2_moyen,
            nb_transactions_mois
        FROM transactions_immobilieres
        WHERE annee_transaction = 2023
        ORDER BY departement, ville, id_ville, annee_transaction, mois_transaction
    """
    
    input_df = pl.read_database(query, connection=db_connection)
    
    # Requête SQL pour identifier les villes valides (≥6 mois de données)
    query_villes_valides = """
        SELECT DISTINCT departement, ville, id_ville
        FROM transactions_immobilieres
        WHERE annee_transaction = 2023
        GROUP BY departement, ville, id_ville
        HAVING COUNT(*) >= 6
    """
    
    input_villes_valides = pl.read_database(query_villes_valides, connection=db_connection)
    
    # Act
    result_df = compute_price_per_m2_features(
        input_df,
        sort_columns=["departement", "ville", "id_ville", "annee_transaction", "mois_transaction"]
    )
    
    result_villes = (
        result_df
        .select(["departement", "ville", "id_ville"])
        .unique()
    )
    
    # Assert
    assert len(result_villes) >= len(input_villes_valides), \
        f"Perte de villes : {len(input_villes_valides)} attendues, {len(result_villes)} obtenues"
    
    assert "prix_m2_moyen_mois_precedent" in result_df.columns
    assert "prix_m2_moyen_glissant_6mo" in result_df.columns
    assert len(result_df) > 0, "Le DataFrame résultat est vide"

Chaque ligne précédée de la commande assert, va forcer le code de s’arrêter si jamais la condition booléenne de la ligne de code renvoie False. Ici en l’occurrence, la fonction : 

  • Detecte si une ville a plus de 6 mois de données en entrée

  • Detecte si une supprission du nombre de NaNs élemine trop agressivement des lignes

  • Vérifie que les transformations (shift, rolling_mean) ont bien été appliquées

  • Détecte une régression si quelqu'un renomme ou supprime des colonnes

  • Garantit qu'on ne retourne pas un DataFrame vide par accident

Voici 3 exemples de bugs détectés par le test : 

  1. Exemple 1 : Bug de tri Si quelqu'un modifie le code et oublie le  .sort(), les opérations  .shift().over()  mélangent les séries temporelles de différentes villes → le filtre NaN élimine tout → le test échoue.

  2. Exemple 2 : Mauvais identifiant de groupement Si on remplace  CITY_UNIQUE_ID  par une colonne erronée, les rolling windows calculent sur des groupes incorrects → NaN massifs → perte de villes → test échoue.

  3. Exemple 3 : Changement de window_size Si quelqu'un passe  window_size=12  au lieu de 6, toutes les villes avec 6-11 mois disparaissent → test échoue.

Enfin, vous verrez une fonction juste au-dessus du test avec un décorateur pytest.fixture. Ce décorateur permet de préciser à pytest qu’il faut réaliser certaines opérations AVANT d'exécuter les tests unitaires. Dans notre cas, il faut établir la connexion avec la base de données Postgres, utilisée dans le test que l’on vient de regarder ensemble.

Mais du coup, si j’ai déjà une étape dans ma CI qui exécute les tests unitaires, je n’ai pas à modifier le fichier de CI qu’on a vu dans le screencast non ? 

Presque ! Le seul souci avec le fichier yaml qu’on a c’est qu’il ne va pas reconnaitre la base de données Postgres au moment de l'exécution de la CI. N’oubliez pas qu’une pipeline CI, comme Docker, part d’une page blanche. Il faut alors enrichir le fichier yaml avec une section qui assure la connexion.

Cela ressemble à quelque chose comme ceci : 

jobs:
  test:
    runs-on: ubuntu-latest
    
    # Ajout du service PostgreSQL
    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_USER: test_user
          POSTGRES_PASSWORD: test_password
          POSTGRES_DB: test_db
        ports:
          - 5432:5432

steps : 
  <RESTE DE LA CI ICI>

Les lignes de codes liées à Postgres sont sous une catégorie “services” ? Je croyais qu’on avait que des jobs composés de steps ? 

C’est vrai et délibéré. Les jobs et les steps représentent le cœur du YAML, mais elles ont une limitation fondamentale, les steps s'exécutent de manière séquentielle. Or nous pouvons potentiellement avoir besoin de la base de données Postgres en arrière-plan pendant tout le long des étapes de la CI. 

La catégorie services au sein d’un yaml permet de lancer des conteneurs Docker qui tournent en parallèle pendant toute la durée du job. Encore une fois, nous n’avons pas besoin de réinventer la roue ici : Postgres est une solution très connue et Github nous propose une image Docker sur étagère à inclure dans notre CI.

Bravo ! Vous avez désormais les billes pour travailler avec une équipe qui utilise une CI, concept incontournable de ML Engineering et de MLOps dans les équipes Data modernes d’aujourd’hui.

En résumé 

  • Automatisez la détection des erreurs grâce à la CI, déclenchée à chaque pull request.

  • Intégrez des étapes de linting, de test d’installation de packages et de tests unitaires pour fiabiliser votre code ML.

  • Structurez vos fichiers de CI dans un dossier.github/workflows/avec des fichiers YAML bien définis.

  • Utilisez des services comme PostgreSQL dans vos pipelines pour tester le code en conditions réalistes.

  • Rédigez des tests unitaires robustes pour vérifier la cohérence des features critiques, comme les moyennes glissantes ou les transformations temporelles.

Maintenant que vous savez automatiser la validation de votre code, passons au chapitre suivant pour découvrir comment automatiser aussi sa mise en production, avec la CD (Continuous Delivery) !

Et si vous obteniez un diplôme OpenClassrooms ?
  • Formations jusqu’à 100 % financées
  • Date de début flexible
  • Projets professionnalisants
  • Mentorat individuel
Trouvez la formation et le financement faits pour vous