Culprit : une trace de pile pour la dérive de modèle. 90 322 $ d'erreur remontés à une seule colonne dont le max passe de 6 à 7.
Fraîcheur, volume, taux de nuls et schéma étaient tous au vert. Le modèle se trompait discrètement depuis six mois. Culprit a remonté la lignée ML dans DataHub jusqu'à la colonne responsable, a déposé l'incident dans le graphe avec les dollars attachés, a écrit le correctif, a exécuté dbt build contre le vrai warehouse pour le vérifier, a rejeté son premier patch qui aurait supprimé 87 693 lignes, et seulement là a ouvert la PR. 90 322 $ d'erreur de modèle attribuable, chiffrés contre un contrôle contrefactuel sur 19,3 M de vrais enregistrements de taxi NYC.
Le monitoring de modèle fait l'analyse de cause racine à l'intérieur de la frontière du modèle. L'observabilité de données le fait à l'intérieur de la frontière des données. Les deux sont bons à ça maintenant, et je ne prétends pas le contraire. Mais le fait qui décide de ce cas vit à la frontière entre les deux : quelles valeurs de catégorie l'encodeur déployé a réellement vues à l'entraînement. Ce fait vit dans la lignée ML, à côté de la run d'entraînement, et c'est ce qui transforme « cette feature a bougé » en « ce modèle n'a jamais appris cette valeur, voici le re-entraînement qui l'a cuit, voici ce que ça a coûté ».
![Page modèle DataHub pour nyc_fare_predictor montrant la propriété vendors_in_training_data avec la valeur [1, 2, 6], capturée avant toute dérive. C'est le fait qui décide toute l'investigation.](/projectImages/culprit_01_model_properties.jpg)
L'incident réel sur lequel c'est bâti
La faute dans ce dépôt n'est pas plantée. Elle est réellement survenue, dans un jeu de données public, et on peut la vérifier soi-même. Un nouveau fournisseur de taxi est apparu dans le flux TLC de la ville de New York en décembre 2024. VendorID = 7 (Helix) a commencé à 230 lignes (0,006 % du flux), puis a été multiplié par près de 300 en six mois. Le modèle dbt fct_trip_features encodait les fournisseurs avec un CASE codé en dur sur les trois fournisseurs existants à l'écriture. C'est du code dbt ordinaire, défendable. C'est aussi le coupable : une course du fournisseur 7 affirme « aucun fournisseur », une combinaison qui n'apparaît nulle part dans les données d'entraînement.

Il y a un deuxième défaut empilé par-dessus. Le fournisseur 7 émet des horodatages de prise en charge et de dépose identiques, donc chacune de ses courses a une durée exactement nulle. Le modèle de features se garde contre la division par zéro comme tout le monde : `coalesce(trip_distance / nullif(trip_minutes / 60.0, 0), 0) as avg_speed_mph`. Le garde-fou de sûreté nulle est ce qui masque la corruption. Sans lui, avg_speed_mph deviendrait NULL et un moniteur de taux de nuls se déclencherait. Avec lui, la colonne reste propre et rapporte avec assurance 0 mph pour 66 146 courses.

Mesurer les dégâts, net d'un contrôle contrefactuel
La partie honnête. Un chiffre en dollars est facile à gonfler, alors voici comment celui-ci est produit. Deux modèles sont entraînés avec des hyperparamètres identiques : production voit les fournisseurs {1, 2, 6} ; contrôle voit {1, 2, 6, 7}. Les deux notent le vrai mois complet 2025-06. Sur le fournisseur 7, la production a MAE 4,6060 $ et le contrôle a MAE 3,1674 $ — un écart naïf de 1,4386 $ par course. L'objection évidente est que le contrôle a vu plus de données, et plus fraîches, donc une partie de son avantage est juste ça, pas la correction d'encodage. Correct, et c'est pourquoi l'écart naïf n'est pas le titre.
L'avantage non mérité du contrôle est directement mesurable sur les segments sans défaut d'encodage : 0,0731 $ par ligne sur 3 840 878 lignes non affectées. En soustrayant on obtient un difference-in-differences : (1,4386 $ − 0,0731 $) × 66 146 courses = 90 322,36 $. C'est le coût de servir six mois de courses d'un fournisseur pour lequel le modèle déployé n'a jamais été ajusté, chiffré comme erreur de prédiction moyenne absolue contre un contrôle contrefactuel. Ce n'est pas de la perte de revenu réalisée, et ce n'est pas un chiffre que le patch dbt seul récupère ; un re-entraînement sur une fenêtre contenant le fournisseur est nécessaire pour une réparation complète. Culprit le dit dans la PR plutôt que de laisser entendre que le correctif est terminé.

La run enregistrée : 13 tours, 28 appels d'outils, 152 secondes, 0,279 $
L'agent reçoit un URN de modèle et une phrase vague (« les devis de course ont dérivé, personne ne sait pourquoi »). Il lit le contexte du modèle dans le graphe, compare le comportement des inputs à travers les segments de données servies en direct pour trouver ceux qui s'effondrent à une constante ou prennent des valeurs impossibles, remonte la lignée depuis la feature suspecte à travers les entités ML puis dans la lignée de colonnes dérivée de dbt jusqu'à la colonne source brute, profile cette colonne dans le temps pour trouver le changement de sens, confirme que les moniteurs standards n'auraient pas déclenché, chiffre les dégâts en dollars en SQL net d'un contrôle, et réécrit le constat dans DataHub. Rien sur les taxis, les fournisseurs ou l'encodage one-hot n'apparaît dans le prompt système ni dans le catalogue d'outils.

Comment ça utilise DataHub
- Lit à travers le propre serveur MCP de DataHub. culprit/mcp_bridge.py lance mcp-server-datahub sur stdio et expose ses outils à l'agent (search, get_lineage, get_entities, list_schema_fields, get_dataset_queries, get_lineage_paths_between).
- Contribue la moitié ML du graphe. Aucun datapack DataHub ne fournit d'entités ML, alors pipeline/emit_ml_lineage.py émet 13 mlFeatures, un mlFeatureTable, un mlModelGroup, un mlModel et une run d'entraînement dataProcessInstance via le SDK Python DataHub.
- Utilise de la lignée réelle ingérée plutôt que de la lignée déclarée à la main. La moitié dataset du graphe vient du connecteur dbt natif de DataHub qui parse les vrais artefacts manifest.json et catalog.json, ce qui produit une vraie fineGrainedLineage au niveau colonne.
- Réécrit trois artefacts. culprit/writeback.py lève un Incident DataHub (CUSTOM / « Semantic drift » / HIGH), sauve toute l'investigation comme document de connaissance via l'outil MCP save_document, et annote la colonne source fautive pour que la prochaine personne ou le prochain agent qui l'ouvre hérite du constat.


Fermer la boucle : le correctif généré, rejeté puis accepté
Culprit ne s'arrête pas au diagnostic. `culprit fix` localise la transformation fautive, écrit un patch, puis le prouve en exécutant dbt contre le vrai warehouse avant de proposer quoi que ce soit. Trois garde-fous doivent passer : dbt build réussit sur le modèle patché, les lignes affectées correspondent maintenant à une catégorie, aucun autre segment n'a vu son compte de lignes changer. Seulement alors il ouvre une PR. Ce n'est pas décoratif. La première tentative a échoué à deux des trois garde-fous.
Le modèle a proposé `+ where vendor_id in (1, 2, 6)`. Ça n'encode pas le nouveau fournisseur. Ça supprime les 87 693 lignes du fournisseur 7 (dont 66 146 dans le mois qu'on a chiffré). Ça compile proprement et dbt build passe, donc n'importe quelle vérification basée sur « ce patch a-t-il l'air raisonnable » l'aurait shippé, et le symptôme aurait disparu avec les données. Le garde-fou de compte de lignes l'a attrapé et a refusé d'ouvrir la PR. Le patch accepté a ajouté un bucket fourre-tout à la place : `case when vendor_id not in (1, 2, 6) then 1 else 0 end as is_vendor_unknown` — meilleur que le correctif évident écrit à la main parce qu'il ne cassera pas à nouveau au prochain nouveau fournisseur.

Note de conception : le modèle est le moteur, SQL est le garde-fou
Le raisonnement est celui du modèle. Quelles features ont l'air fausses, quelles colonnes remonter, ce qu'un changement dans ces valeurs signifie, et si une hypothèse survit — tout est décidé par l'agent. Provider-agnostique (OpenAI, Anthropic, ou tout serveur local compatible OpenAI comme Ollama). La couche déterministe existe pour une raison étroite : le modèle n'est jamais sollicité pour produire un chiffre. Chaque figure en dollars et chaque compte de lignes est retourné par SQL et remis à l'agent comme un fait. C'est un garde-fou sur le moteur, pas un remplacement, et ça ferme le mode d'échec évident où un modèle de langage fabrique un impact financier plausible.
Limites honnêtes
- La correspondance feature-vers-colonne-racine est une propriété enregistrée, pas un parcours dérivé. `pipeline/emit_ml_lineage.py` écrit root_columns sur chaque mlFeature, donc « is_vendor_cmt → vendor_id » est relu en un seul appel plutôt que parcouru saut par saut. Ça correspond à ce que les feature stores de production (Feast, Tecton) publient, et DataHub ingère séparément la vraie lignée au niveau colonne pour le même chemin, donc la propriété est une réponse mise en cache plutôt qu'une inventée. Mais le verbe honnête est « lit la colonne racine déclarée du modèle depuis DataHub », pas « la dérive par parcours ».
- Culprit détecte actuellement le changement sémantique dans les colonnes à faible cardinalité. Les changements d'unité et les fuites dues aux backfills sont décrits dans la méthode de l'agent mais seul le chemin de nouvelle valeur catégorielle est exercé de bout en bout ici.
- Le contrôle contrefactuel nécessite de pouvoir ré-entraîner. Là où le ré-entraînement est coûteux, l'estimateur naïf est le repli et il surestime.
- L'agent n'est pas déterministe. Trois runs ont été faites contre une checklist écrite avant chacune d'elles ; la run commitée est la première qui a rempli les cinq critères. Laquelle des deux routes de dégâts est rapportée (l'encodage non mappé vs l'effondrement de trip-minutes) n'est pas totalement fiable entre runs. C'est une vraie limitation, et aucune run n'a été éditée à la main pour la cacher.
Culprit: A Stack Trace for Model Decay. Named the Column, Priced the Damage at $90,322/mo Across 66,146 Real NYC Taxi Trips, and Opened the PR.
Voir le projet