Skip to content

Commit 5ae7aab

Browse files
authored
Discrete Markov Chain example (#889)
1 parent ca54233 commit 5ae7aab

2 files changed

Lines changed: 1528 additions & 0 deletions

File tree

examples/mixture_models/discrete_markov_chain_marginalization.ipynb

Lines changed: 1175 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 353 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
1+
---
2+
jupytext:
3+
text_representation:
4+
extension: .md
5+
format_name: myst
6+
format_version: 0.13
7+
kernelspec:
8+
display_name: .venv
9+
language: python
10+
name: python3
11+
myst:
12+
substitutions:
13+
extra_dependencies: pymc-extras
14+
---
15+
16+
(discrete_markov_chain_marginalization)=
17+
# Hidden Markov Models in PyMC: marginalize and recover a `DiscreteMarkovChain`
18+
19+
:::{post} July, 2026
20+
:tags: hidden markov model, marginalization, time series, mixture model
21+
:category: intermediate, how-to
22+
:author: Juan Orduz, Ricardo Vieira
23+
:::
24+
25+
A hidden Markov model (HMM) describes a system that moves through a sequence of hidden discrete states, where each state emits a noisy observation. We never see the states directly; we only see the emissions, and we want to reason backward to the states that most likely produced them.
26+
27+
This notebook works through a tiny weather example: the hidden state is whether a day is rainy or sunny, the weather is "sticky" (it tends to persist from one day to the next), and our only measurement is a noisy thermometer. From the temperatures alone we will recover the most likely sequence of hidden weather states, together with our uncertainty about it.
28+
29+
The classic HMM inference questions are the evidence (how likely is the observed data), filtering and smoothing (the posterior over hidden states given the data), and decoding (the single most likely state path). This notebook focuses on the smoothing posterior, obtained through two composable tools from `pymc_extras`. For a general introduction to automatic marginalization of discrete variables, see {ref}`marginalizing-models`.
30+
31+
This example showcases three additions that together make HMM inference in PyMC turnkey:
32+
33+
1. **Time-varying transition matrices.** `DiscreteMarkovChain` accepts `time_varying_P=True`, so `P` can carry one transition matrix per step with shape `(*batch, steps, k, k)`.
34+
2. **Marginalization of a whole chain.** `marginalize` can integrate a `DiscreteMarkovChain` out of a model analytically (the forward algorithm), leaving only continuous parameters for NUTS to sample.
35+
3. **Recovery of the hidden states.** `recover` (and the lower-level `conditional`) can reconstruct the posterior over a marginalized `DiscreteMarkovChain`. The trick that makes this work is that the conditional posterior of the chain is itself a time-inhomogeneous `DiscreteMarkovChain`, which is exactly why the time-varying `P` support from point 1 is needed.
36+
37+
+++
38+
39+
## Prepare Notebook
40+
41+
```{code-cell} ipython3
42+
import arviz as az
43+
import matplotlib.pyplot as plt
44+
import numpy as np
45+
import pymc as pm
46+
47+
from pymc_extras.distributions import DiscreteMarkovChain
48+
from pymc_extras.marginal import conditional, marginalize, recover
49+
```
50+
51+
```{code-cell} ipython3
52+
%config InlineBackend.figure_format = 'retina'
53+
az.style.use("arviz-darkgrid")
54+
plt.rcParams["figure.figsize"] = [10, 6]
55+
plt.rcParams["figure.dpi"] = 100
56+
plt.rcParams["figure.facecolor"] = "white"
57+
rng = np.random.default_rng(42)
58+
```
59+
60+
## Why hidden discrete states are hard
61+
62+
PyMC's default sampler is NUTS, a gradient-based method. Gradients only exist for continuous parameters, so NUTS cannot move a discrete latent variable such as our per-day weather state. Historically this left a few options: sample the discrete states with a bespoke Gibbs or Metropolis step (which mixes slowly and couples awkwardly to the continuous parameters), or hand-write the forward-backward recursions for the specific model at hand.
63+
64+
Marginalization offers a cleaner path. Because the hidden states are discrete and finite, we can sum them out of the likelihood exactly. NUTS then samples only the smooth, continuous parameters (here the observation noise), and we reconstruct the hidden states afterward from their exact conditional posterior. The two steps below, `marginalize` and `recover`, automate exactly this.
65+
66+
+++
67+
68+
## The weather and the thermometer
69+
70+
We describe eight days of weather with a single generative model and draw one realization from it. The hidden state is `0` for rain and `1` for sun, and the weather is sticky: each day it stays the same with probability `0.9`. The thermometer reads about 0 degrees Celsius on rainy days and about 20 degrees on sunny days, corrupted by Gaussian noise with standard deviation `true_sigma`.
71+
72+
Rather than writing the data-generating process by hand and then again as a PyMC model, we build the model once, clamp `sigma` to its true value with `pm.do`, and take a single prior draw. This gives us both the hidden weather path (`true_sunny_day`) and the observed temperatures (`obs_temp`) from one source of truth.
73+
74+
```{code-cell} ipython3
75+
# Hidden weather: 0 = Rain, 1 = Sun. Weather is "sticky": it tends to persist.
76+
P = np.array(
77+
[
78+
[0.9, 0.1], # Rain -> [Rain, Sun]
79+
[0.1, 0.9], # Sun -> [Rain, Sun]
80+
]
81+
)
82+
83+
n = 8
84+
days = np.arange(n)
85+
true_sigma = 4.0
86+
87+
# A single generative model: a sticky Markov chain over the hidden weather with a
88+
# noisy thermometer emission. We reuse this exact model for inference below.
89+
with pm.Model(coords={"day": days}) as generative_model:
90+
sigma = pm.HalfNormal("sigma", 5.0)
91+
init = pm.Categorical.dist(p=[0.5, 0.5]) # day-1 prior: 50/50
92+
sunny_day = DiscreteMarkovChain("sunny_day", P=P, init_dist=init, dims="day")
93+
pm.Normal("temp", mu=sunny_day * 20.0, sigma=sigma, dims="day")
94+
95+
# Draw a single realization with the observation noise clamped to a known value.
96+
prior_draw = pm.sample_prior_predictive(
97+
model=pm.do(generative_model, {"sigma": true_sigma}),
98+
draws=1,
99+
var_names=["sunny_day", "temp"],
100+
random_seed=156,
101+
)
102+
true_sunny_day = prior_draw.prior["sunny_day"].sel(chain=0, draw=0).to_numpy()
103+
obs_temp = prior_draw.prior["temp"].sel(chain=0, draw=0).to_numpy()
104+
105+
obs_temp.round(2)
106+
```
107+
108+
Let's plot the data.
109+
110+
```{code-cell} ipython3
111+
fig, ax = plt.subplots()
112+
point_colors = np.where(true_sunny_day == 1, "C1", "C0")
113+
ax.plot(days, obs_temp, color="gray", lw=1, zorder=2)
114+
ax.scatter(days, obs_temp, c=point_colors, s=90, zorder=3)
115+
ax.axhline(0, ls="--", color="C0", alpha=0.6, label="Rain mean (0C)")
116+
ax.axhline(20, ls="--", color="C1", alpha=0.6, label="Sun mean (20C)")
117+
ax.legend()
118+
ax.set(
119+
xlabel="Day",
120+
ylabel="Temperature (C)",
121+
title="Observed temperatures (orange = true Sun, blue = true Rain)",
122+
);
123+
```
124+
125+
## The model
126+
127+
We reuse the same generative model for inference; the only change is that the temperatures are now observed. `pm.observe` takes the generative model and conditions it on the drawn `obs_temp`, so we never rewrite the model a second time.
128+
129+
The model places a weakly informative prior on the observation noise, a uniform prior on the first day's weather, and a `DiscreteMarkovChain` prior on the sequence of hidden states. The likelihood ties each day's temperature to its hidden state through a mean of `state * 20` degrees.
130+
131+
The transition matrix `P` is known and fixed here, so we can focus on the marginalize and recover mechanics. In a real application `P` would typically be given its own `Dirichlet` prior.
132+
133+
```{code-cell} ipython3
134+
# Reuse the generative model, conditioning on the temperatures we drew above.
135+
model = pm.observe(generative_model, {"temp": obs_temp})
136+
137+
pm.model_to_graphviz(model)
138+
```
139+
140+
## How marginalization works
141+
142+
Write $x_{1:n}$ for the hidden states and $y_{1:n}$ for the temperatures. The HMM factorizes the joint distribution into an initial state, a product of transitions, and a product of emissions:
143+
144+
$$
145+
p(x_{1:n}, y_{1:n} \mid \sigma) = p(x_1) \, \prod_{t=2}^{n} p(x_t \mid x_{t-1}) \, \prod_{t=1}^{n} p(y_t \mid x_t, \sigma).
146+
$$
147+
148+
To sample the continuous parameter $\sigma$ with NUTS we need the marginal likelihood $p(y_{1:n} \mid \sigma)$, which sums the joint over every possible state path. Enumerating all $k^n$ paths is infeasible, but the forward algorithm computes the sum in linear time by carrying forward a vector of partial sums $\alpha_t(i) = p(y_{1:t}, x_t = i \mid \sigma)$:
149+
150+
$$
151+
\alpha_1(i) = p(x_1 = i) \, p(y_1 \mid x_1 = i, \sigma), \qquad \alpha_t(j) = p(y_t \mid x_t = j, \sigma) \sum_{i} \alpha_{t-1}(i) \, p(x_t = j \mid x_{t-1} = i).
152+
$$
153+
154+
The evidence is then $p(y_{1:n} \mid \sigma) = \sum_i \alpha_n(i)$.
155+
156+
157+
```{code-cell} ipython3
158+
marginal_model = marginalize(model, ["sunny_day"])
159+
pm.model_to_graphviz(marginal_model)
160+
```
161+
162+
The hidden chain is gone from the graph: only the continuous `sigma` and the observed `temp` remain. This is the model NUTS actually samples.
163+
164+
```{code-cell} ipython3
165+
idata = pm.sample(model=marginal_model, target_accept=0.85, random_seed=rng)
166+
```
167+
168+
```{code-cell} ipython3
169+
pc = az.plot_trace_dist(idata, var_names=["sigma"], figure_kwargs={"figsize": (12, 4)})
170+
fig = pc.viz["figure"].values.item()
171+
axes = fig.axes
172+
axes[0].axvline(true_sigma, color="k", ls="--", label=r"True $\sigma$")
173+
axes[0].legend(loc="upper right")
174+
axes[1].axhline(true_sigma, color="k", ls="--", label=r"True $\sigma$")
175+
axes[1].legend(loc="upper right")
176+
fig.suptitle(r"Posterior distribution of $\sigma$", fontsize=18, fontweight="bold");
177+
```
178+
179+
The posterior for `sigma` is smooth and well behaved because the discrete states are no longer part of the sampled space. The value used to simulate the data was `4.0`.
180+
181+
+++
182+
183+
## How recovery works
184+
185+
Marginalizing removed the hidden states, but the states are usually what we actually care about. We want the smoothing posterior $p(x_t \mid y_{1:n}, \sigma)$, the probability of each hidden state given all of the data and the sampled parameters.
186+
187+
The forward pass above gives $\alpha_t$. A backward pass gives $\beta_t(i) = p(y_{t+1:n} \mid x_t = i, \sigma)$, and combining the two yields the per-step smoothing posterior. Crucially, the full conditional $p(x_{1:n} \mid y_{1:n}, \sigma)$ is itself a Markov chain, but one whose transition matrices change from step to step. This is why time-varying transitions matter: the recovered chain is time-inhomogeneous even when the original chain was homogeneous.
188+
189+
```{code-cell} ipython3
190+
recover(idata, model=marginal_model, random_seed=rng)
191+
192+
p_sun = idata.posterior["sunny_day"].mean(("chain", "draw")).values
193+
194+
print("true Sun: ", true_sunny_day.tolist())
195+
print("recovered P(Sun) per day: ", [float(round(p, 2)) for p in p_sun])
196+
```
197+
198+
```{code-cell} ipython3
199+
az.plot_forest(idata, var_names=["sunny_day"], combined=True, figure_kwargs={"figsize": (8, 4)});
200+
```
201+
202+
We can plot the recovered probabilities of sun per day.
203+
204+
```{code-cell} ipython3
205+
fig, ax = plt.subplots(figsize=(9, 4))
206+
ax.step(days, true_sunny_day, where="mid", color="C2", lw=2, label="true Sun (1) / Rain (0)")
207+
ax.plot(days, p_sun, "o-", color="C3", label="recovered P(Sun)")
208+
ax.legend()
209+
ax.set(
210+
xlabel="Day",
211+
ylabel="P(Sun)",
212+
ylim=(-0.05, 1.05),
213+
title="Recovered probability of Sun per day",
214+
);
215+
```
216+
217+
The recovered probabilities snap close to the true path: the model is confident, and correct, about which days were sunny, even though it only ever saw noisy temperatures.
218+
219+
If you need the conditional distribution as a model object rather than posterior draws, `conditional` gives it to you directly. The recovered `sunny_day` is now a free random variable whose distribution is the conditional posterior.
220+
221+
```{code-cell} ipython3
222+
cond_model = conditional(marginal_model)
223+
cond_model.free_RVs
224+
```
225+
226+
## Time-varying transitions: a change in the weather regime
227+
228+
So far the weather was equally sticky on every day: a single transition matrix `P` governed all of the days. Real weather is not like that. A settled high-pressure spell can hold the same conditions for a week, and then a front arrives and the weather turns unsettled, flipping between sun and rain from one day to the next.
229+
230+
We can capture this by letting the transition matrix change over time. The `time_varying_P=True` flag lets `P` carry one transition matrix per step, with shape `(steps, k, k)` for a single chain (or `(*batch, steps, k, k)` with batch dimensions), where `steps` is the number of transitions, one fewer than the number of days.
231+
232+
We continue the weather story over a longer window. The first stretch is a stable, sticky spell; then a front arrives at `changepoint` and the weather becomes unsettled, close to a coin flip each day.
233+
234+
```{code-cell} ipython3
235+
n_season = 14
236+
days_season = np.arange(n_season)
237+
changepoint = 7 # a weather front arrives, splitting the window into two regimes
238+
239+
# Two transition regimes for the hidden weather.
240+
P_stable = np.array([[0.95, 0.05], [0.05, 0.95]]) # settled: weather persists
241+
P_unsettled = np.array([[0.5, 0.5], [0.5, 0.5]]) # stormy: sun and rain flip freely
242+
243+
# One transition matrix per step: stable before the front, unsettled after.
244+
P_time = np.stack([P_stable] * changepoint + [P_unsettled] * (n_season - 1 - changepoint))
245+
P_time.shape # (steps, k, k) with steps = n_season - 1
246+
```
247+
248+
```{code-cell} ipython3
249+
# The same generative model as before, now with a time-varying transition matrix.
250+
with pm.Model(coords={"day": days_season}) as season_generative_model:
251+
sigma = pm.HalfNormal("sigma", 5.0)
252+
init = pm.Categorical.dist(p=[0.5, 0.5])
253+
sunny_day = DiscreteMarkovChain(
254+
"sunny_day", P=P_time, init_dist=init, time_varying_P=True, dims="day"
255+
)
256+
pm.Normal("temp", mu=sunny_day * 20.0, sigma=sigma, dims="day")
257+
258+
# A single prior draw of the weather: long runs during the settled spell, then
259+
# rapid switching once the front arrives. The noise is clamped to true_sigma.
260+
season_prior_draw = pm.sample_prior_predictive(
261+
model=pm.do(season_generative_model, {"sigma": true_sigma}),
262+
draws=1,
263+
var_names=["sunny_day", "temp"],
264+
random_seed=23,
265+
)
266+
true_sunny_day_season = season_prior_draw.prior["sunny_day"].sel(chain=0, draw=0).to_numpy()
267+
obs_temp_season = season_prior_draw.prior["temp"].sel(chain=0, draw=0).to_numpy()
268+
269+
true_sunny_day_season.tolist()
270+
```
271+
272+
The single prior draw above gives us a known weather sequence that spans both regimes together with its noisy thermometer readings. We condition on those readings with `pm.observe` and then marginalize, sample, and recover exactly as in the first example. Nothing about the workflow changes; only `P` is now time-varying.
273+
274+
```{code-cell} ipython3
275+
# Reuse the generative model, conditioning on the temperatures drawn above.
276+
season_model = pm.observe(season_generative_model, {"temp": obs_temp_season})
277+
278+
pm.model_to_graphviz(season_model)
279+
```
280+
281+
We now marginalize and sample.
282+
283+
```{code-cell} ipython3
284+
season_marginal = marginalize(season_model, ["sunny_day"])
285+
season_idata = pm.sample(model=season_marginal, target_accept=0.85, random_seed=rng)
286+
recover(season_idata, model=season_marginal, random_seed=rng)
287+
288+
p_sun_season = season_idata.posterior["sunny_day"].mean(("chain", "draw")).values
289+
print("true Sun: ", true_sunny_day_season.tolist())
290+
print("recovered P(Sun) per day: ", [float(round(p, 2)) for p in p_sun_season])
291+
```
292+
293+
Let's plot the recovered probabilities of sun per day.
294+
295+
```{code-cell} ipython3
296+
fig, ax = plt.subplots(figsize=(12, 7))
297+
ax.step(
298+
days_season,
299+
true_sunny_day_season,
300+
where="mid",
301+
color="C2",
302+
lw=2,
303+
label="true Sun (1) / Rain (0)",
304+
)
305+
ax.plot(days_season, p_sun_season, "o-", color="C3", label="recovered P(Sun)")
306+
ax.axvline(changepoint - 0.5, ls="--", color="gray", label="front arrives")
307+
ax.text(changepoint / 2, 1.12, "settled", size=16, ha="center", va="center", color="black")
308+
ax.text(
309+
(changepoint + n_season) / 2,
310+
1.12,
311+
"unsettled",
312+
size=16,
313+
ha="center",
314+
va="center",
315+
color="black",
316+
)
317+
ax.legend(loc="center left")
318+
ax.set(
319+
xlabel="Day",
320+
ylabel="P(Sun)",
321+
ylim=(-0.05, 1.25),
322+
title="Recovered probability of Sun per day across a regime change",
323+
);
324+
```
325+
326+
Recovery works identically with time-varying transitions: `recover` does not care whether `P` was shared across days or changed from day to day. The recovered probabilities stay sharp across both regimes because the thermometer strongly identifies each day on its own. In the settled spell the sticky transitions reinforce the emissions, while in the unsettled stretch the transitions carry little information, so the recovered states lean almost entirely on the temperatures.
327+
328+
+++
329+
330+
## Summary
331+
332+
We inferred a hidden weather sequence from noisy temperatures using three steps:
333+
334+
1. Build the model with a `DiscreteMarkovChain` prior over the hidden states.
335+
2. `marginalize` the chain so NUTS samples only the continuous parameters, using the forward algorithm under the hood.
336+
3. `recover` the hidden states from their exact conditional posterior, which is itself a time-inhomogeneous `DiscreteMarkovChain`.
337+
338+
+++
339+
340+
## Authors
341+
- Authored by [Juan Orduz](https://juanitorduz.github.io/) and [Ricardo Vieira](https://github.com/ricardoV94) in July 2026
342+
343+
+++
344+
345+
## Watermark
346+
347+
```{code-cell} ipython3
348+
%load_ext watermark
349+
%watermark -n -u -v -iv -w -p pytensor
350+
```
351+
352+
:::{include} ../page_footer.md
353+
:::

0 commit comments

Comments
 (0)