Count abundance and extra zeros
Status — Stable (Poisson + NB2 + zi + hu)
Mirrors drmTMB's Count abundance and extra zeros. In DRM.jl today: the Poisson family Poisson(), the negative-binomial family NegBinomial2() (overdispersed counts), and both the zi zero-inflation and hu hurdle modifiers on either count family (ZIP / ZINB and hurdle-Poisson / hurdle-NB).
Counts — abundances, visit tallies, event counts — are non-negative integers, so a Gaussian model is the wrong shape. The Poisson family models the log of the expected count.
A Poisson abundance model
using DRM, Random
import Distributions # `Poisson` below is DRM's family; qualify the distribution
Random.seed!(20260615)
n = 3000
x = randn(n)
λ = exp.(0.3 .+ 0.5 .* x) # log-mean increases with x
y = Float64.([rand(Distributions.Poisson(λi)) for λi in λ])
dat = (; y, x)
fit = drm(bf(@formula(y ~ x)), Poisson(); data = dat)
coef(fit, :mu) # coefficients on log λ2-element Vector{Float64}:
0.2769619140427078
0.4924642349014983The slope is a log rate ratio: a one-unit increase in x multiplies the expected count by exp(β).
exp(coef(fit, :mu)[2]) # rate ratio per unit x (≈ exp(0.5) ≈ 1.65)1.6363435907106614Fitted values are returned on the response (count) scale:
extrema(fitted(fit)) # fitted means λ̂ = exp(Xβ̂)(0.20461582863054456, 8.779496294903673)When Poisson is too tight
Poisson forces variance = mean. Real counts are often overdispersed (variance > mean). The negative-binomial family NegBinomial2() relaxes that — read on. (For extra zeros, see the zi and hu modifiers below.)
Overdispersion: the negative-binomial (NB2) family
NegBinomial2() adds a dispersion (size) parameter θ carried on the log-σ scale in the sigma slot: θ = 1/σ² = exp(-2·coef(:sigma)). The variance is μ + μ²/θ, so smaller θ means heavier overdispersion and θ → ∞ returns to Poisson.
Random.seed!(20260616)
θ = 2.5
μnb = exp.(0.4 .+ 0.5 .* x)
ynb = Float64.([rand(Distributions.NegativeBinomial(θ, θ / (θ + μi))) for μi in μnb])
datnb = (; y = ynb, x)
fitnb = drm(bf(@formula(y ~ x), @formula(sigma ~ 1)), NegBinomial2(); data = datnb)
exp(-2 * coef(fitnb, :sigma)[1]) # estimated dispersion θ = 1/σ² (≈ 2.5 ⇒ real overdispersion)2.3375415786974294A finite, smallish θ is the signal that the counts are overdispersed and a plain Poisson would understate the uncertainty.
Extra zeros: the zi modifier
Many count datasets have more zeros than any single count distribution predicts (a site is unsuitable, a detector was off). Add a zi ~ … formula and the model becomes a mixture — with probability π (logit link) the count is a structural zero, otherwise it is drawn from the count family. Works on both Poisson() (ZIP) and NegBinomial2() (ZINB):
Random.seed!(20260619)
π = 0.40 # 40% structural zeros
λzi = exp.(0.6 .+ 0.4 .* x)
yzi = Float64.([rand() < π ? 0 : rand(Distributions.Poisson(λi)) for λi in λzi])
datzi = (; y = yzi, x)
fitzi = drm(bf(@formula(y ~ x), @formula(zi ~ 1)), Poisson(); data = datzi)
1 / (1 + exp(-coef(fitzi, :zi)[1])) # recovered zero-inflation probability (≈ 0.40)0.3755471955671332coef(fitzi, :mu) is still the log-mean of the count process; the :zi block is the structural-zero model on the logit scale.
A different two-part story: the hu hurdle modifier
A hurdle model splits the data in two: a logit "hurdle" decides zero vs positive (π = P(y = 0)), and the positive counts follow the zero-truncated count distribution. Unlike zi, all zeros come from the hurdle — use it when "is it present at all?" and "how many, given present?" are distinct processes. Add a hu ~ … formula (works on Poisson() and NegBinomial2()):
Random.seed!(20260620)
πh = 0.40 # 40% structural (hurdle) zeros
λh = exp.(0.6 .+ 0.4 .* x)
rtpois(λ) = (while true; k = rand(Distributions.Poisson(λ)); k > 0 && return k; end)
yh = Float64.([rand() < πh ? 0 : rtpois(λi) for λi in λh])
dath = (; y = yh, x)
fith = drm(bf(@formula(y ~ x), @formula(hu ~ 1)), Poisson(); data = dath)
1 / (1 + exp(-coef(fith, :hu)[1])) # recovered hurdle (zero) probability (≈ 0.40)0.40433333333334537zi and hu cannot both be set on one model — they are competing stories for the zeros.
Counts that can't be zero: truncated NB2
Some counts are positive by construction — litter sizes, group sizes given presence. TruncatedNegBinomial2() is the NB2 conditioned on y ≥ 1 (P(k) = NB(k)/(1 − NB(0))), with the same parameters as NegBinomial2():
Random.seed!(20260622)
rtnb(r, p) = (while true; k = rand(Distributions.NegativeBinomial(r, p)); k > 0 && return k; end)
μt = exp.(0.8 .+ 0.3 .* x); θt = 3.0
yt = Float64.([rtnb(θt, θt / (θt + μi)) for μi in μt])
fitt = drm(bf(@formula(y ~ x), @formula(sigma ~ 1)), TruncatedNegBinomial2(); data = (; y = yt, x))
exp(-2 * coef(fitt, :sigma)[1]) # recovered dispersion θ = 1/σ²2.6957913347460076Random effects: a count GLMM
Counts collected in groups (sites, individuals, broods) want a random intercept. Add (1 | g) to the mean — the group effect on log λ is integrated out per group by Gauss–Hermite quadrature, so Poisson() becomes a proper count GLMM:
Random.seed!(20260627)
Gc = 40; mc = 30; nc = Gc * mc
gc = repeat(1:Gc, inner = mc); xc = randn(nc)
bg = 0.6 .* randn(Gc) # group random intercepts, SD 0.6
yc2 = Float64.([rand(Distributions.Poisson(exp(0.3 + 0.5 * xc[i] + bg[gc[i]]))) for i in 1:nc])
fitre = drm(bf(@formula(y ~ x + (1 | g))), Poisson(); data = (; y = yc2, x = xc, g = gc))
re_sd(fitre)[:g] # recovered group random-intercept SD (≈ 0.6)0.6281163243370657re_sd(fit)[:g] is the group SD; coef(fitre, :mu) are the population-level (b = 0) log-rate coefficients.
See also
What can I fit today? — the family/feature matrix.