About the simulated data This module uses a fixed-seed simulated public-health cohort of respiratory emergency-department visits. Follow-up differs in person-years, and some people have structural zeros because their care is outside the participating-capture system. The data contain no real people or clinical records.
Define the event and the exposure → inspect the count distribution → fit a Poisson rate model → assess dispersion and zeros → choose an interpretable extension → check predictions and calibration → report the design and limitations.
The word “zero-inflated” names a probability model, not a diagnosis. Start with the data-generating and measurement processes; then ask whether a mixture model answers a meaningful question.
After completing this tutorial, you should be able to:
offset(log(person_years));A count is the number of observed events: 3 respiratory emergency-department (ED) visits. A rate divides events by person-time: 3 visits in 1.5 person-years equals 2 visits per person-year. A risk is the probability of at least one event in a specified interval, which needs a fixed follow-up definition and is not generally the same as a rate. A zero probability, , is neither an IRR nor a risk unless the outcome and observation window make it one.
For person , let be ED visits and be observed person-years. A Poisson rate model assumes
The coefficient of log(t_i) is fixed at one. It is an
offset, not a covariate whose association is estimated.
Thus
is an expected event rate per person-year, and exponentiated covariate
coefficients are IRRs holding other modeled predictors fixed.
Do not divide the outcome and then also
use an offset For unequal observation time, model the integer
count with offset(log(person_years)). A linear model for
visits/person_years has the wrong error structure, gives
unstable weight to short follow-up, and should not be combined with the
offset as though it were a second correction.
The simulated cohort has people. Program participation, smoking, severity, rural residence, and age influence the rate while access barriers and rural residence influence whether care is outside capture. This mechanism deliberately creates more zeros than a one-process Poisson model expects.
summary_table <- data.frame(
Quantity = c("People", "Total visits", "Person-years", "Observed zero visits",
"Mean visits", "Variance of visits", "Mean follow-up"),
Value = c(nrow(count_data), sum(count_data$visits), sum(count_data$person_years),
sum(count_data$visits == 0), mean(count_data$visits), var(count_data$visits),
mean(count_data$person_years))
)
knitr::kable(summary_table, digits = 2,
caption = "Descriptive summary of the simulated respiratory ED-visit cohort")| Quantity | Value |
|---|---|
| People | 2400.00 |
| Total visits | 1717.00 |
| Person-years | 2926.23 |
| Observed zero visits | 1451.00 |
| Mean visits | 0.72 |
| Variance of visits | 1.29 |
| Mean follow-up | 1.22 |
count_tab <- table(factor(count_data$visits, levels = 0:max(count_data$visits)))
barplot(count_tab, col = palette_count["teal"], border = "white", xlab = "Observed ED visits",
ylab = "Number of people", main = "Observed count distribution")The distribution of observed respiratory ED-visit counts has a large bar at zero and a decreasing right tail. This plot motivates diagnosis but does not by itself identify a zero-inflated process.
The outcome must be a nonnegative integer; identify whether a zero means no event, no opportunity, a recording rule, or an unobserved event. Confirm that person-time is positive and belongs to the same observation period as the count. Check duplicate rows, impossible dates, coding changes, and whether care outside a network is observed.
analysis_variables <- c("visits", "person_years", "program", "smoking", "severity",
"rural", "age_c10", "access_barrier")
complete_rows <- complete.cases(count_data[, analysis_variables])
data.frame(
Eligible_rows = nrow(count_data), Complete_rows = sum(complete_rows),
Excluded_for_missingness = sum(!complete_rows),
Minimum_person_years = min(count_data$person_years),
Maximum_person_years = max(count_data$person_years)
)| Eligible_rows | Complete_rows | Excluded_for_missingness | Minimum_person_years | Maximum_person_years |
|---|---|---|---|---|
| 2400 | 2400 | 0 | 0.45 | 2 |
This simulated example is complete by construction. In a study, complete-case analysis is unbiased only under restrictive conditions; report missingness by variable and use a prespecified approach such as multiple imputation when justified. Do not impute the event count or person-time casually: the imputation model must honor their measurement process.
poisson_fit <- glm(
visits ~ program + smoking + severity + rural + age_c10 + offset(log(person_years)),
family = poisson(link = "log"), data = count_data
)
poisson_fit##
## Call: glm(formula = visits ~ program + smoking + severity + rural +
## age_c10 + offset(log(person_years)), family = poisson(link = "log"),
## data = count_data)
##
## Coefficients:
## (Intercept) programProgram smokingYes severity ruralRural
## -0.588 -0.254 0.371 0.368 -0.176
## age_c10
## 0.124
##
## Degrees of Freedom: 2399 Total (i.e. Null); 2394 Residual
## Null Deviance: 3490
## Residual Deviance: 3110 AIC: 5400
The program coefficient compares the adjusted visit rate in the program group with the standard group. The simulated mechanism includes a program log-rate effect of , but real analyses never have this privileged access to the data-generating truth.
For coefficient , the IRR is . A Wald 95% confidence interval is . This is a conditional relative comparison, not an individual probability and not necessarily a causal effect.
poisson_ci <- confint.default(poisson_fit)
poisson_irr <- data.frame(
Term = names(coef(poisson_fit)), IRR = exp(coef(poisson_fit)),
`Lower 95% CI` = exp(poisson_ci[, 1]), `Upper 95% CI` = exp(poisson_ci[, 2]),
check.names = FALSE
)
poisson_effects <- poisson_irr[poisson_irr$Term != "(Intercept)", ]
knitr::kable(poisson_effects, digits = 3,
caption = "Poisson-model covariate incidence rate ratios with Wald 95% confidence intervals")| Term | IRR | Lower 95% CI | Upper 95% CI | |
|---|---|---|---|---|
| programProgram | programProgram | 0.776 | 0.705 | 0.853 |
| smokingYes | smokingYes | 1.449 | 1.308 | 1.605 |
| severity | severity | 1.444 | 1.377 | 1.515 |
| ruralRural | ruralRural | 0.839 | 0.757 | 0.929 |
| age_c10 | age_c10 | 1.132 | 1.087 | 1.180 |
The intercept is omitted from the IRR table. Its exponentiated value is the baseline rate for the reference levels when continuous covariates equal zero, not a covariate-effect ratio.
reference_profiles <- data.frame(
person_years = 1,
program = factor(c("Standard", "Program"), levels = levels(count_data$program)),
smoking = factor("No", levels = levels(count_data$smoking)), severity = 0,
rural = factor("Urban", levels = levels(count_data$rural)), age_c10 = 0,
access_barrier = factor("No", levels = levels(count_data$access_barrier))
)
reference_profiles$poisson_mean_visits <- predict(poisson_fit, reference_profiles, type = "response")
knitr::kable(reference_profiles[, c("program", "person_years", "poisson_mean_visits")], digits = 3,
caption = "Poisson-model expected visits for two reference profiles with one person-year of follow-up")| program | person_years | poisson_mean_visits |
|---|---|---|
| Standard | 1 | 0.555 |
| Program | 1 | 0.431 |
Absolute predictions make the baseline, follow-up time, and covariate pattern visible. For a Poisson model, the predicted probability of at least one visit is ; it is not the same quantity as the expected count .
Under a correctly specified Poisson model, conditional variance equals conditional mean. A common descriptive statistic is
Values well above one suggest overdispersion, but a single cutoff cannot determine its cause. Omitted predictors, nonlinear effects, heterogeneous rates, repeated observations, dependence within clinics, recording mixtures, and excess zeros can all inflate residual variation.
pearson_dispersion <- sum(residuals(poisson_fit, type = "pearson")^2) / df.residual(poisson_fit)
data.frame(
Pearson_dispersion = pearson_dispersion,
Residual_degrees_of_freedom = df.residual(poisson_fit),
Interpretation = ifelse(pearson_dispersion > 1.2, "Overdispersion is present; investigate its source.",
"No large dispersion signal in this descriptive check.")
)| Pearson_dispersion | Residual_degrees_of_freedom | Interpretation |
|---|---|---|
| 1.36 | 2394 | Overdispersion is present; investigate its source. |
A quasi-Poisson model retains the mean model but estimates a scale factor for standard errors. It has no full likelihood, so ordinary AIC comparisons are unavailable. A negative binomial (NB) model adds a dispersion parameter, often written . It is especially useful when unobserved multiplicative heterogeneity is a plausible rate mechanism.
quasi_fit <- glm(
visits ~ program + smoking + severity + rural + age_c10 + offset(log(person_years)),
family = quasipoisson(link = "log"), data = count_data
)
if (!requireNamespace("MASS", quietly = TRUE)) {
stop("This module requires MASS for the negative binomial example. Install MASS first.", call. = FALSE)
}
nb_fit <- MASS::glm.nb(
visits ~ program + smoking + severity + rural + age_c10 + offset(log(person_years)),
data = count_data
)
comparison_rates <- data.frame(
Model = c("Poisson", "Quasi-Poisson", "Negative binomial"),
Program_IRR = c(exp(coef(poisson_fit)["programProgram"]),
exp(coef(quasi_fit)["programProgram"]), exp(coef(nb_fit)["programProgram"])),
Program_SE_log_IRR = c(summary(poisson_fit)$coefficients["programProgram", "Std. Error"],
summary(quasi_fit)$coefficients["programProgram", "Std. Error"],
summary(nb_fit)$coefficients["programProgram", "Std. Error"])
)
knitr::kable(comparison_rates, digits = 3,
caption = "Program rate-ratio estimates from models that address variance differently")| Model | Program_IRR | Program_SE_log_IRR |
|---|---|---|
| Poisson | 0.776 | 0.049 |
| Quasi-Poisson | 0.776 | 0.057 |
| Negative binomial | 0.779 | 0.061 |
An NB model is not a zero-inflated model NB allows greater count variance through continuous rate heterogeneity. ZIP adds a separate point mass for structural zeros. Both may fit the same data, neither is automatically the truth, and their coefficients answer different model-based questions.
In the simulated setting, an observed zero can arise because a person is outside participating-care capture (a structural zero) or because a person inside capture has no visit during follow-up (a Poisson zero). That is a substantive mixture story. Excess observed zeros alone do not prove that story: a wrong mean model, short follow-up, unmodeled clustering, or NB heterogeneity can produce the same visual symptom.
Let identify the structural-zero state and let . Conditional on , . The ZIP probability mass function is
We use log links for the count mean and a logit link for . The count component describes the susceptible/captured process; the zero component describes membership in the always-zero state. A positive zero-component coefficient increases structural-zero odds, not the odds of any observed zero.
The following functions implement maximum likelihood directly with
optim(). For zeros, the likelihood is evaluated as
,
avoiding unstable multiplication on the ordinary probability scale. The
inverse Hessian provides approximate standard errors, so convergence and
the validity of the Hessian must be checked.
# This teaching ZIP implementation uses direct maximum likelihood, stable
# log-probabilities, multiple starts, and Hessian-based standard errors.
fit_zip <- function(count_formula, zero_formula, data, maxit = 5000) {
count_frame <- model.frame(count_formula, data = data, na.action = na.fail)
count_terms <- terms(count_frame)
y <- model.response(count_frame)
if (any(y < 0 | y != floor(y))) stop("The outcome must contain nonnegative integer counts.")
X <- model.matrix(count_terms, count_frame)
count_offset <- model.offset(count_frame)
if (is.null(count_offset)) count_offset <- rep(0, length(y))
zero_frame <- model.frame(zero_formula, data = data, na.action = na.fail)
zero_terms <- terms(zero_frame)
Z <- model.matrix(zero_terms, zero_frame)
if (nrow(Z) != length(y)) stop("Count and zero formulas produced different rows.")
poisson_start <- glm(count_formula, family = poisson(link = "log"), data = data)
beta_start <- coef(poisson_start)
poisson_zero <- mean(exp(-fitted(poisson_start)))
pi_start <- (mean(y == 0) - poisson_zero) / (1 - poisson_zero)
pi_start <- pmin(pmax(pi_start, 0.02), 0.80)
n_count <- ncol(X)
logspace_add <- function(a, b) {
larger <- pmax(a, b)
larger + log(exp(a - larger) + exp(b - larger))
}
nll <- function(par) {
beta <- par[seq_len(n_count)]
gamma <- par[n_count + seq_len(ncol(Z))]
eta_count <- drop(count_offset + X %*% beta)
if (any(!is.finite(eta_count)) || any(abs(eta_count) > 700)) {
return(.Machine$double.xmax / 100)
}
mu <- exp(eta_count)
eta_zero <- drop(Z %*% gamma)
log_pi <- plogis(eta_zero, log.p = TRUE)
log_one_minus_pi <- plogis(-eta_zero, log.p = TRUE)
is_zero <- y == 0
log_lik <- numeric(length(y))
log_lik[is_zero] <- logspace_add(
log_pi[is_zero], log_one_minus_pi[is_zero] - mu[is_zero]
)
log_lik[!is_zero] <- log_one_minus_pi[!is_zero] +
dpois(y[!is_zero], lambda = mu[!is_zero], log = TRUE)
if (any(!is.finite(log_lik))) return(.Machine$double.xmax / 100)
-sum(log_lik)
}
zero_intercept_starts <- unique(c(qlogis(pi_start), qlogis(c(0.05, 0.25, 0.50))))
starts <- lapply(
zero_intercept_starts,
function(intercept) c(beta_start, intercept, rep(0, ncol(Z) - 1L))
)
candidates <- lapply(starts, function(start) {
optim(
par = start, fn = nll, method = "BFGS", hessian = TRUE,
control = list(maxit = maxit, reltol = 1e-10)
)
})
valid <- vapply(
candidates,
function(candidate) candidate$convergence == 0 && is.finite(candidate$value),
logical(1)
)
if (!any(valid)) stop("No ZIP optimization start converged.", call. = FALSE)
valid_candidates <- candidates[valid]
opt <- valid_candidates[[which.min(vapply(valid_candidates, `[[`, numeric(1), "value"))]]
hessian <- (opt$hessian + t(opt$hessian)) / 2
hessian_eigen <- eigen(hessian, symmetric = TRUE, only.values = TRUE)$values
hessian_condition <- max(hessian_eigen) / min(hessian_eigen)
if (any(!is.finite(hessian_eigen)) || min(hessian_eigen) <= 0 ||
!is.finite(hessian_condition) || hessian_condition > 1e10) {
stop("The ZIP Hessian is not sufficiently positive definite.", call. = FALSE)
}
vcov_matrix <- solve(hessian)
numerical_gradient <- vapply(seq_along(opt$par), function(j) {
step <- 1e-5 * max(1, abs(opt$par[j]))
upper <- lower <- opt$par
upper[j] <- upper[j] + step
lower[j] <- lower[j] - step
(nll(upper) - nll(lower)) / (2 * step)
}, numeric(1))
gradient_max <- max(abs(numerical_gradient))
if (!is.finite(gradient_max) || gradient_max > 0.01) {
stop("The ZIP solution has an unacceptably large numerical gradient.", call. = FALSE)
}
coefficient_names <- c(paste0("count_", colnames(X)), paste0("zero_", colnames(Z)))
names(opt$par) <- coefficient_names
dimnames(vcov_matrix) <- list(coefficient_names, coefficient_names)
factor_levels <- function(frame) lapply(frame[vapply(frame, is.factor, logical(1))], levels)
structure(list(coefficients = opt$par, vcov = vcov_matrix, logLik = -opt$value,
convergence = opt$convergence, count_terms = count_terms, zero_terms = zero_terms,
count_xlevels = factor_levels(count_frame), zero_xlevels = factor_levels(zero_frame),
count_contrasts = attr(X, "contrasts"), zero_contrasts = attr(Z, "contrasts"),
n_count = n_count, n_zero = ncol(Z), nobs = length(y), y = y,
starts_tried = length(starts), gradient_max = gradient_max,
hessian_min_eigen = min(hessian_eigen), hessian_condition = hessian_condition),
class = "teaching_zip")
}
predict_zip <- function(object, newdata) {
count_terms <- delete.response(object$count_terms)
count_frame <- model.frame(count_terms, data = newdata, na.action = na.pass,
xlev = object$count_xlevels)
X <- model.matrix(count_terms, count_frame, contrasts.arg = object$count_contrasts)
count_offset <- model.offset(count_frame)
if (is.null(count_offset)) count_offset <- rep(0, nrow(X))
zero_frame <- model.frame(object$zero_terms, data = newdata, na.action = na.pass,
xlev = object$zero_xlevels)
Z <- model.matrix(object$zero_terms, zero_frame, contrasts.arg = object$zero_contrasts)
beta <- object$coefficients[seq_len(object$n_count)]
gamma <- object$coefficients[object$n_count + seq_len(object$n_zero)]
mu <- exp(drop(count_offset + X %*% beta))
pi <- plogis(drop(Z %*% gamma))
data.frame(count_mean = mu, structural_zero_probability = pi,
marginal_mean = (1 - pi) * mu,
observed_zero_probability = pi + (1 - pi) * exp(-mu))
}zip_fit <- fit_zip(
visits ~ program + smoking + severity + rural + age_c10 + offset(log(person_years)),
~ access_barrier + rural, data = count_data
)
if (zip_fit$convergence != 0) stop("ZIP optimization did not converge.")
zip_diagnostics <- data.frame(
Quantity = c("Convergence code", "Starting values tried", "Maximum absolute gradient",
"Minimum Hessian eigenvalue", "Hessian condition number"),
Value = c(zip_fit$convergence, zip_fit$starts_tried, zip_fit$gradient_max,
zip_fit$hessian_min_eigen, zip_fit$hessian_condition)
)
knitr::kable(zip_diagnostics, digits = 4,
caption = "ZIP optimization and curvature diagnostics")| Quantity | Value |
|---|---|
| Convergence code | 0.0000 |
| Starting values tried | 4.0000 |
| Maximum absolute gradient | 0.0004 |
| Minimum Hessian eigenvalue | 17.7536 |
| Hessian condition number | 170.9350 |
zip_se <- sqrt(diag(zip_fit$vcov))
zip_ci <- cbind(zip_fit$coefficients - 1.96 * zip_se, zip_fit$coefficients + 1.96 * zip_se)
zip_table <- data.frame(
Component = ifelse(grepl("^count_", names(zip_fit$coefficients)), "Count mean", "Structural-zero probability"),
Term = sub("^(count_|zero_)", "", names(zip_fit$coefficients)),
Estimate = zip_fit$coefficients, SE = zip_se,
`Exp(estimate)` = exp(zip_fit$coefficients),
`Lower 95% CI` = exp(zip_ci[, 1]), `Upper 95% CI` = exp(zip_ci[, 2]), check.names = FALSE
)
zip_effect_table <- zip_table[zip_table$Term != "(Intercept)", ]
knitr::kable(zip_effect_table, digits = 3,
caption = "ZIP covariate effects. Exponentiated count coefficients are IRRs; exponentiated zero-component coefficients are structural-zero odds ratios.")| Component | Term | Estimate | SE | Exp(estimate) | Lower 95% CI | Upper 95% CI | |
|---|---|---|---|---|---|---|---|
| count_programProgram | Count mean | programProgram | -0.262 | 0.053 | 0.77 | 0.693 | 0.854 |
| count_smokingYes | Count mean | smokingYes | 0.355 | 0.058 | 1.43 | 1.273 | 1.596 |
| count_severity | Count mean | severity | 0.375 | 0.027 | 1.46 | 1.380 | 1.534 |
| count_ruralRural | Count mean | ruralRural | 0.148 | 0.067 | 1.16 | 1.017 | 1.322 |
| count_age_c10 | Count mean | age_c10 | 0.096 | 0.023 | 1.10 | 1.053 | 1.152 |
| zero_access_barrierYes | Structural-zero probability | access_barrierYes | 1.330 | 0.159 | 3.78 | 2.768 | 5.167 |
| zero_ruralRural | Structural-zero probability | ruralRural | 0.752 | 0.181 | 2.12 | 1.488 | 3.023 |
The omitted count intercept exponentiates to the reference profile’s baseline rate per person-year; the omitted zero-component intercept exponentiates to the reference profile’s baseline structural-zero odds. Neither intercept is a covariate-effect ratio.
The programProgram count coefficient estimates a program
IRR within the modeled nonstructural process, conditional on
covariates. The access-barrier coefficient in the zero component changes
odds of being structurally zero. Neither automatically estimates the
population-average causal impact of program participation.
For a covariate profile, ZIP returns:
zip_profiles <- reference_profiles
zip_profiles <- zip_profiles[rep(seq_len(nrow(zip_profiles)), each = 2), ]
rownames(zip_profiles) <- NULL
zip_profiles$access_barrier <- factor(
rep(c("No", "Yes"), times = 2),
levels = levels(count_data$access_barrier)
)
zip_prediction <- cbind(zip_profiles[, c("program", "access_barrier", "person_years")],
predict_zip(zip_fit, zip_profiles))
knitr::kable(zip_prediction, digits = 3,
caption = "ZIP predictions for reference profiles. The marginal mean and observed-zero probability combine both model components.")| program | access_barrier | person_years | count_mean | structural_zero_probability | marginal_mean | observed_zero_probability |
|---|---|---|---|---|---|---|
| Standard | No | 1 | 0.750 | 0.187 | 0.610 | 0.571 |
| Standard | Yes | 1 | 0.750 | 0.465 | 0.402 | 0.717 |
| Program | No | 1 | 0.577 | 0.187 | 0.470 | 0.643 |
| Program | Yes | 1 | 0.577 | 0.465 | 0.309 | 0.765 |
# Parametric coefficient simulation preserves covariance between both ZIP components.
set.seed(20260821)
n_draws <- 1200
coefficient_draws <- matrix(
rnorm(n_draws * length(zip_fit$coefficients)), nrow = n_draws
) %*% chol(zip_fit$vcov)
coefficient_draws <- sweep(coefficient_draws, 2, zip_fit$coefficients, "+")
count_terms_profiles <- delete.response(zip_fit$count_terms)
count_frame_profiles <- model.frame(
count_terms_profiles, zip_profiles, xlev = zip_fit$count_xlevels
)
X_profiles <- model.matrix(
count_terms_profiles, count_frame_profiles,
contrasts.arg = zip_fit$count_contrasts
)
profile_offset <- model.offset(count_frame_profiles)
Z_profiles <- model.matrix(
zip_fit$zero_terms,
model.frame(zip_fit$zero_terms, zip_profiles, xlev = zip_fit$zero_xlevels),
contrasts.arg = zip_fit$zero_contrasts
)
beta_draws <- coefficient_draws[, seq_len(zip_fit$n_count), drop = FALSE]
gamma_draws <- coefficient_draws[, zip_fit$n_count + seq_len(zip_fit$n_zero), drop = FALSE]
mu_draws <- exp(sweep(beta_draws %*% t(X_profiles), 2, profile_offset, "+"))
pi_draws <- plogis(gamma_draws %*% t(Z_profiles))
marginal_draws <- (1 - pi_draws) * mu_draws
zero_probability_draws <- pi_draws + (1 - pi_draws) * exp(-mu_draws)
zip_prediction[["Marginal mean lower 95% limit"]] <- apply(marginal_draws, 2, quantile, 0.025)
zip_prediction[["Marginal mean upper 95% limit"]] <- apply(marginal_draws, 2, quantile, 0.975)
zip_prediction[["Zero probability lower 95% limit"]] <- apply(zero_probability_draws, 2, quantile, 0.025)
zip_prediction[["Zero probability upper 95% limit"]] <- apply(zero_probability_draws, 2, quantile, 0.975)
knitr::kable(zip_prediction, digits = 3,
caption = "ZIP profile predictions with approximate 95% coefficient-uncertainty intervals")| program | access_barrier | person_years | count_mean | structural_zero_probability | marginal_mean | observed_zero_probability | Marginal mean lower 95% limit | Marginal mean upper 95% limit | Zero probability lower 95% limit | Zero probability upper 95% limit |
|---|---|---|---|---|---|---|---|---|---|---|
| Standard | No | 1 | 0.750 | 0.187 | 0.610 | 0.571 | 0.555 | 0.668 | 0.543 | 0.600 |
| Standard | Yes | 1 | 0.750 | 0.465 | 0.402 | 0.717 | 0.347 | 0.462 | 0.680 | 0.755 |
| Program | No | 1 | 0.577 | 0.187 | 0.470 | 0.643 | 0.427 | 0.515 | 0.616 | 0.668 |
| Program | Yes | 1 | 0.577 | 0.465 | 0.309 | 0.765 | 0.267 | 0.357 | 0.733 | 0.796 |
These intervals propagate model-coefficient uncertainty while retaining the cross-covariance between the count and zero components. They are not prediction intervals for a future person’s realized count and do not include model-selection or structural uncertainty.
Do not report as the expected count for everyone In ZIP, is the Poisson mean within the nonstructural component. The population-average expected observed count is . Clearly state which quantity your prediction table reports.
We compare the observed count frequencies with model-based expected frequencies. For a Poisson model, . For ZIP, use the mixture formula for zero and multiply ordinary Poisson probabilities by for positive counts. The last category pools the tail to make a readable diagnostic.
zip_pred_all <- predict_zip(zip_fit, count_data)
mu_pois <- fitted(poisson_fit)
max_show <- 5
observed_frequency <- tabulate(
pmin(count_data$visits, max_show + 1) + 1,
nbins = max_show + 2
)
poisson_frequency <- sapply(0:max_show, function(k) sum(dpois(k, mu_pois)))
poisson_frequency <- c(poisson_frequency, nrow(count_data) - sum(poisson_frequency))
zip_frequency <- sapply(0:max_show, function(k) {
if (k == 0) sum(zip_pred_all$observed_zero_probability) else {
sum((1 - zip_pred_all$structural_zero_probability) * dpois(k, zip_pred_all$count_mean))
}
})
zip_frequency <- c(zip_frequency, nrow(count_data) - sum(zip_frequency))
frequency_check <- data.frame(
Count_category = c(as.character(0:max_show), paste0(max_show + 1, "+")),
Observed = observed_frequency, Poisson_expected = poisson_frequency, ZIP_expected = zip_frequency
)
knitr::kable(frequency_check, digits = 1,
caption = "Observed and model-predicted count frequencies, with the upper tail pooled")| Count_category | Observed | Poisson_expected | ZIP_expected |
|---|---|---|---|
| 0 | 1451 | 1276.1 | 1445.9 |
| 1 | 498 | 718.8 | 506.2 |
| 2 | 257 | 274.0 | 257.8 |
| 3 | 120 | 90.9 | 113.2 |
| 4 | 47 | 28.3 | 46.6 |
| 5 | 15 | 8.5 | 18.6 |
| 6+ | 12 | 3.4 | 11.7 |
barplot(
t(as.matrix(frequency_check[, c("Observed", "Poisson_expected", "ZIP_expected")])),
beside = TRUE, names.arg = frequency_check$Count_category,
col = c(palette_count["navy"], palette_count["orange"], palette_count["teal"]),
border = NA, xlab = "Visit-count category", ylab = "Frequency",
main = "Observed versus expected frequencies"
)
legend("topright", legend = c("Observed", "Poisson expected", "ZIP expected"),
fill = c(palette_count["navy"], palette_count["orange"], palette_count["teal"]), bty = "n")Observed count frequencies are compared with frequencies expected under the Poisson and ZIP models. Agreement across zero, small positive counts, and the pooled tail is more informative than a single fit statistic.
Calibration asks whether predicted outcomes agree with observed outcomes on the relevant scale and population. Group people by predicted marginal mean or zero probability, then compare observed and expected values. Internal apparent calibration can look excellent even when a model is overfit; use resampling or an external cohort for prediction claims.
calibration_group <- cut(rank(zip_pred_all$marginal_mean, ties.method = "first"),
breaks = quantile(seq_len(nrow(count_data)), probs = seq(0, 1, 0.1)),
include.lowest = TRUE, labels = FALSE)
calibration <- aggregate(cbind(observed_visits = count_data$visits,
predicted_visits = zip_pred_all$marginal_mean,
observed_zero = count_data$visits == 0,
predicted_zero = zip_pred_all$observed_zero_probability) ~ calibration_group,
FUN = mean)
knitr::kable(calibration, digits = 3,
caption = "Apparent ZIP calibration by deciles of predicted marginal mean")| calibration_group | observed_visits | predicted_visits | observed_zero | predicted_zero |
|---|---|---|---|---|
| 1 | 0.179 | 0.179 | 0.850 | 0.851 |
| 2 | 0.300 | 0.281 | 0.767 | 0.779 |
| 3 | 0.392 | 0.361 | 0.717 | 0.731 |
| 4 | 0.396 | 0.450 | 0.717 | 0.682 |
| 5 | 0.546 | 0.536 | 0.646 | 0.641 |
| 6 | 0.600 | 0.639 | 0.613 | 0.587 |
| 7 | 0.729 | 0.768 | 0.546 | 0.534 |
| 8 | 0.987 | 0.943 | 0.458 | 0.470 |
| 9 | 1.167 | 1.189 | 0.417 | 0.421 |
| 10 | 1.858 | 1.811 | 0.317 | 0.329 |
plot(calibration$predicted_visits, calibration$observed_visits, pch = 19,
col = palette_count["teal"], xlab = "Mean predicted visits", ylab = "Mean observed visits",
main = "Apparent calibration of the ZIP marginal mean")
abline(0, 1, lty = 2, col = palette_count["gray"], lwd = 2)Calibration of the ZIP marginal mean across ten groups ranked by prediction. Points near the diagonal indicate agreement between average predicted and observed visit counts within these simulated data.
Poisson, NB, and ZIP likelihood-based AIC values can be compared only when they are fitted to the same outcome, rows, and likelihood convention. Quasi-Poisson has no ordinary likelihood AIC. A lower AIC reflects an estimated out-of-sample information criterion, not proof that structural zeros exist, that causal confounding is solved, or that predictions transport to another health system.
zip_aic <- -2 * zip_fit$logLik + 2 * length(zip_fit$coefficients)
aic_table <- data.frame(
Model = c("Poisson", "Negative binomial", "ZIP"),
AIC = c(AIC(poisson_fit), AIC(nb_fit), zip_aic),
Note = c("Single Poisson count process", "Extra-Poisson rate heterogeneity", "Poisson process plus structural-zero mixture")
)
knitr::kable(aic_table, digits = 1,
caption = "AIC values for likelihood-based models fitted to the same simulated cohort")| Model | AIC | Note |
|---|---|---|
| Poisson | 5400 | Single Poisson count process |
| Negative binomial | 5234 | Extra-Poisson rate heterogeneity |
| ZIP | 5086 | Poisson process plus structural-zero mixture |
A hurdle model also has two components, but all zeros come from the binary hurdle and the positive-count distribution is zero-truncated. Once the hurdle is crossed, a positive count is guaranteed. ZIP instead allows a susceptible person to have zero events through the Poisson process. The distinction should follow the process: a screening rule that blocks all recorded events may support a hurdle; participating care with a chance of no visit supports a ZIP-like mixture.
| Question | ZIP | Hurdle model |
|---|---|---|
| Can the count process generate zero? | Yes | No; it is truncated at zero |
| What does the binary part model? | Structural-zero membership | Zero versus any positive count |
| What does a positive count imply? | Not structural zero | The hurdle was crossed |
| Is excess zeros enough to choose it? | No | No |
The program variable is randomized in this simulation only for illustration. In an observational study, program participation may reflect severity, access, prior utilization, clinic policy, or variables not measured. Adjustment can describe conditional associations; a causal rate effect needs an explicit target trial, temporal ordering, a well-defined intervention, no important unmeasured confounding, appropriate handling of selection and censoring, positivity, and a valid outcome-capture process.
Repeated visits within people, people within clinics, or neighborhoods within regions violate independence. Cluster-robust standard errors can address some inference issues with enough independent clusters; mixed-effects or generalized estimating equation approaches address different estimands and correlation structures. A ZIP likelihood that assumes independent rows is not repaired merely by adding many covariates.
Measurement systems can create the zeros Claims records, network boundaries, insurance changes, distance, and access barriers can determine whether an event is visible. Treat zero patterns as possible information-system behavior, not as intrinsic patient behavior. This matters for equity, transportability, and causal interpretation.
program_irr_pois <- exp(coef(poisson_fit)["programProgram"])
program_irr_nb <- exp(coef(nb_fit)["programProgram"])
program_irr_zip <- exp(zip_fit$coefficients["count_programProgram"])
case_summary <- data.frame(
Result = c("Participants", "Observed zero-visit proportion", "Pearson dispersion", "Poisson program IRR", "NB program IRR", "ZIP count-component program IRR", "ZIP convergence"),
Value = c(nrow(count_data), mean(count_data$visits == 0), pearson_dispersion,
program_irr_pois, program_irr_nb, program_irr_zip, zip_fit$convergence)
)
knitr::kable(case_summary, digits = 3,
caption = "Selected results from the simulated respiratory ED-visit analysis")| Result | Value |
|---|---|
| Participants | 2400.000 |
| Observed zero-visit proportion | 0.605 |
| Pearson dispersion | 1.363 |
| Poisson program IRR | 0.776 |
| NB program IRR | 0.779 |
| ZIP count-component program IRR | 0.770 |
| ZIP convergence | 0.000 |
In this simulated cohort, 60.5% of people had no recorded respiratory ED visit. The Poisson Pearson dispersion was 1.36, indicating that a one-process Poisson variance was inadequate. The adjusted program IRR was 0.78 in the Poisson model, 0.78 in the negative binomial model, and 0.77 for the ZIP count component. The ZIP model also estimates a separate structural-zero mechanism. These are model-based associations in simulated data; a favorable fit does not prove a care-capture mechanism or establish a causal program effect.
We analyzed [count definition] observed over [person-time definition] among [population and dates]. The primary estimand was [conditional rate ratio, marginal predicted count, prediction objective, or causal estimand]. We modeled the count using [Poisson/NB/ZIP] with
offset(log([person-time]))and adjusted for [prespecified covariates]. We reported [IRR or other measure] with 95% confidence intervals and absolute predicted outcomes for [named covariate profiles]. We assessed [dispersion, zero frequency, functional form, dependence, calibration]. The observed zero process was interpreted as [measurement/process rationale], and limitations included [missingness, clustering, residual confounding, capture boundaries, transportability].
| Error | Why it is wrong | Better approach |
|---|---|---|
| Model a rate as ordinary Gaussian data | Rates have denominator-dependent variance and can be skewed | Model the count and offset its log person-time |
| Interpret an IRR as a risk ratio | Rate and cumulative risk are distinct | State the event-time scale and report absolute predictions |
| Treat the offset coefficient as estimated | It is fixed at one by design | Verify the person-time definition and use
offset(log(time)) |
| Ignore overdispersion | Poisson standard errors can be too small | Diagnose causes and consider quasi-Poisson, NB, clustering, or a better mean model |
| Choose ZIP because a histogram has many zeros | Multiple mechanisms can create zeros | Articulate and test the measurement/process rationale |
| Read ZIP as the population mean | It is conditional on the nonstructural process | Report when that is the target |
| Call a zero-component odds ratio an observed-zero odds ratio | ZIP zero component is structural-zero membership | Name the model component and scale explicitly |
| Compare quasi-Poisson by AIC | It has no ordinary likelihood AIC | Compare predictive performance or likelihood models appropriately |
| Ignore repeated rows or clinics | Independence-based uncertainty can fail | Use a design-appropriate correlated-data method |
| Claim causality from adjusted count regression | Covariate adjustment does not solve all bias | Define the causal target and identification assumptions |
offset(log(person_years)) used instead of
offset(person_years)?| Target | Formula or code | Interpretation |
|---|---|---|
| Poisson rate model | glm(y ~ x + offset(log(time)), family = poisson) |
Conditional mean count proportional to time |
| IRR | Multiplicative conditional rate comparison | |
| Poisson zero probability | Probability of no event in one-process Poisson model | |
| Pearson dispersion | Descriptive check for extra variation | |
| Quasi-Poisson | family = quasipoisson |
Mean model with scale-adjusted uncertainty |
| Negative binomial | MASS::glm.nb(...) |
Count model with extra-Poisson heterogeneity |
| ZIP zero probability | Structural plus Poisson sources of zero | |
| ZIP marginal mean | Expected observed count across both processes | |
| ZIP fitting here | fit_zip(count_formula, zero_formula, data) |
Transparent MLE with optim() and Hessian SEs |
Next topics include zero-inflated negative binomial models, hurdle models, splines for nonlinear rates, distributed lags, recurrent-event survival models, generalized estimating equations, mixed-effects count models, cluster-robust inference, Bayesian hierarchical count models, and externally validated prediction models.
## R version 4.6.1 (2026-06-24)
## Platform: aarch64-apple-darwin23
## Running under: macOS Tahoe 26.5.1
##
## Matrix products: default
## BLAS: /Library/Frameworks/R.framework/Versions/4.6/Resources/lib/libRblas.0.dylib
## LAPACK: /Library/Frameworks/R.framework/Versions/4.6/Resources/lib/libRlapack.dylib; LAPACK version 3.12.1
##
## locale:
## [1] C.UTF-8/C.UTF-8/C.UTF-8/C/C.UTF-8/C.UTF-8
##
## time zone: America/Edmonton
## tzcode source: internal
##
## attached base packages:
## [1] stats graphics grDevices utils datasets methods base
##
## loaded via a namespace (and not attached):
## [1] digest_0.6.39 R6_2.6.1 fastmap_1.2.0 xfun_0.60
## [5] lattice_0.22-9 cachem_1.1.0 knitr_1.51 htmltools_0.5.9
## [9] rmarkdown_2.31 stats4_4.6.1 lifecycle_1.0.5 cli_3.6.6
## [13] grid_4.6.1 sass_0.4.10 jquerylib_0.1.4 compiler_4.6.1
## [17] tools_4.6.1 nlme_3.1-169 evaluate_1.0.5 bslib_0.12.0
## [21] yaml_2.3.12 rlang_1.3.0 jsonlite_2.0.0 MASS_7.3-65