Two models on the table, one set of data — what do they say differently?
compare_symbolic(sym_a, sym_b)answers the structural half of that question (which submodels, which terms, which assumptions). Withmetrics = TRUEit adds AIC, BIC, log-likelihood, and df with a delta — the numbers that ground model selection.
This vignette walks three worked examples that cover the cases most biologists actually meet:
- A full-versus-reduced fixed-effects comparison (same data, same family, different terms).
- A with-versus-without-random-effects comparison (same data, same family, different assumption status).
- A univariate-versus-bivariate comparison (different family — showing
how
metricsrefuses to compute deltas).
It also says where the function stops being the right answer.
1. What compare_symbolic is for
compare_symbolic is structural. It
tells you what’s different about the specification of two fits
— which submodels exist, which predictors are in each one, which
assumptions changed status. The default return is four slots:
-
meta— class / family / response / n_obs on each side. -
diff_submodels— presence per submodel:left_only,right_only, orboth. -
diff_terms— presence per(submodel, term_label)pair. -
diff_assumptions—left_status,right_status, and asame_statuslogical.
When you pass metrics = TRUE a fifth slot appears:
-
diff_metrics— AIC, BIC, log-likelihood, and df on each side plus adeltacolumn (right - left). Refuses to compute deltas when the two fits are obviously incomparable (different family, response, orn_obs) and instead carries acomparable = FALSEattribute plus anoteexplaining why.
For fit-time identifiability and convergence diagnostics — singular
Hessian, parameter-at-boundary, near-zero variance components —
compare_symbolic is not the tool. Run
drmTMB::check_drm() (or the gllvmTMB analogue) on each fit
before interpreting the structural diff. The compare surface
assumes both fits actually converged.
2. Worked example 1: full versus reduced fixed effects
A common workflow is to fit a richer model and a simpler nested model on the same data and ask whether the richer model is worth the extra parameters.
library(symbolizer)
library(drmTMB)
#>
#> Attaching package: 'drmTMB'
#> The following object is masked from 'package:base':
#>
#> beta
set.seed(20260524)
n <- 200
dat <- data.frame(
temperature = runif(n, 8, 30),
food = rlnorm(n, 1.5, 0.6)
)
dat$body_mass <- with(dat,
rnorm(n, 5 + 0.5 * temperature + 2 * log(food), 2)
)
fit_full <- drmTMB(
drm_formula(body_mass ~ temperature + log(food),
sigma ~ temperature),
family = gaussian(), data = dat
)
fit_reduced <- drmTMB(
drm_formula(body_mass ~ temperature, sigma ~ 1),
family = gaussian(), data = dat
)
sym_full <- symbolize(fit_full)
sym_reduced <- symbolize(fit_reduced)The structural diff with metrics:
cmp1 <- compare_symbolic(sym_full, sym_reduced, metrics = TRUE)
cmp1Model summaries
| left | right | |
|---|---|---|
| class | drmTMB | drmTMB |
| family | gaussian | gaussian |
| response | body_mass | body_mass |
| n_obs | 200 | 200 |
Submodels
| submodel | presence |
|---|---|
| mu | both |
| sigma | both |
Terms
| submodel | term_label | presence |
|---|---|---|
| mu | (Intercept) | both |
| mu | temperature | both |
| mu | log(food) | left_only |
| sigma | (Intercept) | both |
| sigma | temperature | left_only |
Assumptions
| assumption | left_status | right_status | same_status |
|---|---|---|---|
| conditional_distribution | stated | stated | TRUE |
| linear_predictor | stated | stated | TRUE |
| independence | implied | implied | TRUE |
| positivity | implied | implied | TRUE |
| no_missing_at_random | not_checked | not_checked | TRUE |
Metrics
| metric | left | right | delta |
|---|---|---|---|
| AIC | 846.4 | 892.9 | 46.54 |
| BIC | 862.9 | 902.8 | 39.94 |
| logLik | -418.2 | -443.5 | -25.27 |
| df | 5.000 | 3.000 | -2.000 |
delta = right - left
The Terms section lists the predictors lost when going from full to
reduced: log(food) disappears from the mu submodel and
temperature disappears from the sigma submodel. The Metrics
section quantifies the cost: the full model uses more df but pays that
back in log-likelihood; AIC and BIC pick the model that balances the
two.
delta = right - left, so the sign tells you which fit
AIC prefers: a negative delta means the right
fit (here, the reduced model) has the lower — better — AIC; a
positive delta means the left fit (the full
model) does. Read the sign of the delta column above before you read the
magnitude.
Takeaway. Same data, same family — pass
metrics = TRUE and read the delta column to do model
selection on top of the structural diff.
3. Worked example 2: with versus without random intercepts
A second common workflow: a study with grouped data (sites,
individuals, populations). Fit both with and without
(1 | group) and inspect what changes — including which
independence assumption is in force.
set.seed(7L)
n2 <- 150
dat2 <- data.frame(
body_mass = rnorm(n2, 30, 2),
temperature = runif(n2, 10, 25),
site = factor(rep(letters[1:6], length.out = n2))
)
fit_noRE <- drmTMB(
drm_formula(body_mass ~ temperature, sigma ~ 1),
family = gaussian(), data = dat2
)
fit_RE <- drmTMB(
drm_formula(body_mass ~ temperature + (1 | site), sigma ~ 1),
family = gaussian(), data = dat2
)
sym_noRE <- symbolize(fit_noRE)
sym_RE <- symbolize(fit_RE)
cmp2 <- compare_symbolic(sym_noRE, sym_RE, metrics = TRUE)
cmp2Model summaries
| left | right | |
|---|---|---|
| class | drmTMB | drmTMB |
| family | gaussian | gaussian |
| response | body_mass | body_mass |
| n_obs | 150 | 150 |
Submodels
| submodel | presence |
|---|---|
| mu | both |
| sigma | both |
Terms
| submodel | term_label | presence |
|---|---|---|
| mu | (Intercept) | both |
| mu | temperature | both |
| sigma | (Intercept) | both |
Assumptions
| assumption | left_status | right_status | same_status |
|---|---|---|---|
| conditional_distribution | stated | stated | TRUE |
| linear_predictor | stated | stated | TRUE |
| independence | implied | NA | FALSE |
| positivity | implied | implied | TRUE |
| no_missing_at_random | not_checked | not_checked | TRUE |
| independence_given_random_effects | NA | stated | FALSE |
Metrics
| metric | left | right | delta |
|---|---|---|---|
| AIC | 612.0 | 614.0 | 2.000 |
| BIC | 621.0 | 626.0 | 5.011 |
| logLik | -303.0 | -303.0 | -0.00000004182 |
| df | 3.000 | 4.000 | 1.000 |
delta = right - left
Two things to notice:
The Assumptions section flips. The left fit has an
independencerow (statusimplied). The right fit drops that row and adds anindependence_given_random_effectsrow (statusstated).compare_symbolicsurfaces both rows and marks them as differing — at the consoleprint()adds a*; in this knitted table the change shows assame_status=FALSE.The Metrics section quantifies the random-intercept cost. Adding
(1 | site)uses one extra df (the variance of the site intercept). Whether it’s worth it depends on the AIC delta and the biology of the study design.
Takeaway. A structural diff makes the assumption
swap visible — going from independence to conditional independence
given the random effects — and metrics = TRUE quantifies
the trade-off.
4. Worked example 3: univariate versus bivariate Gaussian
Sometimes the two fits aren’t on the same scale at all. A univariate
Gaussian fit predicts one response; a bivariate Gaussian fit predicts
two responses jointly. The structural diff still works — and
metrics will refuse to compute deltas, with a
clear note.
set.seed(20260524)
n3 <- 80
e1 <- rnorm(n3); e2 <- 0.6 * e1 + sqrt(1 - 0.6^2) * rnorm(n3)
dat3 <- data.frame(
growth = 30 + 1.5 * rnorm(n3) + e1,
repro = 10 + 0.8 * rnorm(n3) + e2,
x1 = rnorm(n3), x2 = rnorm(n3)
)
fit_uv <- drmTMB(
drm_formula(growth ~ x1, sigma ~ 1),
family = gaussian(), data = dat3
)
fit_bv <- drmTMB(
drm_formula(mu1 = growth ~ x1, mu2 = repro ~ x2,
sigma1 = ~ 1, sigma2 = ~ 1, rho12 = ~ 1),
family = biv_gaussian(), data = dat3
)
sym_uv <- symbolize(fit_uv)
sym_bv <- symbolize(fit_bv)
cmp3 <- compare_symbolic(sym_uv, sym_bv, metrics = TRUE)
cmp3Model summaries
| left | right | |
|---|---|---|
| class | drmTMB | drmTMB |
| family | gaussian | biv_gaussian |
| response | growth | growth, repro |
| n_obs | 80 | 80 |
Submodels
| submodel | presence |
|---|---|
| mu | left_only |
| sigma | left_only |
| mu1 | right_only |
| mu2 | right_only |
| sigma1 | right_only |
| sigma2 | right_only |
| rho12 | right_only |
Terms
| submodel | term_label | presence |
|---|---|---|
| mu | (Intercept) | left_only |
| mu | x1 | left_only |
| sigma | (Intercept) | left_only |
| mu1 | (Intercept) | right_only |
| mu1 | x1 | right_only |
| mu2 | (Intercept) | right_only |
| mu2 | x2 | right_only |
| sigma1 | (Intercept) | right_only |
| sigma2 | (Intercept) | right_only |
| rho12 | (Intercept) | right_only |
Assumptions
| assumption | left_status | right_status | same_status |
|---|---|---|---|
| conditional_distribution | stated | stated | TRUE |
| linear_predictor | stated | NA | FALSE |
| independence | implied | NA | FALSE |
| positivity | implied | NA | FALSE |
| no_missing_at_random | not_checked | not_checked | TRUE |
| linear_predictor_mu1 | NA | stated | FALSE |
| linear_predictor_mu2 | NA | stated | FALSE |
| linear_predictor_sigma1 | NA | stated | FALSE |
| linear_predictor_sigma2 | NA | stated | FALSE |
| linear_predictor_rho12 | NA | stated | FALSE |
| residual_correlation_range | NA | implied | FALSE |
| residual_decomposition | NA | stated | FALSE |
| independence_across_observations | NA | implied | FALSE |
Metrics
| metric | left | right | delta |
|---|---|---|---|
| AIC | 333.5 | 565.3 | NA |
| BIC | 340.7 | 582.0 | NA |
| logLik | -163.8 | -275.7 | NA |
| df | 3.000 | 7.000 | NA |
Deltas not computed: incomparable: family differs (left = gaussian, right = biv_gaussian)
The Submodels block shows the structural extension cleanly:
univariate’s mu, sigma are left_only;
bivariate’s mu1, mu2, sigma1, sigma2, rho12 are
right_only. None of them are in both because
the submodel names differ.
The Metrics block shows the raw AIC, BIC, logLik, df on each side for
transparency — but the delta column is all NA and the print
output flags “deltas not computed: incomparable: family differs
(left = gaussian, right = biv_gaussian)”. That’s deliberate. The
two likelihoods aren’t on the same scale: the univariate fit is a sum of
n univariate Gaussian densities, the bivariate fit is a sum
of n bivariate joint densities. Comparing the AIC values
directly would be misleading.
Takeaway. compare_symbolic works
structurally regardless of whether the two fits are comparable on the
likelihood scale. metrics = TRUE is honest: it refuses to
compute deltas when the fits aren’t comparable, and tells you why.
5. When NOT to use compare_symbolic
compare_symbolic is structural-symbolic. It answers
“what’s different about how these models are specified?” It
does not answer:
-
“Does my model converge?” — that’s a fit-time
question. Use
drmTMB::check_drm(fit)or the gllvmTMB analogue before you callsymbolize(). If a fit didn’t converge, the structural diff is misleading. -
“Are my parameters identifiable?” — see
check_drm()for Hessian-rank diagnostics on drmTMB; for gllvmTMB latent-variable models,vignette("symbolizer-gllvm")Section 7 covers the rotation-and-sign identifiability of \boldsymbol{\Lambda}_B. -
“Which coefficients changed by how much?” — that’s
a coefficient-level numeric question. Use
parameter_interpretation(sym_a)andparameter_interpretation(sym_b)and compare theestimateand(confint_low, confint_high)columns directly. A future slice may adddiff_coefficients()as an opt-in extension ofcompare_symbolic. -
“Should I drop predictor X?” — that’s an inference
question about the structural diff’s consequences.
metrics = TRUEgives you the AIC / BIC delta; that’s evidence, not a verdict. The biology still has to be your call.
Takeaway. The compare surface is structural — it tells you what your two models say differently. Identifiability, convergence, and inference are different lenses that live in different functions.
6. Where to read next
-
vignette("symbolizer")— the package-wide Get Started tour. -
vignette("symbolizer-drmtmb")— the drmTMB-first deep tour (location-scale, random intercepts, bivariate Gaussian, the richer four-role worked example). -
vignette("symbolizer-factors")— the categorical pedagogy guide (dummies, contrasts, interactions, six common pitfalls). -
vignette("symbolizer-gllvm")— the parallel story for latent-variable models ingllvmTMB.