Storing brms models in a targets workflow
This note is a quick test for how to set up a function that fits a brms model in a targets workflow. Specifically, should my function for fitting a brms model return a model or a path to saved model? That is,
f_ret_model <- function(...) { brm(..., file = "model", file_refit = "on_change") } f_ret_file <- function(...) { m <- brm(..., file = "model", file_refit = "on_change") m$file }
We definitely want to leverage the file argument because it caches
the model so that it will only refit if the model’s formula, data,
priors, or generated Stan code change. But should I use inside of a
targets workflow?
Setting up a temporary targets project
First, because this notebook and website is rendered using a targets pipeline, I am going to set up a temporary directory to serve as the working directory for this post.
library(targets) library(tarchetypes) library(brms) dir <- tempfile() dir.create(dir) setwd(dir) knitr::opts_knit$set(root.dir = dir) # I don't want to accidentally mangle my website's targets files, # so I'm adding an extra underscore targets::tar_config_set(script = "__targets.R") targets::tar_config_set(store = "__targets")
Next, I am going to create a _targets.R file that will fit the same
model three times, but the models will try different things:
model_1: returns the model objectmodel_2: returns the path to the model’s .rds filemodel_3: returns the model’s .rds file and targets reads in the .rds file
tar_script({ library(targets) library(brms) dir.create("models", showWarnings = FALSE) dir.create("stan", showWarnings = FALSE) # Avoids recompilations options(cmdstanr_write_stan_file_dir = normalizePath("stan")) # Fit a model and return the model fit <- function(data, file, .sd = 10) { p <- sprintf("normal(0, %s)", .sd) |> prior_string(class = "b") fit1 <- brm( count ~ zBase * Trt + (1|patient), data = data, family = poisson(), prior = p + prior(cauchy(0, 2), class = sd), file = file, file_refit = "on_change", backend = "cmdstanr", silent = 2, chains = 1 ) fit1 } f1 <- function(data, .sd = 10) { m <- fit(data, "models/test1", .sd = .sd) m } f2 <- function(data, .sd = 10) { m <- fit(data, "models/test2", .sd = .sd) m$file } f3 <- function(data, .sd = 10) { m <- fit(data, "models/test3", .sd = .sd) m$file } tarchetypes::tar_assign({ data <- tar_target(epilepsy) model_1 <- tar_target(f1(data)) model_2 <- tar_target(f2(data), format = "file") model_3 <- tarchetypes::tar_file_read( command = f3(data), read = readRDS(file = !! .x) ) }) }, ask = FALSE)
Now, we fit the models:
targets::tar_make() #> Loading required package: Rcpp #> Loading 'brms' package (version 2.23.0). Useful instructions #> can be found by typing help('brms'). A more detailed introduction #> to the package is available through vignette('brms_overview'). #> #> Attaching package: 'brms' #> #> The following object is masked from 'package:stats': #> #> ar #> #> + data dispatched #> ✔ data completed [0ms, 2.68 kB] #> + model_3_file dispatched #> ✔ model_3_file completed [54.6s, 561.05 kB] #> + model_1 dispatched #> ✔ model_1 completed [9.4s, 1.09 MB] #> + model_2 dispatched #> ✔ model_2 completed [10.8s, 552.96 kB] #> + model_3 dispatched #> ✔ model_3 completed [0ms, 561.07 kB] #> ✔ ended pipeline [1m 15.1s, 5 completed, 0 skipped] #> Warning message: #> package 'Rcpp' was built under R version 4.6.1
Confirm that they don’t refit when nothing changes:
targets::tar_make() #> Loading required package: Rcpp #> Loading 'brms' package (version 2.23.0). Useful instructions #> can be found by typing help('brms'). A more detailed introduction #> to the package is available through vignette('brms_overview'). #> #> Attaching package: 'brms' #> #> The following object is masked from 'package:stats': #> #> ar #> #> ✔ skipped pipeline [93ms, 5 skipped] #> Warning message: #> package 'Rcpp' was built under R version 4.6.1
When we try to load in the targets, we see that model_2 is just a
path, and it’s a relative path at that. We have to take an extra step of
reading the file content. model_1 and model_3 provide the actual
brms objects:
targets::tar_load(model_1) class(model_1) #> [1] "brmsfit" targets::tar_load(model_2) model_2 #> [1] "models/test2.rds" class(readRDS(model_2)) #> [1] "brmsfit" targets::tar_load(model_3) class(model_3) #> [1] "brmsfit"
model_3 is actually two targets. tar_file_read(name, command, read)
creates a [name]_file for the result of the command and creates
[name] target for the result of read:
targets::tar_visnetwork() #> Loading required package: Rcpp #> Loading 'brms' package (version 2.23.0). Useful instructions #> can be found by typing help('brms'). A more detailed introduction #> to the package is available through vignette('brms_overview'). #> #> Attaching package: 'brms' #> #> The following object is masked from 'package:stats': #> #> ar #> #> Warning message: #> package 'Rcpp' was built under R version 4.6.1
center
Because targets never does read in the contents of model_2 as R data,
it has no storage footprint:
targets::tar_path_store() |> fs::dir_ls() |> fs::dir_ls(regexp = "objects") |> fs::file_info() |> dplyr::select(path, size) #> # A tibble: 3 × 2 #> path size #> <fs::path> <fs::bytes> #> 1 __targets/objects/data 2.62K #> 2 __targets/objects/model_1 1.04M #> 3 __targets/objects/model_3 547.92K
So, the model_2 approach means that there is no duplicated storage of
models between _targets/objects/ and models/.
At this point, I went down a terrible rabbit-hole
Pop quiz: In the filesize printout, why is model_1 twice as big as
model_3?
Here is a hint. lobstr::obj_size() measures the size of an R object,
including environments referenced by the object:
targets::tar_load(model_1) targets::tar_load(model_2) sizes_1 <- model_1 |> lapply(lobstr::obj_size) |> unlist() sizes_3 <- model_3 |> lapply(lobstr::obj_size) |> unlist() sizes_3 - sizes_1 #> formula data prior data2 stanvars model save_pars algorithm #> -1119784 -1132736 0 0 0 0 0 0 #> backend threads opencl stan_args fit basis criteria file #> 0 0 0 0 86544 0 0 0 #> version family autocor ranef cov_ranef stan_funs data.name #> 0 0 0 -1115512 0 0 0
That’s where within the model structure the sizes differ. What extra thing is in those parts of the model?
Here is another hint:
m <- function() { fit <- lm(mpg ~ 1, mtcars) fit } lobstr::obj_size(m()) #> 11.29 kB m <- function() { fit <- lm(mpg ~ 1, mtcars) BIG_DATA <- rnorm(1000000) fit } lobstr::obj_size(m()) #> 8.01 MB
That’s how the model’s context surrounding context can influence the size of the model. What’s going on?
Here is yet another hint:
model_1$formula$formula |> environment() |> ls() #> [1] "data" "file" "fit1" "p" model_3$formula$formula |> environment() |> ls() #> [1] "data" "file" "p"
That’s the precise difference in the context. But how did that self-reference happen?
Here is a final hint:
tar_path_target("model_1") |> file.size() #> [1] 1087307 model_1$file |> file.size() #> [1] 552379
The formulas in the model are R formula objects which means that they
contain a reference to their surrounding environment. That is a
reference, as opposed to a copy of the environment. If we ask the
model for the environment, we see the environment as it is right now,
including any changes after the fitting of the model. That’s why
BIG_DATA in the second hint can make the model’s lobstr::obj_size()
increase.
In f1(), this sequence happens:
- The model is fit and saved to the
models/file, and return bybrm(). - The model object is assigned to
fit1.fit1is now part of the environment, so that if we asked the model’s formula for the environment, we would seefit1. - targets saves the model object to the
_targets/objectsincluding the updated environment.
This behavior means that we should not assign the model inside f1() if
possible. Now I’m starting to worry about much memory I’ve wasted by mostly
following pattern 1 (storing models in both models and _targets/objects and
not being careful about assignments).
One might ask if the formula’s environment references the fitted model, then doesn’t that “inner” model’s formula also have its own model, ad infinitum? Yes, but it’s just a loop. The environment captured by the out model’s formula and the inner model’s formula are the same.
targets::tar_load(model_1) outer_e <- model_1$formula$formula |> environment() inner_model <- outer_e$fit1 inner_e <- inner_model$formula$formula |> environment() outer_e #> <environment: 0x0000020349d919e0> inner_e #> <environment: 0x0000020349d919e0>
So, what is being duplicated? Let’s surgically remove the referenced model from the formula environment and compare file sizes:
model_1_copy <- unserialize(serialize(model_1, NULL)) # Remove "fit1" in the formula's environment e <- model_1_copy$formula$formula |> environment() rm("fit1", envir = e) saveRDS(model_1, "models/model-1-resave.rds") saveRDS(model_1_copy, "models/model-1-clean.rds") file.size("models/model-1-resave.rds") #> [1] 1087315 file.size("models/model-1-clean.rds") #> [1] 552392
Maybe it’s the posterior draws in the inner model? Let’s only remove those and see what happens.
model_1_copy2 <- unserialize(serialize(model_1, NULL)) # Ablate the `$fit` part of the inner model e <- model_1_copy2$formula$formula |> environment() with(e, fit1$fit <- NULL) is.null(e$fit1$fit) #> [1] TRUE
The outer model has its draws.
model_1_copy2 #> Loading required namespace: rstan #> Family: poisson #> Links: mu = log #> Formula: count ~ zBase * Trt + (1 | patient) #> Data: data (Number of observations: 236) #> Draws: 1 chains, each with iter = 2000; warmup = 1000; thin = 1; #> total post-warmup draws = 1000 #> #> Multilevel Hyperparameters: #> ~patient (Number of levels: 59) #> Estimate Est.Error l-95% CI u-95% CI Rhat Bulk_ESS Tail_ESS #> sd(Intercept) 0.59 0.07 0.46 0.74 1.00 178 379 #> #> Regression Coefficients: #> Estimate Est.Error l-95% CI u-95% CI Rhat Bulk_ESS Tail_ESS #> Intercept 1.78 0.11 1.56 2.01 1.00 192 296 #> zBase 0.70 0.11 0.46 0.91 1.00 209 394 #> Trt1 -0.28 0.16 -0.62 0.01 1.00 186 467 #> zBase:Trt1 0.03 0.16 -0.29 0.31 1.00 201 345 #> #> Draws were sampled using sample(hmc). For each parameter, Bulk_ESS #> and Tail_ESS are effective sample size measures, and Rhat is the potential #> scale reduction factor on split chains (at convergence, Rhat = 1).
But the inner one gives a summary I had never seen before:
e$fit1 #> Family: poisson #> Links: mu = log #> Formula: count ~ zBase * Trt + (1 | patient) #> Data: data (Number of observations: 236) #> #> The model does not contain posterior draws.
Comparing file sizes, it does seem like the duplicated posterior draws are responsible for the inflated size.
saveRDS(model_1_copy2, "models/model-1-clean2.rds") file.size("models/model-1-resave.rds") #> [1] 1087315 file.size("models/model-1-clean.rds") #> [1] 552392 file.size("models/model-1-clean2.rds") #> [1] 555383
Some nonstandard evaluation to make it easy to tweak the targets scripts
This note is now off the rails. We’re in the rabbithole. I want to mess with this environment stuff some more.
In the next few tests, I’ll be tweaking parts of the tar_script()
code, so I am going to define some code chunks and assemble the final
code block by injecting expressions. This move cuts down on copy-pasting
and makes sure the same code is reused across demos.
s_setup <- quote({ library(targets) library(brms) dir.create("models", showWarnings = FALSE) dir.create("stan", showWarnings = FALSE) options(cmdstanr_write_stan_file_dir = normalizePath("stan")) }) s_function <- quote({ # Fit a model and return the model fit <- function(data, file, .sd = 10) { p <- sprintf("normal(0, %s)", .sd) |> prior_string(class = "b") fit1 <- brm( count ~ zBase * Trt + (1|patient), data = data, family = poisson(), prior = p + prior(cauchy(0, 2), class = sd), file = file, file_refit = "on_change", backend = "cmdstanr", silent = 2, chains = 1 ) fit1 } }) s_subfunctions <- quote({ f1 <- function(data, .sd = 10) { m <- fit(data, "models/test1", .sd = .sd) m } f2 <- function(data, .sd = 10) { m <- fit(data, "models/test2", .sd = .sd) m$file } f3 <- function(data, .sd = 10) { m <- fit(data, "models/test3", .sd = .sd) m$file } }) s_targets <- quote({ tarchetypes::tar_assign({ data <- tar_target(epilepsy) model_1 <- tar_target(f1(data)) model_2 <- tar_target(f2(data), format = "file") model_3 <- tarchetypes::tar_file_read( command = f3(data), read = readRDS(file = !! .x) ) }) }) write_tar_script <- function(...) { code <- rlang::expr({!!! list(...)}) rlang::eval_tidy(rlang::expr(tar_script(!! code, ask = FALSE))) } write_tar_script(s_setup, s_function, s_subfunctions, s_targets) targets::tar_make() #> Loading required package: Rcpp #> Loading 'brms' package (version 2.23.0). Useful instructions #> can be found by typing help('brms'). A more detailed introduction #> to the package is available through vignette('brms_overview'). #> #> Attaching package: 'brms' #> #> The following object is masked from 'package:stats': #> #> ar #> #> ✔ skipped pipeline [93ms, 5 skipped] #> Warning message: #> package 'Rcpp' was built under R version 4.6.1
Let’s make a superficial change to the pipeline by 1) cleaning up the
fit1 <- assignment and 2) including the .sd value in the function
arguments in the targets commands. These change affects the targets’
commands so they need to be remade but it does not change the brms
models so they do not need to be refitted. The saved files should suffice:
s_function2 <- quote({ # Fit a model and return the model fit <- function(data, file, .sd = 10) { p <- sprintf("normal(0, %s)", .sd) |> prior_string(class = "b") brm( count ~ zBase * Trt + (1|patient), data = data, family = poisson(), prior = p + prior(cauchy(0, 2), class = sd), file = file, file_refit = "on_change", backend = "cmdstanr", silent = 2, chains = 1 ) } }) s_targets2 <- quote({ tarchetypes::tar_assign({ data <- tar_target(epilepsy) model_1 <- tar_target(f1(data, .sd = 10)) model_2 <- tar_target(f2(data, .sd = 10), format = "file") model_3 <- tarchetypes::tar_file_read( command = f3(data, .sd = 10), read = readRDS(file = !! .x) ) }) }) write_tar_script(s_setup, s_function2, s_subfunctions, s_targets2) targets::tar_make() #> Loading required package: Rcpp #> Loading 'brms' package (version 2.23.0). Useful instructions #> can be found by typing help('brms'). A more detailed introduction #> to the package is available through vignette('brms_overview'). #> #> Attaching package: 'brms' #> #> The following object is masked from 'package:stats': #> #> ar #> #> + model_3_file dispatched #> ✔ model_3_file completed [359ms, 561.05 kB] #> + model_1 dispatched #> ✔ model_1 completed [187ms, 552.39 kB] #> + model_2 dispatched #> ✔ model_2 completed [187ms, 552.96 kB] #> ✔ ended pipeline [6.1s, 3 completed, 2 skipped] #> Warning message: #> package 'Rcpp' was built under R version 4.6.1
Okay, as expected, model_3_file had to be remade, but not model_3
because the underlying file did not change.
And removing the assignment from the model fitting function reduced the
file-size of the _targets version of the model:
targets::tar_path_store() |> fs::dir_ls() |> fs::dir_ls(regexp = "objects") |> fs::file_info() |> dplyr::select(path, size) #> # A tibble: 3 × 2 #> path size #> <fs::path> <fs::bytes> #> 1 __targets/objects/data 2.62K #> 2 __targets/objects/model_1 539.45K #> 3 __targets/objects/model_3 547.92K
Let’s actually change the prior, forcing a refit, and see what happens:
s_targets3 <- quote({ tarchetypes::tar_assign({ data <- tar_target(epilepsy) model_1 <- tar_target(f1(data, .sd = 5)) model_2 <- tar_target(f2(data, .sd = 5), format = "file") model_3 <- tarchetypes::tar_file_read( command = f3(data, .sd = 5), read = readRDS(file = !! .x) ) }) }) write_tar_script(s_setup, s_function2, s_subfunctions, s_targets3) targets::tar_make() #> Loading required package: Rcpp #> Loading 'brms' package (version 2.23.0). Useful instructions #> can be found by typing help('brms'). A more detailed introduction #> to the package is available through vignette('brms_overview'). #> #> Attaching package: 'brms' #> #> The following object is masked from 'package:stats': #> #> ar #> #> + model_3_file dispatched #> ✔ model_3_file completed [31.9s, 561.26 kB] #> + model_1 dispatched #> ✔ model_1 completed [10.4s, 552.85 kB] #> + model_2 dispatched #> ✔ model_2 completed [10.3s, 552.53 kB] #> + model_3 dispatched #> ✔ model_3 completed [15ms, 561.28 kB] #> ✔ ended pipeline [58.1s, 4 completed, 1 skipped] #> Warning message: #> package 'Rcpp' was built under R version 4.6.1
As expected, all three models had to refit. Yes, I feel like inventing an intransitive refit.
targets::tar_load(model_1) targets::tar_load(model_2) targets::tar_load(model_3) # Confirm change in prior prior_summary(model_1)[1, 1] #> [1] "normal(0, 5)" prior_summary(readRDS(model_2))[1, 1] #> [1] "normal(0, 5)" prior_summary(model_3)[1, 1] #> [1] "normal(0, 5)"
Adding LOO scores
brms will store derived model data such as LOO scores inside of the
brmsfit (model) object, and if the model has an associated file, that
file will be updated with the derived model data. Thus, we might run into a
case where an assignment creates something in the model’s environment that
inflates the model size.
Adding LOO scores to an already fitted model. We add an assignment to the
function and use add_criterion() on the model:
s_function3 <- quote({ # Fit a model and return the model fit <- function(data, file, .sd = 10) { p <- sprintf("normal(0, %s)", .sd) |> prior_string(class = "b") m <- brm( count ~ zBase * Trt + (1|patient), data = data, family = poisson(), prior = p + prior(cauchy(0, 2), class = sd), file = file, file_refit = "on_change", backend = "cmdstanr", silent = 2, chains = 1 ) add_criterion(m, "loo") } }) write_tar_script(s_setup, s_function3, s_subfunctions, s_targets3) targets::tar_make() #> Loading required package: Rcpp #> Loading 'brms' package (version 2.23.0). Useful instructions #> can be found by typing help('brms'). A more detailed introduction #> to the package is available through vignette('brms_overview'). #> #> Attaching package: 'brms' #> #> The following object is masked from 'package:stats': #> #> ar #> #> + model_3_file dispatched #> Automatically saving the model object in 'models/test3.rds' #> ✔ model_3_file completed [1.7s, 572.84 kB] #> + model_1 dispatched #> Automatically saving the model object in 'models/test1.rds' #> ✔ model_1 completed [953ms, 564.26 kB] #> + model_2 dispatched #> Automatically saving the model object in 'models/test2.rds' #> ✔ model_2 completed [922ms, 563.95 kB] #> + model_3 dispatched #> ✔ model_3 completed [0ms, 572.84 kB] #> ✔ ended pipeline [9s, 4 completed, 1 skipped] #> Warning messages: #> 1: package 'Rcpp' was built under R version 4.6.1 #> 2: Found 14 observations with a pareto_k > 0.67 in model 'm'. We recommend to run more iterations to get at least about 2200 posterior draws to improve LOO-CV approximation accuracy. #> 3: The ESS has been capped to avoid unstable estimates. #> 4: Found 10 observations with a pareto_k > 0.67 in model 'm'. We recommend to run more iterations to get at least about 2200 posterior draws to improve LOO-CV approximation accuracy. #> 5: Found 7 observations with a pareto_k > 0.67 in model 'm'. We recommend to run more iterations to get at least about 2200 posterior draws to improve LOO-CV approximation accuracy. #> 6: 3 targets produced warnings. Run targets::tar_meta(fields = warnings, complete_only = TRUE) for the messages.
We can see the loo scores inside of the model object:
targets::tar_load(model_1) model_1$criteria$loo #> #> Computed from 1000 posterior draws and 236 log-likelihood terms. #> #> Estimate SE #> elpd_loo -668.5 35.6 #> p_loo 90.7 12.8 #> looic 1337.1 71.1 #> ------ #> MCSE of elpd_loo is NA. #> MCSE and ESS estimates assume MCMC draws (r_eff in [0.3, 3.0]). #> #> Pareto k diagnostic values: #> Count Pct. Min. ESS #> (-Inf, 0.67] (good) 226 95.8% 60 #> (0.67, 1] (bad) 9 3.8% <NA> #> (1, Inf) (very bad) 1 0.4% <NA> #> See help('pareto-k-diagnostic') for details.
The file sizes look okay:
targets::tar_path_store() |> fs::dir_ls() |> fs::dir_ls(regexp = "objects") |> fs::file_info() |> dplyr::select(path, size) #> # A tibble: 3 × 2 #> path size #> <fs::path> <fs::bytes> #> 1 __targets/objects/data 2.62K #> 2 __targets/objects/model_1 551.04K #> 3 __targets/objects/model_3 559.41K
So do the environments:
targets::tar_load(model_1) targets::tar_load(model_3) model_1$formula$formula |> environment() |> ls() #> [1] "data" "file" "p" model_3$formula$formula |> environment() |> ls() #> [1] "data" "file" "p"
Adding LOO scores to a forced refit. But let’s force the models to refit and have their LOO scores computed.
# roll back to default priors write_tar_script(s_setup, s_function3, s_subfunctions, s_targets2) targets::tar_make() #> Loading required package: Rcpp #> Loading 'brms' package (version 2.23.0). Useful instructions #> can be found by typing help('brms'). A more detailed introduction #> to the package is available through vignette('brms_overview'). #> #> Attaching package: 'brms' #> #> The following object is masked from 'package:stats': #> #> ar #> #> + model_3_file dispatched #> Automatically saving the model object in 'models/test3.rds' #> ✔ model_3_file completed [10.5s, 1.10 MB] #> + model_1 dispatched #> Automatically saving the model object in 'models/test1.rds' #> ✔ model_1 completed [10s, 1.10 MB] #> + model_2 dispatched #> Automatically saving the model object in 'models/test2.rds' #> ✔ model_2 completed [11.2s, 1.10 MB] #> + model_3 dispatched #> ✔ model_3 completed [15ms, 1.10 MB] #> ✔ ended pipeline [37.2s, 4 completed, 1 skipped] #> Warning messages: #> 1: package 'Rcpp' was built under R version 4.6.1 #> 2: Found 15 observations with a pareto_k > 0.67 in model 'm'. We recommend to run more iterations to get at least about 2200 posterior draws to improve LOO-CV approximation accuracy. #> 3: Found 14 observations with a pareto_k > 0.67 in model 'm'. We recommend to run more iterations to get at least about 2200 posterior draws to improve LOO-CV approximation accuracy. #> 4: Found 15 observations with a pareto_k > 0.67 in model 'm'. We recommend to run more iterations to get at least about 2200 posterior draws to improve LOO-CV approximation accuracy. #> 5: 3 targets produced warnings. Run targets::tar_meta(fields = warnings, complete_only = TRUE) for the messages.
We can see the changed prior and the loo scores inside of the model object:
targets::tar_load(model_1) prior_summary(model_1)[1, 1] #> [1] "normal(0, 10)" class(model_1$criteria$loo) #> [1] "psis_loo" "importance_sampling_loo" #> [3] "loo"
The file sizes look suspicious:
targets::tar_path_store() |> fs::dir_ls() |> fs::dir_ls(regexp = "objects") |> fs::file_info() |> dplyr::select(path, size) #> # A tibble: 3 × 2 #> path size #> <fs::path> <fs::bytes> #> 1 __targets/objects/data 2.62K #> 2 __targets/objects/model_1 1.05M #> 3 __targets/objects/model_3 1.05M
Here we can see the larger environments affecting all of the models:
targets::tar_load(model_1) model_2 <- readRDS(targets::tar_read(model_2)) targets::tar_load(model_3) model_1$formula$formula |> environment() |> ls() #> [1] "data" "file" "m" "p" model_2$formula$formula |> environment() |> ls() #> [1] "data" "file" "m" "p" model_3$formula$formula |> environment() |> ls() #> [1] "data" "file" "m" "p"
This is utterly strange, but here is what is going on. Think about two
environments:
- the environment where model-fitting happens (referenced in
model$formula$formula) - the environment where
add_criterion()happens.
When the model has already been fitted and cached and the LOO criterion needs to be computed, these two environments are different. They essentially happen in different R sessions.
When the model needs to refit and the LOO criterion needs to be computed, these two environments are the same. When the model with the added LOO scores is saved, the reference to the model-fitting environment is still active so the extra (self) copy sneaks in.
Force refit and use a pipe to skip an intermediate assignment. Let’s rewrite the model-fitting call and LOO-computation call into piped function form. We’ll change the priors again to force a refit.
s_function4 <- quote({ # Fit a model and return the model fit <- function(data, file, .sd = 10) { p <- sprintf("normal(0, %s)", .sd) |> prior_string(class = "b") data |> brm( count ~ zBase * Trt + (1|patient), data = _, family = poisson(), prior = p + prior(cauchy(0, 2), class = sd), file = file, file_refit = "on_change", backend = "cmdstanr", silent = 2, chains = 1 ) |> add_criterion("loo") } }) write_tar_script(s_setup, s_function4, s_subfunctions, s_targets3) targets::tar_make() #> Loading required package: Rcpp #> Loading 'brms' package (version 2.23.0). Useful instructions #> can be found by typing help('brms'). A more detailed introduction #> to the package is available through vignette('brms_overview'). #> #> Attaching package: 'brms' #> #> The following object is masked from 'package:stats': #> #> ar #> #> + model_3_file dispatched #> Automatically saving the model object in 'models/test3.rds' #> ✔ model_3_file completed [10.3s, 564.27 kB] #> + model_1 dispatched #> Automatically saving the model object in 'models/test1.rds' #> ✔ model_1 completed [10.6s, 564.39 kB] #> + model_2 dispatched #> Automatically saving the model object in 'models/test2.rds' #> ✔ model_2 completed [10.1s, 564.07 kB] #> + model_3 dispatched #> ✔ model_3 completed [0ms, 564.29 kB] #> ✔ ended pipeline [36.4s, 4 completed, 1 skipped] #> Warning messages: #> 1: package 'Rcpp' was built under R version 4.6.1 #> 2: Found 14 observations with a pareto_k > 0.67 in model 'brm(count ~ zBase * Trt + (1 | patient), data = data, family = poisson(), prior = p + prior(cauchy(0, 2), class = sd), file = file, file_refit = "on_change", backend = "cmdstanr", silent = 2, chains = 1)'. We recommend to run more iterations to get at least about 2200 posterior draws to improve LOO-CV approximation accuracy. #> 3: The ESS has been capped to avoid unstable estimates. #> 4: Found 10 observations with a pareto_k > 0.67 in model 'brm(count ~ zBase * Trt + (1 | patient), data = data, family = poisson(), prior = p + prior(cauchy(0, 2), class = sd), file = file, file_refit = "on_change", backend = "cmdstanr", silent = 2, chains = 1)'. We recommend to run more iterations to get at least about 2200 posterior draws to improve LOO-CV approximation accuracy. #> 5: Found 7 observations with a pareto_k > 0.67 in model 'brm(count ~ zBase * Trt + (1 | patient), data = data, family = poisson(), prior = p + prior(cauchy(0, 2), class = sd), file = file, file_refit = "on_change", backend = "cmdstanr", silent = 2, chains = 1)'. We recommend to run more iterations to get at least about 2200 posterior draws to improve LOO-CV approximation accuracy. #> 6: 3 targets produced warnings. Run targets::tar_meta(fields = warnings, complete_only = TRUE) for the messages.
The file sizes look good again:
targets::tar_path_store() |> fs::dir_ls() |> fs::dir_ls(regexp = "objects") |> fs::file_info() |> dplyr::select(path, size) #> # A tibble: 3 × 2 #> path size #> <fs::path> <fs::bytes> #> 1 __targets/objects/data 2.62K #> 2 __targets/objects/model_1 551.16K #> 3 __targets/objects/model_3 551.06K
Add another point on the scoreboard for the pipe-programming style.
Explicitly isolate the model fitting environment. I have a hunch
that if I call brm() twice the second brm() will load the saved
model, and when it unserializes (loads) the formula’s saved environment,
it will create a new environment object that can’t be contaminated by
new data in the model-fitting environment.
To clarify things, I am also going to print out environment addresses too. I’ve lost track of things, so I am going to force a refit by changing the code and deleting the models.
s_function5 <- quote({ # Fit a model and return the model fit <- function(data, file, .sd = 10) { p <- sprintf("normal(0, %s)", .sd) |> prior_string(class = "b") message("Model fitting: ", environment() |> format()) m <- brm( count ~ zBase * Trt + (1|patient), data, family = poisson(), prior = p + prior(cauchy(0, 2), class = sd), file = file, file_refit = "on_change", backend = "cmdstanr", silent = 2, chains = 1 ) message( "Formula after fitting: ", m$formula$formula |> environment() |> format() ) m <- brm( count ~ zBase * Trt + (1|patient), data, family = poisson(), prior = p + prior(cauchy(0, 2), class = sd), file = file, file_refit = "on_change", backend = "cmdstanr", silent = 2, chains = 1 ) message( "Formula after reading cached model: ", m$formula$formula |> environment() |> format() ) m <- add_criterion(m, "loo") message( "Formula after add_criterion(): ", m$formula$formula |> environment() |> format() ) m } }) write_tar_script(s_setup, s_function5, s_subfunctions, s_targets3) file.remove("models/test1.rds", "models/test2.rds", "models/test3.rds") #> [1] TRUE TRUE TRUE targets::tar_make() #> Loading required package: Rcpp #> Loading 'brms' package (version 2.23.0). Useful instructions #> can be found by typing help('brms'). A more detailed introduction #> to the package is available through vignette('brms_overview'). #> #> Attaching package: 'brms' #> #> The following object is masked from 'package:stats': #> #> ar #> #> + model_3_file dispatched #> Model fitting: <environment: 0x0000021abf48ba00> #> Formula after fitting: <environment: 0x0000021abf48ba00> #> Formula after reading cached model: <environment: 0x0000021b2855fa58> #> Automatically saving the model object in 'models/test3.rds' #> Formula after add_criterion(): <environment: 0x0000021b2855fa58> #> ✔ model_3_file completed [10.6s, 564.16 kB] #> + model_1 dispatched #> Model fitting: <environment: 0x0000021ac215d110> #> Formula after fitting: <environment: 0x0000021ac215d110> #> Formula after reading cached model: <environment: 0x0000021b24a09bf8> #> Automatically saving the model object in 'models/test1.rds' #> Formula after add_criterion(): <environment: 0x0000021b24a09bf8> #> ✔ model_1 completed [10.8s, 564.27 kB] #> + model_2 dispatched #> Model fitting: <environment: 0x0000021b23c9f900> #> Formula after fitting: <environment: 0x0000021b23c9f900> #> Formula after reading cached model: <environment: 0x0000021ac1838730> #> Automatically saving the model object in 'models/test2.rds' #> Formula after add_criterion(): <environment: 0x0000021ac1838730> #> ✔ model_2 completed [10.1s, 563.95 kB] #> + model_3 dispatched #> ✔ model_3 completed [0ms, 564.16 kB] #> ✔ ended pipeline [37s, 4 completed, 1 skipped] #> Warning messages: #> 1: package 'Rcpp' was built under R version 4.6.1 #> 2: Found 14 observations with a pareto_k > 0.67 in model 'm'. We recommend to run more iterations to get at least about 2200 posterior draws to improve LOO-CV approximation accuracy. #> 3: The ESS has been capped to avoid unstable estimates. #> 4: Found 10 observations with a pareto_k > 0.67 in model 'm'. We recommend to run more iterations to get at least about 2200 posterior draws to improve LOO-CV approximation accuracy. #> 5: Found 7 observations with a pareto_k > 0.67 in model 'm'. We recommend to run more iterations to get at least about 2200 posterior draws to improve LOO-CV approximation accuracy. #> 6: 3 targets produced warnings. Run targets::tar_meta(fields = warnings, complete_only = TRUE) for the messages.
The file sizes look good again:
targets::tar_path_store() |> fs::dir_ls() |> fs::dir_ls(regexp = "objects") |> fs::file_info() |> dplyr::select(path, size) #> # A tibble: 3 × 2 #> path size #> <fs::path> <fs::bytes> #> 1 __targets/objects/data 2.62K #> 2 __targets/objects/model_1 551.05K #> 3 __targets/objects/model_3 550.94K
I can’t believe this do-it-twice trick worked.
targets::tar_load(model_1) model_1$formula$formula |> environment() |> ls() #> [1] "data" "file" "p"
For completeness, let’s look at the environments from a do-it-once version:
s_function6 <- quote({ # Fit a model and return the model fit <- function(data, file, .sd = 10) { p <- sprintf("normal(0, %s)", .sd) |> prior_string(class = "b") message("Model fitting: ", environment() |> format()) m <- brm( count ~ zBase * Trt + (1|patient), data, family = poisson(), prior = p + prior(cauchy(0, 2), class = sd), file = file, file_refit = "on_change", backend = "cmdstanr", silent = 2, chains = 1 ) message( "Formula after fitting: ", m$formula$formula |> environment() |> format() ) m <- add_criterion(m, "loo") message( "Formula after add_criterion(): ", m$formula$formula |> environment() |> format() ) m } }) write_tar_script(s_setup, s_function6, s_subfunctions, s_targets3) file.remove("models/test1.rds", "models/test2.rds", "models/test3.rds") #> [1] TRUE TRUE TRUE targets::tar_make() #> Loading required package: Rcpp #> Loading 'brms' package (version 2.23.0). Useful instructions #> can be found by typing help('brms'). A more detailed introduction #> to the package is available through vignette('brms_overview'). #> #> Attaching package: 'brms' #> #> The following object is masked from 'package:stats': #> #> ar #> #> + model_3_file dispatched #> Model fitting: <environment: 0x000002b52c49e4a8> #> Formula after fitting: <environment: 0x000002b52c49e4a8> #> Automatically saving the model object in 'models/test3.rds' #> Formula after add_criterion(): <environment: 0x000002b52c49e4a8> #> ✔ model_3_file completed [10.3s, 1.10 MB] #> + model_1 dispatched #> Model fitting: <environment: 0x000002b53076a900> #> Formula after fitting: <environment: 0x000002b53076a900> #> Automatically saving the model object in 'models/test1.rds' #> Formula after add_criterion(): <environment: 0x000002b53076a900> #> ✔ model_1 completed [10.5s, 1.11 MB] #> + model_2 dispatched #> Model fitting: <environment: 0x000002b5b1603dd0> #> Formula after fitting: <environment: 0x000002b5b1603dd0> #> Automatically saving the model object in 'models/test2.rds' #> Formula after add_criterion(): <environment: 0x000002b5b1603dd0> #> ✔ model_2 completed [9.9s, 1.10 MB] #> + model_3 dispatched #> ✔ model_3 completed [15ms, 1.10 MB] #> ✔ ended pipeline [36.2s, 4 completed, 1 skipped] #> Warning messages: #> 1: package 'Rcpp' was built under R version 4.6.1 #> 2: Found 14 observations with a pareto_k > 0.67 in model 'm'. We recommend to run more iterations to get at least about 2200 posterior draws to improve LOO-CV approximation accuracy. #> 3: The ESS has been capped to avoid unstable estimates. #> 4: Found 10 observations with a pareto_k > 0.67 in model 'm'. We recommend to run more iterations to get at least about 2200 posterior draws to improve LOO-CV approximation accuracy. #> 5: Found 7 observations with a pareto_k > 0.67 in model 'm'. We recommend to run more iterations to get at least about 2200 posterior draws to improve LOO-CV approximation accuracy. #> 6: 3 targets produced warnings. Run targets::tar_meta(fields = warnings, complete_only = TRUE) for the messages.
The environments are all the same!
That is why adding the criterion’s embloats the models. The do-it-twice version creates a new environment when it loads the model’s saved formula environment.
A clean version of the do-it-twice trick. It looks pretty odd to fit the
same model twice. We can just read in the saved model with
m <- readRDS(m$file) instead for the same effect:
s_function7 <- quote({ # Fit a model and return the model fit <- function(data, file, .sd = 10) { p <- sprintf("normal(0, %s)", .sd) |> prior_string(class = "b") message("Model fitting: ", environment() |> format()) m <- brm( count ~ zBase * Trt + (1|patient), data, family = poisson(), prior = p + prior(cauchy(0, 2), class = sd), file = file, file_refit = "on_change", backend = "cmdstanr", silent = 2, chains = 1 ) message( "Formula after fitting: ", m$formula$formula |> environment() |> format() ) m <- readRDS(m$file) message( "Formula after reading cached model: ", m$formula$formula |> environment() |> format() ) m <- add_criterion(m, "loo", ) message( "Formula after add_criterion(): ", m$formula$formula |> environment() |> format() ) m } }) write_tar_script(s_setup, s_function7, s_subfunctions, s_targets3) file.remove("models/test1.rds", "models/test2.rds", "models/test3.rds") #> [1] TRUE TRUE TRUE targets::tar_make() #> Loading required package: Rcpp #> Loading 'brms' package (version 2.23.0). Useful instructions #> can be found by typing help('brms'). A more detailed introduction #> to the package is available through vignette('brms_overview'). #> #> Attaching package: 'brms' #> #> The following object is masked from 'package:stats': #> #> ar #> #> + model_3_file dispatched #> Model fitting: <environment: 0x000001934059b7e8> #> Formula after fitting: <environment: 0x000001934059b7e8> #> Formula after reading cached model: <environment: 0x00000193a5d5d960> #> Automatically saving the model object in 'models/test3.rds' #> Formula after add_criterion(): <environment: 0x00000193a5d5d960> #> ✔ model_3_file completed [10.2s, 564.16 kB] #> + model_1 dispatched #> Model fitting: <environment: 0x0000019342f04458> #> Formula after fitting: <environment: 0x0000019342f04458> #> Formula after reading cached model: <environment: 0x00000193a6c311c8> #> Automatically saving the model object in 'models/test1.rds' #> Formula after add_criterion(): <environment: 0x00000193a6c311c8> #> ✔ model_1 completed [10.5s, 564.26 kB] #> + model_2 dispatched #> Model fitting: <environment: 0x00000193a5637a50> #> Formula after fitting: <environment: 0x00000193a5637a50> #> Formula after reading cached model: <environment: 0x00000193ae914e80> #> Automatically saving the model object in 'models/test2.rds' #> Formula after add_criterion(): <environment: 0x00000193ae914e80> #> ✔ model_2 completed [10.2s, 563.95 kB] #> + model_3 dispatched #> ✔ model_3 completed [0ms, 564.16 kB] #> ✔ ended pipeline [36.4s, 4 completed, 1 skipped] #> Warning messages: #> 1: package 'Rcpp' was built under R version 4.6.1 #> 2: Found 14 observations with a pareto_k > 0.67 in model 'm'. We recommend to run more iterations to get at least about 2200 posterior draws to improve LOO-CV approximation accuracy. #> 3: The ESS has been capped to avoid unstable estimates. #> 4: Found 10 observations with a pareto_k > 0.67 in model 'm'. We recommend to run more iterations to get at least about 2200 posterior draws to improve LOO-CV approximation accuracy. #> 5: Found 7 observations with a pareto_k > 0.67 in model 'm'. We recommend to run more iterations to get at least about 2200 posterior draws to improve LOO-CV approximation accuracy. #> 6: 3 targets produced warnings. Run targets::tar_meta(fields = warnings, complete_only = TRUE) for the messages.
Quick check of the file sizes:
targets::tar_path_store() |> fs::dir_ls() |> fs::dir_ls(regexp = "objects") |> fs::file_info() |> dplyr::select(path, size) #> # A tibble: 3 × 2 #> path size #> <fs::path> <fs::bytes> #> 1 __targets/objects/data 2.62K #> 2 __targets/objects/model_1 551.04K #> 3 __targets/objects/model_3 550.94K
Observations
The problem of environments inflating model sizes is a known one. The butcher R package, for example, will hack off parts of model to lower their size. I did not expect it to rear its self-referential head into these experiments. I just wanted to experiment to see if its possible to work with just one file per model in a targets workflow. (It is, using the model-2 style).
I’ve been coding in the model-1 style for years. brms caches it so that
the model is only refit when the formula, priors or data changes, and
targets has its own copy of the model in it data store. I’m now inclined
to use the model-3 style because it hands targets the .rds file
created by brms and avoids at least one unexpected environment/size
issue. But it was truly a baffling result that the newly-fitted-model
plus assignment plus LOO-score case led to an inflated file size, and
the model-3 style could not avoid that issue. Immediately loading the saved
model file seemed to help this problem.
I recently completed a project that had some impressively large model objects. I am going to paste in the R results from inspecting one of them:
# [... omitted code to locate/store the model path ...] big_model <- path_big_model |> readRDS() path_big_model |> file.size() |> scales::label_bytes()() #> [1] "433 MB" big_model |> lapply(lobstr::obj_size) |> unlist() |> scales::label_bytes()() #> formula data prior data2 stanvars model #> "230 MB" "231 MB" "6 kB" "48 B" "280 B" "8 kB" #> save_pars algorithm backend threads opencl stan_args #> "944 B" "120 B" "120 B" "904 B" "512 B" "1 kB" #> fit basis criteria file version family #> "229 MB" "460 kB" "89 kB" "168 B" "3 kB" "18 kB" #> autocor ranef cov_ranef stan_funs data.name #> "0 B" "230 MB" "0 B" "0 B" "112 B"
It really does look like the formula, data, and ranef entries, which all
contain formulas, are carrying around an extra 230 MB of stuff.
Leave a comment