--- title: "Detecting and Modeling Underdispersed Counts" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Detecting and Modeling Underdispersed Counts} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>", fig.width = 6, fig.height = 4) set.seed(1) ``` `underdisp` provides tools for detecting and modeling *underdispersion* in count data: the case where the conditional variance is below the conditional mean, so counts cluster more tightly around their expectation than a Poisson allows. The Poisson and negative binomial defaults cannot represent it; the negative binomial in particular collapses onto the Poisson when the data are underdispersed. ```{r setup} library(underdisp) ``` ## Simulating an underdispersed count We generate a count with a conditional variance-to-mean ratio of about one half. ```{r sim} n <- 400 x <- rnorm(n) N <- pmax(round(exp(1.6 + 0.5 * x) / 0.5), 1) y <- rbinom(n, N, 0.5) d <- data.frame(y = y, x = x) c(mean = mean(y), var = var(y), ratio = var(y) / mean(y)) ``` ## Screening `ud_screen()` returns a marginal verdict, and, for zero-inflated outcomes, an at-risk verdict benchmarked against a *zero-truncated* Poisson (which is what separates genuine underdispersion from the artifact of conditioning on positive counts). ```{r screen} ud_screen(y ~ x, data = d, run_cpb = FALSE) ``` ## Fitting the continuous parameter binomial `cpb()` fits the CPB, with `truncated = TRUE` for the common case in which underdispersion lives among the positive counts of a zero-inflated outcome. ```{r fit} fit <- cpb(y ~ x, data = d[d$y > 0, ], se = "none") summary(fit) ``` The dispersion parameter `alpha` summarizes the compression, and each observation carries an implied ceiling `lambda / (1 - alpha)`. ## Quantities of interest Predicted probabilities, the implied ceiling, and King-style first differences are available for user-specified covariate profiles. ```{r qoi} predict(fit, newdata = data.frame(x = c(-1, 0, 1)), type = "response") implied_ceiling(fit, newdata = data.frame(x = 0)) ``` ## Bootstrap inference Because the CPB's support depends on its parameters, Hessian-based standard errors are unreliable; coefficient inference uses a cold-multistart pairs bootstrap (validated to nominal coverage in the companion paper), and the dispersion parameter carries a profile-likelihood interval. ```{r boot} fit_b <- cpb(y ~ x, data = d[d$y > 0, ], se = "bootstrap", B = 99) summary(fit_b) irr(fit_b) # incidence-rate ratios with percentile intervals alpha_confint(fit_b) # profile-likelihood interval for alpha ``` ## The free-dispersion GEC `gec()` fits King's generalized event count (Katz) model, whose dispersion `delta` (the variance-to-mean ratio) is estimated freely — so the data choose the direction of dispersion rather than the analyst presuming it. On the underdispersed count above it recovers `delta` well below one; on a Poisson outcome it sits at one. ```{r gec} gec(y ~ x, data = d, se = "none") # delta ~ 0.5 gec(y ~ x, data = data.frame(y = rpois(n, exp(1 + 0.4 * x)), x = x), se = "none") # delta ~ 1 ``` The GEC carries the same zero-truncated, hurdle (`hurdle_gec()`), zero-inflated (`zi_gec()`), and fixed-effects (`gec_fe()`) variants as the CPB. ## High-dimensional fixed effects Underdispersion is typically a *within-unit* phenomenon that pooled analyses hide. `cpb_fe()` absorbs a full set of unit fixed effects by concentrating them out of the likelihood, so it scales to thousands of units. ```{r fe} panel <- do.call(rbind, lapply(1:50, function(i) { xx <- rnorm(12); NN <- pmax(round(exp(rnorm(1, 0, 0.4) + 0.4 * xx) / 0.5), 1) data.frame(unit = i, x = xx, y = rbinom(12, NN, 0.5)) })) cpb_fe(y ~ x, data = panel, fe = "unit") ``` ## Comparing the family `compare_dispersion()` fits the Poisson, negative binomial, the native soft-tail COM-Poisson, the free-dispersion GEC, and the hard-ceiling CPB, and reports a fit comparison plus the CPB's ceiling-exceedance share. ```{r family} compare_dispersion(y ~ x, data = d)$table ``` ## Matched Poisson, NB, and COM-Poisson baselines For model selection, `count_reg()` fits Poisson, negative-binomial, and COM-Poisson regressions -- each with the same fixed-effects, zero-truncation, hurdle, zero-inflation, offset, and robust-/cluster-standard-error options as the CPB -- so `compare_models()` can place the CPB next to its baselines on one footing (identical degrees of freedom, log-likelihood, and proper-score accounting). ```{r matched} cpb_fit <- cpb(y ~ x, data = d, truncated = FALSE, se = "none") compare_models( CPB = cpb_fit, Poisson = count_reg(y ~ x, data = d, family = "poisson"), NB = count_reg(y ~ x, data = d, family = "negbin"), `COM-Poisson`= count_reg(y ~ x, data = d, family = "compois") ) ``` On underdispersed data the negative binomial collapses onto the Poisson, while the CPB and COM-Poisson capture the compression and win on AIC and the proper scores. The correlated-random-effects device (`mundlak()`) and matching `d`/`p`/`q`/`r` functions (e.g. `rcompois()`, `dcpb()`) round out the family. ## Excess zeros: hurdle and zero-inflated models Many count outcomes mix a participation process (most units at zero) with a tight positive count. The bundled peacekeeping panel -- the number of UN operations each state contributes troops to per year -- shows the package's central move: marginally the count looks overdispersed, but conditioning on country fixed effects and benchmarking the positive counts against a zero-truncated Poisson, the at-risk process is underdispersed. ```{r pkscreen} data(peacekeeping) ud_screen(contributions ~ democracy + lgdppc + lpop + milper + factor(iso3), data = peacekeeping, run_cpb = FALSE, run_gp = FALSE) ``` That is the case for a two-part model with an underdispersed intensity. `hurdle_cpb()` joins a participation logit to a zero-truncated CPB, and `zi_cpb()` fits the structural-zero mixture; `zi_test()` and `compare_models()` adjudicate between them. ```{r hurdle} z <- rnorm(n) yh <- rhurdle_cpb(n, lambda = exp(1.2 + 0.3 * x), alpha = 0.5, p = plogis(0.3 + 0.8 * z)) dh <- data.frame(y = yh, x = x, z = z) h <- hurdle_cpb(y ~ x, data = dh, participation = ~ z) zi <- zi_cpb(y ~ x, data = dh, zero = ~ z) compare_models(hurdle = h, mixture = zi) ``` The hurdle's `first_difference()` separates the extensive and intensive margins exactly -- which channel a covariate moves, not just the blended marginal effect. One practical note: because the hurdle factorizes, its participation stage is an ordinary logistic regression; if a dummy-heavy participation equation separates, fit that stage with a dedicated bias-reduction package (`logistf`, `brglm2`) alongside this package's zero-truncated intensity. ## Short panels: bias-corrected fixed effects The concentrated fixed-effects dispersion estimate carries the incidental- parameters bias of order 1/T: with few observations per unit, `alpha` is biased *downward* (the panel looks more underdispersed than it is). `bias_correct = "jackknife"` removes the leading bias term by the split-panel jackknife of Dhaene and Jochmans (2015), refitting on each unit's temporal halves. ```{r jackknife} short <- do.call(rbind, lapply(1:30, function(i) { xx <- rnorm(8); NN <- pmax(round(exp(1.0 + rnorm(1, 0, 0.4) + 0.3 * xx) / 0.5), 1) data.frame(unit = i, x = xx, y = rbinom(8, NN, 0.5)) })) ml <- cpb_fe(y ~ x, data = short, fe = "unit") jk <- cpb_fe(y ~ x, data = short, fe = "unit", bias_correct = "jackknife") c(ml = ml$alpha, jackknife = jk$alpha) # truth is 0.5; ML is biased downward ``` The correction is only valid when the two half-panels estimate the same parameter (the method's time-homogeneity requirement), so it carries a validity gate: the panel is also split cross-sectionally by units -- a placebo that is exchangeable under any time pattern -- and if the temporal halves disagree beyond that placebo noise, the correction is *refused* with a warning naming the failed assumption and the maximum-likelihood fit is returned. On a trending or regime-changing panel, the refusal is the correct answer. The gate is deliberately powered over sized: in calibration it refuses about 9% of genuinely homogeneous panels (you keep the ordinary ML fit) while catching 98% of dispersion regime changes and all smooth unmodeled trends. ## Simulated-residual diagnostics with DHARMa Every fitted model in the package has a `simulate()` method, so the whole family plugs into `DHARMa`'s simulated-residual diagnostics. ```{r dharma, eval = requireNamespace("DHARMa", quietly = TRUE)} sims <- simulate(h, nsim = 100, seed = 1) res <- DHARMa::createDHARMa(simulatedResponse = as.matrix(sims), observedResponse = dh$y, fittedPredictedResponse = fitted(h), integerResponse = TRUE) plot(res) ```