Observabilité OTel — instrumenter un modèle PyMC avec OpenTelemetry
Pilier 3 des dépôts agent-friendly : context-enriched logging and telemetry. Ce notebook est le carnet Python de référence du dépôt pour l’instrumentation d’un modèle bayésien avec OpenTelemetry — le miroir exact de ce que la série Aspire/OTel (.NET) fait côté C#. Le modèle témoin est le modèle hiérarchique de PyMC-12 ; ici, on ne change pas le modèle, on rend son exécution observable du dehors : qui a tourné, combien de temps, avec quels réglages, dans quelle hiérarchie d’appels.
Pourquoi instrumenter un modèle probabiliste
Un notebook PyMC typique répond à « quel est le posterior ? » ; il laisse muettes les questions d’exécution : combien de temps a coûté la compilation par rapport à l’échantillonnage ? Quel target_accept a réellement servi ? Les diagnostics ArviZ disent si la chaîne a convergé, pas comment elle a tourné. En production — ou devant un agent qui doit relancer, comparer ou auditer des runs — cette couche manquante s’appelle la télémétrie.
Le standard ouvert de l’instrumentation est OpenTelemetry (OTel) : une trace décrit une exécution, découpée en spans hiérarchisés. Chaque span porte un nom, une durée, des attributs clé-valeur, et un lien vers son span parent. Le pilier « context-enriched logging and telemetry » des dépôts agent-friendly demande exactement cela : des sorties qu’un lecteur ou un agent peut relire sans exécuter le code.
Ce que ce carnet fait : un modèle hiérarchique à deux niveaux, instrumenté manuellement à trois étages — compilation (pymc.compile.build, pymc.compile.fast), échantillonnage (pymc.sample), contrôle postérieur (posterior.check) — avec export console (visible dans les sorties) et capture en mémoire (comparable programmatiquement).
Convention honnête sur les noms de spans : pymc.sample et pymc.compile.* sont des noms que notre wrapper pose autour des appels PyMC correspondants — ce ne sont pas des points d’API publics de PyMC. PyMC n’expose pas de télémétrie OTel native ; l’instrumentation manuelle est justement l’objet du cours.
# Imports et versions — l'environnement d'exécution fait partie de la preuveimport sysfrom importlib.metadata import version as pkg_versionimport numpy as npimport pymc as pmimport pytensorimport arviz as azimport opentelemetry.sdk # presence du SDK (l'API seule ne suffit pas a exporter)print("python ", sys.version.split()[0])print("pymc ", pm.__version__)print("pytensor ", pytensor.__version__)print("arviz ", az.__version__)print("opentelemetry-api", pkg_version("opentelemetry-api"))print("opentelemetry-sdk", pkg_version("opentelemetry-sdk"))print("numpy ", np.__version__)
Lecture. Les versions rendues ci-dessus sont celles qui ont produit les sorties de ce carnet — un modèle MCMC n’est interprétable qu’avec son environnement. Aucune clé d’API, aucun réseau : tout l’instrument exporte vers la console locale (et une zone mémoire), jamais vers un collecteur distant.
Le modèle témoin — hiérarchique à deux niveaux
On reprend le terrain de PyMC-12 : des classes de TP dont on mesure les scores d’élèves. Chaque classe a un effet propre, mais les effets partagent une moyenne et une dispersion de population — le partial pooling. Le modèle est écrit en paramétrisation non centrée (theta = mu + sigma_c * z), la recette anti-funnel de la série.
Le modèle est nommé (classes_hierarchiques) : ce nom se propage dans tous les artefacts de l’exécution — le log NUTS, le posterior (les variables y sont préfixées classes_hierarchiques::…) et nos attributs de spans. Nommer un modèle est le premier geste d’observabilité : il rend chaque artefact traçable à son modèle d’origine.
La hiérarchie à deux niveaux donne un modèle d’environ une dizaine de dimensions libres (mu, sigma_c, sigma_obs, huit offsets z_c) — assez pour que la compilation et l’échantillonnage aient des coûts visiblement distincts, assez simple pour tenir en quelques secondes.
classes : 8, eleves : 100
classe A : n=28, moyenne observee=4.294
classe B : n=22, moyenne observee=4.865
classe C : n=18, moyenne observee=3.517
classe D : n=12, moyenne observee=4.290
classe E : n= 8, moyenne observee=4.882
classe F : n= 5, moyenne observee=3.261
classe G : n= 4, moyenne observee=3.933
classe H : n= 3, moyenne observee=5.645
Lecture. Les effectifs vont de quelques élèves à des classes pleines : les moyennes observées des petites classes sont bruyantes, et c’est précisément ce que le partial pooling corrigera — l’interprétation postérieure viendra après l’instrumentation, au tableau récapitulatif.
# Modele hierarchique non centre (meme ecriture que PyMC-12)coords = {"classe": CLASSES}with pm.Model("classes_hierarchiques", coords=coords) as modele: mu = pm.Normal("mu", mu=3.0, sigma=2.0) # niveau population sigma_c = pm.HalfNormal("sigma_c", sigma=1.0) # dispersion inter-classes z_c = pm.Normal("z_c", dims="classe") # offsets standardises theta = pm.Deterministic("theta", mu + sigma_c * z_c, dims="classe") sigma_obs = pm.HalfNormal("sigma_obs", sigma=1.0) pm.Normal("score", mu=theta[classe_idx], sigma=sigma_obs, observed=scores)print(modele)print("variables libres :", modele.free_RVs)
Lecture.pm.Model construit un graphe symbolique — rien n’est encore compilé ni exécuté. Les deux étapes suivantes (compiler la densité, échantillonner) sont celles que nous allons rendre observables : c’est là que la durée d’une exécution se joue.
Mettre en place OpenTelemetry
Trois pièces suffisent pour une trace visible dans un notebook :
un TracerProvider — le propriétaire des traces ;
un exporteur — ici ConsoleSpanExporter, qui imprime chaque span terminé dans la sortie de la cellule ;
un processeur — ici SimpleSpanProcessor, qui exporte synchrone à la fin de chaque span (idéal en pédagogie : le span apparaît dans la cellule qui le termine ; en production on préférerait BatchSpanProcessor, asynchrone).
Le premier span ci-dessous (otel.smoke) sert de contrôle : si ses lignes JSON apparaissent dans la sortie, la chaîne provider → processeur → exporteur est fermée.
# Installation OTel locale : provider global + export console synchroneimport sys as _sysfrom opentelemetry import tracefrom opentelemetry.sdk.trace import TracerProviderfrom opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessorprovider = TracerProvider()provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter(out=_sys.stdout)))trace.set_tracer_provider(provider) # une seule fois par processustracer = trace.get_tracer("pymc.observabilite")with tracer.start_as_current_span("otel.smoke") as span: span.set_attribute("demo", "verification de la chaine d'export") span.set_attribute("runtime", "notebook")print("--- span otel.smoke termine : sa fiche JSON ci-dessus ferme la boucle ---")
Anatomie d’un span. Dans la fiche JSON rendue ci-dessus, repérer :
name — le nom du span, la clé de lecture d’une trace ;
context.trace_id / context.span_id — les identifiants (une même exécution partage son trace_id) ;
parent_span_id — le lien hiérarchique : 0 pour un span racine ;
start_time/end_time (nanosecondes) — la durée se calcule par différence ;
attributes — les faits du run (draws, chains, target_accept…) : c’est ici que la configuration devient relisible sans rouvrir le code.
Ces cinq champs sont tout ce qu’un agent ou unops relit pour reconstituer une exécution.
Instrumenter la compilation
Avant d’échantillonner, PyMC compile le graphe symbolique en fonctions numériques (via PyTensor). Nous posons deux spans wrappers autour des appels réels :
pymc.compile.build — la construction de la densité non normalisée (compile_logp) : traduction du graphe, là où le coût de démarrage se paie ;
pymc.compile.fast — la compilation rapide du gradient (compile_dlogp, mode fast) : la voie qu’emprunte NUTS à chaque pas.
Les attributs posés (model.name, nombre de variables, mode) décrivent ce qui a été compilé — la durée du span dit ce que ça a coûté.
# Compilation instrumentee : deux spans wrappers autour des appels PyTensor de PyMCwith tracer.start_as_current_span("pymc.compile.build") as s_build: s_build.set_attribute("model.name", modele.name) s_build.set_attribute("model.free_vars", len(modele.free_RVs)) logp_fn = modele.compile_logp() s_build.set_attribute("compile.target", "logp")with tracer.start_as_current_span("pymc.compile.fast") as s_fast: s_fast.set_attribute("model.name", modele.name) dlogp_fn = modele.compile_dlogp() s_fast.set_attribute("compile.mode", "default-gradient")# controle sanitaire : la densite en un point du prior doit etre finiept = modele.initial_point()print("logp(initial point) =", float(logp_fn(pt)))print("deux spans pymc.compile.* termines -- durees dans leurs fiches ci-dessus")
Lecture. Sur un modèle de cette taille, la compilation se compte en secondes et l’échantillonnage aussi — mais les proportions sont la leçon : sur des runs courts, le coût fixe de compilation pèse autant que l’échantillonnage lui-même. Une trace répond à cette question sans chronomètre manuel, et surtout sans modifier le modèle.
Instrumenter l’échantillonnage
Le span pymc.sample enveloppe l’appel pm.sample et porte toute la configuration du run en attributs : draws, tune, chains, target_accept, random_seed. Un span fils posterior.check enregistre les verdicts ArviZ (R-hat max, ESS bulk min, divergences) — la hiérarchie parent/enfant dit explicitement : ce diagnostic appartient à ce run.
La barre de progression est coupée (progressbar=False) pour que la sortie de la cellule reste lisible : la trace console est ici l’objet d’étude.
# Echantillonnage instrumente : span parent pymc.sample + span fils posterior.checkDRAWS, TUNE, CHAINS, TARGET_ACCEPT, SEED =500, 500, 2, 0.9, 20261001with tracer.start_as_current_span("pymc.sample") as s_sample: s_sample.set_attribute("model.name", modele.name) s_sample.set_attribute("draws", DRAWS) s_sample.set_attribute("tune", TUNE) s_sample.set_attribute("chains", CHAINS) s_sample.set_attribute("target_accept", TARGET_ACCEPT) s_sample.set_attribute("random_seed", SEED) idata = pm.sample( draws=DRAWS, tune=TUNE, chains=CHAINS, target_accept=TARGET_ACCEPT, random_seed=SEED, progressbar=False, model=modele, )with tracer.start_as_current_span("posterior.check") as s_check:# un modele PYMC NOMME prefixe ses variables dans le posterior ("classes_hierarchiques::mu")# -> on lit la liste reelle depuis l'artefat, jamais depuis une enumeration a la main vars_resume = [v for v in idata.posterior.data_vars ifnot v.endswith("::z_c")] sum_df = az.summary(idata, var_names=vars_resume) s_check.set_attribute("r_hat.max", float(sum_df["r_hat"].max())) s_check.set_attribute("ess_bulk.min", float(sum_df["ess_bulk"].min())) s_check.set_attribute("divergences", int(idata.sample_stats["diverging"].sum())) s_check.set_attribute("verdict", "converge"iffloat(sum_df["r_hat"].max()) <1.05else"a examiner")print("--- spans pymc.sample et posterior.check termines ---")
Lecture de la trace. Dans la sortie ci-dessus, deux fiches se suivent : posterior.check (le fils, terminé en premier), puis son parent pymc.sample. Vérifier sur les fiches que le trace_id est le même pour toute l’exécution, et que parent_span_id du contrôle pointe vers le span_id de l’échantillonnage : c’est la hiérarchie qui transforme une liste de durées en récit d’exécution.
# Le posterior, pour l'interpretation statistique (le modele n'a pas change)import pandas as pdtab = az.summary(idata, var_names=vars_resume)with pd.option_context("display.max_rows", None): display(tab)
mean
sd
eti89_lb
eti89_ub
ess_bulk
ess_tail
r_hat
mcse_mean
mcse_sd
classes_hierarchiques::mu
4.31
0.27
3.9
4.8
259
405
1.00
0.017
0.012
classes_hierarchiques::sigma_c
0.74
0.26
0.41
1.2
287
466
1.00
0.016
0.016
classes_hierarchiques::sigma_obs
0.82
0.062
0.73
0.93
747
685
1.00
0.0023
0.0017
classes_hierarchiques::theta[A]
4.291
0.148
4
4.5
1241
806
1.00
0.0042
0.003
classes_hierarchiques::theta[B]
4.83
0.173
4.5
5.1
1174
910
1.00
0.005
0.0036
classes_hierarchiques::theta[C]
3.59
0.189
3.3
3.9
1211
765
1.00
0.0054
0.0038
classes_hierarchiques::theta[D]
4.29
0.225
3.9
4.7
1275
857
1.01
0.0063
0.0044
classes_hierarchiques::theta[E]
4.78
0.28
4.3
5.2
1315
930
1.00
0.0076
0.0053
classes_hierarchiques::theta[F]
3.51
0.34
3
4.1
1220
759
1.00
0.0098
0.007
classes_hierarchiques::theta[G]
4.03
0.37
3.4
4.6
1306
646
1.00
0.01
0.0075
classes_hierarchiques::theta[H]
5.21
0.45
4.5
5.9
892
659
1.00
0.015
0.011
Lecture statistique — indépendante de l’instrumentation, elle confirme le modèle témoin : theta des petites classes (F, G, H) s’est rétracté vers mu (shrinkage du partial pooling), les classes pleines conservent leurs moyennes observées. Le point du carnet : cette lecture statistique et la lecture d’exécution de la trace sont deux colonnes différentes du même run — ArviZ interprète la chaîne, OTel décrit le run.
La trace comme donnée — capture en mémoire
L’export console est pédagogique mais verbeux : pour comparer des runs, il faut la trace sous forme manipulable. OTel fournit InMemorySpanExporter : on monte un second provider — local, non global — qui accumule les spans terminés dans une liste Python. On relance un échantillonnage court dans ce bac, puis on dresse le tableau des spans (nom, durée en millisecondes, attributs).
# Second provider LOCAL (hors global) : capture en memoire pour comparaison programmatiquefrom opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExportermem_provider = TracerProvider()mem_exporter = InMemorySpanExporter()mem_provider.add_span_processor(SimpleSpanProcessor(mem_exporter))tracer_mem = mem_provider.get_tracer("pymc.observabilite.mem")with tracer_mem.start_as_current_span("pymc.sample") as s: s.set_attribute("model.name", modele.name) s.set_attribute("draws", 200) s.set_attribute("tune", 200) s.set_attribute("chains", 2) s.set_attribute("target_accept", 0.9) idata_court = pm.sample( draws=200, tune=200, chains=2, target_accept=0.9, random_seed=SEED, progressbar=False, model=modele, )spans = mem_exporter.get_finished_spans()rows = [{"span": sp.name,"duree_ms": round((sp.end_time - sp.start_time) /1e6, 1),**{k: v for k, v in sp.attributes.items()},} for sp in spans]display(pd.DataFrame(rows))
span
duree_ms
model.name
draws
tune
chains
target_accept
0
pymc.sample
3791.8
classes_hierarchiques
200
200
2
0.9
Lecture. Le tableau ci-dessus est le produit final de l’instrumentation : une exécution résumée en lignes comparables. Brancher un exporteur OTLP au lieu de l’exporteur mémoire — sans toucher au code instrumenté — enverrait les mêmes spans vers un collecteur (Jaeger, Tempo…) : c’est toute la portée du standard.
Exercice 1 — Instrumenter la génération des données
La partie amont du pipeline (générer les scores) n’est pas instrumentée. Compléter generer_donnees_instrumentee pour qu’elle produise les mêmes données que la cellule témoin sous un span data.generate portant les attributs n_classes, n_eleves et seed.
Ce que l’exercice fait mesurer — le réflexe de base : toute étape qui produit un artefact (ici, le dataset) mérite son span, sinon la première ligne du récit d’exécution manque.
Indices : reprendre la cellule de génération telle quelle et l’envelopper d’un with tracer.start_as_current_span("data.generate") as span: ; span.set_attribute("n_eleves", int(scores.shape[0])) ; le span doit se terminer après la génération complète.
# Exercice 1 -- a completer : generation de donnees sous span data.generatedef generer_donnees_instrumentee(seed, tracer_ref):# Retourne (classe_idx, scores) generes avec ce seed, sous un span data.generate# portant les attributs n_classes, n_eleves et seed.# TODO etudiant : recopier le motif de la cellule de generation, enveloppe# dans un with tracer_ref.start_as_current_span("data.generate") as span:# Etape 1 : rng = np.random.default_rng(seed) ; tirer effets puis scores.# Etape 2 : span.set_attribute("n_classes", ...), ("n_eleves", ...), ("seed", seed).returnNone# TODO etudiant : retourner (classe_idx, scores)# resultat = generer_donnees_instrumentee(20261001, tracer) # decommenter une fois completeprint("Exercice 1 a completer : generation de donnees sous span data.generate.")
Exercice 1 a completer : generation de donnees sous span data.generate.
Exercice 2 — Budget d’incertitude : draws contre la durée
Re-échantillonner le modèle avec draws et tune divisés (par exemple deux cents au lieu de cinq cents, mêmes chains et target_accept), sous un span pymc.sample aux attributs à jour, suivi du contrôle posterior.check.
Ce que l’exercice fait mesurer — la question de budget la plus courante : la durée du run tombe-t-elle linéairement avec draws, et que paie-t-on en diagnostic (r_hat.max monte-t-il, ess_bulk.min descend-il) ? Répondre par les attributs des spans, pas au chronomètre.
Indices : s’inspirer de la cellule d’échantillonnage instrumenté ; comparer les attributs duree du span et r_hat.max du contrôle entre les deux configurations ; une phrase de verdict suffit.
# Exercice 2 -- a completer : run court instrumente, comparaison des attributsdef run_court_instrumente(draws, tune, tracer_ref):# Echantillonne le modele sous span pymc.sample (attributs a jour) + posterior.check.# Retourne (duree_ms_du_span, r_hat_max, ess_bulk_min) lus depuis les spans.# TODO etudiant : reutiliser un provider a exporteur memoire local, lancer# pm.sample(draws=draws, tune=tune, chains=2, target_accept=0.9, random_seed=SEED),# poser les attributs du span, puis lire get_finished_spans() pour extraire# duree (end_time - start_time) et attributs du controle.returnNone# TODO etudiant : retourner le triplet# comparaison = run_court_instrumente(200, 200, tracer_mem) # decommenter ensuiteprint("Exercice 2 a completer : budget draws contre duree, lu dans les spans.")
Exercice 2 a completer : budget draws contre duree, lu dans les spans.
Exercice 3 — Modèle aplati : ce que la hiérarchie change… à la trace
Construire le modèle pooled (un theta unique partagé par toutes les classes — plus de z_c ni sigma_c), l’instrumenter comme le témoin (spans pymc.compile.build, pymc.compile.fast, pymc.sample, posterior.check) et comparer les traces des deux modèles.
Ce que l’exercice fait mesurer — la structure d’un modèle se lit aussi dans sa télémétrie : moins de variables libres (attribut model.free_vars), compilation plus courte, convergence plus facile (r_hat.max)… et un posterior qui a perdu le shrinkage. La trace ne remplace pas l’analyse statistique ; elle l’accompagne.
Indices : écrire le modèle pooled dans un nouveau pm.Model ; conserver les mêmes données et le même random_seed ; comparer les tableaux de spans côte à côte (l’exporteur mémoire retourne une liste triable).
# Exercice 3 -- a completer : modele pooled instrumente, comparaison des tracesdef run_pooled_instrumente(tracer_ref):# Construit le modele pooled, l'echantillonne sous spans instruments.# Retourne la liste des spans termines (exporteur memoire local a creer).# TODO etudiant : nouveau pm.Model avec theta unique (pas de z_c/sigma_c),# meme vraisemblance observed=scores ; spans compile + sample + check ;# attributs model.free_vars et memes cles que le temoin pour la comparaison.returnNone# TODO etudiant : retourner les spans# spans_pooled = run_pooled_instrumente(tracer_mem) # decommenter ensuiteprint("Exercice 3 a completer : modele pooled instrumente, traces comparees.")
Exercice 3 a completer : modele pooled instrumente, traces comparees.
Limites et anti-claims
Console exporter = pédagogie. En production, ConsoleSpanExporter n’est pas une destination : on branche OTLPSpanExporter vers un collecteur — le code instrumenté ne change pas d’une ligne, c’est l’intérêt du standard.
Jamais un span par draw. Instrumenter l’intérieur de la boucle MCMC produirait des millions de spans ; la granularité pertinente est l’appel (sample, compile), éventuellement le diagnostic.
Wrappers, pas de télémétrie native. PyMC n’expose pas de spans OTel publics : les noms pymc.sample / pymc.compile.* sont notre convention documentée, posée autour des appels réels. Ne pas les présenter comme des points d’API PyMC.
La trace ne juge pas le modèle. R-hat et ESS restent dans posterior.check en attributs : la décision statistique appartient à ArviZ et au modélisateur ; OTel décrit l’exécution, il ne remplace pas le diagnostic.
Rejouabilité.random_seed fixé : les durées varient d’une machine à l’autre, les quantités statistiques non.