# libraries
from pymc.distributions.dist_math import binomln, logpow
import vapeplot
import seaborn as sns
import numpy as np
import pandas as pd
import pymc as pm
import arviz as az
import matplotlib.pyplot as plt
# plotting parameters
'fivethirtyeight')
plt.style.use('axes.facecolor'] = 'white'
plt.rcParams['figure.facecolor'] = 'white'
plt.rcParams['axes.spines.left'] = False
plt.rcParams['axes.spines.right'] = False
plt.rcParams['axes.spines.top'] = False
plt.rcParams['axes.spines.bottom'] = False
plt.rcParams["tab10")
sns.set_palette(
# hyperparameters
= 808
SEED = np.random.default_rng(SEED) RNG
Closed capture-recapture
In this notebook, I explore fitting closed population capture-recapture models in PyMC. Capture-recapture, at least the Lincoln-Peterson estimator, has been around for almost 100 years. Since then, countless varieties of capture-recapture models have been developed for closed populations (Otis et al. 1978).
The basic steps in capture-recapture are: capture several individuals–e.g., via trapping–from the population of interest, mark these animals, then release them. We repeat this process several times, each time noting when we recapture individuals.
Individual | \(t_1\) | \(t_2\) | \(t_3\) | \(t_4\) |
---|---|---|---|---|
001 | 1 | 1 | 0 | 1 |
002 | 0 | 1 | 1 | 1 |
003 | 0 | 0 | 1 | 1 |
This produces a capture history for each individual, which allows us to estimate the probability of capture and the number of individuals in the population \(N\).
Model \(M_0\)
I explore fitting the simplest closed capture-recapture model, Model \(M_0,\) through parameter-expanded data-augmentation (PX-DA, Royle and Dorazio 2008). The idea with PX-DA is to augment the capture histories with \(M-n\) all zero capture-histories, where \(M\) is a hyperparameter that should be much greater than the true population size \(N,\) and \(n\) is the total number of individuals that were captured during the study. This allows us to treat the data as a zero-inflated binomial distribution (see below).
def augment_history(history, M):
'''Augment a capture history with all-zero histories.'''
= history.shape
animals_captured, T
# create M - n all zero histories
= M - animals_captured
zero_history_count = np.zeros((zero_history_count, T))
zero_history
# tack those on to the capture history
= np.row_stack((history, zero_history))
augmented
return augmented
To demonstrate this approach, I use the salamander dataset from Bailey, Simons, and Pollock (2004), as demonstrated in Hooten and Hefley (2019), Chapter 24. These data were collected on two salamander species, the red-cheeked salamander (Plethodon jordani) and the pygmy salamander (Desmognathus wrighti), in Great Smoky Mountains National Park. The salamanders were counted in 15m by 15m square plots. In this case, we augment the history by setting \(M=1500\). There were \(n=92\) individual red-cheeked and \(n=132\) pygmy salamanders captured during the course of the survey.
def get_histories():
'''Read, augment, and recombine the salamander histories.'''
# read in salamander data
= pd.read_csv('sal_data.csv')
sal_data
# labels for capture history columns
= [f'y{t}' for t in range(1, 5)]
col_labs
# subset each dataset before augmenting
= sal_data.spp == 1
is_pyg = sal_data.spp == 0
is_red
= sal_data.loc[is_pyg, col_labs].to_numpy()
pyg = sal_data.loc[is_red, col_labs].to_numpy()
red
return {'pyg': pyg, 'red': red}
def augment_histories(histories, M):
= augment_history(histories['pyg'], M=M)
pyg_augmented = augment_history(histories['red'], M=M)
red_augmented
# recombine into one history
= np.concatenate((pyg_augmented, red_augmented))
history
return history
= get_histories()
histories
= histories['red'].shape
n_red, T = histories['pyg'].shape
n_pyg, T
# # summarize into binomial data
= 1500
M = augment_histories(histories, M=M)
history_augmented = history_augmented.sum(axis=1) history_summarized
For this model, I use the pm.ZeroInflatedBinomial
class, just as I did in the occupancy notebook. That said, the parameters here are different. First, \(p\) represents the probability of capturing a given individual during the survey. Second, \(\psi\) represents a mysterious entity known as the inclusion probability. That is, the probability that an individual from the hypothetical superpopulation \(M\) is included in the population of interest \(N.\) Then, we can simulate the posterior distribution for \(N\) using \(M\) and the posterior distributions of \(\psi.\)
In this example, I combine the two species into one pm.Model
object, making use of coords
. That said, the parameters for each species are treated as independent. In other words, this is a “no-pooling” model.
# index for each species
= np.repeat([0, 1], M)
species_idx
# coordinates identifying parameter each species
= {'species': ['pygmy', 'red_cheeked']}
coords
with pm.Model(coords=coords) as M0:
# priors for the capture and inclusion probabilities
= pm.Beta('psi', 0.001, 1, dims='species')
psi = pm.Uniform('p', 0, 1, dims='species')
p
# likelihood for the summarized data
pm.ZeroInflatedBinomial('history',
=p[species_idx],
p=psi[species_idx],
psi=T,
n=history_summarized
observed
)
pm.model_to_graphviz(M0)
with M0:
= pm.sample() M0_idata
Auto-assigning NUTS sampler...
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (4 chains in 4 jobs)
NUTS: [psi, p]
Sampling 4 chains for 1_000 tune and 1_000 draw iterations (4_000 + 4_000 draws total) took 18 seconds.
=(8,4)); az.plot_trace(M0_idata, figsize
For faster sampling, it’s better to separate the two species into two separate models. On my machine, the individual species models finish sampling in 2-3 seconds, compared to 15-20 seconds for the two species model. That said, the two species model is somewhat more convenient.
Of course, the trace plots lack our true parameter of interest: the population size \(N.\) We can simulate the posterior of \(N\) as a derived quantity, using \(M\) and the posterior distribution of \(\psi\).
# az.extract flattens the chains
= az.extract(M0_idata)
posterior = posterior.psi.values
psi_samps = posterior.p.values
p_samps
# conditional probability that the animal is in the sample
= (1 - p_samps)
not_p = (psi_samps * (not_p) ** T) / (psi_samps * (not_p) ** T + (1 - psi_samps))
p_included
# simulate the number of undetected animals in each population
= RNG.binomial(M - n_pyg, p_included[0])
number_undetected_pyg = RNG.binomial(M - n_red, p_included[1])
number_undetected_red
# simulate N
= n_pyg + number_undetected_pyg
N_pyg = n_red + number_undetected_red N_red
Below I plotted the posterior distributions of \(N\) for both species, adding the estimates from Hooten and Hefley (2019), Chapter 24. Although note that they used a different prior for \(\psi.\)
= [229.6, 450.9]
N_hooten = plt.subplots(figsize=(6,4))
fig, ax ='C0', edgecolor='white', alpha=0.9, bins=30, label='Pygmy')
ax.hist(N_pyg, color='C1', edgecolor='white', alpha=0.9, bins=30, label='Red-cheeked')
ax.hist(N_red, color0], linestyle='--', color='black', linewidth=2)
ax.axvline(N_hooten[1], linestyle='--', color='black', linewidth=2)
ax.axvline(N_hooten['Posterior distributions of $N$')
ax.set_title('Number of samples')
ax.set_ylabel(
ax.legend() plt.show()
We might expect estimates of capture probability \(p\) and the abundance \(N\) to be somewhat correlated. We can explore this relationship visually by plotting the posterior draws.
# create the plot
= plt.subplots(1, 1, figsize=(4, 4))
fig, ax
# add the scatter for each species
= ['Pygmy', 'Red-backed']
labs 0], N_pyg, s=10, alpha=0.2, label=labs[0])
ax.scatter(p_samps[1], N_red, s=10, alpha=0.2, label=labs[1])
ax.scatter(p_samps[
# this removes the opacity for the dots in the legend
= ax.legend()
leg for lh in leg.legend_handles:
set(sizes=[25], alpha=[1])
lh.
# update aesthetics
False)
ax.spines.right.set_visible(False)
ax.spines.top.set_visible(
r'$N$')
ax.set_ylabel(r'$p$')
ax.set_xlabel('Posterior draws')
ax.set_title(
plt.show()
Model \(M_b\)
Next, I fit model \(M_b,\) which accounts for the possibility that the capture probability changes after the animal is first caught. This could be from trap happiness, whereby animals are more likely to be trapped after their first time. Conversely, this could be from subsequent trap avoidance.
Mirroring (Royle and Dorazio 2008, chap. 5), I fit this model to the Microtus dataset reported in (Williams, Nichols, and Conroy 2002, 525). This version of the dataset includes encounter histories of \(n=56\) adult males that were captured on \(T=5\) consecutive days.
# read in the microtus data
= np.loadtxt('microtus.data.txt').astype(int)
microtus
# the last column is not relevant
= microtus[:,:-1]
micro_hist = micro_hist.shape
n, T
# augment with all zero histories
= 100
M = augment_history(micro_hist, M=M)
micro_augmented
# note the occasion when each individual was first seen
= (micro_hist != 0).argmax(axis=1)
first_seen
# create the covariate for the behavior effect
= np.zeros((M, T))
behavior_effect for i, f in enumerate(first_seen):
+ 1):] = 1
behavior_effect[i, (f
# covariate matrix
= np.ones((M, T))
x_int = np.stack((x_int, behavior_effect), axis=2) X
I use the same custom distribution as the occupancy notebook, the zero-inflated model, except the zero-inflation happens at the row-level.
def logp(value, n, p, psi):
= binomln(n, value) + logpow(p, value) + logpow(1 - p, n - value)
binom = pm.math.sum(binom, axis=1)
bin_sum = pm.math.exp(bin_sum)
bin_exp
= pm.math.switch(
res sum(axis=1) > 0,
value.* psi,
bin_exp * psi + (1 - psi)
bin_exp
)
return pm.math.log(res)
= {'alpha_coeffs': ['Intercept', 'B_Response']}
coords with pm.Model(coords=coords) as mb:
# priors for the capture and inclusion probabilities
= pm.Beta('psi', 0.001, 1)
psi = pm.Normal('Alpha', 0, 2, dims='alpha_coeffs')
Alpha
= pm.math.dot(X, Alpha)
nu = pm.Deterministic('p', pm.math.invlogit(nu))
p
# likelihood
pm.CustomDist('y',
1,
p,
psi,=logp,
logp=micro_augmented
observed
)
pm.model_to_graphviz(mb)
with mb:
= pm.sample() mb_idata
Auto-assigning NUTS sampler...
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (4 chains in 4 jobs)
NUTS: [psi, Alpha]
Sampling 4 chains for 1_000 tune and 1_000 draw iterations (4_000 + 4_000 draws total) took 3 seconds.
=['Alpha', 'psi']) az.summary(mb_idata, var_names
mean | sd | hdi_3% | hdi_97% | mcse_mean | mcse_sd | ess_bulk | ess_tail | r_hat | |
---|---|---|---|---|---|---|---|---|---|
Alpha[Intercept] | 0.119 | 0.249 | -0.347 | 0.579 | 0.006 | 0.004 | 2001.0 | 1977.0 | 1.0 |
Alpha[B_Response] | 0.606 | 0.290 | 0.023 | 1.103 | 0.007 | 0.005 | 1941.0 | 1951.0 | 1.0 |
psi | 0.570 | 0.053 | 0.464 | 0.663 | 0.001 | 0.001 | 2453.0 | 2129.0 | 1.0 |
=['Alpha'], combined=True, ess=True, figsize=(6,2)); az.plot_forest(mb_idata, var_names
The forest plot indicates that there is some evidence of a weak, positive behavioral response. Although note that the 94% credible intervals between the baseline capture rate and the behavioral effect overlap considerably.
# simulate draws of N
= az.extract(mb_idata).psi.values
psi_samps = az.extract(mb_idata).p.values[:n]
p_samps
= (1 - p_samps)
not_p = not_p.prod(axis=1)
bin_prod = (psi_samps * bin_prod) / (psi_samps * bin_prod + (1 - psi_samps))
p_included
= RNG.binomial(1, p_included).sum(axis=0)
number_undetected = n + number_undetected
N_samples
# use a bar chart since the posterior is bunched up
= np.unique(N_samples, return_counts=True)
N_values, N_counts
# create the plot
= plt.subplots(figsize=(4, 4))
fig, ax
ax.bar(N_values, N_counts)
ax.annotate('Number\ndetected $n$',
='left',
ha=(N_values[0], N_counts[0]),
xy='black',
color=(n+1, 1600),
xytext=dict(arrowstyle="->", color='black', linewidth=1,
arrowprops="angle3,angleA=90,angleB=0")
connectionstyle
)
'Number of samples')
ax.set_ylabel('Posterior of $N$')
ax.set_title(
plt.show()
Most of the posterior density of \(N\) is at \(n,\) the number of animals detected. The discovery curve hints at why this may be the case. It seems that all the voles in the population may have been captured by the end of the study.
# how many voles have been seen?
= micro_hist.sum(axis=0).cumsum()
total_seen = np.insert(total_seen, 0, 0)
total_seen
# how many new voles have been seen?
= (micro_hist != 0).argmax(axis=1)
first_seen = [sum(first_seen == t) for t in range(T)]
newbies = np.cumsum(newbies)
total_newbies = np.insert(total_newbies, 0, 0)
total_newbies
= plt.subplots(figsize=(5, 3.5))
fig, ax
ax.plot(total_seen, total_newbies)=0.2)
ax.fill_between(total_seen, total_newbies, alpha'Discovery curve')
ax.set_title('Total voles captured')
ax.set_xlabel('Unique voles captured')
ax.set_ylabel( plt.show()
We can also look at the behavioral effect by visualizing the posterior distributions of \(p.\) As we can see, the voles who have been captured before are more likely to be captured again.
= az.extract(mb_idata).p.values.reshape(500, 4000)
p_samples = X[:,:,1].flatten()
b_cov
= p_samples[b_cov==0,].flatten()
zero_p = p_samples[b_cov==1,].flatten()
one_p
= plt.subplots(figsize=(5, 3.5))
fig, ax =ax, label='First detection', color='C0')
az.plot_dist(zero_p, ax=ax, label='Seen before', color='C1')
az.plot_dist(one_p, ax'Posterior distributions of $p$')
ax.set_title(0,1))
ax.set_xlim((
ax.set_yticks([])
ax.legend() plt.show()