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 preuve
import sys
from importlib.metadata import version as pkg_version

import numpy as np
import pymc as pm
import pytensor
import arviz as az
import 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__)
python           3.13.3
pymc             6.0.1
pytensor         3.0.3
arviz            1.1.0
opentelemetry-api 1.44.0
opentelemetry-sdk 1.33.1
numpy            2.3.5

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.

# Donnees synthetiques : 8 classes, effectifs tres inegaux (le shrinkage aura de quoi mordre)
rng = np.random.default_rng(20261001)

CLASSES = ["A", "B", "C", "D", "E", "F", "G", "H"]
effectifs = np.array([28, 22, 18, 12, 8, 5, 4, 3])
mu_vrai, sigma_c_vrai, sigma_obs_vrai = 4.0, 1.0, 0.8

effets_vrais = rng.normal(mu_vrai, sigma_c_vrai, size=len(CLASSES))
scores_par_classe = [
    rng.normal(effets_vrais[k], sigma_obs_vrai, size=effectifs[k]) for k in range(len(CLASSES))
]
classe_idx = np.repeat(np.arange(len(CLASSES)), effectifs)
scores = np.concatenate(scores_par_classe)

print(f"classes : {len(CLASSES)}, eleves : {len(scores)}")
for k, c in enumerate(CLASSES):
    print(f"  classe {c} : n={effectifs[k]:>2}, moyenne observee={scores_par_classe[k].mean():.3f}")
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)
<pymc.model.core.Model object at 0x000002990C72D6A0>
variables libres : [classes_hierarchiques::mu, classes_hierarchiques::sigma_c, classes_hierarchiques::z_c, classes_hierarchiques::sigma_obs]

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 synchrone
import sys as _sys
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor

provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter(out=_sys.stdout)))
trace.set_tracer_provider(provider)  # une seule fois par processus

tracer = 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 ---")
{
    "name": "otel.smoke",
    "context": {
        "trace_id": "0x2e4ba8fc287ef9fa72ca4dde8efde0f7",
        "span_id": "0x18683c24f44b9971",
        "trace_state": "[]"
    },
    "kind": "SpanKind.INTERNAL",
    "parent_id": null,
    "start_time": "2026-10-01T14:40:01.926028Z",
    "end_time": "2026-10-01T14:40:01.926048Z",
    "status": {
        "status_code": "UNSET"
    },
    "attributes": {
        "demo": "verification de la chaine d'export",
        "runtime": "notebook"
    },
    "events": [],
    "links": [],
    "resource": {
        "attributes": {
            "telemetry.sdk.language": "python",
            "telemetry.sdk.name": "opentelemetry",
            "telemetry.sdk.version": "1.33.1",
            "service.name": "unknown_service"
        },
        "schema_url": ""
    }
}
--- 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 PyMC
with 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 finie
pt = modele.initial_point()
print("logp(initial point) =", float(logp_fn(pt)))
print("deux spans pymc.compile.* termines -- durees dans leurs fiches ci-dessus")
{
    "name": "pymc.compile.build",
    "context": {
        "trace_id": "0x4b4a2b425fc01aabd399bf5afcebe666",
        "span_id": "0x69d9d86981658c0d",
        "trace_state": "[]"
    },
    "kind": "SpanKind.INTERNAL",
    "parent_id": null,
    "start_time": "2026-10-01T14:40:01.950787Z",
    "end_time": "2026-10-01T14:40:03.025014Z",
    "status": {
        "status_code": "UNSET"
    },
    "attributes": {
        "model.name": "classes_hierarchiques",
        "model.free_vars": 4,
        "compile.target": "logp"
    },
    "events": [],
    "links": [],
    "resource": {
        "attributes": {
            "telemetry.sdk.language": "python",
            "telemetry.sdk.name": "opentelemetry",
            "telemetry.sdk.version": "1.33.1",
            "service.name": "unknown_service"
        },
        "schema_url": ""
    }
}
{
    "name": "pymc.compile.fast",
    "context": {
        "trace_id": "0xed8e71949b3bc28b0883fd54b6f5a2bf",
        "span_id": "0x0c272d73033ac0d6",
        "trace_state": "[]"
    },
    "kind": "SpanKind.INTERNAL",
    "parent_id": null,
    "start_time": "2026-10-01T14:40:03.025750Z",
    "end_time": "2026-10-01T14:40:03.484562Z",
    "status": {
        "status_code": "UNSET"
    },
    "attributes": {
        "model.name": "classes_hierarchiques",
        "compile.mode": "default-gradient"
    },
    "events": [],
    "links": [],
    "resource": {
        "attributes": {
            "telemetry.sdk.language": "python",
            "telemetry.sdk.name": "opentelemetry",
            "telemetry.sdk.version": "1.33.1",
            "service.name": "unknown_service"
        },
        "schema_url": ""
    }
}
logp(initial point) = -232.95740837051278
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.check
DRAWS, TUNE, CHAINS, TARGET_ACCEPT, SEED = 500, 500, 2, 0.9, 20261001

with 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 if not 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" if float(sum_df["r_hat"].max()) < 1.05 else "a examiner")

print("--- spans pymc.sample et posterior.check termines ---")
{
    "name": "posterior.check",
    "context": {
        "trace_id": "0x9337d5f18e4edc7c2cd8120ee7e57313",
        "span_id": "0xea906117c8dfd442",
        "trace_state": "[]"
    },
    "kind": "SpanKind.INTERNAL",
    "parent_id": "0x77c448431dd54df2",
    "start_time": "2026-10-01T14:40:08.130069Z",
    "end_time": "2026-10-01T14:40:08.185297Z",
    "status": {
        "status_code": "UNSET"
    },
    "attributes": {
        "r_hat.max": 1.01,
        "ess_bulk.min": 259.0,
        "divergences": 0,
        "verdict": "converge"
    },
    "events": [],
    "links": [],
    "resource": {
        "attributes": {
            "telemetry.sdk.language": "python",
            "telemetry.sdk.name": "opentelemetry",
            "telemetry.sdk.version": "1.33.1",
            "service.name": "unknown_service"
        },
        "schema_url": ""
    }
}
{
    "name": "pymc.sample",
    "context": {
        "trace_id": "0x9337d5f18e4edc7c2cd8120ee7e57313",
        "span_id": "0x77c448431dd54df2",
        "trace_state": "[]"
    },
    "kind": "SpanKind.INTERNAL",
    "parent_id": null,
    "start_time": "2026-10-01T14:40:03.754011Z",
    "end_time": "2026-10-01T14:40:08.186040Z",
    "status": {
        "status_code": "UNSET"
    },
    "attributes": {
        "model.name": "classes_hierarchiques",
        "draws": 500,
        "tune": 500,
        "chains": 2,
        "target_accept": 0.9,
        "random_seed": 20261001
    },
    "events": [],
    "links": [],
    "resource": {
        "attributes": {
            "telemetry.sdk.language": "python",
            "telemetry.sdk.name": "opentelemetry",
            "telemetry.sdk.version": "1.33.1",
            "service.name": "unknown_service"
        },
        "schema_url": ""
    }
}
--- 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 pd

tab = 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 programmatique
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter

mem_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.generate
def 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).
    return None  # TODO etudiant : retourner (classe_idx, scores)

# resultat = generer_donnees_instrumentee(20261001, tracer)  # decommenter une fois complete
print("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 attributs
def 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.
    return None  # TODO etudiant : retourner le triplet

# comparaison = run_court_instrumente(200, 200, tracer_mem)  # decommenter ensuite
print("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 traces
def 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.
    return None  # TODO etudiant : retourner les spans

# spans_pooled = run_pooled_instrumente(tracer_mem)  # decommenter ensuite
print("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.

Références

Retour : Série PyMC | PyMC-12 — Modèles Hiérarchiques

Retour au sommet