Skip to contents

The Poisson and negative-binomial distributions model counts observed within comparable units of time, space, or exposure. The worked example below uses Poisson regression; a later section explains the additional shape parameter and priors in negbinomial().

Coal mining disasters

A dataset on coal mining disasters has grown very popular in the change point literature (available in boot::coal). It contains a timestamp of each coal mining disaster from 1851 to 1962. By counting the number of events within each year (fixed time frame), we have something very Poisson-friendly. Note that years without disasters must be included as zeros:

# Number of disasters by year, including years with zero disasters (no data rows)
year = factor(floor(boot::coal$date), levels = 1851:1962)
df = data.frame(date = 1851:1962, n = as.vector(table(year)))

# See it
head(df)
##   date n
## 1 1851 4
## 2 1852 5
## 3 1853 4
## 4 1854 1
## 5 1855 0
## 6 1856 4

The number of events (n) as a function of year (date) is typically modeled as a change between two intercepts. This is very simple to do in mcp:

library(mcp)
future::plan(future::multisession, workers = 3)
model = list(
  n ~ 1,  # intercept-only
  ~ 1  # intercept-only
)

fit = mcp(model, data = df, family = poisson(), par_x = "date")

Let us see the two intercepts (lambda in log-units) and the change point (in years):

result = summary(fit)
result
## Family: poisson
## Links: mu = log
## Draws: 9000 (3 chains with iter = 3000)
## Segments:
##   1: n ~ 1
##   2: ~ 1
## 
## Change point parameters:
##    parameter     mean    sd    lower    upper rhat ess_bulk ess_tail
##  cp_1        1890.5   2.5   1886.1   1896.5   1.00     1944      990
## 
## Population-level parameters:
##    parameter     mean    sd    lower    upper rhat ess_bulk ess_tail
##  Intercept_1    1.131 0.094    0.944    1.313 1.00     4302     4741
##  Intercept_2   -0.09  0.13    -0.35     0.15  1.00     4472     5006

We can see that the model ran well with good convergence and large effective sample sizes. At a first glance, the change point is estimated to lie between the years 1886 and 1897 (95% interval).

Let us take a more direct look, using the default mcp plot:

plot(fit)

It seems to fit the data well, but we can see that the change point probability “lumps” around particular data points. Years with a very low number of disasters abruptly increase the probability that the change to a lower disaster rate has taken place. The posterior distributions of change points regularly take these “weird” forms, i.e., not well-described by our toolbox of parameterized distributions.

We can see this more clearly if plotting the posteriors. We include a traceplot too, just to check convergence visually.

Priors for Poisson models

Brief overview of all default priors

poisson() defaults to link = 'log', meaning that we have to exponentiate the estimates to get the “raw” Poisson parameter \lambda. \lambda has the nice property of being the mean number of events. So the rate is around 3.1 disasters per year in segment 1 and 0.91 in segment 2. Exponentiating the posterior mean of the log-rate gives approximately the posterior median of \lambda, not its mean. For the posterior mean, exponentiate the draws before averaging, e.g., mean(exp(as_draws_df(fit)$Intercept_1)), or use fitted(fit).

The intercept prior is a normal distribution centered on the median of log(n), with zeros replaced by 0.1 (as in brms), and scale equal to the maximum of 2.5 and its MAD. Replacing zeros keeps the log finite and the minimum scale avoids an accidentally narrow prior.

Coefficients are autoscaled as in rstanarm, e.g., dnorm(0, 2.5 / sd(column)), and group-level SDs default to half-normal priors. These normal priors avoid the infinite expected counts that heavier Student-t tails imply under the log link (read more).

## # A tibble: 3 × 5
##   parameter   segment dpar  prior             bounds               
##   <chr>         <int> <chr> <chr>             <chr>                
## 1 cp_1              2 NA    dunif(1851, 1962) [1851, 1962], ordered
## 2 Intercept_1       1 mu    dnorm(0, 2.5)     none                 
## 3 Intercept_2       2 mu    dnorm(0, 2.5)     none

The default prior on the change point restricts it to the observed range. The coefficient priors are deliberately broad defaults, so update them with more informed priors for your particular case when possible, e.g.:

prior = list(
  cp_1 = "dnorm(1900, 30) T(min(date), 1925)"
)
fit_with_prior = mcp(model, data = df, family = poisson(), prior = prior, par_x = "date")

Negative-binomial extension

negbinomial() uses log links for its conditional mean mu and, when modeled, its overdispersion shape, where \operatorname{Var}(y) = \mu + \mu^2 / \mathit{shape}. Its mean priors are the same as for poisson().

When no shape() formula is supplied, shape_1 is the shape itself (like sigma_1 in gaussian()) with an inverse-gamma(0.4, 0.3) prior, matching brms. It covers both strong overdispersion and the large-shape Poisson limit.

An explicit shape() formula models log-shape. Harmonizing with log-mean coefficients, its intercept and contrasts default to dnorm(0, 2.5) and slopes to dnorm(0, 2.5 / sd(column)) (group SDs default to dnorm(0, ...) T(0, )). Where brms uses flat coefficients and a Student-t intercept, mcp uses proper normal priors to avoid heavy tails that pull shape toward extreme overdispersion. Shape regression can be weakly identified, so informed priors may be useful.

Modeling rates using offset()

When modeling event counts where exposure (e.g., person-years, area, or population size) varies across observations, you can model rates \lambda = \text{Count} / \text{exposure} on the log link by supplying offset(log(exposure)):

# Example: Event counts over varying observation durations (as here) or population
model = list(
  events ~ 1 + year + offset(log(exposure)),  # Segment 1
  ~ 1 + year + offset(log(exposure))          # Segment 2
)

fit = mcp(model, data = df, family = poisson())  # implicit link = "log"

fitted() returns expected counts given each row’s exposure. To get expected rates per unit of exposure, set the exposure to 1 in newdata, so that the offset is \log(1) = 0:

fitted(fit, newdata = transform(df, exposure = 1))

This works the same way for predict(), which then returns predicted counts at unit exposure. The fitted(..., rate = TRUE) argument does not apply to Poisson or negative-binomial models; it is only for binomial models, where it converts counts to proportions of trials.

Adding a categorical predictor to the model, you can also model rate ratios. When an offset is present, mcp automatically shifts the default intercept prior from centering on observed log-counts to observed log-rates (\log(y) - \text{offset}), keeping the minimum scale at 2.5.

Note on par_x vs. cumulative exposure:
The offset is evaluated per row as local exposure for that observation. If par_x represents continuous cumulative time from an origin (e.g., [0, t_i]), using offset(log(par_x)) does not integrate piecewise rates across prior segments; instead, the current segment’s rate is applied to the total duration. If your data consists of discrete time intervals (bins) of duration \Delta t_i, using offset(log(delta_t)) is valid.

Model comparison

Despite the popularity of this dataset, a question rarely asked is what the evidence is that there is a change point at all. Let us fit two models without change points and use approximate leave-one-out cross-validation to see how the predictive performance of the two models compare.

A flat model and a one-decay model. The intercept in mcp is at date = 0, far from the data, so we measure the decay from the first year (I(date - 1851)) to reduce the posterior correlation between intercept and slope. This helps sampling:

# Fit an intercept-only model
fit_flat = mcp(list(n ~ 1), data = df, family=poisson(), par_x = "date")
fit_decay = mcp(list(n ~ 1 + I(date - 1851)), data = df, family = poisson(), par_x = "date")


plot(fit_flat) + plot(fit_decay)

Now we compute and compare the LOO ELPDs:

fit_loo = loo(fit)
## Warning: Some Pareto k diagnostic values are too high. See help('pareto-k-diagnostic') for details.
fit_flat_loo = loo(fit_flat)
fit_decay_loo = loo(fit_decay)
loo::loo_compare(fit_loo, fit_flat_loo, fit_decay_loo)
##   model elpd_diff se_diff p_worse       diag_diff      diag_elpd
##  model1       0.0     0.0      NA                 4 k_psis > 0.7
##  model3      -3.2     3.6    0.82 |elpd_diff| < 4               
##  model2     -32.9     8.3    1.00
## 
## Diagnostic flags present.
## See ?`loo-glossary` (sections `diag_diff` and `diag_elpd`)
## or https://mc-stan.org/loo/reference/loo-glossary.html.

The flat model is clearly worse, with elpd_diff / se_diff around 4, so the disaster rate did decrease. The change point model is preferred over the decay model, but the difference is smaller than its standard error. The diag_elpd column also flags a few observations with high Pareto k values in the change point model, so its PSIS-LOO estimate is less reliable. Taken together, the data cannot clearly distinguish an abrupt change from a gradual decay. Another approach is to look at the model weights:

loo_list = list(fit_loo, fit_flat_loo, fit_decay_loo)
loo::loo_model_weights(loo_list, method="pseudobma")
## Method: pseudo-BMA+ with Bayesian bootstrap
## ------
##        weight
## model1 0.794 
## model2 0.000 
## model3 0.206

Again, the change point model is preferred and the weights show the same ranking as implied by loo_compare, but the decay model retains a non-negligible weight.

JAGS code for the Poisson example

Here is the JAGS code for the full Poisson change-point model above.

fit$jags_code
## model {
##   # Change points
##   cp_0 = min(date)
##   cp_1 ~ dunif(cp_0, cp_2)  # Uniform within the observed range of date
##   cp_2 = max(date)
## 
##   # Priors for population-level effects
##   Intercept_1 ~ dnorm(0, 1/(2.5)^2)  # Robustly centered log-count intercept with a minimum scale of 2.5
##   Intercept_2 ~ dnorm(0, 1/(2.5)^2)  # Robustly centered log-count intercept with a minimum scale of 2.5
## 
##   # Model and likelihood
##   for (i_ in 1:length(date)) {
##     # date measured from each segment's change point
##     x_local_1_[i_] = min(date[i_], cp_1)
##     x_local_2_[i_] = min(date[i_], cp_2) - cp_1
##     
##     # Formula for mu
##     link_mu_[i_] =
##       (date[i_] >= cp_0) * (date[i_] < cp_1) * inprod(rhs_matrix_[i_, c(1)], c(Intercept_1)) * 1 + 
##       (date[i_] >= cp_1) * inprod(rhs_matrix_[i_, c(2)], c(Intercept_2)) * 1
## 
##     # Likelihood for family = poisson()
##     mu_[i_] = exp(link_mu_[i_])
##     n[i_] ~ dpois(mu_[i_])
##   }
## }