Skip to main content
Jonathan Andrei
Retour aux billets
Août 202613 min de lecture

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.

BuildWithDataHubHackathonDataHubMCPdbtModel ObservabilityML AgentsPython
J'ai créé ce billet et le projet Culprit pour Build with DataHub: The Agent Hackathon (piste Production ML Agents). #BuildWithDataHubHackathon
La revendication en une phrase : 90 322 $ d'erreur de modèle attribuable en un seul mois, sur 66 146 vraies courses de taxi NYC, causés par la valeur maximale d'une colonne amont qui passe de 6 à 7, pendant que fraîcheur, volume, taux de nuls et schéma restaient tous au vert. C'est 1,37 $ par course, mesuré en SQL contre 19,3 M de vrais enregistrements, net d'un contrôle contrefactuel.

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.
Le fait dont dépend tout le diagnostic, enregistré dans le graphe avant que quoi que ce soit ne tourne mal : le modèle a été entraîné sur les fournisseurs 1, 2 et 6. Rien d'autre ne sait ça.

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.

Le dictionnaire de données publié par la TLC de NYC listant les VendorIDs, avec « 7 = Helix » visible ; le document date du 18 mars 2025 tandis que le fournisseur 7 est apparu pour la première fois dans le flux en décembre 2024, ce qui signifie que la donnée a changé avant que la documentation ne rattrape.
Source primaire, pour que rien n'ait à être pris sur parole. Le fournisseur 7 existe. Le flux a changé en décembre 2024, la documentation datée de mars 2025.

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.

Sortie de Culprit montrant la détection de changement sémantique : min reste à 1, max grimpe de 6 à 7 en décembre 2024, taux de nuls reste à 0 %, dtype reste INTEGER, volume de lignes reste normal. Chaque moniteur structurel est resté vert pendant les six mois entiers.
Un entier a changé. C'est tout le signal à la source. Chaque vérification structurelle fraîcheur, volume, taux de nuls, dtype était au vert pendant les six mois entiers de fausseté silencieuse.

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é.

Tableau d'impact montrant la comparaison MAE par fournisseur entre les modèles production et contrôle, avec l'écart de 1,44 $ du fournisseur 7 pointé comme segment affecté ; montre aussi la différence-de-différences de 1,37 $ par course, nette de l'avantage non mérité du contrôle sur les segments non affectés
Le modèle n'est jamais sollicité pour produire un chiffre. Chaque figure en dollars vient de SQL exécuté contre le vrai warehouse, puis remis à l'agent comme un fait. C'est le garde-fou sur le moteur.

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.

Trace d'investigation montrant la séquence d'appels d'outils : search DataHub pour l'URN du modèle, get_lineage sur mlModel pour entrer dans les features, get_entities sur la table de features, feature_drift_report sur les colonnes suspectes, puis dbt_column_lineage franchissant la moitié dataset du graphe, se terminant par measure_attributable_error retournant le chiffre de 90 322 $
Enregistré, pas scripté. 28 appels d'outils en 13 tours. Quatre des appels passent par le propre serveur MCP de DataHub ; les autres passent par la couche SQL déterministe qui retourne les chiffres.

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.
Vue lignée au niveau colonne DataHub pour fct_trip_features montrant la chaîne depuis raw.yellow_trips.vendor_id → stg_yellow_trips → fct_trip_features → les trois colonnes one-hot is_vendor_* → la table de features ML → le modèle
La moitié dataset est le connecteur dbt natif de DataHub faisant de la vraie lignée au niveau colonne. La moitié ML est le SDK qui émet des mlFeatures avec la colonne source exacte dont chacune dérive, ce qui rend possible la marche du modèle jusqu'à la colonne brute.
L'incident DataHub que Culprit a levé : typé CUSTOM / « Semantic drift » / HIGH, déposé sur le dataset source raw.yellow_trips (parce que le type GraphQL MLModel n'a pas de champ incidents donc un incident sur un modèle est invisible), avec le modèle affecté nommé dans le corps et le chiffre en dollars attaché
L'incident est déposé sur le dataset source, pas sur le modèle — ce n'est pas un choix de conception, c'est une limitation de DataHub v1.5 que j'ai confirmée en sondant une instance en direct, puis corrigé ma propre affirmation antérieure erronée à ce sujet. Rédigé dans docs/INCIDENT_PROBE_RESULTS.md.

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.

La sortie du garde-fou montrant le patch rejeté : dbt build PASS, les lignes affectées correspondent maintenant à une catégorie FAIL, aucun autre segment n'a vu son compte changer FAIL, lignes détruites 87693, résultat REJETÉ pull request pas ouverte
Voilà à quoi ressemble un garde-fou de remédiation vérifié par exécution. Le premier patch du modèle compilait et faisait disparaître le symptôme. Il aurait aussi détruit 87 693 lignes. Le garde-fou est la raison pour laquelle la PR qui a effectivement atterri était sûre.

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.
Le graphe sait que le modèle a été entraîné sur les fournisseurs 1, 2 et 6. Le warehouse sait qu'on lui demande de noter le fournisseur 7. Aucun autre système ne détient les deux faits. Culprit lit à travers le propre serveur MCP de DataHub pour les rapprocher, chiffre l'écart en SQL contre 19,3 M de vrais enregistrements, exécute le correctif contre le vrai warehouse avant d'ouvrir la PR, et rejette son propre premier patch quand il aurait détruit 87 693 lignes. La détection n'a jamais été le difficile. L'attribution, oui.
Projet associé

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