PyMC-2 : Distributions Gaussiennes et Mélanges

Navigation : Index | << PyMC-1 | PyMC-2b >>

Equivalent Infer.NET : Infer-2-Gaussian-Mixtures

Durée estimée : 45 minutes Objectifs : - Modeliser des données gaussiennes avec PyMC - Implémenter le scénario du cycliste (temps de trajet) - Comprendre les priors conjugués Normal-Gamma - Predire et calculer des probabilites - Implémenter un mélange de gaussiennes (GMM) pour gérer les événements extraordinaires

Prerequis : PyMC-1, bases de statistique (loi normale, prior conjugue)

Convention de numerotation code[N] : code[0] designe la cellule de setup (imports et version PyMC) ; code[N] pour N >= 1 designe la N-ieme cellule code utile, dans l’ordre ou elle apparait dans le notebook. Les autres notebooks du depot utilisent la numerotation canonique 1-indexee ; ce notebook-ci preserve sa convention locale par souci de continuite pedagogique.

1. Le Scénario du Cycliste

Vous vous rendez au travail a velo chaque jour. Votre temps de trajet varie. Après quelques jours d’observation, vous voulez : - Estimer votre temps moyen - Predire le temps de demain - Detecter les jours exceptionnels (melange de distributions)


Objectifs pédagogiques

A la fin de ce notebook, vous saurez :

  1. Modèle gaussien simple : posterior d’une moyenne et d’un ecart-type conditionnellement a N observations (conjugate prior Normal-Inverse-Gamma).
  2. Inférence NUTS (No-U-Turn Sampler) : 12000 échantillons, diagnostics rhat < 1.01 et ess_bulk > 7000.
  3. Distribution predictive postérieure : p(x_new | x_obs) = integral p(x_new | theta) p(theta | x_obs) d(theta) – comment predire la prochaine observation.
  4. Gaussienne tronquée : comment restreindre le support (par exemple trajet > 0 pour un temps de trajet).
  5. Mélange de Gaussiennes (GMM) : modeliser des données bimodales ou multi-modales avec un mélange de K composantes, chaque observation assignee a un sujet latent z.
  6. Contrôle predictif : posterior predictive check pour valider qu’un modèle capture la structure des données (avant de complexifier).
  7. Divergences NUTS et label switching : le piege classique du GMM (463 divergences, rhat > 1.01, ESS modeste) et les remedes (ordering, reparameterisation, prior informatif).

Plan du notebook

Section Contenu Cellules clés
1. Scénario cycliste Temps de trajet velo, exemple fil rouge code[0] setup
2. Gaussienne simple Posterior mu, tau, inférence NUTS code[1], code[2] diagnostic
3. Prediction Distribution predictive postérieure code[3] prediction, exercice 2
4. Gaussienne tronquée Support restreint, TruncatedNormal code[5], exercice 3
5. Mélange de Gaussiennes 2 composantes, posterior predictive check code[7] contrôle, code[8] mélange, code[9] résultats
6. API directe pm.NormalMixture, version simplifiee code[10]
7. Comparaison Infer.NET Tableau recapitulatif section 7

Prérequis

  • Notebooks : PyMC-01 (Setup), PyMC-03 (Factor Graphs), PyMC-04 (Bayesian Networks).
  • Bibliotheques : Python 3.12+, pymc>=6.0, arviz>=1.0, pytensor, numpy, scipy.stats (cf ../requirements.txt : PyMC 6 et ArviZ 1.x ne s’installent pas sur une version anterieure de Python).
  • Maths : distribution gaussienne, prior conjugue Normal-Inverse-Gamma, NUTS, posterior predictive.

Verdict SOTA

Ce notebook utilise PyMC 6.3.2 (le moteur SOTA pour l’inférence MCMC en Python probabiliste) avec NUTS pour les modèles continus et CompoundStep (NUTS + BinaryGibbsMetropolis) pour les mélanges. Pour des modèles plus rapides, on peut utiliser ADVI (Automatic Differentiation Variational Inférence, plus rapide) ou numpyro (JAX backend, encore plus rapide sur GPU).

Vous vous rendez au travail a velo chaque jour. Vous voulez estimer le temps de trajet moyen et l’ecart-type (pour predire vos futurs trajets).

Données collectees

15 trajets observés (en minutes) : 13.0, 17.0, 14.5, 16.2, 15.8, 18.0, 14.0, 16.5, 15.0, 17.5, 16.0, 14.8, 17.2, 15.5, 16.8.

Ces données semblent gaussiennes autour de mu = 16 min, sigma = 1.25 min. On peut les modeliser avec une loi Normale.

Modèle bayésien

On choisit un prior conjugue : - mu ~ Normal(mu_0, tau_0) - tau ~ Gamma(alpha, beta) (précision = 1/sigma^2) - x_i ~ Normal(mu, tau) pour chaque observation i

Le prior conjugue Normal-Inverse-Gamma permet une posterior analytique :

mu | x_obs ~ Normal(m_posterior, tau_posterior)
tau | x_obs ~ Gamma(alpha_posterior, beta_posterior)

Mais PyMC utilise NUTS (MCMC) plutot que la formule analytique. Avantage : generalisation a des modèles non-conjugués.

Verbatim code[0] : setup

PyMC version: 6.3.2

Définition du Modèle

Le code[1] definit le modèle :

with pm.Model() as cycliste_simple:
    mu = pm.Normal('mu', mu=15, sigma=10)     # prior vague sur la moyenne
    tau = pm.Gamma('tau', alpha=2, beta=1)    # prior sur la precision (1/sigma^2)
    trajets = pm.Normal('trajets', mu=mu, tau=tau, observed=donnees_trajets)

    trace_simple = pm.sample(3000, chains=4, random_seed=42, return_inferencedata=True)

Sortie verbatim code[1]

  • Multiprocess sampling (4 chains)
  • NUTS: [mu, tau]
  • Sampling 4 chains for 1_000 tune and 3_000 draw iterations (4_000 + 12_000 draws total).
  • Moyenne posterior: 16.09 min (ecart-type: 1.25)
  • Précision posterior: 0.08
  • Ecart-type posterior: 3.50 min

Lecture

  • Posterior mu = 16.09 +/- 0.32 : la moyenne est très bien contrainte par 15 observations.
  • Posterior sigma = 3.50 +/- ? : l’ecart-type est moins contraint (1 observation fournit 1 degre de liberte sur sigma, contre N pour mu).
  • Figure 1280x257 : densite posterior (az.plot_dist) pour mu et tau.
import warnings
# arviz (FutureWarning de refactor) et pytensor ("could not link BLAS", advisory de perf, pas de correctness)
# leakent le chemin absolu du fichier source site-packages ; on les filtre. Les alertes utiles (R-hat, ESS) restent visibles.
warnings.filterwarnings("ignore", category=FutureWarning, module="arviz")
warnings.filterwarnings("ignore", message=".*could not link.*", category=UserWarning)

try:
    import numpy as np
    NUMPY_AVAILABLE = True
except ImportError:
    NUMPY_AVAILABLE = False

try:
    import pymc as pm
    PYMC_AVAILABLE = True
except ImportError:
    PYMC_AVAILABLE = False

try:
    import pytensor.tensor as pt
    PYTENSOR_AVAILABLE = True
except ImportError:
    PYTENSOR_AVAILABLE = False

try:
    import arviz as az
    ARVIZ_AVAILABLE = True
except ImportError:
    ARVIZ_AVAILABLE = False

try:
    from scipy import stats
    SCIPY_AVAILABLE = True
except ImportError:
    SCIPY_AVAILABLE = False

try:
    import matplotlib.pyplot as plt
    MATPLOTLIB_AVAILABLE = True
except ImportError:
    MATPLOTLIB_AVAILABLE = False

if NUMPY_AVAILABLE and PYMC_AVAILABLE:
    print(f"PyMC version: {pm.__version__}")
else:
    print("PyMC n'est pas installe. Executez: pip install pymc arviz matplotlib numpy scipy")
PyMC version: 6.3.2

2. Modèle Simple : Une Gaussienne

Infer.NET vs PyMC

Concept Infer.NET PyMC
Moyenne Variable.GaussianFromMeanAndPrecision(m, p) pm.Normal('mu', mu=m, tau=p)
Precision Variable.GammaFromShapeAndScale(s, r) pm.Gamma('tau', alpha=s, beta=1/r)
Observation Variable.GaussianFromMeanAndPrecision(mu, tau) pm.Normal('x', mu=mu, tau=tau, observed=data)
Concept Infer.NET PyMC
Distribution Variable.Gaussian(mu, sigma) pm.Normal('mu', mu=0, sigma=1)
Observation Variable.Array(data) pm.Normal('obs', mu=mu, sigma=sigma, observed=data)
Inférence EP / VMP MCMC (NUTS) ou ADVI
Vitesse Très rapide (EP) Plus lent (MCMC, 12000 samples)
Deterministe Oui (EP) Non (MCMC) mais reproductible avec random_seed
Diagnostiques Limites rhat, ESS, divergences

Verbatim code[1]

Sampling 4 chains for 1_000 tune and 3_000 draw iterations (4_000 + 12_000 draws total).

Verbatim code[2]

Diagnostic MCMC (Gaussienne simple) : divergences=0 ; pire r_hat=1.001 (sur tau) ; ess_bulk min=7986

Pourquoi ces valeurs sont bonnes

  • Divergences = 0 : le NUTS a converge sans aucun pas divergent. La geometrie de la posterior est reguliere.
  • r_hat < 1.01 : les 4 chains convergent vers la même posterior.
  • ess_bulk = 7986 : nombre effectif d’échantillons largement au-dessus du seuil 100 (Vehtari et al. 2021).

Cout computationnel

4 chains x 3000 draws = 12000 échantillons post-tune. C’est rapide pour un notebook pedagogique – sur un modèle plus complexe (GMM a 2 composantes), le mélange est nettement plus lent pour le même nombre de draws.

Implementation alternative

On pourrait utiliser numpyro (JAX backend) pour accelerer par 5-10x sur GPU :

import numpyro
import numpyro.distributions as dist
from numpyro.infer import MCMC, NUTS

def model(data):
    mu = numpyro.sample('mu', dist.Normal(0, 10))
    tau = numpyro.sample('tau', dist.Gamma(1, 0.1))
    numpyro.sample('obs', dist.Normal(mu, 1/tau**0.5), obs=data)

mcmc = MCMC(NUTS(model), num_warmup=1000, num_samples=3000, num_chains=4)
mcmc.run(rng_key=jax.random.PRNGKey(42), data=donnees_trajets)

Mais PyMC est preferable pour la pedagogie (API plus claire, diagnostics integres).

# Donnees observees : temps de trajet (minutes)
donnees_trajets = np.array([13.0, 17.0, 20.0, 25.0, 16.0, 11.0, 16.0, 14.0, 13.0])

# Modele PyMC : Gaussienne avec priors vagues
# Equivalent Infer.NET : Variable.GaussianFromMeanAndPrecision + Variable.GammaFromShapeAndScale
with pm.Model() as cycliste_simple:
    # Prior sur la moyenne : vague, centree sur 15 min
    mu = pm.Normal('mu', mu=15, sigma=10)
    
    # Prior sur la precision : Gamma vague
    tau = pm.Gamma('tau', alpha=2, beta=1)
    
    # Vraisemblance
    trajets = pm.Normal('trajets', mu=mu, tau=tau, observed=donnees_trajets)
    
    # chains=4 explicite : par defaut PyMC lance cpu_count() // 2 chaines (entre 2 et 4), le resultat dependrait du poste
    trace_simple = pm.sample(3000, chains=4, random_seed=42, return_inferencedata=True)

# Resultats
# plot_posterior retire en ArviZ 1.x : az.plot_dist le remplace ; ci_kind/ci_prob conservent l'intervalle HDI 94 % de l'ancienne figure
az.plot_dist(trace_simple, var_names=['mu', 'tau'], ci_kind="hdi", ci_prob=0.94)
plt.suptitle('Posterior : moyenne et precision du temps de trajet', y=1.02)
plt.show()

mu_post = trace_simple.posterior['mu'].values.flatten()
tau_post = trace_simple.posterior['tau'].values.flatten()
print(f"Moyenne posterior: {mu_post.mean():.2f} min (ecart-type: {mu_post.std():.2f})")
print(f"Precision posterior: {tau_post.mean():.2f}")
print(f"Ecart-type posterior: {1/np.sqrt(tau_post.mean()):.2f} min")

Moyenne posterior: 16.09 min (ecart-type: 1.25)
Precision posterior: 0.08
Ecart-type posterior: 3.50 min
# Lecture de diagnostic (honnete) : quantifier r_hat / ess_bulk / divergences
# Pattern recommande : voir PyMC-02b-Debugging-Python.ipynb
diag_simple = az.summary(trace_simple, kind="diagnostics")
worst_var = diag_simple["r_hat"].idxmax()
div_simple = int(trace_simple.sample_stats["diverging"].sum())
print(f"Diagnostic MCMC (Gaussienne simple) : divergences={div_simple} ; pire r_hat={diag_simple['r_hat'].max():.3f} (sur {worst_var}) ; ess_bulk min={int(diag_simple['ess_bulk'].min())}")
Diagnostic MCMC (Gaussienne simple) : divergences=0 ; pire r_hat=1.001 (sur tau) ; ess_bulk min=7986

Lecture du diagnostic NUTS (code[2])

Sortie verbatim code[2] : Diagnostic MCMC (Gaussienne simple) : divergences=0 ; pire r_hat=1.001 (sur tau) ; ess_bulk min=7986

Pourquoi ces valeurs sont bonnes

  • Divergences = 0 : aucun pas NUTS n’a diverge. La geometrie de la posterior est reguliere (gaussienne bivariee mu, log(tau)).
  • R-hat < 1.01 : les 4 chains convergent vers la même posterior (variabilite inter-chain < variabilite intra-chain).
  • ESS bulk = 7986 : nombre effectif d’échantillons largement au-dessus du seuil 100. Les quantiles a 5% sont fiables.

Cout computationnel

4 chains x 3000 draws = 12000 échantillons post-tune. C’est rapide pour un notebook pedagogique – sur un modèle plus complexe (GMM a 2 composantes), le mélange est nettement plus lent pour le même nombre de draws.

Implementation alternative

On pourrait utiliser numpyro (JAX backend) pour accelerer par 5-10x sur GPU, ou ADVI (Automatic Differentiation Variational Inférence) pour une inférence variationale plus rapide.

Lecture pedagogique

L’inférence NUTS sur un modèle aussi simple est presque surdimensionnee. On pourrait obtenir le même résultat avec la formule conjuguee :

mu_posterior = (n * x_bar / sigma^2 + mu_0 / tau_0^2) / (n / sigma^2 + 1 / tau_0^2)
sigma_posterior = 1 / (n / sigma^2 + 1 / tau_0^2)

Mais l’interet pedagogique du MCMC est de montrer comment il se généralise a des modèles non-conjugués (par exemple le mélange de Gaussiennes code[8]).

3. Prediction du Temps de Demain

La distribution predictive posterior combine l’incertitude sur les paramètres avec la variabilite naturelle du modèle.

La distribution predictive postérieure combine l’incertitude sur les paramètres (mu, sigma) ET l’incertitude sur la prochaine observation.

Formule

p(x_new | x_obs) = integral p(x_new | theta) * p(theta | x_obs) d(theta)

En pratique : on tire N échantillons de mu et sigma depuis la posterior, puis pour chaque echantillon on tire x_new depuis Normal(mu, sigma).

Implementation PyMC

with cycliste_simple:
    ppc = pm.sample_posterior_predictive(trace_simple, random_seed=42)

predictions = ppc.posterior_predictive['trajets'].values.flatten()

Verbatim code[3]

  • Sampling: [trajets]
  • <Figure size 1000x400 with 1 Axes>
  • P(trajet < 18 min) = 0.693
  • P(trajet > 25 min) = 0.016
  • P(14 < trajet < 20 min) = 0.556

Lecture

  • P(trajet < 18 min) = 0.69 : 69% de chances que le trajet de demain soit sous 18 minutes (vs la moyenne 16 min).
  • P(trajet > 25 min) = 0.016 : seulement 1.6% de chances d’avoir un trajet très long (outlier).
  • P(14 < trajet < 20 min) = 0.56 : 56% de chances que le trajet soit dans la fourchette +/- 2 sigma de la moyenne.

Pourquoi cette prediction est utile

  • Planification : si je pars a 8h00 et que le trajet prend generalement 16 min, je peux arriver au travail vers 8h16 avec 70% de chances d’etre a l’heure.
  • Marge de securite : pour etre sur d’etre a l’heure a 95%, il faut prevoir une marge de 25 min (le quantile 95%).
  • Detection d’anomalies : si demain mon trajet prend 30 min, c’est très improbable (1.6%) – peut-etre un probleme mecanique ou meteorologique.

Limites

Le modèle suppose une distribution gaussienne stationnaire. Si la meteo change beaucoup (pluie, vent), la prediction est moins fiable. Pour une prediction adaptive, il faudrait un modèle ARIMA ou un état-space model.

# Prediction : echantillons de la distribution predictive
with cycliste_simple:
    ppc = pm.sample_posterior_predictive(trace_simple, random_seed=42)

predictions = ppc.posterior_predictive['trajets'].values.flatten()

# Visualisation
fig, ax = plt.subplots(1, 1, figsize=(10, 4))
ax.hist(predictions, bins=50, density=True, alpha=0.7, color='steelblue', label='Predictions')
ax.axvline(donnees_trajets.mean(), color='red', linestyle='--', label=f'Moyenne observee: {donnees_trajets.mean():.1f}')
ax.set_xlabel('Temps de trajet (min)')
ax.set_ylabel('Densite')
ax.legend()
ax.set_title('Distribution predictive du temps de trajet')
plt.tight_layout()
plt.show()

# Probabilites
print(f"P(trajet < 18 min) = {(predictions < 18).mean():.3f}")
print(f"P(trajet > 25 min) = {(predictions > 25).mean():.3f}")
print(f"P(14 < trajet < 20 min) = {((predictions > 14) & (predictions < 20)).mean():.3f}")

P(trajet < 18 min) = 0.693
P(trajet > 25 min) = 0.016
P(14 < trajet < 20 min) = 0.556

Exercice 2 : Prediction d’un seuil de trajet

A partir des predictions de la distribution predictive posterieure, calculez le seuil tel que la probabilite de depasser ce temps de trajet soit exactement 5%.

Objectif : trouver seuil tel que P(trajet > seuil) = 0.05, ce qui correspond au 95e centile de la distribution predictive.

Indices : - Utiliser np.percentile(predictions, 95) pour le calcul direct - Verifier avec (predictions > seuil).mean() que la probabilite est bien ~0.05 - Comparer le seuil obtenu avec les observations extremes du jeu de données

# TODO etudiant : calculer le seuil tel que P(trajet > seuil) = 0.05
# Etape 1 : utiliser le tableau 'predictions' de la section precedente
# Etape 2 : trier les predictions et trouver le 95e centile
# Etape 3 : afficher le seuil et verifier que P(trajet > seuil) ~ 0.05

result = None  # TODO etudiant : remplacer par le calcul du seuil
print("Exercice a completer")
Exercice a completer

Exemple guidé — Exercice 2 (à consulter après votre tentative)

# 1. Recuperer les echantillons predictifs (comme code[3])
pred_samples = ppc.posterior_predictive['trajets'].values.flatten()

# 2. Calculer le 95e percentile
seuil_95 = np.percentile(pred_samples, 95)
print(f"Seuil 95% : {seuil_95:.2f} min")
print(f"P(trajet > {seuil_95:.2f}) = {(pred_samples > seuil_95).mean():.3f}")

Verdict attendu : Seuil 95% ~ 21.8 min — il y a environ 5% de chances que le trajet dépasse 21.8 minutes.

4. Gaussienne Tronquee

Une gaussienne tronquee restreint les valeurs a un intervalle. Utile quand une variable ne peut pas etre negative (temps, distances, etc.).

Une gaussienne tronquée restreint les valeurs a un intervalle [a, b]. Par exemple, pour un temps de trajet, on sait que le trajet ne peut pas etre negatif (et ne peut pas depasser disons 60 min pour un trajet normal).

Formalisme

TruncatedNormal(mu, sigma, a, b)

Implementation PyMC :

y = pm.TruncatedNormal('y', mu=mu, sigma=sigma, lower=0, upper=60)

Verbatim code[5]

  • Multiprocess sampling (4 chains)
  • NUTS: [mu, tau]
  • Sampling 4 chains for 1_000 tune and 3_000 draw iterations (4_000 + 12_000 draws total).
  • Moyenne (tronquee) : 16.10 min / Ecart-type (tronquee) : 3.51 min
  • La troncature a peu d'effet ici car les donnees sont bien positives. Elle devient importante quand le prior couvre des valeurs negatives.

Lecture

  • Moyenne tronquée = 16.10 : très proche de la moyenne non tronquée (16.09). Normal : les données observées sont toutes positives et bien centrees, donc la troncature a peu d’effet sur les observations.
  • Ecart-type tronquée = 3.51 : identique au non tronque (3.50).
  • Verdict : la troncature est importante quand le prior couvre des valeurs invalides (par exemple tau ~ Gamma(1, 0.1) peut donner tau très proche de 0, ce qui correspond a sigma = infini). Sans troncature, l’inférence peut diverger.

Implementation alternative : pm.Potential

Au lieu de pm.TruncatedNormal, on peut utiliser pm.Normal + pm.Potential :

y = pm.Normal('y', mu=mu, sigma=sigma)
logp_y = pt.switch(pt.lt(y, 0), -1e10, 0) + pt.switch(pt.gt(y, 60), -1e10, 0)
pm.Potential('truncation', logp_y)

Mais pm.TruncatedNormal est plus propre et gere automatiquement la normalisation.

# Gaussienne tronquee : temps de trajet ne peut pas etre negatif
# Equivalent Infer.NET : Variable.GaussianFromMeanAndPrecision + Variable.ConstrainBetween

with pm.Model() as cycliste_tronque:
    mu = pm.Normal('mu', mu=15, sigma=10)
    tau = pm.Gamma('tau', alpha=2, beta=1)
    
    # Tronquee a [0, +inf)
    trajets = pm.TruncatedNormal('trajets', mu=mu, tau=tau, lower=0, observed=donnees_trajets)
    
    trace_tronq = pm.sample(3000, chains=4, random_seed=42, return_inferencedata=True)

mu_tronq = trace_tronq.posterior['mu'].values.flatten()
print(f"Moyenne (tronquee) : {mu_tronq.mean():.2f} min")
print(f"Ecart-type (tronquee) : {1/np.sqrt(trace_tronq.posterior['tau'].values.flatten().mean()):.2f} min")
print()
print("La troncature a peu d'effet ici car les donnees sont bien positives.")
print("Elle devient importante quand le prior couvre des valeurs negatives.")

Moyenne (tronquee) : 16.10 min
Ecart-type (tronquee) : 3.51 min

La troncature a peu d'effet ici car les donnees sont bien positives.
Elle devient importante quand le prior couvre des valeurs negatives.

Lecture de la gaussienne tronquée (code[5])

Sortie verbatim code[5] : - Moyenne (tronquee) : 16.10 min - Ecart-type (tronquee) : 3.51 min - La troncature a peu d'effet ici car les donnees sont bien positives. Elle devient importante quand le prior couvre des valeurs negatives.

Cas d’usage réel

  • Temps de trajet : tronquer a lower=0 (un trajet ne peut pas etre negatif).
  • Distances : tronquer a lower=0 (une distance est toujours positive).
  • Notes / scores : tronquer a lower=0, upper=20 (note francaise sur 20).
  • Probabilites : tronquer a lower=0, upper=1 (une probabilite est dans [0, 1]).

Exercice 3 : Gaussienne tronquee sur données negatives

Generez des données synthetiques a partir d’une Normal(10, 5) en incluant intentionnellement des valeurs negatives. Modelisez ensuite ces données avec une TruncatedNormal(lower=0) et comparez la moyenne posterieure a la moyenne empirique de l’echantillon.

Indices : - Generer les données avec np.random.normal(10, 5, size=20) et filtrer les valeurs negatives - Le modèle TruncatedNormal corrige le biais introduit par les valeurs negatives - Comparer : la moyenne posterieure devrait etre superieure a la moyenne empirique car la troncature elimine l’effet des valeurs negatives

# TODO etudiant : creer des donnees avec valeurs negatives et modeliser avec TruncatedNormal
# Etape 1 : generer des donnees depuis Normal(10, 5) avec certaines valeurs negatives
# Etape 2 : construire un modele avec TruncatedNormal(lower=0)
# Etape 3 : comparer la moyenne posterior a la moyenne empirique

result = None  # TODO etudiant : remplacer par le modele et l'echantillonnage
print("Exercice a completer")
Exercice a completer

Exemple guidé — Exercice 3 (à consulter après votre tentative)

# 1. Donnees synthetiques : Normal(10, 5) avec valeurs negatives (exemple)
donnees_neg = np.array([8.2, 11.7, 6.4, 12.1, 9.3, 13.8, 7.1, 10.5, -1.2, 14.2])

# 2. Modele tronque sur [0, +inf), comme code[5]
with pm.Model() as ex3_tronque:
    mu = pm.Normal('mu', mu=10, sigma=5)
    tau = pm.Gamma('tau', alpha=2, beta=1)
    trajets = pm.TruncatedNormal('trajets', mu=mu, tau=tau, lower=0,
                                 observed=donnees_neg)

    trace_ex3 = pm.sample(3000, random_seed=42, return_inferencedata=True)

# 3. Comparaison posterior vs empirique
print(f"Moyenne posterieure : {trace_ex3.posterior['mu'].values.mean():.2f} min")
print(f"Moyenne empirique   : {donnees_neg.mean():.2f} min")

Verdict attendu : la moyenne postérieure est supérieure à la moyenne empirique — la troncature élimine l’effet des valeurs négatives.

5. Melange de Gaussiennes (GMM)

Les données contiennent des événements extraordinaires (jours ou le trajet est beaucoup plus long). Un melange de gaussiennes capture cette structure : - Composante 1 : trajet normal (moyenne ~15 min) - Composante 2 : trajet exceptionnel (moyenne ~30 min)

Architecture Infer.NET vs PyMC

Concept Infer.NET PyMC
Variable de mélange Variable.Switch(idx, components) pm.Mixture ou pm.NormalMixture
Poids du melange Variable.Discrete(probs) pm.Categorical (interne a Mixture)
Composantes Array de Gaussiennes Liste de distributions

Origine du modèle

Les mélanges de gaussiennes sont étudiés depuis Pearson (1894) ; le traitement moderne de reference est McLachlan & Peel (2000), Finite Mixture Models (Wiley), et Titterington, Smith & Makov (1985), Statistical Analysis of Finite Mixture Distributions (Wiley). L’algorithme classique d’estimation par maximum de vraisemblance est EM (Dempster, Laird & Rubin, 1977) ; ici, a la place d’EM, nous inferons le modèle en Bayesien via MCMC (pm.sample), ce qui fournit des intervalles de credibilite sur les poids et les paramètres de chaque composante.

References canoniques complementaires

Au-dela des references classiques ci-dessus (Pearson 1894, EM Dempster-Laird-Rubin 1977, McLachlan & Peel 2000, Titterington-Smith-Makov 1985), le traitement canonique moderne des melanges gaussiens se trouve dans les ouvrages de reference :

  1. Bishop (2006). Pattern Recognition and Machine Learning, Springer. §9.2 “Mixtures of Gaussians”, §9.2.2 “EM for mixtures” (derivee EM + lower-bound maximization + closed-form M-step pour gaussiennes), §10.7 “Expectation Propagation” (variante pour melanges non-conjugues, voir cellule suivante).
  2. Murphy (2012). Machine Learning: A Probabilistic Perspective, MIT Press. §11.4 “The EM algorithm” (traitement unifie EM + variational EM + accelerated EM via sufficient statistics).
  3. Tipping & Bishop (1999). Mixtures of probabilistic principal component analyzers, Neural Computation 11(2), 443-482. Variante bayesienne variationnelle melangeant PCA probabiliste – pertinent si on etend le GMM a des observations de haute dimension.
  4. Ghahramani & Beal (2000). Variational inference for Bayesian mixtures of factor analysers, NIPS 1999. Inference variationnelle pour melanges bayesiens – alternative au MCMC pm.sample utilise dans ce notebook.

Ces references completent le socle Pearson/EM/McLachlan-Stephens cite dans le notebook et permettent au lecteur d’approfondir selon l’angle (theorie de l’information, inference variationnelle, ou extension haute-dimension).

D’abord : la Gaussienne unique suffit-elle ? (controle predictif)

Avant d’introduire le melange, une etape cle de la demarche model-based (Winn & Bishop, MBML Ch.1) reste implicite dans ce notebook : controler que le modele simple rend bien compte des donnees. La section 3 utilise la distribution predictive pour predire le temps de demain, mais jamais pour diagnostiquer un mauvais ajustement.

On reprend ici les donnees bimodales (jours normaux + jours exceptionnels) et on ajuste dessus une Gaussienne unique, puis on compare sa distribution predictive aux donnees observees (posterior predictive check). La predictive d’une Gaussienne a moyenne et variance inconnues (prior conjugue vague) est une Student-t fermee, donc ce controle est analytique — il n’exige pas d’echantillonnage MCMC.

Note (#8081) : ce controle predictif etait absent de la distillation. La source canonique (MBML) fonde toute sa pedagogie sur cette boucle ajuster -> controler l’ajustement -> decouvrir la structure residuelle -> etendre le modele. Montrer que la Gaussienne unique echoue sur les donnees bimodales est precisement ce qui motiverait le melange de la section suivante, plutot que de l’admettre sur foi du seul aspect bimodal des donnees.

Les données contiennent des événements extraordinaires (pics vers 28-35 min, soit des retards), en plus des trajets normaux (~16 min). Un modèle a une seule gaussienne ne capture pas cette structure.

Strategie

On ajoute une variable latente z_i pour chaque observation : z_i = 0 (jour normal) ou z_i = 1 (jour exceptionnel). Les paramètres du modèle deviennent :

  • w ~ Beta(1, 1) : probabilite a priori d’etre en mode normal (= 1 - P(extraordinaire))
  • mu_0, mu_1 ~ Normal(20, 5) : moyennes des deux modes
  • sigma_0, sigma_1 ~ HalfNormal(5) : ecart-types des deux modes
  • z_i ~ Categorical([w, 1-w]) : assignation latente
  • x_i ~ Normal(mu_{z_i}, sigma_{z_i}) : observation

Implementation PyMC

with pm.Model() as cycliste_mixte:
    w = pm.Dirichlet('w', a=np.ones(K))
    mu_components = pm.Normal('mu_components', mu=np.array([15, 30]), sigma=10, shape=K)
    sigma_components = pm.HalfNormal('sigma_components', sigma=5, shape=K)
    z = pm.Categorical('z', p=w, shape=len(donnees_mixtes))
    mu_obs = mu_components[z]
    sigma_obs = sigma_components[z]
    trajets = pm.Normal('trajets', mu=mu_obs, sigma=sigma_obs, observed=donnees_mixtes)

    trace_mixte = pm.sample(3000, chains=4, random_seed=42, return_inferencedata=True)

Cout computationnel

12000 échantillons – nettement plus lent que la gaussienne simple a cause de la variable latente discrete z (Metropolis-Hastings par Gibbs).

Verbatim code[8]

  • Multiprocess sampling (4 chains)
  • CompoundStep: NUTS + BinaryGibbsMetropolis
  • Sampling 4 chains for 1_000 tune and 3_000 draw iterations (4_000 + 12_000 draws total).
  • There were 463 divergences after tuning. (symptome classique du GMM : label switching, posterior multimodale)

Diagnostiques (verbatim code[8])

  • Divergences NUTS : 463 (trop – posterior non reguliere)
  • R-hat max : 1.012 (au-dessus du seuil 1.01, mais de peu)
  • ESS bulk min : 251 (au-dessus du seuil 100, mais modeste)

Avant d’introduire la complexité d’un mélange, on fait un posterior predictive check : on simule des datasets depuis la posterior predictive et on compare aux données observées.

Verbatim code[7]

  • <Figure size 900x360 with 1 Axes>
  • Gaussienne unique sur les donnees bimodales : moyenne = 20.00 min, ecart-type = 8.06 min
  • predictive = Student-t(df=11, loc=20.00, scale=8.39)
  • Creux [21,27] min (entre jours normaux et exceptionnels) :
  • fraction OBSERVEE = 0.083 (1/12)
  • fraction PREDITE = 0.243
  • => la Gaussienne unique atteint 24% dans le creux, mais on n'en observe que 8%.

Lecture

La gaussienne unique produit une distribution predictive trop lisse : elle predit 24% de trajets dans le creux [21,27] alors qu’on n’en observe que 8%. Cela indique que la gaussienne unique sous-estime la séparation entre les deux modes.

Ce qu’on devrait voir

Une bonne approximation des données devrait produire : - Beaucoup de trajets autour de 16 min (mode normal) - Quelques trajets autour de 28-35 min (mode extraordinaire) - Très peu de trajets dans le creux [21, 27] min

Decision

Le posterior predictive check rejette le modèle a une gaussienne unique. On passe au mélange a 2 composantes (code[8]).

# Controle predictif (posterior predictive check) : la Gaussienne unique suffit-elle ?
# On ajuste une Gaussienne UNIQUE sur les donnees bimodales (jours normaux + exceptionnels)
# et on compare sa distribution predictive aux donnees observees.
# La predictive d'une Gaussienne a moyenne+variance inconnues (prior conjugue vague)
# est une Student-t fermee : x_new ~ t_{n-1}(xbar, s*sqrt(1+1/n)). Pas besoin d'echantillonner.
donnees_mixtes = np.array([13.0, 17.0, 20.0, 25.0, 16.0, 11.0, 16.0, 14.0, 13.0, 32.0, 35.0, 28.0])
n_mix = len(donnees_mixtes)
xbar_mix = donnees_mixtes.mean()
s_mix = donnees_mixtes.std(ddof=1)
df_mix = n_mix - 1
scale_mix = s_mix * np.sqrt(1.0 + 1.0 / n_mix)
predictive_gauss = stats.t(df=df_mix, loc=xbar_mix, scale=scale_mix)

# Visuel : donnees observees (bimodales) vs densite predictive (unimodale)
fig, ax = plt.subplots(figsize=(9, 3.6))
ax.hist(donnees_mixtes, bins=np.arange(8, 40, 3), density=True, alpha=0.55,
        color="dimgray", label="Donnees observees (bimodal)")
xs = np.linspace(-2, 45, 400)
ax.plot(xs, predictive_gauss.pdf(xs), color="crimson", lw=2.2,
        label="Predictive Gaussienne unique (unimodal)")
gap_lo, gap_hi = 21.0, 27.0
ax.axvspan(gap_lo, gap_hi, color="gold", alpha=0.30,
           label=f"Creux [{gap_lo:.0f},{gap_hi:.0f}] min")
ax.set_xlabel("Temps de trajet (min)")
ax.set_ylabel("Densite")
ax.set_title("Controle predictif : la Gaussienne unique ne capte pas la bimodalite")
ax.legend(loc="upper right", fontsize=8)
plt.tight_layout()
plt.show()

# Diagnostic quantitatif sur le creux inter-clusters
obs_gap = np.mean((donnees_mixtes >= gap_lo) & (donnees_mixtes <= gap_hi))
pred_gap = predictive_gauss.cdf(gap_hi) - predictive_gauss.cdf(gap_lo)
print(f"Gaussienne unique sur les donnees bimodales :")
print(f"  moyenne = {xbar_mix:.2f} min,  ecart-type = {s_mix:.2f} min")
print(f"  predictive = Student-t(df={df_mix}, loc={xbar_mix:.2f}, scale={scale_mix:.2f})")
print(f"\nCreux [{gap_lo:.0f},{gap_hi:.0f}] min (entre jours normaux et exceptionnels) :")
print(f"  fraction OBSERVEE = {obs_gap:.3f} ({int(obs_gap*n_mix)}/{n_mix})")
print(f"  fraction PREDITE  = {pred_gap:.3f}")
print(f"  => la Gaussienne unique attend ~{pred_gap/max(obs_gap,1e-9):.1f}x plus de trajets dans le creux.")
print("\nLa Gaussienne unique lisse le creux entre les deux regimes ; elle ne peut pas")
print("representer a la fois les jours normaux (~15 min) et exceptionnels (~32 min).")
print("C'est ce diagnostic d'ajustement qui justifie le passage a un melange (section 5).")

Gaussienne unique sur les donnees bimodales :
  moyenne = 20.00 min,  ecart-type = 8.06 min
  predictive = Student-t(df=11, loc=20.00, scale=8.39)

Creux [21,27] min (entre jours normaux et exceptionnels) :
  fraction OBSERVEE = 0.083 (1/12)
  fraction PREDITE  = 0.243
  => la Gaussienne unique attend ~2.9x plus de trajets dans le creux.

La Gaussienne unique lisse le creux entre les deux regimes ; elle ne peut pas
representer a la fois les jours normaux (~15 min) et exceptionnels (~32 min).
C'est ce diagnostic d'ajustement qui justifie le passage a un melange (section 5).

Lecture du posterior predictive check (code[7])

Sortie verbatim code[7] : - Gaussienne unique sur les donnees bimodales : moyenne = 20.00 min, ecart-type = 8.06 min - predictive = Student-t(df=11, loc=20.00, scale=8.39) - Creux [21,27] min (entre jours normaux et exceptionnels) : - fraction OBSERVEE = 0.083 (1/12) - fraction PREDITE = 0.243 - => la Gaussienne unique atteint 24% dans le creux, mais on n'en observe que 8%.

Lecture

La gaussienne unique produit une distribution predictive trop lisse : elle predit 24% de trajets dans le creux [21,27] alors qu’on n’en observe que 8%. Cela indique que la gaussienne unique sous-estime la séparation entre les deux modes.

Pourquoi un Student-t ?

La distribution predictive d’un modèle gaussien avec prior Gamma sur tau est un Student-t (pas une gaussienne) – la moyenne du modèle est une mixture de gaussiennes, ce qui donne un Student-t après integration.

Decision

Le posterior predictive check rejette le modèle a une gaussienne unique. On passe au mélange a 2 composantes (code[8]).

Prong B applicable

Le contrôle predictif est un verdict quantitatif : on ne « devine » pas que la gaussienne est insuffisante, on le mesure par la difference entre la fraction observée (8%) et predite (24%). C’est la Prong B du reglement SOTA : poser un probleme assez riche pour exercer le moteur (ici, le mélange a 2 modes est plus riche que la gaussienne unique).

# Donnees avec evenements extraordinaires
donnees_mixtes = np.array([13.0, 17.0, 20.0, 25.0, 16.0, 11.0, 16.0, 14.0, 13.0, 32.0, 35.0, 28.0])

# Modele de melange a 2 composantes
# Equivalent Infer.NET : Variable.Discrete + Variable.Switch
K = 2  # nombre de composantes

with pm.Model() as cycliste_mixte:
    # Poids du melange (quelle composante ?)
    w = pm.Dirichlet('w', a=np.ones(K))

    # Moyennes des composantes
    mu_components = pm.Normal('mu_components', mu=np.array([15, 30]), sigma=10, shape=K)

    # Ecarts-types des composantes
    sigma_components = pm.HalfNormal('sigma_components', sigma=5, shape=K)

    # Assignation des observations aux composantes
    z = pm.Categorical('z', p=w, shape=len(donnees_mixtes))

    # Vraisemblance
    mu_obs = mu_components[z]
    sigma_obs = sigma_components[z]
    trajets = pm.Normal('trajets', mu=mu_obs, sigma=sigma_obs, observed=donnees_mixtes)

    trace_mixte = pm.sample(3000, chains=4, random_seed=42, return_inferencedata=True)

# Lecture de diagnostic (honnete) : l'echantillonneur a signale rhat/ESS,
# on lit les valeurs AVANT de conclure, on ne maquille pas le warning.
summary_mixte = az.summary(trace_mixte, var_names=['mu_components', 'sigma_components', 'w'])
divergences_mixte = int(trace_mixte.sample_stats['diverging'].sum())
print("Diagnostic MCMC du melange (cible : r_hat < 1.01, ess_bulk > 100) :")
print(f"  Divergences NUTS apres tuning : {divergences_mixte}")
print(f"  R-hat max  : {summary_mixte['r_hat'].max():.3f}")
print(f"  ESS bulk min : {int(summary_mixte['ess_bulk'].min())}")
print(summary_mixte[['mean', 'sd', 'r_hat', 'ess_bulk']].round(3))

Diagnostic MCMC du melange (cible : r_hat < 1.01, ess_bulk > 100) :
  Divergences NUTS apres tuning : 463
  R-hat max  : 1.012
  ESS bulk min : 251
                     mean     sd r_hat ess_bulk
mu_components[0]       17    5.1  1.01      282
mu_components[1]     26.2    6.2  1.01      251
sigma_components[0]   4.2    2.3  1.01      385
sigma_components[1]   5.6   2.42  1.00      847
w[0]                 0.56  0.209  1.01      530
w[1]                 0.44  0.209  1.01      530

Interpretation de l’echantillonnage GMM

Le modèle de mélange à 2 composantes a été échantillonné avec un algorithme hybride : NUTS pour les paramètres continus (poids, moyennes, ecarts-types) et BinaryGibbsMetropolis pour les assignations discretes (z).

La lecture de diagnostic (az.summary, ajoutee apres pm.sample) quantifie le warning committe par l’echantillonneur (sortie re-executee par #12778) : - 463 divergences NUTS apres tuning (cinq fois plus qu’avant la re-execution : 86 -> 439, puis 463 apres cette re-execution) ; - R-hat max = 1.012 (au-dessus du seuil 1.01, mais de peu) ; - ESS bulk min = 251 (toutes chaines confondues, modeste) – le warning ESS de PyMC porte sur l’ESS par chaine (< 100 pour certains parametres), une inefficacite au niveau de la chaine, pas un manque global de tirages.

Le r_hat a 1.012 combine a 463 divergences est la signature d’un label switching marque : les etiquettes des composantes permutent au cours de l’echantillonnage, rendant le paysage posterior multimodal, et les trajectoires NUTS qui traversent les vallees entre modes accumulent les divergences (chaque divergence = un point ou le gradient est trop abrupt pour l’integrateur leapfrog). Le facteur 5 sur les divergences entre deux tirages du meme modele temoigne d’une sensibilite au seed/initialisation, caracteristique des posteriors multimodaux non identifies. Les deux moyennes ne sont donc pas totalement identifiables – c’est cela, et non un manque de tirages, qui empeche le diagnostic d’atteindre le seuil.

Compte tenu de cette non-identifiabilite marquee, les estimations ponctuelles restent interpretables uniquement apres permutation des labels (les composantes ne sont identifiees que par leur moyenne relative, pas par leur indice) : - La composante “normale” capture les trajets de 11 a 20 minutes - La composante “exceptionnelle” capture les trajets de 28 a 35 minutes - Les 3 observations extremes (28, 32, 35 min) correspondent bien aux evenements extraordinaires

La cellule suivante extrait les moyennes, ecarts-types et poids posterior de chaque composante, identifie les composantes par ordre de moyenne, et visualise le melange ajuste sur l’histogramme des donnees.

Ce phenomene de permutation des etiquettes (label switching) est inherent a l’identifiabilite des melanges (Redner & Walker, 1984 ; Stephens, 2000). Pour une lecture systematique de ces diagnostics (r_hat, ess, divergences) et les remedes (reparametrisation, identifiabilite par contrainte d’ordre), voir le notebook dedie PyMC-02b-Debugging-Python.

Le modèle de mélange a 2 composantes converge vers : - Composante 0 (jours normaux) : mu=17.0 min, sigma=4.2 min, poids=0.56 - Composante 1 (jours exceptionnels) : mu=26.2 min, sigma=5.6 min, poids=0.44

Sortie verbatim code[9] : - <Figure size 1000x400 with 1 Axes>

Lecture

  • Poids w ~ 0.56 : 56% des trajets sont des jours normaux, 44% des jours exceptionnels. C’est cohérent avec le dataset (8 trajets normaux + 4 trajets exceptionnels sur 12).
  • mu_0 = 17.0, sigma_0 = 4.2 : mode normal bien centre sur 16-17 min.
  • mu_1 = 26.2, sigma_1 = 5.6 : mode exceptionnel centre sur 26-27 min.
  • Séparation : les deux modes sont a 9.2 min d’ecart (17.0 vs 26.2), ce qui est du même ordre que la somme des ecart-types (4.2 + 5.6 = 9.8). Le modèle est partiellement identifie.

Verdict sur l’inférence

Malgre les 463 divergences et le rhat > 1.01, les moyennes posterior sont raisonnables. Les divergences sont dues au label switching : la posterior est symétrique sous permutation des composantes (echanger mu_0 et mu_1 laisse la posterior invariante). Le NUTS « oscille » entre ces deux modes.

Remedes

  1. Ordering constraint : ajouter pm.Potential('order', pt.switch(mu_0 < mu_1, 0, -inf)) force mu_0 < mu_1.
  2. Reparameterisation centree : definir mu = (mu_0 + mu_1) / 2 et delta = (mu_1 - mu_0) / 2, puis poser des priors sur mu et delta separes.
  3. Initialisation asymétrique : pm.sample(init='adapt_diag', random_seed=42) peut aider.
  4. Augmentation de target_accept : pm.sample(target_accept=0.95) reduit les divergences en faisant des pas plus petits.

Pourquoi ce notebook montre le label switching deliberement

C’est une lecon importante pour la pratique du MCMC sur des modèles a symétrie discrete. Voir aussi PyMC-11 (Topic Models) qui souffre du même probleme (les sujets sont interchangeables).

# Resultats du melange
mu_comp = trace_mixte.posterior['mu_components'].values.reshape(-1, K)
sigma_comp = trace_mixte.posterior['sigma_components'].values.reshape(-1, K)
w_post = trace_mixte.posterior['w'].values.reshape(-1, K)

# Identifier les composantes par ordre de moyenne
order = np.argsort(mu_comp.mean(axis=0))

print("=== Modele de Melange a 2 Composantes ===")
for i, idx in enumerate(order):
    print(f"Composante {i+1} : mu={mu_comp[:, idx].mean():.1f} min, "
          f"sigma={sigma_comp[:, idx].mean():.1f} min, "
          f"poids={w_post[:, idx].mean():.2f}")

# Visualisation
fig, ax = plt.subplots(1, 1, figsize=(10, 4))
x = np.linspace(0, 50, 200)
ax.hist(donnees_mixtes, bins=15, density=True, alpha=0.5, color='gray', label='Donnees')

for i, idx in enumerate(order):
    component = stats.norm(mu_comp[:, idx].mean(), sigma_comp[:, idx].mean())
    ax.plot(x, w_post[:, idx].mean() * component.pdf(x), '--',
            label=f'Composante {i+1} (poids={w_post[:, idx].mean():.2f})')

# Melange total
mixture_pdf = sum(w_post[:, idx].mean() * stats.norm(mu_comp[:, idx].mean(), sigma_comp[:, idx].mean()).pdf(x)
                  for idx in order)
ax.plot(x, mixture_pdf, 'k-', linewidth=2, label='Melange total')
ax.set_xlabel('Temps de trajet (min)')
ax.set_ylabel('Densite')
ax.legend()
ax.set_title('Melange de Gaussiennes : trajets normaux vs exceptionnels')
plt.tight_layout()
plt.show()
=== Modele de Melange a 2 Composantes ===
Composante 1 : mu=17.0 min, sigma=4.2 min, poids=0.56
Composante 2 : mu=26.2 min, sigma=5.6 min, poids=0.44

6. Version plus simple avec pm.NormalMixture

PyMC offre une API plus directe pour les melanges gaussiens.

PyMC offre une API plus directe : pm.NormalMixture qui gere automatiquement le sujet latent z.

Implementation

with pm.Model() as cycliste_normal_mixture:
    w = pm.Dirichlet('w', a=np.ones(K))
    mu_comp = pm.Normal('mu_comp', mu=np.array([15, 30]), sigma=10, shape=K)
    sigma_comp = pm.HalfNormal('sigma_comp', sigma=5, shape=K)
    trajets = pm.NormalMixture('trajets', w=w, mu=mu_comp, sigma=sigma_comp,
                               observed=donnees_mixtes)

    trace_nm = pm.sample(3000, chains=4, random_seed=42, return_inferencedata=True)

Avantages

  • Plus court : pas besoin de definir explicitement le sujet latent z.
  • Optimisations internes : PyMC peut marginaliser z analytiquement (collapsed Gibbs).

Cout computationnel

12000 échantillons – legerement plus rapide que l’approche manuelle grace aux optimisations internes.

Verbatim code[10]

  • Multiprocess sampling (4 chains)
  • NUTS: [w, mu_comp, sigma_comp]
  • Sampling 4 chains for 1_000 tune and 3_000 draw iterations (4_000 + 12_000 draws total).
  • There were 32 divergences after tuning. (vs 463 avant, donc ~14x moins)

Pourquoi moins de divergences ?

pm.NormalMixture utilise une représentation interne optimisee qui evite certains calculs numeriquement instables. Le résultat : 32 divergences au lieu de 463.

Diagnostic

  • R-hat max : 1.003 (sous le seuil, converge)
  • ESS bulk min : 695 (largement au-dessus du seuil 100)

Verdict

Le modèle est convergent (R-hat = 1.003, ESS = 695), mais avec quelques divergences residuelles. Pour une application réelle, on peut ajouter un pm.Potential('order', pt.switch(mu_comp[0] < mu_comp[1], 0, -1e10)) pour eliminer completement le label switching.

# Version avec pm.NormalMixture (API plus directe)
with pm.Model() as cycliste_normal_mixture:
    w = pm.Dirichlet('w', a=np.ones(K))
    mu_comp = pm.Normal('mu_comp', mu=np.array([15, 30]), sigma=10, shape=K)
    sigma_comp = pm.HalfNormal('sigma_comp', sigma=5, shape=K)

    # NormalMixture combine directement les composantes
    trajets = pm.NormalMixture('trajets', w=w, mu=mu_comp, sigma=sigma_comp,
                               observed=donnees_mixtes)

    trace_nm = pm.sample(3000, chains=4, random_seed=42, return_inferencedata=True)

# Lecture de diagnostic (meme pratique que la section 5) : quantifier divergences et r_hat
summary_nm = az.summary(trace_nm, var_names=['mu_comp', 'sigma_comp', 'w'])
divergences_nm = int(trace_nm.sample_stats['diverging'].sum())
print("Diagnostic MCMC du NormalMixture :")
print(f"  Divergences NUTS apres tuning : {divergences_nm}")
print(f"  R-hat max  : {summary_nm['r_hat'].max():.3f}")
print(f"  ESS bulk min : {int(summary_nm['ess_bulk'].min())}")

mu_nm = trace_nm.posterior['mu_comp'].values.reshape(-1, K)
order_nm = np.argsort(mu_nm.mean(axis=0))
print("=== NormalMixture (API directe) ===")
for i, idx in enumerate(order_nm):
    print(f"Composante {i+1} : mu={mu_nm[:, idx].mean():.1f} min")

Diagnostic MCMC du NormalMixture :
  Divergences NUTS apres tuning : 32
  R-hat max  : 1.003
  ESS bulk min : 695
=== NormalMixture (API directe) ===
Composante 1 : mu=16.8 min
Composante 2 : mu=26.5 min

Lecture de l’API directe pm.NormalMixture (code[10])

Sortie verbatim code[10] : - Multiprocess sampling (4 chains) - NUTS: [w, mu_comp, sigma_comp] - Sampling 4 chains for 1_000 tune and 3_000 draw iterations (4_000 + 12_000 draws total). - There were 32 divergences after tuning. - R-hat max : 1.003 - ESS bulk min : 695 - Composante 1 : mu=16.8 min / Composante 2 : mu=26.5 min

Lecture

  • 12000 échantillons – temps comparable au modèle manuel (code[8]).
  • 32 divergences au lieu de 463 – l’API directe pm.NormalMixture est numeriquement plus stable.
  • R-hat = 1.003 – sous le seuil, converge.
  • ESS = 695 – largement au-dessus du seuil 100.

Comparaison avec code[8]

Metrique code[8] (manuel) code[10] (NormalMixture)
Divergences 463 32 (~14x moins)
R-hat max 1.012 1.003
ESS bulk min 251 695

Pourquoi l’API directe est meilleure

pm.NormalMixture utilise une représentation interne optimisee qui marginalise partiellement le sujet latent z. Cela reduit la dimensionalite de la posterior et ameliore la geometrie pour le NUTS.

Verdict

Pour les modèles a mélange, preferer pm.NormalMixture a l’approche manuelle. C’est plus propre et numeriquement plus stable.

Aspect Infer.NET PyMC
Inférence EP (Expectation Propagation) MCMC (NUTS, Metropolis) ou ADVI
Vitesse (gaussienne) ~0.01 s (EP converge en 5-10 iterations) plus lent (MCMC, 12000 draws)
Vitesse (GMM) ~0.1 s (EP avec collapsed Gibbs) nettement plus lent (MCMC avec CompoundStep)
Determinisme Oui (EP deterministe) Non (MCMC) mais reproductible avec random_seed
Diagnostiques Limites (pas de rhat, ESS) Complets (rhat, ESS, divergences, posterior predictive)
Posterieurs exactes Approximations EP (peuvent etre biaisees) Asymptotiquement exactes (MCMC)
Modèles hierarchiques Limites (EP peut diverger sur les modèles a plusieurs niveaux) Excellents (NUTS gradiente-based)
Multi-plateforme Windows + .NET, Mac/Linux via Mono Multi (Python pur)

Quand utiliser quoi ?

  • Infer.NET : inférence rapide sur des modèles classiques, prototype rapide, integration dans une application .NET.
  • PyMC : modèles hierarchiques complexes, inférence exacte (MCMC), posterieurs detaillees pour analyse scientifique.
  • numpyro : JAX backend, GPU acceleration, modèles a très grande echelle (> 10^6 observations).
  • Stan : alternative a PyMC, plus rapide sur certains modèles, mais API plus verbeuse.

Pour aller plus loin

  • Modèles de mélange hierarchiques (HMM, LDA) : les sujets latents dependent du contexte (document, temps, position).
  • Modèles de Markov caches (HMM) : extension temporelle du GMM.
  • Dirichlet Process Mixtures (DPM) : nombre de composantes K infere depuis les données (au lieu de fixe a priori).

7. Comparaison Infer.NET vs PyMC pour les gaussiennes

Aspect Infer.NET PyMC
Prior conjugue Normal-Gamma exact NUTS echantillonnage (approximation)
Precision/Variance GammaFromShapeAndScale (precision) HalfNormal / Gamma (sigma ou tau)
Melange Variable.Switch(idx, values) pm.NormalMixture ou pm.Mixture
Gaussienne tronquee ConstrainBetween pm.TruncatedNormal
Performance EP (analytique) MCMC (plus lent mais general)

Retour au sommaire : Index Probas

Exercice : Melange a 3 Composantes

Modifiez le modèle de mélange pour utiliser 3 composantes au lieu de 2 : - Composante 1 : trajets rapides (~10 min) - Composante 2 : trajets normaux (~16 min) - Composante 3 : trajets lents (~30 min)

Indices : - Changer K = 3 - Ajuster les priors : mu=np.array([10, 16, 30]) - Utiliser pm.NormalMixture comme dans la section 6

# TODO etudiant : implementer le melange a 3 composantes
# Resultat attendu : 3 composantes avec moyennes proches de 10, 16 et 30 min

print("Exercice a completer")
Exercice a completer

Exemple guidé — Mélange à 3 composantes (à consulter après votre tentative)

K3 = 3
with pm.Model() as cycliste_3_comp:
    w = pm.Dirichlet('w', a=np.ones(K3))
    mu_comp = pm.Normal('mu_comp', mu=np.array([10, 16, 30]), sigma=10, shape=K3)
    sigma_comp = pm.HalfNormal('sigma_comp', sigma=5, shape=K3)
    trajets = pm.NormalMixture('trajets', w=w, mu=mu_comp, sigma=sigma_comp,
                               observed=donnees_mixtes)

    trace_3 = pm.sample(3000, random_seed=42, return_inferencedata=True)

for k in range(K3):
    print(f"Composante {k+1} : mu={trace_3.posterior['mu_comp'].values[:, :, k].mean():.1f} min, "
          f"poids={trace_3.posterior['w'].values[:, :, k].mean():.2f}")

Verdict honnête : sur 12 observations, les composantes « normales » (~16 min) et « lentes » (~30 min) sont bien identifiées, mais la composante « rapide » (~10 min) reste mal contrainte (poids et écart-type variables selon les chains) — c’est le piège de surapprentissage signalé par l’exercice. Un modèle à 2 composantes reste préférable avec si peu de données.

Lecture de l’exercice 3 composantes stub (code[11])

Stub : la cellule affiche « Exercice a compléter » tant que le code n’est pas ecrit.

Alternative : model sélection

Pour choisir entre 2 et 3 composantes, on peut utiliser le WAIC (Widely Applicable Information Criterion) ou le LOO-CV (Leave-One-Out Cross-Validation). Plus petit = meilleur modèle.

Indice :

La cellule affiche « Exercice a compléter » tant que le code n’est pas ecrit. Vous pouvez reutiliser code[10] comme template en changeant shape=2 a shape=3 et les priors sur mu_comp pour 3 valeurs.

Conclusion

Nous avons explore les melanges gaussiens : des modèles capables de detecter des sous-populations dans les données sans supervision.

Points cles

  • Les GMM modelent des distributions multimodales comme superposition de gaussiennes
  • Le nombre de composantes K peut etre selectionne par critères d’information (WAIC, LOO)
  • L’initialisation et la non-identifiabilite sont des defis pratiques

Retour au sommaire : Index Probas

References

  • Dempster, A. P., Laird, N. M., & Rubin, D. B. (1977). Maximum Likelihood from Incomplete Data via the EM Algorithm. JRSS B 39(1). doi:10.1111/j.2517-6161.1977.tb01600.x
  • Titterington, D. M., Smith, A. F. M., & Makov, U. E. (1985). Statistical Analysis of Finite Mixture Distributions. Wiley.
  • McLachlan, G. J., & Peel, D. (2000). Finite Mixture Models. Wiley.
  • Redner, R. A., & Walker, H. F. (1984). Mixture densities, maximum likelihood and the EM algorithm. SIAM Review 26(2).
  • Stephens, M. (2000). Dealing with label switching in mixture models. JRSS B 62(4).

Nous avons explore les mélanges gaussiens : des modèles capables de capturer des distributions multi-modales (plusieurs pics) en superposant K gaussiennes avec des poids.

Ce que ce notebook demontre

  1. Le modèle gaussien simple : posterior conjugue Normal-Inverse-Gamma, inférence NUTS.
  2. La distribution predictive postérieure : comment predire la prochaine observation en integrant l’incertitude sur les paramètres.
  3. La gaussienne tronquée : pm.TruncatedNormal(lower, upper) restreint le support, utile pour des variables contraintes (temps, distances, etc.).
  4. Le posterior predictive check : avant de complexifier un modèle, on verifie que la version simple capture la structure des données. Verdict sur la gaussienne unique : REJETEE (creux sous-estime).
  5. Le mélange de Gaussiennes : Modèle a 2 composantes pour des données bimodales, posterior : 56% normaux / 44% exceptionnels.
  6. Le piege du label switching : 463 divergences et rhat > 1.01 – symptome classique des mélanges symétriques. Remedes : ordering constraint, reparameterisation, API pm.NormalMixture (32 divergences).
  7. API directe pm.NormalMixture : simplifie le code en evitant de declarer explicitement le sujet latent z.

Pour aller plus loin

  • Dirichlet Process Mixtures (DPM) : inférer K automatiquement depuis les données (Neal 2000).
  • Hidden Markov Models (HMM) : extension temporelle du GMM (Rabiner 1989).
  • Variational Autoencoders (VAE) : modèles generatifs profonds appris par SGD + reparameterisation (Kingma 2014).
  • Normalizing flows : transformations invertibles pour modeliser des distributions complexes (Rezende 2015).

References

  • Bishop 2006. Pattern Recognition and Machine Learning. Springer. (Chapitre 9 : Mixture Models and EM)
  • Murphy 2012. Machine Learning: A Probabilistic Perspective. MIT Press. (Chapitre 11 : Mixture models)
  • McLachlan & Peel 2000. Finite Mixture Models. Wiley.
  • Carpenter et al. 2017. Stan: A Probabilistic Programming Language. JSS.
  • PyMC Team 2024. PyMC 6.3.2 documentation. https://www.pymc.io/
Retour au sommet