Dynamic occupancy model with pymc_extras

Good afternoon!

I’m writing to see if y’all think that this dynamic occupancy model would be a good fit for DiscreteMarkovChain and marginalize in the PyMC Extras package. In this model, we detect animals at occupied, z=1, sites (aka patches) with probability p. We assume that animals can’t be detected at unoccupied, z=0, sites. Between seasons, unoccupied sites can be colonized with probability \gamma, and occupied sites can go extinct with probability \epsilon. Within a season, sites are surveyed and closed to colonization / extinction dynamics. Below is some simulation code, and an example of how to fit the model in NumPyro. While the NumPyro version works great, I’m curious if this would be a good fit for the goodies in pymc-extras. Personally I find PyMC syntax a little more digestible.

Thanks in advance for your help!
Phil

from jax import random
from numpyro.contrib.control_flow import scan
from numpyro.infer import NUTS, MCMC, Predictive
import arviz as az
import jax.numpy as jnp
import numpy as np
import numpyro
import numpyro.distributions as dist

# hyperparameters
RANDOM_SEED = 1792

## true values for colext model
PSI_TRUE = 0.6
EPSILON_TRUE = 0.3
GAMMA_TRUE = 0.15
P_TRUE = 0.4
SITE_COUNT = 250
SURVEY_COUNT = 3
SEASON_COUNT = 10
interval_count = SEASON_COUNT - 1

def simulate_data():
    """Simulate detection/non-detection data from a dynamic occupancy model"""

    rng = np.random.default_rng(RANDOM_SEED)

    # empty array to fill in the occupancy states later
    z = np.zeros((SITE_COUNT, SEASON_COUNT), dtype=int)

    # initial values for the occupancy state
    z[:, 0] = rng.binomial(n=1, p=PSI_TRUE, size=SITE_COUNT)

    # simulate transitions
    for t in range(1, SEASON_COUNT):

        # patches can be colonized, go extinct, remain occupied, or remain unoccupied
        mu_z = z[:, t-1] * (1 - EPSILON_TRUE) + (1 - z[:, t-1]) * GAMMA_TRUE
        z[:, t] = rng.binomial(n=1, p=mu_z)

    # simulate detection non-detection data
    mu_x = z * P_TRUE
    x = rng.binomial(n=1, p=mu_x[:, :, None],
                    size=(SITE_COUNT, SEASON_COUNT, SURVEY_COUNT))
    return x

def dynamic_occupancy(detection_history):
    '''Dynamic occupancy model in NumPyro.'''
    site_count, season_count, survey_count = detection_history.shape

    # scalar priors for the four probabilistic parameters
    psi = numpyro.sample("psi", dist.Uniform(0, 1))  # initial occupancy prob
    gamma = numpyro.sample("gamma", dist.Uniform(0, 1)) # colonization prob
    epsilon = numpyro.sample("epsilon", dist.Uniform(0, 1)) # extinction prob
    p = numpyro.sample("p", dist.Uniform(0, 1))  # recapture prob

    def transition_and_detect(carry, y_t):
        """Transitions betweens states and defines the likelihood."""

        # unpack the values that are returned from the transition function at
        # the previous time step
        z_prev, t = carry

        # transition the latent state at every site
        with numpyro.plate("sites", site_count):

            # probability of transitioning according to the previous state
            mu_z_t = z_prev * (1 - epsilon) + (1 - z_prev) * gamma

            # dist.util.clamp_probs() helps the sampler avoid boundary regions
            z = numpyro.sample(
                "z",
                dist.Bernoulli(dist.util.clamp_probs(mu_z_t)),
                infer={"enumerate": "parallel"}, # this is where we marginalize!
            )

            # the likelihood of each observation at each site
            mu_y = z * p
            with numpyro.plate('surveys', survey_count):
                numpyro.sample(
                    "y",
                    dist.Bernoulli(dist.util.clamp_probs(mu_y)),
                    obs=y_t.T
                )

            # carry forward the current z state and incremented time index
            # None indicates we don't return/accumulate any outputs from scan
            return (z, t + 1), None

    # the initial state only depends on psi
    with numpyro.plate('sites', site_count):
        z0 = numpyro.sample(
            "z0",
            dist.Bernoulli(dist.util.clamp_probs(psi)),
            infer={"enumerate": "parallel"},
        )

        # compute the likelihood of the detection data for just the first season
        mu_y = z0 * p
        with numpyro.plate('surveys', survey_count):
            numpyro.sample(
                "y0",
                dist.Bernoulli(dist.util.clamp_probs(mu_y)),
                obs=detection_history[:, 0].T # just the first occasion!
            )

    # now we scan (or apply) the transition function across the remaining seasons
    scan(
        transition_and_detect,                        # function to scan
        (z0, 0),                                      # initial states
        jnp.swapaxes(detection_history[:, 1:], 0, 1), # scan across first dimension of data
    )

detections = simulate_data()
rng_key = random.PRNGKey(RANDOM_SEED)

# specify which sampler you want to use
nuts_kernel = NUTS(dynamic_occupancy)

# configure the MCMC run
mcmc = MCMC(nuts_kernel, num_warmup=500, num_samples=1000, num_chains=4)

# run the MCMC then inspect the output
mcmc.run(rng_key, detections)
mcmc.print_summary()

I think it would work

It would make a good case study for pymc-examples :slight_smile:

Awesome! Well, funnily enough, the act of asking the question seemed to solve my problem! I wasn’t able to get a previous version the code below to work. But it turns out I was just missing the shape argument. Now it’s working and returns parameters.

I would be happy to add this case study to pymc-examples if that would be helpful (I actually have several other ecological examples in PyMC handy). Sadly I know nothing about the process. What does it entail? Do I just submit a Jupyter Notebook as a pull request to pymc-examples?

with pm.Model() as colext:

    # simple model with constant probabilities
    psi = pm.Uniform('ψ', 0, 1)
    epsilon = pm.Uniform('ε', 0, 1)
    gamma = pm.Uniform('γ', 0, 1)
    p = pm.Uniform('p', 0, 1)

    transition_matrix = pt.stack(
            [[1 - gamma,       gamma],
             [  epsilon, 1 - epsilon]], axis=0
    )

    # initial occupancy only depends on psi
    initial_distribution = pm.Bernoulli.dist(psi, shape=SITE_COUNT)

    Z = pmx.DiscreteMarkovChain(
        'Z',
        transition_matrix,
        steps=interval_count,
        init_dist=initial_distribution,
        shape=(SITE_COUNT, SEASON_COUNT)
    )

    mu_x = Z[:, :, None] * p
    pm.Bernoulli('x', mu_x, observed=x)

colext_marginal = pmx.marginalize(colext, ['Z'])

with colext_marginal:
    idata = pm.sample()
2 Likes

That’s quite readable in the end.

To publish to pymc-examples it is suggested you first open a PR that gives some reviewers context for the Notebook. There’s a specific template for new notebook propoosals: GitHub · Where software is built

Then open a PR following these guidelines (somethings are unrelated to you, just skim): GitHub - pymc-devs/pymc-examples: Examples of PyMC models, including a library of Jupyter notebooks.

You can tag me or @jessegrabowski for review. There are some formatting guidelines / gotchas but nothing too cumbersome (I hope).

Ecology examples would be superb. Just check there’s nothing too similar already. If there is we can update/merge/replace if your material is better.

Hey Y’all! Super excited to see the time-varying parameters added to DiscreteMarkovChain! This opens up a lot of possibilities for classical ecological models (e.g., capture-recapture).

I know that the marginalize portion of PyMC-extras is undergoing some change, but I just wanted to flag anyway that the code above no longer seems to work. I tried a fresh re-install of pymc (version 6.1.0) and pymc-extras (version 0.12.0) and got this error message:

---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[1], line 76
     72 
     73 colext_marginal = pmx.marginalize(colext, ['Z'])
     74 
     75 with colext_marginal:
---> 76     idata = pm.sample()
     77 
     78 az.plot_trace(idata)

File ~/source/repos/popan/.pixi/envs/default/lib/python3.14/site-packages/pymc/sampling/mcmc.py:917, in sample(draws, tune, chains, cores, random_seed, progressbar, progressbar_theme, quiet, step, var_names, nuts_sampler, initvals, init, jitter_max_retries, n_init, trace, discard_tuned_samples, compute_convergence_checks, keep_warning_stat, return_inferencedata, idata_kwargs, nuts_sampler_kwargs, callback, mp_ctx, blas_cores, model, backend, compile_kwargs, **kwargs)
    914         msg = f"Only {draws} samples per chain. Reliable r-hat and ESS diagnostics require longer chains for accurate estimate."
    915         _log.warning(msg)
--> 917 provided_steps, selected_steps = assign_step_methods(model, step, methods=STEP_METHODS)
    918 exclusive_nuts = (
    919     # User provided an instantiated NUTS step, and nothing else is needed
    920     (not selected_steps and len(provided_steps) == 1 and isinstance(provided_steps[0], NUTS))
   (...)    927     )
    928 )
    930 if nuts_sampler is None:
    931     # Try to use nutpie by default if no setting is clearly at odds.
    932     # Requires all model variables, numba or jax preference,
    933     # and must not conflict with pymc sample-only arguments.

File ~/source/repos/popan/.pixi/envs/default/lib/python3.14/site-packages/pymc/sampling/mcmc.py:324, in assign_step_methods(model, step, methods)
    322 methods_list: list[type[BlockedStep]] = list(methods or STEP_METHODS)
    323 selected_steps: dict[type[BlockedStep], list] = {}
--> 324 model_logp = model.logp()
    326 for var in model.value_vars:
    327     if var not in assigned_vars:
    328         # determine if a gradient can be computed

File ~/source/repos/popan/.pixi/envs/default/lib/python3.14/site-packages/pymc/model/core.py:728, in Model.logp(self, vars, jacobian, sum)
    726 rv_logps: list[TensorVariable] = []
    727 if rvs:
--> 728     rv_logps = transformed_conditional_logp(
    729         rvs=rvs,
    730         rvs_to_values=self.rvs_to_values,
    731         rvs_to_transforms=self.rvs_to_transforms,
    732         jacobian=jacobian,
    733     )
    734     assert isinstance(rv_logps, list)
    736 # Replace random variables by their value variables in potential terms

File ~/source/repos/popan/.pixi/envs/default/lib/python3.14/site-packages/pymc/logprob/basic.py:642, in transformed_conditional_logp(rvs, rvs_to_values, rvs_to_transforms, jacobian, **kwargs)
    639     transform_rewrite = TransformValuesRewrite(values_to_transforms)  # type: ignore[arg-type]
    641 kwargs.setdefault("warn_rvs", False)
--> 642 temp_logp_terms = conditional_logp(
    643     rvs_to_values,
    644     extra_rewrites=transform_rewrite,
    645     use_jacobian=jacobian,
    646     **kwargs,
    647 )
    649 # The function returns the logp for every single value term we provided to it.
    650 # This includes the extra values we plugged in above, so we filter those we
    651 # actually wanted in the same order they were given in.
    652 logp_terms = {}

File ~/source/repos/popan/.pixi/envs/default/lib/python3.14/site-packages/pymc/logprob/basic.py:572, in conditional_logp(rv_values, warn_rvs, ir_rewriter, extra_rewrites, **kwargs)
    569 node_values = remapped_vars[: len(node_values)]
    570 node_inputs = remapped_vars[len(node_values) :]
--> 572 node_logprobs = _logprob(
    573     node.op,
    574     node_values,
    575     *node_inputs,
    576     **kwargs,
    577 )
    579 if not isinstance(node_logprobs, list | tuple):
    580     node_logprobs = [node_logprobs]

File ~/source/repos/popan/.pixi/envs/default/lib/python3.14/functools.py:982, in singledispatch.<locals>.wrapper(*args, **kw)
    979 if not args:
    980     raise TypeError(f'{funcname} requires at least '
    981                     '1 positional argument')
--> 982 return dispatch(args[0].__class__)(*args, **kw)

File ~/source/repos/popan/.pixi/envs/default/lib/python3.14/site-packages/pymc_extras/model/marginal/distributions.py:308, in marginal_hmm_logp(op, values, *inputs, **kwargs)
    306 @_logprob.register(MarginalDiscreteMarkovChainRV)
    307 def marginal_hmm_logp(op, values, *inputs, **kwargs):
--> 308     chain_rv, *dependent_rvs = inline_ofg_outputs(op, inputs)
    310     P, n_steps_, init_dist_, rng = chain_rv.owner.inputs
    311     domain = pt.arange(P.shape[-1], dtype="int32")

File ~/source/repos/popan/.pixi/envs/default/lib/python3.14/site-packages/pymc_extras/model/marginal/distributions.py:217, in inline_ofg_outputs(op, inputs)
    211 def inline_ofg_outputs(op: OpFromGraph, inputs: Sequence[Variable]) -> tuple[Variable]:
    212     """Inline the inner graph (outputs) of an OpFromGraph Op.
    213 
    214     Whereas `OpFromGraph` "wraps" a graph inside a single Op, this function "unwraps"
    215     the inner graph.
    216     """
--> 217     return graph_replace(
    218         op.inner_outputs,
    219         replace=tuple(zip(op.inner_inputs, inputs)),
    220         strict=False,
    221     )

File ~/source/repos/popan/.pixi/envs/default/lib/python3.14/site-packages/pytensor/graph/replace.py:160, in graph_replace(outputs, replace, strict)
    156 equiv = {c: c.clone(name=f"i-{i}") for i, c in enumerate(conditions)}
    157 # some replace keys may disappear
    158 # the reason is they are outside the graph
    159 # clone the graph but preserve the equiv mapping
--> 160 fg = FunctionGraph(
    161     conditions,
    162     outputs,
    163     # clone_get_equiv kwargs
    164     copy_orphans=False,
    165     copy_inputs=False,
    166     memo=equiv,
    167 )
    168 # replace the conditions back
    169 fg_replace = {equiv[c]: c for c in conditions}

File ~/source/repos/popan/.pixi/envs/default/lib/python3.14/site-packages/pytensor/graph/fg.py:139, in FunctionGraph.__init__(self, inputs, outputs, features, clone, update_mapping, **clone_kwds)
    134     inputs = [
    135         i for i in graph_inputs(outputs) if not isinstance(i, AtomicVariable)
    136     ]
    138 if clone:
--> 139     _memo = clone_get_equiv(
    140         inputs,
    141         outputs,
    142         **clone_kwds,
    143     )
    144     outputs = [cast(Variable, _memo[o]) for o in outputs]
    145     inputs = [cast(Variable, _memo[i]) for i in inputs]

File ~/source/repos/popan/.pixi/envs/default/lib/python3.14/site-packages/pytensor/graph/basic.py:1057, in clone_get_equiv(inputs, outputs, copy_inputs, copy_orphans, memo, clone_inner_graphs, **kwargs)
   1054             else:
   1055                 memo[input] = input
-> 1057     clone_node_and_cache(apply, memo, **kwargs)
   1059 # finish up by cloning any remaining outputs (it can happen)
   1060 for output in outputs:

File ~/source/repos/popan/.pixi/envs/default/lib/python3.14/site-packages/pytensor/graph/basic.py:980, in clone_node_and_cache(node, clone_d, **kwargs)
    976     return None
    978 cloned_inputs: list[Variable] = [cast(Variable, clone_d[i]) for i in node.inputs]
--> 980 new_node = node.clone_with_new_inputs(cloned_inputs, **kwargs)
    982 clone_d[node] = new_node
    984 for old_o, new_o in zip(node.outputs, new_node.outputs, strict=True):

AttributeError: 'FrozenApply' object has no attribute 'clone_with_new_inputs'

Here was the code block.

import arviz as az
import numpy as np
import pymc as pm
import pymc_extras as pmx 
import pytensor.tensor as pt

# hyperparameters
RANDOM_SEED = 1792

## true values for colext model
PSI_TRUE = 0.6
EPSILON_TRUE = 0.3
GAMMA_TRUE = 0.15
P_TRUE = 0.4
SITE_COUNT = 250
SURVEY_COUNT = 3
SEASON_COUNT = 10
interval_count = SEASON_COUNT - 1

def simulate_data():
    """Simulate detection/non-detection data from a dynamic occupancy model"""

    rng = np.random.default_rng(RANDOM_SEED)

    # empty array to fill in the occupancy states later
    z = np.zeros((SITE_COUNT, SEASON_COUNT), dtype=int)

    # initial values for the occupancy state
    z[:, 0] = rng.binomial(n=1, p=PSI_TRUE, size=SITE_COUNT)

    # simulate transitions
    for t in range(1, SEASON_COUNT):

        # patches can be colonized, go extinct, remain occupied, or remain unoccupied
        mu_z = z[:, t-1] * (1 - EPSILON_TRUE) + (1 - z[:, t-1]) * GAMMA_TRUE
        z[:, t] = rng.binomial(n=1, p=mu_z)

    # simulate detection non-detection data
    mu_x = z * P_TRUE
    x = rng.binomial(n=1, p=mu_x[:, :, None],
                    size=(SITE_COUNT, SEASON_COUNT, SURVEY_COUNT))
    return x

x = simulate_data()

with pm.Model() as colext:

    # simple model with constant probabilities
    psi = pm.Uniform('ψ', 0, 1)
    epsilon = pm.Uniform('ε', 0, 1)
    gamma = pm.Uniform('γ', 0, 1)
    p = pm.Uniform('p', 0, 1)

    transition_matrix = pt.stack(
            [[1 - gamma,       gamma],
             [  epsilon, 1 - epsilon]], axis=0
    )

    # initial occupancy only depends on psi
    initial_distribution = pm.Bernoulli.dist(psi, shape=SITE_COUNT)

    Z = pmx.DiscreteMarkovChain(
        'Z',
        transition_matrix,
        steps=interval_count,
        init_dist=initial_distribution,
        shape=(SITE_COUNT, SEASON_COUNT)
    )

    mu_x = Z[:, :, None] * p
    pm.Bernoulli('x', mu_x, observed=x)

colext_marginal = pmx.marginalize(colext, ['Z'])

with colext_marginal:
    idata = pm.sample()

az.plot_trace(idata)

We are in the process of making the dependencies compatible (and pinning it strictly, pymc-extras was too losely pinned, now that’s a bit less experimental we are going to pin better): Compatibility with PyTensor v3.1 and PyMC v6.1 by ricardoV94 · Pull Request #708 · pymc-devs/pymc-extras · GitHub

Also trying to support higher lags: Support marginalization of HMM with lags>1 by ricardoV94 · Pull Request #707 · pymc-devs/pymc-extras · GitHub

Feedback on the time-varing would be nice. For now we focused only on behavior and haven’t benchmarked / optimized for speed.

I’m curious how people actually want to use time-varying, is it okay to expect a dense vector of transition probabilities, or would a function that creates the transition per iteration (based on the counter / state) be more natural?

The reason we added it was to support conditional distribution after marginalization, which require time-varying Ps

Awesome!! I’m glad to hear that it’s all coming together.

That’s a great question. Ecologists like to model the transition probabilities with predictors (e.g., weather), or as time-varying fixed (or random) effects like

def dynamic_occupancy_model(data):

    ...

    with numpyro.plate('seasons', season_count - 1):
        gamma = numpyro.sample('gamma', dist.Uniform(0, 1))
        epsilon = numpyro.sample('epsilon', dist.Normal(0, 1)) 

    def transition_and_observe(carry, x_current):

        z_previous, t = carry

        # transition probability matrix
        trans_probs = jnp.array([
            [1 - gamma[t],       gamma[t]], 
            [  epsilon[t], 1 - epsilon[t]]  
        ])

        with numpyro.plate('sites', site_count):

            ...

Because there’s spatial replication, they also frequently model the effect of predictors (e.g., forest cover) on transition probabilities. So there might just be a handful of parameters but could end up with gamma and epsilon having shape (season_count - 1, site_count). Personally I don’t mind stacking them into a big array P but perhaps a function would be more intuitive. But I have absolutely zero-skill when it comes to software design so I trust y’all’s intuition!

2 Likes

The ultimate design goal is not to force too much into DiscreteMarkovChain but to allow writing arbitrary Scans with categorical emissions and auto-marginalize/recover them. Then users are free to define the transition probabilities outside/inside and pass whatever predictors they want as sequences or loop invariants.

For now that’s just a wish though …

Sweet! That sounds like a great goal. That’s basically the way I interact with NumPyro, and why I’ve found it to be so useful for HMM’s in ecology. But I would be thrilled to get back to PyMC and nutpie, if only to be able to run things on my Mac again!

1 Like