Prior/posterior comparison

Fit a PyMC model, compare prior and posterior draws with compare_draws(), and plot them

compare_draws() is the prior/posterior comparison helper: it calls parameter_draws() once per selected group in the ArviZ object, tags each result with a source column, and stacks them into a single DataFrame. By default it compares posterior against prior, which is the workflow most users need after fitting a PyMC model.

Full PyMC workflow

This example uses the same grouped regression data as the other examples, samples from the model prior before fitting, then compares those prior draws with the fitted posterior. That gives compare_draws() real prior draws and real posterior draws from the same PyMC model.

Code
from pathlib import Path
import sys

import polars as pl
import pymc as pm
import tidydraws as td
import lets_plot as lp
import plotnine as p9


for parent in [Path.cwd(), *Path.cwd().parents]:
    helper_dir = parent / "docs" / "examples"
    if (helper_dir / "_pymc_workflow.py").exists():
        sys.path.insert(0, str(helper_dir))
        break

from _pymc_workflow import simulate_grouped_regression

lp.LetsPlot.setup_html()

workflow = simulate_grouped_regression(seed=2026)
observed = workflow.observed
truth = workflow.truth
/home/runner/work/tidydraws/tidydraws/.venv/lib/python3.12/site-packages/lets_plot/plot/annotation.py:551: SyntaxWarning: invalid escape sequence '\('
  """
/home/runner/work/tidydraws/tidydraws/.venv/lib/python3.12/site-packages/lets_plot/plot/annotation.py:601: SyntaxWarning: invalid escape sequence '\('
  """

Build our PyMC model and fit.

coords = {
    "groups": workflow.group_names,
    "obs_ind": observed.get_column("obs_ind").to_numpy(),
}

with pm.Model(coords=coords) as model:
    x = pm.Data("x", observed.get_column("x").to_numpy(), dims="obs_ind")
    group_idx = pm.Data(
        "group_idx",
        observed.get_column("group_idx").to_numpy().astype("int64"),
        dims="obs_ind",
    )
    intercept = pm.Normal("intercept", mu=0.0, sigma=2.0, dims="groups")
    beta = pm.Normal("beta", mu=0.0, sigma=1.5, dims="groups")
    sigma = pm.HalfNormal("sigma", sigma=1.0)
    mu = pm.Deterministic(
        "mu",
        intercept[group_idx] + beta[group_idx] * x,
        dims="obs_ind",
    )
    pm.Normal(
        "y",
        mu=mu,
        sigma=sigma,
        observed=observed.get_column("y").to_numpy(),
        dims="obs_ind",
    )
    prior = pm.sample_prior_predictive(
        draws=800,
        random_seed=2029,
        var_names=["intercept", "beta", "sigma"],
    )
    dt = pm.sample(
        draws=400,
        random_seed=2030,
    )

    # arviz 1.0+ uses .update(), 0.x uses .extend()
    try:
        dt.update(prior)
    except AttributeError:
        dt.extend(prior)
Sampling: [beta, intercept, sigma]
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (2 chains in 2 jobs)
NUTS: [intercept, beta, sigma]

Sampling 2 chains for 1_000 tune and 400 draw iterations (2_000 + 800 draws total) took 1 seconds.
We recommend running at least 4 chains for robust computation of convergence diagnostics
dt
<xarray.DataTree>
Group: /
├── Group: /posterior
│       Dimensions:    (chain: 2, draw: 400, groups: 4, obs_ind: 96)
│       Coordinates:
│         * chain      (chain) int64 16B 0 1
│         * draw       (draw) int64 3kB 0 1 2 3 4 5 6 7 ... 393 394 395 396 397 398 399
│         * groups     (groups) <U5 80B 'North' 'South' 'East' 'West'
│         * obs_ind    (obs_ind) int64 768B 0 1 2 3 4 5 6 7 ... 88 89 90 91 92 93 94 95
│       Data variables:
│           intercept  (chain, draw, groups) float64 26kB -1.425 0.1027 ... 1.099 2.101
│           beta       (chain, draw, groups) float64 26kB 0.3943 0.782 ... 1.594 -0.6559
│           sigma      (chain, draw) float64 6kB 0.424 0.4397 0.4762 ... 0.3836 0.5073
│           mu         (chain, draw, obs_ind) float64 614kB -2.239 -2.138 ... 0.6829
│       Attributes:
│           created_at:                 2026-07-14T15:09:59.014481+00:00
│           creation_library:           ArviZ
│           creation_library_version:   1.2.0
│           creation_library_language:  Python
│           inference_library:          pymc
│           inference_library_version:  6.0.1
│           sample_dims:                ['chain', 'draw']
│           sampling_time:              0.9929232597351074
│           tuning_steps:               1000
├── Group: /sample_stats
│       Dimensions:                (chain: 2, draw: 400)
│       Coordinates:
│         * chain                  (chain) int64 16B 0 1
│         * draw                   (draw) int64 3kB 0 1 2 3 4 5 ... 395 396 397 398 399
│       Data variables: (12/18)
│           lp                     (chain, draw) float64 6kB -70.2 -70.44 ... -72.28
│           max_energy_error       (chain, draw) float64 6kB -0.8613 0.4267 ... 0.8733
│           step_size_bar          (chain, draw) float64 6kB 0.8464 0.8464 ... 0.8697
│           tree_depth             (chain, draw) int64 6kB 2 3 2 3 3 2 3 ... 2 3 2 3 3 3
│           divergences            (chain, draw) int64 6kB 0 0 0 0 0 0 0 ... 0 0 0 0 0 0
│           reached_max_treedepth  (chain, draw) bool 800B False False ... False False
│           ...                     ...
│           acceptance_rate        (chain, draw) float64 6kB 0.8425 0.8179 ... 0.5899
│           perf_counter_diff      (chain, draw) float64 6kB 0.0003356 ... 0.0003956
│           step_size              (chain, draw) float64 6kB 0.9588 0.9588 ... 0.7891
│           process_time_diff      (chain, draw) float64 6kB 0.0003358 ... 0.0003963
│           n_steps                (chain, draw) float64 6kB 3.0 7.0 3.0 ... 7.0 7.0 7.0
│           index_in_trajectory    (chain, draw) int64 6kB 2 5 -1 4 4 3 ... 1 3 -4 -2 -5
│       Attributes:
│           created_at:                 2026-07-14T15:09:59.026218+00:00
│           creation_library:           ArviZ
│           creation_library_version:   1.2.0
│           creation_library_language:  Python
│           inference_library:          pymc
│           inference_library_version:  6.0.1
│           sample_dims:                ['chain', 'draw']
│           sampling_time:              0.9929232597351074
│           tuning_steps:               1000
├── Group: /observed_data
│       Dimensions:  (obs_ind: 96)
│       Coordinates:
│         * obs_ind  (obs_ind) int64 768B 0 1 2 3 4 5 6 7 8 ... 88 89 90 91 92 93 94 95
│       Data variables:
│           y        (obs_ind) float64 768B -2.106 -1.795 -2.253 ... 1.393 1.081 0.7312
│       Attributes:
│           created_at:                 2026-07-14T15:09:46.859701+00:00
│           creation_library:           ArviZ
│           creation_library_version:   1.2.0
│           creation_library_language:  Python
│           inference_library:          pymc
│           inference_library_version:  6.0.1
│           sample_dims:                []
├── Group: /constant_data
│       Dimensions:    (obs_ind: 96)
│       Coordinates:
│         * obs_ind    (obs_ind) int64 768B 0 1 2 3 4 5 6 7 ... 88 89 90 91 92 93 94 95
│       Data variables:
│           x          (obs_ind) float64 768B -2.063 -1.807 -1.804 ... 1.55 1.835 2.163
│           group_idx  (obs_ind) int32 384B 0 0 0 0 0 0 0 0 0 0 ... 3 3 3 3 3 3 3 3 3 3
│       Attributes:
│           created_at:                 2026-07-14T15:09:46.860851+00:00
│           creation_library:           ArviZ
│           creation_library_version:   1.2.0
│           creation_library_language:  Python
│           inference_library:          pymc
│           inference_library_version:  6.0.1
│           sample_dims:                []
├── Group: /prior
│       Dimensions:    (chain: 1, draw: 800, groups: 4)
│       Coordinates:
│         * chain      (chain) int64 8B 0
│         * draw       (draw) int64 6kB 0 1 2 3 4 5 6 7 ... 793 794 795 796 797 798 799
│         * groups     (groups) <U5 80B 'North' 'South' 'East' 'West'
│       Data variables:
│           intercept  (chain, draw, groups) float64 26kB -1.457 1.446 ... -0.5506
│           beta       (chain, draw, groups) float64 26kB -2.307 -0.09826 ... 3.153
│           sigma      (chain, draw) float64 6kB 1.121 1.051 0.8453 ... 0.1778 0.07849
│       Attributes:
│           created_at:                 2026-07-14T15:09:46.856860+00:00
│           creation_library:           ArviZ
│           creation_library_version:   1.2.0
│           creation_library_language:  Python
│           inference_library:          pymc
│           inference_library_version:  6.0.1
│           sample_dims:                ['chain', 'draw']
└── Group: /prior_predictive
        Attributes:
            created_at:                 2026-07-14T15:09:46.858772+00:00
            creation_library:           ArviZ
            creation_library_version:   1.2.0
            creation_library_language:  Python
            inference_library:          pymc
            inference_library_version:  6.0.1
            sample_dims:                ['chain', 'draw']

Use tidydraws

One call stacks both sources:

compare = td.compare_draws(dt, "beta")
compare.head()
shape: (5, 5)
chaindrawgroupsbetasource
i64i64strf64str
00"North"0.394303"posterior"
00"South"0.782"posterior"
00"East"1.384254"posterior"
00"West"-0.407587"posterior"
01"North"0.473318"posterior"

Plotting

Prior vs posterior (pooled)

The prior is deliberately broad; after seeing the data, the posterior concentrates around the group-specific slopes.

(
    lp.ggplot(compare.to_pandas(), lp.aes("beta", fill="source"))
    + lp.geom_density(alpha=0.5)
    + lp.geom_vline(
        data=truth.to_pandas(),
        mapping=lp.aes(xintercept="beta_true"),
        color="firebrick",
        linetype="dashed",
        size=0.7,
    )
    + lp.labs(x="beta", y="density", fill="source", title="Prior vs posterior slopes")
)

Prior vs posterior density of group-specific slopes, pooled across groups.

(
    p9.ggplot(compare.to_pandas(), p9.aes("beta", fill="source"))
    + p9.geom_density(alpha=0.5)
    + p9.geom_vline(
        data=truth.to_pandas(),
        mapping=p9.aes(xintercept="beta_true"),
        color="firebrick",
        linetype="dashed",
        size=0.7,
    )
    + p9.labs(x="beta", y="density", fill="source", title="Prior vs posterior slopes")
)

Prior vs posterior density of group-specific slopes, pooled across groups.

Prior vs posterior by group

(
    lp.ggplot(compare.to_pandas(), lp.aes("beta", fill="source"))
    + lp.geom_density(alpha=0.5)
    + lp.geom_vline(
        data=truth.to_pandas(),
        mapping=lp.aes(xintercept="beta_true"),
        color="firebrick",
        linetype="dashed",
        size=0.7,
    )
    + lp.facet_wrap(facets="groups", ncol=2)
    + lp.labs(x="beta", y="density", fill="source", title="Prior vs posterior by group")
)

Prior vs posterior density of group-specific slopes, faceted by group.

(
    p9.ggplot(compare.to_pandas(), p9.aes("beta", fill="source"))
    + p9.geom_density(alpha=0.5)
    + p9.geom_vline(
        data=truth.to_pandas(),
        mapping=p9.aes(xintercept="beta_true"),
        color="firebrick",
        linetype="dashed",
        size=0.7,
    )
    + p9.facet_wrap("~groups", ncol=2)
    + p9.labs(x="beta", y="density", fill="source", title="Prior vs posterior by group")
)

Prior vs posterior density of group-specific slopes, faceted by group.

Credible-interval comparison

A different cut: 89% intervals per group, dodged by source. The posterior intervals contract toward the true slopes while the prior intervals remain wide.

ci = td.point_interval(compare, "beta", group_by=["groups", "source"], probs=(0.89,))
(
    lp.ggplot(ci.to_pandas(), lp.aes("groups", "beta", color="source"))
    + lp.geom_pointrange(
        lp.aes(ymin="beta_lower", ymax="beta_upper"),
        position=lp.position_dodge(0.5),
        size=0.8,
    )
    + lp.geom_pointrange(
        data=truth.to_pandas(),
        mapping=lp.aes("groups", "beta_true", ymin="beta_true", ymax="beta_true"),
        color="firebrick",
        size=0.7,
    )
    + lp.geom_hline(yintercept=0, linetype="dashed", color="#888888")
    + lp.labs(
        x="group",
        y="beta (89% CrI)",
        color="source",
        title="Prior vs posterior credible intervals",
    )
)

Prior vs posterior 89% credible intervals of group-specific slopes.

(
    p9.ggplot(ci.to_pandas(), p9.aes("groups", "beta", color="source"))
    + p9.geom_pointrange(
        p9.aes(ymin="beta_lower", ymax="beta_upper"),
        position=p9.position_dodge(0.5),
        size=0.8,
    )
    + p9.geom_pointrange(
        data=truth.to_pandas(),
        mapping=p9.aes("groups", "beta_true", ymin="beta_true", ymax="beta_true"),
        color="firebrick",
        size=0.7,
    )
    + p9.geom_hline(yintercept=0, linetype="dashed", color="#888888")
    + p9.labs(
        x="group",
        y="beta (89% CrI)",
        color="source",
        title="Prior vs posterior credible intervals",
    )
)

Prior vs posterior 89% credible intervals of group-specific slopes.

group_name sets the source column’s name, and groups can compare other ArviZ groups when needed. Next, join posterior expectations to covariates with prediction_draws().