--- title: "Random effects: hierarchical thermal tolerance" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Random effects: hierarchical thermal tolerance} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>", fig.width = 6, fig.height = 3.6, dpi = 96 ) ``` This vignette is for a thermal biologist whose survival assays are **grouped** — colonies, clutches, populations, tanks, broods — and who wants to model between-group variation without spending a fixed coefficient on every group. That is exactly what a **random intercept** does: it treats the per-group deviations as draws from a Gaussian, estimates a single between-group standard deviation, and **shrinks** each group's estimate toward the overall mean (partial pooling). In `freqTLS` a random intercept is available on the thermal-tolerance midpoint `CTmax`, the thermal-sensitivity `log_z`, and the shape coordinates `low` and `log_k`. ```{r setup} library(freqTLS) ``` ## Fixed groups vs. a random intercept With a handful of well-sampled groups you can give each its own **fixed** `CTmax` (`CTmax ~ group`, see `vignette("freqTLS")`). With many groups — or groups you regard as a sample from a population — a **random intercept** `CTmax ~ 1 + (1 | group)` is the better tool: it costs one variance parameter instead of one coefficient per group, and the group estimates borrow strength from each other. The deviations `b_g ~ N(0, sigma_CTmax^2)` are integrated out by `TMB`'s Laplace approximation; `sigma_CTmax` is estimated by marginal maximum likelihood. A formula without `(1 | group)` uses the ordinary fixed-effects objective. Adding `(1 | group)` is not a no-op: it adds and integrates a group-level Gaussian block, even when its fitted variance is near zero. ## A random intercept on CTmax We simulate 14 colonies whose CTmax varies with a between-colony SD of 1.5 °C, then fit a single random intercept. ```{r ctmax-re} set.seed(1) d <- simulate_tls(family = "binomial", CTmax = 36, z = 4, re_sd = 1.5, n_re_groups = 14, seed = 1) fit <- fit_tls( tls_bf(survived | trials(total) ~ time(duration) + temp(temp), CTmax ~ 1 + (1 | colony)), data = d, family = "binomial", tref = 1, quiet = TRUE) # The estimates table carries the population CTmax / z and the between-colony SD. est <- fit$estimates est[est$parameter %in% c("CTmax", "z", "sigma_CTmax"), c("parameter", "estimate", "std.error")] ``` `sigma_CTmax` is the estimated between-colony standard deviation of `CTmax`, in °C — directly interpretable on the thermal-tolerance scale. It is a maximum-likelihood variance component, so it is biased **low** when there are few groups: it is essentially unbiased by ~14 groups but increasingly shrunk toward zero below that. A focused recovery study makes the bias concrete and shows the fixed-effect `CTmax` interval losing coverage in step — the empirical basis for the advisory `fit_tls()` emits below ~8 groups. Repository maintainers can regenerate it with the source-only `data-raw/re-recovery-study.R` script, which is not installed: ```{r re-recovery, echo = FALSE} rr_path <- system.file("extdata", "re_recovery_results.rds", package = "freqTLS") if (nzchar(rr_path) && file.exists(rr_path)) { rr <- readRDS(rr_path) knitr::kable( rr$re_recovery[, c("n_groups", "mean_sigma", "rel_bias", "CTmax_wald_coverage")], col.names = c("n groups", "mean sigma_CTmax", "rel. bias", "CTmax 95% coverage"), caption = sprintf( paste("Random-intercept recovery vs number of groups (true sigma_CTmax = %.1f;", "%d sims/cell). The ML SD is biased low and the fixed-effect CTmax", "interval under-covers with few groups; both settle by ~14 groups."), rr$meta$truth_sigma, rr$meta$nsim), digits = 3) } ``` So with only a handful of groups the reported `sigma_CTmax` understates the true spread and the `CTmax` interval is optimistic. Prefer `confint(method = "bootstrap")` (or `bayesTLS`) for the fixed effects, and describe the variance component as likely downward-biased rather than treating it as a precise estimate or a literal confidence bound. The conditional modes (BLUPs) — each colony's shrunken deviation from the population `CTmax` — come from `ranef()`: ```{r ranef} head(ranef(fit), 4) ``` Prediction distinguishes the population curve from an observed colony's conditional curve. Population predictions set the random intercept to zero; conditional predictions add the fitted BLUP and require the grouping column in `newdata`: ```{r re-predict} colony_1 <- as.character(ranef(fit)$group[1]) new_colony <- data.frame(temp = 36, duration = 2, colony = colony_1) data.frame( target = c("population", "colony"), survival = c( predict(fit, new_colony, re.form = "population"), predict(fit, new_colony, re.form = "conditional") ) ) ``` Calling `predict()` without `re.form` on a random-effects fit warns and returns the population prediction. An unseen group cannot receive an estimated BLUP, so conditional prediction stops and points to `re.form = "population"`. `confint()` routes uncertainty by target. It gives a positive, log-scale Wald interval for `sigma_CTmax`. It profiles the population `CTmax` under the random effect, re-running the Laplace approximation at each grid point, so this is slower than a fixed-effects profile. If that fixed-effect profile does not close, the default fallback is Wald; request `method = "bootstrap"` to redraw the random effects and refit the full model instead. ```{r ctmax-ci} suppressMessages(confint(fit, "sigma_CTmax", method = "wald"))[ , c("parameter", "conf.low", "conf.high")] suppressMessages(confint(fit, "CTmax", method = "profile", npoints = 8))[ , c("parameter", "conf.low", "conf.high", "method")] ``` The Confidence Eye always uses Wald intervals for a random-effects fit, even when `method = "profile"` is requested, because a profile eye would re-run the Laplace approximation at every grid point for every row. Use `confint(fit, method = "profile")` for fixed-effect profiles or `confint(fit, method = "bootstrap")` for random-effects-aware bootstrap intervals. The eye remains a confidence-interval display, never a posterior. ```{r eye, fig.alt = "Confidence Eye for the population CTmax of a random-intercept fit: a pale confidence lens with a hollow point estimate near 36 degrees Celsius, drawn with a Wald interval; a freqTLS uncertainty display, not a posterior density."} suppressMessages(plot_confidence_eye(fit, parm = "CTmax")) ``` ## Random effects on sensitivity and curve shape The same `(1 | group)` syntax works on `log_z` (thermal sensitivity) and on the shape coordinates `low` (the lower, long-exposure asymptote) and `log_k` (the steepness). One nuance matters: because `z`, `low`, and `k` are modelled on transformed scales, their variance components live on those scales — `sigma_logz` is a SD on `log(z)`, `sigma_low` on `logit(low)`, `sigma_logk` on `log(k)`. Read `sigma_logz` as an approximate *multiplicative* spread on `z`: `exp(sigma_logz)` is the fold-change across groups, not a z-unit SD. ```{r logz-re} set.seed(2) dz <- simulate_tls(family = "binomial", CTmax = 36, z = 4, re_sd_z = 0.3, n_re_groups = 14, seed = 2) fit_z <- fit_tls( tls_bf(survived | trials(total) ~ time(duration) + temp(temp), log_z ~ 1 + (1 | colony)), data = dz, family = "binomial", tref = 1, quiet = TRUE) sg <- fit_z$estimates$estimate[fit_z$estimates$parameter == "sigma_logz"] c(sigma_logz = round(sg, 3), approx_fold_spread_in_z = round(exp(sg), 3)) ``` The upper asymptote `up` is the **one shape with no random effect**: under the disjoint-bounds parameterisation `up = up_min + up_w * plogis(beta_up)` it has its own coordinate `beta_up`, but the compiled objective carries no random-intercept term for `up` (and its profile path is not wired either — the same gap that leaves `up` with a delta-method interval). Put the random intercept on `low` or `log_k` instead. ## Combining random effects, and what is out of scope You can place random intercepts on more than one coordinate. When two or more share the **same** grouping factor, `freqTLS` fits them as **independent** variances (all cross-correlations forced to zero) and warns: ```{r combined} set.seed(3) db <- simulate_tls(family = "binomial", CTmax = 36, z = 4, re_sd = 1.2, re_sd_z = 0.3, n_re_groups = 14, seed = 3) # Both random intercepts on the same `colony` grouping. This fits, but emits a # warning that the two variances are independent (no correlation term); we pass # `quiet = TRUE` to silence it here and discuss it below. fit_both <- fit_tls( tls_bf(survived | trials(total) ~ time(duration) + temp(temp), CTmax ~ 1 + (1 | colony), log_z ~ 1 + (1 | colony)), data = db, family = "binomial", tref = 1, quiet = TRUE) fit_both$estimates$parameter[grepl("^sigma", fit_both$estimates$parameter)] ``` In real data the group-level `CTmax` and `z` deviations are usually correlated; by forcing that correlation to zero, the independent-variance fit absorbs it into the marginal SDs and the fixed-effect intervals. If you need a **correlated** random effect — or crossed/nested grouping factors, or a random *slope* — use [`bayesTLS`](https://github.com/daniel1noble/bayesTLS), whose Stan back end fits the full covariance. `freqTLS` deliberately stops at independent random intercepts on `CTmax` / `log_z` / `low` / `log_k`. ## The likelihood view of shrinkage A Gaussian random effect is a distributional model for the latent group deviations, not a prior placed on the reported population parameters. Its conditional modes (BLUPs) have an empirical-Bayes interpretation and shrink toward zero. The fixed effects and variance components are estimated by marginal maximum likelihood, and their intervals are profile-likelihood intervals for fixed-effect coordinates, log-scale Wald intervals for variance components, or parametric-bootstrap intervals when explicitly requested; they are not credible intervals. That is the same complementary stance the rest of `freqTLS` takes toward `bayesTLS` (see `vignette("frequentist-and-bayesian")`): the random effect buys you partial pooling without committing to priors on the parameters you report.