LangueFR
Français
English
Retour au sommaire du cours

Ingénierie des données : formats, pipelines et flux

Les modules précédents ont montré comment écrire du code propre, le paralléliser, manipuler des tableaux, interroger une base et livrer une application dans un conteneur. Il manque une pièce, et c'est souvent celle qui occupe le plus de temps en entreprise : faire arriver les données jusqu'au point où l'analyse commence, de façon fiable, répétable et pas trop chère. C'est le métier de l'ingénierie des données. Un modèle ne vaut jamais mieux que la table sur laquelle il s'entraîne, et cette table ne se matérialise pas toute seule.

1. Le problème : le modèle n'est que la partie visible

Une étude souvent citée de Google sur la dette technique du machine learning — on y revient au module 26 — résume la situation par un schéma : dans un système de ML en production, le code du modèle occupe une petite boîte au centre, entourée de boîtes bien plus grosses qui s'appellent collecte, vérification, transformation, stockage, service, surveillance. Le rapport de taille n'est pas une métaphore : c'est le rapport des efforts réels.

La raison est simple. Un fichier CSV posé sur un bureau se charge en une ligne de pandas. Une source de données vivante, elle, arrive en continu, change de schéma sans prévenir, contient des doublons, arrive parfois en retard, parfois deux fois, et doit être relue entièrement le jour où l'on découvre un bug dans une transformation d'il y a trois mois. Tout ce module traite de ces quatre difficultés :

  • le volume — comment stocker et relire des données qui ne tiennent plus en mémoire, sans tout relire ;
  • le changement — comment survivre à une colonne qui disparaît ou qui change de type ;
  • la répétition — comment relancer un traitement sans créer de doublons ni de trous ;
  • le temps — comment raisonner sur des événements qui n'arrivent pas dans l'ordre où ils se sont produits.

Le vocabulaire, une fois pour toutes

  • Pipeline — une suite de traitements automatisés qui transforment une donnée brute en donnée exploitable. Rien de plus qu'un enchaînement de scripts, mais planifié et surveillé.
  • Lac de données (data lake) — un stockage de fichiers bon marché où l'on dépose les données telles qu'elles arrivent, sans schéma imposé.
  • Entrepôt de données (data warehouse) — une base structurée, organisée pour l'analyse, où les données sont propres et modélisées.
  • Ingestion — l'étape qui fait entrer une donnée extérieure dans le système.
  • Batch (par lots) contre streaming (en flux) — traiter un paquet de données à intervalle régulier, ou traiter chaque événement à mesure qu'il arrive.

2. Ligne ou colonne : CSV, Parquet, Arrow

Le problème

Le CSV est universel et illisible par une machine : aucun type (tout est du texte, il faut deviner), aucune compression, aucune structure interne. Lire la colonne prix d'un CSV de 40 Go impose de lire les 40 Go, puis de convertir chaque champ en nombre. Sur un entrepôt où les requêtes touchent cinq colonnes sur deux cents, c'est un gâchis considérable.

L'intuition : deux façons de poser des données sur un disque

Un fichier est une suite d'octets, donc un tableau à deux dimensions doit être aplati. Il y a deux manières de le faire, et tout découle de ce choix.

le même tableau, deux dispositions
Table logique
  id | ville     | prix   | surface
  ---+-----------+--------+--------
   1 | Lyon      | 320000 | 68
   2 | Nantes    | 245000 | 54
   3 | Lille     | 198000 | 61

ORIENTÉ LIGNE (CSV, PostgreSQL classique)
  [1,Lyon,320000,68][2,Nantes,245000,54][3,Lille,198000,61]
   └── une ligne entière, puis la suivante
   → lire UN bien : une seule lecture contiguë      ✓ rapide
   → lire TOUS les prix : sauter partout            ✗ lent

ORIENTÉ COLONNE (Parquet, DuckDB, ClickHouse)
  [1,2,3][Lyon,Nantes,Lille][320000,245000,198000][68,54,61]
   └── toute une colonne, puis la suivante
   → lire TOUS les prix : une seule lecture         ✓ rapide
   → lire UN bien entier : quatre lectures          ✗ moins bon

La disposition en colonnes a une seconde vertu, moins évidente et souvent plus décisive que la première : une colonne est homogène. Mille prix à la suite sont mille nombres qui se ressemblent, donc qui se compressent très bien ; mille villes à la suite contiennent quelques dizaines de valeurs distinctes, qu'on remplace par un dictionnaire et des entiers courts. Un CSV mélange types et magnitudes à chaque ligne, ce qui ruine toute compression. En pratique, un CSV de 40 Go devient couramment un Parquet de 3 à 6 Go, sans perte.

Ce que Parquet ajoute au fait d'être colonne

  1. Un schéma — le type de chaque colonne est écrit dans le fichier. Plus de dtype à deviner, plus de code postal transformé en flottant.
  2. Des statistiques par bloc — les données sont découpées en groupes de lignes, et chaque groupe stocke le min et le max de chaque colonne. Une requête filtrant sur prix > 500000 saute les blocs dont le max vaut 300000 sans les lire. C'est ce qu'on appelle l'élagage de blocs.
  3. Une compression par colonne — chaque colonne peut utiliser le codec qui lui convient.
  4. Une lecture partielle — on demande trois colonnes, on lit trois colonnes.
python — mesurer la différence
import time, pathlib
import numpy as np, pandas as pd

rng = np.random.default_rng(0)
n = 2_000_000
df = pd.DataFrame({
    "id": np.arange(n),
    "ville": rng.choice(["Lyon", "Nantes", "Lille", "Rennes"], n),
    "prix": rng.integers(80_000, 900_000, n),
    "surface": rng.integers(15, 200, n),
})

df.to_csv("biens.csv", index=False)
df.to_parquet("biens.parquet")          # nécessite pyarrow

for f in ("biens.csv", "biens.parquet"):
    mo = pathlib.Path(f).stat().st_size / 1e6
    print(f"{f:16s} {mo:7.1f} Mo")
# biens.csv           78.9 Mo
# biens.parquet       10.2 Mo      → 7,7× plus petit

# lire UNE colonne
t = time.perf_counter()
pd.read_csv("biens.csv", usecols=["prix"])
print(f"csv     : {time.perf_counter() - t:.2f} s")      # ~0.75 s

t = time.perf_counter()
pd.read_parquet("biens.parquet", columns=["prix"])
print(f"parquet : {time.perf_counter() - t:.2f} s")      # ~0.02 s

Le facteur n'est pas un détail d'optimisation : à l'échelle d'un entrepôt, il décide de ce qui est faisable interactivement et de ce qui ne l'est pas.

Et Arrow, alors ?

Confusion fréquente, qui vaut d'être levée nettement. Parquet est un format de disque ; Arrow est un format de mémoire. Parquet est compressé et encodé, donc compact mais inexploitable tel quel : il faut le décoder. Arrow décrit comment un tableau colonne est disposé dans la RAM, non compressé, prêt à être lu par le processeur.

L'intérêt d'Arrow est ailleurs : c'est une convention partagée. Quand polars, DuckDB, pandas 2 et un moteur Java utilisent tous la disposition Arrow, passer une table de l'un à l'autre ne coûte rien — aucune conversion, aucune copie, on se transmet un pointeur. Avant Arrow, chaque échange entre deux outils imposait une sérialisation complète. C'est ce qui explique qu'on puisse interroger en SQL, avec DuckDB, un objet polars présent en mémoire sans rien recopier (module 4).

Quand garder du CSV. Pour échanger avec un humain, pour un fichier de moins de quelques mégaoctets, ou quand l'outil d'en face ne sait rien lire d'autre. Dès qu'un fichier est relu plus d'une fois par un programme, Parquet est le bon défaut. Notez que read_csv est lui-même piégeux : la ligne NA d'une colonne texte devient un manquant, un identifiant à zéros non significatifs perd ses zéros. Parquet supprime la question.

3. Partitionner : ranger pour ne pas tout lire

Un fichier Parquet unique de 500 Go pose un problème que la compression ne résout pas : toute requête doit l'ouvrir. La solution est de découper les données en répertoires dont le nom porte l'information de filtrage.

une arborescence partitionnée
annonces/
├── annee=2025/
│   ├── mois=11/
│   │   ├── part-0000.parquet
│   │   └── part-0001.parquet
│   └── mois=12/
│       └── part-0000.parquet
└── annee=2026/
    └── mois=01/
        └── part-0000.parquet

Requête : « les annonces de janvier 2026 »
→ le moteur lit le nom des répertoires, ouvre UN fichier,
  ignore les autres sans les toucher. C'est l'élagage de partitions.
python — écrire et relire un jeu partitionné
import polars as pl

df.write_parquet("annonces/", partition_by=["annee", "mois"])

# la lecture ne touche que la partition utile
q = (
    pl.scan_parquet("annonces/**/*.parquet", hive_partitioning=True)
      .filter((pl.col("annee") == 2026) & (pl.col("mois") == 1))
      .group_by("ville")
      .agg(pl.col("prix").median().alias("prix_median"))
)
print(q.collect())

Choisir sa clé de partition

La règle : partitionner sur ce qui apparaît dans le WHERE de la plupart des requêtes, presque toujours une date. Deux erreurs symétriques :

  • Trop fin — partitionner par heure et par ville produit des centaines de milliers de répertoires contenant chacun quelques kilo-octets. Ouvrir un fichier a un coût fixe, et il finit par dominer : c'est le problème dit des « petits fichiers ». Visez des fichiers de 100 Mo à 1 Go.
  • Trop grossier — partitionner par année sur dix ans d'historique oblige à lire un dixième des données pour une requête portant sur un jour.

4. Lire ce qui n'est pas propre : JSON, logs, expressions régulières

Les données rarement arrivent sous forme de table. Elles arrivent en JSON imbriqué depuis une API, en lignes de journal depuis un serveur, en champs libres saisis par des humains. Trois outils suffisent à couvrir l'essentiel.

Aplatir du JSON imbriqué

python — du JSON arborescent à une table
import pandas as pd

reponse = [
    {"id": 1, "bien": {"ville": "Lyon", "prix": 320000},
     "options": ["balcon", "cave"]},
    {"id": 2, "bien": {"ville": "Nantes", "prix": 245000},
     "options": []},
]

# json_normalize déplie les dictionnaires imbriqués en colonnes
df = pd.json_normalize(reponse, sep="_")
print(df.columns.tolist())
# ['id', 'options', 'bien_ville', 'bien_prix']

# une liste dans une cellule n'est pas une table : on la déplie en lignes
df_opt = df.explode("options")[["id", "options"]]
print(df_opt)
#    id options
# 0   1  balcon
# 0   1    cave
# 1   2     NaN

Le choix de fond, ici, est de décider ce qui devient une colonne et ce qui devient une ligne. Une liste d'options par annonce peut rester un tableau dans une colonne (Parquet sait le stocker) ou devenir une table séparée avec une ligne par option — la forme normalisée du module 4. La table séparée se joint et s'agrège facilement ; la colonne-tableau évite une jointure. On tranche selon l'usage dominant.

Les expressions régulières, le minimum utile

Une expression régulière est un motif décrivant une famille de chaînes de caractères. C'est l'outil standard pour extraire un champ d'un texte non structuré. Trois quarts des usages tiennent en une poignée de symboles.

le vocabulaire minimal
\d   un chiffre            \w   lettre, chiffre ou _      .    n'importe quoi
\s   une espace            +    une fois ou plus          *    zéro fois ou plus
?    zéro ou une fois      {2,4} entre 2 et 4 fois        |    ou
[]   un caractère parmi    ()   un GROUPE à capturer      ^ $  début, fin
\.   un point littéral (l'antislash « échappe » le sens spécial)
python — extraire des champs d'un texte libre
import re
import pandas as pd

annonces = pd.Series([
    "T3 de 68 m2 - 320 000 EUR - Lyon 3e",
    "Studio 21m2, 148500 EUR, Nantes",
    "Maison 140 m2 | 495 000 EUR | Rennes",
])

# (?P<nom>...) nomme le groupe capturé : il devient le nom de colonne
motif = r"(?P<surface>\d+)\s?m2.*?(?P<prix>[\d\s]+)\s?EUR"
extrait = annonces.str.extract(motif)
extrait["prix"] = extrait["prix"].str.replace(r"\s", "", regex=True)
print(extrait.astype({"surface": int, "prix": int}))
#    surface    prix
# 0       68  320000
# 1       21  148500
# 2      140  495000
Les limites, qu'il faut connaître avant d'y perdre une journée. Une expression régulière décrit un motif régulier : elle ne sait pas compter les imbrications. Analyser du HTML ou du JSON avec une expression régulière finit toujours mal — utilisez un analyseur syntaxique (module 28 pour le HTML). Par ailleurs, un motif comportant plusieurs quantificateurs imbriqués, comme (a+)+b, peut exploser en temps exponentiel sur une entrée bien choisie : c'est une faille de déni de service connue. Préférez des motifs simples, ancrés, testés sur des cas réels.

5. ETL, ELT, et pourquoi l'ordre s'est inversé

Trois lettres qui décrivent l'ordre des opérations entre la source et l'entrepôt.

deux ordres, deux époques
ETL — Extract, Transform, Load          (années 1990-2010)
  source ──► serveur de traitement ──► entrepôt
             │ nettoyage, jointures,
             │ agrégations
             └─ AVANT le chargement

  Pourquoi : l'entrepôt était une machine coûteuse et peu extensible.
  On n'y chargeait que le strict nécessaire, déjà propre.
  Conséquence : la donnée brute n'existe plus. Un bug de transformation
  d'il y a six mois est irréparable — on n'a plus la source.

ELT — Extract, Load, Transform          (depuis ~2015)
  source ──► entrepôt (brut) ──► entrepôt (transformé)
                                 └─ transformations EN SQL, dans l'entrepôt

  Pourquoi : le stockage objet coûte ~20 € le téraoctet-mois, et les
  moteurs analytiques passent à l'échelle horizontalement.
  Conséquence : la donnée brute est conservée. On peut TOUT rejouer.

Le basculement n'est donc pas une mode : c'est la conséquence directe de l'effondrement du coût du stockage. Garder la donnée brute pour rien coûte moins cher que de devoir la redemander à la source — quand c'est encore possible. La conséquence pratique, pour vous, est que les transformations s'écrivent désormais en SQL sur l'entrepôt (module 4) plutôt qu'en Python sur une machine intermédiaire, ce qui est aussi plus facile à tester et à relire.

Les trois couches, un vocabulaire très répandu

La plupart des entrepôts s'organisent en trois niveaux de raffinage — souvent appelés bronze / argent / or, ou raw / staging / marts :

  1. Brut — copie fidèle de la source, aucune transformation, on n'y touche jamais. C'est le filet de sécurité.
  2. Intermédiaire — typage, renommage, déduplication, une table par entité source. Toujours au grain de la source.
  3. Exposé — les tables que consomment les analystes et les modèles : modélisées, agrégées, documentées, stables.

La règle qui donne toute sa valeur à ce découpage : une couche ne lit que la couche immédiatement en dessous. Sans elle, on retombe sur un réseau de dépendances impossible à démêler.

6. Modéliser pour l'analyse : le schéma en étoile

Le problème

Le module 4 a présenté la normalisation : éviter la redondance, une information en un seul endroit. C'est la bonne réponse pour une base transactionnelle, où l'on écrit beaucoup et où toute duplication est un risque d'incohérence. Pour l'analyse, l'objectif est inverse : on écrit une fois, on lit des millions de fois, et chaque jointure supplémentaire coûte. Un schéma parfaitement normalisé impose huit jointures pour répondre à « chiffre d'affaires par région et par mois ».

L'intuition

On sépare les données en deux natures. Les faits sont des événements mesurables, nombreux, qui ne changent pas : une vente, un clic, une annonce publiée. Les dimensions sont les axes selon lesquels on veut découper ces faits : le temps, le client, le produit, la ville. Une table de faits au centre, entourée de tables de dimensions — d'où le nom.

un schéma en étoile
        dim_date                      dim_ville
   ┌──────────────────┐          ┌──────────────────┐
   │ date_id (PK)     │          │ ville_id (PK)    │
   │ jour, mois, année│          │ nom, departement │
   │ jour_semaine     │          │ region, population│
   │ est_ferie        │          └────────┬─────────┘
   └────────┬─────────┘                   │
            │        fait_annonce         │
            │   ┌─────────────────────┐   │
            └──►│ date_id      (FK)   │◄──┘
                │ ville_id     (FK)   │
                │ bien_id      (FK)   │◄──┐
                │─────────────────────│   │
                │ prix        (mesure)│   │   dim_bien
                │ surface     (mesure)│   │ ┌──────────────┐
                │ nb_vues     (mesure)│   └─│ bien_id (PK) │
                └─────────────────────┘     │ type, etage  │
                                            │ dpe, meuble  │
   FAITS : beaucoup de lignes, que des      └──────────────┘
           clés et des mesures numériques
   DIMENSIONS : peu de lignes, beaucoup de colonnes descriptives
sql — ce que le schéma rend facile
-- « prix médian au m2 par région, pour les annonces de 2026,
--    hors jours fériés » : deux jointures, lisible d'un coup d'œil
SELECT v.region,
       PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY f.prix / f.surface)
         AS prix_m2_median,
       COUNT(*) AS n
FROM fait_annonce f
JOIN dim_ville v ON v.ville_id = f.ville_id
JOIN dim_date  d ON d.date_id  = f.date_id
WHERE d.annee = 2026
  AND NOT d.est_ferie
GROUP BY v.region
ORDER BY prix_m2_median DESC;

La table dim_date mérite un mot : la construire explicitement, avec une ligne par jour et des colonnes pré-calculées (trimestre, semaine ISO, jour férié, période de vacances), évite de réécrire cette logique dans chaque requête — et surtout évite qu'elle diverge d'une requête à l'autre.

Les dimensions qui changent dans le temps

Un client déménage de Lyon à Bordeaux. Faut-il que ses commandes passées soient désormais attribuées à Bordeaux ? Presque jamais : on veut savoir où il habitait au moment de l'achat. C'est le problème des dimensions à évolution lente, et il a trois réponses standard :

  • Type 1 — on écrase. La ville est mise à jour, l'historique est perdu. Acceptable pour corriger une faute de frappe.
  • Type 2 — on ajoute une ligne. Chaque version du client devient une ligne avec valide_du, valide_au et un indicateur est_actuel. Le fait pointe vers la version en vigueur à sa date. C'est la réponse correcte dans la grande majorité des cas.
  • Type 3 — on garde la valeur précédente dans une colonne dédiée. Utile quand une seule étape d'historique suffit.

Ce point n'est pas qu'une subtilité d'entrepôt : c'est exactement le même piège que la fuite de données en ML (module 12). Utiliser la ville actuelle pour expliquer un achat passé, c'est faire entrer dans le modèle une information qui n'existait pas encore.

7. dbt : du SQL versionné, testé, documenté

Le problème

Une fois qu'on transforme en SQL dans l'entrepôt, on se retrouve avec deux cents requêtes qui se lisent les unes les autres, copiées dans une interface web, sans historique, sans tests, et dont personne ne sait dans quel ordre les lancer. On a réinventé le pire du code — sauf qu'on a tout ce qu'il faut pour bien faire : Git (module 1), des tests (module 1), des environnements.

Le principe

dbt (data build tool) applique les pratiques du développement logiciel au SQL analytique. Chaque transformation est un fichier .sql dans un dépôt Git, contenant un simple SELECT. dbt se charge du reste : il déduit l'ordre d'exécution, matérialise les résultats en tables ou en vues, et exécute des tests.

models/marts/annonces_journalier.sql
-- Le SELECT seul suffit : dbt l'enveloppe en CREATE TABLE AS.
{{ config(materialized='incremental', unique_key='jour_ville') }}

WITH base AS (
    -- ref() déclare la dépendance : dbt en déduit l'ordre d'exécution
    SELECT * FROM {{ ref('stg_annonces') }}
)
SELECT
    date_trunc('day', publiee_le)           AS jour,
    ville,
    date_trunc('day', publiee_le) || ville   AS jour_ville,
    COUNT(*)                                AS n_annonces,
    APPROX_QUANTILES(prix, 100)[OFFSET(50)] AS prix_median
FROM base
{% if is_incremental() %}
  -- au premier lancement : tout. Ensuite : seulement le nouveau.
  WHERE publiee_le > (SELECT MAX(jour) FROM {{ this }})
{% endif %}
GROUP BY jour, ville

Les accolades doubles sont du Jinja — le moteur de gabarits rencontré au module 6 avec FastAPI, réutilisé ici pour générer du SQL. ref() est le mécanisme central : en écrivant ref('stg_annonces') plutôt que le nom réel de la table, on déclare une dépendance. dbt en construit le graphe complet et sait donc quoi reconstruire, dans quel ordre, et ce qu'un changement casse en aval.

models/marts/schema.yml — tests et documentation
version: 2
models:
  - name: annonces_journalier
    description: "Une ligne par jour et par ville."
    columns:
      - name: jour_ville
        description: "Clé composite jour + ville."
        tests: [unique, not_null]
      - name: prix_median
        tests:
          - dbt_utils.accepted_range:
              min_value: 10000
              max_value: 5000000
bash — le cycle de travail
dbt run                      # construit tous les modèles, dans l'ordre
dbt run --select stg_annonces+   # ce modèle ET tout ce qui en dépend
dbt test                     # exécute les tests déclarés
dbt build                    # run + test, modèle par modèle : s'arrête
                             # au premier échec, avant de propager l'erreur
dbt docs generate && dbt docs serve   # documentation + graphe navigable

Le gain réel n'est pas la syntaxe, c'est le changement de statut : une transformation devient un objet relu en revue de code, testé automatiquement, dont on connaît les dépendances. Alternatives dans le même esprit : SQLMesh, ou un simple ensemble de scripts SQL orchestrés à la main quand le projet est petit — l'essentiel est le principe, pas l'outil.

8. Orchestration : DAG, idempotence, backfill

Le problème

cron lance un script à heure fixe. Il ne sait pas si le script précédent a réussi, ne réessaie pas, ne prévient personne, et ne garde aucune trace. Dès qu'un traitement en attend un autre, il faut un outil qui connaisse les dépendances : un orchestrateur (Airflow, Dagster, Prefect).

Le modèle commun est le graphe orienté acyclique : des tâches, des flèches de dépendance, et aucun cycle — sinon rien ne pourrait démarrer. L'orchestrateur exécute en parallèle tout ce qui peut l'être, réessaie ce qui échoue, et journalise tout.

un graphe de tâches typique
  extraire_api ──┐
                 ├──► valider ──► charger_brut ──► dbt_build ──► alerter
  extraire_sql ──┘                                     │
                                                       └──► rafraichir_modele

  · extraire_api et extraire_sql : en parallèle, rien ne les lie
  · valider attend les deux
  · si dbt_build échoue, alerter et rafraichir_modele ne partent pas

Les deux propriétés qui rendent un pipeline exploitable

Elles paraissent techniques ; elles décident en réalité de la quantité de nuits blanches par an.

Idempotence

Relancer une tâche sur la même période doit produire exactement le même résultat, pas un résultat doublé. Un INSERT nu n'est pas idempotent : deux exécutions, deux fois les lignes. Trois façons de le devenir :

  • Écraser la partition — la tâche du 12 janvier supprime puis réécrit la partition du 12 janvier. Simple et robuste.
  • Insérer ou mettre à jour — INSERT ... ON CONFLICT (cle) DO UPDATE en PostgreSQL, MERGE ailleurs.
  • Déduplication à la lecture — on insère toujours, et une vue ne garde que la dernière version de chaque clé.

Exécution paramétrée par la date

Une tâche ne doit jamais écrire WHERE date = CURRENT_DATE. Elle reçoit en paramètre la date de la période qu'elle traite. La différence est invisible au quotidien, et décisive le jour où l'on doit rejouer l'historique (backfill) : corriger un bug découvert tardivement se réduit alors à relancer la tâche pour chaque jour concerné. Avec CURRENT_DATE en dur, il n'y a aucun moyen de reconstruire le passé.

python — un DAG Airflow minimal et idempotent
from datetime import datetime
from airflow.decorators import dag, task

@dag(schedule="0 3 * * *",            # tous les jours à 3 h UTC
     start_date=datetime(2026, 1, 1),
     catchup=True,                    # rejoue les jours manqués
     max_active_runs=1,               # pas deux exécutions concurrentes
     default_args={"retries": 3})
def annonces_quotidien():

    @task
    def extraire(jour: str) -> str:
        """Écrit UNE partition, celle du jour reçu en paramètre."""
        chemin = f"s3://lac/annonces/jour={jour}/"
        # ... extraction ...
        return chemin

    @task
    def charger(chemin: str, jour: str) -> None:
        # idempotent : on efface la partition avant de la réécrire
        execute(f"DELETE FROM brut.annonces WHERE jour = '{jour}'")
        execute(f"COPY brut.annonces FROM '{chemin}'")

    # {{ ds }} = date de la période traitée, pas la date du jour
    jour = "{{ ds }}"
    charger(extraire(jour), jour)

annonces_quotidien()

Notez catchup=True : si l'orchestrateur est arrêté trois jours, il lancera au redémarrage les trois exécutions manquantes, chacune avec sa propre date. C'est la combinaison du paramétrage par la date et de l'idempotence qui rend cela sûr.

9. Le temps, cette source de bugs

Le sujet paraît trivial. Il est responsable d'une proportion remarquable des incidents en production, et il faut trois règles pour s'en prémunir.

Règle 1 — tout stocker en UTC

Un instant absolu et sa représentation locale sont deux choses différentes. On stocke l'instant en UTC, on convertit à l'affichage seulement. Le passage à l'heure d'été suffit à montrer pourquoi : en France, le dernier dimanche d'octobre, 2 h 30 du matin existe deux fois. Une date locale, seule, est ambiguë ; l'UTC ne l'est jamais.

python — manipuler des instants sans se tromper
from datetime import datetime, timezone
from zoneinfo import ZoneInfo          # standard depuis Python 3.9

# ✗ naïf : aucun fuseau attaché, on ne sait pas ce que ça représente
mauvais = datetime(2026, 10, 25, 2, 30)

# ✓ conscient du fuseau
paris = ZoneInfo("Europe/Paris")
t = datetime(2026, 10, 25, 2, 30, tzinfo=paris)
print(t.astimezone(timezone.utc))      # 2026-10-25 00:30:00+00:00
print(t.utcoffset())                   # 2:00:00 — mais 2 h 30 existe 2 fois !

# la règle sûre : raisonner en UTC, convertir à l'affichage
maintenant = datetime.now(timezone.utc)
print(maintenant.astimezone(paris).strftime("%d/%m/%Y %H:%M"))

Règle 2 — distinguer trois dates

  • Temps d'événement — quand la chose s'est produite (l'annonce a été publiée).
  • Temps d'ingestion — quand la donnée est entrée dans le système.
  • Temps de traitement — quand le calcul a eu lieu.

Les trois diffèrent, parfois de plusieurs jours. Une analyse métier se fait sur le temps d'événement ; une surveillance de pipeline sur le temps d'ingestion. Conserver les trois colonnes coûte quelques octets et évite des discussions sans fin.

Règle 3 — se méfier des données en retard

Le tableau de bord du lundi affiche les chiffres de dimanche. Le mercredi, ces mêmes chiffres ont augmenté : des événements de dimanche sont arrivés avec deux jours de retard. Rien n'est cassé — c'est la nature des données distribuées. Il faut le décider explicitement : ou bien on fige à J+3 (et on l'écrit), ou bien on recalcule sur une fenêtre glissante (et on assume que l'historique bouge).

10. Par lots ou en flux : Kafka et les fenêtres

Le problème

Traiter par lots signifie attendre. Un pipeline quotidien donne une réponse avec vingt-quatre heures de retard, ce qui convient parfaitement à un tableau de bord commercial et pas du tout à la détection d'une fraude sur une carte bancaire. Le traitement en flux traite chaque événement à mesure qu'il arrive.

L'intuition : le journal

La bonne image pour comprendre Kafka n'est ni une file d'attente ni une base : c'est un journal, au sens d'un registre où l'on écrit à la suite sans jamais raturer. Les producteurs ajoutent des lignes à la fin. Les consommateurs lisent en avançant à leur rythme, chacun mémorisant simplement la ligne où il en est. Rien n'est effacé à la lecture, et un nouveau consommateur peut revenir au début.

le journal partitionné
Sujet « annonces », 3 partitions

partition 0 : [e0][e1][e2][e3][e4][e5]───►  écriture toujours à la fin
                          ▲         ▲
                   groupe A│  groupe B│     chacun avance à son rythme
                   (offset 3)  (offset 5)

partition 1 : [e0][e1][e2]───►
partition 2 : [e0][e1][e2][e3]───►

· l'ordre n'est garanti QUE dans une partition
· la clé de l'événement décide de sa partition : tous les événements
  d'une même annonce tombent dans la même partition, donc restent ordonnés
· la rétention est une durée (7 jours) ou une taille, pas « jusqu'à lecture »

Cette rétention change tout par rapport à une file classique : on peut rejouer. Un bug dans le consommateur ? On corrige, on remet le curseur trois jours en arrière, on relit. C'est le même principe que la conservation de la donnée brute vue plus haut, appliqué aux événements.

python — produire et consommer
from kafka import KafkaProducer, KafkaConsumer
import json

prod = KafkaProducer(
    bootstrap_servers="localhost:9092",
    value_serializer=lambda v: json.dumps(v).encode(),
    key_serializer=lambda k: k.encode(),
)
# la clé fixe la partition → ordre garanti par annonce
prod.send("annonces", key="a-4821",
          value={"id": "a-4821", "prix": 320000, "ts": "2026-01-12T09:00:00Z"})
prod.flush()

conso = KafkaConsumer(
    "annonces",
    bootstrap_servers="localhost:9092",
    group_id="indexeur",              # le groupe porte la position de lecture
    enable_auto_commit=False,         # on valide APRÈS traitement
    value_deserializer=lambda b: json.loads(b),
)
for msg in conso:
    traiter(msg.value)                # si ça échoue, on ne valide pas
    conso.commit()                    # → le message sera relu au redémarrage

Fenêtres : agréger un flux qui n'a pas de fin

Sur un flux infini, GROUP BY n'a pas de sens : on ne peut pas attendre la fin. On agrège donc sur des fenêtres de temps.

trois découpages, trois usages
FIXE (tumbling) — se suivent, ne se recouvrent pas
  |--- 10:00-10:05 ---|--- 10:05-10:10 ---|--- 10:10-10:15 ---|
  usage : « nombre d'annonces publiées par tranche de 5 minutes »

GLISSANTE (sliding) — se recouvrent
  |--- 10:00-10:05 ---|
       |--- 10:01-10:06 ---|
            |--- 10:02-10:07 ---|
  usage : « moyenne sur les 5 dernières minutes, rafraîchie chaque minute »

DE SESSION — délimitée par une période d'inactivité
  [clic clic clic] ....30 min de silence.... [clic clic]
  └─ session 1 ─┘                            └─ sess. 2 ┘
  usage : « durée d'une visite », sans durée fixée a priori

Les trois sémantiques de livraison

Une question qui revient en entretien, et surtout en incident :

  • Au plus une fois — on valide la position avant de traiter. Un plantage perd le message. Acceptable pour des métriques d'usage.
  • Au moins une fois — on valide après traitement. Un plantage entre les deux provoque un doublon. C'est le réglage par défaut, et le bon.
  • Exactement une fois — coûteux et fragile à obtenir strictement. En pratique, on prend « au moins une fois » et on rend le traitement idempotent — exactement la propriété de la section 8. Le doublon devient inoffensif.
Le flux n'est pas un objectif en soi. Il ajoute une infrastructure permanente, une surveillance, et des raisonnements délicats sur les événements en retard. La question à se poser est : combien vaut une minute de fraîcheur ? Pour un tableau de bord relu le matin, rien. Pour une détection de fraude, beaucoup. Un traitement par lots toutes les quinze minutes couvre une majorité de besoins « temps réel » exprimés, pour une fraction du coût.

11. Qualité : contrats de données et observabilité

Le problème

Un pipeline qui échoue bruyamment est un bon pipeline : on le voit, on le répare. Le vrai danger est le pipeline qui réussit avec des données fausses. Une source change silencieusement le prix d'euros en centimes ; tout s'exécute sans erreur ; le modèle s'entraîne sur des valeurs multipliées par cent et personne ne s'en aperçoit avant trois semaines.

Le contrat de données

La réponse est de déclarer explicitement ce qu'on attend, et de faire échouer l'ingestion quand la réalité s'en écarte. C'est exactement l'idée de Pydantic (module 6), transposée à des tables entières.

python — un contrat vérifié à l'entrée
import pandera.polars as pa
import polars as pl

class Annonce(pa.DataFrameModel):
    id: str            = pa.Field(unique=True)
    ville: str         = pa.Field(isin=["Lyon", "Nantes", "Lille", "Rennes"])
    prix: int          = pa.Field(gt=10_000, lt=5_000_000)
    surface: float     = pa.Field(gt=5, lt=1_000)
    publiee_le: pl.Datetime

    class Config:
        strict = "filter"     # une colonne inconnue est écartée, pas ignorée

    @pa.check("prix")
    def prix_au_m2_plausible(cls, data) -> bool:
        """Une règle métier que le typage ne peut pas exprimer."""
        return True   # calcul réel : prix / surface entre 500 et 20 000

df = pl.read_parquet("brut/annonces.parquet")
propre = Annonce.validate(df, lazy=True)   # lazy : TOUTES les erreurs d'un coup

Le point important est où placer cette barrière : à la frontière, au moment où la donnée entre dans le système. Valider en sortie, c'est découvrir le problème après l'avoir propagé partout.

Les six vérifications qui attrapent presque tout

  1. Fraîcheur — la table a-t-elle été mise à jour depuis moins de N heures ? Détecte un pipeline arrêté.
  2. Volume — le nombre de lignes du jour est-il dans la plage habituelle ? Détecte une extraction partielle. Un fichier deux fois trop petit est plus fréquent qu'un fichier absent.
  3. Schéma — les colonnes et leurs types sont-ils ceux attendus ?
  4. Unicité — la clé primaire est-elle réellement unique ? Détecte une double ingestion.
  5. Valeurs manquantes — le taux de NULL par colonne a-t-il bougé ?
  6. Distribution — la moyenne et les quantiles sont-ils comparables à hier ? C'est la détection de dérive du module 26, appliquée aux données brutes.
Le piège du seuil. Une alerte qui se déclenche tous les jours n'est plus une alerte : elle est ignorée en une semaine. Mieux vaut trois vérifications fiables et bloquantes que trente alertes floues. Distinguez ce qui doit arrêter le pipeline (schéma cassé, clé dupliquée) de ce qui doit seulement prévenir (volume inhabituel).

12. Quelle architecture pour quelle taille

Le feature store, en deux mots

Un problème très concret survient quand un modèle passe en production. La variable « nombre d'annonces publiées par ce vendeur les 30 derniers jours » est calculée en SQL sur l'entrepôt pour l'entraînement, et réécrite en Python dans l'API pour la prédiction. Deux implémentations de la même idée : elles finissent toujours par diverger — l'une exclut le jour courant, l'autre non. Le modèle reçoit alors en production des valeurs qui ne ressemblent pas à celles vues à l'entraînement, et se dégrade sans qu'aucune alerte ne se déclenche. C'est le décalage entraînement/service.

Un feature store (Feast, Tecton) résout cela en imposant une seule définition, servie de deux façons : en lot pour construire le jeu d'entraînement — en respectant la date, donc sans fuite —, et à la milliseconde pour l'API. Utile à partir de plusieurs modèles partageant des variables ; superflu avant, où une fonction Python unique appelée des deux côtés suffit.

Lakehouse : le compromis actuel

Le lac de données (des fichiers, souple, pas cher) et l'entrepôt (structuré, transactionnel, cher) ont longtemps coexisté, avec des copies dans les deux sens. Les formats de table ouverts — Delta Lake, Apache Iceberg, Apache Hudi — apportent aux fichiers Parquet ce qui leur manquait : des transactions, l'évolution de schéma, et surtout le voyage dans le temps, c'est-à-dire la possibilité d'interroger la table telle qu'elle était il y a deux semaines. Pour la reproductibilité d'un entraînement (module 26), c'est décisif : « ce modèle a été entraîné sur la version 47 de cette table » devient une phrase vérifiable.

choisir selon l'échelle, pas selon la mode
< 10 Go, une personne
  fichiers Parquet + DuckDB + scripts Python planifiés par cron
  → n'ajoutez rien d'autre. Cela tient sur un portable et suffit longtemps.

10 Go – 1 To, petite équipe
  stockage objet (S3) partitionné + DuckDB ou PostgreSQL
  + dbt + un orchestrateur léger (Dagster, Prefect)

1 To – 100 To, plusieurs équipes
  entrepôt distribué (BigQuery, Snowflake, ClickHouse)
  + format de table ouvert (Iceberg, Delta) + dbt + Airflow
  + contrats de données à la frontière

> 100 To, ou besoin de latence inférieure à la minute
  ajouter Kafka et un moteur de flux (Flink, Spark Structured Streaming)
  ─ et seulement alors.

Ce qu'il faut retenir

  • Parquet par défaut pour tout fichier relu par un programme ; Arrow est son pendant en mémoire, et permet aux outils de s'échanger des tables sans copie.
  • Partitionner sur la date, viser des fichiers de 100 Mo à 1 Go, éviter les myriades de petits fichiers.
  • Conserver le brut : c'est la seule chose qui rende un bug de transformation réparable.
  • Idempotence et paramétrage par la date : les deux propriétés qui rendent un pipeline rejouable — et qui, au passage, règlent le problème des doublons en flux.
  • Tout stocker en UTC, distinguer temps d'événement et temps d'ingestion.
  • Valider à la frontière, avec peu de règles mais bloquantes.
  • Choisir l'outil le plus simple qui tienne : la complexité d'une architecture se paie tous les jours, en exploitation.

La suite du cours quitte la plomberie pour les mathématiques : le module 8 reprend l'optimisation et l'algèbre linéaire dont tout le machine learning découle. On retrouvera l'ingénierie des données au module 28, où le projet de bout en bout construit un pipeline complet — scraping, validation, base PostgreSQL, ingestion idempotente — en appliquant chacune des règles ci-dessus.


ParquetArrowdbtAirflowKafkaSchéma en étoile