Some responses are a single choice among several categories that have
no natural order: a bird’s diet guild (frugivore,
insectivore, granivore), a plant’s pollination syndrome, a patch’s
dominant land cover. multinomial() models such a response
as a set of baseline-category log-odds and recovers how a predictor
moves the odds of each category against a common reference.
This article shows when to reach for it, how to read its coefficients and predicted probabilities, and — just as important — where it stops in the current release.
When to use multinomial()
Match the family to how the categories relate, not just to the fact that the response is a factor.
| Response | Family | Why |
|---|---|---|
Unordered categories, K >= 3
|
multinomial() |
No category is “between” the others; each gets its own contrast against a reference. |
Ordered categories, K >= 3
|
ordinal_probit() |
The order is information. A single latent axis crosses ordered thresholds; see Choose a response family. |
| Two categories | binomial(link = "logit") |
One log-odds is enough. multinomial() on a 2-level
response errors and points you here. |
Two cautions before you start:
-
multinomial()is a response family. It is not the same ascategorical(), which is a constructor for imputing a missing categorical predictor. The two are unrelated. - “Ordered-looking” numeric codes are not ordinal by themselves, and
genuinely ordered categories are not multinomial. Diet guilds are
unordered; abundance classes (
low < medium < high) are ordered and belong toordinal_probit().
The model: baseline-category log-odds
Pick a reference category (the first factor level by default). For a response with categories and reference ,
The reference is pinned at so the model is identified, and each non-reference category carries its own intercept and slopes. A coefficient is a contrast against the reference: is the log-odds of category versus the reference at , and is the change in that log-odds per unit of . A single categorical trait therefore occupies linear predictors.
Which category is the reference is only a labelling choice: the
fitted probabilities and the maximised likelihood are identical
whichever you pick, and only the coefficient labels change. You can set
it with multinomial(baseline = ...) (shown below).
A worked example: diet guild
We simulate the diet guild of 250 bird species — frugivore,
insectivore, or granivore — as a function of one scaled morphological
axis, bill (larger values = deeper bills). The
data-generating odds make deeper-billed species more likely to be
granivores and finer-billed species more likely to be insectivores,
relative to frugivores.
library(gllvmTMB)
set.seed(20260716)
n_species <- 250L
bill <- as.numeric(scale(rnorm(n_species)))
# True baseline-category log-odds, reference = frugivore.
eta <- cbind(
frugivore = 0,
insectivore = 0.3 - 0.9 * bill,
granivore = -0.2 + 1.1 * bill
)
prob <- exp(eta - apply(eta, 1L, max))
prob <- prob / rowSums(prob)
guild <- c("frugivore", "insectivore", "granivore")[
apply(prob, 1L, function(p) sample.int(3L, 1L, prob = p))
]
diet <- data.frame(
species = factor(seq_len(n_species)),
trait = factor("diet"),
guild = factor(guild, levels = c("frugivore", "insectivore", "granivore")),
bill = bill
)
table(diet$guild)
#>
#> frugivore insectivore granivore
#> 55 116 79This worked example has one categorical response column, so it uses
the long form directly; there is no useful wide traits(...)
twin for this isolated fixed-effect fit. The separate partial
cross-family route uses a shared long-format mixed-family model and is
demonstrated in Cross-family
trait correlations, including nominal responses.
fit <- gllvmTMB(
guild ~ 0 + trait + (0 + trait):bill,
data = diet,
trait = "trait",
unit = "species",
family = multinomial(),
silent = TRUE
)
c(convergence = fit$opt$convergence, pdHess = isTRUE(fit$sd_report$pdHess))
#> convergence pdHess
#> 0 1Reading the contrasts
The fit holds
contrasts (insectivore and granivore, each versus the frugivore
reference), each with an intercept and a bill slope. We
pull them into a readable table.
sdf <- summary(fit$sd_report, "fixed")
est <- sdf[grepl("b_fix", rownames(sdf)), "Estimate"]
names(est) <- fit$X_fix_names
pick <- function(cat, slope = FALSE) {
key <- if (slope) paste0(cat, ":bill") else paste0(cat, "$")
unname(est[grepl(key, names(est))])
}
data.frame(
contrast = c("insectivore vs frugivore", "granivore vs frugivore"),
intercept = round(c(pick("insectivore"), pick("granivore")), 2),
bill_slope = round(c(pick("insectivore", TRUE), pick("granivore", TRUE)), 2),
truth_slope = c(-0.9, 1.1)
)
#> contrast intercept bill_slope truth_slope
#> 1 insectivore vs frugivore 0.54 -0.87 -0.9
#> 2 granivore vs frugivore -0.06 1.05 1.1Read the bill slopes as log-odds changes against the
frugivore reference. The granivore slope is positive: deeper-billed
species are more likely to be granivores than frugivores. The
insectivore slope is negative: deeper-billed species are less
likely to be insectivores than frugivores. Both recover the signs and
rough magnitudes of the truth. An intercept is the log-odds of that
guild versus frugivory at the mean bill (bill = 0).
Coefficients are contrasts, not category-level effects: a positive granivore slope does not by itself mean the probability of granivory rises everywhere, because every category shares the denominator. For probabilities, predict.
Predicted category probabilities
predict(type = "response") returns a long data frame
with one row per observation and category; the est column
is the fitted probability, and the probabilities sum to one within each
observation.
p_hat <- predict(fit, type = "response")
head(p_hat)
#> species trait category est
#> 1 1 diet frugivore 0.2493847
#> 2 1 diet insectivore 0.5919154
#> 3 1 diet granivore 0.1586999
#> 4 2 diet frugivore 0.2713860
#> 5 2 diet insectivore 0.3127362
#> 6 2 diet granivore 0.4158779
# probabilities sum to 1 within each observation
head(tapply(p_hat$est, p_hat$species, sum), 3)
#> 1 2 3
#> 1 1 1To see the trend, evaluate the fitted softmax across a grid of
bill values directly from the estimated contrasts (the
reference stays at
):
bill_grid <- seq(-2, 2, by = 1)
eta_hat <- cbind(
frugivore = 0,
insectivore = pick("insectivore") + pick("insectivore", TRUE) * bill_grid,
granivore = pick("granivore") + pick("granivore", TRUE) * bill_grid
)
p_grid <- exp(eta_hat) / rowSums(exp(eta_hat))
cbind(bill = bill_grid, round(p_grid, 2))
#> bill frugivore insectivore granivore
#> [1,] -2 0.09 0.90 0.01
#> [2,] -1 0.18 0.76 0.06
#> [3,] 0 0.27 0.47 0.26
#> [4,] 1 0.23 0.16 0.61
#> [5,] 2 0.11 0.03 0.86Fitted probability of granivory rises with bill depth while insectivory falls — the diet composition the data were built from.
Prediction is limited to the fitted data in this release:
predict() with a newdata argument on a
multinomial fit is not supported and fails loud rather than returning a
silently wrong answer. Build the softmax grid from the coefficients (as
above) when you need probabilities at new predictor values.
Changing the reference category
Any category can be the reference. Refitting with a different baseline relabels the contrasts but leaves the fit unchanged — same log-likelihood, same fitted probabilities.
fit_gran <- gllvmTMB(
guild ~ 0 + trait + (0 + trait):bill,
data = diet,
trait = "trait",
unit = "species",
family = multinomial(baseline = "granivore"),
silent = TRUE
)
c(default_logLik = as.numeric(logLik(fit)),
granivore_logLik = as.numeric(logLik(fit_gran)))
#> default_logLik granivore_logLik
#> -216.4474 -216.4474The two log-likelihoods match; only whether coefficients read “versus frugivore” or “versus granivore” changes. Choose the reference that makes your contrasts easiest to interpret.
Current scope boundary
The fixed-effect route demonstrated above is validated. Two later extensions are deliberately partial, not universal categorical covariance support:
- The phylogenetic route admits one
phylo_latent()term and reports the standalone among-category phylogenetic covariance described below. Its recovery is data-hungry. - The cross-family route permits one multinomial trait to share an
ordinary
latent()block with other response families. The nominal trait remains an explicit baseline-contrast block;extract_cross_correlations()supplies the reference-invariant partner summary. See Cross-family trait correlations, including nominal responses. - A fixed-effect-only fit still has no latent covariance, so
extract_correlations()andextract_Sigma()refuse it rather than fabricate a matrix. On that cross-family route, point extraction is the default; Wald/bootstrap summaries are target-specific and uncalibrated, and nonlinear profile requests are withdrawn. - Multiple multinomial traits, augmented slopes, explicit multinomial
unique()/indep(), and unlisted animal, spatial, kernel, or phylogenetic tiers remain blocked and fail loud.
Thus coefficient recovery is validated only for the fixed-effect
route, while the phylogenetic and cross-family covariance routes retain
their narrower partial labels. None of these rows
establishes interval coverage.
The phylogenetic route: a standalone among-category table
The standalone
table is most natural in a phylogenetic setting, and gllvmTMB
now reports it there (design 84), following the phylogenetic multinomial
GLMM of Mizuno, Drobniak, Williams, Lagisz & Nakagawa (2025,
Journal of Evolutionary Biology, doi:10.1093/jeb/voaf116).
A phylo_latent() factor on the
category contrasts estimates the among-category covariance
— how the category liabilities coevolve across the tree — read out with
extract_Sigma(fit, level = "phy", part = "shared", link_residual = "none").
That explicit call returns the fitted
;
the default total extraction with link_residual = "auto"
adds the fixed softmax residual
.
A categorical trait already occupies
latent dimensions, so a low-rank factor on those contrasts is exactly
the right object; no new likelihood is needed beyond the softmax you
have here.
The honest caveat is that this table is data-hungry. A single categorical draw carries little information about the -dimensional liability, so recovering needs either per-species replication or a large number of species; one observation per species is too weakly informative to pin the among-category correlation reliably (the point estimate is high-variance and can reach the boundary). Read it as recovery-oriented, and prefer replicated or large samples — the same discipline the coefficient recovery above is held to.
Two scales are in play, and it helps to name them.
above is the covariance of the phylogenetic factor on the latent
(log-odds) scale. To place a categorical trait on the same footing as
ordinary single-scale traits you also need its
observation-scale residual, and for the softmax that residual
is a matrix, not a scalar: each category contrast carries
— the same distribution-specific link variance a binomial
logit uses (Nakagawa & Schielzeth 2010, Biological Reviews
85, 935–956; Nakagawa, Johnson & Schielzeth 2017, J. R. Soc.
Interface 14, 20170213) — with a
covariance between contrasts induced by their shared baseline category,
i.e. .
That is the covariance of the differenced Gumbel errors in the softmax’s
random-utility representation (McFadden 1974, in Frontiers in
Econometrics, ed. P. Zarembka, pp. 105–142); it reduces to the
binomial’s
when
,
and it is the scale on which a categorical trait can eventually be made
commensurable with single-scale traits.
Within those limits, multinomial() gives you an honest,
identified baseline-category model for a single unordered categorical
response.
See also
-
Choose a response family —
matching every response type to a family, including the ordered-category
route with
ordinal_probit(). -
?multinomial— the family constructor and itsbaselineargument. -
?ordinal_probit— for ordered categories. -
?categorical— the unrelated missing-predictor imputation family.
