This vignette demonstrates how to use one family,
binegbin(), from simulation to prediction: counts are
simulated from known parameters, fitted, and the credible intervals
checked against the values that generated them. The other joint families
(bipois(), and the partially observed
binegbin_partialobs() and bipois_partialobs())
follow the same pattern; the README compares them.
Everything here is a brms custom family (Bürkner 2017), so the fitting, prediction and model-comparison interfaces are those of brms. The non-linear formula syntax used further down is documented in Bürkner (2018).
The generative model
Each observed count is the sum of a latent count that both sources
recorded and one that only that source recorded. This construction is
trivariate reduction: Holgate
(1964) and Karlis and Ntzoufras (2003) use it with Poisson components,
Kirkpatrick and Neale (2016)
and Kirkpatrick (2022)
with negative-binomial components. binegbin() takes all
three latent counts to be independent and negative binomial:
is the latent count recorded by both sources; and are the two source-exclusive components, the counts recorded by one source only. induces the correlation between the pair. It is never observed, and is marginalised out analytically in the likelihood.
NB2(m, phi) is neg_binomial_2 in Stan and
dnbinom(size = phi, mu = m) in R: mean m,
variance m + m^2/phi. Larger phi means
less overdispersion.
Six dpars, all with a log link:
| dpar | role |
|---|---|
mu |
rate of the shared component, |
lambdaone |
rate of the exclusive component of the first source, |
lambdatwo |
rate of the exclusive component of the second source, |
shapes |
dispersion of the shared component |
shapexone |
dispersion of the exclusive component of the first source |
shapextwo |
dispersion of the exclusive component of the second source |
brms::custom_family() rejects any family whose dpars do
not include one named literally mu. The requirement is on
the name, not on the position: mu may appear anywhere in
the vector. Here it is bound to the rate of the shared
component, so it is not the mean of either response, nor the mean of
their difference. E[y1] = mu + lambdaone.
The two rates are spelled
lambdaone/lambdatwo rather than
lambda1/lambda2 because
custom_family() rejects dpar names ending in a digit. The
maths below writes them
and
.
All six use a log link, the conventional log-linear rate parameterisation for this construction (Karlis and Ntzoufras 2003).
Simulate from known parameters
set.seed(20260724)
n <- 400
truth <- list(
mu = 8,
lambdaone = 3,
lambdatwo = 2,
shapes = 4,
shapexone = 6,
shapextwo = 2
)
n_shared <- rnbinom(n, size = truth$shapes, mu = truth$mu)
n1 <- rnbinom(n, size = truth$shapexone, mu = truth$lambdaone)
n2 <- rnbinom(n, size = truth$shapextwo, mu = truth$lambdatwo)
dat <- data.frame(
y1 = n_shared + n1,
y2 = n_shared + n2
)
str(dat)
#> 'data.frame': 400 obs. of 2 variables:
#> $ y1: num 11 8 7 15 5 13 9 15 13 8 ...
#> $ y2: num 11 7 5 16 6 11 9 16 9 7 ...
cor(dat$y1, dat$y2)
#> [1] 0.8518386The correlation is positive and substantial because both counts
include the same n_shared.
Fit
Two things are specific to this package.
The second count is supplied through
vint(). brms::custom_family()
declares a single response column, so only y1 can be the
response. y2 is passed alongside as supplementary integer
data, and the Stan signature of the family reads it from there.
stanvars is not optional.
binegbin_stanvars() injects the binegbin_lpmf
Stan function into the functions block of the model.
Without it, the generated model will not compile.
fit <- brm(
bf(
y1 | vint(y2) ~ 1,
lambdaone ~ 1,
lambdatwo ~ 1,
shapes ~ 1,
shapexone ~ 1,
shapextwo ~ 1
),
family = binegbin(),
stanvars = binegbin_stanvars(),
data = dat,
prior = c(
prior(normal(2, 1), class = "Intercept"),
prior(normal(2, 1), class = "Intercept", dpar = "lambdaone"),
prior(normal(2, 1), class = "Intercept", dpar = "lambdatwo"),
prior(normal(2, 1), class = "Intercept", dpar = "shapes"),
prior(normal(2, 1), class = "Intercept", dpar = "shapexone"),
prior(normal(2, 1), class = "Intercept", dpar = "shapextwo")
),
chains = 2,
iter = 1500,
refresh = 0,
backend = backend,
seed = 1
)One weakly-informative prior serves all six, since every dpar
here is a log-linked positive quantity of similar magnitude. On
the natural scale normal(2, 1) is lognormal, with 95% of
its mass between 1 and 52.
The six truths span 0.69 to 2.08 on the log scale, so no single prior sits away from all of them: the true shared rate falls near the prior mean, the true second exclusive rate 1.3 prior SDs below it. Recovery here is therefore not on its own evidence that the prior is uninfluential. The article Mapping native parameters to interpretable coordinates tests that directly, shifting a prior median sevenfold and moving the corresponding posterior median by 0.005.
To adapt these priors, shift the mean to the scale of the counts rather than increasing the SD. All six dpars are log-linked, so a normal prior is lognormal on the natural scale, and increasing its SD moves mass to implausible values rather than making the prior neutral (Smith et al. 2020, supplement 3, Fig. S3-1).
Recovery of the generating values
All six dpars are log-linked, so each posterior intercept exponentiates back onto the natural scale.
draws <- as_draws_df(fit)
pars <- c(
mu = "b_Intercept",
lambdaone = "b_lambdaone_Intercept",
lambdatwo = "b_lambdatwo_Intercept",
shapes = "b_shapes_Intercept",
shapexone = "b_shapexone_Intercept",
shapextwo = "b_shapextwo_Intercept"
)
recovery <- do.call(rbind, lapply(names(pars), function(p) {
x <- exp(draws[[pars[[p]]]])
data.frame(
dpar = p,
truth = truth[[p]],
est = median(x),
lower = unname(quantile(x, 0.025)),
upper = unname(quantile(x, 0.975))
)
}))
recovery$covered <- with(recovery, truth >= lower & truth <= upper)
knitr::kable(recovery, digits = 2)| dpar | truth | est | lower | upper | covered |
|---|---|---|---|---|---|
| mu | 8 | 7.91 | 7.14 | 8.61 | TRUE |
| lambdaone | 3 | 3.33 | 2.81 | 3.97 | TRUE |
| lambdatwo | 2 | 2.29 | 1.77 | 2.94 | TRUE |
| shapes | 4 | 3.58 | 2.68 | 4.73 | TRUE |
| shapexone | 6 | 7.16 | 3.31 | 21.90 | TRUE |
| shapextwo | 2 | 3.13 | 1.44 | 10.05 | TRUE |
The three rates should land tightly on their true values. The three dispersions are estimated from an aggregate mean–variance mismatch rather than from any directly observed quantity, so their intervals are wider; the dispersions of the two exclusive components especially, since they are identified only through the part of the spread in the pair that the shared component cannot explain. Wide but covering is the expected result here, not a warning sign.
Predict and score
posterior_predict() draws new y1
conditional on the observed y2 in each
row. It samples the discrete conditional distribution of
given y2, then adds a fresh draw of the exclusive component
— the exact conditional, not an approximation.
yrep <- posterior_predict(fit, ndraws = 200)
dim(yrep)
#> [1] 200 400
data.frame(
quantity = c("mean", "sd", "max"),
observed = c(mean(dat$y1), sd(dat$y1), max(dat$y1)),
predicted = c(mean(yrep), mean(apply(yrep, 1, sd)), mean(apply(yrep, 1, max)))
) |>
knitr::kable(digits = 2)| quantity | observed | predicted |
|---|---|---|
| mean | 11.25 | 11.24 |
| sd | 5.59 | 5.43 |
| max | 37.00 | 33.23 |
log_lik() gives the pointwise log-likelihood of the
joint pair, evaluated by an independent R implementation of the
same marginalisation sum that the Stan function computes. It feeds
loo() and waic() in the usual way (Vehtari et al.
2017). The Pareto
diagnostic reported alongside the estimate flags observations whose
importance weights are unreliable (Vehtari et al. 2024).
ll <- log_lik(fit)
dim(ll)
#> [1] 1500 400
loo(fit)
#>
#> Computed from 1500 by 400 log-likelihood matrix.
#>
#> Estimate SE
#> elpd_loo -2202.8 22.6
#> p_loo 5.8 0.6
#> looic 4405.7 45.2
#> ------
#> MCSE of elpd_loo is 0.1.
#> MCSE and ESS estimates assume MCMC draws (r_eff in [0.3, 1.5]).
#>
#> All Pareto k estimates are good (k < 0.69).
#> See help('pareto-k-diagnostic') for details.Quantities returned by posterior_epred()
Both families return the same quantity:
the expected first count conditional on the observed second count, not the marginal expectation .
For bipois(),
is closed form, because conditioning a sum of independent Poissons on
its total gives a Binomial split. For binegbin() there is
no such shortcut, so the conditional is evaluated over its support
and summed. Both are exact, and each agrees with the mean of its own
posterior_predict() draws to within Monte Carlo error.
The conditional expectation is returned for every row, including —
under binegbin_partialobs() and
bipois_partialobs() — those where
was never observed. Those two constructors exist so that the fitted
model can impute
on those rows.
posterior_epred(fit) needs no special handling: it
dispatches to the family method, as do fitted() and
conditional_effects().
Related families and further reading
-
bipois()— the non-overdispersed Poisson sibling. Same construction, three dpars instead of six. Use it when the margins are not overdispersed; compare the two withloo(). -
The symmetric model. The fit above let the
dispersions of the two exclusive components differ. To impose
instead, route both through one non-linear parameter —
nlf(shapexone ~ shapexx),nlf(shapextwo ~ shapexx),shapexx ~ 1, withnl = TRUE— and set its prior withnlpar = "shapexx". That is the model this package fitted under a singleshapexdpar before 0.10.0, term for term. -
binegbin_partialobs()— for data where the first count is missing on some rows. It is the same family: same name, same six dpars, same likelihood and the same post-processing. What it adds is a secondvint()integer, a 0/1 flag saying whether was recorded. Rows where it was not are scored by the integrated-out marginal of this same joint rather than dropped, so they still inform , , , and any group-level effects; afterwards the fit imputes the missing count conditional on the observed one. Note that and are then identified by the paired rows alone. Despite its former name, this is unrelated to the brmscens()addition term, which means a value known to lie in a set. - The
reparameterisation — the midpoint
of the two expected counts, congruence
,
and source bias
,
with the inverse dispersions converted to overdispersions
,
where
is the Poisson limit. It is fitted through
nlf()rather than a separate family, andbinegbin_mfd_to_dpars()converts between the two. Because and are finite, interpretable nulls, these coordinates admit shrinkage priors towards the simpler model unless the data support otherwise (Simpson et al. 2017). The rate parameterisation has no such nulls. Worked end to end in the article Mapping native parameters to interpretable coordinates. -
bipois_partialobs()— the same partial observation, equidispersed: the Poisson special case ofbinegbin_partialobs(). Its integrated-out marginal is closed form, , since a sum of independent Poissons is Poisson. Use it when the margins are not overdispersed, rather than fittingbinegbin_partialobs()with its dispersions pressed against the Poisson boundary. -
Modelling the difference instead. Where only the
disagreement
is of interest, or where truncation via
resp_trunc()is required, the Skellam and discrete Laplace/normal families in the companion packageskellambrms(Smith 2026) model directly. The difference of two independent Poisson counts is Skellam-distributed (Skellam 1946); for the Bayesian treatment of count differences generally, see Karlis and Ntzoufras (2006). Note what that reduction discards: the shared component cancels out of , so the midpoint and the congruence are no longer estimable.