Feature store : quand la valeur servie n'est plus celle de l'entraînement
Le train/serve skew se cache dans le passage entre le chemin offline et le chemin online. Aucune erreur ne remonte, les métriques offline restent bonnes. Les trois formes que je rencontre, un calcul simple pour l'âge d'une valeur servie, et le contrôle qui permet de le voir.
Un feature store a un seul travail : la valeur qu'un modèle voit à l'entraînement doit être la même que celle qu'il voit en production. Quand ce n'est plus le cas, le modèle répond correctement à une question qui n'est plus la bonne.
Le problème, c'est que rien ne casse visiblement. Aucune exception, aucune alerte, aucun job en échec. Les métriques offline restent excellentes, puisque offline tout est cohérent.
Deux chemins pour une même feature
La même feature est calculée deux fois : une fois en batch pour l'entraînement, une fois en temps réel pour l'inférence. Les deux chemins tournent sur des infrastructures différentes et sont souvent écrits par des personnes différentes, à des moments différents.
Les trois formes de skew
1. La dérive d'horodatage
Offline, on demande la valeur de la feature au moment de l'événement. Online, on demande sa valeur maintenant. Si le magasin online est rafraîchi toutes les cinq minutes, une requête qui arrive à 14 h 03 lit une valeur calculée à 14 h 00. Le jeu d'entraînement, lui, n'a jamais contenu ce retard : la jointure point-in-time était exacte.
Le modèle a donc appris sur des features fraîches et sert sur des features qui ont jusqu'à cinq minutes de retard. L'écart ne se voit pas offline, et il grossit quand le pipeline prend du retard, donc quand le système est chargé.
Ce qu'on observe : la précision baisse aux heures de pointe et remonte la nuit, sans aucun changement de modèle.
2. Le backfill incohérent
Un backfill recalcule l'historique avec la logique d'aujourd'hui. C'est souvent ce qu'on veut. C'est aussi comme ça qu'une feature récupère une information qui n'existait pas à l'époque.
Prenons une colonne « valeur vie client » recalculée avec la définition actuelle. Elle contient des achats qui n'avaient pas encore eu lieu à la date de l'événement de la ligne. Le modèle apprend du futur, sort des scores superbes offline, et se retrouve sans appui en production.
C'est une fuite de données qui ressemble à une amélioration de qualité. Elle passe la revue de code parce que la modification ressemble à une correction de bug.
Ce qu'on observe : une belle amélioration offline après une modification du pipeline, et rien qui bouge online.
3. La conversion de schéma
L'entrepôt et le magasin online n'ont pas les mêmes types, et l'un des deux convertit sans prévenir.
Un DECIMAL(10,2) de l'entrepôt devient un float64 offline, une chaîne dans le JSON online, puis un float32 au moment du service. Un null devient NaN d'un côté et 0.0 de l'autre. Une catégorie encodée par index trié offline reçoit un autre index online, parce que le tri a porté sur un autre ensemble de valeurs.
Ce dernier cas mérite d'être dit clairement : un encodeur de catégories ajusté à l'entraînement puis réajusté au service devient une autre fonction. Un encodeur ajusté est un artefact du modèle. Sa place est à côté des poids, et il ne doit jamais être recalculé dans une étape de prétraitement exécutée deux fois.
Ce qu'on observe : un petit écart de précision constant, qu'aucun ré-entraînement n'arrive à combler.
L'intuition mathématique : l'âge d'une valeur servie
La dérive d'horodatage se mesure avec une seule grandeur : l'âge de la valeur renvoyée par le magasin online, c'est-à-dire le temps écoulé depuis son calcul.
Si le magasin est rafraîchi toutes les T minutes, cet âge repart à zéro à chaque rafraîchissement puis monte jusqu'à T. On obtient une dent de scie. Une requête arrive à un instant quelconque, donc en moyenne au milieu d'une dent :
âge moyen = d + T/2
avec d le délai du pipeline, entre l'événement et l'écriture dans le magasin. Pendant ce temps, la vraie valeur continue de bouger. L'écart entre ce que le modèle reçoit et ce qu'il aurait dû recevoir vaut à peu près la vitesse de variation de la feature multipliée par l'âge : écart ≈ pente × âge.
Avec un rafraîchissement toutes les 5 minutes et 1 minute de délai, chaque requête lit en moyenne une valeur vieille de 3,5 minutes. Offline, la jointure point-in-time donne un âge nul, l'entraînement n'a donc jamais vu cet écart.
Deux conséquences en pratique. Pour une feature qui bouge lentement, l'écart est négligeable. Pour une feature qui bouge vite, comme un compteur de clics ou un prix, il compte. Et il augmente quand le pipeline prend du retard, car d grandit justement aux heures de pointe : c'est le symptôme de la première section.
Comment les détecter
Regarder un seul des deux chemins ne sert à rien. Chacun est cohérent avec lui-même, le désaccord n'apparaît qu'en les comparant.
Journaliser ce qui a vraiment été servi. Il faut le vecteur de features exact qui est entré dans le modèle, au moment de la requête, avec l'identifiant de requête. Tous les contrôles qui suivent reposent là-dessus, et c'est l'étape qu'on saute le plus souvent, en général à cause du coût de stockage. Un échantillon suffit : 1 % du trafic permet déjà de trouver un skew systématique.
Rejouer offline à partir de ce journal. On recalcule les mêmes features par le chemin offline, pour les mêmes entités aux mêmes horodatages, et on compare. Un écart sur une feature censée être déterministe est un skew, par définition.
class=class="tok-str">"tok-com"># Contrôle de skew : recalculer par le chemin offline, comparer à ce qui a été servi.
class=class="tok-str">"tok-com">#
class=class="tok-str">"tok-com"># On compare colonne par colonne : une baisse de précision dit que quelque
class=class="tok-str">"tok-com"># chose ne va pas, ce script dit quelle feature est en cause.
import pandas as pd
served = pd.read_parquet("logs/served_features/dt=class="tok-num">2026-class="tok-num">03-class="tok-num">23") # request_id, entity_id, event_ts, features
offline = feature_store.get_historical_features(
entity_df=served[["entity_id", "event_ts"]],
features=FEATURE_LIST,
).to_df()
merged = served.merge(offline, on=["entity_id", "event_ts"], suffixes=("_online", "_offline"))
for col in FEATURE_LIST:
a, b = merged[f"{col}_online"], merged[f"{col}_offline"]
mismatch = ~((a == b) | (a.isna() & b.isna()))
if mismatch.any():
rate = mismatch.mean()
print(f"{col}: {rate:.class="tok-num">2%} mismatched")
print(merged.loc[mismatch, [f"{col}_online", f"{col}_offline"]].head())Ce script doit tourner de façon planifiée. Le skew arrive avec les changements, et les changements arrivent tout le temps.
Comparer des distributions quand l'égalité n'a pas de sens. Certaines features ne sont pas déterministes, par exemple tout ce qui dépend d'un horodatage. Pour celles-là, on compare des distributions et on alerte sur un déplacement des statistiques résumées.
Rendre la fraîcheur visible. J'émets l'âge de chaque valeur online comme une métrique. Sans ce chiffre, impossible de raisonner sur la dérive d'horodatage, et il est presque toujours plus élevé que ce que laisse penser la planification.
Corriger
Une seule définition pour les deux chemins. La correction qui tient dans le temps, c'est une définition déclarative unique de la feature, dont on génère les deux chemins. Deux implémentations écrites à la main finiront par diverger, la seule question est quand. C'est pour ça que les frameworks de feature store existent, et c'est bon de s'en souvenir quand l'abstraction paraît lourde.
Livrer les transformateurs ajustés avec le modèle. Encodeurs, normaliseurs, imputeurs : ajustés à l'entraînement, sérialisés, rechargés au service. Une transformation dont les paramètres sont appris sur les données fait partie du modèle.
Tester la justesse point-in-time. Un petit jeu de données synthétique dont on connaît la bonne réponse, passé dans le chemin offline à chaque modification du pipeline. Les jointures point-in-time sont assez piégeuses pour qu'un test vaille mieux qu'une relecture.
Traiter le journal des features servies comme une donnée de production. Rétention, schéma, monitoring. C'est la seule trace fiable de ce que le modèle a réellement vu.
Pourquoi c'est difficile
Les deux équipes tirent dans des directions différentes. Le chemin offline est écrit par des gens qui cherchent la justesse sur tout l'historique. Le chemin online, par des gens qui surveillent la latence p99. Les deux se rejoignent sur un schéma, et un schéma ne sait pas dire « à la date de l'événement » ou « maintenant ».
Chaque chemin, pris seul, est correct. Rien ne lève d'exception. Le seul signal, c'est un modèle qui fait moins bien que prévu, et on le prend facilement pour un modèle simplement moyen. C'est d'ailleurs le premier diagnostic que posent la plupart des équipes.
La défense est simple et peu spectaculaire : journaliser ce qu'on sert, le rejouer, comparer, alerter sur l'écart. Le reste de l'architecture aide à éviter le problème. Pour savoir s'il est là, il faut le mesurer.