The Julia engine is available now as an optional
backend for drmTMB. You keep the R formula and
data interface and select engine = "julia" when fitting a
supported model. The default, engine = "tmb", does not
require Julia.
Julia is useful for repeated fitting and for models that can use DRM.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. DRM.jl currently needs a local source
checkout; installing a registered package named DRM is not
the setup route used by this bridge.
Run once in a terminal, choosing a directory where you keep source packages:
git clone https://github.com/itchyshin/DRM.jl.git
julia --project=DRM.jl -e 'using Pkg; Pkg.instantiate()'Then, in R, set the checkout path before the first Julia call:
install.packages("JuliaCall")
Sys.setenv(DRM_JL_PATH = "/absolute/path/to/DRM.jl")
library(drmTMB)The same setup works in ordinary noninteractive scripts run with
Rscript; no test opt-in is required. Julia startup remains
disabled in marked R package check processes unless explicitly enabled
for repository testing. Do not enable live Julia tests on CRAN or
win-builder.
Replace the path with the directory containing DRM.jl’s
Project.toml and src/. The bridge initializes
Julia when needed. The first call includes startup and compilation; time
later calls separately when comparing repeated workflows. Keep the
checkout revision with your analysis so results can be reproduced; a
package version alone may describe more than one source build.
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 DRM.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 routes have bridge-side profile and bootstrap intervals
r_bridge_status in
inst/extdata/julia-capabilities.tsv marks whether
engine = "julia" profile and bootstrap intervals have been
checked against engine = "tmb" on the same fitted target,
not just whether the family fits. Two routes carry
r_bridge_status "supported" today:
base_gaussian_location_scale (the fixed-effect Gaussian
location-scale model) and plain_binomial_nonphylo (a
stats::binomial() fit without phylo()). For
each, Wald and profile confidence intervals on a fixed-effect mean
coefficient agree with engine = "tmb" within
1e-4, and both engines converge. Bootstrap intervals
(R = 99) overlap between engines, but this is a weaker
claim than profile or Wald agreement: engine = "tmb" and
engine = "julia" draw bootstrap replicates from independent
random-number streams, so the same seed does not reproduce
the same replicates across engines, and there is no same-seed design to
compare against. Treat a bootstrap-interval comparison as overlap
evidence, not agreement within tolerance, even where both engines report
0/99 failed replicates.
biv_gaussian_residual (the residual-only bivariate
Gaussian route, fitting rho12 without a structured or
phylogenetic covariance term) is expected to join those two once
drmTMB#1187 lands; as of this vignette it still carries
r_bridge_status "partial" because the bridge
has no profile- or bootstrap-ready target for that route on any
parameter. #1187 adds one. Until it merges, use
engine = "tmb" for residual bivariate Gaussian
intervals.
REML standard errors are not directly comparable across engines
Where REML is admitted on the bridge (see
docs/design/261-reml-by-route.md), a fixed-effect
mean-block Wald standard error is not expected to match between engines
by construction, not error: engine = "tmb"’s
sdreport() moves the mean coefficients into TMB’s
random-effect block under REML and propagates variance-parameter
uncertainty into their reported standard errors, while DRM.jl profiles
the mean coefficients out exactly at each variance-parameter value and
reports the canonical GLS covariance (Xmu' Vhat^-1 Xmu)^-1
with no such propagation (drmTMB#1201). Two more REML routes are landing
after this vignette: the mean-only phylogenetic Gaussian cell
(drmTMB#1199) and the residual-only bivariate Gaussian cell
(drmTMB#1197).
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, DRM.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. See the REML column in the table below anddocs/design/261-reml-by-route.mdbefore assuming ML-only. - Accepted families do not imply that every combination of structured
effects, weights, controls and post-fit methods is admitted.
phylo(),relmat(),animal()andspatial()terms on the fixed-effect-only families below are already refused by existing gates before Julia starts. An ordinary random-effect term ((1 | g), a random slope) or ansd()/sd_phylo()scale submodel on one of these families lands with drmTMB#1196: forstudent(),lognormal(),truncated_nbinom2(),zero_one_beta(),tweedie(),beta_binomial(),cumulative_logit(), andskew_normal()once #1176 lands, such a term is refused before Julia starts, naming the family and pointing toengine = "tmb". Until #1196 merges, that combination is not refused bydrmTMBbefore Julia starts; the call round-trips to DRM.jl and DRM.jl refuses it in its own words instead (for example,TruncatedNegBinomial2() currently supports fixed effects only, pinned intests/testthat/test-julia-family-truncated_nbinom2.R). Treat any such combination as unsupported regardless of which side reports the refusal. -
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
Every family below has a same-target parity receipt against
engine = "tmb" on a committed fixture (coefficients and
log-likelihood within 1e-4, Wald SEs within 1e-3 relative), except the
one row marked “not yet admitted”. The “Receipt” column names the
capability-ledger row (inst/extdata/julia-capabilities.tsv,
r_bridge_status column) where one exists; three
fixed-effect-only families were admitted without a ledger row yet, so
their receipt lives only in the family’s own test file.
| Family |
engine = "julia" route |
Receipt |
|---|---|---|
gaussian() |
fixed effect; mean-only phylo(); coupled
mean+sigma phylo; ordinary random intercept/slope on
mu and sigma;
relmat()/spatial()/animal()
|
base_gaussian_location_scale,
gaussian_phylo_mean,
gaussian_random_intercept_mu,
gaussian_random_slope_mu,
gaussian_sigma_random_intercept,
general_covariance_structured,
location_scale_scale (partial/experimental; see the TSV for
which) |
biv_gaussian() |
fixed effect (residual rho12); coupled q = 4 phylo |
biv_gaussian_residual,
biv_q4_phylo_reml
|
student() |
fixed effect only | fe_student |
lognormal() |
fixed effect only | fe_lognormal |
poisson() |
fixed effect; large-p phylo(); zi dpar on
the fixed-effect route |
fe_poisson, phylo_count_large_p,
zi_poisson
|
nbinom2() |
fixed effect; large-p phylo(); coupled
mean+sigma phylo; zi dpar; hu
dpar (bridge spelling only – see note below) |
fe_nbinom2, phylo_count_large_p,
zi_nbinom2, hurdle_nbinom2
|
gamma() |
fixed effect; large-p phylo(); coupled
mean+sigma phylo |
fe_gamma, phylo_gamma_beta_binomial
|
beta() |
fixed effect; large-p phylo(); coupled
mean+sigma phylo |
fe_beta, phylo_gamma_beta_binomial
|
stats::binomial(link = "logit") |
fixed effect; large-p mean-only phylo()
|
plain_binomial_nonphylo,
phylo_gamma_beta_binomial
|
truncated_nbinom2() |
fixed effect only | no ledger row yet;
tests/testthat/test-julia-family-truncated_nbinom2.R
|
zero_one_beta() |
fixed effect only | no ledger row yet;
tests/testthat/test-julia-family-zero_one_beta.R
|
tweedie() |
fixed effect only | no ledger row yet;
tests/testthat/test-julia-family-tweedie.R
|
beta_binomial() |
fixed effect only (phylo() refuses: DRM.jl’s phylo
BetaBinomial route is constant-sigma only, no
receipt) |
fe_beta_binomial |
cumulative_logit() |
fixed effect only | no ledger row yet;
tests/testthat/test-julia-family-cumulative_logit.R
|
skew_normal() |
not yet admitted (open PR) | none |
any other family
(zi_poisson()/zi_nbinom2()/hurdle_nbinom2()
as their own family constructors, mixture families, etc.) |
refused | none |
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 ~ ... (DRM.jl reads hu on
nbinom2() as the hurdle model). The same call does not fit
on both engines yet; see hurdle_nbinom2 in the capability
table for the measured cross-spelling receipt and the tracked defect.
### Julia optimizer controls
For the base Julia bridge, use the Julia-native controls through the
existing drm_control() interface:
control = drm_control(optimizer = list(g_tol = 1e-6, algorithm = "lbfgs"))These settings control DRM.jl’s gradient tolerance and solver choice.
They do not make its optimizer equivalent to TMB’s nlminb()
presets or iteration budgets.
The forwarded set is a closed whitelist, and it is short:
drm_control() setting |
reaches DRM.jl as | where |
|---|---|---|
optimizer$g_tol |
drm(; g_tol =) |
every base / phylo / bivariate route |
optimizer$algorithm |
drm(; algorithm =), one of "auto",
"gls", "lbfgs", "em",
"sparse", "sparse_lbfgs"
|
as above, except q = 4 |
optimizer$q4_vcov |
drm(; q4_vcov =) |
bivariate q = 4 phylogenetic only |
On the bivariate q = 4 phylogenetic route
optimizer$g_tol is forwarded as DRM.jl’s
q4_g_tol (that route’s outer-gradient tolerance)
and optimizer$algorithm is refused, because its optimiser
has no solver-selection setting.
Everything else drm_control() carries is rejected before
Julia starts, with an error naming the setting – it is never silently
ignored. That includes the storage flags (se,
keep_data, keep_model_frame,
keep_tmb_object), the SE-shaping flags
(se_report_covariance, se_skip_delta_method,
se_group_sd), sparse_fixed,
aggregate_gaussian, logsigma_clamp,
logsigma_clamp_margin, optimizer_preset,
newton_polish, multi_start,
fallback_optimizer, start, and any
nlminb() name inside optimizer
(iter.max, eval.max, rel.tol, …).
Each of these describes an nlminb()/TMB program that DRM.jl
does not run, so there is nothing to forward it to: use
engine = "tmb" when you need one of them.
The structured, bivariate q2 structured, and cross-family Julia
routes are narrower again: they accept only a default
control, so even optimizer$g_tol is refused
there.