DRModels.jl is the optional Julia companion to the R
package drmTMB. Here, an engine is the
software that fits the model. You keep the R formula and data interface
and select engine = "julia" when fitting a supported model.
The default, engine = "tmb", uses Template Model
Builder (TMB) and does not require Julia. The two engines do
not support every model or summary in the same way; check the limits in
this guide before switching.
Julia is useful for repeated fitting and for models that can use DRModels.jl’s sparse structured solvers. It also fits ordinary regression models: a large phylogeny is not a prerequisite. Which backend is faster depends on the model, requested inference, data size, and whether Julia has already started and compiled the code.
This guide starts with an ordinary regression, then shows group-level
and phylogenetic scale regression. The examples are not run during the
standard R package build, so that installing or reading
drmTMB does not require Julia.
Setup and prerequisites
Install Julia 1.10 or later and the optional R
package JuliaCall. The Julia companion is
DRModels.jl. The bridge uses a local source checkout;
registration is not required.
Run once in a terminal, choosing a directory where you keep source packages:
git clone https://github.com/itchyshin/DRModels.jl.git
julia --project=DRModels.jl -e 'using Pkg; Pkg.instantiate()'Then, in R, set the checkout path before the first Julia call:
install.packages("JuliaCall")
Sys.setenv(DRMODELS_JL_PATH = "/absolute/path/to/DRModels.jl")
library(drmTMB)Use
options(drmTMB.DRModels.jl.path = "/absolute/path/to/DRModels.jl")
instead of the environment setting if you prefer to keep the path in
your R setup. For a new analysis, use one of these two settings. Restart
R after changing checkouts.
Replace the path with the directory containing the checkout’s
Project.toml and src/. The bridge initializes
Julia when needed. The first fit includes startup and compilation, so
compare later fits when timing a repeated workflow. Record the checkout
revision with an analysis that needs to be reproduced.
Start with an ordinary regression
These data are generated in R and use the same formula for both backends:
set.seed(42)
dat <- data.frame(x = seq(-1, 1, length.out = 120))
dat$y <- 1 + 0.8 * dat$x + rnorm(nrow(dat), sd = exp(-0.4 + 0.2 * dat$x))
form <- bf(y ~ x, sigma ~ x)
fit_r <- drmTMB(form, data = dat, family = gaussian(), engine = "tmb")
fit_julia <- drmTMB(form, data = dat, family = gaussian(), engine = "julia")
summary(fit_julia)
coef(fit_julia)
predict(fit_julia, dpar = "mu")
predict(fit_julia, dpar = "sigma", type = "response")
logLik(fit_r)
logLik(fit_julia)The mu coefficients describe the conditional mean. The
sigma coefficients are on the log standard-deviation scale:
exponentiating a coefficient gives a multiplicative change in residual
standard deviation, not residual variance. Compare the same estimator
and requested outputs when checking the two fits.
Model variation between groups
In a location-scale-scale model, predictors can enter the mean, residual standard deviation, and group-level standard deviation. Here sex is constant within each individual, and each individual has repeated observations:
set.seed(43)
individuals <- data.frame(
individual = factor(seq_len(40)),
sex = factor(rep(c("female", "male"), each = 20))
)
individuals$b <- rnorm(40, sd = exp(-0.3 + 0.25 * (individuals$sex == "male")))
personality_data <- individuals[rep(seq_len(40), each = 4), ]
personality_data$exploration_score <-
1 + 0.5 * (personality_data$sex == "male") + personality_data$b +
rnorm(nrow(personality_data), sd = 0.5)
formula_lss <- bf(
exploration_score ~ sex + (1 | individual),
sigma ~ sex,
sd(individual) ~ sex
)
fit_lss <- drmTMB(
formula_lss, family = gaussian(), data = personality_data,
REML = TRUE, engine = "julia"
)
summary(fit_lss)
names(coef(fit_lss))Use the returned coefficient-block names when selecting a block with
coef(); formula text such as sd(individual)
need not be the stored block name. Gaussian REML is available on
supported routes. It accounts for estimating specified fixed effects,
but does not guarantee unbiased estimates in every model or sample. Keep
ML/REML choices explicit when comparing fits.
Add a phylogeny
The Julia bridge accepts rooted polytomies with positive branch lengths. Tip labels remain literal: spaces, apostrophes and Unicode do not need to be renamed. The bridge quotes labels for transport and restores observation order. Zero-length branches and unary nodes remain unsupported; this bridge uses ultrametric trees on the correlation scale.
The same scale-regression idea applies to a phylogenetic group effect. This small simulated example creates an ultrametric tree and matches data rows to its tip labels explicitly:
install.packages("ape") # once, if not already installed
set.seed(44)
bird_tree <- ape::rcoal(40)
bird_data <- data.frame(
species = bird_tree$tip.label,
habitat = rnorm(40),
latitude = runif(40, -1, 1)
)
A <- ape::vcv(bird_tree, corr = TRUE)[bird_data$species, bird_data$species]
sd_phylo <- exp(-0.3 + 0.2 * bird_data$habitat)
u <- sd_phylo * drop(t(chol(A)) %*% rnorm(40))
bird_data$log_body_mass <- 1 + 0.4 * bird_data$habitat + u +
rnorm(40, sd = exp(-0.7 + 0.1 * bird_data$latitude))
formula_phylo_lss <- bf(
log_body_mass ~ habitat + phylo(1 | species, tree = bird_tree),
sigma ~ latitude,
sd(species, level = "phylogenetic") ~ habitat
)
fit_phylo_lss <- drmTMB(
formula_phylo_lss, family = gaussian(), data = bird_data,
REML = TRUE, engine = "julia"
)
summary(fit_phylo_lss)Here habitat predicts both the mean and the phylogenetic
standard deviation; latitude predicts residual standard
deviation. The dense matrix above is only for generating this small
example, not a recipe for simulating very large trees.
Missing responses
For supported Gaussian routes, response = "include"
retains the full tree and group design while conditioning the likelihood
on observed responses. This is not predictor imputation. Proper tree
pruning can preserve covariances between observed tips; dropping a
response does not inherently change those covariances.
bird_data_with_missing <- bird_data
bird_data_with_missing$log_body_mass[c(3, 9)] <- NA_real_
fit_missing <- drmTMB(
formula_phylo_lss, family = gaussian(), data = bird_data_with_missing,
missing = miss_control(response = "include"),
REML = TRUE, engine = "julia"
)
summary(fit_missing)Check each prediction method’s documented target and row behavior.
Retaining unobserved tips in the model does not by itself establish
support for every prediction target, such as
predict(..., dpar = "sd(species)").
Inference and post-fit methods
The bridge has methods for summary(),
coef(), vcov(), logLik(),
predict() and confint(). Availability depends
on the fitted route and target; a drmTMB_julia object does
not automatically inherit every native-TMB diagnostic.
vcov(fit_julia)
confint(fit_julia, method = "wald")
confint(fit_julia, method = "profile", parm = "fixef:mu:x")With matching development versions of drmTMB and DRModels.jl,
transformed terms retain their public formula names. For a model
containing I(x^2), select
parm = "fixef:mu:I(x^2)"; factor and interaction selectors
likewise use the names returned by coef(fit_julia). Do not
substitute generated Julia column numbers. Supported fixed-effect
predictions on new data retain the training centering, scaling and
polynomial basis. Older fitted objects without this label metadata keep
their original coefficient names.
These interval examples use the ordinary ML fit above. Check the documented objective and target before comparing ML and REML inference. Agreement between engines does not establish nominal interval coverage.
Inspect conf.status and profile.message,
not just the limits. A profile_failed result has an invalid
endpoint; signed infinity can be a failure placeholder, and a
transformed SD lower limit of zero does not clear that failure. A
profile that did not cross the threshold within the searched range is a
separate diagnostic. Generic Julia profiles now report failed nuisance
solves, but successful optimizer termination does not by itself prove a
global optimum or a practical large-tree runtime.
Which interval routes can I use?
For ordinary fixed-effect Gaussian location-scale models and ordinary
binomial models, the Julia bridge supports Wald and profile intervals
for fixed-effect mean coefficients. Bootstrap results can be useful as a
sensitivity check, but they are not a substitute for a successful
profile or for evidence about interval coverage. For residual-only
bivariate Gaussian models, use engine = "tmb" when you need
intervals. Check the capability
guide for the fitted route and target before interpreting any
Julia-engine interval.
REML standard errors are not directly comparable across engines
On a supported REML route, a fixed-effect Wald standard error can differ between the Julia and TMB engines because they use different uncertainty calculations. That difference is not, by itself, evidence that either fit is wrong. Do not treat standard errors from the two engines as interchangeable; use the engine and interval method documented for your fitted route.
One modelled missing predictor (development route)
The Julia engine also has a narrow, development
route for one modelled missing predictor. It is limited to a Gaussian
identity-link response, one bare additive mi(x) term,
complete fixed-effect exogenous designs, and a Gaussian or Bernoulli
fixed-effect predictor model. Missing x values are
integrated in the joint likelihood; they are not filled before
fitting.
set.seed(45)
n <- 80
joint_data <- data.frame(z = seq(-1, 1, length.out = n))
joint_data$x <- 0.2 + 0.7 * joint_data$z + rnorm(n, sd = 0.25)
joint_data$y <- 0.4 + 0.5 * joint_data$z + 0.8 * joint_data$x + rnorm(n, sd = 0.3)
joint_data$x[c(8, 21, 53)] <- NA_real_
joint_data$y[c(15, 53)] <- NA_real_
fit_joint <- drmTMB(
bf(y ~ z + mi(x), sigma ~ 1), family = gaussian(), data = joint_data,
impute = list(x = x ~ z),
missing = miss_control(response = "include", predictor = "model"),
engine = "julia"
)
coef(fit_joint)
imputed(fit_joint, rows = "all")For a binary predictor, use
impute = list(x = impute_model(x ~ z, family = binomial())).
The R development route also accepts response = "drop"; it
drops missing-response rows during preparation. That preprocessing
differs from native-TMB behaviour, so it is not a response-policy parity
claim.
This route excludes other response families, additional or interacted
mi() terms, random or structured effects, offsets, weights,
non-default controls, and REML. summary() and Wald
confint() require a usable returned covariance; profile and
bootstrap intervals are unsupported. For a Gaussian predictor, the
predictor-SD Wald interval is transformed to the natural SD scale, can
cross zero, and is not a native-interval-parity or coverage result. Two
public bridge adapter cases pass. Native training prediction and binary
newdata handling have been repaired and checked
independently. Full numerical parity remains open: small differences in
optimizer stopping affect coefficients and predictions beyond the
declared tolerance. This development route makes no speed or
interval-coverage claim.
Species counts, sparse solvers and parallel work
There is no universal 5,000-species limit. The limit belongs to particular dense Gaussian location-scale-scale (LSS) routes, and is a limit on observations, not a general Julia limit.
For a single phylogenetic LSS component, DRModels.jl automatically
chooses its sparse tree engine once there are more than 500 species. In
direct Julia use, you can also request that route explicitly with
sparse = true or algorithm = :sparse_lbfgs.
The sparse route avoids constructing the dense species-by-species
covariance matrix. A deliberately forced dense fallback, and the current
multi-component LSS route, still stop at 5,000 observations. Repeated
observations per species can therefore reach the dense limit before a
dataset has 5,000 species.
Other sparse phylogenetic routes have different practical limits. Species count, observation count, number of latent effects, and requested uncertainty all affect cost.
A dense double-precision 5,000 by 5,000 matrix alone occupies about 200 MB, before factorizations and temporary storage. Its storage grows quadratically and a general dense factorization grows cubically. Tree-based augmented-state solvers can avoid the dense covariance, but the precision is tree-sparse, not generally tridiagonal. Crossed effects and other structures can introduce additional fill.
Sparse likelihood calculations, model construction, standard errors, profiles, and bootstrap refits are different workloads. A fast likelihood evaluation is not evidence that a complete fit or interval calculation takes the same time. There is no single speedup factor for all Julia-backed models.
To make multiple Julia threads available, set
JULIA_NUM_THREADS before Julia starts (for example in the
shell launching R). A multi-threaded runtime does not make every fit
parallel automatically. Use the documented options for the specific
workflow, and avoid combining many Julia workers with many BLAS
threads.
Current boundaries
The Julia backend is implemented, but it is not yet a replacement for every native-TMB workflow. Check the capability guide for the model you intend to fit. In particular:
- The one-modelled-predictor development route above is available
through
engine = "julia"; other missing-predictor models remain native-TMB workflows. - Missing-response inclusion and REML depend on the model route. Most
non-Gaussian bridge routes fit ML only, but large-p Poisson
phylo()is a genuine exception: the bridge fitsREML = TRUEthere by Cox-Reid Laplace, a route nativeengine = "tmb"does not have. Check the table below before assuming that a non-Gaussian Julia model is ML-only. - An accepted family does not mean that every random effect,
structured term, weight, control, or post-fit method is available. The
fixed-effect-only families in the table do not support
phylo(),relmat(),animal(),spatial(), ordinary random effects, orsd()/sd_phylo()scale models on the Julia route. Useengine = "tmb"if your analysis needs one of those extensions. -
predict()on new data currently has narrower Julia support than in-sample prediction; do not assume that every distributional parameter is available.
If the bridge rejects a combination, use the native engine for that analysis and retain the warning or error when reporting the limitation.
Family and route reference
The table below is a starting point, not a promise that every neighbouring formula will work. Choose the listed route, then consult the capability guide before adding terms or choosing an interval.
| Family | Available Julia route |
|---|---|
gaussian() |
fixed effects; selected phylo(), ordinary
random-effect, relmat(), spatial(), and
animal() routes |
biv_gaussian() |
fixed effects with residual rho12; selected coupled
phylogenetic route |
student() or lognormal()
|
fixed effects only |
poisson() or nbinom2()
|
fixed effects; selected large-phylogeny routes; fixed-effect
zi terms; nbinom2() also has the Julia hurdle
spelling below |
gamma() or beta_family()
|
fixed effects; selected large-phylogeny routes |
stats::binomial(link = "logit") |
fixed effects; selected large-phylogeny mean route |
truncated_nbinom2(), zero_one_beta(),
tweedie(), beta_binomial(), or
cumulative_logit()
|
fixed effects only |
skew_normal() or another family |
not currently available through the Julia engine |
The zi and hu dpars are not separate
families: they are formula terms (bf(y ~ x, zi ~ x) or
bf(y ~ x, hu ~ x)) added to poisson() or
nbinom2(), and the bridge admits them through those
families’ fixed-effect route rather than through a family-specific
ledger row. hu has a spelling mismatch between engines
today: native engine = "tmb" fits the hurdle model as
truncated_nbinom2() with hu ~ ..., while
engine = "julia" fits it as nbinom2() with
hu ~ ... (DRModels.jl reads hu on
nbinom2() as the hurdle model). The same call does not fit
on both engines yet. Use the TMB spelling when you need a model that can
move between both engines.
Controls
Start with the default control settings when using the Julia engine.
Its advanced optimizer settings are intentionally narrower than the TMB
controls, and some structured and multi-response Julia routes accept no
extra controls. If your analysis depends on a particular
drm_control() setting, use engine = "tmb"
unless the capability guide
explicitly lists it for your route.