Installation
install.packages("pak") # development version
pak::pak("gcol33/tulpaRatio")Overview
This vignette covers random effects in tulpaRatio, including random intercepts, random slopes, crossed effects, and nested effects. tulpaRatio uses lme4-style formula syntax with extensions for correlated vs. uncorrelated slopes.
The examples use one simulated data set: counts and sampling effort from three plots at each of 16 sites in four regions, surveyed in six years. Region, site, plot and observation-level effects enter both the count and the effort; year and observer effects and the site-specific slopes of the covariates enter only the count.
library(tulpaRatio)
set.seed(42)
df <- expand.grid(plot = 1:3, site = 1:4, region = 1:4, year = 1:6)
df$site <- factor(paste(df$region, df$site, sep = "_"))
df$plot <- factor(df$plot)
df$region <- factor(df$region)
df$year <- factor(df$year)
n <- nrow(df)
df$depth <- rnorm(n)
df$temp <- rnorm(n)
df$x <- rnorm(n)
df$z <- rnorm(n)
df$observer <- factor(sample(1:6, n, replace = TRUE))
b_region <- rnorm(4, 0, 0.4)[df$region]
b_site <- rnorm(16, 0, 0.5)[df$site]
b_plot <- rnorm(48, 0, 0.3)[interaction(df$site, df$plot)]
b_year <- rnorm(6, 0, 0.4)[df$year]
b_observer <- rnorm(6, 0, 0.3)[df$observer]
slopes <- matrix(rnorm(16 * 5, 0, 0.2), nrow = 16)[df$site, ]
u <- rnorm(n, 0, 0.4)
df$effort <- rgamma(n, shape = 5, rate = 5 / exp(0.5 + b_region + b_site + b_plot + u))
df$count <- rpois(n, exp(
1 + b_region + b_site + b_plot + b_year + b_observer + u +
(0.4 + slopes[, 1]) * df$depth + (-0.2 + slopes[, 2]) * df$temp +
(0.3 + slopes[, 3]) * df$x + (-0.2 + slopes[, 4]) * df$z +
(0.1 + slopes[, 5]) * df$x * df$z
))
ctl <- list(seed = 1, adapt_delta = 0.95)Random Intercepts
Single Grouping Factor
# Random intercept for site
fit <- tratio(
count | effort ~ depth + (1 | site),
data = df,
family = ratiod_poisson_gamma(),
control = ctl
)
summary(fit)
#> ratio model summary
#> ===================
#>
#> Inference: Exact (Tier 1) via hmc
#> Family: poisson_gamma
#>
#> Fixed effects (numerator):
#> parameter mean sd 2.5% 97.5% rhat ess_bulk
#> num:(Intercept) 0.958 0.221 0.503 1.376 1.017 200
#> num:depth 0.303 0.035 0.230 0.370 1.002 1102
#>
#> Fixed effects (denominator):
#> parameter mean sd 2.5% 97.5% rhat ess_bulk
#> denom:(Intercept) 0.346 0.221 -0.109 0.775 1.016 200
#> denom:depth -0.046 0.039 -0.121 0.030 1.006 1049
#>
#> Variance components:
#> parameter mean sd 2.5% 97.5% rhat ess_bulk
#> shape 2.421 0.195 2.049 2.809 1.007 870
#> sigma_re 0.837 0.173 0.576 1.255 1.006 294
#>
#> Diagnostics:
#> Divergences: 0
#> Avg. acceptance: 0.954
#> Chains: 4
#> Iterations: 2000
#> Warmup: 1000
#> R-hat > 1.01 for 2 parameter(s), largest 1.017 (num:(Intercept)).
#> Bulk ESS < 400 for 3 parameter(s), smallest 200 (denom:(Intercept)).
#> Run longer chains or reparameterise before using these estimates.This estimates a site-specific intercept for each observation :
where .
Multiple (Crossed) Grouping Factors
When observations are classified by multiple independent grouping factors:
# Crossed random effects: site and year
fit <- tratio(
count | effort ~ depth + (1 | site) + (1 | year),
data = df,
family = ratiod_poisson_gamma(),
control = ctl
)This estimates: - -
The linear predictor becomes:
Nested Random Effects
When groups are hierarchically nested (plots within sites):
# Nested: plots within sites
fit_nested <- tratio(
count | effort ~ depth + (1 | site/plot),
data = df,
family = ratiod_poisson_gamma(),
control = ctl
)This expands to:
# Equivalent explicit form
fit <- tratio(
count | effort ~ depth + (1 | site) + (1 | site:plot),
data = df,
family = ratiod_poisson_gamma(),
control = ctl
)The site:plot term groups by each plot-within-site
combination.
Deep Nesting
For deeper hierarchies:
# region/site/plot expands to:
# (1 | region) + (1 | region:site) + (1 | region:site:plot)
# Every region, site and plot holds several observations, so each effect
# is well informed by the data and the centred parameterisation suits it
fit <- tratio(
count | effort ~ x + (1 | region/site/plot),
data = df,
family = ratiod_poisson_gamma(),
control = modifyList(ctl, list(re_param = "centered"))
)Random Slopes
Random slopes allow the effect of a predictor to vary by group.
Correlated Intercept and Slope
Use single | for correlated random effects:
# Intercept and depth effect vary by site, with correlation
fit_slope <- tratio(
count | effort ~ depth + (1 + depth | site),
data = df,
family = ratiod_poisson_gamma(),
control = ctl
)This estimates:
The correlation
captures whether sites with higher intercepts also tend to have steeper
slopes. The summary reports it as cor_re[1,intercept:depth]
beside the two standard deviations:
Uncorrelated Intercept and Slope
Use double || for independent random effects:
# Intercept and depth effect vary by site, independently
fit <- tratio(
count | effort ~ depth + (1 + depth || site),
data = df,
family = ratiod_poisson_gamma(),
control = ctl
)This is computationally simpler and appropriate when you don’t expect correlation between intercepts and slopes.
Multiple Slopes
# Multiple correlated slopes
fit <- tratio(
count | effort ~ depth + temp + (1 + depth + temp | site),
data = df,
family = ratiod_poisson_gamma(),
control = ctl
)
# Multiple uncorrelated slopes
fit <- tratio(
count | effort ~ depth + temp + (1 + depth + temp || site),
data = df,
family = ratiod_poisson_gamma(),
control = ctl
)Formula Expansion
tulpaRatio supports lme4-style formula expansion for random slopes.
Interactions
# x*z expands to x + z + x:z
fit <- tratio(
count | effort ~ x*z + (1 + x*z | site),
data = df,
family = ratiod_poisson_gamma(),
control = ctl
)
# Random slopes for x, z, and their interactionPolynomials
# Quadratic random slope
fit <- tratio(
count | effort ~ x + I(x^2) + (1 + x + I(x^2) | site),
data = df,
family = ratiod_poisson_gamma(),
control = ctl
)Shared Random Effects
By default, tulpaRatio shares random effects between numerator and
denominator when the same grouping factor appears in both. This is the
core of the tulpaRatio philosophy (see
vignette("philosophy")).
Default: Shared Structure
# Site effect shared between count and effort processes
fit <- tratio(
count | effort ~ (1 | site),
formula_num = ~ depth,
data = df,
family = ratiod_poisson_gamma(),
control = ctl
)The site effect enters both linear predictors identically:
Shared effects cancel in the ratio, removing spurious variation from common unmeasured drivers.
Explicit Shared Structure
# Only site is shared, observer is process-specific
fit <- tratio(
count | effort ~ (1 | site) + (1 | observer),
shared = ~ (1 | site),
data = df,
family = ratiod_poisson_gamma(),
control = ctl
)Independent Structure (Use with Caution)
set.seed(7)
trials_df <- data.frame(
block = factor(rep(1:8, each = 6)),
treatment = factor(rep(c("control", "treated"), 24)),
trials = 20
)
b_block <- rnorm(8, 0, 0.5)[trials_df$block]
trials_df$successes <- rbinom(
48, size = 20,
prob = plogis(-0.5 + 0.8 * (trials_df$treatment == "treated") + b_block)
)
# Request independence (triggers warning)
fit <- tratio(
successes | trials ~ treatment + (1 | block),
shared = ~ 0,
data = trials_df,
family = ratiod_binomial(),
control = ctl
)
#> Warning: You have specified `shared = ~ 0`, assuming independence between numerator and denominator processes.
#>
#> This assumption is often violated in practice and can lead to spurious ratio effects when both processes share unmeasured drivers.
#>
#> Consider whether shared random effects would be more appropriate.The warning is issued every time shared = ~ 0 is
requested, because independence between the two processes is an
assumption the data rarely support.
Extracting Random Effects
Random-effect draws are named re[term,group], with terms
numbered in formula order (for site/plot, term 1 is
site and term 2 is site:plot) and a third
index naming the component of a random slope. as_draws()
returns them in the posterior
format.
# Posterior summaries for random effects
re <- posterior::subset_draws(as_draws(fit_nested), variable = "re")
head(posterior::summarise_draws(re))
#> # A tibble: 6 × 10
#> variable mean median sd mad q5 q95 rhat ess_bulk ess_tail
#> <chr> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
#> 1 re[1,1] 0.786 0.788 0.310 0.311 0.280 1.28 1.01 437. 1077.
#> 2 re[1,2] 0.343 0.345 0.313 0.307 -0.169 0.867 1.01 559. 1084.
#> 3 re[1,3] 0.418 0.419 0.312 0.317 -0.0928 0.928 1.01 474. 1070.
#> 4 re[1,4] 0.364 0.357 0.315 0.301 -0.151 0.892 1.01 578. 946.
#> 5 re[1,5] 0.789 0.780 0.301 0.299 0.314 1.30 1.01 561. 1157.
#> 6 re[1,6] -0.395 -0.393 0.317 0.316 -0.920 0.118 1.00 621. 1377.
# For random slopes: intercept and depth components of the first site
re_slope <- posterior::subset_draws(
as_draws(fit_slope),
variable = c("re[1,1,intercept]", "re[1,1,depth]")
)
posterior::summarise_draws(re_slope)
#> # A tibble: 2 × 10
#> variable mean median sd mad q5 q95 rhat ess_bulk ess_tail
#> <chr> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
#> 1 re[1,1,inte… 0.867 0.876 0.242 0.231 0.451 1.25 1.03 184. 473.
#> 2 re[1,1,dept… -0.278 -0.276 0.0979 0.1000 -0.441 -0.120 1.00 1129. 1796.Prior Specification
Default PC Priors
tulpaRatio uses penalized complexity (PC) priors that favor simpler models:
# Default: P(sigma > 1) = 0.01
fit <- tratio(
count | effort ~ depth + (1 | site),
data = df,
family = ratiod_poisson_gamma(),
priors = ratiod_priors(),
control = ctl
)Custom Priors
# Expect smaller random effects
fit <- tratio(
count | effort ~ depth + (1 | site),
data = df,
family = ratiod_poisson_gamma(),
priors = ratiod_priors(
sigma = prior_half_cauchy(0.5) # Half-Cauchy with scale 0.5
),
control = ctl
)Latent Factors for Unmeasured Confounders
When you suspect unmeasured confounders are driving both numerator and denominator but don’t have a natural grouping structure, latent factors provide an alternative to random effects.
Single Latent Factor
# Capture shared unmeasured variation
# Observation-level factors mix slowly, so the chains run longer
fit_latent <- tratio(
count | effort ~ (1 | site),
formula_num = ~ depth,
data = df,
family = ratiod_poisson_gamma(),
latent = latent_factor(n_factors = 1),
control = list(seed = 1)
)
head(latent_factors(fit_latent))
#> observation factor mean sd q2.5 q50 q97.5
#> 1 1 1 -1.44020897 0.6014386 -2.67137054 -1.41283988 -0.3321069
#> 2 2 1 1.09839661 0.4033631 0.27574066 1.10558310 1.8680457
#> 3 3 1 -0.05163295 0.4786275 -1.04969767 -0.03596029 0.8303736
#> 4 4 1 -1.11649373 0.6688172 -2.49970142 -1.11253748 0.1268144
#> 5 5 1 0.82943143 0.4523220 -0.09131511 0.84582517 1.6877243
#> 6 6 1 -0.35728172 0.6157591 -1.63180607 -0.33230960 0.7811181Latent factors are observation-level scores on which each process loads with its own loading:
where with a sum-to-zero constraint and . The factor adds the covariance between an observation’s two linear predictors and moves the ratio by .
Two Latent Factors
Two factors with a lower-triangular loading matrix give the two
linear predictors an unrestricted residual covariance. A third factor
would have nothing left to identify, so latent_factor()
stops at two (one when shared = FALSE).
fit <- tratio(
count | effort ~ depth,
data = df,
family = ratiod_poisson_gamma(),
latent = latent_factor(n_factors = 2),
control = list(seed = 1)
)Latent Factors vs Random Effects
| Feature | Random Effects | Latent Factors |
|---|---|---|
| Grouping | Required (site, year, etc.) | None needed |
| Level | Group-level | Observation-level |
| Use case | Known grouping structure | Residual confounding |
| Dimensionality | 1 per group | K factors × N observations |
Use latent factors when: - You suspect unmeasured confounders but don’t know the grouping structure - Residual variation after random effects is still correlated - You want to capture observation-level shared structure
Prior Specification
# Stronger regularization on latent factors
fit <- tratio(
count | effort ~ depth,
data = df,
family = ratiod_poisson_gamma(),
latent = latent_factor(
n_factors = 1,
prior = prior_pc(U = 0.5, alpha = 0.01) # P(loading > 0.5) = 0.01
),
control = list(seed = 1)
)See Also
-
vignette("getting-started")- Basic usage -
vignette("philosophy")- Why shared structure matters -
vignette("spatial-temporal")- Spatial and temporal extensions