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

@philpatton if you want to give it a try, it should now work with the latest releases: Release v0.13.0 · pymc-devs/pymc-extras · GitHub

Awesome, it works! And I’m thrilled to see Allow closed form marginalization and implement `conditional` model transform by ricardoV94 · Pull Request #688 · pymc-devs/pymc-extras · GitHub. This is actually very helpful for capture-recapture, because we often want to recover the discrete alive/dead state conditional on the capture history. (Basically, if you saw an individual in 2012, 2014, and 2015, you know they must have been alive in 2013, but you are unsure if they were alive in, say, 2011 or 2016.) Thanks again for the help!

1 Like

If you want to do a capture-recapture example for pymc-examples using this feature it’d be very welcome :slight_smile:

1 Like

I’d be thrilled to! I can whip together a Jolly-Seber example, which will be good for showing off conditional because we want the conditional distribution of the total population size over time given the capture histories.

I’d also like to make use of time_varying_P since, for better or for worse, this is canon in capture-recapture. But I’m having trouble getting the shapes rights with the replicated time series (see below for an example with the occupancy model, which is a nearly identical model). Any ideas?

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

from pymc_extras.marginal import marginalize, conditional
from pymc_extras import DiscreteMarkovChain

# 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=SURVEY_COUNT, p=mu_x, size=(SITE_COUNT, SEASON_COUNT))
    return x, z

x, z_true = simulate_data()

with pm.Model() as colext:

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

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

    # create the array of transition matrices by first stacking each row
    P = pt.stack([
        pt.stack([1 - gamma, gamma], axis=-1),
        pt.stack([epsilon, 1 - epsilon], axis=-1)
    ], axis=-2) # resulting shape (interval_count, 2, 2)

    Z = DiscreteMarkovChain(
        'Z',
        P=P,
        steps=interval_count,
        init_dist=init_dist,
        time_varying_P=True,
        shape=(SITE_COUNT, SEASON_COUNT)
    )

    pm.Binomial('x', SURVEY_COUNT, Z * p, observed=x)

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

with colext_marginal:
    idata = pm.sample()

Ah that’s a shame. I think it’s fixed in: Support marginalization of HMM with lags>1 by ricardoV94 · Pull Request #707 · pymc-devs/pymc-extras · GitHub

Let me try to clean that up over the finish line

1 Like

Hopefully this fixed it @philpatton: Release v0.13.1 · pymc-devs/pymc-extras · GitHub