Module 5 — Jointures correctes dans le temps
Voici le module qui, à lui seul, justifie l'existence d'un magasin de variables pour beaucoup d'équipes. Il traite un bogue si commun qu'il a englouti des projets entiers : la fuite du futur dans les données d'entraînement, par une jointure naïve entre événements et variables.
Un exemple qui parle
On veut entraîner le scoreur de fraude sur l'historique. On dispose de deux tables. La première, paiements, liste les événements à scorer :
| card_id_hash | event_timestamp | est_fraude |
|---|---|---|
| abcd1234 | 2026-03-15 14:32:07 | 0 |
| abcd1234 | 2026-03-15 15:04:52 | 1 |
| efgh5678 | 2026-03-15 09:11:03 | 0 |
La seconde, variables_porteur, est une capture périodique (toutes les heures) de l'agrégat nb_transactions_24h par porteur :
| card_id_hash | valid_at | nb_transactions_24h |
|---|---|---|
| abcd1234 | 2026-03-15 14:00:00 | 12 |
| abcd1234 | 2026-03-15 15:00:00 | 17 |
| abcd1234 | 2026-03-15 16:00:00 | 22 |
| efgh5678 | 2026-03-15 09:00:00 | 4 |
Question : quelle valeur de nb_transactions_24h faut-il joindre au paiement de abcd1234 à 14:32:07 ?
La jointure naïve fuit
L'écriture spontanée est celle-ci :
SELECT p.*, v.nb_transactions_24h
FROM paiements p
JOIN variables_porteur v USING (card_id_hash)
WHERE ...
Sans filtre supplémentaire, la jointure produit une ligne par capture de variable. On agrège ensuite — au dernier point disponible, à la moyenne, au maximum. Toutes ces agrégations sont contaminées parce qu'elles incluent les captures postérieures à l'événement.
Le paiement de 14:32:07 reçoit alors comme variable la valeur 17 (capture de 15:00:00, la plus proche), ou pire, 22 (capture de 16:00:00, si MAX). Or 17 inclut le paiement lui-même, et 22 inclut le deuxième paiement, celui qui est frauduleux. Le modèle apprend une corrélation triviale : « quand nb_transactions_24h est plus élevé après cet événement, cet événement est frauduleux ». Sur le papier, ses métriques flambent — AUC 0,97, rappel 92 %.
En production, où le futur n'est pas connu, la variable disponible à 14:32:07 est 12 (capture de 14:00:00, la dernière antérieure à l'événement). Le modèle, entraîné sur 17, s'effondre. Un projet complet a été livré ainsi, remis en cause deux semaines après le déploiement par une chute brutale du rappel.
La jointure point-en-temps
La bonne jointure prend, pour chaque événement, la dernière capture de variable dont valid_at est strictement antérieur à event_timestamp. C'est l'as-of join de la finance quantitative, la jointure point-en-temps du monde ML :
SELECT p.card_id_hash, p.event_timestamp, p.est_fraude,
v.nb_transactions_24h
FROM paiements p
LEFT JOIN LATERAL (
SELECT nb_transactions_24h
FROM variables_porteur v
WHERE v.card_id_hash = p.card_id_hash
AND v.valid_at < p.event_timestamp
AND v.valid_at >= p.event_timestamp - INTERVAL '24 hours'
ORDER BY v.valid_at DESC
LIMIT 1
) v ON true
Deux clauses portent la garantie :
v.valid_at < p.event_timestampinterdit toute fuite du futur.v.valid_at >= p.event_timestamp - INTERVAL '24 hours'respecte la durée de validité (ttl) déclarée sur la vue de variable ; au-delà, la valeur est jugée périmée et renvoyée àNULL, comme elle le serait en production sur un porteur inactif depuis 24 heures.
Cette jointure reproduit, sur chaque ligne d'entraînement, ce que le magasin en ligne aurait renvoyé si on avait interrogé Redis au moment exact de la transaction. C'est la définition opérationnelle du terme « point-en-temps correct ».
Ce que le magasin apporte
Écrire cette jointure à la main est faisable mais laborieux : il faut la répliquer pour chaque variable, gérer les fuseaux horaires, les fenêtres de validité, les entités composées (porteur × commerçant). L'appel get_historical_features(entities, timestamps) du magasin fait tout cela pour toutes les vues demandées, avec une seule commande.
Concrètement, en Feast :
from feast import FeatureStore
import pandas as pd
store = FeatureStore(repo_path=".")
evenements = pd.read_parquet("paiements.parquet")
training_df = store.get_historical_features(
entity_df=evenements, # doit contenir event_timestamp et card_id_hash
features=[
"porteur_transactions_recentes:nb_transactions_1h",
"porteur_transactions_recentes:nb_transactions_24h",
"porteur_transactions_recentes:nb_transactions_7j",
],
).to_df()
Le magasin applique la jointure point-en-temps pour chaque variable, respecte la durée de validité de la vue, et renvoie un DataFrame prêt pour l'entraînement.
Effet sur les métriques : quantifier la fuite
Sur le cas de fraude, mesurer l'ampleur du bogue est instructif. Sur un jeu réel de 8 millions de paiements, avec est_fraude à 0,3 % :
| Type de jointure | AUC (validation) | Rappel à précision 50 % (validation) | Rappel à précision 50 % (production) |
|---|---|---|---|
| Naïve (dernier point, tous) | 0,97 | 91 % | 48 % |
Naïve avec <= | 0,89 | 78 % | 56 % |
Point-en-temps stricte (<) | 0,82 | 63 % | 62 % |
Le meilleur score de validation est aussi le pire score de production. C'est la signature de la fuite : plus les métriques sont belles à l'entraînement, plus elles s'écroulent à la mise en service. Un modèle honnête préfère un AUC de 0,82 stable qu'un AUC de 0,97 mensonger.
La fuite du futur ne concerne pas seulement des variables agrégées. Un champ apparemment anodin comme derniere_categorie_marchand, capturé après l'événement, contient déjà l'événement. Toute variable dérivée d'un état postérieur est suspecte : la jointure point-en-temps est le seul garde-fou automatique.
En résumé
- La jointure naïve inclut des captures postérieures à l'événement et introduit une fuite du futur qui décore les métriques de validation.
- La jointure point-en-temps ne prend que la dernière capture strictement antérieure, dans la fenêtre de validité de la vue.
- L'écart entre AUC de validation et rappel de production est la signature caractéristique de la fuite.
- Le magasin fournit
get_historical_features: une seule commande calque, sur l'historique, ce que le magasin en ligne renverrait à la milliseconde de la décision.
Module suivant : la matérialisation — le processus qui alimente les deux magasins à la bonne fréquence, avec la bonne fraîcheur.