This is a reference page. It says what frm() accepts,
what it does to what you give it, what reaches the output, and what it
costs to run. For a tour of the modeling grammar, see
vignette("frmtmb").
Terminology
The package uses a small vocabulary consistently. These are the terms.
Response. The left side of a model formula, plus its
addition terms (weights(), trials(),
cens(), trunc(), se(),
rate()). A multivariate model has more than one
response.
Distributional parameter (dpar). Every parameter of
the response distribution, not only the mean. A Gaussian response has
mu and sigma; a negative binomial has
mu and shape. Auxiliary parameters are dpars
like any other: each has a link, each takes a formula with the full
predictor grammar, and each can be fixed to a constant with
bf(..., sigma = 1).
Linear predictor. One response paired with one dpar
gives one linear predictor, keyed
<response>.<dpar>. Its value is
eta = X %*% beta + Z %*% b + offset, and the dpar is
link_inverse(eta). A model has as many linear predictors as
it has (response, dpar) pairs.
Link. The monotone function that maps a dpar onto
the unbounded scale the linear predictor lives on. Links come from an
AD-safe registry, not from stats::make.link(), because the
C level clamping in the base links is invisible to automatic
differentiation.
Outer and inner parameters. The optimizer moves the
outer parameters: the fixed-effect coefficients
(beta, betad), the covariance and smoothing
parameters (theta), and any family extras such as ordinal
thresholds. The inner parameters are the random-effect
coefficients b, which include the wiggly parts of smooths
and the values of Gaussian process fields. They are not optimized in the
outer loop; they are integrated out.
Laplace approximation. The method that integrates
the inner parameters out of the joint likelihood to leave a marginal
likelihood in the outer parameters alone. It comes from TMB and RTMB.
frmtmb.sample::check_laplace() audits it against NUTS draws
on the same objective.
ML and REML. Under maximum likelihood, only
b is inner. REML = TRUE moves the fixed
effects into the inner set as well, so they are integrated out too. This
is why bounds and vcov() blocks that name fixed effects are
refused under REML: those coefficients are no longer outer
parameters.
Covariance structure (covstruct). The
parameterization of one random-effect block: us,
diag, cs, ar1, ou,
exp, gau, mat, rr,
car, spde, and the rest. It decides how many
theta entries the block owns and how they map to a
covariance matrix.
How a formula becomes design matrices
Model building has four stages. Each one has an inspectable output,
and dry_run = stops the pipeline at the first two.
bf(y ~ x + (1 | g), sigma ~ s(z)) + gaussian() user grammar
| bf(), mvbf(), +.frmtmb_formula
v
frmtmb_formula unevaluated spec
| parse_spec() [R/parse.R]
v
frmtmb_spec data-free description
| assemble_frame(spec, data) [R/frame.R]
v
frmtmb_frame X, Z, blocks, parameters
| build_objective(frame) [R/objective.R]
v
nll(pars) closure data baked in as constants
| RTMB::MakeADFun(nll, template, random = "b")
v
obj -> nlminb(obj$par, obj$fn, obj$gr) -> sdreport(obj)
Stage 1, parse. parse_spec() reads the
formula alone. It never touches the data. It splits each linear
predictor into a parametric part (bars and special terms removed), a
list of random-effect terms, a list of smooth terms, and the special
terms mo(), mi(), cs(),
gp(), and the field terms of registered covariance
structures, such as frmtmb.spatial’s car() and
spde(). Because the output is data free, a spec is a pure
description of the model.
Stage 2, assemble. assemble_frame()
binds the spec to a data frame and produces the matrices.
- The parametric part goes through
stats::model.frame()andstats::model.matrix(). The resultingterms,xlevelsandcontrastsare stored per linear predictor, and prediction on new data reapplies exactly those.frmtmb_control(sparse_x = TRUE)swaps inMatrix::sparse.model.matrix(); the two paths are checked against each other so the set of dropped aliased columns cannot differ. - Random-effect terms go through
reformulas::mkReTrms(), which returns lme4’s transposedZt. frmtmb transposes each term’s slice back out and storesZwith observations in rows. - Smooths go through
mgcv::smoothCon(absorb.cons = TRUE, modCon = 3)andmgcv::smooth2random(type = 2). The fixed part is appended toX; the wiggly part becomes a one-variance block inZ, so the smoothing parameter is estimated as a variance component. Prediction usesmgcv::PredictMat()with the stored transforms.modCon = 3makes that basis the fitted basis: at2()smooth otherwise carries a second, sum-to-zero prediction constraint thatPredictMat()honors and the fit does not. It changes no fitted quantity.s()andt2()are supported,te()is not. - Gaussian processes and the field terms (frmtmb.spatial’s
car()andspde()) build their own sparse columns and carry their auxiliary data (distances, adjacency, finite-element matrices) on the block. -
offset()terms are summed per linear predictor and added toetaafterX %*% beta + Z %*% b.weights()is not a design matrix at all: it is an addition term that multiplies the per-observation log density.
Every linear predictor’s Z spans the whole random-effect
coefficient vector, so a Z column index is a
coefficient index. Columns are ordered level major and coefficient minor
inside a block, at
(level - 1) * dim + offset + coefficient. Terms linked with
an |ID| key are merged into one block, which is only a
matter of where their columns land.
Stage 3, generate. build_objective()
closes over the assembled matrices and returns a plain R function of the
parameter list. Family, link and covariance dispatch all resolve before
taping, so the closure stays vectorized and free of branches on
parameters.
Stage 4, tape and optimize.
RTMB::MakeADFun() tapes the closure with
random = "b" (and "beta" under REML),
nlminb() moves the outer parameters, and
sdreport() supplies standard errors.
You can look at the first two stages without fitting:
set.seed(1)
d <- data.frame(y = 0, x = rnorm(30), g = factor(rep(1:6, 5)))
rownames(d) <- paste0("obs", seq_len(30))
# The draw takes its own seed, away from the chunk's: reusing
# set.seed(1) restarts the same random stream that made x, and the
# residuals come out equal to the covariate.
d$y <- frm_simulate(bf(y ~ x + (1 | g)) + gaussian(), d,
newparams = list(b_Intercept = 0, b_x = 0.4, sigma = 1,
sd_g__Intercept = 0.5),
nsim = 1, seed = 1001)[[1]]
spec <- frm(bf(y ~ x + (1 | g)) + gaussian(), data = d, dry_run = "spec")
names(spec$responses$y$dpars)
#> [1] "mu" "sigma"
spec$responses$y$dpars$mu$fixed
#> ~x
frame <- frm(bf(y ~ x + (1 | g)) + gaussian(), data = d, dry_run = "frame")
names(frame$linpreds)
#> [1] "y.mu" "y.sigma"
c(X = dim(frame$linpreds[["y.mu"]]$X),
Z = dim(frame$linpreds[["y.mu"]]$Z))
#> X1 X2 Z1 Z2
#> 30 2 30 6
lengths(frame$par_template)
#> beta betad b theta
#> 2 1 6 1beta holds the coefficients of the mean linear
predictors, betad those of the other dpars, b
the random-effect coefficients, and theta the covariance
and smoothing parameters. Here the outer problem has four parameters and
the inner problem six.
Argument types and lengths
frm() is the only entry point. There is no matrix-only
interface: the model is always a formula and a data frame.
| Argument | Type and length | On mismatch |
|---|---|---|
formula |
One bf() object with a family attached by
+, or one plain two-sided formula with
family = given separately. |
A formula with no left side, or no family from either source, is an error. |
data |
One data frame. Its nrow() sets the number of
observations. Every variable in every linear predictor must resolve in
it. |
A missing variable is an error naming the variable and the linear predictor. |
family |
A frmtmb_family, a stats::family object or
its constructor function, or a length-one family name. Overrides any
family carried by formula. |
An unsupported name errors and lists every supported name. |
REML |
Length-one logical. | Moves the fixed effects into the inner set, which makes
drop1() and fixed-effect bounds refuse. |
start |
Named list, one entry per parameter component.
par_template(formula, data) returns it filled with the
defaults, for a formula or for a fit. A named vector inside it addresses
entries by name and leaves the rest alone; an unnamed one is positional
and must be full length. |
An unknown component is an error that points at the template. An unknown parameter name lists the valid ones. Naming some entries of one vector and not others is refused. |
control |
The list from frmtmb_control(). |
See below. |
se |
Length-one logical, default FALSE. Defers
sdreport(), which is roughly a quarter of the fit
time. |
|
na.action |
A function, as in stats::lm(). Default
stats::na.omit. |
na.fail errors on any NA.
na.exclude drops rows for the fit and pads
fitted(), residuals(), predict()
and simulate() back to the input length with
NA in the original positions. |
prior |
A set_prior() specification, or a
brmsprior object brms built with prior() or
get_prior(), which is translated row by row: a row applies
whatever its prior string says, and lands where brms puts
it (a distributional parameter’s own class on that parameter, a
class = "Intercept" density at the intercept at the
predictor means). set_prior() takes the same class names
with the same meanings, so both routes are one code path. Makes the fit
penalized (MAP), so the reported log likelihood is penalized too.
Spelled as brms spells it; the priors of releases before
0.43 is gone, and a call still using it fails as an unused
argument. |
set_prior() checks the argument count of each
distribution. A brms prior class with no faithful frmtmb spelling, and a
coef on a class frmtmb resolves per block, are refused by
name rather than translated into a different density or applied wider
than asked; every refused row of a table is named in one message. A
distributional parameter’s own class is refused on a model that gives
that parameter a predictor, and class = "Intercept", dpar =
on one that does not, each naming the spelling that applies, as brms
refuses them. |
quadrature |
Length-one logical. Scalar random-intercept models only. | Any other structure, and mi(), trunc(),
REML = TRUE or profile = TRUE, is an error
naming the conflict. |
data2 |
Named list of objects that the formula names and data
cannot hold: the adjacency matrix of car(), the mesh triple
of spde(), and the matrices of gr(prec = ),
gr(cov = ) and equalto(). |
Anything that is not a named list is an error. A name a formula asks for and does not find is an error naming it. |
dry_run |
NULL, "spec", "frame" or
"objective". |
Any other value is ignored and the model fits normally. |
verbose |
FALSE, TRUE, or an integer level: 0
silent, 1 stage progress, 2 optimizer trace. An explicit
control$verbose wins. |
Never errors. NA and negative values become 0. |
frmtmb_control() takes optimizer
("nlminb", "optim", or a function),
optCtrl, restarts, grad_tol,
profile, sparse_x, autoscale,
check_nlev_1, check_olre and
verbose.
predict() is brms’s predictive summary and takes
newdata (a data frame), re_formula,
transform, resp, ndraws,
summary, robust, probs and
allow_new_levels. frm_linpred() is the linear
predictor and takes newdata, type,
dpar, resp, re_formula,
se.fit and allow_new_levels. Every name is
brms’s: where brms and lme4 or glmmTMB disagree, brms decides. An
argument that is not in the list is an ERROR naming it, and
predict(type =) and predict(se.fit =) are
refused with frm_linpred() named in the message. The lme4
spellings re.form and allow.new.levels were
live until the rename and are refused by name too.
Predictor classes
Accepted
| Class | Treatment |
|---|---|
numeric |
Used directly. |
factor |
Contrasts are read from the column and stored, then reapplied to new data. The package never creates a factor for a predictor; that would change the model silently. |
ordered factor |
Kept ordered, with its polynomial contrasts. mo() takes
an ordered factor with any number of levels; two levels are a linear
term in the code, as in brms. |
logical |
Handled as a two-level factor by model.matrix(), giving
one TRUE column. Also accepted directly as a
mo() or mi() interaction multiplier. |
character, as a grouping variable |
Accepted. An integer, a factor and a character grouping variable give the same fit. |
character, in a fixed-effect term |
Works, but by delegation: stats::model.frame() coerces
it to a factor with the default contrasts. Level order then follows the
locale collation, not your data. Convert to a factor yourself if the
level order matters. |
| Matrix-valued terms |
poly(), ns(), scale() and
matrix covariates for functional regression are supported. Their basis
is frozen at fit time through predvars, so new data is
projected onto the fitted basis rather than refitted. |
Date, POSIXct, difftime
|
Accepted as a number. model.matrix() reduces the column
to its underlying value, so the coefficient is per day
(Date), per second (POSIXct), or per unit of
the units attribute (difftime). Frame assembly
reports the coercion, naming the column, its unit and its origin. See
“Dates and times” below. |
Rejected
These raise an error rather than fitting something surprising.
| Input | Behavior |
|---|---|
| List column in any term | Error from model.frame():
invalid type (list) for variable. There is no frmtmb-level
check, so the message names the variable but not the model. |
character or factor multiplier in a
mo() or mi() interaction |
Error. as.numeric() on a character vector gives all
NA, and the only symptom would be an
NA/NaN gradient evaluation from the optimizer. The same
type gate runs again on newdata. |
Unordered factor in mo(), or fewer than three
categories |
Error. |
| Factor response with a family that is neither ordinal nor binomial | Error. |
Inf or NaN in the response |
Error. This is deliberately separate from NA, which
na.action owns. |
Matrix response under mi()
|
Error. |
cs() grouping that is not a single factor |
Error. |
gr(cov =) or gr(prec =) that is not
square, or whose dimnames miss a grouping level |
Error. |
car(type =) outside "escar",
"icar", "esicar", "bym2"
(frmtmb.spatial) |
Error listing the four types. |
One case warns instead of failing: ar1() and
hetar1() correlate levels by position, so whole-number
factor levels with a gap in them (1, 2, 4) get a warning
that the gap counts as one step, with a pointer to
ou(num_factor(...)) for irregular spacing.
Dates and times
A Date, POSIXct or difftime
predictor is fitted as the number underneath it. The model is the one
you asked for, but it is expressed in an origin you did not choose:
as.numeric() on a Date counts days from
1970-01-01, and on a POSIXct it counts seconds. So the
slope is per day or per second, and the intercept is the fitted value on
1970-01-01.
That intercept is the problem. A modern date is about 18000 as a number, and a modern timestamp about 1.7e9, which puts the intercept far outside the data and makes the objective badly conditioned. The same model on days-since-the-first-day converges where the raw column reports false convergence, with an identical slope:
d <- data.frame(day = as.Date("2020-01-01") + 0:59)
d$y <- rnorm(60, 1 + 0.02 * as.numeric(d$day - min(d$day)), 0.5)
frm(bf(y ~ day) + gaussian(), data = d)
#> Date/time column used as a number: day (Date, days since 1970-01-01).
#> The coefficient is per unit of that origin and the intercept is the
#> value at it, which is far outside the data and can stop the optimizer
#> converging. Center the column, for example as.numeric(x - min(x)), to
#> put the intercept back in range.
#> Warning: Optimizer did not report convergence: false convergence (8)
#> (Intercept) = -366.16, day = 0.0201
# the same fit, with the intercept back inside the data
d$days <- as.numeric(d$day - min(d$day))
frm(bf(y ~ days) + gaussian(), data = d)
#> (Intercept) = 1.051, days = 0.0201Frame assembly emits that message once per fit. It is a message and
not a warning because the coercion is deliberate and correct in meaning;
suppressMessages() silences it once you have centered the
column or if you want the epoch origin.
Character options and case
All character options are matched with match.arg(), so
matching is case sensitive and accepts unambiguous prefixes.
"response" and "resp" both work for
frm_linpred(type =); "Response" does not.
| Function | Argument | Values |
|---|---|---|
frm_linpred() |
type |
"link", "response",
"conditional", "zprob", "zlink",
"disp"
|
residuals() |
type |
"response" (brms’s "ordinary"),
"pearson", "deviance", "osa"
|
confint() |
method |
"wald", "Wald", "profile",
"uniroot", "boot"
|
drop1() |
test |
"none", "Chisq"
|
hypothesis() |
method |
"wald", "profile",
"boot"
|
conditional_effects() |
method |
"epred", "predict" (brms’s
"posterior_epred" and "posterior_predict" are
accepted as aliases) |
anova() on frm_multiple()
|
method, use
|
"D3", "D1", "D2";
"likelihood", "wald"
|
frmtmb_control() |
check_nlev_1, check_olre
|
"warning", "ignore",
"stop"
|
set_prior() |
class |
"b", "Intercept", "sd",
"cor", "theta"
|
conditional_effects() |
band |
"wald", "profile",
"boot"
|
loo_compare() |
criterion |
"loo", "waic"
|
vcov_cluster() |
type |
"CR0", "CR1", "CR1p",
"CR1S"
|
confint(method =) is the one place that accepts two
spellings of the same choice: "Wald" is listed alongside
"wald" and normalized immediately, because both spellings
are common in other packages.
Options that do not use match.arg() are matched exactly,
with no prefix matching: car(type =), a family given as a
string, getME(name =), frm(dry_run =) and
frmtmb_control(optimizer =).
The single case-insensitive option in the package is the code
vocabulary of cens(). There, "right",
"Right" and "R" are the same code, because a
capitalized word reads as a spelling variant rather than as garbage. The
numeric codes 0, -1, 1,
2 mean the same thing.
Naming collisions
Four vocabularies meet in a model: the columns of data,
the distributional parameters of the family, the nonlinear parameters a
formula declares, and the display names that hypothesis(),
variables() and set_prior() use. They
namespace apart by construction, so a column may carry any name at all.
The rules below say what happens where two of them do meet.
Columns and coefficients never collide. A
coefficient is stored under its dpar prefix, so a covariate named
sigma in the mean is sigma in
fixef_by_dpar(fit)$mu while the residual standard deviation
is (Intercept) in fixef_by_dpar(fit)$sigma.
Both fit, and neither reads the other.
A nonlinear parameter may not also be a column.
bf(y ~ a * exp(-b * x), a ~ 1, b ~ 1, nl = TRUE) on data
that has a column a is refused: the body would use the
parameter and ignore the column silently. Rename one of them. A declared
nonlinear parameter that no body uses is refused too, and so is a dpar
formula for a parameter the family does not have
(bf(y ~ x, shape ~ 1) on gaussian(), which
names sigma as the one available).
In a nonlinear body, a column wins over a distributional
parameter. A name in an nlf() body resolves in
this order: a nonlinear parameter of the model, then a column of
data, then another distributional parameter of the same
response (read per row, after its link inverse). The dpar reference is
an extension of brms, which has only the first two; putting the column
ahead of it is what keeps every body brms accepts meaning here what it
means there. So nlf(sigma ~ ls + th * log(abs(mu))) is a
variance function of the model’s own mean, unless data
carries a column mu, in which case it is a variance
function of that column.
In a nonlinear body, a function resolves in RTMB
first. That order is for the names a body READS. A name a body
CALLS follows a different one: RTMB, then the formula’s own environment,
then the search path. A bare pnorm(),
qgamma(), dbeta(), besselK() or
matrix() in a body is therefore RTMB’s tape-capable
version, with no RTMB:: prefix and no
library(RTMB), and it beats a function of the same name
that you defined yourself. Qualify with stats:: to reach
the other one. Only the names RTMB replaces are shadowed, so a helper of
your own is found where it always was; and because only the CALLED names
are shadowed, a data frame named df or a vector named
order still reads as your object.
The internal .eta_<dpar> names are
unreachable from data. The objective keeps each
linear predictor beside its parameter under a reserved
.eta_ name, for the families that need the untransformed
scale. Those live in an internal list rather than in the model frame, so
a column named .eta_mu is an ordinary covariate and gets an
ordinary coefficient.
In hypothesis(), a coefficient and a
natural-scale name do not meet. hypothesis() and
variables() use brms’s names, and brms puts b_
in front of every coefficient. A covariate literally named
sigma is the coefficient b_sigma, and
sigma is the residual standard deviation:
# a covariate literally named sigma
fit <- frm(bf(y ~ sigma + (1 | g)), family = gaussian(), data = dd)
hypothesis(fit, "sigma = 0") # b_sigma, brms's class = "b"
hypothesis(fit, "sigma = 1", class = NULL) # the residual SDThe same holds for a coefficient that spells out
sd_<group>__<coef>, cor_... or an
autocorrelation name such as ar[1]. As in brms, a
natural-scale name needs class = NULL, because the default
class = "b" reads every bare name as a coefficient.
Two coefficients can still meet, and each case is handled as brms
handles it. y ~ Intercept + x is refused when the model is
built, because (Intercept) and Intercept are
both Intercept after brms’s renaming.
bf(y ~ sigma_z, sigma ~ z) gives the mean’s
b_sigma_z and sigma’s b_sigma_z__1. The same
group-level coefficient on one group in two terms,
(1 | g) + (x | g), is refused; give the second term a copy
of the grouping column.
What reaches the output
Preserved
-
Row names. The row names of
datareachfitted(),predict(),residuals(),model.frame()and theZmatrix fromgetME(fit, "Z"). They are carried by the design matrix, not reassigned, so a row name survives a subset and a reorder. -
Coefficient names.
vcov(),confint()andfixef()share one naming scheme. -
Factor levels and contrasts. The level set and the
contrast matrix of every factor are stored per linear predictor and
reapplied to
newdata. A level missing fromnewdatais still a known level; a level that is new is an error unlessallow_new_levels = TRUE. -
Data-dependent bases.
poly(),ns()andscale()on the predictor side are frozen throughpredvars, sonewdatauses the fitted centering, scaling and knots. -
Row positions under
na.exclude. Output is padded back to the input length withNAin the dropped positions.
Not preserved
-
Attributes on the response. The response reaches
the objective as
as.numeric(as.vector(y)), orstorage.mode(y) <- "double"for a matrix response. Ascale()d response therefore arrives without its"scaled:center"and"scaled:scale"attributes, and a one-column matrix response arrives without itsdim. If you need to invert the transformation later, keep the attributes yourself. -
Column attributes such as
unitsorhavenlabels. Nothing in the package inspects, preserves or warns about them.model.frame()andmodel.matrix()drop them, and the coefficient is reported in whatever numeric units the column held. -
dimnameson a matrix covariate. Design column names come from the term labels, not from the covariate’s own dimnames. -
Rows dropped by
na.omit. They are absent fromfitted()andresiduals(). Usena.action = stats::na.excludeto get them back asNA.
Runtime and how it scales
A fit has five costs, and only one of them grows quickly.
| Stage | Behavior with data size |
|---|---|
| parse | Independent of the data. Effectively free. |
| frame | Linear in n and in the number of random-effect
columns. |
| tape | One pass over the objective. Independent of the number of optimizer iterations. |
| optimize | (number of outer iterations) x (cost of one objective evaluation). This dominates. |
| Hessian | At the optimum, to check that every parameter has a standard error.
Without random effects the exact Hessian, about one gradient per
coefficient. With random effects two gradients per outer parameter, when
the model has at most 10 outer parameters or those gradients are at most
a quarter of the optimizer’s evaluations; otherwise it waits for the
first standard-error use.
frmtmb_control(check_se = "ignore") skips it. |
| sdreport | One sparse solve at the optimum, which reuses that Hessian. Skipped
unless se = TRUE or a method needs it. |
One objective evaluation costs an inner Newton solve, whose work is
the sparse Cholesky factorization of the inner Hessian. That matrix is
(number of random-effect coefficients) square, and its sparsity comes
from the grouping structure, so the cost tracks the number and the
overlap of the random-effect levels rather than n alone.
The number of outer iterations tracks the number of outer parameters and
the conditioning of the problem, not the number of rows.
Measured on lme4::InstEval (n = 73421, 2972 students,
1128 lecturers) for y ~ service + (1 | s) + (1 | d), which
tapes to 5 outer and 4100 inner parameters with a 4100 x 4100 inner
Hessian holding 77521 nonzeros:
| Stage | Seconds |
|---|---|
| parse | 0.00 |
| frame | 0.14 |
| tape | 0.66 |
| optimize | 22.78 |
| restart | 0.44 |
| sdreport | 3.84 |
| total | 28.63 |
Everything outside optimize is roughly fixed: parse,
frame and tape together are about 0.8 s, and sdreport()
about 3.8 s. optimize is 80 percent of the fit, and 100
percent of optimize is objective evaluation:
nlminb itself costs nothing measurable on a five parameter
problem. The first obj$fn call is about 1.6 s because the
inner solve starts cold; every later one is about 0.17 s because it warm
starts from the previous point, and obj$gr is about 0.18 s.
This model takes 40 outer iterations, so halving the iteration count
roughly halves the fit.
Fit times across sizes, all wall clock seconds on the same machine:
| Model | frmtmb | glmmTMB | lme4 |
|---|---|---|---|
LMM, n = 180 (sleepstudy) |
0.19 | 0.08 | 0.03 |
LMM, n = 50000, 200 groups, (x | g)
|
4.61 | 2.86 | 0.35 |
| Poisson GLMM, n = 100000, 500 groups | 6.00 | 5.18 | 9.89 |
Negative binomial with dispformula, n = 20000 |
1.31 | 0.71 | |
| Zero-inflated Poisson, n = 20000 | 0.47 | 0.58 | |
LMM, n = 73421 (InstEval) |
21.50 | 4.42 |
Read this as context, not as a race. lme4::lmer() is
about 4.9 times faster on InstEval because it profiles both
the fixed effects and the residual scale out of the deviance, leaving a
two parameter problem over a purpose-built sparse Cholesky, while frmtmb
hands nlminb five parameters over a generic Laplace tape.
The two agree on the objective to four decimal places (118865.3077).
frmtmb is faster than lme4 on the large Poisson GLMM, where
glmer’s adaptive quadrature and penalized iteratively
reweighted least squares scale worse than Laplace with exact
derivatives.
Three controls change the runtime measurably on
InstEval:
-
frmtmb_control(profile = TRUE)takes it from 21.50 s to 13.54 s, and should help more as the number of fixed effects grows. -
frmtmb_control(optimizer = "optim")takes it to 10.44 s, but it is slower thannlminbon the intercept-only version of the same model at matched tolerance, so it is a per-model option rather than a better default. -
REML = TRUEtakes it to 12.06 s.
frmtmb_control(sparse_x = TRUE) changes nothing here,
because X is 73421 by 2. It is for genuinely sparse and
wide fixed-effect designs.
Reproducing the timings
Every number above comes from a script in dev/ of the
source repository at https://github.com/aforren1/frmtmb. dev/ is
not part of the installed package, so clone the repository to run
them.
| Script | Produces |
|---|---|
dev/bench-env.R |
machine, R and package versions |
dev/bench-setup.R |
one-time install into a scratch library |
dev/bench-op-baseline.R |
the per-stage table |
dev/bench-op-profile.R |
the split of optimize into obj$fn and
obj$gr
|
dev/bench-op-ceiling.R |
standalone per-call costs |
dev/bench-op-context.R |
the frmtmb, glmmTMB and lme4 comparison |
dev/bench-op-tight.R |
the same comparison at matched convergence tolerance |
dev/bench-rtmbp-core.R |
the parallel-backend appendix |
The comparison table is one command:
Run Rscript dev/bench-setup.R once first, and edit the
scratch library path at the top of each script to a directory you can
write to. Run the timing scripts one at a time; the numbers move by
about 15 percent with another R process on the machine.
The headline frmtmb against lme4 comparison does not need the harness. This one line reproduces it from an installed frmtmb:
Rscript -e 'library(frmtmb); data(InstEval, package = "lme4"); print(system.time(frm(bf(y ~ service + (1 | s) + (1 | d)) + gaussian(), data = InstEval))); print(system.time(lme4::lmer(y ~ service + (1 | s) + (1 | d), data = InstEval, REML = FALSE)))'The findings behind these numbers, including why parallelizing the
outer optimizer does not help and why an RTMB tape cannot be shipped to
a worker process, are written up in dev/benchmarks.md.