3 BMM code structure
You do not have to edit any of the files described here to add a model: use_model_template() (Chapter 5) writes a model file, and the package finds it. This chapter explains what happens to that file when a user fits a model, so that you know which part of it runs when.
3.1 What happens when a user calls bmm()
bmm() in R/bmm.R knows nothing about specific models. It passes the user’s formula, data and model to a pipeline of S3 generics, and each model supplies methods for the steps where it differs from the default:
bmm(formula, data, model)
|- configure_fit() R/helpers-model.R
| |- check_model() is the model valid, are the user's links allowed
| |- check_data() validate and transform the data <- model method
| |- check_formula() one formula per parameter
| |- configure_model() brms formula, family, Stan code <- model method
| |- configure_prior() default priors + the user's prior
| `- create_initfun() initial values for the sampler
|- brms::brm() compile and sample
`- postprocess_brm() rename, store metadata <- optional model methodconfigure_fit() holds the whole configuration sequence:
R/helpers-model.R
configure_fit.bmmformula <- function(formula, data = NULL, model = NULL, prior = NULL,
until = "init", frame_args = list()) {
user_formula <- formula
# the user's expression for the data lives in the caller of the generic
# (bmm() or an extractor). It is set before check_data(), whose methods print
# it (order_data_query()), and again after, because some methods rebuild the
# data frame without it
data_name <- substitute_name(data, envir = parent.frame())
if (!missing(data) && !is.null(data) && (is.list(data) || is.atomic(data))) {
attr(data, "data_name") <- data_name
}
model <- check_model(model, data, formula)
data <- check_data(model, data, formula)
attr(data, "data_name") <- data_name
formula <- check_formula(model, data, formula)
config_args <- configure_model(model, data, formula)
if (until == "model") {
return(nlist(config_args, model, user_formula))
}
prior <- brms::do_call(
configure_prior, c(list(model, data, config_args$formula, prior), frame_args)
)
# the prior decides which parameters exist, so the inits are built from it
if (until == "init") {
config_args$init <- brms::do_call(
create_initfun, c(list(model, data, config_args$formula, prior), frame_args)
)
}
nlist(config_args, prior, model, user_formula)
}The same function serves stancode(), standata() and default_prior(), which stop early through until. So the methods you write for check_data() and configure_model() are also what a user sees when they inspect the Stan code or the default priors of your model without fitting it.
Three details of bmm() itself concern model authors:
- Caching. With
file_refit = "on_change", a cached fit is reused only if the Stan code and data are unchanged.configure_model()must therefore generate the same Stan code for the same inputs. - Model-frame arguments.
brms_frame_args()picks out thebrm()arguments that change the model frame (data2,knots,drop_unused_levels) and passes them toconfigure_prior()andcreate_initfun(). This is why those methods need...in their signature. - Threading.
local_brms_threads()records whether the user asked for within-chain threading, because a model may need to generate different Stan code for it (Section A.5).
3.2 The bmmformula class
Users specify what to estimate with a bmmformula (alias bmf()): a list of formulas, one per model parameter. Unlike a brmsformula, it has no response variable on the left-hand side, because the response is named when the model object is constructed.
A parameter the user does not mention gets an intercept-only formula, with a message. During configuration, the bmf2bf() method converts the bmmformula to a brmsformula. It adds the response as the first formula line and turns each parameter formula into a linear (brms::lf()) or non-linear (brms::nlf()) component.
3.3 Models
Each model is an S3 class with an internal constructor, .model_<name>(), and a user-facing alias, <name>(). The parameters, links, fixed parameters, priors and initialization ranges live in a separate list, .<name>_defaults, or, for a model with several versions, .<name>_version_table (Section A.6). The constructor reads its fields from that list:
.my_model_defaults <- list(
parameters = list(
par1 = "Parameter 1 = description of parameter 1",
par2 = "Parameter 2 = description of parameter 2"
),
links = list(par1 = "identity", par2 = "log"),
fixed_parameters = list(mu = 0),
priors = list(
par1 = list(main = "normal(0, 1)", effects = "normal(0, 0.5)", sd = "exponential(1)"),
par2 = list(main = "normal(0, 0.5)", effects = "normal(0, 0.5)", sd = "exponential(2)")
),
init_ranges = list(par1 = c(-1, 1), par2 = c(0.5, 1.5))
)
.model_my_model <- function(resp_var1 = NULL, required_arg1 = NULL,
links = NULL, call = NULL, ...) {
out <- structure(
list(
resp_vars = nlist(resp_var1),
other_vars = nlist(required_arg1),
domain = "",
task = "",
name = "",
citation = character(),
version = "NA",
requirements = "",
parameters = .my_model_defaults[["parameters"]],
links = .my_model_defaults[["links"]],
fixed_parameters = .my_model_defaults[["fixed_parameters"]],
default_priors = .my_model_defaults[["priors"]],
init_ranges = .my_model_defaults[["init_ranges"]]
),
class = c("bmmodel", "my_model"),
call = call
)
set_links(out, links)
}The fields of the specification:
parameters: the parameters a user can estimate or fix, each with a description. A dummy parameter that exists only for brms stays out (Section A.2).links: the link function of each parameter, which maps the scale Stan samples on to the scale the parameter is interpreted on. Users can change links through the alias’slinksargument;set_links()validates their choice (Section A.1).fixed_parameters: parameters held at a constant, such aslist(mu = 0).priors: per parameter, a prior for the intercept (main), for regression coefficients (effects) and for random-effect standard deviations (sd); see Section 3.8.init_ranges: optionalc(lower, upper)windows for initial values, for parameters whose likelihood is undefined in part of their range (Section A.3).
The metadata fields (domain, task, name, citation, requirements) fill the help page and the model listings. domain decides under which task group bmm_models() and the website list the model, so reuse the string of the models yours belongs with. Tests check that every constructor runs with no arguments and lists its fields in the order above.
The alias validates the user’s arguments and calls the constructor. Its help page is generated from the constructor’s fields by model_docs():
#' @title `r .model_my_model()$name`
#' @name my_model
#' @details `r model_docs(.model_my_model())`
#' @param resp_var1 A description of the response variable
#' @param required_arg1 A description of the required argument
#' @param ... used internally for testing, ignore it
#' @return An object of class `bmmodel`
#' @keywords bmmodel
#' @export
my_model <- function(resp_var1, required_arg1, links = NULL, ...) {
call <- match.call()
stop_missing_args()
.model_my_model(resp_var1 = resp_var1, required_arg1 = required_arg1,
links = links, call = call, ...)
}A model with versions adds version = c("v1", "v2") to the alias, validates it with match.arg() and appends the version to the class. use_model_template() generates either variant.
3.4 Class chains
The class vector of a model encodes where it sits in the taxonomy, and it decides which methods run:
| Task group | Models and class chains |
|---|---|
| Continuous reproduction | mixture2p: c("bmmodel", "circular", "mixture2p"); mixture3p: c("bmmodel", "circular", "non_targets", "mixture3p"); imm: c("bmmodel", "circular", "non_targets", "imm", "imm_full"); sdm: c("bmmodel", "circular", "sdm", "sdm_simple") |
| Categorical recall | m3: c("bmmodel", "m3", "m3_ss") |
| Processing trees | mpt: c("bmmodel", "mpt") |
| Signal detection | sdt_yn: c("bmmodel", "sdt", "sdt_yn"), likewise sdt_mafc, sdt_ranking, sdt_cdp; sdt_rating: c("bmmodel", "sdt", "sdt_rating", "sdt_rating_standard") |
| Choices and response times | ddm: c("bmmodel", "ddm"); ezdm: c("bmmodel", "ezdm", "ezdm_3par"); cswald: c("bmmodel", "cswald", "cswald_simple") |
Shared classes such as "circular", "non_targets" and "sdt" carry methods that several models use: check_data.circular() checks that responses are in radians, and check_formula.sdt() refuses the stimulus column as a predictor. Dispatch walks the class vector from left to right, so check_data.bmmodel() runs first and each method hands on to the next with NextMethod(). The model’s own method runs last. Chapter 4 follows one such chain through the IMM.
3.5 S3 generics
Each pipeline step is a generic, such as
R/helpers-model.R
and a model adds a method for it, configure_model.my_model(). That is how new models are added without editing bmm(). The generics of the pipeline:
| Generic | Signature | Defined in |
|---|---|---|
check_model |
(model, data = NULL, formula = NULL) |
helpers-model.R |
check_data |
(model, data, formula) |
helpers-data.R |
check_formula |
(model, data, formula) |
bmmformula.R |
bmf2bf |
(model, formula) |
bmmformula.R |
configure_model |
(model, data, formula) |
helpers-model.R |
configure_prior |
(model, data, formula, user_prior, ...) |
helpers-prior.R |
create_initfun |
(model, data, formula, prior = NULL, ...) |
helpers-inits.R |
postprocess_brm |
(model, fit, ...) |
helpers-postprocess.R |
Match these signatures exactly, including ... and the = NULL defaults. R CMD check compares every method with its generic.
A model writes check_data() and configure_model() methods almost always, bmf2bf() when its response needs wrapping (DDM: rt | dec(response) ~ 1) or packs several columns (vreal(), vint(), trials()), and postprocess_brm() rarely. configure_model() returns nlist(formula, data, stanvars): the brmsformula with its family set, the data, and the Stan code for a custom family. A model built on a native brms family, such as brms::mixture() for the IMM, needs no stanvars.
Further generics let a model adjust behaviour around the pipeline. Each has a default that covers most models; Chapter 5 says when to write a method:
- before and after the fit:
revert_check_data()(Section 3.7),settable_links()(Section A.1),data_check_findings(); - for post-processing:
native_transform(),pp_observables()andpp_simulate().
A few more exist for single model families, such as generated_parameters() for MPT or summary_notes() for the SDT models. Their source files explain them.
3.6 Passing information from check_data() to configure_model()
check_data() and configure_model() are separate methods and cannot share local variables. When check_data() computes something that configure_model() needs, it stores it as an attribute of the data frame:
Two cautions. The infrastructure uses the same channel for its own bookkeeping (check_data.bmmodel() sets data_name and checked), so not every attribute you find is yours. And a configure_model() method can be called directly, without check_data(), so the attribute may be missing. configure_model.sdm() recomputes the value in that case; if you do not guard, make the failure loud.
3.7 Re-running check_data() in update()
update(fit) without new data refits on the data stored in the fit. That is brms’s model frame, which holds only the variables the formula uses and is already the output of your check_data(). Running check_data() on it again fails for any model whose check adds, renames or packs columns.
So update() first calls revert_check_data(model, data) to bring the stored frame back into the shape the user’s data had. The default returns the data unchanged, which is right for a check_data() that only validates. If yours changes columns, write a method in R/update.R:
- End with
NextMethod("revert_check_data"), so that a method on a shared class still runs. - A column your
check_data()needs may be missing from the stored frame, because brms keeps only the columns the formula uses. A method that has to rebuild such a column names it in the"rebuilt"attribute of the data, andupdate()drops it again after the check. Append to that attribute rather than overwriting it.
revert_check_data.m3() and the SDT methods are the examples. The stored_frame case your model registers for the tests (Chapter 5) is what checks that this works.
3.8 Default priors
configure_prior() reads each parameter’s entry in priors:
mainapplies to the intercept. If the user drops the intercept (kappa ~ 0 + condition), it applies to each level of the factor instead. Keep it first in the list: without an intercept, it is found by position.effectsapplies to regression coefficients. Without it, brms’ defaults apply.sdapplies to the random-effect standard deviations. Without it, brms falls back tostudent_t(3, 0, 2.5), which on a log link lets individual values vary by a factor of 12 around the group value. The shipped models useexponential()with a rate chosen by what the parameter measures (Section A.7).
The strings use the format of brms::set_prior(). Correlations among random effects get lkj(2) without anything in the model definition. For a parameter in both fixed_parameters and parameters, such as mu1 in the IMM, the fixed value holds unless the user gives it a formula.
3.9 File organization
| File | Contents |
|---|---|
R/bmm.R |
bmm(). You do not edit it for a new model. |
R/helpers-model.R |
configure_fit(), check_model(), configure_model() defaults, use_model_template(), and the central tables a new model adds to: data_column_roles, response_annotations() |
R/helpers-data.R, -prior.R, -inits.R, -postprocess.R, -parameters.R |
The defaults of the other pipeline steps; also the RT data utilities (flag_contaminant_rts(), ezdm_summary_stats()) |
R/bmmformula.R |
bmmformula()/bmf(), check_formula(), bmf2bf() |
R/model_<name>.R |
One model: specification, constructor, alias, its methods, and the log_lik, posterior_predict and posterior_epred functions of a custom family. MPT, the largest model, is split over several model_mpt_*.R files. |
R/helpers-sdt.R, R/helpers-analysis.R |
Code shared by the signal detection models, and their ROC/AUC analyses |
R/distributions.R |
The d*, r* (and where available p*, q*) functions of each model |
R/update.R, R/restructure.R |
update() with the revert_check_data() methods; bringing old fits up to date |
R/data_check.R, R/bmm_setup.R |
bmm_data_check() and bmm_setup() |
| post-processing files | conditional_effects.R, emmeans.R, pp_check.R, pp_observables.R, ppp.R, summary.R, plot.R, report-extract.R. They work on any fitted model. |
R/brms-misc.R |
A read-only copy of brms internals. Do not edit it. |
inst/stan_chunks/ |
Stan code for custom families, named <model>_<block>.stan; code shared by several models gets its own file (sdt_dist_funs.stan) |
Good model files to read first are R/model_ddm.R (flat specification), R/model_cswald.R (version table) and R/model_sdt_yn.R (aggregated counts).
When you write Stan code, remember that the sampler needs continuous gradients. Non-differentiable operations such as fabs() or fmax() cause divergent transitions; where they are mathematically needed, use a smooth stand-in such as sqrt(x^2 + epsilon) for |x|.