Formation ouverte au Data Engineering

Apprendre à bâtir des pipelines de données, pour de vrai

Cette formation, je la construis d'abord pour structurer mon propre apprentissage, et je l'ouvre à toute personne qui veut entrer dans le data engineering. L'objectif est simple : que vous appreniez l'essentiel ici, sur le site, dans le bon ordre, avec des exemples concrets tirés de notre réalité (mobile money, microfinance, inclusion financière), et que vous soyez capables de tenir un poste à la fin.

Aucun prérequis fort. Si vous savez allumer un ordinateur et que vous êtes prêts à taper des commandes, vous êtes au bon endroit. On avance module par module, sans brûler les étapes.

Un mot d'honnêteté. On n'apprend pas ce métier en lisant, mais en pratiquant. Ce site vous donne le socle, la logique et les bons réflexes. La maîtrise, elle, vient quand vous construisez vos propres pipelines et que vous réparez ce qui casse. Les projets guidés à la fin sont faits pour ça.

0%
Votre progression 0 / 0 leçons
Module 01

Le métier, avant les outils

Avant d'écrire une ligne de code, il faut comprendre à quoi sert vraiment un data engineer, et pourquoi ce rôle est devenu central. On pose ici la carte du terrain et l'ordre dans lequel on va apprendre.


Le rôle

À quoi sert un data engineer

Une entreprise produit de la donnée en permanence. Une agence de microfinance enregistre des prêts et des remboursements, un opérateur de mobile money encaisse des milliers de transactions par minute, une application garde des journaux d'activité. Toute cette donnée arrive brute, éparpillée, souvent sale, dans des formats qui n'ont pas été pensés pour l'analyse.

Le data engineer est la personne qui construit les tuyaux fiables qui transforment ce désordre en tables propres, à jour et dignes de confiance. Sur ces tables, les analystes calculent des indicateurs, les data scientists entraînent des modèles, et les équipes métier prennent des décisions. Si les tuyaux fuient, tout ce qui est en aval fuit aussi. C'est un métier de plomberie de haute précision, et c'est exactement pour ça qu'il est recherché.

Un raccourci utile pour retenir le périmètre : l'analyste répond à la question, le data scientist prédit, et le data engineer rend les deux possibles en garantissant que la donnée est là, propre, et rejouable.

Notre contexte

En Afrique de l'Ouest, la donnée explose (mobile money, fintech, inclusion financière dans l'UEMOA) alors que les ingénieurs data restent rares. C'est une opportunité réelle. Les exemples de cette formation viennent de ce terrain, parce que c'est là qu'on va travailler.

La vue d'ensemble

Le chemin de la donnée, et la stack qu'on va bâtir

Presque tous les systèmes de données modernes suivent le même chemin. On extrait la donnée d'un système source, on la range dans un stockage central, on la transforme par étapes jusqu'à obtenir des tables métier, puis on la sert aux tableaux de bord, aux modèles et aux applications. Le tout est piloté par un orchestrateur qui décide quand et dans quel ordre chaque étape s'exécute.

Sourcesmobile money, core banking Ingestionextraire, charger Stockagelac, entrepôt Transformerdbt, SQL Tables métierprêtes à l'emploi UsageBI, ML, API Orchestration : planifie, enchaîne, reprend en cas d'erreur
Chaque module de cette formation suit cet ordre. On apprend d'abord le socle, puis chaque maillon, et on finit par les relier dans des projets complets.

Vous entendrez souvent les sigles ETL et ELT. Les deux décrivent le même trajet, l'ordre change. En ETL on transforme avant de charger dans l'entrepôt, en ELT on charge d'abord le brut puis on transforme à l'intérieur de l'entrepôt. L'approche moderne est ELT, parce que les entrepôts d'aujourd'hui sont assez puissants pour transformer chez eux, et parce que garder le brut permet de tout recalculer si on découvre une erreur.

Voici l'ordre dans lequel on va apprendre, et il n'est pas négociable : le socle (terminal, git), puis SQL, puis la modélisation, puis dbt, puis l'ingestion, puis l'orchestration, puis le cloud, puis les conteneurs, puis la qualité, puis l'IA. On ne mélange pas. Chaque brique s'appuie sur la précédente.

Module 02

Le socle technique

Deux outils vous suivront chaque jour, quel que soit l'employeur : le terminal et Git. On les apprend maintenant, calmement, parce que tout le reste s'appuie dessus.


Le terminal

Parler à la machine en ligne de commande

Le terminal fait peur au début, puis devient une seconde nature. C'est une fenêtre où l'on tape des commandes texte au lieu de cliquer. En data, on ne l'évite pas : les serveurs n'ont pas d'interface graphique, les pipelines lancent des commandes, et le debug se fait ici.

Trois commandes suffisent pour ne jamais être perdu. pwd affiche où vous êtes. ls liste ce qu'il y a autour. cd vous déplace de dossier en dossier. Avec ça, vous naviguez.

pwd                 # où suis-je ?
ls -la              # tout lister, fichiers cachés compris
cd projets/data     # entrer dans un dossier
cd ..               # remonter d'un cran

La vraie puissance vient du pipe, la barre verticale |. Elle branche la sortie d'une commande sur l'entrée de la suivante. On assemble ainsi de petits outils pour obtenir un résultat précis, sans ouvrir aucun fichier. Imaginez un fichier de transactions mobile money et cette question : combien de lignes contiennent une erreur ?

cat transactions.log | grep -i "error" | wc -l

On lit le fichier, on ne garde que les lignes contenant error, on les compte. Trois outils simples, une réponse. Ce réflexe d'assemblage se retrouve partout ensuite, dans les scripts, dans Docker, dans Airflow.

Quatre outils qui font la moitié du travail

grep filtre les lignes, sort les trie, uniq -c compte les répétitions, wc -l compte les lignes. Enchaînés, ils remplacent beaucoup de petits programmes qu'on croyait devoir écrire.

Pour compléter à votre rythme, une référence gratuite et sérieuse : le cours « The Missing Semester » du MIT couvre le shell et les outils en ligne de commande.

Git, étape 1

Le modèle mental et le cycle quotidien

Git enregistre l'histoire de votre code, version après version. Il permet de revenir en arrière, de travailler à plusieurs sans s'écraser, et de comprendre qui a changé quoi. La confusion des débutants vient presque toujours d'un point qu'on va clarifier tout de suite, un fichier traverse trois espaces.

Il y a ce que vous éditez sur votre disque, le répertoire de travail. Il y a ce que vous avez sélectionné pour le prochain enregistrement, la zone de préparation, le staging. Et il y a l'historique gravé, le dépôt. On déplace du travail d'un espace à l'autre avec des commandes.

répertoire de travail  ──git add──▶  staging  ──git commit──▶  historique

Le cycle de tous les jours tient en quatre gestes. On regarde ce qui a changé, on relit précisément la modification, on sélectionne, on grave avec un message clair.

git init                    # démarrer un dépôt dans le dossier courant
git status                  # qu'est-ce qui a changé ?
git diff                    # montre les modifications ligne par ligne
git add transform.sql       # je sélectionne ce fichier
git commit -m "ajout du calcul PAR30"
git log --oneline           # l'historique, une ligne par enregistrement

Le staging n'est pas une lourdeur administrative, c'est ce qui vous laisse composer des enregistrements propres, un sujet à la fois. Prenez l'habitude de faire un git diff avant chaque commit, c'est votre relecture avant de graver. Un bon message dit ce que fait le changement, à l'impératif et sans jargon.

Le piège à éviter dès le premier jour

Ne mettez jamais un mot de passe ou une clé d'accès dans le code. Une fois gravée dans l'historique, la clé y reste même après suppression, et elle doit être considérée comme compromise. La bonne habitude, un fichier .gitignore et des secrets rangés dans des variables d'environnement, hors du dépôt.

Git, étape 2

Branches, fusion et conflits

Une branche est une ligne de travail parallèle. On en crée une pour développer une fonctionnalité sans toucher à la version principale, puis on fusionne quand c'est prêt. C'est la base du travail en équipe, mais c'est utile même seul, pour isoler une idée.

git branch                        # lister les branches et voir où l'on est
git switch -c feature/par30        # créer une branche et s'y placer
# ... on travaille, on ajoute, on enregistre sur cette branche ...
git switch main                    # revenir sur la principale
git merge feature/par30            # fusionner le travail dans main

Tant que deux branches modifient des fichiers différents, la fusion est automatique. Le conflit arrive quand les deux ont changé les mêmes lignes. Git ne choisit pas à votre place, il marque la zone en désaccord et vous demande de trancher.

<<<<<<< HEAD
    montant * 1.10        # la version de main
=======
    montant * 1.12        # la version de la branche
>>>>>>> feature/par30

Vous ouvrez le fichier, vous gardez la bonne version, vous retirez les trois lignes de marqueurs, puis vous terminez la fusion comme un enregistrement normal.

git add tarifs.sql       # le conflit est résolu dans ce fichier
git commit               # finaliser la fusion

Git, étape 3

Travailler à plusieurs, et réparer sans paniquer

Le dépôt distant, souvent sur GitHub, est la copie partagée de l'équipe. On récupère un projet, on récupère les changements des autres avant de commencer, et on envoie les siens quand ils sont prêts.

git clone git@github.com:org/projet.git   # récupérer un dépôt
git pull                                    # récupérer les changements distants
git push                                    # envoyer les siens

Vient toujours le moment où l'on veut annuler quelque chose. Voici les gestes de secours, du plus doux au plus fort. Le point clé, --soft garde votre travail, --hard le détruit, donc réfléchissez avant ce dernier.

git restore fichier.sql          # jeter mes modifications non enregistrées
git restore --staged fichier.sql # retirer du staging sans perdre le fichier
git commit --amend              # corriger le dernier enregistrement
git reset --soft HEAD~1         # annuler le dernier commit, garder le travail
git revert <identifiant>         # annuler proprement un commit déjà partagé

La distinction entre reset et revert compte. On utilise reset sur du travail local pas encore partagé. Dès qu'un commit est parti sur le dépôt distant, on utilise revert, qui ajoute un commit d'annulation au lieu de réécrire une histoire que d'autres ont peut-être déjà récupérée.

# .gitignore, pour ne jamais versionner ce qui ne doit pas l'être
.venv/
__pycache__/
.env
*.duckdb
target/
Module 03

SQL, le langage qui ne vous quittera jamais

Les outils passent, SQL reste. C'est le langage de l'entrepôt, et dbt, que l'on verra plus loin, n'est qu'une façon d'organiser du SQL. Ce module l'enseigne pour de bon, avec des exemples de transactions et de prêts.


Interroger

Choisir, filtrer, trier

Une requête SQL décrit ce que vous voulez, pas comment l'obtenir. On indique les colonnes voulues avec SELECT, la table avec FROM, les lignes à garder avec WHERE, et l'ordre avec ORDER BY. Prenons une table transactions de mobile money, avec un montant en FCFA, un statut, et une date.

SELECT id, montant, statut
FROM transactions
WHERE montant > 100000
  AND statut = 'reussie'
ORDER BY montant DESC
LIMIT 10;

On lit cette requête comme une phrase. Parmi les transactions, garde celles dont le montant dépasse 100 000 FCFA et qui ont réussi, montre l'identifiant, le montant et le statut, du plus gros montant au plus petit, et arrête-toi aux dix premières. C'est tout. La difficulté n'est pas la syntaxe, c'est de penser en ensembles de lignes plutôt qu'en boucles.

Un ordre mental aide à raisonner. La base lit d'abord FROM pour savoir de quelle table on parle, applique WHERE pour ne garder que certaines lignes, puis SELECT pour choisir les colonnes, et enfin ORDER BY et LIMIT pour trier et couper. Beaucoup d'erreurs de débutant disparaissent quand on garde cet ordre en tête.

Regrouper et croiser

Agréger avec GROUP BY, relier avec JOIN

Passer du détail à l'indicateur, c'est le cœur de l'analytique. On regroupe des lignes avec GROUP BY, puis on les résume avec des fonctions comme COUNT, SUM ou AVG. Combien de transactions par statut, et pour quel montant total ?

SELECT statut,
       COUNT(*)      AS nb,
       SUM(montant)  AS total_fcfa
FROM transactions
GROUP BY statut;

Souvent la donnée utile est éclatée sur plusieurs tables. Les transactions d'un côté, les clients de l'autre, reliés par un identifiant. Le JOIN les recolle. On indique la table à joindre et la condition de liaison.

SELECT c.pays, COUNT(*) AS nb, SUM(t.montant) AS volume
FROM transactions t
JOIN clients c ON c.id = t.client_id
WHERE t.statut = 'reussie'
GROUP BY c.pays
HAVING COUNT(*) > 1000;

Notez la différence entre WHERE et HAVING. Le premier filtre les lignes avant le regroupement, le second filtre les groupes après. Ce n'est pas un détail cosmétique, c'est une question d'ordre d'exécution, et c'est la source de bien des surprises.

Le JOIN qui gonfle les chiffres

Si la clé de la table de droite n'est pas unique, chaque ligne de gauche se duplique lors du JOIN, et un SUM ensuite devient faux, sans lever la moindre erreur. Le réflexe qui sauve : compter les lignes avant et après le join quand un total paraît trop gros. Un volume de transactions qui double sans raison, c'est presque toujours ça.

SQL qui fait la différence

CTE et fonctions de fenêtre

Quand une requête grossit, elle devient illisible. Les CTE, introduites par WITH, la découpent en étapes nommées que l'on enchaîne. Chaque étape porte un nom parlant, on la lit de haut en bas comme un raisonnement. C'est exactement la logique qu'on retrouvera dans dbt, où chaque étape devient un fichier.

Les fonctions de fenêtre, elles, calculent sur un groupe de lignes sans les écraser. Contrairement à GROUP BY qui réduit, une fonction de fenêtre garde chaque ligne et lui ajoute un calcul. Classements, cumuls, comparaison avec la ligne précédente. Prenons le volume mensuel par client, avec son cumul dans le temps.

WITH mensuel AS (
  SELECT client_id,
         date_trunc('month', date_tx) AS mois,
         SUM(montant) AS volume
  FROM transactions
  GROUP BY 1, 2
)
SELECT client_id, mois, volume,
       SUM(volume) OVER (
         PARTITION BY client_id ORDER BY mois
       ) AS volume_cumule
FROM mensuel;

La partie OVER définit la fenêtre. Ici, pour chaque client pris séparément, dans l'ordre des mois, additionne les volumes depuis le début. On obtient le volume du mois et le cumul, ligne par ligne. Ce genre de calcul est partout en fintech : encours cumulé, rang des meilleurs clients, écart avec le mois précédent.

Le réflexe du bon SQL

Si une sous-requête revient deux fois dans votre code, c'est une CTE qui s'ignore. Nommez-la une fois, réutilisez-la. Votre requête devient plus courte, plus lisible, et plus facile à corriger.

Module 04

Modéliser la donnée

Transformer, ce n'est pas empiler des requêtes jusqu'à ce que ça marche. C'est organiser la donnée en couches, du brut vers le métier. Ce principe est ce qui rend un projet compréhensible six mois plus tard, par vous ou par la personne qui reprend votre poste.


L'architecture en couches

Du brut au métier, une étape à la fois

La donnée brute est rarement utilisable telle quelle. Colonnes aux noms obscurs, types incohérents, doublons, valeurs manquantes. Plutôt qu'une énorme requête qui fait tout d'un coup, on fait progresser la donnée par couches, chacune avec une responsabilité claire.

Bruttel qu'il arrive Stagingnettoyé, renommé,typé Intermédiairejointures, logiquemétier Métierindicateurs, features,tables finales on n'y touche jamais consommé par la BI, le ML, les API
Chaque couche a un seul rôle. Si une source change de format, seul le staging correspondant est touché, le reste du projet ne bouge pas.

L'intérêt est très concret. Imaginez que l'opérateur mobile money renomme une colonne dans son export. Sans couches, il faut chasser ce changement partout. Avec des couches, on corrige un seul fichier de staging, et tout le métier en aval continue de fonctionner. On gagne aussi des points où tester et documenter, à chaque étape.

Le vocabulaire varie selon les équipes. Certaines parlent de bronze, argent et or, d'autres de staging, intermédiaire et marts. Peu importe le nom, l'idée est la même : ne jamais aller directement du brut au tableau de bord.

La modélisation dimensionnelle

Faits et dimensions

Pour les tables finales, un modèle éprouvé s'est imposé, celui des faits et des dimensions. Une table de faits contient les événements mesurables, une ligne par transaction par exemple, avec les montants. Les tables de dimensions contiennent le contexte descriptif : le client, le produit, la date, l'agence.

On sépare ainsi le quoi mesurer du par quoi analyser. La table de faits reste fine et longue, les dimensions restent riches et courtes. Pour analyser, on relie les faits aux dimensions. Cette organisation, appelée schéma en étoile, rend les tableaux de bord rapides et les requêtes lisibles. Un analyste peut demander le volume par pays, par mois, par type de compte, sans réécrire la logique de calcul à chaque fois.

On n'a pas besoin d'en faire une religion. Retenez le principe : isolez les mesures dans des faits, isolez le contexte dans des dimensions, reliez par des identifiants. Vous y reviendrez naturellement quand vos marts grandiront.

Pour approfondir un jour, les techniques de Ralph Kimball font référence sur la modélisation dimensionnelle.

Module 05

dbt, de zéro à un projet complet

Ce module ne survole pas dbt, il vous fait construire un vrai projet, fichier par fichier. À la fin, vous devez pouvoir créer le projet, déclarer des sources, écrire des modèles en couches, une macro, des tests, un modèle incrémental, un snapshot et un contrat, puis tout lancer. Ne vous contentez pas de lire, ouvrez un éditeur et tapez chaque fichier. On travaille sur un thème mobile money, avec DuckDB pour que tout tourne en local sans compte cloud.


Étape 1

Créer le projet et le faire tourner

Un projet dbt est un dossier avec une organisation précise. Créez-le, puis regardez sa structure. Chaque emplacement a un rôle, et vous vous y référerez tout le temps.

pip install dbt-core dbt-duckdb
dbt init mm_analytics       # crée le squelette du projet
mm_analytics/
├── dbt_project.yml         # la configuration du projet
├── packages.yml            # les paquets externes (dbt_utils, etc.)
├── models/
│   ├── staging/            # une source nettoyée par modèle
│   ├── intermediate/       # jointures et logique métier
│   └── marts/              # tables finales, prêtes à l'usage
├── macros/                 # fonctions SQL réutilisables
├── tests/                  # tests de qualité écrits à la main
├── snapshots/              # historisation (SCD type 2)
└── seeds/                  # petits fichiers CSV de référence

Le fichier dbt_project.yml est le cœur. On y nomme le projet et on y règle, par dossier, comment les modèles sont matérialisés. Ici, tout le staging sera en vue, les marts en table. Vous n'aurez plus à le répéter dans chaque fichier.

# dbt_project.yml
name: 'mm_analytics'
profile: 'mm_analytics'
version: '1.0.0'

models:
  mm_analytics:
    staging:
      +materialized: view
    intermediate:
      +materialized: view
    marts:
      +materialized: table

La connexion à la base vit à part, dans ~/.dbt/profiles.yml. On la sépare du code pour ne jamais versionner de secret. Voici une cible DuckDB locale, un simple fichier sur votre disque.

# ~/.dbt/profiles.yml
mm_analytics:
  target: dev
  outputs:
    dev:
      type: duckdb
      path: mm_analytics.duckdb
      threads: 4

Vérifiez que tout est branché, puis lancez une première construction. Ces trois commandes reviendront chaque jour.

dbt debug     # la connexion fonctionne-t-elle ?
dbt deps      # installe les paquets de packages.yml
dbt build     # construit et teste tous les modèles

Étape 2

Déclarer les sources et écrire un modèle de staging

On ne lit jamais une table brute directement dans un modèle. On la déclare d'abord comme source, dans un fichier YAML. Cela donne un nom logique, permet de surveiller la fraîcheur, et documente d'où vient la donnée.

# models/staging/mobile_money/_mm__sources.yml
version: 2
sources:
  - name: mobile_money
    schema: raw
    tables:
      - name: transactions
        loaded_at_field: ingested_at
        freshness:
          warn_after:  { count: 12, period: hour }
          error_after: { count: 24, period: hour }
        columns:
          - name: id
            description: "Identifiant unique de la transaction"

Le modèle de staging fait un travail précis et limité, il nettoie une seule source. Renommer les colonnes en clair, forcer les bons types, gérer les valeurs manquantes, et surtout retirer les doublons. Voici un staging complet, pas un extrait.

-- models/staging/mobile_money/stg_mm__transactions.sql
WITH source AS (
    SELECT * FROM {{ source('mobile_money', 'transactions') }}
),

nettoye AS (
    SELECT
        CAST(id AS BIGINT)              AS transaction_id,
        CAST(client_id AS BIGINT)       AS client_id,
        CAST(montant AS DECIMAL(18,2)) AS montant_fcfa,
        LOWER(TRIM(statut))            AS statut,
        CAST(date_tx AS TIMESTAMP)      AS date_tx,
        COALESCE(canal, 'inconnu')      AS canal
    FROM source
    WHERE id IS NOT NULL
),

dedoublonne AS (
    SELECT *,
        ROW_NUMBER() OVER (
            PARTITION BY transaction_id ORDER BY date_tx DESC
        ) AS rn
    FROM nettoye
)

SELECT transaction_id, client_id, montant_fcfa, statut, date_tx, canal
FROM dedoublonne
WHERE rn = 1   -- on ne garde que la version la plus récente de chaque id

Chaque étape est une CTE nommée, on lit le fichier de haut en bas comme un raisonnement. La dernière CTE numérote les lignes par identifiant, du plus récent au plus ancien, et on ne garde que la première. C'est la façon standard de dédoublonner en SQL, à connaître par cœur.

Une convention qui aide

Nommez vos modèles de staging stg_source__objet, par exemple stg_mm__transactions. On sait d'un coup d'où vient la donnée. La cohérence des noms est un vrai gain quand le projet grandit.

Étape 3

Construire les couches intermédiaire et métier

Le staging est propre mais isolé, une source à la fois. La couche intermédiaire relie les sources entre elles et porte la logique métier réutilisable. Ici, on rattache chaque transaction à son client, et on marque celles qui ont réussi.

-- models/intermediate/int_transactions_enrichies.sql
SELECT
    t.transaction_id,
    t.client_id,
    c.pays,
    c.date_inscription,
    t.montant_fcfa,
    t.date_tx,
    (t.statut = 'reussie') AS est_reussie
FROM {{ ref('stg_mm__transactions') }} t
LEFT JOIN {{ ref('stg_mm__clients') }} c
    ON c.client_id = t.client_id

On utilise un LEFT JOIN pour ne pas perdre une transaction dont le client serait absent du référentiel, ce qui arrive en vrai. La colonne est_reussie calcule un booléen une seule fois, ici, pour que les marts n'aient plus à le refaire.

La couche métier produit enfin une table prête à l'usage. Un indicateur par client, avec des fonctions d'agrégation et une fonction de fenêtre pour la première date d'activité.

-- models/marts/fct_activite_client.sql
SELECT
    client_id,
    pays,
    COUNT(*)                                   AS nb_transactions,
    COUNT(*) FILTER (WHERE est_reussie)     AS nb_reussies,
    SUM(montant_fcfa) FILTER (WHERE est_reussie) AS volume_fcfa,
    MIN(date_tx)                              AS premiere_activite,
    MAX(date_tx)                              AS derniere_activite
FROM {{ ref('int_transactions_enrichies') }}
GROUP BY client_id, pays

La clause FILTER permet de compter et sommer seulement les transactions réussies, dans la même requête, sans sous-requête. C'est plus lisible qu'un CASE WHEN imbriqué. Vous avez maintenant une chaîne complète, source, staging, intermédiaire, mart, que dbt exécutera dans le bon ordre grâce aux ref().

Étape 4

Les macros, pour ne pas se répéter

Une macro est une fonction écrite en Jinja qui produit du SQL. Dès qu'un même bout de logique revient dans plusieurs modèles, on en fait une macro. Prenons un cas concret et fréquent en fintech, classer un montant en tranches.

-- macros/tranche_montant.sql
{% macro tranche_montant(colonne) %}
    CASE
        WHEN {{ colonne }} < 10000   THEN 'micro'
        WHEN {{ colonne }} < 100000  THEN 'petit'
        WHEN {{ colonne }} < 1000000 THEN 'moyen'
        ELSE 'gros'
    END
{% endmacro %}

On l'appelle ensuite dans n'importe quel modèle, en passant le nom de la colonne. La logique de découpage vit à un seul endroit. Le jour où les seuils changent, on modifie la macro, et tous les modèles qui l'utilisent suivent.

SELECT transaction_id,
       montant_fcfa,
       {{ tranche_montant('montant_fcfa') }} AS tranche
FROM {{ ref('stg_mm__transactions') }}

On n'a pas besoin de tout réinventer. Le paquet dbt_utils fournit des macros toutes prêtes, comme la génération de clés de substitution ou la sélection dynamique de colonnes. On l'ajoute au projet et on l'installe.

# packages.yml
packages:
  - package: dbt-labs/dbt_utils
    version: [">=1.1.0", "<2.0.0"]
dbt deps    # télécharge le paquet

# puis, dans un modèle, une clé unique construite à partir de deux colonnes
SELECT {{ dbt_utils.generate_surrogate_key(['client_id', 'date_tx']) }} AS cle, ...

Étape 5

Tester la donnée, la vraie qualité (DQC)

dbt permet deux familles de tests. Les tests génériques se déclarent en YAML, à côté du modèle, et sont réutilisables. On y affirme qu'une colonne est unique, jamais vide, ne prend que certaines valeurs, ou pointe bien vers une clé d'une autre table.

# models/staging/mobile_money/_mm__models.yml
version: 2
models:
  - name: stg_mm__transactions
    columns:
      - name: transaction_id
        tests:
          - unique
          - not_null
      - name: statut
        tests:
          - accepted_values:
              values: ['reussie', 'echouee', 'en_attente']
      - name: client_id
        tests:
          - relationships:
              to: ref('stg_mm__clients')
              field: client_id

Le test relationships mérite qu'on s'y arrête, il vérifie l'intégrité référentielle, autrement dit que chaque client_id d'une transaction existe bien dans la table des clients. C'est un des contrôles les plus utiles en pratique.

La seconde famille, ce sont les tests singuliers, une requête SQL que vous écrivez à la main dans le dossier tests. La règle est simple, le test échoue si la requête renvoie au moins une ligne. On l'utilise pour des règles métier qu'aucun test générique ne couvre.

-- tests/assert_pas_de_montant_negatif.sql
-- doit renvoyer zéro ligne : un montant négatif est une anomalie
SELECT transaction_id, montant_fcfa
FROM {{ ref('stg_mm__transactions') }}
WHERE montant_fcfa < 0

Pour des contrôles plus riches, le paquet dbt_expectations apporte des tests statistiques, par exemple vérifier qu'une valeur reste dans une fourchette, ou que le nombre de lignes ne s'effondre pas d'un jour à l'autre.

      - name: montant_fcfa
        tests:
          - dbt_expectations.expect_column_values_to_be_between:
              min_value: 0
              max_value: 5000000

On lance tout avec dbt test. Chaque test peut être réglé en avertissement ou en erreur. Un test en erreur casse le pipeline, ce qui est exactement ce que l'on veut sur une clé dupliquée. Un test en avertissement remonte le problème sans bloquer, utile pour surveiller une tendance.

Étape 6

Incrémental, snapshot, et contrat

Sur de gros volumes, reconstruire une table entière chaque nuit devient trop lent et trop cher. Un modèle incrémental ne traite que ce qui est nouveau. Voici la version complète, avec la stratégie de fusion et la clé unique.

-- models/marts/fct_transactions.sql
{{ config(
    materialized='incremental',
    unique_key='transaction_id',
    incremental_strategy='merge'
) }}

SELECT transaction_id, client_id, montant_fcfa, statut, date_tx
FROM {{ ref('stg_mm__transactions') }}

{% if is_incremental() %}
  -- {{ this }} désigne la table elle-même, telle qu'elle existe déjà
  WHERE date_tx > (SELECT MAX(date_tx) FROM {{ this }})
{% endif %}

Au premier passage, dbt construit la table complète. Ensuite, is_incremental() devient vrai, le filtre ne prend que les lignes plus récentes que le maximum déjà présent, et la stratégie merge met à jour les lignes existantes grâce à la clé unique.

Un snapshot, lui, garde la mémoire des changements. Le statut d'un prêt qui passe en défaut écrase l'ancienne valeur à la source. Le snapshot conserve chaque version datée.

-- snapshots/snap_prets.sql
{% snapshot snap_prets %}
{{ config(
    target_schema='snapshots',
    unique_key='pret_id',
    strategy='check',
    check_cols=['statut', 'montant_du']
) }}
SELECT * FROM {{ source('core_banking', 'prets') }}
{% endsnapshot %}

Ici la stratégie check compare les colonnes surveillées, et crée une nouvelle version dès que l'une change. dbt ajoute automatiquement les dates de début et de fin de validité. On peut alors répondre à quel était le statut de ce prêt à telle date.

Enfin, un contrat fige le schéma promis d'une table. Si un jour la requête ne produit plus exactement ces colonnes et ces types, dbt refuse de construire le modèle.

models:
  - name: fct_transactions
    config:
      contract: { enforced: true }
    columns:
      - name: transaction_id
        data_type: bigint
        constraints: [{ type: not_null }]
      - name: montant_fcfa
        data_type: decimal(18,2)

Étape 7

Piloter, documenter, fiabiliser

Vous savez construire les modèles, voyons comment les piloter au quotidien. Quelques commandes suffisent, et il faut comprendre ce que chacune fait.

dbt run                       # construit les modèles
dbt test                      # lance les tests
dbt build                     # construit ET teste, dans l'ordre du graphe
dbt run --select fct_activite_client+   # ce modèle et tout ce qui en dépend
dbt docs generate && dbt docs serve     # doc navigable avec le graphe de lineage

La syntaxe de sélection est puissante. Le signe plus après un nom veut dire ce modèle et tout son aval, avant le nom veut dire ce modèle et tout son amont. On l'utilise pour ne reconstruire qu'une partie du projet.

À chaque exécution, dbt écrit deux fichiers dans le dossier target. Le manifest contient le graphe complet, le lineage. Le fichier run_results contient le résultat de chaque modèle. Ces fichiers pilotent la reprise, une commande précieuse.

dbt build     # trois modèles échouent...
dbt retry     # relance uniquement ce qui a échoué et son aval
Le piège de la reprise en production

Si chaque exécution tourne dans un conteneur neuf, le dossier target disparaît entre deux tentatives, et dbt retry n'a plus rien à lire. Conservez ces artifacts sur un volume partagé ou dans un bucket, sinon la reprise ne marche pas, sans erreur visible.

Pour tout tenir en un seul endroit, on passe par un point d'entrée, un petit script qui sait lancer dbt et sauvegarder les artifacts. Le reste du pipeline appelle ce script, jamais dbt directement.

#!/usr/bin/env bash
set -euo pipefail
dbt deps
dbt build --target "${DBT_TARGET:-dev}"
gsutil cp target/manifest.json target/run_results.json "gs://artifacts/${RUN_ID}/"

Ces patterns avancés, incrémental, snapshots et contrats sur un vrai jeu de données, sont visibles dans dbt-advanced-patterns. Ouvrez-le une fois ce module terminé et retrouvez chaque notion dans le code.

Module 06

Ingestion et systèmes sources

Tout commence par faire sortir la donnée d'ailleurs, souvent d'un système qui n'a pas été conçu pour l'analyse. En banque, ce système est fréquemment un core banking comme Oracle FLEXCUBE. Bien s'y brancher demande de la méthode, parce qu'on n'a pas le droit de perturber la production.


Deux stratégies

Extraction par lots ou capture des changements

Il existe deux grandes façons de faire sortir la donnée. La première, l'extraction par lots, prend périodiquement un paquet de lignes, par exemple toutes les transactions d'hier. C'est simple et robuste, mais en retard sur le temps réel, et lourd si on relit à chaque fois tout l'historique.

La seconde, la capture des changements, souvent désignée par le sigle CDC, lit le journal interne de la base source et n'émet que les modifications au fil de l'eau, les insertions, les mises à jour, les suppressions. On obtient une fraîcheur quasi temps réel et une charge minimale sur le système source, ce qui est capital quand ce système sert des clients en direct.

Un point mérite toute votre attention, l'idempotence. Un pipeline doit pouvoir être rejoué sans créer de doublon. Si le traitement plante à mi-course et repart, on ne veut surtout pas compter deux fois une transaction. On l'obtient en rechargeant par partition, par exemple en remplaçant entièrement la journée concernée, ou en fusionnant sur une clé unique plutôt qu'en insérant aveuglément.

Le cas banque

Se brancher à un core banking sans casser la production

Un core banking comme FLEXCUBE est un système critique. Chaque requête analytique lancée directement sur sa base peut ralentir des opérations clients, un virement, un retrait. La règle d'or est donc de ne jamais interroger la production pour analyser. On passe par une réplique en lecture, par un export, ou par la capture des changements sur les journaux. On atterrit dans un stockage intermédiaire, et c'est seulement là, à l'écart, que l'on transforme.

Core bankingFLEXCUBE Capture (CDC)sur les journaux Atterrissagestockage brut Entrepôt dbt production, jamais touchée
Le core banking reste isolé. On capte les changements sur ses journaux, on atterrit dans un stockage brut, puis l'entrepôt et dbt prennent le relais.

En banque, on ajoute une étape que l'analytique classique néglige, la réconciliation. Les totaux calculés côté analytique doivent correspondre à ceux du core banking, au franc près. Si votre entrepôt affiche un encours différent de celui du système officiel, ce n'est pas un détail, c'est un signal d'alarme. Cette exigence de traçabilité et de justesse est ce qui distingue la data en environnement réglementé.

Un exemple complet d'ingestion réelle, d'une API publique vers un stockage puis dbt, est disponible dans le projet west-africa-financial-inclusion.

Module 07

Airflow, construire de vrais DAG

Ce module vous fait installer Airflow, écrire des DAG complets et les rendre robustes, jusqu'à un pipeline de production de bout en bout. On avance étape par étape, avec du code entier. L'objectif est qu'à la fin vous puissiez écrire, configurer et fiabiliser un DAG seul, sans copier au hasard.


Étape 1

Installer Airflow et comprendre l'architecture

Airflow n'est pas un seul programme, c'est plusieurs services qui travaillent ensemble. Le plus simple pour apprendre est de tout lancer avec Docker Compose, à partir du fichier officiel.

curl -LfO 'https://airflow.apache.org/docs/apache-airflow/stable/docker-compose.yaml'
mkdir -p dags logs plugins
echo "AIRFLOW_UID=$(id -u)" > .env
docker compose up airflow-init     # prépare la base de métadonnées
docker compose up -d               # démarre tous les services
# interface web sur http://localhost:8080, identifiants airflow / airflow

Vos DAG vivent dans le dossier dags, monté dans les conteneurs. Vous écrivez un fichier Python, il apparaît dans l'interface au bout de quelques secondes. Voici les rôles à connaître pour ne pas être perdu quand quelque chose coince.

  • Le planificateur relit vos fichiers de DAG en continu et décide quand lancer les tâches.
  • L'exécuteur et les ouvriers font tourner réellement les tâches.
  • La base de métadonnées garde l'état de tout, chaque exécution, chaque résultat.
  • Le déclencheur gère les tâches en attente sans bloquer un ouvrier, on y revient avec les capteurs différés.
  • L'interface web sert à observer et relancer, elle ne fait pas le travail.
La règle d'or, à retenir tout de suite

Comme le planificateur relit vos fichiers toutes les quelques secondes, ne mettez jamais un appel réseau ou un traitement lourd au niveau global d'un fichier de DAG. Ce code s'exécuterait sans arrêt et étoufferait le planificateur. Tout ce qui fait un vrai travail doit vivre à l'intérieur d'une tâche.

Étape 2

Écrire un premier DAG complet

Un DAG est un graphe de tâches, avec un ordre et sans boucle. On l'écrit en Python. Voici un DAG entier et fonctionnel, à déposer dans le dossier dags. Lisez chaque paramètre, ils reviennent dans tous vos DAG.

# dags/pipeline_quotidien.py
from __future__ import annotations
import pendulum
from airflow.decorators import dag, task

default_args = {
    "owner": "data",
    "retries": 2,
    "retry_delay": pendulum.duration(minutes=5),
}

@dag(
    dag_id="pipeline_quotidien",
    schedule="@daily",                                # tous les jours
    start_date=pendulum.datetime(2026, 1, 1, tz="UTC"),  # date de départ
    catchup=False,                                    # ne pas rattraper le passé manqué
    default_args=default_args,                          # réglages communs aux tâches
    tags=["mobile_money", "demo"],
)
def pipeline_quotidien():

    @task
    def extraire() -> list[dict]:
        return [{"id": 1, "montant": 25000}, {"id": 2, "montant": 90000}]

    @task
    def total_reussi(lignes: list[dict]) -> int:
        total = sum(l["montant"] for l in lignes)
        print(f"total: {total} FCFA")
        return total

    total_reussi(extraire())     # passer le résultat crée la dépendance

pipeline_quotidien()

Trois paramètres méritent qu'on les comprenne bien. Le schedule fixe la fréquence. Le start_date est la date de départ, pas la date d'aujourd'hui. Et catchup mis à faux évite qu'Airflow lance d'un coup toutes les exécutions manquées si le pipeline a été à l'arrêt plusieurs jours, ce qui est presque toujours ce que l'on veut. En passant le résultat de extraire à total_reussi, vous déclarez la dépendance, et Airflow transporte la donnée entre les deux tâches.

Étape 3

Les operators, et l'ordre des tâches

Le style à base de @task convient au Python. Mais Airflow fournit aussi des operators, des modèles de tâches prêts à l'emploi pour des actions courantes. Le plus utilisé lance une commande shell, un autre exécute une fonction Python, et des dizaines d'autres parlent à BigQuery, au stockage ou à dbt.

from airflow.operators.bash import BashOperator
from airflow.operators.python import PythonOperator

charger = BashOperator(
    task_id="charger_vers_bq",
    bash_command="bq load --source_format=PARQUET raw.tx gs://lac/tx/{{ ds }}/*.parquet",
)

def _controler(**context):
    print("contrôle du chargement...")

controler = PythonOperator(task_id="controler", python_callable=_controler)

charger >> controler     # l'opérateur >> fixe l'ordre : charger d'abord, puis controler

L'expression {{ ds }} dans la commande est un modèle, Airflow le remplace à l'exécution par la date du jour traité. C'est ainsi qu'un même DAG traite hier, aujourd'hui ou une date précise sans changer le code. L'opérateur >> établit l'ordre entre deux tâches, la flèche pointe vers ce qui vient après.

Quand utiliser quoi

Pour de la logique Python, préférez le style @task, plus lisible et qui passe la donnée tout seul. Pour lancer une commande, un outil externe ou dbt, un BashOperator est parfait. On mélange les deux dans un même DAG sans problème.

Étape 4

Connexions, variables et hooks

Un secret n'a jamais sa place dans le code. Airflow range les identifiants dans des connexions, et la configuration dans des variables. On les crée en ligne de commande, ou depuis l'interface, ou par variable d'environnement.

# une connexion vers Google Cloud, les identifiants restent hors du code
airflow connections add gcp_prod \
  --conn-type google_cloud_platform \
  --conn-extra '{"key_path": "/secrets/sa.json", "project": "mm-prod"}'

# une variable de configuration réutilisable
airflow variables set seuil_montant 1000000

Dans une tâche, on lit la variable, et on parle au système externe via un hook. Le hook est la brique bas niveau qui utilise la connexion pour se connecter. Un operator s'appuie souvent sur un hook, mais on peut aussi l'utiliser directement quand on a besoin de logique sur mesure.

from airflow.models import Variable
from airflow.providers.google.cloud.hooks.gcs import GCSHook

@task
def lister_fichiers():
    seuil = int(Variable.get("seuil_montant", default_var=0))
    hook = GCSHook(gcp_conn_id="gcp_prod")      # le hook lit la connexion
    return hook.list("mon-bucket", prefix="raw/")
Ne lisez pas une variable au mauvais endroit

Appeler Variable.get() au niveau global d'un fichier de DAG déclenche une requête à la base de métadonnées à chaque relecture par le planificateur, soit toutes les quelques secondes. Lisez toujours vos variables à l'intérieur d'une tâche, jamais au niveau du module.

Étape 5

Fiabilité, reprises, pools et parallélisme

Un pipeline de production tombe parfois, et doit se relever seul. On règle des reprises sur les tâches fragiles, avec un délai qui grandit à chaque échec, pour absorber une panne réseau ou une limite de débit atteinte.

from datetime import timedelta

@task(retries=3, retry_delay=timedelta(minutes=5), retry_exponential_backoff=True)
def appel_api_operateur():
    ...   # si ça échoue, Airflow réessaie 3 fois, en espaçant

Quand une étape doit se démultiplier, par exemple scorer des centaines de lots en parallèle, on utilise le mapping dynamique. La fonction expand crée une tâche par élément, le nombre n'étant connu qu'à l'exécution. Et pour ne pas écrouler une ressource fragile, on plafonne avec un pool.

# créer le pool une fois : cinquante places
# airflow pools set entrepot 50 "Limite les requetes concurrentes"

@task
def decouper() -> list[str]:
    return [f"lot_{i}" for i in range(500)]

@task(pool="entrepot")          # chaque tâche prend une place du pool
def scorer(lot: str):
    print(f"scoring de {lot}")

scorer.expand(lot=decouper())    # 500 tâches, mais 50 au maximum en même temps
Le cas qui revient tout le temps

Votre scoring découpe dix millions de clients en cinq cents lots. Sans garde-fou, cinq cents requêtes frappent l'entrepôt d'un coup et il s'écroule. Le pool à cinquante places les fait passer par vagues, Airflow met le reste en file d'attente. Le travail se fait, la base tient. C'est le réflexe qui sépare une démo d'un vrai pipeline.

Étape 6

Attendre, réagir, prévenir

Souvent une tâche doit attendre un événement plutôt que le deviner, par exemple qu'un fichier soit déposé avant de charger. C'est le rôle d'un capteur. En mode différé, il libère l'ouvrier au lieu de bloquer une place pendant des heures.

from airflow.providers.google.cloud.sensors.gcs import GCSObjectExistenceSensor

attendre = GCSObjectExistenceSensor(
    task_id="attendre_le_fichier",
    bucket="lac", object="raw/tx/{{ ds }}/data.parquet",
    deferrable=True,     # libère la ressource pendant l'attente
)

Plus moderne encore, on peut déclencher un DAG quand un autre a produit la donnée attendue, au lieu d'un simple horaire. On déclare un jeu de données, un DAG le produit, un autre s'y abonne.

from airflow.datasets import Dataset
transactions = Dataset("gs://lac/tx/data.parquet")

# DAG producteur : il signale qu'il a mis à jour le jeu de données
@task(outlets=[transactions])
def charger(): ...

# DAG consommateur : il part dès que le jeu de données change
@dag(schedule=[transactions], start_date=pendulum.datetime(2026,1,1), catchup=False)
def transformer_transactions(): ...

Enfin, un pipeline doit prévenir quand il déraille. On envoie une notification vers un canal de discussion, par webhook, déclenchée seulement si une tâche a échoué grâce à une règle de déclenchement.

from airflow.providers.slack.operators.slack_webhook import SlackWebhookOperator

alerte = SlackWebhookOperator(
    task_id="alerte_echec",
    slack_webhook_conn_id="slack_ops",
    message=":rotating_light: échec du pipeline (run {{ run_id }})",
    trigger_rule="one_failed",   # seulement si une tâche amont a échoué
)

Étape 7

Un DAG de production, de bout en bout

Réunissons tout dans un pipeline réaliste de scoring de crédit nocturne. Il attend le fichier du jour, le charge, lance la transformation dbt, contrôle la dérive du modèle, publie si tout va bien, et alerte en cas de problème. C'est exactement la forme d'un vrai DAG d'entreprise.

# dags/scoring_credit_nuit.py
import pendulum
from airflow.decorators import dag, task
from airflow.operators.bash import BashOperator
from airflow.providers.slack.operators.slack_webhook import SlackWebhookOperator

default_args = {"owner": "credit", "retries": 2, "retry_delay": pendulum.duration(minutes=5)}

@dag(dag_id="scoring_credit_nuit", schedule="@daily",
     start_date=pendulum.datetime(2026,1,1, tz="UTC"), catchup=False,
     default_args=default_args, tags=["scoring", "credit"])
def scoring_credit_nuit():

    charger = BashOperator(
        task_id="charger",
        bash_command="./ingest.sh {{ ds }}",
    )

    dbt_build = BashOperator(
        task_id="dbt_build",
        bash_command="./dbt_entrypoint.sh",   # le point d'entrée du module dbt
    )

    @task
    def controler_derive() -> float:
        psi = calculer_psi()             # écart entre population du jour et référence
        if psi > 0.2:
            raise ValueError(f"dérive trop forte, PSI={psi}, publication bloquée")
        return psi

    @task
    def publier(psi: float):
        print(f"scores publiés, PSI={psi}")

    alerte = SlackWebhookOperator(
        task_id="alerte", slack_webhook_conn_id="slack_ops",
        message=":rotating_light: scoring en échec (run {{ run_id }})",
        trigger_rule="one_failed",
    )

    psi = controler_derive()
    charger >> dbt_build >> psi
    publier(psi)
    [charger, dbt_build, psi] >> alerte

scoring_credit_nuit()

Remarquez la garde de dérive. Si l'écart dépasse le seuil, la tâche lève une erreur, ce qui bloque la publication et déclenche l'alerte. C'est de la gouvernance, la partie qui refuse de publier un score douteux, et c'est souvent ce qui distingue un candidat sérieux en entretien.

Ce pipeline reprend la logique de airflow-batch-credit-scoring, avec les features dbt de credit-scoring-dbt. Ouvrez-les et retrouvez chaque brique dans le code réel.

Module 08

Cloud et entrepôt

L'entrepôt moderne ne se gère plus comme un serveur, il est facturé à l'usage. La compétence qui distingue ici n'est pas d'écrire une requête, c'est d'en maîtriser le coût. On prend BigQuery comme exemple, mais les principes valent pour ses concurrents.


L'entrepôt

On paie ce que l'on scanne

BigQuery range la donnée par colonnes et facture les octets lus. Cela change complètement la façon d'écrire. Sélectionner seulement les colonnes utiles, filtrer intelligemment, tout cela réduit directement la facture. Deux leviers font l'essentiel du travail.

Le partitionnement découpe une table par tranche, le plus souvent par date. Quand une requête filtre sur cette date, l'entrepôt ne lit que la tranche concernée. Une table de deux téraoctets filtrée sur une seule journée ne scanne qu'une fraction minuscule si elle est partitionnée par jour. Le clustering, lui, range physiquement les lignes proches ensemble, ce qui accélère les filtres sur les colonnes choisies.

CREATE TABLE marts.transactions
PARTITION BY DATE(date_tx)
CLUSTER BY pays, statut
AS SELECT ...;
L'étoile qui coûte cher

Écrire SELECT * sur une grande table est le moyen le plus sûr de faire exploser la facture, parce qu'on lit toutes les colonnes à chaque fois. Ne sélectionnez que ce dont vous avez besoin, et filtrez sur la colonne de partition. La différence de coût se compte en ordres de grandeur, pas en pourcentages.

Le stockage et l'accès

Le lac de données, les droits, la ligne de commande

Un lac de données est du stockage de fichiers dans le cloud, des objets rangés dans des seaux. Il n'y a pas de vrais dossiers, seulement des préfixes de noms. On organise souvent les fichiers par date, comme un chemin qui contient la journée, ce qui rend le chargement incrémental évident, on ne lit que le préfixe du jour.

Côté sécurité, un compte de service est une identité machine pour vos pipelines. On lui donne le strict nécessaire, jamais plus, c'est le principe du moindre privilège. Et tout se pilote en ligne de commande, ce qui est indispensable dans les traitements automatisés.

gsutil -m cp -r ./export gs://lac/brut/transactions/date=2026-07-01/
bq load --source_format=PARQUET brut.transactions "gs://lac/brut/transactions/date=2026-07-01/*.parquet"
Le droit de trop

Ne donnez jamais les pleins pouvoirs au compte de service d'un pipeline. Si sa clé fuit, c'est tout le projet qui tombe. Un compte qui ne peut qu'écrire dans un seul jeu de données limite les dégâts à ce jeu de données.

Module 09

Conteneurs et infrastructure décrite en code

Ça marche sur ma machine n'est pas une stratégie de déploiement. Docker fige l'environnement pour qu'il tourne partout pareil, Kubernetes fait tourner ces conteneurs à grande échelle, et Terraform décrit l'infrastructure en fichiers versionnés au lieu de cliquer dans une console. On voit les trois avec du code complet.


Docker, étape 1

Image, conteneur, et un Dockerfile complet

Deux mots à ne pas confondre. Une image est un paquet figé qui contient le code, ses dépendances et le système minimal pour tourner. Un conteneur est une instance de cette image en train de s'exécuter. On construit une fois l'image, on lance autant de conteneurs qu'on veut, tous identiques. C'est ce qui élimine les différences entre votre machine et le serveur.

On décrit l'image dans un fichier nommé Dockerfile. Voici un fichier complet pour un pipeline Python, commenté ligne par ligne. L'ordre des instructions n'est pas décoratif, Docker met en cache chaque étape, et on l'exploite.

# Dockerfile
FROM python:3.11-slim         # image de base, légère
WORKDIR /app                  # dossier de travail dans le conteneur
COPY requirements.txt .       # copié seul, avant le code
RUN pip install --no-cache-dir -r requirements.txt
COPY . .                     # le code, qui change souvent, en dernier
ENV PYTHONUNBUFFERED=1        # journaux affichés en direct
ENTRYPOINT ["python", "pipeline.py"]

On construit l'image, on lui donne un nom et une version, puis on lance un conteneur. On peut passer des variables d'environnement et monter un dossier local pour échanger des fichiers.

docker build -t mm-pipeline:1.0 .          # construire l'image
docker run --rm mm-pipeline:1.0            # lancer, --rm nettoie à la fin
docker run --rm -e DBT_TARGET=prod \
  -v "$(pwd)/data:/app/data" mm-pipeline:1.0   # variable + dossier partagé
Pourquoi copier les dépendances avant le code

Docker garde en cache chaque étape. Si vous copiez tout d'un coup, la moindre modification du code invalide le cache et réinstalle toutes les dépendances, ce qui est long. En copiant requirements.txt d'abord, l'étape d'installation reste en cache tant que les dépendances ne bougent pas. Un rebuild passe de plusieurs minutes à quelques secondes.

Docker, étape 2

Images légères et sûres, le build multi-étapes

Une image trop grosse est lente à envoyer et à démarrer, une image qui tourne en administrateur est un risque. Deux pratiques règlent l'essentiel. D'abord, un fichier .dockerignore pour ne pas embarquer de fichiers inutiles ou sensibles.

# .dockerignore
.venv
__pycache__
.git
data/
*.duckdb

Ensuite, le build multi-étapes. On installe les dépendances dans une première étape, puis on ne copie que le nécessaire dans une image finale propre. On en profite pour créer un utilisateur sans privilèges.

FROM python:3.11-slim AS build
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

FROM python:3.11-slim         # image finale, sans les outils de build
WORKDIR /app
COPY --from=build /install /usr/local   # on récupère juste les paquets installés
COPY . .
RUN useradd -m app && chown -R app /app
USER app                     # ne pas tourner en administrateur
ENTRYPOINT ["python", "pipeline.py"]

La première étape porte le nom build, la seconde repart d'une base propre et ne récupère que les paquets installés, pas les outils qui ont servi à les compiler. L'image finale est plus petite, plus rapide, et plus sûre. C'est le standard attendu en production.

Docker, étape 3

Faire tourner plusieurs services avec Compose

Un vrai système a rarement un seul conteneur. Un pipeline a besoin d'une base, Airflow a besoin d'une base de métadonnées. Docker Compose décrit tous ces services dans un seul fichier et les fait tourner ensemble, sur un réseau où chacun s'appelle par son nom.

# docker-compose.yml
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: secret
    volumes: ["pgdata:/var/lib/postgresql/data"]   # les données survivent aux redémarrages

  pipeline:
    build: .                # construit l'image à partir du Dockerfile local
    depends_on: [db]        # démarre après la base
    environment:
      DB_HOST: db           # on joint la base par son nom de service

volumes:
  pgdata:
docker compose up -d       # démarre tous les services en arrière-plan
docker compose logs -f     # suit les journaux
docker compose down        # arrête et nettoie

Le point à retenir, un service en joint un autre par son nom, ici db, pas par une adresse. Les volumes conservent les données entre deux redémarrages. C'est exactement de cette façon qu'on fait tourner Airflow en local, comme vu au module sept, avec un service pour la base et d'autres pour le planificateur et l'interface.

Kubernetes, étape 1

Les concepts qui comptent, et un manifeste

Un conteneur seul suffit sur une machine. Dès qu'il faut en faire tourner beaucoup, les redémarrer quand ils tombent, et les répartir sur plusieurs serveurs, on a besoin d'un orchestrateur de conteneurs. Kubernetes est le standard. Quatre notions suffisent pour démarrer.

  • Le pod est la plus petite unité, un ou plusieurs conteneurs qui tournent ensemble.
  • Le deployment maintient un nombre voulu de copies d'un pod, et en recrée une si l'une meurt.
  • Le service donne une adresse stable pour joindre un groupe de pods, même s'ils changent.
  • Le namespace cloisonne les ressources, par équipe ou par environnement.

Kubernetes est déclaratif, on décrit l'état voulu dans un fichier, il se charge de l'atteindre et de le maintenir. Voici un deployment qui garde trois copies d'un service de scoring, avec des limites de ressources.

# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: scorer
spec:
  replicas: 3                 # trois pods maintenus en vie
  selector:
    matchLabels: { app: scorer }
  template:
    metadata:
      labels: { app: scorer }
    spec:
      containers:
        - name: scorer
          image: registry/scorer:1.0
          resources:
            requests: { cpu: "500m", memory: "512Mi" }
            limits:   { cpu: "1",    memory: "1Gi" }
kubectl apply -f deployment.yaml   # appliquer l'état voulu
kubectl get pods                   # voir les pods qui tournent
kubectl logs deploy/scorer         # lire les journaux

Les requests disent ce dont le pod a besoin pour être placé, les limits plafonnent ce qu'il peut consommer. Sans limites, un pod glouton peut affamer ses voisins, une cause fréquente d'incidents.

Kubernetes, étape 2

Kubernetes pour la data, une tâche par pod

Là où Kubernetes devient précieux en data engineering, c'est pour exécuter chaque tâche lourde dans son propre pod, isolé, avec ses propres dépendances et ses propres ressources. Airflow le permet directement avec un operator dédié.

from airflow.providers.cncf.kubernetes.operators.pod import KubernetesPodOperator

scorer = KubernetesPodOperator(
    task_id="scorer",
    name="scorer-pod",
    image="registry/scorer:1.0",        # l'image du scoring, ses propres dépendances
    cmds=["python", "score.py"],
    arguments=["{{ ds }}"],               # la date du jour traité
    container_resources={"request_cpu": "500m", "request_memory": "1Gi"},
    get_logs=True,
)

Chaque exécution de cette tâche crée un pod neuf, fait le travail, puis disparaît. Les avantages sont concrets. Une tâche qui a besoin d'une bibliothèque particulière n'impose rien aux autres. Un plantage reste isolé. Et on peut faire tourner ces pods sur des serveurs bon marché, quitte à en perdre parfois, puisque la tâche sera simplement relancée.

Ce pattern, des pods par tâche sur des serveurs économiques, est mis en œuvre dans airflow-cloud-data-platform.

Terraform

Décrire l'infrastructure, ne pas la cliquer

Créer un jeu de données ou un seau à la main dans une console ne laisse aucune trace et n'est pas reproductible. Terraform décrit l'infrastructure dans des fichiers que l'on versionne comme du code. On y déclare ce que l'on veut, un jeu de données, un seau, un compte de service, et Terraform se charge de le créer.

resource "google_bigquery_dataset" "marts" {
  dataset_id = "marts"
  location   = "EU"
}

Le cycle est toujours le même. On lance un plan, qui montre ce qui va changer sans rien appliquer, on le relit, puis on applique. Cette relecture est essentielle, car Terraform peut parfois vouloir détruire une ressource pour la recréer. Sur une table de production, ça se remarque, mais trop tard si on a appliqué sans lire.

On applique souvent Terraform en intégration continue, avec un état stocké à distance dans un bucket partagé, pour que toute l'équipe travaille sur la même infrastructure sans conflit.

Module 10

Qualité et fiabilité

Un pipeline qui tourne n'est pas un pipeline de confiance. Ce qui fait la différence, c'est de mesurer la qualité au lieu de l'espérer, de détecter une donnée périmée avant de bâtir dessus, et de savoir exactement ce qu'un changement va impacter.


Mesurer la qualité

Nommer les dimensions, tester, surveiller la fraîcheur

Ça a l'air bon n'est pas un test. La qualité se décompose en dimensions concrètes, chacune traduisible en vérification automatique. La complétude, pas de valeurs manquantes là où c'est critique. L'unicité, pas de doublons sur les clés. La validité, des valeurs dans un domaine autorisé. La cohérence, des chiffres qui concordent entre les tables. Et la fraîcheur, une donnée assez récente.

Les tests dbt couvrent une bonne partie de ces dimensions. Pour la fraîcheur, dbt sait vérifier l'âge d'une source et lever une alerte si la donnée est trop vieille, signe que l'ingestion est en panne. On règle un seuil d'avertissement et un seuil d'erreur.

freshness:
  warn_after:  { count: 12, period: hour }
  error_after: { count: 24, period: hour }
Un incident typique

Les scores du jour sont à jour, mais un flux amont a dupliqué toutes les transactions. La dimension violée est l'unicité. Un simple test d'unicité sur la clé de transaction, ou un contrôle du nombre de lignes comparé à la veille, aurait signalé le problème avant qu'il ne pollue les scores. Le contrôle en amont coûte mille fois moins cher que l'incident en aval.

Comprendre l'impact

Le lineage et le rayon d'impact d'un changement

Le graphe des dépendances, celui que dbt construit à partir des ref(), ne sert pas qu'à faire joli. En le parcourant, on répond à une question précieuse. Si je modifie ce modèle de staging, quelles tables métier, quels tableaux de bord, quels modèles en aval sont impactés ?

Cette information transforme une communication paniquée, attention tout le monde, en une action ciblée, ces trois équipes précises doivent être prévenues. En environnement bancaire, aller plus loin est même une exigence. Le lineage au niveau colonne permet d'expliquer, pour un chiffre affiché dans un rapport, d'où il vient exactement, de quelle colonne source, à travers quelles transformations. C'est précieux en audit, et souvent obligatoire chez le régulateur.

Module 11

L'IA dans les pipelines

L'IA ne remplace pas le data engineering, elle s'y branche. Deux mondes cohabitent, le machine learning classique qui vit dans l'entrepôt, et les grands modèles de langage qui ouvrent de nouveaux usages. Le point commun, un modèle ne vaut que la donnée qu'on lui donne.


Le ML dans l'entrepôt

Features, scoring, et la garde contre la dérive

Avant un modèle, il y a des features, ces variables construites à partir de la donnée brute. La moyenne des transactions sur trente jours, l'ancienneté du compte, la régularité des remboursements. dbt est excellent pour les fabriquer, parce qu'elles deviennent reproductibles, testées et versionnées. C'est un terrain naturel pour le scoring de crédit, notamment pour évaluer des personnes sans historique bancaire à partir de leur activité mobile money.

Le scoring se fait souvent par lots, la nuit, orchestré par Airflow. Mais il y a une garde essentielle à poser, la détection de la dérive. Si la population des clients change trop par rapport à celle sur laquelle le modèle a appris, le modèle n'est peut-être plus valable. On mesure cet écart, et si l'indicateur dépasse un seuil, on retient la publication des scores plutôt que de décider sur une base douteuse.

Ce schéma complet, features avec dbt, scoring par lots avec Airflow et garde de dérive, est mis en œuvre dans credit-scoring-dbt.

Les grands modèles de langage

Récupération augmentée, vecteurs, et garde-fous

Les grands modèles de langage entrent dans les pipelines par plusieurs portes. La plus répandue est la récupération augmentée. On transforme des documents en vecteurs numériques, on les range dans une base spécialisée, puis, à chaque question, on récupère les passages pertinents et on les donne au modèle pour qu'il réponde en s'appuyant dessus au lieu d'inventer. Beaucoup d'entrepôts savent désormais générer et interroger ces vecteurs directement en SQL.

Dans l'autre sens, l'IA aide le travail du data engineer, classer des transactions, documenter des tables, suggérer des tests, ou traduire une question en langage courant vers du SQL. C'est utile, à condition de garder des garde-fous, valider les sorties, borner les coûts, et ne jamais laisser un modèle décider seul sur un sujet sensible comme un refus de crédit.

Le bon usage de l'IA quand on apprend

Se reposer sur l'IA pour produire un code que l'on ne comprend pas crée une dépendance qui vous freine. L'IA est un formidable accélérateur quand on maîtrise le fond, un piège quand elle le remplace. Faites-la expliquer, relire, proposer des pistes, puis écrivez vous-même ce qui compte. C'est comme ça qu'on progresse vraiment.

Module 12

Projets guidés, pour tout relier

Ici, plus de nouvelles notions, on assemble tout ce qui précède. Chaque projet s'appuie sur un dépôt réel que vous pouvez cloner, faire tourner, casser et reconstruire. Suivez les étapes dans l'ordre, en écrivant vous-même le code. C'est le moment où les connaissances deviennent des compétences.


Projet 1, niveau accessible

Un pipeline complet, d'une API vers des tables métier

Objectif, construire de bout en bout un pipeline qui va chercher des données publiques sur l'inclusion financière dans l'UEMOA, les range localement, et les transforme en indicateurs. Dépôt de référence, west-africa-financial-inclusion. On y fait tourner tout en local avec DuckDB, sans compte cloud, donc tout le monde peut le refaire.

  1. Extraire. Écrivez un petit client qui interroge l'API et récupère les données, avec une reprise en cas d'erreur réseau. C'est le module 06 en action.
  2. Charger sans doublon. Rangez la réponse dans DuckDB de façon idempotente, pour qu'un second passage ne duplique rien. Pensez remplacement de partition.
  3. Transformer par couches. Écrivez les modèles dbt, du staging aux marts, comme au module 04 et 05. Une couche qui nettoie, une couche qui calcule les indicateurs.
  4. Tester. Ajoutez des tests d'unicité et de non-nullité sur les clés, et un contrôle de cohérence sur les totaux.
  5. Orchestrer. Enchaînez extraire, charger, transformer dans un DAG Airflow, avec une reprise sur la tâche d'extraction.

À la fin, vous aurez touché à toute la chaîne sur un cas réel. Prenez le temps de comprendre chaque fichier du dépôt en vous demandant, pour chaque choix, pourquoi celui-ci plutôt qu'un autre.

Projet 2, niveau intermédiaire

Un moteur de scoring de crédit gouverné

Objectif, transformer des transactions mobile money en features, entraîner un score, et le publier chaque nuit avec une garde de qualité. Dépôts de référence, credit-scoring-dbt pour les features et airflow-batch-credit-scoring pour l'orchestration.

  1. Construire les features avec dbt. Moyennes glissantes, régularité, ancienneté. Chaque feature est un calcul SQL testé.
  2. Scorer par lots. Découpez la population en morceaux et scorez en parallèle, avec un pool pour ne pas écrouler l'entrepôt.
  3. Poser la garde de dérive. Comparez la population du jour à la population de référence, et bloquez la publication si l'écart est trop grand.
  4. Publier ou retenir. Selon la garde, publiez les scores ou sautez l'étape en prévenant l'équipe par une notification.

Ce projet touche au cœur de la fintech, décider à qui prêter, à partir de données alternatives. La partie technique compte, mais la gouvernance, cette garde qui refuse de publier un score douteux, est ce qui sépare un jouet d'un système que l'on met vraiment en production.

Projet 3, pour consolider

Un ELT microfinance rejouable en une commande

Objectif, monter un pipeline complet mais léger, d'un fichier plat vers des tables métier, que n'importe qui peut lancer d'une seule commande. Dépôt de référence, elt-microfinance-demo. C'est le projet idéal pour vérifier que vous savez tout relier proprement, du chargement à la transformation jusqu'à l'orchestration, sans dépendance lourde.

Une fois ces trois projets faits et compris, vous ne serez plus quelqu'un qui a lu une formation, mais quelqu'un qui a construit des pipelines. C'est exactement ce qu'un employeur cherche à vérifier.

Module 13

Prêt pour un poste

Savoir faire et savoir montrer sont deux choses. Ce dernier module aide à transformer les compétences acquises en une candidature crédible, avec un regard sur notre marché.


Se rendre visible

Ce que cherche un employeur, et comment le prouver

Un recruteur en data engineering ne cherche pas quelqu'un qui récite des définitions, il cherche quelqu'un capable de livrer un pipeline fiable et de le réparer. La meilleure preuve n'est pas un diplôme, c'est un dépôt public que l'on peut ouvrir et faire tourner. Un projet complet, propre, documenté, avec un fichier d'accueil clair, vaut mieux que dix projets abandonnés.

Concentrez-vous sur quelques signaux forts. Un pipeline de bout en bout, de la source à la table métier. Des tests de qualité qui prouvent que vous pensez fiabilité. Une orchestration qui montre que vous savez enchaîner et reprendre. Et un fichier d'accueil qui explique le problème, la solution et comment lancer le projet. Ces éléments, un recruteur les voit en une minute.

  • Choisissez trois ou quatre projets solides, et rendez chacun impeccable, plutôt que d'accumuler des ébauches.
  • Écrivez un fichier d'accueil qui donne envie, avec le contexte, l'architecture, et la commande pour lancer.
  • Sachez raconter un projet à l'oral en trois minutes, le problème, vos choix, ce que vous feriez différemment.
  • Restez honnête sur ce que vous maîtrisez. Un junior lucide inspire plus confiance qu'un débutant qui gonfle son CV.
Notre marché

L'écosystème data grandit vite en Afrique de l'Ouest, porté par la fintech, le mobile money et l'inclusion financière. Les entreprises cherchent des profils capables de construire proprement, souvent avec des budgets serrés, où la maîtrise des coûts cloud est un vrai atout. Les projets de cette formation, ancrés sur ce terrain, parlent directement à ces employeurs.

Cette formation est vivante. Elle s'étoffe au fil du temps, module après module. Si un chapitre mérite plus de détails, plus d'exemples ou un projet supplémentaire, c'est le genre de chose qui la fait grandir.