--- title: "Proportions and success rates" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Proportions and success rates} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>") library(drmTMB) ``` Proportion data are common in ecology and evolution, but the measurement process matters. Some proportions are counts of successes out of known trials: germinated seeds out of seeds planted, infected hosts out of hosts checked, or occupied quadrats out of quadrats surveyed. Other proportions are continuous rates already measured on the unit interval, such as leaf damage, vegetation cover, or the fraction of time an animal spends foraging. This tutorial assumes the location-scale reading pattern from [When variance carries signal](location-scale.html) and extends it to a bounded response. This article stays inside the currently implemented `drmTMB` bounded-response surface: - use `stats::binomial(link = "logit")` for ordinary 0/1 events or counted successes out of known trials with binomial sampling variation only; - use `beta_binomial()` for counted successes out of known trials when extra-binomial variation is part of the scientific question; - use `beta()` for continuous proportions strictly inside `(0, 1)`; - use `zero_one_beta()` for continuous proportions on `[0, 1]` when exact 0 or 1 values are structural outcomes rather than trial-count outcomes. The source motivation comes from Nakagawa et al. (2026), who treat overdispersed proportions as part of the same location-scale framework as continuous and count responses. They distinguish discrete proportions from continuous proportions, recommend beta-binomial models for successes out of trials when extra-binomial variation is present, and warn that exact 0 or 1 values in continuous beta responses need explicit zero-one processes rather than being hidden by the model. ## Choose The Proportion Route Start from how the response was measured: | Response | Example | First `drmTMB` family | Boundary status | |---|---|---|---| | 0/1 event indicator | early NDVI onset, host infected or not, quadrat occupied or not | `stats::binomial(link = "logit")` | rows are ordinary Bernoulli trials; no modelled `sigma` | | Counted successes out of trials with ordinary binomial variation | germinated seeds out of planted seeds | `stats::binomial(link = "logit")` | successes can be 0 or all trials through the binomial count process | | Counted successes out of trials with extra-binomial variation | germinated seeds out of planted seeds, with tray-to-tray probability scatter | `beta_binomial()` | adds modelled `sigma` for variation beyond binomial sampling | | Continuous proportion strictly inside `(0, 1)` | leaf-area damage, vegetation cover, foraging time fraction | `beta()` | exact 0 or 1 is not supported by the strict beta likelihood | | Continuous proportion with structural 0 or 1 values | 0% disease, 100% cover, complete absence or saturation | `zero_one_beta()` | `zoi` models exact boundary mass; `coi` models exact ones among boundary observations | The distinction is not cosmetic. A denominator-aware model knows that 3 successes out of 6 trials and 30 successes out of 60 trials carry different sampling information. A strict beta model assumes the observation is already a continuous rate and that the boundaries are not part of the observed support. For denominator-aware responses, write `cbind(successes, failures)`. Do not write `weights = trials`, `successes / trials`, or `cbind(successes, trials)`: top-level weights are likelihood weights, and the second column of a two-column binomial response is failures, not trial totals. ## Plain Binomial Equation And Syntax Use `stats::binomial(link = "logit")` when the response is an event probability and ordinary binomial sampling variation is enough: \[ \begin{aligned} Y_i \mid n_i, \mu_i &\sim \operatorname{Binomial}(n_i, \mu_i),\\ \operatorname{logit}(\mu_i) &= \beta_0 + \beta_1 \text{canopy}_i + \beta_2 \text{NDVI}_i,\\ E[Y_i / n_i] &= \mu_i,\\ \operatorname{Var}(Y_i / n_i) &= \mu_i(1 - \mu_i) / n_i. \end{aligned} \] For a 0/1 response, \(n_i = 1\): ```r drmTMB( bf(early_onset ~ canopy + ndvi), family = stats::binomial(link = "logit"), data = onset_data ) ``` For success counts, keep both the successes and failures: ```r drmTMB( bf(cbind(germinated, failed) ~ treatment + moisture), family = stats::binomial(link = "logit"), data = seed_trials ) ``` The fitted `mu` is the event probability. `fitted(fit)` and `predict(fit, dpar = "mu")` return probabilities; multiply by `successes_i + failures_i` only when the target summary is the expected number of successes. Here is a small 0/1 event example. Suppose each site is scored for whether a vegetation index crosses an early-season threshold. The model asks how canopy condition and spring NDVI change that event probability: ```{r} set.seed(196) n_onset <- 240 onset_data <- data.frame( canopy = factor( rep(c("open", "closed"), each = n_onset / 2), levels = c("open", "closed") ), ndvi = as.numeric(scale(runif(n_onset, 0.15, 0.85))) ) closed <- as.numeric(onset_data$canopy == "closed") mu_onset <- plogis(-0.85 - 0.35 * closed + 1.10 * onset_data$ndvi) onset_data$early_onset <- rbinom(n_onset, size = 1, prob = mu_onset) fit_onset <- drmTMB( bf(early_onset ~ canopy + ndvi), family = stats::binomial(link = "logit"), data = onset_data ) coef(fit_onset, "mu") ``` Predicted probabilities are already on the response scale: ```{r} new_onset <- data.frame( canopy = factor(c("open", "closed"), levels = levels(onset_data$canopy)), ndvi = c(0, 0) ) data.frame( canopy = new_onset$canopy, ndvi = new_onset$ndvi, early_onset_probability = predict(fit_onset, newdata = new_onset, dpar = "mu") ) ``` The original plain-binomial slice was fixed-effect only. The current R engine also fits ordinary unlabelled `mu` random intercepts and independent numeric slopes at recovery grade. It still rejects non-logit links, factor responses, proportions with trial weights, correlated or labelled slopes, structured effects, bivariate or mixed responses, and `engine = "julia"`. ## Beta-Binomial Equation And Syntax Use `beta_binomial()` when the response is still successes out of known trials but the probability varies more than ordinary binomial sampling can explain. The model uses \[ \begin{aligned} Y_i \mid n_i, p_i &\sim \operatorname{Binomial}(n_i, p_i),\\ p_i \mid \mu_i, \sigma_i &\sim \operatorname{Beta}(\alpha_i, \beta_i),\\ \operatorname{logit}(\mu_i) &= \beta_0 + \beta_1 \text{sheltered}_i + \beta_2 \text{moisture}_i,\\ \log(\sigma_i) &= \gamma_0 + \gamma_1 \text{sheltered}_i,\\ \phi_i &= 1 / \sigma_i^2,\\ \alpha_i &= \mu_i\phi_i,\\ \beta_i &= (1 - \mu_i)\phi_i,\\ E[Y_i / n_i] &= \mu_i,\\ \operatorname{Var}(Y_i / n_i) &= \frac{\mu_i(1 - \mu_i)(1 + n_i\sigma_i^2)} {n_i(1 + \sigma_i^2)}. \end{aligned} \] The matching `drmTMB` syntax is: ```r drmTMB( bf(cbind(germinated, failed) ~ treatment + moisture, sigma ~ treatment), family = beta_binomial(), data = seed_trials ) ``` If trays, plots, or subjects are repeatedly observed, the first fitted mixed-model slice is an ordinary `mu` random intercept, optionally paired with an independent numeric `mu` slope: ```r drmTMB( bf(cbind(germinated, failed) ~ treatment + moisture + (1 | tray) + (0 + moisture | tray), sigma ~ treatment), family = beta_binomial(), data = seed_trials ) ``` The random effects change the logit success-probability predictor. They do not make `sigma` a random effect, and they do not open correlated slopes or structured bounded-response effects. Read each parameter before interpreting the fitted model: | Symbol or syntax | Meaning | In the seed-germination example | |---|---|---| | \(Y_i\), `germinated` | number of successes | seeds that germinated in tray \(i\) | | \(n_i\), `germinated + failed` | known number of trials | viable seeds planted in tray \(i\) | | \(\mu_i\), `mu` | expected success probability | germination probability for a seed in that tray | | \(\boldsymbol{\beta}_{\mu}\) | location coefficients on the logit probability scale | treatment and moisture effects on germination probability | | \(\sigma_i\), `sigma` | extra-binomial scale | tray-to-tray probability variation beyond binomial sampling | | \(\phi_i\) | beta precision | `phi_i = 1 / sigma_i^2`, so larger `sigma` means lower precision | | \(\boldsymbol{\beta}_{\sigma}\) | scale coefficients on the log `sigma` scale | treatment effects on extra-binomial variation | If `gamma_1` is the sheltered-treatment coefficient, then \[ \frac{\sigma_\text{sheltered}}{\sigma_\text{open}} = \exp(\gamma_1), \qquad \frac{\phi_\text{sheltered}}{\phi_\text{open}} = \exp(-2\gamma_1). \] That second ratio reverses direction because \(\phi = 1 / \sigma^2\). In `drmTMB`, report `sigma` when you want the direction to mean more or less modelled variation. ## Seed Germination Example Suppose seeds are planted in trays under open and sheltered microsites during a restoration trial. Shelter may increase average germination by reducing heat stress, and it may also make tray outcomes more predictable by buffering microclimate. Each tray has a known number of seeds, so the denominator belongs in the model. This transparent simulation gives us known structure before fitting: ```{r} set.seed(197) n <- 360 seed_trials <- data.frame( treatment = factor( rep(c("open", "sheltered"), each = n / 2), levels = c("open", "sheltered") ), moisture = as.numeric(scale(runif(n, 0.1, 0.9))), trials = sample(18:32, n, replace = TRUE) ) sheltered <- as.numeric(seed_trials$treatment == "sheltered") mu_seed <- plogis(-0.55 + 0.70 * sheltered + 0.45 * seed_trials$moisture) sigma_seed <- exp(-1.15 - 0.35 * sheltered) phi_seed <- 1 / sigma_seed^2 tray_probability <- rbeta( n, shape1 = mu_seed * phi_seed, shape2 = (1 - mu_seed) * phi_seed ) seed_trials$germinated <- rbinom(n, size = seed_trials$trials, prob = tray_probability) seed_trials$failed <- seed_trials$trials - seed_trials$germinated head(seed_trials) ``` Fit the beta-binomial location-scale model: ```{r} fit_seed <- drmTMB( bf(cbind(germinated, failed) ~ treatment + moisture, sigma ~ treatment), family = beta_binomial(), data = seed_trials ) ``` Run diagnostics before interpreting coefficients: ```{r} check_drm(fit_seed) ``` The `mu` coefficients describe the log-odds of germination. The `sigma` coefficients describe extra-binomial variation in the latent tray-level success probability: ```{r} coef(fit_seed, "mu") coef(fit_seed, "sigma") sigma_ratio <- exp(coef(fit_seed, "sigma")["treatmentsheltered"]) c( sigma_ratio_sheltered_vs_open = sigma_ratio, precision_ratio_sheltered_vs_open = sigma_ratio^(-2) ) ``` For a response-scale summary, predict the expected success probability and the proportion-level standard deviation for trays with the same number of seeds: ```{r} new_seed_trays <- data.frame( treatment = factor(c("open", "sheltered"), levels = levels(seed_trials$treatment)), moisture = c(0, 0), trials = c(24, 24) ) mu_hat <- predict(fit_seed, newdata = new_seed_trays, dpar = "mu") sigma_hat <- predict(fit_seed, newdata = new_seed_trays, dpar = "sigma") prop_var <- mu_hat * (1 - mu_hat) * (1 + new_seed_trays$trials * sigma_hat^2) / (new_seed_trays$trials * (1 + sigma_hat^2)) data.frame( treatment = new_seed_trays$treatment, trials = new_seed_trays$trials, expected_probability = mu_hat, expected_successes = new_seed_trays$trials * mu_hat, sigma = sigma_hat, phi = 1 / sigma_hat^2, proportion_sd = sqrt(prop_var) ) ``` This table keeps the two parts of the question separate: expected germination probability and extra-binomial scatter around that probability. ```{r beta-binomial-tray-figure, eval=requireNamespace("ggplot2", quietly = TRUE), fig.width=7.2, fig.height=4.4, fig.cap="Beta-binomial tray summary for the seed-germination example. Faint points are observed tray proportions; overlaid points are fitted expected germination probabilities; vertical bars show plus or minus one fitted proportion-level standard deviation, not confidence intervals.", fig.alt="Jittered point plot of observed germination proportions for open and sheltered trays. Overlaid larger points show fitted expected probabilities, and vertical bars show one fitted proportion-level standard deviation for each treatment."} library(ggplot2) seed_trials$observed_proportion <- seed_trials$germinated / seed_trials$trials seed_plot_summary <- data.frame( treatment = new_seed_trays$treatment, expected_probability = mu_hat, proportion_sd = sqrt(prop_var) ) seed_plot_summary$lower <- pmax( 0, seed_plot_summary$expected_probability - seed_plot_summary$proportion_sd ) seed_plot_summary$upper <- pmin( 1, seed_plot_summary$expected_probability + seed_plot_summary$proportion_sd ) ggplot(seed_trials, aes(treatment, observed_proportion, colour = treatment)) + geom_jitter(width = 0.12, height = 0, alpha = 0.18, size = 0.9) + geom_errorbar( data = seed_plot_summary, aes( y = expected_probability, ymin = lower, ymax = upper ), width = 0.12, linewidth = 0.8 ) + geom_point(data = seed_plot_summary, aes(y = expected_probability), size = 3) + scale_colour_manual(values = c("open" = "#0072B2", "sheltered" = "#009E73")) + coord_cartesian(ylim = c(0, 1)) + labs( title = "Denominator-aware proportions can still show raw trays", subtitle = "Bars show fitted tray-level scatter, not confidence intervals", x = "Microsite treatment", y = "Germinated proportion", colour = "Treatment" ) + guides(colour = "none") + theme_minimal(base_size = 11) + theme( panel.grid.minor = element_blank(), legend.position = "bottom", plot.title = element_text(face = "bold"), plot.subtitle = element_text(colour = "grey30") ) ``` ## Strict Continuous Proportions Use `beta()` when the response is a continuous proportion strictly inside the open interval. For example, vegetation cover measured from image analysis may record 0.03, 0.41, or 0.82 rather than successes out of a known number of quadrats. The beta model uses \[ \begin{aligned} Y_i \mid \mu_i, \sigma_i &\sim \operatorname{Beta}(\alpha_i, \beta_i),\\ \operatorname{logit}(\mu_i) &= \beta_0 + \beta_1 \text{grazed}_i + \beta_2 \text{moisture}_i,\\ \log(\sigma_i) &= \gamma_0 + \gamma_1 \text{grazed}_i,\\ \phi_i &= 1 / \sigma_i^2,\\ \operatorname{Var}(Y_i) &= \frac{\mu_i(1 - \mu_i)\sigma_i^2}{1 + \sigma_i^2}. \end{aligned} \] The fitted syntax is the same one-formula-per-parameter pattern: ```r drmTMB( bf(cover ~ grazing + moisture, sigma ~ grazing), family = beta(), data = cover_data ) ``` A small transparent example: ```{r} set.seed(198) n_cover <- 300 cover_data <- data.frame( grazing = factor( rep(c("ungrazed", "grazed"), each = n_cover / 2), levels = c("ungrazed", "grazed") ), moisture = as.numeric(scale(runif(n_cover, 0.05, 0.95))) ) grazed <- as.numeric(cover_data$grazing == "grazed") mu_cover <- plogis(0.35 - 0.75 * grazed + 0.35 * cover_data$moisture) sigma_cover <- exp(-1.10 + 0.45 * grazed) phi_cover <- 1 / sigma_cover^2 cover_data$cover <- rbeta( n_cover, shape1 = mu_cover * phi_cover, shape2 = (1 - mu_cover) * phi_cover ) fit_cover <- drmTMB( bf(cover ~ grazing + moisture, sigma ~ grazing), family = beta(), data = cover_data ) check_drm(fit_cover) ``` Here, grazing changes both expected cover and the variation of cover among sampling units: ```{r} coef(fit_cover, "mu") coef(fit_cover, "sigma") new_cover <- data.frame( grazing = factor(c("ungrazed", "grazed"), levels = levels(cover_data$grazing)), moisture = c(0, 0) ) mu_cover_hat <- predict(fit_cover, newdata = new_cover, dpar = "mu") sigma_cover_hat <- predict(fit_cover, newdata = new_cover, dpar = "sigma") data.frame( grazing = new_cover$grazing, expected_cover = mu_cover_hat, sigma = sigma_cover_hat, phi = 1 / sigma_cover_hat^2, cover_sd = sqrt( mu_cover_hat * (1 - mu_cover_hat) * sigma_cover_hat^2 / (1 + sigma_cover_hat^2) ) ) ``` Do not use this strict beta route if the observed response includes exact 0 or 1 values. A sprayed plot with 0% damage or a quadrat with 100% cover is a boundary-generating process, not an interior beta observation. Use `zero_one_beta()` when those endpoints are structural outcomes. ## Continuous Proportions With Exact Boundaries Use `zero_one_beta()` when the response is a continuous proportion on `[0, 1]` and exact 0 or 1 values are generated by a separate boundary process. The interior observations still use the beta mean-scale model. The two extra probability formulas describe the boundary process: \[ \begin{aligned} \Pr(Y_i = 0) &= zoi_i(1 - coi_i),\\ \Pr(Y_i = 1) &= zoi_i coi_i,\\ \Pr(0 < Y_i < 1) &= 1 - zoi_i,\\ \operatorname{logit}(\mu_i) &= \beta_0 + \beta_1 \text{grazed}_i,\\ \log(\sigma_i) &= \gamma_0 + \gamma_1 \text{grazed}_i,\\ \operatorname{logit}(zoi_i) &= \delta_0 + \delta_1 \text{drought}_i,\\ \operatorname{logit}(coi_i) &= \kappa_0 + \kappa_1 \text{canopy}_i. \end{aligned} \] ```r drmTMB( bf(cover ~ grazing, sigma ~ grazing, zoi ~ drought, coi ~ canopy), family = zero_one_beta(), data = cover_data ) ``` `predict(fit, dpar = "mu")` describes the mean among interior observations. `predict(fit, dpar = "zoi")` is the probability of an exact boundary outcome, and `predict(fit, dpar = "coi")` is the probability that a boundary outcome is exactly 1. `fitted(fit)` returns the unconditional mean `(1 - zoi) * mu + zoi * coi`, which is usually the response-scale summary an applied reader expects. ## Current Boundary The implemented bounded-response path is univariate. Use `stats::binomial(link = "logit")` for ordinary fixed-effect event probability models, `beta_binomial()` for overdispersed counted successes and failures, `beta()` for a single continuous proportion strictly between 0 and 1, and `zero_one_beta()` for a single continuous proportion with structural exact 0 or 1 values. `beta()` and `beta_binomial()` support ordinary unlabelled `mu` random intercepts and independent numeric slopes as first mixed-model slices: ```r drmTMB( bf(cbind(successes, failures) ~ treatment + dose + (1 | id) + (0 + dose | id), sigma ~ treatment), family = beta_binomial(), data = dat ) ``` ```r drmTMB( bf(proportion ~ treatment + dose + (1 | id) + (0 + dose | id), sigma ~ treatment), family = beta(), data = dat ) ``` Do not teach the following as fitted proportion examples yet: - correlated random slopes, labelled covariance blocks, or random effects in `sigma`, `zoi`, or `coi`; - `sd(group) ~ ...` random-effect scale models; - `meta_V(V = V)`, or deprecated `meta_known_V(V = V)` for compatibility, with beta or beta-binomial responses; - `phylo()`, `spatial()`, `animal()`, or `relmat()` bounded-response models, with one exception: `beta()` already accepts a single unlabelled `animal()` term -- an intercept or one-slope on `mu`, or an intercept on `sigma`, one endpoint at a time -- but only at recovery grade (trust the point estimate, not the interval), so it is held back here on tier grounds, not for want of likelihood code; - bivariate or mixed-response bounded models such as `family = c(beta(), gaussian())`; - ordered beta or beta-binomial zero-inflation; - denominator shorthand such as `successes / trials`, `weights = trials`, or `cbind(successes, trials)` as a replacement for `cbind(successes, failures)`. Most of those are useful future routes that still need likelihood code, simulation recovery, diagnostics, and [source-map](source-map.html) evidence before they become tutorial syntax.